Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/llmist/src/agent/stream-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export class StreamProcessor {
// Gadget limiting per response
private readonly maxGadgetsPerResponse: number;
private gadgetStartedCount: number = 0;
private limitExceeded: boolean = false;

constructor(options: StreamProcessorOptions) {
this.iteration = options.iteration;
Expand Down Expand Up @@ -420,6 +421,14 @@ export class StreamProcessor {
}
}
}

// Step 5: Break stream loop if gadget limit exceeded
// This stops reading further chunks, letting in-flight gadgets complete
// and allowing the agent to continue to the next iteration
if (this.limitExceeded) {
this.logger.info("Breaking stream loop due to gadget limit");
break;
}
}

// Signal that LLM response is complete (tokens stopped flowing)
Expand Down Expand Up @@ -554,6 +563,11 @@ export class StreamProcessor {
* enabling real-time UI feedback.
*/
private async *processGadgetCallGenerator(call: ParsedGadgetCall): AsyncGenerator<StreamEvent> {
// Early exit if limit already exceeded - don't emit events for buffered gadgets
if (this.limitExceeded) {
return;
}

// Yield gadget_call IMMEDIATELY (real-time feedback before execution)
yield { type: "gadget_call", call };

Expand Down Expand Up @@ -1271,7 +1285,10 @@ export class StreamProcessor {
if (this.gadgetStartedCount >= this.maxGadgetsPerResponse) {
const errorMessage = `Gadget limit (${this.maxGadgetsPerResponse}) exceeded. Consider calling fewer gadgets per response.`;

this.logger.info("Gadget skipped due to maxGadgetsPerResponse limit", {
// Set flag to break stream loop - stops reading further chunks
this.limitExceeded = true;

this.logger.info("Gadget limit exceeded, stopping stream processing", {
gadgetName: call.gadgetName,
invocationId: call.invocationId,
limit: this.maxGadgetsPerResponse,
Expand Down Expand Up @@ -1355,6 +1372,11 @@ export class StreamProcessor {
* but results are yielded as they become available.
*/
private async *processPendingGadgetsGenerator(): AsyncGenerator<StreamEvent> {
// Skip processing pending gadgets if limit already exceeded
if (this.limitExceeded) {
return;
}

let progress = true;

while (progress && this.gadgetsAwaitingDependencies.size > 0) {
Expand Down
25 changes: 12 additions & 13 deletions packages/llmist/src/e2e/max-gadgets-per-response.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { collectAllEvents, filterEventsByType } from "./setup.js";
*
* These tests verify that:
* 1. Gadget limit is enforced correctly
* 2. Excess gadgets are skipped with informative messages
* 3. Agent loop continues to next iteration after hitting limit
* 4. Skip events have correct format and reason
* 2. The gadget that exceeds the limit emits a skip event
* 3. Stream loop breaks immediately (no further gadgets parsed)
* 4. Agent loop continues to next iteration after hitting limit
*/
describe("E2E: maxGadgetsPerResponse", () => {
beforeEach(() => {
Expand Down Expand Up @@ -134,15 +134,14 @@ five
const gadgetResults = filterEventsByType(events, "gadget_result");
const skippedEvents = filterEventsByType(events, "gadget_skipped");

// First 3 execute, last 2 are skipped
// First 3 execute, 4th triggers skip and breaks loop (5th never parsed)
expect(gadgetResults).toHaveLength(3);
expect(skippedEvents).toHaveLength(2);
expect(skippedEvents).toHaveLength(1);

// Verify skip reason
for (const skipEvent of skippedEvents) {
expect(skipEvent.failedDependency).toBe("maxGadgetsPerResponse");
expect(skipEvent.failedDependencyError).toContain("Gadget limit (3) exceeded");
}
const skipEvent = skippedEvents[0];
expect(skipEvent.failedDependency).toBe("maxGadgetsPerResponse");
expect(skipEvent.failedDependencyError).toContain("Gadget limit (3) exceeded");
},
TEST_TIMEOUTS.QUICK,
);
Expand Down Expand Up @@ -303,7 +302,7 @@ three
);

it(
"emits gadget_call events for all gadgets (including skipped ones)",
"emits gadget_call event for the skipped gadget (before breaking loop)",
async () => {
mockLLM()
.forModel("gpt-5-nano")
Expand Down Expand Up @@ -337,11 +336,11 @@ three

const events = await collectAllEvents(agent.run());

// gadget_call events should be emitted for ALL gadgets
// gadget_call events emitted for parsed gadgets (includes the one that triggered limit)
const gadgetCalls = filterEventsByType(events, "gadget_call");
expect(gadgetCalls).toHaveLength(3); // All 3 gadgets called
expect(gadgetCalls).toHaveLength(3); // gc_1, gc_2, gc_3 all parsed

// But only 2 have results, 1 is skipped
// 2 have results, 1 is skipped (which triggers loop break)
const gadgetResults = filterEventsByType(events, "gadget_result");
const skippedEvents = filterEventsByType(events, "gadget_skipped");
expect(gadgetResults).toHaveLength(2);
Expand Down