Skip to content

feat(telemetry): archive model-visible tool evidence - #268

Open
brotherzhao2019 wants to merge 3 commits into
mainfrom
feat/model-visible-evidence-archive
Open

feat(telemetry): archive model-visible tool evidence#268
brotherzhao2019 wants to merge 3 commits into
mainfrom
feat/model-visible-evidence-archive

Conversation

@brotherzhao2019

Copy link
Copy Markdown
Collaborator

Summary

  • archive the ToolResult values present on successful provider request wires, using the existing llm/NNN.request.json artifacts rather than raw tool output or size-bounded trace previews
  • support OpenAI-compatible Chat Completions, OpenAI Responses, and Anthropic Messages request shapes
  • add client-side secret redaction, SHA-256 content addressing, cross-step deduplication, UTF-8-safe chunks, per-request manifests, and a terminal turn commit
  • durably stage evidence independently from traces and retry failed proxy/Axiom uploads; upload the turn commit only after all content and manifests are acknowledged
  • add a schema-validating /v1/evidence Vercel proxy backed by a separate Axiom evidence dataset
  • document the schema, privacy gates, deployment configuration, recovery behavior, and current integration boundary

Behavior and scope

The archive follows the Agent's existing context behavior: if socai kept, truncated, or omitted comments before sending a provider request, the evidence archive records that same model-visible value. It does not restore data from raw tool artifacts.

This change does not modify Agent prompts, request_messages, context compaction, provider requests, or LLM call counts, so it adds no model input tokens. It only adds terminal-run telemetry processing, local durable staging, network upload, and Axiom storage.

Evaluator integration is intentionally out of scope for this PR. Existing evaluations continue using the bounded trace packet until evaluator gains an evidence reader and integrity verifier.

Privacy and reliability

  • evidence content follows both SOCAI_TELEMETRY_CHAT_TEXT and SOCAI_TELEMETRY_EVIDENCE
  • disabled content produces a manifest-only disabled commit
  • API keys, JWTs, Bearer values, sensitive JSON fields, and URL query credentials such as xsec_token are redacted before staging
  • chunk hashes are verified by the proxy; unknown schema fields and mixed client identities are rejected
  • pending files are removed only after the proxy confirms the exact accepted record count

Deployment prerequisites

Before enabling production uploads:

  1. Create socai-evidence-dev and socai-evidence-prod.
  2. Configure AXIOM_EVIDENCE_TOKEN with dataset-scoped ingest permission.
  3. Configure AXIOM_EVIDENCE_DATASET and optionally AXIOM_EVIDENCE_URL / AXIOM_ORG_ID.
  4. Deploy the new /v1/evidence Vercel function before releasing the client.

Verification

  • cargo check --workspace
  • cargo test --workspace (99 passed, 0 failed, 2 ignored)
  • pnpm build in app/
  • pnpm build in site/
  • provider smoke coverage for OpenAI Chat, OpenAI Responses, and Anthropic request shapes
  • 84,123-byte long-comment smoke: 1 deduplicated object, 4 UTF-8-safe chunks, successful reconstruction and credential redaction
  • proxy smoke: exact Unicode chunk forwarded; invalid chunk hash rejected
  • targeted rustfmt --check, Node syntax check, and git diff --check

Copilot AI balanced review requested due to automatic review settings August 21, 2026 14:51

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
socai-site Ready Ready Preview Aug 25, 2026 2:41am

Request Review

@MingruiZhang MingruiZhang left a comment

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.

Full review of the evidence-archive pipeline (10 review angles + line-level verification of every claim below). Direction is solid — separate lossless model-visible record, content-addressed identity, commit-last upload, double privacy gating are all the right calls. But there are 3 ship-blockers in the upload protocol that make the feature fail for typical runs, plus a cluster of durability bugs. All are cheap to fix relative to the PR. Details inline; summary here.

Blockers (inline comments 👇)

  1. Client batches by bytes only, proxy rejects >32 records → most real runs get a permanent 400 + infinite 30s retry (mod.rs:417 / evidence.js:128).
  2. No poison-file handling: deterministic 400s retry forever with identical bytes, and the first-5 retry scan lets ≤5 poisoned files starve all newer evidence (mod.rs:442, mod.rs:330).
  3. Redaction corrupts adjacent content: the query-value scan doesn't stop at \ , ) ] etc., so it eats JSON escapes and neighboring fields — corruption is then hashed into the "lossless" content_sha256 (trace.rs:996).

Bugs

  • Restart-from-batch-zero on mid-file failure → unbounded duplicate ingestion + livelock under the 32MiB/min rate cap (mod.rs:392).
  • Evidence silently lost on the cancel→delete path (deferred EvidenceRun staging races remove_dir_all; .trace-staged marker only tracks the trace; dropped try_send has no recovery) (mod.rs:147).
  • write_json_atomic deletes before renaming — a crash in between permanently loses trace.json; on Unix a bare rename is already atomic. run_logging.rs has a safer copy to reuse (evidence.rs:574).
  • Full archive build (read+redact+hash of every cumulative request file, O(steps²) bytes) runs synchronously on the caller's thread — desktop completion/cancel paths are async Tauri tasks (mod.rs:141).
  • Proxy: rate limiter is bypassable by rotating install_id and runs after validation (evidence.js:233); a 2xx Axiom response with unparsable body is acked as full ingestion (evidence.js:270); body cap measures the re-minified body, not wire bytes (evidence.js:87).

Design notes (non-blocking, worth deciding consciously)

  • Transport-only convention: traces.js documents that proxies never shape payloads so the client can evolve the schema without a proxy deploy; evidence.js hand-duplicates the schema as a reject-on-unknown allowlist (evidence.js:156). Combined with blockers 1–2, any future schema evolution bricks the fleet's evidence pipeline until a synchronized proxy deploy. Either relax the proxy to pipe-guards or consciously revise the convention.
  • Facts inferred at a distance instead of recorded at write time: provider wire format is re-derived heuristically when Backend::request_payload knows it definitively (new providers silently land in the wrong extractor or unsupported); response_status decides "accepted" by attempting an LLMResponse deserialization — a shape change silently turns every archive partial (evidence.rs:438). Recording wire_format + an explicit status at write time removes both fragilities.
  • The redactor's 3-name query list is a hand-maintained subset of SECRET_JSON_FIELDS four lines above it — client_secret=, refresh_token=, password= in URL form pass through unredacted today.
  • retry_pending_evidence/stage_evidence_when_ready/evidence_endpoint are verbatim copies of the trace counterparts; consider one spool abstraction. patch_trace_summary makes evidence.rs a second post-hoc writer of trace.json (3 writers now, clobber window on cancellation) when the worker's enrich_trace_resource seam already exists for upload-time attributes.

Minor

  • O(n²) batch sizing: whole-batch clone + full re-serialization per record (mod.rs:408) — keep a running byte total.
  • Dead (_, bool) tuple in upload_run_trace; unreachable IP fallback in rateLimitKey; batch_sha256 computed per response but never checked by the client.
  • Duplicated request_status expression + evidence_ids JSON round-trip (evidence.rs:192).
  • split_utf8's end == start fallback emits the whole remainder as one chunk — unreachable while max_bytes ≥ 4, but violates the contract if reused.
  • Identity enrichment at upload time (app_session_id, app_version) means a legal retry after app restart/update produces duplicates that conflict on those fields — which the doc's reader rule ("reject conflicting values for the same stable key") would reject. Either enrich at staging time or exempt these fields in the reader contract.

Deployment checklist feedback (re: socai-evidence-remote-deployment-checklist)

The checklist is operationally solid (scoped ingest tokens, dev→prod, endpoint-before-client release order, rollback via SOCAI_TELEMETRY_EVIDENCE=off). Four adjustments:

  1. The dev acceptance test ("one complete task with tool calls") is too small to trip blocker 1 — a short task stays under 32 records and passes while real 15+-step runs fail. Add one long-run acceptance case (20+ steps).
  2. Sequence the client-side blocker fixes before the final "release client" step; the infra steps can proceed in parallel now.
  3. The "duplicates are expected, dedupe by stable ID" note conflicts with the repo doc's "reject conflicting values" rule per the enrichment issue above — one of the two needs updating.
  4. Treat a rising 400 ratio on /v1/evidence as page-worthy, not informational: under the current client every deterministic 400 is a permanently-retrying spool file on a user's machine.

Verified clean

No new Rust tests (per repo rule), lowercase branding, docs placement correct; chunk sizes compatible (24KB < 32KB), trace/span id formats match the proxy regex, step numbering 1-based as required, client field set matches the proxy allowlist today, and the redactor's byte cursor can't panic on multibyte input (all terminators ASCII).

Comment thread core/src/telemetry/mod.rs Outdated
});
let candidate_len =
serde_json::to_vec(&candidate_body).map_or(usize::MAX, |body| body.len());
let should_flush = candidate_len > EVIDENCE_BATCH_MAX_BYTES && !batch.is_empty();

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.

Blocker — most runs can never upload. Batches are split by bytes only (512KB), but the proxy rejects any request with more than MAX_RECORDS = 32 records (evidence.js:128). A typical 20-step run produces 40–60 small records (one manifest per step + a few-KB chunk per tool result) totalling far under 512KB → one batch → 400 too_many_records → spool file retained → identical retry every 30s forever.

Fix: also flush when candidate.len() > 32 (or better, >= MAX_RECORDS shared as a constant with a comment pointing at the proxy).

Comment thread core/src/telemetry/mod.rs Outdated
let Ok(response) = client.post(evidence_endpoint()).json(&body).send().await else {
return false;
};
if !response.status().is_success() {

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.

Blocker — no poison-file handling. Every non-2xx is treated identically: a deterministic 400 (schema rejection, too_many_records, >500 manifest ids, empty provider/model) is retried with byte-identical content every 30s forever — no dead-letter, no backoff, no give-up. Unlike traces, evidence has structural rejection modes, so this actually bites.

Suggest: on 4xx, move the file to a dead/ subdir (or delete after N attempts) and count it in telemetry; keep retrying only on 5xx/network.

Comment thread core/src/telemetry/mod.rs Outdated
return;
};
let mut paths = Vec::new();
while paths.len() < TRACE_RETRY_BATCH_SIZE {

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.

Compounding the poison-file issue: this scan takes only the first TRACE_RETRY_BATCH_SIZE (5) directory entries per tick, in read_dir order. Five permanently-failing files sitting first in that order starve every newer pending-evidence file indefinitely. (Also: this function is a verbatim copy of retry_pending_traces — consider one collect_pending_json(dir) helper so a quarantine fix lands in both.)

Comment thread core/src/telemetry/trace.rs Outdated
.take_while(|byte| {
!matches!(
**byte,
b'&' | b'#' | b' ' | b'\t' | b'\r' | b'\n' | b'"' | b'\''

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.

Blocker — redaction corrupts adjacent content. The value scan stops only at & # " ' and whitespace — not at \ , ) ] } ; <. Two concrete failures:

  1. access_token=abc,expires_in=3600,scope=readaccess_token=[redacted] — the non-secret fields after the comma are destroyed.
  2. In a string leaf containing escaped JSON (ubiquitous in XHS browser tool results — every URL carries xsec_token=): ...xsec_token=abc\"... — the scan eats the \ of the \" escape, leaving the embedded JSON unparseable.

The corruption happens before hashing, so content_sha256 faithfully signs the damage — undetectable downstream in a feature whose whole point is losslessness. This also affects the pre-existing trace/chat text paths since it was appended to shared redact_secrets.

Fix: add the missing terminators. Also consider deriving the parameter list from SECRET_JSON_FIELDS above instead of a hand-maintained 3-name subset (client_secret=, refresh_token=, password= in URL form pass through unredacted today).

Comment thread core/src/telemetry/mod.rs Outdated
if commits.len() != 1 {
return;
}
if !upload_evidence_batches(client, evaluation_id, body_records).await {

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.

Bug — restart-from-zero on mid-file failure. There's no progress marker, and the proxy forwards each batch to Axiom before a later batch fails. An archive larger than the 32MiB/min per-install rate budget 429s at the same point on every retry: the leading ~32MiB is re-ingested into Axiom every minute, the turn_commit never lands, the file never deletes. Same duplicate-on-retry applies to any transient mid-sequence failure (though those at least eventually complete).

Mitigations: per-file attempt state (skip already-acked batch count), and/or honor 429 with a real backoff instead of the fixed 30s tick.

Comment thread core/src/telemetry/evidence.rs Outdated
let manifest_body = json!({
"step": step,
"request_status": if extraction_error.is_some() { "unsupported" } else { request_status },
"evidence_ids": evidence_ids,

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.

Protocol gap: evidence_ids is unbounded here, but the proxy rejects manifests with more than 500 ids (evidence.js:194). Late-step requests carry the full accumulated history, so a long conversation with many tool results can legitimately exceed 500 — producing a permanently-rejected archive (same infinite-retry failure mode as the batch-size blocker). Either cap/truncate client-side with a marker, or raise/remove the proxy cap.

Comment thread site/api/evidence.js
const specificFields = RECORD_FIELDS[record.record_type];
if (!specificFields) return 'unknown_record_type';
const allowedFields = new Set([...COMMON_FIELDS, ...specificFields]);
if (Object.keys(record).some((field) => !allowedFields.has(field))) return 'unknown_record_field';

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.

Design decision to make consciously: traces.js documents the transport-only philosophy ("the proxy guards the pipe … never inspects span contents … so clients can evolve the schema without a proxy deploy"), but this route hand-duplicates the Rust record schema as a reject-on-unknown allowlist plus enum/count validation. Combined with the client's retry-forever spool, any future schema evolution — a new field, a new status value, a new record type — permanently poisons every fleet client's pending evidence until a synchronized proxy deploy.

Either relax this to pipe-guards (size, rate, chunk-hash recompute) per the existing convention, or explicitly revise the convention — but the current state contradicts the documented contract next door.

Comment thread site/api/evidence.js Outdated
return createHash('sha256').update(text, 'utf8').digest('hex');
}

function rateLimitKey(req, records) {

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.

Rate-limiter gaps: (1) keyed on the client-asserted install_id — rotating ids per request gets unlimited metered ingestion; (2) runs after full validation, so invalid 1MiB bodies get free per-request SHA-256 over up to 32×32KiB chunks with no metering; (3) the IP fallback is unreachable dead code — validation already guarantees a valid install_id on every record, so install:${records[0].install_id} is equivalent; (4) the in-memory Map is per-serverless-instance, so N concurrent instances = N× the documented cap. Same class as the sibling routes, but this endpoint does real CPU work per request, so worth noting before prod.

Comment thread site/api/evidence.js
});
if (!response.ok) throw new Error(`Axiom evidence ingest failed: ${response.status}`);

const result = await response.json().catch(() => null);

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.

A 2xx Axiom response with an unparsable or shapeless body (result === null) skips the partial-ingest check entirely and gets acked as accepted: records.length — after which the client deletes its spool file. If Axiom (or an intermediary) ever returns 200 with a truncated/non-JSON body or changes the response shape, records are silently dropped despite the "removed only after all acks" durability guarantee. Suggest treating an unverifiable ingest result as failure (5xx to the client, which retries).

Comment thread site/api/evidence.js Outdated

async function readJsonBody(req) {
if (req.body !== undefined && req.body !== null) {
const text = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);

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.

When Vercel has pre-parsed req.body, the 1MiB cap and rate-limit byte accounting measure JSON.stringify(req.body) — the re-minified serialization — not wire bytes. A whitespace-padded 1.4MiB wire body can minify under the cap, and bodyBytes per install undercounts. Also the stringify → parseJson round trip re-parses up to 1MiB for nothing — input is just req.body. Prefer Content-Length (or the raw stream path) for size accounting.

Copilot AI review requested due to automatic review settings August 25, 2026 02:31

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@brotherzhao2019

Copy link
Copy Markdown
Collaborator Author

Implemented the review follow-up in ed73dbb without merging.

Ship-blocker fixes

  • client batches now honor both 32 records and 512 KiB; 31/32/33/64/65 boundaries are covered
  • upload progress is checkpointed per batch; restart resumes from the first unacknowledged batch and commit remains last
  • deterministic local/400/413/422 failures move to pending-evidence/dead/ with a content-free reason; 401/403/404, 429, 5xx, and network failures use classified backoff; eligible files are sorted so quarantined/deferred entries cannot starve newer files
  • URL secret redaction now derives from the structured secret-field list and consumes token-safe characters only, preserving commas, JSON escapes, brackets, and adjacent fields
  • cancellation/deletion waits for both trace and evidence durable staging; failed staging preserves task artifacts
  • Axiom 2xx is acknowledged only when ingested == records.length, failed == 0, and the JSON response is verifiable

Additional review fixes

  • rename-first atomic byte writes are shared by trace/evidence paths; compact trace serialization remains compact
  • desktop archive staging runs under spawn_blocking; repeated model-visible ToolResults are cached before redact/hash (30-step synthetic release benchmark improved from 277 ms to 68 ms for 28.1 MB cumulative request input)
  • request wire format and prepared/accepted/failed outcome are recorded explicitly in NNN.request.meta.json, with legacy inference retained as fallback
  • proxy verifies client batch SHA-256, checks Content-Length, applies an IP bucket before expensive validation plus an install bucket, and removes the arbitrary 500-evidence-id cap
  • retry-varying session/version enrichment was removed; client version/platform are fixed at archive staging time
  • versioned strict evidence gateway behavior and the production WAF requirement are documented

Verification

  • cargo test --workspace: 107 passed, 0 failed, 2 ignored
  • evidence Rust tests cover record/byte batching, Rust/JS hash parity, mid-file resume, permanent-400 quarantine, redaction boundaries, wire metadata, and telemetry opt-out deletion behavior
  • pnpm test:evidence: 5 passed, including 32/33 boundary, hash mismatch, malformed Axiom 2xx, and pre-parsed Content-Length
  • desktop build and site build pass
  • Vercel checks for ed73dbb pass; PR remains OPEN/CLEAN

Remaining operator validation

The previously shared Preview bypass value must be revoked/rotated and is not used here. With the rotated secret, please replay a generated 33+/65-record archive and one 20+ step equivalent against socai-evidence-dev, confirm batch/checkpoint behavior, pending cleanup, and the final turn commit. Production remains intentionally untouched. Vercel Firewall/WAF rate limiting is still an operator-side prerequisite because function-local maps are not global controls.

Copilot AI review requested due to automatic review settings August 25, 2026 02:40

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

3 participants