diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a64d4c580..9fc6ee1a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ Docs: https://docs.openclaw.ai ### Changes +- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. + ### Fixes +- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. - TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. - TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. - TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index bb254d8e8e..081e4933b6 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -77,7 +77,10 @@ Text + native (when enabled): - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/whoami` (show your sender id; alias: `/id`) -- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session) +- `/subagents list|kill|log|info|send|steer` (inspect, kill, log, or steer sub-agent runs for the current session) +- `/kill ` (immediately abort one or all running sub-agents for this session; no confirmation message) +- `/steer ` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message) +- `/tell ` (alias for `/steer`) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`) - `/usage off|tokens|full|cost` (per-response usage footer or local cost summary) diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 6712e2b623..3dd66d6608 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -6,465 +6,208 @@ read_when: title: "Sub-Agents" --- -# Sub-Agents +# Sub-agents -Sub-agents let you run background tasks without blocking the main conversation. When you spawn a sub-agent, it runs in its own isolated session, does its work, and announces the result back to the chat when finished. +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 channel. -**Use cases:** +## Slash command -- Research a topic while the main agent continues answering questions -- Run multiple long tasks in parallel (web scraping, code analysis, file processing) -- Delegate tasks to specialized agents in a multi-agent setup +Use `/subagents` to inspect or control sub-agent runs for the **current session**: -## Quick Start +- `/subagents list` +- `/subagents kill ` +- `/subagents log [limit] [tools]` +- `/subagents info ` +- `/subagents send ` -The simplest way to use sub-agents is to ask your agent naturally: +`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup). -> "Spawn a sub-agent to research the latest Node.js release notes" +Primary goals: -The agent will call the `sessions_spawn` tool behind the scenes. When the sub-agent finishes, it announces its findings back into your chat. +- Parallelize "research / long task / slow tool" work without blocking the main run. +- Keep sub-agents isolated by default (session separation + optional sandboxing). +- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default. +- Support configurable nesting depth for orchestrator patterns. -You can also be explicit about options: +Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive +tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model. +You can configure this via `agents.defaults.subagents.model` or per-agent overrides. -> "Spawn a sub-agent to analyze the server logs from today. Use gpt-5.2 and set a 5-minute timeout." +## Tool -## How It Works +Use `sessions_spawn`: - - - The main agent calls `sessions_spawn` with a task description. The call is **non-blocking** — the main agent gets back `{ status: "accepted", runId, childSessionKey }` immediately. - - - A new isolated session is created (`agent::subagent:`) on the dedicated `subagent` queue lane. - - - When the sub-agent finishes, it announces its findings back to the requester chat. The main agent posts a natural-language summary. - - - The sub-agent session is auto-archived after 60 minutes (configurable). Transcripts are preserved. - - +- Starts a sub-agent run (`deliver: false`, global lane: `subagent`) +- Then runs an announce step and posts the announce reply to the requester chat channel +- Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. +- Default thinking: inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. - -Each sub-agent has its **own** context and token usage. Set a cheaper model for sub-agents to save costs — see [Setting a Default Model](#setting-a-default-model) below. - +Tool params: -## Configuration +- `task` (required) +- `label?` (optional) +- `agentId?` (optional; spawn under another agent id if allowed) +- `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) +- `thinking?` (optional; overrides thinking level for the sub-agent run) +- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) +- `cleanup?` (`delete|keep`, default `keep`) -Sub-agents work out of the box with no configuration. Defaults: +Allowlist: -- Model: target agent’s normal model selection (unless `subagents.model` is set) -- Thinking: no sub-agent override (unless `subagents.thinking` is set) -- Max concurrent: 8 -- Auto-archive: after 60 minutes +- `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent. -### Setting a Default Model +Discovery: -Use a cheaper model for sub-agents to save on token costs: +- Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`. + +Auto-archive: + +- Sub-agent sessions are automatically archived after `agents.defaults.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. +- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive. +- Auto-archive applies equally to depth-1 and depth-2 sessions. + +## Nested Sub-Agents + +By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). You can enable one level of nesting by setting `maxSpawnDepth: 2`, which allows the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents. + +### How to enable ```json5 { agents: { defaults: { subagents: { - model: "minimax/MiniMax-M2.1", + maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) + maxChildrenPerAgent: 5, // max active children per agent session (default: 5) + maxConcurrent: 8, // global concurrency lane cap (default: 8) }, }, }, } ``` -### Setting a Default Thinking Level +### Depth levels -```json5 -{ - agents: { - defaults: { - subagents: { - thinking: "low", - }, - }, - }, -} -``` +| Depth | Session key shape | Role | Can spawn? | +| ----- | -------------------------------------------- | --------------------------------------------- | ---------------------------- | +| 0 | `agent::main` | Main agent | Always | +| 1 | `agent::subagent:` | Sub-agent (orchestrator when depth 2 allowed) | Only if `maxSpawnDepth >= 2` | +| 2 | `agent::subagent::subagent:` | Sub-sub-agent (leaf worker) | Never | -### Per-Agent Overrides +### Announce chain -In a multi-agent setup, you can set sub-agent defaults per agent: +Results flow back up the chain: -```json5 -{ - agents: { - list: [ - { - id: "researcher", - subagents: { - model: "anthropic/claude-sonnet-4", - }, - }, - { - id: "assistant", - subagents: { - model: "minimax/MiniMax-M2.1", - }, - }, - ], - }, -} -``` +1. Depth-2 worker finishes → announces to its parent (depth-1 orchestrator) +2. Depth-1 orchestrator receives the announce, synthesizes results, finishes → announces to main +3. Main agent receives the announce and delivers to the user -### Concurrency +Each level only sees announces from its direct children. -Control how many sub-agents can run at the same time: +### Tool policy by depth -```json5 -{ - agents: { - defaults: { - subagents: { - maxConcurrent: 4, // default: 8 - }, - }, - }, -} -``` +- **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied. +- **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior). +- **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children. -Sub-agents use a dedicated queue lane (`subagent`) separate from the main agent queue, so sub-agent runs don't block inbound replies. +### Per-agent spawn limit -### Auto-Archive +Each agent session (at any depth) can have at most `maxChildrenPerAgent` (default: 5) active children at a time. This prevents runaway fan-out from a single orchestrator. -Sub-agent sessions are automatically archived after a configurable period: +### Cascade stop -```json5 -{ - agents: { - defaults: { - subagents: { - archiveAfterMinutes: 120, // default: 60 - }, - }, - }, -} -``` +Stopping a depth-1 orchestrator automatically stops all its depth-2 children: - -Archive renames the transcript to `*.deleted.` (same folder) — transcripts are preserved, not deleted. Auto-archive timers are best-effort; pending timers are lost if the gateway restarts. - - -## The `sessions_spawn` Tool - -This is the tool the agent calls to create sub-agents. - -### Parameters - -| Parameter | Type | Default | Description | -| ------------------- | ---------------------- | ------------------ | -------------------------------------------------------------- | -| `task` | string | _(required)_ | What the sub-agent should do | -| `label` | string | — | Short label for identification | -| `agentId` | string | _(caller's agent)_ | Spawn under a different agent id (must be allowed) | -| `model` | string | _(optional)_ | Override the model for this sub-agent | -| `thinking` | string | _(optional)_ | Override thinking level (`off`, `low`, `medium`, `high`, etc.) | -| `runTimeoutSeconds` | number | `0` (no limit) | Abort the sub-agent after N seconds | -| `cleanup` | `"delete"` \| `"keep"` | `"keep"` | `"delete"` archives immediately after announce | - -### Model Resolution Order - -The sub-agent model is resolved in this order (first match wins): - -1. Explicit `model` parameter in the `sessions_spawn` call -2. Per-agent config: `agents.list[].subagents.model` -3. Global default: `agents.defaults.subagents.model` -4. Target agent’s normal model resolution for that new session - -Thinking level is resolved in this order: - -1. Explicit `thinking` parameter in the `sessions_spawn` call -2. Per-agent config: `agents.list[].subagents.thinking` -3. Global default: `agents.defaults.subagents.thinking` -4. Otherwise no sub-agent-specific thinking override is applied - - -Invalid model values are silently skipped — the sub-agent runs on the next valid default with a warning in the tool result. - - -### Cross-Agent Spawning - -By default, sub-agents can only spawn under their own agent id. To allow an agent to spawn sub-agents under other agent ids: - -```json5 -{ - agents: { - list: [ - { - id: "orchestrator", - subagents: { - allowAgents: ["researcher", "coder"], // or ["*"] to allow any - }, - }, - ], - }, -} -``` - - -Use the `agents_list` tool to discover which agent ids are currently allowed for `sessions_spawn`. - - -## Managing Sub-Agents (`/subagents`) - -Use the `/subagents` slash command to inspect and control sub-agent runs for the current session: - -| Command | Description | -| ---------------------------------------- | ---------------------------------------------- | -| `/subagents list` | List all sub-agent runs (active and completed) | -| `/subagents stop ` | Stop a running sub-agent | -| `/subagents log [limit] [tools]` | View sub-agent transcript | -| `/subagents info ` | Show detailed run metadata | -| `/subagents send ` | Send a message to a running sub-agent | - -You can reference sub-agents by list index (`1`, `2`), run id prefix, full session key, or `last`. - - - - ``` - /subagents list - ``` - - ``` - 🧭 Subagents (current session) - Active: 1 · Done: 2 - 1) ✅ · research logs · 2m31s · run a1b2c3d4 · agent:main:subagent:... - 2) ✅ · check deps · 45s · run e5f6g7h8 · agent:main:subagent:... - 3) 🔄 · deploy staging · 1m12s · run i9j0k1l2 · agent:main:subagent:... - ``` - - ``` - /subagents stop 3 - ``` - - ``` - ⚙️ Stop requested for deploy staging. - ``` - - - - ``` - /subagents info 1 - ``` - - ``` - ℹ️ Subagent info - Status: ✅ - Label: research logs - Task: Research the latest server error logs and summarize findings - Run: a1b2c3d4-... - Session: agent:main:subagent:... - Runtime: 2m31s - Cleanup: keep - Outcome: ok - ``` - - - - ``` - /subagents log 1 10 - ``` - - Shows the last 10 messages from the sub-agent's transcript. Add `tools` to include tool call messages: - - ``` - /subagents log 1 10 tools - ``` - - - - ``` - /subagents send 3 "Also check the staging environment" - ``` - - Sends a message into the running sub-agent's session and waits up to 30 seconds for a reply. - - - - -## Announce (How Results Come Back) - -When a sub-agent finishes, it goes through an **announce** step: - -1. The sub-agent's final reply is captured -2. A summary message is sent to the main agent's session with the result, status, and stats -3. The main agent posts a natural-language summary to your chat - -Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads). - -### Announce Stats - -Each announce includes a stats line with: - -- Runtime duration -- Token usage (input/output/total) -- Estimated cost (when model pricing is configured via `models.providers.*.models[].cost`) -- Session key, session id, and transcript path - -### Announce Status - -The announce message includes a status derived from the runtime outcome (not from model output): - -- **successful completion** (`ok`) — task completed normally -- **error** — task failed (error details in notes) -- **timeout** — task exceeded `runTimeoutSeconds` -- **unknown** — status could not be determined - - -If no user-facing announcement is needed, the main-agent summarize step can return `NO_REPLY` and nothing is posted. -This is different from `ANNOUNCE_SKIP`, which is used in agent-to-agent announce flow (`sessions_send`). - - -## Tool Policy - -By default, sub-agents get **all tools except** a set of denied tools that are unsafe or unnecessary for background tasks: - - - - | Denied tool | Reason | - |-------------|--------| - | `sessions_list` | Session management — main agent orchestrates | - | `sessions_history` | Session management — main agent orchestrates | - | `sessions_send` | Session management — main agent orchestrates | - | `sessions_spawn` | No nested fan-out (sub-agents cannot spawn sub-agents) | - | `gateway` | System admin — dangerous from sub-agent | - | `agents_list` | System admin | - | `whatsapp_login` | Interactive setup — not a task | - | `session_status` | Status/scheduling — main agent coordinates | - | `cron` | Status/scheduling — main agent coordinates | - | `memory_search` | Pass relevant info in spawn prompt instead | - | `memory_get` | Pass relevant info in spawn prompt instead | - - - -### Customizing Sub-Agent Tools - -You can further restrict sub-agent tools: - -```json5 -{ - tools: { - subagents: { - tools: { - // deny always wins over allow - deny: ["browser", "firecrawl"], - }, - }, - }, -} -``` - -To restrict sub-agents to **only** specific tools: - -```json5 -{ - tools: { - subagents: { - tools: { - allow: ["read", "exec", "process", "write", "edit", "apply_patch"], - // deny still wins if set - }, - }, - }, -} -``` - - -Custom deny entries are **added to** the default deny list. If `allow` is set, only those tools are available (the default deny list still applies on top). - +- `/stop` in the main chat stops all depth-1 agents and cascades to their depth-2 children. +- `/subagents kill ` stops a specific sub-agent and cascades to its children. +- `/subagents kill all` stops all sub-agents for the requester and cascades. ## Authentication Sub-agent auth is resolved by **agent id**, not by session type: -- The auth store is loaded from the target agent's `agentDir` -- The main agent's auth profiles are merged in as a **fallback** (agent profiles win on conflicts) -- The merge is additive — main profiles are always available as fallbacks +- The sub-agent session key is `agent::subagent:`. +- The auth store is loaded from that agent's `agentDir`. +- The main agent's auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts. - -Fully isolated auth per sub-agent is not currently supported. - +Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet. -## Context and System Prompt +## Announce -Sub-agents receive a reduced system prompt compared to the main agent: +Sub-agents report back via an announce step: -- **Included:** Tooling, Workspace, Runtime sections, plus `AGENTS.md` and `TOOLS.md` -- **Not included:** `SOUL.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` +- The announce step runs inside the sub-agent session (not the requester session). +- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. +- Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`). +- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads). +- Announce messages are normalized to a stable template: + - `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`). + - `Result:` the summary content from the announce step (or `(not available)` if missing). + - `Notes:` error details and other useful context. +- `Status` is not inferred from model output; it comes from runtime outcome signals. -The sub-agent also receives a task-focused system prompt that instructs it to stay focused on the assigned task, complete it, and not act as the main agent. +Announce payloads include a stats line at the end (even when wrapped): -## Stopping Sub-Agents +- 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) -| Method | Effect | -| ---------------------- | ------------------------------------------------------------------------- | -| `/stop` in the chat | Aborts the main session **and** all active sub-agent runs spawned from it | -| `/subagents stop ` | Stops a specific sub-agent without affecting the main session | -| `runTimeoutSeconds` | Automatically aborts the sub-agent run after the specified time | +## Tool Policy (sub-agent tools) - -`runTimeoutSeconds` does **not** auto-archive the session. The session remains until the normal archive timer fires. - +By default, sub-agents get **all tools except session tools** and system tools: -## Full Configuration Example +- `sessions_list` +- `sessions_history` +- `sessions_send` +- `sessions_spawn` + +When `maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive `sessions_spawn`, `subagents`, `sessions_list`, and `sessions_history` so they can manage their children. + +Override via config: - ```json5 { agents: { defaults: { - model: { primary: "anthropic/claude-sonnet-4" }, subagents: { - model: "minimax/MiniMax-M2.1", - thinking: "low", - maxConcurrent: 4, - archiveAfterMinutes: 30, + maxConcurrent: 1, }, }, - list: [ - { - id: "main", - default: true, - name: "Personal Assistant", - }, - { - id: "ops", - name: "Ops Agent", - subagents: { - model: "anthropic/claude-sonnet-4", - allowAgents: ["main"], // ops can spawn sub-agents under "main" - }, - }, - ], }, tools: { subagents: { tools: { - deny: ["browser"], // sub-agents can't use the browser + // deny wins + deny: ["gateway", "cron"], + // if allow is set, it becomes allow-only (deny still wins) + // allow: ["read", "exec", "process"] }, }, }, } ``` - + +## Concurrency + +Sub-agents use a dedicated in-process queue lane: + +- Lane name: `subagent` +- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `8`) + +## Stopping + +- Sending `/stop` in the requester chat aborts the requester session and stops any active sub-agent runs spawned from it, cascading to nested children. +- `/subagents kill ` stops a specific sub-agent and cascades to its children. ## Limitations - -- **Best-effort announce:** If the gateway restarts, pending announce work is lost. -- **No nested spawning:** Sub-agents cannot spawn their own sub-agents. -- **Shared resources:** Sub-agents share the gateway process; use `maxConcurrent` as a safety valve. -- **Auto-archive is best-effort:** Pending archive timers are lost on gateway restart. - - -## See Also - -- [Session Tools](/concepts/session-tool) — details on `sessions_spawn` and other session tools -- [Multi-Agent Sandbox and Tools](/tools/multi-agent-sandbox-tools) — per-agent tool restrictions and sandboxing -- [Configuration](/gateway/configuration) — `agents.defaults.subagents` reference -- [Queue](/concepts/queue) — how the `subagent` lane works +- Sub-agent announce is **best-effort**. If the gateway restarts, pending "announce back" work is lost. +- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve. +- `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately. +- Sub-agent context only injects `AGENTS.md` + `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`). +- Maximum nesting depth is 5 (`maxSpawnDepth` range: 1–5). Depth 2 is recommended for most use cases. +- `maxChildrenPerAgent` caps active children per session (default: 5, range: 1–20). diff --git a/src/agents/bash-tools.process.poll-timeout.test.ts b/src/agents/bash-tools.process.poll-timeout.test.ts new file mode 100644 index 0000000000..e72d95a342 --- /dev/null +++ b/src/agents/bash-tools.process.poll-timeout.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, test } from "vitest"; +import { resetProcessRegistryForTests } from "./bash-process-registry.js"; +import { createExecTool } from "./bash-tools.exec.js"; +import { createProcessTool } from "./bash-tools.process.js"; + +afterEach(() => { + resetProcessRegistryForTests(); +}); + +const sleepAndEcho = + process.platform === "win32" + ? "Start-Sleep -Milliseconds 300; Write-Output done" + : "sleep 0.3; echo done"; + +test("process poll waits for completion when timeout is provided", async () => { + const execTool = createExecTool(); + const processTool = createProcessTool(); + const started = Date.now(); + const run = await execTool.execute("toolcall", { + command: sleepAndEcho, + background: true, + }); + expect(run.details.status).toBe("running"); + const sessionId = run.details.sessionId; + + const poll = await processTool.execute("toolcall", { + action: "poll", + sessionId, + timeout: 2000, + }); + const elapsedMs = Date.now() - started; + const details = poll.details as { status?: string; aggregated?: string }; + expect(details.status).toBe("completed"); + expect(details.aggregated ?? "").toContain("done"); + expect(elapsedMs).toBeGreaterThanOrEqual(200); +}); + +test("process poll accepts string timeout values", async () => { + const execTool = createExecTool(); + const processTool = createProcessTool(); + const run = await execTool.execute("toolcall", { + command: sleepAndEcho, + background: true, + }); + expect(run.details.status).toBe("running"); + const sessionId = run.details.sessionId; + + const poll = await processTool.execute("toolcall", { + action: "poll", + sessionId, + timeout: "2000", + }); + const details = poll.details as { status?: string; aggregated?: string }; + expect(details.status).toBe("completed"); + expect(details.aggregated ?? "").toContain("done"); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 3fa32438f5..0c74733f4d 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -64,8 +64,28 @@ const processSchema = Type.Object({ eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })), offset: Type.Optional(Type.Number({ description: "Log offset" })), limit: Type.Optional(Type.Number({ description: "Log length" })), + timeout: Type.Optional( + Type.Union([Type.Number(), Type.String()], { + description: "For poll: wait up to this many milliseconds before returning", + }), + ), }); +const MAX_POLL_WAIT_MS = 120_000; + +function resolvePollWaitMs(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.min(MAX_POLL_WAIT_MS, Math.floor(value))); + } + if (typeof value === "string") { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) { + return Math.max(0, Math.min(MAX_POLL_WAIT_MS, parsed)); + } + } + return 0; +} + export function createProcessTool( defaults?: ProcessToolDefaults, // oxlint-disable-next-line typescript/no-explicit-any @@ -106,6 +126,7 @@ export function createProcessTool( eof?: boolean; offset?: number; limit?: number; + timeout?: number | string; }; if (params.action === "list") { @@ -258,6 +279,15 @@ export function createProcessTool( details: { status: "failed" }, }; } + const pollWaitMs = resolvePollWaitMs(params.timeout); + if (pollWaitMs > 0 && !scopedSession.exited) { + const deadline = Date.now() + pollWaitMs; + while (!scopedSession.exited && Date.now() < deadline) { + await new Promise((resolve) => + setTimeout(resolve, Math.min(250, deadline - Date.now())), + ); + } + } const { stdout, stderr } = drainSession(scopedSession); const exited = scopedSession.exited; const exitCode = scopedSession.exitCode ?? 0; diff --git a/src/agents/model-fallback.e2e.test.ts b/src/agents/model-fallback.e2e.test.ts index 9100304533..f14b1c53cb 100644 --- a/src/agents/model-fallback.e2e.test.ts +++ b/src/agents/model-fallback.e2e.test.ts @@ -24,6 +24,22 @@ function makeCfg(overrides: Partial = {}): OpenClawConfig { } describe("runWithModelFallback", () => { + it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => { + const cfg = makeCfg(); + const run = vi.fn().mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-5.3-codex", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("openai-codex", "gpt-5.3-codex"); + }); + it("does not fall back on non-auth errors", async () => { const cfg = makeCfg(); const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok"); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 79d0b6d0b2..b17c3bb126 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -16,9 +16,11 @@ import { buildConfiguredAllowlistKeys, buildModelAliasIndex, modelKey, + normalizeModelRef, resolveConfiguredModelRef, resolveModelRefFromString, } from "./model-selection.js"; +import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js"; type ModelCandidate = { provider: string; @@ -143,8 +145,9 @@ function resolveFallbackCandidates(params: { : null; const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER; const defaultModel = primary?.model ?? DEFAULT_MODEL; - const provider = String(params.provider ?? "").trim() || defaultProvider; - const model = String(params.model ?? "").trim() || defaultModel; + const providerRaw = String(params.provider ?? "").trim() || defaultProvider; + const modelRaw = String(params.model ?? "").trim() || defaultModel; + const normalizedPrimary = normalizeModelRef(providerRaw, modelRaw); const aliasIndex = buildModelAliasIndex({ cfg: params.cfg ?? {}, defaultProvider, @@ -171,7 +174,7 @@ function resolveFallbackCandidates(params: { candidates.push(candidate); }; - addCandidate({ provider, model }, false); + addCandidate(normalizedPrimary, false); const modelFallbacks = (() => { if (params.fallbacksOverride !== undefined) { @@ -272,6 +275,14 @@ export async function runWithModelFallback(params: { if (shouldRethrowAbort(err)) { throw err; } + // Context overflow errors should be handled by the inner runner's + // compaction/retry logic, not by model fallback. If one escapes as a + // throw, rethrow it immediately rather than trying a different model + // that may have a smaller context window and fail worse. + const errMessage = err instanceof Error ? err.message : String(err); + if (isLikelyContextOverflowError(errMessage)) { + throw err; + } const normalized = coerceToFailoverError(err, { provider: candidate.provider, diff --git a/src/agents/model-selection.e2e.test.ts b/src/agents/model-selection.e2e.test.ts index 48f6a64c23..6e7546d201 100644 --- a/src/agents/model-selection.e2e.test.ts +++ b/src/agents/model-selection.e2e.test.ts @@ -54,6 +54,21 @@ describe("model-selection", () => { }); }); + it("normalizes openai gpt-5.3 codex refs to openai-codex provider", () => { + expect(parseModelRef("openai/gpt-5.3-codex", "anthropic")).toEqual({ + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + expect(parseModelRef("gpt-5.3-codex", "openai")).toEqual({ + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + expect(parseModelRef("openai/gpt-5.3-codex-codex", "anthropic")).toEqual({ + provider: "openai-codex", + model: "gpt-5.3-codex-codex", + }); + }); + it("should return null for empty strings", () => { expect(parseModelRef("", "anthropic")).toBeNull(); expect(parseModelRef(" ", "anthropic")).toBeNull(); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index e3d68a70ff..e39b850e91 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -21,6 +21,7 @@ const ANTHROPIC_MODEL_ALIASES: Record = { "opus-4.5": "claude-opus-4-5", "sonnet-4.5": "claude-sonnet-4-5", }; +const OPENAI_CODEX_OAUTH_MODEL_PREFIXES = ["gpt-5.3-codex"] as const; function normalizeAliasKey(value: string): string { return value.trim().toLowerCase(); @@ -78,6 +79,28 @@ function normalizeProviderModelId(provider: string, model: string): string { return model; } +function shouldUseOpenAICodexProvider(provider: string, model: string): boolean { + if (provider !== "openai") { + return false; + } + const normalized = model.trim().toLowerCase(); + if (!normalized) { + return false; + } + return OPENAI_CODEX_OAUTH_MODEL_PREFIXES.some( + (prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`), + ); +} + +export function normalizeModelRef(provider: string, model: string): ModelRef { + const normalizedProvider = normalizeProviderId(provider); + const normalizedModel = normalizeProviderModelId(normalizedProvider, model.trim()); + if (shouldUseOpenAICodexProvider(normalizedProvider, normalizedModel)) { + return { provider: "openai-codex", model: normalizedModel }; + } + return { provider: normalizedProvider, model: normalizedModel }; +} + export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null { const trimmed = raw.trim(); if (!trimmed) { @@ -85,18 +108,14 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef | } const slash = trimmed.indexOf("/"); if (slash === -1) { - const provider = normalizeProviderId(defaultProvider); - const model = normalizeProviderModelId(provider, trimmed); - return { provider, model }; + return normalizeModelRef(defaultProvider, trimmed); } const providerRaw = trimmed.slice(0, slash).trim(); - const provider = normalizeProviderId(providerRaw); const model = trimmed.slice(slash + 1).trim(); - if (!provider || !model) { + if (!providerRaw || !model) { return null; } - const normalizedModel = normalizeProviderModelId(provider, model); - return { provider, model: normalizedModel }; + return normalizeModelRef(providerRaw, model); } export function resolveAllowlistModelKey(raw: string, defaultProvider: string): string | null { diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index 972bc73d77..df8e1bb718 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { + addSubagentRunForTests, + listSubagentRunsForRequester, + resetSubagentRegistryForTests, +} from "./subagent-registry.js"; const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ @@ -72,7 +77,7 @@ describe("sessions tools", () => { expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number"); expect(schemaProp("sessions_spawn", "thinking").type).toBe("string"); expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number"); - expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number"); + expect(schemaProp("subagents", "recentMinutes").type).toBe("number"); }); it("sessions_list filters kinds and includes messages", async () => { @@ -672,4 +677,333 @@ describe("sessions tools", () => { message: "announce now", }); }); + + it("subagents lists active and recent runs", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const now = Date.now(); + addSubagentRunForTests({ + runId: "run-active", + childSessionKey: "agent:main:subagent:active", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "investigate auth", + cleanup: "keep", + createdAt: now - 2 * 60_000, + startedAt: now - 2 * 60_000, + }); + addSubagentRunForTests({ + runId: "run-recent", + childSessionKey: "agent:main:subagent:recent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "summarize findings", + cleanup: "keep", + createdAt: now - 15 * 60_000, + startedAt: now - 14 * 60_000, + endedAt: now - 5 * 60_000, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-old", + childSessionKey: "agent:main:subagent:old", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "old completed run", + cleanup: "keep", + createdAt: now - 90 * 60_000, + startedAt: now - 89 * 60_000, + endedAt: now - 80 * 60_000, + outcome: { status: "ok" }, + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + }).find((candidate) => candidate.name === "subagents"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-subagents-list", { action: "list" }); + const details = result.details as { + status?: string; + active?: unknown[]; + recent?: unknown[]; + text?: string; + }; + expect(details.status).toBe("ok"); + expect(details.active).toHaveLength(1); + expect(details.recent).toHaveLength(1); + expect(details.text).toContain("active subagents:"); + expect(details.text).toContain("recent (last 30m):"); + resetSubagentRegistryForTests(); + }); + + it("subagents list usage separates io tokens from prompt/cache", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const now = Date.now(); + addSubagentRunForTests({ + runId: "run-usage-active", + childSessionKey: "agent:main:subagent:usage-active", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "wait and check weather", + cleanup: "keep", + createdAt: now - 2 * 60_000, + startedAt: now - 2 * 60_000, + }); + + const sessionsModule = await import("../config/sessions.js"); + const loadSessionStoreSpy = vi + .spyOn(sessionsModule, "loadSessionStore") + .mockImplementation(() => ({ + "agent:main:subagent:usage-active": { + modelProvider: "anthropic", + model: "claude-opus-4-6", + inputTokens: 12, + outputTokens: 1000, + totalTokens: 197000, + }, + })); + + try { + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + }).find((candidate) => candidate.name === "subagents"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-subagents-list-usage", { action: "list" }); + const details = result.details as { + status?: string; + text?: string; + }; + expect(details.status).toBe("ok"); + expect(details.text).toContain("tokens 1k (in 12 / out 1k)"); + expect(details.text).toContain("prompt/cache 197k"); + expect(details.text).not.toContain("1.0k io"); + } finally { + loadSessionStoreSpy.mockRestore(); + resetSubagentRegistryForTests(); + } + }); + + it("subagents steer sends guidance to a running run", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent") { + return { runId: "run-steer-1" }; + } + return {}; + }); + addSubagentRunForTests({ + runId: "run-steer", + childSessionKey: "agent:main:subagent:steer", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "prepare release notes", + cleanup: "keep", + createdAt: Date.now() - 60_000, + startedAt: Date.now() - 60_000, + }); + + const sessionsModule = await import("../config/sessions.js"); + const loadSessionStoreSpy = vi + .spyOn(sessionsModule, "loadSessionStore") + .mockImplementation(() => ({ + "agent:main:subagent:steer": { + sessionId: "child-session-steer", + updatedAt: Date.now(), + }, + })); + + try { + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + }).find((candidate) => candidate.name === "subagents"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-subagents-steer", { + action: "steer", + target: "1", + message: "skip changelog and focus on tests", + }); + const details = result.details as { status?: string; runId?: string; text?: string }; + expect(details.status).toBe("accepted"); + expect(details.runId).toBe("run-steer-1"); + expect(details.text).toContain("steered"); + const steerWaitIndex = callGatewayMock.mock.calls.findIndex( + (call) => + (call[0] as { method?: string; params?: { runId?: string } }).method === "agent.wait" && + (call[0] as { method?: string; params?: { runId?: string } }).params?.runId === + "run-steer", + ); + expect(steerWaitIndex).toBeGreaterThanOrEqual(0); + const steerRunIndex = callGatewayMock.mock.calls.findIndex( + (call) => (call[0] as { method?: string }).method === "agent", + ); + expect(steerRunIndex).toBeGreaterThan(steerWaitIndex); + expect(callGatewayMock.mock.calls[steerWaitIndex]?.[0]).toMatchObject({ + method: "agent.wait", + params: { runId: "run-steer", timeoutMs: 5_000 }, + timeoutMs: 7_000, + }); + expect(callGatewayMock.mock.calls[steerRunIndex]?.[0]).toMatchObject({ + method: "agent", + params: { + lane: "subagent", + sessionKey: "agent:main:subagent:steer", + sessionId: "child-session-steer", + timeout: 0, + }, + }); + + const trackedRuns = listSubagentRunsForRequester("agent:main:main"); + expect(trackedRuns).toHaveLength(1); + expect(trackedRuns[0].runId).toBe("run-steer-1"); + expect(trackedRuns[0].endedAt).toBeUndefined(); + } finally { + loadSessionStoreSpy.mockRestore(); + resetSubagentRegistryForTests(); + } + }); + + it("subagents numeric targets follow active-first list ordering", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + addSubagentRunForTests({ + runId: "run-active", + childSessionKey: "agent:main:subagent:active", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "active task", + cleanup: "keep", + createdAt: Date.now() - 120_000, + startedAt: Date.now() - 120_000, + }); + addSubagentRunForTests({ + runId: "run-recent", + childSessionKey: "agent:main:subagent:recent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "recent task", + cleanup: "keep", + createdAt: Date.now() - 30_000, + startedAt: Date.now() - 30_000, + endedAt: Date.now() - 10_000, + outcome: { status: "ok" }, + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + }).find((candidate) => candidate.name === "subagents"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-subagents-kill-order", { + action: "kill", + target: "1", + }); + const details = result.details as { status?: string; runId?: string; text?: string }; + expect(details.status).toBe("ok"); + expect(details.runId).toBe("run-active"); + expect(details.text).toContain("killed"); + + resetSubagentRegistryForTests(); + }); + + it("subagents kill stops a running run", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + addSubagentRunForTests({ + runId: "run-kill", + childSessionKey: "agent:main:subagent:kill", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "long running task", + cleanup: "keep", + createdAt: Date.now() - 60_000, + startedAt: Date.now() - 60_000, + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + }).find((candidate) => candidate.name === "subagents"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-subagents-kill", { + action: "kill", + target: "1", + }); + const details = result.details as { status?: string; text?: string }; + expect(details.status).toBe("ok"); + expect(details.text).toContain("killed"); + resetSubagentRegistryForTests(); + }); + + it("subagents kill-all cascades through ended parents to active descendants", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const now = Date.now(); + const endedParentKey = "agent:main:subagent:parent-ended"; + const activeChildKey = "agent:main:subagent:parent-ended:subagent:worker"; + addSubagentRunForTests({ + runId: "run-parent-ended", + childSessionKey: endedParentKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "orchestrator", + cleanup: "keep", + createdAt: now - 120_000, + startedAt: now - 120_000, + endedAt: now - 60_000, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-worker-active", + childSessionKey: activeChildKey, + requesterSessionKey: endedParentKey, + requesterDisplayKey: endedParentKey, + task: "leaf worker", + cleanup: "keep", + createdAt: now - 30_000, + startedAt: now - 30_000, + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + }).find((candidate) => candidate.name === "subagents"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-subagents-kill-all-cascade-ended", { + action: "kill", + target: "all", + }); + const details = result.details as { status?: string; killed?: number; text?: string }; + expect(details.status).toBe("ok"); + expect(details.killed).toBe(1); + expect(details.text).toContain("killed 1 subagent"); + + const descendants = listSubagentRunsForRequester(endedParentKey); + const worker = descendants.find((entry) => entry.runId === "run-worker-active"); + expect(worker?.endedAt).toBeTypeOf("number"); + resetSubagentRegistryForTests(); + }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts new file mode 100644 index 0000000000..ee65b5962c --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -0,0 +1,288 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +const callGatewayMock = vi.fn(); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +let storeTemplatePath = ""; +let configOverride: Record = { + session: { + mainKey: "main", + scope: "per-sender", + }, +}; + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => configOverride, + }; +}); + +function writeStore(agentId: string, store: Record) { + const storePath = storeTemplatePath.replaceAll("{agentId}", agentId); + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8"); +} + +describe("sessions_spawn depth + child limits", () => { + beforeEach(() => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + storeTemplatePath = path.join( + os.tmpdir(), + `openclaw-subagent-depth-${Date.now()}-${Math.random().toString(16).slice(2)}-{agentId}.json`, + ); + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + }; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string }; + if (req.method === "agent") { + return { runId: "run-depth" }; + } + if (req.method === "agent.wait") { + return { status: "running" }; + } + return {}; + }); + }); + + it("rejects spawning when caller depth reaches maxSpawnDepth", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); + const result = await tool.execute("call-depth-reject", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 1, max: 1)", + }); + }); + + it("allows depth-1 callers when maxSpawnDepth is 2", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }; + + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); + const result = await tool.execute("call-depth-allow", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "accepted", + childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + runId: "run-depth", + }); + + const calls = callGatewayMock.mock.calls.map( + (call) => call[0] as { method?: string; params?: Record }, + ); + const agentCall = calls.find((entry) => entry.method === "agent"); + expect(agentCall?.params?.spawnedBy).toBe("agent:main:subagent:parent"); + + const spawnDepthPatch = calls.find( + (entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2, + ); + expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/); + }); + + it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }; + + const callerKey = "agent:main:subagent:flat-depth-2"; + writeStore("main", { + [callerKey]: { + sessionId: "flat-depth-2", + updatedAt: Date.now(), + spawnDepth: 2, + }, + }); + + const tool = createSessionsSpawnTool({ agentSessionKey: callerKey }); + const result = await tool.execute("call-depth-2-reject", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", + }); + }); + + it("rejects depth-2 callers when spawnDepth is missing but spawnedBy ancestry implies depth 2", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }; + + const depth1 = "agent:main:subagent:depth-1"; + const callerKey = "agent:main:subagent:depth-2"; + writeStore("main", { + [depth1]: { + sessionId: "depth-1", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + }, + [callerKey]: { + sessionId: "depth-2", + updatedAt: Date.now(), + spawnedBy: depth1, + }, + }); + + const tool = createSessionsSpawnTool({ agentSessionKey: callerKey }); + const result = await tool.execute("call-depth-ancestry-reject", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", + }); + }); + + it("rejects depth-2 callers when the requester key is a sessionId", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }; + + const depth1 = "agent:main:subagent:depth-1"; + const callerKey = "agent:main:subagent:depth-2"; + writeStore("main", { + [depth1]: { + sessionId: "depth-1-session", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + }, + [callerKey]: { + sessionId: "depth-2-session", + updatedAt: Date.now(), + spawnedBy: depth1, + }, + }); + + const tool = createSessionsSpawnTool({ agentSessionKey: "depth-2-session" }); + const result = await tool.execute("call-depth-sessionid-reject", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", + }); + }); + + it("rejects when active children for requester session reached maxChildrenPerAgent", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + maxChildrenPerAgent: 1, + }, + }, + }, + }; + + addSubagentRunForTests({ + runId: "existing-run", + childSessionKey: "agent:main:subagent:existing", + requesterSessionKey: "agent:main:subagent:parent", + requesterDisplayKey: "agent:main:subagent:parent", + task: "existing", + cleanup: "keep", + createdAt: Date.now(), + startedAt: Date.now(), + }); + + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); + const result = await tool.execute("call-max-children", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "sessions_spawn has reached max active children for this session (1/1)", + }); + }); + + it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storeTemplatePath, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + maxChildrenPerAgent: 5, + maxConcurrent: 1, + }, + }, + }, + }; + + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); + const result = await tool.execute("call-max-concurrent-independent", { task: "hello" }); + + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-depth", + }); + }); +}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index cdb1e0c695..4955a9bcc2 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createOpenClawTools } from "./openclaw-tools.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; @@ -113,7 +114,9 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { expect(patchIndex).toBeGreaterThan(-1); expect(agentIndex).toBeGreaterThan(-1); expect(patchIndex).toBeLessThan(agentIndex); - const patchCall = calls[patchIndex]; + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); expect(patchCall?.params).toMatchObject({ key: expect.stringContaining("subagent:"), model: "claude-haiku-4-5", @@ -223,12 +226,55 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { modelApplied: true, }); - const patchCall = calls.find((call) => call.method === "sessions.patch"); + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); expect(patchCall?.params).toMatchObject({ model: "minimax/MiniMax-M2.1", }); }); + it("sessions_spawn falls back to runtime default model when no model config is set", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "sessions.patch") { + return { ok: true }; + } + if (request.method === "agent") { + return { runId: "run-runtime-default-model", status: "accepted" }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call-runtime-default-model", { + task: "do thing", + }); + expect(result.details).toMatchObject({ + status: "accepted", + modelApplied: true, + }); + + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); + expect(patchCall?.params).toMatchObject({ + model: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`, + }); + }); + it("sessions_spawn prefers per-agent subagent model over defaults", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); diff --git a/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts b/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts new file mode 100644 index 0000000000..38d1c825cd --- /dev/null +++ b/src/agents/openclaw-tools.subagents.steer-failure-clears-suppression.test.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { + session: { + mainKey: "main", + scope: "per-sender", + }, +}; + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => configOverride, + }; +}); + +import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; +import { + addSubagentRunForTests, + listSubagentRunsForRequester, + resetSubagentRegistryForTests, +} from "./subagent-registry.js"; + +describe("openclaw-tools: subagents steer failure", () => { + beforeEach(() => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const storePath = path.join( + os.tmpdir(), + `openclaw-subagents-steer-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + store: storePath, + }, + }; + fs.writeFileSync(storePath, "{}", "utf-8"); + }); + + it("restores announce behavior when steer replacement dispatch fails", async () => { + addSubagentRunForTests({ + runId: "run-old", + childSessionKey: "agent:main:subagent:worker", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do work", + cleanup: "keep", + createdAt: Date.now(), + startedAt: Date.now(), + }); + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + if (request.method === "agent") { + throw new Error("dispatch failed"); + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + }).find((candidate) => candidate.name === "subagents"); + if (!tool) { + throw new Error("missing subagents tool"); + } + + const result = await tool.execute("call-steer", { + action: "steer", + target: "1", + message: "new direction", + }); + + expect(result.details).toMatchObject({ + status: "error", + action: "steer", + runId: expect.any(String), + error: "dispatch failed", + }); + + const runs = listSubagentRunsForRequester("agent:main:main"); + expect(runs).toHaveLength(1); + expect(runs[0].runId).toBe("run-old"); + expect(runs[0].suppressAnnounceReason).toBeUndefined(); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index eed73b85c9..eed12b72d4 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -17,6 +17,7 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; +import { createSubagentsTool } from "./tools/subagents-tool.js"; import { createTtsTool } from "./tools/tts-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; @@ -147,6 +148,9 @@ export function createOpenClawTools(options?: { sandboxed: options?.sandboxed, requesterAgentIdOverride: options?.requesterAgentIdOverride, }), + createSubagentsTool({ + agentSessionKey: options?.agentSessionKey, + }), createSessionStatusTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts index 5c3d0540c7..318bb3ce6d 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts @@ -59,6 +59,17 @@ describe("sanitizeUserFacingText", () => { expect(sanitizeUserFacingText(text)).toBe(text); }); + it("does not rewrite normal text that mentions billing and plan", () => { + const text = + "Firebase downgraded us to the free Spark plan; check whether we need to re-enable billing."; + expect(sanitizeUserFacingText(text)).toBe(text); + }); + + it("rewrites billing error-shaped text", () => { + const text = "billing: please upgrade your plan"; + expect(sanitizeUserFacingText(text)).toContain("billing error"); + }); + it("sanitizes raw API error payloads", () => { const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; expect(sanitizeUserFacingText(raw, { errorContext: true })).toBe( diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index e2719f4ee4..ab14076680 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -107,6 +107,8 @@ const ERROR_PREFIX_RE = /^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i; const CONTEXT_OVERFLOW_ERROR_HEAD_RE = /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; +const BILLING_ERROR_HEAD_RE = + /^(?:error[:\s-]+)?billing(?:\s+error)?(?:[:\s-]+|$)|^(?:error[:\s-]+)?(?:credit balance|insufficient credits?|payment required|http\s*402\b)/i; const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; const HTML_ERROR_PREFIX_RE = /^\s*(?:; function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { @@ -547,6 +561,13 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo } } + // Preserve legacy behavior for explicit billing-head text outside known + // error contexts (e.g., "billing: please upgrade your plan"), while + // keeping conversational billing mentions untouched. + if (shouldRewriteBillingText(trimmed)) { + return BILLING_ERROR_USER_MESSAGE; + } + // Strip leading blank lines (including whitespace-only lines) without clobbering indentation on // the first content line (e.g. markdown/code blocks). const withoutLeadingEmptyLines = stripped.replace(/^(?:[ \t]*\r?\n)+/, ""); @@ -646,8 +667,18 @@ export function isBillingErrorMessage(raw: string): boolean { if (!value) { return false; } - - return matchesErrorPatterns(value, ERROR_PATTERNS.billing); + if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) { + return true; + } + if (!BILLING_ERROR_HEAD_RE.test(raw)) { + return false; + } + return ( + value.includes("upgrade") || + value.includes("credits") || + value.includes("payment") || + value.includes("plan") + ); } export function isMissingToolCallInputError(raw: string): boolean { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 6a100bd84a..48cf6a69de 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -16,7 +16,7 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js import { getMachineDisplayName } from "../../infra/machine-name.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; -import { isSubagentSessionKey } from "../../routing/session-key.js"; +import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; import { resolveSignalReactionLevel } from "../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; @@ -469,7 +469,10 @@ export async function compactEmbeddedPiSessionDirect( config: params.config, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; + const promptMode = + isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) + ? "minimal" + : "full"; const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 666431ffa7..bb5266419a 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -494,7 +494,8 @@ export async function runEmbeddedPiAgent( // Keep prompt size from the latest model call so session totalTokens // reflects current context usage, not accumulated tool-loop usage. lastRunPromptUsage = lastAssistantUsage ?? attemptUsage; - autoCompactionCount += Math.max(0, attempt.compactionCount ?? 0); + const attemptCompactionCount = Math.max(0, attempt.compactionCount ?? 0); + autoCompactionCount += attemptCompactionCount; const formattedAssistantErrorText = lastAssistant ? formatAssistantErrorText(lastAssistant, { cfg: params.config, @@ -537,9 +538,25 @@ export async function runEmbeddedPiAgent( `error=${errorText.slice(0, 200)}`, ); const isCompactionFailure = isCompactionFailureError(errorText); - // Attempt auto-compaction on context overflow (not compaction_failure) + const hadAttemptLevelCompaction = attemptCompactionCount > 0; + // If this attempt already compacted (SDK auto-compaction), avoid immediately + // running another explicit compaction for the same overflow trigger. if ( !isCompactionFailure && + hadAttemptLevelCompaction && + overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS + ) { + overflowCompactionAttempts++; + log.warn( + `context overflow persisted after in-attempt compaction (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); retrying prompt without additional compaction for ${provider}/${modelId}`, + ); + continue; + } + // Attempt explicit overflow compaction only when this attempt did not + // already auto-compact. + if ( + !isCompactionFailure && + !hadAttemptLevelCompaction && overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS ) { if (log.isEnabled("debug")) { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 7c58a68800..9fafd965c7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -10,7 +10,11 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import { isSubagentSessionKey, normalizeAgentId } from "../../../routing/session-key.js"; +import { + isCronSessionKey, + isSubagentSessionKey, + normalizeAgentId, +} from "../../../routing/session-key.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; @@ -410,7 +414,10 @@ export async function runEmbeddedAttempt( }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; + const promptMode = + isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) + ? "minimal" + : "full"; const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], diff --git a/src/agents/pi-embedded-utils.e2e.test.ts b/src/agents/pi-embedded-utils.e2e.test.ts index df1234ec4e..af23ca9b6a 100644 --- a/src/agents/pi-embedded-utils.e2e.test.ts +++ b/src/agents/pi-embedded-utils.e2e.test.ts @@ -92,6 +92,24 @@ describe("extractAssistantText", () => { expect(result).toBe("HTTP 500: Internal Server Error"); }); + it("does not rewrite normal text that references billing plans", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: "Firebase downgraded Chore Champ to the Spark plan; confirm whether billing should be re-enabled.", + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe( + "Firebase downgraded Chore Champ to the Spark plan; confirm whether billing should be re-enabled.", + ); + }); + it("strips Minimax tool invocations with extra attributes", () => { const msg: AssistantMessage = { role: "assistant", diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts index ec571c76e4..6104fc1693 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts @@ -274,6 +274,7 @@ describe("createOpenClawCodingTools", () => { "sessions_history", "sessions_send", "sessions_spawn", + "subagents", "session_status", "image", ]); @@ -296,12 +297,56 @@ describe("createOpenClawCodingTools", () => { expect(names.has("sessions_history")).toBe(false); expect(names.has("sessions_send")).toBe(false); expect(names.has("sessions_spawn")).toBe(false); + // Explicit subagent orchestration tool remains available (list/steer/kill with safeguards). + expect(names.has("subagents")).toBe(true); expect(names.has("read")).toBe(true); expect(names.has("exec")).toBe(true); expect(names.has("process")).toBe(true); expect(names.has("apply_patch")).toBe(false); }); + + it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-")); + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const storePath = storeTemplate.replaceAll("{agentId}", "main"); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:main:subagent:flat": { + sessionId: "session-flat-depth-2", + updatedAt: Date.now(), + spawnDepth: 2, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const tools = createOpenClawCodingTools({ + sessionKey: "agent:main:subagent:flat", + config: { + session: { + store: storeTemplate, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }, + }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("sessions_spawn")).toBe(false); + expect(names.has("sessions_list")).toBe(false); + expect(names.has("sessions_history")).toBe(false); + expect(names.has("subagents")).toBe(true); + }); it("supports allow-only sub-agent tool policy", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", diff --git a/src/agents/pi-tools.policy.e2e.test.ts b/src/agents/pi-tools.policy.e2e.test.ts index 1405d27356..819768be14 100644 --- a/src/agents/pi-tools.policy.e2e.test.ts +++ b/src/agents/pi-tools.policy.e2e.test.ts @@ -1,6 +1,11 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { filterToolsByPolicy, isToolAllowedByPolicyName } from "./pi-tools.policy.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + filterToolsByPolicy, + isToolAllowedByPolicyName, + resolveSubagentToolPolicy, +} from "./pi-tools.policy.js"; function createStubTool(name: string): AgentTool { return { @@ -34,3 +39,93 @@ describe("pi-tools.policy", () => { expect(isToolAllowedByPolicyName("apply_patch", { allow: ["exec"] })).toBe(true); }); }); + +describe("resolveSubagentToolPolicy depth awareness", () => { + const baseCfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, + } as unknown as OpenClawConfig; + + const deepCfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 3 } } }, + } as unknown as OpenClawConfig; + + const leafCfg = { + agents: { defaults: { subagents: { maxSpawnDepth: 1 } } }, + } as unknown as OpenClawConfig; + + it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_spawn", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 1); + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true); + }); + + it("depth-1 orchestrator (maxSpawnDepth=2) allows subagents", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 1); + expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true); + }); + + it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_list", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 1); + expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(true); + }); + + it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_history", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 1); + expect(isToolAllowedByPolicyName("sessions_history", policy)).toBe(true); + }); + + it("depth-1 orchestrator still denies gateway, cron, memory", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 1); + expect(isToolAllowedByPolicyName("gateway", policy)).toBe(false); + expect(isToolAllowedByPolicyName("cron", policy)).toBe(false); + expect(isToolAllowedByPolicyName("memory_search", policy)).toBe(false); + expect(isToolAllowedByPolicyName("memory_get", policy)).toBe(false); + }); + + it("depth-2 leaf denies sessions_spawn", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 2); + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); + }); + + it("depth-2 orchestrator (maxSpawnDepth=3) allows sessions_spawn", () => { + const policy = resolveSubagentToolPolicy(deepCfg, 2); + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true); + }); + + it("depth-3 leaf (maxSpawnDepth=3) denies sessions_spawn", () => { + const policy = resolveSubagentToolPolicy(deepCfg, 3); + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); + }); + + it("depth-2 leaf allows subagents (for visibility)", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 2); + expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true); + }); + + it("depth-2 leaf denies sessions_list and sessions_history", () => { + const policy = resolveSubagentToolPolicy(baseCfg, 2); + expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false); + expect(isToolAllowedByPolicyName("sessions_history", policy)).toBe(false); + }); + + it("depth-1 leaf (maxSpawnDepth=1) denies sessions_spawn", () => { + const policy = resolveSubagentToolPolicy(leafCfg, 1); + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); + }); + + it("depth-1 leaf (maxSpawnDepth=1) denies sessions_list", () => { + const policy = resolveSubagentToolPolicy(leafCfg, 1); + expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false); + }); + + it("defaults to leaf behavior when no depth is provided", () => { + const policy = resolveSubagentToolPolicy(baseCfg); + // Default depth=1, maxSpawnDepth=2 → orchestrator + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true); + }); + + it("defaults to leaf behavior when depth is undefined and maxSpawnDepth is 1", () => { + const policy = resolveSubagentToolPolicy(leafCfg); + // Default depth=1, maxSpawnDepth=1 → leaf + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); + }); +}); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 522a7b60b7..b9d5a8e885 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -36,12 +36,11 @@ function makeToolPolicyMatcher(policy: SandboxToolPolicy) { }; } -const DEFAULT_SUBAGENT_TOOL_DENY = [ - // Session management - main agent orchestrates - "sessions_list", - "sessions_history", - "sessions_send", - "sessions_spawn", +/** + * Tools always denied for sub-agents regardless of depth. + * These are system-level or interactive tools that sub-agents should never use. + */ +const SUBAGENT_TOOL_DENY_ALWAYS = [ // System admin - dangerous from subagent "gateway", "agents_list", @@ -53,14 +52,40 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [ // Memory - pass relevant info in spawn prompt instead "memory_search", "memory_get", + // Direct session sends - subagents communicate through announce chain + "sessions_send", ]; -export function resolveSubagentToolPolicy(cfg?: OpenClawConfig): SandboxToolPolicy { +/** + * Additional tools denied for leaf sub-agents (depth >= maxSpawnDepth). + * These are tools that only make sense for orchestrator sub-agents that can spawn children. + */ +const SUBAGENT_TOOL_DENY_LEAF = ["sessions_list", "sessions_history", "sessions_spawn"]; + +/** + * Build the deny list for a sub-agent at a given depth. + * + * - Depth 1 with maxSpawnDepth >= 2 (orchestrator): allowed to use sessions_spawn, + * subagents, sessions_list, sessions_history so it can manage its children. + * - Depth >= maxSpawnDepth (leaf): denied sessions_spawn and + * session management tools. Still allowed subagents (for list/status visibility). + */ +function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] { + const isLeaf = depth >= Math.max(1, Math.floor(maxSpawnDepth)); + if (isLeaf) { + return [...SUBAGENT_TOOL_DENY_ALWAYS, ...SUBAGENT_TOOL_DENY_LEAF]; + } + // Orchestrator sub-agent: only deny the always-denied tools. + // sessions_spawn, subagents, sessions_list, sessions_history are allowed. + return [...SUBAGENT_TOOL_DENY_ALWAYS]; +} + +export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy { const configured = cfg?.tools?.subagents?.tools; - const deny = [ - ...DEFAULT_SUBAGENT_TOOL_DENY, - ...(Array.isArray(configured?.deny) ? configured.deny : []), - ]; + const maxSpawnDepth = cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1; + const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth); + const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])]; const allow = Array.isArray(configured?.allow) ? configured.allow : undefined; return { allow, deny }; } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index cdc7a1fdba..5e72cdb5a1 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -44,6 +44,7 @@ import { wrapToolParamNormalization, } from "./pi-tools.read.js"; import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -236,7 +237,10 @@ export function createOpenClawCodingTools(options?: { options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined); const subagentPolicy = isSubagentSessionKey(options?.sessionKey) && options?.sessionKey - ? resolveSubagentToolPolicy(options.config) + ? resolveSubagentToolPolicy( + options.config, + getSubagentDepthFromSessionStore(options.sessionKey, { cfg: options.config }), + ) : undefined; const allowBackground = isToolAllowedByPolicies("process", [ profilePolicyWithAlsoAllow, diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 26a32054c9..3076dac5d2 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -22,6 +22,7 @@ export const DEFAULT_TOOL_ALLOW = [ "sessions_history", "sessions_send", "sessions_spawn", + "subagents", "session_status", ] as const; diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 882e85a767..894a80eb32 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -9,6 +9,11 @@ const embeddedRunMock = { queueEmbeddedPiMessage: vi.fn(() => false), waitForEmbeddedPiRunEnd: vi.fn(async () => true), }; +const subagentRegistryMock = { + isSubagentSessionRunActive: vi.fn(() => true), + countActiveDescendantRuns: vi.fn(() => 0), + resolveRequesterForChildSession: vi.fn(() => null), +}; let sessionStore: Record> = {}; let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { session: { @@ -52,6 +57,8 @@ vi.mock("../config/sessions.js", () => ({ vi.mock("./pi-embedded.js", () => embeddedRunMock); +vi.mock("./subagent-registry.js", () => subagentRegistryMock); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -68,6 +75,9 @@ describe("subagent announce formatting", () => { embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false); embeddedRunMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false); embeddedRunMock.waitForEmbeddedPiRunEnd.mockReset().mockResolvedValue(true); + subagentRegistryMock.isSubagentSessionRunActive.mockReset().mockReturnValue(true); + subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0); + subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null); readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply"); sessionStore = {}; configOverride = { @@ -80,6 +90,11 @@ describe("subagent announce formatting", () => { it("sends instructional message to main agent with status and findings", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-123", + }, + }; await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", childRunId: "run-123", @@ -99,12 +114,17 @@ describe("subagent announce formatting", () => { }; const msg = call?.params?.message as string; expect(call?.params?.sessionKey).toBe("agent:main:main"); + expect(msg).toContain("[System Message]"); + expect(msg).toContain("[sessionId: child-session-123]"); expect(msg).toContain("subagent task"); expect(msg).toContain("failed"); expect(msg).toContain("boom"); - expect(msg).toContain("Findings:"); + expect(msg).toContain("Result:"); expect(msg).toContain("raw subagent reply"); expect(msg).toContain("Stats:"); + expect(msg).toContain("A completed subagent task is ready for user delivery."); + expect(msg).toContain("Convert the result above into your normal assistant voice"); + expect(msg).toContain("Keep this internal context private"); }); it("includes success status when outcome is ok", async () => { @@ -129,6 +149,49 @@ describe("subagent announce formatting", () => { expect(msg).toContain("completed successfully"); }); + it("keeps full findings and includes compact stats", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-usage", + inputTokens: 12, + outputTokens: 1000, + totalTokens: 197000, + }, + }; + readLatestAssistantReplyMock.mockResolvedValue( + Array.from({ length: 140 }, (_, index) => `step-${index}`).join(" "), + ); + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-usage", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("Result:"); + expect(msg).toContain("Stats:"); + expect(msg).toContain("tokens 1.0k (in 12 / out 1.0k)"); + expect(msg).toContain("prompt/cache 197.0k"); + expect(msg).toContain("[sessionId: child-session-usage]"); + expect(msg).toContain("A completed subagent task is ready for user delivery."); + expect(msg).toContain( + "Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.", + ); + expect(msg).toContain("step-0"); + expect(msg).toContain("step-139"); + }); + it("steers announcements into an active run when queue mode is steer", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -160,7 +223,7 @@ describe("subagent announce formatting", () => { expect(didAnnounce).toBe(true); expect(embeddedRunMock.queueEmbeddedPiMessage).toHaveBeenCalledWith( "session-123", - expect.stringContaining("subagent task"), + expect.stringContaining("[System Message]"), ); expect(agentSpy).not.toHaveBeenCalled(); }); @@ -203,6 +266,44 @@ describe("subagent announce formatting", () => { expect(call?.params?.accountId).toBe("kev"); }); + it("queues announce delivery back into requester subagent session", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:subagent:orchestrator": { + sessionId: "session-orchestrator", + spawnDepth: 1, + queueMode: "collect", + queueDebounceMs: 0, + }, + }; + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-worker-queued", + requesterSessionKey: "agent:main:subagent:orchestrator", + requesterDisplayKey: "agent:main:subagent:orchestrator", + requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct" }, + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + expect(didAnnounce).toBe(true); + await expect.poll(() => agentSpy.mock.calls.length).toBe(1); + + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator"); + expect(call?.params?.deliver).toBe(false); + expect(call?.params?.channel).toBeUndefined(); + expect(call?.params?.to).toBeUndefined(); + }); + it("includes threadId when origin has an active topic/thread", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -356,9 +457,41 @@ describe("subagent announce formatting", () => { }); expect(didAnnounce).toBe(true); - const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + const call = agentSpy.mock.calls[0]?.[0] as { + params?: Record; + expectFinal?: boolean; + }; expect(call?.params?.channel).toBe("whatsapp"); expect(call?.params?.accountId).toBe("acct-123"); + expect(call?.expectFinal).toBe(true); + }); + + it("injects direct announce into requester subagent session instead of chat channel", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-worker", + requesterSessionKey: "agent:main:subagent:orchestrator", + requesterOrigin: { channel: "whatsapp", accountId: "acct-123", to: "+1555" }, + requesterDisplayKey: "agent:main:subagent:orchestrator", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + expect(didAnnounce).toBe(true); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator"); + expect(call?.params?.deliver).toBe(false); + expect(call?.params?.channel).toBeUndefined(); + expect(call?.params?.to).toBeUndefined(); }); it("retries reading subagent output when early lifecycle completion had no text", async () => { @@ -394,6 +527,117 @@ describe("subagent announce formatting", () => { expect(call?.params?.message).not.toContain("(no output)"); }); + it("uses advisory guidance when sibling subagents are still active", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:main" ? 2 : 0, + ); + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-child", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("There are still 2 active subagent runs for this session."); + expect(msg).toContain( + "If they are part of the same workflow, wait for the remaining results before sending a user update.", + ); + expect(msg).toContain("If they are unrelated, respond normally using only the result above."); + }); + + it("defers announce while the finished run still has active descendants", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:subagent:parent" ? 1 : 0, + ); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:parent", + childRunId: "run-parent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + expect(didAnnounce).toBe(false); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("bubbles child announce to parent requester when requester subagent already ended", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false); + subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue({ + requesterSessionKey: "agent:main:main", + requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct-main" }, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:leaf", + childRunId: "run-leaf", + requesterSessionKey: "agent:main:subagent:orchestrator", + requesterDisplayKey: "agent:main:subagent:orchestrator", + task: "do thing", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + expect(didAnnounce).toBe(true); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.sessionKey).toBe("agent:main:main"); + expect(call?.params?.deliver).toBe(true); + expect(call?.params?.channel).toBe("whatsapp"); + expect(call?.params?.to).toBe("+1555"); + expect(call?.params?.accountId).toBe("acct-main"); + }); + + it("keeps announce retryable when ended requester subagent has no fallback requester", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false); + subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue(null); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:leaf", + childRunId: "run-leaf-missing-fallback", + requesterSessionKey: "agent:main:subagent:orchestrator", + requesterDisplayKey: "agent:main:subagent:orchestrator", + task: "do thing", + timeoutMs: 1000, + cleanup: "delete", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + }); + + expect(didAnnounce).toBe(false); + expect(subagentRegistryMock.resolveRequesterForChildSession).toHaveBeenCalledWith( + "agent:main:subagent:orchestrator", + ); + expect(agentSpy).not.toHaveBeenCalled(); + expect(sessionsDeleteSpy).not.toHaveBeenCalled(); + }); + it("defers announce when child run is still active after wait timeout", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 98bf961052..4d454a04bb 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,16 +1,13 @@ import crypto from "node:crypto"; -import path from "node:path"; import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveAgentIdFromSessionKey, resolveMainSessionKey, - resolveSessionFilePath, resolveStorePath, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; -import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -25,10 +22,28 @@ import { waitForEmbeddedPiRunEnd, } from "./pi-embedded.js"; import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { readLatestAssistantReply } from "./tools/agent-step.js"; +function formatDurationShort(valueMs?: number) { + if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { + return "n/a"; + } + 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)) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return "0"; } if (value >= 1_000_000) { @@ -40,65 +55,44 @@ function formatTokenCount(value?: number) { 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 }) { +async function buildCompactAnnounceStatsLine(params: { + sessionKey: string; + startedAt?: number; + endedAt?: number; +}) { 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()) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const hasTokenData = + typeof entry?.inputTokens === "number" || + typeof entry?.outputTokens === "number" || + typeof entry?.totalTokens === "number"; + if (hasTokenData) { break; } + await new Promise((resolve) => setTimeout(resolve, 150)); + entry = loadSessionStore(storePath)[params.sessionKey]; } - return { entry, storePath }; + + const input = typeof entry?.inputTokens === "number" ? entry.inputTokens : 0; + const output = typeof entry?.outputTokens === "number" ? entry.outputTokens : 0; + const ioTotal = input + output; + const promptCache = typeof entry?.totalTokens === "number" ? entry.totalTokens : undefined; + const runtimeMs = + typeof params.startedAt === "number" && typeof params.endedAt === "number" + ? Math.max(0, params.endedAt - params.startedAt) + : undefined; + + const parts = [ + `runtime ${formatDurationShort(runtimeMs)}`, + `tokens ${formatTokenCount(ioTotal)} (in ${formatTokenCount(input)} / out ${formatTokenCount(output)})`, + ]; + if (typeof promptCache === "number" && promptCache > ioTotal) { + parts.push(`prompt/cache ${formatTokenCount(promptCache)}`); + } + return `Stats: ${parts.join(" • ")}`; } type DeliveryContextSource = Parameters[0]; @@ -114,6 +108,8 @@ function resolveAnnounceOrigin( } async function sendAnnounce(item: AnnounceQueueItem) { + const requesterDepth = getSubagentDepthFromSessionStore(item.sessionKey); + const requesterIsSubagent = requesterDepth >= 1; const origin = item.origin; const threadId = origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined; @@ -122,15 +118,14 @@ async function sendAnnounce(item: AnnounceQueueItem) { params: { sessionKey: item.sessionKey, message: item.prompt, - channel: origin?.channel, - accountId: origin?.accountId, - to: origin?.to, - threadId, - deliver: true, + channel: requesterIsSubagent ? undefined : origin?.channel, + accountId: requesterIsSubagent ? undefined : origin?.accountId, + to: requesterIsSubagent ? undefined : origin?.to, + threadId: requesterIsSubagent ? undefined : threadId, + deliver: !requesterIsSubagent, idempotencyKey: crypto.randomUUID(), }, - expectFinal: true, - timeoutMs: 60_000, + timeoutMs: 15_000, }); } @@ -219,74 +214,6 @@ async function maybeQueueSubagentAnnounce(params: { return "none"; } -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 agentId = resolveAgentIdFromSessionKey(params.sessionKey); - let transcriptPath: string | undefined; - if (sessionId && storePath) { - try { - transcriptPath = resolveSessionFilePath(sessionId, entry, { - agentId, - sessionsDir: path.dirname(storePath), - }); - } catch { - transcriptPath = 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 = formatDurationCompact(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 ")}`; -} - function loadSessionEntryByKey(sessionKey: string) { const cfg = loadConfig(); const agentId = resolveAgentIdFromSessionKey(sessionKey); @@ -323,49 +250,85 @@ export function buildSubagentSystemPrompt(params: { childSessionKey: string; label?: string; task?: string; + /** Depth of the child being spawned (1 = sub-agent, 2 = sub-sub-agent). */ + childDepth?: number; + /** Config value: max allowed spawn depth. */ + maxSpawnDepth?: number; }) { const taskText = typeof params.task === "string" && params.task.trim() ? params.task.replace(/\s+/g, " ").trim() : "{{TASK_DESCRIPTION}}"; + const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1; + const maxSpawnDepth = typeof params.maxSpawnDepth === "number" ? params.maxSpawnDepth : 1; + const canSpawn = childDepth < maxSpawnDepth; + const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; + const lines = [ "# Subagent Context", "", - "You are a **subagent** spawned by the main agent for a specific task.", + `You are a **subagent** spawned by the ${parentLabel} for a specific task.`, "", "## Your Role", `- You were created to handle: ${taskText}`, "- Complete this task. That's your entire purpose.", - "- You are NOT the main agent. Don't try to be.", + `- You are NOT the ${parentLabel}. Don't try to be.`, "", "## Rules", "1. **Stay focused** - Do your assigned task, nothing else", - "2. **Complete the task** - Your final message will be automatically reported to the main agent", + `2. **Complete the task** - Your final message will be automatically reported to the ${parentLabel}`, "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests", "4. **Be ephemeral** - You may be terminated after task completion. That's fine.", + "5. **Trust push-based completion** - Descendant results are auto-announced back to you; do not busy-poll for status.", "", "## Output Format", "When complete, your final response should include:", - "- What you accomplished or found", - "- Any relevant details the main agent should know", + `- What you accomplished or found`, + `- Any relevant details the ${parentLabel} should know`, "- Keep it concise but informative", "", "## What You DON'T Do", - "- NO user conversations (that's main agent's job)", + `- NO user conversations (that's ${parentLabel}'s job)`, "- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel", "- NO cron jobs or persistent state", - "- NO pretending to be the main agent", - "- Only use the `message` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the main agent deliver it", + `- NO pretending to be the ${parentLabel}`, + `- Only use the \`message\` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the ${parentLabel} deliver it`, "", + ]; + + if (canSpawn) { + lines.push( + "## Sub-Agent Spawning", + "You CAN spawn your own sub-agents for parallel or complex work using `sessions_spawn`.", + "Use the `subagents` tool to steer, kill, or do an on-demand status check for your spawned sub-agents.", + "Your sub-agents will announce their results back to you automatically (not to the main agent).", + "Default workflow: spawn work, continue orchestrating, and wait for auto-announced completions.", + "Do NOT repeatedly poll `subagents list` in a loop unless you are actively debugging or intervening.", + "Coordinate their work and synthesize results before reporting back.", + "", + ); + } else if (childDepth >= 2) { + lines.push( + "## Sub-Agent Spawning", + "You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigned task.", + "", + ); + } + + lines.push( "## Session Context", - params.label ? `- Label: ${params.label}` : undefined, - params.requesterSessionKey ? `- Requester session: ${params.requesterSessionKey}.` : undefined, - params.requesterOrigin?.channel - ? `- Requester channel: ${params.requesterOrigin.channel}.` - : undefined, - `- Your session: ${params.childSessionKey}.`, + ...[ + params.label ? `- Label: ${params.label}` : undefined, + params.requesterSessionKey + ? `- Requester session: ${params.requesterSessionKey}.` + : undefined, + params.requesterOrigin?.channel + ? `- Requester channel: ${params.requesterOrigin.channel}.` + : undefined, + `- Your session: ${params.childSessionKey}.`, + ].filter((line): line is string => line !== undefined), "", - ].filter((line): line is string => line !== undefined); + ); return lines.join("\n"); } @@ -376,6 +339,21 @@ export type SubagentRunOutcome = { export type SubagentAnnounceType = "subagent task" | "cron job"; +function buildAnnounceReplyInstruction(params: { + remainingActiveSubagentRuns: number; + requesterIsSubagent: boolean; + announceType: SubagentAnnounceType; +}): string { + if (params.remainingActiveSubagentRuns > 0) { + const activeRunsLabel = params.remainingActiveSubagentRuns === 1 ? "run" : "runs"; + return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`; + } + if (params.requesterIsSubagent) { + return "Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: NO_REPLY."; + } + return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.`; +} + export async function runSubagentAnnounceFlow(params: { childSessionKey: string; childRunId: string; @@ -396,7 +374,8 @@ export async function runSubagentAnnounceFlow(params: { let didAnnounce = false; let shouldDeleteChildSession = params.cleanup === "delete"; try { - const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin); + let targetRequesterSessionKey = params.requesterSessionKey; + let targetRequesterOrigin = normalizeDeliveryContext(params.requesterOrigin); const childSessionId = (() => { const entry = loadSessionEntryByKey(params.childSessionKey); return typeof entry?.sessionId === "string" && entry.sessionId.trim() @@ -478,12 +457,19 @@ export async function runSubagentAnnounceFlow(params: { outcome = { status: "unknown" }; } - // Build stats - const statsLine = await buildSubagentStatsLine({ - sessionKey: params.childSessionKey, - startedAt: params.startedAt, - endedAt: params.endedAt, - }); + let activeChildDescendantRuns = 0; + try { + const { countActiveDescendantRuns } = await import("./subagent-registry.js"); + activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey)); + } catch { + // Best-effort only; fall back to direct announce behavior when unavailable. + } + if (activeChildDescendantRuns > 0) { + // The finished run still has active descendant subagents. Defer announcing + // this run until descendants settle so we avoid posting in-progress updates. + shouldDeleteChildSession = false; + return false; + } // Build status label const statusLabel = @@ -498,24 +484,70 @@ export async function runSubagentAnnounceFlow(params: { // Build instructional message for main agent const announceType = params.announceType ?? "subagent task"; const taskLabel = params.label || params.task || "task"; - const triggerMessage = [ - `A ${announceType} "${taskLabel}" just ${statusLabel}.`, + const announceSessionId = childSessionId || "unknown"; + const findings = reply || "(no output)"; + let triggerMessage = ""; + + let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + let requesterIsSubagent = requesterDepth >= 1; + // If the requester subagent has already finished, bubble the announce to its + // requester (typically main) so descendant completion is not silently lost. + if (requesterIsSubagent) { + const { isSubagentSessionRunActive, resolveRequesterForChildSession } = + await import("./subagent-registry.js"); + if (!isSubagentSessionRunActive(targetRequesterSessionKey)) { + const fallback = resolveRequesterForChildSession(targetRequesterSessionKey); + if (!fallback?.requesterSessionKey) { + // Without a requester fallback we cannot safely deliver this nested + // completion. Keep cleanup retryable so a later registry restore can + // recover and re-announce instead of silently dropping the result. + shouldDeleteChildSession = false; + return false; + } + targetRequesterSessionKey = fallback.requesterSessionKey; + targetRequesterOrigin = + normalizeDeliveryContext(fallback.requesterOrigin) ?? targetRequesterOrigin; + requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + requesterIsSubagent = requesterDepth >= 1; + } + } + + let remainingActiveSubagentRuns = 0; + try { + const { countActiveDescendantRuns } = await import("./subagent-registry.js"); + remainingActiveSubagentRuns = Math.max( + 0, + countActiveDescendantRuns(targetRequesterSessionKey), + ); + } catch { + // Best-effort only; fall back to default announce instructions when unavailable. + } + const replyInstruction = buildAnnounceReplyInstruction({ + remainingActiveSubagentRuns, + requesterIsSubagent, + announceType, + }); + const statsLine = await buildCompactAnnounceStatsLine({ + sessionKey: params.childSessionKey, + startedAt: params.startedAt, + endedAt: params.endedAt, + }); + triggerMessage = [ + `[System Message] [sessionId: ${announceSessionId}] A ${announceType} "${taskLabel}" just ${statusLabel}.`, "", - "Findings:", - reply || "(no output)", + "Result:", + findings, "", statsLine, "", - "Summarize this naturally for the user. Keep it brief (1-2 sentences). Flow it into the conversation naturally.", - `Do not mention technical details like tokens, stats, or that this was a ${announceType}.`, - "You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).", + replyInstruction, ].join("\n"); const queued = await maybeQueueSubagentAnnounce({ - requesterSessionKey: params.requesterSessionKey, + requesterSessionKey: targetRequesterSessionKey, triggerMessage, summaryLine: taskLabel, - requesterOrigin, + requesterOrigin: targetRequesterOrigin, }); if (queued === "steered") { didAnnounce = true; @@ -526,29 +558,30 @@ export async function runSubagentAnnounceFlow(params: { return true; } - // Send to main agent - it will respond in its own voice - let directOrigin = requesterOrigin; - if (!directOrigin) { - const { entry } = loadRequesterSessionEntry(params.requesterSessionKey); + // Send to the requester session. For nested subagents this is an internal + // follow-up injection (deliver=false) so the orchestrator receives it. + let directOrigin = targetRequesterOrigin; + if (!requesterIsSubagent && !directOrigin) { + const { entry } = loadRequesterSessionEntry(targetRequesterSessionKey); directOrigin = deliveryContextFromSession(entry); } await callGateway({ method: "agent", params: { - sessionKey: params.requesterSessionKey, + sessionKey: targetRequesterSessionKey, message: triggerMessage, - deliver: true, - channel: directOrigin?.channel, - accountId: directOrigin?.accountId, - to: directOrigin?.to, + deliver: !requesterIsSubagent, + channel: requesterIsSubagent ? undefined : directOrigin?.channel, + accountId: requesterIsSubagent ? undefined : directOrigin?.accountId, + to: requesterIsSubagent ? undefined : directOrigin?.to, threadId: - directOrigin?.threadId != null && directOrigin.threadId !== "" + !requesterIsSubagent && directOrigin?.threadId != null && directOrigin.threadId !== "" ? String(directOrigin.threadId) : undefined, idempotencyKey: crypto.randomUUID(), }, expectFinal: true, - timeoutMs: 60_000, + timeoutMs: 15_000, }); didAnnounce = true; diff --git a/src/agents/subagent-depth.test.ts b/src/agents/subagent-depth.test.ts new file mode 100644 index 0000000000..66980d2d09 --- /dev/null +++ b/src/agents/subagent-depth.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; + +describe("getSubagentDepthFromSessionStore", () => { + it("uses spawnDepth from the session store when available", () => { + const key = "agent:main:subagent:flat"; + const depth = getSubagentDepthFromSessionStore(key, { + store: { + [key]: { spawnDepth: 2 }, + }, + }); + expect(depth).toBe(2); + }); + + it("derives depth from spawnedBy ancestry when spawnDepth is missing", () => { + const key1 = "agent:main:subagent:one"; + const key2 = "agent:main:subagent:two"; + const key3 = "agent:main:subagent:three"; + const depth = getSubagentDepthFromSessionStore(key3, { + store: { + [key1]: { spawnedBy: "agent:main:main" }, + [key2]: { spawnedBy: key1 }, + [key3]: { spawnedBy: key2 }, + }, + }); + expect(depth).toBe(3); + }); + + it("resolves depth when caller is identified by sessionId", () => { + const key1 = "agent:main:subagent:one"; + const key2 = "agent:main:subagent:two"; + const key3 = "agent:main:subagent:three"; + const depth = getSubagentDepthFromSessionStore("subagent-three-session", { + store: { + [key1]: { sessionId: "subagent-one-session", spawnedBy: "agent:main:main" }, + [key2]: { sessionId: "subagent-two-session", spawnedBy: key1 }, + [key3]: { sessionId: "subagent-three-session", spawnedBy: key2 }, + }, + }); + expect(depth).toBe(3); + }); + + it("resolves prefixed store keys when caller key omits the agent prefix", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-")); + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const prefixedKey = "agent:main:subagent:flat"; + const storePath = storeTemplate.replaceAll("{agentId}", "main"); + fs.writeFileSync( + storePath, + JSON.stringify( + { + [prefixedKey]: { + sessionId: "subagent-flat", + updatedAt: Date.now(), + spawnDepth: 2, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const depth = getSubagentDepthFromSessionStore("subagent:flat", { + cfg: { + session: { + store: storeTemplate, + }, + }, + }); + + expect(depth).toBe(2); + }); + + it("falls back to session-key segment counting when metadata is missing", () => { + const key = "agent:main:subagent:flat"; + const depth = getSubagentDepthFromSessionStore(key, { + store: { + [key]: {}, + }, + }); + expect(depth).toBe(1); + }); +}); diff --git a/src/agents/subagent-depth.ts b/src/agents/subagent-depth.ts new file mode 100644 index 0000000000..ac7b812bee --- /dev/null +++ b/src/agents/subagent-depth.ts @@ -0,0 +1,176 @@ +import JSON5 from "json5"; +import fs from "node:fs"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { resolveDefaultAgentId } from "./agent-scope.js"; + +type SessionDepthEntry = { + sessionId?: unknown; + spawnDepth?: unknown; + spawnedBy?: unknown; +}; + +function normalizeSpawnDepth(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isInteger(value) && value >= 0 ? value : undefined; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const numeric = Number(trimmed); + return Number.isInteger(numeric) && numeric >= 0 ? numeric : undefined; + } + return undefined; +} + +function normalizeSessionKey(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function readSessionStore(storePath: string): Record { + try { + const raw = fs.readFileSync(storePath, "utf-8"); + const parsed = JSON5.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // ignore missing/invalid stores + } + return {}; +} + +function buildKeyCandidates(rawKey: string, cfg?: OpenClawConfig): string[] { + if (!cfg) { + return [rawKey]; + } + if (rawKey === "global" || rawKey === "unknown") { + return [rawKey]; + } + if (parseAgentSessionKey(rawKey)) { + return [rawKey]; + } + const defaultAgentId = resolveDefaultAgentId(cfg); + const prefixed = `agent:${defaultAgentId}:${rawKey}`; + return prefixed === rawKey ? [rawKey] : [rawKey, prefixed]; +} + +function findEntryBySessionId( + store: Record, + sessionId: string, +): SessionDepthEntry | undefined { + const normalizedSessionId = normalizeSessionKey(sessionId); + if (!normalizedSessionId) { + return undefined; + } + for (const entry of Object.values(store)) { + const candidateSessionId = normalizeSessionKey(entry?.sessionId); + if (candidateSessionId && candidateSessionId === normalizedSessionId) { + return entry; + } + } + return undefined; +} + +function resolveEntryForSessionKey(params: { + sessionKey: string; + cfg?: OpenClawConfig; + store?: Record; + cache: Map>; +}): SessionDepthEntry | undefined { + const candidates = buildKeyCandidates(params.sessionKey, params.cfg); + + if (params.store) { + for (const key of candidates) { + const entry = params.store[key]; + if (entry) { + return entry; + } + } + return findEntryBySessionId(params.store, params.sessionKey); + } + + if (!params.cfg) { + return undefined; + } + + for (const key of candidates) { + const parsed = parseAgentSessionKey(key); + if (!parsed?.agentId) { + continue; + } + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed.agentId }); + let store = params.cache.get(storePath); + if (!store) { + store = readSessionStore(storePath); + params.cache.set(storePath, store); + } + const entry = store[key] ?? findEntryBySessionId(store, params.sessionKey); + if (entry) { + return entry; + } + } + + return undefined; +} + +export function getSubagentDepthFromSessionStore( + sessionKey: string | undefined | null, + opts?: { + cfg?: OpenClawConfig; + store?: Record; + }, +): number { + const raw = (sessionKey ?? "").trim(); + const fallbackDepth = getSubagentDepth(raw); + if (!raw) { + return fallbackDepth; + } + + const cache = new Map>(); + const visited = new Set(); + + const depthFromStore = (key: string): number | undefined => { + const normalizedKey = normalizeSessionKey(key); + if (!normalizedKey) { + return undefined; + } + if (visited.has(normalizedKey)) { + return undefined; + } + visited.add(normalizedKey); + + const entry = resolveEntryForSessionKey({ + sessionKey: normalizedKey, + cfg: opts?.cfg, + store: opts?.store, + cache, + }); + + const storedDepth = normalizeSpawnDepth(entry?.spawnDepth); + if (storedDepth !== undefined) { + return storedDepth; + } + + const spawnedBy = normalizeSessionKey(entry?.spawnedBy); + if (!spawnedBy) { + return undefined; + } + + const parentDepth = depthFromStore(spawnedBy); + if (parentDepth !== undefined) { + return parentDepth + 1; + } + + return getSubagentDepth(spawnedBy) + 1; + }; + + return depthFromStore(raw) ?? fallbackDepth; +} diff --git a/src/agents/subagent-registry.nested.test.ts b/src/agents/subagent-registry.nested.test.ts new file mode 100644 index 0000000000..399917d177 --- /dev/null +++ b/src/agents/subagent-registry.nested.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const noop = () => {}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(async () => ({ + status: "ok", + startedAt: 111, + endedAt: 222, + })), +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: vi.fn(() => noop), +})); + +vi.mock("./subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn(async () => true), + buildSubagentSystemPrompt: vi.fn(() => "test prompt"), +})); + +describe("subagent registry nested agent tracking", () => { + afterEach(async () => { + const mod = await import("./subagent-registry.js"); + mod.resetSubagentRegistryForTests(); + }); + + it("listSubagentRunsForRequester returns children of the requesting session", async () => { + const { registerSubagentRun, listSubagentRunsForRequester } = + await import("./subagent-registry.js"); + + // Main agent spawns a depth-1 orchestrator + registerSubagentRun({ + runId: "run-orch", + childSessionKey: "agent:main:subagent:orch-uuid", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "orchestrate something", + cleanup: "keep", + label: "orchestrator", + }); + + // Depth-1 orchestrator spawns a depth-2 leaf + registerSubagentRun({ + runId: "run-leaf", + childSessionKey: "agent:main:subagent:orch-uuid:subagent:leaf-uuid", + requesterSessionKey: "agent:main:subagent:orch-uuid", + requesterDisplayKey: "subagent:orch-uuid", + task: "do leaf work", + cleanup: "keep", + label: "leaf", + }); + + // Main sees its direct child (the orchestrator) + const mainRuns = listSubagentRunsForRequester("agent:main:main"); + expect(mainRuns).toHaveLength(1); + expect(mainRuns[0].runId).toBe("run-orch"); + + // Orchestrator sees its direct child (the leaf) + const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch-uuid"); + expect(orchRuns).toHaveLength(1); + expect(orchRuns[0].runId).toBe("run-leaf"); + + // Leaf has no children + const leafRuns = listSubagentRunsForRequester( + "agent:main:subagent:orch-uuid:subagent:leaf-uuid", + ); + expect(leafRuns).toHaveLength(0); + }); + + it("announce uses requesterSessionKey to route to the correct parent", async () => { + const { registerSubagentRun } = await import("./subagent-registry.js"); + // Register a sub-sub-agent whose parent is a sub-agent + registerSubagentRun({ + runId: "run-subsub", + childSessionKey: "agent:main:subagent:orch:subagent:child", + requesterSessionKey: "agent:main:subagent:orch", + requesterDisplayKey: "subagent:orch", + task: "nested task", + cleanup: "keep", + label: "nested-leaf", + }); + + // When announce fires for the sub-sub-agent, it should target the sub-agent (depth-1), + // NOT the main session. The registry entry's requesterSessionKey ensures this. + // We verify the registry entry has the correct requesterSessionKey. + const { listSubagentRunsForRequester } = await import("./subagent-registry.js"); + const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch"); + expect(orchRuns).toHaveLength(1); + expect(orchRuns[0].requesterSessionKey).toBe("agent:main:subagent:orch"); + expect(orchRuns[0].childSessionKey).toBe("agent:main:subagent:orch:subagent:child"); + }); + + it("countActiveRunsForSession only counts active children of the specific session", async () => { + const { registerSubagentRun, countActiveRunsForSession } = + await import("./subagent-registry.js"); + + // Main spawns orchestrator (active) + registerSubagentRun({ + runId: "run-orch-active", + childSessionKey: "agent:main:subagent:orch1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "orchestrate", + cleanup: "keep", + }); + + // Orchestrator spawns two leaves + registerSubagentRun({ + runId: "run-leaf-1", + childSessionKey: "agent:main:subagent:orch1:subagent:leaf1", + requesterSessionKey: "agent:main:subagent:orch1", + requesterDisplayKey: "subagent:orch1", + task: "leaf 1", + cleanup: "keep", + }); + + registerSubagentRun({ + runId: "run-leaf-2", + childSessionKey: "agent:main:subagent:orch1:subagent:leaf2", + requesterSessionKey: "agent:main:subagent:orch1", + requesterDisplayKey: "subagent:orch1", + task: "leaf 2", + cleanup: "keep", + }); + + // Main has 1 active child + expect(countActiveRunsForSession("agent:main:main")).toBe(1); + + // Orchestrator has 2 active children + expect(countActiveRunsForSession("agent:main:subagent:orch1")).toBe(2); + }); + + it("countActiveDescendantRuns traverses through ended parents", async () => { + const { addSubagentRunForTests, countActiveDescendantRuns } = + await import("./subagent-registry.js"); + + addSubagentRunForTests({ + runId: "run-parent-ended", + childSessionKey: "agent:main:subagent:orch-ended", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "orchestrate", + cleanup: "keep", + createdAt: 1, + startedAt: 1, + endedAt: 2, + cleanupHandled: false, + }); + addSubagentRunForTests({ + runId: "run-leaf-active", + childSessionKey: "agent:main:subagent:orch-ended:subagent:leaf", + requesterSessionKey: "agent:main:subagent:orch-ended", + requesterDisplayKey: "orch-ended", + task: "leaf", + cleanup: "keep", + createdAt: 1, + startedAt: 1, + cleanupHandled: false, + }); + + expect(countActiveDescendantRuns("agent:main:main")).toBe(1); + expect(countActiveDescendantRuns("agent:main:subagent:orch-ended")).toBe(1); + }); +}); diff --git a/src/agents/subagent-registry.persistence.e2e.test.ts b/src/agents/subagent-registry.persistence.e2e.test.ts index 0f8a6d4fc1..9b3f5348c4 100644 --- a/src/agents/subagent-registry.persistence.e2e.test.ts +++ b/src/agents/subagent-registry.persistence.e2e.test.ts @@ -274,4 +274,12 @@ describe("subagent registry persistence", () => { }; expect(afterSecond.runs?.["run-4"]).toBeUndefined(); }); + + it("uses isolated temp state when OPENCLAW_STATE_DIR is unset in tests", async () => { + delete process.env.OPENCLAW_STATE_DIR; + vi.resetModules(); + const { resolveSubagentRegistryPath } = await import("./subagent-registry.store.js"); + const registryPath = resolveSubagentRegistryPath(); + expect(registryPath).toContain(path.join(os.tmpdir(), "openclaw-test-state")); + }); }); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts new file mode 100644 index 0000000000..a4a0a70109 --- /dev/null +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const noop = () => {}; +let lifecycleHandler: + | ((evt: { stream?: string; runId: string; data?: { phase?: string } }) => void) + | undefined; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + return {}; + }), +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => { + lifecycleHandler = handler; + return noop; + }), +})); + +const announceSpy = vi.fn(async () => true); +vi.mock("./subagent-announce.js", () => ({ + runSubagentAnnounceFlow: (...args: unknown[]) => announceSpy(...args), +})); + +describe("subagent registry steer restarts", () => { + afterEach(async () => { + announceSpy.mockClear(); + lifecycleHandler = undefined; + const mod = await import("./subagent-registry.js"); + mod.resetSubagentRegistryForTests(); + }); + + it("suppresses announce for interrupted runs and only announces the replacement run", async () => { + const mod = await import("./subagent-registry.js"); + + mod.registerSubagentRun({ + runId: "run-old", + childSessionKey: "agent:main:subagent:steer", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "initial task", + cleanup: "keep", + }); + + const previous = mod.listSubagentRunsForRequester("agent:main:main")[0]; + expect(previous?.runId).toBe("run-old"); + + const marked = mod.markSubagentRunForSteerRestart("run-old"); + expect(marked).toBe(true); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-old", + data: { phase: "end" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(announceSpy).not.toHaveBeenCalled(); + + const replaced = mod.replaceSubagentRunAfterSteer({ + previousRunId: "run-old", + nextRunId: "run-new", + fallback: previous, + }); + expect(replaced).toBe(true); + + const runs = mod.listSubagentRunsForRequester("agent:main:main"); + expect(runs).toHaveLength(1); + expect(runs[0].runId).toBe("run-new"); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-new", + data: { phase: "end" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(announceSpy).toHaveBeenCalledTimes(1); + + const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string }; + expect(announce.childRunId).toBe("run-new"); + }); + + it("restores announce for a finished run when steer replacement dispatch fails", async () => { + const mod = await import("./subagent-registry.js"); + + mod.registerSubagentRun({ + runId: "run-failed-restart", + childSessionKey: "agent:main:subagent:failed-restart", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "initial task", + cleanup: "keep", + }); + + expect(mod.markSubagentRunForSteerRestart("run-failed-restart")).toBe(true); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-failed-restart", + data: { phase: "end" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(announceSpy).not.toHaveBeenCalled(); + + expect(mod.clearSubagentRunSteerRestart("run-failed-restart")).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(announceSpy).toHaveBeenCalledTimes(1); + const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string }; + expect(announce.childRunId).toBe("run-failed-restart"); + }); + + it("marks killed runs terminated and inactive", async () => { + const mod = await import("./subagent-registry.js"); + const childSessionKey = "agent:main:subagent:killed"; + + mod.registerSubagentRun({ + runId: "run-killed", + childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "kill me", + cleanup: "keep", + }); + + expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(true); + const updated = mod.markSubagentRunTerminated({ + childSessionKey, + reason: "manual kill", + }); + expect(updated).toBe(1); + expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(false); + + const run = mod.listSubagentRunsForRequester("agent:main:main")[0]; + expect(run?.outcome).toEqual({ status: "error", error: "manual kill" }); + expect(run?.cleanupHandled).toBe(true); + expect(typeof run?.cleanupCompletedAt).toBe("number"); + }); + + it("retries deferred parent cleanup after a descendant announces", async () => { + const mod = await import("./subagent-registry.js"); + let parentAttempts = 0; + announceSpy.mockImplementation(async (params: unknown) => { + const typed = params as { childRunId?: string }; + if (typed.childRunId === "run-parent") { + parentAttempts += 1; + return parentAttempts >= 2; + } + return true; + }); + + mod.registerSubagentRun({ + runId: "run-parent", + childSessionKey: "agent:main:subagent:parent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "parent task", + cleanup: "keep", + }); + mod.registerSubagentRun({ + runId: "run-child", + childSessionKey: "agent:main:subagent:parent:subagent:child", + requesterSessionKey: "agent:main:subagent:parent", + requesterDisplayKey: "parent", + task: "child task", + cleanup: "keep", + }); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-parent", + data: { phase: "end" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-child", + data: { phase: "end" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const childRunIds = announceSpy.mock.calls.map( + (call) => (call[0] as { childRunId?: string }).childRunId, + ); + expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(2); + expect(childRunIds.filter((id) => id === "run-child")).toHaveLength(1); + }); +}); diff --git a/src/agents/subagent-registry.store.ts b/src/agents/subagent-registry.store.ts index ad82ce132a..7e58bc9e0a 100644 --- a/src/agents/subagent-registry.store.ts +++ b/src/agents/subagent-registry.store.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import type { SubagentRunRecord } from "./subagent-registry.js"; import { resolveStateDir } from "../config/paths.js"; @@ -29,8 +30,19 @@ type LegacySubagentRunRecord = PersistedSubagentRunRecord & { requesterAccountId?: unknown; }; +function resolveSubagentStateDir(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.OPENCLAW_STATE_DIR?.trim(); + if (explicit) { + return resolveStateDir(env); + } + if (env.VITEST || env.NODE_ENV === "test") { + return path.join(os.tmpdir(), "openclaw-test-state", String(process.pid)); + } + return resolveStateDir(env); +} + export function resolveSubagentRegistryPath(): string { - return path.join(resolveStateDir(), "subagents", "runs.json"); + return path.join(resolveSubagentStateDir(process.env), "subagents", "runs.json"); } export function loadSubagentRegistryFromDisk(): Map { diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index f50392dfd0..dee77aef49 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -19,6 +19,8 @@ export type SubagentRunRecord = { task: string; cleanup: "delete" | "keep"; label?: string; + model?: string; + runTimeoutSeconds?: number; createdAt: number; startedAt?: number; endedAt?: number; @@ -26,6 +28,7 @@ export type SubagentRunRecord = { archiveAtMs?: number; cleanupCompletedAt?: number; cleanupHandled?: boolean; + suppressAnnounceReason?: "steer-restart" | "killed"; }; const subagentRuns = new Map(); @@ -46,29 +49,8 @@ function persistSubagentRuns() { const resumedRuns = new Set(); -function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecord): boolean { - if (!beginSubagentCleanup(runId)) { - return false; - } - const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); - void runSubagentAnnounceFlow({ - childSessionKey: entry.childSessionKey, - childRunId: entry.runId, - requesterSessionKey: entry.requesterSessionKey, - requesterOrigin, - requesterDisplayKey: entry.requesterDisplayKey, - task: entry.task, - timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, - cleanup: entry.cleanup, - waitForCompletion: false, - startedAt: entry.startedAt, - endedAt: entry.endedAt, - label: entry.label, - outcome: entry.outcome, - }).then((didAnnounce) => { - finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); - }); - return true; +function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) { + return entry?.suppressAnnounceReason === "steer-restart"; } function resumeSubagentRun(runId: string) { @@ -84,16 +66,38 @@ function resumeSubagentRun(runId: string) { } if (typeof entry.endedAt === "number" && entry.endedAt > 0) { - if (!startSubagentAnnounceCleanupFlow(runId, entry)) { + if (suppressAnnounceForSteerRestart(entry)) { + resumedRuns.add(runId); return; } + if (!beginSubagentCleanup(runId)) { + return; + } + const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + void runSubagentAnnounceFlow({ + childSessionKey: entry.childSessionKey, + childRunId: entry.runId, + requesterSessionKey: entry.requesterSessionKey, + requesterOrigin, + requesterDisplayKey: entry.requesterDisplayKey, + task: entry.task, + timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, + cleanup: entry.cleanup, + waitForCompletion: false, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + label: entry.label, + outcome: entry.outcome, + }).then((didAnnounce) => { + finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + }); resumedRuns.add(runId); return; } // Wait for completion again after restart. const cfg = loadConfig(); - const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, undefined); + const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, entry.runTimeoutSeconds); void waitForSubagentCompletion(runId, waitTimeoutMs); resumedRuns.add(runId); } @@ -144,7 +148,7 @@ function resolveSubagentWaitTimeoutMs( cfg: ReturnType, runTimeoutSeconds?: number, ) { - return resolveAgentTimeoutMs({ cfg, overrideSeconds: runTimeoutSeconds }); + return resolveAgentTimeoutMs({ cfg, overrideSeconds: runTimeoutSeconds ?? 0 }); } function startSweeper() { @@ -229,7 +233,31 @@ function ensureListener() { } persistSubagentRuns(); - void startSubagentAnnounceCleanupFlow(evt.runId, entry); + if (suppressAnnounceForSteerRestart(entry)) { + return; + } + + if (!beginSubagentCleanup(evt.runId)) { + return; + } + const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + void runSubagentAnnounceFlow({ + childSessionKey: entry.childSessionKey, + childRunId: entry.runId, + requesterSessionKey: entry.requesterSessionKey, + requesterOrigin, + requesterDisplayKey: entry.requesterDisplayKey, + task: entry.task, + timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, + cleanup: entry.cleanup, + waitForCompletion: false, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + label: entry.label, + outcome: entry.outcome, + }).then((didAnnounce) => { + finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce); + }); }); } @@ -241,16 +269,38 @@ function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didA if (!didAnnounce) { // Allow retry on the next wake if announce was deferred or failed. entry.cleanupHandled = false; + resumedRuns.delete(runId); persistSubagentRuns(); return; } if (cleanup === "delete") { subagentRuns.delete(runId); persistSubagentRuns(); + retryDeferredCompletedAnnounces(runId); return; } entry.cleanupCompletedAt = Date.now(); persistSubagentRuns(); + retryDeferredCompletedAnnounces(runId); +} + +function retryDeferredCompletedAnnounces(excludeRunId?: string) { + for (const [runId, entry] of subagentRuns.entries()) { + if (excludeRunId && runId === excludeRunId) { + continue; + } + if (typeof entry.endedAt !== "number") { + continue; + } + if (entry.cleanupCompletedAt || entry.cleanupHandled) { + continue; + } + if (suppressAnnounceForSteerRestart(entry)) { + continue; + } + resumedRuns.delete(runId); + resumeSubagentRun(runId); + } } function beginSubagentCleanup(runId: string) { @@ -269,6 +319,99 @@ function beginSubagentCleanup(runId: string) { return true; } +export function markSubagentRunForSteerRestart(runId: string) { + const key = runId.trim(); + if (!key) { + return false; + } + const entry = subagentRuns.get(key); + if (!entry) { + return false; + } + if (entry.suppressAnnounceReason === "steer-restart") { + return true; + } + entry.suppressAnnounceReason = "steer-restart"; + persistSubagentRuns(); + return true; +} + +export function clearSubagentRunSteerRestart(runId: string) { + const key = runId.trim(); + if (!key) { + return false; + } + const entry = subagentRuns.get(key); + if (!entry) { + return false; + } + if (entry.suppressAnnounceReason !== "steer-restart") { + return true; + } + entry.suppressAnnounceReason = undefined; + persistSubagentRuns(); + // If the interrupted run already finished while suppression was active, retry + // cleanup now so completion output is not lost when restart dispatch fails. + resumedRuns.delete(key); + if (typeof entry.endedAt === "number" && !entry.cleanupCompletedAt) { + resumeSubagentRun(key); + } + return true; +} + +export function replaceSubagentRunAfterSteer(params: { + previousRunId: string; + nextRunId: string; + fallback?: SubagentRunRecord; + runTimeoutSeconds?: number; +}) { + const previousRunId = params.previousRunId.trim(); + const nextRunId = params.nextRunId.trim(); + if (!previousRunId || !nextRunId) { + return false; + } + + const previous = subagentRuns.get(previousRunId); + const source = previous ?? params.fallback; + if (!source) { + return false; + } + + if (previousRunId !== nextRunId) { + subagentRuns.delete(previousRunId); + resumedRuns.delete(previousRunId); + } + + const now = Date.now(); + const cfg = loadConfig(); + const archiveAfterMs = resolveArchiveAfterMs(cfg); + const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined; + const runTimeoutSeconds = params.runTimeoutSeconds ?? source.runTimeoutSeconds ?? 0; + const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds); + + const next: SubagentRunRecord = { + ...source, + runId: nextRunId, + startedAt: now, + endedAt: undefined, + outcome: undefined, + cleanupCompletedAt: undefined, + cleanupHandled: false, + suppressAnnounceReason: undefined, + archiveAtMs, + runTimeoutSeconds, + }; + + subagentRuns.set(nextRunId, next); + ensureListener(); + persistSubagentRuns(); + if (archiveAtMs) { + startSweeper(); + } + void waitForSubagentCompletion(nextRunId, waitTimeoutMs); + return true; +} + export function registerSubagentRun(params: { runId: string; childSessionKey: string; @@ -278,13 +421,15 @@ export function registerSubagentRun(params: { task: string; cleanup: "delete" | "keep"; label?: string; + model?: string; runTimeoutSeconds?: number; }) { const now = Date.now(); const cfg = loadConfig(); const archiveAfterMs = resolveArchiveAfterMs(cfg); const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined; - const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, params.runTimeoutSeconds); + const runTimeoutSeconds = params.runTimeoutSeconds ?? 0; + const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds); const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin); subagentRuns.set(params.runId, { runId: params.runId, @@ -295,6 +440,8 @@ export function registerSubagentRun(params: { task: params.task, cleanup: params.cleanup, label: params.label, + model: params.model, + runTimeoutSeconds, createdAt: now, startedAt: now, archiveAtMs, @@ -357,7 +504,30 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) { if (mutated) { persistSubagentRuns(); } - void startSubagentAnnounceCleanupFlow(runId, entry); + if (suppressAnnounceForSteerRestart(entry)) { + return; + } + if (!beginSubagentCleanup(runId)) { + return; + } + const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + void runSubagentAnnounceFlow({ + childSessionKey: entry.childSessionKey, + childRunId: entry.runId, + requesterSessionKey: entry.requesterSessionKey, + requesterOrigin, + requesterDisplayKey: entry.requesterDisplayKey, + task: entry.task, + timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, + cleanup: entry.cleanup, + waitForCompletion: false, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + label: entry.label, + outcome: entry.outcome, + }).then((didAnnounce) => { + finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + }); } catch { // ignore } @@ -381,7 +551,6 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { export function addSubagentRunForTests(entry: SubagentRunRecord) { subagentRuns.set(entry.runId, entry); - persistSubagentRuns(); } export function releaseSubagentRun(runId: string) { @@ -394,6 +563,122 @@ export function releaseSubagentRun(runId: string) { } } +function findRunIdsByChildSessionKey(childSessionKey: string): string[] { + const key = childSessionKey.trim(); + if (!key) { + return []; + } + const runIds: string[] = []; + for (const [runId, entry] of subagentRuns.entries()) { + if (entry.childSessionKey === key) { + runIds.push(runId); + } + } + return runIds; +} + +function getRunsSnapshotForRead(): Map { + const merged = new Map(); + const shouldReadDisk = !(process.env.VITEST || process.env.NODE_ENV === "test"); + if (shouldReadDisk) { + try { + // Registry state is persisted to disk so other worker processes (for + // example cron runners) can observe active children spawned elsewhere. + for (const [runId, entry] of loadSubagentRegistryFromDisk().entries()) { + merged.set(runId, entry); + } + } catch { + // Ignore disk read failures and fall back to local memory state. + } + } + for (const [runId, entry] of subagentRuns.entries()) { + merged.set(runId, entry); + } + return merged; +} + +export function resolveRequesterForChildSession(childSessionKey: string): { + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; +} | null { + const key = childSessionKey.trim(); + if (!key) { + return null; + } + let best: SubagentRunRecord | undefined; + for (const entry of getRunsSnapshotForRead().values()) { + if (entry.childSessionKey !== key) { + continue; + } + if (!best || entry.createdAt > best.createdAt) { + best = entry; + } + } + if (!best) { + return null; + } + return { + requesterSessionKey: best.requesterSessionKey, + requesterOrigin: normalizeDeliveryContext(best.requesterOrigin), + }; +} + +export function isSubagentSessionRunActive(childSessionKey: string): boolean { + const runIds = findRunIdsByChildSessionKey(childSessionKey); + for (const runId of runIds) { + const entry = subagentRuns.get(runId); + if (!entry) { + continue; + } + if (typeof entry.endedAt !== "number") { + return true; + } + } + return false; +} + +export function markSubagentRunTerminated(params: { + runId?: string; + childSessionKey?: string; + reason?: string; +}): number { + const runIds = new Set(); + if (typeof params.runId === "string" && params.runId.trim()) { + runIds.add(params.runId.trim()); + } + if (typeof params.childSessionKey === "string" && params.childSessionKey.trim()) { + for (const runId of findRunIdsByChildSessionKey(params.childSessionKey)) { + runIds.add(runId); + } + } + if (runIds.size === 0) { + return 0; + } + + const now = Date.now(); + const reason = params.reason?.trim() || "killed"; + let updated = 0; + for (const runId of runIds) { + const entry = subagentRuns.get(runId); + if (!entry) { + continue; + } + if (typeof entry.endedAt === "number") { + continue; + } + entry.endedAt = now; + entry.outcome = { status: "error", error: reason }; + entry.cleanupHandled = true; + entry.cleanupCompletedAt = now; + entry.suppressAnnounceReason = "killed"; + updated += 1; + } + if (updated > 0) { + persistSubagentRuns(); + } + return updated; +} + export function listSubagentRunsForRequester(requesterSessionKey: string): SubagentRunRecord[] { const key = requesterSessionKey.trim(); if (!key) { @@ -402,6 +687,86 @@ export function listSubagentRunsForRequester(requesterSessionKey: string): Subag return [...subagentRuns.values()].filter((entry) => entry.requesterSessionKey === key); } +export function countActiveRunsForSession(requesterSessionKey: string): number { + const key = requesterSessionKey.trim(); + if (!key) { + return 0; + } + let count = 0; + for (const entry of getRunsSnapshotForRead().values()) { + if (entry.requesterSessionKey !== key) { + continue; + } + if (typeof entry.endedAt === "number") { + continue; + } + count += 1; + } + return count; +} + +export function countActiveDescendantRuns(rootSessionKey: string): number { + const root = rootSessionKey.trim(); + if (!root) { + return 0; + } + const runs = getRunsSnapshotForRead(); + const pending = [root]; + const visited = new Set([root]); + let count = 0; + while (pending.length > 0) { + const requester = pending.shift(); + if (!requester) { + continue; + } + for (const entry of runs.values()) { + if (entry.requesterSessionKey !== requester) { + continue; + } + if (typeof entry.endedAt !== "number") { + count += 1; + } + const childKey = entry.childSessionKey.trim(); + if (!childKey || visited.has(childKey)) { + continue; + } + visited.add(childKey); + pending.push(childKey); + } + } + return count; +} + +export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] { + const root = rootSessionKey.trim(); + if (!root) { + return []; + } + const runs = getRunsSnapshotForRead(); + const pending = [root]; + const visited = new Set([root]); + const descendants: SubagentRunRecord[] = []; + while (pending.length > 0) { + const requester = pending.shift(); + if (!requester) { + continue; + } + for (const entry of runs.values()) { + if (entry.requesterSessionKey !== requester) { + continue; + } + descendants.push(entry); + const childKey = entry.childSessionKey.trim(); + if (!childKey || visited.has(childKey)) { + continue; + } + visited.add(childKey); + pending.push(childKey); + } + } + return descendants; +} + export function initSubagentRegistry() { restoreSubagentRunsOnce(); } diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index 1c26d09155..65f8d7852d 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildSubagentSystemPrompt } from "./subagent-announce.js"; import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js"; describe("buildAgentSystemPrompt", () => { @@ -103,6 +104,26 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Do not invent commands"); }); + it("marks system message blocks as internal and not user-visible", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + }); + + expect(prompt).toContain("`[System Message] ...` blocks are internal context"); + expect(prompt).toContain("are not user-visible by default"); + expect(prompt).toContain("reports completed cron/subagent work"); + expect(prompt).toContain("rewrite it in your normal assistant voice"); + }); + + it("guides subagent workflows to avoid polling loops", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + }); + + expect(prompt).toContain("Completion is push-based: it will auto-announce when done."); + expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop"); + }); + it("lists available tools when provided", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", @@ -450,3 +471,81 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode."); }); }); + +describe("buildSubagentSystemPrompt", () => { + it("includes sub-agent spawning guidance for depth-1 orchestrator when maxSpawnDepth >= 2", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc", + task: "research task", + childDepth: 1, + maxSpawnDepth: 2, + }); + + expect(prompt).toContain("## Sub-Agent Spawning"); + expect(prompt).toContain("You CAN spawn your own sub-agents"); + expect(prompt).toContain("sessions_spawn"); + expect(prompt).toContain("`subagents` tool"); + expect(prompt).toContain("announce their results back to you automatically"); + expect(prompt).toContain("Do NOT repeatedly poll `subagents list`"); + }); + + it("does not include spawning guidance for depth-1 leaf when maxSpawnDepth == 1", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc", + task: "research task", + childDepth: 1, + maxSpawnDepth: 1, + }); + + expect(prompt).not.toContain("## Sub-Agent Spawning"); + expect(prompt).not.toContain("You CAN spawn"); + }); + + it("includes leaf worker note for depth-2 sub-sub-agents", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc:subagent:def", + task: "leaf task", + childDepth: 2, + maxSpawnDepth: 2, + }); + + expect(prompt).toContain("## Sub-Agent Spawning"); + expect(prompt).toContain("leaf worker"); + expect(prompt).toContain("CANNOT spawn further sub-agents"); + }); + + it("uses 'parent orchestrator' label for depth-2 agents", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc:subagent:def", + task: "leaf task", + childDepth: 2, + maxSpawnDepth: 2, + }); + + expect(prompt).toContain("spawned by the parent orchestrator"); + expect(prompt).toContain("reported to the parent orchestrator"); + }); + + it("uses 'main agent' label for depth-1 agents", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc", + task: "orchestrator task", + childDepth: 1, + maxSpawnDepth: 2, + }); + + expect(prompt).toContain("spawned by the main agent"); + expect(prompt).toContain("reported to the main agent"); + }); + + it("defaults to depth 1 and maxSpawnDepth 1 when not provided", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc", + task: "basic task", + }); + + // Should not include spawning guidance (default maxSpawnDepth is 1, depth 1 is leaf) + expect(prompt).not.toContain("## Sub-Agent Spawning"); + expect(prompt).toContain("spawned by the main agent"); + }); +}); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 3c3847b1b3..21a176ac8c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -109,6 +109,9 @@ function buildMessagingSection(params: { "## Messaging", "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", "- Cross-session messaging → use sessions_send(sessionKey, message)", + "- Sub-agent orchestration → use subagents(action=list|steer|kill)", + "- `[System Message] ...` blocks are internal context and are not user-visible by default.", + "- If a `[System Message]` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to NO_REPLY).", "- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.", params.availableTools.has("message") ? [ @@ -241,6 +244,7 @@ export function buildAgentSystemPrompt(params: { sessions_history: "Fetch history for another session/sub-agent", sessions_send: "Send a message to another session/sub-agent", sessions_spawn: "Spawn a sub-agent session", + subagents: "List, steer, or kill sub-agent runs for this requester session", session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override", image: "Analyze an image with the configured image model", @@ -268,6 +272,7 @@ export function buildAgentSystemPrompt(params: { "sessions_list", "sessions_history", "sessions_send", + "subagents", "session_status", "image", ]; @@ -403,6 +408,7 @@ export function buildAgentSystemPrompt(params: { "- apply_patch: apply multi-file patches", `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, `- ${processToolName}: manage background exec sessions`, + `- For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, "- browser: control OpenClaw's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", @@ -410,10 +416,12 @@ export function buildAgentSystemPrompt(params: { "- sessions_list: list sessions", "- sessions_history: fetch session history", "- sessions_send: send to another session", + "- subagents: list/steer/kill sub-agent runs", '- session_status: show usage/time/model state and answer "what model are we using?"', ].join("\n"), "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", - "If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.", + "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.", + "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", "", "## Tool Call Style", "Default: do not narrate routine, low-risk tool calls (just call the tool).", diff --git a/src/agents/tool-display.e2e.test.ts b/src/agents/tool-display.e2e.test.ts index 760ef591a4..f18b24c4d6 100644 --- a/src/agents/tool-display.e2e.test.ts +++ b/src/agents/tool-display.e2e.test.ts @@ -10,7 +10,6 @@ describe("tool display details", () => { task: "double-message-bug-gpt", label: 0, runTimeoutSeconds: 0, - timeoutSeconds: 0, }, }), ); diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 3fea81405e..8e469884c0 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -267,10 +267,18 @@ "model", "thinking", "runTimeoutSeconds", - "cleanup", - "timeoutSeconds" + "cleanup" ] }, + "subagents": { + "emoji": "🤖", + "title": "Subagents", + "actions": { + "list": { "label": "list", "detailKeys": ["recentMinutes"] }, + "kill": { "label": "kill", "detailKeys": ["target"] }, + "steer": { "label": "steer", "detailKeys": ["target"] } + } + }, "session_status": { "emoji": "📊", "title": "Session Status", diff --git a/src/agents/tool-policy.e2e.test.ts b/src/agents/tool-policy.e2e.test.ts index b349d7f645..b4b9d20a08 100644 --- a/src/agents/tool-policy.e2e.test.ts +++ b/src/agents/tool-policy.e2e.test.ts @@ -24,6 +24,7 @@ describe("tool-policy", () => { const group = TOOL_GROUPS["group:openclaw"]; expect(group).toContain("browser"); expect(group).toContain("message"); + expect(group).toContain("subagents"); expect(group).toContain("session_status"); }); }); diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index e318f9ee19..d4fa9a69b1 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -26,6 +26,7 @@ export const TOOL_GROUPS: Record = { "sessions_history", "sessions_send", "sessions_spawn", + "subagents", "session_status", ], // UI helpers @@ -49,6 +50,7 @@ export const TOOL_GROUPS: Record = { "sessions_history", "sessions_send", "sessions_spawn", + "subagents", "session_status", "memory_search", "memory_get", diff --git a/src/agents/tools/agent-step.test.ts b/src/agents/tools/agent-step.test.ts new file mode 100644 index 0000000000..d83feb5aa4 --- /dev/null +++ b/src/agents/tools/agent-step.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +import { readLatestAssistantReply } from "./agent-step.js"; + +describe("readLatestAssistantReply", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("returns the most recent assistant message when compaction markers trail history", async () => { + callGatewayMock.mockResolvedValue({ + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "All checks passed and changes were pushed." }], + }, + { role: "toolResult", content: [{ type: "text", text: "tool output" }] }, + { role: "system", content: [{ type: "text", text: "Compaction" }] }, + ], + }); + + const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" }); + + expect(result).toBe("All checks passed and changes were pushed."); + expect(callGatewayMock).toHaveBeenCalledWith({ + method: "chat.history", + params: { sessionKey: "agent:main:child", limit: 50 }, + }); + }); + + it("falls back to older assistant text when latest assistant has no text", async () => { + callGatewayMock.mockResolvedValue({ + messages: [ + { role: "assistant", content: [{ type: "text", text: "older output" }] }, + { role: "assistant", content: [] }, + { role: "system", content: [{ type: "text", text: "Compaction" }] }, + ], + }); + + const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" }); + + expect(result).toBe("older output"); + }); +}); diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts index 98b688d06c..406367e0ac 100644 --- a/src/agents/tools/agent-step.ts +++ b/src/agents/tools/agent-step.ts @@ -13,8 +13,21 @@ export async function readLatestAssistantReply(params: { params: { sessionKey: params.sessionKey, limit: params.limit ?? 50 }, }); const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); - const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined; - return last ? extractAssistantText(last) : undefined; + for (let i = filtered.length - 1; i >= 0; i -= 1) { + const candidate = filtered[i]; + if (!candidate || typeof candidate !== "object") { + continue; + } + if ((candidate as { role?: unknown }).role !== "assistant") { + continue; + } + const text = extractAssistantText(candidate); + if (!text?.trim()) { + continue; + } + return text; + } + return undefined; } export async function runAgentStep(params: { diff --git a/src/agents/tools/sessions-helpers.e2e.test.ts b/src/agents/tools/sessions-helpers.e2e.test.ts index e87a990a60..887cc1f467 100644 --- a/src/agents/tools/sessions-helpers.e2e.test.ts +++ b/src/agents/tools/sessions-helpers.e2e.test.ts @@ -40,4 +40,19 @@ describe("extractAssistantText", () => { }; expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); }); + + it("keeps normal status text that mentions billing", () => { + const message = { + role: "assistant", + content: [ + { + type: "text", + text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + }, + ], + }; + expect(extractAssistantText(message)).toBe( + "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + ); + }); }); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1ed7bcd1c1..11486c025e 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -5,17 +5,15 @@ import type { AnyAgentTool } from "./common.js"; import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thinking.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; -import { - isSubagentSessionKey, - normalizeAgentId, - parseAgentSessionKey, -} from "../../routing/session-key.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; +import { resolveDefaultModelForAgent } from "../model-selection.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; -import { registerSubagentRun } from "../subagent-registry.js"; +import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; +import { countActiveRunsForSession, registerSubagentRun } from "../subagent-registry.js"; import { jsonResult, readStringParam } from "./common.js"; import { resolveDisplaySessionKey, @@ -30,8 +28,6 @@ const SessionsSpawnToolSchema = Type.Object({ model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), - // Back-compat alias. Prefer runTimeoutSeconds. - timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -99,32 +95,18 @@ export function createSessionsSpawnTool(opts?: { to: opts?.agentTo, threadId: opts?.agentThreadId, }); - const runTimeoutSeconds = (() => { - const explicit = - typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) - ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : undefined; - if (explicit !== undefined) { - return explicit; - } - const legacy = - typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) - ? Math.max(0, Math.floor(params.timeoutSeconds)) - : undefined; - return legacy ?? 0; - })(); + // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived + // by default and should not inherit the main agent 600s timeout. + const runTimeoutSeconds = + typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) + ? Math.max(0, Math.floor(params.runTimeoutSeconds)) + : 0; let modelWarning: string | undefined; let modelApplied = false; const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = opts?.agentSessionKey; - if (typeof requesterSessionKey === "string" && isSubagentSessionKey(requesterSessionKey)) { - return jsonResult({ - status: "forbidden", - error: "sessions_spawn is not allowed from sub-agent sessions", - }); - } const requesterInternalKey = requesterSessionKey ? resolveInternalSessionKey({ key: requesterSessionKey, @@ -138,6 +120,24 @@ export function createSessionsSpawnTool(opts?: { mainKey, }); + const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg }); + const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + if (callerDepth >= maxSpawnDepth) { + return jsonResult({ + status: "forbidden", + error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`, + }); + } + + const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5; + const activeChildren = countActiveRunsForSession(requesterInternalKey); + if (activeChildren >= maxChildren) { + return jsonResult({ + status: "forbidden", + error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`, + }); + } + const requesterAgentId = normalizeAgentId( opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, ); @@ -166,12 +166,19 @@ export function createSessionsSpawnTool(opts?: { } } const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; + const childDepth = callerDepth + 1; const spawnedByKey = requesterInternalKey; const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); + const runtimeDefaultModel = resolveDefaultModelForAgent({ + cfg, + agentId: targetAgentId, + }); const resolvedModel = normalizeModelSelection(modelOverride) ?? normalizeModelSelection(targetAgentConfig?.subagents?.model) ?? - normalizeModelSelection(cfg.agents?.defaults?.subagents?.model); + normalizeModelSelection(cfg.agents?.defaults?.subagents?.model) ?? + normalizeModelSelection(cfg.agents?.defaults?.model?.primary) ?? + normalizeModelSelection(`${runtimeDefaultModel.provider}/${runtimeDefaultModel.model}`); const resolvedThinkingDefaultRaw = readStringParam(targetAgentConfig?.subagents ?? {}, "thinking") ?? @@ -191,6 +198,22 @@ export function createSessionsSpawnTool(opts?: { } thinkingOverride = normalized; } + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, spawnDepth: childDepth }, + timeoutMs: 10_000, + }); + } catch (err) { + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return jsonResult({ + status: "error", + error: messageText, + childSessionKey, + }); + } + if (resolvedModel) { try { await callGateway({ @@ -240,6 +263,8 @@ export function createSessionsSpawnTool(opts?: { childSessionKey, label: label || undefined, task, + childDepth, + maxSpawnDepth, }); const childIdem = crypto.randomUUID(); @@ -260,7 +285,7 @@ export function createSessionsSpawnTool(opts?: { lane: AGENT_LANE_SUBAGENT, extraSystemPrompt: childSystemPrompt, thinking: thinkingOverride, - timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, + timeout: runTimeoutSeconds, label: label || undefined, spawnedBy: spawnedByKey, groupId: opts?.agentGroupId ?? undefined, @@ -292,6 +317,7 @@ export function createSessionsSpawnTool(opts?: { task, cleanup, label: label || undefined, + model: resolvedModel, runTimeoutSeconds, }); diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts new file mode 100644 index 0000000000..f479fc76b2 --- /dev/null +++ b/src/agents/tools/subagents-tool.ts @@ -0,0 +1,840 @@ +import { Type } from "@sinclair/typebox"; +import crypto from "node:crypto"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { AnyAgentTool } from "./common.js"; +import { clearSessionQueues } from "../../auto-reply/reply/queue.js"; +import { loadConfig } from "../../config/config.js"; +import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; +import { callGateway } from "../../gateway/call.js"; +import { logVerbose } from "../../globals.js"; +import { + isSubagentSessionKey, + parseAgentSessionKey, + type ParsedAgentSessionKey, +} from "../../routing/session-key.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import { AGENT_LANE_SUBAGENT } from "../lanes.js"; +import { abortEmbeddedPiRun } from "../pi-embedded.js"; +import { optionalStringEnum } from "../schema/typebox.js"; +import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; +import { + clearSubagentRunSteerRestart, + listSubagentRunsForRequester, + markSubagentRunTerminated, + markSubagentRunForSteerRestart, + replaceSubagentRunAfterSteer, + type SubagentRunRecord, +} from "../subagent-registry.js"; +import { jsonResult, readNumberParam, readStringParam } from "./common.js"; +import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; + +const SUBAGENT_ACTIONS = ["list", "kill", "steer"] as const; +type SubagentAction = (typeof SUBAGENT_ACTIONS)[number]; + +const DEFAULT_RECENT_MINUTES = 30; +const MAX_RECENT_MINUTES = 24 * 60; +const MAX_STEER_MESSAGE_CHARS = 4_000; +const STEER_RATE_LIMIT_MS = 2_000; +const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; + +const steerRateLimit = new Map(); + +const SubagentsToolSchema = Type.Object({ + action: optionalStringEnum(SUBAGENT_ACTIONS), + target: Type.Optional(Type.String()), + message: Type.Optional(Type.String()), + recentMinutes: Type.Optional(Type.Number({ minimum: 1 })), +}); + +type SessionEntryResolution = { + storePath: string; + entry: SessionEntry | undefined; +}; + +type ResolvedRequesterKey = { + requesterSessionKey: string; + callerSessionKey: string; + callerIsSubagent: boolean; +}; + +type TargetResolution = { + entry?: SubagentRunRecord; + error?: string; +}; + +function formatDurationCompact(valueMs?: number) { + if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { + return "n/a"; + } + const minutes = Math.max(1, Math.round(valueMs / 60_000)); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const minutesRemainder = minutes % 60; + if (hours < 24) { + return minutesRemainder > 0 ? `${hours}h${minutesRemainder}m` : `${hours}h`; + } + const days = Math.floor(hours / 24); + const hoursRemainder = hours % 24; + return hoursRemainder > 0 ? `${days}d${hoursRemainder}h` : `${days}d`; +} + +function formatTokenShort(value?: number) { + if (!value || !Number.isFinite(value) || value <= 0) { + return undefined; + } + const n = Math.floor(value); + if (n < 1_000) { + return `${n}`; + } + if (n < 10_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + if (n < 1_000_000) { + return `${Math.round(n / 1_000)}k`; + } + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; +} + +function truncate(text: string, maxLength: number) { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength).trimEnd()}...`; +} + +function resolveRunLabel(entry: SubagentRunRecord, fallback = "subagent") { + const raw = entry.label?.trim() || entry.task?.trim() || ""; + return raw || fallback; +} + +function resolveRunStatus(entry: SubagentRunRecord) { + if (!entry.endedAt) { + return "running"; + } + const status = entry.outcome?.status ?? "done"; + if (status === "ok") { + return "done"; + } + if (status === "error") { + return "failed"; + } + return status; +} + +function sortRuns(runs: SubagentRunRecord[]) { + return [...runs].toSorted((a, b) => { + const aTime = a.startedAt ?? a.createdAt ?? 0; + const bTime = b.startedAt ?? b.createdAt ?? 0; + return bTime - aTime; + }); +} + +function resolveModelRef(entry?: SessionEntry) { + const model = typeof entry?.model === "string" ? entry.model.trim() : ""; + const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; + if (model.includes("/")) { + return model; + } + if (model && provider) { + return `${provider}/${model}`; + } + if (model) { + return model; + } + if (provider) { + return provider; + } + // Fall back to override fields which are populated at spawn time, + // before the first run completes and writes model/modelProvider. + const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; + const overrideProvider = + typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; + if (overrideModel.includes("/")) { + return overrideModel; + } + if (overrideModel && overrideProvider) { + return `${overrideProvider}/${overrideModel}`; + } + if (overrideModel) { + return overrideModel; + } + return overrideProvider || undefined; +} + +function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) { + const modelRef = resolveModelRef(entry) || fallbackModel || undefined; + if (!modelRef) { + return "model n/a"; + } + const slash = modelRef.lastIndexOf("/"); + if (slash >= 0 && slash < modelRef.length - 1) { + return modelRef.slice(slash + 1); + } + return modelRef; +} + +function resolveTotalTokens(entry?: SessionEntry) { + if (!entry) { + return undefined; + } + if (typeof entry.totalTokens === "number" && Number.isFinite(entry.totalTokens)) { + return entry.totalTokens; + } + const input = typeof entry.inputTokens === "number" ? entry.inputTokens : 0; + const output = typeof entry.outputTokens === "number" ? entry.outputTokens : 0; + const total = input + output; + return total > 0 ? total : undefined; +} + +function resolveIoTokens(entry?: SessionEntry) { + if (!entry) { + return undefined; + } + const input = + typeof entry.inputTokens === "number" && Number.isFinite(entry.inputTokens) + ? entry.inputTokens + : 0; + const output = + typeof entry.outputTokens === "number" && Number.isFinite(entry.outputTokens) + ? entry.outputTokens + : 0; + const total = input + output; + if (total <= 0) { + return undefined; + } + return { input, output, total }; +} + +function resolveUsageDisplay(entry?: SessionEntry) { + const io = resolveIoTokens(entry); + const promptCache = resolveTotalTokens(entry); + const parts: string[] = []; + if (io) { + const input = formatTokenShort(io.input) ?? "0"; + const output = formatTokenShort(io.output) ?? "0"; + parts.push(`tokens ${formatTokenShort(io.total)} (in ${input} / out ${output})`); + } else if (typeof promptCache === "number" && promptCache > 0) { + parts.push(`tokens ${formatTokenShort(promptCache)} prompt/cache`); + } + if (typeof promptCache === "number" && io && promptCache > io.total) { + parts.push(`prompt/cache ${formatTokenShort(promptCache)}`); + } + return parts.join(", "); +} + +function resolveSubagentTarget( + runs: SubagentRunRecord[], + token: string | undefined, + options?: { recentMinutes?: number }, +): TargetResolution { + const trimmed = token?.trim(); + if (!trimmed) { + return { error: "Missing subagent target." }; + } + const sorted = sortRuns(runs); + const recentMinutes = options?.recentMinutes ?? DEFAULT_RECENT_MINUTES; + const recentCutoff = Date.now() - recentMinutes * 60_000; + const numericOrder = [ + ...sorted.filter((entry) => !entry.endedAt), + ...sorted.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff), + ]; + if (trimmed === "last") { + return { entry: sorted[0] }; + } + if (/^\d+$/.test(trimmed)) { + const idx = Number.parseInt(trimmed, 10); + if (!Number.isFinite(idx) || idx <= 0 || idx > numericOrder.length) { + return { error: `Invalid subagent index: ${trimmed}` }; + } + return { entry: numericOrder[idx - 1] }; + } + if (trimmed.includes(":")) { + const bySessionKey = sorted.find((entry) => entry.childSessionKey === trimmed); + return bySessionKey + ? { entry: bySessionKey } + : { error: `Unknown subagent session: ${trimmed}` }; + } + const lowered = trimmed.toLowerCase(); + const byExactLabel = sorted.filter((entry) => resolveRunLabel(entry).toLowerCase() === lowered); + if (byExactLabel.length === 1) { + return { entry: byExactLabel[0] }; + } + if (byExactLabel.length > 1) { + return { error: `Ambiguous subagent label: ${trimmed}` }; + } + const byLabelPrefix = sorted.filter((entry) => + resolveRunLabel(entry).toLowerCase().startsWith(lowered), + ); + if (byLabelPrefix.length === 1) { + return { entry: byLabelPrefix[0] }; + } + if (byLabelPrefix.length > 1) { + return { error: `Ambiguous subagent label prefix: ${trimmed}` }; + } + const byRunIdPrefix = sorted.filter((entry) => entry.runId.startsWith(trimmed)); + if (byRunIdPrefix.length === 1) { + return { entry: byRunIdPrefix[0] }; + } + if (byRunIdPrefix.length > 1) { + return { error: `Ambiguous subagent run id prefix: ${trimmed}` }; + } + return { error: `Unknown subagent target: ${trimmed}` }; +} + +function resolveStorePathForKey( + cfg: ReturnType, + key: string, + parsed?: ParsedAgentSessionKey | null, +) { + return resolveStorePath(cfg.session?.store, { + agentId: parsed?.agentId, + }); +} + +function resolveSessionEntryForKey(params: { + cfg: ReturnType; + key: string; + cache: Map>; +}): SessionEntryResolution { + const parsed = parseAgentSessionKey(params.key); + const storePath = resolveStorePathForKey(params.cfg, params.key, parsed); + let store = params.cache.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + params.cache.set(storePath, store); + } + return { + storePath, + entry: store[params.key], + }; +} + +function resolveRequesterKey(params: { + cfg: ReturnType; + agentSessionKey?: string; +}): ResolvedRequesterKey { + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + const callerRaw = params.agentSessionKey?.trim() || alias; + const callerSessionKey = resolveInternalSessionKey({ + key: callerRaw, + alias, + mainKey, + }); + if (!isSubagentSessionKey(callerSessionKey)) { + return { + requesterSessionKey: callerSessionKey, + callerSessionKey, + callerIsSubagent: false, + }; + } + + // Check if this sub-agent can spawn children (orchestrator). + // If so, it should see its own children, not its parent's children. + const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg }); + const maxSpawnDepth = params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + if (callerDepth < maxSpawnDepth) { + // Orchestrator sub-agent: use its own session key as requester + // so it sees children it spawned. + return { + requesterSessionKey: callerSessionKey, + callerSessionKey, + callerIsSubagent: true, + }; + } + + // Leaf sub-agent: walk up to its parent so it can see sibling runs. + const cache = new Map>(); + const callerEntry = resolveSessionEntryForKey({ + cfg: params.cfg, + key: callerSessionKey, + cache, + }).entry; + const spawnedBy = typeof callerEntry?.spawnedBy === "string" ? callerEntry.spawnedBy.trim() : ""; + return { + requesterSessionKey: spawnedBy || callerSessionKey, + callerSessionKey, + callerIsSubagent: true, + }; +} + +async function killSubagentRun(params: { + cfg: ReturnType; + entry: SubagentRunRecord; + cache: Map>; +}): Promise<{ killed: boolean; sessionId?: string }> { + if (params.entry.endedAt) { + return { killed: false }; + } + const childSessionKey = params.entry.childSessionKey; + const resolved = resolveSessionEntryForKey({ + cfg: params.cfg, + key: childSessionKey, + cache: params.cache, + }); + const sessionId = resolved.entry?.sessionId; + const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; + const cleared = clearSessionQueues([childSessionKey, sessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents tool kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + if (resolved.entry) { + await updateSessionStore(resolved.storePath, (store) => { + const current = store[childSessionKey]; + if (!current) { + return; + } + current.abortedLastRun = true; + current.updatedAt = Date.now(); + store[childSessionKey] = current; + }); + } + const marked = markSubagentRunTerminated({ + runId: params.entry.runId, + childSessionKey, + reason: "killed", + }); + const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0; + return { killed, sessionId }; +} + +/** + * Recursively kill all descendant subagent runs spawned by a given parent session key. + * This ensures that when a subagent is killed, all of its children (and their children) are also killed. + */ +async function cascadeKillChildren(params: { + cfg: ReturnType; + parentChildSessionKey: string; + cache: Map>; + seenChildSessionKeys?: Set; +}): Promise<{ killed: number; labels: string[] }> { + const childRuns = listSubagentRunsForRequester(params.parentChildSessionKey); + const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set(); + let killed = 0; + const labels: string[] = []; + + for (const run of childRuns) { + const childKey = run.childSessionKey?.trim(); + if (!childKey || seenChildSessionKeys.has(childKey)) { + continue; + } + seenChildSessionKeys.add(childKey); + + if (!run.endedAt) { + const stopResult = await killSubagentRun({ + cfg: params.cfg, + entry: run, + cache: params.cache, + }); + if (stopResult.killed) { + killed += 1; + labels.push(resolveRunLabel(run)); + } + } + + // Recurse for grandchildren even if this parent already ended. + const cascade = await cascadeKillChildren({ + cfg: params.cfg, + parentChildSessionKey: childKey, + cache: params.cache, + seenChildSessionKeys, + }); + killed += cascade.killed; + labels.push(...cascade.labels); + } + + return { killed, labels }; +} + +function buildListText(params: { + active: Array<{ line: string }>; + recent: Array<{ line: string }>; + recentMinutes: number; +}) { + const lines: string[] = []; + lines.push("active subagents:"); + if (params.active.length === 0) { + lines.push("(none)"); + } else { + lines.push(...params.active.map((entry) => entry.line)); + } + lines.push(""); + lines.push(`recent (last ${params.recentMinutes}m):`); + if (params.recent.length === 0) { + lines.push("(none)"); + } else { + lines.push(...params.recent.map((entry) => entry.line)); + } + return lines.join("\n"); +} + +export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAgentTool { + return { + label: "Subagents", + name: "subagents", + description: + "List, kill, or steer spawned sub-agents for this requester session. Use this for sub-agent orchestration.", + parameters: SubagentsToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const action = (readStringParam(params, "action") ?? "list") as SubagentAction; + const cfg = loadConfig(); + const requester = resolveRequesterKey({ + cfg, + agentSessionKey: opts?.agentSessionKey, + }); + const runs = sortRuns(listSubagentRunsForRequester(requester.requesterSessionKey)); + const recentMinutesRaw = readNumberParam(params, "recentMinutes"); + const recentMinutes = recentMinutesRaw + ? Math.max(1, Math.min(MAX_RECENT_MINUTES, Math.floor(recentMinutesRaw))) + : DEFAULT_RECENT_MINUTES; + + if (action === "list") { + const now = Date.now(); + const recentCutoff = now - recentMinutes * 60_000; + const cache = new Map>(); + + let index = 1; + const active = runs + .filter((entry) => !entry.endedAt) + .map((entry) => { + const sessionEntry = resolveSessionEntryForKey({ + cfg, + key: entry.childSessionKey, + cache, + }).entry; + const totalTokens = resolveTotalTokens(sessionEntry); + const usageText = resolveUsageDisplay(sessionEntry); + const status = resolveRunStatus(entry); + const runtime = formatDurationCompact(now - (entry.startedAt ?? entry.createdAt)); + const label = truncate(resolveRunLabel(entry), 48); + const task = truncate(entry.task.trim(), 72); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + const view = { + index, + runId: entry.runId, + sessionKey: entry.childSessionKey, + label, + task, + status, + runtime, + runtimeMs: now - (entry.startedAt ?? entry.createdAt), + model: resolveModelRef(sessionEntry) || entry.model, + totalTokens, + startedAt: entry.startedAt, + }; + index += 1; + return { line, view }; + }); + const recent = runs + .filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff) + .map((entry) => { + const sessionEntry = resolveSessionEntryForKey({ + cfg, + key: entry.childSessionKey, + cache, + }).entry; + const totalTokens = resolveTotalTokens(sessionEntry); + const usageText = resolveUsageDisplay(sessionEntry); + const status = resolveRunStatus(entry); + const runtime = formatDurationCompact( + (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), + ); + const label = truncate(resolveRunLabel(entry), 48); + const task = truncate(entry.task.trim(), 72); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + const view = { + index, + runId: entry.runId, + sessionKey: entry.childSessionKey, + label, + task, + status, + runtime, + runtimeMs: (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), + model: resolveModelRef(sessionEntry) || entry.model, + totalTokens, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + }; + index += 1; + return { line, view }; + }); + + const text = buildListText({ active, recent, recentMinutes }); + return jsonResult({ + status: "ok", + action: "list", + requesterSessionKey: requester.requesterSessionKey, + callerSessionKey: requester.callerSessionKey, + callerIsSubagent: requester.callerIsSubagent, + total: runs.length, + active: active.map((entry) => entry.view), + recent: recent.map((entry) => entry.view), + text, + }); + } + + if (action === "kill") { + const target = readStringParam(params, "target", { required: true }); + if (target === "all" || target === "*") { + const cache = new Map>(); + const seenChildSessionKeys = new Set(); + const killedLabels: string[] = []; + let killed = 0; + for (const entry of runs) { + const childKey = entry.childSessionKey?.trim(); + if (!childKey || seenChildSessionKeys.has(childKey)) { + continue; + } + seenChildSessionKeys.add(childKey); + + if (!entry.endedAt) { + const stopResult = await killSubagentRun({ cfg, entry, cache }); + if (stopResult.killed) { + killed += 1; + killedLabels.push(resolveRunLabel(entry)); + } + } + + // Traverse descendants even when the direct run is already finished. + const cascade = await cascadeKillChildren({ + cfg, + parentChildSessionKey: childKey, + cache, + seenChildSessionKeys, + }); + killed += cascade.killed; + killedLabels.push(...cascade.labels); + } + return jsonResult({ + status: "ok", + action: "kill", + target: "all", + killed, + labels: killedLabels, + text: + killed > 0 + ? `killed ${killed} subagent${killed === 1 ? "" : "s"}.` + : "no running subagents to kill.", + }); + } + const resolved = resolveSubagentTarget(runs, target, { recentMinutes }); + if (!resolved.entry) { + return jsonResult({ + status: "error", + action: "kill", + target, + error: resolved.error ?? "Unknown subagent target.", + }); + } + const killCache = new Map>(); + const stopResult = await killSubagentRun({ + cfg, + entry: resolved.entry, + cache: killCache, + }); + const seenChildSessionKeys = new Set(); + const targetChildKey = resolved.entry.childSessionKey?.trim(); + if (targetChildKey) { + seenChildSessionKeys.add(targetChildKey); + } + // Traverse descendants even when the selected run is already finished. + const cascade = await cascadeKillChildren({ + cfg, + parentChildSessionKey: resolved.entry.childSessionKey, + cache: killCache, + seenChildSessionKeys, + }); + if (!stopResult.killed && cascade.killed === 0) { + return jsonResult({ + status: "done", + action: "kill", + target, + runId: resolved.entry.runId, + sessionKey: resolved.entry.childSessionKey, + text: `${resolveRunLabel(resolved.entry)} is already finished.`, + }); + } + const cascadeText = + cascade.killed > 0 + ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` + : ""; + return jsonResult({ + status: "ok", + action: "kill", + target, + runId: resolved.entry.runId, + sessionKey: resolved.entry.childSessionKey, + label: resolveRunLabel(resolved.entry), + cascadeKilled: cascade.killed, + cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined, + text: stopResult.killed + ? `killed ${resolveRunLabel(resolved.entry)}${cascadeText}.` + : `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveRunLabel(resolved.entry)}.`, + }); + } + if (action === "steer") { + const target = readStringParam(params, "target", { required: true }); + const message = readStringParam(params, "message", { required: true }); + if (message.length > MAX_STEER_MESSAGE_CHARS) { + return jsonResult({ + status: "error", + action: "steer", + target, + error: `Message too long (${message.length} chars, max ${MAX_STEER_MESSAGE_CHARS}).`, + }); + } + const resolved = resolveSubagentTarget(runs, target, { recentMinutes }); + if (!resolved.entry) { + return jsonResult({ + status: "error", + action: "steer", + target, + error: resolved.error ?? "Unknown subagent target.", + }); + } + if (resolved.entry.endedAt) { + return jsonResult({ + status: "done", + action: "steer", + target, + runId: resolved.entry.runId, + sessionKey: resolved.entry.childSessionKey, + text: `${resolveRunLabel(resolved.entry)} is already finished.`, + }); + } + if ( + requester.callerIsSubagent && + requester.callerSessionKey === resolved.entry.childSessionKey + ) { + return jsonResult({ + status: "forbidden", + action: "steer", + target, + runId: resolved.entry.runId, + sessionKey: resolved.entry.childSessionKey, + error: "Subagents cannot steer themselves.", + }); + } + + const rateKey = `${requester.callerSessionKey}:${resolved.entry.childSessionKey}`; + const now = Date.now(); + const lastSentAt = steerRateLimit.get(rateKey) ?? 0; + if (now - lastSentAt < STEER_RATE_LIMIT_MS) { + return jsonResult({ + status: "rate_limited", + action: "steer", + target, + runId: resolved.entry.runId, + sessionKey: resolved.entry.childSessionKey, + error: "Steer rate limit exceeded. Wait a moment before sending another steer.", + }); + } + steerRateLimit.set(rateKey, now); + + // Suppress announce for the interrupted run before aborting so we don't + // emit stale pre-steer findings if the run exits immediately. + markSubagentRunForSteerRestart(resolved.entry.runId); + + const targetSession = resolveSessionEntryForKey({ + cfg, + key: resolved.entry.childSessionKey, + cache: new Map>(), + }); + const sessionId = + typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim() + ? targetSession.entry.sessionId.trim() + : undefined; + + // Interrupt current work first so steer takes precedence immediately. + if (sessionId) { + abortEmbeddedPiRun(sessionId); + } + const cleared = clearSessionQueues([resolved.entry.childSessionKey, sessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents tool steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + + // Best effort: wait for the interrupted run to settle so the steer + // message appends onto the existing conversation context. + try { + await callGateway({ + method: "agent.wait", + params: { + runId: resolved.entry.runId, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, + }, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, + }); + } catch { + // Continue even if wait fails; steer should still be attempted. + } + + const idempotencyKey = crypto.randomUUID(); + let runId: string = idempotencyKey; + try { + const response = await callGateway<{ runId: string }>({ + method: "agent", + params: { + message, + sessionKey: resolved.entry.childSessionKey, + sessionId, + idempotencyKey, + deliver: false, + channel: INTERNAL_MESSAGE_CHANNEL, + lane: AGENT_LANE_SUBAGENT, + timeout: 0, + }, + timeoutMs: 10_000, + }); + if (typeof response?.runId === "string" && response.runId) { + runId = response.runId; + } + } catch (err) { + // Replacement launch failed; restore normal announce behavior for the + // original run so completion is not silently suppressed. + clearSubagentRunSteerRestart(resolved.entry.runId); + const error = err instanceof Error ? err.message : String(err); + return jsonResult({ + status: "error", + action: "steer", + target, + runId, + sessionKey: resolved.entry.childSessionKey, + sessionId, + error, + }); + } + + replaceSubagentRunAfterSteer({ + previousRunId: resolved.entry.runId, + nextRunId: runId, + fallback: resolved.entry, + runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0, + }); + + return jsonResult({ + status: "accepted", + action: "steer", + target, + runId, + sessionKey: resolved.entry.childSessionKey, + sessionId, + mode: "restart", + label: resolveRunLabel(resolved.entry), + text: `steered ${resolveRunLabel(resolved.entry)}.`, + }); + } + return jsonResult({ + status: "error", + error: "Unsupported action.", + }); + }, + }; +} diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index e058e3fd30..bf5f33992a 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { isSubagentSessionKey } from "../routing/session-key.js"; +import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { resolveWorkspaceTemplateDir } from "./workspace-templates.js"; @@ -453,16 +453,16 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name)); + return files.filter((file) => MINIMAL_BOOTSTRAP_ALLOWLIST.has(file.name)); } export async function loadExtraBootstrapFiles( diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 9a8c02cfa5..a799d1358f 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -249,15 +249,15 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "subagents", nativeName: "subagents", - description: "List/stop/log/info subagent runs for this session.", + description: "List, kill, log, or steer subagent runs for this session.", textAlias: "/subagents", category: "management", args: [ { name: "action", - description: "list | stop | log | info | send", + description: "list | kill | log | info | send | steer", type: "string", - choices: ["list", "stop", "log", "info", "send"], + choices: ["list", "kill", "log", "info", "send", "steer"], }, { name: "target", @@ -273,6 +273,41 @@ function buildChatCommands(): ChatCommandDefinition[] { ], argsMenu: "auto", }), + defineChatCommand({ + key: "kill", + nativeName: "kill", + description: "Kill a running subagent (or all).", + textAlias: "/kill", + category: "management", + args: [ + { + name: "target", + description: "Label, run id, index, or all", + type: "string", + }, + ], + argsMenu: "auto", + }), + defineChatCommand({ + key: "steer", + nativeName: "steer", + description: "Send guidance to a running subagent.", + textAlias: "/steer", + category: "management", + args: [ + { + name: "target", + description: "Label, run id, or index", + type: "string", + }, + { + name: "message", + description: "Steering message", + type: "string", + captureRemaining: true, + }, + ], + }), defineChatCommand({ key: "config", nativeName: "config", @@ -582,6 +617,7 @@ function buildChatCommands(): ChatCommandDefinition[] { registerAlias(commands, "verbose", "/v"); registerAlias(commands, "reasoning", "/reason"); registerAlias(commands, "elevated", "/elev"); + registerAlias(commands, "steer", "/tell"); assertCommandRegistry(commands); return commands; diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 984b6e8aac..76b0889e8c 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -28,10 +28,12 @@ vi.mock("../../process/command-queue.js", () => commandQueueMocks); const subagentRegistryMocks = vi.hoisted(() => ({ listSubagentRunsForRequester: vi.fn(() => []), + markSubagentRunTerminated: vi.fn(() => 1), })); vi.mock("../../agents/subagent-registry.js", () => ({ listSubagentRunsForRequester: subagentRegistryMocks.listSubagentRunsForRequester, + markSubagentRunTerminated: subagentRegistryMocks.markSubagentRunTerminated, })); describe("abort detection", () => { @@ -233,4 +235,168 @@ describe("abort detection", () => { expect(result.stoppedSubagents).toBe(1); expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${childKey}`); }); + + it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); + const storePath = path.join(root, "sessions.json"); + const cfg = { session: { store: storePath } } as OpenClawConfig; + const sessionKey = "telegram:parent"; + const depth1Key = "agent:main:subagent:child-1"; + const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1"; + const sessionId = "session-parent"; + const depth1SessionId = "session-child"; + const depth2SessionId = "session-grandchild"; + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId, + updatedAt: Date.now(), + }, + [depth1Key]: { + sessionId: depth1SessionId, + updatedAt: Date.now(), + }, + [depth2Key]: { + sessionId: depth2SessionId, + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + ); + + // First call: main session lists depth-1 children + // Second call (cascade): depth-1 session lists depth-2 children + // Third call (cascade from depth-2): no further children + subagentRegistryMocks.listSubagentRunsForRequester + .mockReturnValueOnce([ + { + runId: "run-1", + childSessionKey: depth1Key, + requesterSessionKey: sessionKey, + requesterDisplayKey: "telegram:parent", + task: "orchestrator", + cleanup: "keep", + createdAt: Date.now(), + }, + ]) + .mockReturnValueOnce([ + { + runId: "run-2", + childSessionKey: depth2Key, + requesterSessionKey: depth1Key, + requesterDisplayKey: depth1Key, + task: "leaf worker", + cleanup: "keep", + createdAt: Date.now(), + }, + ]) + .mockReturnValueOnce([]); + + const result = await tryFastAbortFromMessage({ + ctx: buildTestCtx({ + CommandBody: "/stop", + RawBody: "/stop", + CommandAuthorized: true, + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + From: "telegram:parent", + To: "telegram:parent", + }), + cfg, + }); + + // Should stop both depth-1 and depth-2 agents (cascade) + expect(result.stoppedSubagents).toBe(2); + expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth1Key}`); + expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`); + }); + + it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => { + subagentRegistryMocks.listSubagentRunsForRequester.mockReset(); + subagentRegistryMocks.markSubagentRunTerminated.mockClear(); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); + const storePath = path.join(root, "sessions.json"); + const cfg = { session: { store: storePath } } as OpenClawConfig; + const sessionKey = "telegram:parent"; + const depth1Key = "agent:main:subagent:child-ended"; + const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active"; + const now = Date.now(); + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "session-parent", + updatedAt: now, + }, + [depth1Key]: { + sessionId: "session-child-ended", + updatedAt: now, + }, + [depth2Key]: { + sessionId: "session-grandchild-active", + updatedAt: now, + }, + }, + null, + 2, + ), + ); + + // main -> ended depth-1 parent + // depth-1 parent -> active depth-2 child + // depth-2 child -> none + subagentRegistryMocks.listSubagentRunsForRequester + .mockReturnValueOnce([ + { + runId: "run-1", + childSessionKey: depth1Key, + requesterSessionKey: sessionKey, + requesterDisplayKey: "telegram:parent", + task: "orchestrator", + cleanup: "keep", + createdAt: now - 1_000, + endedAt: now - 500, + outcome: { status: "ok" }, + }, + ]) + .mockReturnValueOnce([ + { + runId: "run-2", + childSessionKey: depth2Key, + requesterSessionKey: depth1Key, + requesterDisplayKey: depth1Key, + task: "leaf worker", + cleanup: "keep", + createdAt: now - 500, + }, + ]) + .mockReturnValueOnce([]); + + const result = await tryFastAbortFromMessage({ + ctx: buildTestCtx({ + CommandBody: "/stop", + RawBody: "/stop", + CommandAuthorized: true, + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + From: "telegram:parent", + To: "telegram:parent", + }), + cfg, + }); + + // Should skip killing the ended depth-1 run itself, but still kill depth-2. + expect(result.stoppedSubagents).toBe(1); + expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`); + expect(subagentRegistryMocks.markSubagentRunTerminated).toHaveBeenCalledWith( + expect.objectContaining({ runId: "run-2", childSessionKey: depth2Key }), + ); + }); }); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index de2583c9d3..f2b4e8bc70 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -2,7 +2,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; -import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js"; +import { + listSubagentRunsForRequester, + markSubagentRunTerminated, +} from "../../agents/subagent-registry.js"; import { resolveInternalSessionKey, resolveMainSessionAlias, @@ -141,30 +144,42 @@ export function stopSubagentsForRequester(params: { let stopped = 0; for (const run of runs) { - if (run.endedAt) { - continue; - } const childKey = run.childSessionKey?.trim(); if (!childKey || seenChildKeys.has(childKey)) { continue; } seenChildKeys.add(childKey); - const cleared = clearSessionQueues([childKey]); - const parsed = parseAgentSessionKey(childKey); - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); - let store = storeCache.get(storePath); - if (!store) { - store = loadSessionStore(storePath); - storeCache.set(storePath, store); - } - const entry = store[childKey]; - const sessionId = entry?.sessionId; - const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; + if (!run.endedAt) { + const cleared = clearSessionQueues([childKey]); + const parsed = parseAgentSessionKey(childKey); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); + let store = storeCache.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + storeCache.set(storePath, store); + } + const entry = store[childKey]; + const sessionId = entry?.sessionId; + const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; + const markedTerminated = + markSubagentRunTerminated({ + runId: run.runId, + childSessionKey: childKey, + reason: "killed", + }) > 0; - if (aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0) { - stopped += 1; + if (markedTerminated || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0) { + stopped += 1; + } } + + // Cascade: also stop any sub-sub-agents spawned by this child. + const cascadeResult = stopSubagentsForRequester({ + cfg: params.cfg, + requesterSessionKey: childKey, + }); + stopped += cascadeResult.stopped; } if (stopped > 0) { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 488d770a79..482a2d3efb 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -356,8 +356,7 @@ export async function runAgentTurnWithFallback(params: { // Track auto-compaction completion if (evt.stream === "compaction") { const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; - const willRetry = Boolean(evt.data.willRetry); - if (phase === "end" && !willRetry) { + if (phase === "end") { autoCompactionCompleted = true; } } diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index f73c5c60dd..22c489c535 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -153,8 +153,7 @@ export async function runMemoryFlushIfNeeded(params: { onAgentEvent: (evt) => { if (evt.stream === "compaction") { const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; - const willRetry = Boolean(evt.data.willRetry); - if (phase === "end" && !willRetry) { + if (phase === "end") { memoryCompactionCompleted = true; } } diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 3830805598..8603a11c48 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -3,7 +3,13 @@ import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; import type { CommandHandler } from "./commands-types.js"; import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js"; import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; -import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js"; +import { + clearSubagentRunSteerRestart, + listSubagentRunsForRequester, + markSubagentRunTerminated, + markSubagentRunForSteerRestart, + replaceSubagentRunAfterSteer, +} from "../../agents/subagent-registry.js"; import { extractAssistantText, resolveInternalSessionKey, @@ -11,10 +17,14 @@ import { sanitizeTextContent, stripToolMessages, } from "../../agents/tools/sessions-helpers.js"; -import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; +import { + type SessionEntry, + loadSessionStore, + resolveStorePath, + updateSessionStore, +} from "../../config/sessions.js"; import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; -import { formatDurationCompact } from "../../infra/format-time/format-duration.ts"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; @@ -28,7 +38,163 @@ type SubagentTargetResolution = { }; const COMMAND = "/subagents"; -const ACTIONS = new Set(["list", "stop", "log", "send", "info", "help"]); +const COMMAND_KILL = "/kill"; +const COMMAND_STEER = "/steer"; +const COMMAND_TELL = "/tell"; +const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "help"]); +const RECENT_WINDOW_MINUTES = 30; +const SUBAGENT_TASK_PREVIEW_MAX = 110; +const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; + +function formatDurationCompact(valueMs?: number) { + if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { + return "n/a"; + } + const minutes = Math.max(1, Math.round(valueMs / 60_000)); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const minutesRemainder = minutes % 60; + if (hours < 24) { + return minutesRemainder > 0 ? `${hours}h${minutesRemainder}m` : `${hours}h`; + } + const days = Math.floor(hours / 24); + const hoursRemainder = hours % 24; + return hoursRemainder > 0 ? `${days}d${hoursRemainder}h` : `${days}d`; +} + +function formatTokenShort(value?: number) { + if (!value || !Number.isFinite(value) || value <= 0) { + return undefined; + } + const n = Math.floor(value); + if (n < 1_000) { + return `${n}`; + } + if (n < 10_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + if (n < 1_000_000) { + return `${Math.round(n / 1_000)}k`; + } + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; +} + +function truncateLine(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength).trimEnd()}...`; +} + +function compactLine(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function formatTaskPreview(value: string) { + return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX); +} + +function resolveModelDisplay( + entry?: { + model?: unknown; + modelProvider?: unknown; + modelOverride?: unknown; + providerOverride?: unknown; + }, + fallbackModel?: string, +) { + const model = typeof entry?.model === "string" ? entry.model.trim() : ""; + const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; + let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model; + if (!combined) { + // Fall back to override fields which are populated at spawn time, + // before the first run completes and writes model/modelProvider. + const overrideModel = + typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; + const overrideProvider = + typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; + combined = overrideModel.includes("/") + ? overrideModel + : overrideModel && overrideProvider + ? `${overrideProvider}/${overrideModel}` + : overrideModel; + } + if (!combined) { + combined = fallbackModel?.trim() || ""; + } + if (!combined) { + return "model n/a"; + } + const slash = combined.lastIndexOf("/"); + if (slash >= 0 && slash < combined.length - 1) { + return combined.slice(slash + 1); + } + return combined; +} + +function resolveTotalTokens(entry?: { + totalTokens?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; +}) { + if (!entry || typeof entry !== "object") { + return undefined; + } + if (typeof entry.totalTokens === "number" && Number.isFinite(entry.totalTokens)) { + return entry.totalTokens; + } + const input = typeof entry.inputTokens === "number" ? entry.inputTokens : 0; + const output = typeof entry.outputTokens === "number" ? entry.outputTokens : 0; + const total = input + output; + return total > 0 ? total : undefined; +} + +function resolveIoTokens(entry?: { inputTokens?: unknown; outputTokens?: unknown }) { + if (!entry || typeof entry !== "object") { + return undefined; + } + const input = + typeof entry.inputTokens === "number" && Number.isFinite(entry.inputTokens) + ? entry.inputTokens + : 0; + const output = + typeof entry.outputTokens === "number" && Number.isFinite(entry.outputTokens) + ? entry.outputTokens + : 0; + const total = input + output; + if (total <= 0) { + return undefined; + } + return { input, output, total }; +} + +function resolveUsageDisplay(entry?: { + totalTokens?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; +}) { + const io = resolveIoTokens(entry); + const promptCache = resolveTotalTokens(entry); + const parts: string[] = []; + if (io) { + const input = formatTokenShort(io.input) ?? "0"; + const output = formatTokenShort(io.output) ?? "0"; + parts.push(`tokens ${formatTokenShort(io.total)} (in ${input} / out ${output})`); + } else if (typeof promptCache === "number" && promptCache > 0) { + parts.push(`tokens ${formatTokenShort(promptCache)} prompt/cache`); + } + if (typeof promptCache === "number" && io && promptCache > io.total) { + parts.push(`prompt/cache ${formatTokenShort(promptCache)}`); + } + return parts.join(", "); +} + +function resolveDisplayStatus(entry: SubagentRunRecord) { + const status = formatRunStatus(entry); + return status === "error" ? "failed" : status; +} function formatTimestamp(valueMs?: number) { if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { @@ -66,17 +232,39 @@ function resolveSubagentTarget( return { entry: sorted[0] }; } const sorted = sortSubagentRuns(runs); + const recentCutoff = Date.now() - RECENT_WINDOW_MINUTES * 60_000; + const numericOrder = [ + ...sorted.filter((entry) => !entry.endedAt), + ...sorted.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff), + ]; if (/^\d+$/.test(trimmed)) { const idx = Number.parseInt(trimmed, 10); - if (!Number.isFinite(idx) || idx <= 0 || idx > sorted.length) { + if (!Number.isFinite(idx) || idx <= 0 || idx > numericOrder.length) { return { error: `Invalid subagent index: ${trimmed}` }; } - return { entry: sorted[idx - 1] }; + return { entry: numericOrder[idx - 1] }; } if (trimmed.includes(":")) { const match = runs.find((entry) => entry.childSessionKey === trimmed); return match ? { entry: match } : { error: `Unknown subagent session: ${trimmed}` }; } + const lowered = trimmed.toLowerCase(); + const byLabel = runs.filter((entry) => formatRunLabel(entry).toLowerCase() === lowered); + if (byLabel.length === 1) { + return { entry: byLabel[0] }; + } + if (byLabel.length > 1) { + return { error: `Ambiguous subagent label: ${trimmed}` }; + } + const byLabelPrefix = runs.filter((entry) => + formatRunLabel(entry).toLowerCase().startsWith(lowered), + ); + if (byLabelPrefix.length === 1) { + return { entry: byLabelPrefix[0] }; + } + if (byLabelPrefix.length > 1) { + return { error: `Ambiguous subagent label prefix: ${trimmed}` }; + } const byRunId = runs.filter((entry) => entry.runId.startsWith(trimmed)); if (byRunId.length === 1) { return { entry: byRunId[0] }; @@ -89,15 +277,19 @@ function resolveSubagentTarget( function buildSubagentsHelp() { return [ - "🧭 Subagents", + "Subagents", "Usage:", "- /subagents list", - "- /subagents stop ", + "- /subagents kill ", "- /subagents log [limit] [tools]", "- /subagents info ", "- /subagents send ", + "- /subagents steer ", + "- /kill ", + "- /steer ", + "- /tell ", "", - "Ids: use the list index (#), runId prefix, or full session key.", + "Ids: use the list index (#), runId/session prefix, label, or full session key.", ].join("\n"); } @@ -158,10 +350,20 @@ function formatLogLines(messages: ChatMessage[]) { return lines; } -function loadSubagentSessionEntry(params: Parameters[0], childKey: string) { +type SessionStoreCache = Map>; + +function loadSubagentSessionEntry( + params: Parameters[0], + childKey: string, + storeCache?: SessionStoreCache, +) { const parsed = parseAgentSessionKey(childKey); const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); - const store = loadSessionStore(storePath); + let store = storeCache?.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + storeCache?.set(storePath, store); + } return { storePath, store, entry: store[childKey] }; } @@ -170,21 +372,39 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo return null; } const normalized = params.command.commandBodyNormalized; - if (!normalized.startsWith(COMMAND)) { + const handledPrefix = normalized.startsWith(COMMAND) + ? COMMAND + : normalized.startsWith(COMMAND_KILL) + ? COMMAND_KILL + : normalized.startsWith(COMMAND_STEER) + ? COMMAND_STEER + : normalized.startsWith(COMMAND_TELL) + ? COMMAND_TELL + : null; + if (!handledPrefix) { return null; } if (!params.command.isAuthorizedSender) { logVerbose( - `Ignoring /subagents from unauthorized sender: ${params.command.senderId || ""}`, + `Ignoring ${handledPrefix} from unauthorized sender: ${params.command.senderId || ""}`, ); return { shouldContinue: false }; } - const rest = normalized.slice(COMMAND.length).trim(); - const [actionRaw, ...restTokens] = rest.split(/\s+/).filter(Boolean); - const action = actionRaw?.toLowerCase() || "list"; - if (!ACTIONS.has(action)) { - return { shouldContinue: false, reply: { text: buildSubagentsHelp() } }; + const rest = normalized.slice(handledPrefix.length).trim(); + const restTokens = rest.split(/\s+/).filter(Boolean); + let action = "list"; + if (handledPrefix === COMMAND) { + const [actionRaw] = restTokens; + action = actionRaw?.toLowerCase() || "list"; + if (!ACTIONS.has(action)) { + return { shouldContinue: false, reply: { text: buildSubagentsHelp() } }; + } + restTokens.splice(0, 1); + } else if (handledPrefix === COMMAND_KILL) { + action = "kill"; + } else { + action = "steer"; } const requesterKey = resolveRequesterSessionKey(params); @@ -198,43 +418,82 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo } if (action === "list") { - if (runs.length === 0) { - return { shouldContinue: false, reply: { text: "🧭 Subagents: none for this session." } }; - } const sorted = sortSubagentRuns(runs); - const active = sorted.filter((entry) => !entry.endedAt); - const done = sorted.length - active.length; - const lines = ["🧭 Subagents (current session)", `Active: ${active.length} · Done: ${done}`]; - sorted.forEach((entry, index) => { - const status = formatRunStatus(entry); - const label = formatRunLabel(entry); - const runtime = - entry.endedAt && entry.startedAt - ? (formatDurationCompact(entry.endedAt - entry.startedAt) ?? "n/a") - : formatTimeAgo(Date.now() - (entry.startedAt ?? entry.createdAt), { fallback: "n/a" }); - const runId = entry.runId.slice(0, 8); - lines.push( - `${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`, - ); - }); + const now = Date.now(); + const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000; + const storeCache: SessionStoreCache = new Map(); + let index = 1; + const activeLines = sorted + .filter((entry) => !entry.endedAt) + .map((entry) => { + const { entry: sessionEntry } = loadSubagentSessionEntry( + params, + entry.childSessionKey, + storeCache, + ); + const usageText = resolveUsageDisplay(sessionEntry); + const label = truncateLine(formatRunLabel(entry, { maxLength: 48 }), 48); + const task = formatTaskPreview(entry.task); + const runtime = formatDurationCompact(now - (entry.startedAt ?? entry.createdAt)); + const status = resolveDisplayStatus(entry); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + index += 1; + return line; + }); + const recentLines = sorted + .filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff) + .map((entry) => { + const { entry: sessionEntry } = loadSubagentSessionEntry( + params, + entry.childSessionKey, + storeCache, + ); + const usageText = resolveUsageDisplay(sessionEntry); + const label = truncateLine(formatRunLabel(entry, { maxLength: 48 }), 48); + const task = formatTaskPreview(entry.task); + const runtime = formatDurationCompact( + (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), + ); + const status = resolveDisplayStatus(entry); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + index += 1; + return line; + }); + + const lines = ["active subagents:", "-----"]; + if (activeLines.length === 0) { + lines.push("(none)"); + } else { + lines.push(activeLines.join("\n")); + } + lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----"); + if (recentLines.length === 0) { + lines.push("(none)"); + } else { + lines.push(recentLines.join("\n")); + } return { shouldContinue: false, reply: { text: lines.join("\n") } }; } - if (action === "stop") { + if (action === "kill") { const target = restTokens[0]; if (!target) { - return { shouldContinue: false, reply: { text: "⚙️ Usage: /subagents stop " } }; + return { + shouldContinue: false, + reply: { + text: + handledPrefix === COMMAND + ? "Usage: /subagents kill " + : "Usage: /kill ", + }, + }; } if (target === "all" || target === "*") { - const { stopped } = stopSubagentsForRequester({ + stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: requesterKey, }); - const label = stopped === 1 ? "subagent" : "subagents"; - return { - shouldContinue: false, - reply: { text: `⚙️ Stopped ${stopped} ${label}.` }, - }; + return { shouldContinue: false }; } const resolved = resolveSubagentTarget(runs, target); if (!resolved.entry) { @@ -246,7 +505,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo if (resolved.entry.endedAt) { return { shouldContinue: false, - reply: { text: "⚙️ Subagent already finished." }, + reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` }, }; } @@ -259,7 +518,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo const cleared = clearSessionQueues([childKey, sessionId]); if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { logVerbose( - `subagents stop: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + `subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, ); } if (entry) { @@ -270,10 +529,17 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo nextStore[childKey] = entry; }); } - return { - shouldContinue: false, - reply: { text: `⚙️ Stop requested for ${formatRunLabel(resolved.entry)}.` }, - }; + markSubagentRunTerminated({ + runId: resolved.entry.runId, + childSessionKey: childKey, + reason: "killed", + }); + // Cascade: also stop any sub-sub-agents spawned by this child. + stopSubagentsForRequester({ + cfg: params.cfg, + requesterSessionKey: childKey, + }); + return { shouldContinue: false }; } if (action === "info") { @@ -299,7 +565,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo : "n/a"; const lines = [ "ℹ️ Subagent info", - `Status: ${formatRunStatus(run)}`, + `Status: ${resolveDisplayStatus(run)}`, `Label: ${formatRunLabel(run)}`, `Task: ${run.task}`, `Run: ${run.runId}`, @@ -347,13 +613,20 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo return { shouldContinue: false, reply: { text: [header, ...lines].join("\n") } }; } - if (action === "send") { + if (action === "send" || action === "steer") { + const steerRequested = action === "steer"; const target = restTokens[0]; const message = restTokens.slice(1).join(" ").trim(); if (!target || !message) { return { shouldContinue: false, - reply: { text: "✉️ Usage: /subagents send " }, + reply: { + text: steerRequested + ? handledPrefix === COMMAND + ? "Usage: /subagents steer " + : `Usage: ${handledPrefix} ` + : "Usage: /subagents send ", + }, }; } const resolved = resolveSubagentTarget(runs, target); @@ -363,6 +636,52 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` }, }; } + if (steerRequested && resolved.entry.endedAt) { + return { + shouldContinue: false, + reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` }, + }; + } + const { entry: targetSessionEntry } = loadSubagentSessionEntry( + params, + resolved.entry.childSessionKey, + ); + const targetSessionId = + typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim() + ? targetSessionEntry.sessionId.trim() + : undefined; + + if (steerRequested) { + // Suppress stale announce before interrupting the in-flight run. + markSubagentRunForSteerRestart(resolved.entry.runId); + + // Force an immediate interruption and make steer the next run. + if (targetSessionId) { + abortEmbeddedPiRun(targetSessionId); + } + const cleared = clearSessionQueues([resolved.entry.childSessionKey, targetSessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + + // Best effort: wait for the interrupted run to settle so the steer + // message is appended on the existing conversation state. + try { + await callGateway({ + method: "agent.wait", + params: { + runId: resolved.entry.runId, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, + }, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, + }); + } catch { + // Continue even if wait fails; steer should still be attempted. + } + } + const idempotencyKey = crypto.randomUUID(); let runId: string = idempotencyKey; try { @@ -371,10 +690,12 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo params: { message, sessionKey: resolved.entry.childSessionKey, + sessionId: targetSessionId, idempotencyKey, deliver: false, channel: INTERNAL_MESSAGE_CHANNEL, lane: AGENT_LANE_SUBAGENT, + timeout: 0, }, timeoutMs: 10_000, }); @@ -383,9 +704,29 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo runId = responseRunId; } } catch (err) { + if (steerRequested) { + // Replacement launch failed; restore announce behavior for the + // original run so completion is not silently suppressed. + clearSubagentRunSteerRestart(resolved.entry.runId); + } const messageText = err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return { shouldContinue: false, reply: { text: `⚠️ Send failed: ${messageText}` } }; + return { shouldContinue: false, reply: { text: `send failed: ${messageText}` } }; + } + + if (steerRequested) { + replaceSubagentRunAfterSteer({ + previousRunId: resolved.entry.runId, + nextRunId: runId, + fallback: resolved.entry, + runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0, + }); + return { + shouldContinue: false, + reply: { + text: `steered ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`, + }, + }; } const waitMs = 30_000; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index cef3e5149e..b98fbf4ecd 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -6,14 +6,22 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; import { addSubagentRunForTests, + listSubagentRunsForRequester, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; +import { updateSessionStore } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { resetBashChatCommandForTests } from "./bash-command.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; import { parseInlineDirectives } from "./directive-handling.js"; +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +import { buildCommandContext, handleCommands } from "./commands.js"; + // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => { @@ -256,6 +264,7 @@ describe("handleCommands context", () => { describe("handleCommands subagents", () => { it("lists subagents when none exist", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -263,11 +272,43 @@ describe("handleCommands subagents", () => { const params = buildParams("/subagents list", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Subagents: none"); + expect(result.reply?.text).toContain("active subagents:"); + expect(result.reply?.text).toContain("active subagents:\n-----\n"); + expect(result.reply?.text).toContain("recent subagents (last 30m):"); + expect(result.reply?.text).toContain("\n\nrecent subagents (last 30m):"); + expect(result.reply?.text).toContain("recent subagents (last 30m):\n-----\n"); + }); + + it("truncates long subagent task text in /subagents list", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + addSubagentRunForTests({ + runId: "run-long-task", + childSessionKey: "agent:main:subagent:long-task", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "This is a deliberately long task description used to verify that subagent list output keeps the full task text instead of appending ellipsis after a short hard cutoff.", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/subagents list", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain( + "This is a deliberately long task description used to verify that subagent list output keeps the full task text", + ); + expect(result.reply?.text).toContain("..."); + expect(result.reply?.text).not.toContain("after a short hard cutoff."); }); it("lists subagents for the current command session over the target session", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", @@ -278,6 +319,16 @@ describe("handleCommands subagents", () => { createdAt: 1000, startedAt: 1000, }); + addSubagentRunForTests({ + runId: "run-2", + childSessionKey: "agent:main:subagent:def", + requesterSessionKey: "agent:main:slack:slash:u1", + requesterDisplayKey: "agent:main:slack:slash:u1", + task: "another thing", + cleanup: "keep", + createdAt: 2000, + startedAt: 2000, + }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -289,8 +340,46 @@ describe("handleCommands subagents", () => { params.sessionKey = "agent:main:slack:slash:u1"; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Subagents (current session)"); - expect(result.reply?.text).toContain("agent:main:subagent:abc"); + expect(result.reply?.text).toContain("active subagents:"); + expect(result.reply?.text).toContain("do thing"); + expect(result.reply?.text).not.toContain("\n\n2."); + }); + + it("formats subagent usage with io and prompt/cache breakdown", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + addSubagentRunForTests({ + runId: "run-usage", + childSessionKey: "agent:main:subagent:usage", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + const storePath = path.join(testWorkspaceDir, "sessions-subagents-usage.json"); + await updateSessionStore(storePath, (store) => { + store["agent:main:subagent:usage"] = { + sessionId: "child-session-usage", + updatedAt: Date.now(), + inputTokens: 12, + outputTokens: 1000, + totalTokens: 197000, + model: "opencode/claude-opus-4-6", + }; + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + } as OpenClawConfig; + const params = buildParams("/subagents list", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("tokens 1k (in 12 / out 1k)"); + expect(result.reply?.text).toContain("prompt/cache 197k"); + expect(result.reply?.text).not.toContain("1k io"); }); it("omits subagent status line when none exist", async () => { @@ -309,6 +398,7 @@ describe("handleCommands subagents", () => { it("returns help for unknown subagents action", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -321,6 +411,7 @@ describe("handleCommands subagents", () => { it("returns usage for subagents info without target", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -333,6 +424,7 @@ describe("handleCommands subagents", () => { it("includes subagent count in /status when active", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", @@ -356,6 +448,7 @@ describe("handleCommands subagents", () => { it("includes subagent details in /status when verbose", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", @@ -393,6 +486,8 @@ describe("handleCommands subagents", () => { it("returns info for a subagent", async () => { resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const now = Date.now(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", @@ -400,9 +495,9 @@ describe("handleCommands subagents", () => { requesterDisplayKey: "main", task: "do thing", cleanup: "keep", - createdAt: 1000, - startedAt: 1000, - endedAt: 2000, + createdAt: now - 20_000, + startedAt: now - 20_000, + endedAt: now - 1_000, outcome: { status: "ok" }, }); const cfg = { @@ -417,6 +512,228 @@ describe("handleCommands subagents", () => { expect(result.reply?.text).toContain("Run: run-1"); expect(result.reply?.text).toContain("Status: done"); }); + + it("kills subagents via /kill alias without a confirmation reply", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/kill 1", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }); + + it("resolves numeric aliases in active-first display order", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const now = Date.now(); + addSubagentRunForTests({ + runId: "run-active", + childSessionKey: "agent:main:subagent:active", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "active task", + cleanup: "keep", + createdAt: now - 120_000, + startedAt: now - 120_000, + }); + addSubagentRunForTests({ + runId: "run-recent", + childSessionKey: "agent:main:subagent:recent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "recent task", + cleanup: "keep", + createdAt: now - 30_000, + startedAt: now - 30_000, + endedAt: now - 10_000, + outcome: { status: "ok" }, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/kill 1", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }); + + it("sends follow-up messages to finished subagents", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: { runId?: string } }; + if (request.method === "agent") { + return { runId: "run-followup-1" }; + } + if (request.method === "agent.wait") { + return { status: "done" }; + } + if (request.method === "chat.history") { + return { messages: [] }; + } + return {}; + }); + const now = Date.now(); + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: now - 20_000, + startedAt: now - 20_000, + endedAt: now - 1_000, + outcome: { status: "ok" }, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/subagents send 1 continue with follow-up details", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("✅ Sent to"); + + const agentCall = callGatewayMock.mock.calls.find( + (call) => (call[0] as { method?: string }).method === "agent", + ); + expect(agentCall?.[0]).toMatchObject({ + method: "agent", + params: { + lane: "subagent", + sessionKey: "agent:main:subagent:abc", + timeout: 0, + }, + }); + + const waitCall = callGatewayMock.mock.calls.find( + (call) => + (call[0] as { method?: string; params?: { runId?: string } }).method === "agent.wait" && + (call[0] as { method?: string; params?: { runId?: string } }).params?.runId === + "run-followup-1", + ); + expect(waitCall).toBeDefined(); + }); + + it("steers subagents via /steer alias", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent") { + return { runId: "run-steer-1" }; + } + return {}; + }); + const storePath = path.join(testWorkspaceDir, "sessions-subagents-steer.json"); + await updateSessionStore(storePath, (store) => { + store["agent:main:subagent:abc"] = { + sessionId: "child-session-steer", + updatedAt: Date.now(), + }; + }); + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + } as OpenClawConfig; + const params = buildParams("/steer 1 check timer.ts instead", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("steered"); + const steerWaitIndex = callGatewayMock.mock.calls.findIndex( + (call) => + (call[0] as { method?: string; params?: { runId?: string } }).method === "agent.wait" && + (call[0] as { method?: string; params?: { runId?: string } }).params?.runId === "run-1", + ); + expect(steerWaitIndex).toBeGreaterThanOrEqual(0); + const steerRunIndex = callGatewayMock.mock.calls.findIndex( + (call) => (call[0] as { method?: string }).method === "agent", + ); + expect(steerRunIndex).toBeGreaterThan(steerWaitIndex); + expect(callGatewayMock.mock.calls[steerWaitIndex]?.[0]).toMatchObject({ + method: "agent.wait", + params: { runId: "run-1", timeoutMs: 5_000 }, + timeoutMs: 7_000, + }); + expect(callGatewayMock.mock.calls[steerRunIndex]?.[0]).toMatchObject({ + method: "agent", + params: { + lane: "subagent", + sessionKey: "agent:main:subagent:abc", + sessionId: "child-session-steer", + timeout: 0, + }, + }); + const trackedRuns = listSubagentRunsForRequester("agent:main:main"); + expect(trackedRuns).toHaveLength(1); + expect(trackedRuns[0].runId).toBe("run-steer-1"); + expect(trackedRuns[0].endedAt).toBeUndefined(); + }); + + it("restores announce behavior when /steer replacement dispatch fails", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + if (request.method === "agent") { + throw new Error("dispatch failed"); + } + return {}; + }); + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/steer 1 check timer.ts instead", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("send failed: dispatch failed"); + + const trackedRuns = listSubagentRunsForRequester("agent:main:main"); + expect(trackedRuns).toHaveLength(1); + expect(trackedRuns[0].runId).toBe("run-1"); + expect(trackedRuns[0].suppressAnnounceReason).toBeUndefined(); + }); }); describe("handleCommands /tts", () => { diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 96d1b6016b..85a9d35c3d 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -81,7 +81,7 @@ describe("createFollowupRunner compaction", () => { }) => { params.onAgentEvent?.({ stream: "compaction", - data: { phase: "end", willRetry: false }, + data: { phase: "end", willRetry: true }, }); return { payloads: [{ text: "final" }], meta: {} }; }, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index cdc392369e..5ecb37043a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -176,8 +176,7 @@ export function createFollowupRunner(params: { return; } const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; - const willRetry = Boolean(evt.data.willRetry); - if (phase === "end" && !willRetry) { + if (phase === "end") { autoCompactionCompleted = true; } }, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 942146189f..f7edf2aa31 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -50,6 +50,7 @@ vi.mock("./body.js", () => ({ vi.mock("./groups.js", () => ({ buildGroupIntro: vi.fn().mockReturnValue(""), + buildGroupChatContext: vi.fn().mockReturnValue(""), })); vi.mock("./inbound-meta.js", () => ({ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 951ebbfbfc..66d64f5be7 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -39,7 +39,7 @@ import { import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { runReplyAgent } from "./agent-runner.js"; import { applySessionHints } from "./body.js"; -import { buildGroupIntro } from "./groups.js"; +import { buildGroupChatContext, buildGroupIntro } from "./groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; import { resolveQueueSettings } from "./queue.js"; import { routeReply } from "./route-reply.js"; @@ -171,6 +171,9 @@ export async function runPreparedReply( const shouldInjectGroupIntro = Boolean( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), ); + // Always include persistent group chat context (name, participants, reply guidance) + const groupChatContext = isGroupChat ? buildGroupChatContext({ sessionCtx }) : ""; + // Behavioral intro (activation mode, lurking, etc.) only on first turn / activation needed const groupIntro = shouldInjectGroupIntro ? buildGroupIntro({ cfg, @@ -184,7 +187,7 @@ export async function runPreparedReply( const inboundMetaPrompt = buildInboundMetaSystemPrompt( isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined }, ); - const extraSystemPrompt = [inboundMetaPrompt, groupIntro, groupSystemPrompt] + const extraSystemPrompt = [inboundMetaPrompt, groupChatContext, groupIntro, groupSystemPrompt] .filter(Boolean) .join("\n\n"); const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index d2b4702993..32818eb593 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -105,7 +105,7 @@ export async function getReplyFromConfig( }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); - const timeoutMs = resolveAgentTimeoutMs({ cfg }); + const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideSeconds: opts?.timeoutOverrideSeconds }); const configuredTypingSeconds = agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; const typingIntervalSeconds = diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 03b9f87bc4..a76c53c44b 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -59,6 +59,51 @@ export function defaultGroupActivation(requireMention: boolean): "always" | "men return !requireMention ? "always" : "mention"; } +/** + * Resolve a human-readable provider label from the raw provider string. + */ +function resolveProviderLabel(rawProvider: string | undefined): string { + const providerKey = rawProvider?.trim().toLowerCase() ?? ""; + if (!providerKey) { + return "chat"; + } + if (isInternalMessageChannel(providerKey)) { + return "WebChat"; + } + const providerId = normalizeChannelId(rawProvider?.trim()); + if (providerId) { + return getChannelPlugin(providerId)?.meta.label ?? providerId; + } + return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; +} + +/** + * Build a persistent group-chat context block that is always included in the + * system prompt for group-chat sessions (every turn, not just the first). + * + * Contains: group name, participants, and an explicit instruction to reply + * directly instead of using the message tool. + */ +export function buildGroupChatContext(params: { sessionCtx: TemplateContext }): string { + const subject = params.sessionCtx.GroupSubject?.trim(); + const members = params.sessionCtx.GroupMembers?.trim(); + const providerLabel = resolveProviderLabel(params.sessionCtx.Provider); + + const lines: string[] = []; + if (subject) { + lines.push(`You are in the ${providerLabel} group chat "${subject}".`); + } else { + lines.push(`You are in a ${providerLabel} group chat.`); + } + if (members) { + lines.push(`Participants: ${members}.`); + } + lines.push( + "Your replies are automatically sent to this group chat. Do not use the message tool to send to this same group — just reply normally.", + ); + return lines.join(" "); +} + export function buildGroupIntro(params: { cfg: OpenClawConfig; sessionCtx: TemplateContext; @@ -69,23 +114,7 @@ export function buildGroupIntro(params: { const activation = normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation; const rawProvider = params.sessionCtx.Provider?.trim(); - const providerKey = rawProvider?.toLowerCase() ?? ""; const providerId = normalizeChannelId(rawProvider); - const providerLabel = (() => { - if (!providerKey) { - return "chat"; - } - if (isInternalMessageChannel(providerKey)) { - return "WebChat"; - } - if (providerId) { - return getChannelPlugin(providerId)?.meta.label ?? providerId; - } - return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; - })(); - // Do not embed attacker-controlled labels (group subject, members) in system prompts. - // These labels are provided as user-role "untrusted context" blocks instead. - const subjectLine = `You are replying inside a ${providerLabel} group chat.`; const activationLine = activation === "always" ? "Activation: always-on (you receive every group message)." @@ -115,15 +144,7 @@ export function buildGroupIntro(params: { "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available."; const styleLine = "Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly."; - return [ - subjectLine, - activationLine, - providerIdsLine, - silenceLine, - cautionLine, - lurkLine, - styleLine, - ] + return [activationLine, providerIdsLine, silenceLine, cautionLine, lurkLine, styleLine] .filter(Boolean) .join(" ") .concat(" Address the specific sender noted in the message context."); diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 6993af45b8..29a51a8758 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -43,6 +43,8 @@ export type GetReplyOptions = { skillFilter?: string[]; /** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */ hasRepliedRef?: { value: boolean }; + /** Override agent timeout in seconds (0 = no timeout). Threads through to resolveAgentTimeoutMs. */ + timeoutOverrideSeconds?: number; }; export type ReplyPayload = { diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index f2de12bcb5..5bd0f73e48 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -26,6 +26,10 @@ vi.mock("../../infra/restart.js", () => ({ markGatewaySigusr1RestartHandled: () => markGatewaySigusr1RestartHandled(), })); +vi.mock("../../infra/process-respawn.js", () => ({ + restartGatewayProcessWithFreshPid: () => ({ mode: "skipped" }), +})); + vi.mock("../../process/command-queue.js", () => ({ getActiveTaskCount: () => getActiveTaskCount(), waitForActiveTasks: (timeoutMs: number) => waitForActiveTasks(timeoutMs), diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 7cd1902f57..84c2bbc326 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -1,6 +1,7 @@ import type { startGatewayServer } from "../../gateway/server.js"; import type { defaultRuntime } from "../../runtime.js"; import { acquireGatewayLock } from "../../infra/gateway-lock.js"; +import { restartGatewayProcessWithFreshPid } from "../../infra/process-respawn.js"; import { consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed, @@ -82,8 +83,26 @@ export async function runGatewayLoop(params: { clearTimeout(forceExitTimer); server = null; if (isRestart) { - shuttingDown = false; - restartResolver?.(); + const respawn = restartGatewayProcessWithFreshPid(); + if (respawn.mode === "spawned" || respawn.mode === "supervised") { + const modeLabel = + respawn.mode === "spawned" + ? `spawned pid ${respawn.pid ?? "unknown"}` + : "supervisor restart"; + gatewayLog.info(`restart mode: full process restart (${modeLabel})`); + cleanupSignals(); + params.runtime.exit(0); + } else { + if (respawn.mode === "failed") { + gatewayLog.warn( + `full process restart failed (${respawn.detail ?? "unknown error"}); falling back to in-process restart`, + ); + } else { + gatewayLog.info("restart mode: in-process restart (OPENCLAW_NO_RESPAWN)"); + } + shuttingDown = false; + restartResolver?.(); + } } else { cleanupSignals(); params.runtime.exit(0); diff --git a/src/commands/agent-via-gateway.e2e.test.ts b/src/commands/agent-via-gateway.e2e.test.ts index d0513b6ccb..2b364091ae 100644 --- a/src/commands/agent-via-gateway.e2e.test.ts +++ b/src/commands/agent-via-gateway.e2e.test.ts @@ -48,6 +48,31 @@ beforeEach(() => { }); describe("agentCliCommand", () => { + it("uses a timer-safe max gateway timeout when --timeout is 0", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(callGateway).mockResolvedValue({ + runId: "idem-1", + status: "ok", + result: { + payloads: [{ text: "hello" }], + meta: { stub: true }, + }, + }); + + try { + await agentCliCommand({ message: "hi", to: "+1555", timeout: "0" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = vi.mocked(callGateway).mock.calls[0]?.[0] as { timeoutMs?: number }; + expect(request.timeoutMs).toBe(2_147_000_000); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("uses gateway by default", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-")); const store = path.join(dir, "sessions.json"); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index ef1e8e97ba..a4a0d6e042 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -31,6 +31,8 @@ type GatewayAgentResponse = { result?: AgentGatewayResult; }; +const NO_GATEWAY_TIMEOUT_MS = 2_147_000_000; + export type AgentCliOpts = { message: string; agent?: string; @@ -57,8 +59,8 @@ function parseTimeoutSeconds(opts: { cfg: ReturnType; timeout opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) : (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600); - if (Number.isNaN(raw) || raw <= 0) { - throw new Error("--timeout must be a positive integer (seconds)"); + if (Number.isNaN(raw) || raw < 0) { + throw new Error("--timeout must be a non-negative integer (seconds; 0 means no timeout)"); } return raw; } @@ -104,7 +106,10 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim } } const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout }); - const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000); + const gatewayTimeoutMs = + timeoutSeconds === 0 + ? NO_GATEWAY_TIMEOUT_MS // no timeout (timer-safe max) + : Math.max(10_000, (timeoutSeconds + 30) * 1000); const sessionKey = resolveSessionKeyForRequest({ cfg, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index fb919cd3ae..b304725661 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -12,12 +12,14 @@ import { clearSessionAuthProfileOverride } from "../agents/auth-profiles/session import { runCliAgent } from "../agents/cli-runner.js"; import { getCliSessionId } from "../agents/cli-session.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { AGENT_LANE_SUBAGENT } from "../agents/lanes.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runWithModelFallback } from "../agents/model-fallback.js"; import { buildAllowedModelSet, isCliProvider, modelKey, + normalizeModelRef, resolveConfiguredModelRef, resolveThinkingDefault, } from "../agents/model-selection.js"; @@ -123,13 +125,19 @@ export async function agentCommand( throw new Error('Invalid verbose level. Use "on", "full", or "off".'); } + const laneRaw = typeof opts.lane === "string" ? opts.lane.trim() : ""; + const isSubagentLane = laneRaw === String(AGENT_LANE_SUBAGENT); const timeoutSecondsRaw = - opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) : undefined; + opts.timeout !== undefined + ? Number.parseInt(String(opts.timeout), 10) + : isSubagentLane + ? 0 + : undefined; if ( timeoutSecondsRaw !== undefined && - (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) + (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw < 0) ) { - throw new Error("--timeout must be a positive integer (seconds)"); + throw new Error("--timeout must be a non-negative integer (seconds; 0 means no timeout)"); } const timeoutMs = resolveAgentTimeoutMs({ cfg, @@ -250,11 +258,15 @@ export async function agentCommand( } : cfg; - const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({ + const configuredDefaultRef = resolveConfiguredModelRef({ cfg: cfgForModelSelection, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); + const { provider: defaultProvider, model: defaultModel } = normalizeModelRef( + configuredDefaultRef.provider, + configuredDefaultRef.model, + ); let provider = defaultProvider; let model = defaultModel; const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; @@ -283,9 +295,10 @@ export async function agentCommand( const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { - const key = modelKey(overrideProvider, overrideModel); + const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); + const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if ( - !isCliProvider(overrideProvider, cfg) && + !isCliProvider(normalizedOverride.provider, cfg) && allowedModelKeys.size > 0 && !allowedModelKeys.has(key) ) { @@ -307,14 +320,15 @@ export async function agentCommand( const storedModelOverride = sessionEntry?.modelOverride?.trim(); if (storedModelOverride) { const candidateProvider = storedProviderOverride || defaultProvider; - const key = modelKey(candidateProvider, storedModelOverride); + const normalizedStored = normalizeModelRef(candidateProvider, storedModelOverride); + const key = modelKey(normalizedStored.provider, normalizedStored.model); if ( - isCliProvider(candidateProvider, cfg) || + isCliProvider(normalizedStored.provider, cfg) || allowedModelKeys.size === 0 || allowedModelKeys.has(key) ) { - provider = candidateProvider; - model = storedModelOverride; + provider = normalizedStored.provider; + model = normalizedStored.model; } } if (sessionEntry) { @@ -382,13 +396,34 @@ export async function agentCommand( opts.replyChannel ?? opts.channel, ); const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy; + // When a session has an explicit model override, prevent the fallback logic + // from silently appending the global primary model as a backstop. Passing an + // empty array (instead of undefined) tells resolveFallbackCandidates to skip + // the implicit primary append, so the session stays on its overridden model. + const agentFallbacksOverride = resolveAgentModelFallbacksOverride(cfg, sessionAgentId); + const effectiveFallbacksOverride = storedModelOverride + ? (agentFallbacksOverride ?? []) + : agentFallbacksOverride; + + // Track model fallback attempts so retries on an existing session don't + // re-inject the original prompt as a duplicate user message. + let fallbackAttemptIndex = 0; const fallbackResult = await runWithModelFallback({ cfg, provider, model, agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId), + fallbacksOverride: effectiveFallbacksOverride, run: (providerOverride, modelOverride) => { + const isFallbackRetry = fallbackAttemptIndex > 0; + fallbackAttemptIndex += 1; + // On fallback retries the session file already contains the original + // prompt from the first attempt. Re-injecting the full prompt would + // create a duplicate user message. Use a short continuation hint + // instead so the model picks up where it left off. + const effectivePrompt = isFallbackRetry + ? "Continue where you left off. The previous model attempt failed or timed out." + : body; if (isCliProvider(providerOverride, cfg)) { const cliSessionId = getCliSessionId(sessionEntry, providerOverride); return runCliAgent({ @@ -398,7 +433,7 @@ export async function agentCommand( sessionFile, workspaceDir, config: cfg, - prompt: body, + prompt: effectivePrompt, provider: providerOverride, model: modelOverride, thinkLevel: resolvedThinkLevel, @@ -406,7 +441,7 @@ export async function agentCommand( runId, extraSystemPrompt: opts.extraSystemPrompt, cliSessionId, - images: opts.images, + images: isFallbackRetry ? undefined : opts.images, streamParams: opts.streamParams, }); } @@ -433,8 +468,8 @@ export async function agentCommand( workspaceDir, config: cfg, skillsSnapshot, - prompt: body, - images: opts.images, + prompt: effectivePrompt, + images: isFallbackRetry ? undefined : opts.images, clientTools: opts.clientTools, provider: providerOverride, model: modelOverride, diff --git a/src/config/config.agent-concurrency-defaults.test.ts b/src/config/config.agent-concurrency-defaults.test.ts index 9ad565a969..accedc42a2 100644 --- a/src/config/config.agent-concurrency-defaults.test.ts +++ b/src/config/config.agent-concurrency-defaults.test.ts @@ -9,6 +9,7 @@ import { } from "./agent-limits.js"; import { loadConfig } from "./config.js"; import { withTempHome } from "./test-helpers.js"; +import { OpenClawSchema } from "./zod-schema.js"; describe("agent concurrency defaults", () => { it("resolves defaults when unset", () => { @@ -42,6 +43,22 @@ describe("agent concurrency defaults", () => { expect(resolveSubagentMaxConcurrent(cfg)).toBe(1); }); + it("accepts subagent spawn depth and per-agent child limits", () => { + const parsed = OpenClawSchema.parse({ + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + maxChildrenPerAgent: 7, + }, + }, + }, + }); + + expect(parsed.agents?.defaults?.subagents?.maxSpawnDepth).toBe(2); + expect(parsed.agents?.defaults?.subagents?.maxChildrenPerAgent).toBe(7); + }); + it("injects defaults on load", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".openclaw"); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 0eabe9334c..1d0012a749 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -35,6 +35,8 @@ export type SessionEntry = { sessionFile?: string; /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ spawnedBy?: string; + /** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */ + spawnDepth?: number; systemSent?: boolean; abortedLastRun?: boolean; chatType?: SessionChatType; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 72d49e1b28..b58a803906 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -206,6 +206,10 @@ export type AgentDefaultsConfig = { subagents?: { /** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */ maxConcurrent?: number; + /** Maximum depth allowed for sessions_spawn chains. Default behavior: 1 (no nested spawns). */ + maxSpawnDepth?: number; + /** Maximum active children a single requester session may spawn. Default behavior: 5. */ + maxChildrenPerAgent?: number; /** Auto-archive sub-agent sessions after N minutes (default: 60). */ archiveAfterMinutes?: number; /** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 31dd1f672c..d655a797ad 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -141,6 +141,24 @@ export const AgentDefaultsSchema = z subagents: z .object({ maxConcurrent: z.number().int().positive().optional(), + maxSpawnDepth: z + .number() + .int() + .min(1) + .max(5) + .optional() + .describe( + "Maximum nesting depth for sub-agent spawning. 1 = no nesting (default), 2 = sub-agents can spawn sub-sub-agents.", + ), + maxChildrenPerAgent: z + .number() + .int() + .min(1) + .max(20) + .optional() + .describe( + "Maximum number of active children a single agent session can spawn (default: 5).", + ), archiveAfterMinutes: z.number().int().positive().optional(), model: z .union([ diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 95bd6145b0..c10e66b17e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -28,7 +28,12 @@ import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { runSubagentAnnounceFlow } from "../../agents/subagent-announce.js"; +import { + countActiveDescendantRuns, + listDescendantRunsForRequester, +} from "../../agents/subagent-registry.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { readLatestAssistantReply } from "../../agents/tools/agent-step.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js"; import { ensureAgentWorkspace } from "../../agents/workspace.js"; import { @@ -36,6 +41,7 @@ import { normalizeVerboseLevel, supportsXHighThinking, } from "../../auto-reply/thinking.js"; +import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js"; import { resolveAgentMainSessionKey, @@ -94,6 +100,152 @@ function resolveCronDeliveryBestEffort(job: CronJob): boolean { return false; } +const CRON_SUBAGENT_WAIT_POLL_MS = 500; +const CRON_SUBAGENT_WAIT_MIN_MS = 30_000; +const CRON_SUBAGENT_FINAL_REPLY_GRACE_MS = 5_000; + +function isLikelyInterimCronMessage(value: string): boolean { + const text = value.trim(); + if (!text) { + return true; + } + const normalized = text.toLowerCase().replace(/\s+/g, " "); + const words = normalized.split(" ").filter(Boolean).length; + const interimHints = [ + "on it", + "pulling everything together", + "give me a few", + "give me a few min", + "few minutes", + "let me compile", + "i'll gather", + "i will gather", + "working on it", + "retrying now", + "should be about", + "should have your summary", + "subagent spawned", + "spawned a subagent", + "it'll auto-announce when done", + "it will auto-announce when done", + "auto-announce when done", + "both subagents are running", + "wait for them to report back", + ]; + return words <= 45 && interimHints.some((hint) => normalized.includes(hint)); +} + +function expectsSubagentFollowup(value: string): boolean { + const normalized = value.trim().toLowerCase().replace(/\s+/g, " "); + if (!normalized) { + return false; + } + const hints = [ + "subagent spawned", + "spawned a subagent", + "auto-announce when done", + "both subagents are running", + "wait for them to report back", + ]; + return hints.some((hint) => normalized.includes(hint)); +} + +async function readDescendantSubagentFallbackReply(params: { + sessionKey: string; + runStartedAt: number; +}): Promise { + const descendants = listDescendantRunsForRequester(params.sessionKey) + .filter( + (entry) => + typeof entry.endedAt === "number" && + entry.endedAt >= params.runStartedAt && + entry.childSessionKey.trim().length > 0, + ) + .toSorted((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0)); + if (descendants.length === 0) { + return undefined; + } + + const latestByChild = new Map(); + for (const entry of descendants) { + const childKey = entry.childSessionKey.trim(); + if (!childKey) { + continue; + } + const current = latestByChild.get(childKey); + if (!current || (entry.endedAt ?? 0) >= (current.endedAt ?? 0)) { + latestByChild.set(childKey, entry); + } + } + + const replies: string[] = []; + const latestRuns = [...latestByChild.values()] + .toSorted((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0)) + .slice(-4); + for (const entry of latestRuns) { + const reply = (await readLatestAssistantReply({ sessionKey: entry.childSessionKey }))?.trim(); + if (!reply || reply.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { + continue; + } + replies.push(reply); + } + if (replies.length === 0) { + return undefined; + } + if (replies.length === 1) { + return replies[0]; + } + return replies.join("\n\n"); +} + +async function waitForDescendantSubagentSummary(params: { + sessionKey: string; + initialReply?: string; + timeoutMs: number; + observedActiveDescendants?: boolean; +}): Promise { + const initialReply = params.initialReply?.trim(); + const deadline = Date.now() + Math.max(CRON_SUBAGENT_WAIT_MIN_MS, Math.floor(params.timeoutMs)); + let sawActiveDescendants = params.observedActiveDescendants === true; + let drainedAtMs: number | undefined; + while (Date.now() < deadline) { + const activeDescendants = countActiveDescendantRuns(params.sessionKey); + if (activeDescendants > 0) { + sawActiveDescendants = true; + drainedAtMs = undefined; + await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_WAIT_POLL_MS)); + continue; + } + if (!sawActiveDescendants) { + return initialReply; + } + if (!drainedAtMs) { + drainedAtMs = Date.now(); + } + const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); + if ( + latest && + latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && + (latest !== initialReply || !isLikelyInterimCronMessage(latest)) + ) { + return latest; + } + if (Date.now() - drainedAtMs >= CRON_SUBAGENT_FINAL_REPLY_GRACE_MS) { + return undefined; + } + await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_WAIT_POLL_MS)); + } + const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); + if ( + latest && + latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && + (latest !== initialReply || !isLikelyInterimCronMessage(latest)) + ) { + return latest; + } + return undefined; +} + export type RunCronAgentTurnResult = { status: "ok" | "error" | "skipped"; summary?: string; @@ -507,11 +659,11 @@ export async function runCronIsolatedAgentTurn(params: { await persistSessionEntry(); } const firstText = payloads[0]?.text ?? ""; - const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText); - const outputText = pickLastNonEmptyTextFromPayloads(payloads); - const synthesizedText = outputText?.trim() || summary?.trim() || undefined; + let summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText); + let outputText = pickLastNonEmptyTextFromPayloads(payloads); + let synthesizedText = outputText?.trim() || summary?.trim() || undefined; const deliveryPayload = pickLastDeliverablePayload(payloads); - const deliveryPayloads = + let deliveryPayloads = deliveryPayload !== undefined ? [deliveryPayload] : synthesizedText @@ -609,9 +761,56 @@ export async function runCronIsolatedAgentTurn(params: { typeof params.job.name === "string" && params.job.name.trim() ? params.job.name.trim() : `cron:${params.job.id}`; + const initialSynthesizedText = synthesizedText.trim(); + let activeSubagentRuns = countActiveDescendantRuns(agentSessionKey); + const expectedSubagentFollowup = expectsSubagentFollowup(initialSynthesizedText); + const hadActiveDescendants = activeSubagentRuns > 0; + if (activeSubagentRuns > 0 || expectedSubagentFollowup) { + let finalReply = await waitForDescendantSubagentSummary({ + sessionKey: agentSessionKey, + initialReply: initialSynthesizedText, + timeoutMs, + observedActiveDescendants: activeSubagentRuns > 0 || expectedSubagentFollowup, + }); + activeSubagentRuns = countActiveDescendantRuns(agentSessionKey); + if ( + !finalReply && + activeSubagentRuns === 0 && + (hadActiveDescendants || expectedSubagentFollowup) + ) { + finalReply = await readDescendantSubagentFallbackReply({ + sessionKey: agentSessionKey, + runStartedAt, + }); + } + if (finalReply && activeSubagentRuns === 0) { + outputText = finalReply; + summary = pickSummaryFromOutput(finalReply) ?? summary; + synthesizedText = finalReply; + deliveryPayloads = [{ text: finalReply }]; + } + } + if (activeSubagentRuns > 0) { + // Parent orchestration is still in progress; avoid announcing a partial + // update to the main requester. + return withRunSession({ status: "ok", summary, outputText }); + } + if ( + (hadActiveDescendants || expectedSubagentFollowup) && + synthesizedText.trim() === initialSynthesizedText && + isLikelyInterimCronMessage(initialSynthesizedText) && + initialSynthesizedText.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() + ) { + // Descendants existed but no post-orchestration synthesis arrived, so + // suppress stale parent text like "on it, pulling everything together". + return withRunSession({ status: "ok", summary, outputText }); + } + if (synthesizedText.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { + return withRunSession({ status: "ok", summary, outputText }); + } try { const didAnnounce = await runSubagentAnnounceFlow({ - childSessionKey: runSessionKey, + childSessionKey: agentSessionKey, childRunId: `${params.job.id}:${runSessionId}`, requesterSessionKey: announceSessionKey, requesterOrigin: { diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index e0d855bd4f..0b32ef8621 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -71,6 +71,7 @@ export const SessionsPatchParamsSchema = Type.Object( execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])), sendPolicy: Type.Optional( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), ), diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 3ec635fbca..fed4d190f8 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -400,6 +400,7 @@ export const agentHandlers: GatewayRequestHandlers = { providerOverride: entry?.providerOverride, label: labelValue, spawnedBy: spawnedByValue, + spawnDepth: entry?.spawnDepth, channel: entry?.channel ?? request.channel?.trim(), groupId: resolvedGroupId ?? entry?.groupId, groupChannel: resolvedGroupChannel ?? entry?.groupChannel, diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index 553b6c4574..d783e42d53 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -196,6 +196,38 @@ describe("gateway server agent", () => { expect(call.to).toBeUndefined(); }); + test("agent preserves spawnDepth on subagent sessions", async () => { + setRegistry(defaultRegistry); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + const storePath = path.join(dir, "sessions.json"); + testState.sessionStorePath = storePath; + await writeSessionStore({ + entries: { + "agent:main:subagent:depth": { + sessionId: "sess-sub-depth", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + spawnDepth: 2, + }, + }, + }); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "agent:main:subagent:depth", + idempotencyKey: "idem-agent-subdepth", + }); + expect(res.ok).toBe(true); + + const raw = await fs.readFile(storePath, "utf-8"); + const persisted = JSON.parse(raw) as Record< + string, + { spawnDepth?: number; spawnedBy?: string } + >; + expect(persisted["agent:main:subagent:depth"]?.spawnDepth).toBe(2); + expect(persisted["agent:main:subagent:depth"]?.spawnedBy).toBe("agent:main:main"); + }); + test("agent derives sessionKey from agentId", async () => { setRegistry(defaultRegistry); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 768e3c54d8..a6f8059f48 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -127,4 +127,34 @@ describe("gateway sessions patch", () => { expect(res.entry.authProfileOverrideSource).toBeUndefined(); expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined(); }); + + test("sets spawnDepth for subagent sessions", async () => { + const store: Record = {}; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:subagent:child", + patch: { spawnDepth: 2 }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.spawnDepth).toBe(2); + }); + + test("rejects spawnDepth on non-subagent sessions", async () => { + const store: Record = {}; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { spawnDepth: 1 }, + }); + expect(res.ok).toBe(false); + if (res.ok) { + return; + } + expect(res.error.message).toContain("spawnDepth is only supported"); + }); }); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index c5240b5d17..5e1800c0f4 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -100,6 +100,28 @@ export async function applySessionsPatchToStore(params: { } } + if ("spawnDepth" in patch) { + const raw = patch.spawnDepth; + if (raw === null) { + if (typeof existing?.spawnDepth === "number") { + return invalid("spawnDepth cannot be cleared once set"); + } + } else if (raw !== undefined) { + if (!isSubagentSessionKey(storeKey)) { + return invalid("spawnDepth is only supported for subagent:* sessions"); + } + const numeric = Number(raw); + if (!Number.isInteger(numeric) || numeric < 0) { + return invalid("invalid spawnDepth (use an integer >= 0)"); + } + const normalized = numeric; + if (typeof existing?.spawnDepth === "number" && existing.spawnDepth !== normalized) { + return invalid("spawnDepth cannot be changed once set"); + } + next.spawnDepth = normalized; + } + } + if ("label" in patch) { const raw = patch.label; if (raw === null) { diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts new file mode 100644 index 0000000000..3760abebed --- /dev/null +++ b/src/infra/process-respawn.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); + +import { restartGatewayProcessWithFreshPid } from "./process-respawn.js"; + +const originalEnv = { ...process.env }; +const originalArgv = [...process.argv]; +const originalExecArgv = [...process.execArgv]; + +function restoreEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +afterEach(() => { + restoreEnv(); + process.argv = [...originalArgv]; + process.execArgv = [...originalExecArgv]; + spawnMock.mockReset(); +}); + +describe("restartGatewayProcessWithFreshPid", () => { + it("returns disabled when OPENCLAW_NO_RESPAWN is set", () => { + process.env.OPENCLAW_NO_RESPAWN = "1"; + const result = restartGatewayProcessWithFreshPid(); + expect(result.mode).toBe("disabled"); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("returns supervised when launchd/systemd hints are present", () => { + process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; + const result = restartGatewayProcessWithFreshPid(); + expect(result.mode).toBe("supervised"); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("spawns detached child with current exec argv", () => { + delete process.env.OPENCLAW_NO_RESPAWN; + delete process.env.LAUNCH_JOB_LABEL; + process.execArgv = ["--import", "tsx"]; + process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"]; + spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() }); + + const result = restartGatewayProcessWithFreshPid(); + + expect(result).toEqual({ mode: "spawned", pid: 4242 }); + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + ["--import", "tsx", "/repo/dist/index.js", "gateway", "run"], + expect.objectContaining({ + detached: true, + stdio: "inherit", + }), + ); + }); + + it("returns failed when spawn throws", () => { + spawnMock.mockImplementation(() => { + throw new Error("spawn failed"); + }); + const result = restartGatewayProcessWithFreshPid(); + expect(result.mode).toBe("failed"); + expect(result.detail).toContain("spawn failed"); + }); +}); diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts new file mode 100644 index 0000000000..3c6ef37106 --- /dev/null +++ b/src/infra/process-respawn.ts @@ -0,0 +1,61 @@ +import { spawn } from "node:child_process"; + +type RespawnMode = "spawned" | "supervised" | "disabled" | "failed"; + +export type GatewayRespawnResult = { + mode: RespawnMode; + pid?: number; + detail?: string; +}; + +const SUPERVISOR_HINT_ENV_VARS = [ + "LAUNCH_JOB_LABEL", + "LAUNCH_JOB_NAME", + "INVOCATION_ID", + "SYSTEMD_EXEC_PID", + "JOURNAL_STREAM", +]; + +function isTruthy(value: string | undefined): boolean { + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + +function isLikelySupervisedProcess(env: NodeJS.ProcessEnv = process.env): boolean { + return SUPERVISOR_HINT_ENV_VARS.some((key) => { + const value = env[key]; + return typeof value === "string" && value.trim().length > 0; + }); +} + +/** + * Attempt to restart this process with a fresh PID. + * - supervised environments (launchd/systemd): caller should exit and let supervisor restart + * - OPENCLAW_NO_RESPAWN=1: caller should keep in-process restart behavior (tests/dev) + * - otherwise: spawn detached child with current argv/execArgv, then caller exits + */ +export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { + if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) { + return { mode: "disabled" }; + } + if (isLikelySupervisedProcess(process.env)) { + return { mode: "supervised" }; + } + + try { + const args = [...process.execArgv, ...process.argv.slice(1)]; + const child = spawn(process.execPath, args, { + env: process.env, + detached: true, + stdio: "inherit", + }); + child.unref(); + return { mode: "spawned", pid: child.pid ?? undefined }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { mode: "failed", detail }; + } +} diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index 5c1fa60632..a675617f94 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -102,4 +102,20 @@ describe("restart sentinel", () => { expect(trimmed?.length).toBeLessThanOrEqual(8001); expect(trimmed?.startsWith("…")).toBe(true); }); + + it("formats restart messages without volatile timestamps", () => { + const payloadA = { + kind: "restart" as const, + status: "ok" as const, + ts: 100, + message: "Restart requested by /restart", + stats: { mode: "gateway.restart", reason: "/restart" }, + }; + const payloadB = { ...payloadA, ts: 200 }; + const textA = formatRestartSentinelMessage(payloadA); + const textB = formatRestartSentinelMessage(payloadB); + expect(textA).toBe(textB); + expect(textA).toContain("Gateway restart restart ok"); + expect(textA).not.toContain('"ts"'); + }); }); diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 8405426cbd..919fb56a35 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -109,10 +109,22 @@ export async function consumeRestartSentinel( } export function formatRestartSentinelMessage(payload: RestartSentinelPayload): string { - if (payload.message?.trim()) { - return payload.message.trim(); + const message = payload.message?.trim(); + if (message && !payload.stats) { + return message; } - return summarizeRestartSentinel(payload); + const lines: string[] = [summarizeRestartSentinel(payload)]; + if (message) { + lines.push(message); + } + const reason = payload.stats?.reason?.trim(); + if (reason) { + lines.push(`Reason: ${reason}`); + } + if (payload.doctorHint?.trim()) { + lines.push(payload.doctorHint.trim()); + } + return lines.join("\n"); } export function summarizeRestartSentinel(payload: RestartSentinelPayload): string { diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index a33ca94e81..90c25039b6 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import process from "node:process"; import type { GatewayLockHandle } from "../infra/gateway-lock.js"; +import { restartGatewayProcessWithFreshPid } from "../infra/process-respawn.js"; declare const __OPENCLAW_VERSION__: string | undefined; @@ -178,8 +179,26 @@ async function main() { } server = null; if (isRestart) { - shuttingDown = false; - restartResolver?.(); + const respawn = restartGatewayProcessWithFreshPid(); + if (respawn.mode === "spawned" || respawn.mode === "supervised") { + const modeLabel = + respawn.mode === "spawned" + ? `spawned pid ${respawn.pid ?? "unknown"}` + : "supervisor restart"; + defaultRuntime.log(`gateway: restart mode full process restart (${modeLabel})`); + cleanupSignals(); + process.exit(0); + } else { + if (respawn.mode === "failed") { + defaultRuntime.log( + `gateway: full process restart failed (${respawn.detail ?? "unknown error"}); falling back to in-process restart`, + ); + } else { + defaultRuntime.log("gateway: restart mode in-process restart (OPENCLAW_NO_RESPAWN)"); + } + shuttingDown = false; + restartResolver?.(); + } } else { cleanupSignals(); process.exit(0); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index ff41b14d6f..7c332dd09a 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -51,6 +51,9 @@ const RESERVED_COMMANDS = new Set([ // Agent control "skill", "subagents", + "kill", + "steer", + "tell", "model", "models", "queue", diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 052a1ff2f7..bd5cf5f4de 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -2,6 +2,8 @@ import type { ChatType } from "../channels/chat-type.js"; import { parseAgentSessionKey, type ParsedAgentSessionKey } from "../sessions/session-key-utils.js"; export { + getSubagentDepth, + isCronSessionKey, isAcpSessionKey, isSubagentSessionKey, parseAgentSessionKey, diff --git a/src/sessions/session-key-utils.test.ts b/src/sessions/session-key-utils.test.ts new file mode 100644 index 0000000000..3d9ce20864 --- /dev/null +++ b/src/sessions/session-key-utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getSubagentDepth, isCronSessionKey } from "./session-key-utils.js"; + +describe("getSubagentDepth", () => { + it("returns 0 for non-subagent session keys", () => { + expect(getSubagentDepth("agent:main:main")).toBe(0); + expect(getSubagentDepth("main")).toBe(0); + expect(getSubagentDepth(undefined)).toBe(0); + }); + + it("returns 1 for depth-1 subagent session keys", () => { + expect(getSubagentDepth("agent:main:subagent:123")).toBe(1); + }); + + it("returns 2 for nested subagent session keys", () => { + expect(getSubagentDepth("agent:main:subagent:parent:subagent:child")).toBe(2); + }); +}); + +describe("isCronSessionKey", () => { + it("matches base and run cron agent session keys", () => { + expect(isCronSessionKey("agent:main:cron:job-1")).toBe(true); + expect(isCronSessionKey("agent:main:cron:job-1:run:run-1")).toBe(true); + }); + + it("does not match non-cron sessions", () => { + expect(isCronSessionKey("agent:main:main")).toBe(false); + expect(isCronSessionKey("agent:main:subagent:worker")).toBe(false); + expect(isCronSessionKey("cron:job-1")).toBe(false); + expect(isCronSessionKey(undefined)).toBe(false); + }); +}); diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index a8cdb3f947..61bd401997 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -33,6 +33,14 @@ export function isCronRunSessionKey(sessionKey: string | undefined | null): bool return /^cron:[^:]+:run:[^:]+$/.test(parsed.rest); } +export function isCronSessionKey(sessionKey: string | undefined | null): boolean { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return false; + } + return parsed.rest.toLowerCase().startsWith("cron:"); +} + export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean { const raw = (sessionKey ?? "").trim(); if (!raw) { @@ -45,6 +53,14 @@ export function isSubagentSessionKey(sessionKey: string | undefined | null): boo return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:")); } +export function getSubagentDepth(sessionKey: string | undefined | null): number { + const raw = (sessionKey ?? "").trim().toLowerCase(); + if (!raw) { + return 0; + } + return raw.split(":subagent:").length - 1; +} + export function isAcpSessionKey(sessionKey: string | undefined | null): boolean { const raw = (sessionKey ?? "").trim(); if (!raw) { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index c941bdfa43..a7e0b9aa2b 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -11,6 +11,41 @@ import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; +type SessionDefaultsSnapshot = { + mainSessionKey?: string; + mainKey?: string; +}; + +function resolveSidebarChatSessionKey(state: AppViewState): string { + const snapshot = state.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + if (mainSessionKey) { + return mainSessionKey; + } + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + if (mainKey) { + return mainKey; + } + return "main"; +} + +function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) { + state.sessionKey = sessionKey; + state.chatMessage = ""; + state.chatStream = null; + (state as unknown as OpenClawApp).chatStreamStartedAt = null; + state.chatRunId = null; + (state as unknown as OpenClawApp).resetToolStream(); + (state as unknown as OpenClawApp).resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey, + lastActiveSessionKey: sessionKey, + }); +} + export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); return html` @@ -29,6 +64,13 @@ export function renderTab(state: AppViewState, tab: Tab) { return; } event.preventDefault(); + if (tab === "chat") { + const mainSessionKey = resolveSidebarChatSessionKey(state); + if (state.sessionKey !== mainSessionKey) { + resetChatStateForSessionSwitch(state, mainSessionKey); + void state.loadAssistantIdentity(); + } + } state.setTab(tab); }} title=${titleForTab(tab)} @@ -195,11 +237,6 @@ export function renderChatControls(state: AppViewState) { `; } -type SessionDefaultsSnapshot = { - mainSessionKey?: string; - mainKey?: string; -}; - function resolveMainSessionKey( hello: AppViewState["hello"], sessions: SessionsListResult | null, diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 62e240fae8..c2ef3865d4 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -84,6 +84,21 @@ describe("control UI routing", () => { expect(window.location.pathname).toBe("/channels"); }); + it("resets to the main session when opening chat from sidebar navigation", async () => { + const app = mountApp("/sessions?session=agent:main:subagent:task-123"); + await app.updateComplete; + + const link = app.querySelector('a.nav-item[href="/chat"]'); + expect(link).not.toBeNull(); + link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + + await app.updateComplete; + expect(app.tab).toBe("chat"); + expect(app.sessionKey).toBe("main"); + expect(window.location.pathname).toBe("/chat"); + expect(window.location.search).toBe("?session=main"); + }); + it("keeps chat and nav usable on narrow viewports", async () => { const app = mountApp("/chat"); await app.updateComplete;