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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode — a remote-server profile.
|
|
3
|
+
*
|
|
4
|
+
* The whole driver is `bindRemoteProfile` over the constants below: the
|
|
5
|
+
* `@opencode-ai/sdk` constructors, port 4096, the text-preferred
|
|
6
|
+
* delivery contract, the fuzzy `provider/model` parser, and the
|
|
7
|
+
* Telegram-sized model picker budget.
|
|
8
|
+
*
|
|
9
|
+
* Text-preferred delivery: plain assistant text is the reply; tools only
|
|
10
|
+
* for genuine side effects. Single-sourced from the shared contract
|
|
11
|
+
* templates (prompts/system/contract-text-preferred.md).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createOpencodeClient,
|
|
16
|
+
createOpencodeServer,
|
|
17
|
+
type OpencodeClient,
|
|
18
|
+
} from "@opencode-ai/sdk/v2";
|
|
19
|
+
import {
|
|
20
|
+
normalizeModelLookup,
|
|
21
|
+
parseRemoteModelQuery,
|
|
22
|
+
} from "../model-catalog/index.js";
|
|
23
|
+
import type { RemoteModelSelection } from "../server-bindings.js";
|
|
24
|
+
import { bindRemoteProfile, type RemoteProfile } from "./bind.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse the stored model-selection string into a `{providerID?, modelID}`
|
|
28
|
+
* pair. The parser is fuzzy — it tries to extract a provider hint from the
|
|
29
|
+
* prefix while preserving the full model id when ambiguous. See
|
|
30
|
+
* `remote-server/model-catalog/` for the underlying `parseRemoteModelQuery`.
|
|
31
|
+
*/
|
|
32
|
+
function parseStoredOpenCodeModelSelection(
|
|
33
|
+
value: string,
|
|
34
|
+
): RemoteModelSelection {
|
|
35
|
+
const { providerQuery, modelQuery } = parseRemoteModelQuery(value);
|
|
36
|
+
return {
|
|
37
|
+
providerID: providerQuery ? normalizeModelLookup(providerQuery) : undefined,
|
|
38
|
+
modelID: modelQuery,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const opencodeProfile: RemoteProfile<OpencodeClient> =
|
|
43
|
+
bindRemoteProfile<OpencodeClient>({
|
|
44
|
+
id: "opencode",
|
|
45
|
+
label: "OpenCode",
|
|
46
|
+
sdkPackage: "@opencode-ai/sdk",
|
|
47
|
+
defaultPort: 4096,
|
|
48
|
+
portEnv: "OPENCODE_PORT",
|
|
49
|
+
deliveryContract: "text-preferred",
|
|
50
|
+
createClient: (baseUrl) =>
|
|
51
|
+
createOpencodeClient({ baseUrl, throwOnError: true }),
|
|
52
|
+
createServer: ({ hostname, port, timeout }) =>
|
|
53
|
+
createOpencodeServer({ hostname, port, timeout }),
|
|
54
|
+
parseModelSelection: parseStoredOpenCodeModelSelection,
|
|
55
|
+
// OpenCode's model picker renders through Telegram inline keyboards —
|
|
56
|
+
// callback_data caps at 64 bytes and the keyboard is tight, so quick
|
|
57
|
+
// picks stay at 4 and only short separator-free ids are embedded raw.
|
|
58
|
+
maxCallbackIdLength: 48,
|
|
59
|
+
allowCallbackSeparators: false,
|
|
60
|
+
quickPickLimit: 4,
|
|
61
|
+
});
|
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
* of hand-written pass-through wrappers around the shared helpers. They
|
|
15
15
|
* drifted in the small ways copies do (one documented the port override,
|
|
16
16
|
* the other didn't; one exported `errMsg`, the other aliased it) while
|
|
17
|
-
* doing exactly the same thing.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `vi.mock("../backend/<name>/server.js")` all address.
|
|
17
|
+
* doing exactly the same thing. Those wrapper files are gone:
|
|
18
|
+
* `profiles/bind.ts` calls this once per driver and hands the result
|
|
19
|
+
* straight to `createRemoteBackendFactory`.
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
import type { TalonConfig } from "../../core/config/index.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot run hooks — the shared, throw-proof call site for
|
|
3
|
+
* `OneShotAgentParams.onAssistantText`.
|
|
4
|
+
*
|
|
5
|
+
* Every backend's isolated runner (`BackgroundRunner.runOneShotAgent`) already
|
|
6
|
+
* renders the model's assistant text into the run log as markdown. The hook is
|
|
7
|
+
* the same text as data, so a caller (the sub-agent runner) can take a run's
|
|
8
|
+
* result without parsing the log back apart. It lives here rather than in each
|
|
9
|
+
* backend so all four report it identically: same "final answer only" meaning,
|
|
10
|
+
* same empty-string skip, same swallow-and-log on a throwing callback.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Final assistant text only. Reasoning/thinking blocks and tool-call
|
|
14
|
+
* payloads are log-only; they never reach the hook.
|
|
15
|
+
* - Synchronous. The runner does not await the consumer, so a hook that
|
|
16
|
+
* wants to do async work owns its own queueing.
|
|
17
|
+
* - Never throws into the run. A consumer bug must not abort a heartbeat.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { OneShotAgentParams } from "../../core/types.js";
|
|
21
|
+
import { logWarn } from "../../util/log.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Report one assistant text segment to the run's optional consumer.
|
|
25
|
+
*
|
|
26
|
+
* No-ops when there is no hook (the heartbeat/dream/cron path) or when the
|
|
27
|
+
* text is empty — the backends guard their log appends the same way, so the
|
|
28
|
+
* hook sees exactly the segments the log does.
|
|
29
|
+
*/
|
|
30
|
+
export function emitAssistantText(
|
|
31
|
+
onAssistantText: OneShotAgentParams["onAssistantText"],
|
|
32
|
+
text: string,
|
|
33
|
+
): void {
|
|
34
|
+
if (!onAssistantText || !text) return;
|
|
35
|
+
try {
|
|
36
|
+
onAssistantText(text);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logWarn(
|
|
39
|
+
"agent",
|
|
40
|
+
`one-shot onAssistantText hook threw (ignored): ${
|
|
41
|
+
err instanceof Error ? err.message : String(err)
|
|
42
|
+
}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
initTriggers,
|
|
34
34
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
35
35
|
} from "./core/background/triggers/index.js";
|
|
36
|
+
import { initAgents } from "./core/agents/index.js";
|
|
36
37
|
import { initDream, maybeStartDream } from "./core/background/dream/index.js";
|
|
37
38
|
import { initHeartbeat } from "./core/background/heartbeat/index.js";
|
|
38
39
|
import { log, logWarn, logDebug } from "./util/log.js";
|
|
@@ -464,7 +465,7 @@ export async function initBackendAndDispatcher(
|
|
|
464
465
|
model: config.heartbeatModel ?? config.model ?? null,
|
|
465
466
|
}),
|
|
466
467
|
});
|
|
467
|
-
|
|
468
|
+
initWakeSubsystems(config);
|
|
468
469
|
resumeTriggersAfterRestart().catch((err) =>
|
|
469
470
|
log("triggers", `resumeAfterRestart failed: ${err}`),
|
|
470
471
|
);
|
|
@@ -567,6 +568,19 @@ export async function initBackendAndDispatcher(
|
|
|
567
568
|
return { backend };
|
|
568
569
|
}
|
|
569
570
|
|
|
571
|
+
/**
|
|
572
|
+
* Wire the two subsystems that wake a chat with a synthetic turn: trigger
|
|
573
|
+
* scripts firing, and sub-agents reporting. Same dependency (the dispatcher),
|
|
574
|
+
* same delivery shape, so they are wired together.
|
|
575
|
+
*/
|
|
576
|
+
function initWakeSubsystems(config: TalonConfig): void {
|
|
577
|
+
initTriggers({ execute: dispatcherExecute });
|
|
578
|
+
initAgents({
|
|
579
|
+
execute: dispatcherExecute,
|
|
580
|
+
...(config.agents ? { caps: config.agents } : {}),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
570
584
|
/**
|
|
571
585
|
* Wire the admin notification seam to the admin's frontend, and start the
|
|
572
586
|
* login-expiry monitor that rides it: the CLIs' "N days to log in again"
|
package/src/cli/chat.ts
CHANGED
|
@@ -47,4 +47,9 @@ export async function startChat(): Promise<void> {
|
|
|
47
47
|
process.exit(0);
|
|
48
48
|
});
|
|
49
49
|
await frontend.start();
|
|
50
|
+
// start() resolves once the prompt is up (the frontend lifecycle
|
|
51
|
+
// contract); readline drives the session from its own callbacks after
|
|
52
|
+
// that, so hold this entry point open until /exit or SIGINT exits the
|
|
53
|
+
// process.
|
|
54
|
+
await new Promise<never>(() => {});
|
|
50
55
|
}
|
package/src/cli/events.ts
CHANGED
|
@@ -35,6 +35,15 @@ function describe(event: TalonEvent): string {
|
|
|
35
35
|
return `chat=${event.chatId} ${event.backendId}/${event.model} (${event.source})`;
|
|
36
36
|
case "turn.completed":
|
|
37
37
|
return `chat=${event.chatId} ${event.durationMs}ms in=${event.inputTokens} out=${event.outputTokens}`;
|
|
38
|
+
case "agent.spawned":
|
|
39
|
+
return (
|
|
40
|
+
`${event.agentId} "${event.label}" ${event.backendId}/${event.model} ` +
|
|
41
|
+
`depth=${event.depth} parent=${event.parent}`
|
|
42
|
+
);
|
|
43
|
+
case "agent.settled":
|
|
44
|
+
return `${event.agentId} "${event.label}" → ${event.state} (${event.durationMs}ms)`;
|
|
45
|
+
case "agent.message":
|
|
46
|
+
return `${event.from} → ${event.to} (${event.kind})`;
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
* They are deliberately not the same types. Two client arguments do not
|
|
32
32
|
* survive a process boundary and the wire shapes say so:
|
|
33
33
|
*
|
|
34
|
-
* - `OneShotAgentParams` carries an `AbortController` and
|
|
35
|
-
* `appendLog`
|
|
36
|
-
* subset; Phase 2 maps `appendLog` onto `log`
|
|
34
|
+
* - `OneShotAgentParams` carries an `AbortController` and two
|
|
35
|
+
* callbacks (`appendLog`, `onAssistantText`). `HostOneShotParams`
|
|
36
|
+
* is the serialisable subset; Phase 2 maps `appendLog` onto `log`
|
|
37
|
+
* notices, `onAssistantText` onto a notice of its own, and the
|
|
37
38
|
* abort onto an `interrupt`-shaped request.
|
|
38
39
|
* - `hello.config` is the `claude-sdk` slice of `TalonConfig` as
|
|
39
40
|
* JSON. The in-process client takes the real `TalonConfig` object.
|
|
@@ -59,9 +60,9 @@ export const AGENT_HOST_PROTOCOL_VERSION = 1;
|
|
|
59
60
|
// ── Shared payload shapes ───────────────────────────────────────────────────
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
* The serialisable half of `OneShotAgentParams`. `abortController
|
|
63
|
-
* `appendLog` are host-local concerns (see the file
|
|
64
|
-
* else is exactly what a background run needs.
|
|
63
|
+
* The serialisable half of `OneShotAgentParams`. `abortController`,
|
|
64
|
+
* `appendLog` and `onAssistantText` are host-local concerns (see the file
|
|
65
|
+
* header); everything else is exactly what a background run needs.
|
|
65
66
|
*
|
|
66
67
|
* Unexported on purpose — it is reachable as
|
|
67
68
|
* `Extract<HostRequest, { type: "one_shot" }>["params"]`, and a second
|
|
@@ -103,6 +103,9 @@ export interface ChatBackend {
|
|
|
103
103
|
* trigger log-file producers keep their direct write path.
|
|
104
104
|
* Resolves with the run's token usage when the SDK reports it
|
|
105
105
|
* (the task table records it at settlement); void otherwise.
|
|
106
|
+
* Implementations must also honour the optional `onAssistantText`
|
|
107
|
+
* hook — the run's final answers as data, for callers (the
|
|
108
|
+
* sub-agent runner) that need a result rather than a markdown log.
|
|
106
109
|
* - `evictOrphanSubprocesses(label)` — backends that spawn
|
|
107
110
|
* per-run subprocesses (Claude SDK) implement this so a hung
|
|
108
111
|
* run can be force-cleaned after the abort grace window.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent context vocabulary — the one place that knows what an
|
|
3
|
+
* `agent:<id>` context label looks like.
|
|
4
|
+
*
|
|
5
|
+
* A one-shot run carries a `contextLabel` (`OneShotAgentParams`) that every
|
|
6
|
+
* layer downstream keys on: the backend picks the MCP server set from it, the
|
|
7
|
+
* MCP hub binds a tool session to it, and the bridge sends it back to the
|
|
8
|
+
* gateway as `_chatId`. A sub-agent's label is `agent:<id>`, which is how the
|
|
9
|
+
* gateway recognises a tool call as coming from that agent rather than from a
|
|
10
|
+
* chat.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately a dependency-free leaf: `backend/claude-sdk` and
|
|
13
|
+
* `core/engine/gateway` both import it, and routing either through
|
|
14
|
+
* `core/agents/index.ts` (which pulls in the runner, and with it the backend
|
|
15
|
+
* pool) would close an import cycle.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Prefix of every sub-agent context label / chat key. */
|
|
19
|
+
export const AGENT_CONTEXT_PREFIX = "agent:";
|
|
20
|
+
|
|
21
|
+
/** The context label a sub-agent's one-shot run (and its tools) is bound to. */
|
|
22
|
+
export function agentContextLabel(agentId: string): string {
|
|
23
|
+
return `${AGENT_CONTEXT_PREFIX}${agentId}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The agent id inside a context label, or null when it isn't one. */
|
|
27
|
+
export function agentIdFromContextLabel(label: string): string | null {
|
|
28
|
+
if (!label.startsWith(AGENT_CONTEXT_PREFIX)) return null;
|
|
29
|
+
const id = label.slice(AGENT_CONTEXT_PREFIX.length);
|
|
30
|
+
return id.length > 0 ? id : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a context label denotes a background run that gets the full
|
|
35
|
+
* cross-surface tool set: frontend tools (outbound with an explicit
|
|
36
|
+
* `chat_id`) plus every loaded plugin.
|
|
37
|
+
*
|
|
38
|
+
* Two labels qualify: `heartbeat` (also reused by isolated cron/trigger jobs,
|
|
39
|
+
* see `background/cron/job-prompt.ts`) and any `agent:<id>` sub-agent run.
|
|
40
|
+
* `dream` deliberately does not — it is a memory-consolidation pass with no
|
|
41
|
+
* business messaging anyone.
|
|
42
|
+
*/
|
|
43
|
+
export function isBackgroundToolContext(contextLabel: string): boolean {
|
|
44
|
+
return (
|
|
45
|
+
contextLabel === "heartbeat" ||
|
|
46
|
+
agentIdFromContextLabel(contextLabel) !== null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery — moving text between a sub-agent and its parent, both ways.
|
|
3
|
+
*
|
|
4
|
+
* Downward (`deliverToAgent`) is a mailbox push the agent drains at its own
|
|
5
|
+
* milestones. Upward — a report or an interim note — has two parents, two
|
|
6
|
+
* channels, one shape:
|
|
7
|
+
*
|
|
8
|
+
* - **A chat** is woken with a synthetic turn (`source: "agent"`), exactly
|
|
9
|
+
* as a trigger fires one. The turn resumes the chat's own session, so
|
|
10
|
+
* the model reads the report with full conversational context and
|
|
11
|
+
* decides for itself whether the user hears about it.
|
|
12
|
+
* - **An agent** gets the text pushed into its mailbox, which it drains
|
|
13
|
+
* with `check_inbox`. A parent that has already settled has nowhere to
|
|
14
|
+
* put it: the message is logged and dropped, because reviving a
|
|
15
|
+
* finished run to hear late news is worse than losing the news.
|
|
16
|
+
*
|
|
17
|
+
* No batching: several settlements arriving while a chat is busy become
|
|
18
|
+
* several queued turns, and the weaver serialises per chat. That is the
|
|
19
|
+
* behaviour triggers already have, and the model handles it fine.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { execute as dispatcherExecute } from "../engine/dispatcher.js";
|
|
23
|
+
import { log, logWarn, logError } from "../../util/log.js";
|
|
24
|
+
import { bus } from "../bus/index.js";
|
|
25
|
+
import { agentRegistry } from "./registry.js";
|
|
26
|
+
import { buildMessagePrompt, buildSettlementPrompt } from "./prompt.js";
|
|
27
|
+
import type { AgentMessage, AgentParent, AgentRecord } from "./types.js";
|
|
28
|
+
|
|
29
|
+
/** Injected at startup so this module knows nothing about the dispatcher. */
|
|
30
|
+
export type AgentDeliveryDeps = {
|
|
31
|
+
/** Wakes a chat with a synthetic turn. */
|
|
32
|
+
execute: typeof dispatcherExecute;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Reassignable holder so a re-init (or a test) can swap the deps. */
|
|
36
|
+
const deliveryDeps: { deps: AgentDeliveryDeps | null } = { deps: null };
|
|
37
|
+
|
|
38
|
+
/** Wire the delivery path. Called once from the composition root. */
|
|
39
|
+
export function initAgentDelivery(deps: AgentDeliveryDeps): void {
|
|
40
|
+
deliveryDeps.deps = deps;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sender/recipient key of a parent, for the `agent.message` event. */
|
|
44
|
+
function parentKey(parent: AgentParent): string {
|
|
45
|
+
return parent.kind === "chat" ? parent.chatId : parent.agentId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wake a chat with a synthetic agent turn. */
|
|
49
|
+
async function wakeChat(
|
|
50
|
+
chatId: string,
|
|
51
|
+
numericChatId: number,
|
|
52
|
+
prompt: string,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const deps = deliveryDeps.deps;
|
|
55
|
+
if (!deps) {
|
|
56
|
+
logWarn(
|
|
57
|
+
"agents",
|
|
58
|
+
`delivery not initialised — dropped a report for ${chatId}`,
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await deps.execute({
|
|
64
|
+
chatId,
|
|
65
|
+
numericChatId,
|
|
66
|
+
prompt,
|
|
67
|
+
senderName: "Agent",
|
|
68
|
+
isGroup: false,
|
|
69
|
+
source: "agent",
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logError("agents", `wake dispatch failed for chat ${chatId}`, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Push into a live agent's mailbox, or say why it could not be delivered. */
|
|
77
|
+
function pushToAgent(
|
|
78
|
+
parentAgentId: string,
|
|
79
|
+
message: AgentMessage,
|
|
80
|
+
what: string,
|
|
81
|
+
): void {
|
|
82
|
+
if (!agentRegistry.isLive(parentAgentId)) {
|
|
83
|
+
logWarn(
|
|
84
|
+
"agents",
|
|
85
|
+
`dropped ${what} from ${message.from}: parent ${parentAgentId} has already settled`,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!agentRegistry.push(parentAgentId, message)) {
|
|
90
|
+
logWarn(
|
|
91
|
+
"agents",
|
|
92
|
+
`dropped ${what} from ${message.from}: parent ${parentAgentId}'s inbox is full`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send an instruction down to a live agent. Returns false when the agent is
|
|
99
|
+
* gone or its mailbox is full — the caller turns that into a tool error, so
|
|
100
|
+
* a parent always learns that its instruction did not land.
|
|
101
|
+
*/
|
|
102
|
+
export function deliverToAgent(
|
|
103
|
+
from: string,
|
|
104
|
+
agentId: string,
|
|
105
|
+
text: string,
|
|
106
|
+
): boolean {
|
|
107
|
+
if (!agentRegistry.isLive(agentId)) return false;
|
|
108
|
+
if (!agentRegistry.push(agentId, { from, text, at: Date.now() }))
|
|
109
|
+
return false;
|
|
110
|
+
bus.publish({
|
|
111
|
+
type: "agent.message",
|
|
112
|
+
from,
|
|
113
|
+
to: agentId,
|
|
114
|
+
kind: "message",
|
|
115
|
+
});
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Deliver a settled agent's report to its parent. */
|
|
120
|
+
export async function deliverSettlement(record: AgentRecord): Promise<void> {
|
|
121
|
+
const prompt = buildSettlementPrompt(record);
|
|
122
|
+
bus.publish({
|
|
123
|
+
type: "agent.message",
|
|
124
|
+
from: record.id,
|
|
125
|
+
to: parentKey(record.parent),
|
|
126
|
+
kind: "result",
|
|
127
|
+
});
|
|
128
|
+
if (record.parent.kind === "chat") {
|
|
129
|
+
log(
|
|
130
|
+
"agents",
|
|
131
|
+
`${record.id} "${record.label}" ${record.state} — waking chat ${record.parent.chatId}`,
|
|
132
|
+
);
|
|
133
|
+
await wakeChat(record.parent.chatId, record.parent.numericChatId, prompt);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
pushToAgent(
|
|
137
|
+
record.parent.agentId,
|
|
138
|
+
{ from: record.id, text: prompt, at: Date.now() },
|
|
139
|
+
"a report",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Deliver an interim `message_parent` note. */
|
|
144
|
+
export async function deliverMessage(
|
|
145
|
+
record: AgentRecord,
|
|
146
|
+
text: string,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
bus.publish({
|
|
149
|
+
type: "agent.message",
|
|
150
|
+
from: record.id,
|
|
151
|
+
to: parentKey(record.parent),
|
|
152
|
+
kind: "message",
|
|
153
|
+
});
|
|
154
|
+
if (record.parent.kind === "chat") {
|
|
155
|
+
await wakeChat(
|
|
156
|
+
record.parent.chatId,
|
|
157
|
+
record.parent.numericChatId,
|
|
158
|
+
buildMessagePrompt(record, text),
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
pushToAgent(
|
|
163
|
+
record.parent.agentId,
|
|
164
|
+
{ from: record.id, text: buildMessagePrompt(record, text), at: Date.now() },
|
|
165
|
+
"a message",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agents — the public surface.
|
|
3
|
+
*
|
|
4
|
+
* Talon's own delegation mechanism: any chat turn, on any backend, can spawn
|
|
5
|
+
* an isolated agent with its own backend and model, talk to it while it runs,
|
|
6
|
+
* and be woken with its report. See `docs/agents.md` for the model and
|
|
7
|
+
* `registry` / `runner` / `delivery` for the three moving parts.
|
|
8
|
+
*
|
|
9
|
+
* Wiring points: `initAgents` at the composition root (bootstrap),
|
|
10
|
+
* `shutdownAgents` on daemon teardown (app), the `agents` tool family
|
|
11
|
+
* (`core/tools/ops/agents.ts`) through `core/engine/gateway-actions/agents/`,
|
|
12
|
+
* and `GET /agents` on the gateway.
|
|
13
|
+
*
|
|
14
|
+
* `context.ts` is imported directly by `backend/claude-sdk` and the gateway:
|
|
15
|
+
* it is a dependency-free leaf on purpose, so knowing what an `agent:<id>`
|
|
16
|
+
* label looks like never drags the runner (and the backend pool) along.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { agentContextLabel, agentIdFromContextLabel } from "./context.js";
|
|
20
|
+
export { AgentRegistry, agentRegistry } from "./registry.js";
|
|
21
|
+
export {
|
|
22
|
+
DEFAULT_AGENT_CAPS,
|
|
23
|
+
clampTimeout,
|
|
24
|
+
getAgentCaps,
|
|
25
|
+
initAgents,
|
|
26
|
+
killAgent,
|
|
27
|
+
shutdownAgents,
|
|
28
|
+
spawnAgent,
|
|
29
|
+
} from "./runner.js";
|
|
30
|
+
export {
|
|
31
|
+
deliverMessage,
|
|
32
|
+
deliverSettlement,
|
|
33
|
+
deliverToAgent,
|
|
34
|
+
initAgentDelivery,
|
|
35
|
+
} from "./delivery.js";
|
|
36
|
+
export { describeParent } from "./prompt.js";
|
|
37
|
+
export type { AgentCaps, AgentParent, AgentRecord } from "./types.js";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for a sub-agent run — its system prompt, its activation
|
|
3
|
+
* prompt, its log path and the prompt headers its parent sees.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of side effects (no fs, no backend, no registry) so the wording
|
|
6
|
+
* every other subsystem asserts on is trivially unit-testable, and so the
|
|
7
|
+
* runner stays about lifecycle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { dirs } from "../../util/paths.js";
|
|
12
|
+
import { loadSystemTemplate } from "../prompt/templates.js";
|
|
13
|
+
import type { AgentParent, AgentRecord } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Where sub-agent run logs live: `~/.talon/workspace/logs/agents/`. */
|
|
16
|
+
const AGENT_LOGS_DIR = resolve(dirs.logs, "agents");
|
|
17
|
+
|
|
18
|
+
/** Absolute path of one agent's run log. */
|
|
19
|
+
export function agentLogPath(agentId: string): string {
|
|
20
|
+
return resolve(AGENT_LOGS_DIR, `${agentId}.md`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How a parent is named to its child and in wake prompts. */
|
|
24
|
+
export function describeParent(parent: AgentParent): string {
|
|
25
|
+
return parent.kind === "chat"
|
|
26
|
+
? `chat ${parent.chatId}`
|
|
27
|
+
: `sub-agent ${parent.agentId}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The agent's system prompt: who it is, how to report, how to talk to its
|
|
32
|
+
* parent, and what it may not do. The brief itself is the *user* prompt —
|
|
33
|
+
* same split as the isolated cron/trigger jobs, so the framing half stays
|
|
34
|
+
* identical (and cacheable) across every run.
|
|
35
|
+
*/
|
|
36
|
+
export function buildAgentSystemPrompt(args: {
|
|
37
|
+
agentId: string;
|
|
38
|
+
label: string;
|
|
39
|
+
parent: AgentParent;
|
|
40
|
+
depth: number;
|
|
41
|
+
maxDepth: number;
|
|
42
|
+
}): string {
|
|
43
|
+
return loadSystemTemplate("agent-brief", {
|
|
44
|
+
agentId: args.agentId,
|
|
45
|
+
label: args.label,
|
|
46
|
+
parent: describeParent(args.parent),
|
|
47
|
+
depth: String(args.depth),
|
|
48
|
+
maxDepth: String(args.maxDepth),
|
|
49
|
+
canSpawn: args.depth < args.maxDepth ? "yes" : "",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The activation prompt — the brief, framed as the job to start on. */
|
|
54
|
+
export function buildAgentPrompt(brief: string): string {
|
|
55
|
+
return (
|
|
56
|
+
`[System: AGENT BRIEF. Work this to a conclusion, then call ` +
|
|
57
|
+
`report_result exactly once.]\n\n${brief}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Header line of a run log. */
|
|
62
|
+
export function agentLogHeader(record: AgentRecord, model: string): string {
|
|
63
|
+
return (
|
|
64
|
+
`# sub-agent ${record.id} "${record.label}" — ${new Date().toISOString()}\n` +
|
|
65
|
+
`**Parent:** ${describeParent(record.parent)} ` +
|
|
66
|
+
`**Backend:** ${record.backendId} **Model:** ${model} ` +
|
|
67
|
+
`**Depth:** ${record.depth}\n\n` +
|
|
68
|
+
`## Brief\n\n${record.brief}\n\n`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One-line token summary for the report a parent chat is woken with. */
|
|
73
|
+
function usageLine(record: AgentRecord): string {
|
|
74
|
+
const usage = record.usage;
|
|
75
|
+
if (!usage) return "";
|
|
76
|
+
return (
|
|
77
|
+
`\n\nTokens: in=${usage.inputTokens} out=${usage.outputTokens} ` +
|
|
78
|
+
`cache_read=${usage.cacheRead} cache_write=${usage.cacheWrite}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The wake prompt a parent chat receives when one of its agents settles.
|
|
84
|
+
* Shaped like the trigger wake-up: a `[System: …]` header telling the model
|
|
85
|
+
* what this is and that it must decide what to do, then the payload.
|
|
86
|
+
*/
|
|
87
|
+
export function buildSettlementPrompt(record: AgentRecord): string {
|
|
88
|
+
const body = record.result
|
|
89
|
+
? record.result.details
|
|
90
|
+
? `${record.result.summary}\n\n${record.result.details}`
|
|
91
|
+
: record.result.summary
|
|
92
|
+
: (record.error ?? "(the agent produced no result)");
|
|
93
|
+
const duration = record.endedAt
|
|
94
|
+
? Math.round(
|
|
95
|
+
(record.endedAt - (record.startedAt ?? record.createdAt)) / 1000,
|
|
96
|
+
)
|
|
97
|
+
: 0;
|
|
98
|
+
return (
|
|
99
|
+
`[System: AGENT FINISHED. Sub-agent ${record.id} "${record.label}" ` +
|
|
100
|
+
`ended as "${record.state}" after ${duration}s. This is the report from ` +
|
|
101
|
+
`an agent you spawned earlier. Decide whether to act on it, tell the ` +
|
|
102
|
+
`user, or do nothing.]\n\n` +
|
|
103
|
+
`[Agent "${record.label}" (${record.id}) — ${record.state}]\n\n` +
|
|
104
|
+
`${body}${usageLine(record)}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The wake prompt for an interim `message_parent` note. */
|
|
109
|
+
export function buildMessagePrompt(record: AgentRecord, text: string): string {
|
|
110
|
+
return (
|
|
111
|
+
`[System: AGENT MESSAGE from ${record.id} "${record.label}". ` +
|
|
112
|
+
`This is an interim note from an agent you spawned; it is still ` +
|
|
113
|
+
`running. Decide whether to act, reply with send_to_agent, or do ` +
|
|
114
|
+
`nothing.]\n\n${text}`
|
|
115
|
+
);
|
|
116
|
+
}
|