Skip to content

Fix orphan tool results - #845

Open
ajonkisz wants to merge 23 commits into
masterfrom
goal/fix-orphan-too-72ff4bfa
Open

Fix orphan tool results#845
ajonkisz wants to merge 23 commits into
masterfrom
goal/fix-orphan-too-72ff4bfa

Conversation

@ajonkisz

Copy link
Copy Markdown
Collaborator

Summary

  • Repair persisted orphan tool results during transcript restore while preserving valid tool-call/result pairs.
  • Add an OpenAI Responses/Codex request guard that drops orphan function_call_output items before provider requests.
  • Harden generated guard extension storage with a dedicated read-only sandbox mount.
  • Add focused sanitizer/guard tests, API E2E lifecycle coverage, and documentation.

Validation

  • npm run check
  • npm run test:unit
  • npm run build:server && npm run test:e2e:run -- tests/e2e/orphan-tool-results.spec.ts
  • Focused sanitizer/guard/docker/container translation tests

🤖 Generated with Bobbit

@SuuBro

SuuBro commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Verifiable bug: sanitizeTranscriptContent() does not reset retained tool-call ids at compaction boundaries.

The docs in docs/orphan-tool-result-hardening.md say a compaction boundary creates a fresh retained context, but the implementation only preserves/skips non-message records and leaves seenToolCallIds unchanged. That means this transcript incorrectly preserves the post-compaction tool result:

{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","id":"t1"}]}}
{"type":"compaction","id":"c1"}
{"type":"message","message":{"role":"toolResult","toolCallId":"t1","content":"late result"}}

Expected: toolResult t1 is dropped because the producing assistant call is pre-compaction and no longer in retained context.
Actual: it is kept because t1 remains in seenToolCallIds.

Suggested fix: clear retained tool-call ids when encountering entry.type === "compaction", and add a regression test where the assistant tool call is before the compaction marker and the matching result is after it.

ajonkisz and others added 2 commits June 22, 2026 10:50
A `compaction` entry marks a fresh retained-context boundary — the
assistant turns before it have been summarized away and are no longer in
the retained context rehydrated for the provider. The sanitizer previously
kept `seenToolCallIds` across the compaction marker, so a post-compaction
`toolResult` could incorrectly match a pre-compaction assistant tool call
and be kept as valid history, rehydrating an orphan `function_call_output`
with no matching call.

Treat the compaction marker as a fresh retained-context boundary by
clearing `seenToolCallIds` when one is encountered. Add regression tests:
- assistant tool call before compaction marker + matching toolResult after
  marker is dropped (the PR #845 scenario);
- valid toolCall + toolResult pair both after the marker stays byte-identical.

Preserves all existing behavior and diagnostics.

Addresses PR #845 review comment.

Co-authored-by: bobbit-ai <bobbit@bobbit.ai>
@ajonkisz

Copy link
Copy Markdown
Collaborator Author

Thanks, this was correct. I fixed to treat as a retained-context boundary by clearing tracked tool-call IDs when the marker is encountered.

Added regressions for:

  • assistant tool call before compaction + matching after compaction is dropped;
  • valid toolCall/result pair both after the compaction marker remains byte-identical.

Validation passed:

  • [transcript-sanitizer] un-poisoned 1 blank-text user message(s) in /var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/transcript-sanitizer-agentdir-WEMwvZ/sessions/--cwd--/2026-01-01T00-00-00-000Z_abc.jsonl
    [transcript-sanitizer] Refusing to access path outside agent sessions dir: /var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/transcript-sanitizer-agentdir-WEMwvZ/outside-the-root.jsonl (from /var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/transcript-sanitizer-agentdir-WEMwvZ/outside-the-root.jsonl)
    [transcript-sanitizer] Refusing to access path outside agent sessions dir: /var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/transcript-sanitizer-agentdir-WEMwvZ/sessions/evil-link.jsonl (from /var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/transcript-sanitizer-agentdir-WEMwvZ/sessions/evil-link.jsonl)
    ▶ sanitizeTranscriptContent
    ✔ (a) rewrites image-adjacent blank-text user message (1.121375ms)
    ✔ (b) rewrites standalone blank-text user message (0.135166ms)
    ✔ (c) inserts leading text block when user message has only an image (0.111416ms)
    ✔ (d) rewrites blank string-content user message (0.060667ms)
    ✔ (e) leaves a valid transcript byte-identical (idempotent) (0.0985ms)
    ✔ (f) does NOT touch a blank assistant message (0.0455ms)
    ✔ (g) leaves a valid toolCall + toolResult row pair byte-identical and idempotent (0.116ms)
    ✔ (g') leaves a valid tool_use + toolResult-block user message byte-identical (0.103708ms)
    ✔ (g' pi) leaves a valid pi {toolCallId,toolName} + toolResult row pair byte-identical (0.106458ms)
    ✔ (g'') leaves a valid tool_result + blank-text user message byte-identical (tool result wins) (0.120833ms)
    ✔ drops a toolResult row whose only matching assistant row was aborted (0.108875ms)
    ✔ drops a toolResult row whose only matching assistant row errored (0.096083ms)
    ✔ drops a toolResult row after a compaction boundary with no retained tool call (0.048666ms)
    ✔ drops a post-compaction toolResult whose matching assistant tool call was before the compaction marker (0.077292ms)
    ✔ keeps a valid toolCall + toolResult pair when both are after the compaction marker (0.054625ms)
    ✔ filters orphan user/content tool_result blocks while preserving valid blocks and other content (0.116958ms)
    ✔ rewrites blank/image-only user content after filtering orphan tool_result blocks (0.063583ms)
    ✔ rewrites image-only user content after filtering orphan toolResult blocks (0.048459ms)
    ✔ drops a user/content tool_result-only row when every block is orphaned (0.031208ms)
    ✔ preserves other lines and only rewrites poisoned ones in a multi-line file (0.051917ms)
    ✔ preserves trailing newline shape (0.066375ms)
    ✔ skips non-JSON and non-message lines untouched (0.07075ms)
    ✔ empty input is a no-op (0.022916ms)
    ✔ sanitizeTranscriptContent (3.634792ms)
    ▶ rebaseTranscriptCwdMetadataContent
    ✔ rewrites system init cwd metadata from an old cwd to the new cwd (0.150208ms)
    ✔ rewrites Pi-style session cwd metadata and is idempotent (0.045542ms)
    ✔ rewrites legacy system cwd metadata with no subtype (0.042958ms)
    ✔ leaves unrelated system cwd values unchanged (0.035042ms)
    ✔ leaves user and assistant message content byte-identical even when text mentions old paths (0.05625ms)
    ✔ is idempotent after rebasing cwd metadata (0.046ms)
    ✔ rebaseTranscriptCwdMetadataContent (0.443833ms)
    ▶ transcript write path validation
    ✔ isWithinAgentSessionsDir accepts a path inside the sessions root (0.161292ms)
    ✔ isWithinAgentSessionsDir rejects paths outside the root, traversal, and empty (0.057584ms)
    ✔ sanitizeAgentTranscriptFile rewrites a poisoned file inside the sessions root (1.1445ms)
    ✔ sanitizeAgentTranscriptFile refuses to clobber a file OUTSIDE the sessions root (0.286417ms)
    ✔ sanitizeAgentTranscriptFile rejects a symlink inside the sessions root (no read, no write) (0.300875ms)
    ✔ resolveSafeSessionsPath rejects symlink/out-of-root and accepts a real in-root file (0.312125ms)
    ✔ transcript write path validation (3.316666ms)
    ℹ tests 35
    ℹ suites 3
    ℹ pass 35
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 325.276667

bobbit@0.13.0 check
tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit

bobbit@0.13.0 build:server
tsc -p tsconfig.server.json && shx chmod +x dist/server/cli.js && shx rm -rf dist/server/defaults && node scripts/copy-defaults.mjs && shx rm -rf dist/server/builtin-packs && node scripts/copy-builtin-packs.mjs

Built dist/server/defaults/ from defaults/
Built dist/server/builtin-packs/market-packs/ from market-packs/ (pr-walkthrough, hindsight)

bobbit@0.13.0 test:e2e:run
node scripts/run-playwright-e2e.mjs tests/e2e/orphan-tool-results.spec.ts

[no-new-sleeps] 1 file(s) below baseline — run with --update to ratchet:
ui/review-annotations-persistence.spec.ts: 0 (baseline 1)
[no-new-sleeps] OK — 8 files / 11 sleeps within baseline

Running 2 tests using 1 worker

�[1A�[2K[1/2] [api] › tests/e2e/orphan-tool-results.spec.ts:158:2 › orphan tool-result restore lifecycle › restore repairs persisted orphan tool results, is idempotent, and leaves the session usable
�[1A�[2K[api] › tests/e2e/orphan-tool-results.spec.ts:158:2 › orphan tool-result restore lifecycle › restore repairs persisted orphan tool results, is idempotent, and leaves the session usable
Creating .bobbit/ in /private/var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/bobbit-e2e/.e2e-inproc-37148-0-1782121902600...

�[1A�[2KCreated .bobbit/ in /private/var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/bobbit-e2e/.e2e-inproc-37148-0-1782121902600. Customize roles, workflows, and system prompt in .bobbit/config/

�[1A�[2K[migration] Starting per-project state migration. Default project: "System" (system)

�[1A�[2K[migration] Distributed 0 goals across 0 project(s)

�[1A�[2K[migration] Distributed 0 sessions across 0 project(s)

�[1A�[2K[migration] Distributed 0 tasks

�[1A�[2K[migration] Distributed 0 teams

�[1A�[2K[migration] Distributed 0 gate states

�[1A�[2K[migration] Per-project state migration complete. Marker written.

�[1A�[2K[trigger-engine] Started (60s poll interval)

�[1A�[2K[aigw-manager] Wrote models.json to /private/var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/bobbit-e2e/.e2e-inproc-37148-0-1782121902600/agent/models.json

�[1A�[2K[aigw-manager] Wrote 48 contextWindow overrides to models.json

�[1A�[2K[team-manager] Re-subscribed to events for 0 team(s)

�[1A�[2K[boot] sweeper start (0 projects)

�[1A�[2K[boot] sweeper done in 10ms (reclaimed=0 cleaned=0 repaired=0)

�[1A�[2K[boot] background tasks complete in 10ms

�[1A�[2K[session-manager] Restoring 1 session(s) + 0 delegate(s) live...

�[1A�[2K[transcript-sanitizer] dropped 1 orphan tool result row(s) in /private/var/folders/qb/7js4lg914yqglb4_y8ctkz5r0000gn/T/bobbit-e2e/.e2e-inproc-37148-0-1782121902600/agent/sessions/-private-var-folders-qb-7js4lg914yqglb4-y8ctkz5r0000gn-T-bob/2026-06-22T09-51-44-369Z_ef2118c6-8ebd-4a7b-8c4b-48fbc5e96072.jsonl

�[1A�[2K[session-manager] Restoring 1 session(s) + 0 delegate(s) live...

�[1A�[2K[2/2] [api] › tests/e2e/orphan-tool-results.spec.ts:206:2 › orphan tool-result restore lifecycle › generated OpenAI Responses guard hook drops only orphan function_call_output items

�[1A�[2K[trigger-engine] Stopped

�[1A�[2K[sandbox-manager] All 0 sandbox(es) shut down

�[1A�[2K 2 passed (2.7s)

@ajonkisz

Copy link
Copy Markdown
Collaborator Author

Correction to my previous reply (shell quoting mangled the Markdown): the PR comment was correct.

Fixed in 033a4f71:

  • sanitizeTranscriptContent() now treats entry.type === "compaction" as a retained-context boundary and clears tracked tool-call IDs.
  • A toolResult after compaction can no longer match an assistant tool call from before the compaction marker.

Added regressions:

  • assistant tool call before compaction + matching role:"toolResult" after compaction is dropped;
  • valid toolCall/result pair both after the compaction marker remains byte-identical.

Validation passed:

  • npx tsx --test --test-force-exit tests/transcript-sanitizer.test.ts
  • npm run check
  • npm run build:server && npm run test:e2e:run -- tests/e2e/orphan-tool-results.spec.ts

function toolCallIdFromAssistantBlock(block: unknown): string | null {
if (!block || typeof block !== "object") return null;
const b = block as any;
if (b.type === "toolCall" || b.type === "tool_use") return stringField(b.id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This extractor is narrower than the tolerant transcript shapes used elsewhere. For type: "toolCall" / "tool_use" it returns only b.id, so a valid block shaped like { type: "toolCall", toolCallId: "t1", ... } will not register as a producer and the matching role: "toolResult" row is dropped as orphaned. Existing tolerant code in src/server/extension-host/action-guard.ts uses b.id ?? b.toolCallId ?? b.tool_use_id; this should do the same before deciding the result is orphaned.

// the retained tool-call ids so a post-compaction `toolResult` cannot
// incorrectly match a pre-compaction assistant tool call (which would
// rehydrate an orphan `function_call_output` with no matching call).
if (entry?.type === "compaction") seenToolCallIds.clear();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clearing every seen tool-call id at a compaction marker can drop valid retained history. pi compaction records firstKeptEntryId and rehydrates the summary plus messages from firstKeptEntryId through the compaction entry, then messages after it. If an assistant tool call before the compaction marker is still in that retained range, a later toolResult for it is still valid, but this reset makes the sanitizer drop it as orphaned. The sanitizer needs to model the retained range from the compaction entry, or otherwise avoid clearing IDs for retained pre-compaction calls.

ajonkisz and others added 4 commits June 22, 2026 11:27
Address PR #845 review comments (two parts):

1. toolCallIdFromAssistantBlock() now tolerantly resolves the id of typed
   toolCall/tool_use blocks via id ?? toolCallId ?? tool_use_id, matching
   src/server/extension-host/action-guard.ts. Previously it only accepted id
   for typed blocks, so a block carrying toolCallId or tool_use_id instead
   of id was skipped and its matching toolResult wrongly dropped as orphaned.
   Add regressions for typed blocks using toolCallId/tool_use_id.

2. Model retained ranges at compaction boundaries instead of blindly clearing
   seen tool-call ids. When a compaction entry carries a resolvable
   firstKeptEntryId (matches a kept assistant entry id seen before the marker),
   drop only the ids registered strictly before that kept entry; keep those
   at/after it. Fall back to clearing at the marker when firstKeptEntryId is
   absent or unresolvable. Add tests proving a kept-range assistant tool call
   before the marker validates a later result, while a call before
   firstKeptEntryId cannot.

Co-authored-by: bobbit-ai <bobbit@bobbit.ai>
Co-authored-by: bobbit-ai <bobbit@bobbit.ai>
Co-authored-by: bobbit-ai <bobbit@bobbit.ai>
@ajonkisz

Copy link
Copy Markdown
Collaborator Author

Addressed the two latest sanitizer review comments in 71fae0c1.

What changed:

  • toolCallIdFromAssistantBlock() now resolves typed toolCall / tool_use blocks with id ?? toolCallId ?? tool_use_id, matching the tolerant extension-host handling.
  • Compaction retained-range pruning now resolves firstKeptEntryId against all parsed JSONL entry ids, not just tracked tool-call entries.
    • If firstKeptEntryId resolves, only tool-call ids registered before that retained boundary are pruned.
    • If it is absent or unresolved, the sanitizer falls back to clearing at the compaction marker.
  • Updated the hardening doc to describe exact firstKeptEntryId handling vs marker fallback.

Added regressions for:

  • typed toolCall with toolCallId and typed tool_use with tool_use_id preserving matching results;
  • pre-marker kept-range tool calls preserving later results when firstKeptEntryId resolves;
  • pre-firstKeptEntryId summarized calls dropping later results;
  • absent/unresolved firstKeptEntryId fallback clearing at the marker;
  • firstKeptEntryId pointing at a non-tool/user row while a later retained tool call remains valid.

Validation passed:

  • npx tsx --test --test-force-exit tests/transcript-sanitizer.test.ts
  • npm run check
  • npm run build:server && npm run test:e2e:run -- tests/e2e/orphan-tool-results.spec.ts

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.

2 participants