@mcp-abap-adt/llm-agent-server-libs 19.1.2 → 19.2.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/dist/factories/controller-factory.d.ts +18 -4
- package/dist/factories/controller-factory.d.ts.map +1 -1
- package/dist/factories/controller-factory.js +50 -10
- package/dist/factories/controller-factory.js.map +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pipelines/controller.d.ts.map +1 -1
- package/dist/pipelines/controller.js +41 -0
- package/dist/pipelines/controller.js.map +1 -1
- package/dist/pipelines/dag.d.ts.map +1 -1
- package/dist/pipelines/dag.js +2 -1
- package/dist/pipelines/dag.js.map +1 -1
- package/dist/pipelines/flat.d.ts.map +1 -1
- package/dist/pipelines/flat.js +2 -1
- package/dist/pipelines/flat.js.map +1 -1
- package/dist/pipelines/linear.d.ts.map +1 -1
- package/dist/pipelines/linear.js +2 -1
- package/dist/pipelines/linear.js.map +1 -1
- package/dist/pipelines/register-skill-sources.d.ts +21 -0
- package/dist/pipelines/register-skill-sources.d.ts.map +1 -0
- package/dist/pipelines/register-skill-sources.js +57 -0
- package/dist/pipelines/register-skill-sources.js.map +1 -0
- package/dist/pipelines/server-context.d.ts +37 -1
- package/dist/pipelines/server-context.d.ts.map +1 -1
- package/dist/pipelines/server-context.js.map +1 -1
- package/dist/smart-agent/config.d.ts.map +1 -1
- package/dist/smart-agent/config.js +4 -0
- package/dist/smart-agent/config.js.map +1 -1
- package/dist/smart-agent/controller/artifacts.d.ts +116 -0
- package/dist/smart-agent/controller/artifacts.d.ts.map +1 -0
- package/dist/smart-agent/controller/artifacts.js +192 -0
- package/dist/smart-agent/controller/artifacts.js.map +1 -0
- package/dist/smart-agent/controller/board.d.ts +73 -0
- package/dist/smart-agent/controller/board.d.ts.map +1 -0
- package/dist/smart-agent/controller/board.js +233 -0
- package/dist/smart-agent/controller/board.js.map +1 -0
- package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +67 -7
- package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
- package/dist/smart-agent/controller/controller-coordinator-handler.js +923 -61
- package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
- package/dist/smart-agent/controller/finalizer.d.ts +48 -0
- package/dist/smart-agent/controller/finalizer.d.ts.map +1 -0
- package/dist/smart-agent/controller/finalizer.js +126 -0
- package/dist/smart-agent/controller/finalizer.js.map +1 -0
- package/dist/smart-agent/controller/memorizer.d.ts +2 -2
- package/dist/smart-agent/controller/memorizer.d.ts.map +1 -1
- package/dist/smart-agent/controller/memorizer.js +2 -2
- package/dist/smart-agent/controller/memorizer.js.map +1 -1
- package/dist/smart-agent/controller/outcome.d.ts +28 -0
- package/dist/smart-agent/controller/outcome.d.ts.map +1 -0
- package/dist/smart-agent/controller/outcome.js +30 -0
- package/dist/smart-agent/controller/outcome.js.map +1 -0
- package/dist/smart-agent/controller/planner.d.ts +28 -5
- package/dist/smart-agent/controller/planner.d.ts.map +1 -1
- package/dist/smart-agent/controller/planner.js +111 -29
- package/dist/smart-agent/controller/planner.js.map +1 -1
- package/dist/smart-agent/controller/reviewer.d.ts +48 -0
- package/dist/smart-agent/controller/reviewer.d.ts.map +1 -0
- package/dist/smart-agent/controller/reviewer.js +116 -0
- package/dist/smart-agent/controller/reviewer.js.map +1 -0
- package/dist/smart-agent/controller/run-scope.d.ts +50 -0
- package/dist/smart-agent/controller/run-scope.d.ts.map +1 -0
- package/dist/smart-agent/controller/run-scope.js +99 -0
- package/dist/smart-agent/controller/run-scope.js.map +1 -0
- package/dist/smart-agent/controller/session-bundle.d.ts +5 -0
- package/dist/smart-agent/controller/session-bundle.d.ts.map +1 -1
- package/dist/smart-agent/controller/session-bundle.js +27 -0
- package/dist/smart-agent/controller/session-bundle.js.map +1 -1
- package/dist/smart-agent/controller/types.d.ts +117 -7
- package/dist/smart-agent/controller/types.d.ts.map +1 -1
- package/dist/smart-agent/controller/types.js +30 -1
- package/dist/smart-agent/controller/types.js.map +1 -1
- package/dist/smart-agent/embedder-knowledge-index.d.ts +28 -0
- package/dist/smart-agent/embedder-knowledge-index.d.ts.map +1 -0
- package/dist/smart-agent/embedder-knowledge-index.js +71 -0
- package/dist/smart-agent/embedder-knowledge-index.js.map +1 -0
- package/dist/smart-agent/jsonl-knowledge-backend.d.ts +26 -10
- package/dist/smart-agent/jsonl-knowledge-backend.d.ts.map +1 -1
- package/dist/smart-agent/jsonl-knowledge-backend.js +81 -9
- package/dist/smart-agent/jsonl-knowledge-backend.js.map +1 -1
- package/dist/smart-agent/pg-pool.d.ts +58 -0
- package/dist/smart-agent/pg-pool.d.ts.map +1 -0
- package/dist/smart-agent/pg-pool.js +149 -0
- package/dist/smart-agent/pg-pool.js.map +1 -0
- package/dist/smart-agent/skill-plugins-config.d.ts +97 -0
- package/dist/smart-agent/skill-plugins-config.d.ts.map +1 -0
- package/dist/smart-agent/skill-plugins-config.js +318 -0
- package/dist/smart-agent/skill-plugins-config.js.map +1 -0
- package/dist/smart-agent/skill-plugins-config.test.d.ts +2 -0
- package/dist/smart-agent/skill-plugins-config.test.d.ts.map +1 -0
- package/dist/smart-agent/skill-plugins-config.test.js.map +1 -0
- package/dist/smart-agent/skill-plugins-host-factory.d.ts +98 -0
- package/dist/smart-agent/skill-plugins-host-factory.d.ts.map +1 -0
- package/dist/smart-agent/skill-plugins-host-factory.js +284 -0
- package/dist/smart-agent/skill-plugins-host-factory.js.map +1 -0
- package/dist/smart-agent/skill-plugins-host-factory.test.d.ts +2 -0
- package/dist/smart-agent/skill-plugins-host-factory.test.d.ts.map +1 -0
- package/dist/smart-agent/skill-plugins-host-factory.test.js.map +1 -0
- package/dist/smart-agent/smart-server.d.ts +25 -0
- package/dist/smart-agent/smart-server.d.ts.map +1 -1
- package/dist/smart-agent/smart-server.js +139 -6
- package/dist/smart-agent/smart-server.js.map +1 -1
- package/package.json +9 -7
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { externalToolCallId, } from '@mcp-abap-adt/llm-agent';
|
|
2
2
|
import { summaryToUsage, } from '@mcp-abap-adt/llm-agent-libs';
|
|
3
|
+
import { cosine } from '../embedder-knowledge-index.js';
|
|
4
|
+
import { readClaims, readPlanDecisions, writePlanDecision, } from './artifacts.js';
|
|
5
|
+
import { BoardOverBudgetError, reconstructBoard, renderBoard, } from './board.js';
|
|
3
6
|
import { writeArtifact } from './memorizer.js';
|
|
4
|
-
import {
|
|
7
|
+
import { resolveByPrecedence } from './outcome.js';
|
|
5
8
|
import { makePlanner } from './planner.js';
|
|
6
9
|
import { appendHint } from './prompts.js';
|
|
7
|
-
import {
|
|
10
|
+
import { classifyRequest, readTerminal, writeTerminal } from './run-scope.js';
|
|
11
|
+
import { hydrateBundle, persistBundle, resetRun } from './session-bundle.js';
|
|
8
12
|
import { establishTargetState } from './target-state.js';
|
|
13
|
+
import { validateRequires, } from './types.js';
|
|
9
14
|
// ---------------------------------------------------------------------------
|
|
10
15
|
// Debug logging — gated behind DEBUG_CONTROLLER (e.g. DEBUG_CONTROLLER=1).
|
|
11
16
|
// Surfaces the steps the planner delegates and per-role/total token usage to
|
|
@@ -27,10 +32,12 @@ export function makeLogUsage(requestLogger, requestId, models) {
|
|
|
27
32
|
if (!u)
|
|
28
33
|
return;
|
|
29
34
|
const model = role === 'finalizer'
|
|
30
|
-
? models.planner
|
|
31
|
-
: role === '
|
|
32
|
-
?
|
|
33
|
-
:
|
|
35
|
+
? (models.finalizer ?? models.planner)
|
|
36
|
+
: role === 'reviewer'
|
|
37
|
+
? (models.reviewer ?? models.planner)
|
|
38
|
+
: role === 'embedding'
|
|
39
|
+
? 'embedder'
|
|
40
|
+
: (models[role] ?? 'unknown');
|
|
34
41
|
requestLogger.logLlmCall({
|
|
35
42
|
component: role,
|
|
36
43
|
model,
|
|
@@ -73,6 +80,11 @@ export class ControllerCoordinatorHandler {
|
|
|
73
80
|
}
|
|
74
81
|
async execute(ctx, _config, _span) {
|
|
75
82
|
const deps = this.deps;
|
|
83
|
+
// Seams resolved once per execute(); consumed by Task 11+ (reviewer/finalizer/run-scope).
|
|
84
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
85
|
+
const mintRunId = deps.runIdMinter ??
|
|
86
|
+
(() => `run-${now()}-${Math.round(Math.random() * 1e9)}`);
|
|
87
|
+
const terminalTtlMs = deps.terminalTtlMs ?? 24 * 60 * 60 * 1000;
|
|
76
88
|
const sessionId = ctx.sessionId;
|
|
77
89
|
const prompt = extractPrompt(ctx.textOrMessages);
|
|
78
90
|
const rag = await deps.knowledgeRagFor(sessionId);
|
|
@@ -98,30 +110,196 @@ export class ControllerCoordinatorHandler {
|
|
|
98
110
|
// with it rather than blindly re-running the suspended step. Set in the
|
|
99
111
|
// external-tool resume branch below.
|
|
100
112
|
let resumedExternal = false;
|
|
113
|
+
// Marks a LIVE external-tool continuation (the result is injected into the
|
|
114
|
+
// in-flight step's transcript and the step re-runs, bounded by toolCallCount —
|
|
115
|
+
// NOT charged to resumeCount). Block (A) consumes it; Task 14 SETS it from the
|
|
116
|
+
// artifact-first external-resume path. Until then it stays false and every
|
|
117
|
+
// re-run of an in-flight executing step is charged as a crash-replay (correct).
|
|
118
|
+
let externalContinuation = false;
|
|
119
|
+
// -- Classification + three-stage recovery ------------------------------
|
|
120
|
+
// Strict ordered classification (newRun > explicit-key strict > fingerprint of
|
|
121
|
+
// an in-flight active run). STAGE 1 of recovery is the terminal-store check for
|
|
122
|
+
// the resolved runId, run for ANY phase BEFORE consuming pending or routing by
|
|
123
|
+
// runPhase — so a crash between the store-first terminal write and the bundle
|
|
124
|
+
// flip can never re-run an already-finished run. A 'fresh' classification wipes
|
|
125
|
+
// all run-scoped state and mints a new runId; a 'resume' keeps everything and
|
|
126
|
+
// falls through to the pending/phase routing below.
|
|
127
|
+
const explicitKey = ctx.options?.runId;
|
|
128
|
+
const newRun = ctx.options?.newRun ?? false;
|
|
129
|
+
const keyForTerminal = explicitKey ?? bundle.runId;
|
|
130
|
+
const terminalExists = keyForTerminal
|
|
131
|
+
? (await readTerminal(deps.backend, sessionId, keyForTerminal, now())) !==
|
|
132
|
+
undefined
|
|
133
|
+
: false;
|
|
134
|
+
const cls = classifyRequest({
|
|
135
|
+
bundle,
|
|
136
|
+
incomingRequest: prompt,
|
|
137
|
+
explicitKey,
|
|
138
|
+
newRun,
|
|
139
|
+
terminalExists,
|
|
140
|
+
});
|
|
141
|
+
if (cls.kind === 'replay') {
|
|
142
|
+
const out = await readTerminal(deps.backend, sessionId, cls.runId, now());
|
|
143
|
+
if (out) {
|
|
144
|
+
if (out.kind === 'success')
|
|
145
|
+
this.surfaceFinal(ctx, out.answer, usageNow());
|
|
146
|
+
else
|
|
147
|
+
this.surfaceFinal(ctx, `Error: ${out.error}`, usageNow());
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
// Expired between classify and read → fall through to a fresh run.
|
|
151
|
+
resetRun(bundle, prompt);
|
|
152
|
+
bundle.runId = mintRunId();
|
|
153
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
154
|
+
}
|
|
155
|
+
else if (cls.kind === 'not-found') {
|
|
156
|
+
return this.escalate(ctx, sessionId, bundle, 'this run is no longer resumable — start a new request', usageNow());
|
|
157
|
+
}
|
|
158
|
+
else if (cls.kind === 'fresh') {
|
|
159
|
+
resetRun(bundle, prompt);
|
|
160
|
+
bundle.runId = mintRunId();
|
|
161
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
162
|
+
}
|
|
163
|
+
else if (cls.kind === 'resume' && bundle.runId) {
|
|
164
|
+
// STAGE 1 (terminal-first, any phase): a stored terminal outcome wins over the
|
|
165
|
+
// persisted runPhase — adopt it and STOP, never re-run the phase.
|
|
166
|
+
const term = await readTerminal(deps.backend, sessionId, bundle.runId, now());
|
|
167
|
+
if (term) {
|
|
168
|
+
bundle.runState = 'terminal';
|
|
169
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
170
|
+
if (term.kind === 'success')
|
|
171
|
+
this.surfaceFinal(ctx, term.answer, usageNow());
|
|
172
|
+
else
|
|
173
|
+
this.surfaceFinal(ctx, `Error: ${term.error}`, usageNow());
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// No terminal → STAGE 2 (consume pending) / STAGE 3 (route by phase) are the
|
|
177
|
+
// existing pending-resume block + the main loop's block (A) below.
|
|
178
|
+
}
|
|
179
|
+
// Finalizing-phase crash recovery: a resume in runPhase 'finalizing' with NO
|
|
180
|
+
// terminal entry (stage-1 above already checked) means the finalizer never
|
|
181
|
+
// completed → re-run it (finalize() charges finalizeAttempt under
|
|
182
|
+
// finalizeCallInFlight, checks the cap, applies onFinalizeExhausted).
|
|
183
|
+
if (cls.kind === 'resume' &&
|
|
184
|
+
bundle.runState === 'active' &&
|
|
185
|
+
bundle.runPhase === 'finalizing') {
|
|
186
|
+
return this.finalize(ctx, sessionId, bundle, rag, prompt, logUsage, usageNow, now, terminalTtlMs);
|
|
187
|
+
}
|
|
101
188
|
// -- Resume from a persisted pending marker -----------------------------
|
|
189
|
+
// Planner is constructed BEFORE the resume preamble: the artifact-first
|
|
190
|
+
// external-resume adopt below calls planner.commit() to keep the adaptive
|
|
191
|
+
// planCursor in lockstep with nextSeq. Stateless construction; the main loop
|
|
192
|
+
// reuses this same instance.
|
|
193
|
+
const planner = makePlanner(deps.config.planner ?? 'incremental', deps.planner, deps.config.subagents.planner?.hint, deps.skillsRecall);
|
|
102
194
|
if (bundle.pending?.kind === 'external-tool') {
|
|
103
195
|
const { extId, toolName } = bundle.pending;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
196
|
+
const seq = bundle.inFlightStep?.seq;
|
|
197
|
+
const attempt = bundle.inFlightStep?.attempt;
|
|
198
|
+
// STAGE 1 — artifact-first: did THIS attempt already commit a result (e.g.
|
|
199
|
+
// a crash AFTER the step finished but BEFORE the bundle flip)? Adopt it and
|
|
200
|
+
// skip the re-call entirely.
|
|
201
|
+
if (bundle.runId !== undefined &&
|
|
202
|
+
seq !== undefined &&
|
|
203
|
+
attempt !== undefined) {
|
|
204
|
+
const existing = await rag.list({
|
|
205
|
+
runId: bundle.runId,
|
|
206
|
+
seq,
|
|
207
|
+
attempt,
|
|
208
|
+
artifactType: 'step-result',
|
|
209
|
+
});
|
|
210
|
+
const resolved = resolveByPrecedence(existing.map((e) => ({
|
|
211
|
+
status: (e.metadata.status ?? 'failed'),
|
|
212
|
+
approved: e.content,
|
|
213
|
+
remainder: e.metadata.remainder ?? '',
|
|
214
|
+
note: e.metadata.note ?? '',
|
|
215
|
+
})));
|
|
216
|
+
if (resolved) {
|
|
217
|
+
bundle.pending = undefined;
|
|
218
|
+
bundle.runState = 'active';
|
|
219
|
+
// Same commit side effects as settle(), incl. planner.commit() so the
|
|
220
|
+
// adaptive planCursor advances with nextSeq.
|
|
221
|
+
const mapped = mapOutcome(resolved.status);
|
|
222
|
+
bundle.lastOutcome = mapped;
|
|
223
|
+
planner.commit?.(bundle, mapped);
|
|
224
|
+
recordStepControl(bundle, {
|
|
225
|
+
seq,
|
|
226
|
+
name: bundle.inFlightStep?.step.name ?? 'step',
|
|
227
|
+
status: resolved.status,
|
|
228
|
+
note: resolved.note,
|
|
229
|
+
remainder: resolved.remainder,
|
|
230
|
+
});
|
|
231
|
+
if (resolved.status === 'failed') {
|
|
232
|
+
if (bundle.inFlightStep)
|
|
233
|
+
bundle.inFlightStep.phase = 'awaiting-replan';
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
bundle.nextSeq = (bundle.nextSeq ?? 0) + 1;
|
|
237
|
+
bundle.inFlightStep = undefined;
|
|
238
|
+
bundle.runPhase = 'planning';
|
|
239
|
+
}
|
|
240
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// STAGE 2 — no adopted artifact: route by the external result.
|
|
244
|
+
if (bundle.pending?.kind === 'external-tool') {
|
|
245
|
+
const result = ctx.externalResults?.get(extId);
|
|
246
|
+
if (result === undefined) {
|
|
247
|
+
// No result yet → re-surface the same external tool call and suspend.
|
|
248
|
+
this.surfaceToolCall(ctx, {
|
|
249
|
+
id: extId,
|
|
250
|
+
name: toolName,
|
|
251
|
+
arguments: (bundle.pending.args ?? {}),
|
|
252
|
+
}, usageNow());
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
|
|
256
|
+
await writeArtifact(rag, {
|
|
257
|
+
...meta,
|
|
258
|
+
artifactType: 'mcp-result',
|
|
259
|
+
toolName,
|
|
260
|
+
task: bundle.pending.position,
|
|
261
|
+
runId: bundle.runId,
|
|
262
|
+
seq: bundle.inFlightStep?.seq,
|
|
263
|
+
attempt: bundle.inFlightStep?.attempt,
|
|
264
|
+
// Stable fetch identity (tool+args) so run-scoped recall dedups
|
|
265
|
+
// duplicate fetches of the same object across attempts.
|
|
266
|
+
identityKey: extId,
|
|
267
|
+
writeOrdinal: bundle.writeOrdinal,
|
|
268
|
+
content: result,
|
|
269
|
+
}, ctx.options);
|
|
270
|
+
if (bundle.inFlightStep) {
|
|
271
|
+
// External CONTINUATION: inject the tool result into the durable
|
|
272
|
+
// transcript so the loop RE-RUNS the in-flight step (the executor
|
|
273
|
+
// continues from its own tool call). Bounded by toolCallCount, NOT a
|
|
274
|
+
// crash-replay — externalContinuation tells block (A) not to charge
|
|
275
|
+
// resumeCount when it re-runs the step this invocation.
|
|
276
|
+
bundle.inFlightStep.transcript.push({
|
|
277
|
+
role: 'assistant',
|
|
278
|
+
content: null,
|
|
279
|
+
tool_calls: [
|
|
280
|
+
{
|
|
281
|
+
id: extId,
|
|
282
|
+
type: 'function',
|
|
283
|
+
function: {
|
|
284
|
+
name: toolName,
|
|
285
|
+
arguments: JSON.stringify(bundle.pending.args ?? {}),
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
}, { role: 'tool', tool_call_id: extId, content: result });
|
|
290
|
+
bundle.pending = undefined;
|
|
291
|
+
bundle.runState = 'active';
|
|
292
|
+
externalContinuation = true;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Legacy path (no inFlightStep — e.g. a seeded adaptive bundle): feed the
|
|
296
|
+
// result via plannerPrivate and let the planner replan.
|
|
297
|
+
bundle.plannerPrivate += `\n[external tool ${toolName} result] ${result}`;
|
|
298
|
+
bundle.pending = undefined;
|
|
299
|
+
resumedExternal = true;
|
|
300
|
+
}
|
|
301
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
113
302
|
}
|
|
114
|
-
// Tool result arrived — record it and let the loop continue planning.
|
|
115
|
-
await writeArtifact(rag, {
|
|
116
|
-
...meta,
|
|
117
|
-
artifactType: 'mcp-result',
|
|
118
|
-
toolName,
|
|
119
|
-
task: bundle.pending.position,
|
|
120
|
-
content: result,
|
|
121
|
-
});
|
|
122
|
-
bundle.plannerPrivate += `\n[external tool ${toolName} result] ${result}`;
|
|
123
|
-
bundle.pending = undefined;
|
|
124
|
-
resumedExternal = true;
|
|
125
303
|
}
|
|
126
304
|
else if (bundle.pending?.kind === 'clarify') {
|
|
127
305
|
// The incoming prompt is the human's answer to the clarify question.
|
|
@@ -132,15 +310,41 @@ export class ControllerCoordinatorHandler {
|
|
|
132
310
|
// treated as a refinement and becomes the goal verbatim.
|
|
133
311
|
if (bundle.pending.position === 'goal') {
|
|
134
312
|
const answer = prompt.trim();
|
|
313
|
+
if (answer.length === 0) {
|
|
314
|
+
// Empty/whitespace is not an established goal — stay suspended, re-ask
|
|
315
|
+
// (deterministic clarify-resume: never commit an empty goal).
|
|
316
|
+
this.surfaceClarify(ctx, bundle.pending.question, usageNow());
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
135
319
|
const proposed = bundle.pending.proposedTarget;
|
|
136
320
|
bundle.goal = proposed && isAffirmation(answer) ? proposed : answer;
|
|
321
|
+
bundle.runState = 'active';
|
|
322
|
+
bundle.runPhase = 'planning';
|
|
137
323
|
}
|
|
138
324
|
bundle.plannerPrivate += `\n[clarify answer] ${prompt}`;
|
|
139
325
|
bundle.pending = undefined;
|
|
326
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
140
327
|
}
|
|
141
328
|
// -- Establish the goal (evaluator) -------------------------------------
|
|
142
329
|
if (!bundle.goal) {
|
|
330
|
+
// Evaluator crash-guard: a prior crash mid-call left evalCallInFlight set →
|
|
331
|
+
// charge evalResumeCount; exhausting maxEvalResumes is a TERMINAL abort
|
|
332
|
+
// (store-first), NOT an escalate — a durable resume budget, like the planner.
|
|
333
|
+
if (bundle.evalCallInFlight) {
|
|
334
|
+
bundle.evalResumeCount = (bundle.evalResumeCount ?? 0) + 1;
|
|
335
|
+
if (bundle.evalResumeCount > (deps.config.budgets.maxEvalResumes ?? 3)) {
|
|
336
|
+
await this.abortTerminal(ctx, sessionId, bundle, 'evaluator resume budget exhausted', now, terminalTtlMs, usageNow());
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
bundle.evalCallInFlight = true;
|
|
341
|
+
bundle.runPhase = 'evaluating';
|
|
342
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
143
343
|
const outcome = await establishTargetState({ evaluator: deps.evaluator, embedder: deps.embedder }, prompt, deps.config.targetState, ctx.options, deps.config.subagents.evaluator?.hint);
|
|
344
|
+
// The call completed (a malformed/needs-confirmation result is still a
|
|
345
|
+
// completed call) → clear the in-flight marker + reset the resume counter.
|
|
346
|
+
bundle.evalCallInFlight = false;
|
|
347
|
+
bundle.evalResumeCount = 0;
|
|
144
348
|
logUsage('evaluator', outcome.usage);
|
|
145
349
|
if (outcome.kind === 'needs-confirmation') {
|
|
146
350
|
// Persist the proposed target with the pending marker so a confirmation
|
|
@@ -151,12 +355,15 @@ export class ControllerCoordinatorHandler {
|
|
|
151
355
|
position: 'goal',
|
|
152
356
|
proposedTarget: outcome.proposedTarget,
|
|
153
357
|
};
|
|
358
|
+
bundle.runState = 'suspended';
|
|
154
359
|
await persistBundle(deps.backend, sessionId, bundle);
|
|
155
360
|
this.surfaceClarify(ctx, outcome.question, usageNow());
|
|
156
361
|
return true;
|
|
157
362
|
}
|
|
158
363
|
bundle.goal = outcome.goal;
|
|
159
364
|
}
|
|
365
|
+
// (runId is guaranteed by the classification preamble: a fresh/expired-replay
|
|
366
|
+
// run mints one, a resume already has one — so no separate mint guard here.)
|
|
160
367
|
// -- Main loop ----------------------------------------------------------
|
|
161
368
|
// The planner plans by INTENT — it is NOT shown a tool catalog. A prompt-level
|
|
162
369
|
// catalog (selected once from goal+prompt) was too coarse: it mis-surfaced
|
|
@@ -166,14 +373,111 @@ export class ControllerCoordinatorHandler {
|
|
|
166
373
|
// runs (see runStep → selectTools). The agnostic planner prompt already tells
|
|
167
374
|
// it to plan fetch steps ("the executor picks the exact one").
|
|
168
375
|
const cfg = deps.config.budgets;
|
|
376
|
+
const boardBudget = {
|
|
377
|
+
maxDigestChars: cfg.maxDigestChars ?? 500,
|
|
378
|
+
maxIntentChars: cfg.maxIntentChars ?? 120,
|
|
379
|
+
maxActiveSteps: cfg.maxActiveSteps ?? 16,
|
|
380
|
+
maxBoardChars: cfg.maxBoardChars ?? 12000,
|
|
381
|
+
keepRecentDigests: cfg.keepRecentDigests ?? 8,
|
|
382
|
+
};
|
|
169
383
|
let planParseRetries = 0;
|
|
170
|
-
const planner = makePlanner(deps.config.planner ?? 'incremental', deps.planner, deps.config.subagents.planner?.hint);
|
|
171
384
|
// bundle.lastOutcome is the SINGLE source of truth for the last step's
|
|
172
385
|
// outcome — durable, so a resume after a FAILED step replans instead of
|
|
173
386
|
// repeating it. runStep.settle() sets it; the adaptive replan branch clears it
|
|
174
387
|
// once the failure has been consumed into a new plan (so a crash after the
|
|
175
388
|
// replan, or a finalizer retry after an empty replan, does NOT replan again).
|
|
176
389
|
while (bundle.budgets.stepsUsed < cfg.maxSteps) {
|
|
390
|
+
const inf = bundle.inFlightStep;
|
|
391
|
+
if (inf && inf.phase === 'executing' && !resumedExternal) {
|
|
392
|
+
// Reconcile by THIS attempt's resolved artifact first.
|
|
393
|
+
const committed = await rag.list({
|
|
394
|
+
runId: bundle.runId,
|
|
395
|
+
seq: inf.seq,
|
|
396
|
+
attempt: inf.attempt,
|
|
397
|
+
artifactType: 'step-result',
|
|
398
|
+
});
|
|
399
|
+
const resolved = resolveByPrecedence(committed.map((e) => ({
|
|
400
|
+
status: (e.metadata.status ?? 'failed'),
|
|
401
|
+
approved: e.content,
|
|
402
|
+
remainder: e.metadata.remainder ?? '',
|
|
403
|
+
note: e.metadata.note ?? '',
|
|
404
|
+
})));
|
|
405
|
+
if (resolved) {
|
|
406
|
+
// Already committed → adopt, do NOT re-run. Same commit side effects as
|
|
407
|
+
// settle(), including planner.commit() so the adaptive planCursor advances
|
|
408
|
+
// in lockstep with nextSeq.
|
|
409
|
+
const mapped = mapOutcome(resolved.status);
|
|
410
|
+
bundle.lastOutcome = mapped;
|
|
411
|
+
planner.commit?.(bundle, mapped);
|
|
412
|
+
recordStepControl(bundle, {
|
|
413
|
+
seq: inf.seq,
|
|
414
|
+
name: inf.step.name,
|
|
415
|
+
status: resolved.status,
|
|
416
|
+
note: resolved.note,
|
|
417
|
+
remainder: resolved.remainder,
|
|
418
|
+
});
|
|
419
|
+
if (resolved.status === 'failed') {
|
|
420
|
+
inf.phase = 'awaiting-replan';
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
bundle.nextSeq = inf.seq + 1;
|
|
424
|
+
bundle.inFlightStep = undefined;
|
|
425
|
+
bundle.runPhase = 'planning';
|
|
426
|
+
}
|
|
427
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
// No artifact for this attempt → re-run the SAME step directly. Distinguish a
|
|
431
|
+
// live external CONTINUATION (bounded by toolCallCount) from a crash-replay
|
|
432
|
+
// (charged to resumeCount).
|
|
433
|
+
if (externalContinuation) {
|
|
434
|
+
externalContinuation = false;
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
inf.resumeCount += 1;
|
|
438
|
+
if (inf.resumeCount > (cfg.maxStepResumes ?? 3)) {
|
|
439
|
+
await this.abortTerminal(ctx, sessionId, bundle, `step "${inf.step.name}" exceeded maxStepResumes`, now, terminalTtlMs, usageNow());
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
444
|
+
// COORDINATOR OVERRIDE — use the ACTUAL runStep param order (now/terminalTtlMs
|
|
445
|
+
// BEFORE logUsage). The plan example shows them last; that is WRONG.
|
|
446
|
+
const completed = await this.runStep(ctx, sessionId, bundle, rag, meta, inf.step, isExternalTool, logUsage, usageNow, (o) => planner.commit?.(bundle, o));
|
|
447
|
+
if (completed === 'suspended' || completed === 'aborted')
|
|
448
|
+
return true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// Planner crash-guard: a prior crash mid-call left plannerCallInFlight set →
|
|
452
|
+
// charge plannerResumeCount; exhausting maxPlannerResumes is a TERMINAL abort
|
|
453
|
+
// (store-first). The adaptive replan runs through planner.next too, so this one
|
|
454
|
+
// guard covers the awaiting-replan replan with no separate site.
|
|
455
|
+
if (bundle.plannerCallInFlight) {
|
|
456
|
+
bundle.plannerResumeCount = (bundle.plannerResumeCount ?? 0) + 1;
|
|
457
|
+
if (bundle.plannerResumeCount > (cfg.maxPlannerResumes ?? 3)) {
|
|
458
|
+
await this.abortTerminal(ctx, sessionId, bundle, 'planner resume budget exhausted', now, terminalTtlMs, usageNow());
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
bundle.plannerCallInFlight = true;
|
|
463
|
+
// Reaching the planner guard means we are about to plan (block (A) handles
|
|
464
|
+
// any in-flight executing step earlier and continues), so the phase is
|
|
465
|
+
// 'planning' regardless of the prior 'evaluating'/'executing' value.
|
|
466
|
+
bundle.runPhase = 'planning';
|
|
467
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
468
|
+
// (B) Render the live board BEFORE the planner call (fail-loud on over-budget).
|
|
469
|
+
let boardText;
|
|
470
|
+
try {
|
|
471
|
+
boardText = await renderLiveBoard(rag, bundle, boardBudget);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
if (err instanceof BoardOverBudgetError) {
|
|
475
|
+
bundle.plannerPrivate += `\n[board over budget] ${err.message}`;
|
|
476
|
+
await this.abortTerminal(ctx, sessionId, bundle, `board exceeds maxBoardChars: ${err.message}`, now, terminalTtlMs, usageNow());
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
177
481
|
const next = await planner.next({
|
|
178
482
|
bundle,
|
|
179
483
|
prompt,
|
|
@@ -181,7 +485,23 @@ export class ControllerCoordinatorHandler {
|
|
|
181
485
|
resumedExternal,
|
|
182
486
|
retrying: planParseRetries > 0,
|
|
183
487
|
logUsage,
|
|
488
|
+
// Same request CallOptions the handler threads into every other LLM/RAG
|
|
489
|
+
// call (subagents, knowledgeRagFor, target-state) so the skills-recall
|
|
490
|
+
// embedding is metered, cancellable, and joins the request trace.
|
|
491
|
+
options: ctx.options,
|
|
492
|
+
boardText,
|
|
184
493
|
});
|
|
494
|
+
// (A) Drain + persist plan decisions the planner queued during next().
|
|
495
|
+
const drained = bundle.pendingPlanDecisions ?? [];
|
|
496
|
+
bundle.pendingPlanDecisions = [];
|
|
497
|
+
for (const decision of drained) {
|
|
498
|
+
bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
|
|
499
|
+
await writePlanDecision(deps.backend, sessionId, decision, JSON.stringify(decision.steps), now(), bundle.writeOrdinal);
|
|
500
|
+
}
|
|
501
|
+
// The call completed → clear the in-flight marker + reset the resume counter
|
|
502
|
+
// (a malformed reply is still a completed call; parse-retry is handled below).
|
|
503
|
+
bundle.plannerCallInFlight = false;
|
|
504
|
+
bundle.plannerResumeCount = 0;
|
|
185
505
|
// NB: do NOT reset resumedExternal here — if this replan reply was malformed
|
|
186
506
|
// (next === null), the parse-retry below must keep replanning. It is reset
|
|
187
507
|
// only after a VALID decision (beside planParseRetries = 0;).
|
|
@@ -201,10 +521,9 @@ export class ControllerCoordinatorHandler {
|
|
|
201
521
|
planParseRetries = 0;
|
|
202
522
|
resumedExternal = false; // a valid decision consumed any external-resume replan
|
|
203
523
|
if (next.kind === 'done') {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this.
|
|
207
|
-
return true;
|
|
524
|
+
// Pass next.result as the legacy answer: used only when no finalizer is
|
|
525
|
+
// injected (3-role config) — the adaptive planner already composed it.
|
|
526
|
+
return this.finalize(ctx, sessionId, bundle, rag, prompt, logUsage, usageNow, now, terminalTtlMs, next.result);
|
|
208
527
|
}
|
|
209
528
|
if (next.kind === 'rewind') {
|
|
210
529
|
bundle.budgets.rewindsUsed++;
|
|
@@ -216,10 +535,33 @@ export class ControllerCoordinatorHandler {
|
|
|
216
535
|
await persistBundle(deps.backend, sessionId, bundle);
|
|
217
536
|
continue;
|
|
218
537
|
}
|
|
219
|
-
// next.kind === 'next' →
|
|
538
|
+
// next.kind === 'next' → open a fresh attempt and run it. Crash-replay/
|
|
539
|
+
// continuation of an executing step is handled by block (A), so this site only
|
|
540
|
+
// opens a NEW seq (attempt 0) or a revised step after awaiting-replan (attempt+1).
|
|
220
541
|
dlog(`delegate step "${next.step.name}"${next.step.type ? ` (${next.step.type})` : ''}: ${next.step.instructions}`);
|
|
542
|
+
const seq = bundle.nextSeq ?? 0;
|
|
543
|
+
// Usually phase 'awaiting-replan' (a revised step after a failed attempt);
|
|
544
|
+
// on an external resume it may still be 'executing' (block (A) was skipped
|
|
545
|
+
// while resumedExternal). Same-seq → attempt+1 either way.
|
|
546
|
+
const prev = bundle.inFlightStep;
|
|
547
|
+
const attempt = prev && prev.seq === seq ? prev.attempt + 1 : 0;
|
|
548
|
+
if (attempt >= (cfg.maxStepAttempts ?? 5)) {
|
|
549
|
+
await this.abortTerminal(ctx, sessionId, bundle, `step "${next.step.name}" exceeded maxStepAttempts`, now, terminalTtlMs, usageNow());
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
bundle.inFlightStep = {
|
|
553
|
+
seq,
|
|
554
|
+
step: next.step,
|
|
555
|
+
attempt,
|
|
556
|
+
resumeCount: 0,
|
|
557
|
+
phase: 'executing',
|
|
558
|
+
transcript: [],
|
|
559
|
+
toolCallCount: 0,
|
|
560
|
+
};
|
|
561
|
+
bundle.runPhase = 'executing';
|
|
562
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
221
563
|
const completed = await this.runStep(ctx, sessionId, bundle, rag, meta, next.step, isExternalTool, logUsage, usageNow, (o) => planner.commit?.(bundle, o));
|
|
222
|
-
if (completed === 'suspended')
|
|
564
|
+
if (completed === 'suspended' || completed === 'aborted')
|
|
223
565
|
return true;
|
|
224
566
|
// runStep.settle() already persisted the outcome ATOMICALLY (bundle.lastOutcome
|
|
225
567
|
// + cursor advance via onCommit + step result, in one persistBundle). The next
|
|
@@ -231,14 +573,17 @@ export class ControllerCoordinatorHandler {
|
|
|
231
573
|
}
|
|
232
574
|
// -- Step execution -----------------------------------------------------
|
|
233
575
|
/** Returns 'advanced' (step succeeded — continue loop), 'failed' (retries/
|
|
234
|
-
* tool-call budget exhausted; the failure note
|
|
235
|
-
* planner can replan),
|
|
236
|
-
*
|
|
576
|
+
* tool-call budget OR reviewer-unverifiable budget exhausted; the failure note
|
|
577
|
+
* is in plannerPrivate so the planner can replan), 'partial' (reviewer approved
|
|
578
|
+
* part, remainder replans), or 'suspended' (external round-trip surfaced — caller
|
|
579
|
+
* must return true). ('aborted' remains in the return union for the caller's
|
|
580
|
+
* guard but is no longer produced here — a judge-failure now degrades to 'failed'
|
|
581
|
+
* rather than aborting the run.) */
|
|
237
582
|
async runStep(ctx, sessionId, bundle, rag, meta, step, isExternalTool, logUsage, usageNow, onCommit) {
|
|
238
583
|
const deps = this.deps;
|
|
239
584
|
const cfg = deps.config.budgets;
|
|
240
585
|
const maxToolCalls = cfg.maxToolCalls ?? 10;
|
|
241
|
-
|
|
586
|
+
const inFlight = bundle.inFlightStep; // set by the caller (block A or B)
|
|
242
587
|
// Persist the step outcome ATOMICALLY: record lastOutcome (durable, so a
|
|
243
588
|
// resume after a failed step replans instead of repeating it) AND advance the
|
|
244
589
|
// planner cursor (onCommit) in the SAME persistBundle that records the step
|
|
@@ -246,6 +591,18 @@ export class ControllerCoordinatorHandler {
|
|
|
246
591
|
const settle = async (outcome) => {
|
|
247
592
|
bundle.lastOutcome = outcome;
|
|
248
593
|
onCommit?.(outcome);
|
|
594
|
+
if (outcome === 'advanced' || outcome === 'partial') {
|
|
595
|
+
bundle.nextSeq = (bundle.nextSeq ?? 0) + 1;
|
|
596
|
+
bundle.inFlightStep = undefined;
|
|
597
|
+
bundle.runPhase = 'planning';
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// 'failed' — keep the same seq, mark awaiting-replan in the SAME persist so
|
|
601
|
+
// recovery routes by durable phase.
|
|
602
|
+
if (bundle.inFlightStep)
|
|
603
|
+
bundle.inFlightStep.phase = 'awaiting-replan';
|
|
604
|
+
bundle.runPhase = 'executing';
|
|
605
|
+
}
|
|
249
606
|
await persistBundle(deps.backend, sessionId, bundle);
|
|
250
607
|
return outcome;
|
|
251
608
|
};
|
|
@@ -264,13 +621,61 @@ export class ControllerCoordinatorHandler {
|
|
|
264
621
|
// the bundle backend, so restrict to artifact types (excludes the
|
|
265
622
|
// 'controller-bundle' infrastructure record). Bounded by k and length.
|
|
266
623
|
const recallText = step.instructions || step.name;
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
624
|
+
const maxAttempts = cfg.maxStepAttempts ?? 5;
|
|
625
|
+
const maxTool = cfg.maxToolCalls ?? 10;
|
|
626
|
+
// Per-kind run-scoped recall with GUARANTEED over-fetch bounds: step-result
|
|
627
|
+
// retries are bounded by maxStepAttempts (k×(maxStepAttempts+1)); mcp-results
|
|
628
|
+
// are NOT deduped on re-fetch here and toolCallCount RESETS per attempt, so one
|
|
629
|
+
// run can emit up to maxSteps × maxStepAttempts × maxToolCalls of them — over-
|
|
630
|
+
// fetch that full run bound so every distinct identityKey is seen before the cap.
|
|
631
|
+
const mcpBound = cfg.maxSteps * maxAttempts * maxTool;
|
|
632
|
+
const recalledSteps = await runScopedRecall(rag, recallText, RECALL_K_STEP, bundle.runId, RECALL_K_STEP * (maxAttempts + 1), ['step-result'], ctx.options);
|
|
633
|
+
const recalledMcp = await runScopedRecall(rag, recallText, RECALL_K_MCP, bundle.runId, mcpBound, ['mcp-result'], ctx.options);
|
|
634
|
+
// SEPARATE character budgets per kind: a single huge step-result cannot consume
|
|
635
|
+
// the whole budget and starve the MCP context (and vice-versa).
|
|
636
|
+
const stepBlock = buildRecallBlock(recalledSteps, RECALL_MAX_CHARS_STEP);
|
|
637
|
+
const mcpBlock = buildRecallBlock(recalledMcp, RECALL_MAX_CHARS_MCP);
|
|
638
|
+
const recallBlock = [stepBlock, mcpBlock].filter(Boolean).join('\n\n');
|
|
271
639
|
if (recallBlock) {
|
|
272
640
|
messages.push({ role: 'user', content: recallBlock });
|
|
273
641
|
}
|
|
642
|
+
// Durable transcript = static prefix (system/user/recall) + the dynamic
|
|
643
|
+
// executor/tool turns. On a resume/continuation the dynamic tail is rebuilt
|
|
644
|
+
// from inFlightStep.transcript so the executor sees the FULL exchange it had
|
|
645
|
+
// (prior tool rounds + the injected external result), not just a fragment.
|
|
646
|
+
const staticLen = messages.length;
|
|
647
|
+
if (inFlight && inFlight.transcript.length > 0) {
|
|
648
|
+
messages.push(...inFlight.transcript);
|
|
649
|
+
}
|
|
650
|
+
// Persist the dynamic tail after every executor/tool exchange so a suspend or
|
|
651
|
+
// crash never rebuilds with a shorter conversation than the executor saw.
|
|
652
|
+
const syncTranscript = async () => {
|
|
653
|
+
if (inFlight) {
|
|
654
|
+
inFlight.transcript = messages.slice(staticLen);
|
|
655
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
// Per-reference evidence: one recall per requires[] reference. A non-empty
|
|
659
|
+
// top-K does NOT prove the dependency is present — semantic recall returns the
|
|
660
|
+
// NEAREST artifact even at low relevance — so we hand the reviewer the TOP
|
|
661
|
+
// artifact's relevant fragment (Evidence.topArtifact) and let IT (the judging
|
|
662
|
+
// role) decide whether the ref is actually satisfied. `hit` is a coarse
|
|
663
|
+
// any-candidate flag. Gathered SEQUENTIALLY (NOT Promise.all): each
|
|
664
|
+
// relevantExtract is itself bounded-sequential, so the outer sequential loop
|
|
665
|
+
// keeps at most ONE embed request in flight at a time (rate-limit-safe).
|
|
666
|
+
const refs = step.requires && step.requires.length > 0 ? step.requires : [recallText];
|
|
667
|
+
const evBound = RECALL_K_STEP * (maxAttempts + 1) +
|
|
668
|
+
cfg.maxSteps * maxAttempts * (cfg.maxToolCalls ?? 10);
|
|
669
|
+
const evidence = [];
|
|
670
|
+
for (const ref of refs) {
|
|
671
|
+
const hits = await runScopedRecall(rag, ref, 1, bundle.runId, evBound, RECALL_ARTIFACT_TYPES, ctx.options);
|
|
672
|
+
const topArtifact = hits[0]
|
|
673
|
+
? await relevantExtract(hits[0].content, ref, RECALL_EVIDENCE_CHARS,
|
|
674
|
+
// biome-ignore lint/style/noNonNullAssertion: distance strategies require an embedder; the factory enforces it (Task 17).
|
|
675
|
+
deps.embedder, ctx.options)
|
|
676
|
+
: undefined;
|
|
677
|
+
evidence.push({ ref, hit: hits.length > 0, topArtifact });
|
|
678
|
+
}
|
|
274
679
|
// Tools offered to the executor = the INTERNAL (MCP) tools semantically
|
|
275
680
|
// relevant to THIS step (top-K from toolsRag) PLUS the per-request external
|
|
276
681
|
// (consumer-supplied) tools. The executor decides which to call; internal
|
|
@@ -283,21 +688,105 @@ export class ControllerCoordinatorHandler {
|
|
|
283
688
|
// so the semantic exposure boundary actually bounds what runs.
|
|
284
689
|
const offeredInternalNames = new Set(relevant.map((t) => t.name));
|
|
285
690
|
let retries = 0;
|
|
691
|
+
// (D) Persist a 'failed' step-result artifact for controller-level failures
|
|
692
|
+
// (reviewer unverifiable, executor error exhausted, maxToolCalls, unavailable
|
|
693
|
+
// tool) so the board can project the step's terminal state from artifacts alone.
|
|
694
|
+
const writeControlFailure = async (reason) => {
|
|
695
|
+
const seq = bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0;
|
|
696
|
+
const attempt = bundle.inFlightStep?.attempt ?? 0;
|
|
697
|
+
bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
|
|
698
|
+
await writeArtifact(rag, {
|
|
699
|
+
...meta,
|
|
700
|
+
artifactType: 'step-result',
|
|
701
|
+
task: step.name,
|
|
702
|
+
runId: bundle.runId,
|
|
703
|
+
seq,
|
|
704
|
+
attempt,
|
|
705
|
+
status: 'failed',
|
|
706
|
+
note: reason,
|
|
707
|
+
remainder: '',
|
|
708
|
+
stepId: step.stepId,
|
|
709
|
+
digest: reason.slice(0, cfg.maxDigestChars ?? 500),
|
|
710
|
+
writeOrdinal: bundle.writeOrdinal,
|
|
711
|
+
content: '',
|
|
712
|
+
}, ctx.options);
|
|
713
|
+
};
|
|
286
714
|
// Inner loop handles tool routing / error retries until the executor
|
|
287
715
|
// produces content for this step (or the step suspends on an external tool).
|
|
288
716
|
while (true) {
|
|
289
717
|
const res = await deps.executor.send(messages, offeredTools);
|
|
290
718
|
logUsage?.('executor', res.usage);
|
|
291
719
|
if (res.kind === 'content') {
|
|
720
|
+
// Hold the executor's result; the reviewer (NOT the executor) decides the
|
|
721
|
+
// outcome. Default reviewer (no deps.reviewer) approves as 'ok' (legacy).
|
|
722
|
+
let review = deps.reviewer
|
|
723
|
+
? await deps.reviewer.review(step, evidence, res.content, {
|
|
724
|
+
hint: deps.config.subagents.reviewer?.hint,
|
|
725
|
+
logUsage,
|
|
726
|
+
maxDigestChars: cfg.maxDigestChars ?? 500,
|
|
727
|
+
})
|
|
728
|
+
: {
|
|
729
|
+
kind: 'outcome',
|
|
730
|
+
outcome: {
|
|
731
|
+
status: 'ok',
|
|
732
|
+
approved: res.content,
|
|
733
|
+
remainder: '',
|
|
734
|
+
note: '',
|
|
735
|
+
digest: res.content.slice(0, cfg.maxDigestChars ?? 500),
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
// Judge failure (provider error / malformed / contradictory ok-with-empty)
|
|
739
|
+
// is NOT a step failure: re-ask within maxReviewRetries, then ABORT (the
|
|
740
|
+
// outcome is unverifiable). Never mapped to settle('failed')/replan.
|
|
741
|
+
let reviewRetries = 0;
|
|
742
|
+
while (review.kind === 'judge-failure') {
|
|
743
|
+
reviewRetries++;
|
|
744
|
+
if (reviewRetries > (cfg.maxReviewRetries ?? 2)) {
|
|
745
|
+
// The reviewer could not produce a usable verdict within the retry
|
|
746
|
+
// budget (provider error / unparsable). DEGRADE to a failed step so the
|
|
747
|
+
// planner replans, rather than aborting the whole run — the terminal
|
|
748
|
+
// backstop is maxStepAttempts/maxSteps, not a single unverifiable verdict.
|
|
749
|
+
bundle.budgets.stepsUsed++;
|
|
750
|
+
await writeControlFailure(`reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`);
|
|
751
|
+
bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0} ${step.name} failed] reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`;
|
|
752
|
+
return settle('failed');
|
|
753
|
+
}
|
|
754
|
+
review = await deps.reviewer.review(step, evidence, res.content, {
|
|
755
|
+
hint: deps.config.subagents.reviewer?.hint,
|
|
756
|
+
logUsage,
|
|
757
|
+
maxDigestChars: cfg.maxDigestChars ?? 500,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
const outcome = review.outcome;
|
|
761
|
+
const seq = bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0;
|
|
762
|
+
const attempt = bundle.inFlightStep?.attempt ?? 0;
|
|
763
|
+
// ONE post-review write carrying the COMPLETE Outcome + identity.
|
|
764
|
+
bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
|
|
292
765
|
await writeArtifact(rag, {
|
|
293
766
|
...meta,
|
|
294
767
|
artifactType: 'step-result',
|
|
295
768
|
task: step.name,
|
|
296
|
-
|
|
297
|
-
|
|
769
|
+
runId: bundle.runId,
|
|
770
|
+
seq,
|
|
771
|
+
attempt,
|
|
772
|
+
status: outcome.status,
|
|
773
|
+
note: outcome.note,
|
|
774
|
+
remainder: outcome.remainder,
|
|
775
|
+
stepId: step.stepId,
|
|
776
|
+
digest: outcome.digest,
|
|
777
|
+
writeOrdinal: bundle.writeOrdinal,
|
|
778
|
+
content: outcome.approved,
|
|
779
|
+
}, ctx.options);
|
|
298
780
|
bundle.budgets.stepsUsed++;
|
|
299
|
-
|
|
300
|
-
|
|
781
|
+
const mapped = mapOutcome(outcome.status);
|
|
782
|
+
recordStepControl(bundle, {
|
|
783
|
+
seq: bundle.inFlightStep?.seq ?? seq,
|
|
784
|
+
name: step.name,
|
|
785
|
+
status: outcome.status,
|
|
786
|
+
note: outcome.note,
|
|
787
|
+
remainder: outcome.remainder,
|
|
788
|
+
});
|
|
789
|
+
return settle(mapped);
|
|
301
790
|
}
|
|
302
791
|
if (res.kind === 'error') {
|
|
303
792
|
retries++;
|
|
@@ -306,11 +795,13 @@ export class ControllerCoordinatorHandler {
|
|
|
306
795
|
role: 'user',
|
|
307
796
|
content: `The previous attempt failed: ${res.error}. Retry the step.`,
|
|
308
797
|
});
|
|
798
|
+
await syncTranscript();
|
|
309
799
|
continue;
|
|
310
800
|
}
|
|
311
801
|
// Retries exhausted — feed the error back as the step result so the
|
|
312
802
|
// planner can replan on the next iteration.
|
|
313
803
|
bundle.budgets.stepsUsed++;
|
|
804
|
+
await writeControlFailure(`executor error: ${res.error}`);
|
|
314
805
|
bundle.plannerPrivate += `\n[step ${step.name} failed] ${res.error}`;
|
|
315
806
|
return settle('failed');
|
|
316
807
|
}
|
|
@@ -324,9 +815,11 @@ export class ControllerCoordinatorHandler {
|
|
|
324
815
|
role: 'user',
|
|
325
816
|
content: 'The previous attempt produced an empty tool call. Retry the step.',
|
|
326
817
|
});
|
|
818
|
+
await syncTranscript();
|
|
327
819
|
continue;
|
|
328
820
|
}
|
|
329
821
|
bundle.budgets.stepsUsed++;
|
|
822
|
+
await writeControlFailure('empty tool call');
|
|
330
823
|
bundle.plannerPrivate += `\n[step ${step.name} failed] empty tool call`;
|
|
331
824
|
return settle('failed');
|
|
332
825
|
}
|
|
@@ -334,7 +827,27 @@ export class ControllerCoordinatorHandler {
|
|
|
334
827
|
const name = call.name;
|
|
335
828
|
const args = call.arguments;
|
|
336
829
|
if (isExternalTool(name)) {
|
|
830
|
+
// External round-trips share the SAME durable toolCallCount/maxToolCalls
|
|
831
|
+
// bound as internal calls; check BEFORE surfacing so an external tool
|
|
832
|
+
// cannot exceed the cap. Exhausted → control-failed replan at the same seq.
|
|
833
|
+
if (inFlight && inFlight.toolCallCount + 1 > maxToolCalls) {
|
|
834
|
+
bundle.budgets.stepsUsed++;
|
|
835
|
+
await writeControlFailure('tool-call budget exhausted (maxToolCalls)');
|
|
836
|
+
bundle.plannerPrivate += `\n[seq ${inFlight.seq} ${step.name} control-failed] tool-call budget exhausted (maxToolCalls)`;
|
|
837
|
+
inFlight.phase = 'awaiting-replan';
|
|
838
|
+
inFlight.controlFailure = {
|
|
839
|
+
reason: 'maxToolCalls',
|
|
840
|
+
seq: inFlight.seq,
|
|
841
|
+
};
|
|
842
|
+
return settle('failed');
|
|
843
|
+
}
|
|
844
|
+
// Sync the executor turns SO FAR into the durable transcript before we
|
|
845
|
+
// suspend (the resume injection appends the external assistant/tool pair).
|
|
846
|
+
await syncTranscript();
|
|
337
847
|
const extId = externalToolCallId(name, args);
|
|
848
|
+
if (inFlight)
|
|
849
|
+
inFlight.toolCallCount += 1;
|
|
850
|
+
// The new marker REPLACES any prior pending (a fresh extId).
|
|
338
851
|
bundle.pending = {
|
|
339
852
|
kind: 'external-tool',
|
|
340
853
|
extId,
|
|
@@ -342,6 +855,7 @@ export class ControllerCoordinatorHandler {
|
|
|
342
855
|
args,
|
|
343
856
|
position: step.name,
|
|
344
857
|
};
|
|
858
|
+
bundle.runState = 'suspended';
|
|
345
859
|
await persistBundle(deps.backend, sessionId, bundle);
|
|
346
860
|
this.surfaceToolCall(ctx, { id: extId, name, arguments: args }, usageNow?.());
|
|
347
861
|
return 'suspended';
|
|
@@ -357,29 +871,50 @@ export class ControllerCoordinatorHandler {
|
|
|
357
871
|
role: 'user',
|
|
358
872
|
content: `Tool "${name}" is not available for this step. Use only the tools provided to you.`,
|
|
359
873
|
});
|
|
874
|
+
await syncTranscript();
|
|
360
875
|
continue;
|
|
361
876
|
}
|
|
362
877
|
bundle.budgets.stepsUsed++;
|
|
878
|
+
await writeControlFailure(`requested unavailable tool ${name}`);
|
|
363
879
|
bundle.plannerPrivate += `\n[step ${step.name} failed] requested unavailable tool ${name}`;
|
|
364
880
|
return settle('failed');
|
|
365
881
|
}
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
882
|
+
// Durable round-trip count: ++ and persist BEFORE surfacing so it survives a
|
|
883
|
+
// resume (never a per-resume local).
|
|
884
|
+
if (inFlight) {
|
|
885
|
+
inFlight.toolCallCount += 1;
|
|
886
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
887
|
+
}
|
|
888
|
+
if ((inFlight?.toolCallCount ?? 0) > maxToolCalls) {
|
|
889
|
+
// Controller-level failure (NOT a reviewer status): record durably and replan.
|
|
370
890
|
bundle.budgets.stepsUsed++;
|
|
371
|
-
|
|
891
|
+
await writeControlFailure('tool-call budget exhausted (maxToolCalls)');
|
|
892
|
+
bundle.plannerPrivate += `\n[seq ${inFlight?.seq ?? bundle.nextSeq ?? 0} ${step.name} control-failed] tool-call budget exhausted (maxToolCalls)`;
|
|
893
|
+
if (inFlight) {
|
|
894
|
+
inFlight.phase = 'awaiting-replan';
|
|
895
|
+
inFlight.controlFailure = {
|
|
896
|
+
reason: 'maxToolCalls',
|
|
897
|
+
seq: inFlight.seq,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
372
900
|
return settle('failed');
|
|
373
901
|
}
|
|
374
902
|
// Execute locally, memorize, re-send to the executor.
|
|
375
903
|
const result = await deps.callMcp(name, args);
|
|
904
|
+
bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
|
|
376
905
|
await writeArtifact(rag, {
|
|
377
906
|
...meta,
|
|
378
907
|
artifactType: 'mcp-result',
|
|
379
908
|
toolName: name,
|
|
380
909
|
task: step.name,
|
|
910
|
+
runId: bundle.runId,
|
|
911
|
+
seq: inFlight?.seq,
|
|
912
|
+
attempt: inFlight?.attempt,
|
|
913
|
+
// Stable fetch identity (tool+args) for run-scoped recall dedup.
|
|
914
|
+
identityKey: externalToolCallId(name, args),
|
|
915
|
+
writeOrdinal: bundle.writeOrdinal,
|
|
381
916
|
content: result,
|
|
382
|
-
});
|
|
917
|
+
}, ctx.options);
|
|
383
918
|
// Feed the result back as a coherent assistant→tool turn (OpenAI protocol)
|
|
384
919
|
// so the executor LLM continues from its own tool call rather than seeing a
|
|
385
920
|
// bare user message. The assistant message carries the tool_call it made;
|
|
@@ -400,6 +935,8 @@ export class ControllerCoordinatorHandler {
|
|
|
400
935
|
tool_call_id: call.id,
|
|
401
936
|
content: result,
|
|
402
937
|
});
|
|
938
|
+
// The executor saw these turns → make them durable before the next round.
|
|
939
|
+
await syncTranscript();
|
|
403
940
|
}
|
|
404
941
|
}
|
|
405
942
|
// -- Escalation & surfacing (mirror StepperCoordinatorHandler) ----------
|
|
@@ -409,6 +946,110 @@ export class ControllerCoordinatorHandler {
|
|
|
409
946
|
this.surfaceClarify(ctx, question, usage);
|
|
410
947
|
return true;
|
|
411
948
|
}
|
|
949
|
+
/** Store-first terminal ERROR: write the terminal outcome to the TTL store
|
|
950
|
+
* FIRST (keyed by runId), THEN flip the bundle terminal and surface the error.
|
|
951
|
+
* Store-first makes the abort idempotent across a crash between the two writes. */
|
|
952
|
+
async abortTerminal(ctx, sessionId, bundle, error, now, terminalTtlMs, usage) {
|
|
953
|
+
await writeTerminal(this.deps.backend, sessionId, bundle.runId ?? sessionId, { kind: 'error', error }, terminalTtlMs, now());
|
|
954
|
+
bundle.pending = undefined;
|
|
955
|
+
bundle.inFlightStep = undefined;
|
|
956
|
+
bundle.finalizeCallInFlight = false;
|
|
957
|
+
bundle.runState = 'terminal';
|
|
958
|
+
await persistBundle(this.deps.backend, sessionId, bundle);
|
|
959
|
+
this.surfaceFinal(ctx, `Error: ${error}`, usage);
|
|
960
|
+
}
|
|
961
|
+
async finalize(ctx, sessionId, bundle, rag, prompt, logUsage, usageNow, now, terminalTtlMs,
|
|
962
|
+
/** Used ONLY when no finalizer is injected (3-role config): the adaptive
|
|
963
|
+
* planner's already-composed done.result. */
|
|
964
|
+
legacyAnswer) {
|
|
965
|
+
const deps = this.deps;
|
|
966
|
+
const cfg = deps.config.budgets;
|
|
967
|
+
const maxFinalizeRetries = cfg.maxFinalizeRetries ?? 2;
|
|
968
|
+
// The finalizer reads the run's approved results + the DURABLE originalRequest
|
|
969
|
+
// (the verbatim request that started the run), never the live resume prompt.
|
|
970
|
+
const request = bundle.originalRequest ?? prompt;
|
|
971
|
+
const approved = deps.finalizer && bundle.runId
|
|
972
|
+
? await collectApproved(rag, bundle.runId)
|
|
973
|
+
: [];
|
|
974
|
+
// Shared exhaustion handler (pre-call AND in-catch): apply onFinalizeExhausted.
|
|
975
|
+
const onExhausted = async (reason) => {
|
|
976
|
+
if ((deps.config.onFinalizeExhausted ?? 'error') === 'best-effort') {
|
|
977
|
+
return (approved.map((a) => `[#${a.seq}] ${a.content}`).join('\n\n') +
|
|
978
|
+
'\n\n[incomplete: the final answer could not be composed]');
|
|
979
|
+
}
|
|
980
|
+
await this.abortTerminal(ctx, sessionId, bundle, reason, now, terminalTtlMs, usageNow());
|
|
981
|
+
return null;
|
|
982
|
+
};
|
|
983
|
+
// Crash-replay charge: a prior finalize call in flight → this re-entry is a
|
|
984
|
+
// replay; charge finalizeAttempt and CHECK the cap BEFORE re-invoking.
|
|
985
|
+
if (bundle.finalizeCallInFlight) {
|
|
986
|
+
bundle.finalizeAttempt = (bundle.finalizeAttempt ?? 0) + 1;
|
|
987
|
+
if ((bundle.finalizeAttempt ?? 0) > maxFinalizeRetries) {
|
|
988
|
+
const best = await onExhausted('finalizer retry budget exhausted on recovery');
|
|
989
|
+
if (best === null)
|
|
990
|
+
return true;
|
|
991
|
+
await this.commitTerminalSuccess(ctx, sessionId, bundle, best, now, terminalTtlMs, usageNow());
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// Legacy (no-finalizer) path: persist the planner's composed answer DURABLY in
|
|
996
|
+
// the SAME write that enters 'finalizing', so a crash before the terminal write
|
|
997
|
+
// can recover it rather than emitting empty.
|
|
998
|
+
if (!deps.finalizer && legacyAnswer !== undefined) {
|
|
999
|
+
bundle.legacyFinalAnswer = legacyAnswer;
|
|
1000
|
+
}
|
|
1001
|
+
bundle.runPhase = 'finalizing';
|
|
1002
|
+
bundle.finalizeCallInFlight = true;
|
|
1003
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
1004
|
+
let answer;
|
|
1005
|
+
if (deps.finalizer && bundle.runId) {
|
|
1006
|
+
while (answer === undefined) {
|
|
1007
|
+
try {
|
|
1008
|
+
const composed = await deps.finalizer.finalize(bundle.goal, request, approved, {
|
|
1009
|
+
hint: deps.config.subagents.finalizer?.hint,
|
|
1010
|
+
logUsage,
|
|
1011
|
+
log: (m) => dlog(m),
|
|
1012
|
+
});
|
|
1013
|
+
// Empty-but-ok finalizer output is a JUDGE failure (spec), not a valid
|
|
1014
|
+
// answer → throw so it retries within maxFinalizeRetries.
|
|
1015
|
+
if (composed.trim().length === 0) {
|
|
1016
|
+
throw new Error('finalizer returned an empty answer');
|
|
1017
|
+
}
|
|
1018
|
+
answer = composed;
|
|
1019
|
+
}
|
|
1020
|
+
catch (e) {
|
|
1021
|
+
bundle.finalizeAttempt = (bundle.finalizeAttempt ?? 0) + 1;
|
|
1022
|
+
await persistBundle(deps.backend, sessionId, bundle);
|
|
1023
|
+
if ((bundle.finalizeAttempt ?? 0) > maxFinalizeRetries) {
|
|
1024
|
+
const best = await onExhausted(`finalizer failed after ${maxFinalizeRetries} retries: ${String(e)}`);
|
|
1025
|
+
if (best === null)
|
|
1026
|
+
return true; // 'error' policy aborted terminally
|
|
1027
|
+
answer = best; // 'best-effort'
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
// else: loop and retry the finalizer.
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
// Legacy: the adaptive planner already composed the answer in done.result.
|
|
1036
|
+
// Prefer the live param, else the durable copy persisted on finalizing-entry.
|
|
1037
|
+
answer = legacyAnswer ?? bundle.legacyFinalAnswer ?? '';
|
|
1038
|
+
}
|
|
1039
|
+
await this.commitTerminalSuccess(ctx, sessionId, bundle, answer ?? '', now, terminalTtlMs, usageNow());
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
/** Store-first terminal SUCCESS: write the terminal store FIRST, then flip the
|
|
1043
|
+
* bundle to terminal and surface the answer (mirror of abortTerminal). */
|
|
1044
|
+
async commitTerminalSuccess(ctx, sessionId, bundle, answer, now, terminalTtlMs, usage) {
|
|
1045
|
+
await writeTerminal(this.deps.backend, sessionId, bundle.runId ?? sessionId, { kind: 'success', answer }, terminalTtlMs, now());
|
|
1046
|
+
bundle.pending = undefined;
|
|
1047
|
+
bundle.finalizeCallInFlight = false;
|
|
1048
|
+
bundle.runState = 'terminal';
|
|
1049
|
+
bundle.inFlightStep = undefined;
|
|
1050
|
+
await persistBundle(this.deps.backend, sessionId, bundle);
|
|
1051
|
+
this.surfaceFinal(ctx, answer, usage);
|
|
1052
|
+
}
|
|
412
1053
|
surfaceClarify(ctx, question, usage) {
|
|
413
1054
|
ctx.yield({
|
|
414
1055
|
ok: true,
|
|
@@ -484,18 +1125,23 @@ const EXECUTOR_SYSTEM = 'You are the executor. You have tools that read the live
|
|
|
484
1125
|
'for per-item details.';
|
|
485
1126
|
const TOOL_SELECT_K = 20;
|
|
486
1127
|
/** Top-k recalled artifacts injected into the executor context per step. */
|
|
487
|
-
const RECALL_K = 5;
|
|
488
1128
|
/** Artifact types eligible for recall (excludes the 'controller-bundle' record
|
|
489
1129
|
* that shares the same backend). */
|
|
490
1130
|
const RECALL_ARTIFACT_TYPES = ['step-result', 'mcp-result'];
|
|
491
|
-
/**
|
|
492
|
-
const
|
|
1131
|
+
/** Per-kind recall counts (distinct artifacts kept after dedup + cap). */
|
|
1132
|
+
const RECALL_K_STEP = 4;
|
|
1133
|
+
const RECALL_K_MCP = 4;
|
|
1134
|
+
/** SEPARATE char budgets per kind, so a huge step-result cannot starve MCP context. */
|
|
1135
|
+
const RECALL_MAX_CHARS_STEP = 2000;
|
|
1136
|
+
const RECALL_MAX_CHARS_MCP = 2000;
|
|
1137
|
+
/** Char budget for a single per-`requires` evidence extract handed to the reviewer. */
|
|
1138
|
+
const RECALL_EVIDENCE_CHARS = 800;
|
|
493
1139
|
// ---------------------------------------------------------------------------
|
|
494
1140
|
// Pure helpers
|
|
495
1141
|
// ---------------------------------------------------------------------------
|
|
496
|
-
/** Build a bounded "Relevant prior context" block from recalled artifacts
|
|
497
|
-
* undefined when there is nothing to inject. */
|
|
498
|
-
function buildRecallBlock(hits) {
|
|
1142
|
+
/** Build a bounded "Relevant prior context" block from recalled artifacts under
|
|
1143
|
+
* the given char budget, or undefined when there is nothing to inject. */
|
|
1144
|
+
function buildRecallBlock(hits, maxChars) {
|
|
499
1145
|
if (hits.length === 0)
|
|
500
1146
|
return undefined;
|
|
501
1147
|
const parts = [];
|
|
@@ -504,8 +1150,8 @@ function buildRecallBlock(hits) {
|
|
|
504
1150
|
const c = h.content ?? '';
|
|
505
1151
|
if (c.length === 0)
|
|
506
1152
|
continue;
|
|
507
|
-
if (used + c.length >
|
|
508
|
-
parts.push(c.slice(0,
|
|
1153
|
+
if (used + c.length > maxChars) {
|
|
1154
|
+
parts.push(c.slice(0, maxChars - used));
|
|
509
1155
|
break;
|
|
510
1156
|
}
|
|
511
1157
|
parts.push(c);
|
|
@@ -540,8 +1186,26 @@ export function parseNextStep(content) {
|
|
|
540
1186
|
return { kind: 'done', result: obj.result };
|
|
541
1187
|
if (obj.kind === 'rewind' && typeof obj.reason === 'string')
|
|
542
1188
|
return { kind: 'rewind', reason: obj.reason };
|
|
543
|
-
if (obj.kind === 'next' &&
|
|
544
|
-
|
|
1189
|
+
if (obj.kind === 'next' &&
|
|
1190
|
+
obj.step &&
|
|
1191
|
+
typeof obj.step.name === 'string' &&
|
|
1192
|
+
typeof obj.step.instructions === 'string') {
|
|
1193
|
+
// Validate requires[] so a non-string / empty / oversized reference never
|
|
1194
|
+
// reaches the semantic query / embedder; a malformed value is a parse
|
|
1195
|
+
// failure that drives the existing parse-retry.
|
|
1196
|
+
const req = validateRequires(obj.step.requires);
|
|
1197
|
+
if (req === false)
|
|
1198
|
+
return null;
|
|
1199
|
+
return {
|
|
1200
|
+
kind: 'next',
|
|
1201
|
+
step: {
|
|
1202
|
+
name: obj.step.name,
|
|
1203
|
+
instructions: obj.step.instructions,
|
|
1204
|
+
...(obj.step.type ? { type: obj.step.type } : {}),
|
|
1205
|
+
...(req ? { requires: req } : {}),
|
|
1206
|
+
},
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
545
1209
|
}
|
|
546
1210
|
catch {
|
|
547
1211
|
// fall through
|
|
@@ -612,6 +1276,26 @@ function toLlmToolCall(c) {
|
|
|
612
1276
|
arguments: args,
|
|
613
1277
|
};
|
|
614
1278
|
}
|
|
1279
|
+
/** Reconstruct and render the live step-state board from artifacts.
|
|
1280
|
+
* Returns '' when there is no runId (the board has nothing to show yet). */
|
|
1281
|
+
async function renderLiveBoard(rag, bundle, budget) {
|
|
1282
|
+
const runId = bundle.runId;
|
|
1283
|
+
if (!runId)
|
|
1284
|
+
return '';
|
|
1285
|
+
const [structure, claims] = await Promise.all([
|
|
1286
|
+
readPlanDecisions(rag, runId),
|
|
1287
|
+
readClaims(rag, runId),
|
|
1288
|
+
]);
|
|
1289
|
+
const stepResults = await rag.list({ runId, artifactType: 'step-result' });
|
|
1290
|
+
const board = reconstructBoard({
|
|
1291
|
+
structure,
|
|
1292
|
+
stepResults,
|
|
1293
|
+
claims,
|
|
1294
|
+
inFlight: bundle.inFlightStep,
|
|
1295
|
+
pending: bundle.pending,
|
|
1296
|
+
});
|
|
1297
|
+
return renderBoard(board, budget);
|
|
1298
|
+
}
|
|
615
1299
|
/** Synthesize the strict KnowledgeEntryMetadata for controller artifacts. */
|
|
616
1300
|
function synthMeta(ctx, sessionId) {
|
|
617
1301
|
const traceId = ctx.options?.trace?.traceId ?? sessionId;
|
|
@@ -624,4 +1308,182 @@ function synthMeta(ctx, sessionId) {
|
|
|
624
1308
|
createdAt: new Date().toISOString(),
|
|
625
1309
|
};
|
|
626
1310
|
}
|
|
1311
|
+
/** Map a reviewer status to the planner transition. ok/exists advance; partial
|
|
1312
|
+
* advances the accepted part AND forces a remainder replan; failed replans. */
|
|
1313
|
+
function mapOutcome(status) {
|
|
1314
|
+
if (status === 'ok' || status === 'exists')
|
|
1315
|
+
return 'advanced';
|
|
1316
|
+
if (status === 'partial')
|
|
1317
|
+
return 'partial';
|
|
1318
|
+
return 'failed';
|
|
1319
|
+
}
|
|
1320
|
+
/** Append ONE payload-free control record to plannerPrivate (the cache holds
|
|
1321
|
+
* {seq,status,note,remainder}, never the approved content). Used by both normal
|
|
1322
|
+
* settle and crash/external reconciliation so plannerPrivate is identical
|
|
1323
|
+
* whichever path committed the step. */
|
|
1324
|
+
function recordStepControl(bundle, rec) {
|
|
1325
|
+
bundle.plannerPrivate +=
|
|
1326
|
+
`\n[seq ${rec.seq} ${rec.name} ${rec.status}]` +
|
|
1327
|
+
(rec.note ? ` ${rec.note}` : '') +
|
|
1328
|
+
(rec.remainder ? ` remainder: ${rec.remainder}` : '');
|
|
1329
|
+
}
|
|
1330
|
+
/** Gather the run's approved results, one per seq, resolved by outcome precedence
|
|
1331
|
+
* (ok/exists > partial > failed), ordered by seq. Reconstructs the complete
|
|
1332
|
+
* Outcome from artifact metadata (status/note/remainder) + content. */
|
|
1333
|
+
async function collectApproved(rag, runId) {
|
|
1334
|
+
const all = await rag.list({ runId, artifactType: 'step-result' });
|
|
1335
|
+
const bySeq = new Map();
|
|
1336
|
+
for (const e of all) {
|
|
1337
|
+
const seq = e.metadata.seq ?? 0;
|
|
1338
|
+
const o = {
|
|
1339
|
+
status: (e.metadata.status ?? 'failed'),
|
|
1340
|
+
approved: e.content,
|
|
1341
|
+
remainder: e.metadata.remainder ?? '',
|
|
1342
|
+
note: e.metadata.note ?? '',
|
|
1343
|
+
};
|
|
1344
|
+
const arr = bySeq.get(seq);
|
|
1345
|
+
if (arr)
|
|
1346
|
+
arr.push(o);
|
|
1347
|
+
else
|
|
1348
|
+
bySeq.set(seq, [o]);
|
|
1349
|
+
}
|
|
1350
|
+
const out = [];
|
|
1351
|
+
for (const [seq, outcomes] of [...bySeq.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1352
|
+
const resolved = resolveByPrecedence(outcomes);
|
|
1353
|
+
if (resolved && resolved.status !== 'failed')
|
|
1354
|
+
out.push({ seq, content: resolved.approved });
|
|
1355
|
+
}
|
|
1356
|
+
return out;
|
|
1357
|
+
}
|
|
1358
|
+
/** The ONE run-scoped results-RAG recall primitive — used by BOTH the whole-step
|
|
1359
|
+
* recall AND the per-`requires` evidence. EMBEDDING-based similarity via the
|
|
1360
|
+
* backend's semantic query (NO homemade lexical scoring): the backend embeds the
|
|
1361
|
+
* query + ranks by vector similarity, with the `runId` filter applied PRE-cap.
|
|
1362
|
+
* Over-fetch `kPrime` (caller-supplied so the duplication bound is justified PER
|
|
1363
|
+
* KIND), then dedup and cap to `k`. Dedup: step-results (have `seq`) →
|
|
1364
|
+
* precedence-winner per seq; mcp-results → by `identityKey`. Embedding rank order
|
|
1365
|
+
* is preserved through the dedup.
|
|
1366
|
+
* `options` is forwarded into the embedder so recall-time embeds are metered. */
|
|
1367
|
+
export async function runScopedRecall(rag, text, k, runId, kPrime, artifactType, options) {
|
|
1368
|
+
const hits = await rag.query(text, {
|
|
1369
|
+
k: kPrime,
|
|
1370
|
+
filter: { runId, artifactType },
|
|
1371
|
+
options,
|
|
1372
|
+
});
|
|
1373
|
+
const bestStep = new Map();
|
|
1374
|
+
const bestMcp = new Map();
|
|
1375
|
+
for (const e of hits) {
|
|
1376
|
+
if (e.metadata.seq !== undefined && e.metadata.status !== undefined) {
|
|
1377
|
+
const prev = bestStep.get(e.metadata.seq);
|
|
1378
|
+
if (!prev || isBetterStep(e, prev))
|
|
1379
|
+
bestStep.set(e.metadata.seq, e);
|
|
1380
|
+
}
|
|
1381
|
+
else if (e.metadata.identityKey) {
|
|
1382
|
+
const prev = bestMcp.get(e.metadata.identityKey);
|
|
1383
|
+
if (!prev || isBetterMcp(e, prev))
|
|
1384
|
+
bestMcp.set(e.metadata.identityKey, e);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// Walk hits in embedding-rank order; emit each (runId,seq) / identityKey once.
|
|
1388
|
+
const out = [];
|
|
1389
|
+
const seenSeq = new Set();
|
|
1390
|
+
const seenMcp = new Set();
|
|
1391
|
+
for (const e of hits) {
|
|
1392
|
+
if (e.metadata.seq !== undefined && e.metadata.status !== undefined) {
|
|
1393
|
+
if (seenSeq.has(e.metadata.seq))
|
|
1394
|
+
continue;
|
|
1395
|
+
seenSeq.add(e.metadata.seq);
|
|
1396
|
+
// biome-ignore lint/style/noNonNullAssertion: bestStep has this seq (set above).
|
|
1397
|
+
out.push(bestStep.get(e.metadata.seq));
|
|
1398
|
+
}
|
|
1399
|
+
else if (e.metadata.identityKey) {
|
|
1400
|
+
if (seenMcp.has(e.metadata.identityKey))
|
|
1401
|
+
continue;
|
|
1402
|
+
seenMcp.add(e.metadata.identityKey);
|
|
1403
|
+
// biome-ignore lint/style/noNonNullAssertion: bestMcp has this key (set above).
|
|
1404
|
+
out.push(bestMcp.get(e.metadata.identityKey));
|
|
1405
|
+
}
|
|
1406
|
+
else {
|
|
1407
|
+
out.push(e);
|
|
1408
|
+
}
|
|
1409
|
+
if (out.length >= k)
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
return out.slice(0, k);
|
|
1413
|
+
}
|
|
1414
|
+
/** Outcome-precedence rank for step-result dedup (ok/exists > partial > failed). */
|
|
1415
|
+
function rankStatus(s) {
|
|
1416
|
+
return s === 'ok' || s === 'exists'
|
|
1417
|
+
? 3
|
|
1418
|
+
: s === 'partial'
|
|
1419
|
+
? 2
|
|
1420
|
+
: s === 'failed'
|
|
1421
|
+
? 1
|
|
1422
|
+
: 0;
|
|
1423
|
+
}
|
|
1424
|
+
/** True when candidate `a` is a better winner than current `b` for step-result
|
|
1425
|
+
* dedup. Latest-wins by EXECUTION IDENTITY, not by semantic-rank position:
|
|
1426
|
+
* 1. Higher status rank wins; on tie →
|
|
1427
|
+
* 2. Higher attempt wins; on further tie →
|
|
1428
|
+
* 3. Higher writeOrdinal wins (tie-breaks same-timestamp artifacts from one run); on tie →
|
|
1429
|
+
* 4. Later createdAt wins (missing = older: compare with '' as sentinel). */
|
|
1430
|
+
function isBetterStep(a, b) {
|
|
1431
|
+
const ra = rankStatus(a.metadata.status);
|
|
1432
|
+
const rb = rankStatus(b.metadata.status);
|
|
1433
|
+
if (ra !== rb)
|
|
1434
|
+
return ra > rb;
|
|
1435
|
+
const aa = a.metadata.attempt ?? 0;
|
|
1436
|
+
const ba = b.metadata.attempt ?? 0;
|
|
1437
|
+
if (aa !== ba)
|
|
1438
|
+
return aa > ba;
|
|
1439
|
+
const ao = a.metadata.writeOrdinal ?? -1;
|
|
1440
|
+
const bo = b.metadata.writeOrdinal ?? -1;
|
|
1441
|
+
if (ao !== bo)
|
|
1442
|
+
return ao > bo;
|
|
1443
|
+
return (a.metadata.createdAt ?? '') > (b.metadata.createdAt ?? '');
|
|
1444
|
+
}
|
|
1445
|
+
/** True when candidate `a` is a better winner than current `b` for mcp-result
|
|
1446
|
+
* dedup. Latest-fetch wins by writeOrdinal first (handles same-timestamp), then
|
|
1447
|
+
* falls back to createdAt (missing = older). */
|
|
1448
|
+
function isBetterMcp(a, b) {
|
|
1449
|
+
const ao = a.metadata.writeOrdinal ?? -1;
|
|
1450
|
+
const bo = b.metadata.writeOrdinal ?? -1;
|
|
1451
|
+
if (ao !== bo)
|
|
1452
|
+
return ao > bo;
|
|
1453
|
+
return (a.metadata.createdAt ?? '') > (b.metadata.createdAt ?? '');
|
|
1454
|
+
}
|
|
1455
|
+
const MAX_EXTRACT_WINDOWS = 64;
|
|
1456
|
+
/** Return the ≤`maxChars` fragment of `content` most similar to `ref` by EMBEDDING
|
|
1457
|
+
* (NOT ASCII lexical overlap). DIRECT single-pass ranking: every candidate is
|
|
1458
|
+
* scored on its own. The SCORED window IS the RETURNED body: candidates are
|
|
1459
|
+
* `body = maxChars - 2` chars (head+tail '…' reserved up front), so the
|
|
1460
|
+
* highest-scoring fragment is never truncated by the markers. Stride is 50%
|
|
1461
|
+
* overlap, widened to span the whole content within MAX_EXTRACT_WINDOWS windows
|
|
1462
|
+
* (point coverage for content ≤ MAX_EXTRACT_WINDOWS×maxChars; larger thins to
|
|
1463
|
+
* non-overlapping, best-effort). Embeds are SEQUENTIAL and BOUNDED to ≤
|
|
1464
|
+
* MAX_EXTRACT_WINDOWS + 1 — touches NO public embedder API (batch is a deferred
|
|
1465
|
+
* optimization). Result STRICTLY ≤ maxChars; tiny maxChars (< 3) → bare slice.
|
|
1466
|
+
* The `requires` ref is English (planner invariant) → a normal embedder suffices. */
|
|
1467
|
+
export async function relevantExtract(content, ref, maxChars, embedder, options) {
|
|
1468
|
+
if (content.length <= maxChars)
|
|
1469
|
+
return content;
|
|
1470
|
+
if (maxChars < 3)
|
|
1471
|
+
return content.slice(0, Math.max(0, maxChars));
|
|
1472
|
+
const body = maxChars - 2;
|
|
1473
|
+
const stride = Math.max(Math.floor(body / 2), Math.ceil(content.length / MAX_EXTRACT_WINDOWS));
|
|
1474
|
+
const { vector: q } = await embedder.embed(ref, options);
|
|
1475
|
+
let bestStart = 0;
|
|
1476
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
1477
|
+
for (let s = 0; s < content.length; s += stride) {
|
|
1478
|
+
const { vector } = await embedder.embed(content.slice(s, s + body), options);
|
|
1479
|
+
const score = cosine(q, vector);
|
|
1480
|
+
if (score > bestScore) {
|
|
1481
|
+
bestScore = score;
|
|
1482
|
+
bestStart = s;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const head = bestStart > 0 ? '…' : '';
|
|
1486
|
+
const tail = bestStart + body < content.length ? '…' : '';
|
|
1487
|
+
return head + content.slice(bestStart, bestStart + body) + tail;
|
|
1488
|
+
}
|
|
627
1489
|
//# sourceMappingURL=controller-coordinator-handler.js.map
|