@superdurable/dex-agents 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +1568 -62
  2. package/dist/src/agent-built-in-tools.d.ts +81 -0
  3. package/dist/src/agent-built-in-tools.js +246 -0
  4. package/dist/src/agent-built-in-tools.js.map +1 -0
  5. package/dist/src/agent-context-compaction.d.ts +108 -0
  6. package/dist/src/agent-context-compaction.js +39 -0
  7. package/dist/src/agent-context-compaction.js.map +1 -0
  8. package/dist/src/agent-framework-runner.d.ts +456 -16
  9. package/dist/src/agent-framework-runner.js +4 -3
  10. package/dist/src/agent-framework-runner.js.map +1 -1
  11. package/dist/src/agent-plans.d.ts +48 -0
  12. package/dist/src/agent-plans.js +86 -0
  13. package/dist/src/agent-plans.js.map +1 -0
  14. package/dist/src/agent-session-cancellation-steps.d.ts +28 -0
  15. package/dist/src/agent-session-cancellation-steps.js +154 -0
  16. package/dist/src/agent-session-cancellation-steps.js.map +1 -0
  17. package/dist/src/agent-session-codecs.d.ts +65 -4
  18. package/dist/src/agent-session-codecs.js +342 -8
  19. package/dist/src/agent-session-codecs.js.map +1 -1
  20. package/dist/src/agent-session-flow.d.ts +295 -27
  21. package/dist/src/agent-session-flow.js +368 -21
  22. package/dist/src/agent-session-flow.js.map +1 -1
  23. package/dist/src/agent-session-persistence.d.ts +81 -4
  24. package/dist/src/agent-session-persistence.js +33 -2
  25. package/dist/src/agent-session-persistence.js.map +1 -1
  26. package/dist/src/agent-session-settings.d.ts +58 -0
  27. package/dist/src/agent-session-settings.js +81 -0
  28. package/dist/src/agent-session-settings.js.map +1 -0
  29. package/dist/src/agent-session-step-options.d.ts +27 -0
  30. package/dist/src/agent-session-step-options.js +39 -0
  31. package/dist/src/agent-session-step-options.js.map +1 -0
  32. package/dist/src/agent-session-step-types.d.ts +20 -0
  33. package/dist/src/agent-session-step-types.js +28 -0
  34. package/dist/src/agent-session-step-types.js.map +1 -0
  35. package/dist/src/agent-session-steps.d.ts +101 -8
  36. package/dist/src/agent-session-steps.js +936 -197
  37. package/dist/src/agent-session-steps.js.map +1 -1
  38. package/dist/src/agent-session-tracer.d.ts +61 -0
  39. package/dist/src/agent-session-tracer.js +608 -0
  40. package/dist/src/agent-session-tracer.js.map +1 -0
  41. package/dist/src/agent-session-transitions.d.ts +189 -26
  42. package/dist/src/agent-session-transitions.js +680 -32
  43. package/dist/src/agent-session-transitions.js.map +1 -1
  44. package/dist/src/agent-session-values.d.ts +690 -29
  45. package/dist/src/agent-session-values.js +39 -4
  46. package/dist/src/agent-session-values.js.map +1 -1
  47. package/dist/src/agent-session-wait-steps.d.ts +53 -0
  48. package/dist/src/agent-session-wait-steps.js +389 -0
  49. package/dist/src/agent-session-wait-steps.js.map +1 -0
  50. package/dist/src/agent-tracing-options.d.ts +107 -0
  51. package/dist/src/agent-tracing-options.js +20 -0
  52. package/dist/src/agent-tracing-options.js.map +1 -0
  53. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +40 -0
  54. package/dist/src/ai-sdk/ai-sdk-model-client.js +235 -0
  55. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -0
  56. package/dist/src/ai-sdk/index.d.ts +1 -0
  57. package/dist/src/ai-sdk/index.js +9 -0
  58. package/dist/src/ai-sdk/index.js.map +1 -0
  59. package/dist/src/index.d.ts +9 -3
  60. package/dist/src/index.js +7 -2
  61. package/dist/src/index.js.map +1 -1
  62. package/dist/src/mcp/index.d.ts +3 -0
  63. package/dist/src/mcp/index.js +10 -0
  64. package/dist/src/mcp/index.js.map +1 -0
  65. package/dist/src/mcp/mcp-resource-and-prompt-tools.d.ts +29 -0
  66. package/dist/src/mcp/mcp-resource-and-prompt-tools.js +185 -0
  67. package/dist/src/mcp/mcp-resource-and-prompt-tools.js.map +1 -0
  68. package/dist/src/mcp/mcp-server-config.d.ts +86 -0
  69. package/dist/src/mcp/mcp-server-config.js +38 -0
  70. package/dist/src/mcp/mcp-server-config.js.map +1 -0
  71. package/dist/src/mcp/mcp-tool-source.d.ts +470 -0
  72. package/dist/src/mcp/mcp-tool-source.js +1204 -0
  73. package/dist/src/mcp/mcp-tool-source.js.map +1 -0
  74. package/dist/src/stepwise/agent-definition.d.ts +158 -0
  75. package/dist/src/stepwise/agent-definition.js +9 -0
  76. package/dist/src/stepwise/agent-definition.js.map +1 -0
  77. package/dist/src/stepwise/function-tool.d.ts +130 -0
  78. package/dist/src/stepwise/function-tool.js +75 -0
  79. package/dist/src/stepwise/function-tool.js.map +1 -0
  80. package/dist/src/stepwise/index.d.ts +7 -0
  81. package/dist/src/stepwise/index.js +12 -0
  82. package/dist/src/stepwise/index.js.map +1 -0
  83. package/dist/src/stepwise/json-schema-validation.d.ts +9 -0
  84. package/dist/src/stepwise/json-schema-validation.js +141 -0
  85. package/dist/src/stepwise/json-schema-validation.js.map +1 -0
  86. package/dist/src/stepwise/model-client.d.ts +194 -0
  87. package/dist/src/stepwise/model-client.js +9 -0
  88. package/dist/src/stepwise/model-client.js.map +1 -0
  89. package/dist/src/stepwise/scripted-model-client.d.ts +99 -0
  90. package/dist/src/stepwise/scripted-model-client.js +156 -0
  91. package/dist/src/stepwise/scripted-model-client.js.map +1 -0
  92. package/dist/src/stepwise/stepwise-agent-runner.d.ts +189 -0
  93. package/dist/src/stepwise/stepwise-agent-runner.js +804 -0
  94. package/dist/src/stepwise/stepwise-agent-runner.js.map +1 -0
  95. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +35 -0
  96. package/dist/src/stepwise/stepwise-agent-session-flow.js +56 -0
  97. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +1 -0
  98. package/dist/src/stepwise/stepwise-input-items.d.ts +12 -0
  99. package/dist/src/stepwise/stepwise-input-items.js +172 -0
  100. package/dist/src/stepwise/stepwise-input-items.js.map +1 -0
  101. package/dist/src/stepwise/tool-source.d.ts +103 -0
  102. package/dist/src/stepwise/tool-source.js +9 -0
  103. package/dist/src/stepwise/tool-source.js.map +1 -0
  104. package/dist/src/testing/agent-session-test-client.d.ts +65 -8
  105. package/dist/src/testing/agent-session-test-client.js +95 -22
  106. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  107. package/dist/src/testing/index.d.ts +1 -0
  108. package/dist/src/testing/index.js +1 -0
  109. package/dist/src/testing/index.js.map +1 -1
  110. package/dist/src/testing/private-dex-engine.d.ts +9 -0
  111. package/dist/src/testing/private-dex-engine.js +9 -1
  112. package/dist/src/testing/private-dex-engine.js.map +1 -1
  113. package/dist/src/testing/scripted-model-client.d.ts +1 -0
  114. package/dist/src/testing/scripted-model-client.js +10 -0
  115. package/dist/src/testing/scripted-model-client.js.map +1 -0
  116. package/dist/src/ui-stream/agent-event-json.d.ts +30 -0
  117. package/dist/src/ui-stream/agent-event-json.js +64 -0
  118. package/dist/src/ui-stream/agent-event-json.js.map +1 -0
  119. package/dist/src/ui-stream/agent-event-sse.d.ts +154 -0
  120. package/dist/src/ui-stream/agent-event-sse.js +159 -0
  121. package/dist/src/ui-stream/agent-event-sse.js.map +1 -0
  122. package/dist/src/ui-stream/agent-event-stream-positions.d.ts +42 -0
  123. package/dist/src/ui-stream/agent-event-stream-positions.js +153 -0
  124. package/dist/src/ui-stream/agent-event-stream-positions.js.map +1 -0
  125. package/dist/src/ui-stream/agent-session-record-reader.d.ts +72 -0
  126. package/dist/src/ui-stream/agent-session-record-reader.js +241 -0
  127. package/dist/src/ui-stream/agent-session-record-reader.js.map +1 -0
  128. package/dist/src/ui-stream/agent-ui-message-stream-responses.d.ts +219 -0
  129. package/dist/src/ui-stream/agent-ui-message-stream-responses.js +440 -0
  130. package/dist/src/ui-stream/agent-ui-message-stream-responses.js.map +1 -0
  131. package/dist/src/ui-stream/agent-ui-message-stream.d.ts +138 -0
  132. package/dist/src/ui-stream/agent-ui-message-stream.js +681 -0
  133. package/dist/src/ui-stream/agent-ui-message-stream.js.map +1 -0
  134. package/dist/src/ui-stream/agent-ui-message-types.d.ts +238 -0
  135. package/dist/src/ui-stream/agent-ui-message-types.js +57 -0
  136. package/dist/src/ui-stream/agent-ui-message-types.js.map +1 -0
  137. package/dist/src/ui-stream/agent-ui-messages.d.ts +66 -0
  138. package/dist/src/ui-stream/agent-ui-messages.js +220 -0
  139. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -0
  140. package/dist/src/ui-stream/agent-ui-stream-cursor.d.ts +64 -0
  141. package/dist/src/ui-stream/agent-ui-stream-cursor.js +186 -0
  142. package/dist/src/ui-stream/agent-ui-stream-cursor.js.map +1 -0
  143. package/dist/src/ui-stream/index.d.ts +14 -0
  144. package/dist/src/ui-stream/index.js +22 -0
  145. package/dist/src/ui-stream/index.js.map +1 -0
  146. package/dist/src/ui-stream/server-sent-event-responses.d.ts +6 -0
  147. package/dist/src/ui-stream/server-sent-event-responses.js +34 -0
  148. package/dist/src/ui-stream/server-sent-event-responses.js.map +1 -0
  149. package/package.json +41 -5
@@ -5,16 +5,19 @@
5
5
  // See the LICENSE file in the repository root.
6
6
  //
7
7
  // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
- import { ExecuteFailure, StepMovement, int64Codec, StepRef, Timer, Wait, deadEnd, goTo, goToMany, stringCodec, voidCodec, } from "@superdurable/dex";
8
+ import { ExecuteFailure, StepMovement, int64Codec, StepRef, StepAttemptProgress, Timer, Wait, deadEnd, forceFail, goTo, goToMany, stringCodec, voidCodec, } from "@superdurable/dex";
9
+ import { buildPlanClearedOutput, buildPlanUpdatedOutput, isBlankText, validateAskUserArguments, validateDurableWaitArguments, validateWriteTodosArguments, writeTodosCalledMoreThanOnce, } from "./agent-built-in-tools.js";
10
+ import { isCompactionTriggered, isContextCompactionRunner, readRetainedTokenBudget, } from "./agent-context-compaction.js";
9
11
  import { ToolExecutionError, } from "./agent-framework-runner.js";
10
- import { agentSessionStartInputCodec } from "./agent-session-codecs.js";
11
- import { agentSessionStepTypes, readRecoveryDetail, toolStepTypeForEffect, } from "./agent-session-transitions.js";
12
- import { approvalDeadlinePassed, buildTurnFailureNote, emptyAgentUsage, isFinalToolCallStatus, } from "./agent-session-values.js";
13
- const minuteMs = 60_000;
14
- // Recovery Steps must eventually run; cap their backoff so a returning Worker picks them up quickly.
15
- const recoveryRetry = { maximumIntervalMs: 5_000 };
16
- // An ASYNC FlowConfig could replay a committed result after a Server failover, re-running a write.
17
- const syncDurability = { waitForDurability: "sync", executeDurability: "sync" };
12
+ import { buildPlanExecutionMessage, planningTurnToolRejection, planStatusAfterWrite } from "./agent-plans.js";
13
+ import { agentSessionStartInputCodec, turnFailureRouteInputCodec } from "./agent-session-codecs.js";
14
+ import { messageStepOptions, minuteMs, modelCallStepOptions, recoveryRetry, syncDurability, } from "./agent-session-step-options.js";
15
+ import { agentSessionStepTypes, allPendingMessages, cancelledToolCall, isAbandonedToolTask, isCancellableByTurnControl, readRecoveryDetail, recoveryErrorDetail, toRunnerToolCall, toolStepTypeForEffect, ToolStepHeartbeats, writePlanUpdated, } from "./agent-session-transitions.js";
16
+ import { messageSequenceKey, planExecutionKey, } from "./agent-session-persistence.js";
17
+ import { checkStoredSessionSettings } from "./agent-session-settings.js";
18
+ import { approvalDeadlinePassed, blankToolTaskId, buildTurnFailureNote, emptyAgentUsage, invalidToolTaskPollInterval, isFinalToolCallStatus, missingToolTaskPoller, toolCallNotRunErrorPrefix, toolTaskAbandonedError, } from "./agent-session-values.js";
19
+ /** Longest wait between two polls of a tool task, as in the Python core; a longer interval is shortened. */
20
+ const maximumToolTaskPollIntervalSeconds = 86_400;
18
21
  // An int64 Value, as the Python core's Step[int] encodes it.
19
22
  const expectedResultCountCodec = {
20
23
  typeName: "ExpectedResultCount",
@@ -22,6 +25,8 @@ const expectedResultCountCodec = {
22
25
  encode: (value) => int64Codec.encode(BigInt(requirePositiveResultCount(value))),
23
26
  decode: (value) => requirePositiveResultCount(Number(int64Codec.decode(value))),
24
27
  };
28
+ /** Start of the Flow failure reason when `StartAgentSession` refuses the start input's settings. */
29
+ export const invalidStartSessionSettingsReason = "dex-agents: the start input's session_settings are not valid session settings";
25
30
  export class StartAgentSessionStep {
26
31
  transitions;
27
32
  inputCodec = agentSessionStartInputCodec;
@@ -32,7 +37,7 @@ export class StartAgentSessionStep {
32
37
  return agentSessionStepTypes.startAgentSession;
33
38
  }
34
39
  getStepOptions() {
35
- return syncDurability;
40
+ return { ...syncDurability, executeLoadAttributeMaps: [this.transitions.persistence.agentMessages] };
36
41
  }
37
42
  execute(context, input) {
38
43
  const persistence = this.transitions.persistence;
@@ -42,17 +47,32 @@ export class StartAgentSessionStep {
42
47
  activeTurnId: null,
43
48
  nextMessageSequence: 1,
44
49
  lastTurnError: null,
50
+ acceptsTurnControl: false,
45
51
  };
52
+ const sessionSettings = input.sessionSettings ?? null;
53
+ if (sessionSettings !== null) {
54
+ try {
55
+ checkStoredSessionSettings(sessionSettings);
56
+ }
57
+ catch (error) {
58
+ // Every retry would fail the same way, so the session fails now.
59
+ return forceFail(`${invalidStartSessionSettingsReason}: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ if (Object.keys(sessionSettings).length > 0) {
62
+ persistence.sessionSettings.set(context, sessionSettings);
63
+ }
64
+ }
46
65
  this.transitions.writeAgentState(context, state);
47
66
  persistence.agentUsage.set(context, emptyAgentUsage());
48
67
  persistence.pendingInterrupts.set(context, []);
49
68
  const initialMessage = input.initialMessage;
50
- if (initialMessage === null || initialMessage.trim() === "") {
69
+ if (initialMessage === null || isBlankText(initialMessage)) {
51
70
  return this.transitions.goToAwaitUserMessage();
52
71
  }
53
- return this.transitions.beginTurn(context, state, initialMessage);
72
+ return this.transitions.beginTurn(context, state, [{ text: initialMessage }], this.getStepType(), "chat", undefined, false, sessionSettings);
54
73
  }
55
74
  }
75
+ /** Waits for one queued user message, or an approved plan execution, and starts its turn. */
56
76
  export class AwaitUserMessageStep {
57
77
  transitions;
58
78
  inputCodec = voidCodec;
@@ -63,18 +83,69 @@ export class AwaitUserMessageStep {
63
83
  return agentSessionStepTypes.awaitUserMessage;
64
84
  }
65
85
  getStepOptions() {
66
- return syncDurability;
86
+ // The lock keeps a turn from starting while cancel_turn decides about the last one.
87
+ // Storing the turn's user messages may move the oldest window messages into a chunk.
88
+ return {
89
+ ...syncDurability,
90
+ executeLoadAttributeMaps: [this.transitions.persistence.agentMessages],
91
+ executeLockAttributes: [this.transitions.persistence.turnCancellationRequest.lock()],
92
+ };
67
93
  }
68
- waitFor() {
69
- return Wait.until(this.transitions.persistence.queuedUserMessages.forOne());
94
+ waitFor(context) {
95
+ const persistence = this.transitions.persistence;
96
+ // Dex selects Channel conditions in argument order: a steer, a queued message, a plan.
97
+ const conditions = [
98
+ persistence.turnControlRequests.range(1, allPendingMessages),
99
+ persistence.queuedUserMessages.forOne(),
100
+ ];
101
+ const plan = this.transitions.readOptionalAgentPlan(context);
102
+ // Keyed by revision, so an approval of an earlier revision never starts a turn.
103
+ if (plan !== null && isExecutablePlanStatus(plan.status)) {
104
+ conditions.push(persistence.planExecutions.forOne(planExecutionKey(plan.revision)));
105
+ }
106
+ return Wait.anyOf(...conditions);
70
107
  }
71
108
  execute(context) {
72
- const [message] = context.channelResults(this.transitions.persistence.queuedUserMessages);
73
- if (message === undefined || message.text.trim() === "") {
74
- return this.transitions.goToAwaitUserMessage();
109
+ const transitions = this.transitions;
110
+ const persistence = transitions.persistence;
111
+ if (context.channelResults(persistence.turnControlRequests).length > 0) {
112
+ return transitions.goToApplyTurnControl(context);
113
+ }
114
+ const [message] = context.channelResults(persistence.queuedUserMessages);
115
+ if (message !== undefined) {
116
+ if (isBlankText(message.text)) {
117
+ return transitions.goToAwaitUserMessage();
118
+ }
119
+ return this.beginUserMessageTurn(context, message);
75
120
  }
76
- return this.transitions.beginTurn(context, this.transitions.readAgentState(context), message.text);
121
+ const plan = transitions.readOptionalAgentPlan(context);
122
+ const [execution] = plan === null ? [] : context.channelResults(persistence.planExecutions, planExecutionKey(plan.revision));
123
+ if (execution === undefined) {
124
+ return transitions.goToAwaitUserMessage();
125
+ }
126
+ if (persistence.pendingPlanExecution.get(context) !== undefined) {
127
+ persistence.pendingPlanExecution.delete(context);
128
+ }
129
+ if (plan === null || execution.revision !== plan.revision || !isExecutablePlanStatus(plan.status)) {
130
+ return transitions.goToAwaitUserMessage();
131
+ }
132
+ const wasPlanDraft = plan.status !== "active";
133
+ const executedPlan = wasPlanDraft
134
+ ? { ...plan, status: "active", updatedAt: new Date().toISOString() }
135
+ : plan;
136
+ if (wasPlanDraft) {
137
+ persistence.agentPlan.set(context, executedPlan);
138
+ }
139
+ return transitions.beginTurn(context, transitions.readAgentState(context), [{ text: buildPlanExecutionMessage(plan.revision) }], this.getStepType(), "executing", executedPlan, wasPlanDraft);
77
140
  }
141
+ beginUserMessageTurn(context, message) {
142
+ const transitions = this.transitions;
143
+ return transitions.beginTurn(context, transitions.readAgentState(context), [message], this.getStepType(), transitions.decideInteractionMode(context, [message]));
144
+ }
145
+ }
146
+ /** Plan statuses that `execute_plan` accepts and `AwaitUserMessage` waits for. */
147
+ export function isExecutablePlanStatus(status) {
148
+ return status === "draft" || status === "active";
78
149
  }
79
150
  /** Makes the turn's next model call and fans the requested tool calls out to parallel Steps. */
80
151
  export class RunModelStep {
@@ -87,15 +158,8 @@ export class RunModelStep {
87
158
  return agentSessionStepTypes.runModel;
88
159
  }
89
160
  getStepOptions() {
90
- const persistence = this.transitions.persistence;
91
- return {
92
- ...syncDurability,
93
- executeMethodTimeoutMs: 15 * minuteMs,
94
- heartbeatTimeoutMs: 2 * minuteMs,
95
- executeRetry: { maximumAttempts: 3, totalDurationMs: 30 * minuteMs },
96
- executeFailure: ExecuteFailure.proceedTo(new StepRef(agentSessionStepTypes.recordTurnFailure)),
97
- executeLoadAttributeMaps: [persistence.agentMessages, persistence.agentToolCalls],
98
- };
161
+ // Each movement into RunModel adds the archive chunks of the model input.
162
+ return messageStepOptions(this.transitions.persistence, this.getStepType(), staticMessageChunkLoads(this.transitions));
99
163
  }
100
164
  async execute(context) {
101
165
  const transitions = this.transitions;
@@ -109,40 +173,56 @@ export class RunModelStep {
109
173
  if (openToolCall !== undefined) {
110
174
  throw new Error(`RunModel for ${turnId} started before tool call ${openToolCall.toolCallId} finished`);
111
175
  }
112
- const toolCallsById = new Map(toolCalls.map((toolCall) => [toolCall.toolCallId, toolCall]));
113
- const frameworkRunState = transitions.readFrameworkRunState(context);
176
+ const decision = this.decideBeforeModelCall(context, turnId);
177
+ if (decision !== null) {
178
+ return decision;
179
+ }
114
180
  const events = transitions.createEventWriter(context, turnId);
115
181
  try {
116
- const turnContext = {
117
- flowId: context.flowId,
118
- turnId,
119
- attempt: context.attempt,
120
- messages: transitions.readMessages(context, 1, state.nextMessageSequence),
121
- frameworkRunState,
122
- isResuming: frameworkRunState !== null,
123
- toolCalls,
124
- events,
125
- cancellationSignal: context.cancellationSignal,
126
- deadline: context.deadline,
127
- resolveToolPolicy: (toolName, frameworkEffect) => transitions.resolveToolPolicy(toolName, frameworkEffect),
128
- recordedToolCall: (toolCallId) => toolCallsById.get(toolCallId),
129
- heartbeat: () => transitions.recordHeartbeat(context),
130
- };
131
- const outcome = await transitions.runWithHeartbeats(context, () => transitions.runner.runModelStep(turnContext));
132
- switch (outcome?.kind) {
133
- case "completed":
134
- return this.completeTurn(context, state, turnId, toolCalls, outcome, events);
135
- case "tool_calls_requested":
136
- return this.requestToolCalls(context, state, turnId, toolCalls, outcome, events);
137
- default:
138
- throw new TypeError("runModelStep must return a TurnCompleted or ToolCallsRequested outcome");
182
+ const turnContext = transitions.createTurnContext(context, state, turnId, toolCalls, events, () => transitions.recordHeartbeat(context));
183
+ const throughSequence = await this.selectCompactionBeforeModelCall(turnContext, state);
184
+ if (throughSequence !== null) {
185
+ return this.startCompaction(context, state, turnContext.contextSummary ?? null, throughSequence);
139
186
  }
187
+ return await transitions.tracer.traceModelCall(context, this.getStepType(), turnContext, async (modelCallSpan) => {
188
+ const outcome = await transitions.runWithHeartbeats(context, () => transitions.runner.runModelStep(turnContext));
189
+ switch (outcome?.kind) {
190
+ case "completed": {
191
+ modelCallSpan.recordOutcome(outcome, appendedMessageKeys(state, outcome.newMessages));
192
+ // The index keeps where the turn's replies begin, so the count includes messages summarized away.
193
+ const storedTurnMessageCount = transitions.countStoredTurnReplies(context, state, turnId);
194
+ return this.completeTurn(context, state, turnId, toolCalls, outcome, storedTurnMessageCount, events);
195
+ }
196
+ case "tool_calls_requested":
197
+ if ((outcome.newMessages?.length ?? 0) > 0 && transitions.runner.buildUnfinishedTurnMessages === undefined) {
198
+ // A runner defect: a retry would only repeat the paid model call, so the turn fails now.
199
+ modelCallSpan.recordOutcome(outcome, []);
200
+ transitions.recordUsage(context, outcome.usage, events);
201
+ return recordFailedTurn(transitions, context, state, toolCalls, [], missingUnfinishedTurnMessageBuilder);
202
+ }
203
+ modelCallSpan.recordOutcome(outcome, appendedMessageKeys(state, outcome.newMessages ?? []));
204
+ return this.requestToolCalls(context, state, turnId, toolCalls, outcome, events);
205
+ default:
206
+ throw new TypeError("runModelStep must return a TurnCompleted or ToolCallsRequested outcome");
207
+ }
208
+ });
140
209
  }
141
210
  finally {
142
211
  events.close();
143
212
  }
144
213
  }
145
- completeTurn(context, state, turnId, toolCalls, outcome, events) {
214
+ /** Returns RunModel's move instead of a model call, or `null` to call the model. */
215
+ decideBeforeModelCall(context, turnId) {
216
+ // Execute state is fixed at the first attempt, so this and the compaction check are RunModel's decision points.
217
+ const transitions = this.transitions;
218
+ if (transitions.readTurnControlReason(context, turnId) === null) {
219
+ return null;
220
+ }
221
+ // Ending the turn in this commit leaves FinishTurnCancellation no pending turn end to race.
222
+ transitions.endControlledTurn(context, transitions.readAgentState(context), turnId, context.channelSize(transitions.persistence.steeredUserMessages));
223
+ return transitions.goToApplyTurnControl(context);
224
+ }
225
+ completeTurn(context, state, turnId, toolCalls, outcome, storedTurnMessageCount, events) {
146
226
  const transitions = this.transitions;
147
227
  const persistence = transitions.persistence;
148
228
  const stateWithMessages = transitions.appendMessages(context, state, turnId, outcome.newMessages);
@@ -157,72 +237,301 @@ export class RunModelStep {
157
237
  turnCount: state.turnCount + 1,
158
238
  activeTurnId: null,
159
239
  lastTurnError: null,
240
+ acceptsTurnControl: false,
160
241
  });
161
- events.writeEvent("turn_completed", { message_count: outcome.newMessages.length });
242
+ // The turn's user messages are stored but not counted; incremental runners stored more before now.
243
+ const messageCount = storedTurnMessageCount + outcome.newMessages.length;
244
+ events.writeEvent("turn_completed", { message_count: messageCount });
162
245
  return transitions.goToAwaitUserMessage();
163
246
  }
247
+ /** Returns the sequence to summarize through before this model call, or `null` to make the call. */
248
+ async selectCompactionBeforeModelCall(turn, state) {
249
+ const transitions = this.transitions;
250
+ const policy = transitions.config.compaction;
251
+ const runner = transitions.runner;
252
+ if (policy === undefined || !isContextCompactionRunner(runner)) {
253
+ return null;
254
+ }
255
+ if (!isCompactionTriggered(policy, await runner.estimateInputTokens(turn))) {
256
+ return null;
257
+ }
258
+ const cutoff = await runner.selectCompactionCutoff(turn, readRetainedTokenBudget(policy));
259
+ // A cutoff that does not advance means nothing more to summarize, so the pair cannot loop.
260
+ if (cutoff === null || cutoff <= (turn.contextSummary?.lastSequence ?? 0)) {
261
+ return null;
262
+ }
263
+ if (!Number.isSafeInteger(cutoff) || cutoff >= state.nextMessageSequence) {
264
+ throw new TypeError(`selectCompactionCutoff returned ${String(cutoff)}, which is not a stored message`);
265
+ }
266
+ return cutoff;
267
+ }
268
+ startCompaction(context, state, previousSummary, throughSequence) {
269
+ const persistence = this.transitions.persistence;
270
+ // CompactContext commits once, so this commit is where the status becomes visible.
271
+ this.transitions.writeAgentState(context, { ...state, status: "compacting_context" });
272
+ const firstSequence = this.transitions.readModelInputFirstSequence(context, previousSummary);
273
+ const chunkLoads = this.transitions.messageChunkLoads(context, firstSequence, throughSequence + 1);
274
+ const input = { throughSequence };
275
+ return goToMany(StepMovement.of(new StepRef(agentSessionStepTypes.compactContext), input, {
276
+ ...syncDurability,
277
+ ...modelCallStepOptions,
278
+ executeLoadAttributeMaps: [persistence.agentMessages],
279
+ ...(chunkLoads.instances.length === 0 ? {} : { executeLoadAttributeMapInstances: chunkLoads.instances }),
280
+ }));
281
+ }
164
282
  requestToolCalls(context, state, turnId, toolCalls, outcome, events) {
165
- validateToolCallsRequested(turnId, toolCalls, outcome);
166
283
  const transitions = this.transitions;
284
+ validateToolCallsRequested(turnId, toolCalls, outcome);
285
+ const newMessages = outcome.newMessages ?? [];
286
+ const stateWithMessages = newMessages.length === 0 ? state : transitions.appendMessages(context, state, turnId, newMessages);
167
287
  transitions.persistence.frameworkRunState.set(context, outcome.frameworkRunState);
168
288
  const createdAt = new Date();
169
- const deadline = transitions.config.interruptDeadlineMs === undefined
170
- ? null
171
- : new Date(createdAt.getTime() + transitions.config.interruptDeadlineMs).toISOString();
172
- const interrupts = [];
173
- // goToMany takes movements of mixed input types, which the SDK types as StepMovement<any>.
174
- const movements = [];
289
+ const batch = {
290
+ context,
291
+ turnId,
292
+ createdAt: createdAt.toISOString(),
293
+ interruptDeadline: transitions.config.interruptDeadlineMs === undefined
294
+ ? null
295
+ : new Date(createdAt.getTime() + transitions.config.interruptDeadlineMs).toISOString(),
296
+ interrupts: [],
297
+ traceContext: transitions.tracer.readActivationTraceparent(context),
298
+ movements: [],
299
+ events,
300
+ interactionMode: state.interactionMode ?? "chat",
301
+ writeTodosCallCount: outcome.toolCalls.filter((toolCall) => toolCall.toolName === "write_todos" && transitions.isEnabledBuiltInTool(toolCall.toolName)).length,
302
+ };
175
303
  for (const [index, requestedToolCall] of outcome.toolCalls.entries()) {
176
- const requiresApproval = requestedToolCall.policy.requiresApproval;
177
- const effect = requestedToolCall.policy.effect;
178
- transitions.writeToolCall(context, {
179
- toolCallId: requestedToolCall.toolCallId,
180
- turnId,
181
- toolName: requestedToolCall.toolName,
182
- arguments: requestedToolCall.arguments,
183
- effect,
184
- requiresApproval,
185
- status: requiresApproval ? "awaiting_approval" : "approved",
186
- output: null,
187
- error: null,
188
- rejectionMessage: null,
189
- deferralOrder: toolCalls.length + index,
190
- });
191
- events.writeEvent("tool_call_deferred", {
192
- tool_call_id: requestedToolCall.toolCallId,
193
- tool_name: requestedToolCall.toolName,
194
- effect,
195
- requires_approval: requiresApproval,
196
- });
197
- const nextStepType = requiresApproval
198
- ? agentSessionStepTypes.awaitToolApproval
199
- : toolStepTypeForEffect(effect);
200
- movements.push(toolStepMovement(transitions.persistence, nextStepType, requestedToolCall.toolCallId));
201
- if (!requiresApproval) {
202
- continue;
304
+ const deferralOrder = toolCalls.length + index;
305
+ const toolName = requestedToolCall.toolName;
306
+ if (batch.interactionMode === "planning" && !(toolName === "write_todos" && transitions.isEnabledBuiltInTool(toolName))) {
307
+ this.rejectPlanningTurnToolCall(batch, requestedToolCall, deferralOrder);
308
+ }
309
+ else if (transitions.isEnabledBuiltInTool(toolName)) {
310
+ this.deferBuiltInToolCall(batch, requestedToolCall, toolName, deferralOrder);
311
+ }
312
+ else {
313
+ this.deferApplicationToolCall(batch, requestedToolCall, deferralOrder);
203
314
  }
204
- const interrupt = {
205
- interruptId: requestedToolCall.toolCallId,
206
- kind: "tool_approval",
207
- toolCallId: requestedToolCall.toolCallId,
208
- toolName: requestedToolCall.toolName,
209
- arguments: requestedToolCall.arguments,
210
- createdAt: createdAt.toISOString(),
211
- deadline,
212
- };
213
- interrupts.push(interrupt);
214
- events.writeEvent("interrupt_raised", {
215
- interrupt_id: interrupt.interruptId,
216
- tool_call_id: interrupt.toolCallId,
217
- tool_name: interrupt.toolName,
218
- deadline: interrupt.deadline,
219
- });
220
315
  }
221
- transitions.persistence.pendingInterrupts.set(context, interrupts);
316
+ transitions.persistence.pendingInterrupts.set(context, batch.interrupts);
222
317
  transitions.recordUsage(context, outcome.usage, events);
223
- transitions.writeAgentState(context, { ...state, status: "running_tools" });
224
- movements.push(StepMovement.of(new StepRef(agentSessionStepTypes.resumeTurn), outcome.toolCalls.length));
225
- return goToMany(...movements);
318
+ transitions.writeAgentState(context, { ...stateWithMessages, status: "running_tools" });
319
+ batch.movements.push(StepMovement.of(new StepRef(agentSessionStepTypes.resumeTurn), outcome.toolCalls.length));
320
+ return goToMany(...batch.movements);
321
+ }
322
+ /** A planning turn hides executable tools; a call to one never runs. */
323
+ rejectPlanningTurnToolCall(batch, requestedToolCall, deferralOrder) {
324
+ const isBuiltInTool = this.transitions.isEnabledBuiltInTool(requestedToolCall.toolName);
325
+ const record = createToolCallRecord(batch, requestedToolCall, isBuiltInTool ? "read" : requestedToolCall.policy.effect, isBuiltInTool ? false : requestedToolCall.policy.requiresApproval, deferralOrder);
326
+ writeToolCallDeferred(batch.events, record);
327
+ this.transitions.finishToolCall(batch.context, { ...record, status: "rejected", rejectionMessage: planningTurnToolRejection }, batch.events);
328
+ }
329
+ deferApplicationToolCall(batch, requestedToolCall, deferralOrder) {
330
+ const transitions = this.transitions;
331
+ const requiresApproval = requestedToolCall.policy.requiresApproval;
332
+ const effect = requestedToolCall.policy.effect;
333
+ const record = createToolCallRecord(batch, requestedToolCall, effect, requiresApproval, deferralOrder);
334
+ transitions.writeToolCall(batch.context, {
335
+ ...record,
336
+ status: requiresApproval ? "awaiting_approval" : "approved",
337
+ });
338
+ writeToolCallDeferred(batch.events, record);
339
+ const nextStepType = requiresApproval
340
+ ? agentSessionStepTypes.awaitToolApproval
341
+ : toolStepTypeForEffect(effect);
342
+ batch.movements.push(toolStepMovement(transitions.persistence, nextStepType, requestedToolCall.toolCallId));
343
+ if (!requiresApproval) {
344
+ return;
345
+ }
346
+ const interrupt = {
347
+ interruptId: requestedToolCall.toolCallId,
348
+ kind: "tool_approval",
349
+ toolCallId: requestedToolCall.toolCallId,
350
+ toolName: requestedToolCall.toolName,
351
+ arguments: requestedToolCall.arguments,
352
+ createdAt: batch.createdAt,
353
+ deadline: batch.interruptDeadline,
354
+ question: null,
355
+ choices: [],
356
+ allowsFreeText: true,
357
+ };
358
+ batch.interrupts.push(interrupt);
359
+ transitions.writeInterruptRaised(batch.events, interrupt);
360
+ }
361
+ /** Records a built-in call with its fixed policy and routes it to its wait Step; invalid arguments fail at once. */
362
+ deferBuiltInToolCall(batch, requestedToolCall, builtInTool, deferralOrder) {
363
+ const transitions = this.transitions;
364
+ const persistence = transitions.persistence;
365
+ const record = createToolCallRecord(batch, requestedToolCall, "read", false, deferralOrder);
366
+ writeToolCallDeferred(batch.events, record);
367
+ switch (builtInTool) {
368
+ case "ask_user": {
369
+ const validation = validateAskUserArguments(record.arguments);
370
+ if (!validation.isValid) {
371
+ this.failBuiltInToolCall(batch, record, validation.error);
372
+ return;
373
+ }
374
+ transitions.writeToolCall(batch.context, { ...record, status: "awaiting_answer" });
375
+ const interrupt = {
376
+ interruptId: record.toolCallId,
377
+ kind: "question",
378
+ toolCallId: record.toolCallId,
379
+ toolName: record.toolName,
380
+ arguments: record.arguments,
381
+ createdAt: batch.createdAt,
382
+ deadline: batch.interruptDeadline,
383
+ question: validation.arguments.question,
384
+ choices: validation.arguments.choices,
385
+ allowsFreeText: validation.arguments.allowsFreeText,
386
+ };
387
+ batch.interrupts.push(interrupt);
388
+ transitions.writeInterruptRaised(batch.events, interrupt);
389
+ batch.movements.push(questionAnswerMovement(persistence, { ...record, status: "awaiting_answer" }));
390
+ return;
391
+ }
392
+ case "durable_wait": {
393
+ const maxDurableWaitSeconds = Math.floor(transitions.config.maxDurableWaitMs / 1_000);
394
+ const validation = validateDurableWaitArguments(record.arguments, maxDurableWaitSeconds);
395
+ if (!validation.isValid) {
396
+ this.failBuiltInToolCall(batch, record, validation.error);
397
+ return;
398
+ }
399
+ const waitUntil = new Date(Date.parse(batch.createdAt) + validation.arguments.durationSeconds * 1_000).toISOString();
400
+ transitions.writeToolCall(batch.context, { ...record, status: "waiting_for_timer", waitUntil });
401
+ batch.events.writeEvent("timer_started", {
402
+ tool_call_id: record.toolCallId,
403
+ wait_until: waitUntil,
404
+ reason: validation.arguments.reason,
405
+ });
406
+ batch.movements.push(toolStepMovement(persistence, agentSessionStepTypes.awaitTimer, record.toolCallId));
407
+ return;
408
+ }
409
+ case "write_todos":
410
+ this.writeTodos(batch, record);
411
+ return;
412
+ }
413
+ }
414
+ /** Replaces the plan and records the call final, in this commit; no tool Step runs. */
415
+ writeTodos(batch, record) {
416
+ const transitions = this.transitions;
417
+ if (batch.writeTodosCallCount > 1) {
418
+ this.failBuiltInToolCall(batch, record, writeTodosCalledMoreThanOnce);
419
+ return;
420
+ }
421
+ const validation = validateWriteTodosArguments(record.arguments);
422
+ if (!validation.isValid) {
423
+ this.failBuiltInToolCall(batch, record, validation.error);
424
+ return;
425
+ }
426
+ const tasks = validation.arguments;
427
+ const previous = transitions.readOptionalAgentPlan(batch.context);
428
+ const plan = {
429
+ revision: (previous?.revision ?? 0) + 1,
430
+ status: planStatusAfterWrite(tasks, batch.interactionMode),
431
+ tasks,
432
+ updatedAt: batch.createdAt,
433
+ };
434
+ transitions.persistence.agentPlan.set(batch.context, plan);
435
+ writePlanUpdated(batch.events, plan);
436
+ const output = plan.status === "cleared" ? buildPlanClearedOutput(plan.revision) : buildPlanUpdatedOutput(plan.revision, tasks.length);
437
+ transitions.finishToolCall(batch.context, { ...record, status: "succeeded", output }, batch.events);
438
+ }
439
+ failBuiltInToolCall(batch, record, error) {
440
+ this.transitions.finishToolCall(batch.context, { ...record, status: "failed", error }, batch.events);
441
+ }
442
+ }
443
+ /** Summarizes the messages through a sequence for the model input, then moves back to `RunModel`. */
444
+ export class CompactContextStep {
445
+ transitions;
446
+ inputCodec = turnFailureRouteInputCodec;
447
+ constructor(transitions) {
448
+ this.transitions = transitions;
449
+ }
450
+ getStepType() {
451
+ return agentSessionStepTypes.compactContext;
452
+ }
453
+ getStepOptions() {
454
+ return {
455
+ ...syncDurability,
456
+ ...modelCallStepOptions,
457
+ executeLoadAttributeMaps: [this.transitions.persistence.agentMessages],
458
+ };
459
+ }
460
+ async execute(context, input) {
461
+ const transitions = this.transitions;
462
+ if (input === undefined) {
463
+ throw new TypeError("CompactContext needs a CompactContextInput");
464
+ }
465
+ const state = transitions.readAgentState(context);
466
+ const turnId = state.activeTurnId;
467
+ if (turnId === null) {
468
+ throw new Error(`the agent session ${context.flowId} has no active turn`);
469
+ }
470
+ const previousSummary = transitions.readContextSummary(context);
471
+ const firstSequence = previousSummary === null ? 1 : previousSummary.lastSequence + 1;
472
+ const throughSequence = input.throughSequence;
473
+ if (throughSequence < firstSequence || throughSequence >= state.nextMessageSequence) {
474
+ throw new Error(`CompactContext cannot summarize through message ${throughSequence}`);
475
+ }
476
+ const runner = transitions.runner;
477
+ if (!isContextCompactionRunner(runner)) {
478
+ throw new TypeError("CompactContext needs a runner that implements the compaction methods");
479
+ }
480
+ const events = transitions.createEventWriter(context, turnId);
481
+ try {
482
+ events.writeEvent("compaction_started", { through_sequence: throughSequence });
483
+ const result = await this.summarize(context, runner, turnId, previousSummary, throughSequence, events);
484
+ const summary = {
485
+ generation: previousSummary === null ? 1 : previousSummary.generation + 1,
486
+ firstSequence: 1,
487
+ lastSequence: throughSequence,
488
+ text: result.text,
489
+ createdAt: new Date().toISOString(),
490
+ };
491
+ transitions.persistence.contextSummary.set(context, summary);
492
+ const index = transitions.readMessageIndex(context);
493
+ if (index !== null) {
494
+ const retainedIndex = transitions.applyMessageRetention(context, index, state.nextMessageSequence, summary);
495
+ if (retainedIndex !== index) {
496
+ transitions.persistence.agentMessageIndex.set(context, retainedIndex);
497
+ }
498
+ }
499
+ transitions.recordUsage(context, result.usage, events);
500
+ transitions.writeAgentState(context, { ...state, status: "running_model" });
501
+ events.writeEvent("compaction_finished", { generation: summary.generation, through_sequence: throughSequence });
502
+ return transitions.goToRunModel(context, summary);
503
+ }
504
+ finally {
505
+ events.close();
506
+ }
507
+ }
508
+ async summarize(context, runner, turnId, previousSummary, throughSequence, events) {
509
+ const transitions = this.transitions;
510
+ const firstSequence = transitions.readModelInputFirstSequence(context, previousSummary);
511
+ const messages = transitions.readMessages(context, firstSequence, throughSequence + 1);
512
+ const policy = transitions.config.compaction;
513
+ return transitions.tracer.traceContextSummary(context, this.getStepType(), turnId, throughSequence, messages, async (summarySpan) => {
514
+ const result = await transitions.runWithHeartbeats(context, () => runner.summarizeContext({
515
+ flowId: context.flowId,
516
+ turnId,
517
+ attempt: context.attempt,
518
+ stepExecutionId: context.stepExecutionId,
519
+ previousSummary,
520
+ messages,
521
+ throughSequence,
522
+ events,
523
+ cancellationSignal: context.cancellationSignal,
524
+ deadline: context.deadline,
525
+ ...(policy === undefined ? {} : { inputTokenBudget: policy.contextWindowTokens }),
526
+ sessionSettings: transitions.readTurnSessionSettings(context),
527
+ heartbeat: () => transitions.recordHeartbeat(context),
528
+ }));
529
+ if (typeof result?.text !== "string" || result.text.trim() === "" || typeof result.usage !== "object") {
530
+ throw new TypeError("summarizeContext must return summary text and usage");
531
+ }
532
+ summarySpan.recordResult(result);
533
+ return result;
534
+ });
226
535
  }
227
536
  }
228
537
  /** Waits for one tool call's approval or its deadline, then runs or rejects the call. */
@@ -240,7 +549,10 @@ export class AwaitToolApprovalStep {
240
549
  }
241
550
  waitFor(context, toolCallId) {
242
551
  const transitions = this.transitions;
243
- const conditions = [transitions.persistence.interruptResolutions.forOne(toolCallId)];
552
+ const conditions = [
553
+ transitions.persistence.toolCallCancellations.forOne(toolCallId),
554
+ transitions.persistence.interruptResolutions.forOne(toolCallId),
555
+ ];
244
556
  // A resolved interrupt left PendingInterrupts, so its deadline no longer applies.
245
557
  const interrupt = transitions
246
558
  .readPendingInterrupts(context)
@@ -259,6 +571,9 @@ export class AwaitToolApprovalStep {
259
571
  if (toolCall.status !== "awaiting_approval") {
260
572
  return deadEnd();
261
573
  }
574
+ if (context.channelResults(persistence.toolCallCancellations, toolCallId).length > 0) {
575
+ return cancelWaitingToolCall(this.transitions, context, toolCall, { interruptId: toolCallId, kind: "tool_approval" }, toolStepMovement(persistence, agentSessionStepTypes.awaitToolApproval, toolCallId));
576
+ }
262
577
  const [resolution] = context.channelResults(persistence.interruptResolutions, toolCallId);
263
578
  if (resolution === undefined) {
264
579
  if (!context.hasTimerFired()) {
@@ -271,29 +586,49 @@ export class AwaitToolApprovalStep {
271
586
  return goToMany(toolStepMovement(persistence, agentSessionStepTypes.awaitToolApproval, toolCallId));
272
587
  }
273
588
  persistence.pendingInterrupts.set(context, pendingInterrupts.filter((interrupt) => interrupt.interruptId !== toolCallId));
274
- return this.rejectToolCall(context, toolCall, approvalDeadlinePassed);
275
- }
276
- if (!resolution.approved) {
277
- return this.rejectToolCall(context, toolCall, resolution.rejectionMessage);
589
+ return this.applyResolution(context, toolCall, false, approvalDeadlinePassed, true);
278
590
  }
279
- transitions.writeToolCall(context, { ...toolCall, status: "approved" });
280
- this.writeInterruptResolved(context, toolCall, true, null);
281
- return goToMany(toolStepMovement(persistence, toolStepTypeForEffect(toolCall.effect), toolCallId));
591
+ return this.applyResolution(context, toolCall, resolution.approved, resolution.rejectionMessage, false);
592
+ }
593
+ applyResolution(context, toolCall, isApproved, rejectionMessage, isDeadlinePassed) {
594
+ return this.transitions.tracer.traceInterruptActivation(context, this.getStepType(), toolCall.turnId, toolCall.toolCallId, isApproved, isDeadlinePassed, (activation) => {
595
+ if (!isApproved) {
596
+ return this.rejectToolCall(context, toolCall, rejectionMessage);
597
+ }
598
+ // The approval starts a new activation, so the tool's spans join its root, not the RunModel one.
599
+ const { traceContext: _deferredTraceContext, ...deferredToolCall } = toolCall;
600
+ this.transitions.writeToolCall(context, {
601
+ ...deferredToolCall,
602
+ status: "approved",
603
+ cancellationReason: null,
604
+ ...(activation.traceparent === null ? {} : { traceContext: activation.traceparent }),
605
+ });
606
+ this.transitions.writeInterruptResolved(context, toolCall.turnId, {
607
+ interruptId: toolCall.toolCallId,
608
+ kind: "tool_approval",
609
+ isApproved: true,
610
+ isAnswered: false,
611
+ rejectionMessage: null,
612
+ });
613
+ return goToMany(toolStepMovement(this.transitions.persistence, toolStepTypeForEffect(toolCall.effect), toolCall.toolCallId));
614
+ });
282
615
  }
283
616
  rejectToolCall(context, toolCall, rejectionMessage) {
284
- this.transitions.writeToolCall(context, { ...toolCall, status: "rejected", rejectionMessage });
285
- this.writeInterruptResolved(context, toolCall, false, rejectionMessage);
286
- this.transitions.writeEvents(context, toolCall.turnId, (events) => events.writeToolCallFinished(toolCall.toolCallId, toolCall.toolName, "rejected"));
287
- this.transitions.publishToolCallResult(context, toolCall.toolCallId);
617
+ this.transitions.writeInterruptResolved(context, toolCall.turnId, {
618
+ interruptId: toolCall.toolCallId,
619
+ kind: "tool_approval",
620
+ isApproved: false,
621
+ isAnswered: false,
622
+ rejectionMessage,
623
+ });
624
+ this.transitions.finishToolCall(context, {
625
+ ...toolCall,
626
+ status: "rejected",
627
+ rejectionMessage,
628
+ cancellationReason: null,
629
+ });
288
630
  return deadEnd();
289
631
  }
290
- writeInterruptResolved(context, toolCall, isApproved, rejectionMessage) {
291
- this.transitions.writeEvents(context, toolCall.turnId, (events) => events.writeEvent("interrupt_resolved", {
292
- interrupt_id: toolCall.toolCallId,
293
- approved: isApproved,
294
- rejection_message: rejectionMessage,
295
- }));
296
- }
297
632
  }
298
633
  /** Runs one tool call; one class serves `CallTool` and `CallSingleAttemptTool`. */
299
634
  export class ToolCallStep {
@@ -319,49 +654,119 @@ export class ToolCallStep {
319
654
  if (toolCall.status !== "approved" || toolStepTypeForEffect(toolCall.effect) !== this.stepType) {
320
655
  throw new Error(`tool call ${toolCallId} cannot run in ${this.stepType}`);
321
656
  }
657
+ // Retries reuse the first attempt's state, so a call that started is never abandoned.
658
+ const reason = transitions.readTurnControlReason(context, toolCall.turnId);
659
+ if (reason !== null) {
660
+ transitions.finishToolCall(context, cancelledToolCall(toolCall, reason));
661
+ return deadEnd();
662
+ }
322
663
  const events = transitions.createEventWriter(context, toolCall.turnId);
323
664
  try {
324
- events.writeToolCallStarted(toolCall.toolCallId, toolCall.toolName);
325
- // Dex re-delivers an attempt whose Worker vanished before any output, so emit output first.
326
- await transitions.recordHeartbeat(context);
327
- const execution = {
328
- flowId: context.flowId,
329
- toolCall,
330
- idempotencyKey: `${context.flowId}/${toolCall.turnId}/${toolCall.toolCallId}`,
331
- attempt: context.attempt,
332
- events,
333
- cancellationSignal: context.cancellationSignal,
334
- deadline: context.deadline,
335
- heartbeat: () => transitions.recordHeartbeat(context),
336
- };
337
- let finishedToolCall;
338
- try {
339
- const result = await transitions.runWithHeartbeats(context, () => transitions.runner.executeDeferredToolCall(execution));
340
- if (typeof result?.output !== "string") {
341
- throw new TypeError(`executeDeferredToolCall returned no output for ${toolCallId}`);
665
+ return await transitions.tracer.traceToolCall(context, this.stepType, toolCall, async (toolCallSpan) => {
666
+ events.writeToolCallStarted(toolCall.toolCallId, toolCall.toolName);
667
+ const heartbeats = new ToolStepHeartbeats(context);
668
+ // Output first: Dex returns an attempt as not run when its stream half-closes before any output.
669
+ await heartbeats.record();
670
+ const execution = {
671
+ flowId: context.flowId,
672
+ toolCall: toRunnerToolCall(toolCall),
673
+ idempotencyKey: `${context.flowId}/${toolCall.turnId}/${toolCall.toolCallId}`,
674
+ attempt: context.attempt,
675
+ events,
676
+ cancellationSignal: context.cancellationSignal,
677
+ deadline: context.deadline,
678
+ inputAnswers: toolCall.inputAnswers,
679
+ supportsToolTasks: transitions.runner.pollToolTask !== undefined,
680
+ heartbeat: () => heartbeats.record(),
681
+ ...(heartbeats.previousCheckpoint === undefined ? {} : { heartbeatCheckpoint: heartbeats.previousCheckpoint }),
682
+ saveHeartbeatCheckpoint: (checkpoint) => heartbeats.saveCheckpoint(checkpoint),
683
+ };
684
+ let outcome;
685
+ try {
686
+ outcome = await transitions.runWithHeartbeats(context, () => transitions.runner.executeDeferredToolCall(execution), () => heartbeats.record());
342
687
  }
343
- finishedToolCall = { ...toolCall, status: "succeeded", output: result.output };
344
- }
345
- catch (error) {
346
- if (!(error instanceof ToolExecutionError)) {
347
- throw error;
688
+ catch (error) {
689
+ if (!(error instanceof ToolExecutionError)) {
690
+ throw error;
691
+ }
692
+ const failed = { ...toolCall, status: "failed", error: error.message };
693
+ toolCallSpan.recordFinished(failed);
694
+ transitions.finishToolCall(context, failed, events);
695
+ return deadEnd();
348
696
  }
349
- finishedToolCall = { ...toolCall, status: "failed", error: error.message };
350
- }
351
- transitions.writeToolCall(context, finishedToolCall);
352
- events.writeToolCallFinished(toolCall.toolCallId, toolCall.toolName, finishedToolCall.status);
353
- transitions.publishToolCallResult(context, toolCallId);
354
- return deadEnd();
697
+ if (isToolInputRequested(outcome)) {
698
+ return this.requestToolInput(context, toolCall, outcome, events, toolCallSpan);
699
+ }
700
+ if (isToolTaskStarted(outcome)) {
701
+ return this.startToolTask(context, toolCall, outcome, events, toolCallSpan);
702
+ }
703
+ if (typeof outcome?.output !== "string") {
704
+ throw new TypeError(`executeDeferredToolCall returned no output for ${toolCallId}`);
705
+ }
706
+ const succeeded = { ...toolCall, status: "succeeded", output: outcome.output };
707
+ toolCallSpan.recordFinished(succeeded);
708
+ transitions.finishToolCall(context, succeeded, events);
709
+ return deadEnd();
710
+ });
355
711
  }
356
712
  finally {
357
713
  events.close();
358
714
  }
359
715
  }
716
+ /** Records `awaiting_answer` with the tool's question; `AwaitQuestionAnswer` raises it under the lock. */
717
+ requestToolInput(context, toolCall, request, events, toolCallSpan) {
718
+ const transitions = this.transitions;
719
+ const waiting = recordToolInputRequest(toolCall, request);
720
+ toolCallSpan.recordFinished(waiting);
721
+ if (isFinalToolCallStatus(waiting.status)) {
722
+ transitions.finishToolCall(context, waiting, events);
723
+ return deadEnd();
724
+ }
725
+ // This Step runs unlocked, so it leaves PendingInterrupts to AwaitQuestionAnswer.
726
+ transitions.writeToolCall(context, waiting);
727
+ return goToMany(questionAnswerMovement(transitions.persistence, waiting));
728
+ }
729
+ /** Records `waiting_for_task` and moves to `PollToolTask`, or records a task it cannot poll by effect. */
730
+ startToolTask(context, toolCall, started, events, toolCallSpan) {
731
+ const transitions = this.transitions;
732
+ const pollIntervalSeconds = toolTaskPollIntervalSeconds(started.pollIntervalSeconds);
733
+ const defect = transitions.runner.pollToolTask === undefined
734
+ ? missingToolTaskPoller
735
+ : typeof started.taskId !== "string" || isBlankText(started.taskId)
736
+ ? blankToolTaskId
737
+ : pollIntervalSeconds === null
738
+ ? invalidToolTaskPollInterval
739
+ : undefined;
740
+ if (defect !== undefined || pollIntervalSeconds === null) {
741
+ const finished = recordToolTaskDefect(toolCall, defect ?? invalidToolTaskPollInterval);
742
+ toolCallSpan.recordFinished(finished);
743
+ transitions.finishToolCall(context, finished, events);
744
+ return deadEnd();
745
+ }
746
+ // The tool consumed any continuation of its own questions when it ran again.
747
+ const waiting = {
748
+ ...toolCall,
749
+ status: "waiting_for_task",
750
+ taskId: started.taskId,
751
+ taskPollIntervalSeconds: pollIntervalSeconds,
752
+ inputContinuation: null,
753
+ ...noPendingToolQuestion,
754
+ };
755
+ toolCallSpan.recordFinished(waiting);
756
+ transitions.writeToolCall(context, waiting);
757
+ events.writeEvent("tool_task_started", {
758
+ tool_call_id: waiting.toolCallId,
759
+ tool_name: waiting.toolName,
760
+ task_id: started.taskId,
761
+ });
762
+ return goToMany(toolTaskPollMovement(transitions.persistence, waiting));
763
+ }
360
764
  }
361
765
  /**
362
766
  * Failure route of a tool Step; it receives the failed Step's input, the tool call ID.
363
767
  * `RecordToolFailure` records `failed` for a `read` call and `outcome_unknown` for a write;
364
- * `RecordToolOutcomeUnknown` always records `outcome_unknown`.
768
+ * `RecordToolOutcomeUnknown` always records `outcome_unknown`. Either records `failed` with
769
+ * {@link toolCallNotRunErrorPrefix} when the tool certainly never ran.
365
770
  */
366
771
  export class ToolCallFailureRecordingStep {
367
772
  transitions;
@@ -386,13 +791,34 @@ export class ToolCallFailureRecordingStep {
386
791
  return deadEnd();
387
792
  }
388
793
  const status = this.isReadFailureRecorded && toolCall.effect === "read" ? "failed" : "outcome_unknown";
389
- transitions.writeToolCall(context, { ...toolCall, status, error: readRecoveryDetail(context) });
390
- transitions.writeEvents(context, toolCall.turnId, (events) => events.writeToolCallFinished(toolCall.toolCallId, toolCall.toolName, status));
391
- transitions.publishToolCallResult(context, toolCallId);
794
+ transitions.finishToolCall(context, recoveredToolCall(toolCall, context.recoveryError, status));
392
795
  return deadEnd();
393
796
  }
394
797
  }
395
- /** Waits until every tool call of the batch has a final record, then makes the next model call. */
798
+ /** Returns the final record that a tool Step's failure route writes. */
799
+ export function recoveredToolCall(toolCall, recoveryError, status) {
800
+ // An abandoned task keeps the text CancelToolTask records, whatever made its attempts fail.
801
+ const error = isAbandonedToolTask(toolCall) && (toolCall.cancellationReason ?? null) !== null
802
+ ? toolTaskAbandonedError
803
+ : recoveryErrorDetail(recoveryError);
804
+ if (isToolCallNotRun(toolCall, recoveryError)) {
805
+ return { ...toolCall, status: "failed", error: `${toolCallNotRunErrorPrefix}: ${error}` };
806
+ }
807
+ return { ...toolCall, status, error };
808
+ }
809
+ /** Returns whether the failed Step certainly never ran the call's tool. */
810
+ function isToolCallNotRun(toolCall, recoveryError) {
811
+ // Polls and cancellations follow a tool that already ran, as does an answered question's call.
812
+ return (recoveryError?.attemptProgress === StepAttemptProgress.NOT_STARTED &&
813
+ toolCall.status === "approved" &&
814
+ toolCall.inputAnswers.length === 0);
815
+ }
816
+ /**
817
+ * Waits until every tool call of the batch has a final record, then makes the next model call.
818
+ *
819
+ * A steer wakes it early: it cancels the turn's pending interrupts and Timers, and waits again. After a steer or
820
+ * `cancel_turn`, once every result is in, it moves to `ApplyTurnControl` instead of `RunModel`.
821
+ */
396
822
  export class ResumeTurnStep {
397
823
  transitions;
398
824
  inputCodec = expectedResultCountCodec;
@@ -403,76 +829,201 @@ export class ResumeTurnStep {
403
829
  return agentSessionStepTypes.resumeTurn;
404
830
  }
405
831
  getStepOptions() {
406
- return { ...syncDurability, executeLoadAttributeMaps: [this.transitions.persistence.agentToolCalls] };
832
+ const persistence = this.transitions.persistence;
833
+ // A control wake rewrites PendingInterrupts, which the interrupt RPCs also write.
834
+ return {
835
+ ...syncDurability,
836
+ executeLoadAttributeMaps: [persistence.agentToolCalls],
837
+ executeLockAttributes: [persistence.pendingInterrupts.lock()],
838
+ };
407
839
  }
408
840
  waitFor(_context, expectedResultCount) {
409
- return Wait.until(this.transitions.persistence.toolCallResults.forN(expectedResultCount));
841
+ const persistence = this.transitions.persistence;
842
+ return Wait.anyOf(persistence.toolCallResults.forN(expectedResultCount), persistence.turnControlRequests.range(1, allPendingMessages));
410
843
  }
411
- execute(context) {
844
+ execute(context, expectedResultCount) {
412
845
  const transitions = this.transitions;
413
846
  const state = transitions.readAgentState(context);
414
- const unfinishedCount = transitions
415
- .readTurnToolCalls(context, state.activeTurnId)
416
- .filter((toolCall) => !isFinalToolCallStatus(toolCall.status)).length;
847
+ const turnId = state.activeTurnId;
848
+ const toolCalls = transitions.readTurnToolCalls(context, turnId);
849
+ const reason = transitions.readTurnControlReason(context, turnId);
850
+ if (context.channelResults(transitions.persistence.turnControlRequests).length > 0) {
851
+ // FinishTurnCancellation, not this wake, ends the waits of a cancelled turn.
852
+ if (reason === "steered" && turnId !== null) {
853
+ this.wakeWaitingToolCalls(context, turnId, toolCalls, reason);
854
+ }
855
+ // The results did not all arrive, so wait again for the same count.
856
+ return goTo(new StepRef(agentSessionStepTypes.resumeTurn), expectedResultCount);
857
+ }
858
+ const unfinishedCount = toolCalls.filter((toolCall) => !isFinalToolCallStatus(toolCall.status)).length;
417
859
  if (unfinishedCount > 0) {
418
860
  // A consumed result from an earlier turn must not resume this one early.
419
861
  return goTo(new StepRef(agentSessionStepTypes.resumeTurn), unfinishedCount);
420
862
  }
863
+ if (reason !== null) {
864
+ return transitions.goToApplyTurnControl(context);
865
+ }
421
866
  transitions.writeAgentState(context, { ...state, status: "running_model" });
422
- return goTo(new StepRef(agentSessionStepTypes.runModel), undefined);
867
+ return transitions.goToRunModel(context);
868
+ }
869
+ /** Cancels the turn's pending interrupts and wakes each Step waiting for a person or a Timer. */
870
+ wakeWaitingToolCalls(context, turnId, toolCalls, reason) {
871
+ const transitions = this.transitions;
872
+ const persistence = transitions.persistence;
873
+ const interrupts = transitions.readPendingInterrupts(context);
874
+ const cancelledInterruptIds = new Set();
875
+ for (const toolCall of toolCalls) {
876
+ if (!isCancellableByTurnControl(toolCall)) {
877
+ continue;
878
+ }
879
+ if (toolCall.status === "waiting_for_timer") {
880
+ // A later control wake finds the first message still pending and adds none.
881
+ if (context.channelSize(persistence.toolCallCancellations, toolCall.toolCallId) === 0) {
882
+ persistence.toolCallCancellations.publish(context, toolCall.toolCallId, { reason });
883
+ }
884
+ continue;
885
+ }
886
+ const isApproval = toolCall.status === "awaiting_approval";
887
+ const interruptId = isApproval ? toolCall.toolCallId : questionInterruptId(toolCall);
888
+ const kind = isApproval ? "tool_approval" : "question";
889
+ // An answered interrupt is gone, so its answer wins; an unraised question checks flags.
890
+ if (interrupts.some((interrupt) => interrupt.interruptId === interruptId && interrupt.toolCallId === toolCall.toolCallId)) {
891
+ cancelledInterruptIds.add(interruptId);
892
+ transitions.writeInterruptCancelled(context, turnId, interruptId, kind, reason);
893
+ persistence.toolCallCancellations.publish(context, toolCall.toolCallId, { reason });
894
+ }
895
+ }
896
+ if (cancelledInterruptIds.size > 0) {
897
+ persistence.pendingInterrupts.set(context, interrupts.filter((interrupt) => !cancelledInterruptIds.has(interrupt.interruptId)));
898
+ }
423
899
  }
424
900
  }
425
- export class RecordTurnFailureStep {
901
+ /** Ends a steered or cancelled turn, then starts a turn with the steered messages. */
902
+ export class ApplyTurnControlStep {
426
903
  transitions;
427
904
  inputCodec = voidCodec;
428
905
  constructor(transitions) {
429
906
  this.transitions = transitions;
430
907
  }
908
+ getStepType() {
909
+ return agentSessionStepTypes.applyTurnControl;
910
+ }
911
+ getStepOptions() {
912
+ // Each movement into ApplyTurnControl adds the archive chunks of the model input.
913
+ // The lock keeps a turn from starting while cancel_turn decides about the last one.
914
+ return {
915
+ ...messageStepOptions(this.transitions.persistence, this.getStepType(), staticMessageChunkLoads(this.transitions)),
916
+ executeLockAttributes: [this.transitions.persistence.turnCancellationRequest.lock()],
917
+ };
918
+ }
919
+ waitFor() {
920
+ const persistence = this.transitions.persistence;
921
+ // Both conditions are non-blocking: the wait consumes every steered message and stale wake.
922
+ return Wait.allOf(persistence.steeredUserMessages.atMost(allPendingMessages), persistence.turnControlRequests.atMost(allPendingMessages));
923
+ }
924
+ execute(context) {
925
+ const transitions = this.transitions;
926
+ let state = transitions.readAgentState(context);
927
+ const steeredMessages = context.channelResults(transitions.persistence.steeredUserMessages);
928
+ if (state.activeTurnId !== null) {
929
+ state = transitions.endControlledTurn(context, state, state.activeTurnId, steeredMessages.length);
930
+ }
931
+ if (steeredMessages.length === 0) {
932
+ return transitions.goToAwaitUserMessage();
933
+ }
934
+ // Any steered message with planMode, or a draft plan, makes it a planning turn.
935
+ return transitions.beginTurn(context, state, steeredMessages, this.getStepType(), transitions.decideInteractionMode(context, steeredMessages));
936
+ }
937
+ }
938
+ /** Ends a failed turn; it is the failure route of `RunModel` and `CompactContext`, so it takes either's input. */
939
+ export class RecordTurnFailureStep {
940
+ transitions;
941
+ inputCodec = turnFailureRouteInputCodec;
942
+ constructor(transitions) {
943
+ this.transitions = transitions;
944
+ }
431
945
  getStepType() {
432
946
  return agentSessionStepTypes.recordTurnFailure;
433
947
  }
434
948
  getStepOptions() {
949
+ const persistence = this.transitions.persistence;
950
+ const hasUnfinishedTurnMessages = this.transitions.runner.buildUnfinishedTurnMessages !== undefined;
435
951
  return {
436
952
  ...syncDurability,
437
953
  executeRetry: recoveryRetry,
438
- executeLoadAttributeMaps: [this.transitions.persistence.agentToolCalls],
954
+ // A failure route has only static options, so an incremental runner loads every retained chunk.
955
+ executeLoadAttributeMaps: hasUnfinishedTurnMessages
956
+ ? [persistence.agentToolCalls, persistence.agentMessages, persistence.agentMessageChunks]
957
+ : [persistence.agentToolCalls, persistence.agentMessages],
439
958
  };
440
959
  }
441
960
  execute(context) {
442
961
  const transitions = this.transitions;
443
- const persistence = transitions.persistence;
444
- let state = transitions.readAgentState(context);
445
- const turnError = readRecoveryDetail(context);
962
+ const state = transitions.readAgentState(context);
446
963
  const toolCalls = transitions.readTurnToolCalls(context, state.activeTurnId);
447
964
  const finalToolCalls = toolCalls.filter((toolCall) => isFinalToolCallStatus(toolCall.status));
448
- // The records are deleted below; the note keeps the next model from repeating writes.
449
- if (state.activeTurnId !== null && finalToolCalls.length > 0) {
450
- const note = transitions.runner.buildUserMessage(buildTurnFailureNote(finalToolCalls));
451
- if (note.role !== "user") {
452
- throw new TypeError("buildUserMessage must return a message with role user");
453
- }
454
- state = transitions.appendMessages(context, state, state.activeTurnId, [note]);
455
- }
456
- for (const toolCall of toolCalls) {
457
- persistence.agentToolCalls.delete(context, toolCall.toolCallId);
965
+ const unfinishedTurnMessages = state.activeTurnId === null
966
+ ? null
967
+ : transitions.buildUnfinishedTurnMessages(context, state, state.activeTurnId, finalToolCalls);
968
+ return recordFailedTurn(transitions, context, state, toolCalls, unfinishedTurnMessages ?? [], readRecoveryDetail(context));
969
+ }
970
+ }
971
+ /**
972
+ * Ends the active turn as failed in the current commit: appends the unfinished-turn messages, then the
973
+ * turn-failure note when the turn has final records, and clears the turn.
974
+ */
975
+ function recordFailedTurn(transitions, context, initialState, toolCalls, unfinishedTurnMessages, turnError) {
976
+ const persistence = transitions.persistence;
977
+ const turnId = initialState.activeTurnId;
978
+ let state = initialState;
979
+ const finalToolCalls = toolCalls.filter((toolCall) => isFinalToolCallStatus(toolCall.status));
980
+ if (turnId !== null) {
981
+ state = transitions.appendMessages(context, state, turnId, unfinishedTurnMessages);
982
+ }
983
+ // The records are deleted below; the note keeps the next model from repeating writes.
984
+ if (turnId !== null && finalToolCalls.length > 0) {
985
+ const note = transitions.runner.buildUserMessage(buildTurnFailureNote(finalToolCalls));
986
+ if (note.role !== "user") {
987
+ throw new TypeError("buildUserMessage must return a message with role user");
458
988
  }
459
- persistence.frameworkRunState.delete(context);
460
- persistence.pendingInterrupts.set(context, []);
461
- transitions.writeAgentState(context, {
462
- ...state,
463
- status: "waiting_for_user_message",
464
- turnCount: state.turnCount + 1,
465
- activeTurnId: null,
466
- lastTurnError: turnError,
467
- });
468
- transitions.writeEvents(context, state.activeTurnId, (events) => events.writeEvent("turn_failed", { error: turnError }));
469
- return transitions.goToAwaitUserMessage();
989
+ state = transitions.appendMessages(context, state, turnId, [note]);
470
990
  }
991
+ for (const toolCall of toolCalls) {
992
+ persistence.agentToolCalls.delete(context, toolCall.toolCallId);
993
+ }
994
+ persistence.frameworkRunState.delete(context);
995
+ persistence.pendingInterrupts.set(context, []);
996
+ transitions.writeAgentState(context, {
997
+ ...state,
998
+ status: "waiting_for_user_message",
999
+ turnCount: state.turnCount + 1,
1000
+ activeTurnId: null,
1001
+ lastTurnError: turnError,
1002
+ acceptsTurnControl: false,
1003
+ });
1004
+ transitions.writeEvents(context, turnId, (events) => events.writeEvent("turn_failed", { error: turnError }));
1005
+ return transitions.goToAwaitUserMessage();
471
1006
  }
1007
+ /**
1008
+ * Chunk loads of a Step's static options, used by a retry or by a movement an earlier version scheduled: none
1009
+ * beyond the window with compaction, else every chunk.
1010
+ */
1011
+ function staticMessageChunkLoads(transitions) {
1012
+ return { isWholeMap: transitions.config.compaction === undefined, instances: [] };
1013
+ }
1014
+ /** The `AgentMessages` keys that appending these messages after the state's last one will use. */
1015
+ function appendedMessageKeys(state, messages) {
1016
+ return messages.map((_message, offset) => messageSequenceKey(state.nextMessageSequence + offset));
1017
+ }
1018
+ /** `last_turn_error` of a turn whose runner stored messages incrementally without the builder. */
1019
+ export const missingUnfinishedTurnMessageBuilder = "a runner that sets ToolCallsRequested.newMessages must implement buildUnfinishedTurnMessages";
472
1020
  function validateToolCallsRequested(turnId, existingToolCalls, outcome) {
473
1021
  if (typeof outcome.frameworkRunState !== "string" || outcome.frameworkRunState === "") {
474
1022
  throw new TypeError(`ToolCallsRequested for ${turnId} needs a non-empty frameworkRunState`);
475
1023
  }
1024
+ if (outcome.newMessages !== undefined && !Array.isArray(outcome.newMessages)) {
1025
+ throw new TypeError(`ToolCallsRequested.newMessages for ${turnId} must be an array`);
1026
+ }
476
1027
  if (outcome.toolCalls.length === 0) {
477
1028
  throw new TypeError(`ToolCallsRequested for ${turnId} needs at least one tool call`);
478
1029
  }
@@ -492,14 +1043,34 @@ function validateToolCallsRequested(turnId, existingToolCalls, outcome) {
492
1043
  * Returns the full options of a tool Step type. With a tool call ID, the Step loads only that call's
493
1044
  * record; a movement's options replace the Step's static options, so every option is repeated here.
494
1045
  */
495
- function toolStepOptions(persistence, stepType, toolCallId) {
1046
+ export function toolStepOptions(persistence, stepType, toolCallId) {
496
1047
  const loads = toolCallId === undefined
497
1048
  ? { executeLoadAttributeMaps: [persistence.agentToolCalls] }
498
1049
  : { executeLoadAttributeMapInstances: [persistence.agentToolCalls.load(toolCallId)] };
1050
+ const waitForLoads = toolCallId === undefined
1051
+ ? { waitForLoadAttributeMaps: [persistence.agentToolCalls] }
1052
+ : { waitForLoadAttributeMapInstances: [persistence.agentToolCalls.load(toolCallId)] };
499
1053
  switch (stepType) {
500
1054
  case agentSessionStepTypes.awaitToolApproval:
501
1055
  // The deadline path rewrites PendingInterrupts, which resolve_interrupt and sibling Steps also write.
502
1056
  return { ...syncDurability, ...loads, executeLockAttributes: [persistence.pendingInterrupts.lock()] };
1057
+ case agentSessionStepTypes.awaitQuestionAnswer:
1058
+ // Raising a tool's question and the deadline path rewrite PendingInterrupts, as in AwaitToolApproval.
1059
+ return {
1060
+ ...syncDurability,
1061
+ ...loads,
1062
+ ...waitForLoads,
1063
+ // A movement loads only its question's instance; see questionAnswerMovement.
1064
+ ...(toolCallId === undefined
1065
+ ? {
1066
+ waitForLoadChannelMaps: [persistence.interruptResolutions],
1067
+ executeLoadChannelMaps: [persistence.interruptResolutions],
1068
+ }
1069
+ : {}),
1070
+ executeLockAttributes: [persistence.pendingInterrupts.lock()],
1071
+ };
1072
+ case agentSessionStepTypes.awaitTimer:
1073
+ return { ...syncDurability, ...loads, ...waitForLoads };
503
1074
  case agentSessionStepTypes.callTool:
504
1075
  return {
505
1076
  ...syncDurability,
@@ -514,16 +1085,184 @@ function toolStepOptions(persistence, stepType, toolCallId) {
514
1085
  executeRetry: { maximumAttempts: 1 },
515
1086
  executeFailure: ExecuteFailure.proceedTo(new StepRef(agentSessionStepTypes.recordToolOutcomeUnknown), toolStepOptions(persistence, agentSessionStepTypes.recordToolOutcomeUnknown, toolCallId)),
516
1087
  };
1088
+ case agentSessionStepTypes.pollToolTask:
1089
+ return toolTaskPollOptions(persistence, agentSessionStepTypes.recordToolFailure, toolCallId);
517
1090
  case agentSessionStepTypes.recordToolFailure:
518
1091
  case agentSessionStepTypes.recordToolOutcomeUnknown:
519
1092
  return { ...syncDurability, ...loads, executeRetry: recoveryRetry };
1093
+ case agentSessionStepTypes.cancelToolTask:
1094
+ // Retries that run out still record the call, so the cancelled turn always ends.
1095
+ return {
1096
+ ...syncDurability,
1097
+ ...loads,
1098
+ executeMethodTimeoutMs: 30_000,
1099
+ executeRetry: { maximumAttempts: 3, totalDurationMs: 2 * minuteMs },
1100
+ executeFailure: ExecuteFailure.proceedTo(new StepRef(agentSessionStepTypes.recordToolOutcomeUnknown), toolStepOptions(persistence, agentSessionStepTypes.recordToolOutcomeUnknown, toolCallId)),
1101
+ };
520
1102
  default:
521
1103
  throw new TypeError(`${stepType} is not a tool Step type`);
522
1104
  }
523
1105
  }
524
- function toolStepMovement(persistence, stepType, toolCallId) {
1106
+ export function toolStepMovement(persistence, stepType, toolCallId) {
525
1107
  return StepMovement.of(new StepRef(stepType), toolCallId, toolStepOptions(persistence, stepType, toolCallId));
526
1108
  }
1109
+ /**
1110
+ * Returns the options of `PollToolTask`. It takes no lock and never waits on a cancellation: it owns the
1111
+ * record, and a started task runs on.
1112
+ */
1113
+ function toolTaskPollOptions(persistence, recoveryStepType, toolCallId) {
1114
+ const loads = toolCallId === undefined
1115
+ ? {
1116
+ waitForLoadAttributeMaps: [persistence.agentToolCalls],
1117
+ executeLoadAttributeMaps: [persistence.agentToolCalls],
1118
+ }
1119
+ : {
1120
+ waitForLoadAttributeMapInstances: [persistence.agentToolCalls.load(toolCallId)],
1121
+ executeLoadAttributeMapInstances: [persistence.agentToolCalls.load(toolCallId)],
1122
+ };
1123
+ return {
1124
+ ...syncDurability,
1125
+ ...loads,
1126
+ executeRetry: { maximumAttempts: 3 },
1127
+ executeFailure: ExecuteFailure.proceedTo(new StepRef(recoveryStepType), toolStepOptions(persistence, recoveryStepType, toolCallId)),
1128
+ };
1129
+ }
1130
+ /** Moves to `PollToolTask`; a poll that keeps failing leaves the task running, so the call is recorded by effect. */
1131
+ export function toolTaskPollMovement(persistence, toolCall) {
1132
+ const recoveryStepType = toolStepTypeForEffect(toolCall.effect) === agentSessionStepTypes.callTool
1133
+ ? agentSessionStepTypes.recordToolFailure
1134
+ : agentSessionStepTypes.recordToolOutcomeUnknown;
1135
+ return StepMovement.of(new StepRef(agentSessionStepTypes.pollToolTask), toolCall.toolCallId, toolTaskPollOptions(persistence, recoveryStepType, toolCall.toolCallId));
1136
+ }
1137
+ /** Returns the record of a tool's question: `awaiting_answer`, or `failed` when it is blank. */
1138
+ export function recordToolInputRequest(toolCall, request) {
1139
+ const choices = request.choices ?? [];
1140
+ if (typeof request.question !== "string" ||
1141
+ isBlankText(request.question) ||
1142
+ !choices.every((choice) => typeof choice === "string" && !isBlankText(choice))) {
1143
+ return { ...toolCall, status: "failed", error: "the tool asked for input with a blank question or choice" };
1144
+ }
1145
+ return {
1146
+ ...toolCall,
1147
+ status: "awaiting_answer",
1148
+ inputContinuation: request.continuation,
1149
+ inputQuestion: request.question,
1150
+ inputChoices: [...choices],
1151
+ inputAllowsFreeText: (request.allowsFreeText ?? true) || choices.length === 0,
1152
+ };
1153
+ }
1154
+ /** Returns a final record for a task that cannot be polled: `failed` for a read, else `outcome_unknown`. */
1155
+ export function recordToolTaskDefect(toolCall, error) {
1156
+ return {
1157
+ ...toolCall,
1158
+ status: toolCall.effect === "read" ? "failed" : "outcome_unknown",
1159
+ error,
1160
+ ...noPendingToolQuestion,
1161
+ };
1162
+ }
1163
+ /** Returns a poll interval in whole seconds from 1 to a day, or `null` when it is not a finite number. */
1164
+ export function toolTaskPollIntervalSeconds(seconds) {
1165
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
1166
+ return null;
1167
+ }
1168
+ // Dex Timers take whole seconds, and a zero interval would poll without a pause.
1169
+ return Math.min(Math.max(1, Math.ceil(seconds)), maximumToolTaskPollIntervalSeconds);
1170
+ }
1171
+ function createToolCallRecord(batch, requestedToolCall, effect, requiresApproval, deferralOrder) {
1172
+ return {
1173
+ toolCallId: requestedToolCall.toolCallId,
1174
+ turnId: batch.turnId,
1175
+ toolName: requestedToolCall.toolName,
1176
+ arguments: requestedToolCall.arguments,
1177
+ effect,
1178
+ requiresApproval,
1179
+ status: "approved",
1180
+ output: null,
1181
+ error: null,
1182
+ rejectionMessage: null,
1183
+ deferralOrder,
1184
+ ...(requestedToolCall.toolSourceReference === undefined
1185
+ ? {}
1186
+ : { toolSourceReference: requestedToolCall.toolSourceReference }),
1187
+ ...(batch.traceContext === null ? {} : { traceContext: batch.traceContext }),
1188
+ waitUntil: null,
1189
+ inputContinuation: null,
1190
+ inputAnswers: [],
1191
+ ...noPendingToolQuestion,
1192
+ cancellationReason: null,
1193
+ taskId: null,
1194
+ taskPollIntervalSeconds: null,
1195
+ taskAnswerPending: false,
1196
+ };
1197
+ }
1198
+ function writeToolCallDeferred(events, record) {
1199
+ events.writeEvent("tool_call_deferred", {
1200
+ tool_call_id: record.toolCallId,
1201
+ tool_name: record.toolName,
1202
+ effect: record.effect,
1203
+ requires_approval: record.requiresApproval,
1204
+ });
1205
+ }
1206
+ /** Record fields of a call without a pending tool question. */
1207
+ export const noPendingToolQuestion = {
1208
+ inputQuestion: null,
1209
+ inputChoices: [],
1210
+ inputAllowsFreeText: true,
1211
+ };
1212
+ /** Moves to `AwaitQuestionAnswer`, which reads the answer count to tell an unraised question from an answer. */
1213
+ export function questionAnswerMovement(persistence, toolCall) {
1214
+ const options = toolStepOptions(persistence, agentSessionStepTypes.awaitQuestionAnswer, toolCall.toolCallId);
1215
+ const interruptId = questionInterruptId(toolCall);
1216
+ const answerLoad = persistence.interruptResolutions.loadMessages(interruptId);
1217
+ // A record keyed by the question's interrupt ID means another call already owns that ID.
1218
+ const recordLoads = interruptId === toolCall.toolCallId
1219
+ ? [persistence.agentToolCalls.load(toolCall.toolCallId)]
1220
+ : [persistence.agentToolCalls.load(toolCall.toolCallId), persistence.agentToolCalls.load(interruptId)];
1221
+ return StepMovement.of(new StepRef(agentSessionStepTypes.awaitQuestionAnswer), toolCall.toolCallId, {
1222
+ ...options,
1223
+ waitForLoadAttributeMapInstances: recordLoads,
1224
+ executeLoadAttributeMapInstances: recordLoads,
1225
+ waitForLoadChannelMapInstances: [answerLoad],
1226
+ executeLoadChannelMapInstances: [answerLoad],
1227
+ });
1228
+ }
1229
+ /** An `ask_user` question uses the tool call ID; a tool's `n`-th question appends `-input-<n>`. */
1230
+ export function questionInterruptId(toolCall) {
1231
+ return toolCall.inputQuestion === null
1232
+ ? toolCall.toolCallId
1233
+ : `${toolCall.toolCallId}-input-${toolCall.inputAnswers.length + 1}`;
1234
+ }
1235
+ /**
1236
+ * Records a call that `ToolCallCancellations` woke as cancelled, or waits again. Pass `interrupt` `null` for a
1237
+ * durable wait.
1238
+ */
1239
+ export function cancelWaitingToolCall(transitions, context, toolCall, interrupt, waitAgain) {
1240
+ const persistence = transitions.persistence;
1241
+ // The flags decide; a wake-up left from an earlier turn's call ID changes nothing.
1242
+ const reason = transitions.readTurnControlReason(context, toolCall.turnId);
1243
+ if (reason === null || !isCancellableByTurnControl(toolCall)) {
1244
+ return goToMany(waitAgain);
1245
+ }
1246
+ if (interrupt !== null) {
1247
+ const pendingInterrupts = transitions.readPendingInterrupts(context);
1248
+ if (pendingInterrupts.some((pending) => pending.interruptId === interrupt.interruptId)) {
1249
+ persistence.pendingInterrupts.set(context, pendingInterrupts.filter((pending) => pending.interruptId !== interrupt.interruptId));
1250
+ transitions.writeInterruptCancelled(context, toolCall.turnId, interrupt.interruptId, interrupt.kind, reason);
1251
+ }
1252
+ else if (context.channelSize(persistence.interruptResolutions, interrupt.interruptId) > 0) {
1253
+ // The answer committed first; the next wait consumes it.
1254
+ return goToMany(waitAgain);
1255
+ }
1256
+ }
1257
+ transitions.finishToolCall(context, cancelledToolCall(toolCall, reason));
1258
+ return deadEnd();
1259
+ }
1260
+ export function isToolInputRequested(outcome) {
1261
+ return outcome?.kind === "tool_input_requested";
1262
+ }
1263
+ function isToolTaskStarted(outcome) {
1264
+ return outcome?.kind === "tool_task_started";
1265
+ }
527
1266
  function requirePositiveResultCount(value) {
528
1267
  if (!Number.isSafeInteger(value) || value <= 0) {
529
1268
  throw new TypeError(`ResumeTurn input must be a positive integer: ${value}`);