talon-agent 5.1.0 → 5.2.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 +3 -1
- package/bin/talon.js +35 -0
- package/package.json +2 -2
- package/prompts/identity.md +10 -2
- package/prompts/system/agent-brief.md +43 -0
- package/src/app.ts +19 -26
- package/src/backend/builtins.ts +26 -7
- package/src/backend/claude-sdk/one-shot.ts +13 -5
- package/src/backend/remote-server/index.ts +6 -4
- package/src/backend/remote-server/model-catalog/index.ts +4 -10
- package/src/backend/remote-server/model-catalog/provider.ts +3 -3
- package/src/backend/remote-server/profiles/bind.ts +225 -0
- package/src/backend/remote-server/profiles/index.ts +10 -0
- package/src/backend/remote-server/profiles/kilo.ts +82 -0
- package/src/backend/remote-server/profiles/opencode.ts +61 -0
- package/src/backend/remote-server/server-bindings.ts +3 -4
- package/src/bootstrap.ts +15 -1
- package/src/cli/chat.ts +5 -0
- package/src/cli/events.ts +9 -0
- package/src/core/agents/context.ts +48 -0
- package/src/core/agents/delivery.ts +167 -0
- package/src/core/agents/index.ts +37 -0
- package/src/core/agents/prompt.ts +116 -0
- package/src/core/agents/registry.ts +426 -0
- package/src/core/agents/runner.ts +448 -0
- package/src/core/agents/types.ts +124 -0
- package/src/core/background/cron/job-oneshot.ts +7 -12
- package/src/core/background/cron/job-prompt.ts +1 -1
- package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
- package/src/core/background/run-log.ts +33 -0
- package/src/core/bus/events.ts +49 -2
- package/src/core/config/index.ts +25 -0
- package/src/core/engine/gateway-actions/agents/control.ts +299 -0
- package/src/core/engine/gateway-actions/agents/index.ts +31 -0
- package/src/core/engine/gateway-actions/agents/report.ts +107 -0
- package/src/core/engine/gateway-actions/index.ts +30 -0
- package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
- package/src/core/engine/gateway-actions/native/exec.ts +1 -1
- package/src/core/engine/gateway-actions/native/read.ts +1 -1
- package/src/core/engine/gateway-actions/native/search.ts +1 -1
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway-actions/native/write.ts +1 -1
- package/src/core/engine/gateway-routes.ts +12 -0
- package/src/core/engine/gateway.ts +96 -25
- package/src/core/frontend-runtime/capabilities.ts +18 -0
- package/src/core/frontend-runtime/index.ts +4 -0
- package/src/core/frontend-runtime/lifecycle.ts +33 -0
- package/src/core/frontend-runtime/registry.ts +3 -3
- package/src/core/frontend-runtime/run-loop.ts +59 -0
- package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
- package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
- package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
- package/src/core/mesh/index.ts +6 -2
- package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
- package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
- package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
- package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
- package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
- package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
- package/src/core/prompt/embedded-prompts.ts +38 -36
- package/src/core/tasks/types.ts +2 -2
- package/src/core/tools/index.ts +5 -0
- package/src/core/tools/ops/agents.ts +195 -0
- package/src/core/tools/ops/bridge.ts +4 -0
- package/src/core/tools/types.ts +1 -0
- package/src/core/types.ts +1 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/routes/mesh.ts +1 -1
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/presentation/reports.ts +1 -1
- package/src/frontend/teams/index.ts +4 -3
- package/src/frontend/telegram/commands/info.ts +61 -20
- package/src/frontend/telegram/index.ts +27 -4
- package/src/frontend/telegram/render/reports.ts +1 -1
- package/src/frontend/terminal/index.ts +6 -2
- package/src/frontend/whatsapp/connection/connection.ts +62 -11
- package/src/frontend/whatsapp/index.ts +25 -1
- package/src/frontend/whatsapp/runtime.ts +7 -0
- package/src/util/log.ts +1 -0
- package/src/backend/kilo/factory.ts +0 -53
- package/src/backend/kilo/handler/index.ts +0 -2
- package/src/backend/kilo/handler/message.ts +0 -44
- package/src/backend/kilo/index.ts +0 -61
- package/src/backend/kilo/model-provider.ts +0 -36
- package/src/backend/kilo/models/index.ts +0 -55
- package/src/backend/kilo/one-shot.ts +0 -42
- package/src/backend/kilo/server.ts +0 -98
- package/src/backend/kilo/sessions.ts +0 -37
- package/src/backend/opencode/factory.ts +0 -53
- package/src/backend/opencode/handler/index.ts +0 -2
- package/src/backend/opencode/handler/message.ts +0 -44
- package/src/backend/opencode/index.ts +0 -42
- package/src/backend/opencode/model-provider.ts +0 -36
- package/src/backend/opencode/models/index.ts +0 -54
- package/src/backend/opencode/one-shot.ts +0 -42
- package/src/backend/opencode/server.ts +0 -80
- package/src/backend/opencode/sessions.ts +0 -35
- /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner — turns a spawn request into a live isolated run, and a finished
|
|
3
|
+
* run into a settled record its parent hears about.
|
|
4
|
+
*
|
|
5
|
+
* The shape is the heartbeat / cron-job shape, because a sub-agent *is* one
|
|
6
|
+
* of those: acquire a backend, resolve a model, open a run log, register a
|
|
7
|
+
* task, and hand `runOneShotAgent` to `runIsolatedAgent` for the hard
|
|
8
|
+
* timeout → abort → grace → eviction discipline. Nothing here is
|
|
9
|
+
* backend-specific, which is the whole point: sub-agents work on Claude,
|
|
10
|
+
* Codex, Kilo, OpenCode and any future backend with a background capability.
|
|
11
|
+
*
|
|
12
|
+
* What is specific to agents:
|
|
13
|
+
*
|
|
14
|
+
* - **Identity.** Each run gets `contextLabel: "agent:<id>"`, which the
|
|
15
|
+
* backends turn into a per-agent MCP tool session and the gateway reads
|
|
16
|
+
* back to know which agent is calling `report_result`.
|
|
17
|
+
* - **Result precedence.** The `report_result` tool wins; otherwise the
|
|
18
|
+
* run's last assistant text is used; with neither, the run is a failure,
|
|
19
|
+
* because a sub-agent that says nothing has not done its job.
|
|
20
|
+
* - **Settlement is the delivery trigger.** Every terminal state — done,
|
|
21
|
+
* failed, killed, timed out — reaches the parent. Silence is never an
|
|
22
|
+
* outcome.
|
|
23
|
+
*
|
|
24
|
+
* `spawnAgent` returns as soon as the run is under way: the caller (a chat
|
|
25
|
+
* turn or another agent) keeps working and hears back through the wake turn
|
|
26
|
+
* or its mailbox.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { dirs } from "../../util/paths.js";
|
|
30
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
31
|
+
import {
|
|
32
|
+
acquireBackendInstance,
|
|
33
|
+
getBackendIdForChat,
|
|
34
|
+
isModelValidForBackend,
|
|
35
|
+
} from "../engine/backend-controller/index.js";
|
|
36
|
+
import type {
|
|
37
|
+
Backend,
|
|
38
|
+
BackgroundRunner,
|
|
39
|
+
} from "../agent-runtime/capabilities.js";
|
|
40
|
+
import { taskTable } from "../tasks/index.js";
|
|
41
|
+
import type { TaskHandle, TaskUsage } from "../tasks/types.js";
|
|
42
|
+
import type { OneShotAgentParams } from "../types.js";
|
|
43
|
+
import {
|
|
44
|
+
IsolatedAgentTimeoutError,
|
|
45
|
+
runIsolatedAgent,
|
|
46
|
+
} from "../background/isolated-agent.js";
|
|
47
|
+
import { openRunLog } from "../background/run-log.js";
|
|
48
|
+
import { agentContextLabel } from "./context.js";
|
|
49
|
+
import {
|
|
50
|
+
deliverSettlement,
|
|
51
|
+
initAgentDelivery,
|
|
52
|
+
type AgentDeliveryDeps,
|
|
53
|
+
} from "./delivery.js";
|
|
54
|
+
import {
|
|
55
|
+
agentLogHeader,
|
|
56
|
+
agentLogPath,
|
|
57
|
+
buildAgentPrompt,
|
|
58
|
+
buildAgentSystemPrompt,
|
|
59
|
+
} from "./prompt.js";
|
|
60
|
+
import { agentRegistry } from "./registry.js";
|
|
61
|
+
import type {
|
|
62
|
+
AgentCaps,
|
|
63
|
+
AgentParent,
|
|
64
|
+
AgentRecord,
|
|
65
|
+
AgentSpawnOutcome,
|
|
66
|
+
AgentSpawnSpec,
|
|
67
|
+
} from "./types.js";
|
|
68
|
+
|
|
69
|
+
/** Defaults for `config.agents`, applied when the block is absent. */
|
|
70
|
+
export const DEFAULT_AGENT_CAPS: AgentCaps = {
|
|
71
|
+
maxConcurrent: 6,
|
|
72
|
+
maxDepth: 2,
|
|
73
|
+
defaultTimeoutMs: 15 * 60 * 1000,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Floor and ceiling the tool boundary clamps a requested `timeout_s` into. */
|
|
77
|
+
const MIN_TIMEOUT_MS = 30_000;
|
|
78
|
+
const MAX_TIMEOUT_MS = 60 * 60 * 1000;
|
|
79
|
+
|
|
80
|
+
const capsHolder: { caps: AgentCaps } = { caps: DEFAULT_AGENT_CAPS };
|
|
81
|
+
|
|
82
|
+
/** Wire the sub-agent subsystem. Called once from the composition root. */
|
|
83
|
+
export function initAgents(
|
|
84
|
+
deps: AgentDeliveryDeps & { caps?: Partial<AgentCaps> },
|
|
85
|
+
): void {
|
|
86
|
+
capsHolder.caps = { ...DEFAULT_AGENT_CAPS, ...deps.caps };
|
|
87
|
+
initAgentDelivery({ execute: deps.execute });
|
|
88
|
+
log(
|
|
89
|
+
"agents",
|
|
90
|
+
`Initialized — maxConcurrent=${capsHolder.caps.maxConcurrent} ` +
|
|
91
|
+
`maxDepth=${capsHolder.caps.maxDepth} ` +
|
|
92
|
+
`timeout=${Math.round(capsHolder.caps.defaultTimeoutMs / 1000)}s`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The live caps — read by the tools for their error copy and prompts. */
|
|
97
|
+
export function getAgentCaps(): AgentCaps {
|
|
98
|
+
return capsHolder.caps;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Clamp a model-supplied timeout into the supported window, or fall back to
|
|
103
|
+
* the configured default. Applied at the tool boundary — `spawnAgent` itself
|
|
104
|
+
* honours whatever it is handed, so the runner has one rule and not two.
|
|
105
|
+
*/
|
|
106
|
+
export function clampTimeout(requestedMs: number | undefined): number {
|
|
107
|
+
if (requestedMs === undefined) return capsHolder.caps.defaultTimeoutMs;
|
|
108
|
+
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, requestedMs));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The backend an agent inherits when the caller didn't pick one. */
|
|
112
|
+
function inheritedBackendId(parent: AgentParent): string | null {
|
|
113
|
+
if (parent.kind === "chat") return getBackendIdForChat(parent.chatId);
|
|
114
|
+
return agentRegistry.get(parent.agentId)?.backendId ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The chat a run's task belongs to, for `talon ps`. */
|
|
118
|
+
function taskChatId(parent: AgentParent): string | undefined {
|
|
119
|
+
if (parent.kind === "chat") return parent.chatId;
|
|
120
|
+
const root = agentRegistry.get(parent.agentId)?.parent;
|
|
121
|
+
return root?.kind === "chat" ? root.chatId : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function errText(err: unknown): string {
|
|
125
|
+
return err instanceof Error ? err.message : String(err);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the model for a run: an explicit id is validated against the
|
|
130
|
+
* backend, an absent one falls back to that backend's own default. Returns
|
|
131
|
+
* the error text a tool should show instead of throwing.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveRun(
|
|
134
|
+
backend: Backend,
|
|
135
|
+
backendId: string,
|
|
136
|
+
requested: string | undefined,
|
|
137
|
+
): Promise<
|
|
138
|
+
| { ok: true; model: string; background: BackgroundRunner }
|
|
139
|
+
| { ok: false; error: string }
|
|
140
|
+
> {
|
|
141
|
+
const background = backend.background;
|
|
142
|
+
if (!background) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error:
|
|
146
|
+
`Backend "${backendId}" cannot host a sub-agent (it has no ` +
|
|
147
|
+
`background capability). Pick another backend or leave it unset.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (requested) {
|
|
151
|
+
let valid = false;
|
|
152
|
+
try {
|
|
153
|
+
valid = await isModelValidForBackend(backend, requested);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `Could not validate model "${requested}" on backend "${backendId}": ${errText(err)}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!valid) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error:
|
|
164
|
+
`Model "${requested}" is not selectable on backend "${backendId}". ` +
|
|
165
|
+
`Call list_models to see valid ids, or leave model unset to use ` +
|
|
166
|
+
`that backend's default.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, model: requested, background };
|
|
170
|
+
}
|
|
171
|
+
let fallback: string | null | undefined;
|
|
172
|
+
try {
|
|
173
|
+
fallback = await backend.models?.getDefaultModelId();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `Backend "${backendId}" could not report a default model: ${errText(err)}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (!fallback) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
error:
|
|
184
|
+
`Backend "${backendId}" has no default model — pass an explicit ` +
|
|
185
|
+
`model (list_models shows what it offers).`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, model: fallback, background };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Spawn a sub-agent. Resolves once the run is under way (or refused) — never
|
|
193
|
+
* when the agent finishes; that arrives through delivery.
|
|
194
|
+
*/
|
|
195
|
+
export async function spawnAgent(
|
|
196
|
+
spec: AgentSpawnSpec,
|
|
197
|
+
): Promise<AgentSpawnOutcome> {
|
|
198
|
+
const backendId = spec.backendId ?? inheritedBackendId(spec.parent);
|
|
199
|
+
if (!backendId) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error:
|
|
203
|
+
"Could not resolve a backend for this agent — pass one explicitly.",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Register first: the slot and the depth are claimed synchronously, so two
|
|
208
|
+
// concurrent spawns can never both slip past maxConcurrent while awaiting
|
|
209
|
+
// the backend. A registration that never starts is discarded without trace.
|
|
210
|
+
const registered = agentRegistry.register(
|
|
211
|
+
{
|
|
212
|
+
label: spec.label,
|
|
213
|
+
brief: spec.brief,
|
|
214
|
+
parent: spec.parent,
|
|
215
|
+
backendId,
|
|
216
|
+
...(spec.reasoningEffort
|
|
217
|
+
? { reasoningEffort: spec.reasoningEffort }
|
|
218
|
+
: {}),
|
|
219
|
+
},
|
|
220
|
+
capsHolder.caps,
|
|
221
|
+
);
|
|
222
|
+
if (!registered.ok) return registered;
|
|
223
|
+
const record = registered.record;
|
|
224
|
+
|
|
225
|
+
let acquired: Awaited<ReturnType<typeof acquireBackendInstance>>;
|
|
226
|
+
try {
|
|
227
|
+
acquired = await acquireBackendInstance(backendId);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
agentRegistry.discard(record.id);
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: `Backend "${backendId}" is unavailable: ${errText(err)}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const resolved = await resolveRun(acquired.backend, backendId, spec.model);
|
|
237
|
+
if (!resolved.ok) {
|
|
238
|
+
agentRegistry.discard(record.id);
|
|
239
|
+
await acquired.release();
|
|
240
|
+
return resolved;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The run owns the instance from here: `runAgent` releases it on every
|
|
244
|
+
// path, including the ones that throw.
|
|
245
|
+
void runAgent(record, spec, resolved, acquired);
|
|
246
|
+
return { ok: true, agentId: record.id, backendId, model: resolved.model };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Build the one-shot params for a run, wired to its log and text capture. */
|
|
250
|
+
async function buildRunParams(
|
|
251
|
+
record: AgentRecord,
|
|
252
|
+
spec: AgentSpawnSpec,
|
|
253
|
+
model: string,
|
|
254
|
+
abortController: AbortController,
|
|
255
|
+
capture: { last: string },
|
|
256
|
+
): Promise<OneShotAgentParams> {
|
|
257
|
+
const appendLog = await openRunLog(
|
|
258
|
+
agentLogPath(record.id),
|
|
259
|
+
agentLogHeader(record, model),
|
|
260
|
+
);
|
|
261
|
+
return {
|
|
262
|
+
prompt: buildAgentPrompt(record.brief),
|
|
263
|
+
systemPrompt: buildAgentSystemPrompt({
|
|
264
|
+
agentId: record.id,
|
|
265
|
+
label: record.label,
|
|
266
|
+
parent: record.parent,
|
|
267
|
+
depth: record.depth,
|
|
268
|
+
maxDepth: capsHolder.caps.maxDepth,
|
|
269
|
+
}),
|
|
270
|
+
workspace: dirs.workspace,
|
|
271
|
+
model,
|
|
272
|
+
contextLabel: agentContextLabel(record.id),
|
|
273
|
+
abortController,
|
|
274
|
+
appendLog,
|
|
275
|
+
onAssistantText: (text) => {
|
|
276
|
+
const trimmed = text.trim();
|
|
277
|
+
if (trimmed) capture.last = trimmed;
|
|
278
|
+
},
|
|
279
|
+
...(spec.reasoningEffort ? { reasoningEffort: spec.reasoningEffort } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Settle a run that returned normally, applying the result precedence. */
|
|
284
|
+
function settleSuccess(
|
|
285
|
+
id: string,
|
|
286
|
+
task: TaskHandle,
|
|
287
|
+
lastText: string,
|
|
288
|
+
usage: TaskUsage | undefined,
|
|
289
|
+
): AgentRecord | null {
|
|
290
|
+
if (agentRegistry.hasReported(id)) {
|
|
291
|
+
task.succeed(usage);
|
|
292
|
+
return agentRegistry.settle(id, {
|
|
293
|
+
state: "done",
|
|
294
|
+
...(usage ? { usage } : {}),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (lastText) {
|
|
298
|
+
task.succeed(usage);
|
|
299
|
+
return agentRegistry.settle(id, {
|
|
300
|
+
state: "done",
|
|
301
|
+
result: { summary: lastText },
|
|
302
|
+
...(usage ? { usage } : {}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const error =
|
|
306
|
+
"the agent finished without calling report_result and produced no text";
|
|
307
|
+
task.fail(new Error(error), usage);
|
|
308
|
+
return agentRegistry.settle(id, {
|
|
309
|
+
state: "failed",
|
|
310
|
+
error,
|
|
311
|
+
...(usage ? { usage } : {}),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Settle a run that threw: timeout, kill, or a genuine failure. */
|
|
316
|
+
function settleFailure(
|
|
317
|
+
id: string,
|
|
318
|
+
task: TaskHandle,
|
|
319
|
+
err: unknown,
|
|
320
|
+
): AgentRecord | null {
|
|
321
|
+
const state =
|
|
322
|
+
err instanceof IsolatedAgentTimeoutError
|
|
323
|
+
? "timed_out"
|
|
324
|
+
: agentRegistry.killRequested(id)
|
|
325
|
+
? "killed"
|
|
326
|
+
: "failed";
|
|
327
|
+
task.fail(err);
|
|
328
|
+
return agentRegistry.settle(id, { state, error: errText(err) });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Drive one run end to end. Never rejects — it is the tail of a
|
|
333
|
+
* fire-and-forget spawn, so every failure has to end as a settled record
|
|
334
|
+
* their parent is told about.
|
|
335
|
+
*/
|
|
336
|
+
async function runAgent(
|
|
337
|
+
record: AgentRecord,
|
|
338
|
+
spec: AgentSpawnSpec,
|
|
339
|
+
resolved: { model: string; background: BackgroundRunner },
|
|
340
|
+
acquired: Awaited<ReturnType<typeof acquireBackendInstance>>,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
const { model, background } = resolved;
|
|
343
|
+
const { release } = acquired;
|
|
344
|
+
const id = record.id;
|
|
345
|
+
const abortController = new AbortController();
|
|
346
|
+
const capture = { last: "" };
|
|
347
|
+
const timeoutMs = spec.timeoutMs ?? capsHolder.caps.defaultTimeoutMs;
|
|
348
|
+
|
|
349
|
+
// Registered as queued, bound, then started — so a kill arriving in the
|
|
350
|
+
// gap between the task existing and the abort handle being published still
|
|
351
|
+
// reaches the run.
|
|
352
|
+
const chatId = taskChatId(record.parent);
|
|
353
|
+
const task = taskTable.enqueue({
|
|
354
|
+
kind: "agent",
|
|
355
|
+
label: record.label,
|
|
356
|
+
abort: () => void agentRegistry.requestKill(id),
|
|
357
|
+
...(chatId !== undefined ? { chatId } : {}),
|
|
358
|
+
});
|
|
359
|
+
task.bind({ model, backendId: record.backendId });
|
|
360
|
+
agentRegistry.start(id, { model, abort: abortController, taskId: task.id });
|
|
361
|
+
task.start();
|
|
362
|
+
|
|
363
|
+
let settled: AgentRecord | null = null;
|
|
364
|
+
try {
|
|
365
|
+
const params = await buildRunParams(
|
|
366
|
+
record,
|
|
367
|
+
spec,
|
|
368
|
+
model,
|
|
369
|
+
abortController,
|
|
370
|
+
capture,
|
|
371
|
+
);
|
|
372
|
+
if (abortController.signal.aborted) {
|
|
373
|
+
// A kill that lands during startup — while the backend is being
|
|
374
|
+
// acquired or the log opened — must not be lost. Handing an
|
|
375
|
+
// already-aborted signal to a backend relies on it checking, and not
|
|
376
|
+
// every SDK does; settling here is the one behaviour that always holds.
|
|
377
|
+
settled = settleFailure(
|
|
378
|
+
id,
|
|
379
|
+
task,
|
|
380
|
+
new Error("aborted before the run started"),
|
|
381
|
+
);
|
|
382
|
+
} else {
|
|
383
|
+
const usage = await runIsolatedAgent({
|
|
384
|
+
background,
|
|
385
|
+
params,
|
|
386
|
+
timeoutMs,
|
|
387
|
+
logCategory: "agents",
|
|
388
|
+
// Safe to sweep: the context label is unique to this agent, so no
|
|
389
|
+
// other context's subprocesses share the tag.
|
|
390
|
+
evictLabel: agentContextLabel(id),
|
|
391
|
+
});
|
|
392
|
+
settled = settleSuccess(id, task, capture.last, usage ?? undefined);
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
settled = settleFailure(id, task, err);
|
|
396
|
+
} finally {
|
|
397
|
+
await release().catch((err: unknown) =>
|
|
398
|
+
logError("agents", `failed to release backend for ${id}`, err),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!settled) return;
|
|
403
|
+
log(
|
|
404
|
+
"agents",
|
|
405
|
+
`${id} "${settled.label}" → ${settled.state} ` +
|
|
406
|
+
`(${settled.backendId}/${model}, ${timeoutMs}ms cap)`,
|
|
407
|
+
);
|
|
408
|
+
reapChildren(settled);
|
|
409
|
+
await deliverSettlement(settled).catch((err: unknown) =>
|
|
410
|
+
logError("agents", `delivery failed for ${id}`, err),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Kill a settled agent's still-running children. Their reports would have
|
|
416
|
+
* nowhere to go, so leaving them running only spends tokens.
|
|
417
|
+
*/
|
|
418
|
+
function reapChildren(record: AgentRecord): void {
|
|
419
|
+
for (const child of agentRegistry.liveChildren(record.id)) {
|
|
420
|
+
logWarn(
|
|
421
|
+
"agents",
|
|
422
|
+
`killing ${child}: its parent ${record.id} settled as ${record.state}`,
|
|
423
|
+
);
|
|
424
|
+
killAgent(child);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Request a kill. Routed through the task table when the run has a task, so
|
|
430
|
+
* `talon ps` shows it as `killed` rather than `failed` — one kill path, two
|
|
431
|
+
* surfaces. Returns false when the agent is unknown or already settled.
|
|
432
|
+
*/
|
|
433
|
+
export function killAgent(agentId: string): boolean {
|
|
434
|
+
const record = agentRegistry.get(agentId);
|
|
435
|
+
if (!record || !agentRegistry.isLive(agentId)) return false;
|
|
436
|
+
if (record.taskId !== undefined) return taskTable.kill(record.taskId).ok;
|
|
437
|
+
return agentRegistry.requestKill(agentId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Abort every live agent — the shutdown lever, alongside heartbeat's and
|
|
442
|
+
* cron's. Returns how many kills were requested.
|
|
443
|
+
*/
|
|
444
|
+
export function shutdownAgents(): number {
|
|
445
|
+
const killed = agentRegistry.killAll();
|
|
446
|
+
if (killed > 0) log("agents", `Shutdown: aborted ${killed} running agent(s)`);
|
|
447
|
+
return killed;
|
|
448
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent vocabulary — the shapes the registry, runner and delivery share.
|
|
3
|
+
*
|
|
4
|
+
* A **sub-agent** is one isolated one-shot run that some other agent work
|
|
5
|
+
* started: a chat turn delegating a job, or another sub-agent fanning out.
|
|
6
|
+
* Talon owns the mechanism (not the Claude SDK's own sub-agents) so it works
|
|
7
|
+
* on every backend — the run is an ordinary `runOneShotAgent` with its own
|
|
8
|
+
* backend, model, workspace and tool surface.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ReasoningEffortLevel } from "../types.js";
|
|
12
|
+
import type { TaskUsage } from "../tasks/types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lifecycle. `done` / `failed` / `killed` / `timed_out` are terminal.
|
|
16
|
+
*
|
|
17
|
+
* Wider than `TaskState` on purpose: a task that ran out of wall-clock is
|
|
18
|
+
* indistinguishable from any other abort in the task table, but the parent
|
|
19
|
+
* reading a report needs to know whether its agent was cut off.
|
|
20
|
+
*/
|
|
21
|
+
export type AgentState =
|
|
22
|
+
"queued" | "running" | "done" | "failed" | "killed" | "timed_out";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Who spawned this agent, and therefore where its report goes.
|
|
26
|
+
*
|
|
27
|
+
* A chat parent is woken with a synthetic turn (`source: "agent"`), exactly
|
|
28
|
+
* as a trigger fires; an agent parent gets the report pushed into its
|
|
29
|
+
* mailbox, which it drains with `check_inbox`.
|
|
30
|
+
*/
|
|
31
|
+
export type AgentParent =
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: "chat";
|
|
34
|
+
/** Canonical string chat id — what every store is keyed on. */
|
|
35
|
+
readonly chatId: string;
|
|
36
|
+
/** The frontend's numeric id, needed by the dispatcher. */
|
|
37
|
+
readonly numericChatId: number;
|
|
38
|
+
}
|
|
39
|
+
| { readonly kind: "agent"; readonly agentId: string };
|
|
40
|
+
|
|
41
|
+
/** What an agent reports back when it finishes. */
|
|
42
|
+
export interface AgentResult {
|
|
43
|
+
readonly summary: string;
|
|
44
|
+
readonly details?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One message waiting in an agent's mailbox. */
|
|
48
|
+
export interface AgentMessage {
|
|
49
|
+
/** Chat key or agent id of the sender. */
|
|
50
|
+
readonly from: string;
|
|
51
|
+
readonly text: string;
|
|
52
|
+
readonly at: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Immutable snapshot of one agent, as returned by the registry. */
|
|
56
|
+
export interface AgentRecord {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
/** Short content-free name — safe for task labels, events and `talon ps`. */
|
|
59
|
+
readonly label: string;
|
|
60
|
+
/**
|
|
61
|
+
* The brief the agent was spawned with. Kept for the run log and the
|
|
62
|
+
* agent's own system prompt only — never in a task label or a bus event,
|
|
63
|
+
* which are content-free by contract.
|
|
64
|
+
*/
|
|
65
|
+
readonly brief: string;
|
|
66
|
+
readonly parent: AgentParent;
|
|
67
|
+
readonly backendId: string;
|
|
68
|
+
/**
|
|
69
|
+
* Absent only in the moment between registration and the run starting —
|
|
70
|
+
* the model is whatever the backend's catalog resolved, which is an async
|
|
71
|
+
* answer, while the concurrency slot must be claimed synchronously.
|
|
72
|
+
*/
|
|
73
|
+
readonly model?: string;
|
|
74
|
+
readonly reasoningEffort?: ReasoningEffortLevel;
|
|
75
|
+
readonly state: AgentState;
|
|
76
|
+
/** 0 for an agent spawned by a chat, +1 per generation below that. */
|
|
77
|
+
readonly depth: number;
|
|
78
|
+
readonly createdAt: number;
|
|
79
|
+
readonly startedAt?: number;
|
|
80
|
+
readonly endedAt?: number;
|
|
81
|
+
/** Report tool result, else the run's last assistant text, else null. */
|
|
82
|
+
readonly result: AgentResult | null;
|
|
83
|
+
readonly error?: string;
|
|
84
|
+
readonly usage?: TaskUsage;
|
|
85
|
+
readonly taskId?: number;
|
|
86
|
+
/** Ids of the agents this one spawned. */
|
|
87
|
+
readonly children: readonly string[];
|
|
88
|
+
/** Messages waiting to be drained by `check_inbox`. */
|
|
89
|
+
readonly inboxDepth: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What `spawnAgent` is asked for. */
|
|
93
|
+
export interface AgentSpawnSpec {
|
|
94
|
+
readonly brief: string;
|
|
95
|
+
readonly label: string;
|
|
96
|
+
readonly parent: AgentParent;
|
|
97
|
+
/** Defaults to the parent's backend. */
|
|
98
|
+
readonly backendId?: string;
|
|
99
|
+
/** Defaults to the resolved backend's own default model. */
|
|
100
|
+
readonly model?: string;
|
|
101
|
+
readonly reasoningEffort?: ReasoningEffortLevel;
|
|
102
|
+
/** Hard wall-clock cap. Defaults to `agents.defaultTimeoutMs`. */
|
|
103
|
+
readonly timeoutMs?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `spawnAgent`'s answer — an error here is a tool error, never a throw. */
|
|
107
|
+
export type AgentSpawnOutcome =
|
|
108
|
+
| {
|
|
109
|
+
readonly ok: true;
|
|
110
|
+
readonly agentId: string;
|
|
111
|
+
readonly backendId: string;
|
|
112
|
+
readonly model: string;
|
|
113
|
+
}
|
|
114
|
+
| { readonly ok: false; readonly error: string };
|
|
115
|
+
|
|
116
|
+
/** The caps a deployment puts on sub-agent fan-out (config `agents`). */
|
|
117
|
+
export interface AgentCaps {
|
|
118
|
+
/** Live (queued + running) agents allowed per daemon. */
|
|
119
|
+
readonly maxConcurrent: number;
|
|
120
|
+
/** Deepest `depth` an agent may have — 2 means chat → A → B. */
|
|
121
|
+
readonly maxDepth: number;
|
|
122
|
+
/** Default hard timeout for one run. */
|
|
123
|
+
readonly defaultTimeoutMs: number;
|
|
124
|
+
}
|
|
@@ -10,10 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* This module is deliberately thin: it owns acquisition + log wiring, and
|
|
12
12
|
* delegates the prompt shape to {@link ./job-prompt} and the timeout/abort
|
|
13
|
-
* discipline to {@link
|
|
13
|
+
* discipline to {@link ../isolated-agent}.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { mkdir, appendFile } from "node:fs/promises";
|
|
17
16
|
import { dirs } from "../../../util/paths.js";
|
|
18
17
|
import { log, logWarn } from "../../../util/log.js";
|
|
19
18
|
import {
|
|
@@ -22,12 +21,12 @@ import {
|
|
|
22
21
|
} from "../../engine/backend-controller/index.js";
|
|
23
22
|
import { taskTable } from "../../tasks/index.js";
|
|
24
23
|
import type { OneShotAgentParams } from "../../types.js";
|
|
25
|
-
import { runIsolatedAgent } from "
|
|
24
|
+
import { runIsolatedAgent } from "../isolated-agent.js";
|
|
25
|
+
import { openRunLog } from "../run-log.js";
|
|
26
26
|
import {
|
|
27
27
|
buildJobSystemPrompt,
|
|
28
28
|
jobLogPath,
|
|
29
29
|
JOB_CONTEXT_LABEL,
|
|
30
|
-
JOB_LOGS_DIR,
|
|
31
30
|
type JobKind,
|
|
32
31
|
} from "./job-prompt.js";
|
|
33
32
|
|
|
@@ -70,22 +69,17 @@ export type JobOneShotResult =
|
|
|
70
69
|
{ status: "ran" } | { status: "skipped"; reason: string };
|
|
71
70
|
|
|
72
71
|
/** Open a per-run log file and return an appender bound to it. */
|
|
73
|
-
|
|
72
|
+
function openJobLog(
|
|
74
73
|
kind: JobKind,
|
|
75
74
|
label: string,
|
|
76
75
|
backendId: string,
|
|
77
76
|
model: string,
|
|
78
77
|
): Promise<(text: string) => Promise<void>> {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const appendLog = async (text: string) => {
|
|
82
|
-
await appendFile(file, text).catch(() => {});
|
|
83
|
-
};
|
|
84
|
-
await appendLog(
|
|
78
|
+
return openRunLog(
|
|
79
|
+
jobLogPath(kind, label),
|
|
85
80
|
`# ${kind} job "${label}" — ${new Date().toISOString()}\n` +
|
|
86
81
|
`**Backend:** ${backendId} **Model:** ${model}\n\n`,
|
|
87
82
|
);
|
|
88
|
-
return appendLog;
|
|
89
83
|
}
|
|
90
84
|
|
|
91
85
|
// The warning is deferred to runJobOneShot: an attempt that fails here may
|
|
@@ -182,6 +176,7 @@ async function attemptJobOneShot(
|
|
|
182
176
|
background,
|
|
183
177
|
params: oneShot,
|
|
184
178
|
timeoutMs: params.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
|
|
179
|
+
logCategory: params.kind === "cron" ? "cron" : "triggers",
|
|
185
180
|
// No evictLabel: the job context label is shared with heartbeat, so a
|
|
186
181
|
// sweep here could kill a concurrent heartbeat's subprocess. Bounded
|
|
187
182
|
// abort-grace is enough.
|
|
@@ -10,7 +10,7 @@ import { dirs } from "../../../util/paths.js";
|
|
|
10
10
|
export type JobKind = "trigger" | "cron";
|
|
11
11
|
|
|
12
12
|
/** Where job run logs live. */
|
|
13
|
-
|
|
13
|
+
const JOB_LOGS_DIR = resolve(dirs.logs, "jobs");
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* The one context label every backend wires for the full outbound frontend tool
|