diff --git a/docs/runbooks/subagents.md b/docs/runbooks/subagents.md index 700ab376b..7b6d7d3a3 100644 --- a/docs/runbooks/subagents.md +++ b/docs/runbooks/subagents.md @@ -168,7 +168,7 @@ findings into clear, well-organized summaries. |-------|----------|---------|-------------| | `name` | Yes | — | Unique identifier. Used in `spawn_agent(agent: "")`. Duplicate names across files are rejected with a warning. | | `description` | Yes | — | One-line description shown in the `[available-subagents]` discovery block. | -| `tools` | No | (inherit all) | List of tool names. When omitted, inherits all session tools including MCP tools. When specified, acts as a whitelist to limit access. | +| `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. | | `modelRole` | No | `Compaction` | `Compaction` (cheaper/faster) or `Main` (full model). | | `timeoutSeconds` | No | `60` | Wall-clock timeout in seconds. | | `visibility` | No | `user-facing` | `user-facing` (visible to `spawn_agent`) or `internal` (platform-owned, hidden). Accepts both hyphenated and PascalCase. | @@ -181,16 +181,15 @@ written. ### Loader behavior (fail loud) -At daemon startup, `FileSubAgentDefinitionLoader` scans `~/.netclaw/agents/*.md` -and logs a specific warning for every file it rejects. A rejection does not -stop the scan — other valid files in the same directory still load. Rejection +On the next turn or subagent lookup, `FileSubAgentDefinitionLoader` rescans +`~/.netclaw/agents/*.md` and logs a specific warning for every file it rejects. +A rejection does not stop the scan — other valid files in the same directory +still load. Rejection reasons: - Missing or unparseable YAML frontmatter -- Missing required field (`name`, `description`, or `tools`) +- Missing required field (`name` or `description`) - Empty body (system prompt) -- Empty `tools` list -- One or more tools not in the user-facing allowlist - Duplicate `name` across files (the alphabetically-first file wins) Non-`.md` files in the agents directory (`stray.json`, `README.txt`, etc.) are @@ -211,13 +210,21 @@ ignored at the glob layer and never logged. ### Tool access -When `tools` is omitted from the frontmatter, the subagent inherits all session -tools including MCP tools. This is the recommended default — it matches Claude -Code's agent format and lets subagents use whatever capabilities the session has. +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. When `tools` is specified, it acts as a whitelist limiting which tools the -subagent can access. 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 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]`). + +Spawned subagents inherit the parent session's `session_dir` and current +`project_dir` as read-only grounding. That means file tools resolve against the +same session directory snapshot, and project-scoped instructions are loaded from +the inherited project root for future runs. ## Built-in agents @@ -227,8 +234,8 @@ definitions — you can edit or delete them. **research-assistant** — Deep web research with search and citation. Tools: `web_search`, `web_fetch`, `file_read`, `attach_file`. Timeout: 120s. -**code-analyst** — Analyze code, run commands, and review files. -Tools: (inherits all). Timeout: 120s. +**code-analyst** — Analyze code and review files. +Tools: filtered to the user-facing safe set when loaded from disk. Timeout: 120s. **summarizer** — Summarize documents and content concisely. Tools: `file_read`. Timeout: 60s. @@ -257,15 +264,18 @@ parent session should do next. - Cite file paths with line numbers when referencing specific content. ``` -Restart the daemon. The agent loads at startup after MCP servers connect (so -MCP tool names are resolvable) and appears in the `[available-subagents]` -discovery block. +Save the file. The next turn or subagent lookup reloads the on-disk definitions +and refreshes the `[available-subagents]` discovery block. If a tool name in your frontmatter doesn't match any registered tool, or falls outside the user-facing allowlist, the agent is skipped with a specific warning in the daemon log naming both the file and the disallowed tool — look there first when a new agent "doesn't show up." +If you edit a previously valid agent into an invalid state, the runtime drops it +from the active catalog on the next reload instead of serving the stale last +known-good version. + ## Limitations - Subagents are **single-turn**: they receive a task, run their tool loop, and diff --git a/evals/run-evals.sh b/evals/run-evals.sh index 92d7596ca..508d66a28 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -909,6 +909,10 @@ assert_skill_activation_soft_memory() { daemon_log_skill_loaded 'netclaw-memory' || stdout_tool_called 'find_memories' } +assert_skill_activation_subagent_authoring() { + daemon_log_skill_loaded 'subagent-authoring' +} + # User skills (non-system, from eval fixtures) assert_skill_activation_user_coding() { daemon_log_skill_loaded 'modern-csharp-coding-standards' @@ -1315,6 +1319,11 @@ run_all() { "Do you remember what database we decided to use?" \ "What do you know about my project preferences?" + run_case skill_activation_subagent_authoring "skill loaded" \ + "How do I create a custom subagent in Netclaw?" \ + "Walk me through authoring a new file-based subagent." \ + "What goes in a Netclaw agent definition file?" + # User skills (non-system, loaded from eval fixtures) run_case skill_activation_user_coding "skill loaded" \ "In C#, should I use a record or a class for this DTO?" \ diff --git a/feeds/skills/.system/files/subagent-authoring/SKILL.md b/feeds/skills/.system/files/subagent-authoring/SKILL.md index 02d1b9c6c..71261015b 100644 --- a/feeds/skills/.system/files/subagent-authoring/SKILL.md +++ b/feeds/skills/.system/files/subagent-authoring/SKILL.md @@ -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.1.0" + version: "1.2.1" --- # Subagent Authoring @@ -75,7 +75,7 @@ The markdown body below the closing `---` must also be non-empty. | Field | Default | Notes | |------|---------|-------| -| `tools` | (inherit all) | List of tool names. When omitted, the subagent inherits all session tools including MCP tools. When specified, acts as a whitelist to limit access. | +| `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. | | `modelRole` | `Compaction` | `Main` or `Compaction` (case-insensitive). Invalid values fall back to `Compaction`. | | `timeoutSeconds` | `60` | Wall-clock timeout for subagent execution. | | `visibility` | `user-facing` | Accepts `user-facing`, `UserFacing`, `internal`, or `Internal`. Invalid values fall back to `user-facing`. | @@ -88,34 +88,48 @@ Unknown fields are ignored. ```markdown --- name: notion-planner -description: Automates daily planning workflow in Notion +description: Summarizes local daily planning notes for the parent session timeoutSeconds: 120 +tools: [file_read] --- -You are a planning assistant that works with Notion. +You are a planning assistant that reviews daily planning notes. ## Goal -Create and update daily plans in the user's Notion workspace. +Summarize the latest planning notes and highlight next actions. ## Guidelines -- Use Notion MCP tools to search, fetch, and create/update pages -- If you encounter connectivity issues, report them clearly +- Use file_read to inspect local planning notes and related reference files +- If a referenced file is missing, report that clearly - Follow the user's existing plan format and structure ``` -This agent inherits all session tools (including Notion MCP tools) because no -`tools` field is specified. +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. ## Fail-loud loader behavior -At startup, invalid files are skipped with warnings. Common rejection reasons: +On the next turn or subagent lookup, invalid files are skipped with warnings. +Common rejection reasons: - missing or unparseable YAML frontmatter - missing required fields (`name`, `description`) - empty markdown body - duplicate `name` across files (first file in stable sorted order wins) +If you edit a previously valid file into an invalid state, the runtime drops it +from the active subagent catalog on the next reload instead of serving the stale +last-known-good definition. + +## Inherited parent context + +Spawned subagents inherit the parent session's `session_dir` and current +`project_dir` as read-only grounding. The child can use those paths for file +resolution and project instruction loading, but it does not mutate the parent +session's working context. + Non-`.md` files in `~/.netclaw/agents` are ignored. ## Relationship to skill routing (`metadata.subagent`) @@ -131,12 +145,17 @@ If that target is missing, internal-only, or malformed, activation fails deterministically with no inline fallback. Keep routed skill metadata aligned with real user-facing subagent definitions. +Routed skills go through the same loader + registry contract as explicit +`spawn_agent`: the next routed activation reloads the definition from disk +and inherits the parent session's `session_dir` and `project_dir` exactly +the same way. There is no separate code path for routed execution. + ## Verification checklist After creating or editing a subagent file: -1. restart `netclawd` (subagent files are loaded at startup) +1. save the file and trigger the next turn or subagent lookup 2. confirm the agent appears in `[available-subagents]` -3. run a small `spawn_agent` task to verify tools and output +3. run a small `spawn_agent` task to verify tools, inherited context, and output 4. if missing, check daemon logs for the rejection reason If the user has no agent files yet, `netclaw init` seeds starter definitions. diff --git a/openspec/changes/align-subagent-loading-and-parent-context/design.md b/openspec/changes/align-subagent-loading-and-parent-context/design.md new file mode 100644 index 000000000..51bac50a0 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/design.md @@ -0,0 +1,192 @@ +## Context + +Subagents are currently described as file-defined markdown documents under +`~/.netclaw/agents/*.md`, but operator guidance still says to restart the daemon +after editing them. That means the runtime behaves like a startup-only loader +even though the user-facing authoring loop wants live updates. At the same time, +the current session-context planning work gives the main session an explicit +`session_dir`, a persisted `ProjectDirectory`, and project-instruction loading, +but there is no matching contract saying delegated subagents inherit that same +grounding. + +This leaves three planning gaps: + +1. There is no defined reload boundary for file-defined subagents. +2. There is no defined fail-closed behavior for invalid edits during live reload. +3. There is no defined parent-context inheritance contract for spawned or routed + subagents. + +The active `subagent-explicit-model-selection` change already refers to startup +or reload-boundary validation, so this change needs to define that boundary in a +way later subagent changes can plug into. + +## Goals / Non-Goals + +**Goals:** + +- Pick up subagent definition add/update/delete changes without daemon restart. +- Keep the implementation simple by using an explicit reload boundary rather than + long-lived hidden state. +- Fail closed when an edited definition is no longer valid; do not keep serving a + stale definition after the source file has become invalid. +- Ensure delegated subagents inherit the parent session's `session_dir` and + current `project_dir` as a spawn-time snapshot. +- Ensure delegated subagents load project instructions from the inherited + `project_dir` using the same file precedence as the parent session. +- Keep `spawn_agent` and `metadata.subagent` routed activations on one shared + loading and inheritance contract. + +**Non-Goals:** + +- Hot-reloading model-provider configuration, tool grants, or other + `netclaw.json`-backed settings that already require restart-driven recovery. +- Turning subagents into persistent child sessions with independent working + directories or durable state. +- Letting a subagent mutate the parent session's `ProjectDirectory` or other + `WorkingContext` state. +- Retroactively changing the definition, prompt, or project context of a subagent + that is already running. +- Adding a separate interactive UI for subagent management. + +## Decisions + +### D1. Reload subagent definitions on demand before lookup + +**Decision:** The runtime reload boundary is on demand, immediately before +subagent-registry lookup for `spawn_agent` and `metadata.subagent` routed +execution. The registry tracks the last successful directory fingerprint/mtime +state for `~/.netclaw/agents` and reloads only when that state has changed. + +**Rationale:** Subagent definitions are only needed when a subagent is about to +run. An on-demand reload keeps the design simpler than a background watcher, +avoids extra concurrency surface, and still gives operators live-reload behavior +for the next activation. + +**Alternatives considered:** + +- Background `FileSystemWatcher` with push reload. Rejected for MVP because the + registry is not latency-sensitive enough to justify the extra complexity and + race surface. +- Startup-only loading. Rejected because it keeps the current slow authoring loop + and leaves the reload boundary undefined. + +### D2. Reload rebuilds the active snapshot from disk and drops invalid edits + +**Decision:** When reload is triggered, the runtime rebuilds the active +definition snapshot from disk using the same loader rules as startup. Valid +definitions enter the new snapshot; invalid, duplicate, or now-disallowed +definitions are excluded from the new snapshot and emit deterministic +diagnostics. The runtime SHALL NOT keep serving the prior version of a file that +no longer loads successfully. + +**Rationale:** Keeping a stale last-known-good definition after the operator has +changed the source file is a silent fallback. Excluding the invalid definition is +fail-closed and matches the repo's operational posture. + +**Alternatives considered:** + +- Per-file stale fallback to the old definition. Rejected because it hides the + fact that the source on disk and the active registry have diverged. +- Fail the entire registry reload when any file is invalid. Rejected because the + current startup/load behavior already treats definitions independently and we do + not want one bad file to hide unrelated valid changes. + +### D3. Running subagents keep an immutable definition snapshot + +**Decision:** Once a subagent starts, it keeps the resolved definition, tool set, +model-selection inputs, and inherited parent-context snapshot for the duration of +that run. Reloaded definitions affect only future activations. + +**Rationale:** Mid-run mutation would make subagent behavior nondeterministic and +hard to debug. Spawn-time snapshotting keeps actor execution stable. + +### D4. Subagents inherit parent `session_dir` and `project_dir` as read-only context + +**Decision:** Every subagent execution receives an immutable parent-context +snapshot containing the parent session identifier, parent `session_dir`, and the +parent's current `WorkingContext.ProjectDirectory` when set. + +The snapshot is read-only from the child. Subagent execution may use it for +prompt assembly, tool path/token resolution, and filesystem grounding, but it +does not let the child mutate the parent session's `WorkingContext`. + +**Rationale:** Delegated work should start from the same grounded workspace as +the parent session without forcing the caller to restate it manually. Making the +snapshot read-only preserves session ownership and avoids hidden side effects. + +### D5. Subagents load project instructions from the inherited project directory + +**Decision:** When the inherited parent-context snapshot includes a non-null +`project_dir`, subagent prompt assembly uses that directory to resolve project +identity files with the same precedence as the parent session: + +1. `.netclaw/AGENTS.md` +2. `CLAUDE.md` +3. `AGENTS.md` +4. `CONTEXT.md` + +The resulting project instructions are included in the subagent's system prompt. +If no project directory is present, no project instructions are added. + +**Rationale:** The delegated worker should receive the same project rules and +constraints the parent session is operating under. Requiring the caller to pass +this in ad hoc `context` text is redundant and brittle. + +### D6. Parent project changes affect future subagents only + +**Decision:** If the parent session changes `ProjectDirectory`, the new value is +used for subagents spawned after that change. Any subagent already running keeps +the prior spawn-time snapshot. + +**Rationale:** This matches the immutable-run contract and avoids in-flight prompt +or tool-grounding drift. + +### D7. Routed skill execution uses the same contracts as `spawn_agent` + +**Decision:** `metadata.subagent` routing uses the same reloadable registry +lookup, same definition snapshot semantics, and same parent-context inheritance +contract as explicit `spawn_agent` execution. + +**Rationale:** Routed skills are another entry point into subagent execution, not +an alternate subagent model. Keeping one contract avoids divergence between skill +routing and explicit delegation. + +## Risks / Trade-offs + +- **[Risk]** Reload-before-lookup adds filesystem I/O to subagent spawn and routed + skill execution. -> **Mitigation:** only probe reload state when the definitions + directory fingerprint/mtime changes; unchanged requests continue using the last + successful snapshot. +- **[Risk]** Operators may be surprised that an invalid edit removes the active + definition immediately. -> **Mitigation:** emit explicit diagnostics and update + runbooks/skills to describe the fail-closed behavior. +- **[Risk]** Context inheritance could accidentally broaden child filesystem + behavior. -> **Mitigation:** inherited directories are grounding inputs only; + child tool authorization and file-root policy remain bounded by the parent + session's existing audience and tool policy. +- **[Trade-off]** On-demand reload is not instantaneous in the absence of a new + activation. -> **Mitigation:** this is acceptable because subagent definitions + only matter when a subagent is about to run, and the design stays much simpler + than a background watcher. + +## Migration Plan + +1. Introduce a reloadable subagent registry snapshot and switch `spawn_agent` + lookups to use it. +2. Reuse the same lookup path for `metadata.subagent` routing. +3. Add a parent-context snapshot object for subagent execution and wire it from + the session actor. +4. Update subagent prompt assembly to use inherited `project_dir` for project + instructions. +5. Update operator guidance (`docs/runbooks/subagents.md`, + `feeds/skills/.system/files/subagent-authoring/SKILL.md`, and any related + routing guidance) to reflect live reload and inherited parent context. + +Rollback is straightforward: remove reload-before-lookup and return to +startup-only subagent loading, while keeping the old documentation in sync. + +## Open Questions + +- None for the change-plan phase. Follow-on implementation work can decide the + exact fingerprinting mechanism (directory mtime, per-file hash, or equivalent) + so long as the observable reload and fail-closed contracts remain intact. diff --git a/openspec/changes/align-subagent-loading-and-parent-context/proposal.md b/openspec/changes/align-subagent-loading-and-parent-context/proposal.md new file mode 100644 index 000000000..4fb394b74 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/proposal.md @@ -0,0 +1,82 @@ +## Why + +File-defined subagents currently load at daemon startup, and both +`docs/runbooks/subagents.md` and the `subagent-authoring` system skill tell +operators to restart after every edit. That keeps authoring loops slow, leaves +`spawn_agent` and `metadata.subagent` routed activations on stale registry data, +and makes the active `subagent-explicit-model-selection` planning work refer to +an undefined "reload boundary." + +Delegated subagents also do not yet have a planning contract for inheriting the +parent session's filesystem and project context. The parent session can know its +`session_dir` and `project_dir`, load project identity files, and accumulate +working context, but spawned subagents are not guaranteed to start from the same +grounding. That forces callers to restate project details in per-call context and +creates avoidable drift between main-session and subagent behavior. + +## Source PRDs + +- `PRD-001`: reliable delegation, persistent session continuity, and predictable + runtime behavior. +- `PRD-002`: default-deny, fail-closed behavior for delegated execution and + operator-visible diagnostics. +- `PRD-007`: project instructions, local memory, and working-directory grounded + tool use. +- `PRD-009`: consistent transport-agnostic execution semantics for all session + entry points, including routed subagent execution. + +## What Changes + +- Define a live subagent-definition loading contract for + `~/.netclaw/agents/*.md` so `spawn_agent` and `metadata.subagent` routed + activations pick up add/update/delete changes without daemon restart. +- Define a deterministic reload boundary before subagent lookup using a + reloadable registry snapshot rather than startup-only loading. +- Define fail-closed behavior for invalid edits: invalid or no-longer-loadable + definitions disappear from the active registry with explicit diagnostics rather + than continuing to serve a stale last-known-good version. +- Define parent-context inheritance for subagent executions so the child receives + the parent session's `session_dir` and current `project_dir` as a read-only + execution snapshot. +- Define inherited project-instruction loading for subagents so delegated work + sees the same project identity file precedence as the parent session. +- Align explicit `spawn_agent` delegation and declarative `metadata.subagent` + routing so both paths use the same live-loaded registry and inherited parent + context contract. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `netclaw-subagents`: add live definition reload, fail-closed invalid-edit + behavior, immutable parent-context snapshots, and consistent execution + semantics across subagent entry points. +- `session-cwd`: define how the session `ProjectDirectory` flows into spawned + subagent executions and remains read-only from the child. +- `project-instructions`: define inherited project-instruction loading for + subagent system prompts using the same file precedence as the parent session. +- `skill-execution-routing`: align `metadata.subagent` routing with the same + reloadable registry and parent-context inheritance behavior as `spawn_agent`. + +## Impact + +- **Runtime wiring**: `FileSubAgentDefinitionLoader`, `SubAgentDefinitionRegistry` + (or equivalent registry service), `spawn_agent` lookup path, and routed-skill + dispatch will need a shared reloadable snapshot flow. +- **Delegation context**: the session actor's subagent spawn pipeline will need a + child execution context that carries parent `session_dir` and `project_dir` + without widening permissions. +- **Prompt assembly**: subagent prompt construction will need to load project + identity files from the inherited `project_dir` when present. +- **Security/operations**: invalid subagent edits must fail closed with + actionable diagnostics; stale definitions must not remain silently active. +- **Docs and skills**: the subagent runbook and `subagent-authoring` guidance + need to stop instructing operators to restart after every edit and instead + describe live reload and inherited parent context. +- **Compatibility**: running subagents keep the definition and parent-context + snapshot captured at spawn time; only subsequent spawns/routed activations see + reloaded definitions or later parent project changes. diff --git a/openspec/changes/align-subagent-loading-and-parent-context/specs/netclaw-subagents/spec.md b/openspec/changes/align-subagent-loading-and-parent-context/specs/netclaw-subagents/spec.md new file mode 100644 index 000000000..9fdfaccb7 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/netclaw-subagents/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: File-defined subagent registry reloads without daemon restart + +The system SHALL resolve file-defined subagent definitions from a reloadable +registry backed by `~/.netclaw/agents/*.md`. Before resolving a user-facing +subagent for `spawn_agent` or routed skill execution, the runtime SHALL detect +whether the definitions directory changed since the last successful snapshot and +SHALL reload the registry when needed. + +Reloaded snapshots SHALL apply add, update, and delete changes to subsequent +subagent executions without daemon restart. + +#### Scenario: Added subagent becomes available on next activation + +- **GIVEN** the active registry snapshot does not include `ops-helper` +- **AND** the operator adds a valid `ops-helper.md` definition under + `~/.netclaw/agents` +- **WHEN** the next `spawn_agent` or routed-skill lookup occurs +- **THEN** the runtime reloads the registry before lookup +- **AND** `ops-helper` is available for that activation + +#### Scenario: Edited subagent definition takes effect on next activation + +- **GIVEN** `ops-helper` is already loaded from disk +- **AND** the operator edits its prompt or metadata on disk to a new valid state +- **WHEN** the next subagent lookup occurs +- **THEN** the runtime reloads the registry before lookup +- **AND** the next spawned `ops-helper` run uses the updated definition + +#### Scenario: Deleted subagent disappears on next activation + +- **GIVEN** `ops-helper` is present in the active registry snapshot +- **WHEN** its source file is deleted from `~/.netclaw/agents` +- **AND** the next subagent lookup occurs +- **THEN** the reloaded registry no longer contains `ops-helper` +- **AND** later attempts to resolve it fail deterministically + +### Requirement: Invalid reload changes fail closed + +The runtime SHALL exclude reloaded subagent definitions that no longer pass +loader validation from the active registry snapshot and SHALL emit +deterministic diagnostics. The system SHALL NOT continue serving the prior +version of an invalidated definition. + +#### Scenario: Invalid edit removes previously valid definition + +- **GIVEN** `ops-helper` was valid in the previous registry snapshot +- **AND** the operator edits `ops-helper.md` so it becomes invalid +- **WHEN** the next subagent lookup triggers reload +- **THEN** `ops-helper` is absent from the new active snapshot +- **AND** the runtime emits diagnostics identifying the file and rejection reason +- **AND** resolving `ops-helper` fails instead of using the stale prior version + +### Requirement: Subagent runs use immutable definition snapshots + +Once a subagent run starts, it SHALL keep the resolved definition snapshot for +the duration of that run. Later registry reloads SHALL affect only future +subagent executions. + +#### Scenario: Running subagent ignores mid-run definition edit + +- **GIVEN** a subagent run has already started from a valid definition snapshot +- **WHEN** the source definition file changes on disk before that run completes +- **THEN** the in-flight subagent keeps its original definition snapshot +- **AND** only later activations use the reloaded definition + +### Requirement: Subagent executions inherit parent context snapshot + +When a session launches a subagent, the runtime SHALL capture an immutable +parent-context snapshot for that run. The snapshot SHALL include the parent +session identifier, parent `session_dir`, and the parent's current +`WorkingContext.ProjectDirectory` when set. + +The inherited snapshot provides execution grounding for the child and SHALL NOT +broaden the child beyond the parent session's existing audience, tool, or file +access posture. + +#### Scenario: Spawned subagent inherits parent session and project directories + +- **GIVEN** a parent session has `session_dir` `/tmp/netclaw/sessions/abc` +- **AND** `WorkingContext.ProjectDirectory` is `/home/user/workspaces/netclaw` +- **WHEN** the session spawns a subagent +- **THEN** the child run receives both directory values in its execution snapshot + +#### Scenario: Parent project switch affects only later subagents + +- **GIVEN** a parent session spawns subagent A with project directory + `/home/user/workspaces/project-a` +- **AND** the parent later switches to `/home/user/workspaces/project-b` +- **WHEN** subagent A is still running +- **THEN** subagent A keeps the project-a snapshot +- **AND** only subagents spawned after the switch inherit project-b diff --git a/openspec/changes/align-subagent-loading-and-parent-context/specs/project-instructions/spec.md b/openspec/changes/align-subagent-loading-and-parent-context/specs/project-instructions/spec.md new file mode 100644 index 000000000..11e581c05 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/project-instructions/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Spawned subagents use inherited project instructions + +The system SHALL resolve project identity files from the inherited parent +`project_dir` using the same precedence as the parent session and SHALL include +the resulting project instructions in the subagent system prompt when the +inherited `project_dir` is non-null. + +#### Scenario: Subagent prompt includes inherited project instructions + +- **GIVEN** a parent session has project directory set to + `/home/user/workspaces/netclaw` +- **AND** `/home/user/workspaces/netclaw/AGENTS.md` exists +- **WHEN** the parent spawns a subagent +- **THEN** the subagent system prompt includes the content of that identity file + +#### Scenario: No inherited project directory means no project instructions + +- **GIVEN** a parent session has no project directory set +- **WHEN** the parent spawns a subagent +- **THEN** the subagent system prompt contains no project-specific identity file + content diff --git a/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md b/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md new file mode 100644 index 000000000..5b160bed7 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Project directory flows to spawned subagents as read-only context + +The runtime SHALL copy the current `WorkingContext.ProjectDirectory` into the +child's immutable execution snapshot when a session spawns or routes execution +into a subagent and the directory is set. The inherited value is read-only from +the child, and subagent execution SHALL NOT mutate the parent session's +`ProjectDirectory` or other `WorkingContext` state. + +#### Scenario: Subagent inherits current project directory + +- **GIVEN** a session has `WorkingContext.ProjectDirectory` set to + `/home/user/workspaces/netclaw` +- **WHEN** the session starts a subagent run +- **THEN** the child execution snapshot contains + `/home/user/workspaces/netclaw` + +#### Scenario: Subagent does not change parent project directory + +- **GIVEN** a session has `WorkingContext.ProjectDirectory` set to + `/home/user/workspaces/netclaw` +- **WHEN** a spawned subagent completes +- **THEN** the parent session still has the same `ProjectDirectory` +- **AND** no child-side action implicitly rewrites the parent working context diff --git a/openspec/changes/align-subagent-loading-and-parent-context/specs/skill-execution-routing/spec.md b/openspec/changes/align-subagent-loading-and-parent-context/specs/skill-execution-routing/spec.md new file mode 100644 index 000000000..3344787b4 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/skill-execution-routing/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Routed subagent execution uses live registry and parent context + +When a skill activation resolves through `metadata.subagent`, the runtime SHALL +use the same reloadable subagent registry and the same immutable parent-context +snapshot contract as explicit `spawn_agent` execution. + +#### Scenario: Routed activation picks up edited subagent definition + +- **GIVEN** a skill routes through `metadata.subagent: operations-helper` +- **AND** `operations-helper.md` is edited to a new valid state on disk +- **WHEN** the next routed activation occurs +- **THEN** the runtime reloads the subagent registry before routing +- **AND** the routed activation uses the updated definition + +#### Scenario: Routed activation fails closed after invalid edit + +- **GIVEN** a skill routes through `metadata.subagent: operations-helper` +- **AND** `operations-helper.md` is edited into an invalid state on disk +- **WHEN** the next routed activation occurs +- **THEN** routing fails deterministically against the reloaded registry +- **AND** inline fallback is not attempted +- **AND** the stale prior definition is not used diff --git a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md new file mode 100644 index 000000000..3c5a35c81 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md @@ -0,0 +1,41 @@ +## 1. Reloadable subagent registry + +- [x] 1.1 Introduce a reloadable definition-registry path for `~/.netclaw/agents/*.md` that detects directory changes before `spawn_agent` and `metadata.subagent` lookups. +- [x] 1.2 Rebuild registry snapshots from disk on reload using the same validation/loader rules as startup. +- [x] 1.3 Ensure invalid, duplicate, or no-longer-loadable definitions are excluded from the active snapshot and surface deterministic diagnostics instead of leaving stale definitions active. +- [x] 1.4 Add tests covering add, update, delete, and invalid-edit reload behavior without daemon restart. + +## 2. Shared lookup alignment across subagent entry points + +- [x] 2.1 Update explicit `spawn_agent` lookup to use the reloadable registry snapshot. +- [x] 2.2 Update `metadata.subagent` routed execution to use the same reloadable registry snapshot and failure semantics. +- [x] 2.3 Add tests proving explicit delegation and routed skill execution both pick up reloaded definitions on the next activation. + +## 3. Parent-context snapshot for subagent execution + +- [x] 3.1 Introduce a subagent execution-context snapshot carrying parent session id, `session_dir`, and current `WorkingContext.ProjectDirectory`. +- [x] 3.2 Wire the parent-context snapshot into spawned and routed subagent execution paths. +- [x] 3.3 Ensure child execution treats inherited `session_dir` and `project_dir` as read-only context and does not mutate parent `WorkingContext` state. +- [x] 3.4 Add tests covering parent project unset, parent project set, and parent project changed between two subagent runs. + +## 4. Inherited project instructions for subagents + +- [x] 4.1 Update subagent prompt assembly to load project identity files from inherited `project_dir` using the same precedence as the parent session. +- [x] 4.2 Ensure no project instructions are added when the inherited parent context has no `project_dir`. +- [x] 4.3 Add tests proving spawned subagents receive inherited project instructions and that running subagents keep their spawn-time snapshot after later parent project changes. + +## 5. Documentation and system guidance + +- [x] 5.1 Update `docs/runbooks/subagents.md` to replace restart-required authoring guidance with live-reload guidance and fail-closed invalid-edit behavior. +- [x] 5.2 Update `feeds/skills/.system/files/subagent-authoring/SKILL.md` to describe live reload, inherited parent context, and the new verification workflow. +- [x] 5.3 Update any relevant skill-routing guidance so `metadata.subagent` documentation matches the shared reload and inheritance contract. +- [x] 5.4 Run the eval suite if system skill content changes as part of the implementation PR. + +## 6. Verification and OpenSpec completion + +- [x] 6.1 Run targeted tests for subagent loading, routed skill execution, prompt assembly, and parent-context inheritance. +- [x] 6.2 Run `dotnet slopwatch analyze` and `./scripts/Add-FileHeaders.ps1 -Verify`. +- [x] 6.3 Run `openspec validate align-subagent-loading-and-parent-context`. +- [ ] 6.4 `/opsx-verify align-subagent-loading-and-parent-context` after implementation lands. +- [ ] 6.5 `/opsx-sync align-subagent-loading-and-parent-context` to merge the deltas into the main specs. +- [ ] 6.6 `/opsx-archive align-subagent-loading-and-parent-context` after merge. diff --git a/src/Netclaw.Actors.Tests/Memory/FakeNetclawTool.cs b/src/Netclaw.Actors.Tests/Memory/FakeNetclawTool.cs index 206470537..07696693f 100644 --- a/src/Netclaw.Actors.Tests/Memory/FakeNetclawTool.cs +++ b/src/Netclaw.Actors.Tests/Memory/FakeNetclawTool.cs @@ -27,6 +27,7 @@ public FakeNetclawTool(string name, string result, string grantCategory = "built public bool WasCalled { get; private set; } public IDictionary? LastArguments { get; private set; } + public ToolExecutionContext? LastContext { get; private set; } public AITool ToAITool() => AIFunctionFactory.Create(() => _result, name: Name, description: Description); @@ -36,4 +37,10 @@ public Task ExecuteAsync(IDictionary? arguments, Cancel LastArguments = arguments; return Task.FromResult(_result); } + + public Task ExecuteAsync(IDictionary? arguments, ToolExecutionContext context, CancellationToken ct = default) + { + LastContext = context; + return ExecuteAsync(arguments, ct); + } } diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs index 24867620b..1c8648d34 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs @@ -49,7 +49,8 @@ public static IServiceCollection AddLlmSessionCompositeRecords(this IServiceColl sp.GetService(), sp.GetService(), sp.GetService(), - sp.GetService())); + sp.GetService(), + sp.GetService())); } return services; diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 0a8e6f415..2aa8767c4 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -154,6 +154,7 @@ You specialize in daemon health checks. registry, toolAccessPolicy, approvalService: null, + new StaticSystemPromptProvider(MainIdentityMarker), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); registry.Register(new SpawnAgentTool(subAgentRegistry, spawner, subAgentPaths)); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 092979f49..095f964bd 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -78,10 +78,147 @@ public async Task Tool_call_executes_and_continues() Assert.True(result.Success); Assert.True(fakeTool.WasCalled); + Assert.NotNull(fakeTool.LastContext); // Second LLM call returns text (tool calls only on first call) Assert.Contains("Response #2", result.Output); } + [Fact] + public async Task Tool_execution_inherits_parent_session_and_project_directories() + { + var fakeTool = new FakeNetclawTool("inspect_context", "ok"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = + [ + new FunctionCallContent("call-context", "inspect_context") + ] + }; + + var definition = CreateDefinition([fakeTool]); + var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Inspect the inherited paths.", + Timeout = TimeSpan.FromSeconds(5), + ParentSessionDirectory = "/tmp/netclaw/sessions/abc", + ParentProjectDirectory = "/home/user/workspaces/netclaw" + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeTool.LastContext); + Assert.Equal("/tmp/netclaw/sessions/abc", fakeTool.LastContext!.SessionDirectory); + Assert.Equal("/home/user/workspaces/netclaw", fakeTool.LastContext.ProjectDirectory); + } + + [Fact] + public async Task Tool_execution_with_no_parent_project_directory_passes_null_through() + { + var fakeTool = new FakeNetclawTool("inspect_context", "ok"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = [new FunctionCallContent("call-no-project", "inspect_context")] + }; + + var definition = CreateDefinition([fakeTool]); + var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Inspect inherited paths.", + Timeout = TimeSpan.FromSeconds(5), + ParentSessionDirectory = "/tmp/netclaw/sessions/xyz" + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeTool.LastContext); + Assert.Equal("/tmp/netclaw/sessions/xyz", fakeTool.LastContext!.SessionDirectory); + Assert.Null(fakeTool.LastContext.ProjectDirectory); + } + + [Fact] + public async Task Each_spawn_snapshots_its_own_parent_project_directory() + { + // Mirrors D6: parent project changes between two activations show up + // in the second subagent run but never leak into the first. + var firstTool = new FakeNetclawTool("inspect_context", "ok"); + var firstClient = new FakeChatClient + { + ToolCallsOnFirstCall = [new FunctionCallContent("call-1", "inspect_context")] + }; + var firstAgent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([firstTool]), firstClient)); + + var firstResult = await firstAgent.Ask( + new RunSubAgent + { + Task = "First run.", + Timeout = TimeSpan.FromSeconds(5), + ParentProjectDirectory = "/home/user/workspaces/project-a" + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.True(firstResult.Success); + Assert.Equal("/home/user/workspaces/project-a", firstTool.LastContext!.ProjectDirectory); + + var secondTool = new FakeNetclawTool("inspect_context", "ok"); + var secondClient = new FakeChatClient + { + ToolCallsOnFirstCall = [new FunctionCallContent("call-2", "inspect_context")] + }; + var secondAgent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([secondTool]), secondClient)); + + var secondResult = await secondAgent.Ask( + new RunSubAgent + { + Task = "Second run after parent project switch.", + Timeout = TimeSpan.FromSeconds(5), + ParentProjectDirectory = "/home/user/workspaces/project-b" + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.True(secondResult.Success); + Assert.Equal("/home/user/workspaces/project-b", secondTool.LastContext!.ProjectDirectory); + Assert.Equal("/home/user/workspaces/project-a", firstTool.LastContext!.ProjectDirectory); + } + + [Fact] + public async Task System_prompt_includes_inherited_project_instructions_when_present() + { + var fakeClient = new FakeChatClient(); + var definition = CreateDefinition() with { ProjectInstructions = "Project rules: prefer C#." }; + var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); + + var result = await agent.Ask( + new RunSubAgent { Task = "Do the thing.", Timeout = TimeSpan.FromSeconds(5) }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeClient.LastReceivedMessages); + var systemMessage = fakeClient.LastReceivedMessages!.Single(m => m.Role == ChatRole.System); + Assert.Contains("You are a test agent.", systemMessage.Text); + Assert.Contains("Project rules: prefer C#.", systemMessage.Text); + } + + [Fact] + public async Task System_prompt_omits_project_section_when_no_instructions_inherited() + { + var fakeClient = new FakeChatClient(); + var definition = CreateDefinition(); + var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient)); + + var result = await agent.Ask( + new RunSubAgent { Task = "Do the thing.", Timeout = TimeSpan.FromSeconds(5) }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(fakeClient.LastReceivedMessages); + var systemMessage = fakeClient.LastReceivedMessages!.Single(m => m.Role == ChatRole.System); + Assert.Equal("You are a test agent.", systemMessage.Text); + } + [Fact] public async Task Approval_gated_tool_is_denied_inside_subagent() { diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentDefinitionRegistryTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentDefinitionRegistryTests.cs index c60e2e864..c1b627072 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentDefinitionRegistryTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentDefinitionRegistryTests.cs @@ -117,4 +117,19 @@ public void GetUserFacing_returns_sorted_by_name() Assert.Equal("alpha-agent", userFacing[0].Name); Assert.Equal("zebra-agent", userFacing[1].Name); } + + [Fact] + public void ReplaceFileProfiles_replaces_only_file_backed_entries() + { + var registry = new SubAgentDefinitionRegistry(); + registry.Register(CreateProfile("internal-platform-agent", SubAgentVisibility.Internal)); + + registry.ReplaceFileProfiles([CreateProfile("file-agent-a"), CreateProfile("file-agent-b")]); + registry.ReplaceFileProfiles([CreateProfile("file-agent-b")]); + + var all = registry.GetAll(); + Assert.Contains(all, p => p.Name == "internal-platform-agent"); + Assert.DoesNotContain(all, p => p.Name == "file-agent-a"); + Assert.Contains(all, p => p.Name == "file-agent-b"); + } } diff --git a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs index 96fc285f6..b058b4bc6 100644 --- a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs @@ -708,6 +708,7 @@ private static SubAgentSpawner CreateSubAgentSpawner() registry, policy, approvalService: null, + NullSystemPromptProvider.Instance, NullLogger.Instance); } diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 6ca205fc6..57218dc50 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -153,6 +153,7 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers private readonly Skills.SkillRegistry? _skillRegistry; private readonly SubAgentDefinitionRegistry? _subAgentRegistry; private readonly SubAgentSpawner? _subAgentSpawner; + private readonly FileSubAgentDefinitionLoader? _subAgentLoader; // Memory recall state (transient — reset at turn boundaries and compaction) private readonly SessionRecallManager _recallManager = new(); @@ -199,6 +200,7 @@ public LlmSessionActor( _skillRegistry = tools?.SkillRegistry; _subAgentRegistry = tools?.SubAgentRegistry; _subAgentSpawner = tools?.SubAgentSpawner; + _subAgentLoader = tools?.SubAgentLoader; _toolExecutor = tools?.ToolExecutor; _auditLogger = tools?.AuditLogger; _toolAccessPolicy = tools?.AccessPolicy; @@ -2694,6 +2696,8 @@ private bool TryHandleRoutedSlashCommand(SkillEntry skill, string remainder, IRe return true; } + _subAgentLoader?.SyncInto(_subAgentRegistry); + var profile = _subAgentRegistry.TryGetByName(routedSubagent); if (profile is null) { @@ -2786,6 +2790,7 @@ private async Task ExecuteRoutedSkillAsync( Audience = _currentTurnSource is null ? null : _currentTurnSource.Audience.ToWireValue(), Boundary = _currentTurnSource?.Boundary, ChannelType = _currentTurnSource is null ? null : _currentTurnSource.ChannelType.ToWireValue(), + ProjectDirectory = _state.WorkingContext.ProjectDirectory, SupportsInteractiveApproval = false, }; diff --git a/src/Netclaw.Actors/Sessions/SessionDependencies.cs b/src/Netclaw.Actors/Sessions/SessionDependencies.cs index 18b1f7b80..acf0ed8db 100644 --- a/src/Netclaw.Actors/Sessions/SessionDependencies.cs +++ b/src/Netclaw.Actors/Sessions/SessionDependencies.cs @@ -35,7 +35,8 @@ public sealed record SessionToolServices( Skills.SkillRegistry? SkillRegistry, IToolApprovalService? ApprovalService = null, SubAgentDefinitionRegistry? SubAgentRegistry = null, - SubAgentSpawner? SubAgentSpawner = null); + SubAgentSpawner? SubAgentSpawner = null, + FileSubAgentDefinitionLoader? SubAgentLoader = null); /// /// Memory infrastructure for recall, checkpoint, and curation. diff --git a/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs b/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs index 19bdd9536..93258e9cf 100644 --- a/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs +++ b/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs @@ -25,6 +25,7 @@ public sealed partial class SpawnAgentTool : NetclawTool private readonly SubAgentSpawner _spawner; private readonly NetclawPaths _paths; private readonly SubAgentConfig _subAgentConfig; + private readonly FileSubAgentDefinitionLoader? _loader; public record Params( [property: Description("Name of the subagent to invoke (see available-subagents in context)")] @@ -39,12 +40,14 @@ public record Params( string? Context = null); public SpawnAgentTool(SubAgentDefinitionRegistry registry, SubAgentSpawner spawner, NetclawPaths paths, - SubAgentConfig? subAgentConfig = null) + SubAgentConfig? subAgentConfig = null, + FileSubAgentDefinitionLoader? loader = null) { _registry = registry; _spawner = spawner; _paths = paths; _subAgentConfig = subAgentConfig ?? new SubAgentConfig(); + _loader = loader; } protected override Task ExecuteAsync(Params args, CancellationToken ct) @@ -63,6 +66,8 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon if (string.IsNullOrWhiteSpace(args.Task)) return "Error: 'task' parameter is required."; + _loader?.SyncInto(_registry); + var profile = _registry.TryGetByName(args.Agent); if (profile is null || profile.Visibility != SubAgentVisibility.UserFacing) { diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 68909ee8a..f4547b143 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -104,10 +104,11 @@ private void Idle() var scopeId = !string.IsNullOrWhiteSpace(msg.SessionScopeId) ? msg.SessionScopeId! : $"subagent/{_definition.Name}/{Guid.NewGuid():N}"; - _toolExecutionContext = new ToolExecutionContext(scopeId, null); + _toolExecutionContext = new ToolExecutionContext(scopeId, msg.ParentSessionDirectory); _toolExecutionContext.Audience = msg.Audience ?? TrustAudience.Personal.ToWireValue(); _toolExecutionContext.Boundary = msg.Boundary; _toolExecutionContext.ChannelType = msg.ChannelType; + _toolExecutionContext.ProjectDirectory = msg.ParentProjectDirectory; _toolExecutionContext.SupportsInteractiveApproval = _approvalBridge is not null; _executionCts = new CancellationTokenSource(); var self = Self; // Capture before callback — Self requires active actor context @@ -119,7 +120,7 @@ private void Idle() // Build initial conversation: system prompt (from file, verbatim) + task as user message. // If the caller supplied runtime context, prefix it onto the user message so the // system prompt stays reproducible across invocations. - _history.Add(new AiChatMessage(Microsoft.Extensions.AI.ChatRole.System, _definition.SystemPrompt)); + _history.Add(new AiChatMessage(Microsoft.Extensions.AI.ChatRole.System, BuildSystemPrompt(_definition))); _history.Add(new AiChatMessage(Microsoft.Extensions.AI.ChatRole.User, BuildUserMessage(msg.RuntimeContext, msg.Task))); _log.Info("SubAgent [{AgentName}] starting (tools={ToolCount}, timeout={Timeout})", @@ -495,12 +496,21 @@ private static ToolExecutionContext CreatePerToolExecutionContext(ToolExecutionC Boundary = source.Boundary, RequestedTimeoutSeconds = source.RequestedTimeoutSeconds, ChannelType = source.ChannelType, + ProjectDirectory = source.ProjectDirectory, SupportsInteractiveApproval = source.SupportsInteractiveApproval, OnSubAgentActivity = source.OnSubAgentActivity, SpawnChildActor = source.SpawnChildActor, ApprovalBridge = source.ApprovalBridge }; + private static string BuildSystemPrompt(SubAgentDefinition definition) + { + if (string.IsNullOrWhiteSpace(definition.ProjectInstructions)) + return definition.SystemPrompt; + + return SystemPromptAssembler.Assemble(agents: definition.SystemPrompt, projectInstructions: definition.ProjectInstructions); + } + /// Singleton timeout marker message. private sealed class SubAgentTimeout { diff --git a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs index c134bc45d..7affe9703 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -34,6 +34,11 @@ public sealed record SubAgentDefinition /// Whether successful free-form output should be converted into structured findings. /// public bool EmitStructuredFindings { get; init; } + + /// + /// Optional project-scoped identity content inherited from the parent session. + /// + public string? ProjectInstructions { get; init; } } /// @@ -72,6 +77,16 @@ public sealed record RunSubAgent : INoSerializationVerificationNeeded public string? ChannelType { get; init; } + /// + /// Parent session's session directory snapshot when the subagent was spawned. + /// + public string? ParentSessionDirectory { get; init; } + + /// + /// Parent session's project directory snapshot when the subagent was spawned. + /// + public string? ParentProjectDirectory { get; init; } + /// /// Parent session's approval bridge. When provided, the sub-agent can route /// approval requests back to the interactive user instead of auto-denying. diff --git a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs index c828428e5..c681b1596 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs @@ -25,6 +25,7 @@ public sealed class SubAgentSpawner private readonly ToolRegistry _toolRegistry; private readonly ToolAccessPolicy _toolAccessPolicy; private readonly IToolApprovalService? _approvalService; + private readonly ISystemPromptProvider _promptProvider; private readonly ILogger _logger; public SubAgentSpawner( @@ -32,12 +33,14 @@ public SubAgentSpawner( ToolRegistry toolRegistry, ToolAccessPolicy toolAccessPolicy, IToolApprovalService? approvalService, + ISystemPromptProvider promptProvider, ILogger logger) { _chatClientProvider = chatClientProvider; _toolRegistry = toolRegistry; _toolAccessPolicy = toolAccessPolicy; _approvalService = approvalService; + _promptProvider = promptProvider; _logger = logger; } @@ -85,7 +88,8 @@ public async Task SpawnAsync( SystemPrompt = AppendSystemPromptOverlay(profile.SystemPrompt, systemPromptOverlay), Tools = tools, ModelRole = profile.ModelRole, - EmitStructuredFindings = profile.EmitStructuredFindings + EmitStructuredFindings = profile.EmitStructuredFindings, + ProjectInstructions = ResolveProjectInstructions(context) }; var runId = Guid.NewGuid().ToString("N"); @@ -123,6 +127,8 @@ public async Task SpawnAsync( Audience = context.Audience, Boundary = context.Boundary, ChannelType = context.ChannelType, + ParentSessionDirectory = context.SessionDirectory, + ParentProjectDirectory = context.ProjectDirectory, Cancellation = ct, ApprovalBridge = context.ApprovalBridge }, @@ -242,4 +248,13 @@ private static string AppendSystemPromptOverlay(string basePrompt, string? overl "[Skill Overlay]\n", overlay.Trim()); } + + private string? ResolveProjectInstructions(ToolExecutionContext context) + { + if (string.IsNullOrWhiteSpace(context.ProjectDirectory)) + return null; + + var audience = SecurityPolicyDefaults.ParseAudienceOrPublic(context.Audience); + return _promptProvider.GetProjectInstructions(audience, context.ProjectDirectory); + } } diff --git a/src/Netclaw.Actors/Tools/SkillLoadTool.cs b/src/Netclaw.Actors/Tools/SkillLoadTool.cs index 8bebd963f..f8577a3ea 100644 --- a/src/Netclaw.Actors/Tools/SkillLoadTool.cs +++ b/src/Netclaw.Actors/Tools/SkillLoadTool.cs @@ -12,6 +12,7 @@ using Netclaw.Configuration; using Netclaw.Security.Skills; using Netclaw.Tools; +using Netclaw.Security; namespace Netclaw.Actors.Tools; @@ -30,6 +31,7 @@ public sealed partial class SkillLoadTool : NetclawTool private readonly SubAgentDefinitionRegistry? _subAgentRegistry; private readonly SubAgentSpawner? _subAgentSpawner; private readonly SkillSyncConfig _skillSyncConfig; + private readonly FileSubAgentDefinitionLoader? _subAgentLoader; private readonly ILogger? _logger; public record Params( @@ -47,7 +49,8 @@ public SkillLoadTool( SubAgentDefinitionRegistry? subAgentRegistry = null, SubAgentSpawner? subAgentSpawner = null, SkillSyncConfig? skillSyncConfig = null, - ILogger? logger = null) + ILogger? logger = null, + FileSubAgentDefinitionLoader? subAgentLoader = null) { _skillRegistry = skillRegistry; _scanner = scanner; @@ -55,6 +58,7 @@ public SkillLoadTool( _subAgentRegistry = subAgentRegistry; _subAgentSpawner = subAgentSpawner; _skillSyncConfig = skillSyncConfig ?? new SkillSyncConfig(); + _subAgentLoader = subAgentLoader; _logger = logger; } @@ -90,6 +94,8 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon return $"Skill '{name}' routes to subagent '{decision.RoutedSubagent}', but routed skill execution is unavailable in this runtime."; } + _subAgentLoader?.SyncInto(_subAgentRegistry); + if (string.IsNullOrWhiteSpace(args.Task)) { return $"Skill '{name}' routes to subagent '{decision.RoutedSubagent}'. Provide a non-empty task when invoking skill_load for this skill."; @@ -177,5 +183,4 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon return sb.ToString(); } - } diff --git a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs index c9b555ace..07751454f 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs @@ -73,9 +73,18 @@ public static ToolRegistry WithSkillTools( SubAgentDefinitionRegistry? subAgentRegistry = null, SubAgentSpawner? subAgentSpawner = null, SkillSyncConfig? skillSyncConfig = null, + FileSubAgentDefinitionLoader? subAgentLoader = null, ILogger? skillLoadLogger = null) { - registry.Register(new SkillLoadTool(skillRegistry, scanner, sessionMetrics, subAgentRegistry, subAgentSpawner, skillSyncConfig, skillLoadLogger)); + registry.Register(new SkillLoadTool( + skillRegistry, + scanner, + sessionMetrics, + subAgentRegistry, + subAgentSpawner, + skillSyncConfig, + skillLoadLogger, + subAgentLoader)); registry.Register(new SkillReadResourceTool(skillRegistry, scanner, skillSyncConfig)); registry.Register(new SkillManageTool(skillRegistry, skillIndexLayer, paths, scanner, externalSources)); return registry; diff --git a/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs b/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs index 6749bb64a..46b5b7a9c 100644 --- a/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs +++ b/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs @@ -299,6 +299,112 @@ You are the only valid agent in this directory. Assert.DoesNotContain(_logger.Warnings, w => w.Contains("stray.json", StringComparison.Ordinal)); Assert.DoesNotContain(_logger.Warnings, w => w.Contains("readme.txt", StringComparison.Ordinal)); } + + [Fact] + public void RefreshIfChanged_detects_valid_edits_and_reloads_profiles() + { + var path = WriteAgent("reloadable.md", """ + --- + name: reloadable + description: First description + tools: [file_read] + --- + + First body. + """); + + var first = _loader.LoadAll(); + var initial = Assert.Single(first); + Assert.Equal("First description", initial.Description); + + File.WriteAllText(path, """ + --- + name: reloadable + description: Updated description + tools: [file_read] + --- + + Updated body. + """); + + Assert.True(_loader.RefreshIfChanged(out var refreshed)); + var updated = Assert.Single(refreshed); + Assert.Equal("Updated description", updated.Description); + Assert.Contains("Updated body.", updated.SystemPrompt); + } + + [Fact] + public void RefreshIfChanged_detects_deletes_and_returns_empty_snapshot() + { + var path = WriteAgent("temporary.md", """ + --- + name: temporary + description: Temporary agent + tools: [file_read] + --- + + body + """); + + Assert.Single(_loader.LoadAll()); + + File.Delete(path); + + Assert.True(_loader.RefreshIfChanged(out var refreshed)); + Assert.Empty(refreshed); + } + + [Fact] + public void SyncInto_replaces_registry_profiles_when_disk_changes() + { + // Both spawn_agent and metadata.subagent routed activations go through the + // same SyncInto contract — exercising the loader+registry pair end-to-end + // proves the live-reload requirement for both entry points. + var path = WriteAgent("routable.md", """ + --- + name: routable + description: First description + tools: [file_read] + --- + + First body. + """); + + var registry = new SubAgentDefinitionRegistry(); + Assert.True(_loader.SyncInto(registry)); + Assert.Equal("First description", registry.TryGetByName("routable")!.Description); + + File.WriteAllText(path, """ + --- + name: routable + description: Updated description + tools: [file_read] + --- + + Updated body. + """); + + Assert.True(_loader.SyncInto(registry)); + Assert.Equal("Updated description", registry.TryGetByName("routable")!.Description); + } + + [Fact] + public void SyncInto_is_a_no_op_when_directory_unchanged() + { + WriteAgent("stable.md", """ + --- + name: stable + description: Stable + tools: [file_read] + --- + + body + """); + + var registry = new SubAgentDefinitionRegistry(); + Assert.True(_loader.SyncInto(registry)); + Assert.False(_loader.SyncInto(registry)); + } } /// diff --git a/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs b/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs index f26447488..07165ffed 100644 --- a/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs +++ b/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs @@ -16,8 +16,12 @@ namespace Netclaw.Configuration; /// public sealed class FileSubAgentDefinitionLoader { + private sealed record LoadSnapshot(string Fingerprint, IReadOnlyList Profiles); + private readonly string _agentsDirectory; private readonly ILogger _logger; + private readonly object _snapshotGate = new(); + private LoadSnapshot? _lastSnapshot; public FileSubAgentDefinitionLoader(NetclawPaths paths, ILogger logger) { @@ -31,6 +35,65 @@ public FileSubAgentDefinitionLoader(NetclawPaths paths, ILoggername values across files are rejected for all but the first occurrence. /// public IReadOnlyList LoadAll() + { + return LoadCurrentSnapshot(ComputeDirectoryFingerprint()).Profiles; + } + + public bool RefreshIfChanged(out IReadOnlyList profiles) + { + var fingerprint = ComputeDirectoryFingerprint(); + + lock (_snapshotGate) + { + if (_lastSnapshot is not null && string.Equals(_lastSnapshot.Fingerprint, fingerprint, StringComparison.Ordinal)) + { + profiles = _lastSnapshot.Profiles; + return false; + } + } + + profiles = LoadCurrentSnapshot(fingerprint).Profiles; + return true; + } + + /// + /// Detect on-disk changes and, if any, replace the registry's file-loaded + /// profiles. Returns true when the registry was modified. + /// + public bool SyncInto(SubAgentDefinitionRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + + if (!RefreshIfChanged(out var profiles)) + return false; + + registry.ReplaceFileProfiles(profiles); + return true; + } + + private LoadSnapshot LoadCurrentSnapshot(string fingerprint) + { + lock (_snapshotGate) + { + if (_lastSnapshot is not null && string.Equals(_lastSnapshot.Fingerprint, fingerprint, StringComparison.Ordinal)) + return _lastSnapshot; + } + + // Read disk outside the lock so other callers aren't blocked. A concurrent loader + // may race us; the gate-protected install below accepts whichever snapshot lands + // first with the same fingerprint. + var profiles = LoadProfilesFromDisk(); + var newSnapshot = new LoadSnapshot(fingerprint, profiles); + + lock (_snapshotGate) + { + if (_lastSnapshot is null || !string.Equals(_lastSnapshot.Fingerprint, fingerprint, StringComparison.Ordinal)) + _lastSnapshot = newSnapshot; + return _lastSnapshot; + } + } + + private IReadOnlyList LoadProfilesFromDisk() { if (!Directory.Exists(_agentsDirectory)) { @@ -48,7 +111,6 @@ public IReadOnlyList LoadAll() var results = new List(); var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); - // Enumerate files in a stable order so duplicate-name diagnostics are deterministic. foreach (var filePath in files.OrderBy(p => p, StringComparer.Ordinal)) { var profile = TryParse(filePath); @@ -73,6 +135,25 @@ public IReadOnlyList LoadAll() return results; } + private string ComputeDirectoryFingerprint() + { + if (!Directory.Exists(_agentsDirectory)) + return "missing"; + + // Length is part of the fingerprint so rapid edits within a single mtime tick + // still register as a change. mtime alone is unreliable on low-resolution filesystems + // and during fast successive writes. + var files = Directory.GetFiles(_agentsDirectory, "*.md") + .OrderBy(p => p, StringComparer.Ordinal) + .Select(path => + { + var info = new FileInfo(path); + return $"{path}|{info.Length}|{info.LastWriteTimeUtc.Ticks}"; + }); + + return string.Join(";", files); + } + private SubAgentProfile? TryParse(string filePath) { string content; @@ -122,8 +203,21 @@ public IReadOnlyList LoadAll() // This matches Claude Code's agent format where tools are not specified. var tools = frontmatter.Tools ?? []; - var modelRole = ParseModelRole(frontmatter.ModelRole); - var visibility = ParseVisibility(frontmatter.Visibility); + if (!TryParseModelRole(frontmatter.ModelRole, out var modelRole)) + { + _logger.LogWarning( + "Agent '{Name}' at {Path} has invalid modelRole '{Value}' (expected Main or Compaction) — skipping", + frontmatter.Name, filePath, frontmatter.ModelRole); + return null; + } + + if (!TryParseVisibility(frontmatter.Visibility, out var visibility)) + { + _logger.LogWarning( + "Agent '{Name}' at {Path} has invalid visibility '{Value}' (expected user-facing or internal) — skipping", + frontmatter.Name, filePath, frontmatter.Visibility); + return null; + } return new SubAgentProfile { @@ -138,26 +232,27 @@ public IReadOnlyList LoadAll() }; } - private static ModelRole ParseModelRole(string? value) + private static bool TryParseModelRole(string? value, out ModelRole role) { if (string.IsNullOrWhiteSpace(value)) - return ModelRole.Compaction; + { + role = ModelRole.Compaction; + return true; + } - return Enum.TryParse(value, ignoreCase: true, out var parsed) - ? parsed - : ModelRole.Compaction; + return Enum.TryParse(value, ignoreCase: true, out role); } - private static SubAgentVisibility ParseVisibility(string? value) + private static bool TryParseVisibility(string? value, out SubAgentVisibility visibility) { if (string.IsNullOrWhiteSpace(value)) - return SubAgentVisibility.UserFacing; + { + visibility = SubAgentVisibility.UserFacing; + return true; + } - // Accept both "user-facing" (hyphenated, matches frontmatter convention) - // and "UserFacing" (PascalCase, matches the enum value name). + // Accept both `user-facing` (frontmatter convention) and `UserFacing` (enum name). var normalized = value.Replace("-", "", StringComparison.Ordinal); - return Enum.TryParse(normalized, ignoreCase: true, out var parsed) - ? parsed - : SubAgentVisibility.UserFacing; + return Enum.TryParse(normalized, ignoreCase: true, out visibility); } } diff --git a/src/Netclaw.Configuration/ISystemPromptProvider.cs b/src/Netclaw.Configuration/ISystemPromptProvider.cs index 978659542..fc8043c30 100644 --- a/src/Netclaw.Configuration/ISystemPromptProvider.cs +++ b/src/Netclaw.Configuration/ISystemPromptProvider.cs @@ -18,6 +18,12 @@ public interface ISystemPromptProvider /// The trust audience for the current session. /// Optional project root for loading project-scoped identity files. string GetSystemPrompt(TrustAudience audience, string? projectDirectory = null); + + /// + /// Get only the project-scoped identity content for a project directory. + /// Returns null when no project instructions are available for the audience. + /// + string? GetProjectInstructions(TrustAudience audience, string? projectDirectory); } /// @@ -73,6 +79,8 @@ public StaticSystemPromptProvider(string prompt) } public string GetSystemPrompt(TrustAudience audience, string? projectDirectory = null) => _prompt; + + public string? GetProjectInstructions(TrustAudience audience, string? projectDirectory) => null; } /// @@ -83,6 +91,8 @@ public sealed class NullSystemPromptProvider : ISystemPromptProvider public static readonly NullSystemPromptProvider Instance = new(); public string GetSystemPrompt(TrustAudience audience, string? projectDirectory = null) => string.Empty; + + public string? GetProjectInstructions(TrustAudience audience, string? projectDirectory) => null; } /// @@ -179,7 +189,7 @@ public string GetSystemPrompt(TrustAudience audience, string? projectDirectory = if (audience != TrustAudience.Public) { tooling = TryReadFile(_paths.ToolingPath) ?? TryReadFile(_paths.UserPreferencesPath); - projectInstructions = TryReadProjectIdentityFile(projectDirectory); + projectInstructions = GetProjectInstructions(audience, projectDirectory); } return SystemPromptAssembler.Assemble( @@ -189,6 +199,14 @@ public string GetSystemPrompt(TrustAudience audience, string? projectDirectory = projectInstructions: projectInstructions); } + public string? GetProjectInstructions(TrustAudience audience, string? projectDirectory) + { + if (audience == TrustAudience.Public) + return null; + + return TryReadProjectIdentityFile(projectDirectory); + } + /// /// Check candidate filenames at the project root. First match wins. /// diff --git a/src/Netclaw.Configuration/Resources/AGENTS.md b/src/Netclaw.Configuration/Resources/AGENTS.md index 7ca1d8589..4040ef9cd 100644 --- a/src/Netclaw.Configuration/Resources/AGENTS.md +++ b/src/Netclaw.Configuration/Resources/AGENTS.md @@ -175,6 +175,11 @@ subagent would otherwise have to rediscover. Use it to specialize a general-purpose subagent for the current invocation instead of authoring a whole new agent file. Do not duplicate the agent's built-in instructions. +**Live reload and grounding:** File-defined subagents under `~/.netclaw/agents` +reload automatically on the next turn or subagent lookup. Invalid edits fail +closed — the broken agent disappears until fixed. Spawned subagents inherit the +parent session's `session_dir` and current `project_dir` as read-only grounding. + **Parallelization tip:** When researching multiple independent topics, spawn separate subagents for each — they run concurrently and reduce total wait time. diff --git a/src/Netclaw.Configuration/SubAgentDefinitionRegistry.cs b/src/Netclaw.Configuration/SubAgentDefinitionRegistry.cs index f53f90f1e..35720d7eb 100644 --- a/src/Netclaw.Configuration/SubAgentDefinitionRegistry.cs +++ b/src/Netclaw.Configuration/SubAgentDefinitionRegistry.cs @@ -3,8 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Collections.Concurrent; - namespace Netclaw.Configuration; /// @@ -14,7 +12,9 @@ namespace Netclaw.Configuration; /// public sealed class SubAgentDefinitionRegistry { - private readonly ConcurrentDictionary _profiles = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gate = new(); + private Dictionary _registeredProfiles = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _fileProfiles = new(StringComparer.OrdinalIgnoreCase); /// /// Register a subagent profile. Rejects duplicates. @@ -23,7 +23,44 @@ public sealed class SubAgentDefinitionRegistry public bool Register(SubAgentProfile profile) { ArgumentNullException.ThrowIfNull(profile); - return _profiles.TryAdd(profile.Name, profile); + + lock (_gate) + { + if (_registeredProfiles.ContainsKey(profile.Name) || _fileProfiles.ContainsKey(profile.Name)) + return false; + + _registeredProfiles[profile.Name] = profile; + return true; + } + } + + /// + /// Replace all file-loaded profiles with a fresh snapshot from disk. + /// Profiles that conflict with explicitly registered profiles are skipped. + /// + public IReadOnlyList ReplaceFileProfiles(IEnumerable profiles) + { + ArgumentNullException.ThrowIfNull(profiles); + + lock (_gate) + { + var next = new Dictionary(StringComparer.OrdinalIgnoreCase); + var conflicts = new List(); + + foreach (var profile in profiles) + { + if (_registeredProfiles.ContainsKey(profile.Name)) + { + conflicts.Add(profile.Name); + continue; + } + + next[profile.Name] = profile; + } + + _fileProfiles = next; + return conflicts; + } } /// @@ -31,23 +68,39 @@ public bool Register(SubAgentProfile profile) /// public SubAgentProfile? TryGetByName(string name) { - return _profiles.TryGetValue(name, out var profile) ? profile : null; + lock (_gate) + { + if (_registeredProfiles.TryGetValue(name, out var registered)) + return registered; + + return _fileProfiles.TryGetValue(name, out var fileLoaded) ? fileLoaded : null; + } } /// /// Returns true when a profile with the given name exists. /// - public bool Contains(string name) => _profiles.ContainsKey(name); + public bool Contains(string name) + { + lock (_gate) + { + return _registeredProfiles.ContainsKey(name) || _fileProfiles.ContainsKey(name); + } + } /// /// Returns all user-facing profiles (visible to spawn_agent and discovery). /// public IReadOnlyList GetUserFacing() { - return _profiles.Values - .Where(p => p.Visibility == SubAgentVisibility.UserFacing) - .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); + lock (_gate) + { + return _registeredProfiles.Values + .Concat(_fileProfiles.Values) + .Where(p => p.Visibility == SubAgentVisibility.UserFacing) + .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } } /// @@ -55,13 +108,26 @@ public IReadOnlyList GetUserFacing() /// public IReadOnlyList GetAll() { - return _profiles.Values - .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); + lock (_gate) + { + return _registeredProfiles.Values + .Concat(_fileProfiles.Values) + .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } } /// /// Returns the count of registered profiles. /// - public int Count => _profiles.Count; + public int Count + { + get + { + lock (_gate) + { + return _registeredProfiles.Count + _fileProfiles.Count; + } + } + } } diff --git a/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs b/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs index b7615b184..ab98b3a80 100644 --- a/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs +++ b/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs @@ -13,16 +13,26 @@ namespace Netclaw.Configuration; public sealed class SubAgentDiscoveryContextLayer : IContextLayerProvider { private readonly SubAgentConfig _config; + private readonly SubAgentDefinitionRegistry? _registry; + private readonly FileSubAgentDefinitionLoader? _loader; + private readonly string? _agentsDirectory; private volatile string _index = string.Empty; public SubAgentDiscoveryContextLayer() : this(new SubAgentConfig()) { } - public SubAgentDiscoveryContextLayer(SubAgentConfig config) + public SubAgentDiscoveryContextLayer( + SubAgentConfig config, + SubAgentDefinitionRegistry? registry = null, + FileSubAgentDefinitionLoader? loader = null, + NetclawPaths? paths = null) { _config = config; + _registry = registry; + _loader = loader; + _agentsDirectory = paths?.AgentsDirectory; } - public ContextLayerTiming Timing => ContextLayerTiming.OnceAtStart; + public ContextLayerTiming Timing => ContextLayerTiming.EveryTurn; /// /// Replace the subagent discovery content. Thread-safe via volatile write. @@ -35,6 +45,65 @@ public string GetContextLayer(TrustAudience audience) return string.Empty; if (!_config.Enabled) return string.Empty; + + RefreshIfNeeded(); return _index; } + + internal static string BuildIndex(IReadOnlyList agents, string agentsDirectory) + { + if (agents.Count == 0) + { + var allowedTools = string.Join(", ", SubAgentToolPolicy.GetAllowedUserFacingTools()); + return string.Join('\n', + [ + "[available-subagents — use spawn_agent to delegate]", + string.Empty, + "No user-facing subagents are currently registered.", + $"Agents directory: {agentsDirectory}", + $"Allowed tools for user-facing agents: {allowedTools}", + string.Empty, + "To add one: create an agent definition at /.md. The next turn or subagent lookup reloads it automatically.", + "Then call `spawn_agent(agent: \"\", task: \"\", context: \"\")`." + ]); + } + + var lines = new List + { + "[available-subagents — use spawn_agent to delegate]", + string.Empty + }; + + foreach (var agent in agents) + { + lines.Add($"## {agent.Name}"); + lines.Add(agent.Description); + lines.Add(agent.ToolNames.Count == 0 + ? "Tools: all registered tools, then filtered for user-facing safety" + : $"Tools: {string.Join(", ", agent.ToolNames)}"); + lines.Add($"Timeout: {agent.TimeoutSeconds}s"); + lines.Add(string.Empty); + } + + lines.Add("## How to delegate"); + lines.Add("Call `spawn_agent(agent: \"\", task: \"\", context: \"\")`."); + lines.Add(string.Empty); + lines.Add("- `task` is what the subagent should do — be concrete and bounded."); + lines.Add("- `context` is optional per-invocation background (workspace details, the user's broader goal,"); + lines.Add(" facts the subagent would otherwise have to rediscover). Do NOT duplicate the agent's built-in"); + lines.Add(" instructions — use this for THIS invocation's situation."); + lines.Add("- Subagents run autonomously with their own tools and return a synthesized result, not a transcript."); + + return string.Join('\n', lines); + } + + private void RefreshIfNeeded() + { + if (_loader is null || _registry is null || string.IsNullOrWhiteSpace(_agentsDirectory)) + return; + + var changed = _loader.SyncInto(_registry); + if (changed || string.IsNullOrWhiteSpace(_index)) + _index = BuildIndex(_registry.GetUserFacing(), _agentsDirectory); + } } diff --git a/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs b/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs index eca188009..0a96ef932 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/ToolIndexUpdaterTests.cs @@ -25,9 +25,9 @@ public async Task StartAsync_with_no_user_facing_agents_sets_actionable_discover var registry = new ToolRegistry(); var memoryLayer = new MemoryIndexContextLayer(); - var subAgentLayer = new SubAgentDiscoveryContextLayer(); var subAgentRegistry = new SubAgentDefinitionRegistry(); var loader = new FileSubAgentDefinitionLoader(paths, NullLogger.Instance); + var subAgentLayer = new SubAgentDiscoveryContextLayer(new SubAgentConfig(), subAgentRegistry, loader, paths); var writer = new McpShadowCatalogWriter(paths, registry, NullLogger.Instance); var updater = new ToolIndexUpdater( @@ -35,7 +35,6 @@ public async Task StartAsync_with_no_user_facing_agents_sets_actionable_discover writer, registry, memoryLayer, - subAgentLayer, subAgentRegistry, loader, subAgentSpawner: null!, @@ -78,7 +77,6 @@ public async Task StartAsync_keeps_public_tool_index_filtered_from_hidden_capabi "search")); var memoryLayer = new MemoryIndexContextLayer(); - var subAgentLayer = new SubAgentDiscoveryContextLayer(new SubAgentConfig { Enabled = false }); var toolIndexLayer = new ToolIndexContextLayer(registry, policy); var subAgentRegistry = new SubAgentDefinitionRegistry(); var loader = new FileSubAgentDefinitionLoader(paths, NullLogger.Instance); @@ -89,7 +87,6 @@ public async Task StartAsync_keeps_public_tool_index_filtered_from_hidden_capabi writer, registry, memoryLayer, - subAgentLayer, subAgentRegistry, loader, subAgentSpawner: null!, diff --git a/src/Netclaw.Daemon/Configuration/SkillToolRegistration.cs b/src/Netclaw.Daemon/Configuration/SkillToolRegistration.cs index 75592bd69..fbb1f6213 100644 --- a/src/Netclaw.Daemon/Configuration/SkillToolRegistration.cs +++ b/src/Netclaw.Daemon/Configuration/SkillToolRegistration.cs @@ -36,6 +36,7 @@ public static void RegisterSkillTools(IServiceProvider services) var metrics = services.GetService(); var subAgentRegistry = services.GetService(); var subAgentSpawner = services.GetService(); + var subAgentLoader = services.GetService(); var skillSyncConfig = services.GetService(); var loggerFactory = services.GetRequiredService(); @@ -52,6 +53,7 @@ public static void RegisterSkillTools(IServiceProvider services) subAgentRegistry, subAgentSpawner, skillSyncConfig, + subAgentLoader, loggerFactory.CreateLogger()); } } diff --git a/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs b/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs index 7ae5fcdd1..eec4c03f3 100644 --- a/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs +++ b/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs @@ -3,7 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Text; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Netclaw.Actors.SubAgents; @@ -24,7 +23,6 @@ internal sealed class ToolIndexUpdater : IHostedService private readonly McpShadowCatalogWriter _shadowCatalogWriter; private readonly ToolRegistry _toolRegistry; private readonly MemoryIndexContextLayer _memoryIndexLayer; - private readonly SubAgentDiscoveryContextLayer _subAgentDiscoveryLayer; private readonly SubAgentDefinitionRegistry _subAgentRegistry; private readonly FileSubAgentDefinitionLoader _agentLoader; private readonly SubAgentSpawner _subAgentSpawner; @@ -36,7 +34,6 @@ public ToolIndexUpdater( McpShadowCatalogWriter shadowCatalogWriter, ToolRegistry toolRegistry, MemoryIndexContextLayer memoryIndexLayer, - SubAgentDiscoveryContextLayer subAgentDiscoveryLayer, SubAgentDefinitionRegistry subAgentRegistry, FileSubAgentDefinitionLoader agentLoader, SubAgentSpawner subAgentSpawner, @@ -47,7 +44,6 @@ public ToolIndexUpdater( _shadowCatalogWriter = shadowCatalogWriter; _toolRegistry = toolRegistry; _memoryIndexLayer = memoryIndexLayer; - _subAgentDiscoveryLayer = subAgentDiscoveryLayer; _subAgentRegistry = subAgentRegistry; _agentLoader = agentLoader; _subAgentSpawner = subAgentSpawner; @@ -57,24 +53,17 @@ public ToolIndexUpdater( public Task StartAsync(CancellationToken cancellationToken) { - var state = ResolveMemoryState(); - - // Load file-based agent definitions (after MCP tools are registered). LoadFileBasedAgents(); - // Register spawn_agent tool now that all agents and the spawner are available. - _toolRegistry.Register(new SpawnAgentTool(_subAgentRegistry, _subAgentSpawner, _paths, _subAgentConfig)); + _toolRegistry.Register(new SpawnAgentTool(_subAgentRegistry, _subAgentSpawner, _paths, _subAgentConfig, _agentLoader)); - // Write catalogs after all tools are registered. _shadowCatalogWriter.WriteCatalogs(); _logger.LogInformation("Tool index updated ({ToolCount} registrations)", _toolRegistry.GetAllRegistrations().Count); + var state = ResolveMemoryState(); _memoryIndexLayer.Update(state); _logger.LogInformation("Memory context layer updated (state: {State})", state); - // Update subagent discovery context layer. - UpdateSubAgentDiscovery(); - return Task.CompletedTask; } @@ -83,74 +72,17 @@ public Task StartAsync(CancellationToken cancellationToken) private void LoadFileBasedAgents() { var profiles = _agentLoader.LoadAll(); - var loaded = 0; - foreach (var profile in profiles) - { - if (_subAgentRegistry.Register(profile)) - { - loaded++; - } - else - { - _logger.LogWarning( - "Agent '{Name}' from file conflicts with an existing registration — skipping", - profile.Name); - } - } - - if (loaded > 0) - _logger.LogInformation("Loaded {Count} file-based agent definition(s)", loaded); - } + var conflicts = _subAgentRegistry.ReplaceFileProfiles(profiles); - private void UpdateSubAgentDiscovery() - { - var agents = _subAgentRegistry.GetUserFacing(); - if (agents.Count == 0) + foreach (var conflict in conflicts) { - var allowedTools = string.Join(", ", SubAgentToolPolicy.GetAllowedUserFacingTools()); - var emptyState = new StringBuilder(); - emptyState.AppendLine("[available-subagents — use spawn_agent to delegate]"); - emptyState.AppendLine(); - emptyState.AppendLine("No user-facing subagents are currently registered."); - emptyState.AppendLine($"Agents directory: {_paths.AgentsDirectory}"); - emptyState.AppendLine($"Allowed tools for user-facing agents: {allowedTools}"); - emptyState.AppendLine(); - emptyState.AppendLine("To add one: create an agent definition at /.md and reload the daemon."); - emptyState.AppendLine("Then call `spawn_agent(agent: \"\", task: \"\", context: \"\")`."); - - _subAgentDiscoveryLayer.Update(emptyState.ToString()); _logger.LogWarning( - "Subagent discovery layer updated with empty-state guidance (agentsDirectory={AgentsDirectory}, allowedTools={AllowedTools})", - _paths.AgentsDirectory, - allowedTools); - return; + "Agent '{Name}' from file conflicts with an existing registration — skipping", + conflict); } - var sb = new StringBuilder(); - sb.AppendLine("[available-subagents — use spawn_agent to delegate]"); - sb.AppendLine(); - - foreach (var agent in agents) - { - sb.AppendLine($"## {agent.Name}"); - sb.AppendLine($"{agent.Description}"); - sb.Append("Tools: "); - sb.AppendLine(string.Join(", ", agent.ToolNames)); - sb.AppendLine($"Timeout: {agent.TimeoutSeconds}s"); - sb.AppendLine(); - } - - sb.AppendLine("## How to delegate"); - sb.AppendLine("Call `spawn_agent(agent: \"\", task: \"\", context: \"\")`."); - sb.AppendLine(); - sb.AppendLine("- `task` is what the subagent should do — be concrete and bounded."); - sb.AppendLine("- `context` is optional per-invocation background (workspace details, the user's broader goal,"); - sb.AppendLine(" facts the subagent would otherwise have to rediscover). Do NOT duplicate the agent's built-in"); - sb.AppendLine(" instructions — use this for THIS invocation's situation."); - sb.AppendLine("- Subagents run autonomously with their own tools and return a synthesized result, not a transcript."); - - _subAgentDiscoveryLayer.Update(sb.ToString()); - _logger.LogInformation("Subagent discovery layer updated ({Count} agents)", agents.Count); + if (profiles.Count > 0) + _logger.LogInformation("Loaded {Count} file-based agent definition(s)", profiles.Count - conflicts.Count); } private static MemoryContextState ResolveMemoryState() => MemoryContextState.SqlitePrimary; diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index d7ee88d8d..9ee3c4357 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -891,10 +891,13 @@ static void ConfigureDaemonServices( services.AddSingleton(memoryIndexLayer); services.AddSingleton(memoryIndexLayer); - // Subagent discovery context layer — updated by ToolIndexUpdater after file-based agents load - var subAgentDiscoveryLayer = new SubAgentDiscoveryContextLayer(subAgentConfig); - services.AddSingleton(subAgentDiscoveryLayer); - services.AddSingleton(subAgentDiscoveryLayer); + // Subagent discovery context layer — rebuilds the catalog on demand from the live file snapshot. + services.AddSingleton(sp => new SubAgentDiscoveryContextLayer( + subAgentConfig, + sp.GetRequiredService(), + sp.GetRequiredService(), + paths)); + services.AddSingleton(sp => sp.GetRequiredService()); // Current time context layer — transient per-turn grounding for date/time-sensitive prompts services.AddSingleton(); @@ -1012,7 +1015,8 @@ static void ConfigureDaemonServices( sp.GetService(), sp.GetService(), sp.GetService(), - sp.GetService())); + sp.GetService(), + sp.GetService())); services.AddSingleton(sp => new SessionMemoryServices( sp.GetService() ?? NullMemoryExtractor.Instance,