@duetso/agent 0.2.2 → 0.2.4
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.
- package/README.md +19 -11
- package/dist/package.json +4 -4
- package/dist/src/cli/config.d.ts +10 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +24 -0
- package/dist/src/cli/config.js.map +1 -0
- package/dist/src/cli/help.d.ts.map +1 -1
- package/dist/src/cli/help.js +16 -13
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/inline-slash.d.ts +2 -1
- package/dist/src/cli/inline-slash.d.ts.map +1 -1
- package/dist/src/cli/inline-slash.js +14 -5
- package/dist/src/cli/inline-slash.js.map +1 -1
- package/dist/src/cli/memory-query.d.ts +1 -1
- package/dist/src/cli/model.d.ts +1 -1
- package/dist/src/cli/model.d.ts.map +1 -1
- package/dist/src/cli/model.js +1 -1
- package/dist/src/cli/model.js.map +1 -1
- package/dist/src/cli/route.d.ts +84 -0
- package/dist/src/cli/route.d.ts.map +1 -0
- package/dist/src/cli/route.js +261 -0
- package/dist/src/cli/route.js.map +1 -0
- package/dist/src/cli/rpc.d.ts +52 -9
- package/dist/src/cli/rpc.d.ts.map +1 -1
- package/dist/src/cli/rpc.js +195 -28
- package/dist/src/cli/rpc.js.map +1 -1
- package/dist/src/cli/run.d.ts +8 -1
- package/dist/src/cli/run.d.ts.map +1 -1
- package/dist/src/cli/run.js +24 -7
- package/dist/src/cli/run.js.map +1 -1
- package/dist/src/cli/shared.d.ts +4 -3
- package/dist/src/cli/shared.d.ts.map +1 -1
- package/dist/src/cli/shared.js +3 -4
- package/dist/src/cli/shared.js.map +1 -1
- package/dist/src/cli/shutdown.d.ts +4 -2
- package/dist/src/cli/shutdown.d.ts.map +1 -1
- package/dist/src/cli/shutdown.js +4 -2
- package/dist/src/cli/shutdown.js.map +1 -1
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +12 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/core/structured-output.d.ts +2 -0
- package/dist/src/core/structured-output.d.ts.map +1 -1
- package/dist/src/core/structured-output.js +1 -0
- package/dist/src/core/structured-output.js.map +1 -1
- package/dist/src/lib/system-reminder.d.ts +14 -0
- package/dist/src/lib/system-reminder.d.ts.map +1 -0
- package/dist/src/lib/system-reminder.js +53 -0
- package/dist/src/lib/system-reminder.js.map +1 -0
- package/dist/src/lib/walk-ancestors.d.ts +9 -0
- package/dist/src/lib/walk-ancestors.d.ts.map +1 -0
- package/dist/src/lib/walk-ancestors.js +28 -0
- package/dist/src/lib/walk-ancestors.js.map +1 -0
- package/dist/src/memory/observational-prompts.d.ts.map +1 -1
- package/dist/src/memory/observational-prompts.js +2 -1
- package/dist/src/memory/observational-prompts.js.map +1 -1
- package/dist/src/memory/observational.d.ts +31 -4
- package/dist/src/memory/observational.d.ts.map +1 -1
- package/dist/src/memory/observational.js +43 -4
- package/dist/src/memory/observational.js.map +1 -1
- package/dist/src/model-resolution/catalog.d.ts +2 -2
- package/dist/src/model-resolution/catalog.d.ts.map +1 -1
- package/dist/src/model-resolution/catalog.js +61 -33
- package/dist/src/model-resolution/catalog.js.map +1 -1
- package/dist/src/model-resolution/duet-gateway.d.ts +3 -3
- package/dist/src/model-resolution/duet-gateway.d.ts.map +1 -1
- package/dist/src/model-resolution/duet-gateway.js +119 -19
- package/dist/src/model-resolution/duet-gateway.js.map +1 -1
- package/dist/src/model-resolution/model-gateway.d.ts.map +1 -0
- package/dist/src/{cli → model-resolution}/model-gateway.js +10 -6
- package/dist/src/model-resolution/model-gateway.js.map +1 -0
- package/dist/src/model-resolution/resolver.d.ts +11 -4
- package/dist/src/model-resolution/resolver.d.ts.map +1 -1
- package/dist/src/model-resolution/resolver.js +39 -8
- package/dist/src/model-resolution/resolver.js.map +1 -1
- package/dist/src/model-routing/advisor-transcript.d.ts +36 -0
- package/dist/src/model-routing/advisor-transcript.d.ts.map +1 -0
- package/dist/src/model-routing/advisor-transcript.js +103 -0
- package/dist/src/model-routing/advisor-transcript.js.map +1 -0
- package/dist/src/model-routing/advisor.d.ts +27 -0
- package/dist/src/model-routing/advisor.d.ts.map +1 -0
- package/dist/src/model-routing/advisor.js +20 -0
- package/dist/src/model-routing/advisor.js.map +1 -0
- package/dist/src/model-routing/classifier.d.ts +57 -0
- package/dist/src/model-routing/classifier.d.ts.map +1 -0
- package/dist/src/model-routing/classifier.js +63 -0
- package/dist/src/model-routing/classifier.js.map +1 -0
- package/dist/src/model-routing/loader.d.ts +48 -0
- package/dist/src/model-routing/loader.d.ts.map +1 -0
- package/dist/src/model-routing/loader.js +79 -0
- package/dist/src/model-routing/loader.js.map +1 -0
- package/dist/src/model-routing/prompts.d.ts +22 -0
- package/dist/src/model-routing/prompts.d.ts.map +1 -0
- package/dist/src/model-routing/prompts.js +87 -0
- package/dist/src/model-routing/prompts.js.map +1 -0
- package/dist/src/model-routing/resolve.d.ts +40 -0
- package/dist/src/model-routing/resolve.d.ts.map +1 -0
- package/dist/src/model-routing/resolve.js +76 -0
- package/dist/src/model-routing/resolve.js.map +1 -0
- package/dist/src/model-routing/router.d.ts +149 -0
- package/dist/src/model-routing/router.d.ts.map +1 -0
- package/dist/src/model-routing/router.js +235 -0
- package/dist/src/model-routing/router.js.map +1 -0
- package/dist/src/model-routing/step-triggers.d.ts +27 -0
- package/dist/src/model-routing/step-triggers.d.ts.map +1 -0
- package/dist/src/model-routing/step-triggers.js +20 -0
- package/dist/src/model-routing/step-triggers.js.map +1 -0
- package/dist/src/model-routing/table.d.ts +126 -0
- package/dist/src/model-routing/table.d.ts.map +1 -0
- package/dist/src/model-routing/table.js +351 -0
- package/dist/src/model-routing/table.js.map +1 -0
- package/dist/src/session/session.d.ts +40 -23
- package/dist/src/session/session.d.ts.map +1 -1
- package/dist/src/session/session.js +173 -97
- package/dist/src/session/session.js.map +1 -1
- package/dist/src/tasks/quiescence.d.ts +13 -0
- package/dist/src/tasks/quiescence.d.ts.map +1 -0
- package/dist/src/tasks/quiescence.js +11 -0
- package/dist/src/tasks/quiescence.js.map +1 -0
- package/dist/src/tasks/task-manager.d.ts +101 -0
- package/dist/src/tasks/task-manager.d.ts.map +1 -0
- package/dist/src/tasks/task-manager.js +311 -0
- package/dist/src/tasks/task-manager.js.map +1 -0
- package/dist/src/tasks/types.d.ts +85 -0
- package/dist/src/tasks/types.d.ts.map +1 -0
- package/dist/src/tasks/types.js +2 -0
- package/dist/src/tasks/types.js.map +1 -0
- package/dist/src/tui/app.d.ts +1 -1
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +12 -2
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/initial-prompt.d.ts.map +1 -1
- package/dist/src/tui/initial-prompt.js +0 -3
- package/dist/src/tui/initial-prompt.js.map +1 -1
- package/dist/src/tui/session-subscription.d.ts +3 -0
- package/dist/src/tui/session-subscription.d.ts.map +1 -1
- package/dist/src/tui/session-subscription.js +29 -5
- package/dist/src/tui/session-subscription.js.map +1 -1
- package/dist/src/tui/sidebar.d.ts +3 -0
- package/dist/src/tui/sidebar.d.ts.map +1 -1
- package/dist/src/tui/sidebar.js +19 -3
- package/dist/src/tui/sidebar.js.map +1 -1
- package/dist/src/tui/slash-commands.d.ts +12 -9
- package/dist/src/tui/slash-commands.d.ts.map +1 -1
- package/dist/src/tui/slash-commands.js +48 -10
- package/dist/src/tui/slash-commands.js.map +1 -1
- package/dist/src/tui/status-controller.d.ts +14 -9
- package/dist/src/tui/status-controller.d.ts.map +1 -1
- package/dist/src/tui/status-controller.js +26 -7
- package/dist/src/tui/status-controller.js.map +1 -1
- package/dist/src/tui/task-lane-renderer.d.ts +30 -0
- package/dist/src/tui/task-lane-renderer.d.ts.map +1 -0
- package/dist/src/tui/task-lane-renderer.js +93 -0
- package/dist/src/tui/task-lane-renderer.js.map +1 -0
- package/dist/src/tui/task-tree.d.ts +47 -0
- package/dist/src/tui/task-tree.d.ts.map +1 -0
- package/dist/src/tui/task-tree.js +190 -0
- package/dist/src/tui/task-tree.js.map +1 -0
- package/dist/src/tui/tool-formatters.js +3 -0
- package/dist/src/tui/tool-formatters.js.map +1 -1
- package/dist/src/turn-runner/agent-events.d.ts.map +1 -1
- package/dist/src/turn-runner/agent-events.js +1 -0
- package/dist/src/turn-runner/agent-events.js.map +1 -1
- package/dist/src/turn-runner/duration.d.ts +2 -2
- package/dist/src/turn-runner/duration.js +3 -3
- package/dist/src/turn-runner/duration.js.map +1 -1
- package/dist/src/turn-runner/prompts.d.ts +5 -1
- package/dist/src/turn-runner/prompts.d.ts.map +1 -1
- package/dist/src/turn-runner/prompts.js +30 -6
- package/dist/src/turn-runner/prompts.js.map +1 -1
- package/dist/src/turn-runner/runtime-clock.d.ts +20 -0
- package/dist/src/turn-runner/runtime-clock.d.ts.map +1 -0
- package/dist/src/turn-runner/runtime-clock.js +38 -0
- package/dist/src/turn-runner/runtime-clock.js.map +1 -0
- package/dist/src/turn-runner/shell-state-handle.d.ts +0 -3
- package/dist/src/turn-runner/shell-state-handle.d.ts.map +1 -1
- package/dist/src/turn-runner/shell-state-handle.js +5 -55
- package/dist/src/turn-runner/shell-state-handle.js.map +1 -1
- package/dist/src/turn-runner/skill-context.d.ts +6 -7
- package/dist/src/turn-runner/skill-context.d.ts.map +1 -1
- package/dist/src/turn-runner/skill-context.js +10 -10
- package/dist/src/turn-runner/skill-context.js.map +1 -1
- package/dist/src/turn-runner/skills.d.ts.map +1 -1
- package/dist/src/turn-runner/skills.js +2 -22
- package/dist/src/turn-runner/skills.js.map +1 -1
- package/dist/src/turn-runner/state-compaction.d.ts +7 -5
- package/dist/src/turn-runner/state-compaction.d.ts.map +1 -1
- package/dist/src/turn-runner/state-compaction.js +69 -10
- package/dist/src/turn-runner/state-compaction.js.map +1 -1
- package/dist/src/turn-runner/state-machine-decisions.d.ts +135 -0
- package/dist/src/turn-runner/state-machine-decisions.d.ts.map +1 -0
- package/dist/src/turn-runner/state-machine-decisions.js +443 -0
- package/dist/src/turn-runner/state-machine-decisions.js.map +1 -0
- package/dist/src/turn-runner/state-machine-session.d.ts +13 -28
- package/dist/src/turn-runner/state-machine-session.d.ts.map +1 -1
- package/dist/src/turn-runner/state-machine-session.js +18 -55
- package/dist/src/turn-runner/state-machine-session.js.map +1 -1
- package/dist/src/turn-runner/subagent.d.ts +156 -0
- package/dist/src/turn-runner/subagent.d.ts.map +1 -0
- package/dist/src/turn-runner/subagent.js +164 -0
- package/dist/src/turn-runner/subagent.js.map +1 -0
- package/dist/src/turn-runner/task-tools.d.ts +76 -0
- package/dist/src/turn-runner/task-tools.d.ts.map +1 -0
- package/dist/src/turn-runner/task-tools.js +370 -0
- package/dist/src/turn-runner/task-tools.js.map +1 -0
- package/dist/src/turn-runner/tools.d.ts +65 -17
- package/dist/src/turn-runner/tools.d.ts.map +1 -1
- package/dist/src/turn-runner/tools.js +173 -59
- package/dist/src/turn-runner/tools.js.map +1 -1
- package/dist/src/turn-runner/turn-runner.d.ts +205 -135
- package/dist/src/turn-runner/turn-runner.d.ts.map +1 -1
- package/dist/src/turn-runner/turn-runner.js +1311 -714
- package/dist/src/turn-runner/turn-runner.js.map +1 -1
- package/dist/src/turn-runner/usage-accounting.d.ts +4 -0
- package/dist/src/turn-runner/usage-accounting.d.ts.map +1 -1
- package/dist/src/turn-runner/usage-accounting.js +19 -0
- package/dist/src/turn-runner/usage-accounting.js.map +1 -1
- package/dist/src/types/config.d.ts +9 -1
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/src/types/config.js +2 -1
- package/dist/src/types/config.js.map +1 -1
- package/dist/src/types/protocol.d.ts +124 -30
- package/dist/src/types/protocol.d.ts.map +1 -1
- package/dist/src/types/state-machine.d.ts +10 -12
- package/dist/src/types/state-machine.d.ts.map +1 -1
- package/dist/src/types/state-machine.js.map +1 -1
- package/package.json +4 -4
- package/dist/src/cli/model-gateway.d.ts.map +0 -1
- package/dist/src/cli/model-gateway.js.map +0 -1
- package/dist/src/turn-runner/state-machine-controller.d.ts +0 -140
- package/dist/src/turn-runner/state-machine-controller.d.ts.map +0 -1
- package/dist/src/turn-runner/state-machine-controller.js +0 -501
- package/dist/src/turn-runner/state-machine-controller.js.map +0 -1
- /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 {
|
|
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,
|
|
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 {
|
|
22
|
-
import {
|
|
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,
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
|
|
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
|
-
|
|
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.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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
|
|
236
|
-
*
|
|
237
|
-
* parent
|
|
238
|
-
*
|
|
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
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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.
|
|
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,14 +489,20 @@ export class TurnRunner {
|
|
|
356
489
|
if (state.wireGuardHorizon) {
|
|
357
490
|
Object.assign(this.wireGuardHorizon, state.wireGuardHorizon);
|
|
358
491
|
}
|
|
359
|
-
this.setState(
|
|
492
|
+
this.setState(recoveredState);
|
|
360
493
|
this.initializeParentAgent();
|
|
361
494
|
this.started = true;
|
|
362
495
|
const hydratedState = this.requireRunnerState();
|
|
363
496
|
this.emit({ type: "turn_started", state: hydratedState });
|
|
364
497
|
return hydratedState;
|
|
365
498
|
}
|
|
366
|
-
|
|
499
|
+
/**
|
|
500
|
+
* Route a turn-driving command and resolve with the shared chain terminal.
|
|
501
|
+
* `onAccepted` fires only after the command has entered the active driver or
|
|
502
|
+
* the runner-owned queue; transports can acknowledge delivery at that
|
|
503
|
+
* boundary without leaking transport metadata into durable turn state.
|
|
504
|
+
*/
|
|
505
|
+
async turn(command, onAccepted) {
|
|
367
506
|
this.requireStarted();
|
|
368
507
|
await this.ensureMemoryLoaded();
|
|
369
508
|
await this.ensureSkillsLoaded();
|
|
@@ -380,10 +519,12 @@ export class TurnRunner {
|
|
|
380
519
|
// turn() is the concurrency boundary: repeated calls extend or queue
|
|
381
520
|
// behind the active chain instead of creating a separate parent transcript.
|
|
382
521
|
this.handleCommandDuringActiveTurn(command);
|
|
522
|
+
onAccepted?.();
|
|
383
523
|
return this.activeTurnPromise;
|
|
384
524
|
}
|
|
385
|
-
const activeTurnPromise = this.
|
|
525
|
+
const activeTurnPromise = this.runTurnLoop(command);
|
|
386
526
|
this.activeTurnPromise = activeTurnPromise;
|
|
527
|
+
onAccepted?.();
|
|
387
528
|
try {
|
|
388
529
|
return await activeTurnPromise;
|
|
389
530
|
}
|
|
@@ -393,46 +534,174 @@ export class TurnRunner {
|
|
|
393
534
|
}
|
|
394
535
|
}
|
|
395
536
|
}
|
|
396
|
-
async
|
|
537
|
+
async runTurnLoop(command) {
|
|
397
538
|
this.turnUsage = undefined;
|
|
398
539
|
this.turnUsageByModel = undefined;
|
|
540
|
+
this.interruptReason = undefined;
|
|
541
|
+
this.interruptCleanup = undefined;
|
|
542
|
+
this.terminalEmitted = false;
|
|
543
|
+
this.activeRootScopeId = `turn-${this.nextRootScope++}`;
|
|
544
|
+
this.turnTools = this.createTools(this.requireRunnerState().mode).tools;
|
|
545
|
+
this.enqueueParentInput(command.type === "wake" ? { type: "wake" } : { type: "user_command", command });
|
|
546
|
+
const carriedUserCommands = this.hydratedQueuedCommands?.filter((queued) => queued.type !== "wake");
|
|
547
|
+
if (carriedUserCommands && carriedUserCommands.length > 0) {
|
|
548
|
+
for (const queued of carriedUserCommands) {
|
|
549
|
+
this.enqueueParentInput({ type: "user_command", command: queued });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
for (const entry of this.getFollowUpQueue()) {
|
|
554
|
+
this.enqueueParentInput({
|
|
555
|
+
type: "user_command",
|
|
556
|
+
command: {
|
|
557
|
+
type: "prompt",
|
|
558
|
+
message: entry.message,
|
|
559
|
+
behavior: "follow_up",
|
|
560
|
+
images: entry.images,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
this.hydratedQueuedCommands = undefined;
|
|
566
|
+
let questions;
|
|
567
|
+
let completion = {
|
|
568
|
+
status: "completed",
|
|
569
|
+
};
|
|
570
|
+
let terminal;
|
|
399
571
|
try {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
572
|
+
while (!questions && !this.interruptReason) {
|
|
573
|
+
this.enqueueAvailableSettlements();
|
|
574
|
+
const pendingBeforeInput = this.taskManager.pendingWork();
|
|
575
|
+
const runnableIndex = pendingBeforeInput.kind === "open"
|
|
576
|
+
? this.parentInputs.findIndex((queued) => queued.type === "task_settlements" ||
|
|
577
|
+
(queued.type === "user_command" && queued.command.behavior === "steer"))
|
|
578
|
+
: 0;
|
|
579
|
+
const input = runnableIndex >= 0 ? this.parentInputs.splice(runnableIndex, 1)[0] : undefined;
|
|
580
|
+
if (!input) {
|
|
581
|
+
if (pendingBeforeInput.kind !== "open")
|
|
582
|
+
break;
|
|
583
|
+
if (this.parentInputs.length > 0) {
|
|
584
|
+
await this.taskManager.waitForSettlement();
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
await this.waitForLoopActivity();
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (input.type === "wake" &&
|
|
592
|
+
input.queued === true &&
|
|
593
|
+
this.taskManager.pendingWork().kind !== "sleep" &&
|
|
594
|
+
this.requireRunnerState().status !== "sleeping") {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const result = await this.processParentLoopInput(input);
|
|
598
|
+
if (result?.type === "ask") {
|
|
599
|
+
if (this.taskManager.pendingWork().kind === "open") {
|
|
600
|
+
// Terminal ⇒ quiescent forbids delivering the ask now. Keep the
|
|
601
|
+
// questions just long enough to remind the parent on its next pass
|
|
602
|
+
// (settlements guarantee one); the parent re-asks if still relevant.
|
|
603
|
+
this.withheldAskQuestions = result.questions;
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
questions = result.questions;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (result?.type === "interrupted")
|
|
610
|
+
this.interruptReason ??= "Interrupted";
|
|
611
|
+
if (result?.type === "state_completed") {
|
|
612
|
+
this.enqueueParentInput({
|
|
613
|
+
type: "transition_enforcement",
|
|
614
|
+
stateName: result.stateName,
|
|
615
|
+
output: result.output,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
if (result?.type === "terminal") {
|
|
619
|
+
completion = {
|
|
620
|
+
status: result.status === "error" ? "failed" : "completed",
|
|
621
|
+
...(result.result !== undefined ? { result: result.result } : {}),
|
|
622
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
623
|
+
};
|
|
624
|
+
if (this.stateMachine?.terminal && !this.stateMachine.terminalAcknowledged) {
|
|
625
|
+
this.enqueueParentInput({ type: "terminal_acknowledgment" });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Preserve the old drain rule: follow-ups arriving after a sleep was
|
|
629
|
+
// selected remain queued for the next user-driven turn. A stale wake,
|
|
630
|
+
// however, is skipped above so it cannot clobber the meaningful result.
|
|
631
|
+
if (this.taskManager.pendingWork().kind === "sleep" &&
|
|
632
|
+
this.parentInputs.length > 0 &&
|
|
633
|
+
this.parentInputs.every((queued) => queued.type === "user_command" && queued.command.behavior === "follow_up")) {
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
completion = {
|
|
640
|
+
status: "failed",
|
|
641
|
+
error: error instanceof Error ? error.message : String(error),
|
|
642
|
+
};
|
|
643
|
+
await this.taskManager.interruptAll(`Turn failed: ${completion.error}`);
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
await this.interruptCleanup;
|
|
647
|
+
if (!this.interruptReason && !questions && this.taskManager.pendingWork().kind === "open") {
|
|
648
|
+
await this.taskManager.interruptAll("Turn exited with in-process work still active.");
|
|
649
|
+
completion = {
|
|
650
|
+
status: "failed",
|
|
651
|
+
error: "Turn exited with in-process work still active.",
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
this.discardStaleTaskSettlements();
|
|
655
|
+
const quiescentState = this.snapshotState(this.requireRunnerState());
|
|
656
|
+
try {
|
|
657
|
+
await this.updateMemoryAfterAgentRun(quiescentState.agent.messages, quiescentState.options);
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (!this.interruptReason) {
|
|
661
|
+
completion = {
|
|
662
|
+
status: "failed",
|
|
663
|
+
error: error instanceof Error ? error.message : String(error),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const state = this.snapshotState(this.requireRunnerState());
|
|
668
|
+
if (this.interruptReason) {
|
|
405
669
|
terminal = {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
670
|
+
type: "interrupted",
|
|
671
|
+
state: {
|
|
672
|
+
...state,
|
|
673
|
+
status: "interrupted",
|
|
674
|
+
agent: { ...state.agent, status: "cancelled" },
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
else if (questions) {
|
|
679
|
+
terminal = {
|
|
680
|
+
type: "ask",
|
|
681
|
+
questions,
|
|
682
|
+
state: { ...state, status: "waiting_for_human" },
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
const settledPending = this.taskManager.pendingWork();
|
|
687
|
+
terminal =
|
|
688
|
+
settledPending.kind === "sleep"
|
|
410
689
|
? {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
690
|
+
type: "sleep",
|
|
691
|
+
wakeAt: settledPending.wakeAt,
|
|
692
|
+
state: { ...state, status: "sleeping" },
|
|
414
693
|
}
|
|
415
|
-
:
|
|
416
|
-
};
|
|
694
|
+
: completeTurn(state, completion.status, completion.result, completion.error);
|
|
417
695
|
}
|
|
696
|
+
terminal = this.attachTurnUsage({ ...terminal, state: this.snapshotState(terminal.state) });
|
|
418
697
|
this.setState(terminal.state);
|
|
419
|
-
this.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
finally {
|
|
698
|
+
this.emitTerminalOnce(terminal);
|
|
699
|
+
this.turnTools = undefined;
|
|
700
|
+
this.activeRootScopeId = undefined;
|
|
423
701
|
this.turnUsage = undefined;
|
|
424
702
|
this.turnUsageByModel = undefined;
|
|
425
703
|
}
|
|
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
|
-
}
|
|
704
|
+
return terminal;
|
|
436
705
|
}
|
|
437
706
|
handleCommandDuringActiveTurn(command) {
|
|
438
707
|
if ((command.type === "prompt" || command.type === "answer") && this.parentAgentRunning) {
|
|
@@ -441,20 +710,11 @@ export class TurnRunner {
|
|
|
441
710
|
this.sendCommandToAgent(this.requireParentAgent(), command);
|
|
442
711
|
return;
|
|
443
712
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
}
|
|
713
|
+
this.enqueueParentInput(command.type === "wake" ? { type: "wake", queued: true } : { type: "user_command", command });
|
|
714
|
+
if ((command.type === "prompt" || command.type === "answer") &&
|
|
715
|
+
command.behavior === "follow_up") {
|
|
716
|
+
this.appendFollowUpPrompt(this.commandToUserMessage(command), command.images);
|
|
456
717
|
}
|
|
457
|
-
this.enqueueTurnCommand(command);
|
|
458
718
|
}
|
|
459
719
|
sendCommandToAgent(agent, command) {
|
|
460
720
|
const message = this.commandToUserMessage(command);
|
|
@@ -468,69 +728,96 @@ export class TurnRunner {
|
|
|
468
728
|
agent.followUp(agentMessage);
|
|
469
729
|
}
|
|
470
730
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
this.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
this.activeStateWorkPrompt = undefined;
|
|
477
|
-
});
|
|
731
|
+
enqueueParentInput(input) {
|
|
732
|
+
this.parentInputs.push(input);
|
|
733
|
+
for (const wake of this.parentInputWaiters)
|
|
734
|
+
wake();
|
|
735
|
+
this.parentInputWaiters.clear();
|
|
478
736
|
}
|
|
479
|
-
async
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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,
|
|
737
|
+
async waitForLoopActivity() {
|
|
738
|
+
if (this.parentInputs.length > 0)
|
|
739
|
+
return;
|
|
740
|
+
let wake;
|
|
741
|
+
const parentInput = new Promise((resolve) => {
|
|
742
|
+
wake = resolve;
|
|
743
|
+
this.parentInputWaiters.add(wake);
|
|
495
744
|
});
|
|
496
|
-
|
|
497
|
-
|
|
745
|
+
try {
|
|
746
|
+
await Promise.race([this.taskManager.waitForSettlement().then(() => undefined), parentInput]);
|
|
747
|
+
}
|
|
748
|
+
finally {
|
|
749
|
+
this.parentInputWaiters.delete(wake);
|
|
750
|
+
}
|
|
498
751
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (
|
|
507
|
-
(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
752
|
+
enqueueAvailableSettlements() {
|
|
753
|
+
if ([...this.taskSettlementDelivery.values()].includes("foreground_pending"))
|
|
754
|
+
return;
|
|
755
|
+
const settlements = [];
|
|
756
|
+
for (let settlement = this.taskManager.nextSettled(); settlement; settlement = this.taskManager.nextSettled()) {
|
|
757
|
+
const delivery = this.taskSettlementDelivery.get(settlement.id);
|
|
758
|
+
this.taskSettlementDelivery.delete(settlement.id);
|
|
759
|
+
if (delivery !== "suppress")
|
|
760
|
+
settlements.push(settlement);
|
|
761
|
+
}
|
|
762
|
+
if (settlements.length === 0)
|
|
763
|
+
return;
|
|
764
|
+
const canSteer = this.parentAgentRunning &&
|
|
765
|
+
this.parentControlResults.length === 0 &&
|
|
766
|
+
settlements.every((settlement) => !this.stateTasks.has(settlement.id));
|
|
767
|
+
if (canSteer) {
|
|
768
|
+
const snapshots = settlements
|
|
769
|
+
.map((settlement) => this.taskManager.output(settlement.id))
|
|
770
|
+
.filter((snapshot) => snapshot !== undefined);
|
|
771
|
+
if (snapshots.length > 0) {
|
|
772
|
+
this.requireParentAgent().steer(buildUserAgentMessage(settlementNotice(snapshots), undefined));
|
|
773
|
+
return;
|
|
511
774
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
775
|
+
}
|
|
776
|
+
this.parentInputs.unshift({ type: "task_settlements", settlements });
|
|
777
|
+
}
|
|
778
|
+
handleTaskEvent(event) {
|
|
779
|
+
if (event.type === "started") {
|
|
780
|
+
this.emit({ type: "task_started", task: event.descriptor });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (event.type === "output") {
|
|
784
|
+
this.emit({ type: "task_output", taskId: event.id, chunk: event.chunk });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
this.emit({ type: "task_settled", settlement: event.settlement });
|
|
788
|
+
if (this.taskSettlementDelivery.get(event.settlement.id) === "foreground_pending")
|
|
789
|
+
return;
|
|
790
|
+
this.scheduleSettlementDelivery();
|
|
791
|
+
}
|
|
792
|
+
scheduleSettlementDelivery() {
|
|
793
|
+
if (this.settlementDeliveryQueued)
|
|
794
|
+
return;
|
|
795
|
+
this.settlementDeliveryQueued = true;
|
|
796
|
+
queueMicrotask(() => {
|
|
797
|
+
this.settlementDeliveryQueued = false;
|
|
798
|
+
this.enqueueAvailableSettlements();
|
|
799
|
+
for (const wake of this.parentInputWaiters)
|
|
800
|
+
wake();
|
|
801
|
+
this.parentInputWaiters.clear();
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
discardStaleTaskSettlements() {
|
|
805
|
+
for (let settlement = this.taskManager.nextSettled(); settlement; settlement = this.taskManager.nextSettled()) {
|
|
806
|
+
this.stateTasks.delete(settlement.id);
|
|
807
|
+
this.ignoredTaskSettlements.delete(settlement.id);
|
|
808
|
+
this.taskSettlementDelivery.delete(settlement.id);
|
|
809
|
+
}
|
|
810
|
+
for (const input of this.parentInputs) {
|
|
811
|
+
if (input.type !== "task_settlements")
|
|
529
812
|
continue;
|
|
813
|
+
for (const settlement of input.settlements) {
|
|
814
|
+
this.stateTasks.delete(settlement.id);
|
|
815
|
+
this.ignoredTaskSettlements.delete(settlement.id);
|
|
816
|
+
this.taskSettlementDelivery.delete(settlement.id);
|
|
530
817
|
}
|
|
531
|
-
latest = await this.executeTurnCommand(queued);
|
|
532
818
|
}
|
|
533
|
-
|
|
819
|
+
const userInputs = this.parentInputs.filter((input) => input.type !== "task_settlements");
|
|
820
|
+
this.parentInputs.splice(0, this.parentInputs.length, ...userInputs);
|
|
534
821
|
}
|
|
535
822
|
commandToUserMessage(command) {
|
|
536
823
|
if (command.type === "prompt") {
|
|
@@ -546,39 +833,30 @@ export class TurnRunner {
|
|
|
546
833
|
return base;
|
|
547
834
|
return `${base}\n\n${this.skillContext.resolveSlashSkillPrompt(command.message)}`;
|
|
548
835
|
}
|
|
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
836
|
replaceFollowUpQueue(entries) {
|
|
557
837
|
this.setFollowUpQueue(entries);
|
|
558
838
|
this.parentAgent?.clearFollowUpQueue();
|
|
559
|
-
|
|
560
|
-
|
|
839
|
+
this.parentInputs.splice(0, this.parentInputs.length, ...this.parentInputs.filter((input) => input.type !== "user_command" || input.command.behavior !== "follow_up"));
|
|
840
|
+
if (this.parentAgentRunning) {
|
|
841
|
+
for (const entry of entries) {
|
|
842
|
+
this.parentAgent?.followUp(buildUserAgentMessage(entry.message, entry.images));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else if (this.activeTurnPromise) {
|
|
846
|
+
for (const entry of entries) {
|
|
847
|
+
this.enqueueParentInput({
|
|
848
|
+
type: "user_command",
|
|
849
|
+
command: {
|
|
850
|
+
type: "prompt",
|
|
851
|
+
message: entry.message,
|
|
852
|
+
behavior: "follow_up",
|
|
853
|
+
images: entry.images,
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
}
|
|
561
857
|
}
|
|
562
|
-
this.replaceQueuedFollowUpCommands(entries);
|
|
563
858
|
this.emitFollowUpQueue();
|
|
564
859
|
}
|
|
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
860
|
isFollowUpQueueCommand(command) {
|
|
583
861
|
return ((command.type === "prompt" || command.type === "answer") && command.behavior === "follow_up");
|
|
584
862
|
}
|
|
@@ -619,264 +897,402 @@ export class TurnRunner {
|
|
|
619
897
|
this.requireStarted();
|
|
620
898
|
if (!this.state)
|
|
621
899
|
return;
|
|
622
|
-
|
|
623
|
-
this.
|
|
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
|
-
}
|
|
900
|
+
this.interruptReason = "Interrupted";
|
|
901
|
+
this.parentAgentInterrupted = this.parentAgentRunning;
|
|
640
902
|
this.parentAgent?.abort();
|
|
641
903
|
this.parentAgent?.clearAllQueues();
|
|
642
|
-
this.
|
|
643
|
-
this.setQueuedCommands([]);
|
|
904
|
+
this.parentInputs.length = 0;
|
|
644
905
|
this.clearFollowUpQueue();
|
|
645
906
|
this.parentAgentRunning = false;
|
|
907
|
+
this.interruptCleanup = this.interruptTasks("Interrupted");
|
|
908
|
+
if (!this.activeTurnPromise) {
|
|
909
|
+
this.terminalEmitted = false;
|
|
910
|
+
void this.interruptCleanup.then(() => {
|
|
911
|
+
const state = this.snapshotState(this.requireRunnerState());
|
|
912
|
+
const terminal = {
|
|
913
|
+
type: "interrupted",
|
|
914
|
+
state: {
|
|
915
|
+
...state,
|
|
916
|
+
status: "interrupted",
|
|
917
|
+
agent: { ...state.agent, status: "cancelled" },
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
this.setState(terminal.state);
|
|
921
|
+
this.emitTerminalOnce(terminal);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
646
924
|
}
|
|
647
925
|
emit(event) {
|
|
648
926
|
for (const handler of this.eventHandlers) {
|
|
649
927
|
handler(event);
|
|
650
928
|
}
|
|
651
929
|
}
|
|
652
|
-
async
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
terminal = await this.runTurnRunnerAgentWithStateMachineTools({
|
|
665
|
-
state,
|
|
666
|
-
prompt,
|
|
667
|
-
images,
|
|
668
|
-
mode: state.mode,
|
|
669
|
-
});
|
|
930
|
+
async processParentLoopInput(input) {
|
|
931
|
+
switch (input.type) {
|
|
932
|
+
case "user_command":
|
|
933
|
+
return this.runUserCommandPass(input.command, input.continuation);
|
|
934
|
+
case "task_settlements":
|
|
935
|
+
return this.processTaskSettlements(input.settlements);
|
|
936
|
+
case "transition_enforcement":
|
|
937
|
+
return this.selectNextStateAfterCompletion(input.stateName, input.output);
|
|
938
|
+
case "terminal_acknowledgment":
|
|
939
|
+
return this.runTerminalAcknowledgmentPass();
|
|
940
|
+
case "wake":
|
|
941
|
+
return this.runWakeInput();
|
|
670
942
|
}
|
|
671
|
-
return this.restoreSleepAfterPromptIfNeeded(originalState, terminal);
|
|
672
|
-
}
|
|
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
943
|
}
|
|
682
|
-
async
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
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.",
|
|
944
|
+
async runUserCommandPass(command, continuation = false) {
|
|
945
|
+
const runningState = {
|
|
946
|
+
...this.requireRunnerState(),
|
|
947
|
+
status: "running",
|
|
698
948
|
};
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
949
|
+
const state = continuation ? runningState : this.clearFinishedTodosAtTurnStart(runningState);
|
|
950
|
+
let prompt = this.commandToUserMessage(command);
|
|
951
|
+
if (!continuation &&
|
|
952
|
+
command.behavior === "steer" &&
|
|
953
|
+
this.taskManager.pendingWork().kind === "open") {
|
|
954
|
+
prompt = `${systemReminder(dedent `
|
|
955
|
+
<system-reminder>
|
|
956
|
+
The user sent this as a steer message while state-machine work is running.
|
|
957
|
+
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.
|
|
958
|
+
</system-reminder>
|
|
959
|
+
`)}\n\n${prompt}`;
|
|
960
|
+
}
|
|
961
|
+
const todoReminder = continuation ? undefined : formatCarriedTodosReminder(state.todos);
|
|
962
|
+
if (todoReminder)
|
|
963
|
+
prompt = `${todoReminder}\n\n${prompt}`;
|
|
964
|
+
if (!continuation)
|
|
965
|
+
this.removeQueuedFollowUpPrompt(command);
|
|
966
|
+
const worker = await this.runParentPass({
|
|
967
|
+
state,
|
|
968
|
+
prompt,
|
|
969
|
+
images: promptImagesToContent(command.images),
|
|
970
|
+
continuation,
|
|
971
|
+
});
|
|
972
|
+
this.setState(worker.outcome.state);
|
|
973
|
+
if (worker.outcome.type === "complete" &&
|
|
974
|
+
worker.outcome.status === "failed" &&
|
|
975
|
+
this.taskManager.pendingWork().kind === "sleep") {
|
|
707
976
|
this.emit({
|
|
708
977
|
type: "system",
|
|
709
978
|
level: "error",
|
|
710
|
-
message:
|
|
979
|
+
message: worker.outcome.error ?? worker.outcome.result ?? "Prompt failed while waiting.",
|
|
711
980
|
});
|
|
712
981
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
982
|
+
if (worker.outcome.type === "interrupted")
|
|
983
|
+
return { type: "interrupted" };
|
|
984
|
+
if (state.mode === "agent") {
|
|
985
|
+
if (worker.control.type === "ask_user_question") {
|
|
986
|
+
return { type: "ask", questions: worker.control.questions };
|
|
987
|
+
}
|
|
988
|
+
return worker.outcome.status === "failed"
|
|
989
|
+
? { type: "terminal", status: "error", error: worker.outcome.error }
|
|
990
|
+
: { type: "terminal", status: "completed", result: worker.outcome.result };
|
|
991
|
+
}
|
|
992
|
+
const result = await this.stateMachineResultFromWorker(worker, worker.outcome.state);
|
|
993
|
+
if (result)
|
|
994
|
+
return result;
|
|
995
|
+
return worker.outcome.status === "failed"
|
|
996
|
+
? { type: "terminal", status: "error", error: worker.outcome.error }
|
|
997
|
+
: { type: "terminal", status: "completed", result: worker.outcome.result };
|
|
721
998
|
}
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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" }),
|
|
999
|
+
async runParentPass(input) {
|
|
1000
|
+
const agent = this.requireParentAgent();
|
|
1001
|
+
if (agent.hasQueuedMessages()) {
|
|
1002
|
+
throw new Error("Parent pi steer/follow-up queues must be empty at internal pass start.");
|
|
1003
|
+
}
|
|
1004
|
+
const recoveredTaskReminder = this.recoveredTaskReminder;
|
|
1005
|
+
this.recoveredTaskReminder = undefined;
|
|
1006
|
+
if (recoveredTaskReminder) {
|
|
1007
|
+
this.setState({
|
|
1008
|
+
...this.requireRunnerState(),
|
|
1009
|
+
pendingLostTaskReminderTaskIds: undefined,
|
|
772
1010
|
});
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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();
|
|
1011
|
+
}
|
|
1012
|
+
const recoveryInput = recoveredTaskReminder
|
|
1013
|
+
? {
|
|
1014
|
+
...input,
|
|
1015
|
+
state: { ...input.state, pendingLostTaskReminderTaskIds: undefined },
|
|
1016
|
+
prompt: `${recoveredTaskReminder}\n\n${input.prompt}`,
|
|
1017
|
+
}
|
|
1018
|
+
: input;
|
|
1019
|
+
const parked = currentParkState(this.stateMachine);
|
|
1020
|
+
// Append to the pass already being made, so a transition out of park costs
|
|
1021
|
+
// no extra model call. The selection tools put the same reminder in their
|
|
1022
|
+
// terminating result on the entry pass, before currentState has changed.
|
|
1023
|
+
return this.runAgentWorker(parked
|
|
1024
|
+
? { ...recoveryInput, prompt: `${recoveryInput.prompt}\n\n${parkNudge(parked.name)}` }
|
|
1025
|
+
: recoveryInput);
|
|
1026
|
+
}
|
|
1027
|
+
async runWakeInput() {
|
|
1028
|
+
const planned = planWake(this.stateMachine, this.clock.now());
|
|
1029
|
+
if (!planned) {
|
|
1030
|
+
return { type: "terminal", status: "completed", result: "Nothing to wake." };
|
|
1031
|
+
}
|
|
1032
|
+
await this.cancelScheduledTasks("Scheduled wake fired.");
|
|
1033
|
+
this.setStateMachine(planned.session);
|
|
1034
|
+
return this.executePlannedWork(planned.work);
|
|
1035
|
+
}
|
|
1036
|
+
async runTerminalAcknowledgmentPass() {
|
|
1037
|
+
const session = this.stateMachine;
|
|
834
1038
|
if (!session?.terminal || session.terminalAcknowledged)
|
|
835
1039
|
return undefined;
|
|
836
|
-
this.
|
|
837
|
-
const
|
|
838
|
-
if (!
|
|
1040
|
+
this.setStateMachine(markTerminalAcknowledged(session));
|
|
1041
|
+
const acknowledged = this.stateMachine;
|
|
1042
|
+
if (!acknowledged?.terminal)
|
|
839
1043
|
return undefined;
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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),
|
|
1044
|
+
const worker = await this.runParentPass({
|
|
1045
|
+
state: this.snapshotState({ ...this.requireRunnerState(), status: "running" }),
|
|
1046
|
+
prompt: formatStateMachineTerminalAcknowledgmentPrompt({ session: acknowledged }),
|
|
1047
|
+
continuation: true,
|
|
851
1048
|
});
|
|
852
|
-
this.setState(
|
|
853
|
-
if (
|
|
854
|
-
return { type: "interrupted"
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1049
|
+
this.setState(worker.outcome.state);
|
|
1050
|
+
if (worker.outcome.type === "interrupted")
|
|
1051
|
+
return { type: "interrupted" };
|
|
1052
|
+
return this.stateMachineResultFromWorker(worker, worker.outcome.state);
|
|
1053
|
+
}
|
|
1054
|
+
async executePlannedWork(work) {
|
|
1055
|
+
if ("terminal" in work) {
|
|
1056
|
+
const settled = recordPlannedTerminal(this.requireStateMachine(), work.terminal);
|
|
1057
|
+
this.setStateMachine(settled.session);
|
|
1058
|
+
return settled.outcome;
|
|
1059
|
+
}
|
|
1060
|
+
if ("schedule" in work) {
|
|
1061
|
+
if (work.schedule.wakeAt > this.clock.now()) {
|
|
1062
|
+
this.taskManager.start({
|
|
1063
|
+
kind: "scheduled",
|
|
1064
|
+
name: work.schedule.stateName,
|
|
1065
|
+
label: `Wait for ${work.schedule.stateName}`,
|
|
1066
|
+
ownerScopeId: this.requireRootScope(),
|
|
1067
|
+
wakeAt: work.schedule.wakeAt,
|
|
1068
|
+
});
|
|
1069
|
+
const settled = recordScheduled(this.requireStateMachine(), work.schedule.stateName, work.schedule.wakeAt);
|
|
1070
|
+
this.setStateMachine(settled.session);
|
|
1071
|
+
return settled.outcome;
|
|
1072
|
+
}
|
|
1073
|
+
const now = this.clock.now();
|
|
1074
|
+
const startedAt = this.stateMachine?.progress?.states[work.schedule.stateName]?.startedAt;
|
|
1075
|
+
const settled = recordSettled(this.requireStateMachine(), work.schedule.stateName, "timer", {
|
|
1076
|
+
type: "completed",
|
|
1077
|
+
output: {
|
|
1078
|
+
elapsedMs: startedAt === undefined ? 0 : Math.max(0, now - startedAt),
|
|
1079
|
+
timestamp: now,
|
|
1080
|
+
},
|
|
1081
|
+
}, undefined, now);
|
|
1082
|
+
this.setStateMachine(settled.session);
|
|
1083
|
+
return settled.outcome;
|
|
1084
|
+
}
|
|
1085
|
+
if ("park" in work)
|
|
1086
|
+
return undefined;
|
|
1087
|
+
if ("subagent" in work.run) {
|
|
1088
|
+
this.startSubagentTask(work.run.subagent, work.run.stateName);
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
this.startShellTask(work.run.shell, work.run.stateName, work.run.pollPolicy);
|
|
864
1092
|
}
|
|
865
1093
|
return undefined;
|
|
866
1094
|
}
|
|
1095
|
+
startSubagentTask(spec, stateName) {
|
|
1096
|
+
const ownerScopeId = this.requireRootScope();
|
|
1097
|
+
const handle = this.taskManager.start({
|
|
1098
|
+
kind: "subagent",
|
|
1099
|
+
name: stateName,
|
|
1100
|
+
label: `Run state ${stateName}`,
|
|
1101
|
+
ownerScopeId,
|
|
1102
|
+
execute: async ({ signal, taskId }) => {
|
|
1103
|
+
const run = this.createStateSubagentRun({
|
|
1104
|
+
state: { kind: "agent", name: stateName, ...spec },
|
|
1105
|
+
prompt: spec.prompt,
|
|
1106
|
+
origin: { taskId },
|
|
1107
|
+
});
|
|
1108
|
+
const metadata = this.stateTasks.get(taskId);
|
|
1109
|
+
if (metadata?.kind === "agent")
|
|
1110
|
+
metadata.run = run;
|
|
1111
|
+
const interrupt = () => run.interrupt(String(signal.reason ?? "Interrupted"));
|
|
1112
|
+
if (signal.aborted)
|
|
1113
|
+
interrupt();
|
|
1114
|
+
signal.addEventListener("abort", interrupt, { once: true });
|
|
1115
|
+
try {
|
|
1116
|
+
return await run.prompt();
|
|
1117
|
+
}
|
|
1118
|
+
finally {
|
|
1119
|
+
signal.removeEventListener("abort", interrupt);
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
});
|
|
1123
|
+
this.stateTasks.set(handle.id, { kind: "agent", stateName });
|
|
1124
|
+
}
|
|
1125
|
+
startShellTask(spec, stateName, pollPolicy) {
|
|
1126
|
+
const shell = createShellStateHandle({
|
|
1127
|
+
command: spec.command,
|
|
1128
|
+
cwd: resolveStateCwd(spec.cwd, this.config.cwd ?? process.cwd()),
|
|
1129
|
+
timeoutMs: spec.timeoutMs,
|
|
1130
|
+
successCodes: spec.successCodes,
|
|
1131
|
+
});
|
|
1132
|
+
let taskId;
|
|
1133
|
+
let finish;
|
|
1134
|
+
const finishedPromise = new Promise((resolve) => {
|
|
1135
|
+
finish = resolve;
|
|
1136
|
+
});
|
|
1137
|
+
// Stop semantics are uniform across executors: process groups die by SIGKILL
|
|
1138
|
+
// immediately (matching pi-bash), so the interrupted terminal is never gated
|
|
1139
|
+
// on a grace window. Graceful TERM cleanup, if ever needed, is a future
|
|
1140
|
+
// explicit opt-in, not a blanket default.
|
|
1141
|
+
const unregisterReaper = this.taskManager.registerReaper(async (reason) => {
|
|
1142
|
+
shell.interrupt(reason);
|
|
1143
|
+
await finishedPromise;
|
|
1144
|
+
});
|
|
1145
|
+
const handle = this.taskManager.start({
|
|
1146
|
+
kind: "tool",
|
|
1147
|
+
name: stateName,
|
|
1148
|
+
label: `Run state ${stateName}`,
|
|
1149
|
+
ownerScopeId: this.requireRootScope(),
|
|
1150
|
+
execute: async ({ signal, onOutput }) => {
|
|
1151
|
+
const interrupt = () => shell.interrupt(String(signal.reason ?? "Interrupted"));
|
|
1152
|
+
signal.addEventListener("abort", interrupt, { once: true });
|
|
1153
|
+
try {
|
|
1154
|
+
const output = await shell.run();
|
|
1155
|
+
onOutput(output.stdout);
|
|
1156
|
+
return { type: "completed", output };
|
|
1157
|
+
}
|
|
1158
|
+
catch (error) {
|
|
1159
|
+
const reason = shell.interruptedReason();
|
|
1160
|
+
if (reason !== undefined)
|
|
1161
|
+
return { type: "interrupted", reason };
|
|
1162
|
+
return {
|
|
1163
|
+
type: "failed",
|
|
1164
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
finally {
|
|
1168
|
+
signal.removeEventListener("abort", interrupt);
|
|
1169
|
+
finish();
|
|
1170
|
+
unregisterReaper();
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
taskId = handle.id;
|
|
1175
|
+
this.stateTasks.set(taskId, {
|
|
1176
|
+
kind: pollPolicy ? "poll" : "script",
|
|
1177
|
+
stateName,
|
|
1178
|
+
shell,
|
|
1179
|
+
...(pollPolicy ? { pollPolicy } : {}),
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
async recordTaskSettlements(settlements) {
|
|
1183
|
+
let latest;
|
|
1184
|
+
for (const settlement of settlements) {
|
|
1185
|
+
if (this.ignoredTaskSettlements.delete(settlement.id)) {
|
|
1186
|
+
this.stateTasks.delete(settlement.id);
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const metadata = this.stateTasks.get(settlement.id);
|
|
1190
|
+
if (!metadata)
|
|
1191
|
+
continue;
|
|
1192
|
+
this.stateTasks.delete(settlement.id);
|
|
1193
|
+
const result = this.stateResultFromSettlement(settlement);
|
|
1194
|
+
const recorded = recordSettled(this.requireStateMachine(), metadata.stateName, metadata.kind, result, this.partialStateOutput(metadata), this.clock.now());
|
|
1195
|
+
this.setStateMachine(recorded.session);
|
|
1196
|
+
latest = recorded.outcome;
|
|
1197
|
+
if (latest.type === "sleep") {
|
|
1198
|
+
this.taskManager.start({
|
|
1199
|
+
kind: "scheduled",
|
|
1200
|
+
name: metadata.stateName,
|
|
1201
|
+
label: `Wait for ${metadata.stateName}`,
|
|
1202
|
+
ownerScopeId: this.requireRootScope(),
|
|
1203
|
+
wakeAt: latest.wakeAt,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return latest;
|
|
1208
|
+
}
|
|
1209
|
+
async processTaskSettlements(settlements) {
|
|
1210
|
+
const stateSettlements = settlements.filter((settlement) => this.stateTasks.has(settlement.id) || this.ignoredTaskSettlements.has(settlement.id));
|
|
1211
|
+
const taskSettlements = settlements.filter((settlement) => !stateSettlements.includes(settlement));
|
|
1212
|
+
const stateOutcome = await this.recordTaskSettlements(stateSettlements);
|
|
1213
|
+
if (taskSettlements.length === 0)
|
|
1214
|
+
return stateOutcome;
|
|
1215
|
+
if (stateOutcome) {
|
|
1216
|
+
this.parentInputs.unshift({ type: "task_settlements", settlements: taskSettlements });
|
|
1217
|
+
return stateOutcome;
|
|
1218
|
+
}
|
|
1219
|
+
const snapshots = taskSettlements
|
|
1220
|
+
.map((settlement) => this.taskManager.output(settlement.id))
|
|
1221
|
+
.filter((snapshot) => snapshot !== undefined);
|
|
1222
|
+
if (snapshots.length === 0)
|
|
1223
|
+
return stateOutcome;
|
|
1224
|
+
const worker = await this.runParentPass({
|
|
1225
|
+
state: this.snapshotState({ ...this.requireRunnerState(), status: "running" }),
|
|
1226
|
+
prompt: settlementNotice(snapshots),
|
|
1227
|
+
continuation: true,
|
|
1228
|
+
});
|
|
1229
|
+
this.setState(worker.outcome.state);
|
|
1230
|
+
if (worker.outcome.type === "interrupted")
|
|
1231
|
+
return { type: "interrupted" };
|
|
1232
|
+
if (worker.control.type === "ask_user_question") {
|
|
1233
|
+
return { type: "ask", questions: worker.control.questions };
|
|
1234
|
+
}
|
|
1235
|
+
if (this.requireRunnerState().mode !== "agent") {
|
|
1236
|
+
const controlled = await this.stateMachineResultFromWorker(worker, worker.outcome.state);
|
|
1237
|
+
if (controlled)
|
|
1238
|
+
return controlled;
|
|
1239
|
+
}
|
|
1240
|
+
return worker.outcome.status === "failed"
|
|
1241
|
+
? { type: "terminal", status: "error", error: worker.outcome.error }
|
|
1242
|
+
: { type: "terminal", status: "completed", result: worker.outcome.result };
|
|
1243
|
+
}
|
|
1244
|
+
stateResultFromSettlement(settlement) {
|
|
1245
|
+
if (settlement.status === "completed") {
|
|
1246
|
+
return settlement.result;
|
|
1247
|
+
}
|
|
1248
|
+
if (settlement.status === "stopped") {
|
|
1249
|
+
return { type: "interrupted", reason: settlement.reason };
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
type: "failed",
|
|
1253
|
+
error: settlement.status === "failed"
|
|
1254
|
+
? settlement.error instanceof Error
|
|
1255
|
+
? settlement.error.message
|
|
1256
|
+
: String(settlement.error)
|
|
1257
|
+
: `Task ${settlement.id} was lost.`,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
partialStateOutput(metadata) {
|
|
1261
|
+
if (metadata.kind === "agent") {
|
|
1262
|
+
const assistantText = metadata.run?.partialAssistantText();
|
|
1263
|
+
return assistantText ? { assistantText } : undefined;
|
|
1264
|
+
}
|
|
1265
|
+
return metadata.shell.partialOutput();
|
|
1266
|
+
}
|
|
1267
|
+
async interruptTasks(reason) {
|
|
1268
|
+
for (const [id, metadata] of this.stateTasks) {
|
|
1269
|
+
if (this.taskManager.output(id)?.descriptor.status !== "running")
|
|
1270
|
+
continue;
|
|
1271
|
+
const recorded = recordSettled(this.requireStateMachine(), metadata.stateName, metadata.kind, { type: "interrupted", reason }, this.partialStateOutput(metadata), this.clock.now());
|
|
1272
|
+
this.setStateMachine(recorded.session);
|
|
1273
|
+
this.ignoredTaskSettlements.add(id);
|
|
1274
|
+
}
|
|
1275
|
+
await this.taskManager.interruptAll(reason);
|
|
1276
|
+
}
|
|
1277
|
+
async cancelScheduledTasks(reason) {
|
|
1278
|
+
const scheduled = this.taskManager.list().filter((task) => task.status === "scheduled");
|
|
1279
|
+
for (const task of scheduled)
|
|
1280
|
+
this.ignoredTaskSettlements.add(task.id);
|
|
1281
|
+
await Promise.all(scheduled.map((task) => this.taskManager.stop(task.id, reason)));
|
|
1282
|
+
}
|
|
867
1283
|
/**
|
|
868
1284
|
* Build a loop-warning system reminder when the orchestrator has selected the
|
|
869
1285
|
* same state many times in a row within a short window with no other state
|
|
870
1286
|
* running in between. This is the idle-"holding" hot loop: re-selecting a
|
|
871
1287
|
* state to "keep waiting" is a no-op because selecting runs the state again
|
|
872
1288
|
* immediately rather than suspending. The reminder re-teaches the only
|
|
873
|
-
* primitives that actually wait —
|
|
1289
|
+
* primitives that actually wait — park for a parent-owned human gate, a poll
|
|
874
1290
|
* for a checkable condition, a timer for a fixed time — and tells the parent
|
|
875
1291
|
* to stop re-selecting the same state unchanged. Returns undefined when the
|
|
876
1292
|
* streak is below threshold or spread over too long a span to be a hot loop.
|
|
877
1293
|
*/
|
|
878
1294
|
repeatedSelectionLoopWarning(stateName) {
|
|
879
|
-
const session = this.
|
|
1295
|
+
const session = this.stateMachine;
|
|
880
1296
|
const count = session ? repeatedSelectionLoopCount(session, stateName) : undefined;
|
|
881
1297
|
if (count === undefined)
|
|
882
1298
|
return undefined;
|
|
@@ -885,7 +1301,7 @@ export class TurnRunner {
|
|
|
885
1301
|
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
1302
|
|
|
887
1303
|
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 →
|
|
1304
|
+
- 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
1305
|
- 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
1306
|
- a fixed future time → a timer state (wakeAt or wakeAfterMs).
|
|
891
1307
|
|
|
@@ -895,7 +1311,7 @@ export class TurnRunner {
|
|
|
895
1311
|
}
|
|
896
1312
|
async selectNextStateAfterCompletion(stateName, output) {
|
|
897
1313
|
const loopWarning = this.repeatedSelectionLoopWarning(stateName);
|
|
898
|
-
return this.enforceParentTransition((retryInstruction) => dedent `
|
|
1314
|
+
return this.enforceParentTransition((retryInstruction) => systemReminder(dedent `
|
|
899
1315
|
The state "${stateName}" finished.
|
|
900
1316
|
|
|
901
1317
|
${toXML({
|
|
@@ -917,38 +1333,16 @@ export class TurnRunner {
|
|
|
917
1333
|
|
|
918
1334
|
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
1335
|
</system-reminder>
|
|
920
|
-
|
|
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.");
|
|
1336
|
+
`), "State completed, but the runner did not call select_state_machine_state.");
|
|
943
1337
|
}
|
|
944
1338
|
/**
|
|
945
1339
|
* Shared bounded-retry loop for transitions the parent owes the machine.
|
|
946
1340
|
* Re-prompts up to `PARENT_TRANSITION_RETRY_BUDGET` times with `buildPrompt`
|
|
947
|
-
* (the second and later attempts carry a retry reminder); the first
|
|
948
|
-
*
|
|
949
|
-
* exhausted with no control action, records an
|
|
950
|
-
* `failureReason` — a runtime failure of the
|
|
951
|
-
* `failed` selection.
|
|
1341
|
+
* (the second and later attempts carry a retry reminder); the first accepted
|
|
1342
|
+
* selection ends enforcement, with an immediate outcome only for synchronous
|
|
1343
|
+
* work. When the budget is exhausted with no control action, records an
|
|
1344
|
+
* `error` terminal carrying `failureReason` — a runtime failure of the
|
|
1345
|
+
* machine, not a deliberate `failed` selection.
|
|
952
1346
|
*/
|
|
953
1347
|
async enforceParentTransition(buildPrompt, failureReason) {
|
|
954
1348
|
for (let attempt = 1; attempt <= PARENT_TRANSITION_RETRY_BUDGET; attempt++) {
|
|
@@ -956,162 +1350,82 @@ export class TurnRunner {
|
|
|
956
1350
|
? undefined
|
|
957
1351
|
: `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
1352
|
const turnState = this.snapshotState({ ...this.requireRunnerState(), status: "running" });
|
|
959
|
-
const workerResult = await this.
|
|
1353
|
+
const workerResult = await this.runParentPass({
|
|
960
1354
|
state: turnState,
|
|
961
1355
|
prompt: buildPrompt(retryInstruction),
|
|
962
|
-
|
|
1356
|
+
continuation: true,
|
|
963
1357
|
});
|
|
964
1358
|
this.setState(workerResult.outcome.state);
|
|
965
|
-
const result = await this.
|
|
1359
|
+
const result = await this.stateMachineResultFromWorker(workerResult, workerResult.outcome.state);
|
|
966
1360
|
if (result)
|
|
967
1361
|
return result;
|
|
1362
|
+
// Starting an asynchronous state has no immediate outcome. The select
|
|
1363
|
+
// control itself proves the transition obligation was satisfied.
|
|
1364
|
+
if (workerResult.control.type === "select_state_machine_state")
|
|
1365
|
+
return undefined;
|
|
968
1366
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
},
|
|
1367
|
+
const failed = failActiveSession(this.requireStateMachine(), this.stateMachine?.currentState ?? "", failureReason);
|
|
1368
|
+
this.setStateMachine(failed.session);
|
|
1369
|
+
return failed.outcome;
|
|
1370
|
+
}
|
|
1371
|
+
createStateSubagentRun(input) {
|
|
1372
|
+
const spec = {
|
|
1373
|
+
prompt: input.prompt,
|
|
1374
|
+
...(input.state.systemPrompt ? { systemPrompt: input.state.systemPrompt } : {}),
|
|
1375
|
+
...(input.state.allowedSkills ? { allowedSkills: input.state.allowedSkills } : {}),
|
|
1376
|
+
...(input.state.cwd ? { cwd: input.state.cwd } : {}),
|
|
1377
|
+
...(input.state.model ? { model: input.state.model } : {}),
|
|
1378
|
+
...(input.state.thinkingLevel ? { thinkingLevel: input.state.thinkingLevel } : {}),
|
|
1379
|
+
...(input.state.forkContext ? { forkContext: true } : {}),
|
|
996
1380
|
};
|
|
997
|
-
const
|
|
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();
|
|
1381
|
+
const session = this.stateMachine;
|
|
1008
1382
|
const machineContext = session
|
|
1009
1383
|
? { definition: session.definition, currentState: input.state.name }
|
|
1010
1384
|
: undefined;
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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;
|
|
1385
|
+
return this.subagentExecutor(spec, {
|
|
1386
|
+
origin: input.origin,
|
|
1387
|
+
...(machineContext ? { machineContext } : {}),
|
|
1043
1388
|
});
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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?.();
|
|
1389
|
+
}
|
|
1390
|
+
/** Build a public spawn only after TaskManager has allocated its identity and child scope. */
|
|
1391
|
+
async createSpawnedSubagentRun(publicSpec, taskId, ownerScopeId, childScopeId, forkSource, callerModelSetting) {
|
|
1392
|
+
const parentSetting = callerModelSetting ??
|
|
1393
|
+
this.requireRunnerState().options?.model ??
|
|
1394
|
+
this.config.model ??
|
|
1395
|
+
DEFAULT_CLI_MODEL;
|
|
1396
|
+
const selection = await this.selectSpawnModel(publicSpec.prompt, parentSetting);
|
|
1397
|
+
const memorySessionId = this.config.sessionId
|
|
1398
|
+
? `${this.config.sessionId}:sub:${taskId}`
|
|
1399
|
+
: undefined;
|
|
1400
|
+
return this.subagentExecutor({
|
|
1401
|
+
...publicSpec,
|
|
1402
|
+
model: selection.modelName,
|
|
1403
|
+
...(selection.thinkingLevel ? { thinkingLevel: selection.thinkingLevel } : {}),
|
|
1404
|
+
}, {
|
|
1405
|
+
origin: { taskId },
|
|
1406
|
+
childScopeId,
|
|
1407
|
+
ownerScopeId,
|
|
1408
|
+
memoryContext: {
|
|
1409
|
+
...(memorySessionId ? { sessionId: memorySessionId } : {}),
|
|
1410
|
+
horizon: createInitialHorizon(),
|
|
1111
1411
|
},
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1412
|
+
...(forkSource ? { forkSource } : {}),
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
/** Stateless spawn classifier seam; deliberately never consults the session ModelRouter. */
|
|
1416
|
+
async selectSpawnModel(prompt, parentSetting) {
|
|
1417
|
+
const table = this.routingTable ?? BUILT_IN_ROUTING_TABLE;
|
|
1418
|
+
return classifySpawnModel(prompt, parentSetting, {
|
|
1419
|
+
table,
|
|
1420
|
+
resolveCatalog: routingCatalogAdapter,
|
|
1421
|
+
classifierOptions: this.classifierOptions(table),
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
async dropSubagentScratch(taskId) {
|
|
1425
|
+
const session = this.memoryPersistence?.session;
|
|
1426
|
+
if (!session || !this.config.sessionId)
|
|
1427
|
+
return;
|
|
1428
|
+
await replaceSessionObservations(session, `${this.config.sessionId}:sub:${taskId}`, []);
|
|
1115
1429
|
}
|
|
1116
1430
|
requireRunnerState() {
|
|
1117
1431
|
if (!this.state) {
|
|
@@ -1119,10 +1433,75 @@ export class TurnRunner {
|
|
|
1119
1433
|
}
|
|
1120
1434
|
return this.state;
|
|
1121
1435
|
}
|
|
1122
|
-
|
|
1123
|
-
if (!
|
|
1124
|
-
|
|
1125
|
-
return
|
|
1436
|
+
requireStateMachine() {
|
|
1437
|
+
if (!this.stateMachine)
|
|
1438
|
+
throw new Error("No state machine is active.");
|
|
1439
|
+
return this.stateMachine;
|
|
1440
|
+
}
|
|
1441
|
+
/** Protected so test probes can supply a scope when driving tools outside a turn. */
|
|
1442
|
+
requireRootScope() {
|
|
1443
|
+
if (!this.activeRootScopeId)
|
|
1444
|
+
throw new Error("No turn root scope is active.");
|
|
1445
|
+
return this.activeRootScopeId;
|
|
1446
|
+
}
|
|
1447
|
+
setStateMachine(session, notify = false) {
|
|
1448
|
+
this.stateMachine = session;
|
|
1449
|
+
if (notify)
|
|
1450
|
+
this.emit({ type: "state_machine", stateMachine: session });
|
|
1451
|
+
}
|
|
1452
|
+
getActiveStateOutput() {
|
|
1453
|
+
for (const [id, metadata] of this.stateTasks) {
|
|
1454
|
+
if (this.taskManager.output(id)?.descriptor.status !== "running")
|
|
1455
|
+
continue;
|
|
1456
|
+
if (metadata.kind === "agent") {
|
|
1457
|
+
const assistantText = metadata.run?.partialAssistantText();
|
|
1458
|
+
return assistantText
|
|
1459
|
+
? { state: metadata.stateName, kind: "agent", output: { assistantText } }
|
|
1460
|
+
: { state: metadata.stateName, kind: "agent" };
|
|
1461
|
+
}
|
|
1462
|
+
const output = metadata.shell.partialOutput();
|
|
1463
|
+
return output
|
|
1464
|
+
? { state: metadata.stateName, kind: metadata.kind, output }
|
|
1465
|
+
: { state: metadata.stateName, kind: metadata.kind };
|
|
1466
|
+
}
|
|
1467
|
+
return undefined;
|
|
1468
|
+
}
|
|
1469
|
+
captureParentControlResult(result) {
|
|
1470
|
+
if (result.type === "none")
|
|
1471
|
+
return;
|
|
1472
|
+
if (this.parentControlResults.length > 0) {
|
|
1473
|
+
throw new Error("A parent pass produced more than one control result.");
|
|
1474
|
+
}
|
|
1475
|
+
this.parentControlResults.push(result);
|
|
1476
|
+
}
|
|
1477
|
+
consumeParentControlResult() {
|
|
1478
|
+
const result = this.parentControlResults.shift() ?? { type: "none" };
|
|
1479
|
+
if (this.parentControlResults.length > 0) {
|
|
1480
|
+
throw new Error("A parent pass produced more than one control result.");
|
|
1481
|
+
}
|
|
1482
|
+
return result;
|
|
1483
|
+
}
|
|
1484
|
+
attachTurnUsage(terminal) {
|
|
1485
|
+
if (!this.turnUsage)
|
|
1486
|
+
return terminal;
|
|
1487
|
+
return {
|
|
1488
|
+
...terminal,
|
|
1489
|
+
turnUsage: this.turnUsage,
|
|
1490
|
+
usageByModel: this.turnUsageByModel ?? [],
|
|
1491
|
+
...(this.lastParentUsageSnapshot
|
|
1492
|
+
? {
|
|
1493
|
+
effectiveContextWindow: this.lastParentUsageSnapshot.effectiveContextWindow,
|
|
1494
|
+
contextWindowUsage: this.lastParentUsageSnapshot.contextWindowUsage,
|
|
1495
|
+
lastMessageUsage: this.lastParentUsageSnapshot.lastMessageUsage,
|
|
1496
|
+
}
|
|
1497
|
+
: {}),
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
emitTerminalOnce(terminal) {
|
|
1501
|
+
if (this.terminalEmitted)
|
|
1502
|
+
return;
|
|
1503
|
+
this.terminalEmitted = true;
|
|
1504
|
+
this.emit(terminal);
|
|
1126
1505
|
}
|
|
1127
1506
|
getState() {
|
|
1128
1507
|
if (!this.state)
|
|
@@ -1140,10 +1519,18 @@ export class TurnRunner {
|
|
|
1140
1519
|
const snapshot = {
|
|
1141
1520
|
...state,
|
|
1142
1521
|
agent: parentAgent,
|
|
1143
|
-
stateMachine: this.
|
|
1522
|
+
stateMachine: this.stateMachine,
|
|
1144
1523
|
todos: copyOptionalArray(state.todos ?? this.state?.todos),
|
|
1145
1524
|
followUpQueue: copyOptionalArray(state.followUpQueue ?? this.state?.followUpQueue),
|
|
1146
|
-
queuedCommands:
|
|
1525
|
+
queuedCommands: this.activeRootScopeId !== undefined
|
|
1526
|
+
? this.parentInputs
|
|
1527
|
+
.filter((input) => input.type === "user_command")
|
|
1528
|
+
.map((input) => input.command)
|
|
1529
|
+
: (this.hydratedQueuedCommands ??
|
|
1530
|
+
copyOptionalArray(state.queuedCommands ?? this.state?.queuedCommands)),
|
|
1531
|
+
tasks: [...this.taskManager.list()],
|
|
1532
|
+
taskOutputTails: this.snapshotTaskOutputTails(),
|
|
1533
|
+
nextTaskId: this.taskManager.nextTaskId(),
|
|
1147
1534
|
// Carry wire-shaping state through every snapshot so persistence
|
|
1148
1535
|
// layers (state.json, terminal payloads, getState consumers) see
|
|
1149
1536
|
// the current value. Copy by spread so consumers can't mutate the
|
|
@@ -1157,6 +1544,16 @@ export class TurnRunner {
|
|
|
1157
1544
|
};
|
|
1158
1545
|
return this.applyAutoStateCompaction(snapshot);
|
|
1159
1546
|
}
|
|
1547
|
+
snapshotTaskOutputTails() {
|
|
1548
|
+
const tails = {};
|
|
1549
|
+
for (const descriptor of this.taskManager.list()) {
|
|
1550
|
+
const output = this.taskManager.output(descriptor.id)?.output;
|
|
1551
|
+
if (output && output.length > 0) {
|
|
1552
|
+
tails[descriptor.id] = output.slice(-3).map((chunk) => chunk.slice(-1_500));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
return Object.keys(tails).length > 0 ? tails : undefined;
|
|
1556
|
+
}
|
|
1160
1557
|
/**
|
|
1161
1558
|
* Caps the size of every state that leaves the runner via emit/return/
|
|
1162
1559
|
* getState. Runs only when `TurnRunnerConfig.autoStateCompaction` is
|
|
@@ -1200,20 +1597,6 @@ export class TurnRunner {
|
|
|
1200
1597
|
return;
|
|
1201
1598
|
this.setState({ ...this.state, followUpQueue: [...entries] });
|
|
1202
1599
|
}
|
|
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
1600
|
getTodos() {
|
|
1218
1601
|
return [...(this.state?.todos ?? [])];
|
|
1219
1602
|
}
|
|
@@ -1268,53 +1651,28 @@ export class TurnRunner {
|
|
|
1268
1651
|
? undefined
|
|
1269
1652
|
: createStateMachineSystemPromptLayer({ mode: state.mode, session: state }),
|
|
1270
1653
|
this.config.memoryDbPath !== undefined ? createRecallMemorySystemPromptLayer() : undefined,
|
|
1654
|
+
// Advisor timing guidance ships with the tool: description-only
|
|
1655
|
+
// steering measurably under-triggers consults on hard tasks.
|
|
1656
|
+
this.advisorPolicy?.enabled ? ADVISOR_EXECUTOR_GUIDANCE_LAYER : undefined,
|
|
1271
1657
|
// Source-of-truth-first guidance always applies: even without
|
|
1272
1658
|
// configured memory, the agent should still prefer live tools,
|
|
1273
1659
|
// skills, and files in cwd over guessed answers.
|
|
1274
1660
|
createSourceOfTruthSystemPromptLayer(),
|
|
1275
1661
|
].filter((layer) => Boolean(layer));
|
|
1276
1662
|
const appendSystemPrompt = layers.length > 0 ? layers.join("\n\n") : undefined;
|
|
1277
|
-
this.
|
|
1663
|
+
this.parentControlResults.length = 0;
|
|
1278
1664
|
this.parentAgent = this.createAgent({
|
|
1279
1665
|
state,
|
|
1280
1666
|
appendSystemPrompt,
|
|
1667
|
+
parentModelRouting: true,
|
|
1668
|
+
...(this.modelRouter ? { router: this.modelRouter } : {}),
|
|
1281
1669
|
...this.createTools(state.mode),
|
|
1282
1670
|
}, (result) => {
|
|
1283
|
-
this.
|
|
1671
|
+
this.captureParentControlResult(result);
|
|
1284
1672
|
});
|
|
1285
|
-
this.replayFollowUpQueueIntoAgent(this.parentAgent);
|
|
1286
1673
|
this.snapshotActiveAgentState();
|
|
1287
1674
|
}
|
|
1288
|
-
async
|
|
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) {
|
|
1675
|
+
async stateMachineResultFromWorker(workerResult, state) {
|
|
1318
1676
|
const control = workerResult.control;
|
|
1319
1677
|
if (control.type === "none")
|
|
1320
1678
|
return undefined;
|
|
@@ -1327,28 +1685,45 @@ export class TurnRunner {
|
|
|
1327
1685
|
// replaceActive. So an active, non-terminal session at this point means
|
|
1328
1686
|
// the agent deliberately chose to replace it — supersede it (recording a
|
|
1329
1687
|
// `cancelled` terminal) before installing the new machine in its place.
|
|
1330
|
-
const active = this.
|
|
1688
|
+
const active = this.stateMachine;
|
|
1331
1689
|
if (active && !active.terminal) {
|
|
1332
|
-
this.
|
|
1690
|
+
await this.replaceActiveStateTasks("Replaced by a new state machine.");
|
|
1691
|
+
this.setStateMachine(supersede(active, `Superseded by a new state machine ("${control.definition.name}").`), true);
|
|
1333
1692
|
}
|
|
1334
1693
|
const firstState = control.firstState;
|
|
1335
|
-
this.
|
|
1694
|
+
this.setStateMachine(startStateMachineSession({
|
|
1336
1695
|
prompt: workerResult.outcome.type === "complete" ? (workerResult.outcome.result ?? "") : "",
|
|
1337
1696
|
definition: control.definition,
|
|
1338
1697
|
currentState: firstState,
|
|
1339
|
-
});
|
|
1340
|
-
return this.
|
|
1698
|
+
}));
|
|
1699
|
+
return this.runStateMachineDecision({ state: firstState });
|
|
1341
1700
|
}
|
|
1342
|
-
if (!this.
|
|
1343
|
-
this.
|
|
1701
|
+
if (!this.stateMachine && typeof state.mode === "object") {
|
|
1702
|
+
this.setStateMachine(startStateMachineSession({
|
|
1344
1703
|
prompt: workerResult.outcome.type === "complete" ? (workerResult.outcome.result ?? "") : "",
|
|
1345
1704
|
definition: state.mode,
|
|
1346
1705
|
currentState: control.decision.state,
|
|
1347
|
-
});
|
|
1706
|
+
}));
|
|
1707
|
+
}
|
|
1708
|
+
return this.runStateMachineDecision(control.decision);
|
|
1709
|
+
}
|
|
1710
|
+
async runStateMachineDecision(decision) {
|
|
1711
|
+
await this.replaceActiveStateTasks("Replaced by a newly selected state.");
|
|
1712
|
+
await this.cancelScheduledTasks("Replaced by a newly selected state.");
|
|
1713
|
+
const planned = planDecision(this.requireStateMachine(), decision, this.clock.now());
|
|
1714
|
+
this.setStateMachine(planned.session, true);
|
|
1715
|
+
return this.executePlannedWork(planned.work);
|
|
1716
|
+
}
|
|
1717
|
+
async replaceActiveStateTasks(reason) {
|
|
1718
|
+
const active = [...this.stateTasks.entries()].filter(([id]) => this.taskManager.output(id)?.descriptor.status === "running");
|
|
1719
|
+
for (const [id, metadata] of active) {
|
|
1720
|
+
const recorded = recordSettled(this.requireStateMachine(), metadata.stateName, metadata.kind, { type: "interrupted", reason }, this.partialStateOutput(metadata), this.clock.now());
|
|
1721
|
+
this.setStateMachine(recorded.session);
|
|
1722
|
+
this.ignoredTaskSettlements.add(id);
|
|
1348
1723
|
}
|
|
1349
|
-
|
|
1724
|
+
await Promise.all(active.map(([id]) => this.taskManager.stop(id, reason)));
|
|
1350
1725
|
}
|
|
1351
|
-
createTools(mode, cwdOverride) {
|
|
1726
|
+
createTools(mode, cwdOverride, includeParentTools = true, childContext) {
|
|
1352
1727
|
const cwd = resolveStateCwd(cwdOverride, this.config.cwd ?? process.cwd());
|
|
1353
1728
|
const todoStorage = {
|
|
1354
1729
|
getTodos: () => this.getTodos(),
|
|
@@ -1362,58 +1737,146 @@ export class TurnRunner {
|
|
|
1362
1737
|
const recallStorage = {
|
|
1363
1738
|
getSession: () => this.memoryPersistence?.session,
|
|
1364
1739
|
embed: this.memoryPersistence?.embed,
|
|
1365
|
-
sessionId: this.config.sessionId,
|
|
1740
|
+
sessionId: childContext?.memorySessionId ?? this.config.sessionId,
|
|
1366
1741
|
// Reuse the resolved memory model so recall_memory's optional
|
|
1367
1742
|
// expand flag goes to the same cheap model the observer uses.
|
|
1368
1743
|
expansionModel: this.resolveMemoryActorModel(undefined),
|
|
1369
1744
|
};
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1745
|
+
const router = this.modelRouter;
|
|
1746
|
+
const advisorStorage = includeParentTools && router && this.advisorPolicy?.enabled
|
|
1747
|
+
? this.createAskAdvisorStorage(router, this.advisorPolicy)
|
|
1748
|
+
: undefined;
|
|
1749
|
+
const baseTools = mode === "agent"
|
|
1750
|
+
? createDefaultTurnRunnerTools(cwd, todoStorage, recallStorage, advisorStorage)
|
|
1751
|
+
: createTurnRunnerTools({
|
|
1752
|
+
cwd,
|
|
1753
|
+
mode,
|
|
1754
|
+
getDefinition: () => this.stateMachine?.definition,
|
|
1755
|
+
getStateMachine: () => this.stateMachine,
|
|
1756
|
+
getActiveStateOutput: () => this.getActiveStateOutput(),
|
|
1757
|
+
todoStorage,
|
|
1758
|
+
skills,
|
|
1759
|
+
recallStorage,
|
|
1760
|
+
advisorStorage,
|
|
1761
|
+
clock: this.clock,
|
|
1762
|
+
minimumScheduledDelayMs: this.minimumScheduledDelayMs,
|
|
1763
|
+
});
|
|
1764
|
+
const childSafeTools = includeParentTools
|
|
1765
|
+
? baseTools
|
|
1766
|
+
: baseTools.filter((tool) => tool.name !== "ask_user_question");
|
|
1767
|
+
const taskAwareTools = childSafeTools.map((tool) => tool.name === "bash"
|
|
1768
|
+
? wrapBackgroundable(tool, {
|
|
1769
|
+
taskManager: this.taskManager,
|
|
1770
|
+
defaultWaitBudgetMs: this.taskWaitBudgetMs,
|
|
1771
|
+
clock: this.clock,
|
|
1772
|
+
ownerScopeId: () => childContext?.ownerScopeId ?? this.requireRootScope(),
|
|
1773
|
+
label: (params) => String(params.command ?? "bash command"),
|
|
1774
|
+
onTaskStarted: (id, foregroundPending) => {
|
|
1775
|
+
this.taskSettlementDelivery.set(id, foregroundPending ? "foreground_pending" : "deliver");
|
|
1776
|
+
},
|
|
1777
|
+
onForegroundResult: (id, converted) => {
|
|
1778
|
+
this.taskSettlementDelivery.set(id, converted ? "deliver" : "suppress");
|
|
1779
|
+
this.scheduleSettlementDelivery();
|
|
1780
|
+
},
|
|
1781
|
+
})
|
|
1782
|
+
: tool);
|
|
1375
1783
|
return {
|
|
1376
1784
|
tools: [
|
|
1377
|
-
...
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
skills,
|
|
1385
|
-
recallStorage,
|
|
1386
|
-
}),
|
|
1785
|
+
...taskAwareTools,
|
|
1786
|
+
...(includeParentTools || childContext
|
|
1787
|
+
? [
|
|
1788
|
+
this.createSpawnTool(childContext),
|
|
1789
|
+
...createTaskAdminTools({ taskManager: this.taskManager, clock: this.clock }),
|
|
1790
|
+
]
|
|
1791
|
+
: []),
|
|
1387
1792
|
...mcpTools,
|
|
1388
1793
|
],
|
|
1389
1794
|
};
|
|
1390
1795
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1796
|
+
createSpawnTool(childContext) {
|
|
1797
|
+
const ownerScopeId = () => childContext?.ownerScopeId ?? this.requireRootScope();
|
|
1798
|
+
return createSpawnAgentTool({
|
|
1799
|
+
taskManager: this.taskManager,
|
|
1800
|
+
defaultWaitBudgetMs: this.taskWaitBudgetMs,
|
|
1801
|
+
clock: this.clock,
|
|
1802
|
+
ownerScopeId,
|
|
1803
|
+
createRun: async ({ spec, taskId, ownerScopeId: callerScopeId, childScopeId }) => this.createSpawnedSubagentRun(spec, taskId, callerScopeId, childScopeId, childContext?.forkSource, childContext?.modelSetting()),
|
|
1804
|
+
dropScratch: async (taskId) => this.dropSubagentScratch(taskId),
|
|
1805
|
+
onTaskStarted: (id, foregroundPending) => {
|
|
1806
|
+
this.taskSettlementDelivery.set(id, foregroundPending ? "foreground_pending" : "deliver");
|
|
1807
|
+
},
|
|
1808
|
+
onForegroundResult: (id, converted) => {
|
|
1809
|
+
this.taskSettlementDelivery.set(id, converted ? "deliver" : "suppress");
|
|
1810
|
+
this.scheduleSettlementDelivery();
|
|
1811
|
+
},
|
|
1401
1812
|
});
|
|
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
1813
|
}
|
|
1407
|
-
|
|
1814
|
+
createAskAdvisorStorage(router, policy) {
|
|
1815
|
+
const resolveAdvisorModel = () => resolveModelName(policy.target.modelName);
|
|
1816
|
+
return {
|
|
1817
|
+
getMessages: () => this.parentAgent?.state.messages ?? this.state?.agent.messages ?? [],
|
|
1818
|
+
getSystemPrompt: () => this.parentAgent?.state.systemPrompt ?? "",
|
|
1819
|
+
getObservations: async () => {
|
|
1820
|
+
const session = this.memoryPersistence?.session;
|
|
1821
|
+
const sessionId = this.config.sessionId;
|
|
1822
|
+
if (!session || !sessionId)
|
|
1823
|
+
return [];
|
|
1824
|
+
const snapshot = await readSessionObservations(session, sessionId);
|
|
1825
|
+
return snapshot.observations.map((observation) => observation.content);
|
|
1826
|
+
},
|
|
1827
|
+
budgetTokens: policy.transcriptTokens,
|
|
1828
|
+
modelName: () => resolveAdvisorModel().id,
|
|
1829
|
+
thinkingLevel: policy.target.thinkingLevel,
|
|
1830
|
+
advisorGate: () => router.beginAdvisorConsult(),
|
|
1831
|
+
noteAdvisorConsult: (success = true) => router.endAdvisorConsult(success),
|
|
1832
|
+
recordUsage: (usage) => {
|
|
1833
|
+
const model = resolveAdvisorModel();
|
|
1834
|
+
this.recordAndEmitUsage(usageFromAiSdk(usage, model), model.id);
|
|
1835
|
+
},
|
|
1836
|
+
onUsageError: (error) => {
|
|
1837
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1838
|
+
this.emit({
|
|
1839
|
+
type: "system",
|
|
1840
|
+
level: "warn",
|
|
1841
|
+
message: `Advisor usage accounting failed; advice was retained: ${reason}`,
|
|
1842
|
+
});
|
|
1843
|
+
},
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Pass-prep applied to EVERY parent pass, including harness overrides of
|
|
1848
|
+
* `runAgentWorker` (they must call this first). One-shot reminders that must
|
|
1849
|
+
* reach "whatever parent pass runs next" live here.
|
|
1850
|
+
*/
|
|
1851
|
+
prepareParentPassInput(input) {
|
|
1852
|
+
const withheldAsk = this.withheldAskQuestions;
|
|
1853
|
+
this.withheldAskQuestions = undefined;
|
|
1854
|
+
if (!withheldAsk)
|
|
1855
|
+
return input;
|
|
1856
|
+
return { ...input, prompt: `${withheldAskReminder(withheldAsk)}\n\n${input.prompt}` };
|
|
1857
|
+
}
|
|
1858
|
+
async runAgentWorker(rawInput) {
|
|
1859
|
+
const input = this.prepareParentPassInput(rawInput);
|
|
1408
1860
|
if (this.parentAgentRunning) {
|
|
1409
1861
|
throw new Error("Cannot start a parent agent while another parent agent is active.");
|
|
1410
1862
|
}
|
|
1411
1863
|
const agent = this.requireParentAgent();
|
|
1412
|
-
this.
|
|
1864
|
+
this.applyPendingModelSelection(agent);
|
|
1865
|
+
agent.state.tools = this.turnTools ?? this.createTools(input.state.mode).tools;
|
|
1866
|
+
this.parentControlResults.length = 0;
|
|
1413
1867
|
this.setParentAgentRunning(true);
|
|
1414
1868
|
const unsubscribe = agent.subscribe((event) => this.emitParentAgentEvent(event));
|
|
1415
1869
|
this.parentAgentInterrupted = false;
|
|
1416
1870
|
try {
|
|
1871
|
+
if (!input.continuation) {
|
|
1872
|
+
this.modelRouter?.noteTurnStart({ promptHasImages: (input.images?.length ?? 0) > 0 });
|
|
1873
|
+
}
|
|
1874
|
+
const switched = await this.modelRouter?.prepareTurn({
|
|
1875
|
+
prevTurnHint: input.prompt,
|
|
1876
|
+
signal: agent.signal,
|
|
1877
|
+
});
|
|
1878
|
+
if (switched)
|
|
1879
|
+
await this.applyRouterSwitch(agent, switched);
|
|
1417
1880
|
await agent.prompt(input.prompt, input.images);
|
|
1418
1881
|
// Single-shot recovery: if the provider rejected the first attempt
|
|
1419
1882
|
// with a context-overflow error, advance the sticky wire-shaping
|
|
@@ -1439,7 +1902,7 @@ export class TurnRunner {
|
|
|
1439
1902
|
}
|
|
1440
1903
|
if (this.parentAgentInterrupted) {
|
|
1441
1904
|
return {
|
|
1442
|
-
control: this.
|
|
1905
|
+
control: this.consumeParentControlResult(),
|
|
1443
1906
|
outcome: { type: "interrupted", state: this.requireRunnerState() },
|
|
1444
1907
|
};
|
|
1445
1908
|
}
|
|
@@ -1456,13 +1919,11 @@ export class TurnRunner {
|
|
|
1456
1919
|
},
|
|
1457
1920
|
todos: live?.todos ?? input.state.todos,
|
|
1458
1921
|
followUpQueue: live?.followUpQueue ?? input.state.followUpQueue,
|
|
1459
|
-
|
|
1922
|
+
tasks: live?.tasks ?? input.state.tasks,
|
|
1923
|
+
nextTaskId: live?.nextTaskId ?? input.state.nextTaskId,
|
|
1460
1924
|
};
|
|
1461
|
-
if (status === "completed") {
|
|
1462
|
-
await this.updateMemoryAfterAgentRun(messages, state.options);
|
|
1463
|
-
}
|
|
1464
1925
|
return {
|
|
1465
|
-
control: this.
|
|
1926
|
+
control: this.consumeParentControlResult(),
|
|
1466
1927
|
outcome: {
|
|
1467
1928
|
type: "complete",
|
|
1468
1929
|
status,
|
|
@@ -1537,6 +1998,7 @@ export class TurnRunner {
|
|
|
1537
1998
|
return false;
|
|
1538
1999
|
messages.pop();
|
|
1539
2000
|
this.wireGuardHorizon.evictionHorizon = newHorizon;
|
|
2001
|
+
this.modelRouter?.noteCompaction();
|
|
1540
2002
|
const remaining = applyEvictionHorizon(observable, newHorizon).length;
|
|
1541
2003
|
const dropped = observable.length - remaining;
|
|
1542
2004
|
this.emit({
|
|
@@ -1608,7 +2070,7 @@ export class TurnRunner {
|
|
|
1608
2070
|
attempt = messagesAppended > 1 ? 1 : attempt + 1;
|
|
1609
2071
|
}
|
|
1610
2072
|
}
|
|
1611
|
-
async updateMemoryAfterAgentRun(messages, options) {
|
|
2073
|
+
async updateMemoryAfterAgentRun(messages, options, sessionId = this.config.sessionId, refreshReflections = true) {
|
|
1612
2074
|
if (this.config.memoryDbPath === undefined) {
|
|
1613
2075
|
return;
|
|
1614
2076
|
}
|
|
@@ -1624,7 +2086,7 @@ export class TurnRunner {
|
|
|
1624
2086
|
const result = await updateObservationalMemory({
|
|
1625
2087
|
session,
|
|
1626
2088
|
memory: this.memory,
|
|
1627
|
-
sessionId
|
|
2089
|
+
sessionId,
|
|
1628
2090
|
effectiveContext: this.resolveEffectiveContext(this.parentAgent?.state.model.contextWindow),
|
|
1629
2091
|
actorModel: memoryModel,
|
|
1630
2092
|
settings: this.config.memory,
|
|
@@ -1646,7 +2108,7 @@ export class TurnRunner {
|
|
|
1646
2108
|
// the new condensed view. Observer-only updates intentionally do
|
|
1647
2109
|
// NOT refresh the pack — they leave the rendered prefix stable so
|
|
1648
2110
|
// the provider's prompt cache survives.
|
|
1649
|
-
if (result.reflections.length > 0) {
|
|
2111
|
+
if (refreshReflections && result.reflections.length > 0) {
|
|
1650
2112
|
await this.refreshMemoryContextPack();
|
|
1651
2113
|
}
|
|
1652
2114
|
}
|
|
@@ -1707,14 +2169,19 @@ export class TurnRunner {
|
|
|
1707
2169
|
}
|
|
1708
2170
|
createAgent(input, onControlResult) {
|
|
1709
2171
|
const options = this.resolveTurnOptions(undefined, input.state.options);
|
|
1710
|
-
const
|
|
2172
|
+
const initialRoute = input.router?.initialTarget({ hasImages: false });
|
|
2173
|
+
const model = resolveModelName(initialRoute?.modelName ?? options.model ?? DEFAULT_CLI_MODEL);
|
|
1711
2174
|
// Parent agent configuration is derived from start/session options, not
|
|
1712
|
-
// per-prompt command options.
|
|
1713
|
-
//
|
|
1714
|
-
|
|
2175
|
+
// per-prompt command options. For concrete selections the model stays
|
|
2176
|
+
// stable for the whole session to protect prompt caching; routed sessions
|
|
2177
|
+
// deliberately trade cache prefixes away when the ModelRouter swaps the
|
|
2178
|
+
// model (per turn or via prepareNextTurn), with the classifier prompt
|
|
2179
|
+
// carrying the don't-switch-mid-task cache preference.
|
|
2180
|
+
let agent;
|
|
2181
|
+
agent = new Agent({
|
|
1715
2182
|
initialState: {
|
|
1716
2183
|
model,
|
|
1717
|
-
thinkingLevel: options.thinkingLevel ?? "medium",
|
|
2184
|
+
thinkingLevel: initialRoute?.thinkingLevel ?? options.thinkingLevel ?? "medium",
|
|
1718
2185
|
systemPrompt: input.systemPrompt ??
|
|
1719
2186
|
this.createBaseSystemPromptWithAppendedLayers({
|
|
1720
2187
|
prepend: [input.prependSystemPrompt],
|
|
@@ -1724,7 +2191,18 @@ export class TurnRunner {
|
|
|
1724
2191
|
messages: input.state.agent.messages,
|
|
1725
2192
|
tools: input.tools,
|
|
1726
2193
|
},
|
|
1727
|
-
transformContext: this.createMemoryTransform(),
|
|
2194
|
+
transformContext: this.createMemoryTransform(input.memoryContext),
|
|
2195
|
+
...(input.parentModelRouting
|
|
2196
|
+
? {
|
|
2197
|
+
prepareNextTurn: async (signal) => {
|
|
2198
|
+
const switched = await this.modelRouter?.prepareTurn({
|
|
2199
|
+
signal,
|
|
2200
|
+
});
|
|
2201
|
+
return switched ? await this.applyRouterSwitch(agent, switched) : undefined;
|
|
2202
|
+
},
|
|
2203
|
+
}
|
|
2204
|
+
: {}),
|
|
2205
|
+
steeringMode: "all",
|
|
1728
2206
|
toolExecution: "parallel",
|
|
1729
2207
|
afterToolCall: async (context) => {
|
|
1730
2208
|
const details = context.result.details;
|
|
@@ -1735,8 +2213,9 @@ export class TurnRunner {
|
|
|
1735
2213
|
},
|
|
1736
2214
|
getApiKey: resolveProviderApiKey,
|
|
1737
2215
|
});
|
|
2216
|
+
return agent;
|
|
1738
2217
|
}
|
|
1739
|
-
createMemoryTransform() {
|
|
2218
|
+
createMemoryTransform(memoryContext) {
|
|
1740
2219
|
// The memory transform owns history retention against both the token
|
|
1741
2220
|
// budget (context-window cost on smaller models) and the wire-byte
|
|
1742
2221
|
// budget (gateway request-body caps). It applies the sticky horizon
|
|
@@ -1744,10 +2223,26 @@ export class TurnRunner {
|
|
|
1744
2223
|
// provider prompt cache stays valid between eviction events.
|
|
1745
2224
|
return createObservationalContextTransform({
|
|
1746
2225
|
memory: this.memory,
|
|
1747
|
-
effectiveContext: this.resolveEffectiveContext(this.parentAgent?.state.model.contextWindow),
|
|
2226
|
+
effectiveContext: () => this.resolveEffectiveContext(this.parentAgent?.state.model.contextWindow),
|
|
1748
2227
|
settings: this.config.memory,
|
|
1749
|
-
horizon: this.wireGuardHorizon,
|
|
1750
|
-
onCompaction: (messages) =>
|
|
2228
|
+
horizon: memoryContext?.horizon ?? this.wireGuardHorizon,
|
|
2229
|
+
onCompaction: async (messages) => {
|
|
2230
|
+
if (memoryContext) {
|
|
2231
|
+
try {
|
|
2232
|
+
await this.updateMemoryAfterAgentRun(messages, undefined, memoryContext.sessionId, false);
|
|
2233
|
+
}
|
|
2234
|
+
catch (error) {
|
|
2235
|
+
this.emit({
|
|
2236
|
+
type: "system",
|
|
2237
|
+
level: "warn",
|
|
2238
|
+
message: `compact: child observation drain failed (${truncateForSystemMessage(error instanceof Error ? error.message : String(error))}); evicting anyway.`,
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
await this.ensureMemoryCoverageForCompaction(messages);
|
|
2244
|
+
this.modelRouter?.noteCompaction();
|
|
2245
|
+
},
|
|
1751
2246
|
});
|
|
1752
2247
|
}
|
|
1753
2248
|
// Sticky across all turns within this runner instance. Resets on
|
|
@@ -1842,31 +2337,6 @@ export class TurnRunner {
|
|
|
1842
2337
|
createBaseSystemPromptWithAppendedLayers(input) {
|
|
1843
2338
|
return this.skillContext.createSystemPromptWithAppendedLayers(input);
|
|
1844
2339
|
}
|
|
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
2340
|
/**
|
|
1871
2341
|
* Single source of truth for per-turn option precedence:
|
|
1872
2342
|
* explicit turn options → carried-over state base → runner config → defaults.
|
|
@@ -1887,6 +2357,11 @@ export class TurnRunner {
|
|
|
1887
2357
|
this.turnUsageByModel = addUsageByModel(this.turnUsageByModel, modelId, usage);
|
|
1888
2358
|
}
|
|
1889
2359
|
}
|
|
2360
|
+
/** Attribute a non-parent model call and publish it once a flat context snapshot exists. */
|
|
2361
|
+
recordAndEmitUsage(usage, modelId) {
|
|
2362
|
+
this.recordUsage(usage, modelId);
|
|
2363
|
+
this.emitTurnUsage();
|
|
2364
|
+
}
|
|
1890
2365
|
emitAgentEvent(event, origin) {
|
|
1891
2366
|
if (event.type === "message_start" && event.message.role === "user") {
|
|
1892
2367
|
this.removeFollowUpPrompt(agentMessageText(event.message));
|
|
@@ -1897,6 +2372,9 @@ export class TurnRunner {
|
|
|
1897
2372
|
}
|
|
1898
2373
|
emitParentAgentEvent(event) {
|
|
1899
2374
|
this.emitAgentEvent(event);
|
|
2375
|
+
if (event.type === "turn_end" && event.message.role === "assistant") {
|
|
2376
|
+
this.modelRouter?.noteAssistantStep(routerStepObservation(event.message, event.toolResults));
|
|
2377
|
+
}
|
|
1900
2378
|
if (event.type !== "message_end" || event.message.role !== "assistant")
|
|
1901
2379
|
return;
|
|
1902
2380
|
// Cache the parent's latest bar/breakdown so subsequent state-agent and
|
|
@@ -1913,7 +2391,7 @@ export class TurnRunner {
|
|
|
1913
2391
|
// consumed tokens, so a rejected attempt (e.g. a provider context-overflow
|
|
1914
2392
|
// error the turn then recovers from) carries no usage and would otherwise
|
|
1915
2393
|
// emit a degenerate all-zero bar.
|
|
1916
|
-
this.recordUsage(event.message.usage,
|
|
2394
|
+
this.recordUsage(event.message.usage, event.message.model);
|
|
1917
2395
|
if (event.message.usage.totalTokens > 0)
|
|
1918
2396
|
this.emitTurnUsage();
|
|
1919
2397
|
}
|
|
@@ -1962,14 +2440,7 @@ export class TurnRunner {
|
|
|
1962
2440
|
// can still refresh `lastUsage`. Session cost was already credited
|
|
1963
2441
|
// at the prior terminal, so a zero `turnUsage.cost.total` does not
|
|
1964
2442
|
// 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
|
-
};
|
|
2443
|
+
const turnUsage = this.turnUsage ?? emptyTokenUsage();
|
|
1973
2444
|
this.emit({
|
|
1974
2445
|
type: "usage",
|
|
1975
2446
|
turnUsage,
|
|
@@ -1983,11 +2454,9 @@ export class TurnRunner {
|
|
|
1983
2454
|
* Emit a `usage` event reflecting the latest `this.turnUsage`. Reuses the
|
|
1984
2455
|
* most recent parent context-window snapshot for the bar/breakdown so
|
|
1985
2456
|
* mid-turn ticks (parent worker finish, state-agent finish) surface cost
|
|
1986
|
-
* without jittering the parent context fields.
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
* parent worker always sets the snapshot before its terminal usage is
|
|
1990
|
-
* recorded.
|
|
2457
|
+
* without jittering the parent context fields. Before the first parent
|
|
2458
|
+
* completion, usage is recorded for the terminal and the first later usage
|
|
2459
|
+
* event, but no incomplete wire event is emitted.
|
|
1991
2460
|
*/
|
|
1992
2461
|
emitTurnUsage(origin) {
|
|
1993
2462
|
if (!this.turnUsage || !this.lastParentUsageSnapshot)
|
|
@@ -2055,6 +2524,134 @@ export class TurnRunner {
|
|
|
2055
2524
|
const userValue = this.config.effectiveContext ?? DEFAULT_EFFECTIVE_CONTEXT;
|
|
2056
2525
|
return modelWindow !== undefined ? Math.min(userValue, modelWindow) : userValue;
|
|
2057
2526
|
}
|
|
2527
|
+
/** Load the project table and build the parent router when the persisted selection is virtual. */
|
|
2528
|
+
async initializeModelRouter(modelName) {
|
|
2529
|
+
this.modelRouter = undefined;
|
|
2530
|
+
this.routingTable = undefined;
|
|
2531
|
+
this.advisorPolicy = undefined;
|
|
2532
|
+
const loaded = await loadRoutingTable({
|
|
2533
|
+
cwd: this.config.cwd ?? process.cwd(),
|
|
2534
|
+
catalogAdapter: routingCatalogAdapter,
|
|
2535
|
+
});
|
|
2536
|
+
this.routingTable = loaded.table;
|
|
2537
|
+
if (!modelName || !isVirtualModel(modelName, loaded.table))
|
|
2538
|
+
return;
|
|
2539
|
+
this.advisorPolicy = loaded.table.tiers[modelName].advisor;
|
|
2540
|
+
this.modelRouter = this.createBoundModelRouter(modelName, loaded.table, routingCatalogAdapter);
|
|
2541
|
+
}
|
|
2542
|
+
createBoundModelRouter(tier, table, resolveCatalog) {
|
|
2543
|
+
return this.createModelRouter({
|
|
2544
|
+
table,
|
|
2545
|
+
tier,
|
|
2546
|
+
classify: async (input, signal) => {
|
|
2547
|
+
return classifyRoute(input, { ...this.classifierOptions(table), signal });
|
|
2548
|
+
},
|
|
2549
|
+
resolveCatalog,
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
/** One metered classifier contract shared by parent routing and spawned children. */
|
|
2553
|
+
classifierOptions(table) {
|
|
2554
|
+
const model = resolveModelName(table.classifier.target.modelName);
|
|
2555
|
+
return {
|
|
2556
|
+
model: `${model.provider}:${model.id}`,
|
|
2557
|
+
thinkingLevel: table.classifier.target.thinkingLevel,
|
|
2558
|
+
onUsage: (usage) => this.recordAndEmitUsage(usage, model.id),
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
/** True when a model name is owned by the validated project table. */
|
|
2562
|
+
isVirtualModelSelection(modelName) {
|
|
2563
|
+
return isVirtualModel(modelName, this.routingTable ?? BUILT_IN_ROUTING_TABLE);
|
|
2564
|
+
}
|
|
2565
|
+
/** Read-only routing snapshot for session-owned UI surfaces. */
|
|
2566
|
+
routeStatus() {
|
|
2567
|
+
return this.modelRouter?.status();
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Retarget subsequent parent turns while preserving the current in-flight turn.
|
|
2571
|
+
* Concrete selections pin routing; virtual selections rebuild it so the next
|
|
2572
|
+
* turn classifies from a fresh tier-local baseline.
|
|
2573
|
+
*/
|
|
2574
|
+
setModel(modelName) {
|
|
2575
|
+
const routed = this.isVirtualModelSelection(modelName);
|
|
2576
|
+
if (!routed)
|
|
2577
|
+
resolveModelName(modelName);
|
|
2578
|
+
const state = this.requireRunnerState();
|
|
2579
|
+
state.options = { ...state.options, model: modelName };
|
|
2580
|
+
this.pendingModelSelection = modelName;
|
|
2581
|
+
if (!this.parentAgentRunning)
|
|
2582
|
+
this.applyPendingModelSelection(this.requireParentAgent());
|
|
2583
|
+
return { routed };
|
|
2584
|
+
}
|
|
2585
|
+
applyPendingModelSelection(agent) {
|
|
2586
|
+
const selection = this.pendingModelSelection;
|
|
2587
|
+
if (!selection)
|
|
2588
|
+
return;
|
|
2589
|
+
this.pendingModelSelection = undefined;
|
|
2590
|
+
if (!this.isVirtualModelSelection(selection)) {
|
|
2591
|
+
this.modelRouter?.pin();
|
|
2592
|
+
this.advisorPolicy = undefined;
|
|
2593
|
+
agent.state.model = resolveModelName(selection);
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
const table = this.routingTable ?? BUILT_IN_ROUTING_TABLE;
|
|
2597
|
+
this.modelRouter?.unpin();
|
|
2598
|
+
this.advisorPolicy = table.tiers[selection].advisor;
|
|
2599
|
+
this.modelRouter = this.createBoundModelRouter(selection, table, routingCatalogAdapter);
|
|
2600
|
+
const initial = this.modelRouter.initialTarget({ hasImages: false });
|
|
2601
|
+
agent.state.model = resolveModelName(initial.modelName);
|
|
2602
|
+
agent.state.thinkingLevel = initial.thinkingLevel;
|
|
2603
|
+
}
|
|
2604
|
+
/** Explicit virtual state models take the tier's default route without classifier latency. */
|
|
2605
|
+
resolveStateModel(modelName) {
|
|
2606
|
+
if (!modelName || !this.isVirtualModelSelection(modelName))
|
|
2607
|
+
return undefined;
|
|
2608
|
+
const table = this.routingTable ?? BUILT_IN_ROUTING_TABLE;
|
|
2609
|
+
return resolveTierDefault(table, modelName, { hasImages: false }, routingCatalogAdapter);
|
|
2610
|
+
}
|
|
2611
|
+
/** Omitted child models inherit the parent's active concrete target, not its virtual setting. */
|
|
2612
|
+
resolveSubagentInheritedOptions() {
|
|
2613
|
+
const options = { ...this.requireRunnerState().options };
|
|
2614
|
+
if (!options.model || !this.isVirtualModelSelection(options.model))
|
|
2615
|
+
return options;
|
|
2616
|
+
const parent = this.requireParentAgent().state;
|
|
2617
|
+
return {
|
|
2618
|
+
...options,
|
|
2619
|
+
model: `${parent.model.provider}:${parent.model.id}`,
|
|
2620
|
+
...(parent.thinkingLevel === "off" ? {} : { thinkingLevel: parent.thinkingLevel }),
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
/** Composition seam used by production binding and deterministic runner tests. */
|
|
2624
|
+
createModelRouter(options) {
|
|
2625
|
+
return new ModelRouter(options);
|
|
2626
|
+
}
|
|
2627
|
+
/** Resolve and atomically apply one router-owned model/effort change. */
|
|
2628
|
+
async applyRouterSwitch(agent, switched) {
|
|
2629
|
+
const model = resolveModelName(switched.toModel);
|
|
2630
|
+
agent.state.model = model;
|
|
2631
|
+
agent.state.thinkingLevel = switched.thinkingLevel;
|
|
2632
|
+
if (this.lastParentUsageSnapshot) {
|
|
2633
|
+
this.lastParentUsageSnapshot = {
|
|
2634
|
+
...this.lastParentUsageSnapshot,
|
|
2635
|
+
effectiveContextWindow: this.resolveEffectiveContext(model.contextWindow),
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
this.emit({ type: "router_switch", ...switched });
|
|
2639
|
+
return { model, thinkingLevel: switched.thinkingLevel };
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
const ROUTER_STEP_TEXT_LIMIT = 2_000;
|
|
2643
|
+
function routerStepObservation(message, toolResults) {
|
|
2644
|
+
const content = [...message.content, ...toolResults.flatMap((result) => result.content)];
|
|
2645
|
+
const text = content
|
|
2646
|
+
.filter((block) => block.type === "text")
|
|
2647
|
+
.map((block) => stripSystemReminders(block.text))
|
|
2648
|
+
.filter(Boolean)
|
|
2649
|
+
.join("\n")
|
|
2650
|
+
.trim();
|
|
2651
|
+
return {
|
|
2652
|
+
blockTypes: content.map((block) => block.type),
|
|
2653
|
+
text: text.slice(-ROUTER_STEP_TEXT_LIMIT),
|
|
2654
|
+
};
|
|
2058
2655
|
}
|
|
2059
2656
|
function sleep(ms) {
|
|
2060
2657
|
return new Promise((resolve) => setTimeout(resolve, ms));
|