talon-agent 3.34.0 → 3.35.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.
- package/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +6 -4
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +290 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +76 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/lifecycle.ts +37 -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/server-bindings.ts +12 -0
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- 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 +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- 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/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- 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/tools/history.ts +10 -1
- package/src/core/weaver/shuttle.ts +4 -0
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/storage/whatsapp-messages.ts +51 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -69,8 +69,22 @@ export interface RunRemoteTurnInputs {
|
|
|
69
69
|
onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
|
|
70
70
|
onTextBlock?: (text: string) => Promise<void>;
|
|
71
71
|
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Fires when the backend is stopped underneath the turn (hot-swap or
|
|
74
|
+
* shutdown). Its `reason` is the error the turn rejects with.
|
|
75
|
+
*/
|
|
76
|
+
stopSignal?: AbortSignal;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
/** Poll cadence for the headless question/permission watchdog. */
|
|
80
|
+
const WATCHDOG_INTERVAL_MS = 350;
|
|
81
|
+
/**
|
|
82
|
+
* Consecutive poll failures before the watchdog gives up. A server that
|
|
83
|
+
* refuses three polls in a row is gone; polling on would only log
|
|
84
|
+
* `fetch failed` every 350ms until the turn is torn down.
|
|
85
|
+
*/
|
|
86
|
+
const WATCHDOG_MAX_CONSECUTIVE_FAILURES = 3;
|
|
87
|
+
|
|
74
88
|
/**
|
|
75
89
|
* Run one turn end-to-end.
|
|
76
90
|
*
|
|
@@ -105,6 +119,7 @@ export async function runRemoteTurn(
|
|
|
105
119
|
onStreamDelta,
|
|
106
120
|
onTextBlock,
|
|
107
121
|
onToolUse,
|
|
122
|
+
stopSignal,
|
|
108
123
|
} = inputs;
|
|
109
124
|
const sessionClient = oc as unknown as RemoteSessionClient;
|
|
110
125
|
|
|
@@ -112,6 +127,10 @@ export async function runRemoteTurn(
|
|
|
112
127
|
// `message.part.updated` events can fire immediately after promptAsync
|
|
113
128
|
// returns, so the iterator must already be alive.
|
|
114
129
|
const sseAbort = new AbortController();
|
|
130
|
+
// A backend stop ends the SSE loop and the watchdog the same way a
|
|
131
|
+
// finished turn does; the turn itself rejects via `rejectWhenStopped`.
|
|
132
|
+
const onStop = (): void => sseAbort.abort();
|
|
133
|
+
stopSignal?.addEventListener("abort", onStop, { once: true });
|
|
115
134
|
const sseDone = subscribeToTurnEvents({
|
|
116
135
|
label,
|
|
117
136
|
oc,
|
|
@@ -144,35 +163,30 @@ export async function runRemoteTurn(
|
|
|
144
163
|
label,
|
|
145
164
|
),
|
|
146
165
|
]);
|
|
147
|
-
const questionWatchdog = (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
logWarn(
|
|
153
|
-
"agent",
|
|
154
|
-
`[${chatId}] question watchdog failed: ${errMsg(err)}`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
await sleep(350, sseAbort.signal);
|
|
158
|
-
}
|
|
159
|
-
})();
|
|
166
|
+
const questionWatchdog = runQuestionWatchdog({
|
|
167
|
+
settlePending,
|
|
168
|
+
signal: sseAbort.signal,
|
|
169
|
+
chatId,
|
|
170
|
+
});
|
|
160
171
|
|
|
161
172
|
try {
|
|
162
173
|
// Fire and forget — promptAsync returns immediately. The await below
|
|
163
174
|
// is on the SSE close event. Per-prompt overrides hide sibling chats'
|
|
164
175
|
// MCP tools while session permissions independently deny execution.
|
|
165
176
|
await awaitRemoteTurn(
|
|
166
|
-
(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
177
|
+
rejectWhenStopped(
|
|
178
|
+
(async () => {
|
|
179
|
+
await oc.session.promptAsync({
|
|
180
|
+
sessionID: sessionId,
|
|
181
|
+
parts: [{ type: "text", text: prompt }],
|
|
182
|
+
model: { providerID, modelID },
|
|
183
|
+
system: systemPrompt,
|
|
184
|
+
...(toolOverrides ? { tools: toolOverrides } : {}),
|
|
185
|
+
});
|
|
186
|
+
await sseDone;
|
|
187
|
+
})(),
|
|
188
|
+
stopSignal,
|
|
189
|
+
),
|
|
176
190
|
{ client: oc, sessionId, chatId, label },
|
|
177
191
|
);
|
|
178
192
|
|
|
@@ -214,6 +228,7 @@ export async function runRemoteTurn(
|
|
|
214
228
|
}
|
|
215
229
|
throw err;
|
|
216
230
|
} finally {
|
|
231
|
+
stopSignal?.removeEventListener("abort", onStop);
|
|
217
232
|
sseAbort.abort();
|
|
218
233
|
// A dead SSE socket may ignore the local abort flag until another event
|
|
219
234
|
// arrives. Bound cleanup so a timed-out turn cannot wedge its caller in
|
|
@@ -228,6 +243,61 @@ export async function runRemoteTurn(
|
|
|
228
243
|
}
|
|
229
244
|
}
|
|
230
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Race the turn against a backend stop. A stop rejects with the signal's
|
|
248
|
+
* `reason` (a `RemoteServerStoppedError`) the moment it fires, instead of
|
|
249
|
+
* waiting for an SSE socket that will never close on its own.
|
|
250
|
+
*/
|
|
251
|
+
function rejectWhenStopped<T>(
|
|
252
|
+
turn: Promise<T>,
|
|
253
|
+
stopSignal: AbortSignal | undefined,
|
|
254
|
+
): Promise<T> {
|
|
255
|
+
if (!stopSignal) return turn;
|
|
256
|
+
if (stopSignal.aborted) return Promise.reject(stopSignal.reason);
|
|
257
|
+
return new Promise<T>((resolve, reject) => {
|
|
258
|
+
const onAbort = (): void => reject(stopSignal.reason);
|
|
259
|
+
stopSignal.addEventListener("abort", onAbort, { once: true });
|
|
260
|
+
turn.then(resolve, reject).finally(() => {
|
|
261
|
+
stopSignal.removeEventListener("abort", onAbort);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Poll the pending question/permission lists until the turn's signal
|
|
268
|
+
* fires. Gives up after {@link WATCHDOG_MAX_CONSECUTIVE_FAILURES} failed
|
|
269
|
+
* polls in a row — a server that stopped answering is not coming back
|
|
270
|
+
* within this turn, and the finally-block settle runs once more anyway.
|
|
271
|
+
*/
|
|
272
|
+
async function runQuestionWatchdog(inputs: {
|
|
273
|
+
settlePending: () => Promise<unknown>;
|
|
274
|
+
signal: AbortSignal;
|
|
275
|
+
chatId: string;
|
|
276
|
+
}): Promise<void> {
|
|
277
|
+
const { settlePending, signal, chatId } = inputs;
|
|
278
|
+
let consecutiveFailures = 0;
|
|
279
|
+
while (!signal.aborted) {
|
|
280
|
+
try {
|
|
281
|
+
await settlePending();
|
|
282
|
+
consecutiveFailures = 0;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
consecutiveFailures += 1;
|
|
285
|
+
logWarn(
|
|
286
|
+
"agent",
|
|
287
|
+
`[${chatId}] question watchdog failed (${consecutiveFailures}/${WATCHDOG_MAX_CONSECUTIVE_FAILURES}): ${errMsg(err)}`,
|
|
288
|
+
);
|
|
289
|
+
if (consecutiveFailures >= WATCHDOG_MAX_CONSECUTIVE_FAILURES) {
|
|
290
|
+
logWarn(
|
|
291
|
+
"agent",
|
|
292
|
+
`[${chatId}] question watchdog stopped: server unreachable`,
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
await sleep(WATCHDOG_INTERVAL_MS, signal);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
231
301
|
interface SubscribeInputs {
|
|
232
302
|
label: string;
|
|
233
303
|
oc: RemoteTurnClient;
|
|
@@ -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
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
|
|
|
28
28
|
import { initCron } from "./core/background/cron.js";
|
|
29
29
|
import { initPlanAlerts } from "./core/background/plan-alerts.js";
|
|
30
30
|
import { setAdminNotifier } from "./core/notify.js";
|
|
31
|
+
import { startAuthExpiryMonitor } from "./core/auth/expiry-monitor.js";
|
|
31
32
|
import {
|
|
32
33
|
initTriggers,
|
|
33
34
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
@@ -486,16 +487,7 @@ export async function initBackendAndDispatcher(
|
|
|
486
487
|
// first consumer is WhatsApp pairing: codes must travel over a LIVE
|
|
487
488
|
// frontend, not the dead one's log). Same delivery route as the plan
|
|
488
489
|
// alerts above.
|
|
489
|
-
|
|
490
|
-
const adminChatId = config.adminUserId;
|
|
491
|
-
setAdminNotifier(async (text: string) =>
|
|
492
|
-
resolveFrontendByNumericId(
|
|
493
|
-
adminChatId,
|
|
494
|
-
String(adminChatId),
|
|
495
|
-
frontends,
|
|
496
|
-
).sendMessage(adminChatId, text),
|
|
497
|
-
);
|
|
498
|
-
}
|
|
490
|
+
wireAdminNotifier(config, frontends);
|
|
499
491
|
|
|
500
492
|
// Soul — initialize the identity kernel singleton from config so the prompt
|
|
501
493
|
// injection / dream hooks see the right enabled state. Off by default; a
|
|
@@ -586,3 +578,25 @@ export async function initBackendAndDispatcher(
|
|
|
586
578
|
|
|
587
579
|
return { backend };
|
|
588
580
|
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Wire the admin notification seam to the admin's frontend, and start the
|
|
584
|
+
* login-expiry monitor that rides it: the CLIs' "N days to log in again"
|
|
585
|
+
* banner, delivered to the admin instead of a terminal nobody is watching
|
|
586
|
+
* (/auth then completes the sign-in from the chat).
|
|
587
|
+
*/
|
|
588
|
+
function wireAdminNotifier(
|
|
589
|
+
config: TalonConfig,
|
|
590
|
+
frontends: Parameters<typeof resolveFrontendByNumericId>[2],
|
|
591
|
+
): void {
|
|
592
|
+
if (!config.adminUserId) return;
|
|
593
|
+
const adminChatId = config.adminUserId;
|
|
594
|
+
setAdminNotifier(async (text: string) =>
|
|
595
|
+
resolveFrontendByNumericId(
|
|
596
|
+
adminChatId,
|
|
597
|
+
String(adminChatId),
|
|
598
|
+
frontends,
|
|
599
|
+
).sendMessage(adminChatId, text),
|
|
600
|
+
);
|
|
601
|
+
startAuthExpiryMonitor();
|
|
602
|
+
}
|