feat(harness): reactive trigger bridge (harness::react) with join fan-in, wire hardening, and spawn console view - #401
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 32 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (39)
📝 WalkthroughWalkthroughThis PR introduces reactive sub-agent orchestration via a new ChangesReactive spawn/react engine backend
Chat harness spawn UI
Provider wire message reordering
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Agent
participant Engine
participant HarnessReact as harness::react
participant IIIState as iii-state
participant HarnessSpawn as harness::spawn
Agent->>Engine: engine::register_trigger (turn-completed, metadata=ReactSpec)
Engine-->>HarnessReact: fire trigger with event payload + metadata
HarnessReact->>HarnessReact: validate_spec, FireGate, depth check
alt join edge
HarnessReact->>IIIState: record predecessor arrival/result
IIIState-->>HarnessReact: accumulator state
HarnessReact->>HarnessReact: fire-once guard
end
HarnessReact->>HarnessSpawn: spawn_reaction(task built from event/join)
HarnessSpawn-->>HarnessReact: child_session_id, child_turn_id
HarnessReact-->>Engine: ReactResult
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…-in and lifecycle hardening Ports the engine's trigger/notify primitives into a harness-native reactive sub-agent bridge and hardens the full registration/fire/teardown lifecycle against gaps found in live testing. - harness::react: sub-agent spec fires on engine triggers (turn events, state, cron, stream); join fan-in with an expect array, fire-once accumulator, and rearm for standing watchers. - Interceptor pass-through (subscribe.rs): agent-issued engine::register_trigger calls get owner + subscription id stamped server-side into the react metadata, closing several trust gaps in the raw registration path. - Idempotent registration (dedup by canonical request key) and a durable owner sweep on session::deleted, replacing two pipelines that could double-register the same reaction. - Startup reconcile: GC react bindings whose owner session is gone and notify bindings unknown to the local registry; never GC on doubt. - Loop breakers: self-edge drop, reactive-depth cap, per-subscription fire-rate limit. - Join results deliver into the registering (owner) session by default instead of a detached, unread child session; parent nesting falls back from the event's session through the owner stamp to resolve_root. - Registration advisories for turn-event filters naming a nonexistent session, and for a join key wired to the same event source as a sibling key. - Policy aid: narrowed sub-agents are told their allowed/denied function surface directly in the system prompt instead of discovering it via a denied functions::list call. - Heal dangling function_calls left by interrupted/compacted turns before the next generate step. - Docs: tech spec, skill, and all prompt variants updated for the react/join doctrine.
Replace the raw-JSON fallback card with an instrument-panel view: policy chips (model/mode/turns/thinking/output/allow/deny), the task rendered as markdown, and the child's result as markdown, highlighted JSON, or the direct-call child ids. Guard errors and failed children route through the existing SandboxErrorView; the approval gate gets a policy-first preview. Session ids link to the child conversation via the sidebar's select when the console knows the session. Includes Zod parsers for the spawn wire schema (excerpt-tolerant), fixtures for all six card states, a gated-spawn playground scenario, and parser tests locking envelope unwrapping and error-before-success dispatch.
Upstream #388 added a test TurnRecord literal that predates this branch's display_parent_session_id / spawned_by_subscription_id / reactive_depth fields; the rebase merged clean but test compilation broke.
Every harness::spawn must pass session_id: a short readable job slug plus a few random characters (fetch-headlines-b4k9), replacing the opaque engine-minted UUIDs in the console tree. Never the parent session id as a prefix; the random suffix carries the run-uniqueness guarantee instead (a reused id silently resumes the old session). Scoped to direct spawn calls only — in a react trigger's metadata a fixed session_id funnels every firing into one session and re-aims join delivery. Fan-in doctrine updated to the same naming across all five prompt variants.
A notification or steering user entry injected while a call window is open
(a parked harness::spawn holds one open for minutes) lands between
function_call and function_result in the durable transcript. Every wire
mapper only repaired MISSING results (orphan placeholder) — a DISPLACED
result survived to the wire as assistant(tool_use) / user(text) /
user(tool_result), which Anthropic 400s ('tool_use ids were found without
tool_result blocks immediately after') and OpenAI/xAI/Responses reject as
a user row between tool_calls and its tool rows. The durable transcript
replays the shape on every retry, permanently wedging the turn.
Fix: shared llm_router::types::messages::reorder_displaced_results runs
first in all four providers' to_wire_messages — each FunctionResult moves
directly after the assistant that emitted its call, order preserved,
orphan results untouched. Wedged sessions self-heal: the transcript
itself was never illegal, only the wire projection.
Repro test written first and failed with the exact live shape; regression
tests in all four providers plus unit tests on the shared helper.
…d reply
A user entry appended while a step is generating (or assembling — the
compaction/hook window) lands before that step's assistant entry in the
durable log. The steering check then re-generates, but the assembled
context ENDS with a call-less assistant message — a prefill request newer
Anthropic models reject ('This model does not support assistant message
prefill. The conversation must end with a user message.'), wedging the
turn on every retry. Older models silently accepted prefill, hiding this
path.
Fix: rotate_mid_generation_users presents arrivals after the previous
step's watermark AFTER the reply they interrupted — semantically exact,
the model answered without seeing them. Two invariants hardened by
adversarial review:
- the new watermark is assigned only after router.chat returns; the
pre-generate put_turn persists the OLD one, so a redelivered step keeps
its rotation window instead of re-issuing the rejected shape forever
- rotation runs on the FINAL assembled values, never on the candidate:
compaction persists tail_start_entry_id as a log-order cursor indexed
from the candidate, and rotating first would silently drop the rotated
message from every future window
Also: has_user_after_watermark now loads include_custom=true, matching
the list the watermark comes from (a watermark landing on a custom entry
silently disabled the steering check).
Main's fs-scope refactor (#397) introduced the bare DispatchError struct; the react-bridge reconcile pass logs it with %e, which needs Display — an error type should carry one anyway.
5b99f21 to
e93fbe0
Compare
skill-check — worker0 verified, 31 skipped (no docs/).
Four for four. Nicely done. |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (6)
llm-router/src/types/messages.rs (1)
142-220: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConsider a regression test for the multi-call, multi-owner interleaving case.
Current tests cover single-call displacement and same-owner multi-call adjacency, but not the case where two different assistant messages each with their own call are separated by an interleaved user message, with only one result displaced (e.g.,
assistant(call t1) → result t1 → assistant(call t2) → user(notification) → result t2). Manual trace confirms current logic handles this correctly, but a test would lock in this guarantee given this is safety-net logic for a live 400 bug fix.🧪 Suggested additional test
#[test] fn multi_owner_interleaved_displacement_keeps_each_result_with_its_call() { let msgs = vec![ assistant(vec![call("t1")]), user_text("[notification] progress"), assistant(vec![call("t2")]), result("t1"), result("t2"), ]; let out = reorder_displaced_results(&msgs); assert!(matches!(out[0], AgentMessage::Assistant(_))); assert!(is_result(out[1], "t1")); assert!(matches!(out[2], AgentMessage::User(_))); assert!(matches!(out[3], AgentMessage::Assistant(_))); assert!(is_result(out[4], "t2")); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@llm-router/src/types/messages.rs` around lines 142 - 220, Add a regression test in the existing messages tests for the multi-owner interleaving case around reorder_displaced_results. The current coverage in helper functions like assistant, user_text, result, and call only verifies single-call displacement and same-owner adjacency, so add a test that builds two Assistant messages with separate function calls and an interleaved User notification, then asserts each FunctionResult is reordered immediately after its matching Assistant. Keep the test focused on the reorder_displaced_results behavior and use the existing is_result helper for matching.console/web/src/components/chat/harness/parsers.ts (2)
53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider
z.discriminatedUnionforoutputContractSchema.The two variants are tagged by
type('text' | 'json');z.discriminatedUnion('type', [...])gives clearer error messages and avoids Zod evaluating every branch.♻️ Proposed refactor
-export const outputContractSchema = z.union([ +export const outputContractSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text') }), z.object({ type: z.literal('json'), schema: z.unknown().optional() }), ])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@console/web/src/components/chat/harness/parsers.ts` around lines 53 - 57, The `outputContractSchema` union in `parsers.ts` is a tagged union on `type`, so replace the current `z.union([...])` with `z.discriminatedUnion('type', [...])` using the same `text` and `json` object variants. Keep `OutputContract` inferred from `outputContractSchema` so validation stays equivalent while improving branch selection and error messages.
80-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider
z.looseObject()over deprecated.passthrough().Zod v4 deprecates
.passthrough()(still functional, not removed) in favor of the top-levelz.looseObject()constructor.♻️ Proposed refactor
-export const taskMessageSchema = z - .object({ - role: z.string().optional(), - content: z.array(z.unknown()).optional(), - }) - .passthrough() +export const taskMessageSchema = z.looseObject({ + role: z.string().optional(), + content: z.array(z.unknown()).optional(), +})🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@console/web/src/components/chat/harness/parsers.ts` around lines 80 - 88, `taskMessageSchema` currently uses the deprecated `.passthrough()` on a `z.object`, so update the schema definition to use `z.looseObject()` instead while preserving the same fields (`role`, `content`) and the existing `spawnTaskSchema`/`SpawnTask` behavior. Keep the refactor localized to the parser definitions in `parsers.ts` so any unknown properties remain accepted as before.harness/src/subscriptions/reconcile.rs (1)
162-199: 🚀 Performance & Scalability | 🔵 Trivial
sweep_ownercost scales with total durable bindings per session delete.Each
session::deletedtriggers a full list of react + notify bindings plus oneengine::registered-triggers::infodispatch per binding. At a large standing binding count this makes chat deletion O(total bindings) engine round-trips. Fine at current scale; if binding counts grow, consider an owner-indexed listing (e.g. filter by owner metadata engine-side) or batching the info lookups.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@harness/src/subscriptions/reconcile.rs` around lines 162 - 199, The sweep_owner path currently does a full list plus per-binding info dispatch for every session delete, making session::deleted scale linearly with total durable bindings. Update sweep_owner to avoid iterating every binding one-by-one: prefer an engine-side owner-indexed lookup or filtering by owner metadata before unregistering, and if that is not available, batch the registered-triggers::info lookups/dispatches instead of issuing one round-trip per id. Keep the existing ownership check and unregister_trigger behavior intact while reducing the number of engine calls in sweep_owner.harness/src/types/turn.rs (1)
264-266: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRound-trip test doesn't exercise the new fields with actual values.
turn_record_round_trips_through_jsonusesrecord(), which setsdisplay_parent_session_id,spawned_by_subscription_id, andreactive_depthall toNone. Since these are skipped whenNone, the test never verifies the fields actually serialize/deserialize correctly under their real key names — a future typo in a#[serde(rename)]or field name would slip through.♻️ Suggested test strengthening
#[test] fn turn_record_round_trips_through_json() { let mut r = record(); r.calls .insert("a".into(), cp(CallState::Pending, Some("s_child"))); + r.display_parent_session_id = Some("s_parent".into()); + r.spawned_by_subscription_id = Some("sub_1".into()); + r.reactive_depth = Some(2); let back: TurnRecord = serde_json::from_value(serde_json::to_value(&r).unwrap()).unwrap(); assert_eq!(back, r); }Also applies to: 330-337
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@harness/src/types/turn.rs` around lines 264 - 266, The round-trip test for TurnRecord is only using record() values where display_parent_session_id, spawned_by_subscription_id, and reactive_depth are None, so it never verifies their JSON keys. Update turn_record_round_trips_through_json to construct a TurnRecord with actual non-None values for those fields, then assert it serializes and deserializes correctly through serde using the TurnRecord type and its round-trip test helper so the real field names are exercised.harness/src/functions/react.rs (1)
338-360: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy liftNo TTL/reconciliation for orphaned join accumulator records.
Unlike react bindings (swept on startup and on session delete per
subscriptions/reconcile.rs), aJOIN_SCOPEaccumulator record has no equivalent cleanup path. If a predecessor never fires (its session/subscription is deleted before completion, or itsmetadatawas malformed and silently ignored per the docs), the record sits iniii-stateforever belowexpected, with no sweep to reclaim it.Worth a follow-up: extend the startup/session-delete reconciliation to also GC stale
harness::react_joinrecords (e.g., by owner-session stamp or age), mirroring the existing react-binding reconciliation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@harness/src/functions/react.rs` around lines 338 - 360, The JOIN_SCOPE accumulator path in harness::react::react_join currently writes state via state_update and never gets reclaimed if the join never completes. Add a reconciliation/TTL cleanup path for stale harness::react_join records, similar to the existing react binding sweep in subscriptions/reconcile.rs, so orphaned join accumulators are garbage-collected on startup and/or session delete. Use the join record identifiers (join.id, owner-session stamp, or age metadata) to detect incomplete records and remove them during the reconciliation flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@harness/prompts/kimi.txt`:
- Around line 174-180: The fan-in session_id guidance in kimi.txt conflicts with
the shared spawn rules and the sibling prompt files. Update the wording around
the child-session naming guidance so it matches the canonical rule used in
SKILL.md and the other prompt files, and ensure the same instruction is applied
consistently in the kimi prompt section that references harness::spawn and react
trigger metadata.
In `@harness/src/functions/react.rs`:
- Around line 405-417: The join cleanup in react’s fire path can leave a stale
accumulator behind when state_delete fails, which permanently wedges rearmed
joins. Update the join handling around join_delivery_session,
gather_inputs_task, and state_delete so the accumulator is reliably removed
before the next cycle: either add a bounded retry/backoff around state_delete
and only warn after exhausting retries, or change the fire gating in the join
path from a fixed fire == 1 check to a generation-based guard so a leftover
record cannot block future firings.
- Around line 244-249: The fallback fire-gate key in the gate_key computation
currently hashes only model, task, and session_id, which causes all predecessors
of the same join to share one budget when subscription_id is missing. Update the
fallback in react.rs so the hash also incorporates the join key from the spec
(the same join identifier used by the join/predecessor flow), ensuring each join
predecessor gets a distinct gate key while preserving the existing
subscription_id path.
In `@harness/src/functions/spawn.rs`:
- Around line 65-74: SpawnRequest is exposing reactive metadata that should
remain internal, allowing model-reachable callers to spoof
spawned_by_subscription_id and reactive_depth and bypass the loop guards. Remove
these fields from the public SpawnRequest schema and ensure
spawn_child/seed_child only populate them on the react-internal path inside
harness::react, where the self-edge breaker and MAX_REACTIVE_DEPTH logic consume
them.
---
Nitpick comments:
In `@console/web/src/components/chat/harness/parsers.ts`:
- Around line 53-57: The `outputContractSchema` union in `parsers.ts` is a
tagged union on `type`, so replace the current `z.union([...])` with
`z.discriminatedUnion('type', [...])` using the same `text` and `json` object
variants. Keep `OutputContract` inferred from `outputContractSchema` so
validation stays equivalent while improving branch selection and error messages.
- Around line 80-88: `taskMessageSchema` currently uses the deprecated
`.passthrough()` on a `z.object`, so update the schema definition to use
`z.looseObject()` instead while preserving the same fields (`role`, `content`)
and the existing `spawnTaskSchema`/`SpawnTask` behavior. Keep the refactor
localized to the parser definitions in `parsers.ts` so any unknown properties
remain accepted as before.
In `@harness/src/functions/react.rs`:
- Around line 338-360: The JOIN_SCOPE accumulator path in
harness::react::react_join currently writes state via state_update and never
gets reclaimed if the join never completes. Add a reconciliation/TTL cleanup
path for stale harness::react_join records, similar to the existing react
binding sweep in subscriptions/reconcile.rs, so orphaned join accumulators are
garbage-collected on startup and/or session delete. Use the join record
identifiers (join.id, owner-session stamp, or age metadata) to detect incomplete
records and remove them during the reconciliation flow.
In `@harness/src/subscriptions/reconcile.rs`:
- Around line 162-199: The sweep_owner path currently does a full list plus
per-binding info dispatch for every session delete, making session::deleted
scale linearly with total durable bindings. Update sweep_owner to avoid
iterating every binding one-by-one: prefer an engine-side owner-indexed lookup
or filtering by owner metadata before unregistering, and if that is not
available, batch the registered-triggers::info lookups/dispatches instead of
issuing one round-trip per id. Keep the existing ownership check and
unregister_trigger behavior intact while reducing the number of engine calls in
sweep_owner.
In `@harness/src/types/turn.rs`:
- Around line 264-266: The round-trip test for TurnRecord is only using record()
values where display_parent_session_id, spawned_by_subscription_id, and
reactive_depth are None, so it never verifies their JSON keys. Update
turn_record_round_trips_through_json to construct a TurnRecord with actual
non-None values for those fields, then assert it serializes and deserializes
correctly through serde using the TurnRecord type and its round-trip test helper
so the real field names are exercised.
In `@llm-router/src/types/messages.rs`:
- Around line 142-220: Add a regression test in the existing messages tests for
the multi-owner interleaving case around reorder_displaced_results. The current
coverage in helper functions like assistant, user_text, result, and call only
verifies single-call displacement and same-owner adjacency, so add a test that
builds two Assistant messages with separate function calls and an interleaved
User notification, then asserts each FunctionResult is reordered immediately
after its matching Assistant. Keep the test focused on the
reorder_displaced_results behavior and use the existing is_result helper for
matching.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5b8f3c6f-c444-470b-a475-bbdf2574b760
⛔ Files ignored due to path filters (2)
provider-anthropic/Cargo.lockis excluded by!**/*.lockprovider-openai/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (39)
console/web/src/components/chat/harness/SpawnView.tsxconsole/web/src/components/chat/harness/__tests__/parsers.test.tsconsole/web/src/components/chat/harness/index.tsxconsole/web/src/components/chat/harness/parsers.tsconsole/web/src/stories/fixtures/harness-fixtures.tsconsole/web/src/stories/playground/Agent.stories.tsxconsole/web/src/stories/playground/scenarios/harness-spawn.tsconsole/web/src/stories/playground/scenarios/index.tsharness/prompts/anthropic.txtharness/prompts/cli.txtharness/prompts/default.txtharness/prompts/gpt.txtharness/prompts/kimi.txtharness/skills/SKILL.mdharness/src/clients/session.rsharness/src/config.rsharness/src/deps.rsharness/src/events.rsharness/src/functions/mod.rsharness/src/functions/on_session_deleted.rsharness/src/functions/react.rsharness/src/functions/send.rsharness/src/functions/spawn.rsharness/src/functions/subscribe.rsharness/src/main.rsharness/src/subagent.rsharness/src/subscriptions/mod.rsharness/src/subscriptions/reconcile.rsharness/src/subscriptions/registry.rsharness/src/turn_loop.rsharness/src/types/turn.rsharness/tests/golden/schemas/harness.spawn.jsoniii-permissions.yamlllm-router/src/types/messages.rsprovider-anthropic/src/wire/messages.rsprovider-openai-codex/src/wire/messages.rsprovider-openai/src/wire/messages.rsprovider-xai/src/wire/messages.rstech-specs/2026-06-agentic/harness.md
Restore tech-specs/2026-06-agentic/harness.md to main's version — the react-bridge spec additions come out of this PR.
- react: include the join (id, key) in the fallback fire-gate hash — state-based join predecessors share the whole downstream spec except their key, so a wide join shared one 10-fires/min budget and tripped the breaker spuriously - react: retry the join accumulator delete (3 attempts) — a failed delete left fire=1 behind, permanently wedging a rearmed join's fire-once guard; persistent failure on a rearmed join now logs at error level with the recovery path - spawn: strip spawned_by_subscription_id / reactive_depth on the model-reachable dispatch path — react-internal bookkeeping a model could spoof to defeat the self-edge breaker and depth cap - skills: align SKILL.md fan-in naming with the prompt doctrine (slug + random suffix, never the originating session id as a prefix) - tests: multi-owner displaced-result reorder case; round-trip the react-bridge TurnRecord fields with real values
What
Ports the engine's trigger/notify primitives into a harness-native reactive sub-agent bridge (
harness::react), plus the lifecycle, wire-shape, and console work that live testing of the reactive pipeline demanded.Reactive bridge (
harness::react)expectarray, fire-once semantics with optionalrearm, durable join accumulation, auto-unregister of predecessor subscriptions.sub_ → engine-idmapping for durable bindings and GCs zombie notify bindings.Wire-shape hardening (two live 400 classes from the notification workload)
fix(providers)): a notification injected into an open call window lands betweenfunction_callandfunction_resultin the durable transcript; all four provider wire mappers only repaired missing results, so the pair split reached the API — Anthropic 400tool_use ids were found without tool_result blocks immediately after, turn permanently wedged. Sharedreorder_displaced_resultsinllm-routernow runs first in every mapper. Wedged sessions self-heal.fix(harness)): a user entry appended while a step generates lands before that step's assistant entry; the steering re-generate then ends the context with a call-less assistant — newer models reject it as prefill (This model does not support assistant message prefill).rotate_mid_generation_userspresents mid-generation arrivals after the reply they interrupted. Hardened by adversarial review: watermark advances only afterrouter.chatreturns (redelivered steps keep their rotation window), and rotation runs on the final assembled values so compaction bookkeeping stays in log order.Prompts
session_id(job slug + random suffix, e.g.fetch-headlines-b4k9) instead of engine-minted UUIDs in the console tree; explicitly not parent-prefixed and scoped to direct spawns (reactmetadata.session_idkeeps its delivery semantics). Fan-in doctrine aligned across all five prompt variants.Console
harness::spawn(request/response panes, parsers, stories, fixtures).Testing
cargo test --libgreen across harness (161), llm-router (45), provider-anthropic (79), provider-openai (65), provider-xai (72), provider-openai-codex (44).Summary by CodeRabbit
New Features
Bug Fixes
Documentation