From c56961ce3a8d6c7ed98897de6b29681b47682c76 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 13 May 2026 09:44:40 -0500 Subject: [PATCH 1/6] feat(subagents): reload file agents and inherit parent context Keep file-authored subagents in sync without daemon restarts and give spawned or routed workers the same session and project grounding as their parent sessions. --- docs/runbooks/subagents.md | 44 ++-- .../.system/files/subagent-authoring/SKILL.md | 38 ++-- .../design.md | 192 ++++++++++++++++++ .../proposal.md | 82 ++++++++ .../specs/netclaw-subagents/spec.md | 93 +++++++++ .../specs/project-instructions/spec.md | 23 +++ .../specs/session-cwd/spec.md | 26 +++ .../specs/skill-execution-routing/spec.md | 24 +++ .../tasks.md | 41 ++++ .../Memory/FakeNetclawTool.cs | 7 + .../Sessions/LlmSessionTestExtensions.cs | 3 +- .../Sessions/SubAgentSpawnIntegrationTests.cs | 7 + .../SubAgents/SubAgentActorTests.cs | 32 +++ .../SubAgentDefinitionRegistryTests.cs | 15 ++ .../Tools/SkillToolTests.cs | 1 + .../Sessions/LlmSessionActor.cs | 15 ++ .../Pipelines/SessionToolExecutionPipeline.cs | 9 +- .../Sessions/SessionDependencies.cs | 3 +- .../SubAgents/SpawnAgentTool.cs | 16 +- src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 14 +- .../SubAgents/SubAgentProtocol.cs | 15 ++ .../SubAgents/SubAgentSpawner.cs | 17 +- src/Netclaw.Actors/Tools/SkillLoadTool.cs | 17 +- .../Tools/ToolRegistrationExtensions.cs | 11 +- .../FileSubAgentDefinitionLoaderTests.cs | 54 +++++ .../FileSubAgentDefinitionLoader.cs | 59 +++++- .../ISystemPromptProvider.cs | 20 +- src/Netclaw.Configuration/Resources/AGENTS.md | 5 + .../SubAgentDefinitionRegistry.cs | 94 +++++++-- .../SubAgentDiscoveryContextLayer.cs | 76 ++++++- .../Configuration/SkillToolRegistration.cs | 2 + src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs | 71 +------ src/Netclaw.Daemon/Program.cs | 14 +- .../ToolExecutionContext.cs | 6 + 34 files changed, 1023 insertions(+), 123 deletions(-) create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/design.md create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/proposal.md create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/specs/netclaw-subagents/spec.md create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/specs/project-instructions/spec.md create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/specs/skill-execution-routing/spec.md create mode 100644 openspec/changes/align-subagent-loading-and-parent-context/tasks.md 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/feeds/skills/.system/files/subagent-authoring/SKILL.md b/feeds/skills/.system/files/subagent-authoring/SKILL.md index 02d1b9c6c..748505b8e 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.0" --- # 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`) @@ -134,9 +148,9 @@ with real user-facing subagent definitions. ## 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..fa23f68ab --- /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 + +When a reloaded subagent definition no longer passes loader validation, the +runtime SHALL exclude that definition from the active registry snapshot and emit +deterministic diagnostics. The system SHALL NOT continue serving the prior +version of that 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..f2f1495a9 --- /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 + +When a subagent execution inherits a non-null parent `project_dir`, the system +SHALL resolve project identity files from that directory using the same +precedence as the parent session and include the resulting project instructions +in the subagent system prompt. + +#### 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..73d153680 --- /dev/null +++ b/openspec/changes/align-subagent-loading-and-parent-context/specs/session-cwd/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Project directory flows to spawned subagents as read-only context + +When a session spawns or routes execution into a subagent, the current +`WorkingContext.ProjectDirectory` SHALL be copied into the child's immutable +execution snapshot when it is set. + +This inherited value is read-only from the child. 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..9a4796658 --- /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. +- [ ] 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. +- [ ] 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`. +- [ ] 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. +- [ ] 5.3 Update any relevant skill-routing guidance so `metadata.subagent` documentation matches the shared reload and inheritance contract. +- [ ] 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`. +- [ ] 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..297b1cbe9 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)); @@ -227,6 +228,12 @@ await sessionManager.Ask(new SendUserMessage Assert.Equal(2, _clientProvider.Main.CallCount); Assert.Equal(1, _clientProvider.Compaction.CallCount); + Assert.NotNull(_recordingFileReadTool); + Assert.NotNull(_recordingFileReadTool!.LastContext); + Assert.EndsWith( + SessionDirectoryHelper.SanitizeSessionId(sessionId), + _recordingFileReadTool.LastContext!.SessionDirectory, + StringComparison.Ordinal); var subagentCall = Assert.Single(_clientProvider.Compaction.ReceivedMessages); Assert.Contains(subagentCall, m => diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 11ddc7e99..3a079d530 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -78,10 +78,42 @@ 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 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 ccb697db4..b505155fb 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(); @@ -198,6 +199,7 @@ public LlmSessionActor( _skillRegistry = tools?.SkillRegistry; _subAgentRegistry = tools?.SubAgentRegistry; _subAgentSpawner = tools?.SubAgentSpawner; + _subAgentLoader = tools?.SubAgentLoader; _toolExecutor = tools?.ToolExecutor; _auditLogger = tools?.AuditLogger; _toolAccessPolicy = tools?.AccessPolicy; @@ -1666,6 +1668,7 @@ await self.Ask( bgJobManager = mgr; _ = SessionToolExecutionPipeline.ExecuteToolsAsync(executor, toolCalls, sessionId, _currentTurnSource, auditLogger, tp, sessionDir, maxInlineToolResultChars, toolExecutionTimeout, self, emitSubAgentOutput, spawnChildActor, + _state.WorkingContext.ProjectDirectory, approvalChannel: _approvalChannel, emitApprovalRequest: request => self.Tell(request), approvalTimeout: Timeout.InfiniteTimeSpan, @@ -2622,6 +2625,8 @@ private bool TryHandleRoutedSlashCommand(SkillEntry skill, string remainder, Lis return true; } + RefreshSubagentsIfNeeded(); + var profile = _subAgentRegistry.TryGetByName(routedSubagent); if (profile is null) { @@ -2714,6 +2719,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, }; @@ -2755,6 +2761,15 @@ await self.Ask( } } + private void RefreshSubagentsIfNeeded() + { + if (_subAgentLoader is null || _subAgentRegistry is null) + return; + + if (_subAgentLoader.RefreshIfChanged(out var profiles)) + _subAgentRegistry.ReplaceFileProfiles(profiles); + } + private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted msg) { if (!msg.Result.Success) diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 03786cb3b..764c659f2 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -46,6 +46,7 @@ public static async Task ExecuteToolsAsync( IActorRef self, Action emitSubAgentOutput, Func> spawnChildActor, + string? projectDir = null, IApprovalChannel? approvalChannel = null, Action? emitApprovalRequest = null, TimeSpan? approvalTimeout = null, @@ -68,6 +69,7 @@ public static async Task ExecuteToolsAsync( maxInlineToolResultChars, emitSubAgentOutput, spawnChildActor, + projectDir, timeout, CancellationToken.None, approvalChannel, @@ -118,6 +120,7 @@ public static async Task ExecuteSingleToolAsync( int maxInlineToolResultChars, Action emitSubAgentOutput, Func> spawnChildActor, + string? projectDir, TimeSpan timeout, CancellationToken ct, IApprovalChannel? approvalChannel = null, @@ -139,7 +142,7 @@ public static async Task ExecuteSingleToolAsync( var sw = Stopwatch.StartNew(); string resultText; - var context = BuildToolExecutionContext(sessionId, source, sessionDir, spawnChildActor); + var context = BuildToolExecutionContext(sessionId, source, sessionDir, spawnChildActor, projectDir); context.RequestedTimeoutSeconds = (int)timeout.TotalSeconds; if (approvalChannel is not null && emitApprovalRequest is not null) { @@ -610,12 +613,14 @@ private static ToolExecutionContext BuildToolExecutionContext( SessionId sessionId, MessageSource? source, string sessionDir, - Func> spawnChildActor) + Func> spawnChildActor, + string? projectDir) { var context = new ToolExecutionContext(sessionId.Value, sessionDir); context.Audience = source is null ? null : source.Audience.ToWireValue(); context.Boundary = source?.Boundary; context.ChannelType = source is null ? null : source.ChannelType.ToWireValue(); + context.ProjectDirectory = projectDir; context.SupportsInteractiveApproval = source?.ChannelType.SupportsInteractiveApproval(); context.SpawnChildActor = spawnChildActor; return context; 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..c1f04e052 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."; + RefreshSubagentsIfNeeded(); + var profile = _registry.TryGetByName(args.Agent); if (profile is null || profile.Visibility != SubAgentVisibility.UserFacing) { @@ -80,4 +85,13 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon ? result.Output : $"Subagent '{args.Agent}' failed: {result.Output}"; } + + private void RefreshSubagentsIfNeeded() + { + if (_loader is null) + return; + + if (_loader.RefreshIfChanged(out var profiles)) + _registry.ReplaceFileProfiles(profiles); + } } diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 3f8017ed3..5d5bea405 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})", @@ -494,12 +495,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 b937f5e87..ec9f3c58a 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -33,6 +33,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; } } /// @@ -71,6 +76,16 @@ public sealed record RunSubAgent 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..eb836bb73 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."; } + RefreshSubagentsIfNeeded(); + 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."; @@ -178,4 +184,13 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon return sb.ToString(); } + private void RefreshSubagentsIfNeeded() + { + if (_subAgentLoader is null || _subAgentRegistry is null) + return; + + if (_subAgentLoader.RefreshIfChanged(out var profiles)) + _subAgentRegistry.ReplaceFileProfiles(profiles); + } + } 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..e71558cec 100644 --- a/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs +++ b/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs @@ -299,6 +299,60 @@ 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); + } } /// diff --git a/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs b/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs index f26447488..c6eb32823 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,44 @@ public FileSubAgentDefinitionLoader(NetclawPaths paths, ILoggername values across files are rejected for all but the first occurrence. /// public IReadOnlyList LoadAll() + { + return LoadCurrentSnapshot().Profiles; + } + + public bool RefreshIfChanged(out IReadOnlyList profiles) + { + var currentFingerprint = ComputeDirectoryFingerprint(); + + lock (_snapshotGate) + { + if (_lastSnapshot is not null && string.Equals(_lastSnapshot.Fingerprint, currentFingerprint, StringComparison.Ordinal)) + { + profiles = _lastSnapshot.Profiles; + return false; + } + } + + var snapshot = LoadCurrentSnapshot(); + profiles = snapshot.Profiles; + return true; + } + + private LoadSnapshot LoadCurrentSnapshot() + { + var fingerprint = ComputeDirectoryFingerprint(); + + lock (_snapshotGate) + { + if (_lastSnapshot is not null && string.Equals(_lastSnapshot.Fingerprint, fingerprint, StringComparison.Ordinal)) + return _lastSnapshot; + + var profiles = LoadProfilesFromDisk(); + _lastSnapshot = new LoadSnapshot(fingerprint, profiles); + return _lastSnapshot; + } + } + + private IReadOnlyList LoadProfilesFromDisk() { if (!Directory.Exists(_agentsDirectory)) { @@ -48,7 +90,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 +114,22 @@ public IReadOnlyList LoadAll() return results; } + private string ComputeDirectoryFingerprint() + { + if (!Directory.Exists(_agentsDirectory)) + return "missing"; + + 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; 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 7a3a35f67..14c00649b 100644 --- a/src/Netclaw.Configuration/Resources/AGENTS.md +++ b/src/Netclaw.Configuration/Resources/AGENTS.md @@ -135,6 +135,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..ed2a92725 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,68 @@ public string GetContextLayer(TrustAudience audience) return string.Empty; if (!_config.Enabled) return string.Empty; + + RefreshIfNeeded(); return _index; } + + public 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.RefreshIfChanged(out var profiles); + if (changed) + _registry.ReplaceFileProfiles(profiles); + + if (string.IsNullOrWhiteSpace(_index) || changed) + _index = BuildIndex(_registry.GetUserFacing(), _agentsDirectory); + } } 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..73310ae71 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; @@ -63,7 +62,7 @@ public Task StartAsync(CancellationToken cancellationToken) 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(); @@ -83,73 +82,23 @@ public Task StartAsync(CancellationToken cancellationToken) private void LoadFileBasedAgents() { var profiles = _agentLoader.LoadAll(); - var loaded = 0; - foreach (var profile in profiles) + var conflicts = _subAgentRegistry.ReplaceFileProfiles(profiles); + + foreach (var conflict in conflicts) { - if (_subAgentRegistry.Register(profile)) - { - loaded++; - } - else - { - _logger.LogWarning( - "Agent '{Name}' from file conflicts with an existing registration — skipping", - profile.Name); - } + _logger.LogWarning( + "Agent '{Name}' from file conflicts with an existing registration — skipping", + conflict); } - if (loaded > 0) - _logger.LogInformation("Loaded {Count} file-based agent definition(s)", loaded); + if (profiles.Count > 0) + _logger.LogInformation("Loaded {Count} file-based agent definition(s)", profiles.Count - conflicts.Count); } private void UpdateSubAgentDiscovery() { var agents = _subAgentRegistry.GetUserFacing(); - if (agents.Count == 0) - { - 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; - } - - 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()); + _subAgentDiscoveryLayer.Update(SubAgentDiscoveryContextLayer.BuildIndex(agents, _paths.AgentsDirectory)); _logger.LogInformation("Subagent discovery layer updated ({Count} agents)", agents.Count); } diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index dbc5d3f08..797918231 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -854,10 +854,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(); @@ -975,7 +978,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, diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index c941321eb..dcd659d11 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -63,6 +63,12 @@ public ToolExecutionContext(string? sessionId, string? sessionDirectory) SessionDirectory = sessionDirectory; } + /// + /// Parent session's current project directory when subagent execution is spawned. + /// Read-only execution grounding; child runs must not mutate parent state. + /// + public string? ProjectDirectory { get; set; } + public string? Audience { get; set; } public string? Boundary { get; set; } From d753912199eb808da704d5f902909080ae4cb2c3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 13 May 2026 18:36:17 +0000 Subject: [PATCH 2/6] fix(subagents): drop unreachable test asserts and pass openspec validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration test never drives the subagent's compaction client to issue a tool call, so the file_read context assertions added with the parent-context inheritance change could only ever observe a null LastContext. The dedicated SubAgentActorTests inheritance test already covers that contract end-to-end. Also rewrite three requirement bodies so the SHALL clause lands on the first sentence — openspec validate treats the opening line as the requirement text and was rejecting the deltas. --- .../specs/netclaw-subagents/spec.md | 6 +++--- .../specs/project-instructions/spec.md | 8 ++++---- .../specs/session-cwd/spec.md | 11 +++++------ .../Sessions/SubAgentSpawnIntegrationTests.cs | 6 ------ 4 files changed, 12 insertions(+), 19 deletions(-) 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 index fa23f68ab..9fdfaccb7 100644 --- 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 @@ -38,10 +38,10 @@ subagent executions without daemon restart. ### Requirement: Invalid reload changes fail closed -When a reloaded subagent definition no longer passes loader validation, the -runtime SHALL exclude that definition from the active registry snapshot and emit +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 that invalidated definition. +version of an invalidated definition. #### Scenario: Invalid edit removes previously valid definition 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 index f2f1495a9..11e581c05 100644 --- 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 @@ -2,10 +2,10 @@ ### Requirement: Spawned subagents use inherited project instructions -When a subagent execution inherits a non-null parent `project_dir`, the system -SHALL resolve project identity files from that directory using the same -precedence as the parent session and include the resulting project instructions -in the subagent system prompt. +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 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 index 73d153680..5b160bed7 100644 --- 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 @@ -2,12 +2,11 @@ ### Requirement: Project directory flows to spawned subagents as read-only context -When a session spawns or routes execution into a subagent, the current -`WorkingContext.ProjectDirectory` SHALL be copied into the child's immutable -execution snapshot when it is set. - -This inherited value is read-only from the child. Subagent execution SHALL NOT -mutate the parent session's `ProjectDirectory` or other `WorkingContext` state. +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 diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 297b1cbe9..2aa8767c4 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -228,12 +228,6 @@ await sessionManager.Ask(new SendUserMessage Assert.Equal(2, _clientProvider.Main.CallCount); Assert.Equal(1, _clientProvider.Compaction.CallCount); - Assert.NotNull(_recordingFileReadTool); - Assert.NotNull(_recordingFileReadTool!.LastContext); - Assert.EndsWith( - SessionDirectoryHelper.SanitizeSessionId(sessionId), - _recordingFileReadTool.LastContext!.SessionDirectory, - StringComparison.Ordinal); var subagentCall = Assert.Single(_clientProvider.Compaction.ReceivedMessages); Assert.Contains(subagentCall, m => From c9b6c0b02b98e7456cf6fdc7039c404b32101570 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 13 May 2026 18:51:54 +0000 Subject: [PATCH 3/6] refactor(subagents): simplify reload plumbing and fail loud on bad frontmatter - Collapse the three identical RefreshSubagentsIfNeeded helpers in SpawnAgentTool, SkillLoadTool, and LlmSessionActor into a single FileSubAgentDefinitionLoader.SyncInto(registry) call. The context layer uses the same method. - Delete the redundant priming UpdateSubAgentDiscovery hook in ToolIndexUpdater; the discovery layer self-refreshes via EveryTurn timing once it's wired with the loader, registry, and paths. The test now constructs the layer with that wiring, exercising real production behavior instead of the empty constructor. - Replace the silently-coercing ParseModelRole and ParseVisibility with TryParse helpers so invalid frontmatter values are rejected with a loud warning instead of quietly becoming defaults. - Read disk outside the snapshot gate so a reload no longer blocks the actor thread; only the snapshot install stays under the lock. - Drop the double fingerprint compute in LoadCurrentSnapshot. - Tighten BuildIndex to internal now that nothing outside the layer needs it, and fix the stray XML-doc indent on ParentProjectDirectory. --- .../Sessions/LlmSessionActor.cs | 11 +-- .../SubAgents/SpawnAgentTool.cs | 11 +-- .../SubAgents/SubAgentProtocol.cs | 4 +- src/Netclaw.Actors/Tools/SkillLoadTool.cs | 12 +-- .../FileSubAgentDefinitionLoader.cs | 84 ++++++++++++++----- .../SubAgentDiscoveryContextLayer.cs | 9 +- .../Mcp/ToolIndexUpdaterTests.cs | 5 +- src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs | 19 +---- 8 files changed, 71 insertions(+), 84 deletions(-) diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 12d815c78..57218dc50 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -2696,7 +2696,7 @@ private bool TryHandleRoutedSlashCommand(SkillEntry skill, string remainder, IRe return true; } - RefreshSubagentsIfNeeded(); + _subAgentLoader?.SyncInto(_subAgentRegistry); var profile = _subAgentRegistry.TryGetByName(routedSubagent); if (profile is null) @@ -2832,15 +2832,6 @@ await self.Ask( } } - private void RefreshSubagentsIfNeeded() - { - if (_subAgentLoader is null || _subAgentRegistry is null) - return; - - if (_subAgentLoader.RefreshIfChanged(out var profiles)) - _subAgentRegistry.ReplaceFileProfiles(profiles); - } - private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted msg) { if (!msg.Result.Success) diff --git a/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs b/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs index c1f04e052..93258e9cf 100644 --- a/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs +++ b/src/Netclaw.Actors/SubAgents/SpawnAgentTool.cs @@ -66,7 +66,7 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon if (string.IsNullOrWhiteSpace(args.Task)) return "Error: 'task' parameter is required."; - RefreshSubagentsIfNeeded(); + _loader?.SyncInto(_registry); var profile = _registry.TryGetByName(args.Agent); if (profile is null || profile.Visibility != SubAgentVisibility.UserFacing) @@ -85,13 +85,4 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon ? result.Output : $"Subagent '{args.Agent}' failed: {result.Output}"; } - - private void RefreshSubagentsIfNeeded() - { - if (_loader is null) - return; - - if (_loader.RefreshIfChanged(out var profiles)) - _registry.ReplaceFileProfiles(profiles); - } } diff --git a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs index b5d0e760e..7affe9703 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -83,8 +83,8 @@ public sealed record RunSubAgent : INoSerializationVerificationNeeded public string? ParentSessionDirectory { get; init; } /// - /// Parent session's project directory snapshot when the subagent was spawned. - /// + /// Parent session's project directory snapshot when the subagent was spawned. + /// public string? ParentProjectDirectory { get; init; } /// diff --git a/src/Netclaw.Actors/Tools/SkillLoadTool.cs b/src/Netclaw.Actors/Tools/SkillLoadTool.cs index eb836bb73..f8577a3ea 100644 --- a/src/Netclaw.Actors/Tools/SkillLoadTool.cs +++ b/src/Netclaw.Actors/Tools/SkillLoadTool.cs @@ -94,7 +94,7 @@ 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."; } - RefreshSubagentsIfNeeded(); + _subAgentLoader?.SyncInto(_subAgentRegistry); if (string.IsNullOrWhiteSpace(args.Task)) { @@ -183,14 +183,4 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon return sb.ToString(); } - - private void RefreshSubagentsIfNeeded() - { - if (_subAgentLoader is null || _subAgentRegistry is null) - return; - - if (_subAgentLoader.RefreshIfChanged(out var profiles)) - _subAgentRegistry.ReplaceFileProfiles(profiles); - } - } diff --git a/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs b/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs index c6eb32823..07165ffed 100644 --- a/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs +++ b/src/Netclaw.Configuration/FileSubAgentDefinitionLoader.cs @@ -36,38 +36,59 @@ public FileSubAgentDefinitionLoader(NetclawPaths paths, ILogger public IReadOnlyList LoadAll() { - return LoadCurrentSnapshot().Profiles; + return LoadCurrentSnapshot(ComputeDirectoryFingerprint()).Profiles; } public bool RefreshIfChanged(out IReadOnlyList profiles) { - var currentFingerprint = ComputeDirectoryFingerprint(); + var fingerprint = ComputeDirectoryFingerprint(); lock (_snapshotGate) { - if (_lastSnapshot is not null && string.Equals(_lastSnapshot.Fingerprint, currentFingerprint, StringComparison.Ordinal)) + if (_lastSnapshot is not null && string.Equals(_lastSnapshot.Fingerprint, fingerprint, StringComparison.Ordinal)) { profiles = _lastSnapshot.Profiles; return false; } } - var snapshot = LoadCurrentSnapshot(); - profiles = snapshot.Profiles; + profiles = LoadCurrentSnapshot(fingerprint).Profiles; return true; } - private LoadSnapshot LoadCurrentSnapshot() + /// + /// 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) { - var fingerprint = ComputeDirectoryFingerprint(); + 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; + } - var profiles = LoadProfilesFromDisk(); - _lastSnapshot = new LoadSnapshot(fingerprint, profiles); + // 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; } } @@ -119,6 +140,9 @@ 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 => @@ -179,8 +203,21 @@ private string ComputeDirectoryFingerprint() // 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 { @@ -195,26 +232,27 @@ private string ComputeDirectoryFingerprint() }; } - 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/SubAgentDiscoveryContextLayer.cs b/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs index ed2a92725..ab98b3a80 100644 --- a/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs +++ b/src/Netclaw.Configuration/SubAgentDiscoveryContextLayer.cs @@ -50,7 +50,7 @@ public string GetContextLayer(TrustAudience audience) return _index; } - public static string BuildIndex(IReadOnlyList agents, string agentsDirectory) + internal static string BuildIndex(IReadOnlyList agents, string agentsDirectory) { if (agents.Count == 0) { @@ -102,11 +102,8 @@ private void RefreshIfNeeded() if (_loader is null || _registry is null || string.IsNullOrWhiteSpace(_agentsDirectory)) return; - var changed = _loader.RefreshIfChanged(out var profiles); - if (changed) - _registry.ReplaceFileProfiles(profiles); - - if (string.IsNullOrWhiteSpace(_index) || changed) + 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/Mcp/ToolIndexUpdater.cs b/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs index 73310ae71..eec4c03f3 100644 --- a/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs +++ b/src/Netclaw.Daemon/Mcp/ToolIndexUpdater.cs @@ -23,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; @@ -35,7 +34,6 @@ public ToolIndexUpdater( McpShadowCatalogWriter shadowCatalogWriter, ToolRegistry toolRegistry, MemoryIndexContextLayer memoryIndexLayer, - SubAgentDiscoveryContextLayer subAgentDiscoveryLayer, SubAgentDefinitionRegistry subAgentRegistry, FileSubAgentDefinitionLoader agentLoader, SubAgentSpawner subAgentSpawner, @@ -46,7 +44,6 @@ public ToolIndexUpdater( _shadowCatalogWriter = shadowCatalogWriter; _toolRegistry = toolRegistry; _memoryIndexLayer = memoryIndexLayer; - _subAgentDiscoveryLayer = subAgentDiscoveryLayer; _subAgentRegistry = subAgentRegistry; _agentLoader = agentLoader; _subAgentSpawner = subAgentSpawner; @@ -56,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, _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; } @@ -95,12 +85,5 @@ private void LoadFileBasedAgents() _logger.LogInformation("Loaded {Count} file-based agent definition(s)", profiles.Count - conflicts.Count); } - private void UpdateSubAgentDiscovery() - { - var agents = _subAgentRegistry.GetUserFacing(); - _subAgentDiscoveryLayer.Update(SubAgentDiscoveryContextLayer.BuildIndex(agents, _paths.AgentsDirectory)); - _logger.LogInformation("Subagent discovery layer updated ({Count} agents)", agents.Count); - } - private static MemoryContextState ResolveMemoryState() => MemoryContextState.SqlitePrimary; } From 24f2bd74aa12da8d3637d04ea49a1b3a9f02a356 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 13 May 2026 18:53:01 +0000 Subject: [PATCH 4/6] =?UTF-8?q?chore(openspec):=20tick=20task=206.3=20?= =?UTF-8?q?=E2=80=94=20openspec=20validate=20now=20passes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/align-subagent-loading-and-parent-context/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md index 9a4796658..bf3cc89ce 100644 --- a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md +++ b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md @@ -35,7 +35,7 @@ - [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`. -- [ ] 6.3 Run `openspec validate align-subagent-loading-and-parent-context`. +- [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. From 3ddec1856567f57f94909ffd693fb4548415f7e2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 13 May 2026 19:43:24 +0000 Subject: [PATCH 5/6] test(subagents): cover reload + parent-context inheritance gaps - FileSubAgentDefinitionLoaderTests: prove SyncInto picks up disk edits between activations (the contract both spawn_agent and metadata.subagent routing share) and is a no-op when nothing changed. Closes task 2.3. - SubAgentActorTests: parent project unset, parent project changed between two runs, and system-prompt project-instructions precedence. Closes tasks 3.4 and 4.3. - subagent-authoring SKILL.md: spell out that routed skills use the same loader+registry contract as spawn_agent. Bump to 1.2.1. Closes task 5.3. - evals: add skill_activation_subagent_authoring case so the suite covers the skill we just edited. --- evals/run-evals.sh | 9 ++ .../.system/files/subagent-authoring/SKILL.md | 7 +- .../tasks.md | 8 +- .../SubAgents/SubAgentActorTests.cs | 105 ++++++++++++++++++ .../FileSubAgentDefinitionLoaderTests.cs | 52 +++++++++ 5 files changed, 176 insertions(+), 5 deletions(-) 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 748505b8e..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.2.0" + version: "1.2.1" --- # Subagent Authoring @@ -145,6 +145,11 @@ 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: diff --git a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md index bf3cc89ce..845f5f3f5 100644 --- a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md +++ b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md @@ -9,26 +9,26 @@ - [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. -- [ ] 2.3 Add tests proving explicit delegation and routed skill execution both pick up reloaded definitions on the next activation. +- [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. -- [ ] 3.4 Add tests covering parent project unset, parent project set, and parent project changed between two subagent runs. +- [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`. -- [ ] 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. +- [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. -- [ ] 5.3 Update any relevant skill-routing guidance so `metadata.subagent` documentation matches the shared reload and inheritance contract. +- [x] 5.3 Update any relevant skill-routing guidance so `metadata.subagent` documentation matches the shared reload and inheritance contract. - [ ] 5.4 Run the eval suite if system skill content changes as part of the implementation PR. ## 6. Verification and OpenSpec completion diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 0863eebf0..095f964bd 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -114,6 +114,111 @@ public async Task Tool_execution_inherits_parent_session_and_project_directories 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.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs b/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs index e71558cec..46b5b7a9c 100644 --- a/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs +++ b/src/Netclaw.Configuration.Tests/FileSubAgentDefinitionLoaderTests.cs @@ -353,6 +353,58 @@ public void RefreshIfChanged_detects_deletes_and_returns_empty_snapshot() 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)); + } } /// From f98bc3de3742f2775326a3f34b30028b4f7329a2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 13 May 2026 19:46:38 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(openspec):=20tick=20task=205.4=20?= =?UTF-8?q?=E2=80=94=20subagent-authoring=20eval=20green=203/3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/align-subagent-loading-and-parent-context/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md index 845f5f3f5..3c5a35c81 100644 --- a/openspec/changes/align-subagent-loading-and-parent-context/tasks.md +++ b/openspec/changes/align-subagent-loading-and-parent-context/tasks.md @@ -29,7 +29,7 @@ - [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. -- [ ] 5.4 Run the eval suite if system skill content changes as part of the implementation PR. +- [x] 5.4 Run the eval suite if system skill content changes as part of the implementation PR. ## 6. Verification and OpenSpec completion