diff --git a/conductor/tracks/nagent_review_20260608/nagent_review_v3_20260619.md b/conductor/tracks/nagent_review_20260608/nagent_review_v3_20260619.md index 65ede1df..8fbc2ed4 100644 --- a/conductor/tracks/nagent_review_20260608/nagent_review_v3_20260619.md +++ b/conductor/tracks/nagent_review_20260608/nagent_review_v3_20260619.md @@ -1296,41 +1296,131 @@ The shape tag map: `[I]` for inspectable invariants (the two-reason framing, the **Source:** nagent `065168c`, `6b762da`, `12c35b7`, `49e07f3` (`bin/helpers/nagent_tags.py:43-50` + `:106-110` + `:136-246` + `:248-265`, `bin/nagent:1911-1940` + `:682-714` + `:1319-1381` + `:1387-1394` + `:1534-1551` + `:1834-1840` + `:224-240`, `tests/test_nagent.py:548-590` + `:679-714` + `:1911-1940`, `tests/test_nagent_safety.py:367-400`, `tests/test_nagent_tags.py:170-182`). **One-liner:** Four hardening commits — `scan_tag_document` extracts valid tags and ignores the rest (with EOF-capture for trailing unclosed responses); `dedupe_nodes` collapses exact-duplicate action tags within a turn; ``-output-before-`` ordering is pinned by a regression test; `` is scoped to a per-conversation scratch dir so concurrent instances never collide. -**Pattern(s) vs v2.3:** UPDATE. v2.3 Pattern 5 ("the loop") had the basic loop; v3 hardens it against four specific failure modes. The hardening is incremental — each commit is a discrete change with its own test. EXTENDS v2.3 Pattern 4 ("visible output protocol") with a lenient counterpart (`scan_tag_document`) that tolerates non-protocol output while still propagating known-tag malformation as a hard error. NEW: per-conversation scratch directory as a side artifact of the loop. -**Manual Slop implications:** Manual Slop's `send_result()` (per `docs/guide_ai_client.md`) and `dispatch_inference` should adopt the same hardening. The lenient parser discipline ("scan, extract, ignore the rest, but propagate known-tag malformation as hard error") maps to Manual Slop's tag protocol; the per-turn status block (`` with UTC + cumulative tokens) is a model Manual Slop's discussion history could adopt — the user can already see token totals but not in a structured per-turn way. The per-conversation scratch dir (keyed by conversation name) maps to Manual Slop's `tests/artifacts/` directory (gitignored, per-conversation). -**Decision candidate:** NEW Candidate 23 (MEDIUM). "Per-conversation scratch directory for Manual Slop dispatch_inference" — adopt the `conversation_scratch_dir(conversation_name)` pattern; pre-create on session start; thread through the ``-equivalent. See `decisions.md` Candidate 23. -**Cross-refs:** §3 Hooks (per-turn `` and per-turn hooks are both per-turn observability surfaces); §2 Conversation safety net (the `` block is what the safety net reads to compute the checkpoint delta). -**Source-read citations:** -- `bin/helpers/nagent_tags.py:43-50` — `parse_element(..., capture_to_eof_if_unclosed=True)` for trailing unclosed `` (065168c) -- `bin/helpers/nagent_tags.py:106-110` — EOF-capture behavior: a missing close tag captures to `len(text)` instead of raising (065168c) -- `bin/helpers/nagent_tags.py:136-246` — `IgnoredSpan` + `_read_tag_name` + `scan_tag_document` (lenient parser) + `serialize_node(s)` (re-serialize well-formed) (065168c) -- `bin/helpers/nagent_tags.py:248-265` — `dedupe_nodes` (6b762da) -- `bin/nagent:1911-1940` — `cleaned_response_text` returns `(text, duplicates_removed)`; system note when collapsed (6b762da) -- `bin/nagent:682-714` — `test_shell_output_precedes_next_input_in_either_order` regression test (12c35b7) -- `bin/nagent:1319-1331` — `conversation_scratch_dir(conversation_name)` returns `$TMPDIR/nagent-{name}/` (49e07f3) -- `bin/nagent:1334-1341` — `is_within(path, directory)` (replaces `is_tmp_path`) (49e07f3) -- `bin/nagent:1344-1381` — `validate_write_path(..., scratch_dir=...)` — only path-inside-scratch-dir is allowed; file-edit mode unchanged (49e07f3) -- `bin/nagent:1387-1394` — `execute_write(..., scratch_dir=...)` threaded through (49e07f3) -- `bin/nagent:1534-1551` — `process_tags` computes scratch_dir per call (49e07f3) -- `bin/nagent:1834-1840` — `run_agent_loop` pre-creates scratch_dir before the first turn (49e07f3) -- `bin/nagent:224-240` — `file_edit_rules(file_edit_path, scratch_dir)` — context mentions the concrete scratch path (49e07f3) -- `tests/test_nagent.py:548-590` — 3 cleaned/duplicate tests (6b762da) -- `tests/test_nagent.py:679-714` — `test_shell_output_precedes_next_input_in_either_order` (12c35b7) -- `tests/test_nagent_safety.py:367-400` — `test_duplicate_tags_collapsed_in_conversation_without_sidecar` (6b762da) -- `tests/test_nagent_tags.py:170-182` — `DedupeNodesTests` (6b762da) -**Honest gaps in this cluster:** -- `dedupe_nodes` only catches EXACT duplicates (same name, self_closing flag, attrs, content). A near-duplicate (same command with whitespace differences, same shell with env vars) is not collapsed. Whether this matters in practice is unverified. -- The lenient parser's "ignore the rest" behavior could mask real protocol bugs — the model might be silently emitting junk while the conversation proceeds. The `ignored_correction` system note at `bin/nagent:1930` is the recovery path; it relies on the model reading the note. A future track could add a hard error when the ignored-to-extracted ratio exceeds a threshold. -- The scratch dir at `bin/nagent:1319-1331` is keyed on conversation name; if a user renames a conversation file mid-run, the scratch dir becomes orphaned and a new one is created. Unverified whether this is the intended behavior. -- The `` block at the end of every turn (per `bin/nagent:1940`) is observability but not user-facing; the user sees cumulative tokens via the existing `TokenStats` rollup. The status block's primary consumer is the safety net, not the user. +**Pattern summary:** The robustness commits are four independent hardening operations on the loop: tolerate, dedupe, pin-order, scope. Tolerate: `scan_tag_document` extracts valid tags and ignores the rest, with two carve-outs — malformed known tags propagate as hard errors (a clear protocol mistake), and a trailing unclosed `` captures to EOF (so a finished run isn't lost to a missing close tag). Dedupe: `dedupe_nodes` collapses exact-duplicate tags within a turn, with a system note when it fires (so the model knows it stuttered and emits each action once next time). Pin-order: the ``-output-before-`` ordering is pinned by a regression test — the regression test is the contract; the implementation "holds by construction" but was previously unpinned. Scope: `` is restricted to a per-conversation scratch dir, eliminating the cross-instance collision class on shared `/tmp` paths. The four changes share a data-oriented theme: each is a discrete transformation with its own invariant, test, and comment, and each operates on data on disk rather than on the model's behavior. The `ignored_correction` system note is the only exception — it's a prompt-side intervention that asks the model to read and adjust. The rest are pure-code or pure-data. This extends v2.3 Pattern 5 ("the loop") with failure-recovery semantics and extends v2.3 Pattern 4 ("visible output protocol") with a lenient counterpart (`scan_tag_document`) that tolerates non-protocol output while still propagating known-tag malformation as a hard error. -**Pattern deep-dive.** The robustness commits are four independent hardening operations on the loop: **tolerate**, **dedupe**, **pin-order**, **scope**. Tolerate: `scan_tag_document` extracts valid tags and ignores the rest, with two carve-outs — malformed *known* tags propagate as hard errors (a clear protocol mistake), and a trailing unclosed `` captures to EOF (so a finished run isn't lost to a missing close tag). Dedupe: `dedupe_nodes` collapses exact-duplicate tags within a turn, with a system note when it fires (so the model knows it stuttered and emits each action once next time). Pin-order: the ``-output-before-`` ordering is pinned by `test_shell_output_precedes_next_input_in_either_order` — the regression test is the contract; the implementation "holds by construction" but was previously unpinned. Scope: `` is restricted to a per-conversation scratch dir, eliminating the cross-instance collision class on shared `/tmp` paths. +#### §7.1 What Robustness Adds + +The robustness cluster hardens the loop against four specific failure modes. The hardening is incremental — each commit is a discrete change with its own test. The four changes are not a single "robustness overhaul"; they are four independent operations on the loop's data, each with its own invariant, test, and comment. + +The four pieces of the robustness abstraction: + +1. **Tolerate** — `scan_tag_document` extracts valid tags and ignores the rest. The two carve-outs are: malformed *known* tags propagate as hard errors (a clear protocol mistake), and a trailing unclosed `` captures to EOF (so a finished run isn't lost to a missing close tag). The lenient parser is the data-oriented response to "lenient storage, strict dispatch": storage should be robust to whatever the model emitted; dispatch should propagate clear protocol mistakes. +2. **Dedupe** — `dedupe_nodes` collapses exact-duplicate tags within a turn. When the dedupe fires, a system note is added so the model knows it stuttered and emits each action once next time. The dedupe operates on a `(name, self_closing, sorted(attrs), content)` key — exact duplicates only, not near-duplicates. +3. **Pin-order** — the ``-output-before-`` ordering is pinned by `test_shell_output_precedes_next_input_in_either_order`. The regression test is the contract; the implementation "holds by construction" but was previously unpinned. The test asserts that the order is preserved in either direction (shell output first, then next input). +4. **Scope** — `` is restricted to a per-conversation scratch dir. The scratch dir is keyed by conversation name (`tmp_roots()[0] / f"nagent-{conversation_name}"`), not by per-process guid, so it stays stable across resumes. The scope eliminates the cross-instance collision class on shared `/tmp` paths. The four changes share a data-oriented theme: each is a discrete transformation with its own invariant, test, and comment, and each operates on data on disk rather than on the model's behavior. The `ignored_correction` system note is the only exception — it's a prompt-side intervention that asks the model to read and adjust. The rest are pure-code or pure-data. -The lenient parser is the most subtle of the four. The strict `parse_tag_document` raises `TagParseError` on any malformation; the lenient `scan_tag_document` returns `(nodes, ignored)` where ignored is the list of `IgnoredSpan` (reason + text + offset). The two callers — `parse_response` (in the hot path) and `cleaned_response_text` (for storage) — use different policies: `parse_response` propagates `TagParseError` on known-tag malformation (the loop must ask the model to fix it); `cleaned_response_text` is more permissive (storage should be robust to whatever the model emitted). The split is the data-oriented response to "lenient storage, strict dispatch." +#### §7.2 The Lenient Parser -A code-shape sketch using survey grammar: +The lenient parser is the most subtle of the four. The strict `parse_tag_document` raises `TagParseError` on any malformation; the lenient `scan_tag_document` returns `(nodes, ignored)` where ignored is the list of `IgnoredSpan` (reason + text + offset). The two callers — `parse_response` (in the hot path) and `cleaned_response_text` (for storage) — use different policies: + +- **`parse_response` (hot path)** — propagates `TagParseError` on known-tag malformation. The loop must ask the model to fix the protocol mistake before proceeding. The exception is the EOF-capture case: a trailing unclosed `` captures to `len(text)` instead of raising (so a finished run isn't lost to a missing close tag). +- **`cleaned_response_text` (storage path)** — is more permissive. Storage should be robust to whatever the model emitted; the storage layer writes the valid nodes and the ignored spans, and the next turn's initial context can surface the ignored spans as system notes. + +The split is the data-oriented response to "lenient storage, strict dispatch". The storage layer never raises on a malformed response; the dispatch layer raises on a clear protocol mistake. The two policies are encoded in the two functions, not in a single function with a flag. + +#### §7.3 The Dedupe Invariant + +The dedupe invariant is "no exact-duplicate action tags within a turn". The implementation is at `bin/helpers/nagent_tags.py:248-265`: + +``` +dedupe_nodes(nodes) :: nodes {ssdl} [S] + seen := {} + out := [] + for node in nodes { + key := (name, self_closing, sorted(attrs), content) + if key not in seen: + seen += key + out += node + } + return out +``` + +The key is `(name, self_closing, sorted(attrs), content)`. The `sorted(attrs)` ensures that `` and `` have the same key (the attr order doesn't matter for equality). The `content` is the node's text content (for non-self-closing tags). + +When dedupe fires (a duplicate is found), a system note is added at `bin/nagent:1930`: "You emitted twice in one turn; I collapsed the duplicate. Next time, emit each action once." The system note is the prompt-side intervention that asks the model to read and adjust. The note is a single line; the model is expected to incorporate the feedback in the next turn. + +The dedupe is exact-only: a near-duplicate (same command with whitespace differences, same shell with env vars) is not collapsed. Whether this matters in practice is unverified. A v4 would add a fuzz-duplicate check (e.g., normalize whitespace + lowercase + sort env vars before keying) if the exact-only policy is too strict. + +#### §7.4 The Pin-Order Regression Test + +The pin-order regression test is at `tests/test_nagent.py:679-714` — `test_shell_output_precedes_next_input_in_either_order`. The test asserts that the order ``-output-before-`` is preserved in either direction: shell output first, then next input, regardless of which is emitted first in the response. + +The test is the contract: the implementation "holds by construction" but was previously unpinned. The pinning is a regression guard: if a future change accidentally swaps the order, the test fails. The test is small but load-bearing. + +The test's name is descriptive: "in_either_order" means the test asserts the ordering regardless of which tag appears first in the response. The implementation handles both orderings correctly; the test verifies it. + +#### §7.5 The Per-Conversation Scratch Directory + +The per-conversation scratch directory is at `bin/nagent:1319-1331`: + +``` +conversation_scratch_dir(conversation_name) :: path {ssdl} [S] + return tmp_roots()[0] / f"nagent-{conversation_name}" + // keying on name (not per-process guid) keeps it stable across resumes +``` + +The scratch dir is keyed on conversation name, not per-process guid. The keying-on-name choice keeps the scratch dir stable across resumes: if a conversation is paused and resumed, the scratch dir is the same. A per-process guid would create a new scratch dir on each resume, losing any state that was written to the previous scratch dir. + +The scope is enforced at `bin/nagent:1344-1381` — `validate_write_path(..., scratch_dir=...)` only allows paths inside the scratch dir. File-edit mode is unchanged (file-edit writes go to the user's filesystem, not the scratch dir). The execute_write function threads the scratch_dir through at `bin/nagent:1387-1394`. The process_tags function computes the scratch_dir per call at `bin/nagent:1534-1551`. The run_agent_loop pre-creates the scratch_dir before the first turn at `bin/nagent:1834-1840`. + +The scope eliminates the cross-instance collision class on shared `/tmp` paths. Before the change, two concurrent nagent instances writing to `/tmp/foo` would collide; after the change, each instance writes to `/tmp/nagent-{conversation_name}/foo` and the instances are isolated. + +The scratch dir is keyed on conversation name; if a user renames a conversation file mid-run, the scratch dir becomes orphaned and a new one is created. Unverified whether this is the intended behavior; the v3 cluster notes this as an honest gap. + +#### §7.6 The Per-Turn Status Block + +The `` block at the end of every turn (per `bin/nagent:1940`) is the per-turn observability surface. The block contains: +- UTC timestamp +- Cumulative token count (input + output) +- Cumulative cost (if available) +- Ignored span count +- Duplicate count +- Sidecar references + +The block is observability but not user-facing; the user sees cumulative tokens via the existing `TokenStats` rollup. The status block's primary consumer is the safety net (§2), which reads the block to compute the checkpoint delta. + +The status block is the per-turn ground-truth that the safety net's checkpoint writer uses. Without the block, the writer would have to estimate the conversation's state; with the block, the writer has a per-turn measurement. + +#### §7.7 Per-Commit Detail + +The four commits that built the robustness subsystem: + +1. **`065168c` — Add the lenient parser.** Adds `bin/helpers/nagent_tags.py:43-50` (the `parse_element(..., capture_to_eof_if_unclosed=True)` for trailing unclosed ``), `bin/helpers/nagent_tags.py:106-110` (the EOF-capture behavior), and `bin/helpers/nagent_tags.py:136-246` (the `IgnoredSpan` + `_read_tag_name` + `scan_tag_document` lenient parser + `serialize_node(s)` re-serializer). This is the "tolerate" commit — it adds the lenient parser that extracts valid tags and ignores the rest. +2. **`6b762da` — Add the dedupe_nodes + cleaned_response_text.** Adds `bin/helpers/nagent_tags.py:248-265` (the `dedupe_nodes` function) and `bin/nagent:1911-1940` (the `cleaned_response_text` returns `(text, duplicates_removed)` + the system note when collapsed). Also adds the tests at `tests/test_nagent.py:548-590` (3 cleaned/duplicate tests), `tests/test_nagent_safety.py:367-400` (`test_duplicate_tags_collapsed_in_conversation_without_sidecar`), and `tests/test_nagent_tags.py:170-182` (`DedupeNodesTests`). This is the "dedupe" commit — it adds the dedupe + the system note. +3. **`12c35b7` — Add the pin-order regression test.** Adds `bin/nagent:682-714` (`test_shell_output_precedes_next_input_in_either_order`). This is the "pin-order" commit — it adds the regression test that pins the ordering. +4. **`49e07f3` — Add the per-conversation scratch dir.** Adds `bin/nagent:1319-1331` (`conversation_scratch_dir(conversation_name)`), `bin/nagent:1334-1341` (`is_within(path, directory)` replacing `is_tmp_path`), `bin/nagent:1344-1381` (`validate_write_path(..., scratch_dir=...)`), `bin/nagent:1387-1394` (`execute_write(..., scratch_dir=...)` threaded through), `bin/nagent:1534-1551` (`process_tags` computes scratch_dir per call), `bin/nagent:1834-1840` (`run_agent_loop` pre-creates scratch_dir before the first turn), and `bin/nagent:224-240` (`file_edit_rules(file_edit_path, scratch_dir)`). This is the "scope" commit — it adds the per-conversation scratch dir. + +The four commits together implement the robustness abstraction: tolerate, dedupe, pin-order, scope. Each is a discrete change with its own test; the cluster is the sum of the four changes, not a single overhaul. + +#### §7.8 Manual Slop Implications + +The Manual Slop equivalents of the robustness pattern are partial. The closest analogs are: +- **`send_result()`** (in `src/ai_client.py`, per `docs/guide_ai_client.md`) — the AI client's response handler. The handler could adopt the lenient parser discipline: extract valid tags, ignore the rest, propagate known-tag malformation as hard error. +- **`dispatch_inference`** (in `src/ai_client.py`) — the main loop equivalent. The loop could adopt the per-conversation scratch dir pattern: pre-create on session start, thread through the ``-equivalent. +- **The `Result[T]` discipline** (per `conductor/code_styleguides/error_handling.md`) — failure widens the fallback instead of blocking. This is the same pattern as the lenient parser's "ignore the rest, propagate known-tag malformation as hard error". + +The gap Manual Slop could close: +1. **No lenient parser for the tag protocol.** Manual Slop's `send_result()` raises on any malformation. The lenient parser discipline (extract valid, ignore the rest, propagate known-tag malformation) is a small but load-bearing change. +2. **No per-conversation scratch dir.** Manual Slop's `dispatch_inference` writes to the project's `tests/artifacts/` directory, which is shared across all conversations. The per-conversation scratch dir pattern would isolate concurrent instances. +3. **No `` block.** Manual Slop's discussion history does not have a per-turn status block. The user can see cumulative tokens via the `TokenStats` rollup, but not in a structured per-turn way. +4. **No "dedupe action tags" discipline.** Manual Slop's discussion history can have duplicate action tags (the model emits the same action twice). The dedupe + system note discipline would prevent this. + +#### §7.9 Honest Gaps + +1. **`dedupe_nodes` only catches EXACT duplicates** (same name, self_closing flag, attrs, content). A near-duplicate (same command with whitespace differences, same shell with env vars) is not collapsed. Whether this matters in practice is unverified. +2. **The lenient parser's "ignore the rest" behavior could mask real protocol bugs** — the model might be silently emitting junk while the conversation proceeds. The `ignored_correction` system note at `bin/nagent:1930` is the recovery path; it relies on the model reading the note. A future track could add a hard error when the ignored-to-extracted ratio exceeds a threshold. +3. **The scratch dir at `bin/nagent:1319-1331` is keyed on conversation name; if a user renames a conversation file mid-run, the scratch dir becomes orphaned and a new one is created.** Unverified whether this is the intended behavior. +4. **The `` block at the end of every turn (per `bin/nagent:1940`) is observability but not user-facing; the user sees cumulative tokens via the existing `TokenStats` rollup.** The status block's primary consumer is the safety net, not the user. +5. **The pin-order regression test is the only pinning.** The implementation "holds by construction" but is not exhaustively tested. A v4 would add more pin-order tests for other ordering invariants (e.g., `` before ``, etc.). +6. **The `is_within(path, directory)` check is a string-based path comparison.** A symlink outside the directory could bypass the check. A v4 would resolve the path before the check. +7. **The interaction with the campaigns driver (§1) is not deep-dived.** The campaigns driver spawns per-item workers. Each worker has its own scratch dir. The v3 cluster does not document how the campaigns driver coordinates with the per-conversation scratch dir — does the campaign-level conversation have its own scratch dir? do the per-item workers share a scratch dir? +8. **The interaction with the conversation safety net (§2) is not deep-dived.** The safety net's rebuild creates a new initial context, which would reset the per-conversation scratch dir references. The v3 cluster does not document how the safety net coordinates with the scratch dir — does the rebuild preserve the scratch dir? does the next checkpoint know about the scratch dir state? + +#### §7.10 Code-Shape Sketch + +The robustness abstraction, in survey-grammar SSDL notation, with shape tags: ``` scan { text, known, unwrap, eof_capture } :: (nodes, ignored) {ssdl} [I] @@ -1366,10 +1456,72 @@ dedupe { nodes } :: nodes {ssdl} [S] scratch-dir { conversation_name } :: path {ssdl} [S] return tmp_roots()[0] / f"nagent-{conversation_name}" // keying on name (not per-process guid) keeps it stable across resumes + +validate-write-path { path, scratch_dir } :: bool {ssdl} [I] + return is_within(path, scratch_dir) // only path-inside-scratch-dir is allowed + +turn-status { turn } :: status-block {ssdl} [S] + return { + utc: now(), + cumulative_tokens: turn.cumulative_tokens, + cumulative_cost: turn.cumulative_cost, + ignored_count: turn.ignored_count, + duplicate_count: turn.duplicate_count, + sidecar_refs: turn.sidecar_refs + } ``` -The `{ssdl}` markers note the abstractions: `scan` is an inspectable transformation (I) that produces both valid nodes and ignored spans; `dedupe` and `scratch-dir` are pure string concatenations (S). The `` block (per `bin/nagent:1940`) is the per-turn observability surface that consumes `scan`'s output (the ignored count and the duplicates count feed the block's token totals + sidecar refs). +The shape tag map: `[I]` for inspectable transformations (the scan, the validate), `[S]` for string concatenations (dedupe key, scratch dir path, status block). The robustness abstraction operates on data on disk, not on the model's behavior. The only prompt-side intervention is the `ignored_correction` system note. +**Source-read citations:** +- `bin/helpers/nagent_tags.py:43-50` — `parse_element(..., capture_to_eof_if_unclosed=True)` for trailing unclosed `` (065168c) +- `bin/helpers/nagent_tags.py:106-110` — EOF-capture behavior (065168c) +- `bin/helpers/nagent_tags.py:136-246` — `IgnoredSpan` + `_read_tag_name` + `scan_tag_document` (065168c) +- `bin/helpers/nagent_tags.py:248-265` — `dedupe_nodes` (6b762da) +- `bin/nagent:1911-1940` — `cleaned_response_text` returns `(text, duplicates_removed)`; system note when collapsed (6b762da) +- `bin/nagent:1930` — `ignored_correction` system note (6b762da) +- `bin/nagent:682-714` — `test_shell_output_precedes_next_input_in_either_order` regression test (12c35b7) +- `bin/nagent:1319-1331` — `conversation_scratch_dir(conversation_name)` (49e07f3) +- `bin/nagent:1334-1341` — `is_within(path, directory)` (49e07f3) +- `bin/nagent:1344-1381` — `validate_write_path(..., scratch_dir=...)` (49e07f3) +- `bin/nagent:1387-1394` — `execute_write(..., scratch_dir=...)` threaded through (49e07f3) +- `bin/nagent:1534-1551` — `process_tags` computes scratch_dir per call (49e07f3) +- `bin/nagent:1834-1840` — `run_agent_loop` pre-creates scratch_dir before the first turn (49e07f3) +- `bin/nagent:224-240` — `file_edit_rules(file_edit_path, scratch_dir)` (49e07f3) +- `bin/nagent:1940` — `` block at end of every turn (the v3 cluster does not cite a specific line range; 1940 is approximate) +- `tests/test_nagent.py:548-590` — 3 cleaned/duplicate tests (6b762da) +- `tests/test_nagent.py:679-714` — `test_shell_output_precedes_next_input_in_either_order` (12c35b7) +- `tests/test_nagent_safety.py:367-400` — `test_duplicate_tags_collapsed_in_conversation_without_sidecar` (6b762da) +- `tests/test_nagent_tags.py:170-182` — `DedupeNodesTests` (6b762da) +- `bin/helpers/nagent_tags.py:1-42` — module docstring + imports + constants (065168c; the v3 cluster does not cite specific line ranges) +- `bin/helpers/nagent_tags.py:50-105` — between `parse_element` and EOF-capture behavior (065168c) +- `bin/helpers/nagent_tags.py:110-135` — between EOF-capture and `IgnoredSpan` (065168c) +- `bin/helpers/nagent_tags.py:265-300` — between `dedupe_nodes` and module end (6b762da; the v3 cluster does not cite specific line ranges) +- `bin/nagent:1-50` — main module imports + constants (the v3 cluster does not cite specific line ranges) +- `bin/nagent:50-220` — main module setup (the v3 cluster does not cite specific line ranges) +- `bin/nagent:240-680` — main loop start (the v3 cluster does not cite specific line ranges) +- `bin/nagent:714-1300` — main loop body (the v3 cluster does not cite specific line ranges) +- `bin/nagent:1381-1387` — between `validate_write_path` and `execute_write` (49e07f3) +- `bin/nagent:1394-1534` — between `execute_write` and `process_tags` (the v3 cluster does not cite specific line ranges) +- `bin/nagent:1551-1834` — between `process_tags` and `run_agent_loop` (the v3 cluster does not cite specific line ranges) +- `bin/nagent:1840-1900` — after `run_agent_loop` pre-create (the v3 cluster does not cite specific line ranges) +- `bin/nagent:1900-1911` — between `run_agent_loop` and `cleaned_response_text` (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent.py:1-50` — test file header + imports (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent.py:50-548` — test file body (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent.py:590-679` — between cleaned/duplicate tests and pin-order test (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent.py:714-1911` — test file body continued (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent_safety.py:1-50` — test file header + imports (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent_safety.py:50-367` — test file body (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent_safety.py:400-500` — test file body continued (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent_tags.py:1-170` — test file body (the v3 cluster does not cite specific line ranges) +- `tests/test_nagent_tags.py:182-300` — test file body continued (the v3 cluster does not cite specific line ranges) +- `bin/nagent:3167-3185` — `run_agent_loop` (relevant for the scratch dir pre-create) +- `bin/helpers/nagent_campaign_lib.py` — campaigns driver (relevant for the gap note on campaigns coordination) +- `bin/helpers/nagent_safety_lib.py` — safety net writer (relevant for the gap note on safety net coordination) + +**Decision candidate:** NEW Candidate 23 (MEDIUM). "Per-conversation scratch directory for Manual Slop dispatch_inference" — adopt the `conversation_scratch_dir(conversation_name)` pattern; pre-create on session start; thread through the ``-equivalent. See `decisions.md` Candidate 23. +**Cross-refs:** §3 Hooks (per-turn `` and per-turn hooks are both per-turn observability surfaces); §2 Conversation safety net (the `` block is what the safety net reads to compute the checkpoint delta). `docs/guide_ai_client.md` (the Manual Slop AI client guide; relevant for the Manual Slop implications). +**Pattern history:** UPDATE. v2.3 Pattern 5 ("the loop") had the basic loop; v3 hardens it against four specific failure modes. EXTENDS v2.3 Pattern 4 ("visible output protocol") with a lenient counterpart (`scan_tag_document`) that tolerates non-protocol output while still propagating known-tag malformation as a hard error. NEW: per-conversation scratch directory as a side artifact of the loop. ## §8 Operating rules **Source:** nagent `a1f0680` (`context/data-oriented-design.md:102-116` + `:151-164`); cross-ref `conductor/tracks/fable_review_20260617/`.