talon-agent 5.9.0 → 5.10.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
CHANGED
|
@@ -488,7 +488,7 @@ Config file: `~/.talon/config.json`
|
|
|
488
488
|
| `heartbeatModel` | --- | Model for the heartbeat agent (falls back to `model`) |
|
|
489
489
|
| `heartbeatEffort` | --- | Reasoning effort for the heartbeat agent: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Unset = the model's own default |
|
|
490
490
|
| `router` | --- | Plan-aware routing for background work: `{ "enabled": true, "ceilingPercent": 85 }`. Unpinned sub-agents, cron `query` jobs and heartbeats run on whichever backend has the most plan headroom, skipping any whose tightest window is at or above the ceiling. `enabled: false` restores inherit-the-caller's-backend ([Backends](docs/backends.md)) |
|
|
491
|
-
| `backendBudgets` | --- | Soft token budgets for backends with no usage API, e.g. `{ "
|
|
491
|
+
| `backendBudgets` | --- | Soft token budgets for backends with no usage API, e.g. `{ "openai-agents": { "tokensPer5h": 2000000, "tokensPerDay": 8000000 } }`. Talon's own rolling ledger is measured against these so such a backend still has a headroom signal — and it is what opts an idle backend into routing. `agy` reports its real quota windows (via `agy -p /usage`); a budget there is only a fallback for when that read fails |
|
|
492
492
|
| `dreamModel` | --- | Model for dream / memory consolidation (falls back to `model`) |
|
|
493
493
|
| `dreamEffort` | --- | Reasoning effort for the dream agent — same levels as `heartbeatEffort` |
|
|
494
494
|
| `braveApiKey` | --- | Brave Search API key |
|
package/package.json
CHANGED
|
@@ -35,6 +35,7 @@ import { evictOrphanSubprocesses } from "./process/orphans.js";
|
|
|
35
35
|
import { getState, resetState } from "./state.js";
|
|
36
36
|
import { resetChat, warmSession, refreshTools } from "./sessions.js";
|
|
37
37
|
import { killAllChildren } from "./process/child.js";
|
|
38
|
+
import { getAgyPlanUsage, resetAgyPlanUsage } from "./plan-usage.js";
|
|
38
39
|
import { unregisterAllMcp } from "./mcp/register.js";
|
|
39
40
|
import {
|
|
40
41
|
resolveModel,
|
|
@@ -92,11 +93,11 @@ const agyFactory: BackendFactory = {
|
|
|
92
93
|
// cache-write counter in its usage payload, hence `cacheMetrics: "read"`.
|
|
93
94
|
const usage: UsageTelemetry = {
|
|
94
95
|
getSessionSnapshot: async (chatId) => getState().lastUsage.get(chatId),
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
getPlanUsage:
|
|
96
|
+
// Quota windows come from `agy -p /usage --output-format text`
|
|
97
|
+
// (see plan-usage.ts): at most one spawn per cache window, and
|
|
98
|
+
// undefined on any failure so headroom falls back to the local
|
|
99
|
+
// `backendBudgets` ledger.
|
|
100
|
+
getPlanUsage: () => getAgyPlanUsage(),
|
|
100
101
|
};
|
|
101
102
|
|
|
102
103
|
const control: SystemControl = {
|
|
@@ -127,6 +128,7 @@ const agyFactory: BackendFactory = {
|
|
|
127
128
|
killAllChildren("shutdown");
|
|
128
129
|
unregisterAllMcp();
|
|
129
130
|
resetModelCache();
|
|
131
|
+
resetAgyPlanUsage();
|
|
130
132
|
resetState();
|
|
131
133
|
log("bot", "Antigravity backend cleaned up");
|
|
132
134
|
},
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity subscription quota windows for `/usage`, `/status` and the
|
|
3
|
+
* plan-aware router.
|
|
4
|
+
*
|
|
5
|
+
* `agy` has no account API, but its `/usage` slash command also runs
|
|
6
|
+
* headlessly: `agy -p /usage --output-format text` prints one
|
|
7
|
+
* tab-separated line per quota window and exits without a model call:
|
|
8
|
+
*
|
|
9
|
+
* Gemini Models Weekly Limit Remaining 60% 2026-09-26T17:40:26Z
|
|
10
|
+
* Gemini Models Five Hour Limit Remaining 100% 2026-09-23T21:23:05Z
|
|
11
|
+
*
|
|
12
|
+
* Columns are the model group, the window, the percent REMAINING, and the
|
|
13
|
+
* next reset (ISO-8601 UTC). Talon speaks percent used, so the figure is
|
|
14
|
+
* flipped on the way in.
|
|
15
|
+
*
|
|
16
|
+
* A read is a subprocess spawn (~a second of Go start-up plus an account
|
|
17
|
+
* round-trip), so it is cached for a minute, concurrent callers share one
|
|
18
|
+
* spawn, and a failed read backs off before trying again. Everything
|
|
19
|
+
* degrades to the last good value or `undefined` — never a throw, so
|
|
20
|
+
* `/status` and the router fall through to the local-budget ledger.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { logWarn } from "../../util/log.js";
|
|
25
|
+
import type {
|
|
26
|
+
PlanUsage,
|
|
27
|
+
PlanWindow,
|
|
28
|
+
} from "../../core/agent-runtime/capabilities.js";
|
|
29
|
+
import { AGY_KILL_GRACE_MS } from "./constants.js";
|
|
30
|
+
import { agyBinary } from "./state.js";
|
|
31
|
+
|
|
32
|
+
/** argv for the headless quota report. */
|
|
33
|
+
const AGY_USAGE_ARGS: readonly string[] = [
|
|
34
|
+
"-p",
|
|
35
|
+
"/usage",
|
|
36
|
+
"--output-format",
|
|
37
|
+
"text",
|
|
38
|
+
];
|
|
39
|
+
const RUN_TIMEOUT_MS = 20_000;
|
|
40
|
+
const CACHE_TTL_MS = 60_000;
|
|
41
|
+
/** After a failed read, how long to wait before spawning `agy` again. */
|
|
42
|
+
const FAILURE_BACKOFF_MS = 15_000;
|
|
43
|
+
/** The report is a handful of lines; anything past this is not it. */
|
|
44
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
45
|
+
|
|
46
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const HOUR_WORDS: Record<string, number> = {
|
|
49
|
+
one: 1,
|
|
50
|
+
two: 2,
|
|
51
|
+
three: 3,
|
|
52
|
+
four: 4,
|
|
53
|
+
five: 5,
|
|
54
|
+
six: 6,
|
|
55
|
+
eight: 8,
|
|
56
|
+
twelve: 12,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Short duration label for a window name, in the claude/codex vocabulary
|
|
61
|
+
* (`5h`, `7d`). Unknown window kinds return undefined and are skipped
|
|
62
|
+
* rather than rendered under a guessed name.
|
|
63
|
+
*/
|
|
64
|
+
function windowLabel(name: string): string | undefined {
|
|
65
|
+
const lower = name.toLowerCase();
|
|
66
|
+
if (/\bweekly\b/.test(lower)) return "7d";
|
|
67
|
+
if (/\bdaily\b/.test(lower)) return "1d";
|
|
68
|
+
const hours = /\b(\d+|[a-z]+)[\s-]+hours?\b/.exec(lower);
|
|
69
|
+
if (hours) {
|
|
70
|
+
const raw = hours[1] as string;
|
|
71
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : HOUR_WORDS[raw];
|
|
72
|
+
if (n && n > 0) return n % 24 === 0 ? `${n / 24}d` : `${n}h`;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `Gemini Models` → `Gemini`, `Claude and GPT models` → `Claude/GPT`. */
|
|
78
|
+
function groupLabel(name: string): string {
|
|
79
|
+
const short = name
|
|
80
|
+
.replace(/\s+models?$/i, "")
|
|
81
|
+
.replace(/\s+and\s+/gi, "/")
|
|
82
|
+
.trim();
|
|
83
|
+
return short.length > 0 ? short : name.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Percent remaining (`60%`, `60`, `12.5 %`) → percent used, 0-100. */
|
|
87
|
+
function usedPercent(raw: string): number | undefined {
|
|
88
|
+
const match = /^(\d+(?:\.\d+)?)\s*%?$/.exec(raw.trim());
|
|
89
|
+
if (!match) return undefined;
|
|
90
|
+
const remaining = Number(match[1]);
|
|
91
|
+
if (!Number.isFinite(remaining)) return undefined;
|
|
92
|
+
return Math.max(0, Math.min(100, Math.round(100 - remaining)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// oxlint-disable-next-line no-control-regex -- stripping terminal escapes
|
|
96
|
+
const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
97
|
+
|
|
98
|
+
function columns(line: string): string[] {
|
|
99
|
+
const clean = line.replace(ANSI, "").trim();
|
|
100
|
+
// Tabs are the real separator; runs of 2+ spaces cover a report that
|
|
101
|
+
// was padded into aligned columns instead.
|
|
102
|
+
const parts = clean.includes("\t")
|
|
103
|
+
? clean.split(/\t+/)
|
|
104
|
+
: clean.split(/ {2,}/);
|
|
105
|
+
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseLine(line: string): PlanWindow | undefined {
|
|
109
|
+
const [group, window, percent, reset] = columns(line);
|
|
110
|
+
if (!group || !window || !percent) return undefined;
|
|
111
|
+
const duration = windowLabel(window);
|
|
112
|
+
const used = usedPercent(percent);
|
|
113
|
+
if (!duration || used === undefined) return undefined;
|
|
114
|
+
return {
|
|
115
|
+
label: `${groupLabel(group)} · ${duration}`,
|
|
116
|
+
percent: used,
|
|
117
|
+
...(reset && Number.isFinite(Date.parse(reset)) ? { resetsAt: reset } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse `agy /usage` text output. Malformed or unknown lines are skipped;
|
|
123
|
+
* `undefined` when no line yields a window.
|
|
124
|
+
*/
|
|
125
|
+
export function parseAgyUsage(stdout: string): PlanUsage | undefined {
|
|
126
|
+
const windows: PlanWindow[] = [];
|
|
127
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
128
|
+
const window = parseLine(line);
|
|
129
|
+
if (window) windows.push(window);
|
|
130
|
+
}
|
|
131
|
+
if (windows.length === 0) return undefined;
|
|
132
|
+
return { windows, fetchedAt: Date.now() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Running ─────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Spawn `agy -p /usage` once and parse what it prints. Non-zero exit,
|
|
139
|
+
* spawn error, timeout or unparseable output all resolve `undefined`.
|
|
140
|
+
*/
|
|
141
|
+
export function runAgyUsage(
|
|
142
|
+
binary: string = agyBinary(),
|
|
143
|
+
timeoutMs: number = RUN_TIMEOUT_MS,
|
|
144
|
+
): Promise<PlanUsage | undefined> {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
let settled = false;
|
|
147
|
+
let stdout = "";
|
|
148
|
+
let stderr = "";
|
|
149
|
+
const finish = (value: PlanUsage | undefined, why?: string) => {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
settled = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (why) logWarn("agent", `agy usage: ${why}`);
|
|
154
|
+
resolve(value);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
let proc: ReturnType<typeof spawn>;
|
|
158
|
+
try {
|
|
159
|
+
proc = spawn(binary, [...AGY_USAGE_ARGS], {
|
|
160
|
+
env: process.env,
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
resolve(undefined);
|
|
166
|
+
logWarn(
|
|
167
|
+
"agent",
|
|
168
|
+
`agy usage: ${err instanceof Error ? err.message : String(err)}`,
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
finish(undefined, `timed out after ${timeoutMs}ms`);
|
|
175
|
+
proc.kill("SIGTERM");
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
178
|
+
proc.kill("SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
}, AGY_KILL_GRACE_MS).unref?.();
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
|
|
183
|
+
proc.stdout?.setEncoding("utf-8");
|
|
184
|
+
proc.stdout?.on("data", (chunk: string) => {
|
|
185
|
+
if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk;
|
|
186
|
+
});
|
|
187
|
+
proc.stderr?.setEncoding("utf-8");
|
|
188
|
+
proc.stderr?.on("data", (chunk: string) => {
|
|
189
|
+
stderr = (stderr + chunk).slice(-500);
|
|
190
|
+
});
|
|
191
|
+
proc.on("error", (err) => finish(undefined, err.message));
|
|
192
|
+
proc.on("close", (code) => {
|
|
193
|
+
if (code !== 0) {
|
|
194
|
+
const tail = stderr.trim().split("\n").pop() ?? "";
|
|
195
|
+
finish(undefined, `exited ${code}${tail ? `: ${tail}` : ""}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const usage = parseAgyUsage(stdout);
|
|
199
|
+
finish(usage, usage ? undefined : "no quota windows in output");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Cache ───────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
let cache: { value: PlanUsage; fetchedAt: number } | undefined;
|
|
207
|
+
let lastFailureAt: number | undefined;
|
|
208
|
+
let inFlight: Promise<PlanUsage | undefined> | undefined;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Plan windows, cached for a minute with concurrent callers sharing one
|
|
212
|
+
* spawn. A failed refresh serves the last good value (its `fetchedAt`
|
|
213
|
+
* lets renderers age it) and is not retried for {@link FAILURE_BACKOFF_MS},
|
|
214
|
+
* so a busy router cannot turn a broken `agy` into a spawn storm.
|
|
215
|
+
*/
|
|
216
|
+
export async function getAgyPlanUsage(): Promise<PlanUsage | undefined> {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) return cache.value;
|
|
219
|
+
if (lastFailureAt !== undefined && now - lastFailureAt < FAILURE_BACKOFF_MS)
|
|
220
|
+
return cache?.value;
|
|
221
|
+
|
|
222
|
+
inFlight ??= runAgyUsage().finally(() => {
|
|
223
|
+
inFlight = undefined;
|
|
224
|
+
});
|
|
225
|
+
const loaded = await inFlight;
|
|
226
|
+
if (loaded) {
|
|
227
|
+
cache = { value: loaded, fetchedAt: loaded.fetchedAt };
|
|
228
|
+
lastFailureAt = undefined;
|
|
229
|
+
} else {
|
|
230
|
+
lastFailureAt = Date.now();
|
|
231
|
+
}
|
|
232
|
+
return loaded ?? cache?.value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop the cached reading — backend cleanup and test isolation. */
|
|
236
|
+
export function resetAgyPlanUsage(): void {
|
|
237
|
+
cache = undefined;
|
|
238
|
+
lastFailureAt = undefined;
|
|
239
|
+
inFlight = undefined;
|
|
240
|
+
}
|
package/src/core/config/index.ts
CHANGED
|
@@ -464,15 +464,18 @@ const configSchema = z.object({
|
|
|
464
464
|
})
|
|
465
465
|
.optional(),
|
|
466
466
|
/**
|
|
467
|
-
* Soft token budgets for backends with no account usage API
|
|
468
|
-
* openai-agents). Talon keeps a local rolling ledger of every turn and
|
|
467
|
+
* Soft token budgets for backends with no account usage API
|
|
468
|
+
* (openai-agents). Talon keeps a local rolling ledger of every turn and
|
|
469
469
|
* one-shot it runs on a backend and derives headroom from it, so a
|
|
470
470
|
* provider that cannot report a plan still has a load-balancing signal.
|
|
471
|
-
*
|
|
472
|
-
*
|
|
471
|
+
* A backend's own plan windows always win; for agy (which reads its
|
|
472
|
+
* quota from `agy -p /usage`) a budget is only the fallback for when
|
|
473
|
+
* that read fails. Keyed by backend id; a backend with no entry
|
|
474
|
+
* contributes no signal (headroom 1, ranked below any backend with real
|
|
475
|
+
* telemetry on a tie).
|
|
473
476
|
*
|
|
474
477
|
* Example:
|
|
475
|
-
* "backendBudgets": { "
|
|
478
|
+
* "backendBudgets": { "openai-agents": { "tokensPer5h": 2000000, "tokensPerDay": 8000000 } }
|
|
476
479
|
*/
|
|
477
480
|
backendBudgets: z
|
|
478
481
|
.record(
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Local rolling token ledger — the headroom signal for backends with no
|
|
3
3
|
* account usage API.
|
|
4
4
|
*
|
|
5
|
-
* Claude and
|
|
6
|
-
*
|
|
7
|
-
* the router would treat those as infinitely fresh and pile every
|
|
8
|
-
* run onto them. So Talon counts what it spends itself: every chat turn,
|
|
5
|
+
* Claude, Codex and `agy` report subscription windows; `openai-agents` has
|
|
6
|
+
* no plan at all, and `agy`'s read (a CLI spawn) can fail. Without a second
|
|
7
|
+
* signal the router would treat those as infinitely fresh and pile every
|
|
8
|
+
* background run onto them. So Talon counts what it spends itself: every chat turn,
|
|
9
9
|
* one-shot and sub-agent folds its token total into a per-backend ledger,
|
|
10
10
|
* and `headroom.ts` reads that against the operator's soft budget
|
|
11
11
|
* (`config.backendBudgets`).
|