talon-agent 3.33.4 → 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.
Files changed (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -241,6 +241,60 @@ interface SubscribeInputs {
241
241
  abortSignal: AbortSignal;
242
242
  }
243
243
 
244
+ /** The SSE wire format wraps every event in `{payload: {type, properties}}`. */
245
+ function unwrapSseEvent(
246
+ evt: unknown,
247
+ ): { type?: string; properties?: Record<string, unknown> } | undefined {
248
+ if (!evt || typeof evt !== "object") return undefined;
249
+ const payload =
250
+ "payload" in evt ? (evt as { payload?: unknown }).payload : evt;
251
+ if (!payload || typeof payload !== "object") return undefined;
252
+ return payload as { type?: string; properties?: Record<string, unknown> };
253
+ }
254
+
255
+ /**
256
+ * A `session.error` on the global stream: ignored when it belongs to
257
+ * another session (a heartbeat's error must not pollute this chat's log),
258
+ * stashed as the turn's synthetic error otherwise. Returns whether the
259
+ * event was ours — ours ends the turn, since the server produces nothing
260
+ * more for this prompt.
261
+ */
262
+ function noteSessionError(
263
+ props: Record<string, unknown>,
264
+ inputs: Pick<SubscribeInputs, "label" | "sessionId" | "state" | "chatId">,
265
+ ): "ours" | "other" {
266
+ const evtSessionID =
267
+ typeof props.sessionID === "string" ? props.sessionID : undefined;
268
+ if (evtSessionID && evtSessionID !== inputs.sessionId) return "other";
269
+ const errProp = props.error as
270
+ | { name?: string; message?: string; data?: Record<string, unknown> }
271
+ | undefined;
272
+ // MessageAbortedError is expected for an explicit user interrupt.
273
+ const isOurAbort =
274
+ inputs.state.turnTerminated &&
275
+ (errProp?.name === "MessageAbortedError" ||
276
+ /abort/i.test(errProp?.name ?? "") ||
277
+ /abort/i.test(errProp?.message ?? ""));
278
+ if (errProp && !isOurAbort) {
279
+ const detail = [
280
+ errProp.name && `name=${errProp.name}`,
281
+ errProp.message && `message=${errProp.message}`,
282
+ errProp.data && `data=${JSON.stringify(errProp.data)}`,
283
+ ]
284
+ .filter(Boolean)
285
+ .join(" ");
286
+ logWarn(
287
+ "agent",
288
+ `[${inputs.chatId}] ${inputs.label} session.error: ${detail}`,
289
+ );
290
+ // Stash the error message so the handler's delivery branch can
291
+ // surface it as `⚠️ <label>: <message>` instead of silence.
292
+ const msg = errProp.message ?? errProp.name;
293
+ if (msg) inputs.state.syntheticError = msg;
294
+ }
295
+ return "ours";
296
+ }
297
+
244
298
  /**
245
299
  * Subscribe to the server's global SSE event stream and translate relevant
246
300
  * events into stream-state mutations / callback firings. Only events scoped
@@ -271,61 +325,12 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
271
325
  try {
272
326
  for await (const evt of stream) {
273
327
  if (abortSignal.aborted) break;
274
- if (!evt || typeof evt !== "object") continue;
275
-
276
- // The SSE wire format wraps every event in `{payload: {type,
277
- // properties}}`. Unwrap here so type/properties land where the rest
278
- // of the loop expects them.
279
- const payload =
280
- "payload" in evt ? (evt as { payload?: unknown }).payload : evt;
281
- if (!payload || typeof payload !== "object") continue;
282
- const event = payload as {
283
- type?: string;
284
- properties?: Record<string, unknown>;
285
- };
328
+ const event = unwrapSseEvent(evt);
329
+ if (!event) continue;
286
330
 
287
- // session.error is observed here for logging; everything else goes
288
- // through the shared pure helper. The SSE stream is global, so
289
- // scope-filter to our own sessionId before attributing the error to
290
- // this chat — a heartbeat session.error would otherwise pollute the
291
- // chat's log.
292
331
  if (event.type === "session.error") {
293
- const props = event.properties ?? {};
294
- const evtSessionID =
295
- typeof props.sessionID === "string" ? props.sessionID : undefined;
296
- if (evtSessionID && evtSessionID !== sessionId) {
297
- continue;
298
- }
299
- const errProp = props.error as
300
- | {
301
- name?: string;
302
- message?: string;
303
- data?: Record<string, unknown>;
304
- }
305
- | undefined;
306
- // MessageAbortedError is expected for an explicit user interrupt.
307
- const isOurAbort =
308
- state.turnTerminated &&
309
- (errProp?.name === "MessageAbortedError" ||
310
- /abort/i.test(errProp?.name ?? "") ||
311
- /abort/i.test(errProp?.message ?? ""));
312
- if (errProp && !isOurAbort) {
313
- const detail = [
314
- errProp.name && `name=${errProp.name}`,
315
- errProp.message && `message=${errProp.message}`,
316
- errProp.data && `data=${JSON.stringify(errProp.data)}`,
317
- ]
318
- .filter(Boolean)
319
- .join(" ");
320
- logWarn("agent", `[${chatId}] ${label} session.error: ${detail}`);
321
- // Stash the error message so the handler's delivery branch can
322
- // surface it as `⚠️ <label>: <message>` instead of silence.
323
- const msg = errProp.message ?? errProp.name;
324
- if (msg) state.syntheticError = msg;
325
- }
326
- // session.error for OUR session ends the turn — the server isn't
327
- // going to produce more events for this prompt.
328
- return;
332
+ if (noteSessionError(event.properties ?? {}, inputs) === "ours") return;
333
+ continue;
329
334
  }
330
335
 
331
336
  const outcome = await processStreamEvent(event, {
@@ -338,7 +343,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
338
343
  onTextBlock,
339
344
  onToolUse,
340
345
  });
341
-
342
346
  if (outcome.kind === "terminator_fired") {
343
347
  // tool_calls counter increment happens per-tool inside
344
348
  // events.ts processPartUpdate. Don't double-count here.
@@ -347,7 +351,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
347
351
  // Delivery is already complete, so wait for natural idle instead.
348
352
  continue;
349
353
  }
350
-
351
354
  if (outcome.kind === "stop") {
352
355
  if (outcome.reason === "out_of_scope") continue;
353
356
  return; // turn.close or idle — stop iterating
@@ -249,10 +249,23 @@ function estimateTokens(text: string): number {
249
249
  return Math.ceil(text.length / 4);
250
250
  }
251
251
 
252
+ /**
253
+ * (label, model) pairs already warned about this process. A recurring
254
+ * caller (heartbeat, dream) builds the same prompt on every run, so the
255
+ * first warning says everything the rest would.
256
+ */
257
+ const warnedCacheMinimums = new Set<string>();
258
+
259
+ /** Test seam. */
260
+ export function resetCacheMinimumWarnings(): void {
261
+ warnedCacheMinimums.clear();
262
+ }
263
+
252
264
  /**
253
265
  * Warn when a prompt is too small to be cacheable on its model. No-op when
254
266
  * the model's floor is unknown or the prompt clears it. `label` names the
255
- * caller (e.g. `"dream"`) so the warning points somewhere.
267
+ * caller (e.g. `"dream"`) so the warning points somewhere. Warns once per
268
+ * (label, model) per process.
256
269
  */
257
270
  export function warnIfBelowCacheMinimum(
258
271
  label: string,
@@ -263,6 +276,9 @@ export function warnIfBelowCacheMinimum(
263
276
  if (min === undefined) return;
264
277
  const estimated = estimateTokens(prompt);
265
278
  if (estimated >= min) return;
279
+ const key = `${label}\0${model}`;
280
+ if (warnedCacheMinimums.has(key)) return;
281
+ warnedCacheMinimums.add(key);
266
282
  logWarn(
267
283
  "agent",
268
284
  `[${label}] prompt ~${estimated} tokens is below ${model}'s ${min}-token ` +
@@ -23,6 +23,7 @@ import {
23
23
  import { classify } from "../../core/errors.js";
24
24
  import type { ChatRunParams } from "../../core/agent-runtime/capabilities.js";
25
25
  import type { QueryParams, QueryResult } from "./handler-types.js";
26
+ import { buildResultEvents } from "./result-events.js";
26
27
 
27
28
  const SENTINEL = Symbol("handler-to-events:sentinel");
28
29
 
@@ -205,21 +206,15 @@ export async function* handlerToEvents(
205
206
  return;
206
207
  }
207
208
 
208
- const usage = {
209
- inputTokens: result.inputTokens,
210
- outputTokens: result.outputTokens,
211
- cacheRead: result.cacheRead,
212
- cacheWrite: result.cacheWrite,
213
- modelId: params.model.id,
214
- };
215
- yield { type: "usage", usage };
216
- yield {
217
- type: "completed",
218
- result: {
219
- text: result.text,
220
- durationMs: result.durationMs,
221
- usage,
222
- modelId: params.model.id,
209
+ yield* buildResultEvents({
210
+ text: result.text,
211
+ durationMs: result.durationMs,
212
+ usage: {
213
+ inputTokens: result.inputTokens,
214
+ outputTokens: result.outputTokens,
215
+ cacheRead: result.cacheRead,
216
+ cacheWrite: result.cacheWrite,
223
217
  },
224
- };
218
+ modelId: params.model.id,
219
+ });
225
220
  }
@@ -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
+ }