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.
Files changed (99) hide show
  1. package/README.md +3 -1
  2. package/bin/talon.js +35 -0
  3. package/package.json +2 -2
  4. package/prompts/identity.md +10 -2
  5. package/prompts/system/agent-brief.md +43 -0
  6. package/src/app.ts +19 -26
  7. package/src/backend/builtins.ts +26 -7
  8. package/src/backend/claude-sdk/one-shot.ts +13 -5
  9. package/src/backend/remote-server/index.ts +6 -4
  10. package/src/backend/remote-server/model-catalog/index.ts +4 -10
  11. package/src/backend/remote-server/model-catalog/provider.ts +3 -3
  12. package/src/backend/remote-server/profiles/bind.ts +225 -0
  13. package/src/backend/remote-server/profiles/index.ts +10 -0
  14. package/src/backend/remote-server/profiles/kilo.ts +82 -0
  15. package/src/backend/remote-server/profiles/opencode.ts +61 -0
  16. package/src/backend/remote-server/server-bindings.ts +3 -4
  17. package/src/bootstrap.ts +15 -1
  18. package/src/cli/chat.ts +5 -0
  19. package/src/cli/events.ts +9 -0
  20. package/src/core/agents/context.ts +48 -0
  21. package/src/core/agents/delivery.ts +167 -0
  22. package/src/core/agents/index.ts +37 -0
  23. package/src/core/agents/prompt.ts +116 -0
  24. package/src/core/agents/registry.ts +426 -0
  25. package/src/core/agents/runner.ts +448 -0
  26. package/src/core/agents/types.ts +124 -0
  27. package/src/core/background/cron/job-oneshot.ts +7 -12
  28. package/src/core/background/cron/job-prompt.ts +1 -1
  29. package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
  30. package/src/core/background/run-log.ts +33 -0
  31. package/src/core/bus/events.ts +49 -2
  32. package/src/core/config/index.ts +25 -0
  33. package/src/core/engine/gateway-actions/agents/control.ts +299 -0
  34. package/src/core/engine/gateway-actions/agents/index.ts +31 -0
  35. package/src/core/engine/gateway-actions/agents/report.ts +107 -0
  36. package/src/core/engine/gateway-actions/index.ts +30 -0
  37. package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
  38. package/src/core/engine/gateway-actions/native/exec.ts +1 -1
  39. package/src/core/engine/gateway-actions/native/read.ts +1 -1
  40. package/src/core/engine/gateway-actions/native/search.ts +1 -1
  41. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  42. package/src/core/engine/gateway-actions/native/write.ts +1 -1
  43. package/src/core/engine/gateway-routes.ts +12 -0
  44. package/src/core/engine/gateway.ts +96 -25
  45. package/src/core/frontend-runtime/capabilities.ts +18 -0
  46. package/src/core/frontend-runtime/index.ts +4 -0
  47. package/src/core/frontend-runtime/lifecycle.ts +33 -0
  48. package/src/core/frontend-runtime/registry.ts +3 -3
  49. package/src/core/frontend-runtime/run-loop.ts +59 -0
  50. package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
  51. package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
  52. package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
  53. package/src/core/mesh/index.ts +6 -2
  54. package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
  55. package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
  56. package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
  57. package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
  58. package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
  59. package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
  60. package/src/core/prompt/embedded-prompts.ts +38 -36
  61. package/src/core/tasks/types.ts +2 -2
  62. package/src/core/tools/index.ts +5 -0
  63. package/src/core/tools/ops/agents.ts +195 -0
  64. package/src/core/tools/ops/bridge.ts +4 -0
  65. package/src/core/tools/types.ts +1 -0
  66. package/src/core/types.ts +1 -1
  67. package/src/frontend/discord/commands/info.ts +1 -1
  68. package/src/frontend/discord/render.ts +1 -1
  69. package/src/frontend/native/bridge/routes/mesh.ts +1 -1
  70. package/src/frontend/native/index.ts +2 -0
  71. package/src/frontend/presentation/reports.ts +1 -1
  72. package/src/frontend/teams/index.ts +4 -3
  73. package/src/frontend/telegram/commands/info.ts +61 -20
  74. package/src/frontend/telegram/index.ts +27 -4
  75. package/src/frontend/telegram/render/reports.ts +1 -1
  76. package/src/frontend/terminal/index.ts +6 -2
  77. package/src/frontend/whatsapp/connection/connection.ts +62 -11
  78. package/src/frontend/whatsapp/index.ts +25 -1
  79. package/src/frontend/whatsapp/runtime.ts +7 -0
  80. package/src/util/log.ts +1 -0
  81. package/src/backend/kilo/factory.ts +0 -53
  82. package/src/backend/kilo/handler/index.ts +0 -2
  83. package/src/backend/kilo/handler/message.ts +0 -44
  84. package/src/backend/kilo/index.ts +0 -61
  85. package/src/backend/kilo/model-provider.ts +0 -36
  86. package/src/backend/kilo/models/index.ts +0 -55
  87. package/src/backend/kilo/one-shot.ts +0 -42
  88. package/src/backend/kilo/server.ts +0 -98
  89. package/src/backend/kilo/sessions.ts +0 -37
  90. package/src/backend/opencode/factory.ts +0 -53
  91. package/src/backend/opencode/handler/index.ts +0 -2
  92. package/src/backend/opencode/handler/message.ts +0 -44
  93. package/src/backend/opencode/index.ts +0 -42
  94. package/src/backend/opencode/model-provider.ts +0 -36
  95. package/src/backend/opencode/models/index.ts +0 -54
  96. package/src/backend/opencode/one-shot.ts +0 -42
  97. package/src/backend/opencode/server.ts +0 -80
  98. package/src/backend/opencode/sessions.ts +0 -35
  99. /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
@@ -14,13 +14,25 @@
14
14
  * unset to avoid sweeping the other context's subprocesses.
15
15
  */
16
16
 
17
- import type { BackgroundRunner } from "../../agent-runtime/capabilities.js";
18
- import type { OneShotAgentParams, OneShotUsage } from "../../types.js";
19
- import { logWarn, logError } from "../../../util/log.js";
17
+ import type { BackgroundRunner } from "../agent-runtime/capabilities.js";
18
+ import type { OneShotAgentParams, OneShotUsage } from "../types.js";
19
+ import { logWarn, logError, type LogComponent } from "../../util/log.js";
20
20
 
21
21
  /** Default bounded grace after an abort before giving up on the backend. */
22
22
  const DEFAULT_ABORT_GRACE_MS = 30 * 1000;
23
23
 
24
+ /**
25
+ * Thrown when the hard timeout fires. A distinct class (rather than a string
26
+ * match on the message) so a caller can tell "ran out of wall-clock" apart
27
+ * from "the backend failed" and settle its own state accordingly.
28
+ */
29
+ export class IsolatedAgentTimeoutError extends Error {
30
+ constructor(timeoutMs: number) {
31
+ super(`isolated agent timed out after ${timeoutMs}ms`);
32
+ this.name = "IsolatedAgentTimeoutError";
33
+ }
34
+ }
35
+
24
36
  export interface IsolatedRunOptions {
25
37
  readonly background: BackgroundRunner;
26
38
  /** Fully-built one-shot params (must carry an `abortController`). */
@@ -34,6 +46,8 @@ export interface IsolatedRunOptions {
34
46
  * ignores the abort. Leave unset when the tag is shared with another context.
35
47
  */
36
48
  readonly evictLabel?: string;
49
+ /** Log category for the abort/eviction diagnostics (default "triggers"). */
50
+ readonly logCategory?: LogComponent;
37
51
  }
38
52
 
39
53
  /** Resolves to the value, or the string "timed_out" if `ms` elapses first. */
@@ -63,20 +77,24 @@ export async function runIsolatedAgent(
63
77
  ): Promise<OneShotUsage | void> {
64
78
  const { background, params, timeoutMs } = opts;
65
79
  const graceMs = opts.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
80
+ const category = opts.logCategory ?? "triggers";
66
81
 
67
- let timedOut = false;
82
+ // Doubles as the "did we time out?" flag: set before the abort, so a
83
+ // backend that rejects synchronously from its abort handler can't make the
84
+ // run look like an ordinary failure.
85
+ let timeoutError: IsolatedAgentTimeoutError | undefined;
68
86
  let timer: ReturnType<typeof setTimeout> | undefined;
69
87
  const agentPromise = background.runOneShotAgent(params);
70
88
 
71
89
  const timeoutPromise = new Promise<never>((_, reject) => {
72
90
  timer = setTimeout(() => {
73
- timedOut = true;
91
+ timeoutError = new IsolatedAgentTimeoutError(timeoutMs);
74
92
  try {
75
93
  params.abortController.abort();
76
94
  } catch {
77
95
  /* ignore */
78
96
  }
79
- reject(new Error(`isolated agent timed out after ${timeoutMs}ms`));
97
+ reject(timeoutError);
80
98
  }, timeoutMs);
81
99
  timer.unref();
82
100
  });
@@ -86,30 +104,33 @@ export async function runIsolatedAgent(
86
104
  } catch (err) {
87
105
  // Snapshot + clear before any await so a late timer can't reclassify a
88
106
  // non-timeout failure as a timeout (heartbeat learned this the hard way).
89
- const wasTimeout = timedOut;
107
+ const wasTimeout = timeoutError;
90
108
  if (timer) {
91
109
  clearTimeout(timer);
92
110
  timer = undefined;
93
111
  }
94
- if (wasTimeout) {
95
- const settled = await raceWithTimeout(
96
- agentPromise.catch(() => "settled"),
97
- graceMs,
98
- );
99
- if (settled === "timed_out" && opts.evictLabel) {
100
- const evict = background.evictOrphanSubprocesses;
101
- if (evict) {
102
- evict(opts.evictLabel).catch((sweepErr: unknown) => {
103
- logError("triggers", "orphan subprocess sweep failed", sweepErr);
104
- });
105
- } else {
106
- logWarn("triggers", "backend ignored abort and has no eviction hook");
107
- }
108
- }
109
- } else {
112
+ if (!wasTimeout) {
110
113
  await agentPromise.catch(() => {});
114
+ throw err;
115
+ }
116
+ const settled = await raceWithTimeout(
117
+ agentPromise.catch(() => "settled"),
118
+ graceMs,
119
+ );
120
+ if (settled === "timed_out" && opts.evictLabel) {
121
+ const evict = background.evictOrphanSubprocesses;
122
+ if (evict) {
123
+ evict(opts.evictLabel).catch((sweepErr: unknown) => {
124
+ logError(category, "orphan subprocess sweep failed", sweepErr);
125
+ });
126
+ } else {
127
+ logWarn(category, "backend ignored abort and has no eviction hook");
128
+ }
111
129
  }
112
- throw err;
130
+ // A backend that honours the abort rejects with its own error, which can
131
+ // win the race against the timeout's rejection. The run still ran out of
132
+ // wall-clock, so that is what the caller is told either way.
133
+ throw wasTimeout;
113
134
  } finally {
114
135
  if (timer) clearTimeout(timer);
115
136
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-run markdown log files for unattended agent runs.
3
+ *
4
+ * Every background one-shot writes a transcript nobody is watching live:
5
+ * heartbeat, dream, isolated cron/trigger jobs, and sub-agents. They all
6
+ * want the same three things — create the directory, stamp a header, hand
7
+ * back an appender that can never throw into the run — so they share one.
8
+ *
9
+ * Appends are best-effort by design: a full disk must slow nothing down and
10
+ * must never fail a run whose only remaining job is to report a result.
11
+ */
12
+
13
+ import { appendFile, mkdir } from "node:fs/promises";
14
+ import { dirname } from "node:path";
15
+
16
+ /** An `OneShotAgentParams.appendLog` bound to one file. */
17
+ export type RunLogAppender = (text: string) => Promise<void>;
18
+
19
+ /**
20
+ * Open (creating the directory) a run log at `file`, write `header`, and
21
+ * return the appender for it.
22
+ */
23
+ export async function openRunLog(
24
+ file: string,
25
+ header: string,
26
+ ): Promise<RunLogAppender> {
27
+ await mkdir(dirname(file), { recursive: true }).catch(() => {});
28
+ const appendLog: RunLogAppender = async (text) => {
29
+ await appendFile(file, text).catch(() => {});
30
+ };
31
+ await appendLog(header);
32
+ return appendLog;
33
+ }
@@ -6,11 +6,15 @@
6
6
  * a one-line union change; each addition should land together with its
7
7
  * publisher (and ideally its first subscriber).
8
8
  *
9
- * Two families exist today:
9
+ * Three families exist today:
10
10
  *
11
11
  * - `task.*` — lifecycle of agent work, published by the task table
12
12
  * (core/tasks). Uniform across kinds: a heartbeat pass, a dream run, an
13
13
  * isolated cron/trigger job, and a chat turn all surface here.
14
+ * - `agent.*` — the sub-agent lifecycle, published by `core/agents`:
15
+ * `agent.spawned` when an isolated sub-agent run starts, `agent.settled`
16
+ * when it reaches a terminal state, `agent.message` when a note or a
17
+ * report crosses between an agent and its parent.
14
18
  * - `turn.*` — the chat-domain moments inside a turn that other
15
19
  * subsystems key off: `turn.started` fires once the warp is bound and
16
20
  * the backend is about to run (never for a no-model refusal);
@@ -18,6 +22,7 @@
18
22
  */
19
23
 
20
24
  import type { TaskRecord } from "../tasks/types.js";
25
+ import type { AgentState } from "../agents/types.js";
21
26
 
22
27
  /** A task left the queue and began running. */
23
28
  export interface TaskStartedEvent {
@@ -50,8 +55,50 @@ interface TurnCompletedEvent {
50
55
  readonly outputTokens: number;
51
56
  }
52
57
 
58
+ /** A sub-agent was registered and its isolated run began. */
59
+ export interface AgentSpawnedEvent {
60
+ readonly type: "agent.spawned";
61
+ readonly agentId: string;
62
+ readonly label: string;
63
+ readonly parentKind: "chat" | "agent";
64
+ /** Chat key or parent agent id — an identifier, never content. */
65
+ readonly parent: string;
66
+ readonly backendId: string;
67
+ readonly model: string;
68
+ readonly depth: number;
69
+ }
70
+
71
+ /** A sub-agent reached a terminal state. */
72
+ export interface AgentSettledEvent {
73
+ readonly type: "agent.settled";
74
+ readonly agentId: string;
75
+ readonly label: string;
76
+ readonly state: AgentState;
77
+ readonly durationMs: number;
78
+ }
79
+
80
+ /**
81
+ * A message crossed between a sub-agent and its parent: an explicit note
82
+ * (`message`), or the settlement report (`result`). Ids only — the text
83
+ * itself never rides the bus.
84
+ */
85
+ interface AgentMessageEvent {
86
+ readonly type: "agent.message";
87
+ /** Chat key or agent id. */
88
+ readonly from: string;
89
+ /** Chat key or agent id. */
90
+ readonly to: string;
91
+ readonly kind: "message" | "result";
92
+ }
93
+
53
94
  export type TalonEvent =
54
- TaskStartedEvent | TaskSettledEvent | TurnStartedEvent | TurnCompletedEvent;
95
+ | TaskStartedEvent
96
+ | TaskSettledEvent
97
+ | TurnStartedEvent
98
+ | TurnCompletedEvent
99
+ | AgentSpawnedEvent
100
+ | AgentSettledEvent
101
+ | AgentMessageEvent;
55
102
 
56
103
  export type TalonEventType = TalonEvent["type"];
57
104
 
@@ -433,6 +433,31 @@ const configSchema = z.object({
433
433
  * silently ignored by Kilo / OpenCode, which have none.
434
434
  */
435
435
  heartbeatEffort: z.enum(REASONING_EFFORT_ENUM).optional(),
436
+ /**
437
+ * Sub-agents — the caps on Talon's own delegation mechanism (see
438
+ * `docs/agents.md`). There is no on/off switch: the tools are always
439
+ * present, and a deployment that doesn't want fan-out sets
440
+ * `maxConcurrent: 1` / `maxDepth: 0`.
441
+ *
442
+ * - `maxConcurrent` — live agents daemon-wide. Each one is a real
443
+ * backend run, so this is the token-spend lever.
444
+ * - `maxDepth` — how far delegation may nest. 0 = chats only, 2 (the
445
+ * default) = chat → agent → agent.
446
+ * - `defaultTimeoutMs` — hard wall-clock cap when a spawn doesn't pass
447
+ * its own. Per-spawn values are clamped to [30s, 60min].
448
+ */
449
+ agents: z
450
+ .object({
451
+ maxConcurrent: z.number().int().min(1).max(64).default(6),
452
+ maxDepth: z.number().int().min(0).max(5).default(2),
453
+ defaultTimeoutMs: z
454
+ .number()
455
+ .int()
456
+ .min(30_000)
457
+ .max(3_600_000)
458
+ .default(15 * 60 * 1000),
459
+ })
460
+ .optional(),
436
461
  braveApiKey: z.string().optional(),
437
462
  /**
438
463
  * Codex-specific OpenAI API key. Prefer this, CODEX_API_KEY, or
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Parent-side sub-agent actions — spawn, list, inspect, wait, message, kill.
3
+ *
4
+ * The caller is whoever the gateway routed: a chat (the ordinary chatKey) or
5
+ * another sub-agent (`agent:<id>`, routed by the gateway's agent-context
6
+ * branch). Both can delegate, so both go through the same handlers; the only
7
+ * difference is the parent recorded on the new agent and which agents the
8
+ * caller may see.
9
+ *
10
+ * Visibility is scoped like triggers are scoped to their chat: a chat sees the
11
+ * agents rooted in it (descendants included), an agent sees its own
12
+ * descendants. An id from another chat is simply "not found".
13
+ */
14
+
15
+ import {
16
+ agentIdFromContextLabel,
17
+ agentRegistry,
18
+ clampTimeout,
19
+ deliverToAgent,
20
+ getAgentCaps,
21
+ killAgent,
22
+ spawnAgent,
23
+ type AgentParent,
24
+ type AgentRecord,
25
+ } from "../../../agents/index.js";
26
+ import { log } from "../../../../util/log.js";
27
+ import type { ReasoningEffortLevel } from "../../../types.js";
28
+ import type { ActionResult } from "../../../types.js";
29
+ import type { SharedActionHandlers } from "../types.js";
30
+
31
+ const EFFORTS: ReadonlySet<string> = new Set([
32
+ "minimal",
33
+ "low",
34
+ "medium",
35
+ "high",
36
+ "xhigh",
37
+ ]);
38
+
39
+ /** Default and ceiling for `wait_for_agent`, in seconds. */
40
+ const DEFAULT_WAIT_S = 60;
41
+ const MAX_WAIT_S = 120;
42
+
43
+ type Caller =
44
+ | { kind: "chat"; chatKey: string; numericChatId: number }
45
+ | { kind: "agent"; agentId: string };
46
+
47
+ /** Who is calling, read off the gateway's chat key. */
48
+ function callerOf(chatId: number, chatKey: string): Caller {
49
+ const agentId = agentIdFromContextLabel(chatKey);
50
+ return agentId
51
+ ? { kind: "agent", agentId }
52
+ : { kind: "chat", chatKey, numericChatId: chatId };
53
+ }
54
+
55
+ /** The parent a newly spawned agent gets. */
56
+ function parentOf(caller: Caller): AgentParent {
57
+ return caller.kind === "chat"
58
+ ? {
59
+ kind: "chat",
60
+ chatId: caller.chatKey,
61
+ numericChatId: caller.numericChatId,
62
+ }
63
+ : { kind: "agent", agentId: caller.agentId };
64
+ }
65
+
66
+ /** The agents this caller is allowed to see and act on. */
67
+ function visible(caller: Caller): AgentRecord[] {
68
+ if (caller.kind === "chat") return agentRegistry.listForChat(caller.chatKey);
69
+ const seen: AgentRecord[] = [];
70
+ const queue = [caller.agentId];
71
+ // Breadth-first over the caller's own descendants; the registry's depth cap
72
+ // bounds the walk, and `seen` ids are unique by construction.
73
+ while (queue.length > 0) {
74
+ const next = agentRegistry.get(queue.shift() as string);
75
+ if (!next) continue;
76
+ for (const child of next.children) {
77
+ const record = agentRegistry.get(child);
78
+ if (!record) continue;
79
+ seen.push(record);
80
+ queue.push(child);
81
+ }
82
+ }
83
+ return seen;
84
+ }
85
+
86
+ /** Look one agent up within the caller's scope. */
87
+ function lookup(caller: Caller, id: string): AgentRecord | null {
88
+ if (!id) return null;
89
+ return visible(caller).find((record) => record.id === id) ?? null;
90
+ }
91
+
92
+ function notFound(id: string): ActionResult {
93
+ return {
94
+ ok: false,
95
+ error: `No agent "${id}" in this chat. Call list_agents to see yours.`,
96
+ };
97
+ }
98
+
99
+ function ageOf(record: AgentRecord): string {
100
+ const end = record.endedAt ?? Date.now();
101
+ return `${Math.round((end - record.createdAt) / 1000)}s`;
102
+ }
103
+
104
+ /** One line per agent for `list_agents`. */
105
+ function renderListEntry(record: AgentRecord): string {
106
+ return (
107
+ `- ${record.label} [${record.state}]\n` +
108
+ ` ID: ${record.id}\n` +
109
+ ` Backend: ${record.backendId}/${record.model ?? "(resolving)"}\n` +
110
+ ` Depth: ${record.depth} Age: ${ageOf(record)} ` +
111
+ `Result: ${record.result ? "yes" : "no"}`
112
+ );
113
+ }
114
+
115
+ /** The full `agent_status` body — everything but the brief. */
116
+ function renderStatus(record: AgentRecord): string {
117
+ const lines = [
118
+ `Agent "${record.label}" (${record.id})`,
119
+ `State: ${record.state}`,
120
+ `Backend: ${record.backendId}/${record.model ?? "(resolving)"}` +
121
+ (record.reasoningEffort ? ` effort=${record.reasoningEffort}` : ""),
122
+ `Depth: ${record.depth} Age: ${ageOf(record)} Inbox: ${record.inboxDepth}`,
123
+ ];
124
+ if (record.children.length > 0) {
125
+ lines.push(`Children: ${record.children.join(", ")}`);
126
+ }
127
+ if (record.usage) {
128
+ lines.push(
129
+ `Tokens: in=${record.usage.inputTokens} out=${record.usage.outputTokens}`,
130
+ );
131
+ }
132
+ if (record.result) {
133
+ lines.push("", `Result: ${record.result.summary}`);
134
+ if (record.result.details) lines.push("", record.result.details);
135
+ }
136
+ if (record.error) lines.push("", `Error: ${record.error}`);
137
+ if (!record.result && !record.error) {
138
+ lines.push("", "No result yet — the agent is still working.");
139
+ }
140
+ return lines.join("\n");
141
+ }
142
+
143
+ /** Validate and normalise `spawn_agent`'s body. */
144
+ function readSpawnBody(body: Record<string, unknown>):
145
+ | {
146
+ ok: true;
147
+ brief: string;
148
+ label: string;
149
+ backendId?: string;
150
+ model?: string;
151
+ reasoningEffort?: ReasoningEffortLevel;
152
+ timeoutMs?: number;
153
+ }
154
+ | { ok: false; error: string } {
155
+ const brief = String(body.brief ?? "").trim();
156
+ if (!brief) return { ok: false, error: "Missing brief" };
157
+ const label = String(body.label ?? "").trim();
158
+ if (!label) return { ok: false, error: "Missing label" };
159
+ const effort = body.effort === undefined ? undefined : String(body.effort);
160
+ if (effort !== undefined && !EFFORTS.has(effort)) {
161
+ return {
162
+ ok: false,
163
+ error: `Unknown effort "${effort}". Use minimal, low, medium, high or xhigh.`,
164
+ };
165
+ }
166
+ const timeoutS =
167
+ body.timeout_s === undefined ? undefined : Number(body.timeout_s);
168
+ if (timeoutS !== undefined && !Number.isFinite(timeoutS)) {
169
+ return { ok: false, error: "timeout_s must be a number of seconds" };
170
+ }
171
+ return {
172
+ ok: true,
173
+ brief,
174
+ label,
175
+ ...(body.backend ? { backendId: String(body.backend) } : {}),
176
+ ...(body.model ? { model: String(body.model) } : {}),
177
+ ...(effort !== undefined
178
+ ? { reasoningEffort: effort as ReasoningEffortLevel }
179
+ : {}),
180
+ ...(timeoutS !== undefined
181
+ ? { timeoutMs: clampTimeout(timeoutS * 1000) }
182
+ : {}),
183
+ };
184
+ }
185
+
186
+ export const agentControlHandlers: SharedActionHandlers = {
187
+ spawn_agent: async (body, chatId, _backend, chatKey) => {
188
+ const parsed = readSpawnBody(body);
189
+ if (!parsed.ok) return parsed;
190
+ const caller = callerOf(chatId, chatKey);
191
+ const outcome = await spawnAgent({
192
+ brief: parsed.brief,
193
+ label: parsed.label,
194
+ parent: parentOf(caller),
195
+ ...(parsed.backendId ? { backendId: parsed.backendId } : {}),
196
+ ...(parsed.model ? { model: parsed.model } : {}),
197
+ ...(parsed.reasoningEffort
198
+ ? { reasoningEffort: parsed.reasoningEffort }
199
+ : {}),
200
+ ...(parsed.timeoutMs !== undefined
201
+ ? { timeoutMs: parsed.timeoutMs }
202
+ : {}),
203
+ });
204
+ if (!outcome.ok) return { ok: false, error: outcome.error };
205
+ const timeoutS = Math.round(clampTimeout(parsed.timeoutMs) / 1000);
206
+ log("gateway", `spawn_agent: "${parsed.label}" [${outcome.agentId}]`);
207
+ return {
208
+ ok: true,
209
+ text:
210
+ `Spawned agent "${parsed.label}" (id: ${outcome.agentId})\n` +
211
+ `Backend: ${outcome.backendId}/${outcome.model}\n` +
212
+ `Timeout: ${timeoutS}s\n` +
213
+ `It runs in the background. You will be woken with its report — ` +
214
+ `carry on with what you were doing.`,
215
+ };
216
+ },
217
+
218
+ list_agents: (body, chatId, _backend, chatKey) => {
219
+ const records = visible(callerOf(chatId, chatKey));
220
+ if (records.length === 0) {
221
+ return { ok: true, text: "No sub-agents for this chat." };
222
+ }
223
+ const caps = getAgentCaps();
224
+ return {
225
+ ok: true,
226
+ text:
227
+ `Agents (${records.length}, ${agentRegistry.liveCount()} live of ` +
228
+ `${caps.maxConcurrent} daemon-wide):\n\n` +
229
+ records.map(renderListEntry).join("\n\n"),
230
+ };
231
+ },
232
+
233
+ agent_status: (body, chatId, _backend, chatKey) => {
234
+ const id = String(body.agent_id ?? "");
235
+ const record = lookup(callerOf(chatId, chatKey), id);
236
+ if (!record) return notFound(id);
237
+ return { ok: true, text: renderStatus(record) };
238
+ },
239
+
240
+ wait_for_agent: async (body, chatId, _backend, chatKey) => {
241
+ const id = String(body.agent_id ?? "");
242
+ const record = lookup(callerOf(chatId, chatKey), id);
243
+ if (!record) return notFound(id);
244
+ const requested = Number(body.timeout_s ?? DEFAULT_WAIT_S);
245
+ const seconds = Math.min(
246
+ MAX_WAIT_S,
247
+ Math.max(1, Number.isFinite(requested) ? requested : DEFAULT_WAIT_S),
248
+ );
249
+ const settled = await agentRegistry.waitForSettle(id, seconds * 1000);
250
+ if (!settled) return notFound(id);
251
+ if (settled.state === "running" || settled.state === "queued") {
252
+ return {
253
+ ok: true,
254
+ text:
255
+ `Agent "${settled.label}" (${id}) is still ${settled.state} after ` +
256
+ `${seconds}s. Stop waiting — its report will wake you when it lands.`,
257
+ };
258
+ }
259
+ return { ok: true, text: renderStatus(settled) };
260
+ },
261
+
262
+ send_to_agent: (body, chatId, _backend, chatKey) => {
263
+ const id = String(body.agent_id ?? "");
264
+ const caller = callerOf(chatId, chatKey);
265
+ const record = lookup(caller, id);
266
+ if (!record) return notFound(id);
267
+ const text = String(body.text ?? "").trim();
268
+ if (!text) return { ok: false, error: "Missing text" };
269
+ const from = caller.kind === "chat" ? caller.chatKey : caller.agentId;
270
+ if (!deliverToAgent(from, id, text)) {
271
+ return {
272
+ ok: false,
273
+ error:
274
+ `Could not deliver to "${id}": it has already settled, or its ` +
275
+ `inbox is full. Check agent_status.`,
276
+ };
277
+ }
278
+ return {
279
+ ok: true,
280
+ text: `Queued for agent "${record.label}" (${id}). It reads its inbox at its own milestones.`,
281
+ };
282
+ },
283
+
284
+ kill_agent: (body, chatId, _backend, chatKey) => {
285
+ const id = String(body.agent_id ?? "");
286
+ const record = lookup(callerOf(chatId, chatKey), id);
287
+ if (!record) return notFound(id);
288
+ if (!killAgent(id)) {
289
+ return {
290
+ ok: true,
291
+ text: `Agent "${record.label}" (${id}) had already settled as "${record.state}".`,
292
+ };
293
+ }
294
+ return {
295
+ ok: true,
296
+ text: `Abort requested for agent "${record.label}" (${id}). You will still get its report.`,
297
+ };
298
+ },
299
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Sub-agent actions — the registry half of `core/tools/ops/agents.ts`.
3
+ *
4
+ * - `control` — what a parent calls: spawn / list / status / wait / send /
5
+ * kill. Works from a chat and from inside another agent.
6
+ * - `report` — what a sub-agent calls about itself: report_result /
7
+ * message_parent / check_inbox.
8
+ *
9
+ * Both sets are reachable from an `agent:<id>` context: the gateway routes
10
+ * those chat keys straight here (see `Gateway.handleAction`), because a
11
+ * sub-agent has an identity but no chat, and `agentContextActions` is the
12
+ * list it consults.
13
+ */
14
+
15
+ import type { SharedActionHandlers } from "../types.js";
16
+ import { agentControlHandlers } from "./control.js";
17
+ import { agentReportHandlers } from "./report.js";
18
+
19
+ export const agentHandlers: SharedActionHandlers = {
20
+ ...agentControlHandlers,
21
+ ...agentReportHandlers,
22
+ };
23
+
24
+ /**
25
+ * Actions the gateway will dispatch for an `agent:<id>` chat key. Everything
26
+ * else stays chat-routed — a sub-agent calling a chat-scoped action still has
27
+ * to name a chat explicitly, exactly as a heartbeat run does.
28
+ */
29
+ export const agentContextActions: ReadonlySet<string> = new Set(
30
+ Object.keys(agentHandlers),
31
+ );