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