feat: update subagent announce + archive
This commit is contained in:
@@ -127,15 +127,17 @@ Parameters:
|
|||||||
- `task` (required)
|
- `task` (required)
|
||||||
- `label?` (optional; used for logs/UI)
|
- `label?` (optional; used for logs/UI)
|
||||||
- `model?` (optional; overrides the sub-agent model; invalid values error)
|
- `model?` (optional; overrides the sub-agent model; invalid values error)
|
||||||
- `timeoutSeconds?` (default 0; 0 = fire-and-forget)
|
- `timeoutSeconds?` (optional; omit for long-running jobs; if set, Clawdbot aborts the sub-agent when the timeout elapses)
|
||||||
- `cleanup?` (`delete|keep`, default `delete`)
|
- `cleanup?` (`delete|keep`, default `keep`)
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- Starts a new `subagent:<uuid>` session with `deliver: false`.
|
- Starts a new `agent:<id>:subagent:<uuid>` session with `deliver: false`.
|
||||||
- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`).
|
- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`).
|
||||||
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
||||||
- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider.
|
- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider.
|
||||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||||
|
- Sub-agent sessions are auto-archived after `agent.subagents.archiveAfterMinutes` (default: 60).
|
||||||
|
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
|
||||||
|
|
||||||
## Sandbox Session Visibility
|
## Sandbox Session Visibility
|
||||||
|
|
||||||
|
|||||||
@@ -757,6 +757,10 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
|||||||
target: "last"
|
target: "last"
|
||||||
},
|
},
|
||||||
maxConcurrent: 3,
|
maxConcurrent: 3,
|
||||||
|
subagents: {
|
||||||
|
maxConcurrent: 1,
|
||||||
|
archiveAfterMinutes: 60
|
||||||
|
},
|
||||||
bash: {
|
bash: {
|
||||||
backgroundMs: 10000,
|
backgroundMs: 10000,
|
||||||
timeoutSec: 1800,
|
timeoutSec: 1800,
|
||||||
@@ -805,6 +809,11 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
|||||||
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||||
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
||||||
|
|
||||||
|
`agent.subagents` configures sub-agent defaults:
|
||||||
|
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||||
|
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||||
|
- `tools.allow` / `tools.deny`: per-subagent tool allow/deny policy (deny wins)
|
||||||
|
|
||||||
`agent.tools` configures a global tool allow/deny policy (deny wins).
|
`agent.tools` configures a global tool allow/deny policy (deny wins).
|
||||||
This is applied even when the Docker sandbox is **off**.
|
This is applied even when the Docker sandbox is **off**.
|
||||||
|
|
||||||
|
|||||||
@@ -150,18 +150,20 @@ Core actions:
|
|||||||
Notes:
|
Notes:
|
||||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
||||||
|
|
||||||
### `sessions_list` / `sessions_history` / `sessions_send`
|
### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn`
|
||||||
List sessions, inspect transcript history, or send to another session.
|
List sessions, inspect transcript history, or send to another session.
|
||||||
|
|
||||||
Core parameters:
|
Core parameters:
|
||||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||||
|
- `sessions_spawn`: `task`, `label?`, `model?`, `timeoutSeconds?`, `cleanup?`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||||
|
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||||
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
||||||
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Sub-agents
|
# Sub-agents
|
||||||
|
|
||||||
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat provider.
|
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<id>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat provider.
|
||||||
|
|
||||||
Primary goals:
|
Primary goals:
|
||||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||||
@@ -25,8 +25,15 @@ Tool params:
|
|||||||
- `task` (required)
|
- `task` (required)
|
||||||
- `label?` (optional)
|
- `label?` (optional)
|
||||||
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
||||||
- `timeoutSeconds?` (default `0`; `0` = fire-and-forget)
|
- `timeoutSeconds?` (optional; omit for long-running jobs; when set, Clawdbot waits up to N seconds and aborts the sub-agent if it is still running)
|
||||||
- `cleanup?` (`delete|keep`, default `delete`)
|
- `cleanup?` (`delete|keep`, default `keep`)
|
||||||
|
|
||||||
|
Auto-archive:
|
||||||
|
- Sub-agent sessions are automatically archived after `agent.subagents.archiveAfterMinutes` (default: 60).
|
||||||
|
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
|
||||||
|
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
|
||||||
|
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||||
|
- Timeouts do **not** auto-archive; they only stop the run. The session remains until auto-archive.
|
||||||
|
|
||||||
## Announce
|
## Announce
|
||||||
|
|
||||||
@@ -35,6 +42,12 @@ Sub-agents report back via an announce step:
|
|||||||
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
|
||||||
- Otherwise the announce reply is posted to the requester chat provider via the gateway `send` method.
|
- Otherwise the announce reply is posted to the requester chat provider via the gateway `send` method.
|
||||||
|
|
||||||
|
Announce payloads include a stats line at the end:
|
||||||
|
- Runtime (e.g., `runtime 5m12s`)
|
||||||
|
- Token usage (input/output/total)
|
||||||
|
- Estimated cost when model pricing is configured (`models.providers.*.models[].cost`)
|
||||||
|
- `sessionKey`, `sessionId`, and transcript path (so the main agent can fetch history via `sessions_history` or inspect the file on disk)
|
||||||
|
|
||||||
## Tool Policy (sub-agent tools)
|
## Tool Policy (sub-agent tools)
|
||||||
|
|
||||||
By default, sub-agents get **all tools except session tools**:
|
By default, sub-agents get **all tools except session tools**:
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ describe("subagents", () => {
|
|||||||
const result = await tool.execute("call1", {
|
const result = await tool.execute("call1", {
|
||||||
task: "do thing",
|
task: "do thing",
|
||||||
timeoutSeconds: 1,
|
timeoutSeconds: 1,
|
||||||
|
cleanup: "delete",
|
||||||
});
|
});
|
||||||
expect(result.details).toMatchObject({ status: "ok", reply: "result" });
|
expect(result.details).toMatchObject({ status: "ok", reply: "result" });
|
||||||
|
|
||||||
@@ -105,11 +106,10 @@ describe("subagents", () => {
|
|||||||
expect(first?.deliver).toBe(false);
|
expect(first?.deliver).toBe(false);
|
||||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
|
||||||
expect(sendParams).toMatchObject({
|
expect(sendParams.provider).toBe("discord");
|
||||||
provider: "discord",
|
expect(sendParams.to).toBe("channel:req");
|
||||||
to: "channel:req",
|
expect(sendParams.message ?? "").toContain("announce now");
|
||||||
message: "announce now",
|
expect(sendParams.message ?? "").toContain("Stats:");
|
||||||
});
|
|
||||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,11 +195,10 @@ describe("subagents", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
expect(sendParams).toMatchObject({
|
expect(sendParams.provider).toBe("whatsapp");
|
||||||
provider: "whatsapp",
|
expect(sendParams.to).toBe("+123");
|
||||||
to: "+123",
|
expect(sendParams.message ?? "").toContain("hello from sub");
|
||||||
message: "hello from sub",
|
expect(sendParams.message ?? "").toContain("Stats:");
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sessions_spawn applies a model to the child session", async () => {
|
it("sessions_spawn applies a model to the child session", async () => {
|
||||||
|
|||||||
@@ -96,6 +96,23 @@ export type EmbeddedPiRunMeta = {
|
|||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function buildModelAliasLines(cfg?: ClawdbotConfig) {
|
||||||
|
const models = cfg?.agent?.models ?? {};
|
||||||
|
const entries: Array<{ alias: string; model: string }> = [];
|
||||||
|
for (const [keyRaw, entryRaw] of Object.entries(models)) {
|
||||||
|
const model = String(keyRaw ?? "").trim();
|
||||||
|
if (!model) continue;
|
||||||
|
const alias = String(
|
||||||
|
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
|
||||||
|
).trim();
|
||||||
|
if (!alias) continue;
|
||||||
|
entries.push({ alias, model });
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.sort((a, b) => a.alias.localeCompare(b.alias))
|
||||||
|
.map((entry) => `- ${entry.alias}: ${entry.model}`);
|
||||||
|
}
|
||||||
|
|
||||||
type ApiKeyInfo = {
|
type ApiKeyInfo = {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
@@ -495,6 +512,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
toolNames: tools.map((tool) => tool.name),
|
toolNames: tools.map((tool) => tool.name),
|
||||||
|
modelAliasLines: buildModelAliasLines(params.config),
|
||||||
userTimezone,
|
userTimezone,
|
||||||
userTime,
|
userTime,
|
||||||
}),
|
}),
|
||||||
@@ -795,6 +813,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
toolNames: tools.map((tool) => tool.name),
|
toolNames: tools.map((tool) => tool.name),
|
||||||
|
modelAliasLines: buildModelAliasLines(params.config),
|
||||||
userTimezone,
|
userTimezone,
|
||||||
userTime,
|
userTime,
|
||||||
}),
|
}),
|
||||||
|
|||||||
288
src/agents/subagent-announce.ts
Normal file
288
src/agents/subagent-announce.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveStorePath,
|
||||||
|
} from "../config/sessions.js";
|
||||||
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { readLatestAssistantReply, runAgentStep } from "./tools/agent-step.js";
|
||||||
|
import { resolveAnnounceTarget } from "./tools/sessions-announce-target.js";
|
||||||
|
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
||||||
|
|
||||||
|
function formatDurationShort(valueMs?: number) {
|
||||||
|
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined;
|
||||||
|
const totalSeconds = Math.round(valueMs / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) return `${hours}h${minutes}m`;
|
||||||
|
if (minutes > 0) return `${minutes}m${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(value?: number) {
|
||||||
|
if (!value || !Number.isFinite(value)) return "0";
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
|
return String(Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsd(value?: number) {
|
||||||
|
if (value === undefined || !Number.isFinite(value)) return undefined;
|
||||||
|
if (value >= 1) return `$${value.toFixed(2)}`;
|
||||||
|
if (value >= 0.01) return `$${value.toFixed(2)}`;
|
||||||
|
return `$${value.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelCost(params: {
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
config: ReturnType<typeof loadConfig>;
|
||||||
|
}):
|
||||||
|
| {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
|
const provider = params.provider?.trim();
|
||||||
|
const model = params.model?.trim();
|
||||||
|
if (!provider || !model) return undefined;
|
||||||
|
const models = params.config.models?.providers?.[provider]?.models ?? [];
|
||||||
|
const entry = models.find((candidate) => candidate.id === model);
|
||||||
|
return entry?.cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSessionUsage(params: { sessionKey: string }) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||||
|
let entry = loadSessionStore(storePath)[params.sessionKey];
|
||||||
|
if (!entry) return { entry, storePath };
|
||||||
|
const hasTokens = () =>
|
||||||
|
entry &&
|
||||||
|
(typeof entry.totalTokens === "number" ||
|
||||||
|
typeof entry.inputTokens === "number" ||
|
||||||
|
typeof entry.outputTokens === "number");
|
||||||
|
if (hasTokens()) return { entry, storePath };
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
entry = loadSessionStore(storePath)[params.sessionKey];
|
||||||
|
if (hasTokens()) break;
|
||||||
|
}
|
||||||
|
return { entry, storePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSubagentStatsLine(params: {
|
||||||
|
sessionKey: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
}) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const { entry, storePath } = await waitForSessionUsage({
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = entry?.sessionId;
|
||||||
|
const transcriptPath =
|
||||||
|
sessionId && storePath
|
||||||
|
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const input = entry?.inputTokens;
|
||||||
|
const output = entry?.outputTokens;
|
||||||
|
const total =
|
||||||
|
entry?.totalTokens ??
|
||||||
|
(typeof input === "number" && typeof output === "number"
|
||||||
|
? input + output
|
||||||
|
: undefined);
|
||||||
|
const runtimeMs =
|
||||||
|
typeof params.startedAt === "number" && typeof params.endedAt === "number"
|
||||||
|
? Math.max(0, params.endedAt - params.startedAt)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const provider = entry?.modelProvider;
|
||||||
|
const model = entry?.model;
|
||||||
|
const costConfig = resolveModelCost({ provider, model, config: cfg });
|
||||||
|
const cost =
|
||||||
|
costConfig && typeof input === "number" && typeof output === "number"
|
||||||
|
? (input * costConfig.input + output * costConfig.output) / 1_000_000
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const runtime = formatDurationShort(runtimeMs);
|
||||||
|
parts.push(`runtime ${runtime ?? "n/a"}`);
|
||||||
|
if (typeof total === "number") {
|
||||||
|
const inputText =
|
||||||
|
typeof input === "number" ? formatTokenCount(input) : "n/a";
|
||||||
|
const outputText =
|
||||||
|
typeof output === "number" ? formatTokenCount(output) : "n/a";
|
||||||
|
const totalText = formatTokenCount(total);
|
||||||
|
parts.push(`tokens ${totalText} (in ${inputText} / out ${outputText})`);
|
||||||
|
} else {
|
||||||
|
parts.push("tokens n/a");
|
||||||
|
}
|
||||||
|
const costText = formatUsd(cost);
|
||||||
|
if (costText) parts.push(`est ${costText}`);
|
||||||
|
parts.push(`sessionKey ${params.sessionKey}`);
|
||||||
|
if (sessionId) parts.push(`sessionId ${sessionId}`);
|
||||||
|
if (transcriptPath) parts.push(`transcript ${transcriptPath}`);
|
||||||
|
|
||||||
|
return `Stats: ${parts.join(" \u2022 ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubagentSystemPrompt(params: {
|
||||||
|
requesterSessionKey?: string;
|
||||||
|
requesterProvider?: string;
|
||||||
|
childSessionKey: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
"Sub-agent context:",
|
||||||
|
params.label ? `Label: ${params.label}` : undefined,
|
||||||
|
params.requesterSessionKey
|
||||||
|
? `Requester session: ${params.requesterSessionKey}.`
|
||||||
|
: undefined,
|
||||||
|
params.requesterProvider
|
||||||
|
? `Requester provider: ${params.requesterProvider}.`
|
||||||
|
: undefined,
|
||||||
|
`Your session: ${params.childSessionKey}.`,
|
||||||
|
"Run the task. Provide a clear final answer (plain text).",
|
||||||
|
'After you finish, you may be asked to produce an "announce" message to post back to the requester chat.',
|
||||||
|
].filter(Boolean);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubagentAnnouncePrompt(params: {
|
||||||
|
requesterSessionKey?: string;
|
||||||
|
requesterProvider?: string;
|
||||||
|
announceChannel: string;
|
||||||
|
task: string;
|
||||||
|
subagentReply?: string;
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
"Sub-agent announce step:",
|
||||||
|
params.requesterSessionKey
|
||||||
|
? `Requester session: ${params.requesterSessionKey}.`
|
||||||
|
: undefined,
|
||||||
|
params.requesterProvider
|
||||||
|
? `Requester provider: ${params.requesterProvider}.`
|
||||||
|
: undefined,
|
||||||
|
`Post target provider: ${params.announceChannel}.`,
|
||||||
|
`Original task: ${params.task}`,
|
||||||
|
params.subagentReply
|
||||||
|
? `Sub-agent result: ${params.subagentReply}`
|
||||||
|
: "Sub-agent result: (not available).",
|
||||||
|
'Reply exactly "ANNOUNCE_SKIP" to stay silent.',
|
||||||
|
"Any other reply will be posted to the requester chat provider.",
|
||||||
|
].filter(Boolean);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSubagentAnnounceFlow(params: {
|
||||||
|
childSessionKey: string;
|
||||||
|
childRunId: string;
|
||||||
|
requesterSessionKey: string;
|
||||||
|
requesterProvider?: string;
|
||||||
|
requesterDisplayKey: string;
|
||||||
|
task: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
cleanup: "delete" | "keep";
|
||||||
|
roundOneReply?: string;
|
||||||
|
waitForCompletion?: boolean;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
let reply = params.roundOneReply;
|
||||||
|
if (!reply && params.waitForCompletion !== false) {
|
||||||
|
const waitMs = Math.min(params.timeoutMs, 60_000);
|
||||||
|
const wait = (await callGateway({
|
||||||
|
method: "agent.wait",
|
||||||
|
params: {
|
||||||
|
runId: params.childRunId,
|
||||||
|
timeoutMs: waitMs,
|
||||||
|
},
|
||||||
|
timeoutMs: waitMs + 2000,
|
||||||
|
})) as { status?: string };
|
||||||
|
if (wait?.status !== "ok") return;
|
||||||
|
reply = await readLatestAssistantReply({
|
||||||
|
sessionKey: params.childSessionKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reply) {
|
||||||
|
reply = await readLatestAssistantReply({
|
||||||
|
sessionKey: params.childSessionKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const announceTarget = await resolveAnnounceTarget({
|
||||||
|
sessionKey: params.requesterSessionKey,
|
||||||
|
displayKey: params.requesterDisplayKey,
|
||||||
|
});
|
||||||
|
if (!announceTarget) return;
|
||||||
|
|
||||||
|
const announcePrompt = buildSubagentAnnouncePrompt({
|
||||||
|
requesterSessionKey: params.requesterSessionKey,
|
||||||
|
requesterProvider: params.requesterProvider,
|
||||||
|
announceChannel: announceTarget.provider,
|
||||||
|
task: params.task,
|
||||||
|
subagentReply: reply,
|
||||||
|
});
|
||||||
|
|
||||||
|
const announceReply = await runAgentStep({
|
||||||
|
sessionKey: params.childSessionKey,
|
||||||
|
message: "Sub-agent announce step.",
|
||||||
|
extraSystemPrompt: announcePrompt,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
lane: "nested",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!announceReply ||
|
||||||
|
!announceReply.trim() ||
|
||||||
|
isAnnounceSkip(announceReply)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const statsLine = await buildSubagentStatsLine({
|
||||||
|
sessionKey: params.childSessionKey,
|
||||||
|
startedAt: params.startedAt,
|
||||||
|
endedAt: params.endedAt,
|
||||||
|
});
|
||||||
|
const message = statsLine
|
||||||
|
? `${announceReply.trim()}\n\n${statsLine}`
|
||||||
|
: announceReply.trim();
|
||||||
|
|
||||||
|
await callGateway({
|
||||||
|
method: "send",
|
||||||
|
params: {
|
||||||
|
to: announceTarget.to,
|
||||||
|
message,
|
||||||
|
provider: announceTarget.provider,
|
||||||
|
accountId: announceTarget.accountId,
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
||||||
|
} finally {
|
||||||
|
if (params.cleanup === "delete") {
|
||||||
|
try {
|
||||||
|
await callGateway({
|
||||||
|
method: "sessions.delete",
|
||||||
|
params: { key: params.childSessionKey, deleteTranscript: true },
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/agents/subagent-registry.ts
Normal file
195
src/agents/subagent-registry.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { onAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
|
||||||
|
|
||||||
|
export type SubagentRunRecord = {
|
||||||
|
runId: string;
|
||||||
|
childSessionKey: string;
|
||||||
|
requesterSessionKey: string;
|
||||||
|
requesterProvider?: string;
|
||||||
|
requesterDisplayKey: string;
|
||||||
|
task: string;
|
||||||
|
cleanup: "delete" | "keep";
|
||||||
|
createdAt: number;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
archiveAtMs?: number;
|
||||||
|
announceHandled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||||
|
let sweeper: NodeJS.Timeout | null = null;
|
||||||
|
let listenerStarted = false;
|
||||||
|
|
||||||
|
function resolveArchiveAfterMs() {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60;
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
|
||||||
|
return Math.max(1, Math.floor(minutes)) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSweeper() {
|
||||||
|
if (sweeper) return;
|
||||||
|
sweeper = setInterval(() => {
|
||||||
|
void sweepSubagentRuns();
|
||||||
|
}, 60_000);
|
||||||
|
sweeper.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSweeper() {
|
||||||
|
if (!sweeper) return;
|
||||||
|
clearInterval(sweeper);
|
||||||
|
sweeper = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweepSubagentRuns() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [runId, entry] of subagentRuns.entries()) {
|
||||||
|
if (!entry.archiveAtMs || entry.archiveAtMs > now) continue;
|
||||||
|
subagentRuns.delete(runId);
|
||||||
|
try {
|
||||||
|
await callGateway({
|
||||||
|
method: "sessions.delete",
|
||||||
|
params: { key: entry.childSessionKey, deleteTranscript: true },
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subagentRuns.size === 0) stopSweeper();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureListener() {
|
||||||
|
if (listenerStarted) return;
|
||||||
|
listenerStarted = true;
|
||||||
|
onAgentEvent((evt) => {
|
||||||
|
if (!evt || evt.stream !== "lifecycle") return;
|
||||||
|
const entry = subagentRuns.get(evt.runId);
|
||||||
|
if (!entry) return;
|
||||||
|
const phase = evt.data?.phase;
|
||||||
|
if (phase === "start") {
|
||||||
|
const startedAt =
|
||||||
|
typeof evt.data?.startedAt === "number"
|
||||||
|
? (evt.data.startedAt as number)
|
||||||
|
: undefined;
|
||||||
|
if (startedAt) entry.startedAt = startedAt;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phase !== "end" && phase !== "error") return;
|
||||||
|
const endedAt =
|
||||||
|
typeof evt.data?.endedAt === "number"
|
||||||
|
? (evt.data.endedAt as number)
|
||||||
|
: Date.now();
|
||||||
|
entry.endedAt = endedAt;
|
||||||
|
if (!beginSubagentAnnounce(evt.runId)) {
|
||||||
|
if (entry.cleanup === "delete") {
|
||||||
|
subagentRuns.delete(evt.runId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: entry.childSessionKey,
|
||||||
|
childRunId: entry.runId,
|
||||||
|
requesterSessionKey: entry.requesterSessionKey,
|
||||||
|
requesterProvider: entry.requesterProvider,
|
||||||
|
requesterDisplayKey: entry.requesterDisplayKey,
|
||||||
|
task: entry.task,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
cleanup: entry.cleanup,
|
||||||
|
waitForCompletion: false,
|
||||||
|
startedAt: entry.startedAt,
|
||||||
|
endedAt: entry.endedAt,
|
||||||
|
});
|
||||||
|
if (entry.cleanup === "delete") {
|
||||||
|
subagentRuns.delete(evt.runId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginSubagentAnnounce(runId: string) {
|
||||||
|
const entry = subagentRuns.get(runId);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (entry.announceHandled) return false;
|
||||||
|
entry.announceHandled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSubagentRun(params: {
|
||||||
|
runId: string;
|
||||||
|
childSessionKey: string;
|
||||||
|
requesterSessionKey: string;
|
||||||
|
requesterProvider?: string;
|
||||||
|
requesterDisplayKey: string;
|
||||||
|
task: string;
|
||||||
|
cleanup: "delete" | "keep";
|
||||||
|
}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const archiveAfterMs = resolveArchiveAfterMs();
|
||||||
|
const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined;
|
||||||
|
subagentRuns.set(params.runId, {
|
||||||
|
runId: params.runId,
|
||||||
|
childSessionKey: params.childSessionKey,
|
||||||
|
requesterSessionKey: params.requesterSessionKey,
|
||||||
|
requesterProvider: params.requesterProvider,
|
||||||
|
requesterDisplayKey: params.requesterDisplayKey,
|
||||||
|
task: params.task,
|
||||||
|
cleanup: params.cleanup,
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: now,
|
||||||
|
archiveAtMs,
|
||||||
|
announceHandled: false,
|
||||||
|
});
|
||||||
|
ensureListener();
|
||||||
|
if (archiveAfterMs) startSweeper();
|
||||||
|
void probeImmediateCompletion(params.runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeImmediateCompletion(runId: string) {
|
||||||
|
try {
|
||||||
|
const wait = (await callGateway({
|
||||||
|
method: "agent.wait",
|
||||||
|
params: {
|
||||||
|
runId,
|
||||||
|
timeoutMs: 0,
|
||||||
|
},
|
||||||
|
timeoutMs: 2000,
|
||||||
|
})) as { status?: string; startedAt?: number; endedAt?: number };
|
||||||
|
if (wait?.status !== "ok" && wait?.status !== "error") return;
|
||||||
|
const entry = subagentRuns.get(runId);
|
||||||
|
if (!entry) return;
|
||||||
|
if (typeof wait.startedAt === "number") entry.startedAt = wait.startedAt;
|
||||||
|
if (typeof wait.endedAt === "number") entry.endedAt = wait.endedAt;
|
||||||
|
if (!entry.endedAt) entry.endedAt = Date.now();
|
||||||
|
if (!beginSubagentAnnounce(runId)) return;
|
||||||
|
void runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: entry.childSessionKey,
|
||||||
|
childRunId: entry.runId,
|
||||||
|
requesterSessionKey: entry.requesterSessionKey,
|
||||||
|
requesterProvider: entry.requesterProvider,
|
||||||
|
requesterDisplayKey: entry.requesterDisplayKey,
|
||||||
|
task: entry.task,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
cleanup: entry.cleanup,
|
||||||
|
waitForCompletion: false,
|
||||||
|
startedAt: entry.startedAt,
|
||||||
|
endedAt: entry.endedAt,
|
||||||
|
});
|
||||||
|
if (entry.cleanup === "delete") {
|
||||||
|
subagentRuns.delete(runId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSubagentRegistryForTests() {
|
||||||
|
subagentRuns.clear();
|
||||||
|
stopSweeper();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseSubagentRun(runId: string) {
|
||||||
|
subagentRuns.delete(runId);
|
||||||
|
if (subagentRuns.size === 0) stopSweeper();
|
||||||
|
}
|
||||||
@@ -58,4 +58,18 @@ describe("buildAgentSystemPromptAppend", () => {
|
|||||||
expect(prompt).toContain("User timezone: America/Chicago");
|
expect(prompt).toContain("User timezone: America/Chicago");
|
||||||
expect(prompt).toContain("Current user time: 2026-01-05 15:26");
|
expect(prompt).toContain("Current user time: 2026-01-05 15:26");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes model alias guidance when aliases are provided", () => {
|
||||||
|
const prompt = buildAgentSystemPromptAppend({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
modelAliasLines: [
|
||||||
|
"- Opus: anthropic/claude-opus-4-5",
|
||||||
|
"- Sonnet: anthropic/claude-sonnet-4-5",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("## Model Aliases");
|
||||||
|
expect(prompt).toContain("Prefer aliases when specifying model overrides");
|
||||||
|
expect(prompt).toContain("- Opus: anthropic/claude-opus-4-5");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function buildAgentSystemPromptAppend(params: {
|
|||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
reasoningTagHint?: boolean;
|
reasoningTagHint?: boolean;
|
||||||
toolNames?: string[];
|
toolNames?: string[];
|
||||||
|
modelAliasLines?: string[];
|
||||||
userTimezone?: string;
|
userTimezone?: string;
|
||||||
userTime?: string;
|
userTime?: string;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
@@ -162,6 +163,16 @@ export function buildAgentSystemPromptAppend(params: {
|
|||||||
: "",
|
: "",
|
||||||
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
|
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
|
||||||
"",
|
"",
|
||||||
|
params.modelAliasLines && params.modelAliasLines.length > 0
|
||||||
|
? "## Model Aliases"
|
||||||
|
: "",
|
||||||
|
params.modelAliasLines && params.modelAliasLines.length > 0
|
||||||
|
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
|
||||||
|
: "",
|
||||||
|
params.modelAliasLines && params.modelAliasLines.length > 0
|
||||||
|
? params.modelAliasLines.join("\n")
|
||||||
|
: "",
|
||||||
|
params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "",
|
||||||
"## Workspace",
|
"## Workspace",
|
||||||
`Your working directory is: ${params.workspaceDir}`,
|
`Your working directory is: ${params.workspaceDir}`,
|
||||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||||
|
|||||||
@@ -9,16 +9,22 @@ import {
|
|||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
parseAgentSessionKey,
|
parseAgentSessionKey,
|
||||||
} from "../../routing/session-key.js";
|
} from "../../routing/session-key.js";
|
||||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
import {
|
||||||
|
buildSubagentSystemPrompt,
|
||||||
|
runSubagentAnnounceFlow,
|
||||||
|
} from "../subagent-announce.js";
|
||||||
|
import {
|
||||||
|
beginSubagentAnnounce,
|
||||||
|
registerSubagentRun,
|
||||||
|
} from "../subagent-registry.js";
|
||||||
|
import { readLatestAssistantReply } from "./agent-step.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
|
|
||||||
import {
|
import {
|
||||||
resolveDisplaySessionKey,
|
resolveDisplaySessionKey,
|
||||||
resolveInternalSessionKey,
|
resolveInternalSessionKey,
|
||||||
resolveMainSessionAlias,
|
resolveMainSessionAlias,
|
||||||
} from "./sessions-helpers.js";
|
} from "./sessions-helpers.js";
|
||||||
import { isAnnounceSkip } from "./sessions-send-helpers.js";
|
|
||||||
|
|
||||||
const SessionsSpawnToolSchema = Type.Object({
|
const SessionsSpawnToolSchema = Type.Object({
|
||||||
task: Type.String(),
|
task: Type.String(),
|
||||||
@@ -30,140 +36,6 @@ const SessionsSpawnToolSchema = Type.Object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildSubagentSystemPrompt(params: {
|
|
||||||
requesterSessionKey?: string;
|
|
||||||
requesterProvider?: string;
|
|
||||||
childSessionKey: string;
|
|
||||||
label?: string;
|
|
||||||
}) {
|
|
||||||
const lines = [
|
|
||||||
"Sub-agent context:",
|
|
||||||
params.label ? `Label: ${params.label}` : undefined,
|
|
||||||
params.requesterSessionKey
|
|
||||||
? `Requester session: ${params.requesterSessionKey}.`
|
|
||||||
: undefined,
|
|
||||||
params.requesterProvider
|
|
||||||
? `Requester provider: ${params.requesterProvider}.`
|
|
||||||
: undefined,
|
|
||||||
`Your session: ${params.childSessionKey}.`,
|
|
||||||
"Run the task. Provide a clear final answer (plain text).",
|
|
||||||
'After you finish, you may be asked to produce an "announce" message to post back to the requester chat.',
|
|
||||||
].filter(Boolean);
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSubagentAnnouncePrompt(params: {
|
|
||||||
requesterSessionKey?: string;
|
|
||||||
requesterProvider?: string;
|
|
||||||
announceChannel: string;
|
|
||||||
task: string;
|
|
||||||
subagentReply?: string;
|
|
||||||
}) {
|
|
||||||
const lines = [
|
|
||||||
"Sub-agent announce step:",
|
|
||||||
params.requesterSessionKey
|
|
||||||
? `Requester session: ${params.requesterSessionKey}.`
|
|
||||||
: undefined,
|
|
||||||
params.requesterProvider
|
|
||||||
? `Requester provider: ${params.requesterProvider}.`
|
|
||||||
: undefined,
|
|
||||||
`Post target provider: ${params.announceChannel}.`,
|
|
||||||
`Original task: ${params.task}`,
|
|
||||||
params.subagentReply
|
|
||||||
? `Sub-agent result: ${params.subagentReply}`
|
|
||||||
: "Sub-agent result: (not available).",
|
|
||||||
'Reply exactly "ANNOUNCE_SKIP" to stay silent.',
|
|
||||||
"Any other reply will be posted to the requester chat provider.",
|
|
||||||
].filter(Boolean);
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSubagentAnnounceFlow(params: {
|
|
||||||
childSessionKey: string;
|
|
||||||
childRunId: string;
|
|
||||||
requesterSessionKey: string;
|
|
||||||
requesterProvider?: string;
|
|
||||||
requesterDisplayKey: string;
|
|
||||||
task: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
cleanup: "delete" | "keep";
|
|
||||||
roundOneReply?: string;
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
let reply = params.roundOneReply;
|
|
||||||
if (!reply) {
|
|
||||||
const waitMs = Math.min(params.timeoutMs, 60_000);
|
|
||||||
const wait = (await callGateway({
|
|
||||||
method: "agent.wait",
|
|
||||||
params: {
|
|
||||||
runId: params.childRunId,
|
|
||||||
timeoutMs: waitMs,
|
|
||||||
},
|
|
||||||
timeoutMs: waitMs + 2000,
|
|
||||||
})) as { status?: string };
|
|
||||||
if (wait?.status !== "ok") return;
|
|
||||||
reply = await readLatestAssistantReply({
|
|
||||||
sessionKey: params.childSessionKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const announceTarget = await resolveAnnounceTarget({
|
|
||||||
sessionKey: params.requesterSessionKey,
|
|
||||||
displayKey: params.requesterDisplayKey,
|
|
||||||
});
|
|
||||||
if (!announceTarget) return;
|
|
||||||
|
|
||||||
const announcePrompt = buildSubagentAnnouncePrompt({
|
|
||||||
requesterSessionKey: params.requesterSessionKey,
|
|
||||||
requesterProvider: params.requesterProvider,
|
|
||||||
announceChannel: announceTarget.provider,
|
|
||||||
task: params.task,
|
|
||||||
subagentReply: reply,
|
|
||||||
});
|
|
||||||
|
|
||||||
const announceReply = await runAgentStep({
|
|
||||||
sessionKey: params.childSessionKey,
|
|
||||||
message: "Sub-agent announce step.",
|
|
||||||
extraSystemPrompt: announcePrompt,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
lane: "nested",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!announceReply ||
|
|
||||||
!announceReply.trim() ||
|
|
||||||
isAnnounceSkip(announceReply)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await callGateway({
|
|
||||||
method: "send",
|
|
||||||
params: {
|
|
||||||
to: announceTarget.to,
|
|
||||||
message: announceReply.trim(),
|
|
||||||
provider: announceTarget.provider,
|
|
||||||
accountId: announceTarget.accountId,
|
|
||||||
idempotencyKey: crypto.randomUUID(),
|
|
||||||
},
|
|
||||||
timeoutMs: 10_000,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
|
||||||
} finally {
|
|
||||||
if (params.cleanup === "delete") {
|
|
||||||
try {
|
|
||||||
await callGateway({
|
|
||||||
method: "sessions.delete",
|
|
||||||
params: { key: params.childSessionKey, deleteTranscript: true },
|
|
||||||
timeoutMs: 10_000,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSessionsSpawnTool(opts?: {
|
export function createSessionsSpawnTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentProvider?: string;
|
agentProvider?: string;
|
||||||
@@ -183,7 +55,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
const cleanup =
|
const cleanup =
|
||||||
params.cleanup === "keep" || params.cleanup === "delete"
|
params.cleanup === "keep" || params.cleanup === "delete"
|
||||||
? (params.cleanup as "keep" | "delete")
|
? (params.cleanup as "keep" | "delete")
|
||||||
: "delete";
|
: "keep";
|
||||||
const timeoutSeconds =
|
const timeoutSeconds =
|
||||||
typeof params.timeoutSeconds === "number" &&
|
typeof params.timeoutSeconds === "number" &&
|
||||||
Number.isFinite(params.timeoutSeconds)
|
Number.isFinite(params.timeoutSeconds)
|
||||||
@@ -301,17 +173,17 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerSubagentRun({
|
||||||
|
runId: childRunId,
|
||||||
|
childSessionKey,
|
||||||
|
requesterSessionKey: requesterInternalKey,
|
||||||
|
requesterProvider: opts?.agentProvider,
|
||||||
|
requesterDisplayKey,
|
||||||
|
task,
|
||||||
|
cleanup,
|
||||||
|
});
|
||||||
|
|
||||||
if (timeoutSeconds === 0) {
|
if (timeoutSeconds === 0) {
|
||||||
void runSubagentAnnounceFlow({
|
|
||||||
childSessionKey,
|
|
||||||
childRunId,
|
|
||||||
requesterSessionKey: requesterInternalKey,
|
|
||||||
requesterProvider: opts?.agentProvider,
|
|
||||||
requesterDisplayKey,
|
|
||||||
task,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
cleanup,
|
|
||||||
});
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
childSessionKey,
|
childSessionKey,
|
||||||
@@ -323,6 +195,8 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
|
|
||||||
let waitStatus: string | undefined;
|
let waitStatus: string | undefined;
|
||||||
let waitError: string | undefined;
|
let waitError: string | undefined;
|
||||||
|
let waitStartedAt: number | undefined;
|
||||||
|
let waitEndedAt: number | undefined;
|
||||||
try {
|
try {
|
||||||
const wait = (await callGateway({
|
const wait = (await callGateway({
|
||||||
method: "agent.wait",
|
method: "agent.wait",
|
||||||
@@ -331,9 +205,18 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
},
|
},
|
||||||
timeoutMs: timeoutMs + 2000,
|
timeoutMs: timeoutMs + 2000,
|
||||||
})) as { status?: string; error?: string };
|
})) as {
|
||||||
|
status?: string;
|
||||||
|
error?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
};
|
||||||
waitStatus = typeof wait?.status === "string" ? wait.status : undefined;
|
waitStatus = typeof wait?.status === "string" ? wait.status : undefined;
|
||||||
waitError = typeof wait?.error === "string" ? wait.error : undefined;
|
waitError = typeof wait?.error === "string" ? wait.error : undefined;
|
||||||
|
waitStartedAt =
|
||||||
|
typeof wait?.startedAt === "number" ? wait.startedAt : undefined;
|
||||||
|
waitEndedAt =
|
||||||
|
typeof wait?.endedAt === "number" ? wait.endedAt : undefined;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const messageText =
|
const messageText =
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
@@ -350,16 +233,15 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (waitStatus === "timeout") {
|
if (waitStatus === "timeout") {
|
||||||
void runSubagentAnnounceFlow({
|
try {
|
||||||
childSessionKey,
|
await callGateway({
|
||||||
childRunId,
|
method: "chat.abort",
|
||||||
requesterSessionKey: requesterInternalKey,
|
params: { sessionKey: childSessionKey, runId: childRunId },
|
||||||
requesterProvider: opts?.agentProvider,
|
timeoutMs: 5_000,
|
||||||
requesterDisplayKey,
|
});
|
||||||
task,
|
} catch {
|
||||||
timeoutMs: 30_000,
|
// best-effort
|
||||||
cleanup,
|
}
|
||||||
});
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "timeout",
|
status: "timeout",
|
||||||
error: waitError,
|
error: waitError,
|
||||||
@@ -370,16 +252,6 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (waitStatus === "error") {
|
if (waitStatus === "error") {
|
||||||
void runSubagentAnnounceFlow({
|
|
||||||
childSessionKey,
|
|
||||||
childRunId,
|
|
||||||
requesterSessionKey: requesterInternalKey,
|
|
||||||
requesterProvider: opts?.agentProvider,
|
|
||||||
requesterDisplayKey,
|
|
||||||
task,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
cleanup,
|
|
||||||
});
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: waitError ?? "agent error",
|
error: waitError ?? "agent error",
|
||||||
@@ -393,17 +265,21 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
const replyText = await readLatestAssistantReply({
|
const replyText = await readLatestAssistantReply({
|
||||||
sessionKey: childSessionKey,
|
sessionKey: childSessionKey,
|
||||||
});
|
});
|
||||||
void runSubagentAnnounceFlow({
|
if (beginSubagentAnnounce(childRunId)) {
|
||||||
childSessionKey,
|
void runSubagentAnnounceFlow({
|
||||||
childRunId,
|
childSessionKey,
|
||||||
requesterSessionKey: requesterInternalKey,
|
childRunId,
|
||||||
requesterProvider: opts?.agentProvider,
|
requesterSessionKey: requesterInternalKey,
|
||||||
requesterDisplayKey,
|
requesterProvider: opts?.agentProvider,
|
||||||
task,
|
requesterDisplayKey,
|
||||||
timeoutMs: 30_000,
|
task,
|
||||||
cleanup,
|
timeoutMs: 30_000,
|
||||||
roundOneReply: replyText,
|
cleanup,
|
||||||
});
|
roundOneReply: replyText,
|
||||||
|
startedAt: waitStartedAt,
|
||||||
|
endedAt: waitEndedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
|
|||||||
@@ -895,6 +895,8 @@ export type ClawdbotConfig = {
|
|||||||
subagents?: {
|
subagents?: {
|
||||||
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
|
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
|
||||||
|
archiveAfterMinutes?: number;
|
||||||
/** Tool allow/deny policy for sub-agent sessions (deny wins). */
|
/** Tool allow/deny policy for sub-agent sessions (deny wins). */
|
||||||
tools?: {
|
tools?: {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
subagents: z
|
subagents: z
|
||||||
.object({
|
.object({
|
||||||
maxConcurrent: z.number().int().positive().optional(),
|
maxConcurrent: z.number().int().positive().optional(),
|
||||||
|
archiveAfterMinutes: z.number().int().positive().optional(),
|
||||||
tools: z
|
tools: z
|
||||||
.object({
|
.object({
|
||||||
allow: z.array(z.string()).optional(),
|
allow: z.array(z.string()).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user