Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions docs/runbooks/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,21 @@ first user message is just the raw task, identical to the pre-context protocol.
### Execution

1. The `spawn_agent` tool resolves the named agent from the definition registry.
2. Tools listed in the agent's definition are resolved from the tool registry
and filtered against `SubAgentToolPolicy` for user-facing agents.
2. Tools listed in the agent's definition are resolved from the tool registry.
Subagents inherit the parent session's runtime tool policy, then
`SubAgentToolPolicy` removes tools that are statically denied to subagents
(`spawn_agent`).
3. A `SubAgentActor` is spawned as a **child of the session actor** (supervised,
lifecycle-managed — stops when the session stops).
4. The subagent runs an autonomous LLM loop: call tools, process results, repeat.
5. After at most 10 tool iterations or the configured timeout, the subagent
returns its final text response.
5. After at most 10 tool iterations, a final response, or an inactivity timeout,
the subagent returns its final text response.
6. The main agent receives this response as the `spawn_agent` tool result.

Child creation is marshaled back onto the session actor thread, so supervision
stays within Akka's actor-thread rules. If the parent tool call is cancelled or
times out, the subagent is cancelled too.
times out, the subagent is cancelled too. The timeout is an inactivity budget: a
responsive subagent is not stopped merely because wall-clock time has elapsed.

### Observability

Expand Down Expand Up @@ -168,9 +171,9 @@ findings into clear, well-organized summaries.
|-------|----------|---------|-------------|
| `name` | Yes | — | Unique identifier. Used in `spawn_agent(agent: "<name>")`. Duplicate names across files are rejected with a warning. |
| `description` | Yes | — | One-line description shown in the `[available-subagents]` discovery block. |
| `tools` | No | (attempt all, then filter) | List of tool names. When omitted, the runtime starts from all registered tools, then filters user-facing agents through the safe allowlist. When specified, it acts as a whitelist before the same user-facing filter is applied. |
| `tools` | No | (inherit all except denied) | List of tool names. When omitted, the runtime starts from all registered tools available to the parent session, then removes statically denied subagent tools. When specified, it acts as a whitelist before the same denylist is applied. |
| `modelRole` | No | `Compaction` | `Compaction` (cheaper/faster) or `Main` (full model). |
| `timeoutSeconds` | No | `60` | Wall-clock timeout in seconds. |
| `timeoutSeconds` | No | `60` | Inactivity timeout in seconds. The watchdog resets when the subagent makes progress. |
| `visibility` | No | `user-facing` | `user-facing` (visible to `spawn_agent`) or `internal` (platform-owned, hidden). Accepts both hyphenated and PascalCase. |
| `emitStructuredFindings` | No | `false` | When true, successful output becomes a memory-candidate finding for parent-session review. |

Expand Down Expand Up @@ -211,15 +214,14 @@ ignored at the glob layer and never logged.
### Tool access

When `tools` is omitted from the frontmatter, the runtime starts from all
registered tools and then filters user-facing subagents through the safe
allowlist (`attach_file`, `file_read`, `web_fetch`, `web_search`). This keeps
file-authored subagents read-oriented even if the parent session has broader
tool access.
registered tools available under the parent session's audience, boundary,
approval, and shell policies. It then applies the subagent denylist, which
prevents recursive delegation through `spawn_agent`.

When `tools` is specified, it acts as a whitelist limiting which tools the
subagent can access before the same user-facing allowlist is applied. Use this
when you want to restrict a subagent to specific capabilities (e.g., read-only
access via `tools: [file_read, web_search]`).
subagent can access before the same subagent denylist and runtime policy checks
are applied. Use this when you want to restrict a subagent to specific
capabilities (e.g., read-only access via `tools: [file_read, web_search]`).

Spawned subagents inherit the parent session's `session_dir` and current
`project_dir` as read-only grounding. That means file tools resolve against the
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Tuning parameters for LLM session behavior.
| `MaxToolCallsPerTurn` | int | `30` | Max individual tool calls per turn. At ~75% a budget nudge is injected; at 100% tools are stripped and the model is asked to summarize. |
| `SidecarLlmTimeoutSeconds` | int | `90` | Timeout for sidecar LLM calls (title generation, observer summaries, memory extraction). |
| `TurnLlmTimeoutSeconds` | int | `180` | Timeout for the primary per-turn LLM streaming call before forcing an error/recovery path. |
| `ToolExecutionTimeoutSeconds` | int | `90` | Timeout for one tool-execution batch before failing the turn safely. |
| `ToolExecutionTimeoutSeconds` | int | `90` | Per-tool-call inactivity budget. A tool must produce its first result or stream item within this time, and each later item resets the budget. |

### Tools

Expand Down
12 changes: 6 additions & 6 deletions feeds/skills/.system/files/subagent-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: subagent-authoring
description: "How to create and troubleshoot file-defined subagents in ~/.netclaw/agents. Load when the user asks to add, edit, or debug subagent definitions, or when a skill routes via metadata.subagent."
metadata:
author: netclaw
version: "1.2.1"
version: "1.2.2"
---

# Subagent Authoring
Expand Down Expand Up @@ -75,9 +75,9 @@ The markdown body below the closing `---` must also be non-empty.

| Field | Default | Notes |
|------|---------|-------|
| `tools` | (attempt all, then filter) | List of tool names. When omitted, the runtime starts from all registered tools, then filters user-facing agents through the safe allowlist. When specified, it acts as a whitelist before the same filter is applied. |
| `tools` | (inherit all except denied) | List of tool names. When omitted, the runtime starts from all registered tools available to the parent session, then removes statically denied subagent tools. When specified, it acts as a whitelist before the same denylist is applied. |
| `modelRole` | `Compaction` | `Main` or `Compaction` (case-insensitive). Invalid values fall back to `Compaction`. |
| `timeoutSeconds` | `60` | Wall-clock timeout for subagent execution. |
| `timeoutSeconds` | `60` | Inactivity timeout for subagent execution. The watchdog resets when the subagent makes progress. |
| `visibility` | `user-facing` | Accepts `user-facing`, `UserFacing`, `internal`, or `Internal`. Invalid values fall back to `user-facing`. |
| `emitStructuredFindings` | `false` | When true, successful output is emitted as findings for parent-session review. |

Expand Down Expand Up @@ -106,9 +106,9 @@ Summarize the latest planning notes and highlight next actions.
- Follow the user's existing plan format and structure
```

This agent does not automatically inherit every parent-session tool. User-facing
subagents are filtered to the safe allowlist (`attach_file`, `file_read`,
`web_fetch`, `web_search`) even when `tools` is omitted.
This agent inherits the parent session's runtime tool policy. User-facing
subagents then apply the static subagent denylist, which blocks recursive
delegation through `spawn_agent` even when `tools` is omitted.

## Fail-loud loader behavior

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/streaming-tool-call-execution/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17
237 changes: 237 additions & 0 deletions openspec/changes/streaming-tool-call-execution/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
## Context

A `spawn_agent` tool call is bounded today by four uncoordinated timers: the
parent batch `ProcessingWatchdog` (`SessionConfig.ToolExecutionTimeout`, 90s),
the per-tool attempt `CancelAfter` in `ExecuteToolAttemptAsync` (90s), the
sub-agent's own flat timer (`SubAgentConfig.DefaultTimeoutSeconds`, 60s), and the
spawn `Ask` (~65s). Whichever fires first wins, so an identical task passes or
fails by timing, and a sub-agent actively streaming an LLM response is killed
anyway.

`ProcessingWatchdog` is structurally single-operation — one operation id, one
timer key. It cannot represent N concurrent tool calls. PR #1035 tried to keep
sub-agents alive by having them heartbeat the parent so it could refresh that
single watchdog; with two parallel `spawn_agent` calls a healthy sibling's
heartbeats mask a wedged one. That PR is abandoned.

The deeper problem: `INetclawTool.ExecuteAsync` returns `Task<string>`. A tool is
either pending or done — there is no liveness channel. Every long-running tool
needs a bespoke timer. This change gives every tool call a uniform liveness
channel: a stream.

## Goals / Non-Goals

**Goals:**

- Make tool-call execution streaming so liveness is uniform across all tools.
- A per-call inactivity watchdog so parallel tool calls are monitored
independently — a healthy call cannot mask a stalled sibling.
- Move tool-call liveness out of `LlmSessionActor` and `SubAgentActor` into the
tool-execution layer.
- Keep the existing `Task.WhenAll` parallel tool-execution model unchanged.
- Keep the ~28 existing tools and the MCP/AI adapters working with no change.
- Preserve per-call failure isolation: one tool failing never discards a
sibling's result or fails the turn.
- Provide the streaming foundation issue #1038 (background mode) builds on.

**Non-Goals:**

- Implementing background/detached `spawn_agent` runs (#1038).
- Mapping MCP `notifications/progress` to activity items (follow-on).
- Changing the LLM-call or compaction watchdog behavior.
- Streaming intermediate tool output into the LLM context — activity items are
ephemeral by design.
- Persisting or wire-serializing `ToolCallUpdate` items.

## Decisions

### D1. Tool execution yields `IAsyncEnumerable<ToolCallUpdate>`

**Decision:** A tool call produces a stream of `ToolCallUpdate` items: zero or
more non-terminal `ToolActivity` items (a phase label plus an optional output
chunk), then exactly one terminal `ToolCompleted` item carrying the result
string, file attachments, and any sub-agent runs/findings the current
`ToolCallResult` carries. A tool failure or watchdog timeout is surfaced by the
tool-execution layer as a terminal error result, not as an escaping exception.

**Rationale:** A stream is an ordered, cancellable, terminable liveness channel
that works for every tool, drives the per-call watchdog, and matches the shape
issue #1038 needs. It mirrors the existing LLM streaming path, which already
folds a stream of deltas into one `ChatResponse`.

**Alternatives considered:**

- Keep `Task<string>` and add a side-channel progress callback (today's
`OnSubAgentActivity`). Rejected: ad-hoc, sub-agent-specific, and not ordered
or terminable.
- A sub-agent-specific run registry on the session actor. Rejected: solves only
sub-agents and re-introduces shared parent-side state.

### D2. The streaming method is a default interface method on `INetclawTool`

**Decision:** `INetclawTool` gains
`IAsyncEnumerable<ToolCallUpdate> ExecuteStreamAsync(...)` as a default interface
method. Its default body yields one `ToolCompleted` wrapping
`await ExecuteAsync(...)`. `IToolExecutor` / `DispatchingToolExecutor` gain the
matching `ExecuteStreamAsync`. Tools that benefit override the method:
`SpawnAgentTool`, `ShellTool`, `WebFetchTool`.

**Rationale:** Default interface methods may be async iterators, so all ~28
`NetclawTool<TParams>` implementations and the `McpToolAdapter` / `AIToolAdapter`
(which implement `INetclawTool` directly, not via the base) inherit streaming
with no code change. The blast radius is a contract addition plus ~3 opt-in
overrides, not a 28-tool rewrite.

**Alternatives considered:**

- A method on the `NetclawTool<TParams>` base class. Rejected: the MCP and AI
adapters do not derive from the base and would be left out.
- A separate `IStreamingNetclawTool` opt-in interface. Rejected: the executor
would need a type check per call; a default method is uniform.

### D3. A per-call two-phase inactivity watchdog in the tool-execution layer

**Decision:** `SessionToolExecutionPipeline.ExecuteSingleToolAsync` consumes each
tool's stream under a per-call, two-phase inactivity watchdog: a generous
*first-item* budget (time to the first `ToolCallUpdate`) and a tighter
*inter-item* budget that resets on every item. On expiry the watchdog cancels
that call's `CancellationToken` and the call yields a terminal timeout error.
The watchdog is a small, pure, `TimeProvider`-driven helper. It replaces the
flat `CancelAfter` in `ExecuteToolAttemptAsync`.

**Rationale:** Per-call monitoring means parallel tool calls are independent — a
healthy call cannot keep a stalled sibling's timer alive. The two-phase shape
mirrors the LLM watchdog (`PrefillTimeout` then `FirstTokenTimeout`). Enforcement
is centralized in one helper; a streaming tool's only obligation is to emit
activity items.

**Alternatives considered:**

- A single flat per-call timeout. Rejected: cannot distinguish a slow first
response from a stalled stream, the same conflation the LLM watchdog already
resolved (`two-phase-streaming-timeout`).

### D4. `ProcessingWatchdog` reverts to LLM-only; the batch tool watchdog is removed

**Decision:** `LlmSessionActor.HandleToolCallResponse` no longer arms a
`ToolExecution` operation on `ProcessingWatchdog`. The `ToolExecution` watchdog
handling, `RefreshIfCurrent`, and `PauseToolExecutionWatchdogForApprovalWait` /
`ResumeToolExecutionWatchdogAfterApprovalWait` are removed. `ProcessingWatchdog`
governs only LLM calls and compaction.

**Rationale:** Tool-call liveness is now the tool-execution layer's job. Keeping
a separate single-operation batch watchdog re-creates the racing-timers bug and
can fail a whole turn while a tool is legitimately running.

### D5. Activity items are ephemeral — only the terminal result reaches the LLM

**Decision:** Only the terminal `ToolCompleted` result becomes the `role=Tool`
message appended to the conversation, still clamped to `maxInlineToolResultChars`.
`ToolActivity` items are consumed only by the per-call watchdog and an optional
live UI / session-output relay; they are never accumulated into the LLM context.

**Rationale:** This mirrors LLM streaming — the user may watch deltas, but the
stored message is the final assembled response. It guarantees a chatty streaming
tool (e.g. `shell_execute` stdout) cannot blow out the context window.

### D6. `spawn_agent` is a streaming tool; `SubAgentActor` keeps its inactivity watchdog

**Decision:** `SpawnAgentTool` overrides the streaming method. Instead of
`SubAgentSpawner.SpawnAsync` doing `Ask<SubAgentResult>` and blocking, the
sub-agent's progress is surfaced as `ToolActivity` items and the terminal
`SubAgentResult` as the `ToolCompleted` item. `SubAgentActor` keeps its own
internal inactivity watchdog (self-governance). The absolute wall-clock backstop
is dropped: a run is bounded by the per-call inactivity watchdog plus
`SubAgentActor.MaxToolIterations`.

**Rationale:** A sub-agent becomes an ordinary streaming tool — same liveness
model as a long shell command. Industry practice (OpenCode `steps`, Claude Code
`max_turns`) bounds agent runs by an iteration cap plus an inactivity timeout,
not a wall-clock cap; netclaw already has `MaxToolIterations`.

**Alternatives considered:**

- Keep the absolute backstop as defense-in-depth. Rejected: redundant with the
inactivity watchdog and iteration cap, and its "must exceed inherited budgets"
invariant is unenforceable config foot-gun.

### D7. Sub-agent recursion is denied by one resolution-time filter

**Decision:** `spawn_agent` is denied to sub-agents by the single
`SubAgentToolPolicy` denylist applied in `SubAgentSpawner.ResolveTools`. The
redundant `spawn_agent` string-compare in the `SubAgentActor` constructor is
removed.

**Rationale:** One authoritative filter at tool resolution is easier to reason
about than three overlapping string-matches. The actor trusts that
`definition.Tools` is already resolved.

### D8. Approval policy is the single authoritative tool-access boundary

**Decision:** The non-interactive safe-list auto-grant is removed from
`ToolAccessPolicy`. An unapproved tool invoked in a non-interactive session
(e.g. a reminder- or webhook-triggered sub-agent) fails closed with a legible
error that names the tool and the reason.

**Rationale:** The approval policy already defines what each audience may do;
the implicit hardcoded safe-list was a second, weaker boundary. Removing it means
everything a tool can do is inside the envelope the operator already granted.
Fail-closed with a legible error matches the repo's default-deny posture.

### D9. Per-call failures never fault the batch

**Decision:** `ExecuteSingleToolAsync` continues to catch every per-tool
exception and timeout and return it as a `role=Tool` error result keyed to that
`ToolCallId`; it never throws. `ExecuteToolsAsync` keeps `Task.WhenAll`, so all
N results — successes and errors — always reach the LLM as tool-result messages.

**Rationale:** A wedged sub-agent among N must not discard healthy siblings'
results or fail the turn. Every `tool_use` must also receive a `tool_result` for
conversation validity. Removing the batch watchdog (D4) eliminates the only path
that today bypasses this isolation.

## Risks / Trade-offs

- **[Risk]** `INetclawTool` contract change touches a core abstraction. ->
**Mitigation:** the streaming method is a default interface method; existing
tools and adapters are untouched and verified by a regression test.
- **[Risk]** A tool that ignores its `CancellationToken` can still hang the
`await foreach`. -> **Mitigation:** retain a hard `Task`-level cancellation
around stream enumeration; the per-call watchdog cancels the token and the
enumeration is abandoned.
- **[Risk]** Removing the non-interactive auto-grant changes behavior for
reminder/webhook-triggered sub-agents. -> **Mitigation:** intentional; the
failure is legible and operators widen access through the persistent approval
store.
- **[Trade-off]** A slow MCP tool that emits no progress is governed only by the
first-item budget. -> **Mitigation:** that budget is generous and operator-
configurable; mapping MCP progress notifications is a planned follow-on.

## Migration Plan

1. Add the `ToolCallUpdate` type and the `ExecuteStreamAsync` default interface
method on `INetclawTool`; add it to `IToolExecutor` / `DispatchingToolExecutor`.
2. Add the per-call two-phase streaming watchdog helper and switch
`SessionToolExecutionPipeline.ExecuteSingleToolAsync` to consume streams.
3. Remove the `ToolExecution` operation from `ProcessingWatchdog` usage in
`LlmSessionActor`.
4. Override the streaming method for `SpawnAgentTool`, `ShellTool`, `WebFetchTool`;
wire `SubAgentActor` progress into the spawn tool's stream; drop the absolute
backstop.
5. Collapse sub-agent recursion to the single `SubAgentToolPolicy` filter; remove
the non-interactive auto-grant from `ToolAccessPolicy`.
6. Add the per-call inactivity budgets to `SessionConfig` and
`netclaw-config.v1.schema.json`.
7. Update tests, runbooks, and the eval suite.

Rollback: the default interface method makes the contract additive, so reverting
is removing the per-call watchdog and restoring the flat `CancelAfter` and the
`ToolExecution` watchdog operation.

## Open Questions

- Exact default values for the first-item and inter-item budgets, and whether
they live on `SessionConfig` or a tool-scoped config section — resolved during
implementation so long as the observable two-phase contract holds.
- Whether `MaxToolIterations` becomes per-`SubAgentProfile` configurable in this
change or a follow-on — implementation may defer it.
Loading
Loading