Skip to content

feat(agent): per-turn attribution columns and the capture policy for persisted tool I/O - #2071

Draft
GabrielDrapor wants to merge 24 commits into
feat/agent-trace-executions-prfrom
feat/agent-trace-attribution-pr
Draft

feat(agent): per-turn attribution columns and the capture policy for persisted tool I/O#2071
GabrielDrapor wants to merge 24 commits into
feat/agent-trace-executions-prfrom
feat/agent-trace-attribution-pr

Conversation

@GabrielDrapor

Copy link
Copy Markdown
Collaborator

Two changes that both write to Postgres and are therefore independent of any trace flag.

Attribution. ai_messages gains turn_kind (rca_execution | rca_followup | chat | detector | digest, set per turn at write time), execution_id (FK to detector_rca_executions) and initiator_user_id; ai_sessions gains execution_id. Today all of this is inferred from ai_sessions.user_id IS NULL. The migration fills turn_kind deterministically from the existing kind column — a column rename in effect, not a backfill — so metering never needs a dual read path.

Capture policy for persisted tool I/O, one module applied identically to span attributes and tool_step metadata:

  • args captured for every tool, after redaction (ghp_/gho_, sk-, AKIA, bearer tokens, .env-style assignments)
  • output captured only for an allowlist — download_traces, download_session, submit_result, i.e. data the customer already owns in TraceRoot. bash, read, write, git_clone, check_github_access record exit code, byte count and duration only
  • redact, then truncate — truncating first can split a token and defeat the pattern
  • budgets: 8 KB per step, 256 KB per run; over budget, later steps degrade to size-only

Lands before any emit, because the step rows write to Postgres immediately and are not behind the flag.

Closes #2062. Part of #2058.

@GabrielDrapor
GabrielDrapor requested a review from a team as a code owner August 31, 2026 13:53
Comment thread frontend/ee/agent/src/index.ts Outdated
@trident-sentinel

trident-sentinel Bot commented Aug 31, 2026

Copy link
Copy Markdown

PR overview

This pull request adds per-turn attribution, durable streaming message persistence, shared tool-I/O redaction, and per-session chat stream isolation.

One security concern remains open: an authenticated user can cause multi-megabyte tool arguments to bypass the capture budgets and consume Postgres storage and write capacity. Exploitation requires an authenticated project user and is limited to resource exhaustion.

Open issues (1)

Scanned with Semgrep · TruffleHog · Trident review. View in Trident

Fixed/addressed: 1 · PR risk: 4/10

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 12 files

Confidence score: 3/5

  • In frontend/ee/agent/src/stream-persister.ts, non-allowlisted tool rows omit exit code and duration while retaining only byte count and isError, which can make execution outcomes and performance unavailable for bash, read, and writ...; pass status and timing through capture and persist those fields.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="frontend/ee/agent/src/stream-persister.ts">

<violation number="1" location="frontend/ee/agent/src/stream-persister.ts:66">
P2: For non-allowlisted tools, these rows record only byte count and `isError`; they omit exit code and duration. Pass execution status and timing through the capture path and persist those fields so `bash`, `read`, `write`, `git_clone`, and `check_github_access` retain the required size-only diagnostics.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Client
    participant API as Agent API (index.ts)
    participant SM as SessionManager
    participant CP as CapturePolicy
    participant DB as Postgres (Prisma)
    participant Worker as Worker Processors
    participant Trace as TraceRoot SDK

    Note over Client,DB: Per-Turn Attribution Flow (NEW)
    
    Client->>API: POST /sessions (with optional executionId)
    API->>DB: createSession(executionId)
    DB-->>API: Session created
    
    Client->>API: POST /messages (prompt)
    API->>DB: Find session (with executionId)
    DB-->>API: Session
    
    alt Session.userId === null
        alt Request has x-user-id
            Note over API: Attribution: rca_followup
        else
            Note over API: Attribution: rca_execution
        end
    else
        Note over API: Attribution: chat
    end
    
    API->>SM: appendMessage("user", prompt, attribution)
    SM->>DB: Create AIMessage with turnKind, executionId, initiatorUserId
    SM->>SM: deriveAttribution() fallback
    DB-->>SM: Message persisted
    SM-->>API: Message record
    
    Note over API,DB: Agent Run + Tool Capture
    
    API->>API: Create StreamPersister with attribution
    
    loop Agent events
        API->>API: Process tool_execution_start
        API->>API: Store args in pendingToolArgs
        API->>API: Process tool_execution_end
        API->>API: applyCapturePolicy(toolName, args, result)
        API->>CP: Redact args (deep)
        API->>CP: Check output allowlist
        
        alt Tool in OUTPUT_ALLOWLIST
            CP->>CP: Redact result, truncate to 8KB
            CP-->>API: Keep result + outputBytes
        else Tool not allowlisted
            CP-->>API: Withhold result, keep outputBytes only
        end
        
        API->>SM: appendMessage("tool_step", metadata, attribution)
        SM->>DB: Insert tool_step row with captured I/O
    end
    
    API->>SM: appendMessage("assistant", response, attribution)
    SM->>DB: Insert assistant message
    
    Note over Worker,DB: Detector/Digest Attribution (CHANGED)
    
    Worker->>DB: Insert detector messages with turnKind="detector"
    Worker->>DB: Insert digest messages with turnKind="digest"
    
    Note over DB: Migration Backfill
    
    DB->>DB: Add turn_kind columns
    DB->>DB: Backfill turn_kind from legacy kind column
    DB->>DB: Set initiator_user_id from session
    DB->>DB: Add indexes for turnKind queries
Loading

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread frontend/ee/agent/src/index.ts Outdated
Comment thread frontend/packages/core/src/lib/capture-policy.ts Outdated
Comment thread frontend/packages/core/src/lib/capture-policy.ts Outdated
result: event.result,
args: captured.args,
...(captured.result !== undefined ? { result: captured.result } : {}),
outputBytes: captured.outputBytes,

@cubic-dev-ai cubic-dev-ai Bot Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: For non-allowlisted tools, these rows record only byte count and isError; they omit exit code and duration. Pass execution status and timing through the capture path and persist those fields so bash, read, write, git_clone, and check_github_access retain the required size-only diagnostics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/ee/agent/src/stream-persister.ts, line 66:

<comment>For non-allowlisted tools, these rows record only byte count and `isError`; they omit exit code and duration. Pass execution status and timing through the capture path and persist those fields so `bash`, `read`, `write`, `git_clone`, and `check_github_access` retain the required size-only diagnostics.</comment>

<file context>
@@ -50,11 +54,18 @@ export class StreamPersister {
-        result: event.result,
+        args: captured.args,
+        ...(captured.result !== undefined ? { result: captured.result } : {}),
+        outputBytes: captured.outputBytes,
+        ...(captured.truncated ? { truncated: true } : {}),
+        ...(captured.withheld ? { withheld: captured.withheld } : {}),
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Half right, and the half that holds is fixed: duration is now recorded for every step, which matters most exactly where output is withheld — it and the byte count are all that survives. Exit code is not available: pi's tool_execution_end carries isError and the result, no status field, so there is nothing to pass through. Surfacing it would need the same SDK-side channel tracked for the tool span id (traceroot-ai/traceroot-ts#150).

Comment thread frontend/ee/agent/src/index.ts Outdated
Comment thread frontend/packages/core/src/lib/capture-policy.ts Outdated
Comment thread frontend/packages/core/src/lib/capture-policy.ts Outdated
Comment thread frontend/packages/core/src/index.ts Outdated
@GabrielDrapor
GabrielDrapor force-pushed the feat/agent-trace-attribution-pr branch from e31e851 to 46ce6f1 Compare August 31, 2026 14:16
@GabrielDrapor
GabrielDrapor marked this pull request as draft August 31, 2026 14:17
@GabrielDrapor

Copy link
Copy Markdown
Collaborator Author

⚠️ This PR currently carries #1960 / #1961's commits, and should not be reviewed or merged until they land.

The stack was restructured so agent self-trace does not share a stack with the chat-session work. #2067#2070 are now independent and based on main. This PR cannot be: the attribution work modifies the exact code in frontend/ee/agent/src/index.ts that #1961's stream persister introduces — attribution is applied to the rows that persister writes, so without it there is nothing to attribute. That is a real functional dependency, not a patch-context one (verified by cherry-picking onto main without #1961: conflict in index.ts).

So the 10 commits from #1960 and #1961 ride along here for now, and the only commits belonging to this PR are the last two:

  • feat(agent): per-turn attribution columns on ai_messages
  • feat(agent): capture policy for persisted tool I/O

Once #1961 merges to main, this branch gets rebased and those 10 commits disappear from the diff. Marked draft until then. The same applies to everything above it in the stack (#2072#2074).

Comment on lines +71 to +82
const args = redactDeep(input.args);
const raw = toText(input.result);
const outputBytes = Buffer.byteLength(raw, "utf8");
if (!OUTPUT_ALLOWLIST.has(input.toolName)) {
return { args, outputBytes, truncated: false, withheld: "not-allowlisted" };
}
if (state.spentBytes >= budget.perRunBytes) {
return { args, outputBytes, truncated: false, withheld: "budget" };
}
const { text, truncated } = truncateTo(redactSecrets(raw), budget.perStepBytes);
state.spentBytes += Buffer.byteLength(text, "utf8");
return { args, result: text, outputBytes, truncated, withheld: null };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: Unbounded tool arguments

applyCapturePolicy redacts input.args but applies perStepBytes and perRunBytes only to the serialized tool result, while StreamPersister writes captured.args directly into tool_step metadata at line 64. An authenticated project user can send a message to /api/v1/projects/p/sessions/s/messages that causes a tool call with a multi-megabyte argument such as { "command": "A".repeat(10000000) }, and the argument is persisted even after the run's 256 KB budget is exhausted. This lets the attacker consume excessive Postgres row storage and write capacity through oversized tool-I/O records. Bound the redacted serialized arguments and include their bytes in the per-step and per-run budgets before persisting them.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same finding as the P2 on line 71 and fixed with it: args are bounded and charged to the step/run budget rather than written through unbounded. Thanks for pinning the exact write path in StreamPersister — that is what made the omission obvious.

@GabrielDrapor
GabrielDrapor force-pushed the feat/agent-trace-attribution-pr branch from 46ce6f1 to d9ed5e7 Compare August 31, 2026 14:37
@GabrielDrapor
GabrielDrapor force-pushed the feat/agent-trace-attribution-pr branch from d9ed5e7 to b4c9f8c Compare August 31, 2026 14:40
@GabrielDrapor
GabrielDrapor force-pushed the feat/agent-trace-attribution-pr branch 2 times, most recently from 6fdda5b to a896331 Compare September 1, 2026 01:44

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 10 files (changes from recent commits).

Confidence score: 3/5

  • frontend/packages/core/src/lib/capture-policy.ts: capArgs resets the 8 KB limit for each argument leaf, so a step with multiple leaves can exceed the intended per-step capture ceiling and produce oversized output; track one remaining byte budget across all arguments before applying the output limit.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="frontend/packages/core/src/lib/capture-policy.ts">

<violation number="1" location="frontend/packages/core/src/lib/capture-policy.ts:125">
P2: A step with multiple argument leaves can exceed the 8 KB per-step capture ceiling because `capArgs` resets the limit for every leaf, then the output gets a separate limit. Track one per-step remaining byte budget across the complete persisted args and result, including structural JSON bytes, before updating the run total.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread frontend/ee/agent/src/index.ts Outdated
if (typeof value === "string") {
const remaining = budget.perRunBytes - state.spentBytes;
if (remaining <= 0) return "[withheld: budget]";
const { text } = truncateTo(value, Math.min(budget.perStepBytes, remaining));

@cubic-dev-ai cubic-dev-ai Bot Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: A step with multiple argument leaves can exceed the 8 KB per-step capture ceiling because capArgs resets the limit for every leaf, then the output gets a separate limit. Track one per-step remaining byte budget across the complete persisted args and result, including structural JSON bytes, before updating the run total.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/packages/core/src/lib/capture-policy.ts, line 125:

<comment>A step with multiple argument leaves can exceed the 8 KB per-step capture ceiling because `capArgs` resets the limit for every leaf, then the output gets a separate limit. Track one per-step remaining byte budget across the complete persisted args and result, including structural JSON bytes, before updating the run total.</comment>

<file context>
@@ -68,16 +89,48 @@ export function applyCapturePolicy(
+    if (typeof value === "string") {
+      const remaining = budget.perRunBytes - state.spentBytes;
+      if (remaining <= 0) return "[withheld: budget]";
+      const { text } = truncateTo(value, Math.min(budget.perStepBytes, remaining));
+      state.spentBytes += Buffer.byteLength(text, "utf8");
+      return text;
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — the per-leaf reset let an args object with N string fields keep N × perStepBytes, and the result then got its own allowance on top. One remaining-byte budget is now shared across every leaf and the result. Writing the test found a related off-by-three: with less room left than the truncation marker itself, truncateTo still appended it and went over; it keeps nothing in that case now.

Comment thread frontend/ee/agent/src/stream-persister.ts Outdated
ka1kqi and others added 4 commits September 2, 2026 19:07
The streaming hook held a single messages array and one reader for the
whole chat, so an in-flight SSE run kept writing into whatever session
the user switched to, and returning to the streaming session showed it
frozen (the DB only gets the assistant row when the run completes).

Rework useAIStream around per-session state: each run writes to its own
session's message bucket, streaming flags are per session, and runs are
cancelled per session (send-again, delete) or all at once (close panel,
project switch, unmount). useAiChat renders only the active session's
bucket, skips the DB history load for sessions that are still streaming
so their live progress stays visible, and no longer aborts anything on
new-session or session-switch — background runs keep accumulating and
can be watched live by switching back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two sends racing before the first POST /sessions resolved each saw a null
active-session ref and created separate sessions. ensureSession now parks
the in-flight creation promise in a ref and hands it to every caller until
it settles, so concurrent sends land in one session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
The shared pending-creation promise outlived a project switch: a send in
the new project could adopt a session created for the old one. The
project-switch reset now aborts in-flight creations and drops the pending
promise, and a settling creation only clears the ref when it still owns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
A send whose session creation was still in flight could clobber a
session boundary the user crossed in the meantime: clicking New Session
left the pending creation in pendingSessionRef (so the next send reused
the old session instead of opening a fresh one), and the send's post-await
commit restored the old session as active, undoing the New Session click,
a history selection, or an externally opened RCA session.

Every boundary (New Session, history selection, an initialSessionId
arriving, close, project switch) now bumps a generation ref; a send only
commits its session as active when no boundary was crossed while its
creation was in flight. The message itself still delivers to the session
it was created for, which stays reachable via history — matching how
background streams already behave. New Session also drops the shared
pending creation (without aborting it) and syncs the active-session ref
so a send in the same tick can't reuse the previous session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
ka1kqi and others added 20 commits September 2, 2026 19:07
The session-boundary epoch stopped a late-resolving send from re-activating
its session, but the message itself still dispatched — so a run could start
in a panel the user had closed or a project they had left. Hard boundaries
(close, project switch) now bump their own generation, and a send that
crossed one returns before dispatch. Soft boundaries (New Session, history
selection) keep the existing behavior: the send continues in the background
and stays reachable via history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
handleClose aborted the in-flight creation but left pendingSessionRef to
the promise's own settle-time self-clear. When the response is already on
the wire and the abort settles late, a send after reopening rode the
pre-close creation — landing the new conversation in a session built with
the closed conversation's trace context. Clear the ref at the boundary,
matching handleNewSession; the self-clear's identity check makes the late
settle harmless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
…sy state

isSending was a single boolean shared by every send, so a pre-close send
settling late cleared it while a post-reopen send was still waiting on its
session creation — briefly showing the empty greeting and dropping the
stop button. Track a count with functional updaters instead; the busy state
holds until every in-flight send has wound down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
…vives reload

Tool-call bubbles existed only as SSE events: the agent service forwarded
them to the client and persisted a single assistant row (with thinking
deltas silently concatenated in) when the run finished, so reloading a
session dropped every tool step and collapsed interleaved text.

Add a StreamPersister that mirrors the run into durable rows: assistant
text flushes as a segment at each tool boundary (final segment carries
the token usage), each tool call becomes a tool_step row whose metadata
holds args/result/isError, and thinking goes to segment metadata instead
of content. Inserts are chained so the synchronous event callback cannot
interleave them, and a failed run still persists what it produced.

History loaders map the persisted rows back into the exact live bubble
shapes — tool bubbles, thinking, and the token/cost footer — via a
shared mapDbMessages util. buildContext now also restores assistant rows
as plain text turns; previously the model lost its own answers on any
agent rebuild (service restart or mid-session model switch). Tool steps
stay UI-only so the agent re-invokes tools instead of replaying stale
results.

Billing counted raw assistant rows as chat runs, which would have billed
every tool boundary as an extra run once segments landed; the run count
and the overage cutoff now count only usage-carrying rows (the final
segment — exactly one per run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…store

Review follow-ups on the tool-step persistence work:

- A run that ends at a tool boundary (no trailing text) previously dropped
  its token usage entirely, so it escaped run counting and billed cost.
  StreamPersister.finish now persists a content-less usage-carrying
  assistant row; buildContext skips such rows and the history mapper folds
  their usage into the previous assistant bubble, mirroring the live UI.
- The error path now passes the accumulated usage to finish, so tokens a
  run consumed before failing still count toward the meters.
- A failing pricing lookup no longer rejects toTokenUsage (which left the
  SSE response hanging); it falls back to $0 with the tokens kept.
- Persisted cost is a Prisma Decimal (a string over JSON); the history
  mapper coerces it to a number so the usage footer doesn't throw.
- The cumulative session token total now survives reload via the final
  segment's metadata.
- Test hygiene: the persister serialization test drains microtasks instead
  of sleeping, and buildContext's empty-session branch is actually covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
The history mapper folded a content-less usage row into the nearest
assistant bubble anywhere earlier in the transcript, so a tool-only run
following a normal answer overwrote that answer's usage. The fold now
stops at the preceding user turn; a run with no text bubble of its own
keeps the carrier as its own row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
…execution_id, initiator_user_id)

DB unreachable in this worktree (no .env) — migration.sql was hand-written to
match the plan verbatim and validated only via `prisma generate`/`tsc`, not
applied; appendMessage's return type was widened to return the created row
(Promise<AIMessage> in place of Promise<void>) a release early, since Task 13
needs the row id for turnTraceId and the wider return type is a superset any
existing caller can ignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
…, truncate, budget)

The redactSecrets gh*_ pattern keeps the plan's documented fix ($1 capture
group preserving the prefix) instead of the initial pattern + normalizer,
since the normalizer is a no-op once the prefix is preserved on match.
Updated the pre-existing "records tool args..." persister test to use
download_traces (allowlisted) instead of get_traces so its result assertion
still holds under the new policy — get_traces is not allowlisted and would
now have its result withheld.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
…s project

Six review findings on this PR, all real.

**Redaction missed the most common shapes.** The assignment pattern required a
3+ uppercase prefix, so bare `TOKEN=`, `PASSWORD=` and lowercase `api_key=` —
what a .env file or a printed environment actually looks like — were persisted
verbatim; `Bearer` was case-sensitive, so an echoed lowercase header survived.
Both are now case-insensitive, with the name matched on a word boundary so
`monkey=` and `token_count=` stay readable. A test pins each shape; it caught an
over-broad first attempt that redacted `monkey=business`.

**Budgets governed only half of what a step writes.** Args are captured for
every tool and a `write` call carries its file body in them, but only the result
was bounded. Args are now capped and charged per string leaf (truncating the
serialised JSON would leave unparseable metadata), and a step near the run limit
takes only the remaining budget instead of a full step on top of it. The
truncation marker is reserved rather than appended after the cut — otherwise
every truncated capture sat a few bytes over the cap, which a test caught.

**Steps recorded no timing.** For a non-allowlisted tool, "what ran and how much
came back" is all that survives; a duration makes that useful. Recorded from the
start event for every step.

**executionId was taken on trust.** It becomes the attribution on every message
in the session, so an id from another project would attribute this project's
turns to that one. Validated against the project before storing, with the check
extracted so it is testable.

**capture-policy was on the client barrel.** Same issue as rca-executions: it
uses the Node-only Buffer global and `@traceroot/core` is imported by client
components. Moved to a subpath export, and the barrel test now covers both.

Co-Authored-By: Claude <noreply@anthropic.com>
A truthiness guard skips validation for `executionId: ""` and then stores the
empty string, so the session's first message fails the foreign key rather than
the route returning the documented 400. The value is normalised to undefined
before the check, so an empty or non-string id is simply absent.

Co-Authored-By: Claude <noreply@anthropic.com>
…clock

Two follow-up review findings on the capture policy.

**The per-step cap reset for every args leaf.** An args object with five string
fields could keep five times perStepBytes, and the result then got its own
allowance on top. One remaining-byte budget is now shared by every leaf and the
result, so the step ceiling means what it says. Writing the test surfaced a
related off-by-three: with less room left than the truncation marker itself,
truncateTo still appended it and pushed the capture over the allowance; it now
keeps nothing in that case.

**Duration used a wall clock.** Date.now() steps with NTP, DST and manual
changes, so a step's persisted durationMs could come out wrong or negative.
Measured with performance.now() instead.

Co-Authored-By: Claude <noreply@anthropic.com>
… of a hardcoded list

New subpath exports (rca-executions, capture-policy) were dropped by the image
build's rewrite, so the worker/agent images could not resolve them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
Args of every tool are persisted, including `write` file bodies and `bash`
command lines, and allowlisted results are JSON-stringified before redaction.
The patterns missed the shapes those actually arrive in:

- quoted assignment values (`PASSWORD="…"`, `export DB_PASSWORD='…'`) — the
  value class stopped at the opening quote;
- the colon form (`"password":"…"`, `api_key: …`, `x-api-key: …`) — the
  dominant shape for span attributes in download_traces output;
- Stripe keys (`sk_live_…` / `sk_test_…`) — the pattern required `sk-`;
- the password segment of a `scheme://user:pass@host` URL;
- PEM private-key blocks, with or without their footer.

The known false positives of the name pattern (`sort_key=`, `primary_key=`,
`?key=`) are documented rather than special-cased: tool_step rows are display
only and never fed back to the model.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…ags consistent

truncateTo cut the byte buffer mid-codepoint, so the decoder emitted a 3-byte
U+FFFD for the fragment and both the returned text and the bytes charged to the
run could exceed the budget the policy exists to enforce. Back the cut off to a
codepoint boundary; the multibyte test asserts the byte length of the result and
spentBytes against the budgets directly.

Two flag inconsistencies while here: args that used up the step allowance left
the result as `result: ""` (an empty string that reads as real output) where a
spent run budget yields `withheld: "budget"` — both now withhold; and
`truncated` only reflected the result, so a cut `bash` command line reported
false. capArgs reports whether any leaf was cut and it is OR-ed into the flag.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…icy surface

redactDeep and capArgs walked the same args tree twice; redaction now happens
in capArgs's string branch, immediately before the cut, so "redact before
truncate" is one line instead of two functions. DEFAULT_CAPTURE_BUDGET and
OUTPUT_ALLOWLIST had no consumer outside the module anywhere in the stack and
are no longer exported. The dangling "Capture policy" comment on the core
barrel is folded into the existing NOTE about subpath-only modules.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…Attribution

Both appendMessage callers pass the turn's attribution, so the fallback that
derived one from the session (and its per-row aIMessage.count query and the
extra userId/executionId select) never ran — and it encoded a different rule
(prior-assistant count) from the one the route uses (x-user-id presence). Dead
code with a second, contradictory rule is what a later contributor "fixes" into
use. Delete it and make attribution required; it moves ahead of the optional
metadata/tokenUsage, so the signature is now

  appendMessage(role, content, attribution, metadata?, tokenUsage?)

The persister's AppendMessageFn loses the `attribution?` parameter it never
passed (the route binds attribution into the closure). The hand-written
TurnKind union is replaced by Prisma's generated enum type, re-exported from
the core barrel like the other Prisma types. The "later turn → rca_followup"
test only echoed its own input and is replaced by tests of what appendMessage
actually does: the columns it writes, the legacy `kind` per turnKind, and the
missing-session error.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
… drop durationMs

The persister owned its own `{ spentBytes }` while the SDK's captureToolIo
hook (next PR) charges the run's accumulator — two budgets that agree only
while both see the identical event sequence, and that double the effective
cap the moment they diverge. StreamPersister takes an optional `state` in a
constructor options object so the route can hand it the run's accumulator;
omitted, it still allocates its own. The capture-policy docstring no longer
claims the two stores hold "exactly the same content" — that depends on the
shared state, which the docstring now says.

durationMs and the pendingToolStart map go: nothing in the stack reads them,
and once tool spans carry start/end the span is the timing source.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
A non-string executionId (a number, an object, null) was coerced to undefined
and the session created unattributed — a worker bug sending `{executionId: 123}`
would have produced unattributed executions with no error. Any executionId
present in the body must now be a non-empty string, or the route returns 400;
"" and whitespace take the same path instead of being silently dropped.

No route in ee/agent had a test, which left the executionId normalisation, the
400 branches and the per-turn attribution ternary (x-user-id present → chat /
rca_followup, absent → rca_execution) uncovered. routes.test.ts drives the Hono
app through app.request() with the service's start-up stubbed, and runs the
real StreamPersister and capture policy under the route's own closure to lock
the rule that every row a turn produces — user, text, thinking-only, tool_step,
usage carrier, and the rows persisted on the error path — carries the same
attribution.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…ion_id a foreign key

ai_messages.execution_id had an ON DELETE SET NULL foreign key but no index, so
deleting an execution (cascaded from a finding delete) scanned the table to
null it — and the agent-trace viewer reads a finding's turns by execution.
ai_sessions.execution_id had neither, so it could dangle after an execution
delete. Both now carry the FK (SET NULL / NO ACTION, matching the execution
table's own relations) and an index. The two session↔execution relations are
named so Prisma can tell the execution that opened a session apart from the
session an execution records once it completes.

The speculative ix_ai_message_workspace_turnkind_time is dropped: metering
still groups by `kind`, and nothing in the stack queries by turnKind yet. The
unmerged migration is edited in place.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
@GabrielDrapor
GabrielDrapor force-pushed the feat/agent-trace-attribution-pr branch from 6845175 to 48b930c Compare September 2, 2026 11:07
GabrielDrapor added a commit that referenced this pull request Sep 2, 2026
Rebased onto the fixed attribution tip. #2070 dropped advanceLatest,
setExecutionTraceStatus, detector_rcas.latest_execution_id and
detector_rca_executions.result; the worker now finishes a finding through
finishFindingIfLatest (one guarded write for done and failed alike), writes
the execution row's traceStatus/sessionId/finishedAt in one update, and gates
the pre-run "running" flip on the attempt still being the highest
(markFindingRunningIfLatest). This PR's own failFindingIfLatest is gone — it
duplicated the new helper. runRcaSession's execution identity is required;
a stream that carried output but no `trace` frame records `failed`.

#2071 made attribution a required appendMessage argument and let the
persister charge an external capture budget: the route now builds the
persister inside the traced run and hands it the run's own accumulator, so
spans and rows share one budget. The trace kind is derived from the turn's
attribution, and the parent-execution lookup for a follow-up only runs when
that follow-up will be traced and can no longer fail the turn.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
GabrielDrapor added a commit that referenced this pull request Sep 2, 2026
Since #2071 the persister stores a tool step's result as text (serialised,
then redacted and truncated) and records why a result was not kept
(`withheld`), whether the capture was cut (`truncated`) and the real output
size (`outputBytes`). The sidebar still JSON.stringified the metadata value,
so a reloaded step showed a one-line escaped string where the live stream
had shown a pretty-printed object, and a withheld `bash` result reloaded as a
bubble with no output and no explanation.

Parse an intact JSON result back into the value the live stream rendered (a
truncated one cannot be valid JSON, a plain-string result never was; both
stay text), and render the capture verdict:
  [output withheld: not-allowlisted · 44 bytes]
  [captured I/O truncated · 90,000 bytes of output]

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
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.

feat(agent): per-turn attribution columns and the capture policy for persisted tool I/O

2 participants