Commit Graph

6 Commits

Author SHA1 Message Date
Patrick Buckley 56624f9597 fix(core): scrub credentials and control chars from tool-args log preview
`tool_args_preview` feeds `stream.tool_args_malformed` (WARNING) and
`wire.tool_args_legalized` (DEBUG), and tool arguments are model/user
controlled — they can carry secrets (a token in a bash command, a password in a
connection string) or raw CR/LF that break log lines. Route the preview through
`output_guard.redact_credentials` over the full value first (before the 120-char
cap, so a secret straddling the cut isn't half-shown past the pattern's reach),
then collapse every control char to a space, mirroring `audit._scrub_string`.

Addresses the PR review comments.
2026-07-05 11:59:52 -07:00
Patrick Buckley 16a68ae6d6 fix(core): legalize malformed tool-call arguments before the wire
A tool call whose `arguments` is not a JSON-object string (an unterminated
string from a non-`length` truncation, or an empty `""` from a no-arg call)
was committed verbatim and replayed on every subsequent send. Strict renderers
that re-parse arguments at render time (vLLM's `deepseek_v4`
`_postprocess_messages` runs `json.loads` on them) reject the whole request
with HTTP 400, wedging the conversation. The only prior guard dropped partial
tool calls on `finish_reason == "length"`; a `stop`/`tool_calls` finish reason
carrying invalid JSON slipped through, and its synthetic "retry" result kept it
from being an orphan, so the orphan-repair pass never touched it.

Add `sanitize_tool_call_arguments`, a wire-neutral legalize pass in lowering
(fold, legalize, repair), normalizing any non-JSON-object `arguments` to `{}`
on the transient wire copy only. The canonical trajectory keeps the raw model
output, so a wedged session self-recovers on its next send. A
`wire_valid_arguments` predicate is shared with a non-destructive
`stream.tool_args_malformed` warning at the stream accumulator, which surfaces
the model-quality problem at production time.

Convert `lowering.py` to structlog so the new pass emits structured events.
2026-07-05 11:59:52 -07:00
Patrick Buckley c7d8acb6a5 fix(effect-status): harden effect_status decode + fix tests for typed synth
- Turn.effect_status also catches TypeError: a corrupt non-string meta value
  (e.g. a dict that survived into the column) would otherwise crash a consumer
  on access, since EffectStatus(non-str) raises TypeError, not ValueError.
  Degrade to None, mirroring the meta decoders (Copilot review).
- test_lowering: the wire-repair synth now carries the _effect_status side
  channel (stripped before the provider wire) — assert it.
- test_session_mcp_dispatch_error: the _capture stub swallows the new status
  kwarg via **_ so it stays signature-compatible with _report_tool_result.
2026-06-26 08:38:31 -07:00
Patrick Buckley 16294397c2 perf: dict-native wire-prep — drop the per-send Turn<->dict round-trip
The canonical-Turn migration left lowering's fold/drop/repair passes Turn-typed even
though they convert to dicts internally and feed dicts to the translators, so
_prepare_wire_messages round-tripped the whole history Turn->dict->Turn ~7-8x per send
(even on the no-op early-return paths). Make fold_system_turns / drop_empty_user_turns
/ repair_wire_messages dict-native (list[dict]->list[dict]); _prepare_wire_messages now
threads the dict projection _full_messages already produced straight through, with no
Turn round-trip. self.messages stays the canonical Turn trajectory. export.py is
simplified (it converted to dicts immediately after repair anyway). Equivalence-
preserving — test_wire_payload_golden stays byte-identical.
2026-06-04 11:03:13 -07:00
Patrick Buckley 3bf32d0649 refactor(core): lowering operates on canonical Turns
repair_wire_messages / fold_system_turns / drop_empty_user_turns take and
return list[Turn] — the neutral lowering layer (A representation + B validity)
now speaks the canonical type.  Their intricate content-merge / orphan-detect
internals run over the dict projection (reading Turn content blocks would only
duplicate turn_to_dict's content logic), so each bridges
dicts_from_turns ↔ turns_from_dicts at its boundary; byte-identical.

ChatSession._prepare_wire_messages lifts the wire dicts into Turns, runs the
lowering passes, and lowers the result back to the dict projection the provider
translators (the C layer) consume — the dict bridge now lives in the wire layer,
not in _full_messages.  Export runs the same repair, reordered before the
non-canonical reasoning-content attach (a key the Turn model does not carry).

The provider translators keep their dict input by design: they are the format
layer that emits provider bytes, the vLLM reasoning-attach is a non-canonical
wire concern that sits between lowering and the provider on dicts, and feeding
the converters the lowered projection is equivalent to — and simpler than —
threading Turn content through them.  Wire harness byte-identical; full
non-live suite green (7130).
2026-06-04 11:03:13 -07:00
Patrick Buckley dbf8d88a73 refactor(providers): unify orphan tool-call repair into one send-time pass
Synthesizing a cancellation result for an assistant tool_call with no
matching tool result was triplicated across the translators: Anthropic's
verbatim-replay (pc_tool_ids) and rebuild branches, and sanitize_messages
for the OpenAI-compatible lanes (Chat, Responses, Google). The Anthropic
pc_tool_ids branch was also the sole repairer of a native tool_use orphan.

Lift it to one neutral policy — lowering.repair_wire_messages — run once in
ChatSession._prepare_wire_messages before the translator. It reads tool_calls
only, which is sound because the native/tool_calls mirror is enforced at save
(normalize_native_for_save): a verbatim-replay orphan is caught via its
mirrored top-level call. The translators become pure format translation and
carry no orphan synthesis.

The neutral cancellation turn carries is_error=True; Anthropic renders it on
the tool_result block, the OpenAI-compatible tool message has no such field
so sanitize_messages drops it (the C-layer translation of the flag).

sanitize_messages keeps one orphan synth of its own: a back-filled empty-id
tool_call (local servers that omit ids) is id-less when the upstream repair
runs and so invisible to it, so that lane owns its cancellation — preserving
the pre-refactor behavior for local servers.

reconstruct's load-time strip and the runtime-cancel persist-synth are
unchanged. Proven byte-identical against the per-provider wire-payload golden
harness (including a new native_orphan fixture); the harness applies the same
send-side repair the session does.
2026-06-04 11:03:13 -07:00