-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathloop.ts
More file actions
831 lines (742 loc) · 29.4 KB
/
Copy pathloop.ts
File metadata and controls
831 lines (742 loc) · 29.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
import { getTool, toolRegistry } from "../tools";
import { closeReplSessions } from "../tools/replSession";
import { takePendingImages } from "../tools/readImage";
import { blockedInPlanMode, planModeRefusal, planModeTools } from "./planMode";
import { isRetryableError } from "./retry";
import { compactToolHistory, toolHistoryBudget } from "./compaction";
import { TurnState, normalizeToolKey } from "./turnState";
import { recentMessages } from "../config/config";
import { SYSTEM_PROMPT } from "../config/systemPrompt";
import type {
AgentCallbacks,
Message,
PromptSegments,
ProviderClient,
TurnContext,
StreamEvent,
TokenUsage,
Tool,
} from "../config/types";
/** Characters of a single tool result that reach the model. */
export const MAX_TOOL_RESULT = 4000;
/**
* Trims an oversized tool result, keeping both ends.
*
* Truncation used to keep the first 4000 characters and drop the rest, which
* discarded exactly the part that usually matters: a test run puts its summary
* last, a build puts its errors last, and a stack trace puts the root cause
* last. An agent shown only the head of a failing test run sees that it ran and
* not that it failed.
*
* The head is kept too, because read_file has no range parameter — an agent
* that loses the top of a file cannot ask for it again, it can only re-read the
* whole thing. Both ends are cut to line boundaries so neither resumes
* mid-token, and the marker states what was dropped rather than implying the
* output simply ended.
*/
export function truncateToolResult(
result: string,
limit = MAX_TOOL_RESULT,
): string {
if (result.length <= limit) return result;
const half = Math.floor(limit / 2);
// Extend to the enclosing line break where one is close enough to be the
// real boundary; falling back to the raw offset keeps a single enormous line
// (minified output, a long JSON blob) from collapsing the whole budget.
const headCut = result.lastIndexOf("\n", half);
const head = result.slice(0, headCut > half * 0.5 ? headCut : half);
const tailStart = result.length - half;
const tailCut = result.indexOf("\n", tailStart);
const tail = result.slice(
tailCut !== -1 && tailCut < result.length - half * 0.5 ? tailCut + 1 : tailStart,
);
const dropped = result.length - head.length - tail.length;
const lines = result.slice(head.length, result.length - tail.length).split("\n").length - 1;
return (
`${head}\n\n…${dropped} characters omitted from the middle` +
`${lines > 0 ? ` (${lines} lines)` : ""}; ` +
`the start and end of the output are shown…\n\n${tail}`
);
}
/**
* Measures the pieces of the prompt about to be sent.
*
* Deliberately measures the messages that are actually sent — the result of
* `recentMessages`, not the full transcript — so the numbers describe the
* request rather than what the session happens to be holding in memory.
*/
export function measureSegments(
messages: Message[],
context: TurnContext,
): PromptSegments {
const { repository, executionLog, instructions } = normalizeContext(context);
let conversation = 0;
let toolResults = 0;
for (const message of messages) {
switch (message.role) {
case "user":
case "assistant":
conversation += message.content.length;
break;
case "assistant_tool_call":
// The arguments are what is serialised into the request, so they are
// what counts here; the tool's name is negligible beside them.
toolResults += JSON.stringify(message.arguments).length;
break;
case "tool":
toolResults += message.content.length;
break;
}
}
return {
systemPrompt: SYSTEM_PROMPT.length,
repoContext: repository.length,
executionLog: executionLog.length,
conversation,
toolResults,
// Omitted rather than reported as 0 when there are none, so an ordinary turn
// measures exactly as it did before modes existed.
...(instructions ? { modeInstructions: instructions.length } : {}),
};
}
/** Accepts the older string form and the split form as one shape. */
function normalizeContext(context: TurnContext): {
repository: string;
executionLog: string;
instructions: string;
} {
return typeof context === "string"
? { repository: context, executionLog: "", instructions: "" }
: {
repository: context.repository,
executionLog: context.executionLog ?? "",
instructions: context.instructions ?? "",
};
}
/**
* Renders the turn context into the single string the provider receives.
*
* The join lives here rather than in the caller so that one place decides how
* the pieces are ordered and separated, and the measurement above cannot drift
* from what is actually sent.
*/
export function renderContext(context: TurnContext): string {
const { repository, executionLog, instructions } = normalizeContext(context);
// Instructions come first: they say how this turn must behave, which the model
// should read before the material it is to act on. Ordered explicitly here so
// the measurement above and the string sent cannot drift apart.
return [instructions, repository, executionLog].filter(Boolean).join("\n\n");
}
/**
* Steps a turn may take before it stops to ask whether to keep going.
*
* Not a quota guard, though it was written as one. The provider enforces quota
* itself and exactly: a 429 carries a `RetryInfo` saying when to come back,
* `providerRetryDelayMs` honours it, and the client turns it into a message
* naming the quota page. A constant here cannot know what is left of anyone's
* budget, so as a spending limit it is always either too tight or too loose.
*
* What it does guard is a pathological loop with nobody watching — an agent
* re-reading the same three files until the day's requests are gone. That wants
* a ceiling high enough that ordinary work never reaches it. Twenty was not:
* turns were dying mid-edit, having done nothing wrong, and the number fired
* almost only on the false positive. Reaching this one asks rather than fails
* (see `onBudgetExhausted`), which is what makes it safe to be generous.
*/
const DEFAULT_MAX_ITERATIONS = 40;
/** Conversation turns kept in the window sent to the provider. */
const MAX_TURNS = 6;
/**
* Repeats of one call, with identical arguments, before it is skipped.
*
* Four allowed three wasted round trips before acting. A benchmark trial ran
* `readelf -s doomgeneric_mips | grep -i init` three times and several other
* readelf variants twice each, all inside a budget it went on to exhaust. Two
* is enough to let a genuine retry through — a command run again after
* something changed — while ending a loop one step sooner.
*/
const SAME_TOOL_THRESHOLD = 2;
/** Iterations left when the model is told the budget is running out. */
const REMAINING_ITERATIONS_WARNING = 5;
/**
* Asked once, never twice. The model may have a good reason not to verify —
* the change may be unverifiable, or the tests may not exist — and a loop that
* insists would spend the budget arguing rather than let the turn end.
*/
const MAX_VERIFICATION_REMINDERS = 1;
/**
* Raised when the loop runs out of iterations.
*
* Distinct from a generic failure because it is not one: the agent ran, it
* simply did not finish inside its budget. Callers that report an exit status
* use this to separate "produced an incomplete result" from "something broke",
* which matters to any harness that treats the two differently.
*/
export class IterationBudgetExhaustedError extends Error {
constructor(limit: number) {
super(
`Agent exceeded the maximum number of iterations (${limit}).\n\n` +
`This usually means:\n` +
` • The task is too complex - try breaking it into smaller steps\n` +
` • The agent is stuck in analysis - it may need clearer instructions\n` +
` • More iterations are needed - raise WOOPCODE_MAX_ITERATIONS`,
);
this.name = "IterationBudgetExhaustedError";
}
}
/**
* Resolves the loop budget, allowing `WOOPCODE_MAX_ITERATIONS` to set it.
*
* An interactive session can afford a checkpoint at the ceiling, because
* somebody is there to answer it. An automated caller cannot — there is nobody
* to ask, so the number it starts with is the number it gets — which is why the
* limit has to be settable from outside rather than compiled in.
*/
function maxIterations(env: Record<string, string | undefined> = process.env): number {
const raw = env.WOOPCODE_MAX_ITERATIONS?.trim();
if (!raw) return DEFAULT_MAX_ITERATIONS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
process.stderr.write(
`⚠️ ignoring WOOPCODE_MAX_ITERATIONS=${raw} (expected a positive integer)\n`,
);
return DEFAULT_MAX_ITERATIONS;
}
return parsed;
}
/** Per-turn switches that are not part of the conversation. */
export interface AgentLoopOptions {
/**
* Investigate but change nothing; see runtime/planMode.ts.
*
* Read once here, at the start of the turn, for the same reason the tool-history
* budget and the thinking budget are: a mode toggled mid-turn would leave two
* requests of one turn assembled under different rules. A Tab pressed while the
* agent is working therefore takes effect on the next turn.
*/
planMode?: boolean;
}
type ToolCallEvent = Extract<StreamEvent, { type: "tool_call" }>;
/** What one provider response produced. */
interface IterationResult {
assistantText: string;
toolCalls: ToolCallEvent[];
usage?: TokenUsage;
/**
* Set when the stream died after the model had already said something.
*
* The client retries only while nothing has been observed, because repeating
* the request would duplicate text the user watched arrive — so recovering a
* half-delivered response is this loop's job, not the client's.
*/
truncated?: Error;
/** The user cancelled. Reported rather than thrown, so the caller decides how to end. */
cancelled?: boolean;
}
/**
* Runs one provider request to completion, collecting text and tool calls.
*
* A stream that fails is salvaged only when there is something to salvage and
* the failure was transient; otherwise the error travels and ends the turn.
*/
async function streamIteration(
client: ProviderClient,
sentMessages: Message[],
renderedContext: string,
offeredTools: readonly Tool[],
useTools: boolean,
callbacks: AgentCallbacks,
state: TurnState,
signal?: AbortSignal,
): Promise<IterationResult> {
let assistantText = "";
const toolCalls: ToolCallEvent[] = [];
let usage: TokenUsage | undefined;
try {
for await (const event of client.stream(
sentMessages,
renderedContext,
signal,
useTools,
offeredTools,
)) {
switch (event.type) {
case "text":
assistantText += event.content;
callbacks.onText?.(event.content);
break;
case "tool_call":
toolCalls.push(event);
break;
case "retry":
state.retries++;
callbacks.onRetry?.({
attempt: event.attempt,
delayMs: event.delayMs,
reason: event.reason,
error: event.error,
});
// The ⚠️ prefix keeps this in the transcript as a notice rather than
// replacing the activity indicator: the turn is still running.
callbacks.onStatus?.(
`⚠️ provider request failed (${event.reason}), retrying in ${Math.round(event.delayMs / 100) / 10}s`,
);
break;
case "done":
usage = event.usage;
break;
}
}
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
// Cancellation is not salvaged; the caller handles it.
if (signal?.aborted) {
return { assistantText, toolCalls, usage, cancelled: true };
}
// Nothing was observed, so the client already exhausted its retries and
// there is nothing to keep. Let the failure travel.
if (!assistantText && toolCalls.length === 0) {
throw failure;
}
// Only a transient failure is worth continuing from. A fatal one — a
// rejected request, a bug — would otherwise be retried until the iteration
// budget ran out, burning quota to arrive at the same error twenty
// iterations later instead of reporting it now.
if (!isRetryableError(failure)) {
throw failure;
}
return { assistantText, toolCalls, usage, truncated: failure };
}
return { assistantText, toolCalls, usage };
}
/**
* Announces a call and records it in the conversation.
*
* Every path through a tool call does these two things first — the ones that
* run it, the ones that skip it as a duplicate, and the ones plan mode refuses
* — because the provider requires the call to appear in history whether or not
* anything executed. The `batchId` ties calls that arrived in one response
* together: a model that batches signs only the first, and the provider rejects
* history that splits such a batch across turns.
*/
function recordToolCall(
messages: Message[],
callbacks: AgentCallbacks,
toolCall: ToolCallEvent,
batchId: string,
): void {
callbacks.onToolStart?.({
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
});
messages.push({
role: "assistant_tool_call",
toolName: toolCall.name,
toolCallId: toolCall.id,
arguments: toolCall.arguments,
thoughtSignature: toolCall.thoughtSignature,
batchId,
});
}
/** Feeds a result back to the model. Every call owes exactly one of these. */
function pushToolResult(
messages: Message[],
toolCall: ToolCallEvent,
content: string,
): void {
messages.push({
role: "tool",
toolName: toolCall.name,
toolCallId: toolCall.id,
content,
});
}
/** How a single tool call ended, from the loop's point of view. */
type ToolCallOutcome =
| { kind: "continue" }
| { kind: "cancelled" }
| { kind: "declined"; outcome: string };
/**
* Runs one requested tool call, or declines to.
*
* Four things can happen before the tool is reached: it is unknown (thrown, a
* bug), it repeats a call already made (skipped), plan mode refuses it, or the
* user cancels. Each still owes the model a result, because a call left without
* one makes the history invalid for the next request.
*/
async function executeToolCall(
toolCall: ToolCallEvent,
messages: Message[],
callbacks: AgentCallbacks,
state: TurnState,
batchId: string,
planMode: boolean,
signal?: AbortSignal,
): Promise<ToolCallOutcome> {
const tool = getTool(toolCall.name);
if (!tool) {
throw new Error(`Unknown tool: ${toolCall.name}`);
}
const toolKey = normalizeToolKey(toolCall.name, toolCall.arguments);
if (state.seenCount(toolKey) >= SAME_TOOL_THRESHOLD) {
const output =
`Skipped duplicate ${toolCall.name} call. The result for these exact arguments ` +
`is already in the conversation; use it and continue with a different action.`;
recordToolCall(messages, callbacks, toolCall, batchId);
callbacks.onToolFinish?.({
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
output,
});
pushToolResult(messages, toolCall, output);
return { kind: "continue" };
}
// Plan mode's second gate. The tool never runs, and the model is told why as
// a result rather than an exception, so it can adjust and finish the plan
// instead of losing the turn.
//
// Counted against the duplicate threshold but not against the tools executed:
// a third identical attempt should be skipped as a repeat, and nothing ran,
// so nothing is owed to the efficiency warning.
if (planMode && blockedInPlanMode(toolCall.name, toolCall.arguments)) {
state.countAttempt(toolKey);
recordToolCall(messages, callbacks, toolCall, batchId);
// Its own callback, not onToolError: the tool did not fail, it was never
// run. The write marks are deliberately untouched too — nothing was
// written, so the turn must not look like an unverified edit.
callbacks.onToolBlocked?.({
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
error: "Plan mode",
});
pushToolResult(messages, toolCall, planModeRefusal(toolCall.name));
return { kind: "continue" };
}
state.countAttempt(toolKey);
state.toolCallsExecuted++;
recordToolCall(messages, callbacks, toolCall, batchId);
let result: string;
try {
result = await tool.execute(toolCall.arguments, signal);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
callbacks.onToolError?.({
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
error: message,
});
pushToolResult(messages, toolCall, `Tool failed: ${message}`);
return { kind: "continue" };
}
// A tool may have completed at the same time that the user cancelled the
// turn. Do not report its result or start another provider call.
if (signal?.aborted) {
return { kind: "cancelled" };
}
const toolResult = truncateToolResult(result);
const editWasDeclined =
toolResult.startsWith("Edit rejected") ||
toolResult.startsWith("Edit cancelled");
if (editWasDeclined) {
callbacks.onToolError?.({
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
error: "Rejected by user",
});
pushToolResult(messages, toolCall, toolResult);
const outcome =
"The proposed file change was not applied because it was rejected.";
messages.push({ role: "assistant", content: outcome });
callbacks.onText?.(outcome);
return { kind: "declined", outcome };
}
// Recorded here rather than before execute: a tool that threw changed
// nothing, and a declined edit returned above without reaching this point, so
// neither is counted as a workspace change.
state.recordToolEffect(toolCall.name, toolCall.arguments);
callbacks.onToolFinish?.({
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
output: toolResult,
});
pushToolResult(messages, toolCall, toolResult);
// Images ride on a user message after the tool result, because that is the
// only shape all three providers accept — see `ImageAttachment`. Pushed here,
// after the result and before the next request, so the model sees the tool's
// description of the file and the file itself in the order it asked for them.
const images = takePendingImages();
if (images.length > 0) {
messages.push({
role: "user",
content:
images.length === 1
? "The image requested above:"
: `The ${images.length} images requested above:`,
images,
});
}
return { kind: "continue" };
}
/** Whether the turn is over, or the loop should ask the model once more. */
type TurnEnding = { kind: "continue" } | { kind: "done"; text: string };
/**
* Decides what happens when the model responds without calling any tool.
*
* Usually that means it is finished. Twice it does not: a stream that died
* mid-sentence has to be resumed, and a turn that changed files without
* checking them is asked once to verify. Both push a user message and go round
* again, which is why this returns an instruction rather than a value.
*/
function finishTurn(
messages: Message[],
callbacks: AgentCallbacks,
state: TurnState,
assistantText: string,
maxIterations: number,
truncated?: Error,
): TurnEnding {
messages.push({ role: "assistant", content: assistantText });
// A stream that died mid-sentence is not the model choosing to stop. Ending
// here would finish the turn on a half-written answer, so the partial text
// stays and the loop asks again.
//
// The follow-up has to be a user message. Gemini rejects a request whose last
// message is from the model — "Requests ending with a model turn are not
// supported" — so continuing after an assistant message without one fails
// with a 400. That costs one of the turns recentMessages keeps, which is the
// price of continuing at all.
if (truncated) {
messages.push({
role: "user",
content:
"Your previous message was cut off before it finished. Continue from where it stopped.",
});
return { kind: "continue" };
}
// The turn is about to end having changed files with nothing run afterwards
// to check them. Ask once, then let it finish either way.
if (
state.hasUnverifiedEdits() &&
state.verificationReminders < MAX_VERIFICATION_REMINDERS &&
state.iterations < maxIterations
) {
state.verificationReminders++;
messages.push({
role: "user",
content:
"You changed files and have not run anything since. Run the project's " +
"tests, build or type check to confirm the change works, then report the " +
"result. If it genuinely cannot be verified — no test exists, or the " +
"tooling is unavailable — say so plainly and finish. Do not claim it was " +
"verified unless a command actually ran.",
});
callbacks.onStatus?.(
"⚠️ files changed without a check - asking the agent to verify",
);
return { kind: "continue" };
}
return { kind: "done", text: assistantText };
}
export async function agentLoop(
client: ProviderClient,
messages: Message[],
context: TurnContext,
callbacks: AgentCallbacks,
signal?: AbortSignal,
useTools = true,
options: AgentLoopOptions = {},
) {
// The budget for one stretch of work, and the amount each checkpoint grants.
// Mutable because a turn the user chooses to continue is the same turn: the
// history, the model's reasoning and the footer's clock all carry on, and
// nothing has to be re-established.
const BUDGET_STEP = maxIterations();
let budget = BUDGET_STEP;
const planMode = options.planMode === true;
// Withholding the writing tools is the first of plan mode's two gates. The
// second is the refusal below, which is what covers a write reaching the disk
// through run_terminal — a tool this list has to keep.
const offeredTools = planMode ? planModeTools(toolRegistry) : toolRegistry;
// Rendered once: the provider receives one string, while the measurement
// above keeps the pieces apart.
const renderedContext = renderContext(context);
// Read once per turn so a mid-turn environment change cannot make two
// iterations of the same turn assemble to different rules.
const historyBudget = toolHistoryBudget();
const state = new TurnState();
try {
while (state.iterations < budget) {
state.iterations++;
// Said to the model and to nobody else. A benchmark trial that exhausted
// its 200 iterations was still writing at its 198th tool call, because
// this warning only ever reached stderr — a status callback cannot change
// what the model does next, and a message in the conversation can.
//
// It used to be shown to the user as well, back when reaching the ceiling
// ended the turn as a failure and a warning was the only notice they got.
// Now the ceiling asks them directly, so a row saying the turn is nearly
// over is a worse version of a question they are about to be asked.
if (state.iterations === budget - REMAINING_ITERATIONS_WARNING) {
const remaining = budget - state.iterations;
messages.push({
role: "user",
content:
`Only ${remaining} more steps are available before this turn is stopped. ` +
`Finish what you have started rather than beginning anything new, ` +
`make sure the work is in a usable state, and report what is done and ` +
`what is not.`,
});
}
// Measured from the same array that is sent, so the segment sizes and
// the provider's token count describe one and the same request.
// Compaction is opt-in; see runtime/compaction.ts for the benchmark that
// turned it off. When enabled it applies to the request only, because the
// execution log is built from `messages` after the turn and shrinking
// what is sent is not the same as forgetting what happened.
const windowed = recentMessages(messages, MAX_TURNS);
const sentMessages =
historyBudget === null
? windowed
: compactToolHistory(windowed, historyBudget);
const segments = measureSegments(sentMessages, context);
const iterationStartedAt = Date.now();
const iteration = await streamIteration(
client,
sentMessages,
renderedContext,
offeredTools,
useTools,
callbacks,
state,
signal,
);
if (iteration.cancelled) {
callbacks.onCancel?.();
return "";
}
const { assistantText, toolCalls, usage, truncated } = iteration;
if (signal?.aborted) {
callbacks.onCancel?.();
return "";
}
if (truncated) {
state.salvagedIterations++;
callbacks.onStatus?.(
`⚠️ response was cut short (${truncated.message}); continuing from what arrived`,
);
}
// After the cancellation check: a turn the user interrupted did not
// complete an iteration, and reporting one would put a half-measured
// request into the log.
callbacks.onUsage?.({
iteration: state.iterations,
usage,
segments,
toolCalls: toolCalls.length,
durationMs: Date.now() - iterationStartedAt,
});
if (toolCalls.length === 0) {
const ending = finishTurn(
messages,
callbacks,
state,
assistantText,
budget,
truncated,
);
if (ending.kind === "continue") continue;
callbacks.onDone?.();
return ending.text;
}
// A provider can request several independent tools in one response. Run
// every requested call before asking the model for its next turn.
//
// They are tagged with the response they arrived in: a model that batches
// calls signs only the first of them, and the provider rejects history
// that splits such a batch across separate turns.
const batchId = crypto.randomUUID();
for (const toolCall of toolCalls) {
const outcome = await executeToolCall(
toolCall,
messages,
callbacks,
state,
batchId,
planMode,
signal,
);
if (outcome.kind === "cancelled") {
callbacks.onCancel?.();
return "";
}
if (outcome.kind === "declined") {
callbacks.onDone?.();
return outcome.outcome;
}
}
// The budget is spent and the turn is still working. Ask before ending
// it: the work so far is on disk either way, and whether to spend more is
// the user's call rather than this constant's.
//
// Inside the loop rather than after it, so answering `continue` re-enters
// the same `while` with a raised ceiling instead of restarting anything.
if (state.iterations >= budget) {
// No handler means nobody is there to answer, which is not the same as
// an answer of `stop`. Headless runs rely on this: they never implement
// it, so exhaustion stays the error their exit code is built on.
if (!callbacks.onBudgetExhausted) break;
const decision = await callbacks.onBudgetExhausted({
steps: state.iterations,
});
// No separate abort check: cancelling resolves an open checkpoint as
// `stop`, so Ctrl+C arrives here as the answer below.
if (decision === "stop") {
// Reported as a cancellation because that is what it is: the user
// stopped a turn that was still going. It also means the turn footer
// reads `cancelled` rather than `failed` — the controller sets that
// from this callback — which is the honest word for work that was
// halted rather than broken.
callbacks.onCancel?.();
return "";
}
budget += BUDGET_STEP;
}
}
throw new IterationBudgetExhaustedError(budget);
} catch (error) {
if (signal?.aborted) {
callbacks.onCancel?.();
return "";
}
const agentError =
error instanceof Error ? error : new Error(String(error));
callbacks.onError?.(agentError);
throw agentError;
} finally {
// Interpreter sessions are scoped to the turn, and this is the only place
// that runs on every one of its exits — completion, cancellation, an
// exhausted budget, a provider failure. A session that outlived its turn
// would answer the next one with variables nobody in that conversation set.
//
// Background processes deliberately do not end here: a server started this
// turn has to still be up for the user in the next one, so `process_stop`
// and session exit are what end those.
closeReplSessions();
// An image read on the last call before a cancellation is never attached,
// because the path that attaches them returns before reaching it. Dropping
// it here is what stops it arriving in the next turn, where it would be
// introduced as "the image requested above" with no such request in sight.
takePendingImages();
// Every exit is a turn that ended and is worth a record: a normal
// completion, a rejected edit, cancellation, an exhausted budget, a
// provider failure. A finally is what makes that exactly one record per
// call regardless of which path got here.
callbacks.onTurnSummary?.(state.toSummary());
}
}