@namzu/sdk 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +437 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +172 -162
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
- package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
- package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
- package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +4 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +24 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/contracts/api.d.ts +1 -1
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
- package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
- package/dist/gateway/completion-inbox.d.ts +94 -6
- package/dist/gateway/completion-inbox.d.ts.map +1 -1
- package/dist/gateway/completion-inbox.js +235 -15
- package/dist/gateway/completion-inbox.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +8 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +12 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/run/reporter.d.ts.map +1 -1
- package/dist/run/reporter.js +11 -0
- package/dist/run/reporter.js.map +1 -1
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
- package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
- package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
- package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
- package/dist/runtime/query/guard.d.ts +28 -0
- package/dist/runtime/query/guard.d.ts.map +1 -1
- package/dist/runtime/query/guard.js +31 -0
- package/dist/runtime/query/guard.js.map +1 -1
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
- package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
- package/dist/runtime/query/iteration/index.d.ts +92 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +814 -566
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
- package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +61 -4
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
- package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +109 -11
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/untrusted-envelope.d.ts.map +1 -1
- package/dist/tools/untrusted-envelope.js +9 -1
- package/dist/tools/untrusted-envelope.js.map +1 -1
- package/dist/types/agent/gateway.d.ts +18 -0
- package/dist/types/agent/gateway.d.ts.map +1 -1
- package/dist/types/provider/interface.d.ts +35 -0
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/types/run/entity.d.ts +16 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +75 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/run/prepare-step.d.ts +17 -2
- package/dist/types/run/prepare-step.d.ts.map +1 -1
- package/dist/types/verification/index.d.ts +98 -0
- package/dist/types/verification/index.d.ts.map +1 -1
- package/dist/types/verification/index.js +10 -0
- package/dist/types/verification/index.js.map +1 -1
- package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
- package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
- package/dist/verification/__tests__/argument-pattern.test.js +122 -0
- package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
- package/dist/verification/gate.d.ts.map +1 -1
- package/dist/verification/gate.js +44 -0
- package/dist/verification/gate.js.map +1 -1
- package/dist/verification/rules.d.ts.map +1 -1
- package/dist/verification/rules.js +27 -0
- package/dist/verification/rules.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +175 -166
- package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
- package/src/bridge/a2a/mapper.ts +4 -0
- package/src/bridge/sse/mapper.ts +25 -0
- package/src/contracts/api.ts +1 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
- package/src/gateway/completion-inbox.ts +248 -16
- package/src/manager/run/persistence.ts +12 -0
- package/src/run/reporter.ts +12 -0
- package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
- package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
- package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
- package/src/runtime/query/guard.ts +32 -0
- package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
- package/src/runtime/query/iteration/index.ts +902 -636
- package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
- package/src/runtime/query/iteration/phases/compaction.ts +83 -10
- package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
- package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
- package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
- package/src/tools/coordinator/index.ts +122 -15
- package/src/tools/untrusted-envelope.ts +9 -1
- package/src/types/agent/gateway.ts +18 -0
- package/src/types/provider/interface.ts +39 -0
- package/src/types/run/entity.ts +17 -0
- package/src/types/run/events.ts +75 -0
- package/src/types/run/prepare-step.ts +17 -2
- package/src/types/verification/index.ts +61 -0
- package/src/verification/__tests__/argument-pattern.test.ts +158 -0
- package/src/verification/gate.ts +45 -0
- package/src/verification/rules.ts +28 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SpanStatusCode } from '@opentelemetry/api';
|
|
2
|
+
import { resolveContextWindow } from '../../../compaction/context-window.js';
|
|
2
3
|
import { extractFromAssistantMessage } from '../../../compaction/extractor.js';
|
|
3
4
|
import { AUTO_CONTINUATION_USER_MESSAGE } from '../../../constants/continuation.js';
|
|
4
5
|
import { DEFAULT_STRUCTURED_OUTPUT_RETRIES, STRUCTURED_OUTPUT_REPROMPT, } from '../../../constants/tools/index.js';
|
|
@@ -8,6 +9,7 @@ import { collect } from '../../../provider/collect.js';
|
|
|
8
9
|
import { GENAI, NAMZU, agentIterationSpanName, parentContext, } from '../../../telemetry/attributes.js';
|
|
9
10
|
import { getTracer } from '../../../telemetry/runtime-accessors.js';
|
|
10
11
|
import { STRUCTURED_OUTPUT_TOOL_NAME } from '../../../tools/builtins/structuredOutput.js';
|
|
12
|
+
import { DELEGATION_TIMEOUT_MS } from '../../../tools/coordinator/index.js';
|
|
11
13
|
import { createAssistantMessage, createSystemMessage, createUserMessage, } from '../../../types/message/index.js';
|
|
12
14
|
import { classifyProviderError } from '../../../types/provider/errors.js';
|
|
13
15
|
import { toErrorMessage } from '../../../utils/error.js';
|
|
@@ -15,7 +17,7 @@ import { generateMessageId } from '../../../utils/id.js';
|
|
|
15
17
|
import { applyLifecycleHookResults } from '../plugin-hooks.js';
|
|
16
18
|
import { runAdvisoryPhase } from './phases/advisory.js';
|
|
17
19
|
import { runIterationCheckpoint } from './phases/checkpoint.js';
|
|
18
|
-
import { relieveOverflow, runCompactionCheck } from './phases/compaction.js';
|
|
20
|
+
import { measureContext, relieveOverflow, runCompactionCheck } from './phases/compaction.js';
|
|
19
21
|
import { runPlanGate } from './phases/plan.js';
|
|
20
22
|
import { runToolReview } from './phases/tool-review.js';
|
|
21
23
|
import { refreshWorkingMemory } from './phases/working-memory.js';
|
|
@@ -29,19 +31,82 @@ import { streamProviderTurn } from './stream-turn.js';
|
|
|
29
31
|
* on the thing that actually went wrong.
|
|
30
32
|
*/
|
|
31
33
|
const DEFAULT_ANSWER_REVIEW_LIMIT = 3;
|
|
34
|
+
/**
|
|
35
|
+
* The share of a run's REMAINING time a settle-hold may take.
|
|
36
|
+
*
|
|
37
|
+
* The rule is borrowed from `AGENT_MANAGER_DEFAULTS.maxBudgetFraction`, which
|
|
38
|
+
* gives a spawned child at most half of what its parent has left: one
|
|
39
|
+
* sub-activity may take a share of the remainder, never the remainder. The
|
|
40
|
+
* value is written out here rather than imported, because that field is a
|
|
41
|
+
* host-tunable knob about TOKEN allocation and coupling the two would let a
|
|
42
|
+
* host lowering one silently change the other.
|
|
43
|
+
*
|
|
44
|
+
* Half, specifically, because the hold is not the last thing the run does.
|
|
45
|
+
* Its whole purpose is to put a worker's result where the model can read it,
|
|
46
|
+
* and reading it costs a turn. A hold that spent everything remaining would
|
|
47
|
+
* deliver a notification into a run with no turn left to act on it — the same
|
|
48
|
+
* "the result exists and the model is never told" failure this mechanism was
|
|
49
|
+
* built to close, wearing a different costume.
|
|
50
|
+
*/
|
|
51
|
+
const SETTLE_GRACE_FRACTION = 0.5;
|
|
32
52
|
/**
|
|
33
53
|
* How long a finishing run waits for a background worker it launched.
|
|
34
54
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
55
|
+
* Derived from the run rather than fixed, because a constant is wrong in both
|
|
56
|
+
* directions at once. The 120 seconds this replaces held a run configured for
|
|
57
|
+
* a twenty-second timeout open for 120,267 ms — six times its own budget, and
|
|
58
|
+
* unreachable by the guard, which only checks between iterations — while on an
|
|
59
|
+
* hour-long run it abandoned workers measured at 4m21s, 5m58s and 8m04s, all
|
|
60
|
+
* of them well inside the hour the delegation tools themselves declare.
|
|
61
|
+
*
|
|
62
|
+
* **Bounded by construction, and against the right boundary.** The input is
|
|
63
|
+
* time-to-FINALIZE, not time-to-deadline (see
|
|
64
|
+
* `GuardCoordinator.remainingBeforeFinalizeMs`). Measuring to the deadline was
|
|
65
|
+
* the first attempt and it was wrong in a way that looked safe: a hold cannot
|
|
66
|
+
* outlive the deadline either way, but half of the time-to-deadline started
|
|
67
|
+
* just under the warning threshold ends at 95% of the budget — so the slice
|
|
68
|
+
* that exists for the run to produce a closing answer is half spent waiting
|
|
69
|
+
* for the result that answer was supposed to use. Against the finalize point
|
|
70
|
+
* the hold cannot reach the reserve at all, which is what makes the guard's
|
|
71
|
+
* inability to interrupt a hold a non-issue rather than a smaller issue.
|
|
72
|
+
*
|
|
73
|
+
* **The floor of zero is a decision, not a clamp artefact.** A run with no
|
|
74
|
+
* time left before it must start finishing has no turn in which to read a
|
|
75
|
+
* notification, so waiting could only delay a stop that is already due.
|
|
76
|
+
* Nothing is lost by it: `CompletionInbox.waitForArrival` returns before it
|
|
77
|
+
* looks at its timer when a completion is already in hand, so a zero grace
|
|
78
|
+
* still delivers everything that has arrived. No minimum is invented on top,
|
|
79
|
+
* because zero is exactly what a run past the threshold should wait — and
|
|
80
|
+
* reading the remainder at hold time rather than trusting `forceFinalize`,
|
|
81
|
+
* which is sampled at the top of the iteration, is what makes a long iteration
|
|
82
|
+
* that crossed the line in between compute it.
|
|
83
|
+
*
|
|
84
|
+
* **The ceiling is the longest anything in this subsystem waits for a
|
|
85
|
+
* delegated worker.** It binds only for a host whose run timeout exceeds
|
|
86
|
+
* roughly two and a quarter hours; below that the fraction is smaller.
|
|
39
87
|
*/
|
|
40
|
-
|
|
88
|
+
export function settleGraceMs(remainingBeforeFinalizeMs) {
|
|
89
|
+
return Math.min(Math.floor(remainingBeforeFinalizeMs * SETTLE_GRACE_FRACTION), DELEGATION_TIMEOUT_MS);
|
|
90
|
+
}
|
|
41
91
|
export class IterationOrchestrator {
|
|
42
92
|
ctx;
|
|
43
93
|
/** Rejections so far. See {@link DEFAULT_ANSWER_REVIEW_LIMIT}. */
|
|
44
94
|
answerReviewAttempts = 0;
|
|
95
|
+
/**
|
|
96
|
+
* The previous iteration held a `stopWhen` decision open for a worker.
|
|
97
|
+
*
|
|
98
|
+
* Set when the stop predicate fired and the run took one extra turn to
|
|
99
|
+
* read a delegated result, so the turn that then ends the run can report
|
|
100
|
+
* WHY it is over. Without it the outcome was right and the record was
|
|
101
|
+
* wrong: the run stopped because the host said so and reported `end_turn`,
|
|
102
|
+
* and this repo carries thirteen `StopReason` values precisely so that a
|
|
103
|
+
* run which ends for a nameable reason names it.
|
|
104
|
+
*
|
|
105
|
+
* Lives for exactly one iteration — see the read-and-clear at the top of
|
|
106
|
+
* the loop, which is the only site that touches it besides the one that
|
|
107
|
+
* sets it.
|
|
108
|
+
*/
|
|
109
|
+
stopDeferredForOutstandingWork = false;
|
|
45
110
|
constructor(ctx) {
|
|
46
111
|
this.ctx = ctx;
|
|
47
112
|
}
|
|
@@ -72,378 +137,382 @@ export class IterationOrchestrator {
|
|
|
72
137
|
const planSignal = yield* runPlanGate(this.ctx);
|
|
73
138
|
if (planSignal === 'stop')
|
|
74
139
|
return;
|
|
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
|
-
if (iterationActivity) {
|
|
111
|
-
this.ctx.activityStore.start(iterationActivity.id);
|
|
112
|
-
}
|
|
113
|
-
// Parent explicitly: this body is an async generator, so the
|
|
114
|
-
// ambient context at resume time belongs to the CONSUMER, not to
|
|
115
|
-
// whoever created the run span. Without this every iteration
|
|
116
|
-
// emits as its own root and a 20-turn run shows up as 21
|
|
117
|
-
// disconnected traces.
|
|
118
|
-
const iterSpan = tracer.startSpan(agentIterationSpanName(iterationNum), {}, parentContext(this.ctx.rootSpan));
|
|
119
|
-
try {
|
|
120
|
-
// Tool spans for this turn belong under this iteration. Inside
|
|
121
|
-
// the try rather than before it: a throw from any of these left
|
|
122
|
-
// the span open, and an iteration span that never ends is a
|
|
123
|
-
// trace that never closes — the export is incomplete for exactly
|
|
124
|
-
// the run that failed.
|
|
125
|
-
this.ctx.toolExecutor.setParentSpan(iterSpan);
|
|
126
|
-
iterSpan.setAttributes({
|
|
127
|
-
[NAMZU.ITERATION]: iterationNum,
|
|
128
|
-
[NAMZU.RUN_ID]: runMgr.id,
|
|
129
|
-
[GENAI.REQUEST_MODEL]: model,
|
|
130
|
-
});
|
|
131
|
-
await this.ctx.emitEvent({
|
|
132
|
-
type: 'iteration_started',
|
|
133
|
-
runId: runMgr.id,
|
|
134
|
-
iteration: iterationNum,
|
|
135
|
-
});
|
|
136
|
-
yield* this.ctx.drainPending();
|
|
137
|
-
if (this.ctx.pluginManager) {
|
|
138
|
-
const hookResults = await this.ctx.pluginManager.executeHooks('iteration_start', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
|
|
139
|
-
applyLifecycleHookResults('iteration_start', hookResults);
|
|
140
|
-
yield* this.ctx.drainPending();
|
|
141
|
-
}
|
|
142
|
-
// Re-pin the working-memory block from ground truth at the primacy
|
|
143
|
-
// edge BEFORE compaction runs (so the refreshed slot is what
|
|
144
|
-
// compaction preserves). No-op when no provider is configured.
|
|
145
|
-
await refreshWorkingMemory(this.ctx);
|
|
146
|
-
await runCompactionCheck(this.ctx);
|
|
147
|
-
// Cache discipline: keep the tools param byte-stable even on the
|
|
148
|
-
// forced-final iteration and forbid tool use via tool_choice
|
|
149
|
-
// 'none' instead. Dropping the tools array would invalidate the
|
|
150
|
-
// entire prompt-cache prefix (tools render at position 0) and
|
|
151
|
-
// risks a 400 because the history still carries
|
|
152
|
-
// tool_use/tool_result blocks.
|
|
153
|
-
// Snapshot the cumulative counters so the step can report ITS
|
|
154
|
-
// own usage rather than the run total.
|
|
155
|
-
const stepStartedAt = Date.now();
|
|
156
|
-
const usageBefore = { ...runMgr.tokenUsage };
|
|
157
|
-
const costBefore = { ...runMgr.costInfo };
|
|
158
|
-
// Shape this step before calling the model. `stopWhen` decides
|
|
159
|
-
// whether to keep going; this decides HOW. No-op when the host
|
|
160
|
-
// supplied no hook.
|
|
161
|
-
const step = await this.prepareStep(iterationNum);
|
|
162
|
-
const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools;
|
|
163
|
-
const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools);
|
|
164
|
-
// The same list the request was built from now also bounds what
|
|
165
|
-
// may run. Narrowing only the request left the restriction
|
|
166
|
-
// presentational — the model was shown fewer tools and could
|
|
167
|
-
// still call any of them by name.
|
|
168
|
-
this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools);
|
|
169
|
-
const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools);
|
|
170
|
-
const stepModel = step.model ?? model;
|
|
171
|
-
const baseMessages = forceFinalize
|
|
172
|
-
? [
|
|
173
|
-
...runMgr.messages,
|
|
174
|
-
createUserMessage('[SYSTEM] You are approaching your resource limits. Provide your final, comprehensive response now based on everything you have gathered so far. Do not request any more tool calls.'),
|
|
175
|
-
]
|
|
176
|
-
: runMgr.messages;
|
|
177
|
-
// Step guidance is appended to the REQUEST, never pushed onto
|
|
178
|
-
// the run's history: it applies to this step only, and pushing
|
|
179
|
-
// it would accumulate one stale instruction per iteration.
|
|
180
|
-
// Copy before it crosses the provider boundary. `runMgr.messages`
|
|
181
|
-
// is the LIVE run array, and the loop pushes onto it after the
|
|
182
|
-
// call returns — so a driver that retains what it was handed
|
|
183
|
-
// (to log it, cache it, or replay it on retry) watched its own
|
|
184
|
-
// input grow new turns underneath it. A capture provider in the
|
|
185
|
-
// estate recorded every turn as identical to the last for
|
|
186
|
-
// exactly this reason. Shallow is enough: the defect is array
|
|
187
|
-
// mutation, and per-iteration this is trivial next to the model
|
|
188
|
-
// call it precedes.
|
|
189
|
-
// A step's skills and its guidance ride the same ephemeral
|
|
190
|
-
// trailing system message. Appending leaves the cached prefix
|
|
191
|
-
// intact; rewriting the run's own prompt to carry a phase's
|
|
192
|
-
// skills would invalidate it on every iteration.
|
|
193
|
-
// `renderSkillsSection` already answers null for an empty list, so
|
|
194
|
-
// there is no length check here — a second guard for the same
|
|
195
|
-
// case is one more thing to keep in agreement with the first.
|
|
196
|
-
const stepSkills = step.skills ? renderSkillsSection([...step.skills]) : null;
|
|
197
|
-
const stepPreamble = [step.system, stepSkills].filter(Boolean).join('\n\n');
|
|
198
|
-
const messages = stepPreamble
|
|
199
|
-
? [...baseMessages, createSystemMessage(stepPreamble)]
|
|
200
|
-
: [...baseMessages];
|
|
201
|
-
if (this.ctx.pluginManager) {
|
|
202
|
-
const hookResults = await this.ctx.pluginManager.executeHooks('pre_llm_call', {
|
|
203
|
-
runId: runMgr.id,
|
|
204
|
-
iteration: iterationNum,
|
|
205
|
-
// Built inside the guard: a run with no plugins installed
|
|
206
|
-
// pays nothing for a projection nobody reads.
|
|
207
|
-
request: Object.freeze({
|
|
208
|
-
model: stepModel,
|
|
209
|
-
// Copied per turn, not handed over live: these are the
|
|
210
|
-
// run's own message objects, and a hook writing into
|
|
211
|
-
// one would edit the history the run is about to send.
|
|
212
|
-
messages: Object.freeze(messages.map((m) => Object.freeze({ ...m }))),
|
|
213
|
-
toolNames: Object.freeze(llmTools.map((t) => t.function.name)),
|
|
214
|
-
temperature: step.temperature ?? runConfig.temperature,
|
|
215
|
-
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
216
|
-
}),
|
|
217
|
-
}, this.ctx.emitEvent);
|
|
218
|
-
applyLifecycleHookResults('pre_llm_call', hookResults);
|
|
219
|
-
yield* this.ctx.drainPending();
|
|
220
|
-
}
|
|
221
|
-
// Phase 4 (ses_001-tool-stream-events): consume the
|
|
222
|
-
// streaming response natively, emitting message and
|
|
223
|
-
// tool-input lifecycle events as deltas arrive. The
|
|
224
|
-
// helper yields RunEvents through drainPending() so SSE
|
|
225
|
-
// consumers see live progress; its return value is the
|
|
226
|
-
// aggregated `ChatCompletionResponse` for the legacy
|
|
227
|
-
// downstream paths (assistantMsg construction, working
|
|
228
|
-
// state extraction, telemetry attribute stamping).
|
|
229
|
-
const { response, messageId } = yield* streamProviderTurn(this.ctx.provider, {
|
|
230
|
-
model: stepModel,
|
|
231
|
-
messages,
|
|
232
|
-
tools: llmTools.length > 0 ? llmTools : undefined,
|
|
233
|
-
...(enforceToolInputSchema ? { enforceToolInputSchema } : {}),
|
|
234
|
-
// The forced-final turn wins: a step that asked to force a
|
|
235
|
-
// tool cannot override the loop's own decision to stop
|
|
236
|
-
// asking for them. Otherwise the step's choice applies —
|
|
237
|
-
// and only to this step, because the next one is prepared
|
|
238
|
-
// from scratch.
|
|
239
|
-
toolChoice: forceFinalize && llmTools.length > 0
|
|
240
|
-
? 'none'
|
|
241
|
-
: llmTools.length > 0
|
|
242
|
-
? step.toolChoice
|
|
243
|
-
: undefined,
|
|
244
|
-
temperature: step.temperature ?? runConfig.temperature,
|
|
245
|
-
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
246
|
-
cacheControl: { type: 'auto' },
|
|
247
|
-
...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
|
|
248
|
-
...(runConfig.effort ? { effort: runConfig.effort } : {}),
|
|
249
|
-
// Thread the run abort into the model call so a Stop tears the
|
|
250
|
-
// in-flight turn down (provider passes it to fetch; the consumer
|
|
251
|
-
// also races it). Inert when never aborted.
|
|
252
|
-
signal: this.ctx.abortController.signal,
|
|
253
|
-
}, this.ctx.emitEvent, this.ctx.drainPending, runMgr.id, iterationNum, forceFinalize, this.ctx.log, iterSpan);
|
|
254
|
-
// Main-loop turn: also records the prompt size compaction reads.
|
|
255
|
-
runMgr.recordTurnUsage(response.usage);
|
|
256
|
-
// The turn went through, so the run is not sitting on an
|
|
257
|
-
// irreducible prompt any more. Re-arm relief for the next one.
|
|
258
|
-
overflowRelieved = false;
|
|
259
|
-
if (this.ctx.pluginManager) {
|
|
260
|
-
const hookResults = await this.ctx.pluginManager.executeHooks('post_llm_call', {
|
|
140
|
+
// A `finally` rather than a line at each exit, for the reason written
|
|
141
|
+
// beside `iterSpan.end()` below: this loop leaves by eight `break`s,
|
|
142
|
+
// two `return`s and a `throw`, and a rule every future edit has to
|
|
143
|
+
// remember is a rule that gets forgotten — measured, it had been. Only
|
|
144
|
+
// the ordinary final-answer exit consulted the inbox, so a run that
|
|
145
|
+
// ended on a terminal tool, a structured output or the host's
|
|
146
|
+
// `stopWhen` settled over a finished worker's output and threw it away.
|
|
147
|
+
// A `finally` also covers a generator abandoned by its consumer, which
|
|
148
|
+
// no post-loop block reaches.
|
|
149
|
+
try {
|
|
150
|
+
while (true) {
|
|
151
|
+
// Read AND clear, in that order, in this one place.
|
|
152
|
+
//
|
|
153
|
+
// The flag is set by the previous iteration and read by this
|
|
154
|
+
// one, so a clear that ran before the read would wipe it
|
|
155
|
+
// before anything could use it — the obvious spelling of
|
|
156
|
+
// "clear it at the top" is the broken one. Taking the value
|
|
157
|
+
// into a local first gives the flag a lifetime of exactly one
|
|
158
|
+
// iteration, which is the property that makes this cheap: no
|
|
159
|
+
// path has to remember to clear it, because the next iteration
|
|
160
|
+
// does so whether or not anything read it, and there is no
|
|
161
|
+
// path by which a stale deferral can reach a later turn.
|
|
162
|
+
const stopWasDeferredForOutstandingWork = this.stopDeferredForOutstandingWork;
|
|
163
|
+
this.stopDeferredForOutstandingWork = false;
|
|
164
|
+
const guardResult = this.ctx.guard.beforeIteration(runMgr, this.ctx.abortController.signal);
|
|
165
|
+
if (guardResult.shouldStop) {
|
|
166
|
+
if (guardResult.isCancelled) {
|
|
167
|
+
this.ctx.log.info('Run cancelled by signal', { runId: runMgr.id });
|
|
168
|
+
runMgr.setStopReason('cancelled');
|
|
169
|
+
runMgr.markCancelled();
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const stopReason = guardResult.stopReason ?? 'end_turn';
|
|
173
|
+
this.ctx.log.info('Guard enforcing stop', {
|
|
261
174
|
runId: runMgr.id,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}),
|
|
269
|
-
}, this.ctx.emitEvent);
|
|
270
|
-
applyLifecycleHookResults('post_llm_call', hookResults);
|
|
175
|
+
stopReason,
|
|
176
|
+
iteration: runMgr.currentIteration,
|
|
177
|
+
inputTokens: runMgr.tokenUsage.promptTokens,
|
|
178
|
+
outputTokens: runMgr.tokenUsage.completionTokens,
|
|
179
|
+
});
|
|
180
|
+
await this.requestFinalResponse(model, stopReason);
|
|
271
181
|
yield* this.ctx.drainPending();
|
|
182
|
+
runMgr.setStopReason(stopReason);
|
|
183
|
+
break;
|
|
272
184
|
}
|
|
273
|
-
|
|
185
|
+
const forceFinalize = guardResult.forceFinalize;
|
|
186
|
+
const iterationNum = runMgr.incrementIteration();
|
|
187
|
+
this.ctx.log.debug('Iteration started', {
|
|
274
188
|
runId: runMgr.id,
|
|
275
189
|
iteration: iterationNum,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
promptTokens: response.usage.promptTokens,
|
|
280
|
-
completionTokens: response.usage.completionTokens,
|
|
281
|
-
totalTokens: runMgr.tokenUsage.totalTokens,
|
|
282
|
-
totalCost: runMgr.costInfo.totalCost,
|
|
283
|
-
});
|
|
284
|
-
await this.ctx.emitEvent({
|
|
285
|
-
type: 'token_usage_updated',
|
|
286
|
-
runId: runMgr.id,
|
|
287
|
-
usage: runMgr.tokenUsage,
|
|
288
|
-
cost: runMgr.costInfo,
|
|
190
|
+
model,
|
|
191
|
+
forceFinalize,
|
|
192
|
+
messageCount: runMgr.messages.length,
|
|
289
193
|
});
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// and no separate atomicity rule is needed.
|
|
294
|
-
const assistantMsg = createAssistantMessage(response.message.content, forceFinalize ? undefined : response.message.toolCalls, response.message.reasoning,
|
|
295
|
-
// Rides with the turn it belongs to, like reasoning does, so
|
|
296
|
-
// trimming or compacting the turn takes its evidence with it
|
|
297
|
-
// rather than leaving citations pointing at prose that is gone.
|
|
298
|
-
response.message.citations);
|
|
299
|
-
runMgr.pushMessage(assistantMsg);
|
|
300
|
-
if (this.ctx.workingStateManager && this.ctx.compactionConfig && assistantMsg.content) {
|
|
301
|
-
extractFromAssistantMessage(this.ctx.workingStateManager, assistantMsg.content, this.ctx.compactionConfig);
|
|
302
|
-
}
|
|
303
|
-
yield* this.ctx.drainPending();
|
|
304
|
-
iterSpan.setAttributes({
|
|
305
|
-
[GENAI.USAGE_INPUT_TOKENS]: response.usage.promptTokens,
|
|
306
|
-
[GENAI.USAGE_OUTPUT_TOKENS]: response.usage.completionTokens,
|
|
194
|
+
const iterationActivity = this.ctx.activityStore.create({
|
|
195
|
+
type: 'llm_turn',
|
|
196
|
+
description: `LLM iteration ${iterationNum}`,
|
|
307
197
|
});
|
|
308
|
-
iterSpan.setStatus({ code: SpanStatusCode.OK });
|
|
309
198
|
if (iterationActivity) {
|
|
310
|
-
this.ctx.activityStore.
|
|
311
|
-
content: response.message.content,
|
|
312
|
-
hasToolCalls: forceFinalize ? false : !!response.message.toolCalls?.length,
|
|
313
|
-
});
|
|
199
|
+
this.ctx.activityStore.start(iterationActivity.id);
|
|
314
200
|
}
|
|
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
|
-
this.ctx.
|
|
343
|
-
runId: runMgr.id,
|
|
344
|
-
iteration: iterationNum,
|
|
345
|
-
});
|
|
201
|
+
// Parent explicitly: this body is an async generator, so the
|
|
202
|
+
// ambient context at resume time belongs to the CONSUMER, not to
|
|
203
|
+
// whoever created the run span. Without this every iteration
|
|
204
|
+
// emits as its own root and a 20-turn run shows up as 21
|
|
205
|
+
// disconnected traces.
|
|
206
|
+
const iterSpan = tracer.startSpan(agentIterationSpanName(iterationNum), {}, parentContext(this.ctx.rootSpan));
|
|
207
|
+
try {
|
|
208
|
+
// Tool spans for this turn belong under this iteration. Inside
|
|
209
|
+
// the try rather than before it: a throw from any of these left
|
|
210
|
+
// the span open, and an iteration span that never ends is a
|
|
211
|
+
// trace that never closes — the export is incomplete for exactly
|
|
212
|
+
// the run that failed.
|
|
213
|
+
this.ctx.toolExecutor.setParentSpan(iterSpan);
|
|
214
|
+
iterSpan.setAttributes({
|
|
215
|
+
[NAMZU.ITERATION]: iterationNum,
|
|
216
|
+
[NAMZU.RUN_ID]: runMgr.id,
|
|
217
|
+
[GENAI.REQUEST_MODEL]: model,
|
|
218
|
+
});
|
|
219
|
+
await this.ctx.emitEvent({
|
|
220
|
+
type: 'iteration_started',
|
|
221
|
+
runId: runMgr.id,
|
|
222
|
+
iteration: iterationNum,
|
|
223
|
+
});
|
|
224
|
+
yield* this.ctx.drainPending();
|
|
225
|
+
if (this.ctx.pluginManager) {
|
|
226
|
+
const hookResults = await this.ctx.pluginManager.executeHooks('iteration_start', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
|
|
227
|
+
applyLifecycleHookResults('iteration_start', hookResults);
|
|
228
|
+
yield* this.ctx.drainPending();
|
|
346
229
|
}
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
230
|
+
// Re-pin the working-memory block from ground truth at the primacy
|
|
231
|
+
// edge BEFORE compaction runs (so the refreshed slot is what
|
|
232
|
+
// compaction preserves). No-op when no provider is configured.
|
|
233
|
+
await refreshWorkingMemory(this.ctx);
|
|
234
|
+
await runCompactionCheck(this.ctx);
|
|
235
|
+
// Cache discipline: keep the tools param byte-stable even on the
|
|
236
|
+
// forced-final iteration and forbid tool use via tool_choice
|
|
237
|
+
// 'none' instead. Dropping the tools array would invalidate the
|
|
238
|
+
// entire prompt-cache prefix (tools render at position 0) and
|
|
239
|
+
// risks a 400 because the history still carries
|
|
240
|
+
// tool_use/tool_result blocks.
|
|
241
|
+
// Snapshot the cumulative counters so the step can report ITS
|
|
242
|
+
// own usage rather than the run total.
|
|
243
|
+
const stepStartedAt = Date.now();
|
|
244
|
+
const usageBefore = { ...runMgr.tokenUsage };
|
|
245
|
+
const costBefore = { ...runMgr.costInfo };
|
|
246
|
+
// Shape this step before calling the model. `stopWhen` decides
|
|
247
|
+
// whether to keep going; this decides HOW. No-op when the host
|
|
248
|
+
// supplied no hook.
|
|
249
|
+
const step = await this.prepareStep(iterationNum);
|
|
250
|
+
const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools;
|
|
251
|
+
const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools);
|
|
252
|
+
// The same list the request was built from now also bounds what
|
|
253
|
+
// may run. Narrowing only the request left the restriction
|
|
254
|
+
// presentational — the model was shown fewer tools and could
|
|
255
|
+
// still call any of them by name.
|
|
256
|
+
this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools);
|
|
257
|
+
const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools);
|
|
258
|
+
const stepModel = step.model ?? model;
|
|
259
|
+
const baseMessages = forceFinalize
|
|
260
|
+
? [
|
|
261
|
+
...runMgr.messages,
|
|
262
|
+
createUserMessage('[SYSTEM] You are approaching your resource limits. Provide your final, comprehensive response now based on everything you have gathered so far. Do not request any more tool calls.'),
|
|
263
|
+
]
|
|
264
|
+
: runMgr.messages;
|
|
265
|
+
// Step guidance is appended to the REQUEST, never pushed onto
|
|
266
|
+
// the run's history: it applies to this step only, and pushing
|
|
267
|
+
// it would accumulate one stale instruction per iteration.
|
|
268
|
+
// Copy before it crosses the provider boundary. `runMgr.messages`
|
|
269
|
+
// is the LIVE run array, and the loop pushes onto it after the
|
|
270
|
+
// call returns — so a driver that retains what it was handed
|
|
271
|
+
// (to log it, cache it, or replay it on retry) watched its own
|
|
272
|
+
// input grow new turns underneath it. A capture provider in the
|
|
273
|
+
// estate recorded every turn as identical to the last for
|
|
274
|
+
// exactly this reason. Shallow is enough: the defect is array
|
|
275
|
+
// mutation, and per-iteration this is trivial next to the model
|
|
276
|
+
// call it precedes.
|
|
277
|
+
// A step's skills and its guidance ride the same ephemeral
|
|
278
|
+
// trailing system message. Appending leaves the cached prefix
|
|
279
|
+
// intact; rewriting the run's own prompt to carry a phase's
|
|
280
|
+
// skills would invalidate it on every iteration.
|
|
281
|
+
// `renderSkillsSection` already answers null for an empty list, so
|
|
282
|
+
// there is no length check here — a second guard for the same
|
|
283
|
+
// case is one more thing to keep in agreement with the first.
|
|
284
|
+
const stepSkills = step.skills ? renderSkillsSection([...step.skills]) : null;
|
|
285
|
+
const stepPreamble = [step.system, stepSkills].filter(Boolean).join('\n\n');
|
|
286
|
+
const messages = stepPreamble
|
|
287
|
+
? [...baseMessages, createSystemMessage(stepPreamble)]
|
|
288
|
+
: [...baseMessages];
|
|
289
|
+
if (this.ctx.pluginManager) {
|
|
290
|
+
const hookResults = await this.ctx.pluginManager.executeHooks('pre_llm_call', {
|
|
369
291
|
runId: runMgr.id,
|
|
370
292
|
iteration: iterationNum,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
293
|
+
// Built inside the guard: a run with no plugins installed
|
|
294
|
+
// pays nothing for a projection nobody reads.
|
|
295
|
+
request: Object.freeze({
|
|
296
|
+
model: stepModel,
|
|
297
|
+
// Copied per turn, not handed over live: these are the
|
|
298
|
+
// run's own message objects, and a hook writing into
|
|
299
|
+
// one would edit the history the run is about to send.
|
|
300
|
+
messages: Object.freeze(messages.map((m) => Object.freeze({ ...m }))),
|
|
301
|
+
toolNames: Object.freeze(llmTools.map((t) => t.function.name)),
|
|
302
|
+
temperature: step.temperature ?? runConfig.temperature,
|
|
303
|
+
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
304
|
+
}),
|
|
305
|
+
}, this.ctx.emitEvent);
|
|
306
|
+
applyLifecycleHookResults('pre_llm_call', hookResults);
|
|
380
307
|
yield* this.ctx.drainPending();
|
|
381
|
-
continue;
|
|
382
308
|
}
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
309
|
+
// Phase 4 (ses_001-tool-stream-events): consume the
|
|
310
|
+
// streaming response natively, emitting message and
|
|
311
|
+
// tool-input lifecycle events as deltas arrive. The
|
|
312
|
+
// helper yields RunEvents through drainPending() so SSE
|
|
313
|
+
// consumers see live progress; its return value is the
|
|
314
|
+
// aggregated `ChatCompletionResponse` for the legacy
|
|
315
|
+
// downstream paths (assistantMsg construction, working
|
|
316
|
+
// state extraction, telemetry attribute stamping).
|
|
317
|
+
const { response, messageId } = yield* streamProviderTurn(this.ctx.provider, {
|
|
318
|
+
model: stepModel,
|
|
319
|
+
messages,
|
|
320
|
+
tools: llmTools.length > 0 ? llmTools : undefined,
|
|
321
|
+
...(enforceToolInputSchema ? { enforceToolInputSchema } : {}),
|
|
322
|
+
// The forced-final turn wins: a step that asked to force a
|
|
323
|
+
// tool cannot override the loop's own decision to stop
|
|
324
|
+
// asking for them. Otherwise the step's choice applies —
|
|
325
|
+
// and only to this step, because the next one is prepared
|
|
326
|
+
// from scratch.
|
|
327
|
+
toolChoice: forceFinalize && llmTools.length > 0
|
|
328
|
+
? 'none'
|
|
329
|
+
: llmTools.length > 0
|
|
330
|
+
? step.toolChoice
|
|
331
|
+
: undefined,
|
|
332
|
+
temperature: step.temperature ?? runConfig.temperature,
|
|
333
|
+
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
334
|
+
cacheControl: { type: 'auto' },
|
|
335
|
+
...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
|
|
336
|
+
...(runConfig.effort ? { effort: runConfig.effort } : {}),
|
|
337
|
+
// Thread the run abort into the model call so a Stop tears the
|
|
338
|
+
// in-flight turn down (provider passes it to fetch; the consumer
|
|
339
|
+
// also races it). Inert when never aborted.
|
|
340
|
+
signal: this.ctx.abortController.signal,
|
|
341
|
+
}, this.ctx.emitEvent, this.ctx.drainPending, runMgr.id, iterationNum, forceFinalize, this.ctx.log, iterSpan);
|
|
342
|
+
// Main-loop turn: also records the prompt size compaction reads.
|
|
343
|
+
runMgr.recordTurnUsage(response.usage);
|
|
344
|
+
// The turn went through, so the run is not sitting on an
|
|
345
|
+
// irreducible prompt any more. Re-arm relief for the next one.
|
|
346
|
+
overflowRelieved = false;
|
|
347
|
+
if (this.ctx.pluginManager) {
|
|
348
|
+
const hookResults = await this.ctx.pluginManager.executeHooks('post_llm_call', {
|
|
407
349
|
runId: runMgr.id,
|
|
408
350
|
iteration: iterationNum,
|
|
409
|
-
|
|
410
|
-
|
|
351
|
+
response: Object.freeze({
|
|
352
|
+
content: response.message.content,
|
|
353
|
+
toolNames: Object.freeze((response.message.toolCalls ?? []).map((c) => c.function.name)),
|
|
354
|
+
finishReason: response.finishReason,
|
|
355
|
+
usage: Object.freeze({ ...response.usage }),
|
|
356
|
+
}),
|
|
357
|
+
}, this.ctx.emitEvent);
|
|
358
|
+
applyLifecycleHookResults('post_llm_call', hookResults);
|
|
411
359
|
yield* this.ctx.drainPending();
|
|
412
|
-
continue;
|
|
413
360
|
}
|
|
414
|
-
|
|
361
|
+
this.ctx.log.debug('LLM response received', {
|
|
362
|
+
runId: runMgr.id,
|
|
363
|
+
iteration: iterationNum,
|
|
364
|
+
finishReason: response.finishReason,
|
|
365
|
+
hasContent: response.message.content !== null && response.message.content.length > 0,
|
|
366
|
+
toolCallCount: response.message.toolCalls?.length ?? 0,
|
|
367
|
+
promptTokens: response.usage.promptTokens,
|
|
368
|
+
completionTokens: response.usage.completionTokens,
|
|
369
|
+
totalTokens: runMgr.tokenUsage.totalTokens,
|
|
370
|
+
totalCost: runMgr.costInfo.totalCost,
|
|
371
|
+
});
|
|
372
|
+
// The context figures ride with the spend figures because a
|
|
373
|
+
// surface showing one almost always wants the other — and
|
|
374
|
+
// because the two were confusable enough that a host divided
|
|
375
|
+
// cumulative spend by a context window and shipped it. They
|
|
376
|
+
// are measured here rather than left to be derived, since the
|
|
377
|
+
// only correct derivation needs internals a host cannot see.
|
|
415
378
|
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
379
|
+
// Absent when the run has no compaction config: nothing then
|
|
380
|
+
// resolves a window, and inventing one would be the guess this
|
|
381
|
+
// replaces.
|
|
382
|
+
const contextFigures = this.ctx.compactionConfig
|
|
383
|
+
? (() => {
|
|
384
|
+
const measured = measureContext(this.ctx);
|
|
385
|
+
const window = resolveContextWindow(this.ctx.compactionConfig?.contextWindowTokens, runConfig.model);
|
|
386
|
+
return {
|
|
387
|
+
contextTokens: measured.tokens,
|
|
388
|
+
contextMeasuredBy: measured.source,
|
|
389
|
+
contextWindowTokens: window.tokens,
|
|
390
|
+
windowSource: window.source,
|
|
391
|
+
};
|
|
392
|
+
})()
|
|
393
|
+
: {};
|
|
394
|
+
await this.ctx.emitEvent({
|
|
395
|
+
type: 'token_usage_updated',
|
|
396
|
+
runId: runMgr.id,
|
|
397
|
+
usage: runMgr.tokenUsage,
|
|
398
|
+
cost: runMgr.costInfo,
|
|
399
|
+
...contextFigures,
|
|
400
|
+
});
|
|
401
|
+
// Reasoning rides along with the turn it belongs to, so the
|
|
402
|
+
// replay contract holds automatically: trimming or compacting
|
|
403
|
+
// the assistant message takes its thinking blocks with it,
|
|
404
|
+
// and no separate atomicity rule is needed.
|
|
405
|
+
const assistantMsg = createAssistantMessage(response.message.content, forceFinalize ? undefined : response.message.toolCalls, response.message.reasoning,
|
|
406
|
+
// Rides with the turn it belongs to, like reasoning does, so
|
|
407
|
+
// trimming or compacting the turn takes its evidence with it
|
|
408
|
+
// rather than leaving citations pointing at prose that is gone.
|
|
409
|
+
response.message.citations);
|
|
410
|
+
runMgr.pushMessage(assistantMsg);
|
|
411
|
+
if (this.ctx.workingStateManager && this.ctx.compactionConfig && assistantMsg.content) {
|
|
412
|
+
extractFromAssistantMessage(this.ctx.workingStateManager, assistantMsg.content, this.ctx.compactionConfig);
|
|
413
|
+
}
|
|
414
|
+
yield* this.ctx.drainPending();
|
|
415
|
+
iterSpan.setAttributes({
|
|
416
|
+
[GENAI.USAGE_INPUT_TOKENS]: response.usage.promptTokens,
|
|
417
|
+
[GENAI.USAGE_OUTPUT_TOKENS]: response.usage.completionTokens,
|
|
418
|
+
});
|
|
419
|
+
iterSpan.setStatus({ code: SpanStatusCode.OK });
|
|
420
|
+
if (iterationActivity) {
|
|
421
|
+
this.ctx.activityStore.complete(iterationActivity.id, {
|
|
422
|
+
content: response.message.content,
|
|
423
|
+
hasToolCalls: forceFinalize ? false : !!response.message.toolCalls?.length,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
// Tool calls beat the finish reason. The reason is the
|
|
427
|
+
// provider's SUMMARY of the turn and the tool calls are the
|
|
428
|
+
// turn itself, so when they disagree the calls are the fact.
|
|
429
|
+
// Several function-calling endpoints — gateways and local servers
|
|
430
|
+
// especially — report `stop` alongside a populated
|
|
431
|
+
// `tool_calls`, and three of this repo's drivers pass that
|
|
432
|
+
// value through untouched.
|
|
422
433
|
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
434
|
+
// Reading `stop` first meant the turn ended with every
|
|
435
|
+
// requested call silently skipped, an assistant message
|
|
436
|
+
// carrying tool_use blocks that were never answered, and a
|
|
437
|
+
// run that settled `end_turn` having done nothing it was
|
|
438
|
+
// asked to do. Checking the calls first costs nothing when
|
|
439
|
+
// the provider is honest and is the only thing that saves the
|
|
440
|
+
// run when it is not.
|
|
441
|
+
const hasToolCalls = (response.message.toolCalls?.length ?? 0) > 0;
|
|
442
|
+
if (forceFinalize || !hasToolCalls) {
|
|
443
|
+
// Every task-dispatch tool (create_task, continue_task, Agent)
|
|
444
|
+
// is BLOCKING: the worker's output returns as the dispatching
|
|
445
|
+
// tool_use's canonical tool_result, so by the time the model
|
|
446
|
+
// ends its turn nothing launched by this run should still be
|
|
447
|
+
// in flight. A running task here is an orphan (interrupted
|
|
448
|
+
// tool execution, cancel race) with no delivery path back to
|
|
449
|
+
// the parent — the <task-notification> producer was removed
|
|
450
|
+
// in dc16d58, so waiting on the queue could only ever time
|
|
451
|
+
// out. Log the orphans honestly and end the turn normally.
|
|
452
|
+
if (!forceFinalize && this.hasRunningAgentTasks()) {
|
|
453
|
+
this.ctx.log.warn('LLM ended turn with agent tasks still running — ending run without waiting (orphan tasks have no delivery path)', {
|
|
454
|
+
runId: runMgr.id,
|
|
455
|
+
iteration: iterationNum,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const hasContent = response.message.content !== null && response.message.content.length > 0;
|
|
459
|
+
// Auto-continuation on `stop_reason: max_tokens`. The
|
|
460
|
+
// model hit its per-call output cap mid-text (NOT
|
|
461
|
+
// mid-tool-use — that path is handled separately
|
|
462
|
+
// below via `inputTruncated`). Push a synthetic
|
|
463
|
+
// "continue" user message and let the loop fire
|
|
464
|
+
// another turn. The provider receives the partial
|
|
465
|
+
// assistant content + the continue prompt and
|
|
466
|
+
// resumes from where it left off, mirroring the
|
|
467
|
+
// Auto-continuation after an output-ceiling cutoff.
|
|
468
|
+
//
|
|
469
|
+
// Guards:
|
|
470
|
+
// - `hasContent` so we don't loop forever on an
|
|
471
|
+
// empty cutoff (a provider occasionally emits
|
|
472
|
+
// `stop_reason: max_tokens` with no content
|
|
473
|
+
// when an injected pre-fill blocks the model).
|
|
474
|
+
// - `!forceFinalize` so the forced-finalize path
|
|
475
|
+
// never auto-continues — that path is invoked
|
|
476
|
+
// specifically to extract a closing summary.
|
|
477
|
+
// - max_iterations bounds the loop in any case.
|
|
478
|
+
if (!forceFinalize && response.finishReason === 'length' && hasContent) {
|
|
479
|
+
this.ctx.log.info('LLM hit max_tokens mid-text — auto-continuing', {
|
|
480
|
+
runId: runMgr.id,
|
|
481
|
+
iteration: iterationNum,
|
|
482
|
+
completionTokens: response.usage.completionTokens,
|
|
483
|
+
});
|
|
484
|
+
runMgr.pushMessage(createUserMessage(AUTO_CONTINUATION_USER_MESSAGE));
|
|
485
|
+
await this.ctx.emitEvent({
|
|
486
|
+
type: 'iteration_completed',
|
|
487
|
+
runId: runMgr.id,
|
|
488
|
+
iteration: iterationNum,
|
|
489
|
+
hasToolCalls: false,
|
|
490
|
+
});
|
|
491
|
+
yield* this.ctx.drainPending();
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// The model tried to finish in prose while a structured
|
|
495
|
+
// output was demanded. Send it back with the schema error
|
|
496
|
+
// rather than returning an unusable result — this is the
|
|
497
|
+
// re-prompt half, and it is bounded so a model that cannot
|
|
498
|
+
// satisfy the schema fails loudly instead of looping.
|
|
499
|
+
if (!forceFinalize && this.needsStructuredOutput()) {
|
|
500
|
+
const attempt = ++this.structuredOutputAttempts;
|
|
501
|
+
const limit = this.structuredOutputRetryLimit();
|
|
432
502
|
if (attempt > limit) {
|
|
433
|
-
this.ctx.log.warn('
|
|
503
|
+
this.ctx.log.warn('Structured output not produced within its retries', {
|
|
434
504
|
runId: runMgr.id,
|
|
435
505
|
attempts: attempt - 1,
|
|
436
|
-
limit,
|
|
437
506
|
});
|
|
438
|
-
runMgr.setStopReason('
|
|
507
|
+
runMgr.setStopReason('structured_output_failed');
|
|
439
508
|
break;
|
|
440
509
|
}
|
|
441
|
-
this.ctx.log.info('
|
|
510
|
+
this.ctx.log.info('Re-prompting for structured output', {
|
|
442
511
|
runId: runMgr.id,
|
|
443
512
|
attempt,
|
|
444
513
|
limit,
|
|
445
514
|
});
|
|
446
|
-
runMgr.pushMessage(createUserMessage(
|
|
515
|
+
runMgr.pushMessage(createUserMessage(STRUCTURED_OUTPUT_REPROMPT));
|
|
447
516
|
await this.ctx.emitEvent({
|
|
448
517
|
type: 'iteration_completed',
|
|
449
518
|
runId: runMgr.id,
|
|
@@ -453,249 +522,415 @@ export class IterationOrchestrator {
|
|
|
453
522
|
yield* this.ctx.drainPending();
|
|
454
523
|
continue;
|
|
455
524
|
}
|
|
525
|
+
// Let the host judge the ANSWER and hand back work.
|
|
526
|
+
//
|
|
527
|
+
// The stop predicate is only consulted after tools ran, so
|
|
528
|
+
// there was no seam here at all: the moment the model
|
|
529
|
+
// stopped calling tools the run finalized, whatever it had
|
|
530
|
+
// produced. Verify-then-fix — run the build, feed the
|
|
531
|
+
// failure back, let it try again — meant starting a whole
|
|
532
|
+
// new run and re-supplying the context the first one had.
|
|
533
|
+
//
|
|
534
|
+
// Shaped after the structured-output re-prompt directly
|
|
535
|
+
// above, which solves the same problem for one specific
|
|
536
|
+
// judge: bounded attempts, feedback as a user message, and
|
|
537
|
+
// a loud stop rather than a loop.
|
|
538
|
+
if (!forceFinalize && this.ctx.reviewAnswer) {
|
|
539
|
+
const review = await this.reviewAnswer(response.message.content ?? '');
|
|
540
|
+
if (review && !review.accept) {
|
|
541
|
+
const attempt = ++this.answerReviewAttempts;
|
|
542
|
+
const limit = this.ctx.maxAnswerReviews ?? DEFAULT_ANSWER_REVIEW_LIMIT;
|
|
543
|
+
if (attempt > limit) {
|
|
544
|
+
this.ctx.log.warn('Answer rejected more times than the run allows', {
|
|
545
|
+
runId: runMgr.id,
|
|
546
|
+
attempts: attempt - 1,
|
|
547
|
+
limit,
|
|
548
|
+
});
|
|
549
|
+
runMgr.setStopReason('answer_rejected');
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
this.ctx.log.info('Answer rejected — returning it to the model', {
|
|
553
|
+
runId: runMgr.id,
|
|
554
|
+
attempt,
|
|
555
|
+
limit,
|
|
556
|
+
});
|
|
557
|
+
runMgr.pushMessage(createUserMessage(review.feedback));
|
|
558
|
+
await this.ctx.emitEvent({
|
|
559
|
+
type: 'iteration_completed',
|
|
560
|
+
runId: runMgr.id,
|
|
561
|
+
iteration: iterationNum,
|
|
562
|
+
hasToolCalls: false,
|
|
563
|
+
});
|
|
564
|
+
yield* this.ctx.drainPending();
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// A background worker is still out there, and this turn was
|
|
569
|
+
// about to end the run.
|
|
570
|
+
//
|
|
571
|
+
// Settling here would throw away the very thing the launch
|
|
572
|
+
// existed to produce: the supervisor said "launched", the
|
|
573
|
+
// worker had not finished, and the run closed over it.
|
|
574
|
+
if (!forceFinalize && (yield* this.holdForOutstandingWork(iterationNum, false))) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (!hasContent && !forceFinalize) {
|
|
578
|
+
this.ctx.log.warn('Empty completion detected — requesting final summary', {
|
|
579
|
+
iteration: iterationNum,
|
|
580
|
+
finishReason: response.finishReason,
|
|
581
|
+
});
|
|
582
|
+
await this.requestFinalResponse(model, 'end_turn');
|
|
583
|
+
yield* this.ctx.drainPending();
|
|
584
|
+
}
|
|
585
|
+
await this.ctx.emitEvent({
|
|
586
|
+
type: 'iteration_completed',
|
|
587
|
+
runId: runMgr.id,
|
|
588
|
+
iteration: iterationNum,
|
|
589
|
+
hasToolCalls: false,
|
|
590
|
+
});
|
|
591
|
+
yield* this.ctx.drainPending();
|
|
592
|
+
// A Stop that lands AFTER the final turn streamed but before
|
|
593
|
+
// this break must settle the run as cancelled, not end_turn —
|
|
594
|
+
// otherwise the just-produced answer is recorded as a clean
|
|
595
|
+
// completion. Mirrors the between-iteration cancel at :511.
|
|
596
|
+
if (this.ctx.abortController.signal.aborted) {
|
|
597
|
+
runMgr.setStopReason('cancelled');
|
|
598
|
+
runMgr.markCancelled();
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
// The host's stop predicate, if the previous turn deferred it
|
|
602
|
+
// to let the model read a delegated result. That extra turn
|
|
603
|
+
// is prose, and `stopWhen` is consulted only after a tool
|
|
604
|
+
// batch, so the predicate is never asked again — reporting
|
|
605
|
+
// `end_turn` would name the shape of the last message rather
|
|
606
|
+
// than the reason the run is over.
|
|
607
|
+
//
|
|
608
|
+
// Only here. A terminal tool and a captured structured output
|
|
609
|
+
// also settle as `end_turn`, and there the deferred predicate
|
|
610
|
+
// is not why the run ended: those decided the answer
|
|
611
|
+
// themselves.
|
|
612
|
+
runMgr.setStopReason(stopWasDeferredForOutstandingWork ? 'stop_condition' : 'end_turn');
|
|
613
|
+
break;
|
|
456
614
|
}
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
615
|
+
const reviewOutcome = yield* runToolReview(this.ctx, response, iterationNum);
|
|
616
|
+
// The step record is built even for a rejected batch: a run that
|
|
617
|
+
// spent a turn getting its tools refused still spent the tokens,
|
|
618
|
+
// and a caller reconstructing cost per step must see it.
|
|
619
|
+
this.recordStep({
|
|
620
|
+
stepNumber: iterationNum,
|
|
621
|
+
model,
|
|
622
|
+
messageId,
|
|
623
|
+
response,
|
|
624
|
+
toolResults: reviewOutcome.results,
|
|
625
|
+
toolExecutionMs: reviewOutcome.durationMs,
|
|
626
|
+
startedAt: stepStartedAt,
|
|
627
|
+
usageBefore,
|
|
628
|
+
costBefore,
|
|
629
|
+
});
|
|
630
|
+
if (reviewOutcome.decision === 'stop') {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (reviewOutcome.decision === 'rejected') {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
// A successful `structured_output` call IS the answer, so the
|
|
637
|
+
// run ends here rather than paying for another turn whose only
|
|
638
|
+
// job would be to restate it.
|
|
639
|
+
if (this.captureStructuredOutput(reviewOutcome.results)) {
|
|
640
|
+
this.ctx.log.info('Structured output produced — ending run', {
|
|
469
641
|
runId: runMgr.id,
|
|
470
642
|
iteration: iterationNum,
|
|
471
643
|
});
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
runMgr.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
644
|
+
runMgr.setStopReason('end_turn');
|
|
645
|
+
await this.ctx.emitEvent({
|
|
646
|
+
type: 'iteration_completed',
|
|
647
|
+
runId: runMgr.id,
|
|
648
|
+
iteration: iterationNum,
|
|
649
|
+
hasToolCalls: true,
|
|
650
|
+
});
|
|
651
|
+
yield* this.ctx.drainPending();
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
// A tool the author declared terminal settles the run with its
|
|
655
|
+
// own output, the same rule `structured_output` has always
|
|
656
|
+
// had. Without it a delegation cost the parent one more model
|
|
657
|
+
// call at full context whose only job was to restate what the
|
|
658
|
+
// worker already said — and to restate it through the parent's
|
|
659
|
+
// compacted view, so the caller did not even receive the
|
|
660
|
+
// worker's words.
|
|
661
|
+
const settled = this.terminalToolOutput(reviewOutcome.results, response);
|
|
662
|
+
if (settled !== undefined) {
|
|
663
|
+
this.ctx.log.info('Terminal tool produced the answer — ending run', {
|
|
664
|
+
runId: runMgr.id,
|
|
665
|
+
iteration: iterationNum,
|
|
666
|
+
tool: settled.toolName,
|
|
667
|
+
});
|
|
668
|
+
runMgr.setResult(settled.output);
|
|
669
|
+
runMgr.setStopReason('end_turn');
|
|
670
|
+
await this.ctx.emitEvent({
|
|
671
|
+
type: 'iteration_completed',
|
|
672
|
+
runId: runMgr.id,
|
|
673
|
+
iteration: iterationNum,
|
|
674
|
+
hasToolCalls: true,
|
|
675
|
+
});
|
|
676
|
+
yield* this.ctx.drainPending();
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
// Evaluated AFTER the tools ran, so a predicate can see what they
|
|
680
|
+
// returned — which is what makes a terminal submit_answer tool
|
|
681
|
+
// usable without discarding its output.
|
|
682
|
+
if (await this.shouldStop()) {
|
|
683
|
+
// Outstanding delegated work outranks the host's stop
|
|
684
|
+
// predicate, exactly once.
|
|
685
|
+
//
|
|
686
|
+
// This is a precedence rule chosen here, not something
|
|
687
|
+
// `stopWhen` implies — a stop predicate is a programmable
|
|
688
|
+
// halt and says nothing about whether the answer is
|
|
689
|
+
// complete, which is what separates it from a terminal
|
|
690
|
+
// tool or a captured structured output. Those decide the
|
|
691
|
+
// result, so no turn follows and a hold would buy nothing.
|
|
692
|
+
// This one only says "stop", and stopping one turn later
|
|
693
|
+
// with the worker's result in hand is a better reading of
|
|
694
|
+
// the host's intent than stopping now and discarding it.
|
|
695
|
+
//
|
|
696
|
+
// Bounded: after the notification is delivered the inbox
|
|
697
|
+
// is drained, so the predicate fires again next turn with
|
|
698
|
+
// nothing pending and the run stops. Exactly one extra
|
|
699
|
+
// turn, and `maxIterations` bounds it regardless.
|
|
700
|
+
if (yield* this.holdForOutstandingWork(iterationNum, true)) {
|
|
701
|
+
// Remember WHY the next turn exists, so the turn that
|
|
702
|
+
// ends the run can name the host's decision instead of
|
|
703
|
+
// reporting the shape of the last message.
|
|
704
|
+
this.stopDeferredForOutstandingWork = true;
|
|
483
705
|
continue;
|
|
484
706
|
}
|
|
707
|
+
this.ctx.log.info('Stop condition met', {
|
|
708
|
+
runId: runMgr.id,
|
|
709
|
+
iteration: iterationNum,
|
|
710
|
+
});
|
|
711
|
+
runMgr.setStopReason('stop_condition');
|
|
712
|
+
await this.ctx.emitEvent({
|
|
713
|
+
type: 'iteration_completed',
|
|
714
|
+
runId: runMgr.id,
|
|
715
|
+
iteration: iterationNum,
|
|
716
|
+
hasToolCalls: true,
|
|
717
|
+
});
|
|
718
|
+
yield* this.ctx.drainPending();
|
|
719
|
+
break;
|
|
485
720
|
}
|
|
486
|
-
|
|
487
|
-
|
|
721
|
+
const checkpointSignal = yield* runIterationCheckpoint(this.ctx, iterationNum);
|
|
722
|
+
if (checkpointSignal === 'stop') {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// Workers that finished with nobody listening.
|
|
726
|
+
//
|
|
727
|
+
// A completion normally reaches the supervisor as the
|
|
728
|
+
// `tool_result` of the `create_task` that launched it. Two
|
|
729
|
+
// cases have no such call: a launch made in the background on
|
|
730
|
+
// purpose, and a blocking launch whose deadline passed — the
|
|
731
|
+
// model was told "timed out, it may still be running" and the
|
|
732
|
+
// worker then finished, holding a result nothing would read.
|
|
733
|
+
//
|
|
734
|
+
// This is the channel that was removed in `dc16d58` because it
|
|
735
|
+
// double-delivered: it fired for completions the blocking tool
|
|
736
|
+
// had already handed over, so the supervisor saw each result
|
|
737
|
+
// twice. The inbox restores it with the distinction that was
|
|
738
|
+
// missing — a tool that delivers a completion claims it, and
|
|
739
|
+
// only unclaimed ones arrive here.
|
|
740
|
+
//
|
|
741
|
+
// Placed beside the advisory phase deliberately: that is the
|
|
742
|
+
// established seam for putting a user message in after tool
|
|
743
|
+
// results and before the next turn.
|
|
744
|
+
const unheard = this.ctx.completionInbox?.drain() ?? [];
|
|
745
|
+
if (unheard.length > 0) {
|
|
746
|
+
this.ctx.log.info('Delivering unawaited task completions', {
|
|
747
|
+
runId: runMgr.id,
|
|
488
748
|
iteration: iterationNum,
|
|
489
|
-
|
|
749
|
+
tasks: unheard.map((h) => h.taskId),
|
|
490
750
|
});
|
|
491
|
-
|
|
751
|
+
runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)));
|
|
752
|
+
}
|
|
753
|
+
await runAdvisoryPhase(this.ctx, iterationNum, response);
|
|
754
|
+
if (this.ctx.pluginManager) {
|
|
755
|
+
const hookResults = await this.ctx.pluginManager.executeHooks('iteration_end', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
|
|
756
|
+
applyLifecycleHookResults('iteration_end', hookResults);
|
|
492
757
|
yield* this.ctx.drainPending();
|
|
493
758
|
}
|
|
494
759
|
await this.ctx.emitEvent({
|
|
495
760
|
type: 'iteration_completed',
|
|
496
761
|
runId: runMgr.id,
|
|
497
762
|
iteration: iterationNum,
|
|
498
|
-
hasToolCalls:
|
|
763
|
+
hasToolCalls: true,
|
|
499
764
|
});
|
|
500
765
|
yield* this.ctx.drainPending();
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
//
|
|
504
|
-
//
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
// A Stop that aborted the in-flight turn surfaces here as a
|
|
769
|
+
// thrown abort (the provider stream was raced against the run
|
|
770
|
+
// signal). Settle it as a CANCELLATION — mirroring the
|
|
771
|
+
// between-iteration cancel at the top of the loop — rather than
|
|
772
|
+
// recording it as an SDK failure (error span + failed activity)
|
|
773
|
+
// and re-throwing. The run then returns cleanly with a
|
|
774
|
+
// 'cancelled' stop reason instead of propagating an error.
|
|
505
775
|
if (this.ctx.abortController.signal.aborted) {
|
|
506
776
|
runMgr.setStopReason('cancelled');
|
|
507
777
|
runMgr.markCancelled();
|
|
508
778
|
break;
|
|
509
779
|
}
|
|
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
|
-
// run ends here rather than paying for another turn whose only
|
|
536
|
-
// job would be to restate it.
|
|
537
|
-
if (this.captureStructuredOutput(reviewOutcome.results)) {
|
|
538
|
-
this.ctx.log.info('Structured output produced — ending run', {
|
|
539
|
-
runId: runMgr.id,
|
|
540
|
-
iteration: iterationNum,
|
|
541
|
-
});
|
|
542
|
-
runMgr.setStopReason('end_turn');
|
|
543
|
-
await this.ctx.emitEvent({
|
|
544
|
-
type: 'iteration_completed',
|
|
545
|
-
runId: runMgr.id,
|
|
546
|
-
iteration: iterationNum,
|
|
547
|
-
hasToolCalls: true,
|
|
548
|
-
});
|
|
549
|
-
yield* this.ctx.drainPending();
|
|
550
|
-
break;
|
|
551
|
-
}
|
|
552
|
-
// A tool the author declared terminal settles the run with its
|
|
553
|
-
// own output, the same rule `structured_output` has always
|
|
554
|
-
// had. Without it a delegation cost the parent one more model
|
|
555
|
-
// call at full context whose only job was to restate what the
|
|
556
|
-
// worker already said — and to restate it through the parent's
|
|
557
|
-
// compacted view, so the caller did not even receive the
|
|
558
|
-
// worker's words.
|
|
559
|
-
const settled = this.terminalToolOutput(reviewOutcome.results, response);
|
|
560
|
-
if (settled !== undefined) {
|
|
561
|
-
this.ctx.log.info('Terminal tool produced the answer — ending run', {
|
|
562
|
-
runId: runMgr.id,
|
|
563
|
-
iteration: iterationNum,
|
|
564
|
-
tool: settled.toolName,
|
|
565
|
-
});
|
|
566
|
-
runMgr.setResult(settled.output);
|
|
567
|
-
runMgr.setStopReason('end_turn');
|
|
568
|
-
await this.ctx.emitEvent({
|
|
569
|
-
type: 'iteration_completed',
|
|
570
|
-
runId: runMgr.id,
|
|
571
|
-
iteration: iterationNum,
|
|
572
|
-
hasToolCalls: true,
|
|
573
|
-
});
|
|
574
|
-
yield* this.ctx.drainPending();
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
// Evaluated AFTER the tools ran, so a predicate can see what they
|
|
578
|
-
// returned — which is what makes a terminal submit_answer tool
|
|
579
|
-
// usable without discarding its output.
|
|
580
|
-
if (await this.shouldStop()) {
|
|
581
|
-
this.ctx.log.info('Stop condition met', {
|
|
582
|
-
runId: runMgr.id,
|
|
583
|
-
iteration: iterationNum,
|
|
584
|
-
});
|
|
585
|
-
runMgr.setStopReason('stop_condition');
|
|
586
|
-
await this.ctx.emitEvent({
|
|
587
|
-
type: 'iteration_completed',
|
|
588
|
-
runId: runMgr.id,
|
|
589
|
-
iteration: iterationNum,
|
|
590
|
-
hasToolCalls: true,
|
|
591
|
-
});
|
|
592
|
-
yield* this.ctx.drainPending();
|
|
593
|
-
break;
|
|
594
|
-
}
|
|
595
|
-
const checkpointSignal = yield* runIterationCheckpoint(this.ctx, iterationNum);
|
|
596
|
-
if (checkpointSignal === 'stop') {
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
// Workers that finished with nobody listening.
|
|
600
|
-
//
|
|
601
|
-
// A completion normally reaches the supervisor as the
|
|
602
|
-
// `tool_result` of the `create_task` that launched it. Two
|
|
603
|
-
// cases have no such call: a launch made in the background on
|
|
604
|
-
// purpose, and a blocking launch whose deadline passed — the
|
|
605
|
-
// model was told "timed out, it may still be running" and the
|
|
606
|
-
// worker then finished, holding a result nothing would read.
|
|
607
|
-
//
|
|
608
|
-
// This is the channel that was removed in `dc16d58` because it
|
|
609
|
-
// double-delivered: it fired for completions the blocking tool
|
|
610
|
-
// had already handed over, so the supervisor saw each result
|
|
611
|
-
// twice. The inbox restores it with the distinction that was
|
|
612
|
-
// missing — a tool that delivers a completion claims it, and
|
|
613
|
-
// only unclaimed ones arrive here.
|
|
614
|
-
//
|
|
615
|
-
// Placed beside the advisory phase deliberately: that is the
|
|
616
|
-
// established seam for putting a user message in after tool
|
|
617
|
-
// results and before the next turn.
|
|
618
|
-
const unheard = this.ctx.completionInbox?.drain() ?? [];
|
|
619
|
-
if (unheard.length > 0) {
|
|
620
|
-
this.ctx.log.info('Delivering unawaited task completions', {
|
|
621
|
-
runId: runMgr.id,
|
|
622
|
-
iteration: iterationNum,
|
|
623
|
-
tasks: unheard.map((h) => h.taskId),
|
|
624
|
-
});
|
|
625
|
-
runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)));
|
|
626
|
-
}
|
|
627
|
-
await runAdvisoryPhase(this.ctx, iterationNum, response);
|
|
628
|
-
if (this.ctx.pluginManager) {
|
|
629
|
-
const hookResults = await this.ctx.pluginManager.executeHooks('iteration_end', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
|
|
630
|
-
applyLifecycleHookResults('iteration_end', hookResults);
|
|
631
|
-
yield* this.ctx.drainPending();
|
|
632
|
-
}
|
|
633
|
-
await this.ctx.emitEvent({
|
|
634
|
-
type: 'iteration_completed',
|
|
635
|
-
runId: runMgr.id,
|
|
636
|
-
iteration: iterationNum,
|
|
637
|
-
hasToolCalls: true,
|
|
638
|
-
});
|
|
639
|
-
yield* this.ctx.drainPending();
|
|
640
|
-
}
|
|
641
|
-
catch (err) {
|
|
642
|
-
// A Stop that aborted the in-flight turn surfaces here as a
|
|
643
|
-
// thrown abort (the provider stream was raced against the run
|
|
644
|
-
// signal). Settle it as a CANCELLATION — mirroring the
|
|
645
|
-
// between-iteration cancel at the top of the loop — rather than
|
|
646
|
-
// recording it as an SDK failure (error span + failed activity)
|
|
647
|
-
// and re-throwing. The run then returns cleanly with a
|
|
648
|
-
// 'cancelled' stop reason instead of propagating an error.
|
|
649
|
-
if (this.ctx.abortController.signal.aborted) {
|
|
650
|
-
runMgr.setStopReason('cancelled');
|
|
651
|
-
runMgr.markCancelled();
|
|
652
|
-
break;
|
|
653
|
-
}
|
|
654
|
-
// The one provider failure the kernel can actually do something
|
|
655
|
-
// about. `context_length_exceeded` is correctly non-retryable —
|
|
656
|
-
// resending the identical prompt cannot help — but the kernel
|
|
657
|
-
// owns a compaction subsystem that can make the prompt smaller.
|
|
658
|
-
// Without this the run died holding the remedy: the threshold
|
|
659
|
-
// path had simply guessed low, which a run carrying images or a
|
|
660
|
-
// language the chars-per-token ratio does not fit will do.
|
|
661
|
-
//
|
|
662
|
-
// Relief is attempted ONCE per iteration and only when it
|
|
663
|
-
// actually shed something. A second overflow after a successful
|
|
664
|
-
// compaction means the prompt is irreducible, and looping on it
|
|
665
|
-
// would burn the budget to arrive at the same error.
|
|
666
|
-
if (!overflowRelieved &&
|
|
667
|
-
classifyProviderError(err, this.ctx.provider.id).code === 'context_length_exceeded') {
|
|
668
|
-
overflowRelieved = true;
|
|
669
|
-
const shed = await relieveOverflow(this.ctx);
|
|
670
|
-
if (shed) {
|
|
671
|
-
this.ctx.log.info('Retrying the turn after relieving a context overflow', {
|
|
672
|
-
runId: runMgr.id,
|
|
673
|
-
iteration: iterationNum,
|
|
674
|
-
});
|
|
675
|
-
if (iterationActivity) {
|
|
676
|
-
this.ctx.activityStore.complete(iterationActivity.id);
|
|
780
|
+
// The one provider failure the kernel can actually do something
|
|
781
|
+
// about. `context_length_exceeded` is correctly non-retryable —
|
|
782
|
+
// resending the identical prompt cannot help — but the kernel
|
|
783
|
+
// owns a compaction subsystem that can make the prompt smaller.
|
|
784
|
+
// Without this the run died holding the remedy: the threshold
|
|
785
|
+
// path had simply guessed low, which a run carrying images or a
|
|
786
|
+
// language the chars-per-token ratio does not fit will do.
|
|
787
|
+
//
|
|
788
|
+
// Relief is attempted ONCE per iteration and only when it
|
|
789
|
+
// actually shed something. A second overflow after a successful
|
|
790
|
+
// compaction means the prompt is irreducible, and looping on it
|
|
791
|
+
// would burn the budget to arrive at the same error.
|
|
792
|
+
if (!overflowRelieved &&
|
|
793
|
+
classifyProviderError(err, this.ctx.provider.id).code === 'context_length_exceeded') {
|
|
794
|
+
overflowRelieved = true;
|
|
795
|
+
const shed = await relieveOverflow(this.ctx);
|
|
796
|
+
if (shed) {
|
|
797
|
+
this.ctx.log.info('Retrying the turn after relieving a context overflow', {
|
|
798
|
+
runId: runMgr.id,
|
|
799
|
+
iteration: iterationNum,
|
|
800
|
+
});
|
|
801
|
+
if (iterationActivity) {
|
|
802
|
+
this.ctx.activityStore.complete(iterationActivity.id);
|
|
803
|
+
}
|
|
804
|
+
continue;
|
|
677
805
|
}
|
|
678
|
-
continue;
|
|
679
806
|
}
|
|
807
|
+
if (iterationActivity) {
|
|
808
|
+
this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err));
|
|
809
|
+
}
|
|
810
|
+
iterSpan.setStatus({
|
|
811
|
+
code: SpanStatusCode.ERROR,
|
|
812
|
+
message: toErrorMessage(err),
|
|
813
|
+
});
|
|
814
|
+
iterSpan.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
815
|
+
throw err;
|
|
680
816
|
}
|
|
681
|
-
|
|
682
|
-
|
|
817
|
+
finally {
|
|
818
|
+
// The only place the iteration span ends. It used to be ended at each of
|
|
819
|
+
// seventeen exits, which is a rule every future edit has to
|
|
820
|
+
// remember; a generator abandoned by its consumer never reached
|
|
821
|
+
// any of them.
|
|
822
|
+
iterSpan.end();
|
|
683
823
|
}
|
|
684
|
-
iterSpan.setStatus({
|
|
685
|
-
code: SpanStatusCode.ERROR,
|
|
686
|
-
message: toErrorMessage(err),
|
|
687
|
-
});
|
|
688
|
-
iterSpan.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
689
|
-
throw err;
|
|
690
|
-
}
|
|
691
|
-
finally {
|
|
692
|
-
// The only place the iteration span ends. It used to be ended at each of
|
|
693
|
-
// seventeen exits, which is a rule every future edit has to
|
|
694
|
-
// remember; a generator abandoned by its consumer never reached
|
|
695
|
-
// any of them.
|
|
696
|
-
iterSpan.end();
|
|
697
824
|
}
|
|
698
825
|
}
|
|
826
|
+
finally {
|
|
827
|
+
this.settleOutstandingWork();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Hold the run open for a worker that has not finished, and deliver it.
|
|
832
|
+
*
|
|
833
|
+
* Returns whether a completion arrived and was put in the transcript — the
|
|
834
|
+
* caller continues the loop on `true`, so the model gets a turn in which to
|
|
835
|
+
* USE the result. That turn is the entire justification for waiting, which
|
|
836
|
+
* is why only the exits that can still take one call this.
|
|
837
|
+
*
|
|
838
|
+
* Bounded by `settleGraceMs` and by `maxIterations`, so a worker that never
|
|
839
|
+
* finishes cannot keep the run open.
|
|
840
|
+
*/
|
|
841
|
+
async *holdForOutstandingWork(iterationNum, hasToolCalls) {
|
|
842
|
+
if (!this.ctx.completionInbox?.hasPendingWork)
|
|
843
|
+
return false;
|
|
844
|
+
// Read HERE rather than from `forceFinalize`, which was sampled at the
|
|
845
|
+
// top of the iteration: one that has since crossed the finalize point
|
|
846
|
+
// must not open a wait against a reserve it has already entered.
|
|
847
|
+
const graceMs = settleGraceMs(this.ctx.guard.remainingBeforeFinalizeMs());
|
|
848
|
+
this.ctx.log.info('Holding the run open for a background task', {
|
|
849
|
+
runId: this.ctx.runMgr.id,
|
|
850
|
+
iteration: iterationNum,
|
|
851
|
+
graceMs,
|
|
852
|
+
});
|
|
853
|
+
await this.ctx.completionInbox.waitForArrival(graceMs);
|
|
854
|
+
const arrived = this.ctx.completionInbox.drain();
|
|
855
|
+
if (arrived.length === 0)
|
|
856
|
+
return false;
|
|
857
|
+
this.ctx.runMgr.pushMessage(createUserMessage(formatCompletionNotification(arrived)));
|
|
858
|
+
await this.ctx.emitEvent({
|
|
859
|
+
type: 'iteration_completed',
|
|
860
|
+
runId: this.ctx.runMgr.id,
|
|
861
|
+
iteration: iterationNum,
|
|
862
|
+
hasToolCalls,
|
|
863
|
+
});
|
|
864
|
+
yield* this.ctx.drainPending();
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Account for delegated work on the way out: deliver what arrived, and say
|
|
869
|
+
* what did not.
|
|
870
|
+
*
|
|
871
|
+
* A run that ends with a worker outstanding must not leave the impression
|
|
872
|
+
* that the worker's result was delivered. There are exactly two honest
|
|
873
|
+
* outcomes and this does both:
|
|
874
|
+
*
|
|
875
|
+
* - **What has already arrived is delivered.** It makes no false claim,
|
|
876
|
+
* and dropping it is pure loss — the message rides out on
|
|
877
|
+
* `Run.messages`, so a host reads it and the next turn of a continued
|
|
878
|
+
* thread starts with it. This does NOT wait: a hold buys the model a
|
|
879
|
+
* turn in which to USE a result, and on an exit whose answer is already
|
|
880
|
+
* decided there is no such turn, so waiting would delay a settled answer
|
|
881
|
+
* to append text this run will not read. The bounded hold stays where it
|
|
882
|
+
* was, on the exits that do have a turn left.
|
|
883
|
+
* - **What is still running is NAMED, not cancelled.** Giving up on a wait
|
|
884
|
+
* is a statement about the waiter, not about the work — the rule
|
|
885
|
+
* `wait-with-idle-bound.ts` already states for the same subsystem — and
|
|
886
|
+
* "the parent answered early" is a weaker warrant for killing a child
|
|
887
|
+
* than "the clock ran out", not a stronger one. Killing a worker that
|
|
888
|
+
* may be mid-write is a policy only the host can judge, and it has
|
|
889
|
+
* `cancel_task` and the run controller to judge it with.
|
|
890
|
+
*/
|
|
891
|
+
settleOutstandingWork() {
|
|
892
|
+
this.deliverArrivedCompletions();
|
|
893
|
+
this.recordAbandonedWork();
|
|
894
|
+
}
|
|
895
|
+
/** Delegated work this run walked away from. See {@link settleOutstandingWork}. */
|
|
896
|
+
recordAbandonedWork() {
|
|
897
|
+
const abandoned = this.ctx.completionInbox?.outstandingTaskIds ?? [];
|
|
898
|
+
if (abandoned.length === 0)
|
|
899
|
+
return;
|
|
900
|
+
this.ctx.log.warn('Run ended with delegated work still running', {
|
|
901
|
+
runId: this.ctx.runMgr.id,
|
|
902
|
+
tasks: abandoned,
|
|
903
|
+
});
|
|
904
|
+
this.ctx.runMgr.setAbandonedTaskIds(abandoned);
|
|
905
|
+
}
|
|
906
|
+
deliverArrivedCompletions() {
|
|
907
|
+
const unheard = this.ctx.completionInbox?.drain() ?? [];
|
|
908
|
+
if (unheard.length === 0)
|
|
909
|
+
return;
|
|
910
|
+
// Fix the run's answer BEFORE appending anything after it.
|
|
911
|
+
//
|
|
912
|
+
// `RunPersistence.resolveResult` walks the message tail backwards and
|
|
913
|
+
// stops at the first non-assistant message, and it runs at
|
|
914
|
+
// `markCompleted` — which is AFTER this. So a notification appended
|
|
915
|
+
// after the final assistant turn makes the run's own answer
|
|
916
|
+
// unreachable. Measured, on a run whose model had just said "THIS IS
|
|
917
|
+
// THE RUN ANSWER.": `run.result` came back `undefined`. That trades a
|
|
918
|
+
// lost worker result for a lost RUN result, which is strictly worse
|
|
919
|
+
// than the defect this delivery exists to fix.
|
|
920
|
+
//
|
|
921
|
+
// Materialising resolves it while the tail is still the assistant's;
|
|
922
|
+
// pinning it means the later re-resolution cannot undo the fix. Only
|
|
923
|
+
// when there is something to pin: on the cancelled and thrown paths
|
|
924
|
+
// there may be no answer, and pinning an empty string there would
|
|
925
|
+
// suppress whatever the error path assembles.
|
|
926
|
+
const answer = this.ctx.runMgr.materializeResult();
|
|
927
|
+
if (answer.length > 0)
|
|
928
|
+
this.ctx.runMgr.setResult(answer);
|
|
929
|
+
this.ctx.log.info('Delivering task completions the run would have settled over', {
|
|
930
|
+
runId: this.ctx.runMgr.id,
|
|
931
|
+
tasks: unheard.map((h) => h.taskId),
|
|
932
|
+
});
|
|
933
|
+
this.ctx.runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)));
|
|
699
934
|
}
|
|
700
935
|
/**
|
|
701
936
|
* Ask the host how to shape this step.
|
|
@@ -739,15 +974,28 @@ export class IterationOrchestrator {
|
|
|
739
974
|
}
|
|
740
975
|
const prepared = {};
|
|
741
976
|
if (result.activeTools) {
|
|
742
|
-
// A phase list that outlives a tool rename should narrow the
|
|
743
|
-
// surface, not kill the agent mid-run.
|
|
744
977
|
const known = result.activeTools.filter((name) => this.ctx.tools.has(name));
|
|
745
978
|
const unknown = result.activeTools.filter((name) => !this.ctx.tools.has(name));
|
|
746
979
|
if (unknown.length > 0) {
|
|
747
|
-
|
|
980
|
+
// The all-unknown case gets its own sentence because it has its
|
|
981
|
+
// own consequence. Some names dropped narrows the step; ALL of
|
|
982
|
+
// them dropped leaves it able to call nothing — which is the
|
|
983
|
+
// honest reading of "only these tools" when none of them exist,
|
|
984
|
+
// and is not what a reader of "ignoring them" would expect.
|
|
985
|
+
//
|
|
986
|
+
// Widening back to the run's list would be worse: it grants
|
|
987
|
+
// exactly the tools the caller asked to exclude, on the grounds
|
|
988
|
+
// that their own list failed. A step that can call nothing is
|
|
989
|
+
// constrained; a step that can call everything is a control
|
|
990
|
+
// that stopped applying.
|
|
991
|
+
const message = known.length === 0
|
|
992
|
+
? 'prepareStep named only tools that are not registered — this step can call nothing'
|
|
993
|
+
: 'prepareStep named tools that are not registered — ignoring them';
|
|
994
|
+
this.ctx.log.warn(message, {
|
|
748
995
|
runId: this.ctx.runMgr.id,
|
|
749
996
|
stepNumber,
|
|
750
997
|
unknown,
|
|
998
|
+
remaining: known.length,
|
|
751
999
|
});
|
|
752
1000
|
}
|
|
753
1001
|
prepared.allowedTools = known;
|