@mcp-abap-adt/llm-agent-server-libs 20.4.0 → 20.6.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 (48) hide show
  1. package/dist/factories/controller-factory.d.ts +18 -1
  2. package/dist/factories/controller-factory.d.ts.map +1 -1
  3. package/dist/factories/controller-factory.js +12 -1
  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/mcp/compose-auxiliary.d.ts +35 -0
  8. package/dist/mcp/compose-auxiliary.d.ts.map +1 -0
  9. package/dist/mcp/compose-auxiliary.js +64 -0
  10. package/dist/mcp/compose-auxiliary.js.map +1 -0
  11. package/dist/pipelines/controller.d.ts.map +1 -1
  12. package/dist/pipelines/controller.js +68 -2
  13. package/dist/pipelines/controller.js.map +1 -1
  14. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +19 -3
  15. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
  16. package/dist/smart-agent/controller/controller-coordinator-handler.js +516 -361
  17. package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
  18. package/dist/smart-agent/controller/default-step-execution-control.d.ts +7 -0
  19. package/dist/smart-agent/controller/default-step-execution-control.d.ts.map +1 -0
  20. package/dist/smart-agent/controller/default-step-execution-control.js +36 -0
  21. package/dist/smart-agent/controller/default-step-execution-control.js.map +1 -0
  22. package/dist/smart-agent/controller/noop-run-execution-control.d.ts +6 -0
  23. package/dist/smart-agent/controller/noop-run-execution-control.d.ts.map +1 -0
  24. package/dist/smart-agent/controller/noop-run-execution-control.js +14 -0
  25. package/dist/smart-agent/controller/noop-run-execution-control.js.map +1 -0
  26. package/dist/smart-agent/controller/subagent-client.d.ts +2 -2
  27. package/dist/smart-agent/controller/subagent-client.d.ts.map +1 -1
  28. package/dist/smart-agent/controller/subagent-client.js +2 -2
  29. package/dist/smart-agent/controller/subagent-client.js.map +1 -1
  30. package/dist/smart-agent/controller/types.d.ts +16 -4
  31. package/dist/smart-agent/controller/types.d.ts.map +1 -1
  32. package/dist/smart-agent/controller/types.js.map +1 -1
  33. package/dist/smart-agent/mcp/build-session-mcp-clients.d.ts +44 -0
  34. package/dist/smart-agent/mcp/build-session-mcp-clients.d.ts.map +1 -0
  35. package/dist/smart-agent/mcp/build-session-mcp-clients.js +75 -0
  36. package/dist/smart-agent/mcp/build-session-mcp-clients.js.map +1 -0
  37. package/dist/smart-agent/resolve-config-sections.d.ts.map +1 -1
  38. package/dist/smart-agent/resolve-config-sections.js +3 -0
  39. package/dist/smart-agent/resolve-config-sections.js.map +1 -1
  40. package/dist/smart-agent/session-lifecycle/index.d.ts +16 -0
  41. package/dist/smart-agent/session-lifecycle/index.d.ts.map +1 -1
  42. package/dist/smart-agent/session-lifecycle/index.js +17 -2
  43. package/dist/smart-agent/session-lifecycle/index.js.map +1 -1
  44. package/dist/smart-agent/smart-server.d.ts +22 -1
  45. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  46. package/dist/smart-agent/smart-server.js +71 -6
  47. package/dist/smart-agent/smart-server.js.map +1 -1
  48. package/package.json +7 -7
@@ -1,12 +1,13 @@
1
1
  import { externalToolCallId, McpError, } from '@mcp-abap-adt/llm-agent';
2
- import { summaryToUsage, } from '@mcp-abap-adt/llm-agent-libs';
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';
5
+ import { DefaultStepExecutionControl } from './default-step-execution-control.js';
5
6
  import { writeArtifact } from './memorizer.js';
6
7
  import { resolveByPrecedence } from './outcome.js';
7
8
  import { makeControllerPlanner } from './planner.js';
8
9
  import { appendHint } from './prompts.js';
9
- import { buildRecallBlock, collectApproved, RECALL_ARTIFACT_TYPES, RECALL_EVIDENCE_CHARS, RECALL_K_MCP, RECALL_K_STEP, RECALL_MAX_CHARS_MCP, RECALL_MAX_CHARS_STEP, relevantExtract, runScopedRecall, } from './recall.js';
10
+ import { buildRecallBlock, collectApproved, RECALL_ARTIFACT_TYPES, RECALL_EVIDENCE_CHARS, RECALL_K_STEP, RECALL_MAX_CHARS_STEP, relevantExtract, runScopedRecall, } from './recall.js';
10
11
  import { classifyRequest, readTerminal, writeTerminal } from './run-scope.js';
11
12
  import { hydrateBundle, persistBundle, resetRun } from './session-bundle.js';
12
13
  import { establishTargetState } from './target-state.js';
@@ -563,183 +564,222 @@ export class ControllerCoordinatorHandler {
563
564
  const cfg = deps.config.budgets;
564
565
  const maxToolCalls = cfg.maxToolCalls ?? 10;
565
566
  const inFlight = bundle.inFlightStep; // set by the caller (block A or B)
566
- // Persist the step outcome ATOMICALLY: record lastOutcome (durable, so a
567
- // resume after a failed step replans instead of repeating it) AND advance the
568
- // planner cursor (onCommit) in the SAME persistBundle that records the step
569
- // result — never in a separate write, so a crash cannot replay a completed step.
570
- const settle = async (outcome) => {
571
- bundle.lastOutcome = outcome;
572
- onCommit?.(outcome);
573
- if (outcome === 'advanced' || outcome === 'partial') {
574
- bundle.nextSeq = (bundle.nextSeq ?? 0) + 1;
575
- bundle.inFlightStep = undefined;
576
- bundle.runPhase = 'planning';
567
+ // Per-step execution control: a wall-clock time budget (perStepTimeoutMs) +
568
+ // the prospective maxToolCalls gate, consumer-swappable via deps. Its signal
569
+ // is merged with the caller's cancel signal (below) and fed into both the
570
+ // executor.send and callMcp so a non-converging / hung step is CUT rather than
571
+ // livelocking. Absent perStepTimeoutMs → time never fires → count-only bound.
572
+ const stepControl = deps.stepExecutionControl ?? new DefaultStepExecutionControl();
573
+ const budget = stepControl.beginStep({
574
+ stepName: step.name,
575
+ seq: inFlight?.seq ?? 0,
576
+ attempt: inFlight?.attempt ?? 0,
577
+ budgets: { maxToolCalls, perStepTimeoutMs: cfg.perStepTimeoutMs },
578
+ });
579
+ // The budget owns a wall-clock timer that is NOT unref'd — it MUST be disposed
580
+ // on every step exit. Open the try IMMEDIATELY after beginStep so the
581
+ // budget-dependent pre-loop (recall / evidence embed / selectTools /
582
+ // strategy.record) is inside the SAME try…finally; a throw from any of those
583
+ // documented-fallible awaits (e.g. an embedder 429) would otherwise leak the
584
+ // timer and later fire controller.abort() on an orphaned signal.
585
+ try {
586
+ const stepStartedAt = Date.now();
587
+ // Merge the caller's request/cancel signal with the step budget: an inner call
588
+ // is cancelled by EITHER. The step-timeout DISCRIMINATOR remains
589
+ // budget.signal.aborted SPECIFICALLY, so a pure caller-cancel is NOT mis-mapped
590
+ // to a step-timeout control-failure.
591
+ const callSignal = ctx.options?.signal
592
+ ? AbortSignal.any([ctx.options.signal, budget.signal])
593
+ : budget.signal;
594
+ // Typed reason code (StepControlDecision.reason) → human note. Preserves
595
+ // today's exact wording so existing suites stay byte-identical.
596
+ const noteFor = (r) => r === 'maxToolCalls'
597
+ ? 'tool-call budget exhausted (maxToolCalls)'
598
+ : r === 'step-timeout'
599
+ ? 'step time budget exhausted (step-timeout)'
600
+ : r;
601
+ let roundNo = 0;
602
+ const state = () => ({
603
+ round: roundNo,
604
+ toolCallCount: inFlight?.toolCallCount ?? 0,
605
+ elapsedMs: Date.now() - stepStartedAt,
606
+ });
607
+ // Persist the step outcome ATOMICALLY: record lastOutcome (durable, so a
608
+ // resume after a failed step replans instead of repeating it) AND advance the
609
+ // planner cursor (onCommit) in the SAME persistBundle that records the step
610
+ // 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';
618
+ }
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;
628
+ };
629
+ // The IMMUTABLE per-round prefix: system + step user message + the step-result
630
+ // recall block. Re-emitted verbatim every round via strategy.form(); the dynamic
631
+ // tool rounds are owned by the injected context strategy, NOT accumulated here.
632
+ const staticPrefix = [
633
+ {
634
+ role: 'system',
635
+ content: appendHint(EXECUTOR_SYSTEM, deps.config.subagents.executor?.hint),
636
+ },
637
+ {
638
+ role: 'user',
639
+ content: `Goal: ${bundle.goal}\nStep: ${step.name}\nInstructions: ${step.instructions}`,
640
+ },
641
+ ];
642
+ // Episodic recall: pull prior STEP-RESULT artifacts relevant to this step from
643
+ // session-memory and inject them as static context. The session-memory rag shares
644
+ // the bundle backend, so restrict to 'step-result' (excludes the
645
+ // 'controller-bundle' infrastructure record). Bounded by k and length. The
646
+ // per-round MCP context is now the context strategy's job (its form() supplies
647
+ // the mcp-result rounds — the Window keeps its own buffer, RagRecall recalls),
648
+ // so it is NOT part of the handler-built static prefix.
649
+ const recallText = step.instructions || step.name;
650
+ const maxAttempts = cfg.maxStepAttempts ?? 5;
651
+ const recalledSteps = await runScopedRecall(rag, recallText, RECALL_K_STEP, bundle.runId, RECALL_K_STEP * (maxAttempts + 1), ['step-result'], ctx.options);
652
+ const stepBlock = buildRecallBlock(recalledSteps, RECALL_MAX_CHARS_STEP);
653
+ if (stepBlock) {
654
+ staticPrefix.push({ role: 'user', content: stepBlock });
655
+ }
656
+ // Per-step tool-loop context strategy (record/form). Absent factory →
657
+ // LegacyAccumulateContextStrategy (byte-identical to the historical growing
658
+ // transcript).
659
+ const makeStrategy = () => (deps.toolLoopContextStrategyFactory ??
660
+ (() => new LegacyAccumulateContextStrategy()))({
661
+ run: { rag, runId: bundle.runId, meta, stepName: step.name },
662
+ });
663
+ // Resume / migration selection (Task 12). A step that suspended under the new
664
+ // design carries a serialized `contextStrategyState` → RESTORE it so the
665
+ // pre-suspend rounds (including any INTERNAL tool rounds before an external
666
+ // suspend) come back exactly as the executor last saw them. A PRE-RELEASE
667
+ // in-flight step carries only a raw `transcript` (no snapshot) → migrate it
668
+ // verbatim via the migration-only LegacyTranscriptContextStrategy (one release).
669
+ // Otherwise a fresh step.
670
+ let strategy;
671
+ if (inFlight?.contextStrategyState !== undefined) {
672
+ const state = inFlight.contextStrategyState;
673
+ // A migrated step persisted a LegacyTranscript snapshot ({rawMessages,
674
+ // newRounds}). Restore it through the SAME strategy type so its raw history
675
+ // + post-migration rounds survive a SECOND resume; a normal snapshot
676
+ // restores via the injected/default strategy. Discriminate on shape.
677
+ strategy =
678
+ state.rawMessages !== undefined
679
+ ? new LegacyTranscriptContextStrategy({ rawMessages: [] })
680
+ : makeStrategy();
681
+ strategy.restore(state);
682
+ }
683
+ else if (inFlight?.transcript?.length) {
684
+ strategy = new LegacyTranscriptContextStrategy({
685
+ rawMessages: inFlight.transcript,
686
+ });
687
+ // Adopted verbatim (rawMessages is copied) → clear the durable transcript so
688
+ // it only ever carries UN-recorded external-continuation pairs from here on.
689
+ // The next suspend snapshots this strategy → subsequent resumes take the
690
+ // restore branch above, so the raw history is never re-injected (no double).
691
+ inFlight.transcript.length = 0;
577
692
  }
578
693
  else {
579
- // 'failed' — keep the same seq, mark awaiting-replan in the SAME persist so
580
- // recovery routes by durable phase.
581
- if (bundle.inFlightStep)
582
- bundle.inFlightStep.phase = 'awaiting-replan';
583
- bundle.runPhase = 'executing';
694
+ strategy = makeStrategy(); // fresh step
584
695
  }
585
- await persistBundle(deps.backend, sessionId, bundle);
586
- return outcome;
587
- };
588
- const messages = [
589
- {
590
- role: 'system',
591
- content: appendHint(EXECUTOR_SYSTEM, deps.config.subagents.executor?.hint),
592
- },
593
- {
594
- role: 'user',
595
- content: `Goal: ${bundle.goal}\nStep: ${step.name}\nInstructions: ${step.instructions}`,
596
- },
597
- ];
598
- // Episodic recall: pull prior artifacts relevant to this step from
599
- // session-memory and inject them as context. The session-memory rag shares
600
- // the bundle backend, so restrict to artifact types (excludes the
601
- // 'controller-bundle' infrastructure record). Bounded by k and length.
602
- const recallText = step.instructions || step.name;
603
- const maxAttempts = cfg.maxStepAttempts ?? 5;
604
- const maxTool = cfg.maxToolCalls ?? 10;
605
- // Per-kind run-scoped recall with GUARANTEED over-fetch bounds: step-result
606
- // retries are bounded by maxStepAttempts (k×(maxStepAttempts+1)); mcp-results
607
- // are NOT deduped on re-fetch here and toolCallCount RESETS per attempt, so one
608
- // run can emit up to maxSteps × maxStepAttempts × maxToolCalls of them — over-
609
- // fetch that full run bound so every distinct identityKey is seen before the cap.
610
- const mcpBound = cfg.maxSteps * maxAttempts * maxTool;
611
- const recalledSteps = await runScopedRecall(rag, recallText, RECALL_K_STEP, bundle.runId, RECALL_K_STEP * (maxAttempts + 1), ['step-result'], ctx.options);
612
- const recalledMcp = await runScopedRecall(rag, recallText, RECALL_K_MCP, bundle.runId, mcpBound, ['mcp-result'], ctx.options);
613
- // SEPARATE character budgets per kind: a single huge step-result cannot consume
614
- // the whole budget and starve the MCP context (and vice-versa).
615
- const stepBlock = buildRecallBlock(recalledSteps, RECALL_MAX_CHARS_STEP);
616
- const mcpBlock = buildRecallBlock(recalledMcp, RECALL_MAX_CHARS_MCP);
617
- const recallBlock = [stepBlock, mcpBlock].filter(Boolean).join('\n\n');
618
- if (recallBlock) {
619
- messages.push({ role: 'user', content: recallBlock });
620
- }
621
- // Durable transcript = static prefix (system/user/recall) + the dynamic
622
- // executor/tool turns. On a resume/continuation the dynamic tail is rebuilt
623
- // from inFlightStep.transcript so the executor sees the FULL exchange it had
624
- // (prior tool rounds + the injected external result), not just a fragment.
625
- const staticLen = messages.length;
626
- if (inFlight && inFlight.transcript.length > 0) {
627
- messages.push(...inFlight.transcript);
628
- }
629
- // Persist the dynamic tail after every executor/tool exchange so a suspend or
630
- // crash never rebuilds with a shorter conversation than the executor saw.
631
- const syncTranscript = async () => {
696
+ // Durable, bounded control-message tail (retries only). Aliased IN PLACE so
697
+ // push/prune mutate the persisted field; a legacy call with no inFlightStep gets
698
+ // an ephemeral local (no durable tail to persist).
699
+ let controlTail;
632
700
  if (inFlight) {
633
- inFlight.transcript = messages.slice(staticLen);
634
- await persistBundle(deps.backend, sessionId, bundle);
701
+ inFlight.controlTail = inFlight.controlTail ?? [];
702
+ controlTail = inFlight.controlTail;
635
703
  }
636
- };
637
- // Per-reference evidence: one recall per requires[] reference. A non-empty
638
- // top-K does NOT prove the dependency is present — semantic recall returns the
639
- // NEAREST artifact even at low relevance — so we hand the reviewer the TOP
640
- // artifact's relevant fragment (Evidence.topArtifact) and let IT (the judging
641
- // role) decide whether the ref is actually satisfied. `hit` is a coarse
642
- // any-candidate flag. Gathered SEQUENTIALLY (NOT Promise.all): each
643
- // relevantExtract is itself bounded-sequential, so the outer sequential loop
644
- // keeps at most ONE embed request in flight at a time (rate-limit-safe).
645
- const refs = step.requires && step.requires.length > 0 ? step.requires : [recallText];
646
- const evBound = RECALL_K_STEP * (maxAttempts + 1) +
647
- cfg.maxSteps * maxAttempts * (cfg.maxToolCalls ?? 10);
648
- const evidence = [];
649
- for (const ref of refs) {
650
- const hits = await runScopedRecall(rag, ref, 1, bundle.runId, evBound, RECALL_ARTIFACT_TYPES, ctx.options);
651
- const topArtifact = hits[0]
652
- ? await relevantExtract(hits[0].content, ref, RECALL_EVIDENCE_CHARS,
653
- // biome-ignore lint/style/noNonNullAssertion: distance strategies require an embedder; the factory enforces it (Task 17).
654
- deps.embedder, ctx.options)
655
- : undefined;
656
- evidence.push({ ref, hit: hits.length > 0, topArtifact });
657
- }
658
- // Tools offered to the executor = the INTERNAL (MCP) tools semantically
659
- // relevant to THIS step (top-K from toolsRag) PLUS the per-request external
660
- // (consumer-supplied) tools. The executor decides which to call; internal
661
- // calls route through `callMcp`, external calls round-trip via `isExternalTool`.
662
- const relevant = await deps.selectTools(step.instructions || step.name, TOOL_SELECT_K, ctx.options);
663
- const offeredTools = [...relevant, ...(ctx.externalTools ?? [])];
664
- // The executor may ONLY call a tool that was offered to it: an internal tool
665
- // selected for this step, or a per-request external tool. Any other name
666
- // (hallucinated / stale / not in the top-K) is rejected — never executed —
667
- // so the semantic exposure boundary actually bounds what runs.
668
- const offeredInternalNames = new Set(relevant.map((t) => t.name));
669
- let retries = 0;
670
- // (D) Persist a 'failed' step-result artifact for controller-level failures
671
- // (reviewer unverifiable, executor error exhausted, maxToolCalls, unavailable
672
- // tool) so the board can project the step's terminal state from artifacts alone.
673
- const writeControlFailure = async (reason) => {
674
- const seq = bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0;
675
- const attempt = bundle.inFlightStep?.attempt ?? 0;
676
- bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
677
- await writeArtifact(rag, {
678
- ...meta,
679
- artifactType: 'step-result',
680
- task: step.name,
681
- runId: bundle.runId,
682
- seq,
683
- attempt,
684
- status: 'failed',
685
- note: reason,
686
- remainder: '',
687
- stepId: step.stepId,
688
- digest: reason.slice(0, cfg.maxDigestChars ?? 500),
689
- writeOrdinal: bundle.writeOrdinal,
690
- content: '',
691
- }, ctx.options);
692
- };
693
- // Inner loop handles tool routing / error retries until the executor
694
- // produces content for this step (or the step suspends on an external tool).
695
- while (true) {
696
- const res = await deps.executor.send(messages, offeredTools);
697
- logUsage?.('executor', res.usage);
698
- if (res.kind === 'content') {
699
- // Hold the executor's result; the reviewer (NOT the executor) decides the
700
- // outcome. Default reviewer (no deps.reviewer) approves as 'ok' (legacy).
701
- let review = deps.reviewer
702
- ? await deps.reviewer.review(step, evidence, res.content, {
703
- hint: deps.config.subagents.reviewer?.hint,
704
- logUsage,
705
- maxDigestChars: cfg.maxDigestChars ?? 500,
706
- })
707
- : {
708
- kind: 'outcome',
709
- outcome: {
710
- status: 'ok',
711
- approved: res.content,
712
- remainder: '',
713
- note: '',
714
- digest: res.content.slice(0, cfg.maxDigestChars ?? 500),
715
- },
716
- };
717
- // Judge failure (provider error / malformed / contradictory ok-with-empty)
718
- // is NOT a step failure: re-ask within maxReviewRetries, then ABORT (the
719
- // outcome is unverifiable). Never mapped to settle('failed')/replan.
720
- let reviewRetries = 0;
721
- while (review.kind === 'judge-failure') {
722
- reviewRetries++;
723
- if (reviewRetries > (cfg.maxReviewRetries ?? 2)) {
724
- // The reviewer could not produce a usable verdict within the retry
725
- // budget (provider error / unparsable). DEGRADE to a failed step so the
726
- // planner replans, rather than aborting the whole run — the terminal
727
- // backstop is maxStepAttempts/maxSteps, not a single unverifiable verdict.
728
- bundle.budgets.stepsUsed++;
729
- await writeControlFailure(`reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`);
730
- bundle.plannerPrivate += `\n[seq ${bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0} ${step.name} failed] reviewer unverifiable after ${cfg.maxReviewRetries ?? 2} retries: ${review.reason}`;
731
- return settle('failed');
704
+ else {
705
+ controlTail = [];
706
+ }
707
+ // External CONTINUATION bridge: the resume preamble APPENDS the freshly
708
+ // resolved external assistant/tool pair(s) to inFlight.transcript. Record them
709
+ // as rounds ON TOP of the restored strategy so the executor continues from its
710
+ // own tool call, then CLEAR the transcript — they now live in the strategy, so
711
+ // leaving them would double-record on the next external round-trip. (The
712
+ // LegacyTranscript migration above already adopted AND cleared its transcript,
713
+ // so here the transcript holds ONLY the just-injected external pair — never the
714
+ // migrated raw history; the two never double-inject the same rounds.)
715
+ if (inFlight && inFlight.transcript.length > 0) {
716
+ const t = inFlight.transcript;
717
+ let i = 0;
718
+ while (i < t.length) {
719
+ const assistant = t[i];
720
+ i++;
721
+ const results = [];
722
+ while (i < t.length && t[i]?.role === 'tool') {
723
+ results.push(t[i]);
724
+ i++;
732
725
  }
733
- review = await deps.reviewer.review(step, evidence, res.content, {
734
- hint: deps.config.subagents.reviewer?.hint,
735
- logUsage,
736
- maxDigestChars: cfg.maxDigestChars ?? 500,
737
- });
726
+ if (assistant)
727
+ await strategy.record({ assistant, results }, ctx.options);
728
+ }
729
+ inFlight.transcript.length = 0;
730
+ }
731
+ // Snapshot the strategy state + persist the bundle after every executor/tool
732
+ // exchange so a suspend or crash resumes with the same context the executor saw.
733
+ const persistExchange = async () => {
734
+ if (inFlight) {
735
+ inFlight.contextStrategyState = strategy.snapshot();
736
+ await persistBundle(deps.backend, sessionId, bundle);
738
737
  }
739
- const outcome = review.outcome;
738
+ };
739
+ // Per-reference evidence: one recall per requires[] reference. A non-empty
740
+ // top-K does NOT prove the dependency is present — semantic recall returns the
741
+ // NEAREST artifact even at low relevance — so we hand the reviewer the TOP
742
+ // artifact's relevant fragment (Evidence.topArtifact) and let IT (the judging
743
+ // role) decide whether the ref is actually satisfied. `hit` is a coarse
744
+ // any-candidate flag. Gathered SEQUENTIALLY (NOT Promise.all): each
745
+ // relevantExtract is itself bounded-sequential, so the outer sequential loop
746
+ // 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];
750
+ const evBound = RECALL_K_STEP * (maxAttempts + 1) +
751
+ cfg.maxSteps * maxAttempts * (cfg.maxToolCalls ?? 10);
752
+ const evidence = [];
753
+ for (const ref of refs) {
754
+ const hits = await runScopedRecall(rag, ref, 1, bundle.runId, evBound, RECALL_ARTIFACT_TYPES, ctx.options);
755
+ const topArtifact = hits[0]
756
+ ? await relevantExtract(hits[0].content, ref, RECALL_EVIDENCE_CHARS,
757
+ // biome-ignore lint/style/noNonNullAssertion: distance strategies require an embedder; the factory enforces it (Task 17).
758
+ deps.embedder, ctx.options)
759
+ : undefined;
760
+ evidence.push({ ref, hit: hits.length > 0, topArtifact });
761
+ }
762
+ // Tools offered to the executor = the INTERNAL (MCP) tools semantically
763
+ // relevant to THIS step (top-K from toolsRag) PLUS the per-request external
764
+ // (consumer-supplied) tools. The executor decides which to call; internal
765
+ // calls route through `callMcp`, external calls round-trip via `isExternalTool`.
766
+ const relevant = await deps.selectTools(step.instructions || step.name, TOOL_SELECT_K, ctx.options);
767
+ const offeredTools = [
768
+ ...relevant,
769
+ ...(ctx.externalTools ?? []),
770
+ ];
771
+ // The executor may ONLY call a tool that was offered to it: an internal tool
772
+ // selected for this step, or a per-request external tool. Any other name
773
+ // (hallucinated / stale / not in the top-K) is rejected — never executed —
774
+ // so the semantic exposure boundary actually bounds what runs.
775
+ const offeredInternalNames = new Set(relevant.map((t) => t.name));
776
+ let retries = 0;
777
+ // (D) Persist a 'failed' step-result artifact for controller-level failures
778
+ // (reviewer unverifiable, executor error exhausted, maxToolCalls, unavailable
779
+ // tool) so the board can project the step's terminal state from artifacts alone.
780
+ const writeControlFailure = async (reason) => {
740
781
  const seq = bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0;
741
782
  const attempt = bundle.inFlightStep?.attempt ?? 0;
742
- // ONE post-review write carrying the COMPLETE Outcome + identity.
743
783
  bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
744
784
  await writeArtifact(rag, {
745
785
  ...meta,
@@ -748,219 +788,334 @@ export class ControllerCoordinatorHandler {
748
788
  runId: bundle.runId,
749
789
  seq,
750
790
  attempt,
751
- status: outcome.status,
752
- note: outcome.note,
753
- remainder: outcome.remainder,
791
+ status: 'failed',
792
+ note: reason,
793
+ remainder: '',
754
794
  stepId: step.stepId,
755
- digest: outcome.digest,
795
+ digest: reason.slice(0, cfg.maxDigestChars ?? 500),
756
796
  writeOrdinal: bundle.writeOrdinal,
757
- content: outcome.approved,
797
+ content: '',
758
798
  }, ctx.options);
799
+ };
800
+ // A controller-level (non-reviewer) cut: persist a 'failed' step-result +
801
+ // planner note (preserving today's wording via noteFor) + the TYPED durable
802
+ // ControlFailure, then settle('failed') so the planner replans at this seq.
803
+ const cutControlFailure = async (reason) => {
759
804
  bundle.budgets.stepsUsed++;
760
- const mapped = mapOutcome(outcome.status);
761
- recordStepControl(bundle, {
762
- seq: bundle.inFlightStep?.seq ?? seq,
763
- name: step.name,
764
- status: outcome.status,
765
- note: outcome.note,
766
- remainder: outcome.remainder,
767
- });
768
- return settle(mapped);
769
- }
770
- if (res.kind === 'error') {
771
- retries++;
772
- if (retries <= cfg.maxRetries) {
773
- messages.push({
774
- role: 'user',
775
- content: `The previous attempt failed: ${res.error}. Retry the step.`,
776
- });
777
- await syncTranscript();
778
- continue;
805
+ await writeControlFailure(noteFor(reason));
806
+ bundle.plannerPrivate += `\n[seq ${inFlight?.seq ?? bundle.nextSeq ?? 0} ${step.name} control-failed] ${noteFor(reason)}`;
807
+ if (inFlight) {
808
+ inFlight.phase = 'awaiting-replan';
809
+ const typedReason = reason === 'maxToolCalls' || reason === 'step-timeout'
810
+ ? reason
811
+ : 'control-failure';
812
+ inFlight.controlFailure = {
813
+ reason: typedReason,
814
+ seq: inFlight.seq,
815
+ };
779
816
  }
780
- // Retries exhausted — feed the error back as the step result so the
781
- // planner can replan on the next iteration.
782
- bundle.budgets.stepsUsed++;
783
- await writeControlFailure(`executor error: ${res.error}`);
784
- bundle.plannerPrivate += `\n[step ${step.name} failed] ${res.error}`;
785
817
  return settle('failed');
786
- }
787
- // res.kind === 'tool_call' → route the FIRST tool call.
788
- const firstCall = res.toolCalls[0];
789
- if (firstCall === undefined) {
790
- // Empty tool-call array → treat as an executor error (retry/replan).
791
- retries++;
792
- if (retries <= cfg.maxRetries) {
793
- messages.push({
794
- role: 'user',
795
- content: 'The previous attempt produced an empty tool call. Retry the step.',
818
+ };
819
+ // Inner loop handles tool routing / error retries until the executor
820
+ // produces content for this step (or the step suspends on an external tool).
821
+ // The whole loop is wrapped so the step budget's timer is disposed on EVERY
822
+ // exit (settle / cut / suspend / abort / throw).
823
+ while (true) {
824
+ roundNo++;
825
+ // TIME/abort gate BEFORE each executor round (count is gated per tool call).
826
+ const rc = budget.shouldContinueRound(state());
827
+ if (!rc.continue)
828
+ return cutControlFailure(rc.reason);
829
+ // Form the per-round executor context: the immutable prefix + the strategy's
830
+ // rounds, then the bounded control tail (retries). NEVER a growing raw array.
831
+ const messages = (await strategy.form({ prefix: staticPrefix, queryText: step.instructions }, ctx.options)).concat(controlTail);
832
+ // Merged signal into the executor call. A reject WHILE the step budget is
833
+ // aborted is a step-timeout cut (NOT the executor-error retry); a normal
834
+ // return after the budget fired is ALSO a step-timeout cut.
835
+ let res;
836
+ try {
837
+ res = await deps.executor.send(messages, offeredTools, {
838
+ ...ctx.options,
839
+ signal: callSignal,
796
840
  });
797
- await syncTranscript();
798
- continue;
799
841
  }
800
- bundle.budgets.stepsUsed++;
801
- await writeControlFailure('empty tool call');
802
- bundle.plannerPrivate += `\n[step ${step.name} failed] empty tool call`;
803
- return settle('failed');
804
- }
805
- // Normalize the StreamToolCall (full or delta) into an LlmToolCall inline.
806
- const call = 'arguments' in firstCall &&
807
- typeof firstCall.arguments === 'object' &&
808
- firstCall.arguments !== null
809
- ? {
810
- id: ('id' in firstCall && firstCall.id) || 'call',
811
- name: ('name' in firstCall && firstCall.name) || '',
812
- arguments: firstCall.arguments,
842
+ catch (e) {
843
+ if (budget.signal.aborted)
844
+ return cutControlFailure('step-timeout');
845
+ throw e;
813
846
  }
814
- : (() => {
815
- let iArgs = {};
816
- const raw = 'arguments' in firstCall ? firstCall.arguments : undefined;
817
- if (typeof raw === 'string' && raw.length > 0) {
818
- try {
819
- iArgs = JSON.parse(raw);
820
- }
821
- catch {
822
- iArgs = {};
847
+ if (budget.signal.aborted)
848
+ return cutControlFailure('step-timeout');
849
+ logUsage?.('executor', res.usage);
850
+ if (res.kind === 'content') {
851
+ // Hold the executor's result; the reviewer (NOT the executor) decides the
852
+ // outcome. Default reviewer (no deps.reviewer) approves as 'ok' (legacy).
853
+ let review = deps.reviewer
854
+ ? await deps.reviewer.review(step, evidence, res.content, {
855
+ hint: deps.config.subagents.reviewer?.hint,
856
+ logUsage,
857
+ maxDigestChars: cfg.maxDigestChars ?? 500,
858
+ })
859
+ : {
860
+ kind: 'outcome',
861
+ outcome: {
862
+ status: 'ok',
863
+ approved: res.content,
864
+ remainder: '',
865
+ note: '',
866
+ digest: res.content.slice(0, cfg.maxDigestChars ?? 500),
867
+ },
868
+ };
869
+ // Judge failure (provider error / malformed / contradictory ok-with-empty)
870
+ // is NOT a step failure: re-ask within maxReviewRetries, then ABORT (the
871
+ // outcome is unverifiable). Never mapped to settle('failed')/replan.
872
+ let reviewRetries = 0;
873
+ while (review.kind === 'judge-failure') {
874
+ reviewRetries++;
875
+ if (reviewRetries > (cfg.maxReviewRetries ?? 2)) {
876
+ // The reviewer could not produce a usable verdict within the retry
877
+ // budget (provider error / unparsable). DEGRADE to a failed step so the
878
+ // planner replans, rather than aborting the whole run — the terminal
879
+ // backstop is maxStepAttempts/maxSteps, not a single unverifiable verdict.
880
+ 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');
823
884
  }
885
+ review = await deps.reviewer.review(step, evidence, res.content, {
886
+ hint: deps.config.subagents.reviewer?.hint,
887
+ logUsage,
888
+ maxDigestChars: cfg.maxDigestChars ?? 500,
889
+ });
890
+ }
891
+ const outcome = review.outcome;
892
+ const seq = bundle.inFlightStep?.seq ?? bundle.nextSeq ?? 0;
893
+ const attempt = bundle.inFlightStep?.attempt ?? 0;
894
+ // ONE post-review write carrying the COMPLETE Outcome + identity.
895
+ bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
896
+ await writeArtifact(rag, {
897
+ ...meta,
898
+ artifactType: 'step-result',
899
+ task: step.name,
900
+ runId: bundle.runId,
901
+ seq,
902
+ attempt,
903
+ status: outcome.status,
904
+ note: outcome.note,
905
+ remainder: outcome.remainder,
906
+ stepId: step.stepId,
907
+ digest: outcome.digest,
908
+ writeOrdinal: bundle.writeOrdinal,
909
+ content: outcome.approved,
910
+ }, ctx.options);
911
+ bundle.budgets.stepsUsed++;
912
+ const mapped = mapOutcome(outcome.status);
913
+ recordStepControl(bundle, {
914
+ seq: bundle.inFlightStep?.seq ?? seq,
915
+ name: step.name,
916
+ status: outcome.status,
917
+ note: outcome.note,
918
+ remainder: outcome.remainder,
919
+ });
920
+ return settle(mapped);
921
+ }
922
+ if (res.kind === 'error') {
923
+ retries++;
924
+ if (retries <= cfg.maxRetries) {
925
+ controlTail.push({
926
+ role: 'user',
927
+ content: `The previous attempt failed: ${res.error}. Retry the step.`,
928
+ });
929
+ await persistExchange();
930
+ continue;
931
+ }
932
+ // Retries exhausted — feed the error back as the step result so the
933
+ // planner can replan on the next iteration.
934
+ bundle.budgets.stepsUsed++;
935
+ await writeControlFailure(`executor error: ${res.error}`);
936
+ bundle.plannerPrivate += `\n[step ${step.name} failed] ${res.error}`;
937
+ return settle('failed');
938
+ }
939
+ // res.kind === 'tool_call' → route the FIRST tool call.
940
+ const firstCall = res.toolCalls[0];
941
+ if (firstCall === undefined) {
942
+ // Empty tool-call array → treat as an executor error (retry/replan).
943
+ retries++;
944
+ if (retries <= cfg.maxRetries) {
945
+ controlTail.push({
946
+ role: 'user',
947
+ content: 'The previous attempt produced an empty tool call. Retry the step.',
948
+ });
949
+ await persistExchange();
950
+ continue;
824
951
  }
825
- return {
952
+ bundle.budgets.stepsUsed++;
953
+ await writeControlFailure('empty tool call');
954
+ bundle.plannerPrivate += `\n[step ${step.name} failed] empty tool call`;
955
+ return settle('failed');
956
+ }
957
+ // Normalize the StreamToolCall (full or delta) into an LlmToolCall inline.
958
+ const call = 'arguments' in firstCall &&
959
+ typeof firstCall.arguments === 'object' &&
960
+ firstCall.arguments !== null
961
+ ? {
826
962
  id: ('id' in firstCall && firstCall.id) || 'call',
827
963
  name: ('name' in firstCall && firstCall.name) || '',
828
- arguments: iArgs,
964
+ arguments: firstCall.arguments,
965
+ }
966
+ : (() => {
967
+ let iArgs = {};
968
+ const raw = 'arguments' in firstCall ? firstCall.arguments : undefined;
969
+ if (typeof raw === 'string' && raw.length > 0) {
970
+ try {
971
+ iArgs = JSON.parse(raw);
972
+ }
973
+ catch {
974
+ iArgs = {};
975
+ }
976
+ }
977
+ return {
978
+ id: ('id' in firstCall && firstCall.id) || 'call',
979
+ name: ('name' in firstCall && firstCall.name) || '',
980
+ arguments: iArgs,
981
+ };
982
+ })();
983
+ const name = call.name;
984
+ const args = call.arguments;
985
+ if (isExternalTool(name)) {
986
+ // External round-trips share the SAME budget as internal calls; the
987
+ // prospective count gate is consulted BEFORE the increment so an external
988
+ // tool cannot exceed the cap. Cut → control-failed replan at the same seq.
989
+ const g = budget.canExecuteTool(state());
990
+ if (!g.continue)
991
+ return cutControlFailure(g.reason);
992
+ // Snapshot the strategy state SO FAR before we suspend (the resume injection
993
+ // appends the external assistant/tool pair, recorded on the next invocation).
994
+ if (inFlight)
995
+ inFlight.contextStrategyState = strategy.snapshot();
996
+ const extId = externalToolCallId(name, args);
997
+ if (inFlight)
998
+ inFlight.toolCallCount += 1;
999
+ // The new marker REPLACES any prior pending (a fresh extId).
1000
+ bundle.pending = {
1001
+ kind: 'external-tool',
1002
+ extId,
1003
+ toolName: name,
1004
+ args,
1005
+ position: step.name,
829
1006
  };
830
- })();
831
- const name = call.name;
832
- const args = call.arguments;
833
- if (isExternalTool(name)) {
834
- // External round-trips share the SAME durable toolCallCount/maxToolCalls
835
- // bound as internal calls; check BEFORE surfacing so an external tool
836
- // cannot exceed the cap. Exhausted → control-failed replan at the same seq.
837
- if (inFlight && inFlight.toolCallCount + 1 > maxToolCalls) {
1007
+ bundle.runState = 'suspended';
1008
+ await persistBundle(deps.backend, sessionId, bundle);
1009
+ this.surfaceToolCall(ctx, { id: extId, name, arguments: args }, usageNow?.());
1010
+ return 'suspended';
1011
+ }
1012
+ // The executor may only call a tool that was OFFERED to it this step. A
1013
+ // name that is neither external nor in the internal top-K (hallucinated /
1014
+ // stale / out-of-scope) is rejected — NOT executed — and fed back as a
1015
+ // tool-not-available error so the executor retries with an offered tool.
1016
+ if (!offeredInternalNames.has(name)) {
1017
+ retries++;
1018
+ if (retries <= cfg.maxRetries) {
1019
+ controlTail.push({
1020
+ role: 'user',
1021
+ content: `Tool "${name}" is not available for this step. Use only the tools provided to you.`,
1022
+ });
1023
+ await persistExchange();
1024
+ continue;
1025
+ }
838
1026
  bundle.budgets.stepsUsed++;
839
- await writeControlFailure('tool-call budget exhausted (maxToolCalls)');
840
- bundle.plannerPrivate += `\n[seq ${inFlight.seq} ${step.name} control-failed] tool-call budget exhausted (maxToolCalls)`;
841
- inFlight.phase = 'awaiting-replan';
842
- inFlight.controlFailure = {
843
- reason: 'maxToolCalls',
844
- seq: inFlight.seq,
845
- };
1027
+ await writeControlFailure(`requested unavailable tool ${name}`);
1028
+ bundle.plannerPrivate += `\n[step ${step.name} failed] requested unavailable tool ${name}`;
846
1029
  return settle('failed');
847
1030
  }
848
- // Sync the executor turns SO FAR into the durable transcript before we
849
- // suspend (the resume injection appends the external assistant/tool pair).
850
- await syncTranscript();
851
- const extId = externalToolCallId(name, args);
852
- if (inFlight)
1031
+ // Prospective count gate BEFORE the increment (the before-increment model:
1032
+ // the increment happens only after canExecuteTool allows the call).
1033
+ const g = budget.canExecuteTool(state());
1034
+ if (!g.continue)
1035
+ return cutControlFailure(g.reason);
1036
+ // Durable round-trip count: ++ and persist BEFORE surfacing so it survives a
1037
+ // resume (never a per-resume local).
1038
+ if (inFlight) {
853
1039
  inFlight.toolCallCount += 1;
854
- // The new marker REPLACES any prior pending (a fresh extId).
855
- bundle.pending = {
856
- kind: 'external-tool',
857
- extId,
858
- toolName: name,
859
- args,
860
- position: step.name,
861
- };
862
- bundle.runState = 'suspended';
863
- await persistBundle(deps.backend, sessionId, bundle);
864
- this.surfaceToolCall(ctx, { id: extId, name, arguments: args }, usageNow?.());
865
- return 'suspended';
866
- }
867
- // The executor may only call a tool that was OFFERED to it this step. A
868
- // name that is neither external nor in the internal top-K (hallucinated /
869
- // stale / out-of-scope) is rejected — NOT executed — and fed back as a
870
- // tool-not-available error so the executor retries with an offered tool.
871
- if (!offeredInternalNames.has(name)) {
872
- retries++;
873
- if (retries <= cfg.maxRetries) {
874
- messages.push({
875
- role: 'user',
876
- content: `Tool "${name}" is not available for this step. Use only the tools provided to you.`,
877
- });
878
- await syncTranscript();
879
- continue;
1040
+ await persistBundle(deps.backend, sessionId, bundle);
880
1041
  }
881
- bundle.budgets.stepsUsed++;
882
- await writeControlFailure(`requested unavailable tool ${name}`);
883
- bundle.plannerPrivate += `\n[step ${step.name} failed] requested unavailable tool ${name}`;
884
- return settle('failed');
885
- }
886
- // Durable round-trip count: ++ and persist BEFORE surfacing so it survives a
887
- // resume (never a per-resume local).
888
- if (inFlight) {
889
- inFlight.toolCallCount += 1;
890
- await persistBundle(deps.backend, sessionId, bundle);
891
- }
892
- if ((inFlight?.toolCallCount ?? 0) > maxToolCalls) {
893
- // Controller-level failure (NOT a reviewer status): record durably and replan.
894
- bundle.budgets.stepsUsed++;
895
- await writeControlFailure('tool-call budget exhausted (maxToolCalls)');
896
- bundle.plannerPrivate += `\n[seq ${inFlight?.seq ?? bundle.nextSeq ?? 0} ${step.name} control-failed] tool-call budget exhausted (maxToolCalls)`;
897
- if (inFlight) {
898
- inFlight.phase = 'awaiting-replan';
899
- inFlight.controlFailure = {
900
- reason: 'maxToolCalls',
901
- seq: inFlight.seq,
902
- };
1042
+ // Execute locally, memorize, re-send to the executor.
1043
+ // FAIL LOUD: surface an MCP-unavailable failure as a terminal abort (not a
1044
+ // silent empty response). The bridge (buildMcpBridge) throws an McpError
1045
+ // IFF the injected classifier deemed it 'unavailable'; a tool-level error
1046
+ // is returned as TEXT, never thrown. So ANY McpError reaching this catch is
1047
+ // already a classifier-unavailable verdict — trust that throw-contract
1048
+ // rather than re-checking the code (which would drop a CUSTOM classifier's
1049
+ // decision → rethrow → outer catch swallow → (no response)). A non-McpError
1050
+ // is a genuine unexpected error and is re-thrown for the outer handler.
1051
+ let result;
1052
+ try {
1053
+ result = await deps.callMcp(name, args, callSignal);
903
1054
  }
904
- return settle('failed');
905
- }
906
- // Execute locally, memorize, re-send to the executor.
907
- // FAIL LOUD: surface an MCP-unavailable failure as a terminal abort (not a
908
- // silent empty response). The bridge (buildMcpBridge) throws an McpError
909
- // IFF the injected classifier deemed it 'unavailable'; a tool-level error
910
- // is returned as TEXT, never thrown. So ANY McpError reaching this catch is
911
- // already a classifier-unavailable verdict — trust that throw-contract
912
- // rather than re-checking the code (which would drop a CUSTOM classifier's
913
- // decision → rethrow → outer catch swallow → (no response)). A non-McpError
914
- // is a genuine unexpected error and is re-thrown for the outer handler.
915
- let result;
916
- try {
917
- result = await deps.callMcp(name, args);
918
- }
919
- catch (mcpErr) {
920
- if (mcpErr instanceof McpError) {
921
- const now = deps.now ?? (() => new Date().toISOString());
922
- const terminalTtlMs = deps.terminalTtlMs ?? 24 * 60 * 60 * 1000;
923
- await this.abortTerminal(ctx, sessionId, bundle, `MCP server unavailable: ${mcpErr.message}`, now, terminalTtlMs, usageNow?.());
924
- return 'aborted';
1055
+ catch (mcpErr) {
1056
+ // A step-timeout cancellation aborts the merged signal → the bridge rejects.
1057
+ // Map that to a step-timeout control-failure BEFORE the McpError escalate so
1058
+ // it is NOT mis-classified as MCP-unavailable (20.4.0 escalate order is
1059
+ // otherwise unchanged).
1060
+ if (budget.signal.aborted)
1061
+ return cutControlFailure('step-timeout');
1062
+ if (mcpErr instanceof McpError) {
1063
+ const now = deps.now ?? (() => new Date().toISOString());
1064
+ const terminalTtlMs = deps.terminalTtlMs ?? 24 * 60 * 60 * 1000;
1065
+ await this.abortTerminal(ctx, sessionId, bundle, `MCP server unavailable: ${mcpErr.message}`, now, terminalTtlMs, usageNow?.());
1066
+ return 'aborted';
1067
+ }
1068
+ throw mcpErr;
925
1069
  }
926
- throw mcpErr;
927
- }
928
- bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
929
- await writeArtifact(rag, {
930
- ...meta,
931
- artifactType: 'mcp-result',
932
- toolName: name,
933
- task: step.name,
934
- runId: bundle.runId,
935
- seq: inFlight?.seq,
936
- attempt: inFlight?.attempt,
937
- // Stable fetch identity (tool+args) for run-scoped recall dedup.
938
- identityKey: externalToolCallId(name, args),
939
- writeOrdinal: bundle.writeOrdinal,
940
- content: result,
941
- }, ctx.options);
942
- // Feed the result back as a coherent assistant→tool turn (OpenAI protocol)
943
- // so the executor LLM continues from its own tool call rather than seeing a
944
- // bare user message. The assistant message carries the tool_call it made;
945
- // the tool message carries the result keyed by the same id.
946
- messages.push({
947
- role: 'assistant',
948
- content: null,
949
- tool_calls: [
950
- {
951
- id: call.id,
952
- type: 'function',
953
- function: { name, arguments: JSON.stringify(args) },
1070
+ // Record this exchange as a coherent assistant→tool ROUND (OpenAI protocol)
1071
+ // via the context strategy so the executor LLM continues from its own tool
1072
+ // call. The strategy owns the per-round context (Window keeps a bounded
1073
+ // buffer; RagRecall (Task 13) persists the mcp-result + recalls it) — the
1074
+ // handler no longer writes the mcp-result artifact or grows a raw transcript.
1075
+ // Durable monotonic write ordinal for this mcp-result write. RagRecall's
1076
+ // run-scoped dedup (isBetterMcp) tie-breaks on writeOrdinal FIRST (then
1077
+ // createdAt); since all mcp-result writes in a step share createdAt, a later
1078
+ // same-identityKey fetch only wins with a strictly-higher ordinal. Increment
1079
+ // BEFORE building the round so the value rides on it into strategy.record.
1080
+ bundle.writeOrdinal = (bundle.writeOrdinal ?? 0) + 1;
1081
+ const round = {
1082
+ assistant: {
1083
+ role: 'assistant',
1084
+ content: null,
1085
+ tool_calls: [
1086
+ {
1087
+ id: call.id,
1088
+ type: 'function',
1089
+ function: { name, arguments: JSON.stringify(args) },
1090
+ },
1091
+ ],
954
1092
  },
955
- ],
956
- });
957
- messages.push({
958
- role: 'tool',
959
- tool_call_id: call.id,
960
- content: result,
961
- });
962
- // The executor saw these turns → make them durable before the next round.
963
- await syncTranscript();
1093
+ results: [
1094
+ {
1095
+ role: 'tool',
1096
+ tool_call_id: call.id,
1097
+ content: result,
1098
+ },
1099
+ ],
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.
1103
+ meta: [
1104
+ { identityKey: externalToolCallId(name, args), isError: false },
1105
+ ],
1106
+ ordinal: bundle.writeOrdinal,
1107
+ roundId: undefined,
1108
+ };
1109
+ await strategy.record(round, ctx.options);
1110
+ // A recorded round supersedes any pending control retry → prune the tail.
1111
+ controlTail.length = 0;
1112
+ // The executor saw this round → make the strategy state durable before next.
1113
+ await persistExchange();
1114
+ }
1115
+ }
1116
+ finally {
1117
+ // Dispose the step budget (clears its wall-clock timer) on EVERY exit.
1118
+ budget.dispose();
964
1119
  }
965
1120
  }
966
1121
  // -- Escalation & surfacing (mirror StepperCoordinatorHandler) ----------