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.
- package/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- 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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|