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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex main message handler.
|
|
3
3
|
*
|
|
4
|
-
* Orchestrates the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Orchestrates the turn on top of `@openai/codex-sdk`'s `Thread.runStreamed`.
|
|
5
|
+
* Codex-specific bits: reading the `runStreamed` event stream, translating
|
|
6
|
+
* items into shared stream state (see `events.ts`), resuming via
|
|
7
|
+
* `codex.resumeThread(id)`, the rollout-JSONL live/settle usage accounting
|
|
8
|
+
* (`rollout-accounting.ts`), and the ChatGPT-OAuth model-mismatch recovery
|
|
9
|
+
* ladder. The post-stream phases are the shared ones in
|
|
10
|
+
* `backend/shared/turn-phases.ts`.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { Thread, Usage } from "@openai/codex-sdk";
|
|
@@ -14,9 +15,6 @@ import type { QueryParams, QueryResult } from "../../shared/handler-types.js";
|
|
|
14
15
|
import {
|
|
15
16
|
getSession,
|
|
16
17
|
incrementTurns,
|
|
17
|
-
recordUsage,
|
|
18
|
-
setSessionName,
|
|
19
|
-
setSessionId,
|
|
20
18
|
resetSession,
|
|
21
19
|
} from "../../../storage/sessions.js";
|
|
22
20
|
import { getChatSettings } from "../../../storage/chat-settings.js";
|
|
@@ -26,20 +24,19 @@ import { incrementCounter } from "../../../storage/metrics.js";
|
|
|
26
24
|
|
|
27
25
|
import {
|
|
28
26
|
createStreamState,
|
|
29
|
-
recordTokens,
|
|
30
27
|
finalizeResponseText,
|
|
31
28
|
formatUserPrompt,
|
|
32
29
|
prepareSystemPrompt,
|
|
33
|
-
extractSessionName,
|
|
34
|
-
summarizeUsage,
|
|
35
30
|
routeDelivery,
|
|
36
31
|
buildDeliveryFailureReminder,
|
|
37
32
|
TextBlockDeliveryError,
|
|
38
33
|
applyRetryDecision,
|
|
39
|
-
recordTurnMetrics,
|
|
40
|
-
recordFailedTurnAccounting,
|
|
41
|
-
pushLiveUsage,
|
|
42
34
|
registerTurnInterrupt,
|
|
35
|
+
accountTurn,
|
|
36
|
+
accountFailedTurn,
|
|
37
|
+
nameSessionFromFirstMessage,
|
|
38
|
+
finishCallbackTurn,
|
|
39
|
+
type StreamState,
|
|
43
40
|
} from "../../shared/index.js";
|
|
44
41
|
|
|
45
42
|
import {
|
|
@@ -47,7 +44,6 @@ import {
|
|
|
47
44
|
CODEX_DEFAULT_MODEL,
|
|
48
45
|
CODEX_CHATGPT_DEFAULT_MODEL,
|
|
49
46
|
CODEX_THREAD_PERMISSIONS,
|
|
50
|
-
CODEX_LIVE_POLL_INTERVAL_MS,
|
|
51
47
|
} from "../constants.js";
|
|
52
48
|
import {
|
|
53
49
|
frontendsForChat,
|
|
@@ -67,16 +63,24 @@ import {
|
|
|
67
63
|
import { supportsReasoningLevel } from "../../../core/models/reasoning-levels.js";
|
|
68
64
|
import { toCodexReasoningEffort } from "../effort.js";
|
|
69
65
|
import { markOAuthIncompat } from "../oauth-incompat.js";
|
|
70
|
-
import { readLastRolloutSnapshot } from "../token-usage.js";
|
|
71
66
|
import { activeAborts } from "./state.js";
|
|
72
67
|
import { CodexUsageExhaustedError, probeUsageExhausted } from "./usage.js";
|
|
73
|
-
import { handleEvent } from "./events.js";
|
|
68
|
+
import { handleEvent, type HandleEventContext } from "./events.js";
|
|
69
|
+
import {
|
|
70
|
+
createRolloutAccounting,
|
|
71
|
+
type RolloutAccounting,
|
|
72
|
+
} from "./rollout-accounting.js";
|
|
74
73
|
|
|
75
74
|
// ── Local utility ───────────────────────────────────────────────────────────
|
|
76
75
|
|
|
77
76
|
const errMsg = (e: unknown): string =>
|
|
78
77
|
e instanceof Error ? e.message : String(e);
|
|
79
78
|
|
|
79
|
+
/** The expected close on `end_turn` / a user interrupt: abort after the terminator. */
|
|
80
|
+
const isTerminatorAbort = (state: StreamState, err: unknown): boolean =>
|
|
81
|
+
state.turnTerminated &&
|
|
82
|
+
(errMsg(err) === "AbortError" || /abort/i.test(errMsg(err)));
|
|
83
|
+
|
|
80
84
|
/**
|
|
81
85
|
* One-shot ChatGPT-OAuth model-mismatch recovery.
|
|
82
86
|
*
|
|
@@ -183,52 +187,24 @@ async function maybeFallbackForChatGptMismatch(
|
|
|
183
187
|
return await handleMessage({ ...params, model: fallbackModel }, true);
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
// ──
|
|
187
|
-
|
|
188
|
-
export async function handleMessage(
|
|
189
|
-
params: QueryParams,
|
|
190
|
-
_retried = false,
|
|
191
|
-
): Promise<QueryResult> {
|
|
192
|
-
const state = getState();
|
|
193
|
-
const config = state.config;
|
|
194
|
-
if (!config) {
|
|
195
|
-
throw new Error("Codex agent not initialized");
|
|
196
|
-
}
|
|
197
|
-
const codex = ensureCodex(params.chatId);
|
|
198
|
-
|
|
199
|
-
const {
|
|
200
|
-
chatId,
|
|
201
|
-
text,
|
|
202
|
-
senderName,
|
|
203
|
-
senderHandle,
|
|
204
|
-
isGroup,
|
|
205
|
-
messageId,
|
|
206
|
-
onTextBlock,
|
|
207
|
-
onToolUse,
|
|
208
|
-
onToolStart,
|
|
209
|
-
onToolEnd,
|
|
210
|
-
} = params;
|
|
211
|
-
const t0 = Date.now();
|
|
212
|
-
const session = getSession(chatId);
|
|
213
|
-
const previousTurns = session.turns;
|
|
190
|
+
// ── Model resolution ────────────────────────────────────────────────────────
|
|
214
191
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Codex accepts arbitrary model strings; we pass through whatever the
|
|
194
|
+
* caller resolved (chat-settings → config) and fall back to the auth-aware
|
|
195
|
+
* default: `gpt-5-codex` when an API key is present, `gpt-5.5` when only
|
|
196
|
+
* ChatGPT OAuth is configured (because `gpt-5-codex` is rejected with a
|
|
197
|
+
* 400 on ChatGPT-mode accounts). A model known to be OAuth-incompat on a
|
|
198
|
+
* ChatGPT-OAuth account is swapped pre-emptively rather than letting the
|
|
199
|
+
* first turn fail.
|
|
200
|
+
*/
|
|
201
|
+
function resolveCodexModel(chatId: string, requested: string | undefined) {
|
|
222
202
|
const authInfo = getCodexAuthInfo();
|
|
223
203
|
const authAwareDefault =
|
|
224
204
|
authInfo?.mode === "chatgpt"
|
|
225
205
|
? CODEX_CHATGPT_DEFAULT_MODEL
|
|
226
206
|
: CODEX_DEFAULT_MODEL;
|
|
227
|
-
const requestedModel =
|
|
228
|
-
params.model ?? chatSettings.model ?? config.model ?? authAwareDefault;
|
|
229
|
-
// If the resolved model is known OAuth-incompat AND we're on
|
|
230
|
-
// ChatGPT OAuth, pre-emptively swap to the chatgpt-compatible
|
|
231
|
-
// fallback rather than letting the first turn fail.
|
|
207
|
+
const requestedModel = requested ?? authAwareDefault;
|
|
232
208
|
let activeModel = requestedModel;
|
|
233
209
|
if (authInfo?.mode === "chatgpt" && isCodexOAuthIncompat(requestedModel)) {
|
|
234
210
|
const fallback =
|
|
@@ -246,6 +222,188 @@ export async function handleMessage(
|
|
|
246
222
|
}
|
|
247
223
|
}
|
|
248
224
|
log("agent", `[${chatId}] Codex model resolved: ${activeModel}`);
|
|
225
|
+
return activeModel;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Availability check (does this model offer the level?) then vocabulary
|
|
230
|
+
* translation (can Codex express it?) — the latter is shared with the
|
|
231
|
+
* one-shot path via `toCodexReasoningEffort` so the two can't drift.
|
|
232
|
+
*/
|
|
233
|
+
async function buildThreadOptions(
|
|
234
|
+
activeModel: string,
|
|
235
|
+
requestedEffort: ReturnType<typeof getChatSettings>["effort"],
|
|
236
|
+
) {
|
|
237
|
+
const activeModelInfo = await getModelInfo(activeModel).catch(
|
|
238
|
+
() => undefined,
|
|
239
|
+
);
|
|
240
|
+
const supportedReasoningLevels =
|
|
241
|
+
activeModelInfo?.supportedReasoningLevels ?? [];
|
|
242
|
+
const modelReasoningEffort =
|
|
243
|
+
requestedEffort &&
|
|
244
|
+
supportsReasoningLevel(requestedEffort, supportedReasoningLevels)
|
|
245
|
+
? toCodexReasoningEffort(requestedEffort)
|
|
246
|
+
: undefined;
|
|
247
|
+
return {
|
|
248
|
+
activeModelInfo,
|
|
249
|
+
threadOptions: {
|
|
250
|
+
model: activeModel,
|
|
251
|
+
skipGitRepoCheck: true,
|
|
252
|
+
...(modelReasoningEffort ? { modelReasoningEffort } : {}),
|
|
253
|
+
...CODEX_THREAD_PERMISSIONS,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Stream loop ─────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function createEventContext(
|
|
261
|
+
params: QueryParams,
|
|
262
|
+
state: StreamState,
|
|
263
|
+
): HandleEventContext {
|
|
264
|
+
return {
|
|
265
|
+
state,
|
|
266
|
+
seenToolCallIds: new Set<string>(),
|
|
267
|
+
startedToolIds: new Set<string>(),
|
|
268
|
+
codexToolMetrics: { count: 0 },
|
|
269
|
+
onTextBlock: params.onTextBlock,
|
|
270
|
+
onToolUse: params.onToolUse,
|
|
271
|
+
onToolStart: params.onToolStart,
|
|
272
|
+
onToolEnd: params.onToolEnd,
|
|
273
|
+
chatId: params.chatId,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** What the stream reported, readable mid-loop by the failure path too. */
|
|
278
|
+
type CodexStreamOutcome = {
|
|
279
|
+
usage: Usage | null;
|
|
280
|
+
turnFailedError: string | undefined;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
async function driveCodexStream(inputs: {
|
|
284
|
+
thread: Thread;
|
|
285
|
+
inputText: string;
|
|
286
|
+
abortController: AbortController;
|
|
287
|
+
eventContext: HandleEventContext;
|
|
288
|
+
rollout: RolloutAccounting;
|
|
289
|
+
outcome: CodexStreamOutcome;
|
|
290
|
+
}): Promise<void> {
|
|
291
|
+
const { abortController, eventContext, rollout, outcome } = inputs;
|
|
292
|
+
const { state, chatId } = eventContext;
|
|
293
|
+
const { events } = await inputs.thread.runStreamed(inputs.inputText, {
|
|
294
|
+
signal: abortController.signal,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
for await (const event of events) {
|
|
298
|
+
if (abortController.signal.aborted && !state.turnTerminated) break;
|
|
299
|
+
handleEvent(event, eventContext);
|
|
300
|
+
|
|
301
|
+
if (event.type === "thread.started") {
|
|
302
|
+
rollout.threadId = event.thread_id;
|
|
303
|
+
} else if (event.type === "turn.completed") {
|
|
304
|
+
outcome.usage = event.usage;
|
|
305
|
+
} else if (event.type === "turn.failed") {
|
|
306
|
+
outcome.turnFailedError = event.error.message;
|
|
307
|
+
} else if (event.type === "error") {
|
|
308
|
+
outcome.turnFailedError = event.message;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
rollout.pollLive();
|
|
312
|
+
|
|
313
|
+
// Terminator-driven abort: a delivery tool already shipped the
|
|
314
|
+
// reply via the bridge. Cancel further model generation to skip
|
|
315
|
+
// the wrap-up round-trip Codex would otherwise burn.
|
|
316
|
+
if (state.turnTerminated && !abortController.signal.aborted) {
|
|
317
|
+
log("agent", `[${chatId}] terminator fired — aborting Codex turn`);
|
|
318
|
+
try {
|
|
319
|
+
abortController.abort();
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logWarn("agent", `[${chatId}] abort failed: ${errMsg(err)}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The failure ladder: ChatGPT-OAuth mismatch recovery, then the shared
|
|
329
|
+
* retry decision, then terminal-failure accounting and the throw.
|
|
330
|
+
*/
|
|
331
|
+
async function recoverCodexFailure(inputs: {
|
|
332
|
+
err: unknown;
|
|
333
|
+
params: QueryParams;
|
|
334
|
+
retried: boolean;
|
|
335
|
+
activeModel: string;
|
|
336
|
+
state: StreamState;
|
|
337
|
+
rollout: RolloutAccounting;
|
|
338
|
+
outcome: CodexStreamOutcome;
|
|
339
|
+
toolCalls: number;
|
|
340
|
+
t0: number;
|
|
341
|
+
}): Promise<QueryResult> {
|
|
342
|
+
const { err, params, retried, activeModel, state, rollout, outcome } = inputs;
|
|
343
|
+
const { chatId } = params;
|
|
344
|
+
|
|
345
|
+
// Check both the captured event-stream message and the thrown error —
|
|
346
|
+
// Codex SDK surfaces it via both channels. Only use the thread ID from
|
|
347
|
+
// this run.
|
|
348
|
+
const fallback = await maybeFallbackForChatGptMismatch(
|
|
349
|
+
`${outcome.turnFailedError ?? ""} ${errMsg(err)}`,
|
|
350
|
+
activeModel,
|
|
351
|
+
params,
|
|
352
|
+
retried,
|
|
353
|
+
chatId,
|
|
354
|
+
rollout.threadId,
|
|
355
|
+
);
|
|
356
|
+
if (fallback) return fallback;
|
|
357
|
+
|
|
358
|
+
const decision = await applyRetryDecision({
|
|
359
|
+
err,
|
|
360
|
+
chatId,
|
|
361
|
+
activeModel,
|
|
362
|
+
retried,
|
|
363
|
+
params,
|
|
364
|
+
recurseWithRetried: (p) => handleMessage(p, true),
|
|
365
|
+
backendLabel: "Codex",
|
|
366
|
+
resetNoun: "thread",
|
|
367
|
+
});
|
|
368
|
+
if (decision.retry) return decision.retry;
|
|
369
|
+
|
|
370
|
+
// Terminal failure — recover whatever usage the rollout recorded
|
|
371
|
+
// before the turn died, then account for it.
|
|
372
|
+
await rollout.settle(outcome.usage).catch(() => {});
|
|
373
|
+
accountFailedTurn({
|
|
374
|
+
backend: "codex",
|
|
375
|
+
chatId,
|
|
376
|
+
state,
|
|
377
|
+
durationMs: Date.now() - inputs.t0,
|
|
378
|
+
model: activeModel,
|
|
379
|
+
toolCalls: inputs.toolCalls,
|
|
380
|
+
});
|
|
381
|
+
logError("agent", `[${chatId}] Codex error: ${decision.classified.message}`);
|
|
382
|
+
throw decision.classified;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Main handler ────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
export async function handleMessage(
|
|
388
|
+
params: QueryParams,
|
|
389
|
+
_retried = false,
|
|
390
|
+
): Promise<QueryResult> {
|
|
391
|
+
const config = getState().config;
|
|
392
|
+
if (!config) {
|
|
393
|
+
throw new Error("Codex agent not initialized");
|
|
394
|
+
}
|
|
395
|
+
const codex = ensureCodex(params.chatId);
|
|
396
|
+
|
|
397
|
+
const { chatId, text, senderName, senderHandle, isGroup, messageId } = params;
|
|
398
|
+
const t0 = Date.now();
|
|
399
|
+
const session = getSession(chatId);
|
|
400
|
+
const previousTurns = session.turns;
|
|
401
|
+
|
|
402
|
+
const chatSettings = getChatSettings(chatId);
|
|
403
|
+
const activeModel = resolveCodexModel(
|
|
404
|
+
chatId,
|
|
405
|
+
params.model ?? chatSettings.model ?? config.model,
|
|
406
|
+
);
|
|
249
407
|
|
|
250
408
|
// Per-session frozen prompt + Codex-specific delivery suffix.
|
|
251
409
|
const { text: systemPrompt } = prepareSystemPrompt({
|
|
@@ -270,49 +428,22 @@ export async function handleMessage(
|
|
|
270
428
|
log("agent", `[${chatId}] <- (${text.length} chars)`);
|
|
271
429
|
traceMessage(chatId, "in", text, { senderName, isGroup });
|
|
272
430
|
|
|
273
|
-
// Resume
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
() => undefined,
|
|
431
|
+
// Resume the stored Codex thread (persisted under `~/.codex/sessions/`).
|
|
432
|
+
const { activeModelInfo, threadOptions } = await buildThreadOptions(
|
|
433
|
+
activeModel,
|
|
434
|
+
chatSettings.effort,
|
|
278
435
|
);
|
|
279
|
-
const supportedReasoningLevels =
|
|
280
|
-
activeModelInfo?.supportedReasoningLevels ?? [];
|
|
281
|
-
const requestedEffort = chatSettings.effort;
|
|
282
|
-
// Availability check (does this model offer the level?) then vocabulary
|
|
283
|
-
// translation (can Codex express it?) — the latter is shared with the
|
|
284
|
-
// one-shot path via `toCodexReasoningEffort` so the two can't drift.
|
|
285
|
-
const modelReasoningEffort =
|
|
286
|
-
requestedEffort &&
|
|
287
|
-
supportsReasoningLevel(requestedEffort, supportedReasoningLevels)
|
|
288
|
-
? toCodexReasoningEffort(requestedEffort)
|
|
289
|
-
: undefined;
|
|
290
|
-
const threadOptions = {
|
|
291
|
-
model: activeModel,
|
|
292
|
-
skipGitRepoCheck: true,
|
|
293
|
-
...(modelReasoningEffort ? { modelReasoningEffort } : {}),
|
|
294
|
-
...CODEX_THREAD_PERMISSIONS,
|
|
295
|
-
};
|
|
296
436
|
const thread: Thread = session.sessionId
|
|
297
437
|
? codex.resumeThread(session.sessionId, threadOptions)
|
|
298
438
|
: codex.startThread(threadOptions);
|
|
299
439
|
|
|
300
|
-
//
|
|
301
|
-
// BEFORE the turn runs. `total_token_usage` accumulates across the
|
|
302
|
-
// whole session file, so this turn's usage = post-turn totals minus
|
|
303
|
-
// this baseline. Fresh threads have no rollout yet → zero baseline.
|
|
304
|
-
// `null` = resumed thread whose baseline couldn't be read.
|
|
305
|
-
const baselineTotals = session.sessionId
|
|
306
|
-
? ((await readLastRolloutSnapshot(session.sessionId).catch(() => null))
|
|
307
|
-
?.totals ?? null)
|
|
308
|
-
: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
|
309
|
-
|
|
310
|
-
// Bind the stream state to the chat so token mutators mirror counts
|
|
311
|
-
// into the live-turn overlay — /status updates while the turn runs.
|
|
440
|
+
// Chat-bound state mirrors counts into the live-turn overlay.
|
|
312
441
|
const streamState = createStreamState(chatId);
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
442
|
+
const rollout = await createRolloutAccounting({
|
|
443
|
+
state: streamState,
|
|
444
|
+
sessionId: session.sessionId,
|
|
445
|
+
});
|
|
446
|
+
const eventContext = createEventContext(params, streamState);
|
|
316
447
|
const abortController = new AbortController();
|
|
317
448
|
activeAborts.set(chatId, abortController);
|
|
318
449
|
// A user interrupt is a synthetic turn terminator: marking the flag
|
|
@@ -324,217 +455,42 @@ export async function handleMessage(
|
|
|
324
455
|
abortController.abort();
|
|
325
456
|
});
|
|
326
457
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// Throttled mid-turn rollout poll. The Codex CLI appends a
|
|
332
|
-
// `token_count` event to the rollout JSONL after every API call, so
|
|
333
|
-
// tailing it during the turn gives live context-fill / token / API-call
|
|
334
|
-
// stats long before `turn.completed`. Fire-and-forget with an in-flight
|
|
335
|
-
// guard — never blocks the event loop, never throws.
|
|
336
|
-
let rolloutPollInFlight = false;
|
|
337
|
-
let lastRolloutPollAt = 0;
|
|
338
|
-
const pollRolloutForLiveStats = () => {
|
|
339
|
-
if (!resolvedThreadId || rolloutPollInFlight) return;
|
|
340
|
-
const now = Date.now();
|
|
341
|
-
if (now - lastRolloutPollAt < CODEX_LIVE_POLL_INTERVAL_MS) return;
|
|
342
|
-
rolloutPollInFlight = true;
|
|
343
|
-
lastRolloutPollAt = now;
|
|
344
|
-
readLastRolloutSnapshot(resolvedThreadId)
|
|
345
|
-
.then((snap) => {
|
|
346
|
-
if (!snap) return;
|
|
347
|
-
if (snap.usage) {
|
|
348
|
-
streamState.contextTokens = snap.usage.contextTokens;
|
|
349
|
-
if (snap.usage.contextWindow) {
|
|
350
|
-
streamState.contextWindow = snap.usage.contextWindow;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
if (typeof snap.numApiCalls === "number") {
|
|
354
|
-
streamState.numApiCalls = snap.numApiCalls;
|
|
355
|
-
}
|
|
356
|
-
// Same delta-vs-baseline math as the post-loop accounting; the
|
|
357
|
-
// final pass recomputes and overwrites, so a torn mid-turn read
|
|
358
|
-
// can't corrupt the committed numbers.
|
|
359
|
-
if (snap.totals && baselineTotals) {
|
|
360
|
-
streamState.sdkInputTokens = Math.max(
|
|
361
|
-
0,
|
|
362
|
-
snap.totals.inputTokens - baselineTotals.inputTokens,
|
|
363
|
-
);
|
|
364
|
-
streamState.sdkOutputTokens = Math.max(
|
|
365
|
-
0,
|
|
366
|
-
snap.totals.outputTokens - baselineTotals.outputTokens,
|
|
367
|
-
);
|
|
368
|
-
streamState.sdkCacheRead = Math.max(
|
|
369
|
-
0,
|
|
370
|
-
snap.totals.cachedInputTokens - baselineTotals.cachedInputTokens,
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
pushLiveUsage(streamState);
|
|
374
|
-
})
|
|
375
|
-
.catch(() => {})
|
|
376
|
-
.finally(() => {
|
|
377
|
-
rolloutPollInFlight = false;
|
|
378
|
-
});
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
// Final authoritative usage settlement — shared by the success post-loop
|
|
382
|
-
// and the terminal-failure path so failed turns account for the tokens
|
|
383
|
-
// they burned too. Codex's `turn.completed.usage` is CUMULATIVE across
|
|
384
|
-
// every API call in the turn — never in the per-turn units the shared
|
|
385
|
-
// stream state (and everything downstream: /status, the companion's
|
|
386
|
-
// per-message counts) speaks. The rollout JSONL's totals diffed against
|
|
387
|
-
// the pre-turn baseline are this turn's real usage; that is the ONLY
|
|
388
|
-
// authoritative source. The SDK figure is a last-resort fallback when
|
|
389
|
-
// the rollout can't be read, and it overstates multi-call turns.
|
|
390
|
-
const settleUsageAccounting = async (): Promise<void> => {
|
|
391
|
-
const last = resolvedThreadId
|
|
392
|
-
? await readLastRolloutSnapshot(resolvedThreadId).catch(() => null)
|
|
393
|
-
: null;
|
|
394
|
-
if (last?.usage) {
|
|
395
|
-
streamState.contextTokens = last.usage.contextTokens;
|
|
396
|
-
if (last.usage.contextWindow) {
|
|
397
|
-
streamState.contextWindow = last.usage.contextWindow;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
if (typeof last?.numApiCalls === "number") {
|
|
401
|
-
streamState.numApiCalls = last.numApiCalls;
|
|
402
|
-
}
|
|
403
|
-
if (last?.totals && baselineTotals) {
|
|
404
|
-
recordTokens(streamState, {
|
|
405
|
-
inputTokens: last.totals.inputTokens - baselineTotals.inputTokens,
|
|
406
|
-
outputTokens: last.totals.outputTokens - baselineTotals.outputTokens,
|
|
407
|
-
cacheRead:
|
|
408
|
-
last.totals.cachedInputTokens - baselineTotals.cachedInputTokens,
|
|
409
|
-
cacheWrite: 0, // Codex doesn't report cache writes
|
|
410
|
-
});
|
|
411
|
-
} else if (usage) {
|
|
412
|
-
recordTokens(streamState, {
|
|
413
|
-
inputTokens: usage.input_tokens,
|
|
414
|
-
outputTokens: usage.output_tokens,
|
|
415
|
-
cacheRead: usage.cached_input_tokens,
|
|
416
|
-
cacheWrite: 0, // Codex doesn't report cache writes
|
|
417
|
-
});
|
|
418
|
-
}
|
|
458
|
+
const outcome: CodexStreamOutcome = {
|
|
459
|
+
usage: null,
|
|
460
|
+
turnFailedError: undefined,
|
|
419
461
|
};
|
|
420
|
-
|
|
421
462
|
const setupMs = Date.now() - t0;
|
|
422
463
|
let turnMs = 0;
|
|
423
464
|
|
|
424
465
|
try {
|
|
425
466
|
const turnStart = Date.now();
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
// system prompts are baked at thread creation via the CLI's config.
|
|
429
|
-
// Talon-side workaround: prepend the system prompt to the user prompt
|
|
430
|
-
// as a fenced block on the first turn only. Subsequent turns inherit
|
|
431
|
-
// instructions from the resumed thread.
|
|
467
|
+
// `runStreamed` has no `system` slot: prepend the system prompt as a
|
|
468
|
+
// fenced block on the first turn only; resumed threads inherit it.
|
|
432
469
|
const inputText =
|
|
433
470
|
previousTurns === 0 ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
471
|
+
await driveCodexStream({
|
|
472
|
+
thread,
|
|
473
|
+
inputText,
|
|
474
|
+
abortController,
|
|
475
|
+
eventContext,
|
|
476
|
+
rollout,
|
|
477
|
+
outcome,
|
|
437
478
|
});
|
|
438
|
-
|
|
439
|
-
for await (const event of events) {
|
|
440
|
-
if (abortController.signal.aborted && !streamState.turnTerminated) break;
|
|
441
|
-
handleEvent(event, {
|
|
442
|
-
state: streamState,
|
|
443
|
-
seenToolCallIds,
|
|
444
|
-
startedToolIds,
|
|
445
|
-
codexToolMetrics,
|
|
446
|
-
onTextBlock,
|
|
447
|
-
onToolUse,
|
|
448
|
-
onToolStart,
|
|
449
|
-
onToolEnd,
|
|
450
|
-
chatId,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
if (event.type === "thread.started") {
|
|
454
|
-
resolvedThreadId = event.thread_id;
|
|
455
|
-
} else if (event.type === "turn.completed") {
|
|
456
|
-
usage = event.usage;
|
|
457
|
-
} else if (event.type === "turn.failed") {
|
|
458
|
-
turnFailedError = event.error.message;
|
|
459
|
-
} else if (event.type === "error") {
|
|
460
|
-
turnFailedError = event.message;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
pollRolloutForLiveStats();
|
|
464
|
-
|
|
465
|
-
// Terminator-driven abort: a delivery tool already shipped the
|
|
466
|
-
// reply via the bridge. Cancel further model generation to skip
|
|
467
|
-
// the wrap-up round-trip Codex would otherwise burn.
|
|
468
|
-
if (streamState.turnTerminated && !abortController.signal.aborted) {
|
|
469
|
-
log("agent", `[${chatId}] terminator fired — aborting Codex turn`);
|
|
470
|
-
try {
|
|
471
|
-
abortController.abort();
|
|
472
|
-
} catch (err) {
|
|
473
|
-
logWarn("agent", `[${chatId}] abort failed: ${errMsg(err)}`);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
479
|
turnMs = Date.now() - turnStart;
|
|
479
480
|
} catch (err) {
|
|
480
481
|
// Aborted-by-terminator path is the expected close on `end_turn`.
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
(errMsg(err) === "AbortError" || /abort/i.test(errMsg(err)))
|
|
484
|
-
) {
|
|
485
|
-
// Swallow — turn completed via terminator tool.
|
|
486
|
-
} else {
|
|
487
|
-
// ChatGPT-OAuth model-mismatch path. Check both the captured
|
|
488
|
-
// event-stream message and the thrown error — Codex SDK surfaces
|
|
489
|
-
// it via both channels. Only use the thread ID from this run.
|
|
490
|
-
const fallback = await maybeFallbackForChatGptMismatch(
|
|
491
|
-
`${turnFailedError ?? ""} ${errMsg(err)}`,
|
|
492
|
-
activeModel,
|
|
493
|
-
params,
|
|
494
|
-
_retried,
|
|
495
|
-
chatId,
|
|
496
|
-
resolvedThreadId,
|
|
497
|
-
);
|
|
498
|
-
if (fallback) return fallback;
|
|
499
|
-
|
|
500
|
-
const outcome = await applyRetryDecision({
|
|
482
|
+
if (!isTerminatorAbort(streamState, err)) {
|
|
483
|
+
return await recoverCodexFailure({
|
|
501
484
|
err,
|
|
502
|
-
chatId,
|
|
503
|
-
activeModel,
|
|
504
|
-
retried: _retried,
|
|
505
485
|
params,
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// before the turn died, then account for it (failed turns burn
|
|
514
|
-
// real tokens; they must not vanish from /status and /metrics).
|
|
515
|
-
await settleUsageAccounting().catch(() => {});
|
|
516
|
-
recordFailedTurnAccounting({
|
|
517
|
-
backend: "codex",
|
|
518
|
-
chatId,
|
|
519
|
-
durationMs: Date.now() - t0,
|
|
520
|
-
toolCalls: codexToolMetrics.count,
|
|
521
|
-
apiCalls: streamState.numApiCalls,
|
|
522
|
-
model: activeModel,
|
|
523
|
-
usage: {
|
|
524
|
-
inputTokens: streamState.sdkInputTokens,
|
|
525
|
-
outputTokens: streamState.sdkOutputTokens,
|
|
526
|
-
cacheRead: streamState.sdkCacheRead,
|
|
527
|
-
cacheWrite: streamState.sdkCacheWrite,
|
|
528
|
-
},
|
|
529
|
-
contextTokens: streamState.contextTokens,
|
|
530
|
-
contextWindow: streamState.contextWindow,
|
|
486
|
+
retried: _retried,
|
|
487
|
+
activeModel,
|
|
488
|
+
state: streamState,
|
|
489
|
+
rollout,
|
|
490
|
+
outcome,
|
|
491
|
+
toolCalls: eventContext.codexToolMetrics.count,
|
|
492
|
+
t0,
|
|
531
493
|
});
|
|
532
|
-
|
|
533
|
-
logError(
|
|
534
|
-
"agent",
|
|
535
|
-
`[${chatId}] Codex error: ${outcome.classified.message}`,
|
|
536
|
-
);
|
|
537
|
-
throw outcome.classified;
|
|
538
494
|
}
|
|
539
495
|
} finally {
|
|
540
496
|
unregisterInterrupt();
|
|
@@ -548,9 +504,9 @@ export async function handleMessage(
|
|
|
548
504
|
// Event-only ChatGPT-mismatch recovery: if the SDK emitted a
|
|
549
505
|
// `turn.failed` carrying the mismatch text but DIDN'T rethrow, the
|
|
550
506
|
// catch block above never fired. Catch it here too.
|
|
551
|
-
if (turnFailedError && !_retried) {
|
|
507
|
+
if (outcome.turnFailedError && !_retried) {
|
|
552
508
|
const fallback = await maybeFallbackForChatGptMismatch(
|
|
553
|
-
turnFailedError,
|
|
509
|
+
outcome.turnFailedError,
|
|
554
510
|
activeModel,
|
|
555
511
|
params,
|
|
556
512
|
_retried,
|
|
@@ -559,57 +515,35 @@ export async function handleMessage(
|
|
|
559
515
|
if (fallback) return fallback;
|
|
560
516
|
}
|
|
561
517
|
|
|
562
|
-
|
|
563
|
-
const stored = getSession(chatId).sessionId;
|
|
564
|
-
if (stored !== resolvedThreadId) {
|
|
565
|
-
setSessionId(chatId, resolvedThreadId);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
await settleUsageAccounting();
|
|
518
|
+
await rollout.settle(outcome.usage);
|
|
570
519
|
|
|
571
520
|
// Surface a synthetic error if Codex failed the turn upstream.
|
|
572
|
-
if (turnFailedError) {
|
|
573
|
-
streamState.syntheticError = turnFailedError;
|
|
521
|
+
if (outcome.turnFailedError) {
|
|
522
|
+
streamState.syntheticError = outcome.turnFailedError;
|
|
574
523
|
}
|
|
575
524
|
|
|
576
525
|
const responseText = finalizeResponseText(streamState);
|
|
577
526
|
const durationMs = Date.now() - t0;
|
|
578
|
-
|
|
527
|
+
accountTurn({
|
|
579
528
|
chatId,
|
|
580
529
|
backend: "codex",
|
|
581
|
-
|
|
582
|
-
toolCalls: codexToolMetrics.count,
|
|
583
|
-
apiCalls: streamState.numApiCalls,
|
|
584
|
-
failed: Boolean(turnFailedError),
|
|
585
|
-
usage: {
|
|
586
|
-
inputTokens: streamState.sdkInputTokens,
|
|
587
|
-
outputTokens: streamState.sdkOutputTokens,
|
|
588
|
-
cacheRead: streamState.sdkCacheRead,
|
|
589
|
-
cacheWrite: streamState.sdkCacheWrite,
|
|
590
|
-
},
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
recordUsage(chatId, {
|
|
594
|
-
inputTokens: streamState.sdkInputTokens,
|
|
595
|
-
outputTokens: streamState.sdkOutputTokens,
|
|
596
|
-
cacheRead: streamState.sdkCacheRead,
|
|
597
|
-
cacheWrite: streamState.sdkCacheWrite,
|
|
530
|
+
state: streamState,
|
|
598
531
|
durationMs,
|
|
599
532
|
model: activeModel,
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
533
|
+
sessionId: rollout.threadId,
|
|
534
|
+
failed: Boolean(outcome.turnFailedError),
|
|
535
|
+
toolCalls: eventContext.codexToolMetrics.count,
|
|
536
|
+
context: {
|
|
537
|
+
// contextTokens comes from the rollout JSONL when available. Falls
|
|
538
|
+
// back to 0 → /status shows "unknown", correct under-promise behaviour.
|
|
539
|
+
contextTokens: streamState.contextTokens || undefined,
|
|
540
|
+
// Prefer the rollout's reported context window over the static catalog.
|
|
541
|
+
contextWindow:
|
|
542
|
+
streamState.contextWindow ?? activeModelInfo?.contextWindow,
|
|
543
|
+
numApiCalls: streamState.numApiCalls || undefined,
|
|
544
|
+
},
|
|
606
545
|
});
|
|
607
|
-
|
|
608
|
-
// Set a descriptive session name from the user's first message.
|
|
609
|
-
if (previousTurns === 0) {
|
|
610
|
-
const name = extractSessionName(text);
|
|
611
|
-
if (name) setSessionName(chatId, name);
|
|
612
|
-
}
|
|
546
|
+
nameSessionFromFirstMessage({ chatId, text, previousTurns });
|
|
613
547
|
|
|
614
548
|
// ── Delivery — decision tree shared with the other backends ────────────────
|
|
615
549
|
let delivery;
|
|
@@ -619,7 +553,7 @@ export async function handleMessage(
|
|
|
619
553
|
chatId,
|
|
620
554
|
state: streamState,
|
|
621
555
|
responseText,
|
|
622
|
-
onTextBlock,
|
|
556
|
+
onTextBlock: params.onTextBlock,
|
|
623
557
|
propagateDeliveryFailure: true,
|
|
624
558
|
});
|
|
625
559
|
} catch (err) {
|
|
@@ -638,38 +572,13 @@ export async function handleMessage(
|
|
|
638
572
|
}
|
|
639
573
|
|
|
640
574
|
incrementTurns(chatId);
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
);
|
|
646
|
-
|
|
647
|
-
log(
|
|
648
|
-
"agent",
|
|
649
|
-
`[${chatId}] -> (${summarizeUsage(
|
|
650
|
-
{
|
|
651
|
-
inputTokens: streamState.sdkInputTokens,
|
|
652
|
-
outputTokens: streamState.sdkOutputTokens,
|
|
653
|
-
cacheRead: streamState.sdkCacheRead,
|
|
654
|
-
cacheWrite: streamState.sdkCacheWrite,
|
|
655
|
-
},
|
|
656
|
-
{ durationMs, toolCalls: streamState.toolCalls },
|
|
657
|
-
)} terminator=${streamState.turnTerminated ? "yes" : "no"} ` +
|
|
658
|
-
`delivered=${streamState.deliveredTextNorms.length} ` +
|
|
659
|
-
`respLen=${responseText.length} ` +
|
|
660
|
-
`setup=${setupMs}ms turn=${turnMs}ms)`,
|
|
661
|
-
);
|
|
662
|
-
traceMessage(chatId, "out", responseText, {
|
|
575
|
+
return finishCallbackTurn({
|
|
576
|
+
chatId,
|
|
577
|
+
state: streamState,
|
|
578
|
+
responseText,
|
|
663
579
|
durationMs,
|
|
664
|
-
|
|
580
|
+
setupMs,
|
|
581
|
+
turnMs,
|
|
582
|
+
delivery,
|
|
665
583
|
});
|
|
666
|
-
|
|
667
|
-
return {
|
|
668
|
-
text: responseText,
|
|
669
|
-
durationMs,
|
|
670
|
-
inputTokens: streamState.sdkInputTokens,
|
|
671
|
-
outputTokens: streamState.sdkOutputTokens,
|
|
672
|
-
cacheRead: streamState.sdkCacheRead,
|
|
673
|
-
cacheWrite: streamState.sdkCacheWrite,
|
|
674
|
-
};
|
|
675
584
|
}
|