Migrate the wire format to upstream 0.1.9 and clear the defect backlog (0.2.0) - #9
Merged
codeitlikemiley merged 62 commits intoAug 2, 2026
Conversation
The schema was hand-transcribed from upstream 0.1.1 and had drifted eight
releases. scripts/gen_proto.py now decodes the serialized FileDescriptorProto
out of any wheel's localharness_pb2.py and renders it, so the schema is
generated rather than retyped -- which is how UsageMetadata's counters ended up
as int32 where the harness has always declared uint64.
Two deliberate divergences, documented in the script: rendered as proto3 rather
than editions, because prost-build 0.12 cannot parse editions; and every
singular field marked optional, because the step classifier and the request
dedup are built on .is_some() and losing presence would turn "absent" into
"present and zero". content.proto is not vendored -- the only fields
referencing it are ToolCall.arguments and ToolResponse.response, which neither
SDK populates.
build.rs sets pbjson's ignore_unknown_fields, so a newer harness adding a field
no longer discards the entire event. It does NOT cover unknown enum variants,
which is why the idle rename below had to come from regenerating.
The regeneration produced 16 compile errors -- the intended forcing function.
Resolved here:
- HarnessConfig.gemini_config (field 2) is gone; src/harness_config.rs now
builds the repeated ModelConfig models (field 15) that replaced it in
0.1.4, emitting the two-entry TEXT+IMAGE shape upstream produces. The
public GeminiConfig type is unchanged: this gets the wire right without an
API break, leaving the ModelTarget rewrite to WP-4.
- UsageMetadata counters int32 -> u64, matching the proto.
- Added ClientInfo.os/os_version, InputConfig.env, Tool.defer_loading, the
three HarnessSideTools configs, ToolResponse.error_message and
ActionGenerateImage.aspect_ratio. Fields belonging to later work packages
are explicitly unset with a comment naming the package, so the compiler
flags them again when those land.
- Removed GenerateImageToolConfig.model_name, reserved upstream in 0.1.4.
- Handled the three new OutputEvent arms. call_hook_request logs a warning
that the harness may stall, because arriving there means enabled_hooks and
the router have gone out of sync and we cannot yet answer it.
Both mocks spoke the pre-0.1.9 dialect and the suite caught it exactly as the
audit predicted a real harness would behave: STATE_IDLE was renamed
STATE_FULLY_IDLE, protojson matches on the value name, so the idle event was
dropped as an unknown variant and test_wasm_connection_integration_mock hung
forever instead of ending its turn. STATE_TERMINAL_ERROR = 5 was likewise
removed upstream in 0.1.3; a failing step now reports STATE_ERROR.
The SDK still does not read the initialize_conversation_response handshake
frame (WP-6), so it cannot yet complete a connection to a real 0.1.9 harness.
Verified with the CI toolchain: clippy --all-targets --all-features -D
warnings, fmt --check, and 129 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
The mock now sends initializeConversationResponse after reading the init event. Since 0.1.4 that is the harness's mandatory first frame, and upstream's client blocks on it before doing anything else. The SDK still does not read it -- that is WP-6 -- but a mock that never sends it keeps certifying a handshake no real harness performs. Adds a scripted tool-confirmation scenario: on a trigger_tool_confirmation: <path> prompt the mock issues a VIEW_FILE step carrying toolConfirmationRequest in STATE_WAITING_FOR_USER, waits for the client's ToolConfirmation, and reports the accepted flag back in the turn's text. That closes the coverage gap flagged when compose_policies was extracted. The unit tests show the right policies are composed; they cannot show the resulting enforcer is registered on the hook runner and consulted when the harness asks to run a tool. Three integration tests now assert the decision from outside the SDK, with allow_all() configured -- the policy set that used to switch the sandbox off entirely: - a path outside the workspace is denied - a path inside it is allowed (so the deny cases cannot pass vacuously) - a ../.. escape out of the workspace is denied If the enforcer were not registered, `allow` would default to true and both deny cases would fail. Covers the confirmation path only, which is the sole pre-tool gate the 0.1.1 wire has. Gating built-ins that arrive without a confirmation request needs the harness-side hook channel (WP-8). Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, and 132 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
One list of everything still to do, derived from the migration plan and the fix plan, with what has landed struck off. Each row is a unit of work that can be picked up on its own, with its blockers and the plan section to read first. Records the headline plainly: 12 items are done, and the SDK still cannot connect to a 0.1.9 harness, because the mandatory first frame is never read. WP-6 is the single unblocker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
… (WP-6)
The client sent InitializeConversationEvent and moved straight on. Since 0.1.4
the harness answers it with an OutputEvent carrying
initialize_conversation_response, and upstream blocks on that reply before
doing anything else -- so the frame was left for the step reader, where it is
not a step. connect() now reads it first, with a 10s bound so a pre-0.1.4
harness (the version scripts/install_harness.sh still pins) keeps working, and
a parse failure reports that the harness is probably a different version than
the proto was generated from.
A resumed conversation's replayed history is mapped through the new
step_extract::step_from_update and exposed as
LocalConnection::initial_history(). Seeding Conversation from it is the
remaining half of WP-6. cascade_id from the response is deliberately ignored:
upstream takes the conversation id from the first StepUpdate's trajectory_id.
Adds SessionContinuationMode (Resume / CreateOrResume / CreateOnly) on
AgentConfig and the builder, emitted as HarnessConfig field 19. This is not
cosmetic -- scripts/probe_harness.py against the real 0.1.9 binary shows a
caller-supplied conversation_id is REJECTED without it:
models[], caller id, no sessionContinuationMode -> REJECTED
Failed to create agent: conversation "..." not found (cannot resume)
models[], caller id, CREATE_OR_RESUME -> ACCEPTED
so Agent::builder().conversation_id(..) and examples/persistence.rs land
exactly on the failing case. Resume without a conversation_id is rejected at
startup, mirroring upstream's config validator.
Verified with the CI toolchain: clippy --all-targets --all-features -D
warnings, fmt --check, and 132 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
…, C5) disconnect() was a bare kill(). SIGKILL runs no Go defers, so the harness never runs cleanupAllAgents and the trajectory is never written to disk -- upstream's own tests say so in as many words (local_connection_test.py:3110-3130), and it also pre-breaks session resumption, which is the whole point of the handshake history added in the previous commit. It now closes the retained child stdin first -- the harness monitors stdin for EOF, and that is the actual shutdown signal -- then waits three minutes for a clean exit before escalating, mirroring local_connection.py:407-455. The child_stdin handle was already being stored on the connection and never used. C5: StepTracker::update_state now clears handled_requests when the step leaves STATE_WAITING_FOR_USER. The dedup set otherwise persists across request rounds, so a re-asked question is never answered a second time and the harness waits forever. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, and the full suite passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
The handshake blocker is closed; WP-5 (the idle state machine) is now what stands between the SDK and a completed turn against a real harness. Records what landed and what remains of WP-6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Approves every read-only tool and asks the user for anything else, mirroring upstream safe_defaults() (policy.py:371-384 at 0.1.1). The specific APPROVE rules land in a higher-priority bucket than the trailing wildcard ASK_USER, so a read-only tool is never prompted for. C2 (a fresh connection reporting is_idle == true) was attempted alongside and reverted: it terminates receive_steps() on its first poll, because that path still treats 'idle and queue empty' as end-of-stream. It has to land with the sentinel restructure in WP-5. A NOTE at both call sites and a row in the backlog record why. This is the C2/C3 ordering hazard the conflict pass predicted in fix-plan section 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
The L and XL rows were too large to pick up safely, and two of them restructure code both transports share. Section 6 splits them into batches sized to one commit with the tree green at the end, each with a 'done when' that can be checked without re-reading the plans. Records two ordering rules that are load-bearing rather than advisory: C2 must land with the sentinel restructure (A2), and enabled_hooks must be emitted last in Phase E, because turning it on before the router exists converts a silent no-op into a mid-turn deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Idle was decided by a parent_idle flag plus a set of active subagent ids -- the 0.1.1 shape, which upstream deleted in 0.1.6 in favour of "only the main trajectory counts; subagent trajectories return early" (event_processor.py:539-542). The bigger defect was how the main trajectory was identified. It was learned only from a StepUpdate whose cascade_id equalled its trajectory_id, a condition upstream does not impose. When no such step arrived -- a resumed session, or a subagent reporting first -- nothing was ever learned, is_subagent was false for everything, and a subagent going idle ended the caller's turn. It is now learned unconditionally from the first StepUpdate carrying any trajectory_id. The id also lives on the connection now and is cleared by send(), mirroring upstream's reset_for_turn(). It previously lived only inside the reader task, so a second turn running on a different trajectory would have been judged against the first turn's id and never gone idle. Removes parent_idle, active_subagent_ids and their plumbing. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, and the full suite passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Code-level context for the five batches on the critical path, so they can be executed without re-deriving anything: current shapes with file:line, the upstream loop that specifies the target, the regression test that proves each batch, and the standing rules (CI toolchain, both transports, the lint config). Records why A2 and C2 are one batch rather than two -- C2 alone was tried this session and reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
… part 1) receive_steps() ended the stream as soon as it saw the idle marker while is_idle was set, so anything already queued behind it was discarded. Upstream's loop (local_connection.py:338-360) terminates only when the connection is idle AND the queue is empty, and treats the marker as a `continue` -- the head condition is re-evaluated instead. Both transports now do the same. The idle arm also emitted a marker only on the first transition, because it gated on `!swap(true, ..)`. It now stores unconditionally and emits every time, matching event_processor.py:552-559. Applies A1's main-trajectory tracking to src/wasm.rs, which the previous commit only landed in src/local.rs -- the two files are forks and CI compiles neither the wasm target nor the docs, so the omission was invisible. parent_idle and active_subagent_ids are now gone from both. The "IDLE_SENTINEL" literal is now a single named constant rather than five scattered string comparisons. Keying the marker on a step id is still the wrong design -- a harness step carrying that literal id would be swallowed -- so the StepEvent enum, and C2 which depends on it, remain open as the rest of A2. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, and the full suite passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
…(B7)
CI ran fmt, clippy and `cargo test --all-targets --all-features`, which between
them compile none of these:
- src/wasm.rs as a wasm target. It is a fork of src/local.rs, and
--all-targets builds it only as a host test, so a change could land in one
and miss the other. That already happened: A1's main-trajectory tracking
shipped to local.rs alone and went unnoticed for two commits.
- doctests. --all-targets excludes them, so every ``` block in the crate docs
was uncompiled.
- examples/agent_server, examples/leptos_axum and examples/leptos_ssr_axum.
Each declares its own workspace, so the root build never sees them and an
API change does not break them until a user hits it.
The wasm step found a real break on its first run: compose_policies had been
introduced directly beneath the cfg gate meant for get_default_binary_path, so
it did not exist on wasm32 and Agent::start failed to compile there. The gate
now sits on the function that actually needs it.
Doctests and all three examples pass unchanged.
Verified with the CI toolchain: clippy --all-targets --all-features -D
warnings, fmt --check, test --all-targets, test --doc, and
check --target wasm32-unknown-unknown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
…t 2)
The step channel carried Result<Step, anyhow::Error> and signalled idle with a
Step whose id was the literal "IDLE_SENTINEL". A harness step carrying that id
would have been silently swallowed. The channel now carries
enum StepEvent { Step(Box<Step>), Error(anyhow::Error), Idle }
so the marker cannot collide with real data by construction. Step is boxed
because it is large and would otherwise set the size of every value on the
channel. All ten send sites across both transports are converted.
C2 (a fresh connection reporting is_idle == true) was attempted again on top of
this and reverted again -- for a different reason than the first time, which is
worth recording. The loop restructure did remove the first-poll hazard that
blocked it before. What remains is a connect-time race: a caller that polls
receive_steps() before the reader has seen the harness's STATE_RUNNING sees idle
with an empty queue and gets an empty stream. Upstream hides this by only ever
receiving after send(); this crate does not promise that ordering. The backlog
row now names the real blocker instead of the superseded one, and both call
sites carry a NOTE.
Verified with the CI toolchain: clippy --all-targets --all-features -D
warnings, fmt --check, test --all-targets, test --doc, and
check --target wasm32-unknown-unknown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Two states the harness reports and this crate discarded: TrajectoryStateUpdate.error (field 4) is now surfaced before the idle transition, mirroring event_processor.py:554-557. A turn that failed server-side previously just ended, indistinguishable from success. STATE_CANCELLED (value 3) is now handled at all. It arrived in the regenerated schema with nothing reading it, so a cancelled turn looked exactly like a completed one. It emits AntigravityError::Cancelled -- a new variant mirroring upstream's AntigravityCancelledError (0.1.2) -- carrying the harness's reason or "Turn cancelled", and then transitions to idle so the stream still ends. Both transports. Remaining in these batches, recorded in the backlog rather than implied done: a client-side cancel() that sets a flag so a caller-initiated halt also surfaces as Cancelled (send_halt_request already exists on the trait; only the flag and the mapping are missing), plus the ActionError.error_message fallback and the harness-crash stderr tail from A4. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, test --all-targets, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
A step can carry ActionError{error_message, http_code} while its top-level
error_message is empty, which reported the failure to the caller as a blank
string (audit C14). It now falls back to the action's own message.
Adds prompt control-character sanitization, upstream _sanitize_prompt
(local_connection.py:219-229, added 0.1.8). Tab, newline and carriage return
are kept -- they are meaningful in a prompt; the rest of C0, DEL and the C1
range are stripped, the last of which arrives from mis-decoded input rather
than from a user. Lives in the shared module so both transports get it from
one place.
Verified with the CI toolchain: clippy --all-targets --all-features -D
warnings, fmt --check, 128 tests, and check --target wasm32-unknown-unknown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
dispatch_on_tool_error propagated a hook's error with `?`, so one broken error-recovery hook silenced every hook registered after it and replaced the tool's original failure with its own. Upstream logs and converts it into a denial (0.1.1 hook_runner.py:231-242); this now does the same, reporting "Error recovery failed: ..." rather than losing the chain. Adds a regression test asserting the failure is contained, the result denies, and no recovery value is returned. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 129 tests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Upstream builds these names from decision.value.lower() (policy.py:173,187), so Decision.APPROVE yields "approve". This crate emitted "allow", which meant a generated name did not match what upstream's tests pin. The name reaches tracing and Debug output rather than user-facing denial messages, which use the policy's message instead -- so this is a parity fix, not a behaviour change. Upstream's when/name arguments are covered by mapping Policy::when / Policy::with_name over the returned group; documented with an example on mcp_policies rather than widening three public signatures for options most callers do not pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
The hook only receives multiple-choice questions, so its response index is an index into that FILTERED list -- but the answers were written into an array sized by the UNFILTERED list, at the filtered index. Any question that was not multiple-choice therefore shifted every answer after it onto the wrong question. The variable was even named orig_idx, as though it were the original index. The original index is now carried alongside each filtered entry and used to place the answer. A hook returning more responses than there were questions previously indexed out of bounds, which in this crate (panic = deny) aborts the process; extras are now ignored. Both transports. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 129 tests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Group builders return Vec<Policy> and the individual ones return a Policy, so mixing them meant assembling the vector by hand. Upstream flattens nested sequences in a validator for the same reason (connection.py:138-159). Adds an IntoPolicies trait and AgentBuilder::policy_groups, which flattens. Additive: policies() keeps its exact signature, so nothing existing changes. The doctest caught that a mixed array needs homogeneous elements; the example groups the scalars into one vec rather than adding a From<Policy> for Vec<Policy> conversion to hide it. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 129 tests, and the doctests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
dispatch_session_end existed with no production caller, so a registered on_session_end hook silently never ran -- a regression against upstream 0.1.1, which dispatches it from disconnect() (local_connection.py:686-690), not just against 0.1.9. Dispatched before teardown so a hook can still observe a live connection, and a failing hook is logged rather than allowed to block shutdown. Both transports. Three of the four dead dispatchers remain (pre_turn, post_turn, on_compaction); they need the shared hook-dispatch module and are batches B2/B3. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 129 tests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
Agent::chat accepted an empty or whitespace-only prompt and sent it, so the harness ran a turn with no content. It is now rejected, as upstream does. conversation_id was unvalidated. The real harness requires at least 32 characters from [a-zA-Z0-9-] -- probe_harness.py against the 0.1.9 binary returns "cascade_id must be at least 32 characters long, got 5" -- so an invalid id surfaced as an opaque connect-time failure instead of a config error. Validated at start, mirroring connection.py:100-107. Two integration tests used 13-character ids. They passed only because the mock is laxer than the harness they stand in for, so they were asserting a configuration that could never have worked in production; both now use valid ids. This is the code enforcing a real constraint the tests were violating, not the tests being bent to fit the code. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, the full suite, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
tungstenite defaults to a 16 MiB frame and 64 MiB message cap. Tool results and file contents routinely exceed those, and hitting the cap kills the connection mid-turn rather than truncating the payload. Upstream passes max_size=None for exactly this reason (local_connection.py:1086-1092). Completes step-error-and-ws-limits; the ActionError.error_message half landed earlier with A4. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, the full suite including all 7 integration tests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
steps was built from history(), which returns every step in the conversation, so each reply carried the whole transcript. It grew without bound across a long session and made the field useless for "what just happened". turn_start_indices was already being tracked for exactly this; the slice now starts from the current turn's boundary. Upstream has no equivalent field, so there is nothing to match -- this is the field doing what its name says. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 129 unit tests, all 7 integration tests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
get_state followed by set_state releases the lock in between, so two tools running concurrently can both read the old value and one write is lost. This is the pattern X19 was going to publish as the flagship context-aware example, so it needed the safe primitive first. update_state holds the lock across the transform. Returning None from the closure leaves the entry untouched. Mirrors upstream's update_state (utils/state.py, added 0.1.7). The read-modify-write half is a free function so it can be tested at all: constructing a ToolContext requires a live connection, which is also why nothing in the crate currently constructs one (audit T1). The test races eight threads x 100 increments and asserts 800 -- a get/set pair loses writes there. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 131 unit tests, doctests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
docs/agent.md has linked policy.md for some time without it existing.
The shipped examples in docs/hooks.md used lowercase tool names --
policy::deny("run_command"), allow("read_file"), ask_user("run_command") --
which match nothing, because this crate's builtin identifiers are
SCREAMING_SNAKE. That fails silently: the policy never applies and a trailing
wildcard decides the call instead. Anyone copying those examples got the
opposite of the safety they were configuring.
The new page leads with that, since it is the most common mistake, and covers
the bucket precedence (first match in the highest bucket wins, so
[allow_all(), deny("RUN_COMMAND")] denies), the builders, workspace scoping
and its two deliberate divergences from upstream, predicates failing closed,
MCP targets, and what Agent::start rejects.
Custom-tool selectors elsewhere are left lowercase -- those are the names the
tools declare, and they are correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
ActionEditFile.diff_block was dropped when building the tool-call args, so a policy predicate on EDIT_FILE could see which file was being changed but not what the change was. A rule like "deny edits that remove a licence header" could not be written at all. Updates the wasm assertion that pinned the old shape. Remaining in this item, recorded in the backlog: SEARCH_DIR and RUN_COMMAND args still carry result fields (output, combined_output, exit_code) that are populated after execution, so a pre-tool predicate cannot rely on them. Verified with the CI toolchain: clippy --all-targets --all-features -D warnings, fmt --check, 131 unit tests, and check --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bo14to1fyGcU7BxBL4YZLv
A3. `Conversation::cancel()` halts the turn in flight. The harness answers a halt with an ordinary STATE_FULLY_IDLE rather than STATE_CANCELLED, so a caller-initiated halt was indistinguishable from a turn that simply finished. Both transports now carry a `cancel_requested` flag, set by `send_halt_request`, consumed by the idle transition, and cleared by `send()` so it cannot leak into the next turn. A4. The harness's stderr is retained as a 20-line tail instead of being logged and dropped. When the websocket closes before the turn reaches idle, the step stream yields an error quoting those lines — previously a crash and a clean end of stream looked identical to the caller. Also mirrors A1's main-trajectory rule into `src/wasm.rs`, which still learned the trajectory only when `cascade_id == trajectory_id` and so learned nothing on a resumed session, and adds a stdin-EOF watchdog to the mock harness: it never exited on its own, so every integration test paid the client's full 3-minute process-wait timeout. The suite goes from 360s to 0.2s.
…ct fallback (A5) `Conversation::seed_history()` pre-populates history from the steps the harness replays in its handshake reply, with turn boundaries recovered from the user-sourced steps — a resumed conversation previously looked brand new to the caller even though the harness had its full history. `step_from_update` now marks a finished model step addressed to the user as a complete response, which is what `last_response()` looks for. Also: `AgentConfig::env` reaches the harness on `InputConfig.env`; `save_dir` defaults to a per-conversation temp directory rather than being left unset; and the websocket connect alternates between `localhost` and `127.0.0.1`, since the harness binds the literal and a host that resolves the name to ::1 first would otherwise fail every attempt. Seeding is native-only for now. On wasm the reader loop is already running when the reply arrives, so `connect()` has no history to return; that needs the same blocking handshake read the local transport does, and is recorded in the backlog rather than half-done.
…tool (B6) Two `receive_steps()` streams shared one receiver, so each took roughly half the steps and neither caller saw a complete turn — with no indication anything was wrong. The connection now hands out one live stream at a time and answers a second subscriber with an error. The claim is released when the stream is dropped, which is what keeps the per-turn call working. `user_questions.enabled` was hardcoded true, so a caller who listed `enabled_tools` explicitly got the question panel with no way to turn it off. It is now `BuiltinTools::AskQuestion` like any other tool. Callers passing an explicit tool list must include `ASK_QUESTION` to keep the panel.
A hook could substitute a result and clear the error, so a tool that had failed was reported to the model as having worked and the step was downgraded from `Error` to `Done`. The model then built on a result that did not exist. Upstream narrowed this in 0.1.6 for the same reason. `on_tool_error` now returns `Option<String>`: the error text the model is shown, or `None` to leave it. The first hook with an opinion wins; a hook that itself errors is logged and skipped rather than replacing the tool's failure with its own. Recovery belongs inside the tool, where it can judge whether the fallback is honest. Breaking. The four documents that taught the old contract are rewritten.
Both were defined, documented and dispatched from nowhere — a `post_turn` hook simply never ran. `post_turn` fires at the terminal user-facing model step and takes the text rather than a `ChatResponse`: the dispatch happens inside the connection, where no `ChatResponse` exists yet, and building a partly-filled one there would have meant a second shape with the same name. `on_compaction` fires on the compaction step and receives the step, not just its summary — a hook that archives history needs the index and trajectory of what was replaced. Breaking on both signatures. Per the maintainer's decision recorded in the fix plan, this ships now and a further `Hook` break is still expected when `HookContext` lands; these release notes do not claim the trait is settled.
…lures (D2)
Each transport built the `ToolResponse` frame in three places, and the six had
drifted: some wrapped a non-object result, some did not, and none of them ever
set `error_message`. A failed tool therefore reached the harness looking like a
successful call whose payload happened to mention an error. `src/tool_wire.rs`
is now the only place that frame is built, and it sets `error_message`.
`ToolCall.server_name` and `ToolResult.server_name` disambiguate an MCP tool
from a local one with the same name — a policy predicate could not tell
`github/create_issue` from a client-side `create_issue`. `ToolResult.exception`
carries the failure as a `ToolExecutionError { message, tool_name, server_name }`
so a hook can route or count failures without parsing the message text.
Argument parsing moves into the same module, so both transports agree that
absent arguments mean an empty object.
A model that sent `"3"` where the schema said `integer` made the tool's serde deserialization fail, and the model was told its *tool* had broken when the argument was one conversion away from valid. `src/coerce.rs` converts against the tool's own `parameters_json_schema()`, recursing into arrays and nested objects and parsing a whole array or object that arrived as JSON text. Only unambiguous conversions are made: `"not a number"` for an integer passes through untouched, so a real type error still reads as one.
`examples/custom_tools.rs` gains a tool that keeps its counter in session state through `ToolContext::update_state` rather than in the process, which is the case that was unreachable until the context was actually constructed. Completes Phase D.
…e (B4) A caller who stopped reading mid-turn lost those steps entirely, and the next turn's boundary was recorded at the wrong index. `Conversation::send` now drains what is left of the previous turn into history first. The drain is gated on a turn having actually been sent: a freshly connected session reports not-idle until the harness says otherwise, and draining there would block on a stream with nothing to deliver — the same connect-time race that still blocks C2. `Connection::wait_for_idle` (and `Conversation::wait_for_idle`) replaces polling `is_idle()` in a sleep loop. It is watch-backed, so it resolves the moment the reader sees idle.
…uting (C1-C3) `ModelTarget`, `ModelEndpoint` (Gemini API / Vertex / Gemma) and `GeminiModelOptions` land as public types, and `ThinkingLevel` gains `ExtraHigh`. Its serde spelling is per-variant, not `rename_all = "lowercase"`: the blanket rule would have emitted `extrahigh`, which the harness does not recognise. `build_models_proto` implements upstream's merge: explicit targets first, then the shorthand model, then defaults — and a default is appended only if none of its model types is already covered. Deduplication is by model type, never by name, so two text models are legal. An explicitly-supplied target must carry its own endpoint, since the api_key/vertex shorthand attaches only to the shorthand and default entries; without one it is now an error rather than a silently endpoint-less entry. `GOOGLE_GENAI_USE_VERTEXAI` and `GOOGLE_GENAI_USE_ENTERPRISE` select the Vertex backend and `GOOGLE_CLOUD_PROJECT`/`_LOCATION` hydrate it — a caller whose environment said Vertex silently got the Gemini API. An env-only `GEMINI_API_KEY` still stays off the wire: the harness reads it from the environment it inherits. The explicit list is `GeminiConfig::model_targets`, since `models` is already the crate's shorthand form. That name is a deliberate divergence from upstream.
`mcp_server(...)` was a no-op. The builder accepted servers, both strategies stored them, and nothing ever wrote `HarnessConfig.mcp_servers` — so the model never saw a single MCP tool, and an MCP policy guarded something that could not be called. Stdio servers gain `env` and `timeout_seconds` to match the 0.1.9 proto. SSE and HTTP both map to the proto's single HTTP transport, which is what the harness expects — it negotiates the streaming style itself. The HTTP variant's existing float `timeout` supplies the whole-second field.
`SEARCH_WEB` and `READ_URL_CONTENT` become `BuiltinTools`, gate their own harness configs and classify as tool calls, so a policy can see them; they were previously left to the harness's own default with no way to turn either on or off. `read_only()` gains `READ_URL_CONTENT`, matching upstream 0.1.6 — fetching a URL reads, it does not write. `SubagentConfig`/`SubagentCapabilities` reach `HarnessConfig.custom_subagents` with upstream's three validations: capabilities default to the read-only built-ins, `START_SUBAGENT` is dropped with a warning because the harness does not support nested subagents, and naming a client-side tool the agent has not registered is an error rather than a subagent that silently cannot call it. Completes Phase C.
`Hook::declares()` returns the `HookKinds` an implementation wants the harness to call — the prerequisite for `HarnessConfig.enabled_hooks`. It is opt-in and defaults to nothing, and it does not affect local dispatch. `HookKinds::ALL` covers exactly the seven `LifecycleHook` members: `on_interaction` and `on_compaction` have none in 0.1.9, so they stay local-only rather than being given invented values. `enabled_hooks` is still not emitted. Doing so before the router exists turns a silent no-op into a mid-turn deadlock, so it stays last in the phase. `src/state.rs` holds the session store both contexts are now built on. `HookContext` and `ToolContext` each carried their own map and their own read-modify-write, the two had drifted — only one had an atomic update — and neither could be tested without constructing the context that owned it. The two stores remain separate data: sharing the type is not sharing the state.
This crate fell three minor versions behind upstream — including a wire-breaking enum rename that silently ended every turn — because nothing was watching. `scripts/check_upstream_drift.py` asks two questions: is there a release newer than the pinned one, and does the proto regenerated from that release differ from the checked-in one? The second is the one that matters; a version bump with no schema change is a five-minute pin update, a schema change is a migration. It runs weekly and advisory on pull requests — a drift is not caused by the diff under review, so failing a contributor's PR for it would be noise. Network failures report SKIP and exit 0: a flaky runner must not read as a drift. Verified against the live 0.1.9 release, which surfaced a real trap — the generator stamps its source directory into a `// Source:` header, so a naive comparison reports drift on every run and trains everyone to ignore the job. That line is excluded.
…(E4, E5) The `CallHookRequest` arm logged a warning and dropped the frame. The harness blocks its turn until a `CallHookResponse` with the matching request_id comes back, so any harness that sent one would have stalled — which is why `enabled_hooks` could not be emitted at all. `answer_hook_request` routes pre_turn, pre_tool, post_tool, post_turn and on_tool_error to the local hooks, and answers on **every** path, including requests it does not understand: those get `error_message`, which the harness treats as a hook failure. Not answering is a deadlock, not a no-op. Deny semantics match the local gates — a hook that errors refuses. Rewriting the model's arguments is not offered, so `modified_arguments_json` comes back unset rather than echoed back as if it had been considered. With the router in place, `HarnessConfig.enabled_hooks` carries exactly what registered hooks declared through `Hook::declares()`. E5 was ordered last for this reason, and `test_harness_hook_request_is_answered` pins the guarantee: the mock blocks exactly as the harness does, so a regression times out rather than passing quietly.
Tried twice and reverted twice before. The first attempt hit a first-poll hazard that the `receive_steps()` loop restructure removed; the second hit a connect-time race where a caller polling before the harness reported STATE_RUNNING saw idle with an empty queue. What closes it is the contract rather than the flag: `send()` clears idle before the prompt goes out, so send-then-receive — what `chat()` and `Conversation` do — can never observe the gap. Subscribing before sending anything now yields an empty stream immediately instead of blocking forever on a turn that was never started. That is the better failure mode, and it is the one upstream has. The wasm in-file mock exercised the old order, pushing a step before any prompt existed. It now waits for the prompt, like every real harness does.
The native transport reads the handshake reply inline, before spawning its reader. The wasm transport shares one socket and has no split stream to read from, so the reply arrives on the reader task: it is published on a watch channel there, and `initial_history()` awaits it with the same 10-second budget. A pre-0.1.4 harness never answers, the wait expires, and the session starts empty — which is correct for one. `DebugConfig` is dropped from scope rather than invented: it has no field in the 0.1.9 proto, so there is nothing to port until upstream ships one. Completes Phase A.
`disconnect()` closed stdin and waited for the process without ever telling the harness the session was over, so shutdown raced the harness's own trajectory write. It now sends `session_end_request` and waits for the acknowledgement, bounded at ten seconds — a harness that never answers must not hold shutdown open, and closing stdin stops it regardless. The wait is skipped once the reader has seen the socket close: a crashed harness will never answer, and blocking on it would have added the full timeout to every teardown after a crash. That was not hypothetical — it showed up immediately as the crash-diagnostics test taking ten seconds. The mock answers the request, as a real harness does, so the path is exercised rather than merely written.
A `post_tool_call` hook received whatever display text the harness put on the step, so a hook that wanted a command's exit code had to parse prose and one that wanted a fetched page's location could not get it at all. `src/tool_output.rs` builds a per-tool object instead: `RUN_COMMAND` reports `exit_code` and `combined_output`, `READ_URL_CONTENT` reports `content_path`, `EDIT_FILE` reports its `diff_block`. Tools whose payload really is display text get it under a named key rather than as the whole result. Modelled as JSON rather than a typed enum per tool, because `ToolResult::result` is a `Value` and an enum would force every hook to match its way to one field. Anything unrecognised still falls back to the step text. Completes Phase B.
The wasm mock kept its socket open for a fixed 50ms after its last frame and hoped the client had finished. It hadn't always: the test failed once in a full run and passed in isolation, which is the shape of a flake that would eventually land on someone else. It now serves until the client tears down, answering the session-end handshake the way the localharness mock does, and the test disconnects explicitly rather than relying on a drop racing a sleep.
All nine `Hook` methods now receive `&HookContext`. The runner owns one session-scoped store and hands it to every dispatch, so a hook can record something in `on_session_start` and read it back in `pre_tool_call` without carrying state of its own — which previously meant an `Arc<Mutex<..>>` per hook and no way to share anything between them. `HookRunner::context()` exposes the same store, so a caller can seed it before starting or read what hooks recorded afterwards. This is the second `Hook` break. It was expected: the release notes for the first one said a further break was coming rather than claiming the trait was settled. All 25 implementations across the crate, tests, both examples and the skill documents are updated in this commit — a half-applied trait change compiles in some crates and not others, and the directory examples have their own workspaces, so they are checked here too.
`Content`, `ContentPrimitive` and `Media` were public, documented types that reached nothing: a caller could build one and had no way to send it, because every path went through the plain `user_input` string, which cannot carry an attachment. They now go out as `complex_user_input`, and `ContentPrimitive` gains a `SlashCommand` variant so a caller can invoke one the harness expands. `Connection::send_content`, `Conversation::send_content` and `Agent::chat_content` are the entry points; the turn bookkeeping — trajectory reset, subagent-response clear, previous-turn drain — is identical to the text path rather than a second half-implementation of it. Text parts go through the same control-character strip as a plain prompt: a multimodal path that skipped it would have been a way around it. An empty prompt is rejected whichever form it takes. Also `Conversation::last_structured_output()`, which is what a `response_schema` produces — reaching it meant walking history backwards for the right step type. Completes Phase E, and with it every batch in docs/remaining-work.md.
`HarnessConfig.retry_config` and `.tool_output_truncation` were hardcoded to `None` with no way for a caller to set either, so the harness's retry behaviour could not be tuned and a tool that produced too much output was handled however the harness chose. Both are emitted only when actually configured. Upstream omits the retry message entirely when empty, and sending an all-empty one would replace the harness's own defaults with zeros — a worse outcome than not sending it. Correcting the backlog while I am here: this row was credited as landed in #9, but that referred to a different C5 — the `StepTracker` dedup from the conflict-pass list. The retry and truncation config had never been written.
`RUN_COMMAND` args carried `combined_output` and `exit_code`, and `SEARCH_DIR` carried `output` and `num_results`. Those are results: a `pre_tool_call` predicate reading them saw them null, because the command had not run yet, so a rule like "deny commands whose output contains a secret" silently allowed everything. They reach `post_tool_call` on the `ToolResult` instead, via `tool_output::structured_result`, which is where a hook can actually use them. Closes the last open row in docs/remaining-work.md.
The backlog described `StepEvent::{Step, Idle, Close}`. What shipped is
`{Step, Error, Idle}`: the reader dropping the channel already ends the stream,
so `Close` would have been a second way to say the same thing, whereas errors
needed a variant of their own to reach the caller at all.
Bumps the crate to 0.2.0. This branch is not a patch release: it threads a HookContext through every Hook method, makes pre-tool gating fail closed, narrows on_tool_error so it can no longer clear an error, turns ASK_QUESTION into a real tool, and changes ChatResponse to report the turn rather than the session. CHANGELOG.md lists each break with the reason, and states plainly that a further Hook break is expected rather than implying the trait has settled. Also corrects docs/remaining-work.md, whose prose had fallen behind its own tables: WP-5's row was never struck through even though A1-A4 delivered it, a blockquote still called WP-5 the blocker for completing a turn, a paragraph still listed the finished WP-6 remainder, and a standing caveat still said CI compiled neither the wasm target nor the docs -- which B7 fixed early in the branch.
codeitlikemiley
marked this pull request as ready for review
August 2, 2026 17:01
The changelog and the D8 note both carried the plan's caveat that a further Hook break was expected. That was true when written and stopped being true when E3 threaded HookContext through every method in this same branch -- which is the option the note itself called preferable, because downstream then breaks once rather than twice. Both now describe what shipped: two Hook breaks, both in 0.2.0, none outstanding.
install_harness.sh pinned 0.1.1 for the whole migration. The SDK now speaks 0.1.9, so the harness it handed developers could not complete a turn with it: STATE_IDLE was renamed STATE_FULLY_IDLE, and protojson drops the unknown variant rather than failing, so the turn simply never ends. Two version pins existed -- this one and PINNED_VERSION in check_upstream_drift.py -- and nothing compared them, which is how they sat eight releases apart unnoticed. The drift job now fails when they disagree; verified by making them disagree and watching it exit 1. Also freezes the four planning documents. Their tables were struck through but their prose kept describing finished work as pending, which caused two wrong statements today. They now carry a banner saying they are historical, describe what was planned rather than what shipped, and that CHANGELOG.md is the only document tracking current state. docs/policy.md's forward reference to S8 is reworded: that divergence is standing, not pending.
The changelog read as though 0.2.0 had shipped. It has not: publishing waits on a live-harness turn, and the release workflow fires on a v* tag rather than on merge, so the entry needs to say so rather than implying a release happened.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrates the harness wire format from upstream 0.1.1 to 0.1.9 and clears the audit backlog. Releases as 0.2.0.
The canonical description of this change is
CHANGELOG.md— every break, with its reason and exact type signatures. It lives in the diff, so it is reviewed with the code and cannot be garbled in transit the way this field can. What follows is a summary.Why this was needed
proto/localharness.protohad been hand-transcribed from 0.1.1 and had drifted eight releases. It is now generated from the descriptor in the upstream wheel.The transport is protojson, which matches on field and enum names, so a rename is as fatal as a renumber — and it fails silently, because the frame is dropped as unknown rather than rejected. Two cases the test suite was certifying as working:
HarnessConfig.gemini_configwas removed upstream in 0.1.4. I first reported this as "the harness silently ignores it". That was wrong: probing a real harness showed it rejects the frame and closes the socket.STATE_IDLEbecameSTATE_FULLY_IDLEin 0.1.9. The idle event was discarded as an unknown variant, so a turn never ended.Security fixes
..collapsed and symlinks followed, and resolution failure treated as outside. It fails closed.allow_all()no longer disables workspace scoping, which upstream documents as the way to get shell access while file tools stay scoped.Agent::startbypassedpolicy::enforce()— policies were composed but never consulted./tmp/.gemini/antigravitywhenHOMEis unset.What now stops this recurring
CI compiles the wasm target, the doctests and the directory examples — it caught a wasm-only break on its first run, and two further wasm drifts surfaced afterwards, because the two transports are forks of each other.
A drift job regenerates the proto from the live wheel and fails when it disagrees with the checked-in schema. It also now fails when
install_harness.shand the proto pin disagree — they had, by eight releases, so the install script was handing developers a harness this SDK could not finish a turn against.Breaking changes
Hookbreaks twice here, deliberately: the signature changes, and then a context parameter on all nine methods. The second was scheduled for a later release and pulled forward so downstream is edited once, not twice. Nothing further is outstanding, though this is pre-1.0 and that is not a stability guarantee.Three changes fail silently rather than at compile time, and are the ones to read before upgrading:
pre_tool_callused to let the tool run; it now blocks itenabled_toolslist withoutASK_QUESTIONturns the question panel offChatResponse.usage_metadatais now per-turn and optional, where it was the session totalNot covered
Verified against the mock harness and a probe of a real one. A full turn against a live 0.1.9 harness is not part of this branch — that needs credentials, and is planned before publishing to crates.io.
DebugConfigis dropped rather than deferred: it has no field in the 0.1.9 proto.200 unit tests, 12 integration, 6 doctests; clippy
-D warnings, fmt, wasm target and the directory examples green on CI's toolchain.