Skip to content

io-cli 0.26.0 - #66

Merged
aakashpawar1999 merged 11 commits into
mainfrom
develop
Aug 28, 2026
Merged

aakashpawar1999 merged 11 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Release pull request for io-cli 0.26.0.

develop → main, carrying PR #65 and nothing else — git diff --quiet feat/0.26.0 develop is clean, so the tree every gate ran on is the tree that ships.

What ships

  • /effort low|medium|high|off — how much reasoning a turn buys, held as a posture across turns and across a /resume, with its absence sending no reasoning field at all. /profile moves to the session to make room, a re-file rather than a widened bound.
  • [app.io-cli.routing] — escalate after consecutive failed gates, downshift while a run has written little. io-harness evaluates the rules; a rule that could only misfire is refused by name. Inert under containment, disclosed at three surfaces, filed upstream.
  • The provider chain runs — the list /provider has drawn since 0.21.0. A retryable failure falls through; a bad credential does not. Behaviour change for a file with more than one [[provider]] entry.
  • An answered turn says so, and [app.io-cli] conversational asks that every prompt open a run.

Gates, on this tree

  • Suite 90 binaries / 1,362 passed / 0 failed / 33 ignored (0.25.0: 89/1,315/31)
  • Live 33/33, 243s against a real provider (0.25.0: 30/30)
  • All 8 checks green on PR io-cli 0.26.0 #65, including the full three-platform matrix — the gate the sealed record honestly records as unrun at seal time
  • Nightly rustdoc gate exit 0, run twice; clippy and fmt clean
  • Eleven sabotage arms, each killing only its own criterion's gates

The review

Two adversarial reviewers, split by area, against a fully green suite — ninth consecutive release it has found real defects. Three HIGH, five MEDIUM, one LOW, all fixed; both reviewers independently found the same HIGH. A fourth defect was found by tracing callers: a public function with sixteen tests and no caller anywhere.

io-harness pin unmoved at 0.69 — newest published, satisfied by doing nothing, the twelfth time.

Record sealed and pinned at sha256:b7d20acc…, ultraship validate exit 0 over 67 checks.

`config.fallback_specs()` has been read, rendered by `/provider` and then
thrown away: `provider::build` took the head spec and nothing else. An
operator with three providers in their file was shown a promise the product
did not keep.

`Vendor` is one enum over the four vendor types, so a chain of arbitrary
length has the single concrete type a non-dyn-compatible `Provider` requires.
It wraps and never constructs — the four constructors stay written exactly
once, in `maker_for`, which is what the one-construction-site gates assert.

`Chain` is flat rather than nested over `io_harness::Fallback`. The nested
shape does not compile: a `Fallback<Vendor, Chain>` makes the RPITIT future's
auto-trait inference depend on itself, and rustc reports a type cycle that
boxing the recursive arm does not break. The decision to fall over is still
io-harness's — `ProviderErrorKind::is_retryable`, the same predicate its own
`Fallback` and its own in-run retry ask.

`last_served()` answers `None` when the head served, which is a deliberate
difference from `Fallback`. io-harness emits `EventKind::FellBackTo` for any
`Some` (`run/step.rs:503`) while `Fallback::last_served` answers `Some` for
its own primary too, so a chain built on that type would have told the
operator "the provider fell over" on every step of every run.

`--provider` replaces the whole chain rather than heading it: a run scoped to
one vendor on the command line must not spend at another.
…e to make room

`TaskContract::with_effort` has been public and unused since io-harness gained
it. `/effort low|medium|high` puts a level on every later turn, `/effort off`
returns to sending no reasoning field at all, and a bare `/effort` reports.

The absent case is not a fourth level. `TaskContract::effort` is an
`Option<Effort>` and `None` sends the pre-0.31.0 request body byte for byte, so
`contract::buying` leaves the contract Debug-identical when nothing was asked
for — asserted through the same instrument that holds a configured-nothing
session to the contract it built before.

`Turn` stood at ten of ten, and `src/commands.rs` pre-committed the answer for
the release that met the bound: re-file what is in the wrong group, do not
widen it. `/profile` was misfiled by that group's own rule — it changes the
overlay every later turn is built from rather than acting on the work the turn
just finished — so it moves to `Session` and `/effort` takes the slot.

The level is a posture and not a one-shot: it is read by each turn rather than
taken, so it survives to the next one, and it survives `Status::forget_run`
beside `policy` and `planning`. It is drawn by both renderers, because a field
added to `Status::fields` alone is green in a unit test and nowhere the binary
draws.

The decisions live in `contract::buying`, `app::reasoning_of` and
`app::reasoning_said` rather than in the driver, because nothing under `tests/`
links `src/main.rs` and a conditional written there could be neither asserted
nor sabotaged.
…re they do not fire

`[app.io-cli.routing]` carries two rules — escalate to a stronger model after a
number of consecutive failed gate attempts, downshift to a cheaper one while the
run has written little. The decision stays io-harness's: `Routing::model_for` is
its own pure function and the only implementation of it, and this crate never
re-derives the rules or their precedence.

The section is io-cli's own `Deserialize` type rather than `io_harness::Routing`
directly, which is the one place it differs from `[app.io-cli.containment]`.
`Routing` derives no serde at all and is `#[non_exhaustive]`, so it can neither
be deserialized into nor written as a literal; the three builders are the way in
and they keep working when the harness grows a fourth field.

`require_primary` is not offered. It gates on `Provider::reachable`, a defaulted
trait method returning `Ok(true)` that no provider in io-harness 0.69 overrides,
so a key for it would be advertised, accepted and permanently inert.

And routing does not reach a contained turn. io-harness applies it only in the
flat workspace loop; the tree loop takes each agent's model from its own roster
entry. So a session with containment configured is told, once, at startup — and
a session without it is told nothing, because warning every operator with a
routing section about a limitation most of them cannot hit is worse than warning
none.

Alongside it, two smaller repairs to what a turn says about itself.
`[app.io-cli] conversational` is the first way to say that a question which is
only a question should still open a run; absent, the decision stays io-harness's
own, which is what it has always been. And `TurnResult::kind` is read at last:
a turn answered in one completion, with no steps and no tools, has always
arrived as silence, because every line this product draws about a turn comes
from events such a turn never emits.
… assembly

Three of this release's criteria name a sabotage with no site a test could
reach. Two are in `src/main.rs`, which nothing under `tests/` links: whether the
effort level is read by each turn or taken from the session — the difference
between a posture and a one-shot, and the whole of what `/effort` promises — and
whether the driver asks `TurnResult::kind` or infers an answered turn from a
step count, which would call a turn cancelled before its first step a
conversation. Both are gated by reading the driver as text, which is a weak
instrument and the only one available; four gates of the same kind already exist
for the same reason.

The third was in `provider::build`. Every existing test drives `Chain` directly,
and a `build` that folded the chain from the fallback specs alone would produce a
perfectly working chain of the operator's second choice. `Probe` implements
`WithProvider` so the assembly itself can be asserted: which model the chain
asks first, and that every link's host reaches the egress policy.

All three were confirmed by executing the sabotage each names.
…e adds

The gate refused `src/provider.rs`, and it was right to. It also could not have
seen the loop that matters: its three needles were anchored at four spaces, so
they caught a loop written at the top level of a function and were blind to one
written inside an `impl` — which is where an agent loop would actually be
written. The release that introduces this crate's first loop around provider
calls is the release that has to fix that, so the needles are unanchored and
every loop in a provider-calling file is now seen.

Unanchoring immediately refused `src/verify.rs` for three doc comments
containing the word "while". A gate that reads prose forbids a file from
explaining itself — 0.16.0, 0.19.0, and twice in one wave in 0.25.0 — and the
lesson recorded there is that the stripping belongs in the sweep and not only in
the exception. It is in the sweep.

The chain is exempted by path and held to being a boundary rather than a hole:
its loops may iterate nothing but `self.links`, the decision to try another
vendor must be io-harness's own `is_retryable`, it still may not read a
response, and it must still forward every completion method the trait declares.

The count moved rather than being weakened. Subtracting the module's calls one
by one is how this held through 0.17.0, and it stops being honest once the file
holds a delegating enum and a chain: the subtraction would grow until it
cancelled everything, which is a gate going vacuous without going red. So the
count is asserted everywhere else — still exactly one, the wizard's handshake —
and inside the module every call must be on a decorator's inner provider, a
vendor arm or a chain link, counted on squashed text because rustfmt decides
where a long call breaks.

Both halves were sabotaged: a call on an unnamed receiver, and a predicate of
this crate's own instead of the harness's. Each was refused.
… settle

The README gains a section on which model a run asks, with the containment
disclosure as its own paragraph rather than a footnote: the rules parse, are
listed by /config, reach the contract and never fire under containment, and
io-cli says so at start only when both are configured. `/effort` and the moved
`/profile` are in the commands table and the notes; the provider paragraph now
says the chain runs, which failures fall through and which do not.

`docs/config.example.toml` gains the routing sub-tables and `conversational`,
commented at the density the gates block already uses, and both `tests/docs.rs`
and `tests/configure.rs` hold the example and the README to the catalogue.

Two live arms, for the two claims a fixture cannot reach. A turn carrying a
reasoning level has to complete against a real endpoint, because a field the
vendor does not accept is an HTTP 400 rather than a failing comparison. And a
real fall-through needs a real transport failure: the head is a `compatible`
endpoint pointed at a port nothing is listening on, which spends no credential
because the connection never opens, with the real provider underneath it.
Tracing callers before the review reported found this release about to ship
`routing::describe` with none. `tests/routing.rs` covered it thoroughly — three
properties, the singular/plural, the empty case — and no test can see that
nothing calls a function. That is what 0.20.0 shipped seven of behind 1,077
passing tests, and what this release's own risks section calls this codebase's
proven blind spot.

The disclosure had one call site where it needed three. An operator meets this
state at session start, when they open `/config` to edit the rules, and when
they type `/contain on` — and the last is the worst of the three, because they
begin uncontained, are told nothing because nothing applied, and then move into
the one mode where their rules do not fire without a word. `/config` was also
promised the sentence by a comment in `src/configure.rs` and by F4 itself, and
never printed it.

All three now key on `contained` rather than on `containment.is_some()`. The two
are equal at startup and stop being equal the moment `/contain off` is typed,
which is precisely when the rules start working again — a warning keyed on the
configuration would go on being shown to an operator it no longer applies to.

A driver-text gate holds all four call sites, since nothing under `tests/` links
the binary.
Asserted at two widths, which is 0.25.0's lesson rather than a flourish: at
eighty columns a crowded row may not fit a field either way, and a gate that
asserts only there is equally true of a status line that never drew the field
at all — that release shipped one and found it by sabotage. The wide arm proves
it is drawn; the narrow arm proves the row still fits.

The absent case is asserted as an absence on both renderers, because a default
that drew `effort medium` would put a field on every operator's status line for
a release that changed nothing about their turns.

Sabotaged by drawing the level with an em dash: the ASCII arm fails, which is
what a terminal that cannot render it actually pays for.
Two reviewers, split by area, against a suite that was fully green. Both found
the first one independently, which is the strongest signal this gate gives.

**A turn refused at the token ceiling was reported as answered.**
`TurnKind::Reply` carries a second meaning io-harness documents on the variant
itself: a completion that crossed `max_tokens` and was refused rather than
served is also a `Reply`, because no run was opened either way. Reading the kind
alone told an operator with `[run] max_tokens` set that their refused question
had been answered — no answer on screen, no mention of the budget. The guard is
io-harness's own, which emits its `Answered` event only for a `Reply` whose
outcome is `Finished`.

**The resumed run never bought the reasoning the session asked for.**
`contract::buying`'s own note counted three `contract::session` callers that
build a contract nothing runs; there are five sites, and `resume_pending` drives
real completions. `/effort high` applied to every turn except the half of the
work an operator came back to finish, while the status line went on saying
`effort high`. The gate that should have caught it asked `contains` rather than
a count, so one call site satisfied it forever; it counts now.

**Typing `/effort high` took the posture off an eighty-column footer.** The
level went into the group `row` fits all or nothing, so it displaced the policy
and the containment word together — the failure `branch` was moved out of that
group for one release ago. It is in the counters now, pushed last, where
narrowing takes one field at a time.

The startup disclosure used `say`, a one-slot footer notice: it overwrote the
containment line it was meant to qualify and died on the first keystroke.

Half a routing rule failed to deserialize `[app.io-cli]` **entirely**, which
silently reverted the theme, the keys, the ceilings, the capabilities and the
verification gate — and `/config` writes one key at a time, so every path
through that surface to a routing rule passed through it. Both keys in each rule
are optional now and the half rule is refused by name, the shape
`gates::Settings::criterion` already uses. The same refusal covers the three
values that are writable and disastrous: `failures = 0`, which io-harness reads
as "escalate before anything has failed"; `bytes = 0`, which can never be true;
and a model written as the empty string.

`/effort <typo>` reported instead of refusing, so an operator on `high` trying
to spend less was told "every turn asks for high reasoning" and went on paying
for it. `/effort OFF` did the same, because the literal match sat in front of a
`FromStr` that lowercases for itself.

Two tests were the defect rather than the code: the image conjunction asserted
`!all` over two links that both took the trait's default of `false`, which every
possible implementation satisfies; and the catalogue gate matched a key's last
segment, which for two routing keys is `model` and occurs dozens of times in the
file it checks.
The first version of this gate picked eighty columns and asserted the standing
facts were present there. Which width a field starts costing something depends
on the posture string, the glyph set and every counter on the row, so that is
arithmetic a test will get wrong — and it did: with the fixture trimmed so the
control arm passed, the sabotage that put the level back in the all-or-nothing
group sailed through it.

The property has no number in it. Setting a level must never remove a standing
fact that was on the row without it, at any width. Checked at six, with a guard
that at least one of them held a standing fact to begin with — without which the
whole loop is satisfied by a row that never carried one.
@aakashpawar1999
aakashpawar1999 merged commit 333c26f into main Aug 28, 2026
13 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.

1 participant