@duetso/agent 0.2.2 → 0.2.3

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 (235) hide show
  1. package/README.md +19 -11
  2. package/dist/package.json +4 -4
  3. package/dist/src/cli/config.d.ts +10 -0
  4. package/dist/src/cli/config.d.ts.map +1 -0
  5. package/dist/src/cli/config.js +24 -0
  6. package/dist/src/cli/config.js.map +1 -0
  7. package/dist/src/cli/help.d.ts.map +1 -1
  8. package/dist/src/cli/help.js +11 -9
  9. package/dist/src/cli/help.js.map +1 -1
  10. package/dist/src/cli/inline-slash.d.ts +2 -1
  11. package/dist/src/cli/inline-slash.d.ts.map +1 -1
  12. package/dist/src/cli/inline-slash.js +14 -5
  13. package/dist/src/cli/inline-slash.js.map +1 -1
  14. package/dist/src/cli/memory-query.d.ts +1 -1
  15. package/dist/src/cli/model.d.ts +1 -1
  16. package/dist/src/cli/model.d.ts.map +1 -1
  17. package/dist/src/cli/model.js +1 -1
  18. package/dist/src/cli/model.js.map +1 -1
  19. package/dist/src/cli/route.d.ts +84 -0
  20. package/dist/src/cli/route.d.ts.map +1 -0
  21. package/dist/src/cli/route.js +261 -0
  22. package/dist/src/cli/route.js.map +1 -0
  23. package/dist/src/cli/rpc.d.ts +43 -2
  24. package/dist/src/cli/rpc.d.ts.map +1 -1
  25. package/dist/src/cli/rpc.js +176 -23
  26. package/dist/src/cli/rpc.js.map +1 -1
  27. package/dist/src/cli/run.d.ts +8 -1
  28. package/dist/src/cli/run.d.ts.map +1 -1
  29. package/dist/src/cli/run.js +24 -7
  30. package/dist/src/cli/run.js.map +1 -1
  31. package/dist/src/cli/shared.d.ts +4 -3
  32. package/dist/src/cli/shared.d.ts.map +1 -1
  33. package/dist/src/cli/shared.js +3 -4
  34. package/dist/src/cli/shared.js.map +1 -1
  35. package/dist/src/cli/shutdown.d.ts +4 -2
  36. package/dist/src/cli/shutdown.d.ts.map +1 -1
  37. package/dist/src/cli/shutdown.js +4 -2
  38. package/dist/src/cli/shutdown.js.map +1 -1
  39. package/dist/src/cli.d.ts +2 -0
  40. package/dist/src/cli.d.ts.map +1 -1
  41. package/dist/src/cli.js +12 -0
  42. package/dist/src/cli.js.map +1 -1
  43. package/dist/src/core/structured-output.d.ts +2 -0
  44. package/dist/src/core/structured-output.d.ts.map +1 -1
  45. package/dist/src/core/structured-output.js +1 -0
  46. package/dist/src/core/structured-output.js.map +1 -1
  47. package/dist/src/lib/system-reminder.d.ts +14 -0
  48. package/dist/src/lib/system-reminder.d.ts.map +1 -0
  49. package/dist/src/lib/system-reminder.js +53 -0
  50. package/dist/src/lib/system-reminder.js.map +1 -0
  51. package/dist/src/lib/walk-ancestors.d.ts +9 -0
  52. package/dist/src/lib/walk-ancestors.d.ts.map +1 -0
  53. package/dist/src/lib/walk-ancestors.js +28 -0
  54. package/dist/src/lib/walk-ancestors.js.map +1 -0
  55. package/dist/src/memory/observational-prompts.d.ts.map +1 -1
  56. package/dist/src/memory/observational-prompts.js +2 -1
  57. package/dist/src/memory/observational-prompts.js.map +1 -1
  58. package/dist/src/memory/observational.d.ts +31 -4
  59. package/dist/src/memory/observational.d.ts.map +1 -1
  60. package/dist/src/memory/observational.js +43 -4
  61. package/dist/src/memory/observational.js.map +1 -1
  62. package/dist/src/model-resolution/catalog.d.ts +2 -2
  63. package/dist/src/model-resolution/catalog.d.ts.map +1 -1
  64. package/dist/src/model-resolution/catalog.js +61 -33
  65. package/dist/src/model-resolution/catalog.js.map +1 -1
  66. package/dist/src/model-resolution/duet-gateway.d.ts +3 -3
  67. package/dist/src/model-resolution/duet-gateway.d.ts.map +1 -1
  68. package/dist/src/model-resolution/duet-gateway.js +119 -19
  69. package/dist/src/model-resolution/duet-gateway.js.map +1 -1
  70. package/dist/src/model-resolution/model-gateway.d.ts.map +1 -0
  71. package/dist/src/{cli → model-resolution}/model-gateway.js +10 -6
  72. package/dist/src/model-resolution/model-gateway.js.map +1 -0
  73. package/dist/src/model-resolution/resolver.d.ts +11 -4
  74. package/dist/src/model-resolution/resolver.d.ts.map +1 -1
  75. package/dist/src/model-resolution/resolver.js +39 -8
  76. package/dist/src/model-resolution/resolver.js.map +1 -1
  77. package/dist/src/model-routing/advisor-transcript.d.ts +36 -0
  78. package/dist/src/model-routing/advisor-transcript.d.ts.map +1 -0
  79. package/dist/src/model-routing/advisor-transcript.js +103 -0
  80. package/dist/src/model-routing/advisor-transcript.js.map +1 -0
  81. package/dist/src/model-routing/advisor.d.ts +27 -0
  82. package/dist/src/model-routing/advisor.d.ts.map +1 -0
  83. package/dist/src/model-routing/advisor.js +20 -0
  84. package/dist/src/model-routing/advisor.js.map +1 -0
  85. package/dist/src/model-routing/classifier.d.ts +57 -0
  86. package/dist/src/model-routing/classifier.d.ts.map +1 -0
  87. package/dist/src/model-routing/classifier.js +63 -0
  88. package/dist/src/model-routing/classifier.js.map +1 -0
  89. package/dist/src/model-routing/loader.d.ts +48 -0
  90. package/dist/src/model-routing/loader.d.ts.map +1 -0
  91. package/dist/src/model-routing/loader.js +79 -0
  92. package/dist/src/model-routing/loader.js.map +1 -0
  93. package/dist/src/model-routing/prompts.d.ts +22 -0
  94. package/dist/src/model-routing/prompts.d.ts.map +1 -0
  95. package/dist/src/model-routing/prompts.js +87 -0
  96. package/dist/src/model-routing/prompts.js.map +1 -0
  97. package/dist/src/model-routing/resolve.d.ts +40 -0
  98. package/dist/src/model-routing/resolve.d.ts.map +1 -0
  99. package/dist/src/model-routing/resolve.js +76 -0
  100. package/dist/src/model-routing/resolve.js.map +1 -0
  101. package/dist/src/model-routing/router.d.ts +149 -0
  102. package/dist/src/model-routing/router.d.ts.map +1 -0
  103. package/dist/src/model-routing/router.js +235 -0
  104. package/dist/src/model-routing/router.js.map +1 -0
  105. package/dist/src/model-routing/step-triggers.d.ts +27 -0
  106. package/dist/src/model-routing/step-triggers.d.ts.map +1 -0
  107. package/dist/src/model-routing/step-triggers.js +20 -0
  108. package/dist/src/model-routing/step-triggers.js.map +1 -0
  109. package/dist/src/model-routing/table.d.ts +126 -0
  110. package/dist/src/model-routing/table.d.ts.map +1 -0
  111. package/dist/src/model-routing/table.js +351 -0
  112. package/dist/src/model-routing/table.js.map +1 -0
  113. package/dist/src/session/session.d.ts +40 -23
  114. package/dist/src/session/session.d.ts.map +1 -1
  115. package/dist/src/session/session.js +173 -97
  116. package/dist/src/session/session.js.map +1 -1
  117. package/dist/src/tasks/quiescence.d.ts +13 -0
  118. package/dist/src/tasks/quiescence.d.ts.map +1 -0
  119. package/dist/src/tasks/quiescence.js +11 -0
  120. package/dist/src/tasks/quiescence.js.map +1 -0
  121. package/dist/src/tasks/task-manager.d.ts +101 -0
  122. package/dist/src/tasks/task-manager.d.ts.map +1 -0
  123. package/dist/src/tasks/task-manager.js +311 -0
  124. package/dist/src/tasks/task-manager.js.map +1 -0
  125. package/dist/src/tasks/types.d.ts +85 -0
  126. package/dist/src/tasks/types.d.ts.map +1 -0
  127. package/dist/src/tasks/types.js +2 -0
  128. package/dist/src/tasks/types.js.map +1 -0
  129. package/dist/src/tui/app.d.ts +1 -1
  130. package/dist/src/tui/app.d.ts.map +1 -1
  131. package/dist/src/tui/app.js +12 -2
  132. package/dist/src/tui/app.js.map +1 -1
  133. package/dist/src/tui/initial-prompt.d.ts.map +1 -1
  134. package/dist/src/tui/initial-prompt.js +0 -3
  135. package/dist/src/tui/initial-prompt.js.map +1 -1
  136. package/dist/src/tui/session-subscription.d.ts +3 -0
  137. package/dist/src/tui/session-subscription.d.ts.map +1 -1
  138. package/dist/src/tui/session-subscription.js +29 -5
  139. package/dist/src/tui/session-subscription.js.map +1 -1
  140. package/dist/src/tui/sidebar.d.ts +3 -0
  141. package/dist/src/tui/sidebar.d.ts.map +1 -1
  142. package/dist/src/tui/sidebar.js +19 -3
  143. package/dist/src/tui/sidebar.js.map +1 -1
  144. package/dist/src/tui/slash-commands.d.ts +12 -9
  145. package/dist/src/tui/slash-commands.d.ts.map +1 -1
  146. package/dist/src/tui/slash-commands.js +48 -10
  147. package/dist/src/tui/slash-commands.js.map +1 -1
  148. package/dist/src/tui/status-controller.d.ts +14 -9
  149. package/dist/src/tui/status-controller.d.ts.map +1 -1
  150. package/dist/src/tui/status-controller.js +26 -7
  151. package/dist/src/tui/status-controller.js.map +1 -1
  152. package/dist/src/tui/task-lane-renderer.d.ts +30 -0
  153. package/dist/src/tui/task-lane-renderer.d.ts.map +1 -0
  154. package/dist/src/tui/task-lane-renderer.js +93 -0
  155. package/dist/src/tui/task-lane-renderer.js.map +1 -0
  156. package/dist/src/tui/task-tree.d.ts +47 -0
  157. package/dist/src/tui/task-tree.d.ts.map +1 -0
  158. package/dist/src/tui/task-tree.js +190 -0
  159. package/dist/src/tui/task-tree.js.map +1 -0
  160. package/dist/src/tui/tool-formatters.js +3 -0
  161. package/dist/src/tui/tool-formatters.js.map +1 -1
  162. package/dist/src/turn-runner/agent-events.d.ts.map +1 -1
  163. package/dist/src/turn-runner/agent-events.js +1 -0
  164. package/dist/src/turn-runner/agent-events.js.map +1 -1
  165. package/dist/src/turn-runner/duration.d.ts +2 -2
  166. package/dist/src/turn-runner/duration.js +3 -3
  167. package/dist/src/turn-runner/duration.js.map +1 -1
  168. package/dist/src/turn-runner/prompts.d.ts +5 -1
  169. package/dist/src/turn-runner/prompts.d.ts.map +1 -1
  170. package/dist/src/turn-runner/prompts.js +30 -6
  171. package/dist/src/turn-runner/prompts.js.map +1 -1
  172. package/dist/src/turn-runner/runtime-clock.d.ts +20 -0
  173. package/dist/src/turn-runner/runtime-clock.d.ts.map +1 -0
  174. package/dist/src/turn-runner/runtime-clock.js +38 -0
  175. package/dist/src/turn-runner/runtime-clock.js.map +1 -0
  176. package/dist/src/turn-runner/shell-state-handle.d.ts +0 -3
  177. package/dist/src/turn-runner/shell-state-handle.d.ts.map +1 -1
  178. package/dist/src/turn-runner/shell-state-handle.js +5 -55
  179. package/dist/src/turn-runner/shell-state-handle.js.map +1 -1
  180. package/dist/src/turn-runner/skill-context.d.ts +6 -7
  181. package/dist/src/turn-runner/skill-context.d.ts.map +1 -1
  182. package/dist/src/turn-runner/skill-context.js +10 -10
  183. package/dist/src/turn-runner/skill-context.js.map +1 -1
  184. package/dist/src/turn-runner/skills.d.ts.map +1 -1
  185. package/dist/src/turn-runner/skills.js +2 -22
  186. package/dist/src/turn-runner/skills.js.map +1 -1
  187. package/dist/src/turn-runner/state-compaction.d.ts +7 -5
  188. package/dist/src/turn-runner/state-compaction.d.ts.map +1 -1
  189. package/dist/src/turn-runner/state-compaction.js +69 -10
  190. package/dist/src/turn-runner/state-compaction.js.map +1 -1
  191. package/dist/src/turn-runner/state-machine-decisions.d.ts +135 -0
  192. package/dist/src/turn-runner/state-machine-decisions.d.ts.map +1 -0
  193. package/dist/src/turn-runner/state-machine-decisions.js +443 -0
  194. package/dist/src/turn-runner/state-machine-decisions.js.map +1 -0
  195. package/dist/src/turn-runner/state-machine-session.d.ts +13 -28
  196. package/dist/src/turn-runner/state-machine-session.d.ts.map +1 -1
  197. package/dist/src/turn-runner/state-machine-session.js +18 -55
  198. package/dist/src/turn-runner/state-machine-session.js.map +1 -1
  199. package/dist/src/turn-runner/subagent.d.ts +156 -0
  200. package/dist/src/turn-runner/subagent.d.ts.map +1 -0
  201. package/dist/src/turn-runner/subagent.js +164 -0
  202. package/dist/src/turn-runner/subagent.js.map +1 -0
  203. package/dist/src/turn-runner/task-tools.d.ts +76 -0
  204. package/dist/src/turn-runner/task-tools.d.ts.map +1 -0
  205. package/dist/src/turn-runner/task-tools.js +370 -0
  206. package/dist/src/turn-runner/task-tools.js.map +1 -0
  207. package/dist/src/turn-runner/tools.d.ts +65 -17
  208. package/dist/src/turn-runner/tools.d.ts.map +1 -1
  209. package/dist/src/turn-runner/tools.js +173 -59
  210. package/dist/src/turn-runner/tools.js.map +1 -1
  211. package/dist/src/turn-runner/turn-runner.d.ts +198 -134
  212. package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
  213. package/dist/src/turn-runner/turn-runner.js +1302 -713
  214. package/dist/src/turn-runner/turn-runner.js.map +1 -1
  215. package/dist/src/turn-runner/usage-accounting.d.ts +4 -0
  216. package/dist/src/turn-runner/usage-accounting.d.ts.map +1 -1
  217. package/dist/src/turn-runner/usage-accounting.js +19 -0
  218. package/dist/src/turn-runner/usage-accounting.js.map +1 -1
  219. package/dist/src/types/config.d.ts +9 -1
  220. package/dist/src/types/config.d.ts.map +1 -1
  221. package/dist/src/types/config.js +2 -1
  222. package/dist/src/types/config.js.map +1 -1
  223. package/dist/src/types/protocol.d.ts +110 -30
  224. package/dist/src/types/protocol.d.ts.map +1 -1
  225. package/dist/src/types/state-machine.d.ts +10 -12
  226. package/dist/src/types/state-machine.d.ts.map +1 -1
  227. package/dist/src/types/state-machine.js.map +1 -1
  228. package/package.json +4 -4
  229. package/dist/src/cli/model-gateway.d.ts.map +0 -1
  230. package/dist/src/cli/model-gateway.js.map +0 -1
  231. package/dist/src/turn-runner/state-machine-controller.d.ts +0 -140
  232. package/dist/src/turn-runner/state-machine-controller.d.ts.map +0 -1
  233. package/dist/src/turn-runner/state-machine-controller.js +0 -501
  234. package/dist/src/turn-runner/state-machine-controller.js.map +0 -1
  235. /package/dist/src/{cli → model-resolution}/model-gateway.d.ts +0 -0
@@ -2,33 +2,44 @@ import { Agent, } from "@earendil-works/pi-agent-core";
2
2
  import { isContextOverflow } from "@earendil-works/pi-ai";
3
3
  import { resolveProviderApiKey } from "../model-resolution/duet-gateway.js";
4
4
  import dedent from "dedent";
5
+ import { stripSystemReminders, systemReminder } from "../lib/system-reminder.js";
5
6
  import { assistantText } from "../core/serializer.js";
6
- import { scheduledStateFallbackWakeAt } from "./duration.js";
7
+ import { classifyRoute } from "../model-routing/classifier.js";
8
+ import { loadRoutingTable } from "../model-routing/loader.js";
9
+ import { ADVISOR_EXECUTOR_GUIDANCE_LAYER } from "../model-routing/prompts.js";
10
+ import { resolveTierDefault } from "../model-routing/resolve.js";
11
+ import { ModelRouter, } from "../model-routing/router.js";
12
+ import { BUILT_IN_ROUTING_TABLE, isVirtualModel, } from "../model-routing/table.js";
7
13
  import { toXML } from "../lib/xml.js";
8
14
  import { createObservationalContextTransform, DEFAULT_EFFECTIVE_CONTEXT, estimateTokens, resolveObservationalMemorySettings, stripObservationalContextMessages, updateObservationalMemory, } from "../memory/observational.js";
9
15
  import { rebuildMemoryContextPack } from "../memory/context-pack.js";
10
16
  import { createEmbeddingClient } from "../memory/embedding.js";
11
- import { loadStoredMemory } from "../memory/storage.js";
17
+ import { loadStoredMemory, readSessionObservations, replaceSessionObservations, } from "../memory/storage.js";
12
18
  import { MemoryContextCache } from "../memory/store.js";
13
- import { DEFAULT_CLI_MEMORY_MODEL, DEFAULT_CLI_MODEL, resolveModelName, } from "../model-resolution/resolver.js";
19
+ import { DEFAULT_CLI_MEMORY_MODEL, DEFAULT_CLI_MODEL, resolveModelName, routingCatalogAdapter, } from "../model-resolution/resolver.js";
20
+ import { DEFAULT_TASK_WAIT_BUDGET_MS } from "../types/config.js";
14
21
  import { COMPACT_MESSAGE_TOKENS_RATIO, compactTurnState, } from "./state-compaction.js";
15
22
  import { createAgentEventTranslator, agentMessageText } from "./agent-events.js";
16
- import { createRecallMemorySystemPromptLayer, createSourceOfTruthSystemPromptLayer, createForkContextReminder, createStateAgentSystemPromptLayer, createStateMachineSystemPromptLayer, } from "./prompts.js";
23
+ import { createRecallMemorySystemPromptLayer, createSourceOfTruthSystemPromptLayer, createStateMachineSystemPromptLayer, withheldAskReminder, parkNudge, } from "./prompts.js";
17
24
  import { applyEvictionHorizon, calculateWireTokens, createInitialHorizon, findEvictionHorizon, } from "./wire-shaping.js";
18
- import { createDefaultTurnRunnerTools, createTurnRunnerTools, resolveStateCwd, formatCarriedTodosReminder, formatStateMachineTerminalAcknowledgmentPrompt, isTurnRunnerControlResult, } from "./tools.js";
25
+ import { createDefaultTurnRunnerTools, createTurnRunnerTools, resolveStateCwd, formatCarriedTodosReminder, formatStateMachineTerminalAcknowledgmentPrompt, isTurnRunnerControlResult, MINIMUM_STATE_MACHINE_DELAY_MS, } from "./tools.js";
19
26
  import { connectMcpServers } from "./mcp.js";
20
27
  import { SkillContext } from "./skill-context.js";
21
- import { currentScheduledState, isAwaitingUserAnswer, isWaitingOnScheduledState, repeatedSelectionLoopCount, } from "./state-machine-session.js";
22
- import { StateMachineController, } from "./state-machine-controller.js";
28
+ import { currentParkState } from "./state-machine-session.js";
29
+ import { failActiveSession, markTerminalAcknowledged, planDecision, planWake, recordPlannedTerminal, recordScheduled, recordSettled, repeatedSelectionLoopCount, startSession as startStateMachineSession, supersede, } from "./state-machine-decisions.js";
30
+ import { createSubagentExecutor, classifySpawnModel, } from "./subagent.js";
23
31
  import { completeTurn, copyOptionalArray, createInitialTurnState } from "./turn-state.js";
24
32
  import { DEFAULT_TRANSIENT_RETRY_POLICY, lastMessageIsTransientFailure, transientRetryDelayMs, } from "./transient-error.js";
25
- import { addUsage, addUsageByModel, usageFromMessages } from "./usage-accounting.js";
33
+ import { addUsage, addUsageByModel, usageFromAiSdk } from "./usage-accounting.js";
34
+ import { SystemRuntimeClock } from "./runtime-clock.js";
35
+ import { createTaskManager } from "../tasks/task-manager.js";
36
+ import { createShellStateHandle } from "./shell-state-handle.js";
37
+ import { createSpawnAgentTool, createTaskAdminTools, lostTaskRecoveryReminder, settlementNotice, wrapBackgroundable, } from "./task-tools.js";
26
38
  /**
27
39
  * How many times the parent is re-prompted to emit the
28
- * select_state_machine_state it owes (after a state completes or after the
29
- * user answers a state's question) before the runner gives up and records an
30
- * `error` terminal. Bounds the protocol-violation retry loop so a parent that
31
- * never transitions can't spin forever.
40
+ * select_state_machine_state it owes after a state completes before the runner
41
+ * gives up and records an `error` terminal. Bounds the protocol-violation retry
42
+ * loop so a parent that never transitions can't spin forever.
32
43
  */
33
44
  const PARENT_TRANSITION_RETRY_BUDGET = 3;
34
45
  /** Order matches `TurnContextWindowUsage` fields; indexes map to `scaled[0..3]`. */
@@ -101,6 +112,16 @@ export function scaleContextWindowUsageToTotalTokens(base, totalTokens) {
101
112
  globalMemory: scaled[3],
102
113
  };
103
114
  }
115
+ function emptyTokenUsage() {
116
+ return {
117
+ input: 0,
118
+ output: 0,
119
+ cacheRead: 0,
120
+ cacheWrite: 0,
121
+ totalTokens: 0,
122
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
123
+ };
124
+ }
104
125
  export class TurnRunner {
105
126
  config;
106
127
  eventHandlers = new Set();
@@ -116,14 +137,55 @@ export class TurnRunner {
116
137
  * transcript grows across every pi-agent turn in this duet-agent session.
117
138
  */
118
139
  parentAgent;
140
+ /** Virtual-model policy owner for the parent session, retained while a concrete pin suspends it. */
141
+ modelRouter;
142
+ /** Validated project routing table used by parent retargets and explicit virtual state models. */
143
+ routingTable;
144
+ /** Model selection queued while a parent turn is running; applied at the next turn boundary. */
145
+ pendingModelSelection;
146
+ /** Advisor target and transcript budget selected with the current virtual tier. */
147
+ advisorPolicy;
119
148
  /** True only while the parent pi agent is actively producing the public terminal event. */
120
149
  parentAgentRunning = false;
121
- /** Last turn-runner control tool result observed from the parent agent. */
122
- parentControlResult = { type: "none" };
123
- /** Runtime owner for state-machine progress and active state work. */
124
- stateMachineController;
125
- /** Parent prompt started by a steer while state-machine work is driving the turn. */
126
- activeStateWorkPrompt;
150
+ /** Control results captured during the current parent pass. More than one is a protocol error. */
151
+ parentControlResults = [];
152
+ /** Durable state-machine policy ledger; execution lives in TaskManager. */
153
+ stateMachine;
154
+ /** Runtime owner for every state execution and scheduled wake. */
155
+ /** Protected for test probes (task descriptors, scope injection); production owner is the loop. */
156
+ taskManager;
157
+ /** Execution-only metadata keyed by TaskManager's stable task id. */
158
+ stateTasks = new Map();
159
+ /** One-shot context attached to the first real parent pass after lost-task recovery. */
160
+ recoveredTaskReminder;
161
+ /** Ask withheld by the quiescence gate; re-surfaced on the parent's next pass. */
162
+ withheldAskQuestions;
163
+ /** Legacy persisted user-lane projection retained until the next loop owns it. */
164
+ hydratedQueuedCommands;
165
+ /** Inputs waiting for the single parent slot between sequential passes. */
166
+ parentInputs = [];
167
+ /** Wakes the loop when a user command arrives while task work is in process. */
168
+ parentInputWaiters = new Set();
169
+ /** Settlements already folded into the ledger during replacement/interrupt. */
170
+ ignoredTaskSettlements = new Set();
171
+ /** Delivery posture for task-backed foreground bash calls. */
172
+ taskSettlementDelivery = new Map();
173
+ /** Coalesces same-tick settlements into one FIFO drain and one B3 notice. */
174
+ settlementDeliveryQueued = false;
175
+ /** Monotonic root-scope suffix; each public turn owns one root scope. */
176
+ nextRootScope = 1;
177
+ /** Scope currently accepting state tasks. */
178
+ activeRootScopeId;
179
+ /** Tools are built once at the public-turn boundary and reused by every parent pass. */
180
+ turnTools;
181
+ /** Set by interrupt so the loop's sole exit can short-circuit pending-work computation. */
182
+ interruptReason;
183
+ /** Full task/process unwind started by interrupt(), awaited before its terminal. */
184
+ interruptCleanup;
185
+ /** Guards the single public terminal emission for the active turn. */
186
+ terminalEmitted = false;
187
+ /** Shared construction path for relay agent states and spawned children. */
188
+ subagentExecutor;
127
189
  /**
128
190
  * Set true by `interrupt()` while a parent-agent prompt is in flight, so
129
191
  * `runAgentWorker` can tell our own abort apart from a real provider error
@@ -173,20 +235,62 @@ export class TurnRunner {
173
235
  /** MCP servers connected during start(). Disposed on runner.dispose(). */
174
236
  mcpRuntime;
175
237
  skillContext;
176
- constructor(config) {
238
+ clock;
239
+ minimumScheduledDelayMs;
240
+ /** Foreground expiry yields a still-running result without cancelling the task. */
241
+ taskWaitBudgetMs;
242
+ constructor(config, dependencies = {}) {
177
243
  this.config = config;
244
+ this.clock = dependencies.clock ?? new SystemRuntimeClock();
245
+ this.minimumScheduledDelayMs =
246
+ dependencies.minimumScheduledDelayMs ?? MINIMUM_STATE_MACHINE_DELAY_MS;
247
+ this.taskWaitBudgetMs = config.taskWaitBudgetMs ?? DEFAULT_TASK_WAIT_BUDGET_MS;
178
248
  this.skillContext = new SkillContext(config);
179
- this.stateMachineController = new StateMachineController({
180
- cwd: config.cwd ?? process.cwd(),
181
- createStateAgent: (input) => this.createStateAgentHandle(input),
182
- onSessionChanged: (session) => this.emit({ type: "state_machine", stateMachine: session }),
249
+ this.taskManager = createTaskManager({
250
+ clock: this.clock,
251
+ onEvent: (event) => this.handleTaskEvent(event),
252
+ });
253
+ this.subagentExecutor = createSubagentExecutor({
254
+ createAgent: (input, onControlResult) => this.createAgent(input, onControlResult),
255
+ skillContext: {
256
+ resolveSkills: (spec, ctx) => this.skillContext.resolveSubagentSkills(spec.allowedSkills, ctx.machineContext ? `state "${ctx.machineContext.currentState}"` : "sub-agent"),
257
+ resolveSlashSkillPrompt: (prompt, skills) => this.skillContext.resolveSlashSkillPrompt(prompt, skills),
258
+ createSystemPromptWithAppendedLayers: (input) => this.createBaseSystemPromptWithAppendedLayers(input),
259
+ },
260
+ inheritedOptions: () => this.resolveSubagentInheritedOptions(),
261
+ resolveModel: (model) => this.resolveStateModel(model),
262
+ seedMessages: (spec, ctx) => spec.forkContext
263
+ ? [...(ctx.forkSource?.messages() ?? this.parentAgent?.state.messages ?? [])]
264
+ : [],
265
+ parentSystemPrompt: (ctx) => ctx.forkSource?.systemPrompt() ?? this.parentAgent?.state.systemPrompt,
266
+ createTools: (cwd, ctx) => this.createTools("agent", cwd, false, ctx.childScopeId && ctx.ownerScopeId
267
+ ? {
268
+ ownerScopeId: ctx.childScopeId,
269
+ ...(ctx.memoryContext?.sessionId
270
+ ? { memorySessionId: ctx.memoryContext.sessionId }
271
+ : {}),
272
+ forkSource: {
273
+ messages: () => [...(ctx.agent?.state.messages ?? [])],
274
+ systemPrompt: () => ctx.agent?.state.systemPrompt,
275
+ },
276
+ modelSetting: () => {
277
+ const model = ctx.agent?.state.model;
278
+ return model ? `${model.provider}:${model.id}` : DEFAULT_CLI_MODEL;
279
+ },
280
+ }
281
+ : undefined),
282
+ retryTransientServerErrors: (agent) => this.retryTransientServerErrors(agent),
283
+ emitAgentEvent: (event, origin) => this.emitAgentEvent(event, origin),
284
+ recordUsage: (usage, modelId) => this.recordUsage(usage, modelId),
285
+ emitTurnUsage: (origin) => this.emitTurnUsage(origin),
183
286
  });
184
287
  }
185
288
  async dispose() {
186
289
  this.parentAgent?.clearAllQueues();
187
- this.activeStateWorkPrompt = undefined;
188
- this.setQueuedCommands([]);
290
+ this.parentInputs.length = 0;
189
291
  this.clearFollowUpQueue();
292
+ await this.taskManager.interruptAll("Runner disposed.");
293
+ await this.taskManager.reapAll("Runner disposed.");
190
294
  await this.memoryPersistence?.dispose();
191
295
  this.memoryPersistence = undefined;
192
296
  await this.mcpRuntime?.dispose();
@@ -232,28 +336,25 @@ export class TurnRunner {
232
336
  * uses) so the rendered prefix lines up with the new horizon and the
233
337
  * next prompt-cache fault is paid once instead of twice.
234
338
  *
235
- * Rejected with a soft warning when a turn chain is in flight
236
- * mutating the horizon mid-stream would invalidate the request the
237
- * parent (or a state agent) is already dispatching. `activeTurnPromise`
238
- * is the single source of truth here, so poll/timer sleeps pass the
239
- * gate cleanly: the chain has already resolved to the `sleep` terminal
240
- * before compact is allowed to run.
339
+ * Rejected with a soft warning while a parent pass is dispatching or while
340
+ * the turn loop is active without an in-process task. A running task opens
341
+ * the parent-idle window: its transcript is isolated from the parent wire
342
+ * horizon, so compact may safely advance that horizon between passes.
241
343
  */
242
344
  async compact() {
243
345
  this.requireStarted();
244
- // `activeTurnPromise` is the single source of truth for "a turn chain
245
- // is in flight" it covers the parent agent dispatching, a state
246
- // agent dispatching while the parent is paused, and a poll/script
247
- // command currently executing. Sleep terminals (poll/timer waits)
248
- // have already resolved the chain and cleared this, so compact is
249
- // free to run during those waits.
250
- if (this.activeTurnPromise || this.compactInFlight) {
346
+ // Parent work owns the wire transcript and therefore closes the gate.
347
+ // In-process state work leaves the parent idle and opens it; scheduled
348
+ // waits have already settled the public turn and are open as well.
349
+ if (this.parentAgentRunning ||
350
+ (this.activeTurnPromise && this.taskManager.pendingWork().kind !== "open") ||
351
+ this.compactInFlight) {
251
352
  this.emit({
252
353
  type: "system",
253
354
  level: "warn",
254
355
  message: this.compactInFlight
255
356
  ? "compact ignored: a compact pass is already in progress."
256
- : "compact ignored: a turn chain is in flight; send compact between turns.",
357
+ : "compact ignored: a parent pass is in flight; retry in the parent-idle window.",
257
358
  });
258
359
  return;
259
360
  }
@@ -300,6 +401,7 @@ export class TurnRunner {
300
401
  return;
301
402
  }
302
403
  this.wireGuardHorizon.evictionHorizon = newHorizon;
404
+ this.modelRouter?.noteCompaction();
303
405
  const retainedAfter = applyEvictionHorizon(observable, newHorizon);
304
406
  const afterTokens = calculateWireTokens(retainedAfter);
305
407
  const dropped = currentRetained.length - retainedAfter.length;
@@ -347,7 +449,38 @@ export class TurnRunner {
347
449
  options: this.resolveTurnOptions(startOptions, command.state.options),
348
450
  }
349
451
  : createInitialTurnState(mode, this.resolveTurnOptions(startOptions));
350
- this.stateMachineController.hydrate(state.stateMachine);
452
+ await this.initializeModelRouter(state.options?.model);
453
+ this.stateMachine = state.stateMachine;
454
+ const recovery = this.taskManager.recover(state.tasks ?? [], state.nextTaskId, state.taskOutputTails);
455
+ const pendingLostTaskIds = new Set([
456
+ ...(state.pendingLostTaskReminderTaskIds ?? []),
457
+ ...recovery.lost.map((descriptor) => descriptor.id),
458
+ ]);
459
+ const lostSnapshots = [...pendingLostTaskIds]
460
+ .map((id) => this.taskManager.output(id))
461
+ .filter((snapshot) => snapshot !== undefined);
462
+ if (lostSnapshots.length > 0) {
463
+ this.recoveredTaskReminder = lostTaskRecoveryReminder(lostSnapshots);
464
+ while (this.taskManager.nextSettled()) {
465
+ // Recovery is delivered through the one-shot reminder, not the live settlement lane.
466
+ }
467
+ }
468
+ this.hydratedQueuedCommands = state.queuedCommands ? [...state.queuedCommands] : undefined;
469
+ const interruptedOnRecovery = command.state &&
470
+ (state.status === "running" ||
471
+ (state.status === "sleeping" && this.taskManager.pendingWork().kind !== "sleep"));
472
+ const recoveredState = {
473
+ ...state,
474
+ ...(interruptedOnRecovery
475
+ ? { status: "interrupted", agent: { ...state.agent, status: "cancelled" } }
476
+ : {}),
477
+ tasks: [...this.taskManager.list()],
478
+ taskOutputTails: this.snapshotTaskOutputTails(),
479
+ pendingLostTaskReminderTaskIds: lostSnapshots.length > 0
480
+ ? lostSnapshots.map((snapshot) => snapshot.descriptor.id)
481
+ : undefined,
482
+ nextTaskId: this.taskManager.nextTaskId(),
483
+ };
351
484
  // Hydrate the wire-shaping object in place. `this.wireGuardHorizon` is
352
485
  // referenced by the observational context transform; replacing the
353
486
  // reference would orphan the transform's view. `Object.assign` over
@@ -356,7 +489,7 @@ export class TurnRunner {
356
489
  if (state.wireGuardHorizon) {
357
490
  Object.assign(this.wireGuardHorizon, state.wireGuardHorizon);
358
491
  }
359
- this.setState(state);
492
+ this.setState(recoveredState);
360
493
  this.initializeParentAgent();
361
494
  this.started = true;
362
495
  const hydratedState = this.requireRunnerState();
@@ -382,7 +515,7 @@ export class TurnRunner {
382
515
  this.handleCommandDuringActiveTurn(command);
383
516
  return this.activeTurnPromise;
384
517
  }
385
- const activeTurnPromise = this.runTurnChain(command);
518
+ const activeTurnPromise = this.runTurnLoop(command);
386
519
  this.activeTurnPromise = activeTurnPromise;
387
520
  try {
388
521
  return await activeTurnPromise;
@@ -393,46 +526,174 @@ export class TurnRunner {
393
526
  }
394
527
  }
395
528
  }
396
- async runTurnChain(command) {
529
+ async runTurnLoop(command) {
397
530
  this.turnUsage = undefined;
398
531
  this.turnUsageByModel = undefined;
532
+ this.interruptReason = undefined;
533
+ this.interruptCleanup = undefined;
534
+ this.terminalEmitted = false;
535
+ this.activeRootScopeId = `turn-${this.nextRootScope++}`;
536
+ this.turnTools = this.createTools(this.requireRunnerState().mode).tools;
537
+ this.enqueueParentInput(command.type === "wake" ? { type: "wake" } : { type: "user_command", command });
538
+ const carriedUserCommands = this.hydratedQueuedCommands?.filter((queued) => queued.type !== "wake");
539
+ if (carriedUserCommands && carriedUserCommands.length > 0) {
540
+ for (const queued of carriedUserCommands) {
541
+ this.enqueueParentInput({ type: "user_command", command: queued });
542
+ }
543
+ }
544
+ else {
545
+ for (const entry of this.getFollowUpQueue()) {
546
+ this.enqueueParentInput({
547
+ type: "user_command",
548
+ command: {
549
+ type: "prompt",
550
+ message: entry.message,
551
+ behavior: "follow_up",
552
+ images: entry.images,
553
+ },
554
+ });
555
+ }
556
+ }
557
+ this.hydratedQueuedCommands = undefined;
558
+ let questions;
559
+ let completion = {
560
+ status: "completed",
561
+ };
562
+ let terminal;
399
563
  try {
400
- let terminal;
401
- terminal = await this.executeTurnCommand(command);
402
- terminal = await this.drainQueuedTurnCommands(terminal);
403
- terminal = { ...terminal, state: this.snapshotState(terminal.state) };
404
- if (this.turnUsage) {
564
+ while (!questions && !this.interruptReason) {
565
+ this.enqueueAvailableSettlements();
566
+ const pendingBeforeInput = this.taskManager.pendingWork();
567
+ const runnableIndex = pendingBeforeInput.kind === "open"
568
+ ? this.parentInputs.findIndex((queued) => queued.type === "task_settlements" ||
569
+ (queued.type === "user_command" && queued.command.behavior === "steer"))
570
+ : 0;
571
+ const input = runnableIndex >= 0 ? this.parentInputs.splice(runnableIndex, 1)[0] : undefined;
572
+ if (!input) {
573
+ if (pendingBeforeInput.kind !== "open")
574
+ break;
575
+ if (this.parentInputs.length > 0) {
576
+ await this.taskManager.waitForSettlement();
577
+ }
578
+ else {
579
+ await this.waitForLoopActivity();
580
+ }
581
+ continue;
582
+ }
583
+ if (input.type === "wake" &&
584
+ input.queued === true &&
585
+ this.taskManager.pendingWork().kind !== "sleep" &&
586
+ this.requireRunnerState().status !== "sleeping") {
587
+ continue;
588
+ }
589
+ const result = await this.processParentLoopInput(input);
590
+ if (result?.type === "ask") {
591
+ if (this.taskManager.pendingWork().kind === "open") {
592
+ // Terminal ⇒ quiescent forbids delivering the ask now. Keep the
593
+ // questions just long enough to remind the parent on its next pass
594
+ // (settlements guarantee one); the parent re-asks if still relevant.
595
+ this.withheldAskQuestions = result.questions;
596
+ }
597
+ else {
598
+ questions = result.questions;
599
+ }
600
+ }
601
+ if (result?.type === "interrupted")
602
+ this.interruptReason ??= "Interrupted";
603
+ if (result?.type === "state_completed") {
604
+ this.enqueueParentInput({
605
+ type: "transition_enforcement",
606
+ stateName: result.stateName,
607
+ output: result.output,
608
+ });
609
+ }
610
+ if (result?.type === "terminal") {
611
+ completion = {
612
+ status: result.status === "error" ? "failed" : "completed",
613
+ ...(result.result !== undefined ? { result: result.result } : {}),
614
+ ...(result.error !== undefined ? { error: result.error } : {}),
615
+ };
616
+ if (this.stateMachine?.terminal && !this.stateMachine.terminalAcknowledged) {
617
+ this.enqueueParentInput({ type: "terminal_acknowledgment" });
618
+ }
619
+ }
620
+ // Preserve the old drain rule: follow-ups arriving after a sleep was
621
+ // selected remain queued for the next user-driven turn. A stale wake,
622
+ // however, is skipped above so it cannot clobber the meaningful result.
623
+ if (this.taskManager.pendingWork().kind === "sleep" &&
624
+ this.parentInputs.length > 0 &&
625
+ this.parentInputs.every((queued) => queued.type === "user_command" && queued.command.behavior === "follow_up")) {
626
+ break;
627
+ }
628
+ }
629
+ }
630
+ catch (error) {
631
+ completion = {
632
+ status: "failed",
633
+ error: error instanceof Error ? error.message : String(error),
634
+ };
635
+ await this.taskManager.interruptAll(`Turn failed: ${completion.error}`);
636
+ }
637
+ finally {
638
+ await this.interruptCleanup;
639
+ if (!this.interruptReason && !questions && this.taskManager.pendingWork().kind === "open") {
640
+ await this.taskManager.interruptAll("Turn exited with in-process work still active.");
641
+ completion = {
642
+ status: "failed",
643
+ error: "Turn exited with in-process work still active.",
644
+ };
645
+ }
646
+ this.discardStaleTaskSettlements();
647
+ const quiescentState = this.snapshotState(this.requireRunnerState());
648
+ try {
649
+ await this.updateMemoryAfterAgentRun(quiescentState.agent.messages, quiescentState.options);
650
+ }
651
+ catch (error) {
652
+ if (!this.interruptReason) {
653
+ completion = {
654
+ status: "failed",
655
+ error: error instanceof Error ? error.message : String(error),
656
+ };
657
+ }
658
+ }
659
+ const state = this.snapshotState(this.requireRunnerState());
660
+ if (this.interruptReason) {
661
+ terminal = {
662
+ type: "interrupted",
663
+ state: {
664
+ ...state,
665
+ status: "interrupted",
666
+ agent: { ...state.agent, status: "cancelled" },
667
+ },
668
+ };
669
+ }
670
+ else if (questions) {
405
671
  terminal = {
406
- ...terminal,
407
- turnUsage: this.turnUsage,
408
- usageByModel: this.turnUsageByModel ?? [],
409
- ...(this.lastParentUsageSnapshot
672
+ type: "ask",
673
+ questions,
674
+ state: { ...state, status: "waiting_for_human" },
675
+ };
676
+ }
677
+ else {
678
+ const settledPending = this.taskManager.pendingWork();
679
+ terminal =
680
+ settledPending.kind === "sleep"
410
681
  ? {
411
- effectiveContextWindow: this.lastParentUsageSnapshot.effectiveContextWindow,
412
- contextWindowUsage: this.lastParentUsageSnapshot.contextWindowUsage,
413
- lastMessageUsage: this.lastParentUsageSnapshot.lastMessageUsage,
682
+ type: "sleep",
683
+ wakeAt: settledPending.wakeAt,
684
+ state: { ...state, status: "sleeping" },
414
685
  }
415
- : {}),
416
- };
686
+ : completeTurn(state, completion.status, completion.result, completion.error);
417
687
  }
688
+ terminal = this.attachTurnUsage({ ...terminal, state: this.snapshotState(terminal.state) });
418
689
  this.setState(terminal.state);
419
- this.emit(terminal);
420
- return terminal;
421
- }
422
- finally {
690
+ this.emitTerminalOnce(terminal);
691
+ this.turnTools = undefined;
692
+ this.activeRootScopeId = undefined;
423
693
  this.turnUsage = undefined;
424
694
  this.turnUsageByModel = undefined;
425
695
  }
426
- }
427
- async executeTurnCommand(command) {
428
- switch (command.type) {
429
- case "prompt":
430
- return this.prompt(command);
431
- case "answer":
432
- return this.answer(command);
433
- case "wake":
434
- return this.wake();
435
- }
696
+ return terminal;
436
697
  }
437
698
  handleCommandDuringActiveTurn(command) {
438
699
  if ((command.type === "prompt" || command.type === "answer") && this.parentAgentRunning) {
@@ -441,20 +702,11 @@ export class TurnRunner {
441
702
  this.sendCommandToAgent(this.requireParentAgent(), command);
442
703
  return;
443
704
  }
444
- if (command.type === "prompt" || command.type === "answer") {
445
- const message = this.commandToUserMessage(command);
446
- if (this.stateMachineController.hasActiveWork()) {
447
- if (command.behavior === "follow_up") {
448
- // State-machine work is driving the terminal event. Follow-ups are
449
- // transition context, so replay them before the next state decision.
450
- this.enqueueTurnCommand(command);
451
- return;
452
- }
453
- this.startParentPromptDuringActiveStateWork(message, command);
454
- return;
455
- }
705
+ this.enqueueParentInput(command.type === "wake" ? { type: "wake", queued: true } : { type: "user_command", command });
706
+ if ((command.type === "prompt" || command.type === "answer") &&
707
+ command.behavior === "follow_up") {
708
+ this.appendFollowUpPrompt(this.commandToUserMessage(command), command.images);
456
709
  }
457
- this.enqueueTurnCommand(command);
458
710
  }
459
711
  sendCommandToAgent(agent, command) {
460
712
  const message = this.commandToUserMessage(command);
@@ -468,69 +720,96 @@ export class TurnRunner {
468
720
  agent.followUp(agentMessage);
469
721
  }
470
722
  }
471
- startParentPromptDuringActiveStateWork(message, command) {
472
- const prompt = this.runParentPromptDuringActiveStateWork(message, command);
473
- this.activeStateWorkPrompt = prompt;
474
- void prompt.finally(() => {
475
- if (this.activeStateWorkPrompt === prompt)
476
- this.activeStateWorkPrompt = undefined;
477
- });
723
+ enqueueParentInput(input) {
724
+ this.parentInputs.push(input);
725
+ for (const wake of this.parentInputWaiters)
726
+ wake();
727
+ this.parentInputWaiters.clear();
478
728
  }
479
- async runParentPromptDuringActiveStateWork(message, command) {
480
- const prompt = command.behavior === "steer"
481
- ? dedent `
482
- <system-reminder>
483
- The user sent this as a steer message while state-machine work is running.
484
- If the state-machine should change course, call select_state_machine_state to restart the current state with updated input or choose a different state.
485
- </system-reminder>
486
-
487
- ${message}
488
- `
489
- : message;
490
- const terminal = await this.prompt({
491
- type: "prompt",
492
- message: prompt,
493
- behavior: command.behavior,
494
- images: command.type === "prompt" ? command.images : undefined,
729
+ async waitForLoopActivity() {
730
+ if (this.parentInputs.length > 0)
731
+ return;
732
+ let wake;
733
+ const parentInput = new Promise((resolve) => {
734
+ wake = resolve;
735
+ this.parentInputWaiters.add(wake);
495
736
  });
496
- this.setState(terminal.state);
497
- return terminal;
737
+ try {
738
+ await Promise.race([this.taskManager.waitForSettlement().then(() => undefined), parentInput]);
739
+ }
740
+ finally {
741
+ this.parentInputWaiters.delete(wake);
742
+ }
498
743
  }
499
- async drainQueuedTurnCommands(terminal) {
500
- let latest = terminal;
501
- while (this.getQueuedCommands().length > 0) {
502
- if (latest.type === "sleep" &&
503
- this.getQueuedCommands().every((command) => this.isFollowUpQueueCommand(command))) {
504
- return latest;
505
- }
506
- if (latest.type === "interrupted" ||
507
- (latest.type === "complete" && latest.status === "failed")) {
508
- this.setQueuedCommands([]);
509
- this.clearFollowUpQueue();
510
- return latest;
744
+ enqueueAvailableSettlements() {
745
+ if ([...this.taskSettlementDelivery.values()].includes("foreground_pending"))
746
+ return;
747
+ const settlements = [];
748
+ for (let settlement = this.taskManager.nextSettled(); settlement; settlement = this.taskManager.nextSettled()) {
749
+ const delivery = this.taskSettlementDelivery.get(settlement.id);
750
+ this.taskSettlementDelivery.delete(settlement.id);
751
+ if (delivery !== "suppress")
752
+ settlements.push(settlement);
753
+ }
754
+ if (settlements.length === 0)
755
+ return;
756
+ const canSteer = this.parentAgentRunning &&
757
+ this.parentControlResults.length === 0 &&
758
+ settlements.every((settlement) => !this.stateTasks.has(settlement.id));
759
+ if (canSteer) {
760
+ const snapshots = settlements
761
+ .map((settlement) => this.taskManager.output(settlement.id))
762
+ .filter((snapshot) => snapshot !== undefined);
763
+ if (snapshots.length > 0) {
764
+ this.requireParentAgent().steer(buildUserAgentMessage(settlementNotice(snapshots), undefined));
765
+ return;
511
766
  }
512
- const queued = this.shiftQueuedCommand();
513
- if (!queued)
514
- break;
515
- this.removeQueuedFollowUpPrompt(queued);
516
- this.setState({
517
- ...latest.state,
518
- followUpQueue: this.getFollowUpQueue(),
519
- queuedCommands: this.getQueuedCommands(),
520
- });
521
- // A wake only has work to do when the runner is currently sleeping on
522
- // a scheduled state. If the session moved on (the prior queued
523
- // command already drove the state machine, or the session was never
524
- // sleeping to begin with), running wake here would emit a
525
- // "Nothing to wake." terminal that clobbers the real terminal from
526
- // the previous command in this drain chain. Skipping the queued wake
527
- // keeps the meaningful terminal as the single emitted event.
528
- if (queued.type === "wake" && latest.state.status !== "sleeping") {
767
+ }
768
+ this.parentInputs.unshift({ type: "task_settlements", settlements });
769
+ }
770
+ handleTaskEvent(event) {
771
+ if (event.type === "started") {
772
+ this.emit({ type: "task_started", task: event.descriptor });
773
+ return;
774
+ }
775
+ if (event.type === "output") {
776
+ this.emit({ type: "task_output", taskId: event.id, chunk: event.chunk });
777
+ return;
778
+ }
779
+ this.emit({ type: "task_settled", settlement: event.settlement });
780
+ if (this.taskSettlementDelivery.get(event.settlement.id) === "foreground_pending")
781
+ return;
782
+ this.scheduleSettlementDelivery();
783
+ }
784
+ scheduleSettlementDelivery() {
785
+ if (this.settlementDeliveryQueued)
786
+ return;
787
+ this.settlementDeliveryQueued = true;
788
+ queueMicrotask(() => {
789
+ this.settlementDeliveryQueued = false;
790
+ this.enqueueAvailableSettlements();
791
+ for (const wake of this.parentInputWaiters)
792
+ wake();
793
+ this.parentInputWaiters.clear();
794
+ });
795
+ }
796
+ discardStaleTaskSettlements() {
797
+ for (let settlement = this.taskManager.nextSettled(); settlement; settlement = this.taskManager.nextSettled()) {
798
+ this.stateTasks.delete(settlement.id);
799
+ this.ignoredTaskSettlements.delete(settlement.id);
800
+ this.taskSettlementDelivery.delete(settlement.id);
801
+ }
802
+ for (const input of this.parentInputs) {
803
+ if (input.type !== "task_settlements")
529
804
  continue;
805
+ for (const settlement of input.settlements) {
806
+ this.stateTasks.delete(settlement.id);
807
+ this.ignoredTaskSettlements.delete(settlement.id);
808
+ this.taskSettlementDelivery.delete(settlement.id);
530
809
  }
531
- latest = await this.executeTurnCommand(queued);
532
810
  }
533
- return latest;
811
+ const userInputs = this.parentInputs.filter((input) => input.type !== "task_settlements");
812
+ this.parentInputs.splice(0, this.parentInputs.length, ...userInputs);
534
813
  }
535
814
  commandToUserMessage(command) {
536
815
  if (command.type === "prompt") {
@@ -546,39 +825,30 @@ export class TurnRunner {
546
825
  return base;
547
826
  return `${base}\n\n${this.skillContext.resolveSlashSkillPrompt(command.message)}`;
548
827
  }
549
- enqueueTurnCommand(command) {
550
- if ((command.type === "prompt" || command.type === "answer") &&
551
- command.behavior === "follow_up") {
552
- this.appendFollowUpPrompt(this.commandToUserMessage(command), command.images);
553
- }
554
- this.setQueuedCommands([...this.getQueuedCommands(), command]);
555
- }
556
828
  replaceFollowUpQueue(entries) {
557
829
  this.setFollowUpQueue(entries);
558
830
  this.parentAgent?.clearFollowUpQueue();
559
- for (const entry of this.getFollowUpQueue()) {
560
- this.parentAgent?.followUp(buildUserAgentMessage(entry.message, entry.images));
831
+ this.parentInputs.splice(0, this.parentInputs.length, ...this.parentInputs.filter((input) => input.type !== "user_command" || input.command.behavior !== "follow_up"));
832
+ if (this.parentAgentRunning) {
833
+ for (const entry of entries) {
834
+ this.parentAgent?.followUp(buildUserAgentMessage(entry.message, entry.images));
835
+ }
836
+ }
837
+ else if (this.activeTurnPromise) {
838
+ for (const entry of entries) {
839
+ this.enqueueParentInput({
840
+ type: "user_command",
841
+ command: {
842
+ type: "prompt",
843
+ message: entry.message,
844
+ behavior: "follow_up",
845
+ images: entry.images,
846
+ },
847
+ });
848
+ }
561
849
  }
562
- this.replaceQueuedFollowUpCommands(entries);
563
850
  this.emitFollowUpQueue();
564
851
  }
565
- replaceQueuedFollowUpCommands(entries) {
566
- this.removeQueuedFollowUpCommands();
567
- if (!this.state || this.parentAgentRunning)
568
- return;
569
- this.setQueuedCommands([
570
- ...this.getQueuedCommands(),
571
- ...entries.map((entry) => ({
572
- type: "prompt",
573
- message: entry.message,
574
- behavior: "follow_up",
575
- images: entry.images,
576
- })),
577
- ]);
578
- }
579
- removeQueuedFollowUpCommands() {
580
- this.setQueuedCommands(this.getQueuedCommands().filter((command) => !this.isFollowUpQueueCommand(command)));
581
- }
582
852
  isFollowUpQueueCommand(command) {
583
853
  return ((command.type === "prompt" || command.type === "answer") && command.behavior === "follow_up");
584
854
  }
@@ -619,264 +889,402 @@ export class TurnRunner {
619
889
  this.requireStarted();
620
890
  if (!this.state)
621
891
  return;
622
- const hadActiveControllerWork = this.stateMachineController.hasActiveWork();
623
- this.stateMachineController.interrupt("Interrupted");
624
- const interruptedState = this.snapshotState(this.state);
625
- const terminal = {
626
- type: "interrupted",
627
- state: {
628
- ...interruptedState,
629
- status: "interrupted",
630
- agent: { ...interruptedState.agent, status: "cancelled" },
631
- },
632
- };
633
- this.setState(terminal.state);
634
- if (this.parentAgentRunning || hadActiveControllerWork) {
635
- // The active turn emits the terminal event after agent.prompt() unwinds;
636
- // interrupt() only aborts out-of-band. The interrupted snapshot is
637
- // already on `this.state` above — `runAgentWorker` reads it from there.
638
- this.parentAgentInterrupted = true;
639
- }
892
+ this.interruptReason = "Interrupted";
893
+ this.parentAgentInterrupted = this.parentAgentRunning;
640
894
  this.parentAgent?.abort();
641
895
  this.parentAgent?.clearAllQueues();
642
- this.activeStateWorkPrompt = undefined;
643
- this.setQueuedCommands([]);
896
+ this.parentInputs.length = 0;
644
897
  this.clearFollowUpQueue();
645
898
  this.parentAgentRunning = false;
899
+ this.interruptCleanup = this.interruptTasks("Interrupted");
900
+ if (!this.activeTurnPromise) {
901
+ this.terminalEmitted = false;
902
+ void this.interruptCleanup.then(() => {
903
+ const state = this.snapshotState(this.requireRunnerState());
904
+ const terminal = {
905
+ type: "interrupted",
906
+ state: {
907
+ ...state,
908
+ status: "interrupted",
909
+ agent: { ...state.agent, status: "cancelled" },
910
+ },
911
+ };
912
+ this.setState(terminal.state);
913
+ this.emitTerminalOnce(terminal);
914
+ });
915
+ }
646
916
  }
647
917
  emit(event) {
648
918
  for (const handler of this.eventHandlers) {
649
919
  handler(event);
650
920
  }
651
921
  }
652
- async prompt(command) {
653
- const originalState = this.requireRunnerState();
654
- const state = this.clearFinishedTodosAtTurnStart({ ...originalState, status: "running" });
655
- const resolvedPrompt = this.skillContext.resolveSlashSkillPrompt(command.message);
656
- const todoReminder = formatCarriedTodosReminder(state.todos);
657
- const prompt = todoReminder ? `${todoReminder}\n\n${resolvedPrompt}` : resolvedPrompt;
658
- const images = promptImagesToContent(command.images);
659
- let terminal;
660
- if (state.mode === "agent") {
661
- terminal = await this.runAgentMode(state, prompt, images);
662
- }
663
- else {
664
- terminal = await this.runTurnRunnerAgentWithStateMachineTools({
665
- state,
666
- prompt,
667
- images,
668
- mode: state.mode,
669
- });
922
+ async processParentLoopInput(input) {
923
+ switch (input.type) {
924
+ case "user_command":
925
+ return this.runUserCommandPass(input.command, input.continuation);
926
+ case "task_settlements":
927
+ return this.processTaskSettlements(input.settlements);
928
+ case "transition_enforcement":
929
+ return this.selectNextStateAfterCompletion(input.stateName, input.output);
930
+ case "terminal_acknowledgment":
931
+ return this.runTerminalAcknowledgmentPass();
932
+ case "wake":
933
+ return this.runWakeInput();
670
934
  }
671
- return this.restoreSleepAfterPromptIfNeeded(originalState, terminal);
672
935
  }
673
- async answer(command) {
674
- const message = this.commandToUserMessage(command);
675
- return this.prompt({
676
- type: "prompt",
677
- message,
678
- behavior: command.behavior,
679
- images: command.images,
680
- });
681
- }
682
- async wake() {
683
- const originalState = this.requireRunnerState();
684
- const state = { ...originalState, status: "running" };
685
- if (originalState.status === "sleeping") {
686
- // Sleeping scheduled states already ended the previous duet-agent turn.
687
- // Wake starts a new state-machine-driven turn; normal prompts while
688
- // sleeping instead start parent-driven turns.
689
- const result = await this.stateMachineController.wake();
690
- if (result)
691
- return this.driveStateMachineResult(result, state);
692
- }
693
- return {
694
- type: "complete",
695
- status: "completed",
696
- state: originalState,
697
- result: "Nothing to wake.",
936
+ async runUserCommandPass(command, continuation = false) {
937
+ const runningState = {
938
+ ...this.requireRunnerState(),
939
+ status: "running",
698
940
  };
699
- }
700
- restoreSleepAfterPromptIfNeeded(originalState, terminal) {
701
- if (originalState.status !== "sleeping" ||
702
- terminal.type !== "complete" ||
703
- !isWaitingOnScheduledState(terminal.state.stateMachine)) {
704
- return terminal;
705
- }
706
- if (terminal.status === "failed") {
941
+ const state = continuation ? runningState : this.clearFinishedTodosAtTurnStart(runningState);
942
+ let prompt = this.commandToUserMessage(command);
943
+ if (!continuation &&
944
+ command.behavior === "steer" &&
945
+ this.taskManager.pendingWork().kind === "open") {
946
+ prompt = `${systemReminder(dedent `
947
+ <system-reminder>
948
+ The user sent this as a steer message while state-machine work is running.
949
+ If the state-machine should change course, call select_state_machine_state to restart the current state with updated input or choose a different state.
950
+ </system-reminder>
951
+ `)}\n\n${prompt}`;
952
+ }
953
+ const todoReminder = continuation ? undefined : formatCarriedTodosReminder(state.todos);
954
+ if (todoReminder)
955
+ prompt = `${todoReminder}\n\n${prompt}`;
956
+ if (!continuation)
957
+ this.removeQueuedFollowUpPrompt(command);
958
+ const worker = await this.runParentPass({
959
+ state,
960
+ prompt,
961
+ images: promptImagesToContent(command.images),
962
+ continuation,
963
+ });
964
+ this.setState(worker.outcome.state);
965
+ if (worker.outcome.type === "complete" &&
966
+ worker.outcome.status === "failed" &&
967
+ this.taskManager.pendingWork().kind === "sleep") {
707
968
  this.emit({
708
969
  type: "system",
709
970
  level: "error",
710
- message: terminal.error ?? terminal.result ?? "Prompt failed while waiting.",
971
+ message: worker.outcome.error ?? worker.outcome.result ?? "Prompt failed while waiting.",
711
972
  });
712
973
  }
713
- const state = currentScheduledState(terminal.state.stateMachine);
714
- const progress = state ? terminal.state.stateMachine?.progress?.states[state.name] : undefined;
715
- const wakeAt = progress?.nextWakeAt ?? scheduledStateFallbackWakeAt(state);
716
- return {
717
- type: "sleep",
718
- wakeAt,
719
- state: { ...terminal.state, status: "sleeping" },
720
- };
974
+ if (worker.outcome.type === "interrupted")
975
+ return { type: "interrupted" };
976
+ if (state.mode === "agent") {
977
+ if (worker.control.type === "ask_user_question") {
978
+ return { type: "ask", questions: worker.control.questions };
979
+ }
980
+ return worker.outcome.status === "failed"
981
+ ? { type: "terminal", status: "error", error: worker.outcome.error }
982
+ : { type: "terminal", status: "completed", result: worker.outcome.result };
983
+ }
984
+ const result = await this.stateMachineResultFromWorker(worker, worker.outcome.state);
985
+ if (result)
986
+ return result;
987
+ return worker.outcome.status === "failed"
988
+ ? { type: "terminal", status: "error", error: worker.outcome.error }
989
+ : { type: "terminal", status: "completed", result: worker.outcome.result };
721
990
  }
722
- controllerResultToTerminal(result, baseState = this.requireRunnerState()) {
723
- const state = this.snapshotState(baseState);
724
- switch (result.type) {
725
- case "state_completed":
726
- return completeTurn(state, "completed", JSON.stringify(result.output ?? null));
727
- case "ask":
728
- return {
729
- type: "ask",
730
- questions: result.questions,
731
- state: { ...state, status: "waiting_for_human" },
732
- };
733
- case "sleep":
734
- return { type: "sleep", wakeAt: result.wakeAt, state: { ...state, status: "sleeping" } };
735
- case "interrupted":
736
- return { type: "interrupted", state: { ...state, status: "interrupted" } };
737
- case "terminal":
738
- if (result.status === "error") {
739
- // A runtime failure (poll timeout, unknown/invalid selected state,
740
- // agent/script state failure, misconfigured-poll gate, or exhausted
741
- // protocol-violation retries) is the ONLY state-machine outcome that
742
- // fails the turn. The user-facing message rides on event.error; the
743
- // same string is also recorded on state.stateMachine.terminal.reason
744
- // by recordStateFailed, so the parent's acknowledgment turn and the
745
- // board/relay see the identical reason.
746
- return completeTurn(state, "failed", undefined, result.error);
747
- }
748
- // A deliberately selected terminal (completed/failed/cancelled, incl.
749
- // the auto-injected `cancelled` that `wont do` maps to) is a
750
- // successful turn outcome — not a user abort. Report turn-completion
751
- // status "completed" so the backend never surfaces "Error: cancelled —
752
- // try again". The machine's own status survives on
753
- // state.stateMachine.terminal.status (set by recordStateMachineCompleted),
754
- // which is what the board column resolves from. Only an explicit user
755
- // stop — type:"interrupted" via interrupt() — surfaces the cancel
756
- // message.
757
- return completeTurn(state, "completed", result.result);
758
- }
759
- }
760
- async driveStateMachineResult(result, baseState = this.requireRunnerState()) {
761
- let next = result;
762
- let state = baseState;
763
- // The controller only executes states. TurnRunner owns the continuation
764
- // loop so queued follow-ups always update the parent transcript before the
765
- // parent chooses the next state.
766
- while (next.type === "state_completed") {
767
- state = this.requireRunnerState();
768
- const queued = await this.drainQueuedTurnCommands({
769
- type: "complete",
770
- status: "completed",
771
- state: this.snapshotState({ ...state, status: "running" }),
991
+ async runParentPass(input) {
992
+ const agent = this.requireParentAgent();
993
+ if (agent.hasQueuedMessages()) {
994
+ throw new Error("Parent pi steer/follow-up queues must be empty at internal pass start.");
995
+ }
996
+ const recoveredTaskReminder = this.recoveredTaskReminder;
997
+ this.recoveredTaskReminder = undefined;
998
+ if (recoveredTaskReminder) {
999
+ this.setState({
1000
+ ...this.requireRunnerState(),
1001
+ pendingLostTaskReminderTaskIds: undefined,
772
1002
  });
773
- if (queued.type !== "complete" || queued.status !== "completed")
774
- return queued;
775
- this.setState(queued.state);
776
- state = queued.state;
777
- next = await this.selectNextStateAfterCompletion(next.stateName, next.output);
778
- }
779
- if (next.type === "interrupted" && this.activeStateWorkPrompt) {
780
- return this.activeStateWorkPrompt;
781
- }
782
- if (next.type === "terminal") {
783
- // Run the parent's terminal acknowledgment turn so the
784
- // state-machine outcome is propagated back to the parent.
785
- // `terminate: true` on the state-machine tools means the parent's
786
- // prompt loop ends right after it selects a terminal (or a state
787
- // that then errors at runtime) — without this pass the parent has
788
- // no transcript entry for the outcome and cannot summarize it to
789
- // the user before control returns to them.
790
- const acknowledged = await this.runStateMachineTerminalAcknowledgment();
791
- if (acknowledged)
792
- return acknowledged;
793
- state = this.requireRunnerState();
794
- }
795
- return this.controllerResultToTerminal(next, state);
796
- }
797
- /**
798
- * Re-prompt the parent agent once after a state-machine terminal.
799
- *
800
- * The flag is per-session, so a fresh state machine created during
801
- * the acknowledgment turn — which `createStateMachineSession` builds
802
- * as a brand-new session object — will run its own acknowledgment
803
- * when it terminates. The flag's only job is to prevent the same
804
- * session.terminal from being re-acknowledged, which would otherwise
805
- * happen if the parent (incorrectly) routes back into the controller
806
- * on the already-terminal session and the controller re-records a
807
- * terminal on it.
808
- *
809
- * Behavior:
810
- * - Builds the acknowledgment prompt via
811
- * `formatStateMachineTerminalAcknowledgmentPrompt`. The prompt is
812
- * neutral with respect to "decided vs runtime failure" — the
813
- * parent's own transcript already shows whether it selected the
814
- * terminal, and `status`/`reason` carry the rest of the framing.
815
- * - Runs one parent worker pass with full state-machine tool access.
816
- * The parent is steered to reply in plain text and let control
817
- * return to the user; full tool access only means a control action
818
- * on this turn (e.g. an explicitly instructed create) is handled
819
- * rather than silently dropped, per the next bullet.
820
- * - On interruption, surfaces an `interrupted` terminal event for
821
- * the whole turn.
822
- * - When the parent's reply carries a control action, drives that
823
- * control through the standard `driveStateMachineResult` path so
824
- * any newly-created state machine runs end-to-end and itself
825
- * produces a follow-up terminal (which also gets acknowledged via
826
- * its own session).
827
- * - When the parent's reply is plain text, returns `undefined` so
828
- * the caller renders the original terminal (preserving
829
- * status/error/SM history) and the parent's natural-language
830
- * summary lands on the transcript as the final assistant message.
831
- */
832
- async runStateMachineTerminalAcknowledgment() {
833
- const session = this.stateMachineController.getSession();
1003
+ }
1004
+ const recoveryInput = recoveredTaskReminder
1005
+ ? {
1006
+ ...input,
1007
+ state: { ...input.state, pendingLostTaskReminderTaskIds: undefined },
1008
+ prompt: `${recoveredTaskReminder}\n\n${input.prompt}`,
1009
+ }
1010
+ : input;
1011
+ const parked = currentParkState(this.stateMachine);
1012
+ // Append to the pass already being made, so a transition out of park costs
1013
+ // no extra model call. The selection tools put the same reminder in their
1014
+ // terminating result on the entry pass, before currentState has changed.
1015
+ return this.runAgentWorker(parked
1016
+ ? { ...recoveryInput, prompt: `${recoveryInput.prompt}\n\n${parkNudge(parked.name)}` }
1017
+ : recoveryInput);
1018
+ }
1019
+ async runWakeInput() {
1020
+ const planned = planWake(this.stateMachine, this.clock.now());
1021
+ if (!planned) {
1022
+ return { type: "terminal", status: "completed", result: "Nothing to wake." };
1023
+ }
1024
+ await this.cancelScheduledTasks("Scheduled wake fired.");
1025
+ this.setStateMachine(planned.session);
1026
+ return this.executePlannedWork(planned.work);
1027
+ }
1028
+ async runTerminalAcknowledgmentPass() {
1029
+ const session = this.stateMachine;
834
1030
  if (!session?.terminal || session.terminalAcknowledged)
835
1031
  return undefined;
836
- this.stateMachineController.markTerminalAcknowledged();
837
- const sessionForPrompt = this.stateMachineController.getSession();
838
- if (!sessionForPrompt?.terminal)
1032
+ this.setStateMachine(markTerminalAcknowledged(session));
1033
+ const acknowledged = this.stateMachine;
1034
+ if (!acknowledged?.terminal)
839
1035
  return undefined;
840
- const acknowledgmentPrompt = formatStateMachineTerminalAcknowledgmentPrompt({
841
- session: sessionForPrompt,
1036
+ const worker = await this.runParentPass({
1037
+ state: this.snapshotState({ ...this.requireRunnerState(), status: "running" }),
1038
+ prompt: formatStateMachineTerminalAcknowledgmentPrompt({ session: acknowledged }),
1039
+ continuation: true,
842
1040
  });
843
- // `snapshotState` re-reads parentAgent messages directly, so this turn
844
- // state includes the parent's transcript through the terminal-selecting
845
- // tool call without a separate refresh step.
846
- const turnState = this.snapshotState({ ...this.requireRunnerState(), status: "running" });
847
- const workerResult = await this.runAgentWorker({
848
- state: turnState,
849
- prompt: acknowledgmentPrompt,
850
- ...this.createTools(turnState.mode),
851
- });
852
- this.setState(workerResult.outcome.state);
853
- if (workerResult.outcome.type === "interrupted") {
854
- return { type: "interrupted", state: workerResult.outcome.state };
855
- }
856
- const followUp = await this.controllerResultFromWorkerResult(workerResult, workerResult.outcome.state);
857
- // On the acknowledgment turn the prior machine is already terminal, so a
858
- // create_state_machine_definition here is legal follow-up work, not a
859
- // violation controllerResultFromWorkerResult returns it as a result.
860
- // A plain-text reply returns undefined so the caller renders the original
861
- // terminal with the parent's summary as the final assistant message.
862
- if (followUp) {
863
- return this.driveStateMachineResult(followUp, workerResult.outcome.state);
1041
+ this.setState(worker.outcome.state);
1042
+ if (worker.outcome.type === "interrupted")
1043
+ return { type: "interrupted" };
1044
+ return this.stateMachineResultFromWorker(worker, worker.outcome.state);
1045
+ }
1046
+ async executePlannedWork(work) {
1047
+ if ("terminal" in work) {
1048
+ const settled = recordPlannedTerminal(this.requireStateMachine(), work.terminal);
1049
+ this.setStateMachine(settled.session);
1050
+ return settled.outcome;
1051
+ }
1052
+ if ("schedule" in work) {
1053
+ if (work.schedule.wakeAt > this.clock.now()) {
1054
+ this.taskManager.start({
1055
+ kind: "scheduled",
1056
+ name: work.schedule.stateName,
1057
+ label: `Wait for ${work.schedule.stateName}`,
1058
+ ownerScopeId: this.requireRootScope(),
1059
+ wakeAt: work.schedule.wakeAt,
1060
+ });
1061
+ const settled = recordScheduled(this.requireStateMachine(), work.schedule.stateName, work.schedule.wakeAt);
1062
+ this.setStateMachine(settled.session);
1063
+ return settled.outcome;
1064
+ }
1065
+ const now = this.clock.now();
1066
+ const startedAt = this.stateMachine?.progress?.states[work.schedule.stateName]?.startedAt;
1067
+ const settled = recordSettled(this.requireStateMachine(), work.schedule.stateName, "timer", {
1068
+ type: "completed",
1069
+ output: {
1070
+ elapsedMs: startedAt === undefined ? 0 : Math.max(0, now - startedAt),
1071
+ timestamp: now,
1072
+ },
1073
+ }, undefined, now);
1074
+ this.setStateMachine(settled.session);
1075
+ return settled.outcome;
1076
+ }
1077
+ if ("park" in work)
1078
+ return undefined;
1079
+ if ("subagent" in work.run) {
1080
+ this.startSubagentTask(work.run.subagent, work.run.stateName);
1081
+ }
1082
+ else {
1083
+ this.startShellTask(work.run.shell, work.run.stateName, work.run.pollPolicy);
864
1084
  }
865
1085
  return undefined;
866
1086
  }
1087
+ startSubagentTask(spec, stateName) {
1088
+ const ownerScopeId = this.requireRootScope();
1089
+ const handle = this.taskManager.start({
1090
+ kind: "subagent",
1091
+ name: stateName,
1092
+ label: `Run state ${stateName}`,
1093
+ ownerScopeId,
1094
+ execute: async ({ signal, taskId }) => {
1095
+ const run = this.createStateSubagentRun({
1096
+ state: { kind: "agent", name: stateName, ...spec },
1097
+ prompt: spec.prompt,
1098
+ origin: { taskId },
1099
+ });
1100
+ const metadata = this.stateTasks.get(taskId);
1101
+ if (metadata?.kind === "agent")
1102
+ metadata.run = run;
1103
+ const interrupt = () => run.interrupt(String(signal.reason ?? "Interrupted"));
1104
+ if (signal.aborted)
1105
+ interrupt();
1106
+ signal.addEventListener("abort", interrupt, { once: true });
1107
+ try {
1108
+ return await run.prompt();
1109
+ }
1110
+ finally {
1111
+ signal.removeEventListener("abort", interrupt);
1112
+ }
1113
+ },
1114
+ });
1115
+ this.stateTasks.set(handle.id, { kind: "agent", stateName });
1116
+ }
1117
+ startShellTask(spec, stateName, pollPolicy) {
1118
+ const shell = createShellStateHandle({
1119
+ command: spec.command,
1120
+ cwd: resolveStateCwd(spec.cwd, this.config.cwd ?? process.cwd()),
1121
+ timeoutMs: spec.timeoutMs,
1122
+ successCodes: spec.successCodes,
1123
+ });
1124
+ let taskId;
1125
+ let finish;
1126
+ const finishedPromise = new Promise((resolve) => {
1127
+ finish = resolve;
1128
+ });
1129
+ // Stop semantics are uniform across executors: process groups die by SIGKILL
1130
+ // immediately (matching pi-bash), so the interrupted terminal is never gated
1131
+ // on a grace window. Graceful TERM cleanup, if ever needed, is a future
1132
+ // explicit opt-in, not a blanket default.
1133
+ const unregisterReaper = this.taskManager.registerReaper(async (reason) => {
1134
+ shell.interrupt(reason);
1135
+ await finishedPromise;
1136
+ });
1137
+ const handle = this.taskManager.start({
1138
+ kind: "tool",
1139
+ name: stateName,
1140
+ label: `Run state ${stateName}`,
1141
+ ownerScopeId: this.requireRootScope(),
1142
+ execute: async ({ signal, onOutput }) => {
1143
+ const interrupt = () => shell.interrupt(String(signal.reason ?? "Interrupted"));
1144
+ signal.addEventListener("abort", interrupt, { once: true });
1145
+ try {
1146
+ const output = await shell.run();
1147
+ onOutput(output.stdout);
1148
+ return { type: "completed", output };
1149
+ }
1150
+ catch (error) {
1151
+ const reason = shell.interruptedReason();
1152
+ if (reason !== undefined)
1153
+ return { type: "interrupted", reason };
1154
+ return {
1155
+ type: "failed",
1156
+ error: error instanceof Error ? error.message : String(error),
1157
+ };
1158
+ }
1159
+ finally {
1160
+ signal.removeEventListener("abort", interrupt);
1161
+ finish();
1162
+ unregisterReaper();
1163
+ }
1164
+ },
1165
+ });
1166
+ taskId = handle.id;
1167
+ this.stateTasks.set(taskId, {
1168
+ kind: pollPolicy ? "poll" : "script",
1169
+ stateName,
1170
+ shell,
1171
+ ...(pollPolicy ? { pollPolicy } : {}),
1172
+ });
1173
+ }
1174
+ async recordTaskSettlements(settlements) {
1175
+ let latest;
1176
+ for (const settlement of settlements) {
1177
+ if (this.ignoredTaskSettlements.delete(settlement.id)) {
1178
+ this.stateTasks.delete(settlement.id);
1179
+ continue;
1180
+ }
1181
+ const metadata = this.stateTasks.get(settlement.id);
1182
+ if (!metadata)
1183
+ continue;
1184
+ this.stateTasks.delete(settlement.id);
1185
+ const result = this.stateResultFromSettlement(settlement);
1186
+ const recorded = recordSettled(this.requireStateMachine(), metadata.stateName, metadata.kind, result, this.partialStateOutput(metadata), this.clock.now());
1187
+ this.setStateMachine(recorded.session);
1188
+ latest = recorded.outcome;
1189
+ if (latest.type === "sleep") {
1190
+ this.taskManager.start({
1191
+ kind: "scheduled",
1192
+ name: metadata.stateName,
1193
+ label: `Wait for ${metadata.stateName}`,
1194
+ ownerScopeId: this.requireRootScope(),
1195
+ wakeAt: latest.wakeAt,
1196
+ });
1197
+ }
1198
+ }
1199
+ return latest;
1200
+ }
1201
+ async processTaskSettlements(settlements) {
1202
+ const stateSettlements = settlements.filter((settlement) => this.stateTasks.has(settlement.id) || this.ignoredTaskSettlements.has(settlement.id));
1203
+ const taskSettlements = settlements.filter((settlement) => !stateSettlements.includes(settlement));
1204
+ const stateOutcome = await this.recordTaskSettlements(stateSettlements);
1205
+ if (taskSettlements.length === 0)
1206
+ return stateOutcome;
1207
+ if (stateOutcome) {
1208
+ this.parentInputs.unshift({ type: "task_settlements", settlements: taskSettlements });
1209
+ return stateOutcome;
1210
+ }
1211
+ const snapshots = taskSettlements
1212
+ .map((settlement) => this.taskManager.output(settlement.id))
1213
+ .filter((snapshot) => snapshot !== undefined);
1214
+ if (snapshots.length === 0)
1215
+ return stateOutcome;
1216
+ const worker = await this.runParentPass({
1217
+ state: this.snapshotState({ ...this.requireRunnerState(), status: "running" }),
1218
+ prompt: settlementNotice(snapshots),
1219
+ continuation: true,
1220
+ });
1221
+ this.setState(worker.outcome.state);
1222
+ if (worker.outcome.type === "interrupted")
1223
+ return { type: "interrupted" };
1224
+ if (worker.control.type === "ask_user_question") {
1225
+ return { type: "ask", questions: worker.control.questions };
1226
+ }
1227
+ if (this.requireRunnerState().mode !== "agent") {
1228
+ const controlled = await this.stateMachineResultFromWorker(worker, worker.outcome.state);
1229
+ if (controlled)
1230
+ return controlled;
1231
+ }
1232
+ return worker.outcome.status === "failed"
1233
+ ? { type: "terminal", status: "error", error: worker.outcome.error }
1234
+ : { type: "terminal", status: "completed", result: worker.outcome.result };
1235
+ }
1236
+ stateResultFromSettlement(settlement) {
1237
+ if (settlement.status === "completed") {
1238
+ return settlement.result;
1239
+ }
1240
+ if (settlement.status === "stopped") {
1241
+ return { type: "interrupted", reason: settlement.reason };
1242
+ }
1243
+ return {
1244
+ type: "failed",
1245
+ error: settlement.status === "failed"
1246
+ ? settlement.error instanceof Error
1247
+ ? settlement.error.message
1248
+ : String(settlement.error)
1249
+ : `Task ${settlement.id} was lost.`,
1250
+ };
1251
+ }
1252
+ partialStateOutput(metadata) {
1253
+ if (metadata.kind === "agent") {
1254
+ const assistantText = metadata.run?.partialAssistantText();
1255
+ return assistantText ? { assistantText } : undefined;
1256
+ }
1257
+ return metadata.shell.partialOutput();
1258
+ }
1259
+ async interruptTasks(reason) {
1260
+ for (const [id, metadata] of this.stateTasks) {
1261
+ if (this.taskManager.output(id)?.descriptor.status !== "running")
1262
+ continue;
1263
+ const recorded = recordSettled(this.requireStateMachine(), metadata.stateName, metadata.kind, { type: "interrupted", reason }, this.partialStateOutput(metadata), this.clock.now());
1264
+ this.setStateMachine(recorded.session);
1265
+ this.ignoredTaskSettlements.add(id);
1266
+ }
1267
+ await this.taskManager.interruptAll(reason);
1268
+ }
1269
+ async cancelScheduledTasks(reason) {
1270
+ const scheduled = this.taskManager.list().filter((task) => task.status === "scheduled");
1271
+ for (const task of scheduled)
1272
+ this.ignoredTaskSettlements.add(task.id);
1273
+ await Promise.all(scheduled.map((task) => this.taskManager.stop(task.id, reason)));
1274
+ }
867
1275
  /**
868
1276
  * Build a loop-warning system reminder when the orchestrator has selected the
869
1277
  * same state many times in a row within a short window with no other state
870
1278
  * running in between. This is the idle-"holding" hot loop: re-selecting a
871
1279
  * state to "keep waiting" is a no-op because selecting runs the state again
872
1280
  * immediately rather than suspending. The reminder re-teaches the only
873
- * primitives that actually wait — ask_user_question for a human reply, a poll
1281
+ * primitives that actually wait — park for a parent-owned human gate, a poll
874
1282
  * for a checkable condition, a timer for a fixed time — and tells the parent
875
1283
  * to stop re-selecting the same state unchanged. Returns undefined when the
876
1284
  * streak is below threshold or spread over too long a span to be a hot loop.
877
1285
  */
878
1286
  repeatedSelectionLoopWarning(stateName) {
879
- const session = this.stateMachineController.getSession();
1287
+ const session = this.stateMachine;
880
1288
  const count = session ? repeatedSelectionLoopCount(session, stateName) : undefined;
881
1289
  if (count === undefined)
882
1290
  return undefined;
@@ -885,7 +1293,7 @@ export class TurnRunner {
885
1293
  LOOP DETECTED: you have selected the "${stateName}" state ${count} times in a row, with no other state running in between, in quick succession. Selecting a state is NOT how you wait — every select_state_machine_state call runs the state again immediately and returns, so re-selecting the same "holding" state over and over is a no-op hot loop that suspends nothing.
886
1294
 
887
1295
  If you are waiting on something, back it with the primitive that actually suspends, chosen by WHAT you are waiting for:
888
- - a human reply or approval → an agent state that calls ask_user_question, then END YOUR TURN. The user's answer arrives as a fresh turn, and only then do you select the next state. Do not re-select to "keep waiting", and if the user sends an unrelated message while the question is open, answer it in plain text and end the turn rather than re-parking this state.
1296
+ - a human reply or approval → select a park state, ask the user yourself with ask_user_question, then END YOUR TURN. The user's answer arrives as a fresh parent turn; select the next state when the park's purpose is fulfilled.
889
1297
  - a condition a command can check (CI finished, a file appeared, a deploy went ready) → a poll state whose command exits success only when the condition is actually met.
890
1298
  - a fixed future time → a timer state (wakeAt or wakeAfterMs).
891
1299
 
@@ -895,7 +1303,7 @@ export class TurnRunner {
895
1303
  }
896
1304
  async selectNextStateAfterCompletion(stateName, output) {
897
1305
  const loopWarning = this.repeatedSelectionLoopWarning(stateName);
898
- return this.enforceParentTransition((retryInstruction) => dedent `
1306
+ return this.enforceParentTransition((retryInstruction) => systemReminder(dedent `
899
1307
  The state "${stateName}" finished.
900
1308
 
901
1309
  ${toXML({
@@ -917,38 +1325,16 @@ export class TurnRunner {
917
1325
 
918
1326
  CARRY FORWARD BEFORE YOU SELECT. The next state runs in a fresh sub-agent that cannot see this state_completed output, your reasoning, or your reply text — the ONLY thing it receives is the \`input\` and \`override.prompt\` you pass in the select call. So if the next state's job depends on anything this state just produced (a root cause, file path, diagnosis, ID, count, decision), inline those exact facts into the select's \`input\`/\`override.prompt\` on the FIRST transition into it. Do not select it bare and let it come back confused, then add the context on a retry — a select that advances to a finding-dependent state carrying none of the finding is already wrong, even with the right state name. Summarizing the finding in your message to the user does not carry it forward.
919
1327
  </system-reminder>
920
- `, "State completed, but the runner did not call select_state_machine_state.");
921
- }
922
- /**
923
- * Enforce the transition owed after an agent state asked the user a question
924
- * and the user answered without the parent advancing the machine.
925
- *
926
- * The user's answer already ran as an ordinary parent prompt; reaching here
927
- * means that turn emitted no `select_state_machine_state`, so the machine is
928
- * still suspended at the asking state. Re-prompt under the same bounded
929
- * budget as a completed state, then record an `error` terminal if the parent
930
- * still refuses to advance.
931
- */
932
- async enforceTransitionAfterAnsweredAsk() {
933
- const stateName = this.stateMachineController.getSession()?.currentState ?? "";
934
- return this.enforceParentTransition((retryInstruction) => dedent `
935
- You received the user's answer to the question asked by the "${stateName}" state, but you did not call select_state_machine_state. The machine is still suspended at that state and will not advance on its own.
936
-
937
- <system-reminder>
938
- THIS TURN MUST END WITH A select_state_machine_state TOOL CALL. The user's answer is the input you route on: pick the next state (or a terminal, including a cancelled terminal if the answer means the work should stop) and pass the answer forward via input or override.prompt. Narration without the tool call ("the user chose X, so I'll move to Y") does not advance the machine and will be rejected and re-prompted.
939
-
940
- ${retryInstruction ?? ""}
941
- </system-reminder>
942
- `, "User answered the question, but the runner did not call select_state_machine_state.");
1328
+ `), "State completed, but the runner did not call select_state_machine_state.");
943
1329
  }
944
1330
  /**
945
1331
  * Shared bounded-retry loop for transitions the parent owes the machine.
946
1332
  * Re-prompts up to `PARENT_TRANSITION_RETRY_BUDGET` times with `buildPrompt`
947
- * (the second and later attempts carry a retry reminder); the first attempt
948
- * that emits a control action returns its result. When the budget is
949
- * exhausted with no control action, records an `error` terminal carrying
950
- * `failureReason` — a runtime failure of the machine, not a deliberate
951
- * `failed` selection.
1333
+ * (the second and later attempts carry a retry reminder); the first accepted
1334
+ * selection ends enforcement, with an immediate outcome only for synchronous
1335
+ * work. When the budget is exhausted with no control action, records an
1336
+ * `error` terminal carrying `failureReason` — a runtime failure of the
1337
+ * machine, not a deliberate `failed` selection.
952
1338
  */
953
1339
  async enforceParentTransition(buildPrompt, failureReason) {
954
1340
  for (let attempt = 1; attempt <= PARENT_TRANSITION_RETRY_BUDGET; attempt++) {
@@ -956,162 +1342,82 @@ export class TurnRunner {
956
1342
  ? undefined
957
1343
  : `This is retry ${attempt} of ${PARENT_TRANSITION_RETRY_BUDGET}. You did not call select_state_machine_state last time. You must call select_state_machine_state now.`;
958
1344
  const turnState = this.snapshotState({ ...this.requireRunnerState(), status: "running" });
959
- const workerResult = await this.runAgentWorker({
1345
+ const workerResult = await this.runParentPass({
960
1346
  state: turnState,
961
1347
  prompt: buildPrompt(retryInstruction),
962
- ...this.createTools(turnState.mode),
1348
+ continuation: true,
963
1349
  });
964
1350
  this.setState(workerResult.outcome.state);
965
- const result = await this.controllerResultFromWorkerResult(workerResult, workerResult.outcome.state);
1351
+ const result = await this.stateMachineResultFromWorker(workerResult, workerResult.outcome.state);
966
1352
  if (result)
967
1353
  return result;
1354
+ // Starting an asynchronous state has no immediate outcome. The select
1355
+ // control itself proves the transition obligation was satisfied.
1356
+ if (workerResult.control.type === "select_state_machine_state")
1357
+ return undefined;
968
1358
  }
969
- return this.stateMachineController.failActiveSession(this.stateMachineController.getSession()?.currentState ?? "", failureReason);
970
- }
971
- createStateAgentHandle(input) {
972
- let control = { type: "none" };
973
- const seedMessages = this.resolveStateAgentSeedMessages(input.state);
974
- // Capture the seeded prefix length up front. The sub-agent's result,
975
- // partial text, and recorded usage are all computed by slicing this prefix
976
- // off agent.state.messages so a forked parent transcript isn't folded into
977
- // this run. Reading `seedMessages.length` lazily at each call site would be
978
- // wrong if the agent ever appended into the seed array in place, so snapshot
979
- // the count once.
980
- const seedMessageCount = seedMessages.length;
981
- const state = {
982
- status: "running",
983
- mode: "agent",
984
- // Layer the agent state's optional per-state model/thinkingLevel over the
985
- // inherited parent options; see StateMachineAgentState for why these are
986
- // UI-set rather than model-chosen.
987
- options: {
988
- ...this.requireRunnerState().options,
989
- ...(input.state.model ? { model: input.state.model } : {}),
990
- ...(input.state.thinkingLevel ? { thinkingLevel: input.state.thinkingLevel } : {}),
991
- },
992
- agent: {
993
- status: "running",
994
- messages: seedMessages,
995
- },
1359
+ const failed = failActiveSession(this.requireStateMachine(), this.stateMachine?.currentState ?? "", failureReason);
1360
+ this.setStateMachine(failed.session);
1361
+ return failed.outcome;
1362
+ }
1363
+ createStateSubagentRun(input) {
1364
+ const spec = {
1365
+ prompt: input.prompt,
1366
+ ...(input.state.systemPrompt ? { systemPrompt: input.state.systemPrompt } : {}),
1367
+ ...(input.state.allowedSkills ? { allowedSkills: input.state.allowedSkills } : {}),
1368
+ ...(input.state.cwd ? { cwd: input.state.cwd } : {}),
1369
+ ...(input.state.model ? { model: input.state.model } : {}),
1370
+ ...(input.state.thinkingLevel ? { thinkingLevel: input.state.thinkingLevel } : {}),
1371
+ ...(input.state.forkContext ? { forkContext: true } : {}),
996
1372
  };
997
- const stateSkills = this.skillContext.resolveStateAgentSkills(input.state);
998
- // Expand `/skill` slash commands the same way the parent prompt path does,
999
- // scoped to the skills this state is actually allowed to use. Lets state
1000
- // prompts say "use the /foo skill to do xyz" and have the skill body
1001
- // injected, instead of shipping the literal `/foo` text to the model.
1002
- const expandedPrompt = this.skillContext.resolveSlashSkillPrompt(input.prompt, stateSkills);
1003
- // Hand the sub-agent the machine's overall goal, the full state list, and
1004
- // which state it is running so it scopes its work to this state instead of
1005
- // running the whole process (a common over-reach on smaller models). The
1006
- // session always exists while an agent state is executing.
1007
- const session = this.stateMachineController.getSession();
1373
+ const session = this.stateMachine;
1008
1374
  const machineContext = session
1009
1375
  ? { definition: session.definition, currentState: input.state.name }
1010
1376
  : undefined;
1011
- const forkContext = input.state.forkContext === true;
1012
- const identityLayer = createStateAgentSystemPromptLayer(machineContext);
1013
- // When forking, the sub-agent's identity + per-state systemPrompt layers
1014
- // ride in the tail user turn so the system prompt can stay byte-identical to
1015
- // the parent's and preserve the provider prompt-cache prefix. There is one
1016
- // exception: a state that restricts skills via allowedSkills must NOT inherit
1017
- // the parent's full skill catalog. resolveStateAgentSkills returns undefined
1018
- // only for an unrestricted state; when it returns a concrete allowlist we
1019
- // rebuild the system prompt around that allowlist instead of reusing the
1020
- // parent's verbatim, trading the cache prefix for the allowlist contract.
1021
- const forkSystemPrompt = forkContext
1022
- ? stateSkills === undefined
1023
- ? this.parentAgent?.state.systemPrompt
1024
- : this.createBaseSystemPromptWithAppendedLayers({ skills: stateSkills })
1025
- : undefined;
1026
- const tailPrompt = forkContext
1027
- ? [createForkContextReminder(), identityLayer, input.state.systemPrompt, expandedPrompt]
1028
- .filter((part) => Boolean(part))
1029
- .join("\n\n")
1030
- : expandedPrompt;
1031
- const agent = this.createAgent({
1032
- state,
1033
- ...(forkSystemPrompt ? { systemPrompt: forkSystemPrompt } : {}),
1034
- prependSystemPrompt: forkContext ? undefined : identityLayer,
1035
- appendSystemPrompt: forkContext ? undefined : input.state.systemPrompt,
1036
- skills: stateSkills,
1037
- // Per-state cwd lets one agent state operate on a different
1038
- // repository or subdirectory than the parent runner without
1039
- // mutating shared config.
1040
- ...this.createTools("agent", input.state.cwd),
1041
- }, (result) => {
1042
- control = result;
1377
+ return this.subagentExecutor(spec, {
1378
+ origin: input.origin,
1379
+ ...(machineContext ? { machineContext } : {}),
1043
1380
  });
1044
- let unsubscribe;
1045
- let interruptedReason;
1046
- const finish = () => {
1047
- if (control.type === "ask_user_question") {
1048
- return { type: "ask", questions: control.questions };
1049
- }
1050
- if (agent.state.errorMessage) {
1051
- return { type: "failed", error: agent.state.errorMessage };
1052
- }
1053
- return {
1054
- type: "complete",
1055
- result: assistantText(agent.state.messages.slice(seedMessageCount)),
1056
- };
1057
- };
1058
- const origin = {
1059
- kind: "state_machine_agent",
1060
- state: input.state.name,
1061
- };
1062
- // A state agent runs a turn just like the parent — a loop of completion
1063
- // calls — so it streams a `usage` event per completion from its own
1064
- // `message_end` events. The finally block falls back to summing the whole
1065
- // message list only when no `message_end` fired (stubbed-agent test path).
1066
- let recordedMessageUsage = false;
1067
- return {
1068
- prompt: async () => {
1069
- unsubscribe = agent.subscribe((event) => {
1070
- this.emitAgentEvent(event, origin);
1071
- if (event.type !== "message_end" || event.message.role !== "assistant")
1072
- return;
1073
- // Record always; tick only on a completion that consumed tokens (see
1074
- // the parent path for why a zero-usage attempt must not emit).
1075
- this.recordUsage(event.message.usage, agent.state.model.id);
1076
- recordedMessageUsage = true;
1077
- if (event.message.usage.totalTokens > 0)
1078
- this.emitTurnUsage(origin);
1079
- });
1080
- try {
1081
- await agent.prompt(tailPrompt);
1082
- await this.retryTransientServerErrors(agent);
1083
- return interruptedReason ? { type: "interrupted" } : finish();
1084
- }
1085
- catch (error) {
1086
- if (interruptedReason)
1087
- return { type: "interrupted" };
1088
- if (error instanceof Error)
1089
- return { type: "failed", error: error.message };
1090
- return { type: "failed", error: String(error) };
1091
- }
1092
- finally {
1093
- // Fallback for the stubbed-agent path: when no `message_end` streamed,
1094
- // fold the message list's usage in on every exit path — success,
1095
- // error, or interrupt — so partial work still reaches the aggregate.
1096
- // Slice off the seeded prefix so a forked parent transcript's usage
1097
- // isn't double-counted (parent messages may already carry usage from
1098
- // the parent turn).
1099
- if (!recordedMessageUsage) {
1100
- this.recordUsage(usageFromMessages(agent.state.messages.slice(seedMessageCount)), agent.state.model.id);
1101
- this.emitTurnUsage(origin);
1102
- }
1103
- unsubscribe?.();
1104
- }
1105
- },
1106
- interrupt: (reason) => {
1107
- interruptedReason = reason;
1108
- agent.abort();
1109
- agent.clearAllQueues();
1110
- unsubscribe?.();
1381
+ }
1382
+ /** Build a public spawn only after TaskManager has allocated its identity and child scope. */
1383
+ async createSpawnedSubagentRun(publicSpec, taskId, ownerScopeId, childScopeId, forkSource, callerModelSetting) {
1384
+ const parentSetting = callerModelSetting ??
1385
+ this.requireRunnerState().options?.model ??
1386
+ this.config.model ??
1387
+ DEFAULT_CLI_MODEL;
1388
+ const selection = await this.selectSpawnModel(publicSpec.prompt, parentSetting);
1389
+ const memorySessionId = this.config.sessionId
1390
+ ? `${this.config.sessionId}:sub:${taskId}`
1391
+ : undefined;
1392
+ return this.subagentExecutor({
1393
+ ...publicSpec,
1394
+ model: selection.modelName,
1395
+ ...(selection.thinkingLevel ? { thinkingLevel: selection.thinkingLevel } : {}),
1396
+ }, {
1397
+ origin: { taskId },
1398
+ childScopeId,
1399
+ ownerScopeId,
1400
+ memoryContext: {
1401
+ ...(memorySessionId ? { sessionId: memorySessionId } : {}),
1402
+ horizon: createInitialHorizon(),
1111
1403
  },
1112
- partialAssistantText: () => assistantText(agent.state.messages.slice(seedMessageCount)) || undefined,
1113
- interruptedReason: () => interruptedReason,
1114
- };
1404
+ ...(forkSource ? { forkSource } : {}),
1405
+ });
1406
+ }
1407
+ /** Stateless spawn classifier seam; deliberately never consults the session ModelRouter. */
1408
+ async selectSpawnModel(prompt, parentSetting) {
1409
+ const table = this.routingTable ?? BUILT_IN_ROUTING_TABLE;
1410
+ return classifySpawnModel(prompt, parentSetting, {
1411
+ table,
1412
+ resolveCatalog: routingCatalogAdapter,
1413
+ classifierOptions: this.classifierOptions(table),
1414
+ });
1415
+ }
1416
+ async dropSubagentScratch(taskId) {
1417
+ const session = this.memoryPersistence?.session;
1418
+ if (!session || !this.config.sessionId)
1419
+ return;
1420
+ await replaceSessionObservations(session, `${this.config.sessionId}:sub:${taskId}`, []);
1115
1421
  }
1116
1422
  requireRunnerState() {
1117
1423
  if (!this.state) {
@@ -1119,10 +1425,75 @@ export class TurnRunner {
1119
1425
  }
1120
1426
  return this.state;
1121
1427
  }
1122
- resolveStateAgentSeedMessages(state) {
1123
- if (!state.forkContext)
1124
- return [];
1125
- return [...(this.parentAgent?.state.messages ?? [])];
1428
+ requireStateMachine() {
1429
+ if (!this.stateMachine)
1430
+ throw new Error("No state machine is active.");
1431
+ return this.stateMachine;
1432
+ }
1433
+ /** Protected so test probes can supply a scope when driving tools outside a turn. */
1434
+ requireRootScope() {
1435
+ if (!this.activeRootScopeId)
1436
+ throw new Error("No turn root scope is active.");
1437
+ return this.activeRootScopeId;
1438
+ }
1439
+ setStateMachine(session, notify = false) {
1440
+ this.stateMachine = session;
1441
+ if (notify)
1442
+ this.emit({ type: "state_machine", stateMachine: session });
1443
+ }
1444
+ getActiveStateOutput() {
1445
+ for (const [id, metadata] of this.stateTasks) {
1446
+ if (this.taskManager.output(id)?.descriptor.status !== "running")
1447
+ continue;
1448
+ if (metadata.kind === "agent") {
1449
+ const assistantText = metadata.run?.partialAssistantText();
1450
+ return assistantText
1451
+ ? { state: metadata.stateName, kind: "agent", output: { assistantText } }
1452
+ : { state: metadata.stateName, kind: "agent" };
1453
+ }
1454
+ const output = metadata.shell.partialOutput();
1455
+ return output
1456
+ ? { state: metadata.stateName, kind: metadata.kind, output }
1457
+ : { state: metadata.stateName, kind: metadata.kind };
1458
+ }
1459
+ return undefined;
1460
+ }
1461
+ captureParentControlResult(result) {
1462
+ if (result.type === "none")
1463
+ return;
1464
+ if (this.parentControlResults.length > 0) {
1465
+ throw new Error("A parent pass produced more than one control result.");
1466
+ }
1467
+ this.parentControlResults.push(result);
1468
+ }
1469
+ consumeParentControlResult() {
1470
+ const result = this.parentControlResults.shift() ?? { type: "none" };
1471
+ if (this.parentControlResults.length > 0) {
1472
+ throw new Error("A parent pass produced more than one control result.");
1473
+ }
1474
+ return result;
1475
+ }
1476
+ attachTurnUsage(terminal) {
1477
+ if (!this.turnUsage)
1478
+ return terminal;
1479
+ return {
1480
+ ...terminal,
1481
+ turnUsage: this.turnUsage,
1482
+ usageByModel: this.turnUsageByModel ?? [],
1483
+ ...(this.lastParentUsageSnapshot
1484
+ ? {
1485
+ effectiveContextWindow: this.lastParentUsageSnapshot.effectiveContextWindow,
1486
+ contextWindowUsage: this.lastParentUsageSnapshot.contextWindowUsage,
1487
+ lastMessageUsage: this.lastParentUsageSnapshot.lastMessageUsage,
1488
+ }
1489
+ : {}),
1490
+ };
1491
+ }
1492
+ emitTerminalOnce(terminal) {
1493
+ if (this.terminalEmitted)
1494
+ return;
1495
+ this.terminalEmitted = true;
1496
+ this.emit(terminal);
1126
1497
  }
1127
1498
  getState() {
1128
1499
  if (!this.state)
@@ -1140,10 +1511,18 @@ export class TurnRunner {
1140
1511
  const snapshot = {
1141
1512
  ...state,
1142
1513
  agent: parentAgent,
1143
- stateMachine: this.stateMachineController.getSession(),
1514
+ stateMachine: this.stateMachine,
1144
1515
  todos: copyOptionalArray(state.todos ?? this.state?.todos),
1145
1516
  followUpQueue: copyOptionalArray(state.followUpQueue ?? this.state?.followUpQueue),
1146
- queuedCommands: copyOptionalArray(state.queuedCommands ?? this.state?.queuedCommands),
1517
+ queuedCommands: this.activeRootScopeId !== undefined
1518
+ ? this.parentInputs
1519
+ .filter((input) => input.type === "user_command")
1520
+ .map((input) => input.command)
1521
+ : (this.hydratedQueuedCommands ??
1522
+ copyOptionalArray(state.queuedCommands ?? this.state?.queuedCommands)),
1523
+ tasks: [...this.taskManager.list()],
1524
+ taskOutputTails: this.snapshotTaskOutputTails(),
1525
+ nextTaskId: this.taskManager.nextTaskId(),
1147
1526
  // Carry wire-shaping state through every snapshot so persistence
1148
1527
  // layers (state.json, terminal payloads, getState consumers) see
1149
1528
  // the current value. Copy by spread so consumers can't mutate the
@@ -1157,6 +1536,16 @@ export class TurnRunner {
1157
1536
  };
1158
1537
  return this.applyAutoStateCompaction(snapshot);
1159
1538
  }
1539
+ snapshotTaskOutputTails() {
1540
+ const tails = {};
1541
+ for (const descriptor of this.taskManager.list()) {
1542
+ const output = this.taskManager.output(descriptor.id)?.output;
1543
+ if (output && output.length > 0) {
1544
+ tails[descriptor.id] = output.slice(-3).map((chunk) => chunk.slice(-1_500));
1545
+ }
1546
+ }
1547
+ return Object.keys(tails).length > 0 ? tails : undefined;
1548
+ }
1160
1549
  /**
1161
1550
  * Caps the size of every state that leaves the runner via emit/return/
1162
1551
  * getState. Runs only when `TurnRunnerConfig.autoStateCompaction` is
@@ -1200,20 +1589,6 @@ export class TurnRunner {
1200
1589
  return;
1201
1590
  this.setState({ ...this.state, followUpQueue: [...entries] });
1202
1591
  }
1203
- getQueuedCommands() {
1204
- return [...(this.state?.queuedCommands ?? [])];
1205
- }
1206
- setQueuedCommands(commands) {
1207
- if (!this.state)
1208
- return;
1209
- this.setState({ ...this.state, queuedCommands: [...commands] });
1210
- }
1211
- shiftQueuedCommand() {
1212
- const commands = this.getQueuedCommands();
1213
- const [command, ...remaining] = commands;
1214
- this.setQueuedCommands(remaining);
1215
- return command;
1216
- }
1217
1592
  getTodos() {
1218
1593
  return [...(this.state?.todos ?? [])];
1219
1594
  }
@@ -1268,53 +1643,28 @@ export class TurnRunner {
1268
1643
  ? undefined
1269
1644
  : createStateMachineSystemPromptLayer({ mode: state.mode, session: state }),
1270
1645
  this.config.memoryDbPath !== undefined ? createRecallMemorySystemPromptLayer() : undefined,
1646
+ // Advisor timing guidance ships with the tool: description-only
1647
+ // steering measurably under-triggers consults on hard tasks.
1648
+ this.advisorPolicy?.enabled ? ADVISOR_EXECUTOR_GUIDANCE_LAYER : undefined,
1271
1649
  // Source-of-truth-first guidance always applies: even without
1272
1650
  // configured memory, the agent should still prefer live tools,
1273
1651
  // skills, and files in cwd over guessed answers.
1274
1652
  createSourceOfTruthSystemPromptLayer(),
1275
1653
  ].filter((layer) => Boolean(layer));
1276
1654
  const appendSystemPrompt = layers.length > 0 ? layers.join("\n\n") : undefined;
1277
- this.parentControlResult = { type: "none" };
1655
+ this.parentControlResults.length = 0;
1278
1656
  this.parentAgent = this.createAgent({
1279
1657
  state,
1280
1658
  appendSystemPrompt,
1659
+ parentModelRouting: true,
1660
+ ...(this.modelRouter ? { router: this.modelRouter } : {}),
1281
1661
  ...this.createTools(state.mode),
1282
1662
  }, (result) => {
1283
- this.parentControlResult = result;
1663
+ this.captureParentControlResult(result);
1284
1664
  });
1285
- this.replayFollowUpQueueIntoAgent(this.parentAgent);
1286
1665
  this.snapshotActiveAgentState();
1287
1666
  }
1288
- async runTurnRunnerAgentWithStateMachineTools(input) {
1289
- const workerResult = await this.runAgentWorker({
1290
- state: input.state,
1291
- prompt: input.prompt,
1292
- images: input.images,
1293
- });
1294
- const result = await this.controllerResultFromWorkerResult(workerResult, input.state);
1295
- if (result)
1296
- return this.driveStateMachineResult(result, workerResult.outcome.state);
1297
- // The parent emitted no control action. If the machine is suspended at a
1298
- // state that asked the user a question, the parent owed a transition to
1299
- // advance it (the user's answer arrives as an ordinary parent prompt).
1300
- // Enforce it the same way a completed state does, so an answered ask can
1301
- // never silently stall the machine.
1302
- if (isAwaitingUserAnswer(this.stateMachineController.getSession())) {
1303
- const enforced = await this.enforceTransitionAfterAnsweredAsk();
1304
- return this.driveStateMachineResult(enforced, this.requireRunnerState());
1305
- }
1306
- return this.outcomeToTerminal(workerResult.outcome);
1307
- }
1308
- /**
1309
- * Turns a parent worker turn's control output into the state-machine
1310
- * execution result the turn runner should drive next, or `undefined` when
1311
- * the parent emitted no control tool call. Whether that `undefined` is
1312
- * benign (a top-level agent turn just answering) or a violation (it owed a
1313
- * select_state_machine_state) is the caller's decision. A valid control
1314
- * action — select a state, ask the user, or create a machine (including a
1315
- * replaceActive reset of an active machine) — returns its result.
1316
- */
1317
- async controllerResultFromWorkerResult(workerResult, state) {
1667
+ async stateMachineResultFromWorker(workerResult, state) {
1318
1668
  const control = workerResult.control;
1319
1669
  if (control.type === "none")
1320
1670
  return undefined;
@@ -1327,28 +1677,45 @@ export class TurnRunner {
1327
1677
  // replaceActive. So an active, non-terminal session at this point means
1328
1678
  // the agent deliberately chose to replace it — supersede it (recording a
1329
1679
  // `cancelled` terminal) before installing the new machine in its place.
1330
- const active = this.stateMachineController.getSession();
1680
+ const active = this.stateMachine;
1331
1681
  if (active && !active.terminal) {
1332
- this.stateMachineController.supersedeActiveSession(`Superseded by a new state machine ("${control.definition.name}").`);
1682
+ await this.replaceActiveStateTasks("Replaced by a new state machine.");
1683
+ this.setStateMachine(supersede(active, `Superseded by a new state machine ("${control.definition.name}").`), true);
1333
1684
  }
1334
1685
  const firstState = control.firstState;
1335
- this.stateMachineController.startSession({
1686
+ this.setStateMachine(startStateMachineSession({
1336
1687
  prompt: workerResult.outcome.type === "complete" ? (workerResult.outcome.result ?? "") : "",
1337
1688
  definition: control.definition,
1338
1689
  currentState: firstState,
1339
- });
1340
- return this.stateMachineController.runDecision({ state: firstState });
1690
+ }));
1691
+ return this.runStateMachineDecision({ state: firstState });
1341
1692
  }
1342
- if (!this.stateMachineController.getSession() && typeof state.mode === "object") {
1343
- this.stateMachineController.startSession({
1693
+ if (!this.stateMachine && typeof state.mode === "object") {
1694
+ this.setStateMachine(startStateMachineSession({
1344
1695
  prompt: workerResult.outcome.type === "complete" ? (workerResult.outcome.result ?? "") : "",
1345
1696
  definition: state.mode,
1346
1697
  currentState: control.decision.state,
1347
- });
1698
+ }));
1699
+ }
1700
+ return this.runStateMachineDecision(control.decision);
1701
+ }
1702
+ async runStateMachineDecision(decision) {
1703
+ await this.replaceActiveStateTasks("Replaced by a newly selected state.");
1704
+ await this.cancelScheduledTasks("Replaced by a newly selected state.");
1705
+ const planned = planDecision(this.requireStateMachine(), decision, this.clock.now());
1706
+ this.setStateMachine(planned.session, true);
1707
+ return this.executePlannedWork(planned.work);
1708
+ }
1709
+ async replaceActiveStateTasks(reason) {
1710
+ const active = [...this.stateTasks.entries()].filter(([id]) => this.taskManager.output(id)?.descriptor.status === "running");
1711
+ for (const [id, metadata] of active) {
1712
+ const recorded = recordSettled(this.requireStateMachine(), metadata.stateName, metadata.kind, { type: "interrupted", reason }, this.partialStateOutput(metadata), this.clock.now());
1713
+ this.setStateMachine(recorded.session);
1714
+ this.ignoredTaskSettlements.add(id);
1348
1715
  }
1349
- return this.stateMachineController.runDecision(control.decision);
1716
+ await Promise.all(active.map(([id]) => this.taskManager.stop(id, reason)));
1350
1717
  }
1351
- createTools(mode, cwdOverride) {
1718
+ createTools(mode, cwdOverride, includeParentTools = true, childContext) {
1352
1719
  const cwd = resolveStateCwd(cwdOverride, this.config.cwd ?? process.cwd());
1353
1720
  const todoStorage = {
1354
1721
  getTodos: () => this.getTodos(),
@@ -1362,58 +1729,146 @@ export class TurnRunner {
1362
1729
  const recallStorage = {
1363
1730
  getSession: () => this.memoryPersistence?.session,
1364
1731
  embed: this.memoryPersistence?.embed,
1365
- sessionId: this.config.sessionId,
1732
+ sessionId: childContext?.memorySessionId ?? this.config.sessionId,
1366
1733
  // Reuse the resolved memory model so recall_memory's optional
1367
1734
  // expand flag goes to the same cheap model the observer uses.
1368
1735
  expansionModel: this.resolveMemoryActorModel(undefined),
1369
1736
  };
1370
- if (mode === "agent") {
1371
- return {
1372
- tools: [...createDefaultTurnRunnerTools(cwd, todoStorage, recallStorage), ...mcpTools],
1373
- };
1374
- }
1737
+ const router = this.modelRouter;
1738
+ const advisorStorage = includeParentTools && router && this.advisorPolicy?.enabled
1739
+ ? this.createAskAdvisorStorage(router, this.advisorPolicy)
1740
+ : undefined;
1741
+ const baseTools = mode === "agent"
1742
+ ? createDefaultTurnRunnerTools(cwd, todoStorage, recallStorage, advisorStorage)
1743
+ : createTurnRunnerTools({
1744
+ cwd,
1745
+ mode,
1746
+ getDefinition: () => this.stateMachine?.definition,
1747
+ getStateMachine: () => this.stateMachine,
1748
+ getActiveStateOutput: () => this.getActiveStateOutput(),
1749
+ todoStorage,
1750
+ skills,
1751
+ recallStorage,
1752
+ advisorStorage,
1753
+ clock: this.clock,
1754
+ minimumScheduledDelayMs: this.minimumScheduledDelayMs,
1755
+ });
1756
+ const childSafeTools = includeParentTools
1757
+ ? baseTools
1758
+ : baseTools.filter((tool) => tool.name !== "ask_user_question");
1759
+ const taskAwareTools = childSafeTools.map((tool) => tool.name === "bash"
1760
+ ? wrapBackgroundable(tool, {
1761
+ taskManager: this.taskManager,
1762
+ defaultWaitBudgetMs: this.taskWaitBudgetMs,
1763
+ clock: this.clock,
1764
+ ownerScopeId: () => childContext?.ownerScopeId ?? this.requireRootScope(),
1765
+ label: (params) => String(params.command ?? "bash command"),
1766
+ onTaskStarted: (id, foregroundPending) => {
1767
+ this.taskSettlementDelivery.set(id, foregroundPending ? "foreground_pending" : "deliver");
1768
+ },
1769
+ onForegroundResult: (id, converted) => {
1770
+ this.taskSettlementDelivery.set(id, converted ? "deliver" : "suppress");
1771
+ this.scheduleSettlementDelivery();
1772
+ },
1773
+ })
1774
+ : tool);
1375
1775
  return {
1376
1776
  tools: [
1377
- ...createTurnRunnerTools({
1378
- cwd,
1379
- mode,
1380
- getDefinition: () => this.stateMachineController.getSession()?.definition,
1381
- getStateMachine: () => this.stateMachineController.getSession(),
1382
- getActiveStateOutput: () => this.stateMachineController.getActiveOutput(),
1383
- todoStorage,
1384
- skills,
1385
- recallStorage,
1386
- }),
1777
+ ...taskAwareTools,
1778
+ ...(includeParentTools || childContext
1779
+ ? [
1780
+ this.createSpawnTool(childContext),
1781
+ ...createTaskAdminTools({ taskManager: this.taskManager, clock: this.clock }),
1782
+ ]
1783
+ : []),
1387
1784
  ...mcpTools,
1388
1785
  ],
1389
1786
  };
1390
1787
  }
1391
- replayFollowUpQueueIntoAgent(agent) {
1392
- for (const entry of this.getFollowUpQueue()) {
1393
- agent.followUp(buildUserAgentMessage(entry.message, entry.images));
1394
- }
1395
- }
1396
- async runAgentMode(state, prompt, images) {
1397
- const workerResult = await this.runAgentWorker({
1398
- state,
1399
- prompt,
1400
- images,
1788
+ createSpawnTool(childContext) {
1789
+ const ownerScopeId = () => childContext?.ownerScopeId ?? this.requireRootScope();
1790
+ return createSpawnAgentTool({
1791
+ taskManager: this.taskManager,
1792
+ defaultWaitBudgetMs: this.taskWaitBudgetMs,
1793
+ clock: this.clock,
1794
+ ownerScopeId,
1795
+ createRun: async ({ spec, taskId, ownerScopeId: callerScopeId, childScopeId }) => this.createSpawnedSubagentRun(spec, taskId, callerScopeId, childScopeId, childContext?.forkSource, childContext?.modelSetting()),
1796
+ dropScratch: async (taskId) => this.dropSubagentScratch(taskId),
1797
+ onTaskStarted: (id, foregroundPending) => {
1798
+ this.taskSettlementDelivery.set(id, foregroundPending ? "foreground_pending" : "deliver");
1799
+ },
1800
+ onForegroundResult: (id, converted) => {
1801
+ this.taskSettlementDelivery.set(id, converted ? "deliver" : "suppress");
1802
+ this.scheduleSettlementDelivery();
1803
+ },
1401
1804
  });
1402
- if (workerResult.control.type === "ask_user_question") {
1403
- return this.askUserQuestion(workerResult.outcome.state, workerResult.control);
1404
- }
1405
- return this.outcomeToTerminal(workerResult.outcome);
1406
1805
  }
1407
- async runAgentWorker(input) {
1806
+ createAskAdvisorStorage(router, policy) {
1807
+ const resolveAdvisorModel = () => resolveModelName(policy.target.modelName);
1808
+ return {
1809
+ getMessages: () => this.parentAgent?.state.messages ?? this.state?.agent.messages ?? [],
1810
+ getSystemPrompt: () => this.parentAgent?.state.systemPrompt ?? "",
1811
+ getObservations: async () => {
1812
+ const session = this.memoryPersistence?.session;
1813
+ const sessionId = this.config.sessionId;
1814
+ if (!session || !sessionId)
1815
+ return [];
1816
+ const snapshot = await readSessionObservations(session, sessionId);
1817
+ return snapshot.observations.map((observation) => observation.content);
1818
+ },
1819
+ budgetTokens: policy.transcriptTokens,
1820
+ modelName: () => resolveAdvisorModel().id,
1821
+ thinkingLevel: policy.target.thinkingLevel,
1822
+ advisorGate: () => router.beginAdvisorConsult(),
1823
+ noteAdvisorConsult: (success = true) => router.endAdvisorConsult(success),
1824
+ recordUsage: (usage) => {
1825
+ const model = resolveAdvisorModel();
1826
+ this.recordAndEmitUsage(usageFromAiSdk(usage, model), model.id);
1827
+ },
1828
+ onUsageError: (error) => {
1829
+ const reason = error instanceof Error ? error.message : String(error);
1830
+ this.emit({
1831
+ type: "system",
1832
+ level: "warn",
1833
+ message: `Advisor usage accounting failed; advice was retained: ${reason}`,
1834
+ });
1835
+ },
1836
+ };
1837
+ }
1838
+ /**
1839
+ * Pass-prep applied to EVERY parent pass, including harness overrides of
1840
+ * `runAgentWorker` (they must call this first). One-shot reminders that must
1841
+ * reach "whatever parent pass runs next" live here.
1842
+ */
1843
+ prepareParentPassInput(input) {
1844
+ const withheldAsk = this.withheldAskQuestions;
1845
+ this.withheldAskQuestions = undefined;
1846
+ if (!withheldAsk)
1847
+ return input;
1848
+ return { ...input, prompt: `${withheldAskReminder(withheldAsk)}\n\n${input.prompt}` };
1849
+ }
1850
+ async runAgentWorker(rawInput) {
1851
+ const input = this.prepareParentPassInput(rawInput);
1408
1852
  if (this.parentAgentRunning) {
1409
1853
  throw new Error("Cannot start a parent agent while another parent agent is active.");
1410
1854
  }
1411
1855
  const agent = this.requireParentAgent();
1412
- this.parentControlResult = { type: "none" };
1856
+ this.applyPendingModelSelection(agent);
1857
+ agent.state.tools = this.turnTools ?? this.createTools(input.state.mode).tools;
1858
+ this.parentControlResults.length = 0;
1413
1859
  this.setParentAgentRunning(true);
1414
1860
  const unsubscribe = agent.subscribe((event) => this.emitParentAgentEvent(event));
1415
1861
  this.parentAgentInterrupted = false;
1416
1862
  try {
1863
+ if (!input.continuation) {
1864
+ this.modelRouter?.noteTurnStart({ promptHasImages: (input.images?.length ?? 0) > 0 });
1865
+ }
1866
+ const switched = await this.modelRouter?.prepareTurn({
1867
+ prevTurnHint: input.prompt,
1868
+ signal: agent.signal,
1869
+ });
1870
+ if (switched)
1871
+ await this.applyRouterSwitch(agent, switched);
1417
1872
  await agent.prompt(input.prompt, input.images);
1418
1873
  // Single-shot recovery: if the provider rejected the first attempt
1419
1874
  // with a context-overflow error, advance the sticky wire-shaping
@@ -1439,7 +1894,7 @@ export class TurnRunner {
1439
1894
  }
1440
1895
  if (this.parentAgentInterrupted) {
1441
1896
  return {
1442
- control: this.parentControlResult,
1897
+ control: this.consumeParentControlResult(),
1443
1898
  outcome: { type: "interrupted", state: this.requireRunnerState() },
1444
1899
  };
1445
1900
  }
@@ -1456,13 +1911,11 @@ export class TurnRunner {
1456
1911
  },
1457
1912
  todos: live?.todos ?? input.state.todos,
1458
1913
  followUpQueue: live?.followUpQueue ?? input.state.followUpQueue,
1459
- queuedCommands: live?.queuedCommands ?? input.state.queuedCommands,
1914
+ tasks: live?.tasks ?? input.state.tasks,
1915
+ nextTaskId: live?.nextTaskId ?? input.state.nextTaskId,
1460
1916
  };
1461
- if (status === "completed") {
1462
- await this.updateMemoryAfterAgentRun(messages, state.options);
1463
- }
1464
1917
  return {
1465
- control: this.parentControlResult,
1918
+ control: this.consumeParentControlResult(),
1466
1919
  outcome: {
1467
1920
  type: "complete",
1468
1921
  status,
@@ -1537,6 +1990,7 @@ export class TurnRunner {
1537
1990
  return false;
1538
1991
  messages.pop();
1539
1992
  this.wireGuardHorizon.evictionHorizon = newHorizon;
1993
+ this.modelRouter?.noteCompaction();
1540
1994
  const remaining = applyEvictionHorizon(observable, newHorizon).length;
1541
1995
  const dropped = observable.length - remaining;
1542
1996
  this.emit({
@@ -1608,7 +2062,7 @@ export class TurnRunner {
1608
2062
  attempt = messagesAppended > 1 ? 1 : attempt + 1;
1609
2063
  }
1610
2064
  }
1611
- async updateMemoryAfterAgentRun(messages, options) {
2065
+ async updateMemoryAfterAgentRun(messages, options, sessionId = this.config.sessionId, refreshReflections = true) {
1612
2066
  if (this.config.memoryDbPath === undefined) {
1613
2067
  return;
1614
2068
  }
@@ -1624,7 +2078,7 @@ export class TurnRunner {
1624
2078
  const result = await updateObservationalMemory({
1625
2079
  session,
1626
2080
  memory: this.memory,
1627
- sessionId: this.config.sessionId,
2081
+ sessionId,
1628
2082
  effectiveContext: this.resolveEffectiveContext(this.parentAgent?.state.model.contextWindow),
1629
2083
  actorModel: memoryModel,
1630
2084
  settings: this.config.memory,
@@ -1646,7 +2100,7 @@ export class TurnRunner {
1646
2100
  // the new condensed view. Observer-only updates intentionally do
1647
2101
  // NOT refresh the pack — they leave the rendered prefix stable so
1648
2102
  // the provider's prompt cache survives.
1649
- if (result.reflections.length > 0) {
2103
+ if (refreshReflections && result.reflections.length > 0) {
1650
2104
  await this.refreshMemoryContextPack();
1651
2105
  }
1652
2106
  }
@@ -1707,14 +2161,19 @@ export class TurnRunner {
1707
2161
  }
1708
2162
  createAgent(input, onControlResult) {
1709
2163
  const options = this.resolveTurnOptions(undefined, input.state.options);
1710
- const model = resolveModelName(options.model ?? DEFAULT_CLI_MODEL);
2164
+ const initialRoute = input.router?.initialTarget({ hasImages: false });
2165
+ const model = resolveModelName(initialRoute?.modelName ?? options.model ?? DEFAULT_CLI_MODEL);
1711
2166
  // Parent agent configuration is derived from start/session options, not
1712
- // per-prompt command options. Keeping model and prompt shape stable protects
1713
- // prompt caching across all pi-agent turns inside a duet-agent session.
1714
- return new Agent({
2167
+ // per-prompt command options. For concrete selections the model stays
2168
+ // stable for the whole session to protect prompt caching; routed sessions
2169
+ // deliberately trade cache prefixes away when the ModelRouter swaps the
2170
+ // model (per turn or via prepareNextTurn), with the classifier prompt
2171
+ // carrying the don't-switch-mid-task cache preference.
2172
+ let agent;
2173
+ agent = new Agent({
1715
2174
  initialState: {
1716
2175
  model,
1717
- thinkingLevel: options.thinkingLevel ?? "medium",
2176
+ thinkingLevel: initialRoute?.thinkingLevel ?? options.thinkingLevel ?? "medium",
1718
2177
  systemPrompt: input.systemPrompt ??
1719
2178
  this.createBaseSystemPromptWithAppendedLayers({
1720
2179
  prepend: [input.prependSystemPrompt],
@@ -1724,7 +2183,18 @@ export class TurnRunner {
1724
2183
  messages: input.state.agent.messages,
1725
2184
  tools: input.tools,
1726
2185
  },
1727
- transformContext: this.createMemoryTransform(),
2186
+ transformContext: this.createMemoryTransform(input.memoryContext),
2187
+ ...(input.parentModelRouting
2188
+ ? {
2189
+ prepareNextTurn: async (signal) => {
2190
+ const switched = await this.modelRouter?.prepareTurn({
2191
+ signal,
2192
+ });
2193
+ return switched ? await this.applyRouterSwitch(agent, switched) : undefined;
2194
+ },
2195
+ }
2196
+ : {}),
2197
+ steeringMode: "all",
1728
2198
  toolExecution: "parallel",
1729
2199
  afterToolCall: async (context) => {
1730
2200
  const details = context.result.details;
@@ -1735,8 +2205,9 @@ export class TurnRunner {
1735
2205
  },
1736
2206
  getApiKey: resolveProviderApiKey,
1737
2207
  });
2208
+ return agent;
1738
2209
  }
1739
- createMemoryTransform() {
2210
+ createMemoryTransform(memoryContext) {
1740
2211
  // The memory transform owns history retention against both the token
1741
2212
  // budget (context-window cost on smaller models) and the wire-byte
1742
2213
  // budget (gateway request-body caps). It applies the sticky horizon
@@ -1744,10 +2215,26 @@ export class TurnRunner {
1744
2215
  // provider prompt cache stays valid between eviction events.
1745
2216
  return createObservationalContextTransform({
1746
2217
  memory: this.memory,
1747
- effectiveContext: this.resolveEffectiveContext(this.parentAgent?.state.model.contextWindow),
2218
+ effectiveContext: () => this.resolveEffectiveContext(this.parentAgent?.state.model.contextWindow),
1748
2219
  settings: this.config.memory,
1749
- horizon: this.wireGuardHorizon,
1750
- onCompaction: (messages) => this.ensureMemoryCoverageForCompaction(messages),
2220
+ horizon: memoryContext?.horizon ?? this.wireGuardHorizon,
2221
+ onCompaction: async (messages) => {
2222
+ if (memoryContext) {
2223
+ try {
2224
+ await this.updateMemoryAfterAgentRun(messages, undefined, memoryContext.sessionId, false);
2225
+ }
2226
+ catch (error) {
2227
+ this.emit({
2228
+ type: "system",
2229
+ level: "warn",
2230
+ message: `compact: child observation drain failed (${truncateForSystemMessage(error instanceof Error ? error.message : String(error))}); evicting anyway.`,
2231
+ });
2232
+ }
2233
+ return;
2234
+ }
2235
+ await this.ensureMemoryCoverageForCompaction(messages);
2236
+ this.modelRouter?.noteCompaction();
2237
+ },
1751
2238
  });
1752
2239
  }
1753
2240
  // Sticky across all turns within this runner instance. Resets on
@@ -1842,31 +2329,6 @@ export class TurnRunner {
1842
2329
  createBaseSystemPromptWithAppendedLayers(input) {
1843
2330
  return this.skillContext.createSystemPromptWithAppendedLayers(input);
1844
2331
  }
1845
- askUserQuestion(state, control) {
1846
- return {
1847
- type: "ask",
1848
- questions: control.questions,
1849
- state: { ...state, status: "waiting_for_human" },
1850
- };
1851
- }
1852
- /**
1853
- * Lifts the internal `AgentWorkerOutcome` shape into the public
1854
- * `TurnTerminalEvent` shape returned by `runTurnChain`. `runTurnChain`
1855
- * attaches `usage` / `effectiveContextWindow` / `contextWindowUsage`
1856
- * at the chain boundary, so this conversion intentionally omits them.
1857
- */
1858
- outcomeToTerminal(outcome) {
1859
- if (outcome.type === "interrupted") {
1860
- return { type: "interrupted", state: outcome.state };
1861
- }
1862
- return {
1863
- type: "complete",
1864
- status: outcome.status,
1865
- state: outcome.state,
1866
- result: outcome.result,
1867
- error: outcome.error,
1868
- };
1869
- }
1870
2332
  /**
1871
2333
  * Single source of truth for per-turn option precedence:
1872
2334
  * explicit turn options → carried-over state base → runner config → defaults.
@@ -1887,6 +2349,11 @@ export class TurnRunner {
1887
2349
  this.turnUsageByModel = addUsageByModel(this.turnUsageByModel, modelId, usage);
1888
2350
  }
1889
2351
  }
2352
+ /** Attribute a non-parent model call and publish it once a flat context snapshot exists. */
2353
+ recordAndEmitUsage(usage, modelId) {
2354
+ this.recordUsage(usage, modelId);
2355
+ this.emitTurnUsage();
2356
+ }
1890
2357
  emitAgentEvent(event, origin) {
1891
2358
  if (event.type === "message_start" && event.message.role === "user") {
1892
2359
  this.removeFollowUpPrompt(agentMessageText(event.message));
@@ -1897,6 +2364,9 @@ export class TurnRunner {
1897
2364
  }
1898
2365
  emitParentAgentEvent(event) {
1899
2366
  this.emitAgentEvent(event);
2367
+ if (event.type === "turn_end" && event.message.role === "assistant") {
2368
+ this.modelRouter?.noteAssistantStep(routerStepObservation(event.message, event.toolResults));
2369
+ }
1900
2370
  if (event.type !== "message_end" || event.message.role !== "assistant")
1901
2371
  return;
1902
2372
  // Cache the parent's latest bar/breakdown so subsequent state-agent and
@@ -1913,7 +2383,7 @@ export class TurnRunner {
1913
2383
  // consumed tokens, so a rejected attempt (e.g. a provider context-overflow
1914
2384
  // error the turn then recovers from) carries no usage and would otherwise
1915
2385
  // emit a degenerate all-zero bar.
1916
- this.recordUsage(event.message.usage, this.requireParentAgent().state.model.id);
2386
+ this.recordUsage(event.message.usage, event.message.model);
1917
2387
  if (event.message.usage.totalTokens > 0)
1918
2388
  this.emitTurnUsage();
1919
2389
  }
@@ -1962,14 +2432,7 @@ export class TurnRunner {
1962
2432
  // can still refresh `lastUsage`. Session cost was already credited
1963
2433
  // at the prior terminal, so a zero `turnUsage.cost.total` does not
1964
2434
  // lose any spend.
1965
- const turnUsage = this.turnUsage ?? {
1966
- input: 0,
1967
- output: 0,
1968
- cacheRead: 0,
1969
- cacheWrite: 0,
1970
- totalTokens: 0,
1971
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1972
- };
2435
+ const turnUsage = this.turnUsage ?? emptyTokenUsage();
1973
2436
  this.emit({
1974
2437
  type: "usage",
1975
2438
  turnUsage,
@@ -1983,11 +2446,9 @@ export class TurnRunner {
1983
2446
  * Emit a `usage` event reflecting the latest `this.turnUsage`. Reuses the
1984
2447
  * most recent parent context-window snapshot for the bar/breakdown so
1985
2448
  * mid-turn ticks (parent worker finish, state-agent finish) surface cost
1986
- * without jittering the parent context fields. No-ops when no usage has
1987
- * been recorded yet or before the first parent emission both are only
1988
- * possible during construction/teardown or in test harnesses; a real
1989
- * parent worker always sets the snapshot before its terminal usage is
1990
- * recorded.
2449
+ * without jittering the parent context fields. Before the first parent
2450
+ * completion, usage is recorded for the terminal and the first later usage
2451
+ * event, but no incomplete wire event is emitted.
1991
2452
  */
1992
2453
  emitTurnUsage(origin) {
1993
2454
  if (!this.turnUsage || !this.lastParentUsageSnapshot)
@@ -2055,6 +2516,134 @@ export class TurnRunner {
2055
2516
  const userValue = this.config.effectiveContext ?? DEFAULT_EFFECTIVE_CONTEXT;
2056
2517
  return modelWindow !== undefined ? Math.min(userValue, modelWindow) : userValue;
2057
2518
  }
2519
+ /** Load the project table and build the parent router when the persisted selection is virtual. */
2520
+ async initializeModelRouter(modelName) {
2521
+ this.modelRouter = undefined;
2522
+ this.routingTable = undefined;
2523
+ this.advisorPolicy = undefined;
2524
+ const loaded = await loadRoutingTable({
2525
+ cwd: this.config.cwd ?? process.cwd(),
2526
+ catalogAdapter: routingCatalogAdapter,
2527
+ });
2528
+ this.routingTable = loaded.table;
2529
+ if (!modelName || !isVirtualModel(modelName, loaded.table))
2530
+ return;
2531
+ this.advisorPolicy = loaded.table.tiers[modelName].advisor;
2532
+ this.modelRouter = this.createBoundModelRouter(modelName, loaded.table, routingCatalogAdapter);
2533
+ }
2534
+ createBoundModelRouter(tier, table, resolveCatalog) {
2535
+ return this.createModelRouter({
2536
+ table,
2537
+ tier,
2538
+ classify: async (input, signal) => {
2539
+ return classifyRoute(input, { ...this.classifierOptions(table), signal });
2540
+ },
2541
+ resolveCatalog,
2542
+ });
2543
+ }
2544
+ /** One metered classifier contract shared by parent routing and spawned children. */
2545
+ classifierOptions(table) {
2546
+ const model = resolveModelName(table.classifier.target.modelName);
2547
+ return {
2548
+ model: `${model.provider}:${model.id}`,
2549
+ thinkingLevel: table.classifier.target.thinkingLevel,
2550
+ onUsage: (usage) => this.recordAndEmitUsage(usage, model.id),
2551
+ };
2552
+ }
2553
+ /** True when a model name is owned by the validated project table. */
2554
+ isVirtualModelSelection(modelName) {
2555
+ return isVirtualModel(modelName, this.routingTable ?? BUILT_IN_ROUTING_TABLE);
2556
+ }
2557
+ /** Read-only routing snapshot for session-owned UI surfaces. */
2558
+ routeStatus() {
2559
+ return this.modelRouter?.status();
2560
+ }
2561
+ /**
2562
+ * Retarget subsequent parent turns while preserving the current in-flight turn.
2563
+ * Concrete selections pin routing; virtual selections rebuild it so the next
2564
+ * turn classifies from a fresh tier-local baseline.
2565
+ */
2566
+ setModel(modelName) {
2567
+ const routed = this.isVirtualModelSelection(modelName);
2568
+ if (!routed)
2569
+ resolveModelName(modelName);
2570
+ const state = this.requireRunnerState();
2571
+ state.options = { ...state.options, model: modelName };
2572
+ this.pendingModelSelection = modelName;
2573
+ if (!this.parentAgentRunning)
2574
+ this.applyPendingModelSelection(this.requireParentAgent());
2575
+ return { routed };
2576
+ }
2577
+ applyPendingModelSelection(agent) {
2578
+ const selection = this.pendingModelSelection;
2579
+ if (!selection)
2580
+ return;
2581
+ this.pendingModelSelection = undefined;
2582
+ if (!this.isVirtualModelSelection(selection)) {
2583
+ this.modelRouter?.pin();
2584
+ this.advisorPolicy = undefined;
2585
+ agent.state.model = resolveModelName(selection);
2586
+ return;
2587
+ }
2588
+ const table = this.routingTable ?? BUILT_IN_ROUTING_TABLE;
2589
+ this.modelRouter?.unpin();
2590
+ this.advisorPolicy = table.tiers[selection].advisor;
2591
+ this.modelRouter = this.createBoundModelRouter(selection, table, routingCatalogAdapter);
2592
+ const initial = this.modelRouter.initialTarget({ hasImages: false });
2593
+ agent.state.model = resolveModelName(initial.modelName);
2594
+ agent.state.thinkingLevel = initial.thinkingLevel;
2595
+ }
2596
+ /** Explicit virtual state models take the tier's default route without classifier latency. */
2597
+ resolveStateModel(modelName) {
2598
+ if (!modelName || !this.isVirtualModelSelection(modelName))
2599
+ return undefined;
2600
+ const table = this.routingTable ?? BUILT_IN_ROUTING_TABLE;
2601
+ return resolveTierDefault(table, modelName, { hasImages: false }, routingCatalogAdapter);
2602
+ }
2603
+ /** Omitted child models inherit the parent's active concrete target, not its virtual setting. */
2604
+ resolveSubagentInheritedOptions() {
2605
+ const options = { ...this.requireRunnerState().options };
2606
+ if (!options.model || !this.isVirtualModelSelection(options.model))
2607
+ return options;
2608
+ const parent = this.requireParentAgent().state;
2609
+ return {
2610
+ ...options,
2611
+ model: `${parent.model.provider}:${parent.model.id}`,
2612
+ ...(parent.thinkingLevel === "off" ? {} : { thinkingLevel: parent.thinkingLevel }),
2613
+ };
2614
+ }
2615
+ /** Composition seam used by production binding and deterministic runner tests. */
2616
+ createModelRouter(options) {
2617
+ return new ModelRouter(options);
2618
+ }
2619
+ /** Resolve and atomically apply one router-owned model/effort change. */
2620
+ async applyRouterSwitch(agent, switched) {
2621
+ const model = resolveModelName(switched.toModel);
2622
+ agent.state.model = model;
2623
+ agent.state.thinkingLevel = switched.thinkingLevel;
2624
+ if (this.lastParentUsageSnapshot) {
2625
+ this.lastParentUsageSnapshot = {
2626
+ ...this.lastParentUsageSnapshot,
2627
+ effectiveContextWindow: this.resolveEffectiveContext(model.contextWindow),
2628
+ };
2629
+ }
2630
+ this.emit({ type: "router_switch", ...switched });
2631
+ return { model, thinkingLevel: switched.thinkingLevel };
2632
+ }
2633
+ }
2634
+ const ROUTER_STEP_TEXT_LIMIT = 2_000;
2635
+ function routerStepObservation(message, toolResults) {
2636
+ const content = [...message.content, ...toolResults.flatMap((result) => result.content)];
2637
+ const text = content
2638
+ .filter((block) => block.type === "text")
2639
+ .map((block) => stripSystemReminders(block.text))
2640
+ .filter(Boolean)
2641
+ .join("\n")
2642
+ .trim();
2643
+ return {
2644
+ blockTypes: content.map((block) => block.type),
2645
+ text: text.slice(-ROUTER_STEP_TEXT_LIMIT),
2646
+ };
2058
2647
  }
2059
2648
  function sleep(ms) {
2060
2649
  return new Promise((resolve) => setTimeout(resolve, ms));