talon-agent 5.24.1 → 5.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.24.1",
3
+ "version": "5.25.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -90,6 +90,7 @@
90
90
  "knip": "knip",
91
91
  "format": "prettier --write src/ prompts/",
92
92
  "format:check": "prettier --check src/ prompts/",
93
+ "preflight": "bash scripts/preflight.sh",
93
94
  "ci:protect": "node .github/scripts/enforce-ci-gate.mjs",
94
95
  "build:gleam": "cd native/scheduler-core && gleam build --target javascript && node embed.mjs",
95
96
  "build:zig": "node native/textops-wasm/build.mjs",
@@ -33,5 +33,5 @@ export {
33
33
  deliverToAgent,
34
34
  initAgentDelivery,
35
35
  } from "./delivery.js";
36
- export { describeParent } from "./prompt.js";
36
+ export { describeParent, wantsPreflight } from "./prompt.js";
37
37
  export type { AgentCaps, AgentParent, AgentRecord } from "./types.js";
@@ -50,11 +50,38 @@ export function buildAgentSystemPrompt(args: {
50
50
  });
51
51
  }
52
52
 
53
+ /**
54
+ * The standing pre-flight instruction appended to a PR-opening agent's
55
+ * brief: run the light CI suite locally, push only on green. GitHub then
56
+ * confirms a change instead of being the first compiler it meets.
57
+ */
58
+ const PREFLIGHT_INSTRUCTION =
59
+ "[Pre-flight lane] Before every `git push`, run `npm run preflight` in " +
60
+ "the repo (or call the run_preflight tool with cwd set to your checkout). " +
61
+ "Push only when it is green. If it is red, fix it — or, when a failure " +
62
+ "is genuinely out of scope, say in the PR body which step failed and why " +
63
+ "you pushed anyway.";
64
+
65
+ /** A brief that opens, updates or talks about a pull request. */
66
+ const PR_BRIEF = /\bPRs?\b|pull[ -]requests?/i;
67
+
68
+ /**
69
+ * Whether a spawn gets the pre-flight instruction: the caller's explicit
70
+ * choice when given, otherwise on for any brief that mentions a PR.
71
+ */
72
+ export function wantsPreflight(brief: string, explicit?: boolean): boolean {
73
+ return explicit ?? PR_BRIEF.test(brief);
74
+ }
75
+
53
76
  /** The activation prompt — the brief, framed as the job to start on. */
54
- export function buildAgentPrompt(brief: string): string {
77
+ export function buildAgentPrompt(
78
+ brief: string,
79
+ options: { preflight?: boolean } = {},
80
+ ): string {
81
+ const lane = options.preflight ? `\n\n${PREFLIGHT_INSTRUCTION}` : "";
55
82
  return (
56
83
  `[System: AGENT BRIEF. Work this to a conclusion, then call ` +
57
- `report_result exactly once.]\n\n${brief}`
84
+ `report_result exactly once.]\n\n${brief}${lane}`
58
85
  );
59
86
  }
60
87
 
@@ -308,7 +308,9 @@ async function buildRunParams(
308
308
  agentLogHeader(record, model),
309
309
  );
310
310
  return {
311
- prompt: buildAgentPrompt(record.brief),
311
+ prompt: buildAgentPrompt(record.brief, {
312
+ preflight: spec.preflight === true,
313
+ }),
312
314
  systemPrompt: buildAgentSystemPrompt({
313
315
  agentId: record.id,
314
316
  label: record.label,
@@ -101,6 +101,12 @@ export interface AgentSpawnSpec {
101
101
  readonly reasoningEffort?: ReasoningEffortLevel;
102
102
  /** Hard wall-clock cap. Defaults to `agents.defaultTimeoutMs`. */
103
103
  readonly timeoutMs?: number;
104
+ /**
105
+ * Append the pre-flight lane instruction (run `npm run preflight` before
106
+ * every push) to the brief. Resolved by the spawn tool: explicit value,
107
+ * else on when the brief mentions a PR.
108
+ */
109
+ readonly preflight?: boolean;
104
110
  }
105
111
 
106
112
  /** `spawnAgent`'s answer — an error here is a tool error, never a throw. */
@@ -20,6 +20,7 @@ import {
20
20
  getAgentCaps,
21
21
  killAgent,
22
22
  spawnAgent,
23
+ wantsPreflight,
23
24
  type AgentParent,
24
25
  type AgentRecord,
25
26
  } from "../../../agents/index.js";
@@ -150,6 +151,7 @@ function readSpawnBody(body: Record<string, unknown>):
150
151
  model?: string;
151
152
  reasoningEffort?: ReasoningEffortLevel;
152
153
  timeoutMs?: number;
154
+ preflight: boolean;
153
155
  }
154
156
  | { ok: false; error: string } {
155
157
  const brief = String(body.brief ?? "").trim();
@@ -168,10 +170,15 @@ function readSpawnBody(body: Record<string, unknown>):
168
170
  if (timeoutS !== undefined && !Number.isFinite(timeoutS)) {
169
171
  return { ok: false, error: "timeout_s must be a number of seconds" };
170
172
  }
173
+ const preflight = body.preflight;
174
+ if (preflight !== undefined && typeof preflight !== "boolean") {
175
+ return { ok: false, error: "preflight must be true or false" };
176
+ }
171
177
  return {
172
178
  ok: true,
173
179
  brief,
174
180
  label,
181
+ preflight: wantsPreflight(brief, preflight),
175
182
  ...(body.backend ? { backendId: String(body.backend) } : {}),
176
183
  ...(body.model ? { model: String(body.model) } : {}),
177
184
  ...(effort !== undefined
@@ -200,6 +207,7 @@ export const agentControlHandlers: SharedActionHandlers = {
200
207
  ...(parsed.timeoutMs !== undefined
201
208
  ? { timeoutMs: parsed.timeoutMs }
202
209
  : {}),
210
+ preflight: parsed.preflight,
203
211
  });
204
212
  if (!outcome.ok) return { ok: false, error: outcome.error };
205
213
  const timeoutS = Math.round(clampTimeout(parsed.timeoutMs) / 1000);
@@ -211,6 +219,7 @@ export const agentControlHandlers: SharedActionHandlers = {
211
219
  `Backend: ${outcome.backendId}/${outcome.model}` +
212
220
  `${outcome.routing ? ` (routed: ${outcome.routing})` : ""}\n` +
213
221
  `Timeout: ${timeoutS}s\n` +
222
+ (parsed.preflight ? `Pre-flight lane: on\n` : "") +
214
223
  `It runs in the background. You will be woken with its report — ` +
215
224
  `carry on with what you were doing.`,
216
225
  };
@@ -6,6 +6,8 @@
6
6
  * - `report` — what a sub-agent calls about itself and its siblings:
7
7
  * report_result / message_parent / check_inbox / list_peers /
8
8
  * message_peer.
9
+ * - `preflight` — run_preflight: the pre-flight lane (light CI suite) in
10
+ * the caller's checkout, so an agent pushes only on green.
9
11
  *
10
12
  * Both sets are reachable from an `agent:<id>` context: the gateway routes
11
13
  * those chat keys straight here (see `Gateway.handleAction`), because a
@@ -15,11 +17,13 @@
15
17
 
16
18
  import type { SharedActionHandlers } from "../types.js";
17
19
  import { agentControlHandlers } from "./control.js";
20
+ import { agentPreflightHandlers } from "./preflight.js";
18
21
  import { agentReportHandlers } from "./report.js";
19
22
 
20
23
  export const agentHandlers: SharedActionHandlers = {
21
24
  ...agentControlHandlers,
22
25
  ...agentReportHandlers,
26
+ ...agentPreflightHandlers,
23
27
  };
24
28
 
25
29
  /**
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `run_preflight` — run a checkout's pre-flight lane and return its verdict.
3
+ *
4
+ * The pre-flight lane (`scripts/preflight.sh`, `npm run preflight`) is the
5
+ * light CI suite an agent runs before `git push`, so GitHub confirms a change
6
+ * instead of being the first compiler it meets. This action runs it in the
7
+ * caller's checkout on the daemon host and answers with the one-line verdict,
8
+ * the per-step table and the tail of every failing step's log — enough to
9
+ * fix the change without re-running anything by hand.
10
+ *
11
+ * Available from a chat and from inside a sub-agent (it is in
12
+ * `agentContextActions`), because agents are the ones opening PRs.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { readFile, stat } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { dirs } from "../../../../util/paths.js";
19
+ import { resolvePathParam } from "../native/params.js";
20
+ import type { ActionResult } from "../../../types.js";
21
+ import type { SharedActionHandlers } from "../types.js";
22
+
23
+ /** Relative path of the lane inside a checkout. */
24
+ const SCRIPT = join("scripts", "preflight.sh");
25
+ /** Hard cap on one run. The lane targets ≤5 min; this is the backstop. */
26
+ const PREFLIGHT_TIMEOUT_MS = 600_000;
27
+ /** Grace between SIGTERM and SIGKILL of the run's process group. */
28
+ const KILL_GRACE_MS = 5_000;
29
+ /** Lines of each failing step's log to hand back. */
30
+ const FAIL_TAIL_LINES = 30;
31
+
32
+ interface PreflightStep {
33
+ readonly name: string;
34
+ readonly status: "pass" | "fail" | "skipped";
35
+ readonly ms: number;
36
+ readonly note?: string;
37
+ }
38
+
39
+ interface PreflightSummary {
40
+ readonly verdict: "green" | "red";
41
+ readonly ok: boolean;
42
+ readonly base: string;
43
+ readonly totalMs: number;
44
+ readonly failed: readonly string[];
45
+ readonly steps: readonly PreflightStep[];
46
+ }
47
+
48
+ interface RunOutcome {
49
+ readonly code: number | null;
50
+ readonly timedOut: boolean;
51
+ readonly output: string;
52
+ }
53
+
54
+ /** The checkout root for `dir`: its git toplevel, else `dir` itself. */
55
+ function repoRoot(dir: string): Promise<string> {
56
+ return new Promise((resolveRoot) => {
57
+ const child = spawn("git", ["rev-parse", "--show-toplevel"], { cwd: dir });
58
+ let out = "";
59
+ child.stdout.on("data", (chunk: Buffer) => (out += chunk.toString()));
60
+ child.on("error", () => resolveRoot(dir));
61
+ child.on("close", (code) =>
62
+ resolveRoot(code === 0 && out.trim() ? out.trim() : dir),
63
+ );
64
+ });
65
+ }
66
+
67
+ async function isFile(path: string): Promise<boolean> {
68
+ try {
69
+ return (await stat(path)).isFile();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** Run the lane in its own process group so a timeout takes every child. */
76
+ function runLane(root: string, timeoutMs: number): Promise<RunOutcome> {
77
+ return new Promise((resolveRun) => {
78
+ const child = spawn("bash", [SCRIPT], {
79
+ cwd: root,
80
+ detached: process.platform !== "win32",
81
+ env: { ...process.env, PREFLIGHT_QUIET: "1", CI: "1" },
82
+ });
83
+ let output = "";
84
+ const keep = (chunk: Buffer): void => {
85
+ output = (output + chunk.toString()).slice(-8_000);
86
+ };
87
+ child.stdout.on("data", keep);
88
+ child.stderr.on("data", keep);
89
+ let timedOut = false;
90
+ const killGroup = (signal: NodeJS.Signals): void => {
91
+ try {
92
+ if (child.pid && process.platform !== "win32") {
93
+ process.kill(-child.pid, signal);
94
+ } else child.kill(signal);
95
+ } catch {
96
+ // Already gone.
97
+ }
98
+ };
99
+ const timer = setTimeout(() => {
100
+ timedOut = true;
101
+ killGroup("SIGTERM");
102
+ setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS).unref();
103
+ }, timeoutMs);
104
+ child.on("error", (err) => {
105
+ clearTimeout(timer);
106
+ resolveRun({ code: null, timedOut, output: String(err) });
107
+ });
108
+ child.on("close", (code) => {
109
+ clearTimeout(timer);
110
+ resolveRun({ code, timedOut, output });
111
+ });
112
+ });
113
+ }
114
+
115
+ async function readSummary(root: string): Promise<PreflightSummary | null> {
116
+ try {
117
+ const raw = await readFile(join(root, ".preflight", "last.json"), "utf8");
118
+ return JSON.parse(raw) as PreflightSummary;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function logTail(root: string, step: string): Promise<string> {
125
+ try {
126
+ const raw = await readFile(join(root, ".preflight", `${step}.log`), "utf8");
127
+ return raw.trimEnd().split("\n").slice(-FAIL_TAIL_LINES).join("\n");
128
+ } catch {
129
+ return "(no log)";
130
+ }
131
+ }
132
+
133
+ function stepLine(step: PreflightStep): string {
134
+ const mark =
135
+ step.status === "pass" ? "✓" : step.status === "fail" ? "✗" : "·";
136
+ const time =
137
+ step.status === "skipped" ? "" : ` (${Math.round(step.ms / 1000)}s)`;
138
+ const note = step.status === "skipped" && step.note ? ` — ${step.note}` : "";
139
+ return `${mark} ${step.name}${time}${note}`;
140
+ }
141
+
142
+ /** Render the summary a model can act on: verdict, table, failing tails. */
143
+ async function renderPreflight(
144
+ root: string,
145
+ summary: PreflightSummary,
146
+ ): Promise<string> {
147
+ const verdict = summary.ok
148
+ ? `Pre-flight GREEN in ${Math.round(summary.totalMs / 1000)}s — safe to push.`
149
+ : `Pre-flight RED in ${Math.round(summary.totalMs / 1000)}s — failed: ` +
150
+ `${summary.failed.join(", ")}. Fix before pushing, or explain in the PR body.`;
151
+ const parts = [
152
+ verdict,
153
+ `Base: ${summary.base}. Summary: ${join(root, ".preflight", "last.json")}`,
154
+ "",
155
+ ...summary.steps.map(stepLine),
156
+ ];
157
+ for (const name of summary.failed) {
158
+ parts.push("", `── ${name} (last ${FAIL_TAIL_LINES} lines) ──`);
159
+ parts.push(await logTail(root, name));
160
+ }
161
+ return parts.join("\n");
162
+ }
163
+
164
+ /** Run the lane in `dir`'s checkout and describe the result. */
165
+ async function runPreflight(
166
+ dir: string,
167
+ timeoutMs: number = PREFLIGHT_TIMEOUT_MS,
168
+ ): Promise<ActionResult> {
169
+ try {
170
+ if (!(await stat(dir)).isDirectory()) {
171
+ return { ok: false, error: `cwd is not a directory: ${dir}` };
172
+ }
173
+ } catch {
174
+ return { ok: false, error: `Working directory does not exist: ${dir}` };
175
+ }
176
+ const root = await repoRoot(dir);
177
+ if (!(await isFile(join(root, SCRIPT)))) {
178
+ return {
179
+ ok: false,
180
+ error:
181
+ `No ${SCRIPT} in ${root}. Pass cwd = the root of a talon checkout ` +
182
+ `(a branch that has the pre-flight lane).`,
183
+ };
184
+ }
185
+ const run = await runLane(root, timeoutMs);
186
+ if (run.timedOut) {
187
+ return {
188
+ ok: false,
189
+ error:
190
+ `Pre-flight did not finish within ${Math.round(timeoutMs / 1000)}s ` +
191
+ `and was killed. Tail of its output:\n${run.output.slice(-2_000)}`,
192
+ };
193
+ }
194
+ const summary = await readSummary(root);
195
+ if (!summary) {
196
+ return {
197
+ ok: false,
198
+ error:
199
+ `Pre-flight exited ${run.code} without writing .preflight/last.json. ` +
200
+ `Output:\n${run.output.slice(-2_000)}`,
201
+ };
202
+ }
203
+ // A red lane is a successful tool call with a red verdict — the model is
204
+ // meant to read it and act, not treat it as the tool breaking.
205
+ return { ok: true, text: await renderPreflight(root, summary) };
206
+ }
207
+
208
+ export const agentPreflightHandlers: SharedActionHandlers = {
209
+ run_preflight: (body) => {
210
+ const raw =
211
+ typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : "";
212
+ const dir = raw ? resolvePathParam(raw, undefined) : dirs.workspace;
213
+ return runPreflight(dir);
214
+ },
215
+ };
@@ -28,6 +28,8 @@ const LONG_ACTION_TIMEOUTS_MS: Record<string, number> = {
28
28
  // must sit above it so the layer that gives up is the tool (which reports
29
29
  // the agent's live state), never the transport.
30
30
  wait_for_agent: 180_000,
31
+ // The pre-flight lane caps itself at 600s; same margin rule as above.
32
+ run_preflight: 660_000,
31
33
  device_pull_file: 3_600_000,
32
34
  device_push_file: 3_600_000,
33
35
  device_read_file: 3_600_000,
@@ -15,7 +15,7 @@ import { mediaTools } from "./chat/media.js";
15
15
  import { stickerTools } from "./chat/stickers.js";
16
16
  import { schedulingTools } from "./ops/scheduling.js";
17
17
  import { triggerTools } from "./ops/triggers.js";
18
- import { agentTools } from "./ops/agents.js";
18
+ import { agentTools, preflightTools } from "./ops/agents.js";
19
19
  import { goalTools } from "./ops/goals.js";
20
20
  import { memoryTools } from "./content/memory.js";
21
21
  import { scriptTools } from "./ops/scripts.js";
@@ -56,6 +56,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
56
56
  // system prompt (see compose-tools.test.ts).
57
57
  ...agentTools,
58
58
  ...backupTools,
59
+ ...preflightTools,
59
60
  ];
60
61
 
61
62
  /**
@@ -83,6 +83,12 @@ export const agentTools: ToolDefinition[] = [
83
83
  .describe(
84
84
  "Hard wall-clock cap in seconds (default 900, min 30, max 3600). On timeout the agent is aborted and you are told.",
85
85
  ),
86
+ preflight: z
87
+ .boolean()
88
+ .optional()
89
+ .describe(
90
+ "Tell the agent to run the pre-flight lane (`npm run preflight` / run_preflight) before every git push and push only on green. Default: on when the brief mentions a PR, off otherwise.",
91
+ ),
86
92
  },
87
93
  execute: (params, bridge) => bridge("spawn_agent", params),
88
94
  tag: "agents",
@@ -214,3 +220,25 @@ export const agentTools: ToolDefinition[] = [
214
220
  tag: "agents",
215
221
  },
216
222
  ];
223
+
224
+ /**
225
+ * The pre-flight lane tool. Its own list so it can be appended at the END of
226
+ * ALL_TOOLS (the prompt-cache prefix) rather than beside the agent family.
227
+ */
228
+ export const preflightTools: ToolDefinition[] = [
229
+ {
230
+ name: "run_preflight",
231
+ description:
232
+ "Run the pre-flight lane (`npm run preflight`: typecheck, lint, format, architecture/knip/ratchet gates, the unit tests touched by your diff, gitleaks) in a talon checkout on the daemon host and get its verdict. Run it before every `git push` on a PR branch and push only when it is GREEN; a RED result lists each failing step with the tail of its log. Takes a few minutes (hard cap 10). Needs `npm ci` done in the checkout.",
233
+ schema: {
234
+ cwd: z
235
+ .string()
236
+ .optional()
237
+ .describe(
238
+ "Path inside the checkout to check (its git root is used), e.g. /tmp/fix-foo. Defaults to the workspace.",
239
+ ),
240
+ },
241
+ execute: (params, bridge) => bridge("run_preflight", params),
242
+ tag: "agents",
243
+ },
244
+ ];