talon-agent 5.1.0 → 5.2.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/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 +60 -58
- 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/daemon/crash.ts +82 -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 +264 -18
- 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
|
@@ -14,13 +14,25 @@
|
|
|
14
14
|
* unset to avoid sweeping the other context's subprocesses.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type { BackgroundRunner } from "
|
|
18
|
-
import type { OneShotAgentParams, OneShotUsage } from "
|
|
19
|
-
import { logWarn, logError } from "
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
timeoutError = new IsolatedAgentTimeoutError(timeoutMs);
|
|
74
92
|
try {
|
|
75
93
|
params.abortController.abort();
|
|
76
94
|
} catch {
|
|
77
95
|
/* ignore */
|
|
78
96
|
}
|
|
79
|
-
reject(
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/bus/events.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
package/src/core/config/index.ts
CHANGED
|
@@ -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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-path cleanup — what has to happen when the daemon goes down
|
|
3
|
+
* abnormally: an uncaught exception, the forced exit after a shutdown
|
|
4
|
+
* timeout, or a fatal startup error.
|
|
5
|
+
*
|
|
6
|
+
* Ordering is the whole point. The old handler logged first and cleaned
|
|
7
|
+
* up afterwards, which works right up until logging is the thing that
|
|
8
|
+
* broke. On 2026-09-18 the root disk filled; the log file's write stream
|
|
9
|
+
* emitted ENOSPC with nobody listening, the uncaught-exception handler
|
|
10
|
+
* called `logError` as its first act, threw inside itself, and Node
|
|
11
|
+
* aborted the process — no database checkpoint, a stale pidfile left
|
|
12
|
+
* behind, and (the expensive part) an armed `/update` handoff that never
|
|
13
|
+
* spawned its successor. The daemon simply vanished.
|
|
14
|
+
*
|
|
15
|
+
* So: the essentials first, each in its own try/catch, logging last and
|
|
16
|
+
* strictly best-effort. `src/util/log.ts` now keeps a broken log file
|
|
17
|
+
* from throwing at all — this is the second line of defence, for every
|
|
18
|
+
* other way logging can fail while the machine is sick.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { logError, logWarn } from "../../util/log.js";
|
|
22
|
+
import { removePidRecordIfOwnedBy } from "./pidfile.js";
|
|
23
|
+
import { spawnSuccessor } from "./respawn.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Steps the crash path can only get from the composition root.
|
|
27
|
+
* `flushDatabase` is injected because the SQLite handle stays inside
|
|
28
|
+
* `storage/` (.dependency-cruiser: db-handle-stays-in-storage).
|
|
29
|
+
*/
|
|
30
|
+
export type CrashHooks = {
|
|
31
|
+
flushDatabase: () => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run one crash-path step. Never throws, and never uses the logger —
|
|
36
|
+
* a broken logger is precisely the case this path exists for, so the
|
|
37
|
+
* console is the fallback of last resort.
|
|
38
|
+
*/
|
|
39
|
+
export function crashStep(name: string, fn: () => void): void {
|
|
40
|
+
try {
|
|
41
|
+
fn();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
try {
|
|
44
|
+
console.error(`[talon] crash cleanup: ${name} failed`, err);
|
|
45
|
+
} catch {
|
|
46
|
+
/* stdio is gone too — nothing left to try */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The non-logging essentials, in the order that matters:
|
|
53
|
+
* 1. drop the pid record, so `talon status` stops chasing a dead pid
|
|
54
|
+
* (before the successor writes its own — the guarded removal then
|
|
55
|
+
* cannot possibly orphan it),
|
|
56
|
+
* 2. hand off to the successor if `/restart` or `/update` armed one
|
|
57
|
+
* (a no-op otherwise, see respawn.ts),
|
|
58
|
+
* 3. checkpoint the database.
|
|
59
|
+
*/
|
|
60
|
+
export function crashCleanup(hooks: CrashHooks): void {
|
|
61
|
+
crashStep("pid record removal", () => removePidRecordIfOwnedBy(process.pid));
|
|
62
|
+
crashStep("respawn handoff", () => spawnSuccessor());
|
|
63
|
+
crashStep("database flush", hooks.flushDatabase);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `process.on("uncaughtException")` body. Cleanup happens before the
|
|
68
|
+
* crash is reported, never after.
|
|
69
|
+
*/
|
|
70
|
+
export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
|
|
71
|
+
// EPIPE errors from network sockets (e.g. Telegram MTProto) are transient —
|
|
72
|
+
// gramjs will reconnect; crashing the process here is wrong.
|
|
73
|
+
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
|
|
74
|
+
crashStep("EPIPE notice", () =>
|
|
75
|
+
logWarn("bot", `Suppressed transient EPIPE error: ${err.message}`),
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
crashCleanup(hooks);
|
|
80
|
+
crashStep("crash report", () => logError("bot", "Uncaught exception", err));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
@@ -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
|
+
);
|