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