Skip to content

Migrate the wire format to upstream 0.1.9 and clear the defect backlog (0.2.0) - #9

Merged
codeitlikemiley merged 62 commits into
mainfrom
claude/antigravity-python-upstream-changes-ux2sc1
Aug 2, 2026
Merged

Migrate the wire format to upstream 0.1.9 and clear the defect backlog (0.2.0)#9
codeitlikemiley merged 62 commits into
mainfrom
claude/antigravity-python-upstream-changes-ux2sc1

Conversation

@codeitlikemiley

@codeitlikemiley codeitlikemiley commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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.

This PR grew well past its original scope. It opened as WP-1 + WP-2; the maintainer chose to keep the work on one branch so downstream breaks once.

Why this was needed

proto/localharness.proto had 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_config was 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_IDLE became STATE_FULLY_IDLE in 0.1.9. The idle event was discarded as an unknown variant, so a turn never ended.

Security fixes

  • Workspace sandbox escape — containment is decided after resolution, with .. 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::start bypassed policy::enforce() — policies were composed but never consulted.
  • Pre-tool gating failed open — a hook that errored let the tool run, so any hook bug was an open gate.
  • The workspace root no longer falls back to /tmp/.gemini/antigravity when HOME is 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.sh and 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

Hook breaks 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:

  • a hook returning an error from pre_tool_call used to let the tool run; it now blocks it
  • an explicit enabled_tools list without ASK_QUESTION turns the question panel off
  • ChatResponse.usage_metadata is now per-turn and optional, where it was the session total

Not 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. DebugConfig is 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.

claude added 2 commits August 2, 2026 04:06
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
@codeitlikemiley codeitlikemiley changed the title Regenerate the harness proto against upstream 0.1.9 (WP-1) Regenerate the harness proto against 0.1.9, and prove the sandbox end to end (WP-1, WP-2) Aug 2, 2026
claude added 27 commits August 2, 2026 07:21
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.
claude added 24 commits August 2, 2026 14:09
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 codeitlikemiley changed the title Regenerate the harness proto against 0.1.9, and prove the sandbox end to end (WP-1, WP-2) Migrate the wire format to upstream 0.1.9 and clear the defect backlog (0.2.0) Aug 2, 2026
@codeitlikemiley
codeitlikemiley marked this pull request as ready for review August 2, 2026 17:01
claude added 3 commits August 2, 2026 17:04
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.
@codeitlikemiley
codeitlikemiley merged commit 25ef48a into main Aug 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants