talon-agent 5.0.1 → 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 +3 -3
- 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/handler.ts +191 -69
- package/src/backend/claude-sdk/one-shot.ts +30 -7
- package/src/backend/claude-sdk/stream.ts +9 -0
- package/src/backend/codex/one-shot.ts +18 -4
- 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/one-shot.ts +16 -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/backend/runtime/one-shot-hooks.ts +45 -0
- package/src/bootstrap.ts +15 -1
- package/src/cli/chat.ts +5 -0
- package/src/cli/events.ts +9 -0
- package/src/core/agent-runtime/agent-host.ts +7 -6
- package/src/core/agent-runtime/capabilities.ts +3 -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/mcp-hub/children.ts +21 -5
- 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 +12 -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,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentRegistry — the live map of sub-agents plus a bounded settled ring.
|
|
3
|
+
*
|
|
4
|
+
* One instance (`agentRegistry`) serves the daemon. It owns identity, the
|
|
5
|
+
* lifecycle state machine, the per-agent mailbox and the parent/child edges;
|
|
6
|
+
* it owns no timers, no backends and no delivery. The runner drives it, the
|
|
7
|
+
* gateway actions read it, and `GET /agents` serves `list()`.
|
|
8
|
+
*
|
|
9
|
+
* In-memory by design, same reasoning as the task table: an agent is a live
|
|
10
|
+
* run and a daemon restart ends every run, so persisted rows could only
|
|
11
|
+
* describe work that no longer exists. What *happened* is already durable —
|
|
12
|
+
* every `agent.*` event lands in the journal (see `docs/bus.md`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import type {
|
|
17
|
+
AgentCaps,
|
|
18
|
+
AgentMessage,
|
|
19
|
+
AgentParent,
|
|
20
|
+
AgentRecord,
|
|
21
|
+
AgentResult,
|
|
22
|
+
AgentState,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
import type { TaskUsage } from "../tasks/types.js";
|
|
25
|
+
import type { AgentSettledEvent, AgentSpawnedEvent } from "../bus/events.js";
|
|
26
|
+
import type { ReasoningEffortLevel } from "../types.js";
|
|
27
|
+
import { bus } from "../bus/index.js";
|
|
28
|
+
|
|
29
|
+
/** Settled agents kept for status queries after they leave the live map. */
|
|
30
|
+
const DEFAULT_HISTORY_LIMIT = 100;
|
|
31
|
+
/** Messages one agent's mailbox holds before `send_to_agent` is refused. */
|
|
32
|
+
const DEFAULT_MAILBOX_LIMIT = 32;
|
|
33
|
+
|
|
34
|
+
/** What a call site declares when it registers a spawn. */
|
|
35
|
+
export interface AgentRegistration {
|
|
36
|
+
readonly label: string;
|
|
37
|
+
readonly brief: string;
|
|
38
|
+
readonly parent: AgentParent;
|
|
39
|
+
readonly backendId: string;
|
|
40
|
+
readonly reasoningEffort?: ReasoningEffortLevel;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the runner knows once the run is actually under way. */
|
|
44
|
+
export interface AgentBinding {
|
|
45
|
+
/** The model the backend's catalog resolved to. */
|
|
46
|
+
readonly model: string;
|
|
47
|
+
/** The run's abort handle — `requestKill` pulls this. */
|
|
48
|
+
readonly abort: AbortController;
|
|
49
|
+
/** Task-table id for the run, when one was registered. */
|
|
50
|
+
readonly taskId?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Terminal patch handed to `settle`. */
|
|
54
|
+
export interface AgentSettlement {
|
|
55
|
+
readonly state: Extract<
|
|
56
|
+
AgentState,
|
|
57
|
+
"done" | "failed" | "killed" | "timed_out"
|
|
58
|
+
>;
|
|
59
|
+
readonly result?: AgentResult;
|
|
60
|
+
readonly error?: string;
|
|
61
|
+
readonly usage?: TaskUsage;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RegisterOutcome =
|
|
65
|
+
| { readonly ok: true; readonly record: AgentRecord }
|
|
66
|
+
| { readonly ok: false; readonly error: string };
|
|
67
|
+
|
|
68
|
+
export interface AgentRegistryOptions {
|
|
69
|
+
readonly historyLimit?: number;
|
|
70
|
+
readonly mailboxLimit?: number;
|
|
71
|
+
/** Sink for `agent.*` lifecycle events — the singleton wires the bus here. */
|
|
72
|
+
readonly publish?: (event: AgentSpawnedEvent | AgentSettledEvent) => void;
|
|
73
|
+
/** Id factory; overridden in tests for deterministic ids. */
|
|
74
|
+
readonly newId?: () => string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type MutableAgentRecord = {
|
|
78
|
+
-readonly [K in keyof AgentRecord]: AgentRecord[K];
|
|
79
|
+
} & { children: string[] };
|
|
80
|
+
|
|
81
|
+
interface LiveAgent {
|
|
82
|
+
readonly record: MutableAgentRecord;
|
|
83
|
+
readonly mailbox: AgentMessage[];
|
|
84
|
+
readonly waiters: Set<(record: AgentRecord) => void>;
|
|
85
|
+
abort?: AbortController;
|
|
86
|
+
reported: boolean;
|
|
87
|
+
killRequested: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function snapshot(entry: LiveAgent): AgentRecord {
|
|
91
|
+
return {
|
|
92
|
+
...entry.record,
|
|
93
|
+
children: [...entry.record.children],
|
|
94
|
+
inboxDepth: entry.mailbox.length,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class AgentRegistry {
|
|
99
|
+
private readonly live = new Map<string, LiveAgent>();
|
|
100
|
+
private readonly history: AgentRecord[] = [];
|
|
101
|
+
private readonly historyLimit: number;
|
|
102
|
+
private readonly mailboxLimit: number;
|
|
103
|
+
private readonly publish?: (
|
|
104
|
+
event: AgentSpawnedEvent | AgentSettledEvent,
|
|
105
|
+
) => void;
|
|
106
|
+
private readonly newId: () => string;
|
|
107
|
+
|
|
108
|
+
constructor(options: AgentRegistryOptions = {}) {
|
|
109
|
+
this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
110
|
+
this.mailboxLimit = options.mailboxLimit ?? DEFAULT_MAILBOX_LIMIT;
|
|
111
|
+
if (options.publish) this.publish = options.publish;
|
|
112
|
+
this.newId =
|
|
113
|
+
options.newId ?? (() => `agt_${randomBytes(4).toString("hex")}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Claim a slot and allocate an id. The caps are checked here — before the
|
|
120
|
+
* caller does any async backend work — so two concurrent spawns can never
|
|
121
|
+
* both squeeze past `maxConcurrent`.
|
|
122
|
+
*/
|
|
123
|
+
register(spec: AgentRegistration, caps: AgentCaps): RegisterOutcome {
|
|
124
|
+
const depth = this.depthFor(spec.parent);
|
|
125
|
+
if (depth === null) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: `Parent agent is no longer running — it cannot spawn children.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (depth > caps.maxDepth) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error:
|
|
135
|
+
`Sub-agent depth cap reached (maxDepth ${caps.maxDepth}). ` +
|
|
136
|
+
`Do this work yourself instead of delegating it further.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (this.live.size >= caps.maxConcurrent) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error:
|
|
143
|
+
`Sub-agent concurrency cap reached (${caps.maxConcurrent} live). ` +
|
|
144
|
+
`Wait for one to finish (wait_for_agent) or kill one (kill_agent).`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const id = this.newId();
|
|
149
|
+
const record: MutableAgentRecord = {
|
|
150
|
+
id,
|
|
151
|
+
label: spec.label,
|
|
152
|
+
brief: spec.brief,
|
|
153
|
+
parent: spec.parent,
|
|
154
|
+
backendId: spec.backendId,
|
|
155
|
+
state: "queued",
|
|
156
|
+
depth,
|
|
157
|
+
createdAt: Date.now(),
|
|
158
|
+
result: null,
|
|
159
|
+
children: [],
|
|
160
|
+
inboxDepth: 0,
|
|
161
|
+
};
|
|
162
|
+
if (spec.reasoningEffort !== undefined) {
|
|
163
|
+
record.reasoningEffort = spec.reasoningEffort;
|
|
164
|
+
}
|
|
165
|
+
const entry: LiveAgent = {
|
|
166
|
+
record,
|
|
167
|
+
mailbox: [],
|
|
168
|
+
waiters: new Set(),
|
|
169
|
+
reported: false,
|
|
170
|
+
killRequested: false,
|
|
171
|
+
};
|
|
172
|
+
this.live.set(id, entry);
|
|
173
|
+
if (spec.parent.kind === "agent") {
|
|
174
|
+
this.live.get(spec.parent.agentId)?.record.children.push(id);
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, record: snapshot(entry) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Drop a registration that never started (backend/model resolution failed).
|
|
181
|
+
* It leaves no trace: nothing ran, so there is nothing to report on.
|
|
182
|
+
*/
|
|
183
|
+
discard(id: string): void {
|
|
184
|
+
const entry = this.live.get(id);
|
|
185
|
+
if (!entry) return;
|
|
186
|
+
this.live.delete(id);
|
|
187
|
+
const parent = entry.record.parent;
|
|
188
|
+
if (parent.kind === "agent") {
|
|
189
|
+
const children = this.live.get(parent.agentId)?.record.children;
|
|
190
|
+
const at = children?.indexOf(id) ?? -1;
|
|
191
|
+
if (children && at >= 0) children.splice(at, 1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Move a queued agent to running and publish `agent.spawned`. */
|
|
196
|
+
start(id: string, binding: AgentBinding): void {
|
|
197
|
+
const entry = this.live.get(id);
|
|
198
|
+
if (!entry || entry.record.state !== "queued") return;
|
|
199
|
+
entry.abort = binding.abort;
|
|
200
|
+
entry.record.model = binding.model;
|
|
201
|
+
entry.record.state = "running";
|
|
202
|
+
entry.record.startedAt = Date.now();
|
|
203
|
+
if (binding.taskId !== undefined) entry.record.taskId = binding.taskId;
|
|
204
|
+
const { record } = entry;
|
|
205
|
+
this.publish?.({
|
|
206
|
+
type: "agent.spawned",
|
|
207
|
+
agentId: record.id,
|
|
208
|
+
label: record.label,
|
|
209
|
+
parentKind: record.parent.kind,
|
|
210
|
+
parent:
|
|
211
|
+
record.parent.kind === "chat"
|
|
212
|
+
? record.parent.chatId
|
|
213
|
+
: record.parent.agentId,
|
|
214
|
+
backendId: record.backendId,
|
|
215
|
+
model: binding.model,
|
|
216
|
+
depth: record.depth,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Record the agent's own report. Exactly once per run: a second call is
|
|
222
|
+
* refused so the parent can never be handed two different answers.
|
|
223
|
+
*/
|
|
224
|
+
report(id: string, result: AgentResult): boolean {
|
|
225
|
+
const entry = this.live.get(id);
|
|
226
|
+
if (!entry || entry.reported) return false;
|
|
227
|
+
entry.reported = true;
|
|
228
|
+
entry.record.result = result;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether this agent has already called `report_result`. */
|
|
233
|
+
hasReported(id: string): boolean {
|
|
234
|
+
return this.live.get(id)?.reported ?? false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Settle an agent. Idempotent — the first terminal state wins. */
|
|
238
|
+
settle(id: string, patch: AgentSettlement): AgentRecord | null {
|
|
239
|
+
const entry = this.live.get(id);
|
|
240
|
+
if (!entry) return null;
|
|
241
|
+
const { record } = entry;
|
|
242
|
+
record.state = patch.state;
|
|
243
|
+
record.endedAt = Date.now();
|
|
244
|
+
if (patch.result !== undefined) record.result = patch.result;
|
|
245
|
+
if (patch.error !== undefined) record.error = patch.error;
|
|
246
|
+
if (patch.usage !== undefined) record.usage = patch.usage;
|
|
247
|
+
|
|
248
|
+
const settled = snapshot(entry);
|
|
249
|
+
this.live.delete(id);
|
|
250
|
+
this.history.push(settled);
|
|
251
|
+
if (this.history.length > this.historyLimit) {
|
|
252
|
+
this.history.splice(0, this.history.length - this.historyLimit);
|
|
253
|
+
}
|
|
254
|
+
for (const waiter of entry.waiters) waiter(settled);
|
|
255
|
+
entry.waiters.clear();
|
|
256
|
+
this.publish?.({
|
|
257
|
+
type: "agent.settled",
|
|
258
|
+
agentId: settled.id,
|
|
259
|
+
label: settled.label,
|
|
260
|
+
state: settled.state,
|
|
261
|
+
durationMs:
|
|
262
|
+
(settled.endedAt ?? 0) - (settled.startedAt ?? settled.createdAt),
|
|
263
|
+
});
|
|
264
|
+
return settled;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Kill ──────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Request an abort. Returns false when the agent is unknown or already
|
|
271
|
+
* settled. The agent stays `running` until its runner's failure path lands,
|
|
272
|
+
* which is where it settles as `killed`.
|
|
273
|
+
*/
|
|
274
|
+
requestKill(id: string): boolean {
|
|
275
|
+
const entry = this.live.get(id);
|
|
276
|
+
if (!entry) return false;
|
|
277
|
+
if (!entry.killRequested) {
|
|
278
|
+
entry.killRequested = true;
|
|
279
|
+
try {
|
|
280
|
+
entry.abort?.abort();
|
|
281
|
+
} catch {
|
|
282
|
+
// An abort hook must not be able to break the kill path.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Whether a kill was requested for this (still live) agent. */
|
|
289
|
+
killRequested(id: string): boolean {
|
|
290
|
+
return this.live.get(id)?.killRequested ?? false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Request an abort of every live agent. Returns how many were signalled. */
|
|
294
|
+
killAll(): number {
|
|
295
|
+
let killed = 0;
|
|
296
|
+
for (const id of this.live.keys()) {
|
|
297
|
+
if (this.requestKill(id)) killed++;
|
|
298
|
+
}
|
|
299
|
+
return killed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Mailbox ───────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Push a message into a live agent's mailbox. Refused (false) when the
|
|
306
|
+
* agent is gone or its mailbox is full — a bounded queue that silently
|
|
307
|
+
* dropped instructions would be worse than one that says it is full.
|
|
308
|
+
*/
|
|
309
|
+
push(id: string, message: AgentMessage): boolean {
|
|
310
|
+
const entry = this.live.get(id);
|
|
311
|
+
if (!entry) return false;
|
|
312
|
+
if (entry.mailbox.length >= this.mailboxLimit) return false;
|
|
313
|
+
entry.mailbox.push(message);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Drain and return everything waiting for an agent, oldest first. */
|
|
318
|
+
drain(id: string): AgentMessage[] {
|
|
319
|
+
const entry = this.live.get(id);
|
|
320
|
+
if (!entry) return [];
|
|
321
|
+
return entry.mailbox.splice(0, entry.mailbox.length);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The mailbox cap, for the error text the actions surface. */
|
|
325
|
+
get mailboxCapacity(): number {
|
|
326
|
+
return this.mailboxLimit;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/** One agent, live or in the settled ring. */
|
|
332
|
+
get(id: string): AgentRecord | null {
|
|
333
|
+
const entry = this.live.get(id);
|
|
334
|
+
if (entry) return snapshot(entry);
|
|
335
|
+
return this.history.find((record) => record.id === id) ?? null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Whether the agent is still live (queued or running). */
|
|
339
|
+
isLive(id: string): boolean {
|
|
340
|
+
return this.live.has(id);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Ids of this agent's children that are still live. Answered from the
|
|
345
|
+
* settled ring too — the caller that needs it most is the runner reaping
|
|
346
|
+
* the children of an agent that just settled.
|
|
347
|
+
*/
|
|
348
|
+
liveChildren(id: string): string[] {
|
|
349
|
+
const children = this.get(id)?.children ?? [];
|
|
350
|
+
return children.filter((child) => this.live.has(child));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Live agents plus the bounded settled ring, oldest first. */
|
|
354
|
+
list(): AgentRecord[] {
|
|
355
|
+
const records = [...this.history];
|
|
356
|
+
for (const entry of this.live.values()) records.push(snapshot(entry));
|
|
357
|
+
return records.sort((a, b) => a.createdAt - b.createdAt);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Every agent whose ancestry roots in this chat, descendants included. */
|
|
361
|
+
listForChat(chatKey: string): AgentRecord[] {
|
|
362
|
+
return this.list().filter((record) => this.rootChat(record) === chatKey);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Live (queued + running) agent count — what `maxConcurrent` bounds. */
|
|
366
|
+
liveCount(): number {
|
|
367
|
+
return this.live.size;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolve when the agent settles, or after `timeoutMs` with whatever the
|
|
372
|
+
* registry knows now. Never rejects: a bounded wait is a status query, not
|
|
373
|
+
* a failure mode.
|
|
374
|
+
*/
|
|
375
|
+
waitForSettle(id: string, timeoutMs: number): Promise<AgentRecord | null> {
|
|
376
|
+
const entry = this.live.get(id);
|
|
377
|
+
if (!entry) return Promise.resolve(this.get(id));
|
|
378
|
+
return new Promise<AgentRecord | null>((resolve) => {
|
|
379
|
+
const done = (record: AgentRecord | null): void => {
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
entry.waiters.delete(waiter);
|
|
382
|
+
resolve(record);
|
|
383
|
+
};
|
|
384
|
+
const waiter = (record: AgentRecord): void => done(record);
|
|
385
|
+
const timer = setTimeout(() => done(this.get(id)), timeoutMs);
|
|
386
|
+
timer.unref();
|
|
387
|
+
entry.waiters.add(waiter);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Drop every record — tests only; the daemon holds one registry for life. */
|
|
392
|
+
resetForTest(): void {
|
|
393
|
+
this.live.clear();
|
|
394
|
+
this.history.splice(0, this.history.length);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Depth a child of this parent would have, or null when the parent is an
|
|
401
|
+
* agent that is no longer live (its children would have nowhere to report).
|
|
402
|
+
*/
|
|
403
|
+
private depthFor(parent: AgentParent): number | null {
|
|
404
|
+
if (parent.kind === "chat") return 0;
|
|
405
|
+
const entry = this.live.get(parent.agentId);
|
|
406
|
+
if (!entry) return null;
|
|
407
|
+
return entry.record.depth + 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The chat an agent's ancestry roots in, or null if that chain is gone. */
|
|
411
|
+
private rootChat(record: AgentRecord): string | null {
|
|
412
|
+
let current: AgentRecord | null = record;
|
|
413
|
+
// Bounded by maxDepth in practice; the cap here only guards a cycle that
|
|
414
|
+
// the registry's own invariants already make impossible.
|
|
415
|
+
for (let hop = 0; current && hop <= 16; hop++) {
|
|
416
|
+
if (current.parent.kind === "chat") return current.parent.chatId;
|
|
417
|
+
current = this.get(current.parent.agentId);
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The daemon-wide registry. Tests needing isolation construct their own. */
|
|
424
|
+
export const agentRegistry = new AgentRegistry({
|
|
425
|
+
publish: (event) => bus.publish(event),
|
|
426
|
+
});
|