diff --git a/evals/README.md b/evals/README.md index ca0e142a7..85bd194ac 100644 --- a/evals/README.md +++ b/evals/README.md @@ -47,6 +47,13 @@ default provider. `$EVAL_HOME` is deleted. A throwaway root-in-container cleanup step handles files the daemon wrote as UID 0. +The harness preloads `evals/fixtures/config/netclaw.json` into the ephemeral +home before startup. It auto-approves tools and grants read/write access for the +Personal audience because headless sessions cannot answer approval prompts or +edit an interactive trust policy. A companion `tool-approvals.json` trusts Git +for shell-based coding cases. Tool exposure and command-deny rules still apply, +and these policies are never copied into an operator's config. + `--network host` is the default because operators often host their LLM on a Tailscale node — MagicDNS hostnames like `my-gpu-server.tailnet.ts.net` only resolve when the container shares the host's DNS resolver. macOS/Windows @@ -69,6 +76,7 @@ log patterns** (skill loading, memory recall, checkpoint formation). | Autonomy & Execution | 2 | Executes tasks rather than describing them | | Deployment Mission | 1 | Applies the disk mission playbook, loads its required skill, and returns reviewed sales email | | Subagents | 2 | Delegates through `spawn_agent`, completes ambiguous work, and gives specialized subagent guidance precedence over a conflicting deployment playbook | +| Coding Context | 1 | Repeatedly switches between isolated linked worktrees, alternates branch and one-of-four target files by run, and verifies Git grounding, wrong-file/worktree safety, and path-free child handoff | | Complex Task Execution | 5 | Multi-step tool chains complete successfully, incl. bounded tool output — given only the goal (no handling hints), the agent retrieves a deep line from oversized shell output and from a large file, which is only possible by coping with the bound the way AGENTS.md/skills/steer text direct | | Multi-Turn Conversation | 7 | Session resume and speaker attribution recall | @@ -156,20 +164,24 @@ NETCLAW_EVAL_PROVIDER_TYPE=ollama \ NETCLAW_EVAL_PROVIDER_ENDPOINT=http://127.0.0.1:11434 \ NETCLAW_EVAL_MODEL_ID=qwen3:30b \ ./evals/run-evals.sh + +# Run ten alternating linked-worktree/recent-file coherence trials +NETCLAW_IMAGE=netclaw-eval:working-context-treatment \ +NETCLAW_EVAL_PROVIDER_TYPE=openai-compatible \ +NETCLAW_EVAL_PROVIDER_ENDPOINT=https://your-provider.example/v1 \ +NETCLAW_EVAL_MODEL_ID=your-model \ +NETCLAW_EVAL_CASE=coding_context_worktree_handoff \ +NETCLAW_EVAL_RUNS=10 NETCLAW_EVAL_TIMEOUT=180 \ + ./evals/run-evals.sh ``` ## Results Database -Results are stored in `$EVAL_HOME/evals/results.db` (SQLite) inside the -per-run throwaway directory, NOT under `~/.netclaw/`. This means results -don't persist across runs by default — on script exit, the database is -deleted along with `$EVAL_HOME`. - -If you want to retain results for trend analysis, copy the database out -of `$EVAL_HOME` before the EXIT trap fires (look for the "Results: -..." line at the bottom of the script output to get the path). A -dedicated results-retention follow-up may add a `NETCLAW_EVAL_RESULTS_DB` -override. +Results are accumulated in `$EVAL_HOME/evals/results.db` during execution. +On exit, the harness archives the database, run metadata, daemon log, and +per-turn stdout under `evals/runs//` before deleting the throwaway +home. These archives are gitignored and can be compared locally without +touching the operator's `~/.netclaw/` state. Requires `sqlite3` CLI — if not available, the script still runs but skips persistence. diff --git a/evals/fixtures/agents/coding-worker.md b/evals/fixtures/agents/coding-worker.md new file mode 100644 index 000000000..85cbd2ad2 --- /dev/null +++ b/evals/fixtures/agents/coding-worker.md @@ -0,0 +1,7 @@ +--- +name: coding-worker +description: Eval fixture subagent that performs a small, deterministic code edit in the inherited project. +timeoutSeconds: 120 +--- + +You are a headless coding worker. Use the inherited working context to make the requested minimal edit. Inspect only what is necessary, use first-party file tools for edits, do not change branches or worktrees, and report the files you changed. diff --git a/evals/fixtures/config/netclaw.json b/evals/fixtures/config/netclaw.json new file mode 100644 index 000000000..2cc51d363 --- /dev/null +++ b/evals/fixtures/config/netclaw.json @@ -0,0 +1,21 @@ +{ + "configVersion": 1, + "Tools": { + "AudienceProfiles": { + "Personal": { + "ReadFiles": { + "Mode": "All", + "Roots": [] + }, + "WriteFiles": { + "Mode": "All", + "Roots": [] + }, + "ApprovalPolicy": { + "DefaultMode": "Auto", + "ToolOverrides": {} + } + } + } + } +} diff --git a/evals/fixtures/config/tool-approvals.json b/evals/fixtures/config/tool-approvals.json new file mode 100644 index 000000000..e2cd6a0d7 --- /dev/null +++ b/evals/fixtures/config/tool-approvals.json @@ -0,0 +1,37 @@ +{ + "version": 2, + "audiences": { + "personal": { + "shell_execute": [ + { + "verb": "git", + "directory": null + }, + { + "verb": "git branch", + "directory": null + }, + { + "verb": "git config", + "directory": null + }, + { + "verb": "git diff", + "directory": null + }, + { + "verb": "git rev-parse", + "directory": null + }, + { + "verb": "git status", + "directory": null + }, + { + "verb": "git worktree", + "directory": null + } + ] + } + } +} diff --git a/evals/run-evals.sh b/evals/run-evals.sh index b23cef9dd..8d1f1abd7 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -239,10 +239,15 @@ archive_eval_run() { cp "$TMPDIR_EVAL"/stdout_*.txt "$archive_dir/stdout/" 2>/dev/null || true fi - # Write run metadata + # Write run metadata, including the immutable image identity so before/after + # comparisons remain auditable even when tags are later rebuilt. + local image_id + image_id=$(docker image inspect "$NETCLAW_IMAGE" --format '{{.Id}}' 2>/dev/null || echo unknown) cat > "$archive_dir/run-info.txt" <256 KB) text file in the workspaces read-root for the # bounded-tool-output file_read eval (complex_large_file_read_ranged). It must # be too big for one inline read AND have model-unguessable content so the only @@ -386,18 +398,6 @@ start_eval_daemon() { awk 'BEGIN{x=1;for(i=1;i<=30000;i++){x=(x*48271)%2147483647;print x}}' \ > "$EVAL_HOME/data/workspaces/netclaw-eval-largefile.txt" - # Pre-trust the 'sleep' verb so the background-job lifecycle eval can - # actually submit its job: the headless container has no approval - # requester, and background submission evaluates the approval gate before - # StartBackgroundJob — without this every submission dies at the gate and - # the case can only test API shape, not the lifecycle. trust-verb writes a - # global-wildcard (verb, null) entry to tool-approvals.json under the - # CLI's NETCLAW_HOME; $EVAL_HOME/data is what the container mounts at - # /home/netclaw/.netclaw, and the daemon re-reads the file per approval - # evaluation. - NETCLAW_HOME="$EVAL_HOME/data" "$NETCLAW_BIN" approvals trust-verb sleep --audience personal >/dev/null 2>&1 \ - || echo "WARN: could not pre-trust 'sleep' — tool_background_job_lifecycle will fail at the approval gate" >&2 - # The eval container runs as the non-root `netclaw` user and needs write # access to the bind-mounted identity, logs, skills, and data trees. chmod -R ugo+rwX "$EVAL_HOME/identity" "$EVAL_HOME/logs" "$EVAL_HOME/data" "$EVAL_HOME/skills" @@ -768,6 +768,11 @@ run_prompt_resume() { local prompt="$2" local turn_file="$TMPDIR_EVAL/stdout_$(date +%s%N)_turn.txt" + if [[ ! -x "$NETCLAW_BIN" ]]; then + echo "ERROR: eval CLI disappeared during the run: $NETCLAW_BIN" >&2 + exit 2 + fi + # First call in a multi-turn case: open a fresh shared STDOUT_FILE. if [[ -z "${MULTI_TURN_STDOUT_FILE:-}" ]]; then MULTI_TURN_STDOUT_FILE="$TMPDIR_EVAL/stdout_$(date +%s%N)_multi.txt" @@ -823,20 +828,33 @@ run_multi_turn_case() { local session_id="eval/${case_name}-run${run}-$$" MULTI_TURN_STDOUT_FILE="" + local setup_fn="setup_${case_name}" + if declare -f "$setup_fn" >/dev/null 2>&1; then + "$setup_fn" "$run" + fi + local turn=1 local prompt for prompt in "${prompts[@]}"; do - run_prompt_resume "$session_id" "$prompt" + local rendered_prompt="$prompt" + rendered_prompt="${rendered_prompt//\{\{FIRST_WORKTREE\}\}/${CODING_CONTEXT_FIRST_WORKTREE:-}}" + rendered_prompt="${rendered_prompt//\{\{SECOND_WORKTREE\}\}/${CODING_CONTEXT_SECOND_WORKTREE:-}}" + rendered_prompt="${rendered_prompt//\{\{TARGET_BRANCH\}\}/${CODING_CONTEXT_TARGET_BRANCH:-}}" + rendered_prompt="${rendered_prompt//\{\{TARGET_FILE\}\}/${CODING_CONTEXT_TARGET_FILE:-}}" + run_prompt_resume "$session_id" "$rendered_prompt" store_metrics "$case_name" "$run" "$turn" "$LAST_TURN_USAGE_LINE" turn=$((turn + 1)) done local passed=0 local details="fail" + EVAL_ASSERTION_DETAILS="" if $assert_fn 2>/dev/null; then passed=1 passes=$((passes + 1)) details="pass" + elif [[ -n "${EVAL_ASSERTION_DETAILS:-}" ]]; then + details="$EVAL_ASSERTION_DETAILS" fi # Use the first prompt as the representative prompt_used for eval_results. @@ -1226,6 +1244,94 @@ assert_subagent_specialization_precedence() { stdout_response_contains 'Would Tuesday or Wednesday work for a 15-minute call?' } +setup_coding_context_worktree_handoff() { + local run="$1" + if (( run % 2 == 1 )); then + CODING_CONTEXT_FIRST="blue" + CODING_CONTEXT_SECOND="green" + else + CODING_CONTEXT_FIRST="green" + CODING_CONTEXT_SECOND="blue" + fi + CODING_CONTEXT_FIRST_WORKTREE="/home/netclaw/.netclaw/workspaces/coding-context-$CODING_CONTEXT_FIRST" + CODING_CONTEXT_SECOND_WORKTREE="/home/netclaw/.netclaw/workspaces/coding-context-$CODING_CONTEXT_SECOND" + CODING_CONTEXT_TARGET_BRANCH="feature/$CODING_CONTEXT_SECOND" + local -a target_files=( + "src/CalculatorAlpha.cs" + "src/CalculatorBeta.cs" + "src/CalculatorGamma.cs" + "src/CalculatorDelta.cs" + ) + CODING_CONTEXT_TARGET_FILE="${target_files[$(((run - 1) % ${#target_files[@]}))]}" + + docker exec --user netclaw "$EVAL_CONTAINER_NAME" bash -lc ' + set -euo pipefail + base=/home/netclaw/.netclaw/workspaces + rm -rf "$base/coding-context" "$base/coding-context-blue" "$base/coding-context-green" + mkdir -p "$base/coding-context/src" + git -C "$base/coding-context" init -b main >/dev/null + git -C "$base/coding-context" config user.name "Netclaw Eval" + git -C "$base/coding-context" config user.email "eval@netclaw.dev" + for name in Alpha Beta Gamma Delta; do + printf "public static class Calculator%s\n{\n public static int Add(int a, int b) => a + b;\n}\n" "$name" > "$base/coding-context/src/Calculator$name.cs" + done + git -C "$base/coding-context" add src + git -C "$base/coding-context" commit -m seed >/dev/null + git -C "$base/coding-context" worktree add -b feature/blue "$base/coding-context-blue" >/dev/null + git -C "$base/coding-context" worktree add -b feature/green "$base/coding-context-green" >/dev/null + for color in blue green; do + printf "%s staged context\n" "$color" > "$base/coding-context-$color/STAGED-$color.txt" + git -C "$base/coding-context-$color" add "STAGED-$color.txt" + printf "%s untracked context\n" "$color" > "$base/coding-context-$color/UNTRACKED-$color.txt" + done + ' +} + +assert_coding_context_worktree_handoff() { + if ! docker exec --user netclaw \ + -e "EVAL_FIRST=$CODING_CONTEXT_FIRST" \ + -e "EVAL_SECOND=$CODING_CONTEXT_SECOND" \ + -e "EVAL_TARGET_FILE=$CODING_CONTEXT_TARGET_FILE" \ + "$EVAL_CONTAINER_NAME" bash -lc ' + set -euo pipefail + base=/home/netclaw/.netclaw/workspaces + first="$base/coding-context-$EVAL_FIRST" + second="$base/coding-context-$EVAL_SECOND" + main="$base/coding-context" + test "$(git -C "$second" branch --show-current)" = "feature/$EVAL_SECOND" + grep -q "Divide" "$second/$EVAL_TARGET_FILE" + for tree in "$first" "$main"; do + ! grep -R -q "Divide" "$tree/src" + done + while IFS= read -r file; do + [[ "$file" == "$second/$EVAL_TARGET_FILE" ]] || ! grep -q "Divide" "$file" + done < <(find "$second/src" -type f -name "*.cs" -print) + '; then + EVAL_ASSERTION_DETAILS="wrong_worktree_or_missing_edit" + return 1 + fi + if ! stdout_tool_called 'spawn_agent'; then + EVAL_ASSERTION_DETAILS="spawn_agent_not_called" + return 1 + fi + if ! grep -a '^\[tool:call\] spawn_agent' "$STDOUT_FILE" | grep -qv '"Context"'; then + EVAL_ASSERTION_DETAILS="manual_context_injected" + return 1 + fi + if grep -a '^\[tool:call\] spawn_agent' "$STDOUT_FILE" | grep -q 'Calculator'; then + EVAL_ASSERTION_DETAILS="file_name_leaked_to_child" + return 1 + fi + if ! stdout_response_contains "$(basename "$CODING_CONTEXT_TARGET_FILE")"; then + EVAL_ASSERTION_DETAILS="changed_file_not_reported" + return 1 + fi + if ! stdout_response_contains "$CODING_CONTEXT_TARGET_BRANCH"; then + EVAL_ASSERTION_DETAILS="target_branch_not_reported" + return 1 + fi +} + # Category 7: Complex Task Execution assert_complex_write_and_run() { stdout_contains '\[tool:call\] file_write' && \ @@ -1721,6 +1827,16 @@ run_all() { end_category + print_category "Coding Context" + + run_multi_turn_case coding_context_worktree_handoff "maintains branch, worktree, and recent-file coherence across a project switch and child handoff" \ + "Adopt {{FIRST_WORKTREE}} as the project, inspect {{TARGET_FILE}}, and tell me the current branch, worktree, and staged-file count." \ + "Switch the project to {{SECOND_WORKTREE}}, inspect its {{TARGET_FILE}}, and report its current branch. Do not modify either worktree yet." \ + "Call spawn_agent with Agent coding-worker and Task exactly: Add a Divide(int a, int b) method to the file the parent most recently inspected, using a first-party file editing tool. Do not include a Context argument or add any path, file name, file contents, branch, worktree, or cwd to the Task; this exercise measures inherited working context. Return the child result." \ + "Without running any more tools, report the current branch and the exact files the subagent changed." + + end_category + # ── Category 7: Complex Task Execution ── print_category "Complex Task Execution" diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 5f0859aff..15acd651b 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -43,6 +43,13 @@ allowed roots); the project's identity file (`.netclaw/AGENTS.md`, `CLAUDE.md`, `AGENTS.md`, or `CONTEXT.md`) then loads into the prompt. Full rules: `skill_read_resource('netclaw-operations', 'references/projects.md')`. +For Team and Personal sessions, `[working-context]` is refreshed at the start +of each new turn. In a Git project it includes the active worktree, branch, +HEAD, upstream divergence, and dirty counts. Treat this as turn-start +grounding: a checkout or commit performed during the current tool loop appears +in the next turn's snapshot. Subagents receive a read-only project/recent-file +snapshot and return confirmed file edits to the parent when they complete. + ## Scheduling & Background Jobs Reminders: `set_reminder` with schedule type `once` / `interval` / `cron`. Always diff --git a/feeds/skills/.system/files/subagent-authoring/SKILL.md b/feeds/skills/.system/files/subagent-authoring/SKILL.md index 8ebb768d2..158d454b6 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.3.2" + version: "1.4.0" --- # Subagent Authoring @@ -23,6 +23,16 @@ Subagents are subject to two independent gates: Both gates must pass for subagent features to be available. +## Working context + +A spawned subagent receives the parent turn's project directory and recent-file +snapshot in its initial runtime context. The child tracks its own first-party +file reads and edits for the lifetime of the run; it never mutates the parent's +durable working context directly. On successful completion Netclaw returns a +structured file handoff and merges confirmed child edits into the parent for +the next turn. Git changes merely observed in a shared worktree are reported +without claiming that the child authored them. + ## When to use Load this when the user asks to: diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/.openspec.yaml b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/.openspec.yaml new file mode 100644 index 000000000..8803b473e --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/design.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/design.md new file mode 100644 index 000000000..369292556 --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/design.md @@ -0,0 +1,68 @@ +## Context + +The durable main-session `WorkingContext` owns `ProjectDirectory` and `RecentFiles`. `LlmSessionActor` renders it once at turn start into a volatile history nudge so subsequent turns extend the byte-stable prompt prefix. `SubAgentActor` uses a separate ephemeral prompt/tool loop: it inherits project/cwd authority but neither runs `SessionMessageAssembler` nor owns file context. + +Git state is volatile process-derived data. Persisting it would create stale state, while running Git inside the synchronous assembler would mix I/O into a pure cache-layout component. Subagent results cross an actor/tool boundary and must use framework-owned, serialization-safe types. + +## Goals / Non-Goals + +**Goals:** + +- Produce one audience-filtered working-context snapshot implementation for main and child agents. +- Preserve durable parent ownership and cache-stable tail insertion. +- Track confirmed child file activity separately from worktree changes merely observed during the run. +- Keep Git inspection bounded, credential-safe, linked-worktree-aware, and explicit on failure. +- Provide deterministic contract tests and focused multi-turn behavioral evals. + +**Non-Goals:** + +- Refreshing context between tool-loop calls. +- Automatically creating isolated worktrees. +- Proving authorship from shared-worktree status changes. +- Persisting subagent state or Git snapshots. + +## Decisions + +### Shared snapshot service, pure rendering + +Add a working-context snapshot service that accepts audience, project directory, and recent files and returns an immutable snapshot. It performs conditional, strictly time-bounded Git inspection at the existing synchronous turn boundary before prompt assembly. Rendering remains pure and produces one `[working-context]` block with a nested `git:` section. + +Alternative: extend `IContextLayerProvider` with session state. Rejected because subagents do not use that pipeline and the resulting interface would mix process I/O into the assembler. An asynchronous actor continuation was also rejected for v1 because it would add a new reentrancy/state-machine transition to every LLM call; the bounded snapshot preserves the existing actor contract. + +### Boundary-only refresh + +The main session snapshots at the first LLM call of each new turn. A subagent snapshots at spawn and completion. Earlier history bytes are never rewritten. + +Alternative: refresh after Git-mutating tools. Deferred because it adds context messages and invalidation logic inside autonomous tool loops, weakening the cache behavior this pipeline deliberately preserves. + +### Git porcelain inspection + +Use `git` directly through `ProcessStartInfo.ArgumentList`, never a shell. A bounded porcelain-v2 status command supplies branch, HEAD, upstream, ahead/behind, and file state; separate rev-parse queries resolve the worktree root and common Git directory when required. All invocations share a short cancellation deadline and capture bounded output. Remote URLs are not requested or rendered. + +No project directory means no Git section. A successful Git response identifying a non-worktree means no Git section. Missing executable, timeout, permission, or corrupt-repository failures render `git.status: unavailable` with a sanitized reason for Team/Personal and are logged; they do not masquerade as a non-Git directory. + +### Independent child context and structured handoff + +`RunSubAgent` carries a copy of the parent's recent files in addition to existing project/cwd fields. `SubAgentActor` owns an ephemeral context, updates it from the same canonical tool-call path extraction used by the parent, and captures final Git state. `SubAgentResult` gains optional framework-owned working-context metadata. + +Confirmed files come from first-party file-tool semantics. Git start/final differences are `ObservedFiles` because concurrent actors can share the worktree. The parent merges confirmed files only after successful completion; observed files remain structured evidence but are not silently attributed or merged. + +### Compatibility + +New spawn/result members are optional collection/record members with empty defaults, so existing callers and older serialized messages remain readable. No durable session event or config schema changes are introduced. + +## Risks / Trade-offs + +- Git status can be slow on pathological repositories → enforce cancellation, bounded output, and one snapshot per boundary. +- A shared worktree can change concurrently → distinguish confirmed from observed and never claim observed authorship. +- Added context consumes tokens → render compact counts/paths and measure uncached tokens plus avoided discovery calls. +- Child completion may fail before handoff → do not merge partial activity into parent durable state; logs retain diagnostic evidence. +- Git may be absent or broken → fail visibly in eligible context rather than silently emitting a clean/non-Git state. + +## Migration Plan + +Deploy as an additive runtime/protocol change with no configuration migration. Rollback removes the optional child metadata and derived renderer; durable `WorkingContext` remains compatible because its stored shape is unchanged. + +## Open Questions + +None for v1. diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/proposal.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/proposal.md new file mode 100644 index 000000000..388b5e87a --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/proposal.md @@ -0,0 +1,38 @@ +## Why + +Netclaw sessions know their project directory and recently used files, but they do not expose the active Git worktree, branch, HEAD, or dirty state to the model. Ephemeral subagents inherit filesystem authority without inheriting or maintaining model-visible working context, so coding delegates can lose track of the files and worktree they are operating on and cannot return a reliable structured change summary to their parent. + +This advances PRD-001 FR-006 layered session context and PRD-007 project/environment awareness while preserving Netclaw's default-deny audience filtering and cache-stable prompt assembly. + +## What Changes + +- Enrich the existing turn-start `[working-context]` block with bounded, credential-safe Git worktree state when `ProjectDirectory` is inside a Git repository. +- Give each subagent an independent run-scoped working context initialized from the parent's project directory and recent-file snapshot. +- Track child file activity from canonical tool metadata and use start/final Git snapshots to report indirect worktree changes without claiming exclusive authorship. +- Return structured child working-context metadata and merge only confirmed child-touched files into the parent's durable recent-file state after successful completion. +- Add targeted multi-turn coding evals that compare behavioral correctness, redundant orientation calls, structured handoff, and cache usage on deterministic linked-worktree fixtures. + +In scope for MVP: main turn-boundary snapshots, subagent spawn/completion snapshots, linked-worktree awareness, structured handoff, audience filtering, and focused eval coverage. + +Out of scope: refresh during an active tool loop, automatic worktree creation per child, exact authorship attribution in a shared worktree, and GitHub PR/issue context. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `session-cwd`: Working context includes derived, turn-boundary Git worktree state without persisting that volatile state. +- `netclaw-subagents`: Subagents inherit a read-only parent snapshot, maintain run-scoped file context, and return structured working-context results. +- `audience-context-filtering`: Git paths and repository state follow the same Public suppression rule as working context. +- `netclaw-testing`: The behavioral eval harness supports deterministic, fixture-backed multi-turn coding-context cases. + +## Impact + +- Session and subagent actor prompt assembly, subagent spawn/result protocol, tool-result file tracking, and daemon dependency registration. +- Actor protocol serialization compatibility: new result metadata is optional for older messages and does not alter durable session event shapes. +- A bounded local `git` subprocess is added at eligible context boundaries; non-Git projects emit no Git section, while inspection failures are explicit and observable. +- No configuration schema changes and no expansion of tool/file authority. +- Operationally, remote URLs are not emitted and Public turns receive no internal working/Git context. diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/audience-context-filtering/spec.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/audience-context-filtering/spec.md new file mode 100644 index 000000000..1fbc5fdb7 --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/audience-context-filtering/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: Working context suppression for Public + +The working context block, including project directory, recent files, Git worktree paths, branch, HEAD, and dirty state, SHALL NOT be injected into Public-audience main sessions or subagents. + +#### Scenario: Public session has no working context + +- **WHEN** a Public-audience session has a non-empty working context or eligible Git project directory +- **THEN** no `[working-context]` block is injected into the volatile context block +- **AND** no Git inspection result is exposed to the model + +#### Scenario: Public subagent receives no internal working context + +- **GIVEN** a subagent is launched under a Public parent turn +- **WHEN** the child initial prompt is assembled +- **THEN** no parent project path, recent-file list, or Git state is included + +#### Scenario: Team session receives working context + +- **WHEN** a Team-audience session has a non-empty working context +- **THEN** `WorkingContext` and any successfully derived Git enrichment are injected into the volatile context block diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/netclaw-subagents/spec.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/netclaw-subagents/spec.md new file mode 100644 index 000000000..cac9376b2 --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/netclaw-subagents/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Subagents maintain run-scoped working context +Each subagent SHALL own an ephemeral working context initialized from a read-only snapshot of the parent session's project directory and recent files. The initial snapshot SHALL be included in the runtime-context portion of the child user message and SHALL NOT modify the reusable subagent system prompt. Child activity SHALL NOT mutate parent session state during execution. + +#### Scenario: Child receives parent recent-file grounding +- **GIVEN** a parent session with a project directory and recent files +- **WHEN** it spawns a permitted subagent +- **THEN** the child's initial model input contains the parent project directory and recent-file snapshot +- **AND** its tool execution remains grounded by the existing inherited authority context + +#### Scenario: Child file activity is isolated +- **GIVEN** a running child that reads or changes a file +- **WHEN** the child updates its run-scoped working context +- **THEN** the parent durable working context is unchanged until child completion is handled + +### Requirement: Subagent completion returns structured working context +`SubAgentResult` SHALL carry optional structured working-context metadata containing project/worktree identity, files read, confirmed files changed through recognized first-party file tools, files observed changed between bounded Git snapshots, and final branch and HEAD when available. Observed worktree changes SHALL NOT be represented as exclusively authored by the child. + +#### Scenario: First-party edit is confirmed +- **GIVEN** a child changes a file through a recognized first-party file tool +- **WHEN** the child completes successfully +- **THEN** the canonical path appears in confirmed changed files + +#### Scenario: Shell-generated file is observed +- **GIVEN** a child invokes a shell command that changes a Git worktree file without first-party file-tool provenance +- **WHEN** final Git state differs from the spawn snapshot +- **THEN** the file appears in observed changed files +- **AND** is not claimed as a confirmed child-authored file + +#### Scenario: Parent merges only confirmed successful activity +- **GIVEN** a child completes successfully with confirmed and observed file metadata +- **WHEN** the parent handles the structured result +- **THEN** confirmed files are merged into the parent's durable recent-file context +- **AND** observed-only files are not silently merged or attributed + +#### Scenario: Failed child does not merge partial activity +- **GIVEN** a child fails or is cancelled after touching files +- **WHEN** the parent handles the failure result +- **THEN** no child file metadata is merged into parent durable working context diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/netclaw-testing/spec.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/netclaw-testing/spec.md new file mode 100644 index 000000000..418173f17 --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/netclaw-testing/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: Coding-context evals use isolated deterministic fixtures +The behavioral eval suite SHALL support focused multi-turn coding-context cases where every scored run receives a fresh Git repository, linked worktree, unique named session, deterministic file state, and independent filesystem assertions. + +#### Scenario: Main and child context lifecycle is evaluated across turns +- **GIVEN** a fresh linked-worktree fixture and unique resumed session +- **WHEN** one turn establishes file context, a later turn delegates coding, and a final turn reports resulting context +- **THEN** assertions inspect JSON tool behavior, structured child metadata, and direct Git/filesystem state + +#### Scenario: Baseline and treatment results are comparable +- **GIVEN** baseline and treatment images use the same model settings and prompt variants +- **WHEN** the focused coding-context category is run repeatedly +- **THEN** results retain correctness, orientation-call, clarification, token, cache, and latency metrics for comparison diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/session-cwd/spec.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/session-cwd/spec.md new file mode 100644 index 000000000..8b89369d3 --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/specs/session-cwd/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Working context includes derived Git worktree state +For Team and Personal turns whose `WorkingContext.ProjectDirectory` is inside a Git worktree, the system SHALL derive a fresh Git snapshot at turn start and render it as a nested section of `[working-context]`. The snapshot SHALL include worktree root, common repository directory, branch or detached state, HEAD, upstream and ahead/behind when configured, and staged, modified, and untracked counts. Derived Git state SHALL NOT be persisted in session state. + +#### Scenario: Linked worktree is distinguished from common repository +- **GIVEN** a session project directory inside a linked Git worktree +- **WHEN** the next turn-start working-context snapshot is built +- **THEN** the model-visible context identifies the linked worktree path and common repository directory +- **AND** reports the linked worktree's branch and HEAD + +#### Scenario: Git state refreshes on the next turn +- **GIVEN** a tool changes branch, HEAD, or dirty state during one turn +- **WHEN** the session begins its next turn +- **THEN** the new volatile working-context nudge contains the updated Git snapshot +- **AND** earlier history messages are not rewritten + +#### Scenario: Non-Git project has no Git section +- **GIVEN** a valid project directory that is not inside a Git worktree +- **WHEN** working context is assembled +- **THEN** the normal project and recent-file context remains available +- **AND** no Git section is rendered + +#### Scenario: Git inspection failure is visible +- **GIVEN** a project directory whose Git state cannot be inspected because Git is missing, times out, or the repository is invalid +- **WHEN** working context is assembled for an eligible audience +- **THEN** Git status is reported as unavailable with a sanitized reason +- **AND** the failure is not represented as a clean or non-Git worktree + +#### Scenario: Git remote credentials are never rendered +- **GIVEN** a repository with a credential-bearing remote URL +- **WHEN** Git working context is rendered +- **THEN** no remote credentials or complete remote URL appears in model-visible context or logs diff --git a/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/tasks.md b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/tasks.md new file mode 100644 index 000000000..2265826a0 --- /dev/null +++ b/openspec/changes/archive/2026-07-12-git-aware-subagent-working-context/tasks.md @@ -0,0 +1,23 @@ +## 1. Shared Working-Context Snapshot + +- [x] 1.1 Add immutable snapshot/result types and a shared audience-aware snapshot service with pure `[working-context]` rendering. +- [x] 1.2 Add bounded, non-shell Git porcelain inspection for linked worktrees, branch/HEAD/upstream/divergence, dirty counts, non-Git detection, and explicit sanitized failures. +- [x] 1.3 Register the snapshot service and integrate it at the main session's turn-start volatile-tail boundary without persisting Git state. + +## 2. Subagent Context Ownership And Handoff + +- [x] 2.1 Extend the spawn protocol with a read-only parent recent-file snapshot and initialize child runtime context without changing the reusable system prompt. +- [x] 2.2 Reuse canonical tool-result path tracking to maintain child read/confirmed-change state and capture start/final Git snapshots for observed changes. +- [x] 2.3 Extend structured subagent completion metadata and merge only confirmed files from successful children into parent durable working context. + +## 3. Automated Proof + +- [x] 3.1 Add unit tests for Git parsing/inspection, rendering, audience suppression, timeout/failure behavior, linked worktrees, and credential non-disclosure. +- [x] 3.2 Add actor/integration tests for main next-turn refresh, child inheritance/isolation, structured handoff, successful merge, failed-child non-merge, and cache-stable placement. +- [x] 3.3 Add fixture-aware targeted multi-turn coding-context eval cases with direct Git/filesystem assertions and JSON/cache metrics. + +## 4. Guidance And Verification + +- [x] 4.1 Update mapped system-skill guidance and eval documentation for Git-aware main/subagent working context. +- [x] 4.2 Validate OpenSpec artifacts, run targeted tests and focused eval/cache cases where a provider is available, then run Slopwatch and file-header verification. +- [x] 4.3 Verify implementation against the OpenSpec change and sync/archive the completed change. diff --git a/openspec/specs/audience-context-filtering/spec.md b/openspec/specs/audience-context-filtering/spec.md index 8aed5cada..d7538b0bc 100644 --- a/openspec/specs/audience-context-filtering/spec.md +++ b/openspec/specs/audience-context-filtering/spec.md @@ -12,9 +12,7 @@ defines how the audience parameter flows through context-layer assembly, session and working-context blocks, file-access denial messaging, implicit file roots, and audience derivation, with secure-by-default behavior and no default-audience fallback. - ## Requirements - ### Requirement: Context layer audience filtering The context layer system SHALL accept a `TrustAudience` parameter on @@ -78,18 +76,24 @@ audiences. ### Requirement: Working context suppression for Public -The working context block (project directory, recent files) SHALL NOT be -injected into Public-audience sessions. +The working context block, including project directory, recent files, Git worktree paths, branch, HEAD, and dirty state, SHALL NOT be injected into Public-audience main sessions or subagents. #### Scenario: Public session has no working context -- **WHEN** a Public-audience session has a non-empty working context -- **THEN** `WorkingContext.ToContextBlock()` is NOT injected into the volatile context block +- **WHEN** a Public-audience session has a non-empty working context or eligible Git project directory +- **THEN** no `[working-context]` block is injected into the volatile context block +- **AND** no Git inspection result is exposed to the model + +#### Scenario: Public subagent receives no internal working context + +- **GIVEN** a subagent is launched under a Public parent turn +- **WHEN** the child initial prompt is assembled +- **THEN** no parent project path, recent-file list, or Git state is included #### Scenario: Team session receives working context - **WHEN** a Team-audience session has a non-empty working context -- **THEN** `WorkingContext.ToContextBlock()` IS injected into the volatile context block +- **THEN** `WorkingContext` and any successfully derived Git enrichment are injected into the volatile context block ### Requirement: File access error message sanitization diff --git a/openspec/specs/netclaw-subagents/spec.md b/openspec/specs/netclaw-subagents/spec.md index 37d05a545..729808021 100644 --- a/openspec/specs/netclaw-subagents/spec.md +++ b/openspec/specs/netclaw-subagents/spec.md @@ -419,3 +419,41 @@ Every sub-agent SHALL receive the operating-rules composition for its launch aud - **WHEN** the sub-agent prompt is assembled - **THEN** their order is embedded core, deployment playbook, project instructions, sub-agent role, then headless execution contract +### Requirement: Subagents maintain run-scoped working context +Each subagent SHALL own an ephemeral working context initialized from a read-only snapshot of the parent session's project directory and recent files. The initial snapshot SHALL be included in the runtime-context portion of the child user message and SHALL NOT modify the reusable subagent system prompt. Child activity SHALL NOT mutate parent session state during execution. + +#### Scenario: Child receives parent recent-file grounding +- **GIVEN** a parent session with a project directory and recent files +- **WHEN** it spawns a permitted subagent +- **THEN** the child's initial model input contains the parent project directory and recent-file snapshot +- **AND** its tool execution remains grounded by the existing inherited authority context + +#### Scenario: Child file activity is isolated +- **GIVEN** a running child that reads or changes a file +- **WHEN** the child updates its run-scoped working context +- **THEN** the parent durable working context is unchanged until child completion is handled + +### Requirement: Subagent completion returns structured working context +`SubAgentResult` SHALL carry optional structured working-context metadata containing project/worktree identity, files read, confirmed files changed through recognized first-party file tools, files observed changed between bounded Git snapshots, and final branch and HEAD when available. Observed worktree changes SHALL NOT be represented as exclusively authored by the child. + +#### Scenario: First-party edit is confirmed +- **GIVEN** a child changes a file through a recognized first-party file tool +- **WHEN** the child completes successfully +- **THEN** the canonical path appears in confirmed changed files + +#### Scenario: Shell-generated file is observed +- **GIVEN** a child invokes a shell command that changes a Git worktree file without first-party file-tool provenance +- **WHEN** final Git state differs from the spawn snapshot +- **THEN** the file appears in observed changed files +- **AND** is not claimed as a confirmed child-authored file + +#### Scenario: Parent merges only confirmed successful activity +- **GIVEN** a child completes successfully with confirmed and observed file metadata +- **WHEN** the parent handles the structured result +- **THEN** confirmed files are merged into the parent's durable recent-file context +- **AND** observed-only files are not silently merged or attributed + +#### Scenario: Failed child does not merge partial activity +- **GIVEN** a child fails or is cancelled after touching files +- **WHEN** the parent handles the failure result +- **THEN** no child file metadata is merged into parent durable working context diff --git a/openspec/specs/netclaw-testing/spec.md b/openspec/specs/netclaw-testing/spec.md index 98dc193d7..7ede8553e 100644 --- a/openspec/specs/netclaw-testing/spec.md +++ b/openspec/specs/netclaw-testing/spec.md @@ -52,3 +52,16 @@ The system SHALL support optional smoke tests against live endpoints. - **WHEN** CI runs without Tailscale connectivity - **THEN** CI-required test suites still pass because live smoke tests are not required +### Requirement: Coding-context evals use isolated deterministic fixtures +The behavioral eval suite SHALL support focused multi-turn coding-context cases where every scored run receives a fresh Git repository, linked worktree, unique named session, deterministic file state, and independent filesystem assertions. + +#### Scenario: Main and child context lifecycle is evaluated across turns +- **GIVEN** a fresh linked-worktree fixture and unique resumed session +- **WHEN** one turn establishes file context, a later turn delegates coding, and a final turn reports resulting context +- **THEN** assertions inspect JSON tool behavior, structured child metadata, and direct Git/filesystem state + +#### Scenario: Baseline and treatment results are comparable +- **GIVEN** baseline and treatment images use the same model settings and prompt variants +- **WHEN** the focused coding-context category is run repeatedly +- **THEN** results retain correctness, orientation-call, clarification, token, cache, and latency metrics for comparison + diff --git a/openspec/specs/session-cwd/spec.md b/openspec/specs/session-cwd/spec.md index e2f40f219..3e363db91 100644 --- a/openspec/specs/session-cwd/spec.md +++ b/openspec/specs/session-cwd/spec.md @@ -4,9 +4,7 @@ Define how a session tracks its project directory and how the agent declares it via `set_working_directory`. The project directory is the load-bearing input to the approval gate's safe-space root set: declaring it expands the trust boundary for shell invocations under that tree. - ## Requirements - ### Requirement: Session-scoped project directory Each session SHALL maintain a mutable `ProjectDirectory` in `WorkingContext` @@ -243,3 +241,35 @@ when the project directory is set. - **WHEN** `ToContextBlock()` is called - **THEN** the output includes `project_dir: /home/user/workspaces/akadonic` alongside the recent files listing + +### Requirement: Working context includes derived Git worktree state +For Team and Personal turns whose `WorkingContext.ProjectDirectory` is inside a Git worktree, the system SHALL derive a fresh Git snapshot at turn start and render it as a nested section of `[working-context]`. The snapshot SHALL include worktree root, common repository directory, branch or detached state, HEAD, upstream and ahead/behind when configured, and staged, modified, and untracked counts. Derived Git state SHALL NOT be persisted in session state. + +#### Scenario: Linked worktree is distinguished from common repository +- **GIVEN** a session project directory inside a linked Git worktree +- **WHEN** the next turn-start working-context snapshot is built +- **THEN** the model-visible context identifies the linked worktree path and common repository directory +- **AND** reports the linked worktree's branch and HEAD + +#### Scenario: Git state refreshes on the next turn +- **GIVEN** a tool changes branch, HEAD, or dirty state during one turn +- **WHEN** the session begins its next turn +- **THEN** the new volatile working-context nudge contains the updated Git snapshot +- **AND** earlier history messages are not rewritten + +#### Scenario: Non-Git project has no Git section +- **GIVEN** a valid project directory that is not inside a Git worktree +- **WHEN** working context is assembled +- **THEN** the normal project and recent-file context remains available +- **AND** no Git section is rendered + +#### Scenario: Git inspection failure is visible +- **GIVEN** a project directory whose Git state cannot be inspected because Git is missing, times out, or the repository is invalid +- **WHEN** working context is assembled for an eligible audience +- **THEN** Git status is reported as unavailable with a sanitized reason +- **AND** the failure is not represented as a clean or non-Git worktree + +#### Scenario: Git remote credentials are never rendered +- **GIVEN** a repository with a credential-bearing remote URL +- **WHEN** Git working context is rendered +- **THEN** no remote credentials or complete remote URL appears in model-visible context or logs diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs index 2f513248c..6c1baf43c 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -15,7 +15,6 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; using Netclaw.Actors.Tests.Channels.TestHelpers; -using Netclaw.Actors.Memory; using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; using Netclaw.Actors.Tests.Sessions; @@ -74,18 +73,7 @@ protected override void ConfigureServices(HostBuilderContext context, IServiceCo services.AddSingleton(new ImageCapabilityResolver()); services.AddSingleton(); - services.AddSingleton(sp => new SessionServices( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetService>() ?? Array.Empty(), - sp.GetService() ?? TimeProvider.System, - sp.GetRequiredService())); - services.AddSingleton(sp => new SessionMemoryServices( - sp.GetService() ?? NullMemoryExtractor.Instance, - sp.GetService() ?? NullMemoryRecallCoordinator.Instance, - sp.GetService() ?? NullMemoryCheckpointSink.Instance, - sp.GetService())); - services.AddSingleton(new SessionObservability(null, null)); + services.AddLlmSessionCompositeRecords(); } protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) diff --git a/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs index 819f5d8a9..15ff02f52 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackAttachmentIngressTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -15,7 +15,6 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; using Netclaw.Actors.Tests.Channels.TestHelpers; -using Netclaw.Actors.Memory; using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; using Netclaw.Actors.Tests.Sessions; @@ -98,18 +97,7 @@ protected override void ConfigureServices(HostBuilderContext context, IServiceCo services.AddSingleton(new FakeCapabilityResolver()); services.AddSingleton(); - services.AddSingleton(sp => new SessionServices( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetService>() ?? Array.Empty(), - sp.GetService() ?? TimeProvider.System, - sp.GetRequiredService())); - services.AddSingleton(sp => new SessionMemoryServices( - sp.GetService() ?? NullMemoryExtractor.Instance, - sp.GetService() ?? NullMemoryRecallCoordinator.Instance, - sp.GetService() ?? NullMemoryCheckpointSink.Instance, - sp.GetService())); - services.AddSingleton(new SessionObservability(null, null)); + services.AddLlmSessionCompositeRecords(); } protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) diff --git a/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs index 6d65f8961..f167951c5 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -17,7 +17,6 @@ using Microsoft.Extensions.Hosting; using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; -using Netclaw.Actors.Memory; using Netclaw.Actors.Sessions; using Netclaw.Actors.Protocol; using Netclaw.Actors.Tests.Channels.TestHelpers; @@ -84,19 +83,7 @@ protected override void ConfigureServices(HostBuilderContext context, IServiceCo services.AddSingleton(new ImageCapabilityResolver()); services.AddSingleton(); - // Composite records for LlmSessionActor constructor - services.AddSingleton(sp => new SessionServices( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetService>() ?? Array.Empty(), - sp.GetService() ?? TimeProvider.System, - sp.GetRequiredService())); - services.AddSingleton(sp => new SessionMemoryServices( - sp.GetService() ?? NullMemoryExtractor.Instance, - sp.GetService() ?? NullMemoryRecallCoordinator.Instance, - sp.GetService() ?? NullMemoryCheckpointSink.Instance, - sp.GetService())); - services.AddSingleton(new SessionObservability(null, null)); + services.AddLlmSessionCompositeRecords(); } // serialize-messages = on causes the Akka.Streams channel output pipeline to stop diff --git a/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs index dbf125bdd..9a1d27527 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackThreadBackfillIntegrationTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -18,7 +18,6 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; using Netclaw.Actors.Tests.Channels.TestHelpers; -using Netclaw.Actors.Memory; using Netclaw.Actors.Sessions; using Netclaw.Actors.Protocol; using Netclaw.Actors.Tests.Sessions; @@ -77,18 +76,7 @@ protected override void ConfigureServices(HostBuilderContext context, IServiceCo "You are a test assistant.")); services.AddSingleton(new ImageCapabilityResolver()); services.AddSingleton(); - services.AddSingleton(sp => new SessionServices( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetService>() ?? Array.Empty(), - sp.GetService() ?? TimeProvider.System, - sp.GetRequiredService())); - services.AddSingleton(sp => new SessionMemoryServices( - sp.GetService() ?? NullMemoryExtractor.Instance, - sp.GetService() ?? NullMemoryRecallCoordinator.Instance, - sp.GetService() ?? NullMemoryCheckpointSink.Instance, - sp.GetService())); - services.AddSingleton(new SessionObservability(null, null)); + services.AddLlmSessionCompositeRecords(); } protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs index 1c8648d34..ff1a93492 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionTestExtensions.cs @@ -21,10 +21,12 @@ internal static class LlmSessionTestExtensions { public static IServiceCollection AddLlmSessionCompositeRecords(this IServiceCollection services) { + services.TryAddSingleton(); services.TryAddSingleton(sp => new SessionServices( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetService>() ?? Array.Empty(), + sp.GetRequiredService(), sp.GetService() ?? TimeProvider.System, sp.GetRequiredService())); diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionMessageAssemblerTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionMessageAssemblerTests.cs index d956f421a..7dc950234 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionMessageAssemblerTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionMessageAssemblerTests.cs @@ -462,7 +462,11 @@ public void Personal_audience_includes_working_context_in_volatile_block() WorkingContext = WorkingContext.Empty.AddRecentFile("src/Rect.cs") }; var input = MakeInput(SeedHistory("hi"), FakeRecall("mem-1"), audience: TrustAudience.Personal); - input = input with { State = stateWithWorkingContext }; + input = input with + { + State = stateWithWorkingContext, + WorkingContextBlock = stateWithWorkingContext.WorkingContext.ToContextBlock() + }; var block = SessionMessageAssembler.BuildVolatileContextBlock(input); Assert.Contains("[working-context]", block); @@ -491,6 +495,7 @@ private static ContextAssemblyInput MakeInput( SessionsBasePath: "/tmp/netclaw-test", FileReadGranted: fileReadGranted, ActiveRecall: activeRecall, + WorkingContextBlock: state.WorkingContext.ToContextBlock(), Audience: audience); } diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs index 02f282b82..cd334e11a 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs @@ -7,6 +7,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.Reminders; using Netclaw.Actors.Sessions; +using Netclaw.Tools; using Xunit; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -630,6 +631,36 @@ public void ProcessedReminderIds_is_not_persisted_in_snapshot() Assert.Empty(restored.ProcessedReminderIds); } + [Fact] + public void Successful_subagent_merge_adds_only_confirmed_changed_files() + { + var child = new SubAgentWorkingContextInfo + { + ReadFiles = ["src/ReadOnly.cs"], + ConfirmedChangedFiles = ["src/Changed.cs"], + ObservedChangedFiles = ["src/ObservedOnly.cs"] + }; + + var merged = LlmSessionActor.MergeSuccessfulSubAgentWorkingContext( + WorkingContext.Empty, true, child); + + Assert.Equal(["src/Changed.cs"], merged.RecentFiles); + } + + [Fact] + public void Failed_subagent_merge_does_not_change_parent_working_context() + { + var current = WorkingContext.Empty.AddRecentFile("src/Existing.cs"); + var child = new SubAgentWorkingContextInfo + { + ConfirmedChangedFiles = ["src/Denied.cs"] + }; + + var merged = LlmSessionActor.MergeSuccessfulSubAgentWorkingContext(current, false, child); + + Assert.Same(current, merged); + } + private static SessionState WithSystemPrompt(string content) { return SessionState.Empty with diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 26e8aaab7..dd6021dcc 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -178,6 +178,7 @@ You specialize in daemon health checks. toolAccessPolicy, approvalService: null, promptProvider, + new WorkingContextSnapshotProvider(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); registry.Register(new SpawnAgentTool(subAgentRegistry, spawner, subAgentPaths)); diff --git a/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs b/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs new file mode 100644 index 000000000..2666ef33f --- /dev/null +++ b/src/Netclaw.Actors.Tests/Sessions/WorkingContextSnapshotTests.cs @@ -0,0 +1,108 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.Sessions; +using Netclaw.Configuration; + +namespace Netclaw.Actors.Tests.Sessions; + +public class WorkingContextSnapshotTests +{ + [Fact] + public void ParseStatus_reads_branch_divergence_and_dirty_counts() + { + var snapshot = WorkingContextSnapshotProvider.ParseStatus( + "/worktrees/feature", + "/repos/app/.git", + """ + # branch.oid 0123456789abcdef + # branch.head feature/context + # branch.upstream origin/dev + # branch.ab +2 -1 + 1 M. N... 100644 100644 100644 aaaaaaa bbbbbbb src/Staged.cs + 1 .M N... 100644 100644 100644 aaaaaaa bbbbbbb src/Modified.cs + ? src/New.cs + """); + + Assert.Equal("feature/context", snapshot.Branch); + Assert.Equal("0123456789abcdef", snapshot.Head); + Assert.Equal("origin/dev", snapshot.Upstream); + Assert.Equal(2, snapshot.Ahead); + Assert.Equal(1, snapshot.Behind); + Assert.Equal(1, snapshot.Staged); + Assert.Equal(1, snapshot.Modified); + Assert.Equal(1, snapshot.Untracked); + Assert.Equal(3, snapshot.ChangedFiles.Count); + } + + [Fact] + public void ParseStatus_uses_rename_destination_as_changed_file() + { + var snapshot = WorkingContextSnapshotProvider.ParseStatus( + "/worktrees/feature", + "/repos/app/.git", + "2 R. N... 100644 100644 100644 aaaaaaa bbbbbbb R100 src/New Name.cs\tsrc/Old Name.cs"); + + Assert.Equal(["src/New Name.cs"], snapshot.ChangedFiles); + } + + [Fact] + public void Render_nests_git_under_working_context_without_remote_url() + { + var snapshot = new WorkingContextSnapshot + { + WorkingContext = WorkingContext.Empty + .WithProjectDirectory("/worktrees/feature") + .AddRecentFile("src/App.cs"), + Git = new GitWorkingContextSnapshot + { + Worktree = "/worktrees/feature", + CommonDirectory = "/repos/app/.git", + Branch = "feature/context", + Head = "01234567", + Upstream = "origin/dev", + Staged = 1, + Modified = 2, + Untracked = 3 + } + }; + + var block = snapshot.ToContextBlock(); + + Assert.Contains("[working-context]", block); + Assert.Contains("recent_files:\n - src/App.cs", block); + Assert.Contains("git:\n worktree: /worktrees/feature", block); + Assert.Contains("branch: feature/context", block); + Assert.DoesNotContain("https://", block, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Public_audience_does_not_inspect_or_render_git() + { + var provider = new WorkingContextSnapshotProvider( + NullLogger.Instance); + var context = WorkingContext.Empty.WithProjectDirectory("/path/that/does/not/exist"); + + var snapshot = provider.Create(context, TrustAudience.Public); + + Assert.Null(snapshot.Git); + Assert.Null(snapshot.GitUnavailableReason); + Assert.Equal(string.Empty, snapshot.ToContextBlock()); + } + + [Fact] + public void Missing_project_directory_reports_unavailable_for_personal_audience() + { + var provider = new WorkingContextSnapshotProvider( + NullLogger.Instance); + var context = WorkingContext.Empty.WithProjectDirectory("/path/that/does/not/exist"); + + var snapshot = provider.Create(context, TrustAudience.Personal); + + Assert.Equal("project directory does not exist", snapshot.GitUnavailableReason); + Assert.Contains("status: unavailable", snapshot.ToContextBlock()); + } +} diff --git a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs index 88e6fe65e..0595886d2 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using Netclaw.Actors.SubAgents; +using Netclaw.Actors.Sessions; using Netclaw.Actors.Tests.Memory; using Netclaw.Actors.Tools; using Netclaw.Configuration; @@ -74,6 +75,7 @@ public async Task Spawn_agent_streams_activity_through_executor_dispatch_to_watc toolAccessPolicy, approvalService: null, new StaticSystemPromptProvider("You are a summarizer."), + new WorkingContextSnapshotProvider(NullLogger.Instance), NullLogger.Instance); registry.Register(new SpawnAgentTool(subAgentRegistry, spawner, paths)); @@ -163,6 +165,7 @@ public async Task Spawn_agent_self_monitoring_survives_quiet_window_after_first_ toolAccessPolicy, approvalService: null, new StaticSystemPromptProvider("You are a summarizer."), + new WorkingContextSnapshotProvider(NullLogger.Instance), NullLogger.Instance); registry.Register(new SpawnAgentTool(subAgentRegistry, spawner, paths)); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 497e5eb29..f35474b64 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -334,6 +334,7 @@ public async Task Tool_execution_inherits_parent_session_and_project_directories Timeout = TimeSpan.FromSeconds(5), ParentSessionDirectory = "/tmp/netclaw/sessions/abc", ParentProjectDirectory = "/home/user/workspaces/netclaw", + ParentRecentFiles = ["src/Netclaw.Actors/SubAgents/SubAgentActor.cs"], Audience = TrustAudience.Personal, }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -342,6 +343,7 @@ public async Task Tool_execution_inherits_parent_session_and_project_directories Assert.NotNull(fakeTool.LastContext); Assert.Equal("/tmp/netclaw/sessions/abc", fakeTool.LastContext!.SessionDirectory); Assert.Equal("/home/user/workspaces/netclaw", fakeTool.LastContext.ProjectDirectory); + Assert.Equal(["src/Netclaw.Actors/SubAgents/SubAgentActor.cs"], fakeTool.LastContext.RecentFiles); } [Fact] @@ -1359,6 +1361,95 @@ public async Task Null_RuntimeContext_leaves_first_user_message_as_raw_task() Assert.DoesNotContain("Context:", fakeClient.LastReceivedMessages[1].Text); } + [Fact] + public async Task Parent_working_context_is_injected_into_child_user_message() + { + var fakeClient = new FakeChatClient(); + var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition(), fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Continue the implementation.", + Timeout = TimeSpan.FromSeconds(5), + Audience = TrustAudience.Personal, + ParentProjectDirectory = MissingProjectDirectory, + ParentRecentFiles = ["src/Netclaw.Actors/Sessions/WorkingContext.cs"] + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + var userMessage = fakeClient.LastReceivedMessages![1].Text; + Assert.Contains("[working-context]", userMessage); + Assert.Contains($"project_dir: {MissingProjectDirectory}", userMessage); + Assert.Contains("src/Netclaw.Actors/Sessions/WorkingContext.cs", userMessage); + Assert.DoesNotContain("[working-context]", fakeClient.LastReceivedMessages[0].Text); + } + + [Fact] + public async Task Successful_first_party_edit_is_returned_as_confirmed_child_activity() + { + var editTool = new FakeNetclawTool("file_edit", "Successfully edited src/Calculator.cs: replaced 1 occurrence(s)"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = + [ + new FunctionCallContent("call-edit", "file_edit", + new Dictionary { ["Path"] = "src/Calculator.cs" }) + ] + }; + var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([editTool]), fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Edit Calculator.", + Timeout = TimeSpan.FromSeconds(5), + Audience = TrustAudience.Personal, + ParentProjectDirectory = MissingProjectDirectory + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(result.WorkingContext); + Assert.Equal( + Path.GetFullPath(Path.Join(MissingProjectDirectory, "src", "Calculator.cs")), + Assert.Single(result.WorkingContext.ConfirmedChangedFiles)); + Assert.Empty(result.WorkingContext.ObservedChangedFiles); + } + + [Fact] + public async Task Denied_first_party_edit_is_not_returned_as_confirmed_child_activity() + { + var editTool = new FakeNetclawTool("file_edit", "Error: Permission denied: src/Calculator.cs"); + var fakeClient = new FakeChatClient + { + ToolCallsOnFirstCall = + [ + new FunctionCallContent("call-edit", "file_edit", + new Dictionary { ["Path"] = "src/Calculator.cs" }) + ] + }; + var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([editTool]), fakeClient)); + + var result = await agent.Ask( + new RunSubAgent + { + Task = "Edit Calculator.", + Timeout = TimeSpan.FromSeconds(5), + Audience = TrustAudience.Personal, + ParentProjectDirectory = MissingProjectDirectory + }, + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.NotNull(result.WorkingContext); + Assert.Empty(result.WorkingContext.ConfirmedChangedFiles); + } + + private static readonly string MissingProjectDirectory = + Path.Join(Path.GetTempPath(), "netclaw-missing-project"); + // Real PNG: the egress normalizer decodes every model-input image, so a // fake magic-byte stub would now be dropped. Small enough to pass through. private static readonly byte[] FakePngBytes = TestImages.SmallPng(); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs index afea66548..cd44c097e 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnObservabilityTests.cs @@ -4,6 +4,8 @@ // // ----------------------------------------------------------------------- using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.Sessions; using Netclaw.Actors.Protocol; using Netclaw.Actors.SubAgents; using Netclaw.Actors.Tools; @@ -48,6 +50,7 @@ public async Task Spawner_missing_session_context_logs_lifecycle_under_session_s toolAccessPolicy: null!, approvalService: null, promptProvider: null!, + workingContextSnapshots: new WorkingContextSnapshotProvider(NullLogger.Instance), logger); // A context with a session id but no SpawnChildActor factory — the diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs index d04aca85a..8d52c9cfa 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentSpawnerTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Netclaw.Actors.Channels; using Netclaw.Actors.SubAgents; +using Netclaw.Actors.Sessions; using Netclaw.Actors.Tests.Memory; using Netclaw.Actors.Tools; using Netclaw.Configuration; @@ -49,6 +50,7 @@ public async Task Spawn_async_propagates_parent_resolved_cwd_on_run_message() new ShellCommandPolicy()), approvalService: null, new StaticSystemPromptProvider("You are a summarizer."), + new WorkingContextSnapshotProvider(NullLogger.Instance), NullLogger.Instance); var childProbe = CreateTestProbe("subagent-child"); @@ -172,6 +174,7 @@ public async Task Spawn_async_ignores_definition_tool_metadata_for_runtime_tool_ new ShellCommandPolicy()), approvalService: null, new StaticSystemPromptProvider("You are a summarizer."), + new WorkingContextSnapshotProvider(NullLogger.Instance), NullLogger.Instance); var notifications = new List(); @@ -214,6 +217,57 @@ public async Task Spawn_async_ignores_definition_tool_metadata_for_runtime_tool_ Assert.Equal(1, started.ToolCount); } + [Fact] + public async Task Spawn_async_returns_only_unconfirmed_git_changes_as_observed() + { + var projectDirectory = Path.GetFullPath(Path.Join(Path.GetTempPath(), "netclaw-spawner-context")); + var confirmedPath = Path.GetFullPath(Path.Join(projectDirectory, "src", "Confirmed.cs")); + var observedPath = Path.GetFullPath(Path.Join(projectDirectory, "src", "Observed.cs")); + var snapshots = new Queue( + [ + new WorkingContextSnapshot + { + WorkingContext = WorkingContext.Empty.WithProjectDirectory(projectDirectory), + Git = GitSnapshot(projectDirectory) + }, + new WorkingContextSnapshot + { + WorkingContext = WorkingContext.Empty.WithProjectDirectory(projectDirectory), + Git = GitSnapshot(projectDirectory, "src/Confirmed.cs", "src/Observed.cs") + } + ]); + var spawner = CreateSpawner(new SequenceWorkingContextSnapshotProvider(snapshots)); + var childProbe = CreateTestProbe("working-context-child"); + var context = new ToolExecutionContext("console/subagent-parent", "/tmp/netclaw/sessions/parent") + { + Audience = TrustAudience.Personal, + ProjectDirectory = projectDirectory, + SpawnChildActor = (_, _, _) => Task.FromResult(childProbe.Ref) + }; + + var spawnTask = spawner.SpawnAsync( + CreateProfile(), + "Update the project.", + runtimeContext: null, + context, + TestContext.Current.CancellationToken); + + await childProbe.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + childProbe.Reply(SuccessfulResult() with + { + WorkingContext = new SubAgentWorkingContextInfo + { + ProjectDirectory = projectDirectory, + ConfirmedChangedFiles = [confirmedPath] + } + }); + + var result = await spawnTask; + + Assert.Equal([confirmedPath], result.WorkingContext!.ConfirmedChangedFiles); + Assert.Equal([observedPath], result.WorkingContext.ObservedChangedFiles); + } + [Fact] public async Task Spawned_sub_agent_bills_its_llm_calls_to_session_metrics() { @@ -246,6 +300,7 @@ public async Task Spawned_sub_agent_bills_its_llm_calls_to_session_metrics() new ShellCommandPolicy()), approvalService: null, new StaticSystemPromptProvider("You are a summarizer."), + new WorkingContextSnapshotProvider(NullLogger.Instance), NullLogger.Instance, sessionMetrics: metrics); @@ -278,6 +333,10 @@ public async Task Spawned_sub_agent_bills_its_llm_calls_to_session_metrics() } private static SubAgentSpawner CreateSpawner() + => CreateSpawner(new WorkingContextSnapshotProvider( + NullLogger.Instance)); + + private static SubAgentSpawner CreateSpawner(IWorkingContextSnapshotProvider workingContextSnapshots) { var toolRegistry = new ToolRegistry(); toolRegistry.Register(new FakeNetclawTool("inspect_context", "ok")); @@ -295,9 +354,17 @@ private static SubAgentSpawner CreateSpawner() new ShellCommandPolicy()), approvalService: null, new StaticSystemPromptProvider("You are an inspector."), + workingContextSnapshots, NullLogger.Instance); } + private static GitWorkingContextSnapshot GitSnapshot(string worktree, params string[] changedFiles) => new() + { + Worktree = worktree, + CommonDirectory = Path.Join(worktree, ".git"), + ChangedFiles = [.. changedFiles] + }; + private static SubAgentProfile CreateProfile() => new() { Name = "inspector", @@ -313,4 +380,11 @@ private static SubAgentSpawner CreateSpawner() Output = "ok", AgentName = new AgentName("inspector") }; + + private sealed class SequenceWorkingContextSnapshotProvider(Queue snapshots) + : IWorkingContextSnapshotProvider + { + public WorkingContextSnapshot Create(WorkingContext context, TrustAudience audience) + => snapshots.Dequeue(); + } } diff --git a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs index 05fd820e0..6b5c06220 100644 --- a/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.AI; using Netclaw.Actors.Skills; using Netclaw.Actors.SubAgents; +using Netclaw.Actors.Sessions; using Netclaw.Actors.Telemetry; using Netclaw.Actors.Tools; using Netclaw.Configuration; @@ -915,6 +916,7 @@ private static SubAgentSpawner CreateSubAgentSpawner() policy, approvalService: null, NullSystemPromptProvider.Instance, + new WorkingContextSnapshotProvider(NullLogger.Instance), NullLogger.Instance); } diff --git a/src/Netclaw.Actors/Sessions/LlmMessages.cs b/src/Netclaw.Actors/Sessions/LlmMessages.cs index 8fb4c5d9f..30f4f64a9 100644 --- a/src/Netclaw.Actors/Sessions/LlmMessages.cs +++ b/src/Netclaw.Actors/Sessions/LlmMessages.cs @@ -109,6 +109,7 @@ internal sealed record CompletedSubAgentRun : INoSerializationVerificationNeeded public int FindingsCount { get; init; } public string? MemoryDecision { get; init; } public string? MemoryDecisionReason { get; init; } + public SubAgentWorkingContextInfo? WorkingContext { get; init; } } internal sealed record AcceptedSubAgentFinding : INoSerializationVerificationNeeded diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index fa705f950..65753be71 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -55,6 +55,7 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers private readonly SessionConfig _config; private readonly ISystemPromptProvider _promptProvider; private readonly IReadOnlyList _contextLayers; + private readonly IWorkingContextSnapshotProvider _workingContextSnapshots; private readonly IToolExecutor? _toolExecutor; private readonly Tools.ToolRegistry? _toolRegistry; private readonly IToolAuditLogger? _auditLogger; @@ -226,6 +227,7 @@ public LlmSessionActor( _config = config; _promptProvider = services.PromptProvider; _contextLayers = services.ContextLayers; + _workingContextSnapshots = services.WorkingContextSnapshots; _skillRegistry = tools?.SkillRegistry; _subAgentRegistry = tools?.SubAgentRegistry; _subAgentSpawner = tools?.SubAgentSpawner; @@ -852,6 +854,7 @@ private void HandleToolExecutionCompleted(ToolExecutionCompleted msg) foreach (var run in msg.CompletedSubAgentRuns) { + MergeSuccessfulSubAgentWorkingContext(run.Success, run.WorkingContext); if (!emittedRunIds.Add(run.RunId)) continue; @@ -2006,6 +2009,7 @@ await self.Ask( approvalTimeout: Timeout.InfiniteTimeSpan, backgroundJobManager: bgJobManager, projectDirectory: _state.WorkingContext.ProjectDirectory, + recentFiles: _state.WorkingContext.RecentFiles, setWorkingDirectoryAvailable: setWorkingDirectoryAvailable, streamToolResults: true, modelInputModalities: _model.InputModalities, @@ -2693,6 +2697,9 @@ private void FireLlmCall(string? recallQuery = null, bool forceNoTools = false) // through this content on every subsequent turn instead of // re-tokenizing it from scratch. _activeRecall = _recallManager.TurnRecallCache; + var workingContextBlock = _workingContextSnapshots + .Create(_state.WorkingContext, CurrentTurnAudience()) + .ToContextBlock(); var volatileBlock = SessionMessageAssembler.BuildVolatileContextBlock(new ContextAssemblyInput( State: _state, ContextLayers: _contextLayers, @@ -2704,6 +2711,7 @@ private void FireLlmCall(string? recallQuery = null, bool forceNoTools = false) SessionsBasePath: _sessionsBasePath, FileReadGranted: HasFileReadGranted(), ActiveRecall: _activeRecall, + WorkingContextBlock: workingContextBlock, Audience: CurrentTurnAudience(), SkillHint: BuildSkillHint())); if (!string.IsNullOrEmpty(volatileBlock)) @@ -2736,6 +2744,7 @@ private void FireLlmCall(string? recallQuery = null, bool forceNoTools = false) SessionsBasePath: _sessionsBasePath, FileReadGranted: HasFileReadGranted(), ActiveRecall: _activeRecall, + WorkingContextBlock: string.Empty, Audience: CurrentTurnAudience(), SkillHint: skillHint, // Canonical names live in history (post-PR follow-up); the @@ -3038,6 +3047,7 @@ private async Task ExecuteRoutedSkillAsync( ChannelType = _currentTurnContext?.ChannelType?.ToWireValue() ?? (_currentTurnSource is null ? null : _currentTurnSource.ChannelType.ToWireValue()), ProjectDirectory = _state.WorkingContext.ProjectDirectory, + RecentFiles = _state.WorkingContext.RecentFiles, SupportsInteractiveApproval = false, }; @@ -3086,6 +3096,8 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m return; } + MergeSuccessfulSubAgentWorkingContext(msg.Result.Success, msg.Result.WorkingContext); + var userMsg = _state.FindLastUserMessage(); var turnEvent = new TurnRecorded { @@ -3142,6 +3154,30 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m }); } + private void MergeSuccessfulSubAgentWorkingContext( + bool success, + SubAgentWorkingContextInfo? workingContext) + { + var updated = MergeSuccessfulSubAgentWorkingContext(_state.WorkingContext, success, workingContext); + if (!ReferenceEquals(updated, _state.WorkingContext)) + _state = _state with { WorkingContext = updated }; + } + + internal static WorkingContext MergeSuccessfulSubAgentWorkingContext( + WorkingContext current, + bool success, + SubAgentWorkingContextInfo? child) + { + if (!success || child is null) + return current; + + var updated = current; + foreach (var path in child.ConfirmedChangedFiles) + updated = updated.AddRecentFile(path); + + return updated; + } + // Transient: skill body injected by slash-command dispatch for the current turn private string? _slashCommandSkillContent; private string? _sessionPromptOverlay; @@ -4385,6 +4421,7 @@ private void ProcessToolCallResult(Pipelines.ToolCallResult result) foreach (var run in result.CompletedSubAgentRuns) { + MergeSuccessfulSubAgentWorkingContext(run.Success, run.WorkingContext); if (!emittedRunIds.Add(run.RunId)) continue; diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 5f35de5f1..ae47e5c04 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -92,6 +92,7 @@ public static async Task ExecuteToolsAsync( ILogger? logger = null, IActorRef? backgroundJobManager = null, string? projectDirectory = null, + IReadOnlyList? recentFiles = null, bool setWorkingDirectoryAvailable = false, bool streamToolResults = false, ModelModality modelInputModalities = ModelModality.Text, @@ -128,6 +129,7 @@ public static async Task ExecuteToolsAsync( logger, backgroundJobManager, projectDirectory, + recentFiles, setWorkingDirectoryAvailable, modelInputModalities, oneTimeApprovalPreSeed is not null @@ -204,6 +206,7 @@ public static async Task ExecuteSingleToolAsync( ILogger? logger = null, IActorRef? backgroundJobManager = null, string? projectDirectory = null, + IReadOnlyList? recentFiles = null, bool setWorkingDirectoryAvailable = false, ModelModality modelInputModalities = ModelModality.Text, IReadOnlyList? oneTimeApprovalPreSeed = null, @@ -252,6 +255,7 @@ public static async Task ExecuteSingleToolAsync( sessionDir, spawnChildActor, projectDirectory, + recentFiles, turnContext, modelInputModalities, maxInlineToolResultChars); @@ -325,7 +329,8 @@ public static async Task ExecuteSingleToolAsync( Duration = info.Duration, FindingsCount = info.Findings.Count, MemoryDecision = decision, - MemoryDecisionReason = reason + MemoryDecisionReason = reason, + WorkingContext = info.WorkingContext }); } @@ -1052,6 +1057,7 @@ private static ToolExecutionContext BuildToolExecutionContext( string sessionDir, Func> spawnChildActor, string? projectDirectory, + IReadOnlyList? recentFiles, TurnContext? turnContext, ModelModality modelInputModalities, int maxInlineToolResultChars) @@ -1065,6 +1071,7 @@ private static ToolExecutionContext BuildToolExecutionContext( // The session content budget; DispatchingToolExecutor uses it (or a // tool's own override) to bound results and spill the overflow. MaxInlineToolResultChars = maxInlineToolResultChars, + RecentFiles = recentFiles ?? [], }; context.Boundary = turnContext?.Boundary ?? source?.Boundary; context.ChannelType = turnContext?.ChannelType?.ToWireValue() diff --git a/src/Netclaw.Actors/Sessions/SessionDependencies.cs b/src/Netclaw.Actors/Sessions/SessionDependencies.cs index acf0ed8db..e1f21e80a 100644 --- a/src/Netclaw.Actors/Sessions/SessionDependencies.cs +++ b/src/Netclaw.Actors/Sessions/SessionDependencies.cs @@ -20,6 +20,7 @@ public sealed record SessionServices( IChatClientProvider ClientProvider, ISystemPromptProvider PromptProvider, IReadOnlyList ContextLayers, + IWorkingContextSnapshotProvider WorkingContextSnapshots, TimeProvider TimeProvider, NetclawPaths Paths); diff --git a/src/Netclaw.Actors/Sessions/SessionMessageAssembler.cs b/src/Netclaw.Actors/Sessions/SessionMessageAssembler.cs index eb2b99c75..cbfea2aab 100644 --- a/src/Netclaw.Actors/Sessions/SessionMessageAssembler.cs +++ b/src/Netclaw.Actors/Sessions/SessionMessageAssembler.cs @@ -26,6 +26,7 @@ public sealed record ContextAssemblyInput( string SessionsBasePath, bool FileReadGranted, AutomaticRecallResult? ActiveRecall, + string WorkingContextBlock, TrustAudience Audience = TrustAudience.Personal, string? SkillHint = null, // Maps canonical tool names (the form persisted in history) back @@ -219,8 +220,8 @@ internal static string BuildVolatileContextBlock(ContextAssemblyInput input) // Working context is suppressed for Public audience to avoid leaking // internal operational state (project paths, scratch notes, etc.). - if (!input.State.WorkingContext.IsEmpty && input.Audience != TrustAudience.Public) - parts.Add(input.State.WorkingContext.ToContextBlock()); + if (!string.IsNullOrWhiteSpace(input.WorkingContextBlock) && input.Audience != TrustAudience.Public) + parts.Add(input.WorkingContextBlock); // Suppressed for Public audience, same as WorkingContext: the block // exposes internal operational state — commands, rationales, and the diff --git a/src/Netclaw.Actors/Sessions/WorkingContext.cs b/src/Netclaw.Actors/Sessions/WorkingContext.cs index 56b1de179..553af28c5 100644 --- a/src/Netclaw.Actors/Sessions/WorkingContext.cs +++ b/src/Netclaw.Actors/Sessions/WorkingContext.cs @@ -116,23 +116,5 @@ public WorkingContext AddRecentFile(string path) /// emit a barren header. /// public string ToContextBlock() - { - if (IsEmpty) - return string.Empty; - - var sb = new System.Text.StringBuilder(); - sb.Append("[working-context]"); - - if (ProjectDirectory is not null) - sb.Append("\nproject_dir: ").Append(ProjectDirectory); - - if (!RecentFiles.IsEmpty) - { - sb.Append("\nrecent_files:"); - foreach (var path in RecentFiles) - sb.Append("\n - ").Append(path); - } - - return sb.ToString(); - } + => new WorkingContextSnapshot { WorkingContext = this }.ToContextBlock(); } diff --git a/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs b/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs new file mode 100644 index 000000000..c86710d28 --- /dev/null +++ b/src/Netclaw.Actors/Sessions/WorkingContextSnapshot.cs @@ -0,0 +1,296 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; +using Netclaw.Configuration; + +namespace Netclaw.Actors.Sessions; + +public sealed record GitWorkingContextSnapshot +{ + public required string Worktree { get; init; } + public required string CommonDirectory { get; init; } + public string? Branch { get; init; } + public bool Detached { get; init; } + public string? Head { get; init; } + public string? Upstream { get; init; } + public int Ahead { get; init; } + public int Behind { get; init; } + public int Staged { get; init; } + public int Modified { get; init; } + public int Untracked { get; init; } + public ImmutableHashSet ChangedFiles { get; init; } = []; +} + +public sealed record WorkingContextSnapshot +{ + public required WorkingContext WorkingContext { get; init; } + public GitWorkingContextSnapshot? Git { get; init; } + public string? GitUnavailableReason { get; init; } + + public bool IsEmpty => WorkingContext.IsEmpty && Git is null && GitUnavailableReason is null; + + public string ToContextBlock() + { + if (IsEmpty) + return string.Empty; + + var sb = new StringBuilder("[working-context]"); + if (WorkingContext.ProjectDirectory is not null) + sb.Append("\nproject_dir: ").Append(WorkingContext.ProjectDirectory); + + if (!WorkingContext.RecentFiles.IsEmpty) + { + sb.Append("\nrecent_files:"); + foreach (var path in WorkingContext.RecentFiles) + sb.Append("\n - ").Append(path); + } + + if (Git is { } git) + { + sb.Append("\ngit:") + .Append("\n worktree: ").Append(git.Worktree) + .Append("\n common_dir: ").Append(git.CommonDirectory) + .Append("\n branch: ").Append(git.Detached ? "(detached)" : git.Branch) + .Append("\n head: ").Append(git.Head ?? "(unborn)"); + if (git.Upstream is not null) + { + sb.Append("\n upstream: ").Append(git.Upstream) + .Append("\n ahead: ").Append(git.Ahead) + .Append("\n behind: ").Append(git.Behind); + } + sb.Append("\n staged: ").Append(git.Staged) + .Append("\n modified: ").Append(git.Modified) + .Append("\n untracked: ").Append(git.Untracked); + } + else if (GitUnavailableReason is not null) + { + sb.Append("\ngit:") + .Append("\n status: unavailable") + .Append("\n reason: ").Append(GitUnavailableReason); + } + + return sb.ToString(); + } +} + +public interface IWorkingContextSnapshotProvider +{ + WorkingContextSnapshot Create(WorkingContext context, TrustAudience audience); +} + +public sealed class WorkingContextSnapshotProvider(ILogger logger) + : IWorkingContextSnapshotProvider +{ + internal static readonly TimeSpan GitTimeout = TimeSpan.FromSeconds(2); + private const int MaxOutputChars = 256 * 1024; + + public WorkingContextSnapshot Create(WorkingContext context, TrustAudience audience) + { + if (audience == TrustAudience.Public) + return new WorkingContextSnapshot { WorkingContext = WorkingContext.Empty }; + if (context.ProjectDirectory is null) + return new WorkingContextSnapshot { WorkingContext = context }; + + var inspection = InspectGit(context.ProjectDirectory); + if (inspection.Snapshot is not null) + return new WorkingContextSnapshot { WorkingContext = context, Git = inspection.Snapshot }; + if (inspection.IsNotRepository) + return new WorkingContextSnapshot { WorkingContext = context }; + + logger.LogWarning("Git working-context inspection failed: {Reason}", inspection.Error); + return new WorkingContextSnapshot + { + WorkingContext = context, + GitUnavailableReason = SanitizeReason(inspection.Error) + }; + } + + internal static GitInspectionResult InspectGit(string projectDirectory) + { + if (!Directory.Exists(projectDirectory)) + return GitInspectionResult.Failed("project directory does not exist"); + + var roots = RunGit(projectDirectory, + ["rev-parse", "--show-toplevel", "--path-format=absolute", "--git-common-dir"]); + if (!roots.Success) + { + var notRepo = roots.StandardError.Contains("not a git repository", StringComparison.OrdinalIgnoreCase); + return notRepo ? GitInspectionResult.NotRepository() : GitInspectionResult.Failed(roots.Error); + } + + var rootLines = roots.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (rootLines.Length != 2) + return GitInspectionResult.Failed("git returned an unexpected repository-root response"); + + var status = RunGit(projectDirectory, ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"]); + if (!status.Success) + return GitInspectionResult.Failed(status.Error); + + try + { + return GitInspectionResult.Succeeded(ParseStatus(rootLines[0], rootLines[1], status.StandardOutput)); + } + catch (FormatException ex) + { + return GitInspectionResult.Failed(ex.Message); + } + } + + internal static GitWorkingContextSnapshot ParseStatus(string worktree, string commonDirectory, string output) + { + string? branch = null; + string? head = null; + string? upstream = null; + var detached = false; + var ahead = 0; + var behind = 0; + var staged = 0; + var modified = 0; + var untracked = 0; + var files = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("# branch.oid ", StringComparison.Ordinal)) + { + var value = line[13..].Trim(); + head = value == "(initial)" ? null : value; + } + else if (line.StartsWith("# branch.head ", StringComparison.Ordinal)) + { + var value = line[14..].Trim(); + detached = value == "(detached)"; + branch = detached ? null : value; + } + else if (line.StartsWith("# branch.upstream ", StringComparison.Ordinal)) + { + upstream = line[18..].Trim(); + } + else if (line.StartsWith("# branch.ab ", StringComparison.Ordinal)) + { + var pieces = line[12..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (pieces.Length != 2 + || !int.TryParse(pieces[0].TrimStart('+'), out ahead) + || !int.TryParse(pieces[1].TrimStart('-'), out behind)) + throw new FormatException("git returned an invalid ahead/behind response"); + } + else if (line.StartsWith("? ", StringComparison.Ordinal)) + { + untracked++; + files.Add(line[2..]); + } + else if (line.StartsWith("1 ", StringComparison.Ordinal) || line.StartsWith("2 ", StringComparison.Ordinal)) + { + var isRename = line[0] == '2'; + var fields = line.Split(' ', isRename ? 10 : 9, StringSplitOptions.None); + if (fields.Length < 2 || fields[1].Length != 2) + throw new FormatException("git returned an invalid file-status response"); + if (fields[1][0] != '.') staged++; + if (fields[1][1] != '.') modified++; + files.Add(isRename ? fields[^1].Split('\t', 2)[0] : fields[^1]); + } + else if (line.StartsWith("u ", StringComparison.Ordinal)) + { + staged++; + modified++; + var fields = line.Split(' ', 11, StringSplitOptions.None); + files.Add(fields[^1]); + } + } + + return new GitWorkingContextSnapshot + { + Worktree = worktree, + CommonDirectory = commonDirectory, + Branch = branch, + Detached = detached, + Head = head, + Upstream = upstream, + Ahead = ahead, + Behind = behind, + Staged = staged, + Modified = modified, + Untracked = untracked, + ChangedFiles = files.ToImmutable() + }; + } + + private static GitCommandResult RunGit(string workingDirectory, IReadOnlyList arguments) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + foreach (var argument in arguments) + process.StartInfo.ArgumentList.Add(argument); + + process.Start(); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(GitTimeout)) + { + process.Kill(entireProcessTree: true); + return GitCommandResult.Failed("git inspection timed out"); + } + + if (!Task.WaitAll([stdout, stderr], GitTimeout)) + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + return GitCommandResult.Failed("git output collection timed out"); + } + var standardOutput = Bound(stdout.Result); + var standardError = Bound(stderr.Result); + return process.ExitCode == 0 + ? GitCommandResult.Succeeded(standardOutput, standardError) + : GitCommandResult.Failed(string.IsNullOrWhiteSpace(standardError) + ? $"git exited with code {process.ExitCode}" + : standardError, standardOutput, standardError); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException or InvalidOperationException or AggregateException) + { + return GitCommandResult.Failed(ex is AggregateException ? "git output collection timed out" : ex.Message); + } + } + + private static string Bound(string value) => value.Length <= MaxOutputChars ? value : value[..MaxOutputChars]; + + private static string SanitizeReason(string reason) + { + var firstLine = reason.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() + ?? "inspection failed"; + return firstLine.Length <= 200 ? firstLine : firstLine[..200]; + } + + internal sealed record GitInspectionResult( + GitWorkingContextSnapshot? Snapshot, + bool IsNotRepository, + string Error) + { + public static GitInspectionResult Succeeded(GitWorkingContextSnapshot snapshot) => new(snapshot, false, string.Empty); + public static GitInspectionResult NotRepository() => new(null, true, string.Empty); + public static GitInspectionResult Failed(string error) => new(null, false, error); + } + + private sealed record GitCommandResult(bool Success, string StandardOutput, string StandardError, string Error) + { + public static GitCommandResult Succeeded(string stdout, string stderr) => new(true, stdout, stderr, string.Empty); + public static GitCommandResult Failed(string error, string stdout = "", string stderr = "") => new(false, stdout, stderr, error); + } +} diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index d4442cb1a..4a1d1efac 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -119,6 +119,10 @@ [Subagent Execution Contract] // sub-agent's own session.log (SubSessionId) — matching the enriched logger's own context. private string? _parentSessionId; private string? _subSessionId; + private WorkingContext _workingContext = WorkingContext.Empty; + private readonly HashSet _readFiles = new(StringComparer.Ordinal); + private readonly HashSet _confirmedChangedFiles = new(StringComparer.Ordinal); + private readonly Dictionary _pendingFileActivities = new(StringComparer.Ordinal); // Default wait-for-first-delta budget when the spawn message carries none // (direct/test callers). Mirrors SessionConfig.PrefillTimeout so an unset @@ -270,6 +274,7 @@ private void Idle() { Audience = subAgentAudience, InheritedCwd = msg.ParentCwd, + RecentFiles = msg.ParentRecentFiles, }; _toolExecutionContext.Boundary = msg.Boundary; _toolExecutionContext.ChannelType = msg.ChannelType; @@ -277,6 +282,11 @@ private void Idle() _toolExecutionContext.RequestedDeliveryTarget = msg.RequestedDeliveryTarget; _toolExecutionContext.ModelInputModalities = msg.ModelInputModalities; _toolExecutionContext.ProjectDirectory = msg.ParentProjectDirectory; + _workingContext = new WorkingContext + { + ProjectDirectory = msg.ParentProjectDirectory, + RecentFiles = [.. msg.ParentRecentFiles.Take(WorkingContext.MaxRecentFiles)] + }; _toolExecutionContext.SupportsInteractiveApproval = _approvalBridge is not null; _aiTools = ResolveExposedAiTools(); _executionCts = new CancellationTokenSource(); @@ -320,7 +330,13 @@ private void Idle() // 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, BuildSystemPrompt(_definition))); - _history.Add(new AiChatMessage(Microsoft.Extensions.AI.ChatRole.User, BuildUserMessage(msg.RuntimeContext, msg.Task))); + _history.Add(new AiChatMessage(Microsoft.Extensions.AI.ChatRole.User, + BuildUserMessage( + msg.RuntimeContext, + subAgentAudience == TrustAudience.Public + ? string.Empty + : msg.WorkingContextBlock ?? _workingContext.ToContextBlock(), + msg.Task))); _log.Info("SubAgent [{AgentName}] starting (tools={ToolCount}, prefill={Prefill}, interDelta={InterDelta}, noProgress={NoProgress})", _definition.Name, _aiTools.Count, _prefillBudget, _interDeltaBudget, @@ -459,6 +475,16 @@ private void Processing() foreach (var result in msg.ToolResults) { _history.Add(ChatMessageConverter.ToAiMessage(result)); + if (result.ToolCallId is { } callId + && _pendingFileActivities.Remove(callId.Value, out var activity) + && IsSuccessfulFileActivity(activity.Kind, result.Content)) + { + _workingContext = _workingContext.AddRecentFile(activity.Path); + if (activity.Kind == FileActivityKind.Read) + _readFiles.Add(activity.Path); + else + _confirmedChangedFiles.Add(activity.Path); + } var preview = result.Content is { Length: > 200 } ? result.Content[..200] + "..." : result.Content ?? "(null)"; @@ -688,6 +714,12 @@ private void HandleToolCalls(AiChatMessage assistantMessage, List 0 } callId + && WorkingContextUpdater.TryExtractFilePath(argsJson, out var path) + && TryClassifyFileActivity(toolCall.Name, out var kind)) + { + _pendingFileActivities[callId] = new PendingFileActivity(ResolveTrackedPath(path), kind); + } // Log tool START event so tool execution spans are visible in Seq // (previously only tool results were logged, making it impossible to @@ -814,6 +846,7 @@ private void Complete( ? BuildFindings(output, _toolExecutionContext.SessionId, resolvedOutcome, outcomeReason) : []; + var workingContextResult = BuildWorkingContextResult(success); _replyTo.Tell(new SubAgentResult { Success = success, @@ -822,7 +855,8 @@ private void Complete( Outcome = resolvedOutcome, OutcomeReason = outcomeReason, Findings = findings, - FindingsCount = findings.Count + FindingsCount = findings.Count, + WorkingContext = workingContextResult }); Context.Stop(Self); @@ -946,12 +980,68 @@ private void AddModelInputMediaNudge(IReadOnlyList m /// When runtime context is null or whitespace, returns the raw task string for backward /// compatibility with the pre-Context protocol. /// - private static string BuildUserMessage(string? runtimeContext, string task) + private static string BuildUserMessage(string? runtimeContext, string workingContext, string task) { - if (string.IsNullOrWhiteSpace(runtimeContext)) + var contextParts = new[] { runtimeContext?.Trim(), workingContext.Trim() } + .Where(part => !string.IsNullOrWhiteSpace(part)); + var combinedContext = string.Join("\n\n", contextParts); + if (combinedContext.Length == 0) return task; - return $"Context:\n{runtimeContext.Trim()}\n\nTask:\n{task}"; + return $"Context:\n{combinedContext}\n\nTask:\n{task}"; + } + + private SubAgentWorkingContextInfo? BuildWorkingContextResult(bool success) + { + if (!success) + return null; + + return new SubAgentWorkingContextInfo + { + ProjectDirectory = _workingContext.ProjectDirectory, + ReadFiles = _readFiles.Order(StringComparer.Ordinal).ToArray(), + ConfirmedChangedFiles = _confirmedChangedFiles.Order(StringComparer.Ordinal).ToArray(), + ObservedChangedFiles = [] + }; + } + + private string ResolveTrackedPath(string path) + { + if (Path.IsPathRooted(path) || string.IsNullOrWhiteSpace(_workingContext.ProjectDirectory)) + return path; + return Path.GetFullPath(path, _workingContext.ProjectDirectory); + } + + private static bool TryClassifyFileActivity(string toolName, out FileActivityKind kind) + { + kind = toolName switch + { + "file_read" => FileActivityKind.Read, + "file_write" or "file_edit" => FileActivityKind.Changed, + _ => FileActivityKind.None + }; + return kind != FileActivityKind.None; + } + + private static bool IsSuccessfulFileActivity(FileActivityKind kind, string? result) + { + if (string.IsNullOrWhiteSpace(result)) + return false; + + return kind == FileActivityKind.Changed + ? result.StartsWith("Successfully ", StringComparison.Ordinal) + : !result.StartsWith("Error:", StringComparison.Ordinal) + && !result.StartsWith("Tool access denied:", StringComparison.Ordinal) + && !result.Contains("requires approval", StringComparison.OrdinalIgnoreCase); + } + + private sealed record PendingFileActivity(string Path, FileActivityKind Kind); + + private enum FileActivityKind + { + None, + Read, + Changed } private static string ExtractText(AiChatMessage message) @@ -1276,6 +1366,7 @@ private static ToolExecutionContext CreatePerToolExecutionContext(ToolExecutionC RequestedDeliveryTarget = source.RequestedDeliveryTarget, ModelInputModalities = source.ModelInputModalities, ProjectDirectory = source.ProjectDirectory, + RecentFiles = source.RecentFiles, InheritedCwd = source.InheritedCwd, SupportsInteractiveApproval = source.SupportsInteractiveApproval, OnSubAgentActivity = source.OnSubAgentActivity, diff --git a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs index be4524f6e..ee8b84907 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -147,6 +147,12 @@ public sealed record RunSubAgent : ISubAgentCommand /// public string? ParentProjectDirectory { get; init; } + /// Read-only parent recent-file snapshot used for child grounding. + public IReadOnlyList ParentRecentFiles { get; init; } = []; + + /// Pre-rendered, audience-filtered spawn-boundary working context. + public string? WorkingContextBlock { get; init; } + /// /// Snapshot of the parent's ToolExecutionContext.ResolveShellCwd(null) /// at spawn time. Seeds the child's InheritedCwd. Null when the @@ -206,5 +212,8 @@ public sealed record SubAgentResult : ISubAgentResponse /// Total number of structured findings returned before parent-session review. /// public int FindingsCount { get; init; } + + /// Structured, run-scoped file and Git context returned to the parent. + public SubAgentWorkingContextInfo? WorkingContext { get; init; } } } diff --git a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs index e3472ef79..0c974c414 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs @@ -8,6 +8,7 @@ using Akka.Actor; using Microsoft.Extensions.Logging; using Netclaw.Actors.Tools; +using Netclaw.Actors.Sessions; using Netclaw.Configuration; using Netclaw.Security; using Netclaw.Tools; @@ -23,6 +24,9 @@ namespace Netclaw.Actors.SubAgents; /// public sealed class SubAgentSpawner { + private static readonly StringComparer FilePathComparer = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + private const int SubAgentMaxToolIterations = 30; private readonly IChatClientProvider _chatClientProvider; @@ -32,6 +36,7 @@ public sealed class SubAgentSpawner private readonly ISystemPromptProvider _promptProvider; private readonly SubAgentConfig _subAgentConfig; private readonly ILogger _logger; + private readonly IWorkingContextSnapshotProvider _workingContextSnapshots; // The process-wide daily-stats sink, handed to each spawned SubAgentActor so its // LLM calls are billed to `netclaw stats`. Nullable to match the rest of the stats @@ -45,6 +50,7 @@ public SubAgentSpawner( ToolAccessPolicy toolAccessPolicy, IToolApprovalService? approvalService, ISystemPromptProvider promptProvider, + IWorkingContextSnapshotProvider workingContextSnapshots, ILogger logger, SubAgentConfig? subAgentConfig = null, Telemetry.ISessionMetrics? sessionMetrics = null) @@ -54,6 +60,7 @@ public SubAgentSpawner( _toolAccessPolicy = toolAccessPolicy; _approvalService = approvalService; _promptProvider = promptProvider; + _workingContextSnapshots = workingContextSnapshots; _subAgentConfig = subAgentConfig ?? new SubAgentConfig(); _logger = logger; _sessionMetrics = sessionMetrics; @@ -140,6 +147,12 @@ public async Task SpawnAsync( ? $"{context.SessionId}/subagent/{definition.Name}/{runId}" : $"subagent/{definition.Name}/{runId}"; var scopeId = new SubAgentScopeId(subAgentScopeId); + var parentWorkingContext = new WorkingContext + { + ProjectDirectory = context.ProjectDirectory, + RecentFiles = [.. context.RecentFiles.Take(WorkingContext.MaxRecentFiles)] + }; + var initialWorkingSnapshot = _workingContextSnapshots.Create(parentWorkingContext, context.Audience); // Spawn as child of the session actor via the context factory var props = SubAgentActor.CreateProps( @@ -199,6 +212,8 @@ public async Task SpawnAsync( ModelInputModalities = context.ModelInputModalities, ParentSessionDirectory = context.SessionDirectory, ParentProjectDirectory = context.ProjectDirectory, + ParentRecentFiles = context.RecentFiles, + WorkingContextBlock = initialWorkingSnapshot.ToContextBlock(), ParentCwd = context.ResolveShellCwd(null), Cancellation = ct, // A session owns an approval channel even when its transport cannot @@ -229,6 +244,8 @@ public async Task SpawnAsync( sw.Stop(); + result = EnrichWorkingContextResult(result, initialWorkingSnapshot, context.Audience); + context.OnSubAgentActivity?.Invoke(new SubAgentNotificationInfo { RunId = runId, @@ -238,7 +255,8 @@ public async Task SpawnAsync( Outcome = result.Outcome, OutcomeReason = result.OutcomeReason, Duration = sw.Elapsed, - Findings = result.Findings + Findings = result.Findings, + WorkingContext = result.WorkingContext }); SubAgentSpawnBreadcrumbs.Completed(_logger, context, profile.Name, runId, result.Success, sw.ElapsedMilliseconds); @@ -285,6 +303,51 @@ public async Task SpawnAsync( } } + private SubAgentResult EnrichWorkingContextResult( + SubAgentResult result, + WorkingContextSnapshot initialSnapshot, + TrustAudience audience) + { + if (!result.Success || result.WorkingContext is not { } childContext) + return result; + + var finalContext = new WorkingContext + { + ProjectDirectory = childContext.ProjectDirectory, + RecentFiles = [.. childContext.ReadFiles + .Concat(childContext.ConfirmedChangedFiles) + .Distinct(FilePathComparer) + .Take(WorkingContext.MaxRecentFiles)] + }; + var finalSnapshot = _workingContextSnapshots.Create(finalContext, audience); + var initialChanged = CanonicalChangedFiles(initialSnapshot.Git); + var observed = CanonicalChangedFiles(finalSnapshot.Git) + .Except(initialChanged, FilePathComparer) + .Except(childContext.ConfirmedChangedFiles, FilePathComparer) + .Order(FilePathComparer) + .ToArray(); + + return result with + { + WorkingContext = childContext with + { + Worktree = finalSnapshot.Git?.Worktree, + Branch = finalSnapshot.Git?.Branch, + Head = finalSnapshot.Git?.Head, + ObservedChangedFiles = observed + } + }; + } + + private static IEnumerable CanonicalChangedFiles(GitWorkingContextSnapshot? snapshot) + { + if (snapshot is null) + return []; + + return snapshot.ChangedFiles.Select(path => + Path.IsPathRooted(path) ? Path.GetFullPath(path) : Path.GetFullPath(path, snapshot.Worktree)); + } + private IReadOnlyList ResolveTools(SubAgentProfile profile, ToolExecutionContext context) { // Sub-agents inherit the parent session's runtime tool policy. Agent diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index de68204eb..0eec0db14 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -850,6 +850,7 @@ static void ConfigureDaemonServices( // Current time context layer — transient per-turn grounding for date/time-sensitive prompts services.AddSingleton(); + services.AddSingleton(); // Expose all context layers as IReadOnlyList for actor DI resolution services.AddSingleton>(sp => @@ -958,6 +959,7 @@ static void ConfigureDaemonServices( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index d7d57a429..0c0fe6eba 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -18,6 +18,22 @@ public sealed record ModelInputFileInfo(string FilePath, string FileName, MimeTy /// public sealed record FileAttachmentInfo(string FilePath, string FileName, MimeType MimeType); +/// +/// Machine-readable working-context handoff from an ephemeral subagent run. +/// Confirmed changes have first-party tool provenance; observed changes are +/// derived from shared worktree state and do not imply authorship. +/// +public sealed record SubAgentWorkingContextInfo +{ + public string? ProjectDirectory { get; init; } + public string? Worktree { get; init; } + public string? Branch { get; init; } + public string? Head { get; init; } + public IReadOnlyList ReadFiles { get; init; } = []; + public IReadOnlyList ConfirmedChangedFiles { get; init; } = []; + public IReadOnlyList ObservedChangedFiles { get; init; } = []; +} + /// /// Lightweight subagent activity notification for the tools abstraction layer. /// Tools emit these via ; @@ -34,6 +50,7 @@ public sealed record SubAgentNotificationInfo public SubAgentOutcomeReason? OutcomeReason { get; init; } public TimeSpan Duration { get; init; } public IReadOnlyList Findings { get; init; } = []; + public SubAgentWorkingContextInfo? WorkingContext { get; init; } } /// @@ -241,6 +258,12 @@ public IReadOnlySet OneTimeApprovedPatterns /// public string? ProjectDirectory { get; set; } + /// + /// Read-only snapshot of the parent session's recently used files. This is + /// grounding for delegated work and does not grant filesystem authority. + /// + public IReadOnlyList RecentFiles { get; init; } = []; + /// /// Resolves the working directory for a shell-style invocation. Returns /// the first non-empty value of: