feat: update subagent announce + archive
This commit is contained in:
@@ -127,15 +127,17 @@ Parameters:
|
||||
- `task` (required)
|
||||
- `label?` (optional; used for logs/UI)
|
||||
- `model?` (optional; overrides the sub-agent model; invalid values error)
|
||||
- `timeoutSeconds?` (default 0; 0 = fire-and-forget)
|
||||
- `cleanup?` (`delete|keep`, default `delete`)
|
||||
- `timeoutSeconds?` (optional; omit for long-running jobs; if set, Clawdbot aborts the sub-agent when the timeout elapses)
|
||||
- `cleanup?` (`delete|keep`, default `keep`)
|
||||
|
||||
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 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -757,6 +757,10 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
||||
target: "last"
|
||||
},
|
||||
maxConcurrent: 3,
|
||||
subagents: {
|
||||
maxConcurrent: 1,
|
||||
archiveAfterMinutes: 60
|
||||
},
|
||||
bash: {
|
||||
backgroundMs: 10000,
|
||||
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)
|
||||
- `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).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
|
||||
@@ -150,18 +150,20 @@ Core actions:
|
||||
Notes:
|
||||
- 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.
|
||||
|
||||
Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `model?`, `timeoutSeconds?`, `cleanup?`
|
||||
|
||||
Notes:
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||
- `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).
|
||||
- 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 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:
|
||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||
@@ -25,8 +25,15 @@ Tool params:
|
||||
- `task` (required)
|
||||
- `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)
|
||||
- `timeoutSeconds?` (default `0`; `0` = fire-and-forget)
|
||||
- `cleanup?` (`delete|keep`, default `delete`)
|
||||
- `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 `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
|
||||
|
||||
@@ -35,6 +42,12 @@ Sub-agents report back via an announce step:
|
||||
- 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.
|
||||
|
||||
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)
|
||||
|
||||
By default, sub-agents get **all tools except session tools**:
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("subagents", () => {
|
||||
const result = await tool.execute("call1", {
|
||||
task: "do thing",
|
||||
timeoutSeconds: 1,
|
||||
cleanup: "delete",
|
||||
});
|
||||
expect(result.details).toMatchObject({ status: "ok", reply: "result" });
|
||||
|
||||
@@ -105,11 +106,10 @@ describe("subagents", () => {
|
||||
expect(first?.deliver).toBe(false);
|
||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
expect(sendParams).toMatchObject({
|
||||
provider: "discord",
|
||||
to: "channel:req",
|
||||
message: "announce now",
|
||||
});
|
||||
expect(sendParams.provider).toBe("discord");
|
||||
expect(sendParams.to).toBe("channel:req");
|
||||
expect(sendParams.message ?? "").toContain("announce now");
|
||||
expect(sendParams.message ?? "").toContain("Stats:");
|
||||
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));
|
||||
|
||||
expect(sendParams).toMatchObject({
|
||||
provider: "whatsapp",
|
||||
to: "+123",
|
||||
message: "hello from sub",
|
||||
});
|
||||
expect(sendParams.provider).toBe("whatsapp");
|
||||
expect(sendParams.to).toBe("+123");
|
||||
expect(sendParams.message ?? "").toContain("hello from sub");
|
||||
expect(sendParams.message ?? "").toContain("Stats:");
|
||||
});
|
||||
|
||||
it("sessions_spawn applies a model to the child session", async () => {
|
||||
|
||||
@@ -96,6 +96,23 @@ export type EmbeddedPiRunMeta = {
|
||||
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 = {
|
||||
apiKey: string;
|
||||
profileId?: string;
|
||||
@@ -495,6 +512,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
toolNames: tools.map((tool) => tool.name),
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
}),
|
||||
@@ -795,6 +813,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
toolNames: tools.map((tool) => tool.name),
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
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("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[];
|
||||
reasoningTagHint?: boolean;
|
||||
toolNames?: string[];
|
||||
modelAliasLines?: string[];
|
||||
userTimezone?: string;
|
||||
userTime?: 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.",
|
||||
"",
|
||||
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",
|
||||
`Your working directory is: ${params.workspaceDir}`,
|
||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||
|
||||
@@ -9,16 +9,22 @@ import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} 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 { jsonResult, readStringParam } from "./common.js";
|
||||
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
|
||||
import {
|
||||
resolveDisplaySessionKey,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
} from "./sessions-helpers.js";
|
||||
import { isAnnounceSkip } from "./sessions-send-helpers.js";
|
||||
|
||||
const SessionsSpawnToolSchema = Type.Object({
|
||||
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?: {
|
||||
agentSessionKey?: string;
|
||||
agentProvider?: string;
|
||||
@@ -183,7 +55,7 @@ export function createSessionsSpawnTool(opts?: {
|
||||
const cleanup =
|
||||
params.cleanup === "keep" || params.cleanup === "delete"
|
||||
? (params.cleanup as "keep" | "delete")
|
||||
: "delete";
|
||||
: "keep";
|
||||
const timeoutSeconds =
|
||||
typeof params.timeoutSeconds === "number" &&
|
||||
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) {
|
||||
void runSubagentAnnounceFlow({
|
||||
childSessionKey,
|
||||
childRunId,
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterProvider: opts?.agentProvider,
|
||||
requesterDisplayKey,
|
||||
task,
|
||||
timeoutMs: 30_000,
|
||||
cleanup,
|
||||
});
|
||||
return jsonResult({
|
||||
status: "accepted",
|
||||
childSessionKey,
|
||||
@@ -323,6 +195,8 @@ export function createSessionsSpawnTool(opts?: {
|
||||
|
||||
let waitStatus: string | undefined;
|
||||
let waitError: string | undefined;
|
||||
let waitStartedAt: number | undefined;
|
||||
let waitEndedAt: number | undefined;
|
||||
try {
|
||||
const wait = (await callGateway({
|
||||
method: "agent.wait",
|
||||
@@ -331,9 +205,18 @@ export function createSessionsSpawnTool(opts?: {
|
||||
timeoutMs,
|
||||
},
|
||||
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;
|
||||
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) {
|
||||
const messageText =
|
||||
err instanceof Error
|
||||
@@ -350,16 +233,15 @@ export function createSessionsSpawnTool(opts?: {
|
||||
}
|
||||
|
||||
if (waitStatus === "timeout") {
|
||||
void runSubagentAnnounceFlow({
|
||||
childSessionKey,
|
||||
childRunId,
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterProvider: opts?.agentProvider,
|
||||
requesterDisplayKey,
|
||||
task,
|
||||
timeoutMs: 30_000,
|
||||
cleanup,
|
||||
});
|
||||
try {
|
||||
await callGateway({
|
||||
method: "chat.abort",
|
||||
params: { sessionKey: childSessionKey, runId: childRunId },
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return jsonResult({
|
||||
status: "timeout",
|
||||
error: waitError,
|
||||
@@ -370,16 +252,6 @@ export function createSessionsSpawnTool(opts?: {
|
||||
});
|
||||
}
|
||||
if (waitStatus === "error") {
|
||||
void runSubagentAnnounceFlow({
|
||||
childSessionKey,
|
||||
childRunId,
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterProvider: opts?.agentProvider,
|
||||
requesterDisplayKey,
|
||||
task,
|
||||
timeoutMs: 30_000,
|
||||
cleanup,
|
||||
});
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
error: waitError ?? "agent error",
|
||||
@@ -393,17 +265,21 @@ export function createSessionsSpawnTool(opts?: {
|
||||
const replyText = await readLatestAssistantReply({
|
||||
sessionKey: childSessionKey,
|
||||
});
|
||||
void runSubagentAnnounceFlow({
|
||||
childSessionKey,
|
||||
childRunId,
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterProvider: opts?.agentProvider,
|
||||
requesterDisplayKey,
|
||||
task,
|
||||
timeoutMs: 30_000,
|
||||
cleanup,
|
||||
roundOneReply: replyText,
|
||||
});
|
||||
if (beginSubagentAnnounce(childRunId)) {
|
||||
void runSubagentAnnounceFlow({
|
||||
childSessionKey,
|
||||
childRunId,
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterProvider: opts?.agentProvider,
|
||||
requesterDisplayKey,
|
||||
task,
|
||||
timeoutMs: 30_000,
|
||||
cleanup,
|
||||
roundOneReply: replyText,
|
||||
startedAt: waitStartedAt,
|
||||
endedAt: waitEndedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResult({
|
||||
status: "ok",
|
||||
|
||||
@@ -895,6 +895,8 @@ export type ClawdbotConfig = {
|
||||
subagents?: {
|
||||
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
||||
maxConcurrent?: number;
|
||||
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
|
||||
archiveAfterMinutes?: number;
|
||||
/** Tool allow/deny policy for sub-agent sessions (deny wins). */
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
|
||||
@@ -541,6 +541,7 @@ export const ClawdbotSchema = z.object({
|
||||
subagents: z
|
||||
.object({
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
archiveAfterMinutes: z.number().int().positive().optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user