Skip to content

Shepherd trace - #192

Merged
buchenberg merged 2 commits into
mainfrom
shepherd-trace
Aug 10, 2026
Merged

Shepherd trace#192
buchenberg merged 2 commits into
mainfrom
shepherd-trace

Conversation

@buchenberg

@buchenberg buchenberg commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Added durable execution tracing with configurable storage.
    • Added CLI commands to list, inspect, and profile trace sessions.
    • Added sub-agent trace inspection for tool calls, outcomes, errors, and performance.
    • Enabled tracing in the default execution pipeline, with configuration options to disable or customize it.
  • Documentation
    • Added configuration guidance, command examples, and trace troubleshooting documentation.
  • UI Improvements
    • Updated agent information displays to show status and clearly label version details.

Record every tool call and turn boundary to a content-addressed,
append-only SQLite store via a new `shepherd_trace` pipeline middleware
(enabled by default). Each fact is cryptographically hashed and causally
chained, yielding tamper-evident, replayable execution history that
survives crashes and is inspectable independent of OTel exporters.

New `yaah shepherd-trace` commands expose the store:

- `list` — show trace sessions with fact counts
- `show <session>` — tool-call sequence with args and status
- `show --latest` — most recent session
- `profile <session>` — aggregate turns, token usage, tool stats, and
  success rates

Sub-agents now trace under their own owners (`sub-<role>-sess-*-<id>`)
in the same store. When a sub-agent fails, the orchestrator reads its
trace and appends the failing tool-call sequence to the retry prompt,
so the parent sees exactly which calls led to the error. A new
`subagent_trace` tool enables proactive queries during a run.

Configuration:

- `agents.default.shepherd_trace_dir` (default `~/.yaah/traces/`)
  controls the store location.
- No separate enable flag — tracing is active whenever `shepherd_trace`
  is in the pipeline (default on) and disabled via
  `middleware.disabled: [shepherd_trace]`.

Adds the `shepherd-kernel-go` dependency (local replace for now) and a
noop middleware fallback that degrades gracefully when the store cannot
be opened.
- Add shepherd_trace to default pipeline list in tui2
- Use atomic counter for ordinal to support multiple middleware instances
- Replace stderr errors with structured slog logging
- Move agent status display into sessioninfo component
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds durable Shepherd SQLite traces for agent and sub-agent execution. Adds trace middleware, lifecycle recording, CLI and tool inspection commands, configuration, documentation, tests, and updated TUI agent status output.

Changes

Shepherd tracing

Layer / File(s) Summary
Trace storage and middleware
go.mod, internal/agent/pipeline/*
The pipeline creates Shepherd SQLite storage and records tool calls, turn events, causal links, ordinals, and frontier publications.
Agent configuration and turn lifecycle
internal/config/*, internal/agent/options.go, internal/agent/types.go, internal/agent/loop.go, cmd/yaah/build_loop.go, cmd/yaah/tui2.go, docs/configuration.md
Trace paths and session IDs propagate through agent setup. Turn success, cancellation, provider errors, tool errors, and iteration exhaustion record trace outcomes.
Sub-agent trace propagation and tool access
internal/agent/runner/*, internal/agent/subagent_loop.go, internal/tools/subagent_trace.*, internal/prompts/tools/subagent_trace.md
Sub-agents receive trace sessions and directories. Failures can include tool-call profiles. The sub-agent trace tool lists sessions and profiles tool calls.
CLI trace inspection
cmd/yaah/trace.*, README.md, docs/features.md
The CLI lists sessions, shows tool calls, profiles turns, resolves trace stores, and formats statuses, tokens, durations, and tool statistics.

TUI agent information

Layer / File(s) Summary
Agent information display
internal/tui2/components/infopane/infopane.go, internal/tui2/components/sessioninfo/*
The TUI passes active or idle state into agent information output. The formatter labels the heading, status, and version explicitly.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant TraceMiddleware
  participant SQLiteTraceStore
  participant YAAHCLI
  Agent->>TraceMiddleware: Start turn and record tool results
  TraceMiddleware->>SQLiteTraceStore: Append declarations and captures
  Agent->>TraceMiddleware: End or fail turn
  TraceMiddleware->>SQLiteTraceStore: Persist lifecycle result
  YAAHCLI->>SQLiteTraceStore: Read sessions and facts
  SQLiteTraceStore-->>YAAHCLI: Return trace records
  YAAHCLI-->>Agent: Render list, show, or profile output
Loading

Possibly related PRs

  • buchenberg/yaah#176: Introduced the centralized loop-builder path that now propagates the Shepherd trace directory.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding Shepherd trace recording, storage, inspection, and profiling support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch shepherd-trace

Comment @coderabbitai help to get the list of available commands.

@buchenberg
buchenberg merged commit db3094f into main Aug 10, 2026
1 of 5 checks passed
@buchenberg
buchenberg deleted the shepherd-trace branch August 10, 2026 21:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/agent/loop.go (1)

227-254: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Close successful tool-call turns before the next iteration.

After executeToolPhase succeeds, the loop continues without calling EndTurn. Each tool-call turn remains in turn:started state until the next StartTurn overwrites turnRootFactIDs. On exhaustion, FailTurn(l.Config.MaxLoopCycles, err) then records a failure for a turn that was not started.

Call EndTurn after successful tool execution. Record MaxIterationsError as a run-level event, or associate it with an explicitly started turn.

Proposed lifecycle completion
 			if err != nil {
 				if traceMw != nil {
 					traceMw.FailTurn(iter, err)
 				}
 				return "", err
 			}
+
+			if traceMw != nil {
+				traceMw.EndTurn(iter, result.Usage.PromptTokens, result.Usage.CompletionTokens)
+			}
 
 			if turnSpan != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 227 - 254, In the loop around
executeToolPhase, close each successful tool-call turn by calling the trace
middleware’s EndTurn before the next iteration, while preserving the existing
failure handling for unsuccessful execution. Update max-iteration exhaustion so
MaxIterationsError is recorded as a run-level event, or start and associate an
explicit turn before calling FailTurn instead of failing an unstarted turn.
🧹 Nitpick comments (9)
internal/tui2/components/sessioninfo/sessioninfo_test.go (1)

21-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the new status output.

The formatter now emits Status:, but this test does not set or check Info.Status. Set Status: "active" and assert the rendered value.

Proposed test update
 	th := colors.NewDarkTheme()
-	out := Format(Info{Provider: "openai", Model: "gpt-4", Version: "1.2.3"}, &th)
+	th.NoColor = true
+	out := Format(Info{
+		Provider: "openai",
+		Model:    "gpt-4",
+		Version:  "1.2.3",
+		Status:   "active",
+	}, &th)
+	if !strings.Contains(out, "Status: active") {
+		t.Errorf("should contain status, got %q", out)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/components/sessioninfo/sessioninfo_test.go` around lines 21 -
25, Update TestFormat_Version to populate Info.Status with "active" and assert
that Format renders both the "Status:" label and the "active" value in the
output.
internal/tui2/components/infopane/infopane.go (1)

68-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover both AgentActive states.

Format now maps State.AgentActive to "active" or "idle". Add tests in internal/tui2/components/infopane/infopane_test.go for both values and assert the rendered Status: line. This verifies the new state-to-text wiring separately from sessioninfo.Format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/components/infopane/infopane.go` around lines 68 - 77, Add
infopane tests covering both true and false AgentActive values, asserting that
the rendered Status: line contains “active” and “idle” respectively. Exercise
the wiring through the infopane rendering path rather than testing
sessioninfo.Format directly.
cmd/yaah/trace.go (4)

464-469: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Do not discard the configuration load error.

Line 465 ignores the error from config.Load(). A malformed config.yaml then silently falls back to ~/.yaah/traces, and the user sees an unrelated "no trace store found" message. Log or return the error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace.go` around lines 464 - 469, The openShepherdTraceStore
function must handle errors returned by config.Load instead of discarding them.
Capture the load error and return or log it before applying the default trace
directory, while preserving the existing configuration-based directory selection
for successful loads.

263-264: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use an index instead of a pointer into the turns slice.

current points into the backing array of turns. append on Line 286 can reallocate that array. The code reassigns current immediately after each append, so the current behavior is correct. The pattern breaks if any future change appends to turns between two tool records. Track the index instead.

♻️ Proposed refactor
-			var turns []turnRecord
-			var current *turnRecord
+			var turns []turnRecord
+			currentIdx := -1

Then replace current == nil with currentIdx < 0 and current.tools = append(...) with turns[currentIdx].tools = append(...).

Also applies to: 286-287

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace.go` around lines 263 - 264, Replace the current *turnRecord
pointer with an integer currentIdx initialized to -1, and update the surrounding
turn-processing logic to use currentIdx < 0 for the unset check. After appending
a turn record, assign its index to currentIdx, and append tools through
turns[currentIdx].tools instead of current.tools.

95-114: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared latest-session lookup and the read limit.

The show and profile commands repeat the same three steps: read all owners, sort them in reverse, and take the first. The literal 99999 appears four times as the read limit. Extract one helper and one named constant.

♻️ Proposed helper
const traceReadLimit = 99999

func latestTraceSession(store *shepherd.SQLiteTraceStore) (string, error) {
	slice, err := store.ReadOwnerPrefix(shepherd.TrustedReadContext, "", traceReadLimit, "declarations_only")
	if err != nil {
		return "", fmt.Errorf("read traces: %w", err)
	}
	ownerIDs := make([]string, 0, len(slice.OwnerPaths))
	for owner := range slice.OwnerPaths {
		ownerIDs = append(ownerIDs, owner)
	}
	if len(ownerIDs) == 0 {
		return "", nil
	}
	sort.Sort(sort.Reverse(sort.StringSlice(ownerIDs)))
	return ownerIDs[0], nil
}

Then both commands call latestTraceSession and treat an empty result as "No trace sessions found.".

Also applies to: 116-119, 199-224

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace.go` around lines 95 - 114, The show and profile command paths
duplicate latest-session lookup logic and the literal read limit. Define a
shared traceReadLimit constant and latestTraceSession helper that reads owners,
handles read errors, sorts IDs in reverse, and returns the newest ID or an empty
result; update both command paths to use it while preserving the existing “No
trace sessions found.” output.

513-518: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard truncateString against small limits.

If maxLen is less than 3, s[:maxLen-3] panics with a negative index. The current callers pass 50 and 60, so the panic is not reachable today. Add the guard so future callers cannot trigger it. The function also slices bytes, so it can split a multi-byte rune. Slice runes if trace arguments can contain non-ASCII text.

🛡️ Proposed fix
 func truncateString(s string, maxLen int) string {
-	if len(s) <= maxLen {
+	if maxLen <= 0 {
+		return ""
+	}
+	if len(s) <= maxLen {
 		return s
 	}
-	return s[:maxLen-3] + "..."
+	if maxLen <= 3 {
+		return s[:maxLen]
+	}
+	r := []rune(s)
+	if len(r) <= maxLen {
+		return s
+	}
+	return string(r[:maxLen-3]) + "..."
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace.go` around lines 513 - 518, Update truncateString to safely
handle maxLen values below 3 without negative slicing, returning an
appropriately bounded result while preserving the truncation behavior. Also
convert the truncation logic from byte slicing to rune-aware slicing so
non-ASCII trace arguments are not split mid-rune.
cmd/yaah/trace_test.go (3)

385-403: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wrap the table cases in subtests.

TestFormatNum iterates its table without t.Run, so a failure does not name the case. TestTruncateString uses two inline assertions instead of a table with subtests. The coding guidelines require t.Run("name", func(t *testing.T) { ... }) for subtests.

♻️ Proposed fix
 	for _, tt := range tests {
-		got := formatNum(tt.n)
-		if got != tt.want {
-			t.Errorf("formatNum(%d) = %q, want %q", tt.n, got, tt.want)
-		}
+		t.Run(tt.want, func(t *testing.T) {
+			if got := formatNum(tt.n); got != tt.want {
+				t.Errorf("formatNum(%d) = %q, want %q", tt.n, got, tt.want)
+			}
+		})
 	}

As per coding guidelines: "Place tests next to the code they test and use t.Run("name", func(t *testing.T) { ... }) for subtests."

Also applies to: 376-383

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace_test.go` around lines 385 - 403, Update TestFormatNum to
execute each table entry inside a named t.Run subtest, using a descriptive case
name and the subtest’s *testing.T for the assertion. Also convert
TestTruncateString’s inline assertions into named table-driven subtests,
preserving the existing inputs and expected results.

Source: Coding guidelines


84-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add profile assertions for turn status and token totals.

seedTestTraceStore chains the turn:completed capture from lastFactIDs, which is the last tool result, not the turn:created declaration. newShepherdTraceProfileCmd reads turn status and tokens only from the capture whose causal parent is the turn:created declaration. The seeded data therefore never exercises that path, and TestTraceProfile only checks for the strings Tools: and SUCCESS. Add assertions for completed and for the token line so the profile token attribution is covered.

Also applies to: 181-210

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace_test.go` around lines 84 - 97, Update seedTestTraceStore so
the turn:completed capture in the AppendBatch uses the turn:created declaration
as its causal parent instead of lastFactIDs, then extend TestTraceProfile
assertions to require the completed status and expected token totals line. This
must exercise the attribution path read by newShepherdTraceProfileCmd while
preserving existing Tools and SUCCESS checks.

13-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Check the errors in the test helpers.

os.WriteFile on Line 21 and os.MkdirAll on Line 122 return errors that the code discards. If the configuration file is not written, openShepherdTraceStore falls back to ~/.yaah/traces, and the test then asserts against a store it did not seed. The failure message would not identify the cause. The same applies to the store.Append calls in seedTestTraceStore (Lines 37, 54, 68, 85, 101) and to shepherd.NewSQLiteTraceStore on Line 320.

♻️ Proposed fix for the config helper
 func setTestTraceConfig(t *testing.T, home, traceDir string) {
 	t.Helper()
 	configContent := `
 agents:
   default:
     model: test/model
     shepherd_trace_dir: ` + traceDir + `
 `
-	os.WriteFile(filepath.Join(home, "config.yaml"), []byte(configContent), 0o644)
+	if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(configContent), 0o644); err != nil {
+		t.Fatalf("write test config: %v", err)
+	}
 }

Apply the same pattern to os.MkdirAll and to each store.Append call in seedTestTraceStore.

Also applies to: 117-124

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace_test.go` around lines 13 - 22, Handle returned errors in the
trace test helpers: assert or fail the test with context for os.WriteFile in
setTestTraceConfig, os.MkdirAll, every store.Append in seedTestTraceStore, and
shepherd.NewSQLiteTraceStore. Ensure failures identify the operation and stop
the test before continuing with an uninitialized or unseeded store.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/yaah/trace.go`:
- Around line 488-502: Update captureStatus to distinguish a completed failed
capture from a missing capture: when the record has a capture result with
success false and no error message, return "error" rather than "pending".
Preserve "pending" for records without a usable capture and keep the existing
"ok" and explicit-error behavior.
- Around line 477-480: Handle the error returned by os.UserHomeDir in the ~/
expansion block around traceDir: if the lookup fails, return or propagate that
error instead of continuing with an empty home path. Preserve the existing
filepath.Join behavior when the lookup succeeds.

In `@docs/configuration.md`:
- Around line 65-68: Update the documented configuration’s explicit
middleware.enabled list to include shepherd_trace so the nearby
shepherd_trace_dir setting actually activates tracing for copied configurations;
keep the existing middleware entries unchanged.

In `@docs/features.md`:
- Around line 278-280: Update the error message returned by
openShepherdTraceStore to remove the nonexistent shepherd_trace_enabled
configuration key and instruct users to run yaah with the shepherd_trace
middleware enabled, while preserving the trace store path in the message.
- Line 245: Update the fenced code block containing the sample profile output in
features.md to specify the text language, using text after the opening fence so
markdownlint MD040 is satisfied.
- Around line 221-223: Update all three stale middleware-count references in
README.md to state that the pipeline has 12 middleware and 9 enabled by default,
including replacing “Middle ground: 11 middleware and counting.”

In `@go.mod`:
- Line 33: Replace the local shepherd-kernel-go dependency setup with an
immutable published module version, then remove the replace directive pointing
to ../shepherd-kernel-go. Keep github.com/buchenberg/shepherd-kernel-go declared
as a direct dependency so normal clones and CI can resolve it without a sibling
directory.

In `@internal/agent/pipeline/config_test.go`:
- Around line 67-84: Update TestShepherdTraceBuilder_UnwritableDirBuildsNoop to
make initialization fail deterministically by creating a regular file and
setting ShepherdTraceDir to a child path beneath that file. Ensure the
shepherd_trace builder’s directory-creation path invokes os.MkdirAll against
this invalid descendant, then preserve the assertion that it returns
noopShepherdTraceMiddleware.

In `@internal/agent/pipeline/config.go`:
- Around line 108-115: Update the trace directory setup in the shepherd trace
configuration flow around MkdirAll and NewShepherdTraceStore to use owner-only
permissions (0700), and validate existing directories rather than relying solely
on creation mode. Add a test that fails when the trace-store directory permits
group or world access.
- Around line 112-117: In the Loop.Run flow, defer traceMw.Close() immediately
after obtaining the middleware so the trace store is closed on normal return,
errors, cancellation, and recovered panics. Ensure the defer applies to
middleware returned by both NewFromConfig and NewSubAgentLoop, without changing
existing run behavior.

In `@internal/agent/pipeline/trace.go`:
- Around line 259-264: Update the frontier identity construction in
Loop.runMiddleware to use a monotonic session-scoped ordinal rather than the
resettable turnNumber for both FrontierID and AppendIntentID. Preserve the
existing ID formats and ensure each frontier generated during a session remains
unique across ContinueAfterMaxIter cycles.
- Around line 29-37: Remove the package-level nextOrdinal counter and the
ordinal initialization from NewShepherdTraceMiddleware. Replace it with a
session-scoped ordinal strategy, and enforce uniqueness of sessionID when
constructing or registering ShepherdTraceMiddleware while preserving sessionID
as the namespace for append intent IDs.
- Around line 107-112: Update the capture flow around m.lastFactIDs so it
assigns captureReceipt.FactIDs only when the capture append succeeds (err ==
nil). Preserve the existing m.lastFactIDs value when capture fails, while
retaining the current error logging behavior.
- Line 10: Update the Go module configuration used by the Shepherd import in
trace.go so CI can resolve github.com/buchenberg/shepherd-kernel-go without
relying on the unavailable ../shepherd-kernel-go checkout; replace the local
path dependency with a resolvable module version or otherwise provision the
dependency within the repository workflow.

In `@internal/tools/subagent_trace.go`:
- Around line 74-105: Bind SubagentTraceTool operations to the active parent
session: update executeList to return only the sub-agent sessions owned by that
parent, and update executeProfile to reject sessionID values that are not
descendants of or associated with the bound parent session before reading facts.
Add and use the tool’s parent-session identifier when constructing or invoking
it, preserving existing trace formatting and errors for authorized sessions.
- Around line 146-155: Update the status and success-count logic in the
trace-processing loop around captureByParent to check whether the factID has a
captured entry before using it. Count a call as successful only when the capture
exists and cap.success is true; treat missing captures and unsuccessful captures
without error text as non-successful while preserving the existing error message
for failed captures.

---

Outside diff comments:
In `@internal/agent/loop.go`:
- Around line 227-254: In the loop around executeToolPhase, close each
successful tool-call turn by calling the trace middleware’s EndTurn before the
next iteration, while preserving the existing failure handling for unsuccessful
execution. Update max-iteration exhaustion so MaxIterationsError is recorded as
a run-level event, or start and associate an explicit turn before calling
FailTurn instead of failing an unstarted turn.

---

Nitpick comments:
In `@cmd/yaah/trace_test.go`:
- Around line 385-403: Update TestFormatNum to execute each table entry inside a
named t.Run subtest, using a descriptive case name and the subtest’s *testing.T
for the assertion. Also convert TestTruncateString’s inline assertions into
named table-driven subtests, preserving the existing inputs and expected
results.
- Around line 84-97: Update seedTestTraceStore so the turn:completed capture in
the AppendBatch uses the turn:created declaration as its causal parent instead
of lastFactIDs, then extend TestTraceProfile assertions to require the completed
status and expected token totals line. This must exercise the attribution path
read by newShepherdTraceProfileCmd while preserving existing Tools and SUCCESS
checks.
- Around line 13-22: Handle returned errors in the trace test helpers: assert or
fail the test with context for os.WriteFile in setTestTraceConfig, os.MkdirAll,
every store.Append in seedTestTraceStore, and shepherd.NewSQLiteTraceStore.
Ensure failures identify the operation and stop the test before continuing with
an uninitialized or unseeded store.

In `@cmd/yaah/trace.go`:
- Around line 464-469: The openShepherdTraceStore function must handle errors
returned by config.Load instead of discarding them. Capture the load error and
return or log it before applying the default trace directory, while preserving
the existing configuration-based directory selection for successful loads.
- Around line 263-264: Replace the current *turnRecord pointer with an integer
currentIdx initialized to -1, and update the surrounding turn-processing logic
to use currentIdx < 0 for the unset check. After appending a turn record, assign
its index to currentIdx, and append tools through turns[currentIdx].tools
instead of current.tools.
- Around line 95-114: The show and profile command paths duplicate
latest-session lookup logic and the literal read limit. Define a shared
traceReadLimit constant and latestTraceSession helper that reads owners, handles
read errors, sorts IDs in reverse, and returns the newest ID or an empty result;
update both command paths to use it while preserving the existing “No trace
sessions found.” output.
- Around line 513-518: Update truncateString to safely handle maxLen values
below 3 without negative slicing, returning an appropriately bounded result
while preserving the truncation behavior. Also convert the truncation logic from
byte slicing to rune-aware slicing so non-ASCII trace arguments are not split
mid-rune.

In `@internal/tui2/components/infopane/infopane.go`:
- Around line 68-77: Add infopane tests covering both true and false AgentActive
values, asserting that the rendered Status: line contains “active” and “idle”
respectively. Exercise the wiring through the infopane rendering path rather
than testing sessioninfo.Format directly.

In `@internal/tui2/components/sessioninfo/sessioninfo_test.go`:
- Around line 21-25: Update TestFormat_Version to populate Info.Status with
"active" and assert that Format renders both the "Status:" label and the
"active" value in the output.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c478d850-c809-46f6-b777-cd16c5ce04ec

📥 Commits

Reviewing files that changed from the base of the PR and between d3e1954 and f68b381.

📒 Files selected for processing (28)
  • README.md
  • cmd/yaah/build_loop.go
  • cmd/yaah/trace.go
  • cmd/yaah/trace_test.go
  • cmd/yaah/tui2.go
  • docs/configuration.md
  • docs/features.md
  • go.mod
  • internal/agent/loop.go
  • internal/agent/options.go
  • internal/agent/pipeline/config.go
  • internal/agent/pipeline/config_test.go
  • internal/agent/pipeline/pipeline.go
  • internal/agent/pipeline/pipeline_test.go
  • internal/agent/pipeline/trace.go
  • internal/agent/pipeline/trace_test.go
  • internal/agent/runner/runner.go
  • internal/agent/runner/runner_test.go
  • internal/agent/subagent_loop.go
  • internal/agent/types.go
  • internal/config/load.go
  • internal/config/load_test.go
  • internal/prompts/tools/subagent_trace.md
  • internal/tools/subagent_trace.go
  • internal/tools/subagent_trace_test.go
  • internal/tui2/components/infopane/infopane.go
  • internal/tui2/components/sessioninfo/sessioninfo.go
  • internal/tui2/components/sessioninfo/sessioninfo_test.go

Comment thread cmd/yaah/trace.go
Comment on lines +477 to +480
if strings.HasPrefix(traceDir, "~/") {
home, _ := os.UserHomeDir()
traceDir = filepath.Join(home, traceDir[2:])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle the home-directory error during ~/ expansion.

Line 478 discards the error from os.UserHomeDir(). If the lookup fails, home is empty and traceDir becomes a relative path such as .yaah/traces. The command then reports a confusing "no trace store found" message for a path that is relative to the working directory. Return the error instead.

🐛 Proposed fix
 	if strings.HasPrefix(traceDir, "~/") {
-		home, _ := os.UserHomeDir()
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return nil, fmt.Errorf("expand trace dir %q: %w", traceDir, err)
+		}
 		traceDir = filepath.Join(home, traceDir[2:])
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if strings.HasPrefix(traceDir, "~/") {
home, _ := os.UserHomeDir()
traceDir = filepath.Join(home, traceDir[2:])
}
if strings.HasPrefix(traceDir, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("expand trace dir %q: %w", traceDir, err)
}
traceDir = filepath.Join(home, traceDir[2:])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace.go` around lines 477 - 480, Handle the error returned by
os.UserHomeDir in the ~/ expansion block around traceDir: if the lookup fails,
return or propagate that error instead of continuing with an empty home path.
Preserve the existing filepath.Join behavior when the lookup succeeds.

Comment thread cmd/yaah/trace.go
Comment on lines +488 to +502
func captureStatus(fact shepherd.VisibleRecord) string {
rec, ok := fact.(shepherd.Record)
if !ok {
return "pending"
}
success, _ := rec.Body.Payload["success"].(bool)
hasError := rec.Body.Payload["error"] != nil
if hasError {
return "error"
}
if success {
return "ok"
}
return "pending"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report failed tool calls as errors even without an error message.

captureStatus returns "pending" when success is false and no error key exists. The show output then presents a completed but failed tool call as still running. Distinguish a missing capture from a failed capture.

🐛 Proposed fix
 	success, _ := rec.Body.Payload["success"].(bool)
 	hasError := rec.Body.Payload["error"] != nil
 	if hasError {
 		return "error"
 	}
 	if success {
 		return "ok"
 	}
-	return "pending"
+	return "failed"
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func captureStatus(fact shepherd.VisibleRecord) string {
rec, ok := fact.(shepherd.Record)
if !ok {
return "pending"
}
success, _ := rec.Body.Payload["success"].(bool)
hasError := rec.Body.Payload["error"] != nil
if hasError {
return "error"
}
if success {
return "ok"
}
return "pending"
}
func captureStatus(fact shepherd.VisibleRecord) string {
rec, ok := fact.(shepherd.Record)
if !ok {
return "pending"
}
success, _ := rec.Body.Payload["success"].(bool)
hasError := rec.Body.Payload["error"] != nil
if hasError {
return "error"
}
if success {
return "ok"
}
return "failed"
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/trace.go` around lines 488 - 502, Update captureStatus to
distinguish a completed failed capture from a missing capture: when the record
has a capture result with success false and no error message, return "error"
rather than "pending". Preserve "pending" for records without a usable capture
and keep the existing "ok" and explicit-error behavior.

Comment thread docs/configuration.md
Comment on lines +65 to +68
# Execution tracing via Shepherd — records every tool call to a durable,
# inspectable trace store. Active when shepherd_trace is in the pipeline.
shepherd_trace_dir: ~/.yaah/traces # default, optional

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Activate tracing in the documented configuration.

The same example defines an explicit middleware.enabled list at Lines 111-118. That list omits shepherd_trace, so shepherd_trace_dir does not enable tracing for users who copy this configuration.

Add shepherd_trace to the shown enabled list, or remove the trace setting from this explicit-pipeline example.

Proposed fix
   middleware:
     enabled:                          # explicit pipeline order
       - steer
       - followup
       - compaction
       - approval
       - loop_detection
+      - shepherd_trace
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/configuration.md` around lines 65 - 68, Update the documented
configuration’s explicit middleware.enabled list to include shepherd_trace so
the nearby shepherd_trace_dir setting actually activates tracing for copied
configurations; keep the existing middleware entries unchanged.

Comment thread docs/features.md
Comment on lines +221 to 223
| `shepherd_trace` | ✓ | Records every tool call as a durable, inspectable execution trace |
| `sub_agent` | — | Enforces sub-agent depth limits |
| `prompt_caching` | — | Anthropic cache-control breakpoints |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find every place that states the middleware stage counts.
set -euo pipefail

rg -n -C 2 'middleware pipeline|stage middleware|on by default' README.md docs

Repository: buchenberg/yaah

Length of output: 5034


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

features = Path("docs/features.md").read_text()
table = features.split("## Middleware pipeline", 1)[1].split("## Execution traces", 1)[0]
rows = re.findall(r'^\| `([^`]+)` \| ([✓—]) \|', table, re.M)
print("features middleware rows:", len(rows))
print("features enabled rows:", sum(enabled == "✓" for _, enabled in rows))
print("features rows:", rows)

readme = Path("README.md").read_text()
for i, line in enumerate(readme.splitlines(), 1):
    if "middleware" in line and ("11" in line or "8" in line or "pipeline" in line):
        print(f"README:{i}:{line}")
PY

printf '\nMiddleware-related declarations and defaults:\n'
rg -n -C 2 'shepherd_trace|prompt_caching|sub_agent|soft_prune|middleware\.(enabled|disabled)|11 middleware|8 on by default|12 middleware|9 on by default' --glob '*.go' --glob '*.md' .

Repository: buchenberg/yaah

Length of output: 28163


Update all stale middleware counts in README.md.

The pipeline has 12 middleware and 9 enabled by default. Update the three references that still state 11 and 8, including “Middle ground: 11 middleware and counting.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/features.md` around lines 221 - 223, Update all three stale
middleware-count references in README.md to state that the pipeline has 12
middleware and 9 enabled by default, including replacing “Middle ground: 11
middleware and counting.”

Comment thread docs/features.md

Profile output:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language to the fenced code block.

markdownlint reports MD040 for this block. Use text for the sample profile output.

📝 Proposed fix
-```
+```text
 Session:  sess-xxx
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 245-245: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/features.md` at line 245, Update the fenced code block containing the
sample profile output in features.md to specify the text language, using text
after the opening fence so markdownlint MD040 is satisfied.

Source: Linters/SAST tools

Comment on lines +29 to +37
var nextOrdinal atomic.Int64

// NewShepherdTraceMiddleware creates a trace middleware backed by the given store.
func NewShepherdTraceMiddleware(store *shepherd.SQLiteTraceStore, sessionID string) *ShepherdTraceMiddleware {
return &ShepherdTraceMiddleware{
store: store,
sessionID: sessionID,
ordinal: int(nextOrdinal.Add(1 << 20)),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Remove the package global.

nextOrdinal is not an approved global. Use a session-scoped ordinal strategy and enforce unique trace session IDs instead. The session ID already namespaces append intent IDs.

As per coding guidelines, “Do not use globals except the explicitly approved build-time variables, serve-mode state, role registry, and initialized OTel metric instruments.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/pipeline/trace.go` around lines 29 - 37, Remove the
package-level nextOrdinal counter and the ordinal initialization from
NewShepherdTraceMiddleware. Replace it with a session-scoped ordinal strategy,
and enforce uniqueness of sessionID when constructing or registering
ShepherdTraceMiddleware while preserving sessionID as the namespace for append
intent IDs.

Source: Coding guidelines

Comment on lines +107 to +112
if err != nil {
slog.Error("shepherd_trace: capture failed", "err", err)
}

m.lastFactIDs = captureReceipt.FactIDs
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the previous causal parent when capture append fails.

If the capture append fails, captureReceipt.FactIDs can be empty. Line 111 then clears m.lastFactIDs, so the next declaration has no causal parent. Update m.lastFactIDs only after a successful capture append.

Proposed fix
 		if err != nil {
 			slog.Error("shepherd_trace: capture failed", "err", err)
+			continue
 		}
-
 		m.lastFactIDs = captureReceipt.FactIDs
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err != nil {
slog.Error("shepherd_trace: capture failed", "err", err)
}
m.lastFactIDs = captureReceipt.FactIDs
}
if err != nil {
slog.Error("shepherd_trace: capture failed", "err", err)
continue
}
m.lastFactIDs = captureReceipt.FactIDs
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/pipeline/trace.go` around lines 107 - 112, Update the capture
flow around m.lastFactIDs so it assigns captureReceipt.FactIDs only when the
capture append succeeds (err == nil). Preserve the existing m.lastFactIDs value
when capture fails, while retaining the current error logging behavior.

Comment on lines +259 to +264
spec := shepherd.FrontierSpec{
FrontierID: fmt.Sprintf("%s:frontier:%d", m.sessionID, turnNumber),
TargetTraceOwnerID: m.sessionID,
ThroughFactID: m.lastFactIDs[len(m.lastFactIDs)-1],
AppendIntentID: fmt.Sprintf("%s:frontier_intent:%d", m.sessionID, turnNumber),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a monotonic frontier identity.

Loop.runMiddleware resets iter to zero after ContinueAfterMaxIter. Reusing turnNumber here repeats both FrontierID and AppendIntentID within one session. Later frontiers can collide with an earlier execution cycle and leave inspection commands with stale boundaries. Use the monotonic ordinal or a separate session-scoped turn sequence for these IDs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/pipeline/trace.go` around lines 259 - 264, Update the frontier
identity construction in Loop.runMiddleware to use a monotonic session-scoped
ordinal rather than the resettable turnNumber for both FrontierID and
AppendIntentID. Preserve the existing ID formats and ensure each frontier
generated during a session remains unique across ContinueAfterMaxIter cycles.

Comment on lines +74 to +105
func (t *SubagentTraceTool) executeList(store *shepherd.SQLiteTraceStore) (string, error) {
slice, err := store.ReadOwnerPrefix(shepherd.TrustedReadContext, "", 99999, "declarations_only")
if err != nil {
return "", fmt.Errorf("subagent_trace: read store: %w", err)
}

var sb strings.Builder
sb.WriteString("Sub-agent trace sessions:\n")

found := false
for owner, paths := range slice.OwnerPaths {
if !strings.HasPrefix(owner, "sub-") {
continue
}
if !found {
found = true
}
sb.WriteString(fmt.Sprintf(" %s (%d facts)\n", owner, len(paths)))
}
if !found {
sb.WriteString(" (no sub-agent sessions found)\n")
}
return sb.String(), nil
}

func (t *SubagentTraceTool) executeProfile(store *shepherd.SQLiteTraceStore, sessionID string) (string, error) {
slice, err := store.ReadOwnerPrefix(shepherd.TrustedReadContext, sessionID, 99999, "both")
if err != nil {
return "", fmt.Errorf("subagent_trace: read session: %w", err)
}
if len(slice.FactIDs()) == 0 {
return "", fmt.Errorf("subagent_trace: no facts found for session %q", sessionID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Restrict trace access to the current parent session.

executeList reads every trace owner, and executeProfile accepts any session ID. A later agent session can enumerate prior sub-* sessions and return their tool arguments and error text to its model context. This crosses session boundaries and can expose sensitive prompts, paths, commands, or credentials.

Bind SubagentTraceTool to the active parent session. Filter list results and reject profile IDs that do not belong to that parent session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/subagent_trace.go` around lines 74 - 105, Bind
SubagentTraceTool operations to the active parent session: update executeList to
return only the sub-agent sessions owned by that parent, and update
executeProfile to reject sessionID values that are not descendants of or
associated with the bound parent session before reading facts. Add and use the
tool’s parent-session identifier when constructing or invoking it, preserving
existing trace formatting and errors for authorized sessions.

Comment on lines +146 to +155
seq++
total++
cap := captureByParent[factID]
status := "ok"
if cap.errMsg != "" {
status = "error: " + cap.errMsg
}
if status == "ok" {
okCount++
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not report missing or failed captures as successful calls.

The zero-value map entry produces status == "ok" when a declaration has no capture. A capture with success: false and no error text also reports success. This makes the displayed success rate incorrect for incomplete or failed trace records.

Use a presence check for captureByParent. Count a call as successful only when a capture exists and cap.success is true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/subagent_trace.go` around lines 146 - 155, Update the status
and success-count logic in the trace-processing loop around captureByParent to
check whether the factID has a captured entry before using it. Count a call as
successful only when the capture exists and cap.success is true; treat missing
captures and unsuccessful captures without error text as non-successful while
preserving the existing error message for failed captures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant