@mcp-abap-adt/llm-agent-server-libs 20.6.0 → 20.7.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 (70) hide show
  1. package/dist/factories/controller-factory.d.ts +5 -1
  2. package/dist/factories/controller-factory.d.ts.map +1 -1
  3. package/dist/factories/controller-factory.js +1 -0
  4. package/dist/factories/controller-factory.js.map +1 -1
  5. package/dist/factories/cyclic-factory.d.ts.map +1 -1
  6. package/dist/factories/cyclic-factory.js +4 -1
  7. package/dist/factories/cyclic-factory.js.map +1 -1
  8. package/dist/generated/version.d.ts +1 -1
  9. package/dist/generated/version.js +1 -1
  10. package/dist/mcp/compose-auxiliary.d.ts +6 -5
  11. package/dist/mcp/compose-auxiliary.d.ts.map +1 -1
  12. package/dist/mcp/compose-auxiliary.js +10 -6
  13. package/dist/mcp/compose-auxiliary.js.map +1 -1
  14. package/dist/pipelines/controller.d.ts.map +1 -1
  15. package/dist/pipelines/controller.js +13 -0
  16. package/dist/pipelines/controller.js.map +1 -1
  17. package/dist/pipelines/linear.js +1 -1
  18. package/dist/pipelines/linear.js.map +1 -1
  19. package/dist/pipelines/stepper.js +1 -1
  20. package/dist/pipelines/stepper.js.map +1 -1
  21. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +23 -3
  22. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
  23. package/dist/smart-agent/controller/controller-coordinator-handler.js +246 -34
  24. package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
  25. package/dist/smart-agent/controller/finalizer.d.ts +5 -1
  26. package/dist/smart-agent/controller/finalizer.d.ts.map +1 -1
  27. package/dist/smart-agent/controller/finalizer.js +1 -1
  28. package/dist/smart-agent/controller/finalizer.js.map +1 -1
  29. package/dist/smart-agent/controller/parser.d.ts.map +1 -1
  30. package/dist/smart-agent/controller/parser.js +2 -0
  31. package/dist/smart-agent/controller/parser.js.map +1 -1
  32. package/dist/smart-agent/controller/planner.d.ts +3 -2
  33. package/dist/smart-agent/controller/planner.d.ts.map +1 -1
  34. package/dist/smart-agent/controller/planner.js +64 -9
  35. package/dist/smart-agent/controller/planner.js.map +1 -1
  36. package/dist/smart-agent/controller/reviewer.d.ts +5 -1
  37. package/dist/smart-agent/controller/reviewer.d.ts.map +1 -1
  38. package/dist/smart-agent/controller/reviewer.js +1 -1
  39. package/dist/smart-agent/controller/reviewer.js.map +1 -1
  40. package/dist/smart-agent/controller/session-bundle.d.ts +10 -0
  41. package/dist/smart-agent/controller/session-bundle.d.ts.map +1 -1
  42. package/dist/smart-agent/controller/session-bundle.js +27 -0
  43. package/dist/smart-agent/controller/session-bundle.js.map +1 -1
  44. package/dist/smart-agent/controller/subagent-client.d.ts +9 -0
  45. package/dist/smart-agent/controller/subagent-client.d.ts.map +1 -1
  46. package/dist/smart-agent/controller/subagent-client.js +34 -1
  47. package/dist/smart-agent/controller/subagent-client.js.map +1 -1
  48. package/dist/smart-agent/controller/target-state.d.ts +1 -1
  49. package/dist/smart-agent/controller/target-state.d.ts.map +1 -1
  50. package/dist/smart-agent/controller/target-state.js +2 -1
  51. package/dist/smart-agent/controller/target-state.js.map +1 -1
  52. package/dist/smart-agent/controller/types.d.ts +25 -1
  53. package/dist/smart-agent/controller/types.d.ts.map +1 -1
  54. package/dist/smart-agent/controller/types.js.map +1 -1
  55. package/dist/smart-agent/controller/wait-step.d.ts +40 -0
  56. package/dist/smart-agent/controller/wait-step.d.ts.map +1 -0
  57. package/dist/smart-agent/controller/wait-step.js +76 -0
  58. package/dist/smart-agent/controller/wait-step.js.map +1 -0
  59. package/dist/smart-agent/http/chat-route-handler.d.ts.map +1 -1
  60. package/dist/smart-agent/http/chat-route-handler.js +3 -1
  61. package/dist/smart-agent/http/chat-route-handler.js.map +1 -1
  62. package/dist/smart-agent/http/debug-trace-sink.d.ts +9 -0
  63. package/dist/smart-agent/http/debug-trace-sink.d.ts.map +1 -0
  64. package/dist/smart-agent/http/debug-trace-sink.js +17 -0
  65. package/dist/smart-agent/http/debug-trace-sink.js.map +1 -0
  66. package/dist/smart-agent/smart-server.d.ts +8 -3
  67. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  68. package/dist/smart-agent/smart-server.js +12 -5
  69. package/dist/smart-agent/smart-server.js.map +1 -1
  70. 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).
@@ -814,7 +885,8 @@ export class ControllerCoordinatorHandler {
814
885
  seq: inFlight.seq,
815
886
  };
816
887
  }
817
- return settle('failed');
888
+ logDecision(ctx, 'control-failure', noteFor(reason));
889
+ return settle('failed', noteFor(reason));
818
890
  };
819
891
  // Inner loop handles tool routing / error retries until the executor
820
892
  // produces content for this step (or the step suspends on an external tool).
@@ -855,6 +927,7 @@ export class ControllerCoordinatorHandler {
855
927
  hint: deps.config.subagents.reviewer?.hint,
856
928
  logUsage,
857
929
  maxDigestChars: cfg.maxDigestChars ?? 500,
930
+ callOptions: diagnosticCallOptions(ctx.options),
858
931
  })
859
932
  : {
860
933
  kind: 'outcome',
@@ -878,14 +951,19 @@ export class ControllerCoordinatorHandler {
878
951
  // planner replans, rather than aborting the whole run — the terminal
879
952
  // backstop is maxStepAttempts/maxSteps, not a single unverifiable verdict.
880
953
  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');
954
+ const unverifiableReason = `reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`;
955
+ await writeControlFailure(unverifiableReason);
956
+ bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0} ${step.name} failed] ${unverifiableReason}`;
957
+ logDecision(ctx, 'reviewer-unverifiable', unverifiableReason, {
958
+ retries: reviewRetries,
959
+ });
960
+ return settle('failed', unverifiableReason);
884
961
  }
885
962
  review = await deps.reviewer.review(step, evidence, res.content, {
886
963
  hint: deps.config.subagents.reviewer?.hint,
887
964
  logUsage,
888
965
  maxDigestChars: cfg.maxDigestChars ?? 500,
966
+ callOptions: diagnosticCallOptions(ctx.options),
889
967
  });
890
968
  }
891
969
  const outcome = review.outcome;
@@ -917,6 +995,13 @@ export class ControllerCoordinatorHandler {
917
995
  note: outcome.note,
918
996
  remainder: outcome.remainder,
919
997
  });
998
+ if (mapped === 'failed') {
999
+ const rejectReason = outcome.note || `reviewer rejected step "${step.name}"`;
1000
+ logDecision(ctx, 'reviewer-reject', rejectReason, {
1001
+ status: outcome.status,
1002
+ });
1003
+ return settle(mapped, rejectReason);
1004
+ }
920
1005
  return settle(mapped);
921
1006
  }
922
1007
  if (res.kind === 'error') {
@@ -932,9 +1017,10 @@ export class ControllerCoordinatorHandler {
932
1017
  // Retries exhausted — feed the error back as the step result so the
933
1018
  // planner can replan on the next iteration.
934
1019
  bundle.budgets.stepsUsed++;
935
- await writeControlFailure(`executor error: ${res.error}`);
1020
+ const executorErrorReason = `executor error: ${res.error}`;
1021
+ await writeControlFailure(executorErrorReason);
936
1022
  bundle.plannerPrivate += `\n[step ${step.name} failed] ${res.error}`;
937
- return settle('failed');
1023
+ return settle('failed', executorErrorReason);
938
1024
  }
939
1025
  // res.kind === 'tool_call' → route the FIRST tool call.
940
1026
  const firstCall = res.toolCalls[0];
@@ -952,7 +1038,7 @@ export class ControllerCoordinatorHandler {
952
1038
  bundle.budgets.stepsUsed++;
953
1039
  await writeControlFailure('empty tool call');
954
1040
  bundle.plannerPrivate += `\n[step ${step.name} failed] empty tool call`;
955
- return settle('failed');
1041
+ return settle('failed', `step "${step.name}" produced an empty tool call`);
956
1042
  }
957
1043
  // Normalize the StreamToolCall (full or delta) into an LlmToolCall inline.
958
1044
  const call = 'arguments' in firstCall &&
@@ -1024,9 +1110,10 @@ export class ControllerCoordinatorHandler {
1024
1110
  continue;
1025
1111
  }
1026
1112
  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');
1113
+ const unavailableToolReason = `requested unavailable tool ${name}`;
1114
+ await writeControlFailure(unavailableToolReason);
1115
+ bundle.plannerPrivate += `\n[step ${step.name} failed] ${unavailableToolReason}`;
1116
+ return settle('failed', unavailableToolReason);
1030
1117
  }
1031
1118
  // Prospective count gate BEFORE the increment (the before-increment model:
1032
1119
  // the increment happens only after canExecuteTool allows the call).
@@ -1049,10 +1136,18 @@ export class ControllerCoordinatorHandler {
1049
1136
  // decision → rethrow → outer catch swallow → (no response)). A non-McpError
1050
1137
  // is a genuine unexpected error and is re-thrown for the outer handler.
1051
1138
  let result;
1139
+ const mcpCallStartedAt = Date.now();
1052
1140
  try {
1053
1141
  result = await deps.callMcp(name, args, callSignal);
1054
1142
  }
1055
1143
  catch (mcpErr) {
1144
+ ctx.options?.sessionLogger?.logStep('mcp_tool_call', {
1145
+ name,
1146
+ args,
1147
+ result: undefined,
1148
+ isError: true,
1149
+ durationMs: Date.now() - mcpCallStartedAt,
1150
+ }, 'mcp');
1056
1151
  // A step-timeout cancellation aborts the merged signal → the bridge rejects.
1057
1152
  // Map that to a step-timeout control-failure BEFORE the McpError escalate so
1058
1153
  // it is NOT mis-classified as MCP-unavailable (20.4.0 escalate order is
@@ -1067,6 +1162,28 @@ export class ControllerCoordinatorHandler {
1067
1162
  }
1068
1163
  throw mcpErr;
1069
1164
  }
1165
+ ctx.options?.sessionLogger?.logStep('mcp_tool_call', {
1166
+ name,
1167
+ args,
1168
+ result: result.text,
1169
+ // The REAL tool-level isError, threaded through the bridge (#213).
1170
+ // Previously hardcoded false, so a locked-object error looked like a
1171
+ // delivered result and the executor retried it forever.
1172
+ isError: result.isError,
1173
+ durationMs: Date.now() - mcpCallStartedAt,
1174
+ }, 'mcp');
1175
+ // #213 immediate cut: a delivered tool-level error ends the step NOW.
1176
+ // The executor tool-loop does NOT continue (no further tool call, no
1177
+ // reviewer for this step); reuse cutControlFailure so the step settles
1178
+ // 'failed' with the tool's error text and the planner replans / surfaces
1179
+ // it. Read result.isError directly here — BEFORE the round reaches the
1180
+ // context strategy — so no Message/meta replay is needed. The durable
1181
+ // failed step-result + plannerPrivate note (written by cutControlFailure)
1182
+ // ARE the resume carrier; the mcp-result artifact is intentionally not
1183
+ // relied on (the cut may never call strategy.record).
1184
+ if (result.isError) {
1185
+ return cutControlFailure(result.text);
1186
+ }
1070
1187
  // Record this exchange as a coherent assistant→tool ROUND (OpenAI protocol)
1071
1188
  // via the context strategy so the executor LLM continues from its own tool
1072
1189
  // call. The strategy owns the per-round context (Window keeps a bounded
@@ -1094,14 +1211,19 @@ export class ControllerCoordinatorHandler {
1094
1211
  {
1095
1212
  role: 'tool',
1096
1213
  tool_call_id: call.id,
1097
- content: result,
1214
+ content: result.text,
1098
1215
  },
1099
1216
  ],
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.
1217
+ // Stable fetch identity (tool+args) for run-scoped recall dedup, plus
1218
+ // the tool-level isError threaded from the bridge (#213). An unavailable
1219
+ // MCP server still aborts BEFORE record; a returned result carries its
1220
+ // real isError so the executor sees a failed call as failed, not as a
1221
+ // delivered success it retries forever.
1103
1222
  meta: [
1104
- { identityKey: externalToolCallId(name, args), isError: false },
1223
+ {
1224
+ identityKey: externalToolCallId(name, args),
1225
+ isError: result.isError,
1226
+ },
1105
1227
  ],
1106
1228
  ordinal: bundle.writeOrdinal,
1107
1229
  roundId: undefined,
@@ -1118,6 +1240,95 @@ export class ControllerCoordinatorHandler {
1118
1240
  budget.dispose();
1119
1241
  }
1120
1242
  }
1243
+ // -- Wait steps (served by the controller itself) -----------------------
1244
+ /** Serve a `type: 'wait'` step: the controller waits itself — no executor,
1245
+ * no reviewer, no MCP, no tokens. A fresh wait clamps the planner duration by
1246
+ * the engine wait bounds, charges `waitMsUsed`, and persists the deadline
1247
+ * (`waitStartedAt` + `appliedWaitMs`) BEFORE sleeping, so a crash mid-sleep
1248
+ * resumes against a FIXED deadline. Returns 'served' when the step settled and
1249
+ * the loop should continue, 'aborted' when the wait was cancelled (deadline
1250
+ * stays persisted, no artifact, no advance — the next resume serves the
1251
+ * remainder), or 'not-a-wait' so the caller falls through to `runStep`. */
1252
+ async serveWaitStep(args) {
1253
+ const { bundle, step, cfg } = args;
1254
+ if (!isWaitStep(step) || !bundle.inFlightStep)
1255
+ return 'not-a-wait';
1256
+ // ONE clock read for BOTH the plan decision and the persisted waitStartedAt,
1257
+ // so the deadline is not skewed by two Date.now() reads (and clock injection
1258
+ // works in tests). deps.now returns ISO → parse to epoch ms.
1259
+ const waitNow = Date.parse(args.nowIso());
1260
+ const plan = planWait({
1261
+ step,
1262
+ inFlight: bundle.inFlightStep,
1263
+ maxWaitMs: cfg.maxWaitMs ?? 600_000,
1264
+ maxTotalWaitMs: cfg.maxTotalWaitMs ?? 1_800_000,
1265
+ waitMsUsed: bundle.budgets.waitMsUsed ?? 0,
1266
+ now: waitNow,
1267
+ });
1268
+ // Torn write (exactly one deadline field persisted) → a control-failure +
1269
+ // replan, handled BEFORE any describeWait/settling path (describeWait must
1270
+ // never render a torn plan). Mirror cutControlFailure's bookkeeping exactly.
1271
+ if (plan.kind === 'torn') {
1272
+ const reason = `wait deadline half-written: missing ${plan.missing}`;
1273
+ bundle.budgets.stepsUsed++;
1274
+ await this.writeWaitArtifact(args, 'failed', `Wait deadline half-written: missing ${plan.missing}. The step never ran.`, reason);
1275
+ bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep.seq} ${step.name} control-failed] ${reason}`;
1276
+ bundle.inFlightStep.controlFailure = {
1277
+ reason: 'control-failure', // generic; NOT widened for waits
1278
+ seq: bundle.inFlightStep.seq,
1279
+ };
1280
+ await settleStep(this.deps.backend, args.sessionId, bundle, 'failed', args.onCommit);
1281
+ return 'served'; // planner replans
1282
+ }
1283
+ if (plan.kind === 'fresh') {
1284
+ bundle.budgets.waitMsUsed =
1285
+ (bundle.budgets.waitMsUsed ?? 0) + plan.applied;
1286
+ bundle.inFlightStep.waitStartedAt = waitNow; // SAME reading as planWait
1287
+ bundle.inFlightStep.appliedWaitMs = plan.applied;
1288
+ // Durable BEFORE the sleep — one extra write, on wait steps only.
1289
+ await persistBundle(this.deps.backend, args.sessionId, bundle);
1290
+ }
1291
+ const toSleep = plan.kind === 'fresh' ? plan.applied : plan.remaining;
1292
+ const waiter = this.deps.waitStrategy ?? new DefaultWaitStrategy();
1293
+ const outcome = await waiter.wait(toSleep, args.ctx.options?.signal);
1294
+ if (outcome === 'aborted')
1295
+ return 'aborted'; // no artifact, no advance
1296
+ const { text, note } = describeWait(plan, step);
1297
+ await this.writeWaitArtifact(args, 'ok', text, note);
1298
+ bundle.budgets.stepsUsed++;
1299
+ recordStepControl(bundle, {
1300
+ seq: bundle.inFlightStep.seq,
1301
+ name: step.name,
1302
+ status: 'ok',
1303
+ note,
1304
+ remainder: '',
1305
+ });
1306
+ await settleStep(this.deps.backend, args.sessionId, bundle, 'advanced', args.onCommit);
1307
+ return 'served';
1308
+ }
1309
+ /** Persist a `wait` step's step-result artifact using the same metadata shape
1310
+ * the executed-step path uses. `content` is NEVER empty (an empty-content 'ok'
1311
+ * risks surfacing as a blank executed step). Owns its own write because
1312
+ * runStep's `writeControlFailure` is local to that method. */
1313
+ async writeWaitArtifact(args, status, text, note) {
1314
+ const { bundle, step } = args;
1315
+ bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
1316
+ await writeArtifact(args.rag, {
1317
+ ...args.meta,
1318
+ artifactType: 'step-result',
1319
+ task: step.name,
1320
+ runId: bundle.runId,
1321
+ seq: bundle.inFlightStep?.seq ?? 0,
1322
+ attempt: bundle.inFlightStep?.attempt ?? 0,
1323
+ status,
1324
+ note,
1325
+ remainder: '',
1326
+ stepId: step.stepId,
1327
+ digest: text.slice(0, this.deps.config.budgets.maxDigestChars ?? 500),
1328
+ writeOrdinal: bundle.writeOrdinal,
1329
+ content: text,
1330
+ }, args.ctx.options);
1331
+ }
1121
1332
  // -- Escalation & surfacing (mirror StepperCoordinatorHandler) ----------
1122
1333
  async escalate(ctx, sessionId, bundle, question, usage) {
1123
1334
  bundle.pending = { kind: 'clarify', question, position: 'loop' };
@@ -1194,6 +1405,7 @@ export class ControllerCoordinatorHandler {
1194
1405
  logUsage,
1195
1406
  log: (m) => dlog(m),
1196
1407
  skillsBlock,
1408
+ callOptions: diagnosticCallOptions(ctx.options),
1197
1409
  });
1198
1410
  // Empty-but-ok finalizer output is a JUDGE failure (spec), not a valid
1199
1411
  // answer → throw so it retries within maxFinalizeRetries.