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