talon-agent 3.34.0 → 3.34.1

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.
@@ -24,6 +24,8 @@
24
24
  * - `stream-state` — backend-agnostic accumulator for stream loops.
25
25
  * - `turn-interrupt` — user-driven mid-turn interrupt registry (the
26
26
  * shared `ChatBackend.interruptChatTurn` for callback backends).
27
+ * - `turn-phases` — the post-stream phases (accounting, session name,
28
+ * trailing-prose contract, result tail) every handler runs.
27
29
  *
28
30
  * What's NOT here (intentionally):
29
31
  * - SDK-specific event types — those live in each backend.
@@ -34,11 +36,6 @@
34
36
 
35
37
  export { captureDeliveredText } from "./delivered-text.js";
36
38
 
37
- export {
38
- FLOW_VIOLATION_MAX_RETRIES,
39
- detectFlowViolation,
40
- } from "./flow-violation.js";
41
-
42
39
  export { registerTurnInterrupt } from "./turn-interrupt.js";
43
40
 
44
41
  export { formatUserPrompt } from "./prompt-format.js";
@@ -49,8 +46,6 @@ export {
49
46
  buildFirstTurnReminder,
50
47
  } from "./delivery-contract.js";
51
48
 
52
- export { extractSessionName } from "../../util/session-name.js";
53
-
54
49
  export { summarizeUsage } from "./usage.js";
55
50
 
56
51
  // Only what is consumed THROUGH the barrel. Everything else in
@@ -66,8 +61,6 @@ export {
66
61
 
67
62
  export { prepareSystemPrompt, appendBackendSuffix } from "./system-prompt.js";
68
63
 
69
- export { classifyRetry } from "./model-retry.js";
70
-
71
64
  export {
72
65
  createStreamState,
73
66
  appendText,
@@ -88,11 +81,17 @@ export {
88
81
 
89
82
  export { sleep } from "./sleep.js";
90
83
 
91
- export {
92
- recordToolCall,
93
- recordTurnMetrics,
94
- recordFailedTurnAccounting,
95
- recordFlowViolation,
96
- } from "./metrics.js";
84
+ export { recordToolCall } from "./metrics.js";
97
85
 
98
86
  export { applyRetryDecision } from "./handle-retry.js";
87
+
88
+ export {
89
+ accountTurn,
90
+ accountFailedTurn,
91
+ nameSessionFromFirstMessage,
92
+ enforceTrailingProse,
93
+ finishCallbackTurn,
94
+ turnUsageSnapshot,
95
+ } from "./turn-phases.js";
96
+
97
+ export { buildResultEvents } from "./result-events.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The `usage` + `completed` pair that closes every successful
3
+ * `runChatTurn` stream, whether the backend emits events natively
4
+ * (Claude SDK) or through `handlerToEvents`. Kept free of storage and
5
+ * logging imports so the adapter stays a pure event translator.
6
+ */
7
+
8
+ import type { AgentEvent } from "../../core/agent-runtime/events.js";
9
+ import type { TokenUsageSnapshot } from "./usage.js";
10
+
11
+ export function buildResultEvents(inputs: {
12
+ text: string;
13
+ durationMs: number;
14
+ usage: TokenUsageSnapshot;
15
+ modelId: string;
16
+ }): [AgentEvent, AgentEvent] {
17
+ const usage = { ...inputs.usage, modelId: inputs.modelId };
18
+ return [
19
+ { type: "usage", usage },
20
+ {
21
+ type: "completed",
22
+ result: {
23
+ text: inputs.text,
24
+ durationMs: inputs.durationMs,
25
+ usage,
26
+ modelId: inputs.modelId,
27
+ },
28
+ },
29
+ ];
30
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The phases every chat turn runs once its SDK stream loop has ended.
3
+ *
4
+ * Each backend handler owns its options build, its `for await` loop and
5
+ * its event translation — everything that depends on the SDK's shape.
6
+ * What comes after is the same four steps in every backend:
7
+ *
8
+ * 1. `accountTurn` — per-turn metrics, session id, session usage
9
+ * (or `accountFailedTurn` on the terminal-failure path).
10
+ * 2. `nameSessionFromFirstMessage` — the session title.
11
+ * 3. `enforceTrailingProse` — the tool-only delivery contract and the
12
+ * flow-violation re-prompt decision.
13
+ * 4. `finishCallbackTurn` — the delivery summary log lines and the
14
+ * `QueryResult` (the event-stream tail is `result-events.ts`).
15
+ *
16
+ * The functions take the stream state and explicit config bits and return
17
+ * explicit results; the handler decides what to do with a retry decision
18
+ * because recursion is the handler's own entry point.
19
+ */
20
+
21
+ import {
22
+ getSession,
23
+ recordUsage,
24
+ setSessionId,
25
+ setSessionName,
26
+ } from "../../storage/sessions.js";
27
+ import { log } from "../../util/log.js";
28
+ import { extractSessionName } from "../../util/session-name.js";
29
+ import { traceMessage } from "../../util/trace.js";
30
+ import {
31
+ FLOW_VIOLATION_MAX_RETRIES,
32
+ detectFlowViolation,
33
+ type FlowViolationResult,
34
+ } from "./flow-violation.js";
35
+ import type { QueryResult } from "./handler-types.js";
36
+ import {
37
+ recordFailedTurnAccounting,
38
+ recordFlowViolation,
39
+ recordTurnMetrics,
40
+ } from "./metrics.js";
41
+ import type { StreamState } from "./stream-state.js";
42
+ import { summarizeUsage, type TokenUsageSnapshot } from "./usage.js";
43
+
44
+ // ── Usage snapshot ──────────────────────────────────────────────────────────
45
+
46
+ /** The slice of a stream state the post-loop phases read. */
47
+ export type TurnUsageState = Pick<
48
+ StreamState,
49
+ | "sdkInputTokens"
50
+ | "sdkOutputTokens"
51
+ | "sdkCacheRead"
52
+ | "sdkCacheWrite"
53
+ | "toolCalls"
54
+ | "numApiCalls"
55
+ | "contextTokens"
56
+ | "contextWindow"
57
+ >;
58
+
59
+ /** The turn's token totals as the metrics + session layers consume them. */
60
+ export function turnUsageSnapshot(state: TurnUsageState): TokenUsageSnapshot {
61
+ return {
62
+ inputTokens: state.sdkInputTokens,
63
+ outputTokens: state.sdkOutputTokens,
64
+ cacheRead: state.sdkCacheRead,
65
+ cacheWrite: state.sdkCacheWrite,
66
+ };
67
+ }
68
+
69
+ // ── Phase 1: accounting ─────────────────────────────────────────────────────
70
+
71
+ /** Context-fill fields persisted alongside the token totals. */
72
+ type TurnContextUsage = {
73
+ contextTokens?: number;
74
+ contextWindow?: number;
75
+ numApiCalls?: number;
76
+ costUsd?: number;
77
+ };
78
+
79
+ export type AccountTurnInputs = {
80
+ chatId: string;
81
+ /** Backend id — the `backend.<id>.*` metric dimension. */
82
+ backend: string;
83
+ state: TurnUsageState;
84
+ durationMs: number;
85
+ model: string;
86
+ /** Provider session id to persist; skipped when absent or unchanged. */
87
+ sessionId?: string;
88
+ /** True when the turn ended in a delivered failure (Codex `turn.failed`). */
89
+ failed?: boolean;
90
+ /** Override for backends that count tool calls outside the stream state. */
91
+ toolCalls?: number;
92
+ /**
93
+ * Context-fill fields for `recordUsage`. Only backends whose stream
94
+ * reports them pass this; the others leave the session's context
95
+ * display untouched (zeroed) as they always have.
96
+ */
97
+ context?: TurnContextUsage;
98
+ };
99
+
100
+ /**
101
+ * Record the per-turn metric rollup, persist the provider session id and
102
+ * fold the turn's usage into the session. Each write is independent, so
103
+ * the order here is not load-bearing.
104
+ */
105
+ export function accountTurn(inputs: AccountTurnInputs): void {
106
+ const { chatId, state, durationMs } = inputs;
107
+ const usage = turnUsageSnapshot(state);
108
+ recordTurnMetrics({
109
+ chatId,
110
+ backend: inputs.backend,
111
+ durationMs,
112
+ toolCalls: inputs.toolCalls ?? state.toolCalls,
113
+ apiCalls: state.numApiCalls,
114
+ ...(inputs.failed !== undefined ? { failed: inputs.failed } : {}),
115
+ usage,
116
+ });
117
+ persistSessionId(chatId, inputs.sessionId);
118
+ recordUsage(chatId, {
119
+ ...usage,
120
+ durationMs,
121
+ model: inputs.model,
122
+ ...inputs.context,
123
+ });
124
+ }
125
+
126
+ function persistSessionId(chatId: string, sessionId: string | undefined): void {
127
+ if (!sessionId) return;
128
+ if (getSession(chatId).sessionId === sessionId) return;
129
+ setSessionId(chatId, sessionId);
130
+ }
131
+
132
+ export type AccountFailedTurnInputs = {
133
+ chatId: string;
134
+ backend: string;
135
+ state: TurnUsageState;
136
+ durationMs: number;
137
+ model: string;
138
+ /** Overrides for backends that count outside the stream state. */
139
+ toolCalls?: number;
140
+ apiCalls?: number;
141
+ usage?: TokenUsageSnapshot;
142
+ };
143
+
144
+ /**
145
+ * Terminal-failure accounting from the stream state — the tokens a
146
+ * failed turn burned still count. Not for retry paths: the recursive
147
+ * attempt accounts for itself.
148
+ */
149
+ export function accountFailedTurn(inputs: AccountFailedTurnInputs): void {
150
+ const { state } = inputs;
151
+ recordFailedTurnAccounting({
152
+ backend: inputs.backend,
153
+ chatId: inputs.chatId,
154
+ durationMs: inputs.durationMs,
155
+ toolCalls: inputs.toolCalls ?? state.toolCalls,
156
+ apiCalls: inputs.apiCalls ?? state.numApiCalls,
157
+ model: inputs.model,
158
+ usage: inputs.usage ?? turnUsageSnapshot(state),
159
+ contextTokens: state.contextTokens,
160
+ contextWindow: state.contextWindow,
161
+ });
162
+ }
163
+
164
+ // ── Phase 2: session name ───────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Title the session from the user's first message. Skipped on retries,
168
+ * whose `text` is a synthetic reminder rather than what the user said.
169
+ */
170
+ export function nameSessionFromFirstMessage(inputs: {
171
+ chatId: string;
172
+ text: string;
173
+ previousTurns: number;
174
+ isRetry?: boolean;
175
+ }): void {
176
+ if (inputs.previousTurns !== 0 || inputs.isRetry) return;
177
+ const name = extractSessionName(inputs.text);
178
+ if (name) setSessionName(inputs.chatId, name);
179
+ }
180
+
181
+ // ── Phase 3: trailing-prose contract ────────────────────────────────────────
182
+
183
+ export type TrailingProseInputs = {
184
+ chatId: string;
185
+ state: Pick<
186
+ StreamState,
187
+ "lastTrailingText" | "turnTerminated" | "deliveredTextNorms" | "toolCalls"
188
+ >;
189
+ /** Synthetic flow-violation retries already spent on this message. */
190
+ flowRetries: number;
191
+ /** Frontend-aware reminder; omit for the default telegram-shaped text. */
192
+ reminder?: string;
193
+ };
194
+
195
+ /**
196
+ * Apply the tool-only delivery contract to a finished turn: detect a
197
+ * flow violation, count it, log it, and say whether the handler should
198
+ * re-prompt with `reminder`. Callers gate this on the contract actually
199
+ * being in force (a messaging frontend with delivery tools registered).
200
+ */
201
+ export function enforceTrailingProse(
202
+ inputs: TrailingProseInputs,
203
+ ): FlowViolationResult {
204
+ const { chatId, state, flowRetries } = inputs;
205
+ const violation = detectFlowViolation({
206
+ trailingText: state.lastTrailingText,
207
+ turnTerminated: state.turnTerminated,
208
+ deliveredTextNorms: state.deliveredTextNorms,
209
+ toolCalls: state.toolCalls,
210
+ retried: flowRetries > 0,
211
+ retryCount: flowRetries,
212
+ maxRetries: FLOW_VIOLATION_MAX_RETRIES,
213
+ ...(inputs.reminder !== undefined ? { reminder: inputs.reminder } : {}),
214
+ });
215
+ if (!violation.violated) return violation;
216
+
217
+ recordFlowViolation(
218
+ chatId,
219
+ violation.shouldRetry ? "retried" : "cap_exhausted",
220
+ );
221
+ log(
222
+ "agent",
223
+ `[${chatId}] flow violation: ${violation.reason}. ${
224
+ violation.shouldRetry
225
+ ? "Re-prompting with reminder."
226
+ : `Retry cap (${FLOW_VIOLATION_MAX_RETRIES}) exhausted — accepting silent drop.`
227
+ }`,
228
+ );
229
+ return violation;
230
+ }
231
+
232
+ // ── Phase 4: result ─────────────────────────────────────────────────────────
233
+
234
+ export type FinishCallbackTurnInputs = {
235
+ chatId: string;
236
+ state: TurnUsageState &
237
+ Pick<StreamState, "turnTerminated" | "deliveredTextNorms">;
238
+ responseText: string;
239
+ durationMs: number;
240
+ setupMs: number;
241
+ turnMs: number;
242
+ /** `routeDelivery`'s decision, or a backend's own route (`silent`). */
243
+ delivery: { route: string; chars: number };
244
+ /** Extra `key=value` diagnostics appended to the summary line. */
245
+ detail?: string;
246
+ };
247
+
248
+ /**
249
+ * The end-of-turn log lines and trace for a callback-shaped handler, and
250
+ * the `QueryResult` it returns.
251
+ */
252
+ export function finishCallbackTurn(
253
+ inputs: FinishCallbackTurnInputs,
254
+ ): QueryResult {
255
+ const { chatId, state, responseText, durationMs, delivery } = inputs;
256
+ const usage = turnUsageSnapshot(state);
257
+ log(
258
+ "agent",
259
+ `[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
260
+ );
261
+ log(
262
+ "agent",
263
+ `[${chatId}] -> (${summarizeUsage(usage, {
264
+ durationMs,
265
+ toolCalls: state.toolCalls,
266
+ })} terminator=${state.turnTerminated ? "yes" : "no"} ` +
267
+ `delivered=${state.deliveredTextNorms.length} ` +
268
+ `respLen=${responseText.length} ` +
269
+ `setup=${inputs.setupMs}ms turn=${inputs.turnMs}ms` +
270
+ `${inputs.detail ? ` ${inputs.detail}` : ""})`,
271
+ );
272
+ traceMessage(chatId, "out", responseText, {
273
+ durationMs,
274
+ toolCalls: state.toolCalls,
275
+ });
276
+ return { text: responseText, durationMs, ...usage };
277
+ }
@@ -8,16 +8,18 @@ import type { ChildProcess } from "node:child_process";
8
8
  import {
9
9
  getTrigger,
10
10
  updateTrigger,
11
+ SHUTDOWN_KILL_ERROR,
11
12
  type Trigger,
12
13
  type TriggerStatus,
13
14
  } from "../../../storage/trigger-store.js";
14
- import { log, logError } from "../../../util/log.js";
15
+ import { log, logDebug, logError } from "../../../util/log.js";
15
16
  import { appendDailyLog } from "../../../storage/daily-log.js";
16
17
  import {
17
18
  children,
18
19
  timeouts,
19
20
  logStreams,
20
21
  lineBuffers,
22
+ lifecycle,
21
23
  wardened,
22
24
  SIGTERM_GRACE_MS,
23
25
  WARDEN_GRACE_SLACK_MS,
@@ -53,6 +55,9 @@ export function cancelTrigger(id: string): boolean {
53
55
 
54
56
  /** Kill all running children — called during shutdown. */
55
57
  export async function shutdownTriggers(): Promise<void> {
58
+ // Flag first, unconditionally: a child that exits on its own from here on
59
+ // must not dispatch a wake either — the backend pool is going away.
60
+ lifecycle.shuttingDown = true;
56
61
  if (children.size === 0) return;
57
62
  log("triggers", `Shutting down ${children.size} running trigger(s)`);
58
63
  const ids = Array.from(children.keys());
@@ -70,7 +75,7 @@ export async function shutdownTriggers(): Promise<void> {
70
75
  } else {
71
76
  updateTrigger(id, {
72
77
  status: "terminated",
73
- lastError: "Killed by Talon shutdown",
78
+ lastError: SHUTDOWN_KILL_ERROR,
74
79
  });
75
80
  }
76
81
  killChild(id, c);
@@ -188,6 +193,18 @@ export async function finalizeExit(
188
193
  status === "cancelled" ||
189
194
  status === "terminated"
190
195
  ) {
196
+ // During shutdown the backend pool is being torn down alongside us, so a
197
+ // dispatch here can only fail ("Backend role not bound"). Skip it; the
198
+ // record keeps status=terminated + SHUTDOWN_KILL_ERROR and no lastFireAt
199
+ // bump, which is exactly what resumeAfterRestart's late death notice
200
+ // keys on — the chat hears about it on the next boot instead.
201
+ if (lifecycle.shuttingDown) {
202
+ logDebug(
203
+ "triggers",
204
+ `Skipped ${status} wake for "${t.name}" [${id}] — shutting down; deferred to next boot`,
205
+ );
206
+ return;
207
+ }
191
208
  await fireWake(id, status, payload, /* terminal */ true);
192
209
  }
193
210
  }
@@ -9,6 +9,7 @@ import {
9
9
  getAllTriggers,
10
10
  updateTrigger,
11
11
  RESTART_KILL_ERROR,
12
+ SHUTDOWN_KILL_ERROR,
12
13
  type Trigger,
13
14
  } from "../../../storage/trigger-store.js";
14
15
  import { log, logError } from "../../../util/log.js";
@@ -50,19 +51,25 @@ export async function resumeAfterRestart(): Promise<void> {
50
51
  // Late death notice. Two cases earn one:
51
52
  // - never fired at all (the old rule) — the chat heard nothing
52
53
  // from this trigger, so its termination is news; and
53
- // - killed by THIS restart (recoverInterrupted stamped the
54
- // marker error) — even a multi-fire watcher that signalled
55
- // mid-run was still an active promise when the process died,
56
- // and without this wake the chat never learns its watcher is
57
- // gone. (Previously gated on lastFireAt === undefined alone,
58
- // which silently dropped exactly those watchers.)
54
+ // - killed by THIS restart — either the crash path
55
+ // (recoverInterrupted stamped RESTART_KILL_ERROR) or the clean
56
+ // path (shutdownTriggers stamped SHUTDOWN_KILL_ERROR and
57
+ // finalizeExit deliberately skipped the wake because the
58
+ // backend pool was already going away). Even a multi-fire
59
+ // watcher that signalled mid-run was still an active promise
60
+ // when the process died, and without this wake the chat never
61
+ // learns its watcher is gone. (Previously gated on
62
+ // lastFireAt === undefined alone, which silently dropped
63
+ // exactly those watchers.)
59
64
  // Triggers that exited on their own already fired their terminal
60
65
  // wake (lastFireAt set, no marker) — they stay silent here.
61
66
  if (
62
67
  t.status === "terminated" &&
63
68
  t.endedAt &&
64
69
  Date.now() - t.endedAt < 5 * 60_000 &&
65
- (t.lastFireAt === undefined || t.lastError === RESTART_KILL_ERROR)
70
+ (t.lastFireAt === undefined ||
71
+ t.lastError === RESTART_KILL_ERROR ||
72
+ t.lastError === SHUTDOWN_KILL_ERROR)
66
73
  ) {
67
74
  await fireWake(t.id, "terminated", t.lastError, /* terminal */ true);
68
75
  }
@@ -20,6 +20,14 @@ export type TriggerDeps = {
20
20
  /** Reassignable on a holder object so submodules see the injected deps. */
21
21
  export const depsHolder: { deps: TriggerDeps | null } = { deps: null };
22
22
 
23
+ /**
24
+ * Set by shutdownTriggers for the rest of the process lifetime. finalizeExit
25
+ * consults it so the children it kills don't dispatch wakes into a backend
26
+ * pool that is being torn down at the same time. initTriggers clears it so a
27
+ * fresh lifecycle (next boot, or the next test) starts clean.
28
+ */
29
+ export const lifecycle = { shuttingDown: false };
30
+
23
31
  /** Live child handles, keyed by trigger id. */
24
32
  export const children = new Map<string, ChildProcess>();
25
33
  export const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -43,6 +51,7 @@ export const WARDEN_GRACE_SLACK_MS = 2_000;
43
51
 
44
52
  export function initTriggers(d: TriggerDeps): void {
45
53
  depsHolder.deps = d;
54
+ lifecycle.shuttingDown = false;
46
55
  log("triggers", "Initialized");
47
56
  }
48
57