mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Agents: add nested subagent orchestration controls and reduce subagent token waste (#14447)
* Agents: add subagent orchestration controls
* Agents: add subagent orchestration controls (WIP uncommitted changes)
* feat(subagents): add depth-based spawn gating for sub-sub-agents
* feat(subagents): tool policy, registry, and announce chain for nested agents
* feat(subagents): system prompt, docs, changelog for nested sub-agents
* fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback
Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex),
the fallback candidate logic in resolveFallbackCandidates silently appended the
global primary model (opus) as a backstop. On reinjection/steer with a transient
error, the session could fall back to opus which has a smaller context window
and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? []
instead of undefined, preventing the implicit primary backstop.
Bug 2: Active subagents showed 'model n/a' in /subagents list because
resolveModelDisplay only read entry.model/modelProvider (populated after run
completes). Fix: fall back to modelOverride/providerOverride fields which are
populated at spawn time via sessions.patch.
Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could
theoretically escape runEmbeddedPiAgent and be treated as failover candidates
in runWithModelFallback, causing a switch to a model with a smaller context
window. Fix: in runWithModelFallback, detect context overflow errors via
isLikelyContextOverflowError and rethrow them immediately instead of trying the
next model candidate.
* fix(subagents): track spawn depth in session store and fix announce routing for nested agents
* Fix compaction status tracking and dedupe overflow compaction triggers
* fix(subagents): enforce depth block via session store and implement cascade kill
* fix: inject group chat context into system prompt
* fix(subagents): always write model to session store at spawn time
* Preserve spawnDepth when agent handler rewrites session entry
* fix(subagents): suppress announce on steer-restart
* fix(subagents): fallback spawned session model to runtime default
* fix(subagents): enforce spawn depth when caller key resolves by sessionId
* feat(subagents): implement active-first ordering for numeric targets and enhance task display
- Added a test to verify that subagents with numeric targets follow an active-first list ordering.
- Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity.
- Enhanced task display in command responses to prevent truncation of long task descriptions.
- Introduced new utility functions for compacting task text and managing subagent run states.
* fix(subagents): show model for active runs via run record fallback
When the spawned model matches the agent's default model, the session
store's override fields are intentionally cleared (isDefault: true).
The model/modelProvider fields are only populated after the run
completes. This left active subagents showing 'model n/a'.
Fix: store the resolved model on SubagentRunRecord at registration
time, and use it as a fallback in both display paths (subagents tool
and /subagents command) when the session store entry has no model info.
Changes:
- SubagentRunRecord: add optional model field
- registerSubagentRun: accept and persist model param
- sessions-spawn-tool: pass resolvedModel to registerSubagentRun
- subagents-tool: pass run record model as fallback to resolveModelDisplay
- commands-subagents: pass run record model as fallback to resolveModelDisplay
* feat(chat): implement session key resolution and reset on sidebar navigation
- Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar.
- Updated the `renderTab` function to handle session key changes when navigating to the chat tab.
- Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation.
* fix: subagent timeout=0 passthrough and fallback prompt duplication
Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default
- sessions-spawn-tool: default to undefined (not 0) when neither timeout param
is provided; use != null check so explicit 0 passes through to gateway
- agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles
0 → MAX_SAFE_TIMEOUT_MS)
Bug 2: model fallback no longer re-injects the original prompt as a duplicate
- agent.ts: track fallback attempt index; on retries use a short continuation
message instead of the full original prompt since the session file already
contains it from the first attempt
- Also skip re-sending images on fallback retries (already in session)
* feat(subagents): truncate long task descriptions in subagents command output
- Introduced a new utility function to format task previews, limiting their length to improve readability.
- Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately.
- Adjusted related tests to verify that long task descriptions are now truncated in the output.
* refactor(subagents): update subagent registry path resolution and improve command output formatting
- Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically.
- Enhanced the formatting of command output for active and recent subagents, adding separators for better readability.
- Updated related tests to reflect changes in command output structure.
* fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted
The previous fix (75a791106) correctly handled the case where
runTimeoutSeconds was explicitly set to 0 ("no timeout"). However,
when models omit the parameter entirely (which is common since the
schema marks it as optional), runTimeoutSeconds resolved to undefined.
undefined flowed through the chain as:
sessions_spawn → timeout: undefined (since undefined != null is false)
→ gateway agent handler → agentCommand opts.timeout: undefined
→ resolveAgentTimeoutMs({ overrideSeconds: undefined })
→ DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes)
This caused subagents to be killed at exactly 10 minutes even though
the user's intent (via TOOLS.md) was for subagents to run without a
timeout.
Fix: default runTimeoutSeconds to 0 (no timeout) when neither
runTimeoutSeconds nor timeoutSeconds is provided by the caller.
Subagent spawns are long-running by design and should not inherit the
600s agent-command default timeout.
* fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default)
* fix: thread timeout override through getReplyFromConfig dispatch path
getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override,
always falling back to the config default (600s). Add timeoutOverrideSeconds
to GetReplyOptions and pass it through as overrideSeconds so callers of the
dispatch chain can specify a custom timeout (0 = no timeout).
This complements the existing timeout threading in agentCommand and the
cron isolated-agent runner, which already pass overrideSeconds correctly.
* feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling
- Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution.
- Updated the `resolveFallbackCandidates` function to utilize the new normalization logic.
- Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms.
- Introduced a new test case to ensure that the normalization process works as expected for various input formats.
* feat(tests): add unit tests for steer failure behavior in openclaw-tools
- Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails.
- Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected.
- Enhanced the subagent registry with a new function to clear steer restart suppression.
- Updated related components to support the new test scenarios.
* fix(subagents): replace stop command with kill in slash commands and documentation
- Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs.
- Modified related documentation to reflect the change in command usage.
- Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling.
- Enhanced tests to ensure correct behavior of the updated commands and their interactions.
* feat(tests): add unit tests for readLatestAssistantReply function
- Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios.
- Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text.
- Mocked the gateway call to simulate different message histories for comprehensive testing.
* feat(tests): enhance subagent kill-all cascade tests and announce formatting
- Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents.
- Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content.
- Improved the handling of long findings and stats in the announce formatting logic to ensure concise output.
- Refactored related functions to enhance clarity and maintainability in the subagent registry and tools.
* refactor(subagent): update announce formatting and remove unused constants
- Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests.
- Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic.
- Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs.
- Cleaned up unused imports in the commands-subagents file to enhance code clarity.
* feat(tests): enhance billing error handling in user-facing text
- Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context.
- Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages.
- Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output.
- Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements.
* feat(subagent): enhance workflow guidance and auto-announcement clarity
- Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates.
- Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow.
- Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts.
* fix(cron): avoid announcing interim subagent spawn acks
* chore: clean post-rebase imports
* fix(cron): fall back to child replies when parent stays interim
* fix(subagents): make active-run guidance advisory
* fix(subagents): update announce flow to handle active descendants and enhance test coverage
- Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting.
- Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents.
- Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process.
* fix(subagents): enhance announce flow and formatting for user updates
- Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context.
- Refactored the announcement logic to improve clarity and ensure internal context remains private.
- Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates.
- Introduced a new function to build reply instructions based on session context, improving the overall announcement process.
* fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204)
* fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204)
* fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204)
* fix: apply formatting after rebase (#14447) (thanks @tyler6204)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -77,7 +77,10 @@ Text + native (when enabled):
|
||||
- `/approve <id> 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 <id|#|all>` (immediately abort one or all running sub-agents for this session; no confirmation message)
|
||||
- `/steer <id|#> <message>` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message)
|
||||
- `/tell <id|#> <message>` (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)
|
||||
|
||||
@@ -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:<agentId>:subagent:<uuid>`) 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 <id|#|all>`
|
||||
- `/subagents log <id|#> [limit] [tools]`
|
||||
- `/subagents info <id|#>`
|
||||
- `/subagents send <id|#> <message>`
|
||||
|
||||
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`:
|
||||
|
||||
<Steps>
|
||||
<Step title="Main agent spawns">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Sub-agent runs in the background">
|
||||
A new isolated session is created (`agent:<agentId>:subagent:<uuid>`) on the dedicated `subagent` queue lane.
|
||||
</Step>
|
||||
<Step title="Result is announced">
|
||||
When the sub-agent finishes, it announces its findings back to the requester chat. The main agent posts a natural-language summary.
|
||||
</Step>
|
||||
<Step title="Session is archived">
|
||||
The sub-agent session is auto-archived after 60 minutes (configurable). Transcripts are preserved.
|
||||
</Step>
|
||||
</Steps>
|
||||
- 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.
|
||||
|
||||
<Tip>
|
||||
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.
|
||||
</Tip>
|
||||
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.<timestamp>` (same folder).
|
||||
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
|
||||
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||
- `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:<id>:main` | Main agent | Always |
|
||||
| 1 | `agent:<id>:subagent:<uuid>` | Sub-agent (orchestrator when depth 2 allowed) | Only if `maxSpawnDepth >= 2` |
|
||||
| 2 | `agent:<id>:subagent:<uuid>:subagent:<uuid>` | 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:
|
||||
|
||||
<Note>
|
||||
Archive renames the transcript to `*.deleted.<timestamp>` (same folder) — transcripts are preserved, not deleted. Auto-archive timers are best-effort; pending timers are lost if the gateway restarts.
|
||||
</Note>
|
||||
|
||||
## 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
|
||||
|
||||
<Note>
|
||||
Invalid model values are silently skipped — the sub-agent runs on the next valid default with a warning in the tool result.
|
||||
</Note>
|
||||
|
||||
### 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
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Use the `agents_list` tool to discover which agent ids are currently allowed for `sessions_spawn`.
|
||||
</Tip>
|
||||
|
||||
## 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 <id\|#\|all>` | Stop a running sub-agent |
|
||||
| `/subagents log <id\|#> [limit] [tools]` | View sub-agent transcript |
|
||||
| `/subagents info <id\|#>` | Show detailed run metadata |
|
||||
| `/subagents send <id\|#> <message>` | 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`.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Example: list and stop a sub-agent">
|
||||
```
|
||||
/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.
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Example: inspect a sub-agent">
|
||||
```
|
||||
/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
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Example: view sub-agent log">
|
||||
```
|
||||
/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
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Example: send a follow-up message">
|
||||
```
|
||||
/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.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 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
|
||||
|
||||
<Tip>
|
||||
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`).
|
||||
</Tip>
|
||||
|
||||
## Tool Policy
|
||||
|
||||
By default, sub-agents get **all tools except** a set of denied tools that are unsafe or unnecessary for background tasks:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Default denied tools">
|
||||
| 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 |
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### 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
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
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).
|
||||
</Note>
|
||||
- `/stop` in the main chat stops all depth-1 agents and cascades to their depth-2 children.
|
||||
- `/subagents kill <id>` 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:<agentId>:subagent:<uuid>`.
|
||||
- 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.
|
||||
|
||||
<Note>
|
||||
Fully isolated auth per sub-agent is not currently supported.
|
||||
</Note>
|
||||
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 <id>` | 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)
|
||||
|
||||
<Note>
|
||||
`runTimeoutSeconds` does **not** auto-archive the session. The session remains until the normal archive timer fires.
|
||||
</Note>
|
||||
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:
|
||||
|
||||
<Accordion title="Complete sub-agent configuration">
|
||||
```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"]
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
## 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 <id>` stops a specific sub-agent and cascades to its children.
|
||||
|
||||
## Limitations
|
||||
|
||||
<Warning>
|
||||
- **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.
|
||||
</Warning>
|
||||
|
||||
## 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).
|
||||
|
||||
56
src/agents/bash-tools.process.poll-timeout.test.ts
Normal file
56
src/agents/bash-tools.process.poll-timeout.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,22 @@ function makeCfg(overrides: Partial<OpenClawConfig> = {}): 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");
|
||||
|
||||
@@ -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<T>(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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -21,6 +21,7 @@ const ANTHROPIC_MODEL_ALIASES: Record<string, string> = {
|
||||
"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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => configOverride,
|
||||
};
|
||||
});
|
||||
|
||||
function writeStore(agentId: string, store: Record<string, unknown>) {
|
||||
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<string, unknown> },
|
||||
);
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof import("../config/config.js")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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*(?:<!doctype\s+html\b|<html\b)/i;
|
||||
@@ -238,6 +240,18 @@ function shouldRewriteContextOverflowText(raw: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRewriteBillingText(raw: string): boolean {
|
||||
if (!isBillingErrorMessage(raw)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isRawApiErrorPayload(raw) ||
|
||||
isLikelyHttpErrorText(raw) ||
|
||||
ERROR_PREFIX_RE.test(raw) ||
|
||||
BILLING_ERROR_HEAD_RE.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
type ErrorPayload = Record<string, unknown>;
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<unknown, unknown> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DEFAULT_TOOL_ALLOW = [
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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<string, Record<string, unknown>> = {};
|
||||
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<typeof import("../config/config.js")>();
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
const call = agentSpy.mock.calls[0]?.[0] as {
|
||||
params?: Record<string, unknown>;
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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);
|
||||
|
||||
@@ -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<typeof loadConfig>;
|
||||
}):
|
||||
| {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
}
|
||||
| undefined {
|
||||
const provider = params.provider?.trim();
|
||||
const model = params.model?.trim();
|
||||
if (!provider || !model) {
|
||||
return undefined;
|
||||
}
|
||||
const models = params.config.models?.providers?.[provider]?.models ?? [];
|
||||
const entry = models.find((candidate) => candidate.id === model);
|
||||
return entry?.cost;
|
||||
}
|
||||
|
||||
async function waitForSessionUsage(params: { sessionKey: string }) {
|
||||
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<typeof deliveryContextFromSession>[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;
|
||||
|
||||
87
src/agents/subagent-depth.test.ts
Normal file
87
src/agents/subagent-depth.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
176
src/agents/subagent-depth.ts
Normal file
176
src/agents/subagent-depth.ts
Normal file
@@ -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<string, SessionDepthEntry> {
|
||||
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<string, SessionDepthEntry>;
|
||||
}
|
||||
} 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<string, SessionDepthEntry>,
|
||||
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<string, SessionDepthEntry>;
|
||||
cache: Map<string, Record<string, SessionDepthEntry>>;
|
||||
}): 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<string, SessionDepthEntry>;
|
||||
},
|
||||
): number {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
const fallbackDepth = getSubagentDepth(raw);
|
||||
if (!raw) {
|
||||
return fallbackDepth;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Record<string, SessionDepthEntry>>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
165
src/agents/subagent-registry.nested.test.ts
Normal file
165
src/agents/subagent-registry.nested.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
196
src/agents/subagent-registry.steer-restart.test.ts
Normal file
196
src/agents/subagent-registry.steer-restart.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, SubagentRunRecord> {
|
||||
|
||||
@@ -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<string, SubagentRunRecord>();
|
||||
@@ -46,29 +49,8 @@ function persistSubagentRuns() {
|
||||
|
||||
const resumedRuns = new Set<string>();
|
||||
|
||||
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<typeof loadConfig>,
|
||||
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<string, SubagentRunRecord> {
|
||||
const merged = new Map<string, SubagentRunRecord>();
|
||||
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<string>();
|
||||
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<string>([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<string>([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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=<ms>).`,
|
||||
"- 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).",
|
||||
|
||||
@@ -10,7 +10,6 @@ describe("tool display details", () => {
|
||||
task: "double-message-bug-gpt",
|
||||
label: 0,
|
||||
runTimeoutSeconds: 0,
|
||||
timeoutSeconds: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
],
|
||||
// UI helpers
|
||||
@@ -49,6 +50,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
|
||||
49
src/agents/tools/agent-step.test.ts
Normal file
49
src/agents/tools/agent-step.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
840
src/agents/tools/subagents-tool.ts
Normal file
840
src/agents/tools/subagents-tool.ts
Normal file
@@ -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<string, number>();
|
||||
|
||||
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<typeof loadConfig>,
|
||||
key: string,
|
||||
parsed?: ParsedAgentSessionKey | null,
|
||||
) {
|
||||
return resolveStorePath(cfg.session?.store, {
|
||||
agentId: parsed?.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSessionEntryForKey(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
key: string;
|
||||
cache: Map<string, Record<string, SessionEntry>>;
|
||||
}): 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<typeof loadConfig>;
|
||||
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<string, Record<string, SessionEntry>>();
|
||||
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<typeof loadConfig>;
|
||||
entry: SubagentRunRecord;
|
||||
cache: Map<string, Record<string, SessionEntry>>;
|
||||
}): 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<typeof loadConfig>;
|
||||
parentChildSessionKey: string;
|
||||
cache: Map<string, Record<string, SessionEntry>>;
|
||||
seenChildSessionKeys?: Set<string>;
|
||||
}): Promise<{ killed: number; labels: string[] }> {
|
||||
const childRuns = listSubagentRunsForRequester(params.parentChildSessionKey);
|
||||
const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set<string>();
|
||||
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<string, unknown>;
|
||||
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<string, Record<string, SessionEntry>>();
|
||||
|
||||
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<string, Record<string, SessionEntry>>();
|
||||
const seenChildSessionKeys = new Set<string>();
|
||||
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<string, Record<string, SessionEntry>>();
|
||||
const stopResult = await killSubagentRun({
|
||||
cfg,
|
||||
entry: resolved.entry,
|
||||
cache: killCache,
|
||||
});
|
||||
const seenChildSessionKeys = new Set<string>();
|
||||
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<string, Record<string, SessionEntry>>(),
|
||||
});
|
||||
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.",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Workspac
|
||||
return result;
|
||||
}
|
||||
|
||||
const SUBAGENT_BOOTSTRAP_ALLOWLIST = new Set([DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME]);
|
||||
const MINIMAL_BOOTSTRAP_ALLOWLIST = new Set([DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME]);
|
||||
|
||||
export function filterBootstrapFilesForSession(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
sessionKey?: string,
|
||||
): WorkspaceBootstrapFile[] {
|
||||
if (!sessionKey || !isSubagentSessionKey(sessionKey)) {
|
||||
if (!sessionKey || (!isSubagentSessionKey(sessionKey) && !isCronSessionKey(sessionKey))) {
|
||||
return files;
|
||||
}
|
||||
return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name));
|
||||
return files.filter((file) => MINIMAL_BOOTSTRAP_ALLOWLIST.has(file.name));
|
||||
}
|
||||
|
||||
export async function loadExtraBootstrapFiles(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <id|#|all>",
|
||||
"- /subagents kill <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"- /subagents steer <id|#> <message>",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
"",
|
||||
"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<CommandHandler>[0], childKey: string) {
|
||||
type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
|
||||
|
||||
function loadSubagentSessionEntry(
|
||||
params: Parameters<CommandHandler>[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 || "<unknown>"}`,
|
||||
`Ignoring ${handledPrefix} from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
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 <id|#|all>" } };
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
handledPrefix === COMMAND
|
||||
? "Usage: /subagents kill <id|#|all>"
|
||||
: "Usage: /kill <id|#|all>",
|
||||
},
|
||||
};
|
||||
}
|
||||
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 <id|#> <message>" },
|
||||
reply: {
|
||||
text: steerRequested
|
||||
? handledPrefix === COMMAND
|
||||
? "Usage: /subagents steer <id|#> <message>"
|
||||
: `Usage: ${handledPrefix} <id|#> <message>`
|
||||
: "Usage: /subagents send <id|#> <message>",
|
||||
},
|
||||
};
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {} };
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof loadConfig>; 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}). */
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<string, (typeof descendants)[number]>();
|
||||
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<string | undefined> {
|
||||
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: {
|
||||
|
||||
@@ -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()]),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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<string, SessionEntry> = {};
|
||||
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<string, SessionEntry> = {};
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
80
src/infra/process-respawn.test.ts
Normal file
80
src/infra/process-respawn.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
61
src/infra/process-respawn.ts
Normal file
61
src/infra/process-respawn.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -51,6 +51,9 @@ const RESERVED_COMMANDS = new Set([
|
||||
// Agent control
|
||||
"skill",
|
||||
"subagents",
|
||||
"kill",
|
||||
"steer",
|
||||
"tell",
|
||||
"model",
|
||||
"models",
|
||||
"queue",
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
src/sessions/session-key-utils.test.ts
Normal file
32
src/sessions/session-key-utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLAnchorElement>('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;
|
||||
|
||||
Reference in New Issue
Block a user