diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 1708104d0..de4f31fb2 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -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:` session with `deliver: false`. +- Starts a new `agent::subagent:` 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 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 95a1efb60..89e76d143 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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**. diff --git a/docs/tools/index.md b/docs/tools/index.md index 72f0b16c0..c6db325cc 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 6286e3d6b..68a88360d 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -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:`) 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::subagent:`) 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.` (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**: diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts index e27ff2d17..d8be2d249 100644 --- a/src/agents/clawdbot-tools.subagents.test.ts +++ b/src/agents/clawdbot-tools.subagents.test.ts @@ -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 () => { diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index e18f3aebb..8620b2c33 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -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, }), diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts new file mode 100644 index 000000000..3240f339c --- /dev/null +++ b/src/agents/subagent-announce.ts @@ -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; +}): + | { + 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 + } + } + } +} diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts new file mode 100644 index 000000000..210efbb14 --- /dev/null +++ b/src/agents/subagent-registry.ts @@ -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(); +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(); +} diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index e2bc2fe7b..942a6f671 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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"); + }); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 9d1dc5c18..fc811d880 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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.", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 650638082..ea19f370a 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -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", diff --git a/src/config/types.ts b/src/config/types.ts index ac2fc0275..84019131b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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[]; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 56997dde9..b7550b376 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(),