talon-agent 5.3.0 → 5.4.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 +10 -6
- package/package.json +1 -1
- package/src/backend/agy/auth.ts +128 -0
- package/src/backend/agy/constants.ts +88 -0
- package/src/backend/agy/doctor.ts +161 -0
- package/src/backend/agy/effort.ts +76 -0
- package/src/backend/agy/events.ts +402 -0
- package/src/backend/agy/factory.ts +137 -0
- package/src/backend/agy/handler/index.ts +9 -0
- package/src/backend/agy/handler/message.ts +444 -0
- package/src/backend/agy/init.ts +64 -0
- package/src/backend/agy/mcp/config.ts +336 -0
- package/src/backend/agy/mcp/register.ts +134 -0
- package/src/backend/agy/models.ts +335 -0
- package/src/backend/agy/one-shot.ts +301 -0
- package/src/backend/agy/process/child.ts +378 -0
- package/src/backend/agy/process/orphans.ts +106 -0
- package/src/backend/agy/sessions.ts +90 -0
- package/src/backend/agy/state.ts +77 -0
- package/src/backend/builtins.ts +1 -0
- package/src/cli/config-view.ts +5 -0
- package/src/cli/config.ts +4 -2
- package/src/cli/setup.ts +49 -0
- package/src/core/agent-runtime/model-ref.ts +1 -0
- package/src/core/config/index.ts +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity model catalog.
|
|
3
|
+
*
|
|
4
|
+
* There is no models API and no cache file to read — the authority is
|
|
5
|
+
* `agy models`, which prints a "Fetching available models..." line
|
|
6
|
+
* followed by one `id<TAB>label` row per model. Talon runs it once at
|
|
7
|
+
* init and re-runs it behind a TTL, so a model Google adds appears
|
|
8
|
+
* without a Talon release.
|
|
9
|
+
*
|
|
10
|
+
* Effort is baked into most ids (`gemini-3.8-flash-high|medium|low`),
|
|
11
|
+
* so the catalog is where `supportedReasoningLevels` comes from too:
|
|
12
|
+
* a model advertises the levels its sibling slugs exist for, plus
|
|
13
|
+
* whatever `--effort` can express. See `effort.ts` for the precedence.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import type {
|
|
19
|
+
UnifiedModelInfo,
|
|
20
|
+
UnifiedModelResolution,
|
|
21
|
+
UnifiedProviderInfo,
|
|
22
|
+
ModelButton,
|
|
23
|
+
ModelPickerOptions,
|
|
24
|
+
ModelPickerResult,
|
|
25
|
+
ReasoningEffortLevel,
|
|
26
|
+
} from "../../core/types.js";
|
|
27
|
+
import { logWarn } from "../../util/log.js";
|
|
28
|
+
import { agyBinary } from "./state.js";
|
|
29
|
+
import { AGY_DEFAULT_MODEL } from "./constants.js";
|
|
30
|
+
import { effortSuffixOf, modelIdStem } from "./effort.js";
|
|
31
|
+
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
|
|
34
|
+
/** How long a parsed catalog is reused before `agy models` runs again. */
|
|
35
|
+
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
/** Vendor prefix → Talon provider identity. */
|
|
38
|
+
const PROVIDERS: ReadonlyArray<{
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
prefixes: readonly string[];
|
|
42
|
+
}> = [
|
|
43
|
+
{ id: "google", name: "Google", prefixes: ["gemini"] },
|
|
44
|
+
{ id: "anthropic", name: "Anthropic", prefixes: ["claude"] },
|
|
45
|
+
{ id: "openai", name: "OpenAI", prefixes: ["gpt", "o1", "o3", "o4"] },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export type AgyModelInfo = UnifiedModelInfo;
|
|
49
|
+
|
|
50
|
+
// ── Cache ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
let catalog: AgyModelInfo[] = [];
|
|
53
|
+
let fetchedAt: number | null = null;
|
|
54
|
+
let inFlight: Promise<AgyModelInfo[]> | null = null;
|
|
55
|
+
|
|
56
|
+
/** Drop the cached catalog — test isolation and the cleanup hook. */
|
|
57
|
+
export function resetModelCache(): void {
|
|
58
|
+
catalog = [];
|
|
59
|
+
fetchedAt = null;
|
|
60
|
+
inFlight = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The catalog as last parsed, without triggering a probe. */
|
|
64
|
+
export function getCachedModels(): AgyModelInfo[] {
|
|
65
|
+
return [...catalog];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse `agy models` stdout.
|
|
72
|
+
*
|
|
73
|
+
* Tolerates the "Fetching available models..." preamble the CLI prints
|
|
74
|
+
* before the table, blank lines, and any future non-tabbed banner:
|
|
75
|
+
* only lines with a TAB are rows, and an empty id is skipped.
|
|
76
|
+
*/
|
|
77
|
+
export function parseAgyModels(stdout: string): AgyModelInfo[] {
|
|
78
|
+
const rows: Array<{ id: string; label: string }> = [];
|
|
79
|
+
for (const line of stdout.split("\n")) {
|
|
80
|
+
const tab = line.indexOf("\t");
|
|
81
|
+
if (tab < 0) continue;
|
|
82
|
+
const id = line.slice(0, tab).trim();
|
|
83
|
+
const label = line.slice(tab + 1).trim();
|
|
84
|
+
if (!id) continue;
|
|
85
|
+
rows.push({ id, label });
|
|
86
|
+
}
|
|
87
|
+
const byStem = new Map<string, Set<string>>();
|
|
88
|
+
for (const { id } of rows) {
|
|
89
|
+
const suffix = effortSuffixOf(id);
|
|
90
|
+
if (!suffix) continue;
|
|
91
|
+
const stem = modelIdStem(id);
|
|
92
|
+
const set = byStem.get(stem) ?? new Set<string>();
|
|
93
|
+
set.add(suffix);
|
|
94
|
+
byStem.set(stem, set);
|
|
95
|
+
}
|
|
96
|
+
return rows.map(({ id, label }) => toModelInfo(id, label, byStem));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toModelInfo(
|
|
100
|
+
id: string,
|
|
101
|
+
label: string,
|
|
102
|
+
byStem: Map<string, Set<string>>,
|
|
103
|
+
): AgyModelInfo {
|
|
104
|
+
const provider = providerFor(id);
|
|
105
|
+
const siblings = byStem.get(modelIdStem(id));
|
|
106
|
+
const suffix = effortSuffixOf(id);
|
|
107
|
+
// Levels this model can actually be run at: the suffixes its sibling
|
|
108
|
+
// slugs exist for, or — for a suffix-less id — the three `--effort`
|
|
109
|
+
// accepts. Ordered low→high so pickers render predictably.
|
|
110
|
+
const levels: ReasoningEffortLevel[] = (
|
|
111
|
+
["low", "medium", "high"] as const
|
|
112
|
+
).filter((level) => (siblings ? siblings.has(level) : true));
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
displayName: label || id,
|
|
116
|
+
provider: provider.id,
|
|
117
|
+
providerName: provider.name,
|
|
118
|
+
selectable: true,
|
|
119
|
+
reasoning: true,
|
|
120
|
+
supportedReasoningLevels: levels,
|
|
121
|
+
...(suffix ? { defaultReasoningLevel: suffix } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function providerFor(id: string): { id: string; name: string } {
|
|
126
|
+
const lower = id.toLowerCase();
|
|
127
|
+
for (const provider of PROVIDERS) {
|
|
128
|
+
if (provider.prefixes.some((p) => lower.startsWith(p))) {
|
|
129
|
+
return { id: provider.id, name: provider.name };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { id: "antigravity", name: "Antigravity" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Minimal entry for an id the catalog doesn't list — a model the user
|
|
137
|
+
* pinned by hand, or one that landed between probes. Mirrors codex's
|
|
138
|
+
* `synthesizeUnknownModel`: never hide a configured model from
|
|
139
|
+
* `/status` just because discovery hasn't caught up.
|
|
140
|
+
*/
|
|
141
|
+
export function synthesizeUnknownModel(id: string): AgyModelInfo {
|
|
142
|
+
const provider = providerFor(id);
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
displayName: id,
|
|
146
|
+
provider: provider.id,
|
|
147
|
+
providerName: provider.name,
|
|
148
|
+
selectable: true,
|
|
149
|
+
reasoning: true,
|
|
150
|
+
supportedReasoningLevels: ["low", "medium", "high"],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Probing ─────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run `agy models` and cache the result. Concurrent callers share one
|
|
158
|
+
* spawn; a failure is logged and leaves the previous catalog in place
|
|
159
|
+
* (an empty one on the very first failure), so a transient network
|
|
160
|
+
* blip degrades the picker rather than breaking a turn.
|
|
161
|
+
*/
|
|
162
|
+
export async function refreshModels(force = false): Promise<AgyModelInfo[]> {
|
|
163
|
+
const fresh =
|
|
164
|
+
fetchedAt !== null && Date.now() - fetchedAt < CATALOG_TTL_MS && !force;
|
|
165
|
+
if (fresh && catalog.length > 0) return [...catalog];
|
|
166
|
+
if (inFlight) return inFlight;
|
|
167
|
+
|
|
168
|
+
inFlight = (async () => {
|
|
169
|
+
try {
|
|
170
|
+
const { stdout } = await execFileAsync(agyBinary(), ["models"], {
|
|
171
|
+
timeout: 30_000,
|
|
172
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
173
|
+
});
|
|
174
|
+
const parsed = parseAgyModels(stdout);
|
|
175
|
+
if (parsed.length > 0) {
|
|
176
|
+
catalog = parsed;
|
|
177
|
+
fetchedAt = Date.now();
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logWarn(
|
|
181
|
+
"agent",
|
|
182
|
+
`agy models failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
inFlight = null;
|
|
186
|
+
}
|
|
187
|
+
return [...catalog];
|
|
188
|
+
})();
|
|
189
|
+
return inFlight;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Catalog for read paths: cached when warm, probed when cold. */
|
|
193
|
+
async function effectiveModels(): Promise<AgyModelInfo[]> {
|
|
194
|
+
if (catalog.length > 0) return [...catalog];
|
|
195
|
+
return refreshModels();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Catalog surface ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve a query: exact id, then case-insensitive id, then a prefix
|
|
202
|
+
* match on id or display name. Ambiguity is reported rather than
|
|
203
|
+
* guessed — headless agy exits non-zero on an unknown `--model`, so a
|
|
204
|
+
* wrong guess costs a failed turn.
|
|
205
|
+
*/
|
|
206
|
+
export async function resolveModel(
|
|
207
|
+
query: string,
|
|
208
|
+
): Promise<UnifiedModelResolution> {
|
|
209
|
+
const q = query.trim();
|
|
210
|
+
if (!q) return { kind: "missing" };
|
|
211
|
+
const models = await effectiveModels();
|
|
212
|
+
|
|
213
|
+
const exact = models.find((m) => m.id === q);
|
|
214
|
+
if (exact) return { kind: "exact", model: exact, storedValue: exact.id };
|
|
215
|
+
|
|
216
|
+
const lower = q.toLowerCase();
|
|
217
|
+
const insensitive = models.find((m) => m.id.toLowerCase() === lower);
|
|
218
|
+
if (insensitive) {
|
|
219
|
+
return { kind: "exact", model: insensitive, storedValue: insensitive.id };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const matches = models.filter(
|
|
223
|
+
(m) =>
|
|
224
|
+
m.id.toLowerCase().startsWith(lower) ||
|
|
225
|
+
m.displayName.toLowerCase().startsWith(lower),
|
|
226
|
+
);
|
|
227
|
+
if (matches.length === 0) return { kind: "missing" };
|
|
228
|
+
if (matches.length === 1) {
|
|
229
|
+
return { kind: "exact", model: matches[0], storedValue: matches[0].id };
|
|
230
|
+
}
|
|
231
|
+
return { kind: "ambiguous", matches };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Default model id. */
|
|
235
|
+
export function getDefaultModelId(): string {
|
|
236
|
+
return AGY_DEFAULT_MODEL;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Look up one model, synthesising an entry for an unlisted id. */
|
|
240
|
+
export async function getModelInfo(
|
|
241
|
+
id: string,
|
|
242
|
+
): Promise<UnifiedModelInfo | undefined> {
|
|
243
|
+
if (!id) return undefined;
|
|
244
|
+
const models = await effectiveModels();
|
|
245
|
+
return models.find((m) => m.id === id) ?? synthesizeUnknownModel(id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Quick-pick buttons for `/model` and `/settings`. */
|
|
249
|
+
export async function getSettingsPresentation(
|
|
250
|
+
activeModel: string,
|
|
251
|
+
options: ModelPickerOptions = {},
|
|
252
|
+
): Promise<ModelPickerResult> {
|
|
253
|
+
const models = await effectiveModels();
|
|
254
|
+
const callbackPrefix = options.callbackPrefix ?? "settings:model:";
|
|
255
|
+
const modelButtons: ModelButton[] = models.map((m) => ({
|
|
256
|
+
text: `${m.id === activeModel ? "● " : ""}${m.displayName}`,
|
|
257
|
+
callback_data: `${callbackPrefix}${m.id}`,
|
|
258
|
+
}));
|
|
259
|
+
const active = models.find((m) => m.id === activeModel);
|
|
260
|
+
const modelDetails = [
|
|
261
|
+
...(active ? [`Active: ${active.displayName} (${active.id})`] : []),
|
|
262
|
+
`Backend: Antigravity — ${models.length} models (agy models)`,
|
|
263
|
+
];
|
|
264
|
+
return {
|
|
265
|
+
modelButtons,
|
|
266
|
+
modelDetails,
|
|
267
|
+
view: "models",
|
|
268
|
+
page: 1,
|
|
269
|
+
totalPages: 1,
|
|
270
|
+
filter: "all",
|
|
271
|
+
freeCount: 0,
|
|
272
|
+
totalCount: models.length,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Providers, grouped by the vendor prefix of each model id. */
|
|
277
|
+
export async function getProviders(): Promise<UnifiedProviderInfo[]> {
|
|
278
|
+
const models = await effectiveModels();
|
|
279
|
+
const counts = new Map<string, number>();
|
|
280
|
+
for (const m of models) {
|
|
281
|
+
counts.set(m.provider, (counts.get(m.provider) ?? 0) + 1);
|
|
282
|
+
}
|
|
283
|
+
return [...counts.entries()].map(([id, modelCount]) => ({
|
|
284
|
+
id,
|
|
285
|
+
name: models.find((m) => m.provider === id)?.providerName ?? id,
|
|
286
|
+
connected: true,
|
|
287
|
+
modelCount,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Models for one provider (paginated). */
|
|
292
|
+
export async function getProviderModels(
|
|
293
|
+
providerId: string,
|
|
294
|
+
page = 1,
|
|
295
|
+
pageSize = 50,
|
|
296
|
+
): Promise<{ models: UnifiedModelInfo[]; total: number }> {
|
|
297
|
+
const models = (await effectiveModels()).filter(
|
|
298
|
+
(m) => m.provider === providerId,
|
|
299
|
+
);
|
|
300
|
+
const start = (page - 1) * pageSize;
|
|
301
|
+
return {
|
|
302
|
+
models: models.slice(start, start + pageSize),
|
|
303
|
+
total: models.length,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Human-readable error for an unresolvable model query. */
|
|
308
|
+
export function formatModelError(
|
|
309
|
+
query: string,
|
|
310
|
+
resolution: UnifiedModelResolution,
|
|
311
|
+
): string {
|
|
312
|
+
if (resolution.kind === "ambiguous") {
|
|
313
|
+
const list = resolution.matches.map((m) => `\`${m.id}\``).join(", ");
|
|
314
|
+
return `Multiple Antigravity models match \`${query}\`: ${list}. Pick one.`;
|
|
315
|
+
}
|
|
316
|
+
const ids = catalog.map((m) => m.id).join(", ");
|
|
317
|
+
return (
|
|
318
|
+
`No Antigravity model matches \`${query}\`. ` +
|
|
319
|
+
(ids
|
|
320
|
+
? `Available: ${ids}.`
|
|
321
|
+
: "Run `agy models` to see what your account offers.")
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Catalog listing. Antigravity is subscription-backed with no free
|
|
327
|
+
* tier, so `free` is honestly empty rather than aliased to `all`.
|
|
328
|
+
*/
|
|
329
|
+
export async function listModels(
|
|
330
|
+
filter?: "free" | "all",
|
|
331
|
+
): Promise<{ models: UnifiedModelInfo[]; total: number }> {
|
|
332
|
+
if (filter === "free") return { models: [], total: 0 };
|
|
333
|
+
const models = await refreshModels();
|
|
334
|
+
return { models, total: models.length };
|
|
335
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity one-shot runner — heartbeat, dream, cron and sub-agents.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the chat path (one warm child per chat, many turns) an
|
|
5
|
+
* isolated run gets a FRESH `agy -p` process that exits when the turn
|
|
6
|
+
* does. The output format is still `stream-json` so tool steps reach
|
|
7
|
+
* the run log while the run is happening, which is what claude-sdk and
|
|
8
|
+
* codex both do — a heartbeat that only logged its final answer would
|
|
9
|
+
* be undebuggable.
|
|
10
|
+
*
|
|
11
|
+
* Its MCP entries live under their own `__talon__oneshot-<label>__`
|
|
12
|
+
* scope so a heartbeat's servers never collide with a chat's, and are
|
|
13
|
+
* removed in a `finally` whatever the run did.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
18
|
+
import { log, logWarn } from "../../util/log.js";
|
|
19
|
+
import { appendBackendSuffix } from "../runtime/index.js";
|
|
20
|
+
import { emitAssistantText } from "../runtime/one-shot-hooks.js";
|
|
21
|
+
import { AGY_SYSTEM_PROMPT_SUFFIX } from "./constants.js";
|
|
22
|
+
import { agyBinary, getState } from "./state.js";
|
|
23
|
+
import { agyScopeSlug } from "./mcp/config.js";
|
|
24
|
+
import { registerMcpForChat, unregisterMcpScope } from "./mcp/register.js";
|
|
25
|
+
import { toAgyEffort } from "./effort.js";
|
|
26
|
+
import { getDefaultModelId } from "./models.js";
|
|
27
|
+
import { isAgyAuthFailure, agyAuthError } from "./auth.js";
|
|
28
|
+
import {
|
|
29
|
+
agyUsageToTokens,
|
|
30
|
+
describeAgyTool,
|
|
31
|
+
parseAgyLine,
|
|
32
|
+
type AgyEvent,
|
|
33
|
+
type AgyResult,
|
|
34
|
+
type AgyStepUpdate,
|
|
35
|
+
} from "./events.js";
|
|
36
|
+
|
|
37
|
+
const ts = (): string => new Date().toISOString().slice(11, 19);
|
|
38
|
+
|
|
39
|
+
/** MCP scope for one isolated run — never a chat's. */
|
|
40
|
+
export function oneShotScope(contextLabel: string): string {
|
|
41
|
+
return `oneshot-${agyScopeSlug(contextLabel)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SpawnOutcome {
|
|
45
|
+
result: AgyResult | undefined;
|
|
46
|
+
stderr: string;
|
|
47
|
+
code: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Everything the child loop needs, so the spawn helper stays small. */
|
|
51
|
+
interface RunInputs {
|
|
52
|
+
binary: string;
|
|
53
|
+
args: string[];
|
|
54
|
+
cwd: string;
|
|
55
|
+
prompt: string;
|
|
56
|
+
abortController: AbortController;
|
|
57
|
+
appendLog: (text: string) => Promise<void>;
|
|
58
|
+
onAssistantText?: OneShotAgentParams["onAssistantText"];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Render one stream event into the run log (and the assistant hook). */
|
|
62
|
+
async function logEvent(
|
|
63
|
+
inputs: Pick<RunInputs, "appendLog" | "onAssistantText">,
|
|
64
|
+
event: AgyEvent,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const { appendLog } = inputs;
|
|
67
|
+
if (event.event === "init") {
|
|
68
|
+
await appendLog(
|
|
69
|
+
`\n### [${ts()}] Conversation started\n\`${event.conversation_id ?? "(unknown)"}\`\n`,
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (event.event === "result") {
|
|
74
|
+
await logResult(appendLog, event.result);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const step = event.step_update;
|
|
78
|
+
if (step) await logStep(inputs, step);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function logStep(
|
|
82
|
+
inputs: Pick<RunInputs, "appendLog" | "onAssistantText">,
|
|
83
|
+
step: AgyStepUpdate,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
if (step.step_type === "tool" && step.state !== "ACTIVE") {
|
|
86
|
+
const shape = describeAgyTool(step);
|
|
87
|
+
const where = shape.server ? `${shape.server}.${shape.name}` : shape.name;
|
|
88
|
+
const failed = step.state === "ERROR" || Boolean(step.tool_info?.error);
|
|
89
|
+
const detail = step.tool_info?.error?.message;
|
|
90
|
+
await inputs.appendLog(
|
|
91
|
+
`\n**Tool:** \`${where}\`${failed ? " (FAILED)" : ""}\n` +
|
|
92
|
+
"```json\n" +
|
|
93
|
+
`${JSON.stringify(shape.input, null, 2).slice(0, 2000)}\n` +
|
|
94
|
+
"```\n" +
|
|
95
|
+
(detail ? `${detail}\n` : ""),
|
|
96
|
+
);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (step.step_type === "agent_response" && step.state === "DONE") {
|
|
100
|
+
const usage = step.usage;
|
|
101
|
+
if (usage) {
|
|
102
|
+
await inputs.appendLog(
|
|
103
|
+
`\n_step: input=${usage.input_tokens ?? 0} output=${usage.output_tokens ?? 0} ` +
|
|
104
|
+
`thinking=${usage.thinking_tokens ?? 0} cached=${usage.cache_read_tokens ?? 0}_\n`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function logResult(
|
|
111
|
+
appendLog: (text: string) => Promise<void>,
|
|
112
|
+
result: AgyResult | undefined,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
if (!result) return;
|
|
115
|
+
if (result.status && result.status !== "SUCCESS") {
|
|
116
|
+
await appendLog(
|
|
117
|
+
`\n### [${ts()}] Turn ${result.status}\n${result.error ?? "(no message)"}\n`,
|
|
118
|
+
);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const u = result.usage;
|
|
122
|
+
await appendLog(
|
|
123
|
+
`\n### [${ts()}] Turn completed\ninput=${u?.input_tokens ?? 0} ` +
|
|
124
|
+
`output=${u?.output_tokens ?? 0} thinking=${u?.thinking_tokens ?? 0} ` +
|
|
125
|
+
`cached=${u?.cache_read_tokens ?? 0}\n`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Spawn `agy -p`, stream its NDJSON into the log, resolve on exit. */
|
|
130
|
+
function runAgyOnce(inputs: RunInputs): Promise<SpawnOutcome> {
|
|
131
|
+
return new Promise<SpawnOutcome>((resolve, reject) => {
|
|
132
|
+
const proc = spawn(inputs.binary, inputs.args, {
|
|
133
|
+
cwd: inputs.cwd,
|
|
134
|
+
env: process.env,
|
|
135
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
136
|
+
});
|
|
137
|
+
const outcome: SpawnOutcome = { result: undefined, stderr: "", code: null };
|
|
138
|
+
let buffer = "";
|
|
139
|
+
let queue: Promise<void> = Promise.resolve();
|
|
140
|
+
|
|
141
|
+
const onAbort = () => proc.kill("SIGTERM");
|
|
142
|
+
inputs.abortController.signal.addEventListener("abort", onAbort, {
|
|
143
|
+
once: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
proc.stdout.setEncoding("utf-8");
|
|
147
|
+
proc.stdout.on("data", (chunk: string) => {
|
|
148
|
+
buffer += chunk;
|
|
149
|
+
let nl = buffer.indexOf("\n");
|
|
150
|
+
while (nl >= 0) {
|
|
151
|
+
const line = buffer.slice(0, nl);
|
|
152
|
+
buffer = buffer.slice(nl + 1);
|
|
153
|
+
const event = parseAgyLine(line);
|
|
154
|
+
if (event) {
|
|
155
|
+
if (event.event === "result") outcome.result = event.result;
|
|
156
|
+
queue = queue.then(() => logEvent(inputs, event)).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
nl = buffer.indexOf("\n");
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
proc.stderr.setEncoding("utf-8");
|
|
162
|
+
proc.stderr.on("data", (chunk: string) => {
|
|
163
|
+
outcome.stderr = (outcome.stderr + chunk).slice(-4000);
|
|
164
|
+
});
|
|
165
|
+
proc.on("error", (err) => {
|
|
166
|
+
inputs.abortController.signal.removeEventListener("abort", onAbort);
|
|
167
|
+
reject(err);
|
|
168
|
+
});
|
|
169
|
+
proc.on("close", (code) => {
|
|
170
|
+
inputs.abortController.signal.removeEventListener("abort", onAbort);
|
|
171
|
+
outcome.code = code;
|
|
172
|
+
queue.then(() => resolve(outcome)).catch(() => resolve(outcome));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Build the argv for a one-shot `agy -p` run. */
|
|
178
|
+
export function buildOneShotArgs(params: {
|
|
179
|
+
prompt: string;
|
|
180
|
+
model: string;
|
|
181
|
+
effort?: string;
|
|
182
|
+
workspace: string;
|
|
183
|
+
}): string[] {
|
|
184
|
+
const args = [
|
|
185
|
+
"-p",
|
|
186
|
+
params.prompt,
|
|
187
|
+
"--output-format",
|
|
188
|
+
"stream-json",
|
|
189
|
+
"--dangerously-skip-permissions",
|
|
190
|
+
"--print-timeout",
|
|
191
|
+
"0s",
|
|
192
|
+
"--model",
|
|
193
|
+
params.model,
|
|
194
|
+
"--add-dir",
|
|
195
|
+
params.workspace,
|
|
196
|
+
];
|
|
197
|
+
if (params.effort) args.push("--effort", params.effort);
|
|
198
|
+
return args;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runOneShotAgent(
|
|
202
|
+
params: OneShotAgentParams,
|
|
203
|
+
): Promise<OneShotUsage | void> {
|
|
204
|
+
const {
|
|
205
|
+
prompt,
|
|
206
|
+
systemPrompt,
|
|
207
|
+
model,
|
|
208
|
+
reasoningEffort,
|
|
209
|
+
workspace,
|
|
210
|
+
contextLabel,
|
|
211
|
+
abortController,
|
|
212
|
+
appendLog,
|
|
213
|
+
onAssistantText,
|
|
214
|
+
} = params;
|
|
215
|
+
|
|
216
|
+
const scope = oneShotScope(contextLabel);
|
|
217
|
+
registerMcpForChat(contextLabel, { scope });
|
|
218
|
+
|
|
219
|
+
// No system-prompt flag; a one-shot has no history to inherit one
|
|
220
|
+
// from, so it is prepended to the prompt verbatim.
|
|
221
|
+
const finalSystemPrompt = appendBackendSuffix(
|
|
222
|
+
systemPrompt,
|
|
223
|
+
AGY_SYSTEM_PROMPT_SUFFIX,
|
|
224
|
+
);
|
|
225
|
+
const effort = toAgyEffort(reasoningEffort);
|
|
226
|
+
if (reasoningEffort && !effort) {
|
|
227
|
+
logWarn(
|
|
228
|
+
"agent",
|
|
229
|
+
`[${contextLabel}] agy one-shot: effort "${reasoningEffort}" has no ` +
|
|
230
|
+
`agy equivalent — using the model default`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const activeModel = model || getDefaultModelId();
|
|
234
|
+
log("agent", `[${contextLabel}] agy one-shot model: ${activeModel}`);
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (abortController.signal.aborted) {
|
|
238
|
+
throw new Error("Aborted before prompt was sent");
|
|
239
|
+
}
|
|
240
|
+
const outcome = await runAgyOnce({
|
|
241
|
+
binary: agyBinary(getState().config?.agyBinary),
|
|
242
|
+
args: buildOneShotArgs({
|
|
243
|
+
prompt: `${finalSystemPrompt}\n\n---\n\n${prompt}`,
|
|
244
|
+
model: activeModel,
|
|
245
|
+
...(effort ? { effort } : {}),
|
|
246
|
+
workspace,
|
|
247
|
+
}),
|
|
248
|
+
cwd: workspace,
|
|
249
|
+
prompt,
|
|
250
|
+
abortController,
|
|
251
|
+
appendLog,
|
|
252
|
+
...(onAssistantText ? { onAssistantText } : {}),
|
|
253
|
+
});
|
|
254
|
+
return await settleOneShot({
|
|
255
|
+
outcome,
|
|
256
|
+
contextLabel,
|
|
257
|
+
appendLog,
|
|
258
|
+
...(onAssistantText ? { onAssistantText } : {}),
|
|
259
|
+
aborted: abortController.signal.aborted,
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
263
|
+
if (abortController.signal.aborted || /abort/i.test(msg)) {
|
|
264
|
+
await appendLog(`\n### [${ts()}] Aborted\nRun aborted by timeout.\n`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
logWarn("agent", `agy one-shot run failed: ${msg}`);
|
|
268
|
+
await appendLog(`\n### [${ts()}] Error\n${msg}\n`);
|
|
269
|
+
} finally {
|
|
270
|
+
unregisterMcpScope(scope);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Report the run's answer + usage, or its failure, to the log. */
|
|
275
|
+
async function settleOneShot(inputs: {
|
|
276
|
+
outcome: SpawnOutcome;
|
|
277
|
+
contextLabel: string;
|
|
278
|
+
appendLog: (text: string) => Promise<void>;
|
|
279
|
+
onAssistantText?: OneShotAgentParams["onAssistantText"];
|
|
280
|
+
aborted: boolean;
|
|
281
|
+
}): Promise<OneShotUsage | void> {
|
|
282
|
+
const { outcome, appendLog } = inputs;
|
|
283
|
+
if (inputs.aborted) {
|
|
284
|
+
await appendLog(`\n### [${ts()}] Aborted\nRun aborted by timeout.\n`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!outcome.result) {
|
|
288
|
+
const reason = isAgyAuthFailure(outcome.stderr)
|
|
289
|
+
? agyAuthError().message
|
|
290
|
+
: outcome.stderr.trim() || `agy exited ${outcome.code ?? "n/a"}`;
|
|
291
|
+
logWarn("agent", `agy one-shot produced no result: ${reason}`);
|
|
292
|
+
await appendLog(`\n### [${ts()}] Error\n${reason}\n`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const response = outcome.result.response ?? "";
|
|
296
|
+
if (response.trim()) {
|
|
297
|
+
emitAssistantText(inputs.onAssistantText, response);
|
|
298
|
+
await appendLog(`\n## [${ts()}] Assistant\n${response}\n`);
|
|
299
|
+
}
|
|
300
|
+
return agyUsageToTokens(outcome.result.usage);
|
|
301
|
+
}
|