feat: update subagent announce + archive

This commit is contained in:
Peter Steinberger
2026-01-07 06:53:01 +01:00
parent 1673a221f8
commit 75c66acfd8
13 changed files with 628 additions and 197 deletions

View File

@@ -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

View File

@@ -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**.

View File

@@ -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 replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05). - `sessions_send` runs a replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).
- After the pingpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement. - After the pingpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.

View File

@@ -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**:

View File

@@ -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 () => {

View File

@@ -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,
}), }),

View 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
}
}
}
}

View 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();
}

View File

@@ -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");
});
}); });

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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[];

View File

@@ -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(),