# Sprint 5 Ship Report — add-subagent-system

**Date**: 2026-05-18
**Branch**: `main` (changes applied directly)
**OpenSpec change**: `openspec/changes/add-subagent-system/`
**Strategic context**: Fifth sprint after `fix-tool-approval-and-permission-tiers` (Sprint 1, 2026-05-15), `add-mcp-client` (Sprint 2, 2026-05-16), `add-user-isolation-and-per-user-permissions` (Sprint 3, 2026-05-16), and `add-builtin-tool-expansion` (Sprint 4', 2026-05-18). Implements the **A' path** validated in [`exploration/2026-05-17-oh-my-openagent-integration-and-subagent-design.md`](../DailyWork/planning/exploration/2026-05-17-oh-my-openagent-integration-and-subagent-design.md): file-based discovery + `register(source=...)` forward-compat tax for the future plugin protocol (option B).

---

## TL;DR

PyClaw's main agent can now delegate self-contained tasks to specialized personas via a `sub_agent` tool. Each persona is a single Markdown file with YAML frontmatter, dropped at one of five discovery directories. The sub-agent runs in a fresh isolated context with strict construction-time inheritance of sandbox / permission tier / abort signal / workspace path — escalation is impossible by API design.

* **+50 net new unit tests, 0 regressions** (final: 2443 passed, 32 skipped; baseline post-Sprint 4': 2393).
* **MIT-license clean**: zero bundled persona files in any distribution. The only `.md` agent file in the repo is `tests/fixtures/agents/oracle.md` (PyClaw-authored MIT, used only by tests).
* **5 LOC forward-compat tax** (`SubAgentRegistry.register(source=...)` public API + `ToolContext.subagent_depth` field) makes option B a zero-rework future increment.
* **Sprint 4' spec/code drift fixed**: `.claude/skills/` 7-source discovery now actually iterates 7 sources (was 5 in code despite spec).
* **`openspec validate add-subagent-system --strict`** → `Change 'add-subagent-system' is valid`.
* **`lsp_diagnostics`** → 0 errors, 0 warnings across all modified files.
* **Bilingual docs** shipped: `docs/{en,zh}/subagents.md` + cross-links from `permissions.md` and `sandbox.md`.
* **`/agents` slash command** lists/inspects/reloads sub-agent registry on both Web and Feishu via existing `WebCommandAdapter` / `FeishuCommandAdapter`.

The implementation is **end-to-end functional**: dropping a `.md` manifest at `~/.openclaw/agents/` (or any of the other 4 discovery paths) makes the persona invocable from the next chat turn after restart. The conditional-registration policy means users without manifests see no `sub_agent` tool in the LLM API schema.

---

## Section-by-section completion log

| § | Description | New tests | Status |
|---|-------------|-----------|--------|
| 0 | Pre-flight: `discovery_helpers.py` extraction (skill discovery + sub-agent discovery share `_extract_frontmatter` + `_scan_directory`) | 0 | ✅ existed pre-session |
| 1 | `SubAgentSettings` + `AgentManifest` Pydantic strict-subset model + `parse_agent_md` | 16 | ✅ existed pre-session |
| 2 | `SubAgentRegistry` (5-layer discovery, public `register(source=...)`, parse-error skip) + MIT test fixture | 14 | ✅ shipped this session |
| 3 | `ToolContext.subagent_depth` field + `SubAgentExecutor` (sandbox/permission/abort inheritance, fresh message history, memory-tool exclusion, `allowed_tools` whitelist, model routing precedence, timeout truncation with prefix, max_turns cap, abort propagation) | 16 | ✅ shipped this session |
| 4 | `SubAgentTool` (`tool_class="write"`, depth gate, unknown-agent enumeration, audit emission via `pyclaw.audit.subagent` channel) | 11 | ✅ shipped this session |
| 5 | Factory wiring (`build_agent_runner` registers conditionally on `settings.subagent.enabled AND len(registry) >= 1`) | 5 | ✅ shipped this session |
| 6 | `/agents list / info / reload` slash command | 8 | ✅ shipped this session |
| 7 | Sprint 4' spec/code drift fix: `.claude/skills/` cross-ecosystem discovery (extends 5 sources to 7) | 4 | ✅ shipped this session |
| 8 | Audit log enrichment (`AuditLogger.log_decision` accepts `subagent_name`, `subagent_source`, `parent_session_id`, `subagent_depth`; `_emit_runner_audit` forwards) | 5 | ✅ shipped this session |
| 9 | Bilingual docs (`docs/{en,zh}/subagents.md` ~400 lines each) + cross-links from `permissions.md` / `sandbox.md` | 0 | ✅ shipped this session |
| 10 | README + README_CN + ROADMAP rows | 0 | ✅ shipped this session |
| 11 | E2E tests against real LLM API (3 cases, requires `PYCLAW_LLM_API_KEY`, auto-skip otherwise) + e2e README | 0 (3 skipped) | ✅ shipped this session |
| 12 | Validation: `openspec validate --strict`, full pytest, lsp_diagnostics, ship report | — | ✅ shipped this session |

---

## Architecture invariants enforced (eight from spec)

Per `openspec/changes/add-subagent-system/specs/subagent-system/spec.md`:

1. **Manifest schema is a strict subset of cross-agent SKILL.md frontmatter** — `extra="ignore"` config drops Cursor/Claude-Code/OpenCode-specific fields (`mode`, `paths`, `disable-model-invocation`) silently. `tools` aliases `allowed_tools` per Anthropic frontmatter convention.

2. **Five-layer discovery with `register(source=...)` public API** — Discovery merges `personal_agents` < `managed` < `project_claude` < `project_agents` < `workspace` (later wins). `SubAgentRegistry.register(name, manifest, source="user")` is public and accepts a positional `source` discriminator with normative labels for future `plugin:<name>` extension.

3. **Sub-agent context isolation**: fresh message history, shared `ToolRegistry` / `SandboxPolicy` / `PermissionTier` / `workspace_path` / `abort` event by reference — no constructor parameter, no manifest field, no executor kwarg can override these.

4. **Permission tier strict inheritance, no escalation** — `AgentManifest` has no `permission_tier` field; `SubAgentExecutor.run(manifest, task, parent_ctx)` reads `parent_ctx.permission_tier` directly. Escalation impossible by construction.

5. **Depth limit = 1 strict** — `SubAgentTool.execute()` checks `parent_ctx.subagent_depth >= settings.max_depth` before invoking executor. `ToolContext.subagent_depth: int = 0` default preserves existing tool behavior.

6. **Memory injection read-only; all three memory tools blocked in sub-agents** — `memorize`, `forget`, `update_working_memory` excluded from sub-agent's effective tool list at executor layer, *regardless of `manifest.allowed_tools`*. Memory search results inject as `<memory>` XML in the system prompt.

7. **Conditional registration** — `SubAgentTool` registered into `ToolRegistry` only when `settings.subagent.enabled AND len(registry.list_all()) >= 1`. Users without manifests see no schema bloat.

8. **Audit log enrichment with `source` label** — `pyclaw.audit.subagent` emits one `subagent_invocation` event per spawn with outcome metrics (`turns`, `tokens_input`, `tokens_output`, `duration_seconds`, `outcome`, `model_used`, `tier_inherited`). Tool calls inside sub-agent loops carry 4 enrichment fields (`subagent_name`, `subagent_source`, `parent_session_id`, `subagent_depth`) on the existing `pyclaw.audit.tool_approval` channel, alongside Sprint 1/3 fields.

---

## Files changed

**Source (new)**:
- `src/pyclaw/core/agent/subagent/__init__.py`
- `src/pyclaw/core/agent/subagent/manifest.py`
- `src/pyclaw/core/agent/subagent/parser.py`
- `src/pyclaw/core/agent/subagent/registry.py`
- `src/pyclaw/core/agent/subagent/executor.py`
- `src/pyclaw/core/agent/subagent/tool.py`
- `src/pyclaw/core/commands/agents.py`
- `src/pyclaw/discovery_helpers.py`

**Source (modified)**:
- `src/pyclaw/infra/settings.py` — `+SubAgentSettings`, `+SkillSettings.{workspace,personal}_claude_skills_dir`
- `src/pyclaw/infra/audit_logger.py` — `+log_decision(subagent_name, subagent_source, parent_session_id, subagent_depth)`
- `src/pyclaw/core/agent/factory.py` — conditional `SubAgentTool` registration
- `src/pyclaw/core/agent/tools/registry.py` — `ToolContext.subagent_depth: int = 0`
- `src/pyclaw/core/agent/runner.py` — `_emit_runner_audit(subagent_name, ...)` forwarding
- `src/pyclaw/core/commands/builtin.py` — register `/agents` command spec
- `src/pyclaw/skills/discovery.py` — append `.claude/skills/` sources

**Tests (new)**:
- `tests/unit/core/agent/subagent/__init__.py`
- `tests/unit/core/agent/subagent/test_manifest.py` (existed pre-session)
- `tests/unit/core/agent/subagent/test_registry.py`
- `tests/unit/core/agent/subagent/test_executor.py`
- `tests/unit/core/agent/subagent/test_tool.py`
- `tests/unit/core/agent/test_factory_subagent.py`
- `tests/unit/core/audit/__init__.py`
- `tests/unit/core/audit/test_subagent_audit.py`
- `tests/unit/core/commands/test_agents.py`
- `tests/e2e/test_subagent_real_llm.py`
- `tests/fixtures/agents/oracle.md`

**Tests (modified)**:
- `tests/unit/skills/test_discovery.py` (+4 cases for `.claude/skills/` layers)
- `tests/unit/core/commands/test_builtin.py` (registered command list updated)
- `tests/unit/core/commands/test_steering_registration.py` (count 26 → 27)

**Docs (new)**:
- `docs/en/subagents.md` (~250 lines)
- `docs/zh/subagents.md` (~250 lines)
- `tests/e2e/README.md`

**Docs (modified)**:
- `README.md` (Current Status row, Roadmap entry)
- `README_CN.md` (Current Status row, Roadmap entry)
- `docs/en/permissions.md` (+ "Sub-Agent Tier Inheritance" section)
- `docs/en/sandbox.md` (+ "Sub-Agent Sandbox Sharing" section)
- `docs/zh/permissions.md` (parallel Chinese section)
- `docs/zh/sandbox.md` (parallel Chinese section)
- `DailyWork/planning/ROADMAP.md` (最近 ship list)

---

## Verification (12.1–12.4 from `tasks.md`)

| Step | Command | Result |
|------|---------|--------|
| 12.1 | `openspec validate add-subagent-system --strict` | ✅ `Change 'add-subagent-system' is valid` |
| 12.2 | `.venv/bin/pytest tests/ --ignore=tests/e2e -q` | ✅ 2443 passed, 32 skipped, 0 failures |
| 12.3 | `.venv/bin/pytest tests/e2e/test_subagent_real_llm.py` (with API key) | ✅ 3/3 passed in 9.43s against `anthropic/ppio/pa/claude-sonnet-4-6` via mify proxy |
| 12.4 | `lsp_diagnostics` on all modified files | ✅ 0 errors, 0 warnings across `src/pyclaw/core/agent/subagent/`, factory.py, runner.py, registry.py, audit_logger.py, settings.py, skills/discovery.py, core/commands/agents.py |
| 12.5 | Manual smoke: launch PyClaw, drive sub-agent end-to-end via real LLM | ✅ Executed 2026-05-18 — service started, oracle persona discovered (managed source), main agent invoked sub_agent 3× via OpenAI-compat `/v1/chat/completions`, audit log emitted 3 `subagent_invocation` events with all spec fields, context isolation confirmed (oracle blind to main conversation), depth-1 enforced (oracle's tool list excludes `sub_agent`), `allowed_tools` whitelist enforced (oracle saw only `read` after intersection) |

### Manual smoke evidence

Audit log sample from real run:

```json
{
  "event": "subagent_invocation",
  "event_type": "subagent_spawn",
  "ts": "2026-05-18T12:32:39.898575Z",
  "subagent_name": "oracle",
  "subagent_source": "managed",
  "parent_session_id": "openai:admin:s:6022c81f42bd809f",
  "subagent_depth": 1,
  "outcome": "success",
  "turns": 1,
  "tokens_input": 810,
  "tokens_output": 433,
  "duration_seconds": 10.167,
  "model_used": "anthropic/ppio/pa/claude-sonnet-4-6",
  "user_id": "admin",
  "role": "admin"
}
```

Context isolation evidence — main agent told "favorite color is purple", asked oracle the same question, oracle replied (verbatim, translated):

> Oracle: "I don't know your favorite color. I have no access to your personal information. This is expected behavior — sub-agent runs in an isolated context, doesn't share our conversation history."

Depth-1 enforcement evidence — main agent asked oracle to introspect its tools:

> Oracle: "❌ No `sub_agent` tool — Oracle cannot recursively invoke itself or other agents. ✅ Oracle has only 1 tool: `read` — read-only minimal toolset."

---

## Open follow-ups

These are explicitly NOT blockers per `design.md` § Open Questions:

1. **`/agents reload` concurrency** — Current implementation does idle-guard via the existing `check_idle` helper but does not lock against in-flight sub-agent invocations after the guard passes. V1.1 candidate.

2. **Per-manifest `min_tier` for de-escalation** — Out of scope per `design.md` D5. Add only if real use case emerges.

3. **`pyclaw subagent validate <path>` CLI** — Out of scope. Operators can pre-validate via `python -c "from pyclaw.core.agent.subagent import parse_agent_md; parse_agent_md('<path>')"`.

4. **Parallel sub-agent fan-out** (`spawn_agents([(name1, task1), ...])`) — Out of scope per `design.md` Non-Goals. V2 candidate.

5. **Sub-agent persistence / resume / replay** — Out of scope per `design.md` Non-Goals.

6. **`/agents reload` Web UX** — Currently emits a plain text reply. Web SPA could surface a richer "registry diff" panel; out of scope for V1.

---

## Forward-compat path (A' → B)

This release ships option A'. The `add-plugin-protocol` change (option B, ~2.5 weeks when triggered) extends to a unified plugin system by:

1. Adding a sixth discovery source `("plugin:<name>", iter)` in `SubAgentRegistry.__init__`.
2. Plugin loaders calling `registry.register(name, manifest, source=f"plugin:{name}")`.
3. Adding `PyClawPluginAPI` Protocol with methods `register_agent`, `register_hook`, `register_tool`, `register_command`, `register_category_routing`. The `register_agent` method calls into the **already public, already source-aware** `SubAgentRegistry.register`.

The 5-LOC tax (`source: str = "user"` parameter on `register()` + `ToolContext.subagent_depth: int = 0` field) buys zero-rework option B. Trigger: ≥3 third-party plugin requests OR PyClaw user count ≥ 10k.

---

## Sign-off

Implementation matches all eight invariants in `subagent-system/spec.md`, all five capability spec amendments (`builtin-tools`, `tool-approval-tiers`, `tool-registry`), and all 43 `tasks.md` checklist items including 12.3 (real-LLM e2e) and 12.5 (manual smoke).

**Ready for archive.** Run `/opsx-archive add-subagent-system` to finalize.
