Live Stream: derive Anchor-owned file artifacts#6207
Conversation
|
| Filename | Overview |
|---|---|
| api/artifact_references.py | New module providing derivation, sanitization, ownership validation, and budget-capping for workspace-file artifact references; well-bounded with path safety, traversal rejection, and circular-reference protection in the custom JSON size estimator. |
| api/streaming.py | Adds _anchor_artifact_events accumulation, emit of artifact_reference SSE events after proven mutations, and terminal-state reconciliation into the anchor scene; _reconcile_stream_artifacts_into_terminal_anchor_scene omits owner_authority: server but this is self-healing on the browser's first settlement call. |
| api/routes.py | Extends _handle_session_anchor_scene to merge existing and incoming scenes with ownership validation, sets owner_authority: server on records with trusted identity, and wires _anchor_run_id into hydration; logic is correct and well-tested. |
| static/messages.js | Adds artifact_reference SSE event listener (render-skipped), scene artifact bounding before persistence, and artifact hydration in _hydrateAnchorRegistryFromActivityScene; bounding/dedup helpers are near-identical to those added in assistant_turn_anchors.js. |
| static/assistant_turn_anchors.js | Replaces unbounded anchor.artifacts.push() with a bounded append helper and applies the same budget cap on scene export; dedup key and bounding logic is duplicated from messages.js. |
| static/sessions.js | Widens hasAnchorActivityScene to treat artifact-only or side-effect-only scenes as valid, ensuring artifact-only scenes trigger reconnection hydration. |
| tests/test_live_anchor_artifact_reference.py | New test file covering producer, path-safety, ordering, replay-cursor, cancellation, and frontend routing for the artifact reference pipeline. |
| tests/test_anchor_scene_persistence.py | Extended with artifact budget trimming, foreign-owner rejection, ownerless bootstrap rejection, stable-run stream-rotation acceptance, and malformed-parent-owner cases; thorough coverage of the new ownership contract. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Agent
participant StreamingPy as streaming.py
participant ArtifactRefs as artifact_references.py
participant RunJournal as Run Journal
participant Browser
participant RoutesPy as routes.py
Agent->>StreamingPy: tool.completed (write_file / patch)
StreamingPy->>ArtifactRefs: derive_file_artifact_references(name, args, result, workspace)
ArtifactRefs-->>StreamingPy: "[{kind, path, source_tool, tool_call_id}]"
StreamingPy->>RunJournal: "put('artifact_reference', payload) -> event_id"
StreamingPy->>StreamingPy: _record_anchor_artifact_reference(payload, event_id)
RunJournal-->>Browser: "SSE: artifact_reference {path, ...}"
Browser->>Browser: _applyToAnchor('artifact_reference', d, render:false)
Note over StreamingPy: On terminal state (completed / cancelled / error)
StreamingPy->>StreamingPy: _reconcile_stream_artifacts_into_terminal_anchor_scene()
StreamingPy->>ArtifactRefs: merge_anchor_activity_scene(existing, incoming, ...)
StreamingPy->>StreamingPy: "session.anchor_activity_scenes[key] = record"
StreamingPy->>StreamingPy: session.save()
Note over Browser: Scene settlement
Browser->>RoutesPy: POST /api/session/anchor-scene
RoutesPy->>ArtifactRefs: "merge_anchor_activity_scene(existing, incoming, reject_owner_mismatch=True)"
ArtifactRefs-->>RoutesPy: merged scene
RoutesPy->>RoutesPy: "record[owner_authority] = server"
RoutesPy->>RoutesPy: session.save()
RoutesPy-->>Browser: 200 OK
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Agent
participant StreamingPy as streaming.py
participant ArtifactRefs as artifact_references.py
participant RunJournal as Run Journal
participant Browser
participant RoutesPy as routes.py
Agent->>StreamingPy: tool.completed (write_file / patch)
StreamingPy->>ArtifactRefs: derive_file_artifact_references(name, args, result, workspace)
ArtifactRefs-->>StreamingPy: "[{kind, path, source_tool, tool_call_id}]"
StreamingPy->>RunJournal: "put('artifact_reference', payload) -> event_id"
StreamingPy->>StreamingPy: _record_anchor_artifact_reference(payload, event_id)
RunJournal-->>Browser: "SSE: artifact_reference {path, ...}"
Browser->>Browser: _applyToAnchor('artifact_reference', d, render:false)
Note over StreamingPy: On terminal state (completed / cancelled / error)
StreamingPy->>StreamingPy: _reconcile_stream_artifacts_into_terminal_anchor_scene()
StreamingPy->>ArtifactRefs: merge_anchor_activity_scene(existing, incoming, ...)
StreamingPy->>StreamingPy: "session.anchor_activity_scenes[key] = record"
StreamingPy->>StreamingPy: session.save()
Note over Browser: Scene settlement
Browser->>RoutesPy: POST /api/session/anchor-scene
RoutesPy->>ArtifactRefs: "merge_anchor_activity_scene(existing, incoming, reject_owner_mismatch=True)"
ArtifactRefs-->>RoutesPy: merged scene
RoutesPy->>RoutesPy: "record[owner_authority] = server"
RoutesPy->>RoutesPy: session.save()
RoutesPy-->>Browser: 200 OK
Reviews (52): Last reviewed commit: "Merge remote-tracking branch 'origin/mas..." | Re-trigger Greptile
|
|
||
| def _function_block(source: str, name: str) -> str: | ||
| start = source.index(f"def {name}(") | ||
| next_def = source.find("\n def ", start + 1) |
There was a problem hiding this comment.
_function_block is fragile against streaming.py restructuring
The helper locates function boundaries by searching for the literal string "\n def " (12 spaces). If any sibling nested function is added between on_tool and on_tool_start, or if indentation changes, the extracted block will silently shrink — potentially no longer containing put('artifact_reference', ...) — and block.index(...) will raise ValueError with a misleading message rather than a meaningful assertion failure. A small comment explaining the indentation expectation, or using a marker comment in streaming.py as a sentinel, would make future breakages easier to diagnose.
|
Aftercare follow-up pushed in b5f4f77:
Local verification for the follow-up:
|
|
Base refresh pushed in e1cf64a:
GitHub Actions and Greptile have restarted on the new head; no Frank action is needed. |
|
Base refresh pushed in 3c4842e:
GitHub checks have restarted on the new head; no Frank action is needed. |
|
Base refresh pushed in c6dffcb:
GitHub checks and Greptile have restarted on the new head; no Frank action is needed. |
|
Base refresh pushed in 180dc6a:
GitHub checks and Greptile have restarted on the new head; no Frank action is needed. |
|
Proof-matrix aftercare at |
|
Base refresh pushed in 388575f:
GitHub checks have restarted on the new head; no Frank action is needed. |
|
Base refresh pushed in a86ce5b:
GitHub checks have restarted on the new head; no Frank action is needed. |
|
Base refresh pushed in 009e517:
GitHub checks and Greptile have restarted on the new head; no Frank action is needed. |
…o paperclip/pr-6207
|
Aftercare base refresh pushed in
Incoming base was extension sidecar-auth release work and touched Verification passed after the merge:
Readback after push: head |
nesquena-hermes
left a comment
There was a problem hiding this comment.
Thanks @franksong2702 — big progress: an adversarial re-gate confirms all three prior blockers are fixed (exact-path artifact ownership with boundary-whitespace rejected + decoy-filename regression passing; lifecycle ownership now projected at api/routes.py:3286/3747, reconciled for terminal success/error/cancel at api/streaming.py:1668, and hydrated on reload at static/messages.js:4041; result/patch/path-count/total-byte limits at api/artifact_references.py:18-26 with derivation isolated after tool_complete). Projection stays separate from activity rows — no dup/reorder/final-answer-ownership change. Full suite 13531/0.
Two remaining issues before it can land (both verified):
1. [SILENT] tool-call-ID cap bypassed by normalization — api/artifact_references.py:198
Same class as the original path finding, now on the tool-call-ID field: the code .strip()s before measuring, so a 300,007-byte ID (300,000 spaces + call-ok) is accepted and stored as call-ok. That's unbounded preprocessing and it attributes the artifact to a different identity than the caller sent. Fix: require a string, measure the exact original value first, reject boundary whitespace and oversized values, and preserve the accepted ID unchanged (mirror exactly what you did for the path field at :141).
2. Stream-wide budget enforced too late — api/streaming.py:8361,8472
The count/byte budget is checked at final scene assembly, but each artifact event is put() into the journal/queue before that check, so a flood can still hit the journal/queue path. Fix: enforce the stream-wide artifact count + byte budget before journaling/queueing each event, and add a test that asserts the queue/journal count is bounded (not only the final scene).
(Also: rebase onto current master — it now includes the token-v1 sidecar-auth merge c414c39f1 (#6331) — before the next gate.)
Once the tool-call-ID is validated exact-and-bounded and the budget is enforced pre-journal, I'll re-run the full gate. The rest of the diff is solid. 🙏
|
Aftercare follow-up pushed in Addressed the two remaining blockers:
Verification passed:
Readback after push: head |
…o paperclip/pr-6207
|
Aftercare base refresh pushed in
Incoming base was release PR #6371 for compact completed image tool-results. It touched Verification passed after the merge:
Readback after push: head |
nesquena-hermes
left a comment
There was a problem hiding this comment.
Thanks @franksong2702 — both remaining blockers from the last round are confirmed fixed: the tool-call-ID is now measured exact-and-bounded with boundary whitespace rejected, and the artifact budget is enforced before put()/journaling at both emission sites (api/streaming.py:7621-7634, 8453-8457, 8566-8570). The 3 original findings all remain closed (exact-path ownership, lifecycle projection/hydration across replay/reload/settle/error/cancel, derivation isolated after tool_complete), no dup/reorder/final-answer-ownership change. Full suite 13548/0.
An adversarial re-gate found 3 remaining edge cases on the same bounded-parsing subsystem — the middle one is a real bypass, so one more pass:
1. [SILENT] _MAX_RESULT_BYTES not enforced for dict results — api/artifact_references.py:80-91
The byte cap is applied for string results but a dictionary result skips it: a verified 65,537-byte structured result still produced an artifact reference. Fix: use an early-exit bounded JSON-size walker for dict results and reject over-limit or unserializable results before reading mutation fields.
2. [minor] .strip() runs before the size rejection — api/artifact_references.py:54-63,199-205
The constant-time char-count rejection should happen on the exact untrimmed value before calling .strip(), so a huge input can't force unbounded preprocessing. (Same shape as the tool-call-ID fix — apply it to the other measured fields.)
3. [minor] Aggregate byte accounting omits JSON framing — api/artifact_references.py:513-523
The 32,768-byte cap doesn't count list brackets/commas, so a probe produced 32,769 bytes. Fix: include the complete serialized-list framing in the counter, and mirror it browser-side at static/assistant_turn_anchors.js:211-214 and static/messages.js:3669-3670.
Once dict results are byte-capped (the material one), the size check precedes .strip(), and the framing bytes are counted on both sides, I'll re-run the full gate. This is close — the whole ownership/lifecycle/timing core is solid now. 🙏
|
Aftercare follow-up pushed in This addresses the 23:27Z bounded-parsing review edge cases:
Verification passed:
GitHub checks have restarted on the new head. No Frank action is needed. |
|
Aftercare CI retry pushed in The only failed check on I reran the exact gate locally on this checkout and it passed:
Result: live activity, terminal settlement, and hard-reload Anchor scene parity all passed. I tried Current head is |
nesquena-hermes
left a comment
There was a problem hiding this comment.
Thanks @franksong2702. I re-gated exact head 7a657a6e77f9 against the three findings from the review at d27b0feb1b85. The list-framing fix is correct across the server and both browser bounders, but the other two requested bounded-processing invariants remain incomplete.
This was a static-only re-gate. The mandatory threat scan returned SUSPICIOUS on the added local JS test harness's eval(src), so policy required NO-RUN. I did not execute the PR or its tests. The scanner hit looks consistent with the existing checked-in-module test idiom, but the fail-closed verdict remains authoritative.
1. The dict-result byte gate still performs unbounded scalar encoding
api/artifact_references.py::_json_within_bytes (:66-76) uses JSONEncoder.iterencode(). For a string value, CPython's encoder calls and yields _encoder(value) as one complete chunk before this loop can compare total with _MAX_RESULT_BYTES; _utf8_size(chunk) then creates another full representation. An arbitrarily large scalar is therefore fully processed before the 64 KiB rejection. This is not the requested early-exit bounded JSON-size walker.
The handler also catches TypeError, ValueError, and OverflowError, but not RecursionError, so a deeply nested structured result can escape instead of failing closed. The new test covers an ordinary 65 KiB string and a TypeError from object(), but neither boundary.
Fix: replace _json_within_bytes with a genuinely bounded, non-recursive compact-JSON size walker. Use an explicit container stack and active-ID set, count full compact JSON framing, reject cycles/unsupported values/excessive nesting, and stop before fully encoding any scalar once the cap cannot be met. Keep this gate before _mutation_landed() reads mutation fields.
2. Exact strings are measured before .strip(), but the constant-time precheck is still missing
_bounded_clean_string() (:54-63) and the tool-call-ID guard (:212-219) now measure the exact value before boundary-whitespace rejection, which fixes the normalization-order bug. Both still call _utf8_size(value) before a constant-time character-count rejection, however. A huge input must be UTF-8 encoded in full before it is rejected. The new 300,000-space assertion proves the returned value is []; it does not prove bounded preprocessing.
Fix: add len(value) > limit before _utf8_size(value) in both paths, then retain exact untrimmed UTF-8 measurement and boundary-whitespace rejection. Add a regression that makes any call to _utf8_size fail for an input whose character count already exceeds the byte cap, plus deep-nesting/cycle cases for the dict gate.
The framing work in bound_anchor_artifact_events, _boundedAnchorArtifactEvents, and _boundedAnchorSceneArtifacts can remain as-is. After these two bounded-processing gaps are closed and the threat scan permits execution, the targeted artifact/anchor suites need a sandboxed run through warmup-safe-test.sh.
|
Aftercare follow-up pushed in This addresses the 00:54Z bounded-processing review:
Verification passed:
I could not run Readback before this comment: PR head |
nesquena-hermes
left a comment
There was a problem hiding this comment.
Thanks @franksong2702. I re-gated exact head 86f3d5dc0802 against the 00:54Z bounded-processing findings. The prior three items now converge: scalar JSON strings and bounded string/tool-call-ID fields get a constant-time character lower-bound before UTF-8 work, and the recursive encoder path has been replaced with an explicit depth/cycle-aware walker. The mandatory threat scan is CLEAN, and the targeted sandbox gate passes all 24 tests in test_live_anchor_artifact_reference.py.
The replacement still has two deterministic bounded-work gaps, so this is not ready yet.
1. Wide containers are expanded before the 64 KiB cap can reject them
In api/artifact_references.py:_json_within_bytes, dict handling does items = list(item.items()) and then pushes every key/value pair onto stack; list/tuple handling likewise pushes every element before any child bytes are charged. An arbitrarily wide result therefore causes work and temporary memory proportional to the raw container width, independent of _MAX_RESULT_BYTES. A nested wide container has the same problem. This regresses the old encoder's lazy container iteration and defeats the bounded-work purpose of this gate.
Fix: use lazy iterator frames that schedule one dict pair or sequence element at a time. Keep the container ID active until its iterator exits, charge punctuation as each item is scheduled, and bound stack/work by nesting depth plus the serialized prefix examined, not raw width.
2. Serialized string results still encode before a constant-time lower-bound
_result_object calls _utf8_size(result) directly for string results. A very large serialized result is therefore fully encoded before the 64 KiB rejection. Add len(result) > _MAX_RESULT_BYTES before _utf8_size(result), as the new code now does for scalar strings and tool-call IDs.
Please add fail-first coverage for both shapes: instrumented very-wide dict/list inputs that prove scanning stops after a cap-derived prefix, plus an oversized direct serialized-result string whose _utf8_size call must never receive the over-limit value. The current 24 tests do not exercise either path; the nested scalar spy also does not fail on the old JSON-quoted encoder chunk.
The ownership, lifecycle, framing, and earlier string/depth fixes remain intact. This request is limited to closing the two bounded-work gaps introduced or retained in the latest scanner.
|
Handled the bounded-work follow-up from the 01:34Z review. Pushed
Changed:
Verification passed:
No Frank action needed. |
|
Aftercare base refresh pushed in
Incoming base was release PR #6375 and touched Verification passed after the merge:
Checks have restarted on the refreshed head. No Frank action is needed. |
|
Aftercare base refresh pushed in
Incoming base was release PR #6376 and touched Verification passed after the merge:
Checks have restarted on the refreshed head. No Frank action is needed. |
|
Aftercare base refresh pushed in
Incoming base was release PR #6379 and touched Verification passed after the merge:
Checks have restarted on the refreshed head. No Frank action is needed. |
|
Aftercare base refresh pushed in
Incoming base was release PR #6384 and touched Verification passed after the merge:
Checks have restarted on the refreshed head. No Frank action is needed. |
|
Aftercare base refresh pushed in
Incoming base was release PR #6386 and touched Verification passed after the merge:
Checks have restarted on the refreshed head. No Frank action is needed. |
|
Handled PR #6207 base-refresh aftercare.
Incoming base touched Verification passed:
I’ll monitor the restarted checks/Greptile on the refreshed head. No Frank action needed. |
Thinking Path
artifact_reference, and Live Stream: preserve Anchor-owned side effects #6204 carries Anchor-owned artifacts through scene settlement and reload.tool_completeSSE currently hardcodesis_error: false.What Changed
write_fileandpatchresults.artifact_referenceSSE event per unique file aftertool_complete; raw args and result bodies are never included.Fixes #6205.
Refs #3400.
Depends on #6204 for
artifacts[]scene projection, settlement persistence, and reload hydration.Why It Matters
A file mutation is no longer inferred from a completed-looking tool card. The producer proves that the write landed, emits only safe path metadata, and gives the artifact the same Assistant Turn owner as the tool that produced it. Together with #6204, that ownership survives the Live-to-Final transition without changing the readable Worklog.
Release note: Preserve turn-owned file artifact references from successful
write_fileandpatchcalls across live streaming and replay.Verification
tests/test_live_anchor_artifact_reference.pyfailed all 3 initial integration checks.356 passed.node --check static/messages.jspython3 -m py_compile api/artifact_references.py api/streaming.pypython3 scripts/ruff_lint.py --diff origin/master: 0 findings on added/modified lines.git diff --checkgit merge-tree --write-tree HEAD 950b71201442cd46876ca7d6b11c16fc846e2a50: clean composition with Live Stream: preserve Anchor-owned side effects #6204.Risks / Follow-ups
write_file,patch). Shell/code execution and dynamic MCP mutation inference remain out of scope because their side effects cannot be derived reliably from names or command text.tool_complete.is_errorfield remains a separate, broader tool-card correctness issue; artifact derivation does not trust it.403 Request not allowedbefore producing review content. It is not represented as a completed review.Model Used
Contract Routing
Task type: hardening an already accepted Live-to-Final ownership contract; no product contract is removed or reversed.
Touched areas:
activity_scene_v1artifact persistence via Live Stream: preserve Anchor-owned side effects #6204Relevant public docs:
AGENTS.mdCONTRIBUTING.mddocs/CONTRACTS.mddocs/rfcs/live-to-final-assistant-replies.mddocs/rfcs/stable-assistant-turn-anchors.mddocs/architecture/stable-assistant-turn-anchor-phase0.mdState-layer invariant: proven raw tool result -> bounded workspace-relative
artifact_referenceSSE -> run journal -> active Assistant Turn Anchor -> renderer-neutralactivity_scene_v1through #6204, while remaining absent from Compact Worklog rows.Scope boundaries: no rich artifact UI, no terminal/code/MCP guessing, no raw file/tool-result persistence, and no change to final-answer rendering.
Contract Change
Previous implementation state:
artifact_referencewas documented and classified by the Anchor model, but production file mutations emitted no such event.New implementation state: successful canonical file mutations emit a separate path-only event from the backend after the result proves the write landed.
Compatibility: additive event metadata only. Existing tool cards, Artifact tab behavior, final answers, and Compact Worklog rendering remain unchanged.