talon-agent 5.25.1 → 5.26.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/README.md +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- package/src/backend/codex/auth.ts +31 -2
- package/src/backend/codex/constants.ts +21 -7
- package/src/backend/codex/discovery.ts +32 -1
- package/src/backend/codex/factory.ts +3 -5
- package/src/backend/codex/handler/message.ts +7 -7
- package/src/backend/codex/models.ts +53 -11
- package/src/backend/codex/one-shot.ts +139 -37
- package/src/backend/codex/state.ts +11 -0
- package/src/core/agents/abort-reason.ts +46 -0
- package/src/core/agents/registry.ts +3 -2
- package/src/core/background/dream/index.ts +2 -2
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/isolated-agent.ts +1 -1
- package/src/core/backup/index.ts +1 -0
- package/src/core/backup/status.ts +30 -1
- package/src/core/config/index.ts +8 -0
- package/src/core/engine/gateway.ts +5 -0
- package/src/core/mesh/credentials/store.ts +60 -9
- package/src/core/tools/bridge.ts +58 -4
- package/src/frontend/discord/callbacks/components/effort.ts +4 -4
- package/src/frontend/discord/callbacks/components/index.ts +2 -0
- package/src/frontend/discord/commands/backup-panel.ts +219 -0
- package/src/frontend/discord/commands/backup.ts +25 -34
- package/src/frontend/discord/commands/info.ts +17 -5
- package/src/frontend/discord/commands/settings.ts +12 -9
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/credentials/claims.ts +5 -5
- package/src/frontend/native/bridge/routes/chats.ts +40 -20
- package/src/frontend/native/bridge/routes/host.ts +15 -2
- package/src/frontend/native/bridge/routes/table.ts +4 -0
- package/src/frontend/native/commands/admin.ts +64 -0
- package/src/frontend/native/commands/backup.ts +191 -0
- package/src/frontend/native/commands/definitions.ts +113 -0
- package/src/frontend/native/commands/format.ts +24 -0
- package/src/frontend/native/commands/index.ts +106 -0
- package/src/frontend/native/commands/info.ts +97 -0
- package/src/frontend/native/commands/session.ts +130 -0
- package/src/frontend/native/commands/types.ts +26 -0
- package/src/frontend/native/protocol.ts +18 -0
- package/src/frontend/native/surface/handlers.ts +14 -1
- package/src/frontend/native/surface/status.ts +9 -1
- package/src/frontend/native/turn/emit.ts +32 -1
- package/src/frontend/presentation/backup-panel.ts +425 -0
- package/src/frontend/presentation/memory-report.ts +109 -0
- package/src/frontend/presentation/text-commands.ts +243 -0
- package/src/frontend/telegram/admin/sessions.ts +20 -5
- package/src/frontend/telegram/admin.ts +9 -2
- package/src/frontend/telegram/callbacks/backup.ts +153 -22
- package/src/frontend/telegram/callbacks/effort.ts +5 -5
- package/src/frontend/telegram/callbacks/index.ts +2 -2
- package/src/frontend/telegram/callbacks/settings.ts +3 -40
- package/src/frontend/telegram/commands/admin.ts +8 -7
- package/src/frontend/telegram/commands/backup.ts +56 -46
- package/src/frontend/telegram/commands/index.ts +3 -2
- package/src/frontend/telegram/commands/info.ts +45 -26
- package/src/frontend/telegram/commands/memory.ts +8 -92
- package/src/frontend/telegram/commands/settings.ts +13 -45
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
- package/src/frontend/telegram/render/backup-panel.ts +47 -0
- package/src/frontend/telegram/render/menu.ts +21 -30
- package/src/frontend/terminal/builtins/model.ts +20 -11
- package/src/frontend/whatsapp/commands.ts +16 -216
- package/src/frontend/whatsapp/messages/inbound.ts +33 -2
package/README.md
CHANGED
|
@@ -487,6 +487,7 @@ Config file: `~/.talon/config.json`
|
|
|
487
487
|
| `model` | `"default"` | Default model. Interpretation depends on the active backend. |
|
|
488
488
|
| `agyBinary` | --- | Path to the Antigravity `agy` executable. `AGY_BINARY` overrides it. |
|
|
489
489
|
| `codexApiKey` | --- | Codex-only OpenAI API key. Prefer this over `openaiApiKey` for Codex API-key auth. `codex login` takes precedence over shared `openaiApiKey`. |
|
|
490
|
+
| `codexChatGptDefaultModel` | --- | Model a ChatGPT-login Codex session falls back to when a model is rejected or none is set. Default: the Codex CLI's own default for the account (from `~/.codex/models_cache.json`). `TALON_CODEX_CHATGPT_MODEL` overrides it. |
|
|
490
491
|
| `concurrency` | `1` | Max concurrent AI queries (1--20) |
|
|
491
492
|
| `pulse` | `true` | Periodic group engagement |
|
|
492
493
|
| `heartbeat` | `false` | Background maintenance agent |
|
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type AgyResult,
|
|
34
34
|
type AgyStepUpdate,
|
|
35
35
|
} from "./events.js";
|
|
36
|
+
import { abortLogLine } from "../../core/agents/abort-reason.js";
|
|
36
37
|
|
|
37
38
|
const ts = (): string => new Date().toISOString().slice(11, 19);
|
|
38
39
|
|
|
@@ -257,20 +258,39 @@ export async function runOneShotAgent(
|
|
|
257
258
|
appendLog,
|
|
258
259
|
...(onAssistantText ? { onAssistantText } : {}),
|
|
259
260
|
aborted: abortController.signal.aborted,
|
|
261
|
+
abortSignal: abortController.signal,
|
|
260
262
|
});
|
|
261
263
|
} catch (err) {
|
|
264
|
+
// settleOneShot already logged its own failure — just pass it on.
|
|
265
|
+
if (err instanceof AgyOneShotError) throw err;
|
|
262
266
|
const msg = err instanceof Error ? err.message : String(err);
|
|
263
267
|
if (abortController.signal.aborted || /abort/i.test(msg)) {
|
|
264
|
-
await appendLog(
|
|
268
|
+
await appendLog(
|
|
269
|
+
`\n### [${ts()}] Aborted\n${abortLogLine(abortController.signal)}\n`,
|
|
270
|
+
);
|
|
265
271
|
return;
|
|
266
272
|
}
|
|
267
273
|
logWarn("agent", `agy one-shot run failed: ${msg}`);
|
|
268
274
|
await appendLog(`\n### [${ts()}] Error\n${msg}\n`);
|
|
275
|
+
// Surface it: a swallowed failure is recorded as a successful run by
|
|
276
|
+
// cron, heartbeat and the task table.
|
|
277
|
+
throw new AgyOneShotError(msg, { cause: err });
|
|
269
278
|
} finally {
|
|
270
279
|
unregisterMcpScope(scope);
|
|
271
280
|
}
|
|
272
281
|
}
|
|
273
282
|
|
|
283
|
+
/**
|
|
284
|
+
* An agy one-shot that failed: no result, a non-SUCCESS turn, or a spawn
|
|
285
|
+
* error. Thrown so callers record the run as failed rather than ok.
|
|
286
|
+
*/
|
|
287
|
+
class AgyOneShotError extends Error {
|
|
288
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
289
|
+
super(message, options);
|
|
290
|
+
this.name = "AgyOneShotError";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
274
294
|
/** Report the run's answer + usage, or its failure, to the log. */
|
|
275
295
|
async function settleOneShot(inputs: {
|
|
276
296
|
outcome: SpawnOutcome;
|
|
@@ -278,10 +298,14 @@ async function settleOneShot(inputs: {
|
|
|
278
298
|
appendLog: (text: string) => Promise<void>;
|
|
279
299
|
onAssistantText?: OneShotAgentParams["onAssistantText"];
|
|
280
300
|
aborted: boolean;
|
|
301
|
+
abortSignal?: AbortSignal;
|
|
281
302
|
}): Promise<OneShotUsage | void> {
|
|
282
303
|
const { outcome, appendLog } = inputs;
|
|
283
304
|
if (inputs.aborted) {
|
|
284
|
-
|
|
305
|
+
const line = inputs.abortSignal
|
|
306
|
+
? abortLogLine(inputs.abortSignal)
|
|
307
|
+
: "Run aborted.";
|
|
308
|
+
await appendLog(`\n### [${ts()}] Aborted\n${line}\n`);
|
|
285
309
|
return;
|
|
286
310
|
}
|
|
287
311
|
if (!outcome.result) {
|
|
@@ -290,7 +314,14 @@ async function settleOneShot(inputs: {
|
|
|
290
314
|
: outcome.stderr.trim() || `agy exited ${outcome.code ?? "n/a"}`;
|
|
291
315
|
logWarn("agent", `agy one-shot produced no result: ${reason}`);
|
|
292
316
|
await appendLog(`\n### [${ts()}] Error\n${reason}\n`);
|
|
293
|
-
|
|
317
|
+
throw new AgyOneShotError(reason);
|
|
318
|
+
}
|
|
319
|
+
if (outcome.result.status && outcome.result.status !== "SUCCESS") {
|
|
320
|
+
// logResult already wrote the "Turn <status>" section.
|
|
321
|
+
const reason =
|
|
322
|
+
outcome.result.error?.trim() || `agy turn ${outcome.result.status}`;
|
|
323
|
+
logWarn("agent", `agy one-shot turn ${outcome.result.status}: ${reason}`);
|
|
324
|
+
throw new AgyOneShotError(reason);
|
|
294
325
|
}
|
|
295
326
|
const response = outcome.result.response ?? "";
|
|
296
327
|
if (response.trim()) {
|
|
@@ -293,10 +293,39 @@ export function detectCodexAuth(
|
|
|
293
293
|
* `"not supported when using Codex with a ChatGPT account"`. Match on
|
|
294
294
|
* that substring (case-insensitive, generous on whitespace) so a
|
|
295
295
|
* future wording shift still trips a soft-match.
|
|
296
|
+
*
|
|
297
|
+
* Also matches the 404 "model … does not exist or you do not have
|
|
298
|
+
* access" shape ({@link isCodexModelNotFoundError}): a model retired for
|
|
299
|
+
* the account is the same situation from the caller's point of view.
|
|
296
300
|
*/
|
|
297
301
|
export function isChatGptModelMismatchError(message: string): boolean {
|
|
298
|
-
|
|
299
|
-
|
|
302
|
+
if (
|
|
303
|
+
/not\s+supported\s+when\s+using\s+codex\s+with\s+a\s+chatgpt\s+account/i.test(
|
|
304
|
+
message,
|
|
305
|
+
)
|
|
306
|
+
) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
return isCodexModelNotFoundError(message);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Detect the "model retired / not granted" 404 the ChatGPT Codex endpoint
|
|
314
|
+
* returns once a model is withdrawn from an account:
|
|
315
|
+
*
|
|
316
|
+
* `unexpected status 404 Not Found: The model \`gpt-5.5\` does not exist
|
|
317
|
+
* or you do not have access to it.`
|
|
318
|
+
*
|
|
319
|
+
* Every Codex cron run hit exactly this from 2026-09-24 onward. It is as
|
|
320
|
+
* definitive as the 400 mismatch — the server names the model and says
|
|
321
|
+
* the account can't use it — so it takes the same fallback/learning path.
|
|
322
|
+
* Requires both the 404 status and the "model … does not exist" wording
|
|
323
|
+
* so an unrelated 404 (a missing MCP resource, a bad URL) can't trip it.
|
|
324
|
+
*/
|
|
325
|
+
export function isCodexModelNotFoundError(message: string): boolean {
|
|
326
|
+
return (
|
|
327
|
+
/\b404\b/.test(message) &&
|
|
328
|
+
/\bmodel\b[^\n]{0,120}?\bdoes\s+not\s+exist\b/i.test(message)
|
|
300
329
|
);
|
|
301
330
|
}
|
|
302
331
|
|
|
@@ -37,14 +37,28 @@ export const CODEX_SYSTEM_PROMPT_SUFFIX = codexSystemPromptSuffix("telegram");
|
|
|
37
37
|
export const CODEX_DEFAULT_MODEL = "gpt-5-codex";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
* via ChatGPT OAuth (`~/.codex/auth.json` `auth_mode: "chatgpt"`).
|
|
42
|
-
* The `gpt-5-codex` model is rejected with a 400
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
40
|
+
* Last-resort default model for the Codex backend when the user is signed
|
|
41
|
+
* in via ChatGPT OAuth (`~/.codex/auth.json` `auth_mode: "chatgpt"`).
|
|
42
|
+
* The `gpt-5-codex` model is rejected with a 400 `invalid_request_error`
|
|
43
|
+
* ("not supported when using Codex with a ChatGPT account") on this auth
|
|
44
|
+
* path.
|
|
45
|
+
*
|
|
46
|
+
* This is only the floor of the resolution ladder — see
|
|
47
|
+
* `getCodexChatGptDefaultModel()` in `models.ts`, which prefers (1) an
|
|
48
|
+
* explicit operator override, then (2) the Codex CLI's own default for the
|
|
49
|
+
* signed-in account (the first listed model in `~/.codex/models_cache.json`
|
|
50
|
+
* by priority). `gpt-6-astra` is the first entry of the model catalog
|
|
51
|
+
* bundled with codex-cli 0.154. The previous value, `gpt-5.5`, was retired
|
|
52
|
+
* for ChatGPT accounts in Sep 2026: every run on it returned
|
|
53
|
+
* `404 The model gpt-5.5 does not exist or you do not have access to it`.
|
|
54
|
+
*/
|
|
55
|
+
export const CODEX_CHATGPT_DEFAULT_MODEL = "gpt-6-astra";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Environment override for the ChatGPT-OAuth default model. Takes
|
|
59
|
+
* precedence over the `codexChatGptDefaultModel` config key.
|
|
46
60
|
*/
|
|
47
|
-
export const
|
|
61
|
+
export const CODEX_CHATGPT_MODEL_ENV = "TALON_CODEX_CHATGPT_MODEL";
|
|
48
62
|
|
|
49
63
|
/**
|
|
50
64
|
* ThreadOptions permission settings shared by both the chat handler and
|
|
@@ -72,6 +72,8 @@ interface CodexCacheModelEntry {
|
|
|
72
72
|
description?: string;
|
|
73
73
|
visibility?: "list" | "hide" | string;
|
|
74
74
|
supported_in_api?: boolean;
|
|
75
|
+
/** Picker order — lower sorts first; the CLI's default is the lowest. */
|
|
76
|
+
priority?: number;
|
|
75
77
|
context_window?: number;
|
|
76
78
|
default_reasoning_level?: string;
|
|
77
79
|
supported_reasoning_levels?: Array<{
|
|
@@ -351,6 +353,33 @@ export async function fetchOpenAiModels(
|
|
|
351
353
|
* Non-existent cache file throws so the caller's catch path logs it
|
|
352
354
|
* at debug level and the picker falls back to curated.
|
|
353
355
|
*/
|
|
356
|
+
/**
|
|
357
|
+
* The Codex CLI's own default for the account: the listed, API-callable
|
|
358
|
+
* entry with the lowest `priority`. First entry wins ties, matching the
|
|
359
|
+
* CLI's stable sort. `null` when nothing qualifies.
|
|
360
|
+
*/
|
|
361
|
+
function cliDefaultModel(
|
|
362
|
+
data: ReadonlyArray<CodexCacheModelEntry | null | undefined>,
|
|
363
|
+
): string | null {
|
|
364
|
+
let best: string | null = null;
|
|
365
|
+
let bestPriority = Number.POSITIVE_INFINITY;
|
|
366
|
+
for (const entry of data) {
|
|
367
|
+
if (!entry || typeof entry.slug !== "string" || !entry.slug) continue;
|
|
368
|
+
if (entry.visibility === "hide" || entry.supported_in_api === false) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const priority =
|
|
372
|
+
typeof entry.priority === "number" && Number.isFinite(entry.priority)
|
|
373
|
+
? entry.priority
|
|
374
|
+
: Number.MAX_SAFE_INTEGER;
|
|
375
|
+
if (priority < bestPriority) {
|
|
376
|
+
bestPriority = priority;
|
|
377
|
+
best = entry.slug;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return best;
|
|
381
|
+
}
|
|
382
|
+
|
|
354
383
|
export async function loadCodexCacheModels(): Promise<void> {
|
|
355
384
|
const path = getCodexCachePath();
|
|
356
385
|
let raw: string;
|
|
@@ -381,6 +410,7 @@ export async function loadCodexCacheModels(): Promise<void> {
|
|
|
381
410
|
const state = getState();
|
|
382
411
|
state.discoveredModels.clear();
|
|
383
412
|
state.discoveredModelMetadata.clear();
|
|
413
|
+
state.discoveredDefaultModel = cliDefaultModel(data);
|
|
384
414
|
let kept = 0;
|
|
385
415
|
let dropped = 0;
|
|
386
416
|
for (const entry of data) {
|
|
@@ -429,6 +459,7 @@ export async function loadCodexCacheModels(): Promise<void> {
|
|
|
429
459
|
const fetchedAt = json.fetched_at ?? "unknown";
|
|
430
460
|
log(
|
|
431
461
|
"agent",
|
|
432
|
-
`Codex: loaded ${kept} models from ${path} (fetched_at=${fetchedAt}, filtered ${dropped} hidden/api-disabled entries
|
|
462
|
+
`Codex: loaded ${kept} models from ${path} (fetched_at=${fetchedAt}, filtered ${dropped} hidden/api-disabled entries` +
|
|
463
|
+
`${state.discoveredDefaultModel ? `, CLI default ${state.discoveredDefaultModel}` : ""})`,
|
|
433
464
|
);
|
|
434
465
|
}
|
|
@@ -28,10 +28,7 @@ import { initCodexAgent, getCodexAuthInfo } from "./init.js";
|
|
|
28
28
|
import { handleMessage as codexHandleMessage } from "./handler/index.js";
|
|
29
29
|
import { runOneShotAgent as codexRunOneShotAgent } from "./one-shot.js";
|
|
30
30
|
import { resetState as resetCodexState } from "./state.js";
|
|
31
|
-
import {
|
|
32
|
-
CODEX_DEFAULT_MODEL,
|
|
33
|
-
CODEX_CHATGPT_DEFAULT_MODEL,
|
|
34
|
-
} from "./constants.js";
|
|
31
|
+
import { CODEX_DEFAULT_MODEL } from "./constants.js";
|
|
35
32
|
import {
|
|
36
33
|
resolveModel as codexResolveModel,
|
|
37
34
|
getModelInfo as codexGetModelInfo,
|
|
@@ -40,6 +37,7 @@ import {
|
|
|
40
37
|
getProviderModels as codexGetProviderModels,
|
|
41
38
|
formatModelError as codexFormatModelError,
|
|
42
39
|
listModels as codexListModels,
|
|
40
|
+
getCodexChatGptDefaultModel,
|
|
43
41
|
} from "./models.js";
|
|
44
42
|
|
|
45
43
|
const codexFactory: BackendFactory = {
|
|
@@ -71,7 +69,7 @@ const codexFactory: BackendFactory = {
|
|
|
71
69
|
getDefaultModelId: () => {
|
|
72
70
|
const auth = getCodexAuthInfo();
|
|
73
71
|
return auth?.mode === "chatgpt"
|
|
74
|
-
?
|
|
72
|
+
? getCodexChatGptDefaultModel()
|
|
75
73
|
: CODEX_DEFAULT_MODEL;
|
|
76
74
|
},
|
|
77
75
|
getRawModelInfo: (id) => codexGetModelInfo(id),
|
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
import {
|
|
46
46
|
codexSystemPromptSuffix,
|
|
47
47
|
CODEX_DEFAULT_MODEL,
|
|
48
|
-
CODEX_CHATGPT_DEFAULT_MODEL,
|
|
49
48
|
CODEX_THREAD_PERMISSIONS,
|
|
50
49
|
} from "../constants.js";
|
|
51
50
|
import {
|
|
@@ -64,6 +63,7 @@ import {
|
|
|
64
63
|
chatGptFallbackFor,
|
|
65
64
|
getModelInfo,
|
|
66
65
|
isCodexOAuthIncompat,
|
|
66
|
+
getCodexChatGptDefaultModel,
|
|
67
67
|
} from "../models.js";
|
|
68
68
|
import { supportsReasoningLevel } from "../../../core/models/reasoning-levels.js";
|
|
69
69
|
import { toCodexReasoningEffort } from "../effort.js";
|
|
@@ -142,7 +142,7 @@ async function maybeFallbackForChatGptMismatch(
|
|
|
142
142
|
const isOAuth = authInfo?.mode === "chatgpt";
|
|
143
143
|
const silent =
|
|
144
144
|
isOAuth &&
|
|
145
|
-
activeModel !==
|
|
145
|
+
activeModel !== getCodexChatGptDefaultModel() &&
|
|
146
146
|
isSilentOAuthExitError(probeText);
|
|
147
147
|
|
|
148
148
|
if (!explicit && !silent) return undefined;
|
|
@@ -170,7 +170,7 @@ async function maybeFallbackForChatGptMismatch(
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
const fallbackModel =
|
|
173
|
-
chatGptFallbackFor(activeModel) ??
|
|
173
|
+
chatGptFallbackFor(activeModel) ?? getCodexChatGptDefaultModel();
|
|
174
174
|
if (fallbackModel === activeModel) return undefined;
|
|
175
175
|
|
|
176
176
|
// Only EXPLICIT mismatch errors are persisted as OAuth-incompat —
|
|
@@ -227,8 +227,8 @@ function buildTurnPrompt(params: QueryParams): string {
|
|
|
227
227
|
/**
|
|
228
228
|
* Codex accepts arbitrary model strings; we pass through whatever the
|
|
229
229
|
* caller resolved (chat-settings → config) and fall back to the auth-aware
|
|
230
|
-
* default: `gpt-5-codex` when an API key is present,
|
|
231
|
-
* ChatGPT OAuth is configured (because `gpt-5-codex` is rejected with a
|
|
230
|
+
* default: `gpt-5-codex` when an API key is present, the resolved ChatGPT
|
|
231
|
+
* default (`getCodexChatGptDefaultModel`) when only ChatGPT OAuth is configured (because `gpt-5-codex` is rejected with a
|
|
232
232
|
* 400 on ChatGPT-mode accounts). A model known to be OAuth-incompat on a
|
|
233
233
|
* ChatGPT-OAuth account is swapped pre-emptively rather than letting the
|
|
234
234
|
* first turn fail.
|
|
@@ -237,13 +237,13 @@ function resolveCodexModel(chatId: string, requested: string | undefined) {
|
|
|
237
237
|
const authInfo = getCodexAuthInfo();
|
|
238
238
|
const authAwareDefault =
|
|
239
239
|
authInfo?.mode === "chatgpt"
|
|
240
|
-
?
|
|
240
|
+
? getCodexChatGptDefaultModel()
|
|
241
241
|
: CODEX_DEFAULT_MODEL;
|
|
242
242
|
const requestedModel = requested ?? authAwareDefault;
|
|
243
243
|
let activeModel = requestedModel;
|
|
244
244
|
if (authInfo?.mode === "chatgpt" && isCodexOAuthIncompat(requestedModel)) {
|
|
245
245
|
const fallback =
|
|
246
|
-
chatGptFallbackFor(requestedModel) ??
|
|
246
|
+
chatGptFallbackFor(requestedModel) ?? getCodexChatGptDefaultModel();
|
|
247
247
|
// Guard against a learned-but-no-fallback case — only swap when the
|
|
248
248
|
// fallback is actually different from what we'd already run.
|
|
249
249
|
if (fallback !== requestedModel) {
|
|
@@ -37,6 +37,36 @@ import { awaitDiscovery, hasAttemptedDiscovery } from "./discovery.js";
|
|
|
37
37
|
import { getState } from "./state.js";
|
|
38
38
|
import { getCodexAuthInfo } from "./init.js";
|
|
39
39
|
import { isKnownOAuthIncompat } from "./oauth-incompat.js";
|
|
40
|
+
import {
|
|
41
|
+
CODEX_CHATGPT_DEFAULT_MODEL,
|
|
42
|
+
CODEX_CHATGPT_MODEL_ENV,
|
|
43
|
+
} from "./constants.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The model a ChatGPT-OAuth Codex session runs when nothing more specific
|
|
47
|
+
* applies — the target of every pre-emptive OAuth swap and mismatch
|
|
48
|
+
* fallback. Resolution order:
|
|
49
|
+
*
|
|
50
|
+
* 1. `TALON_CODEX_CHATGPT_MODEL` env var (operator override);
|
|
51
|
+
* 2. `codexChatGptDefaultModel` in config;
|
|
52
|
+
* 3. the Codex CLI's own default for the signed-in account (first
|
|
53
|
+
* listed model in `~/.codex/models_cache.json` by priority), unless
|
|
54
|
+
* Talon has already learned that id fails on this account;
|
|
55
|
+
* 4. {@link CODEX_CHATGPT_DEFAULT_MODEL}, the bundled floor.
|
|
56
|
+
*
|
|
57
|
+
* Hardcoding a single id is what broke in Sep 2026: OpenAI retired
|
|
58
|
+
* `gpt-5.5` for ChatGPT accounts and every run 404'd until a release.
|
|
59
|
+
*/
|
|
60
|
+
export function getCodexChatGptDefaultModel(): string {
|
|
61
|
+
const env = process.env[CODEX_CHATGPT_MODEL_ENV]?.trim();
|
|
62
|
+
if (env) return env;
|
|
63
|
+
const state = getState();
|
|
64
|
+
const configured = state.config?.codexChatGptDefaultModel?.trim();
|
|
65
|
+
if (configured) return configured;
|
|
66
|
+
const discovered = state.discoveredDefaultModel;
|
|
67
|
+
if (discovered && !isKnownOAuthIncompat(discovered)) return discovered;
|
|
68
|
+
return CODEX_CHATGPT_DEFAULT_MODEL;
|
|
69
|
+
}
|
|
40
70
|
|
|
41
71
|
/**
|
|
42
72
|
* Codex-specific model metadata extension.
|
|
@@ -56,15 +86,26 @@ export interface CodexModelInfo extends UnifiedModelInfo {
|
|
|
56
86
|
* Curated metadata for models we recognise.
|
|
57
87
|
*
|
|
58
88
|
* Order matters: `getSettingsPresentation` lists curated models in
|
|
59
|
-
* this order, and
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
89
|
+
* this order, and the current flagship is intentionally first — the
|
|
90
|
+
* safe default for Talon-on-Codex deployments where the operator hasn't
|
|
91
|
+
* explicitly picked a model. (`gpt-5.5` held that slot until it was
|
|
92
|
+
* retired for ChatGPT accounts in Sep 2026.)
|
|
63
93
|
*
|
|
64
94
|
* Discovered-but-not-curated ids are appended at the end (synthesised
|
|
65
95
|
* with minimal metadata), so the curated entries always render first.
|
|
66
96
|
*/
|
|
67
97
|
export const CODEX_MODELS: CodexModelInfo[] = [
|
|
98
|
+
{
|
|
99
|
+
// Flagship of the catalog bundled with codex-cli 0.154 and the floor
|
|
100
|
+
// of the ChatGPT default ladder (`getCodexChatGptDefaultModel`).
|
|
101
|
+
// No context window here: the account's models_cache.json supplies it.
|
|
102
|
+
id: "gpt-6-astra",
|
|
103
|
+
displayName: "GPT-6 Astra",
|
|
104
|
+
provider: "openai",
|
|
105
|
+
providerName: "OpenAI",
|
|
106
|
+
selectable: true,
|
|
107
|
+
reasoning: true,
|
|
108
|
+
},
|
|
68
109
|
{
|
|
69
110
|
id: "gpt-5.5",
|
|
70
111
|
displayName: "GPT-5.5",
|
|
@@ -149,19 +190,20 @@ export function isCodexOAuthIncompat(id: string): boolean {
|
|
|
149
190
|
*
|
|
150
191
|
* Returns `undefined` when:
|
|
151
192
|
* - The id isn't recognised as OAuth-incompat (caller can skip).
|
|
152
|
-
* - The id IS the
|
|
153
|
-
*
|
|
154
|
-
*
|
|
193
|
+
* - The id IS the resolved ChatGPT default
|
|
194
|
+
* ({@link getCodexChatGptDefaultModel}) itself — no further fallback
|
|
195
|
+
* exists; if even the default fails, the credential is the problem,
|
|
196
|
+
* not the model.
|
|
155
197
|
*
|
|
156
|
-
* For everything else returns `gpt-5
|
|
157
|
-
* default. (The curated table has only `gpt-5-codex` flagged as
|
|
198
|
+
* For everything else returns the resolved ChatGPT default. (The curated table has only `gpt-5-codex` flagged as
|
|
158
199
|
* `apiKeyOnly: true`; runtime-learned entries cover the rest:
|
|
159
200
|
* `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.2`, etc.)
|
|
160
201
|
*/
|
|
161
202
|
export function chatGptFallbackFor(id: string): string | undefined {
|
|
162
203
|
if (!isCodexOAuthIncompat(id)) return undefined;
|
|
163
|
-
|
|
164
|
-
return
|
|
204
|
+
const fallback = getCodexChatGptDefaultModel();
|
|
205
|
+
if (id === fallback) return undefined;
|
|
206
|
+
return fallback;
|
|
165
207
|
}
|
|
166
208
|
|
|
167
209
|
/**
|
|
@@ -24,13 +24,17 @@ import { emitAssistantText } from "../runtime/one-shot-hooks.js";
|
|
|
24
24
|
import { ensureCodex, getCodexAuthInfo } from "./init.js";
|
|
25
25
|
import {
|
|
26
26
|
CODEX_SYSTEM_PROMPT_SUFFIX,
|
|
27
|
-
CODEX_CHATGPT_DEFAULT_MODEL,
|
|
28
27
|
CODEX_THREAD_PERMISSIONS,
|
|
29
28
|
} from "./constants.js";
|
|
30
29
|
import { isChatGptModelMismatchError } from "./auth.js";
|
|
31
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
chatGptFallbackFor,
|
|
32
|
+
getCodexChatGptDefaultModel,
|
|
33
|
+
isCodexOAuthIncompat,
|
|
34
|
+
} from "./models.js";
|
|
32
35
|
import { markOAuthIncompat } from "./oauth-incompat.js";
|
|
33
36
|
import { toCodexReasoningEffort } from "./effort.js";
|
|
37
|
+
import { abortLogLine } from "../../core/agents/abort-reason.js";
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
40
|
* Resolve the effective model for a one-shot run, applying the same
|
|
@@ -39,7 +43,8 @@ import { toCodexReasoningEffort } from "./effort.js";
|
|
|
39
43
|
* Heartbeats and dream calls pass `params.model` straight through from
|
|
40
44
|
* `config.heartbeatModel ?? config.model`. If that's an OAuth-incompat
|
|
41
45
|
* id (curated `apiKeyOnly: true` or runtime-learned) AND the active
|
|
42
|
-
* Codex credential is ChatGPT OAuth, swap to
|
|
46
|
+
* Codex credential is ChatGPT OAuth, swap to the resolved ChatGPT default
|
|
47
|
+
* (`getCodexChatGptDefaultModel`) to avoid the
|
|
43
48
|
* silent exit-1 failure mode that hit a group chat on 2026-05-20 23:13Z.
|
|
44
49
|
*
|
|
45
50
|
* Returns the resolved model id, whether a swap occurred, and an
|
|
@@ -56,7 +61,8 @@ function resolveOneShotModel(requested: string): {
|
|
|
56
61
|
return { model: requested, swapped: false };
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
const fallback =
|
|
64
|
+
const fallback =
|
|
65
|
+
chatGptFallbackFor(requested) ?? getCodexChatGptDefaultModel();
|
|
60
66
|
if (fallback === requested) return { model: requested, swapped: false };
|
|
61
67
|
|
|
62
68
|
return {
|
|
@@ -131,6 +137,14 @@ export async function runOneShotAgent(
|
|
|
131
137
|
...CODEX_THREAD_PERMISSIONS,
|
|
132
138
|
});
|
|
133
139
|
|
|
140
|
+
// The real reason a run failed lives in the stream, not in whatever the
|
|
141
|
+
// SDK throws afterwards: a model the account can't use yields
|
|
142
|
+
// `turn.failed` ("404 … The model `gpt-5.5` does not exist …") and the
|
|
143
|
+
// SDK then throws only "Codex Exec exited with code 1: Reading prompt
|
|
144
|
+
// from stdin...". Capture both so the failure the caller records is the
|
|
145
|
+
// one that explains it.
|
|
146
|
+
const failure = new StreamFailure();
|
|
147
|
+
|
|
134
148
|
try {
|
|
135
149
|
if (abortController.signal.aborted) {
|
|
136
150
|
throw new Error("Aborted before prompt was sent");
|
|
@@ -146,6 +160,7 @@ export async function runOneShotAgent(
|
|
|
146
160
|
for await (const event of events) {
|
|
147
161
|
if (abortController.signal.aborted) break;
|
|
148
162
|
await appendCodexEvent(appendLog, event, onAssistantText);
|
|
163
|
+
failure.observe(event);
|
|
149
164
|
if (event.type === "turn.completed") {
|
|
150
165
|
const u = (event as { usage?: Record<string, number> }).usage;
|
|
151
166
|
if (u) {
|
|
@@ -158,49 +173,136 @@ export async function runOneShotAgent(
|
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
}
|
|
176
|
+
// A stream that ends cleanly after `turn.failed` is still a failed run
|
|
177
|
+
// — returning here is how 26/26 Codex cron runs were stored as "ok".
|
|
178
|
+
if (!abortController.signal.aborted && failure.message) {
|
|
179
|
+
throw new CodexOneShotError(failure.message);
|
|
180
|
+
}
|
|
161
181
|
return usage;
|
|
162
182
|
} catch (err) {
|
|
183
|
+
const thrown = err instanceof Error ? err.message : String(err);
|
|
163
184
|
if (
|
|
164
|
-
|
|
165
|
-
/abort/i.test(
|
|
185
|
+
!(err instanceof CodexOneShotError) &&
|
|
186
|
+
(abortController.signal.aborted || /abort/i.test(thrown))
|
|
166
187
|
) {
|
|
167
188
|
const ts = new Date().toISOString().slice(11, 19);
|
|
168
|
-
await appendLog(
|
|
189
|
+
await appendLog(
|
|
190
|
+
`\n### [${ts}] Aborted\n${abortLogLine(abortController.signal)}\n`,
|
|
191
|
+
);
|
|
169
192
|
return;
|
|
170
193
|
}
|
|
171
|
-
const msg =
|
|
172
|
-
|
|
173
|
-
// Learn only from EXPLICIT mismatches in one-shot context.
|
|
174
|
-
// Silent-exit failures are ambiguous (transient outage vs real
|
|
175
|
-
// model-incompat) and persisting them would over-poison the
|
|
176
|
-
// learning store with the result that one bad heartbeat
|
|
177
|
-
// permanently downgrades the model. Explicit mismatches carry the
|
|
178
|
-
// unambiguous server message so they're safe to mark.
|
|
179
|
-
//
|
|
180
|
-
// Unlike the interactive handler, heartbeat/dream can't recurse for
|
|
181
|
-
// a retry (would mess with the timing contract and lock
|
|
182
|
-
// semantics), so silent-exit failures here simply surface to the
|
|
183
|
-
// run log; the next scheduled run takes a fresh swing.
|
|
184
|
-
const authInfo = getCodexAuthInfo();
|
|
185
|
-
if (
|
|
186
|
-
authInfo?.mode === "chatgpt" &&
|
|
187
|
-
activeModel !== CODEX_CHATGPT_DEFAULT_MODEL &&
|
|
188
|
-
isChatGptModelMismatchError(msg)
|
|
189
|
-
) {
|
|
190
|
-
const recorded = await markOAuthIncompat(activeModel);
|
|
191
|
-
if (recorded) {
|
|
192
|
-
logWarn(
|
|
193
|
-
"agent",
|
|
194
|
-
`[${contextLabel}] Codex one-shot: recorded ${activeModel} as ` +
|
|
195
|
-
`OAuth-incompat (explicit mismatch) — next ${contextLabel} run ` +
|
|
196
|
-
`will pre-emptively swap to ${CODEX_CHATGPT_DEFAULT_MODEL}`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
194
|
+
const msg = failure.describe(thrown);
|
|
195
|
+
await learnFromMismatch(activeModel, msg, contextLabel);
|
|
201
196
|
logWarn("agent", `Codex one-shot run failed: ${msg}`);
|
|
202
197
|
const ts = new Date().toISOString().slice(11, 19);
|
|
203
198
|
await appendLog(`\n### [${ts}] Error\n${msg}\n`);
|
|
199
|
+
throw err instanceof CodexOneShotError
|
|
200
|
+
? err
|
|
201
|
+
: new CodexOneShotError(msg, { cause: err });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Record a model as OAuth-incompat when a one-shot failed on it with an
|
|
207
|
+
* explicit server mismatch, so the next run pre-emptively swaps.
|
|
208
|
+
*/
|
|
209
|
+
async function learnFromMismatch(
|
|
210
|
+
activeModel: string,
|
|
211
|
+
msg: string,
|
|
212
|
+
contextLabel: string,
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
// Learn only from EXPLICIT mismatches in one-shot context.
|
|
215
|
+
// Silent-exit failures are ambiguous (transient outage vs real
|
|
216
|
+
// model-incompat) and persisting them would over-poison the
|
|
217
|
+
// learning store with the result that one bad heartbeat
|
|
218
|
+
// permanently downgrades the model. Explicit mismatches (the 400
|
|
219
|
+
// "not supported … ChatGPT account" and the 404 "model … does not
|
|
220
|
+
// exist") carry the unambiguous server message so they're safe to
|
|
221
|
+
// mark.
|
|
222
|
+
//
|
|
223
|
+
// Unlike the interactive handler, heartbeat/dream can't recurse for
|
|
224
|
+
// a retry (would mess with the timing contract and lock
|
|
225
|
+
// semantics), so the failure is surfaced to the caller — the task
|
|
226
|
+
// settles as failed — and the next scheduled run takes a fresh
|
|
227
|
+
// swing on the learned fallback.
|
|
228
|
+
const authInfo = getCodexAuthInfo();
|
|
229
|
+
const fallback = getCodexChatGptDefaultModel();
|
|
230
|
+
if (
|
|
231
|
+
authInfo?.mode !== "chatgpt" ||
|
|
232
|
+
activeModel === fallback ||
|
|
233
|
+
!isChatGptModelMismatchError(msg)
|
|
234
|
+
) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const recorded = await markOAuthIncompat(activeModel);
|
|
238
|
+
if (recorded) {
|
|
239
|
+
logWarn(
|
|
240
|
+
"agent",
|
|
241
|
+
`[${contextLabel}] Codex one-shot: recorded ${activeModel} as ` +
|
|
242
|
+
`OAuth-incompat (explicit mismatch) — next ${contextLabel} run ` +
|
|
243
|
+
`will pre-emptively swap to ${fallback}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A Codex one-shot that failed upstream. The message is the most specific
|
|
250
|
+
* reason the stream carried (the `turn.failed` text when there was one),
|
|
251
|
+
* so task tables, cron run records and the backend router see the cause
|
|
252
|
+
* rather than the SDK's generic exit wrapper.
|
|
253
|
+
*/
|
|
254
|
+
class CodexOneShotError extends Error {
|
|
255
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
256
|
+
super(message, options);
|
|
257
|
+
this.name = "CodexOneShotError";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Codex emits `error` events for transient retries it handles itself
|
|
263
|
+
* ("Reconnecting... 2/5 (…)", "Falling back from WebSockets to HTTPS
|
|
264
|
+
* transport. …"). Those are progress, not failure — only the terminal
|
|
265
|
+
* `turn.failed` / final `error` count.
|
|
266
|
+
*/
|
|
267
|
+
const TRANSIENT_ERROR_RE = /^\s*(reconnecting\.\.\.|falling back from)/i;
|
|
268
|
+
|
|
269
|
+
/** Tracks the terminal failure reported on a Codex event stream. */
|
|
270
|
+
class StreamFailure {
|
|
271
|
+
private turnFailed: string | undefined;
|
|
272
|
+
private lastError: string | undefined;
|
|
273
|
+
|
|
274
|
+
observe(event: { type: string } & Record<string, unknown>): void {
|
|
275
|
+
if (event.type === "turn.failed") {
|
|
276
|
+
const err = (event as { error?: { message?: unknown } }).error;
|
|
277
|
+
const text =
|
|
278
|
+
typeof err?.message === "string" && err.message.trim()
|
|
279
|
+
? err.message.trim()
|
|
280
|
+
: "turn failed (no message)";
|
|
281
|
+
this.turnFailed = text;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (event.type === "error" && typeof event.message === "string") {
|
|
285
|
+
const text = event.message.trim();
|
|
286
|
+
if (text && !TRANSIENT_ERROR_RE.test(text)) this.lastError = text;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The failure the stream reported, or undefined when it reported none. */
|
|
291
|
+
get message(): string | undefined {
|
|
292
|
+
return this.turnFailed ?? this.lastError;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Combine the stream's failure with whatever the SDK threw, most
|
|
297
|
+
* specific first, without repeating the same text twice.
|
|
298
|
+
*/
|
|
299
|
+
describe(thrown: string): string {
|
|
300
|
+
const streamed = this.message;
|
|
301
|
+
if (!streamed) return thrown;
|
|
302
|
+
if (!thrown || thrown === streamed || streamed.includes(thrown)) {
|
|
303
|
+
return streamed;
|
|
304
|
+
}
|
|
305
|
+
return `${streamed} (${thrown.trim()})`;
|
|
204
306
|
}
|
|
205
307
|
}
|
|
206
308
|
|