@mcp-abap-adt/llm-agent-server-libs 20.6.0 → 20.7.1

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 (72) hide show
  1. package/README.md +1 -0
  2. package/dist/factories/controller-factory.d.ts +5 -1
  3. package/dist/factories/controller-factory.d.ts.map +1 -1
  4. package/dist/factories/controller-factory.js +1 -0
  5. package/dist/factories/controller-factory.js.map +1 -1
  6. package/dist/factories/cyclic-factory.d.ts.map +1 -1
  7. package/dist/factories/cyclic-factory.js +4 -1
  8. package/dist/factories/cyclic-factory.js.map +1 -1
  9. package/dist/generated/version.d.ts +1 -1
  10. package/dist/generated/version.js +1 -1
  11. package/dist/mcp/compose-auxiliary.d.ts +6 -5
  12. package/dist/mcp/compose-auxiliary.d.ts.map +1 -1
  13. package/dist/mcp/compose-auxiliary.js +10 -6
  14. package/dist/mcp/compose-auxiliary.js.map +1 -1
  15. package/dist/pipelines/controller.d.ts.map +1 -1
  16. package/dist/pipelines/controller.js +13 -0
  17. package/dist/pipelines/controller.js.map +1 -1
  18. package/dist/pipelines/linear.js +1 -1
  19. package/dist/pipelines/linear.js.map +1 -1
  20. package/dist/pipelines/stepper.js +1 -1
  21. package/dist/pipelines/stepper.js.map +1 -1
  22. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +23 -3
  23. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
  24. package/dist/smart-agent/controller/controller-coordinator-handler.js +255 -37
  25. package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
  26. package/dist/smart-agent/controller/finalizer.d.ts +5 -1
  27. package/dist/smart-agent/controller/finalizer.d.ts.map +1 -1
  28. package/dist/smart-agent/controller/finalizer.js +1 -1
  29. package/dist/smart-agent/controller/finalizer.js.map +1 -1
  30. package/dist/smart-agent/controller/parser.d.ts.map +1 -1
  31. package/dist/smart-agent/controller/parser.js +2 -0
  32. package/dist/smart-agent/controller/parser.js.map +1 -1
  33. package/dist/smart-agent/controller/planner.d.ts +3 -2
  34. package/dist/smart-agent/controller/planner.d.ts.map +1 -1
  35. package/dist/smart-agent/controller/planner.js +64 -9
  36. package/dist/smart-agent/controller/planner.js.map +1 -1
  37. package/dist/smart-agent/controller/reviewer.d.ts +5 -1
  38. package/dist/smart-agent/controller/reviewer.d.ts.map +1 -1
  39. package/dist/smart-agent/controller/reviewer.js +1 -1
  40. package/dist/smart-agent/controller/reviewer.js.map +1 -1
  41. package/dist/smart-agent/controller/session-bundle.d.ts +10 -0
  42. package/dist/smart-agent/controller/session-bundle.d.ts.map +1 -1
  43. package/dist/smart-agent/controller/session-bundle.js +27 -0
  44. package/dist/smart-agent/controller/session-bundle.js.map +1 -1
  45. package/dist/smart-agent/controller/subagent-client.d.ts +9 -0
  46. package/dist/smart-agent/controller/subagent-client.d.ts.map +1 -1
  47. package/dist/smart-agent/controller/subagent-client.js +34 -1
  48. package/dist/smart-agent/controller/subagent-client.js.map +1 -1
  49. package/dist/smart-agent/controller/target-state.d.ts +1 -1
  50. package/dist/smart-agent/controller/target-state.d.ts.map +1 -1
  51. package/dist/smart-agent/controller/target-state.js +2 -1
  52. package/dist/smart-agent/controller/target-state.js.map +1 -1
  53. package/dist/smart-agent/controller/types.d.ts +30 -3
  54. package/dist/smart-agent/controller/types.d.ts.map +1 -1
  55. package/dist/smart-agent/controller/types.js +5 -2
  56. package/dist/smart-agent/controller/types.js.map +1 -1
  57. package/dist/smart-agent/controller/wait-step.d.ts +40 -0
  58. package/dist/smart-agent/controller/wait-step.d.ts.map +1 -0
  59. package/dist/smart-agent/controller/wait-step.js +76 -0
  60. package/dist/smart-agent/controller/wait-step.js.map +1 -0
  61. package/dist/smart-agent/http/chat-route-handler.d.ts.map +1 -1
  62. package/dist/smart-agent/http/chat-route-handler.js +3 -1
  63. package/dist/smart-agent/http/chat-route-handler.js.map +1 -1
  64. package/dist/smart-agent/http/debug-trace-sink.d.ts +9 -0
  65. package/dist/smart-agent/http/debug-trace-sink.d.ts.map +1 -0
  66. package/dist/smart-agent/http/debug-trace-sink.js +17 -0
  67. package/dist/smart-agent/http/debug-trace-sink.js.map +1 -0
  68. package/dist/smart-agent/smart-server.d.ts +8 -3
  69. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  70. package/dist/smart-agent/smart-server.js +12 -5
  71. package/dist/smart-agent/smart-server.js.map +1 -1
  72. package/package.json +7 -7
@@ -1,4 +1,4 @@
1
- import { externalToolCallId, McpError, } from '@mcp-abap-adt/llm-agent';
1
+ import { DefaultWaitStrategy, externalToolCallId, McpError, } from '@mcp-abap-adt/llm-agent';
2
2
  import { LegacyAccumulateContextStrategy, LegacyTranscriptContextStrategy, summaryToUsage, } from '@mcp-abap-adt/llm-agent-libs';
3
3
  import { writePlanDecision } from './artifacts.js';
4
4
  import { BoardOverBudgetError, renderLiveBoard, } from './board.js';
@@ -9,9 +9,11 @@ import { makeControllerPlanner } from './planner.js';
9
9
  import { appendHint } from './prompts.js';
10
10
  import { buildRecallBlock, collectApproved, RECALL_ARTIFACT_TYPES, RECALL_EVIDENCE_CHARS, RECALL_K_STEP, RECALL_MAX_CHARS_STEP, relevantExtract, runScopedRecall, } from './recall.js';
11
11
  import { classifyRequest, readTerminal, writeTerminal } from './run-scope.js';
12
- import { hydrateBundle, persistBundle, resetRun } from './session-bundle.js';
12
+ import { hydrateBundle, persistBundle, resetRun, settleStep, } from './session-bundle.js';
13
+ import { diagnosticCallOptions, } from './subagent-client.js';
13
14
  import { establishTargetState } from './target-state.js';
14
15
  import { makeLogUsage } from './usage-logging.js';
16
+ import { describeWait, isWaitStep, planWait } from './wait-step.js';
15
17
  // ---------------------------------------------------------------------------
16
18
  // Debug logging — gated behind DEBUG_CONTROLLER (e.g. DEBUG_CONTROLLER=1).
17
19
  // Surfaces the steps the planner delegates and per-role/total token usage to
@@ -22,6 +24,14 @@ function dlog(msg) {
22
24
  if (process.env.DEBUG_CONTROLLER)
23
25
  console.error(`[controller] ${msg}`);
24
26
  }
27
+ /** Decision-point capture (area `controller`): mirrors `dlog`'s stderr
28
+ * breadcrumb into a per-request session-log record so a DEBUG_CONTROLLER run
29
+ * shows WHY the controller looped/replanned/rejected, not just THAT it did.
30
+ * A no-op when no sessionLogger is wired (observability only). */
31
+ function logDecision(ctx, kind, reason, extra) {
32
+ dlog(`decision ${kind}: ${reason}`);
33
+ ctx.options?.sessionLogger?.logStep(`controller_decision_${kind}`, { kind, reason, ...extra }, 'controller');
34
+ }
25
35
  // ---------------------------------------------------------------------------
26
36
  // Re-exported for import-path stability (helpers moved to sibling modules).
27
37
  // ---------------------------------------------------------------------------
@@ -211,6 +221,8 @@ export class ControllerCoordinatorHandler {
211
221
  if (resolved.status === 'failed') {
212
222
  if (bundle.inFlightStep)
213
223
  bundle.inFlightStep.phase = 'awaiting-replan';
224
+ logDecision(ctx, 'replan', resolved.note ||
225
+ `step "${bundle.inFlightStep?.step.name ?? 'step'}" resolved failed (adopted artifact)`);
214
226
  }
215
227
  else {
216
228
  bundle.nextSeq = (bundle.nextSeq ?? 0) + 1;
@@ -326,6 +338,9 @@ export class ControllerCoordinatorHandler {
326
338
  bundle.evalCallInFlight = false;
327
339
  bundle.evalResumeCount = 0;
328
340
  logUsage('evaluator', outcome.usage);
341
+ logDecision(ctx, 'target-state', outcome.kind === 'established'
342
+ ? `goal established: ${outcome.goal}`
343
+ : `needs confirmation: ${outcome.question}`, { kind: outcome.kind });
329
344
  if (outcome.kind === 'needs-confirmation') {
330
345
  // Persist the proposed target with the pending marker so a confirmation
331
346
  // on resume commits IT (not a bare "yes"). See the clarify-resume above.
@@ -398,6 +413,8 @@ export class ControllerCoordinatorHandler {
398
413
  });
399
414
  if (resolved.status === 'failed') {
400
415
  inf.phase = 'awaiting-replan';
416
+ logDecision(ctx, 'replan', resolved.note ||
417
+ `step "${inf.step.name}" resolved failed (adopted artifact)`);
401
418
  }
402
419
  else {
403
420
  bundle.nextSeq = inf.seq + 1;
@@ -410,6 +427,27 @@ export class ControllerCoordinatorHandler {
410
427
  // No artifact for this attempt → re-run the SAME step directly. Distinguish a
411
428
  // live external CONTINUATION (bounded by toolCallCount) from a crash-replay
412
429
  // (charged to resumeCount).
430
+ //
431
+ // A `wait` step is served here FIRST — BEFORE the resumeCount accounting
432
+ // below. A wait remainder is a continuation against an already-durable
433
+ // deadline, NOT a crash replay: charging it to resumeCount would let
434
+ // repeated abort/resume of a long wait terminally kill the run. Non-wait
435
+ // steps fall through to the resume/replay accounting unchanged.
436
+ const resumeWait = await this.serveWaitStep({
437
+ ctx,
438
+ sessionId,
439
+ bundle,
440
+ rag,
441
+ meta,
442
+ step: inf.step,
443
+ cfg,
444
+ nowIso: now,
445
+ onCommit: (o) => planner.commit?.(bundle, o),
446
+ });
447
+ if (resumeWait === 'aborted')
448
+ return true;
449
+ if (resumeWait === 'served')
450
+ continue;
413
451
  if (externalContinuation) {
414
452
  externalContinuation = false;
415
453
  }
@@ -500,6 +538,16 @@ export class ControllerCoordinatorHandler {
500
538
  }
501
539
  planParseRetries = 0;
502
540
  resumedExternal = false; // a valid decision consumed any external-resume replan
541
+ if (next.kind === 'error') {
542
+ // The planner saw a failure it cannot fix within the consumer's
543
+ // constraints (a pinned name that is taken, an unauthorized op, a lock
544
+ // that will not clear). Terminate the run and return the REAL tool error
545
+ // to the consumer — distinct from the generic abortTerminal reasons and
546
+ // never (no response). (#213)
547
+ logDecision(ctx, 'planner-error', next.error);
548
+ await this.abortTerminal(ctx, sessionId, bundle, next.error, now, terminalTtlMs, usageNow());
549
+ return true;
550
+ }
503
551
  if (next.kind === 'done') {
504
552
  // Pass next.result as the legacy answer: used only when no finalizer is
505
553
  // injected (3-role config) — the plan-first planner already composed it.
@@ -526,7 +574,9 @@ export class ControllerCoordinatorHandler {
526
574
  const prev = bundle.inFlightStep;
527
575
  const attempt = prev && prev.seq === seq ? prev.attempt + 1 : 0;
528
576
  if (attempt >= (cfg.maxStepAttempts ?? 5)) {
529
- await this.abortTerminal(ctx, sessionId, bundle, `step "${next.step.name}" exceeded maxStepAttempts`, now, terminalTtlMs, usageNow());
577
+ const reason = `step "${next.step.name}" exceeded maxStepAttempts`;
578
+ logDecision(ctx, 'retry-exhausted', reason);
579
+ await this.abortTerminal(ctx, sessionId, bundle, reason, now, terminalTtlMs, usageNow());
530
580
  return true;
531
581
  }
532
582
  bundle.inFlightStep = {
@@ -540,6 +590,24 @@ export class ControllerCoordinatorHandler {
540
590
  };
541
591
  bundle.runPhase = 'executing';
542
592
  await persistBundle(deps.backend, sessionId, bundle);
593
+ // A `wait` step is served by the controller itself (no executor / reviewer /
594
+ // MCP). The in-flight step is already durable above, so serveWaitStep can
595
+ // persist its deadline and settle. Non-wait steps fall through to runStep.
596
+ const freshWait = await this.serveWaitStep({
597
+ ctx,
598
+ sessionId,
599
+ bundle,
600
+ rag,
601
+ meta,
602
+ step: next.step,
603
+ cfg,
604
+ nowIso: now,
605
+ onCommit: (o) => planner.commit?.(bundle, o),
606
+ });
607
+ if (freshWait === 'aborted')
608
+ return true;
609
+ if (freshWait === 'served')
610
+ continue;
543
611
  const completed = await this.runStep(ctx, sessionId, bundle, rag, meta, next.step, isExternalTool, logUsage, usageNow, (o) => planner.commit?.(bundle, o));
544
612
  if (completed === 'suspended' || completed === 'aborted')
545
613
  return true;
@@ -608,23 +676,19 @@ export class ControllerCoordinatorHandler {
608
676
  // resume after a failed step replans instead of repeating it) AND advance the
609
677
  // planner cursor (onCommit) in the SAME persistBundle that records the step
610
678
  // result — never in a separate write, so a crash cannot replay a completed step.
611
- const settle = async (outcome) => {
612
- bundle.lastOutcome = outcome;
613
- onCommit?.(outcome);
614
- if (outcome === 'advanced' || outcome === 'partial') {
615
- bundle.nextSeq = (bundle.nextSeq ?? 0) + 1;
616
- bundle.inFlightStep = undefined;
617
- bundle.runPhase = 'planning';
679
+ const settle = (outcome,
680
+ /** Human reason for a 'failed' settle — logged via logDecision (kind
681
+ * 'replan'); the caller usually already computed this string for the
682
+ * plannerPrivate note / writeControlFailure call. Absent → a generic
683
+ * fallback so the decision record is never missing a reason. */
684
+ reason) => {
685
+ // #228 debug-trace: a 'failed' settle induces a replan — record it.
686
+ // The state mutation + atomic persist is the extracted settleStep (#229);
687
+ // logDecision is a trace side-effect, order vs persist is immaterial.
688
+ if (outcome === 'failed') {
689
+ logDecision(ctx, 'replan', reason ?? `step "${step.name}" failed — awaiting replan`);
618
690
  }
619
- else {
620
- // 'failed' — keep the same seq, mark awaiting-replan in the SAME persist so
621
- // recovery routes by durable phase.
622
- if (bundle.inFlightStep)
623
- bundle.inFlightStep.phase = 'awaiting-replan';
624
- bundle.runPhase = 'executing';
625
- }
626
- await persistBundle(deps.backend, sessionId, bundle);
627
- return outcome;
691
+ return settleStep(deps.backend, sessionId, bundle, outcome, onCommit);
628
692
  };
629
693
  // The IMMUTABLE per-round prefix: system + step user message + the step-result
630
694
  // recall block. Re-emitted verbatim every round via strategy.form(); the dynamic
@@ -653,6 +717,13 @@ export class ControllerCoordinatorHandler {
653
717
  if (stepBlock) {
654
718
  staticPrefix.push({ role: 'user', content: stepBlock });
655
719
  }
720
+ ctx.options?.sessionLogger?.logStep('rag_recall', {
721
+ query: recallText,
722
+ extracts: recalledSteps.map((e) => ({
723
+ task: e.metadata.task,
724
+ content: e.content,
725
+ })),
726
+ }, 'rag');
656
727
  // Per-step tool-loop context strategy (record/form). Absent factory →
657
728
  // LegacyAccumulateContextStrategy (byte-identical to the historical growing
658
729
  // transcript).
@@ -744,9 +815,15 @@ export class ControllerCoordinatorHandler {
744
815
  // any-candidate flag. Gathered SEQUENTIALLY (NOT Promise.all): each
745
816
  // relevantExtract is itself bounded-sequential, so the outer sequential loop
746
817
  // keeps at most ONE embed request in flight at a time (rate-limit-safe).
747
- const refs = step.requires && step.requires.length > 0
748
- ? step.requires
749
- : [recallText];
818
+ // ONLY declared dependencies produce evidence. A step with no `requires`
819
+ // is a leaf — it consumes nothing, so there is nothing to attest and the
820
+ // reviewer judges it from the executor's result alone. Falling back to a
821
+ // recall on the step's OWN text (as this once did) asks whether an
822
+ // artifact for the step exists BEFORE the step has produced one: always
823
+ // MISSING, and the reviewer is instructed to fail a missing required
824
+ // reference. That rejected correct work and triggered an endless
825
+ // replan loop — the token balloon in issue #213.
826
+ const refs = step.requires ?? [];
750
827
  const evBound = RECALL_K_STEP * (maxAttempts + 1) +
751
828
  cfg.maxSteps * maxAttempts * (cfg.maxToolCalls ?? 10);
752
829
  const evidence = [];
@@ -814,7 +891,8 @@ export class ControllerCoordinatorHandler {
814
891
  seq: inFlight.seq,
815
892
  };
816
893
  }
817
- return settle('failed');
894
+ logDecision(ctx, 'control-failure', noteFor(reason));
895
+ return settle('failed', noteFor(reason));
818
896
  };
819
897
  // Inner loop handles tool routing / error retries until the executor
820
898
  // produces content for this step (or the step suspends on an external tool).
@@ -855,6 +933,7 @@ export class ControllerCoordinatorHandler {
855
933
  hint: deps.config.subagents.reviewer?.hint,
856
934
  logUsage,
857
935
  maxDigestChars: cfg.maxDigestChars ?? 500,
936
+ callOptions: diagnosticCallOptions(ctx.options),
858
937
  })
859
938
  : {
860
939
  kind: 'outcome',
@@ -878,14 +957,19 @@ export class ControllerCoordinatorHandler {
878
957
  // planner replans, rather than aborting the whole run — the terminal
879
958
  // backstop is maxStepAttempts/maxSteps, not a single unverifiable verdict.
880
959
  bundle.budgets.stepsUsed++;
881
- await writeControlFailure(`reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`);
882
- bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0} ${step.name} failed] reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`;
883
- return settle('failed');
960
+ const unverifiableReason = `reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`;
961
+ await writeControlFailure(unverifiableReason);
962
+ bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0} ${step.name} failed] ${unverifiableReason}`;
963
+ logDecision(ctx, 'reviewer-unverifiable', unverifiableReason, {
964
+ retries: reviewRetries,
965
+ });
966
+ return settle('failed', unverifiableReason);
884
967
  }
885
968
  review = await deps.reviewer.review(step, evidence, res.content, {
886
969
  hint: deps.config.subagents.reviewer?.hint,
887
970
  logUsage,
888
971
  maxDigestChars: cfg.maxDigestChars ?? 500,
972
+ callOptions: diagnosticCallOptions(ctx.options),
889
973
  });
890
974
  }
891
975
  const outcome = review.outcome;
@@ -917,6 +1001,13 @@ export class ControllerCoordinatorHandler {
917
1001
  note: outcome.note,
918
1002
  remainder: outcome.remainder,
919
1003
  });
1004
+ if (mapped === 'failed') {
1005
+ const rejectReason = outcome.note || `reviewer rejected step "${step.name}"`;
1006
+ logDecision(ctx, 'reviewer-reject', rejectReason, {
1007
+ status: outcome.status,
1008
+ });
1009
+ return settle(mapped, rejectReason);
1010
+ }
920
1011
  return settle(mapped);
921
1012
  }
922
1013
  if (res.kind === 'error') {
@@ -932,9 +1023,10 @@ export class ControllerCoordinatorHandler {
932
1023
  // Retries exhausted — feed the error back as the step result so the
933
1024
  // planner can replan on the next iteration.
934
1025
  bundle.budgets.stepsUsed++;
935
- await writeControlFailure(`executor error: ${res.error}`);
1026
+ const executorErrorReason = `executor error: ${res.error}`;
1027
+ await writeControlFailure(executorErrorReason);
936
1028
  bundle.plannerPrivate += `\n[step ${step.name} failed] ${res.error}`;
937
- return settle('failed');
1029
+ return settle('failed', executorErrorReason);
938
1030
  }
939
1031
  // res.kind === 'tool_call' → route the FIRST tool call.
940
1032
  const firstCall = res.toolCalls[0];
@@ -952,7 +1044,7 @@ export class ControllerCoordinatorHandler {
952
1044
  bundle.budgets.stepsUsed++;
953
1045
  await writeControlFailure('empty tool call');
954
1046
  bundle.plannerPrivate += `\n[step ${step.name} failed] empty tool call`;
955
- return settle('failed');
1047
+ return settle('failed', `step "${step.name}" produced an empty tool call`);
956
1048
  }
957
1049
  // Normalize the StreamToolCall (full or delta) into an LlmToolCall inline.
958
1050
  const call = 'arguments' in firstCall &&
@@ -1024,9 +1116,10 @@ export class ControllerCoordinatorHandler {
1024
1116
  continue;
1025
1117
  }
1026
1118
  bundle.budgets.stepsUsed++;
1027
- await writeControlFailure(`requested unavailable tool ${name}`);
1028
- bundle.plannerPrivate += `\n[step ${step.name} failed] requested unavailable tool ${name}`;
1029
- return settle('failed');
1119
+ const unavailableToolReason = `requested unavailable tool ${name}`;
1120
+ await writeControlFailure(unavailableToolReason);
1121
+ bundle.plannerPrivate += `\n[step ${step.name} failed] ${unavailableToolReason}`;
1122
+ return settle('failed', unavailableToolReason);
1030
1123
  }
1031
1124
  // Prospective count gate BEFORE the increment (the before-increment model:
1032
1125
  // the increment happens only after canExecuteTool allows the call).
@@ -1049,10 +1142,18 @@ export class ControllerCoordinatorHandler {
1049
1142
  // decision → rethrow → outer catch swallow → (no response)). A non-McpError
1050
1143
  // is a genuine unexpected error and is re-thrown for the outer handler.
1051
1144
  let result;
1145
+ const mcpCallStartedAt = Date.now();
1052
1146
  try {
1053
1147
  result = await deps.callMcp(name, args, callSignal);
1054
1148
  }
1055
1149
  catch (mcpErr) {
1150
+ ctx.options?.sessionLogger?.logStep('mcp_tool_call', {
1151
+ name,
1152
+ args,
1153
+ result: undefined,
1154
+ isError: true,
1155
+ durationMs: Date.now() - mcpCallStartedAt,
1156
+ }, 'mcp');
1056
1157
  // A step-timeout cancellation aborts the merged signal → the bridge rejects.
1057
1158
  // Map that to a step-timeout control-failure BEFORE the McpError escalate so
1058
1159
  // it is NOT mis-classified as MCP-unavailable (20.4.0 escalate order is
@@ -1067,6 +1168,28 @@ export class ControllerCoordinatorHandler {
1067
1168
  }
1068
1169
  throw mcpErr;
1069
1170
  }
1171
+ ctx.options?.sessionLogger?.logStep('mcp_tool_call', {
1172
+ name,
1173
+ args,
1174
+ result: result.text,
1175
+ // The REAL tool-level isError, threaded through the bridge (#213).
1176
+ // Previously hardcoded false, so a locked-object error looked like a
1177
+ // delivered result and the executor retried it forever.
1178
+ isError: result.isError,
1179
+ durationMs: Date.now() - mcpCallStartedAt,
1180
+ }, 'mcp');
1181
+ // #213 immediate cut: a delivered tool-level error ends the step NOW.
1182
+ // The executor tool-loop does NOT continue (no further tool call, no
1183
+ // reviewer for this step); reuse cutControlFailure so the step settles
1184
+ // 'failed' with the tool's error text and the planner replans / surfaces
1185
+ // it. Read result.isError directly here — BEFORE the round reaches the
1186
+ // context strategy — so no Message/meta replay is needed. The durable
1187
+ // failed step-result + plannerPrivate note (written by cutControlFailure)
1188
+ // ARE the resume carrier; the mcp-result artifact is intentionally not
1189
+ // relied on (the cut may never call strategy.record).
1190
+ if (result.isError) {
1191
+ return cutControlFailure(result.text);
1192
+ }
1070
1193
  // Record this exchange as a coherent assistant→tool ROUND (OpenAI protocol)
1071
1194
  // via the context strategy so the executor LLM continues from its own tool
1072
1195
  // call. The strategy owns the per-round context (Window keeps a bounded
@@ -1094,14 +1217,19 @@ export class ControllerCoordinatorHandler {
1094
1217
  {
1095
1218
  role: 'tool',
1096
1219
  tool_call_id: call.id,
1097
- content: result,
1220
+ content: result.text,
1098
1221
  },
1099
1222
  ],
1100
- // Stable fetch identity (tool+args) for run-scoped recall dedup. The
1101
- // controller has no tool-level error classifier here — an unavailable MCP
1102
- // server aborts BEFORE record; a returned string is a delivered result.
1223
+ // Stable fetch identity (tool+args) for run-scoped recall dedup, plus
1224
+ // the tool-level isError threaded from the bridge (#213). An unavailable
1225
+ // MCP server still aborts BEFORE record; a returned result carries its
1226
+ // real isError so the executor sees a failed call as failed, not as a
1227
+ // delivered success it retries forever.
1103
1228
  meta: [
1104
- { identityKey: externalToolCallId(name, args), isError: false },
1229
+ {
1230
+ identityKey: externalToolCallId(name, args),
1231
+ isError: result.isError,
1232
+ },
1105
1233
  ],
1106
1234
  ordinal: bundle.writeOrdinal,
1107
1235
  roundId: undefined,
@@ -1118,6 +1246,95 @@ export class ControllerCoordinatorHandler {
1118
1246
  budget.dispose();
1119
1247
  }
1120
1248
  }
1249
+ // -- Wait steps (served by the controller itself) -----------------------
1250
+ /** Serve a `type: 'wait'` step: the controller waits itself — no executor,
1251
+ * no reviewer, no MCP, no tokens. A fresh wait clamps the planner duration by
1252
+ * the engine wait bounds, charges `waitMsUsed`, and persists the deadline
1253
+ * (`waitStartedAt` + `appliedWaitMs`) BEFORE sleeping, so a crash mid-sleep
1254
+ * resumes against a FIXED deadline. Returns 'served' when the step settled and
1255
+ * the loop should continue, 'aborted' when the wait was cancelled (deadline
1256
+ * stays persisted, no artifact, no advance — the next resume serves the
1257
+ * remainder), or 'not-a-wait' so the caller falls through to `runStep`. */
1258
+ async serveWaitStep(args) {
1259
+ const { bundle, step, cfg } = args;
1260
+ if (!isWaitStep(step) || !bundle.inFlightStep)
1261
+ return 'not-a-wait';
1262
+ // ONE clock read for BOTH the plan decision and the persisted waitStartedAt,
1263
+ // so the deadline is not skewed by two Date.now() reads (and clock injection
1264
+ // works in tests). deps.now returns ISO → parse to epoch ms.
1265
+ const waitNow = Date.parse(args.nowIso());
1266
+ const plan = planWait({
1267
+ step,
1268
+ inFlight: bundle.inFlightStep,
1269
+ maxWaitMs: cfg.maxWaitMs ?? 600_000,
1270
+ maxTotalWaitMs: cfg.maxTotalWaitMs ?? 1_800_000,
1271
+ waitMsUsed: bundle.budgets.waitMsUsed ?? 0,
1272
+ now: waitNow,
1273
+ });
1274
+ // Torn write (exactly one deadline field persisted) → a control-failure +
1275
+ // replan, handled BEFORE any describeWait/settling path (describeWait must
1276
+ // never render a torn plan). Mirror cutControlFailure's bookkeeping exactly.
1277
+ if (plan.kind === 'torn') {
1278
+ const reason = `wait deadline half-written: missing ${plan.missing}`;
1279
+ bundle.budgets.stepsUsed++;
1280
+ await this.writeWaitArtifact(args, 'failed', `Wait deadline half-written: missing ${plan.missing}. The step never ran.`, reason);
1281
+ bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep.seq} ${step.name} control-failed] ${reason}`;
1282
+ bundle.inFlightStep.controlFailure = {
1283
+ reason: 'control-failure', // generic; NOT widened for waits
1284
+ seq: bundle.inFlightStep.seq,
1285
+ };
1286
+ await settleStep(this.deps.backend, args.sessionId, bundle, 'failed', args.onCommit);
1287
+ return 'served'; // planner replans
1288
+ }
1289
+ if (plan.kind === 'fresh') {
1290
+ bundle.budgets.waitMsUsed =
1291
+ (bundle.budgets.waitMsUsed ?? 0) + plan.applied;
1292
+ bundle.inFlightStep.waitStartedAt = waitNow; // SAME reading as planWait
1293
+ bundle.inFlightStep.appliedWaitMs = plan.applied;
1294
+ // Durable BEFORE the sleep — one extra write, on wait steps only.
1295
+ await persistBundle(this.deps.backend, args.sessionId, bundle);
1296
+ }
1297
+ const toSleep = plan.kind === 'fresh' ? plan.applied : plan.remaining;
1298
+ const waiter = this.deps.waitStrategy ?? new DefaultWaitStrategy();
1299
+ const outcome = await waiter.wait(toSleep, args.ctx.options?.signal);
1300
+ if (outcome === 'aborted')
1301
+ return 'aborted'; // no artifact, no advance
1302
+ const { text, note } = describeWait(plan, step);
1303
+ await this.writeWaitArtifact(args, 'ok', text, note);
1304
+ bundle.budgets.stepsUsed++;
1305
+ recordStepControl(bundle, {
1306
+ seq: bundle.inFlightStep.seq,
1307
+ name: step.name,
1308
+ status: 'ok',
1309
+ note,
1310
+ remainder: '',
1311
+ });
1312
+ await settleStep(this.deps.backend, args.sessionId, bundle, 'advanced', args.onCommit);
1313
+ return 'served';
1314
+ }
1315
+ /** Persist a `wait` step's step-result artifact using the same metadata shape
1316
+ * the executed-step path uses. `content` is NEVER empty (an empty-content 'ok'
1317
+ * risks surfacing as a blank executed step). Owns its own write because
1318
+ * runStep's `writeControlFailure` is local to that method. */
1319
+ async writeWaitArtifact(args, status, text, note) {
1320
+ const { bundle, step } = args;
1321
+ bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
1322
+ await writeArtifact(args.rag, {
1323
+ ...args.meta,
1324
+ artifactType: 'step-result',
1325
+ task: step.name,
1326
+ runId: bundle.runId,
1327
+ seq: bundle.inFlightStep?.seq ?? 0,
1328
+ attempt: bundle.inFlightStep?.attempt ?? 0,
1329
+ status,
1330
+ note,
1331
+ remainder: '',
1332
+ stepId: step.stepId,
1333
+ digest: text.slice(0, this.deps.config.budgets.maxDigestChars ?? 500),
1334
+ writeOrdinal: bundle.writeOrdinal,
1335
+ content: text,
1336
+ }, args.ctx.options);
1337
+ }
1121
1338
  // -- Escalation & surfacing (mirror StepperCoordinatorHandler) ----------
1122
1339
  async escalate(ctx, sessionId, bundle, question, usage) {
1123
1340
  bundle.pending = { kind: 'clarify', question, position: 'loop' };
@@ -1194,6 +1411,7 @@ export class ControllerCoordinatorHandler {
1194
1411
  logUsage,
1195
1412
  log: (m) => dlog(m),
1196
1413
  skillsBlock,
1414
+ callOptions: diagnosticCallOptions(ctx.options),
1197
1415
  });
1198
1416
  // Empty-but-ok finalizer output is a JUDGE failure (spec), not a valid
1199
1417
  // answer → throw so it retries within maxFinalizeRetries.