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)
- `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

View File

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

View File

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

View File

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

View File

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

View File

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

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("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[];
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.",

View File

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

View File

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

View File

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