1217 Commits
Author SHA1 Message Date
Hare b284934e34 test: add captured Codex worker restore fixture 2026-10-24 12:57:28 +09:00
Hare 2d33321fab fix: validate live fixture snapshots and shutdown boundaries 2026-10-24 12:57:28 +09:00
Hare 0975471986 fix: preserve diagnostic context in backend test assertions 2026-10-24 12:51:28 +09:00
Hare 214d6e21dd fix: assert classified workdir session closure in tests 2026-10-24 12:40:37 +09:00
Hare fca5c23345 fix: reject unrestorable and opaque fixture records 2026-10-24 12:39:22 +09:00
Hare f9b1500ba2 fix: align panel tests with current ticket case states 2026-10-24 12:33:31 +09:00
Hare 931bf3b27d test: add isolated saved worker restore scenarios 2026-10-24 12:33:31 +09:00
Hare 6bb671be21 test: add typed stopped worker fixture transport 2026-10-24 12:19:43 +09:00
Hare b8f2fa479c test: add isolated backend fixture seeding 2026-10-24 12:15:10 +09:00
Hare 1639be9841 fix: allow reviewers to read related tickets 2026-10-23 03:22:45 +09:00
Hare aa0fb2f787 fix: continue workdir output reads after long-poll expiry 2026-10-21 04:48:43 +09:00
Hare 1b6d4dbfd8 fix: preserve workdir failure diagnostics across transport 2026-10-21 01:22:04 +09:00
Hare 4ef7b03651 fix: stop background session checks and loading flicker 2026-10-20 22:35:38 +09:00
Hare af615ab177 fix: retain archive and worker restore failure diagnostics 2026-10-20 22:00:58 +09:00
Hare 338e39bcdb fix: defer restored workdir access until use 2026-10-20 22:00:58 +09:00
Hare 3cfe04be3a fix: preserve saved feature configuration during worker restore 2026-10-20 22:00:39 +09:00
Hare 70d153b365 fix: isolate unreadable archives from runtime startup 2026-10-20 17:39:38 +09:00
Hare ce93849948 chore: merge develop into hare/develop 2026-10-20 15:39:09 +09:00
Hare 3c499719fe feat: default Coder ticket work to review flow 2026-10-20 15:38:50 +09:00
Hare 9e1d3df7ad fix: remove overbroad Worker bypass instructions 2026-10-20 15:38:50 +09:00
Hare 18bac16a23 fix: require replay-safe control lifecycle for Subject delegation 2026-10-20 08:12:26 +09:00
Hare 9005856f77 fix: replay accepted delegation without reserving active Workdirs 2026-10-20 07:24:51 +09:00
Hare e39372a52a Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants 2026-10-20 06:38:12 +09:00
Hare 223a74a9e2 fix: retain byte command transport and explicit authorization fixtures 2026-10-20 06:38:12 +09:00
Hare f38d9a16ea Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants
# Conflicts:
#	crates/worker-runtime/src/worker_backend.rs
#	openapi/server-api.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
2026-10-20 06:24:11 +09:00
Hare b135fa5e99 feat: enforce explicit Subject permissions and delegated Worker grants 2026-10-20 06:22:58 +09:00
Hare 07b8b6d41b Merge commit 'b8e548b92d3c785c482dbb418517cc5a2cf9e7e0' into refactor/T-765-session-feature-boundary 2026-10-20 05:34:22 +09:00
Hare 8017c55d82 fix: retain annotated compaction history and honor non-System input policy 2026-10-20 05:34:22 +09:00
Hare b8e548b92d chore: integrate reviewed browser session navigation 2026-10-20 05:31:32 +09:00
Hare 90e38551cf Merge commit '00ad02d04728a8e32a82a4e72747fefcf8a82d4e' into refactor/T-765-session-feature-boundary
# Conflicts:
#	crates/worker/src/feature/builtin.rs
2026-10-20 05:25:16 +09:00
Hare 3e8164e757 fix: fence hosting setup verification against superseded sessions 2026-10-20 04:53:43 +09:00
Hare 3b3381900f fix: preserve role-neutral input policy across compaction and replay 2026-10-20 04:45:50 +09:00
Hare 00ad02d047 chore: integrate bounded shared text page refusal 2026-10-20 03:55:13 +09:00
Hare a774925dec fix: reject byte-truncated line windows before advancing search 2026-10-20 03:53:03 +09:00
Hare 8f759911ab fix: emit Session use policy type in browser bindings 2026-10-20 03:52:55 +09:00
Hare daf1f6ed50 fix: route retained Session migration through Runtime staging 2026-10-20 03:52:55 +09:00
Hare 86602240fa refactor: bind Subject metadata and prompt confirmation to Host lifecycle 2026-10-20 03:52:55 +09:00
Hare bfeee487fd feat(auth): separate anonymous entry from expired session recovery 2026-10-20 03:52:48 +09:00
Hare f9830fe7dd test: reject Bash search pages that would skip an unread long line 2026-10-20 03:40:36 +09:00
Hare de96c5b577 chore: update shared file contracts and validation fixtures
# Conflicts:
#	crates/worker/src/checkout_http_tests.rs
2026-10-20 03:26:56 +09:00
Hare 6e729ed06c refactor: isolate Feature System input migration and compaction policy 2026-10-20 03:21:22 +09:00
Hare f38b2a52b1 fix: record native prior Read only after result validation 2026-10-20 03:21:14 +09:00
Hare dd5384df3b feat: expose retained Bash streams through common WIP file contracts 2026-10-20 03:13:46 +09:00
Hare 6576850b7a chore: connect shared file contracts for Bash validation 2026-10-20 03:03:53 +09:00
Hare 4555aace28 feat: capture bounded Bash output over replayable byte transport 2026-10-20 03:01:22 +09:00
Hare 47b32ef6ab fix: bind capability changes to Object freshness and update contract consumers 2026-10-20 02:53:21 +09:00
Hare bb5c50f9ab feat: persist System input policies through public consumers 2026-10-20 02:44:28 +09:00
Hare 32a409c15d feat: share virtual file contracts and checked binary byte ranges 2026-10-20 02:18:49 +09:00
Hare 19514a5d6a feat: define bounded Feature-owned system input envelopes 2026-10-20 02:14:52 +09:00
Hare 887184796e docs: distinguish commit completion from Feature authority 2026-10-20 02:05:02 +09:00
Hare cd0eb5eb00 fix: allowlist public system input projections 2026-10-20 02:02:28 +09:00
Hare e8dfb3e68d refactor: notify system input producers after durable commit 2026-10-20 02:02:28 +09:00
Hare 434ce34da7 refactor: bind checkout target lifetimes to Object observations 2026-10-20 02:01:22 +09:00
Hare bfb51b7ca9 fix: bound encoded common text results including escape and record overhead 2026-10-20 01:48:23 +09:00
Hare 38d9069632 feat: publish stable root file contracts and target-owned dispatch 2026-10-20 01:46:30 +09:00
Hare e5749b9be0 test: integrate Drive connection and lifetime coverage 2026-10-20 00:37:32 +09:00
Hare 387b5ff7df docs: record Drive connection coverage and fault evidence 2026-10-20 00:35:11 +09:00
Hare ec6c5beda0 test: connect Drive publication to client and resource lifetimes 2026-10-20 00:34:50 +09:00
Hare 8e40fae6f4 fix: recover unpublished Subject creation and preserve auth rejections 2026-10-19 18:56:24 +09:00
Hare c961f244ab fix: preserve feature metadata for concise Journal startup context 2026-10-19 18:45:32 +09:00
Hare cea0225a6e fix: migrate legacy session history and retained archives 2026-10-19 13:52:43 +09:00
Hare b3189d8e85 fix: preserve worker retention failure diagnostics 2026-10-19 13:52:43 +09:00
Hare 24bc45a31e chore: merge develop into hare/develop 2026-10-19 01:31:23 +09:00
Hare b02360fd75 fix: restore orchestrator target push requirement 2026-10-19 01:31:01 +09:00
Hare 41c9d2d5c7 docs: record restored parent regression evidence and cost 2026-10-19 00:02:02 +09:00
Hare cc66bd5052 test: connect restored Runtime parent tools to child session 2026-10-19 00:02:02 +09:00
Hare d1408d3a2f fix: project runtime list subjects for Worker control tools 2026-10-19 00:02:02 +09:00
Hare 19122fb5ea fix: use public Worker control entry in regression design 2026-10-19 00:02:02 +09:00
Hare 1fb216e6ae fix: refresh Ticket regression design evidence 2026-10-19 00:02:02 +09:00
Hare 4b14f7e6b3 docs: design prioritized regression coverage 2026-10-19 00:02:02 +09:00
Hare 67767eeee7 test: integrate Ticket public regression coverage 2026-10-18 02:35:16 +09:00
Hare 1bfb4ce119 fix: align Ticket regression design with Host retry identity 2026-10-18 02:10:15 +09:00
Hare 651820dc6d test: connect Ticket public regressions to real Backend 2026-10-18 02:10:15 +09:00
Hare 75b1557d63 feat: browse Subject Journals and persistent Memory links 2026-10-18 02:03:51 +09:00
Hare 23ff518209 fix: clarify Ticket regression fixture boundaries 2026-10-18 01:26:52 +09:00
Hare fab9e0f4b5 docs: design focused Ticket regression coverage 2026-10-18 01:26:52 +09:00
Hare 5ee42b8db8 docs: record Journal compaction validation and evaluation limits 2026-10-17 12:29:47 +09:00
Hare daf438701f docs: explain shared Journal snapshot boundary guarantees 2026-10-17 12:25:24 +09:00
Hare 23ee401287 fix: disambiguate historical Journal retention instructions 2026-10-17 12:25:24 +09:00
Hare 8f598da931 test: verify frozen Journal compaction across Hosts and restores 2026-10-17 12:25:24 +09:00
Hare 956aeed6b2 feat: freeze Host Journal context across compaction 2026-10-17 12:19:37 +09:00
Hare adfa422973 chore: merge develop into hare/develop 2026-10-17 12:06:05 +09:00
Hare 10d200d9ba chore: align Journal references with integrated develop 2026-10-17 03:52:11 +09:00
Hare 7a5228b8e6 fix: reconcile Memory Journal links with handoff fencing 2026-10-17 03:52:11 +09:00
Hare 0eabdf3a1f chore: integrate Journal-gated Subject handoff 2026-10-17 03:47:11 +09:00
Hare b1808b7c4d fix: unblock MR follow-up and simplify orchestration guidance 2026-10-17 03:35:58 +09:00
Hare 6645ea0a47 fix: observe original presence through saved legacy Journal links 2026-10-17 00:10:35 +09:00
Hare b544e53bd7 feat: connect Memory provenance to durable Journal context 2026-10-16 23:54:54 +09:00
Hare 04a4e59100 feat: fence Subject handoff on recorded Journal and seed new bodies once 2026-10-16 20:51:08 +09:00
Hare 52e914eebc feat: offer ID-free passkey login and explicit legacy migration 2026-10-16 19:01:43 +09:00
Hare 9c12fb4372 feat: verify discoverable passkeys against their saved owners 2026-10-16 19:01:43 +09:00
Hare 3c18916118 test: wait for complete isolated server address records 2026-10-16 19:01:43 +09:00
Hare be1c030350 chore: merge develop into hare/develop 2026-10-16 15:08:40 +09:00
Hare a7faf2cea6 docs: record test optimization results and runner validation 2026-10-16 15:04:52 +09:00
Hare 5cc5ece997 feat: add bounded parallel runner for the normal test suite 2026-10-16 15:04:52 +09:00
Hare ffaaf2ecb0 test: isolate local rules and reduce repeated fixture setup 2026-10-16 15:04:52 +09:00
Hare 6667985883 fix: preserve test completion and fixture invariants under concurrency 2026-10-16 15:04:42 +09:00
Hare 22dcd8a29f fix: make reverse session reads linear for large records 2026-10-16 15:04:42 +09:00
Hare a2816a053d fix: isolate Workspace notifications from public entry 2026-10-16 11:30:00 +09:00
Hare d74660d1d1 fix: retain workspace creation state across session rechecks 2026-10-16 11:08:27 +09:00
Hare 24c6f772f7 fix: serve entry shells independently of workspace catalog 2026-10-16 10:48:51 +09:00
Hare a060ffada8 feat: add public entry and authenticated workspace dashboard 2026-10-16 10:48:51 +09:00
Hare b8b12442a0 feat: separate login registration and own account flows 2026-10-16 09:43:57 +09:00
Hare d22899d39e feat: activate provisional users with scoped passkey enrollment 2026-10-16 08:48:35 +09:00
Hare af008f802d feat: explicit hosting administrators and persistent self-registration policy 2026-10-16 07:26:21 +09:00
Hare 7042ec7d50 chore: merge develop into hare/develop 2026-10-16 05:52:36 +09:00
Hare a2cec5afd1 fix: preserve console alert order and improve alert colors 2026-10-16 05:28:17 +09:00
Hare b5a307811b fix: reconcile erased Archive removals and exercise live compaction recording
Reconcile immutable Runtime removal input under a confirmed permanent erase fence. A confirmed source_removed receipt is audited without republishing erased archives; unknown receipt plus attested remaining aggregate permits an audited stale plan and proof-bound purge replacement. Unknown receipt and inventory remain unresolved. Distinguish domain tombstones from scoped Runtime physical erasure acknowledgements.

Erase diagnostic originals and interrupted staging bound to the same archived Session/removal operation, validating Workspace/Worker/fingerprint and saved receipts without rewriting their historical retained flags. Finalize Backend removal only after physical acknowledgement, including previous Retain disposition.

Connect public standalone protocol, real Worker request-threshold compaction twice within one turn, cancellation and real pre-AI input rollback, original canonical readers, existing Jobs and sequential Journal tools. Gate generation while compaction completes; verify all durable originals were served, summary/rolled-back input exclusion, exact final cursor, no extra input for successor drain, and one ongoing open entry. External model streams/usage are scripted, not semantic evaluation.

Validation: Backend1112lib+11bin (existing ignore1), Runtime429lib+38bin, standalone72lib+27integration (existing model ignore1), runtime-api10, root check/doc/fmt/diff/naming, Server/Runtime builds, isolated worker_environment2 passed. Earlier broad suites remain recorded on the prior source. Fixtures and injected boundaries are not arbitrary crash/remote-transport-loss coverage; worker_environment only verifies startup/connection/cleanup.

Acceptance clarification: T-734 assignment answer event22 explicitly defers real-model semantic condition6 to user use, excluding it from this completion scope. It remains unexecuted and unevaluated, not passed. Technical review findings still require a fresh independent verdict on this source.
2026-10-16 04:58:53 +09:00
Hare 5bd49760e5 fix: fence Journal originals before publication and reconcile removal intent
Protect attributed originals before the first public commit; distinguish pending Session material from missing committed sources. Gate removal admission by the Journal Feature and resolve Subject ownership from durable singleton authority. Resume explicit erasure through signed delete transport and proof-bound purge plans without creating a Journal Job or archive.

Validation: changed-crate suites, root check, doc/fmt/diff/naming checks, Server/Runtime builds and worker_environment isolation suite passed. Public standalone scripted dialog connects Host, automatic Job, sequential tools and drain while dialog continues. Backend public embedded WorkerRemove tests connect removal forwarding, durable Jobs, retention, Runtime and SQLite; Session/attribution/profile fixture setup bypasses creation/auth routes. Runtime signed generated-client tests separately exercise request authentication.

Limits: real-model semantic acceptance condition 6 remains unevaluated without a new explicit model/credential/budget grant. Conditions 1/7/8/11 do not have complete product E2E coverage: worker_environment only verifies isolated startup/connection/cleanup. Compaction tests connect local hooks/reader/store rather than the full public dialog-to-Journal chain. Erasure racing a previously prepared Archive removal plan fails closed with conflict; automatic stale-plan replacement is not implemented.
2026-10-16 03:59:45 +09:00
Hare 9debf43645 Merge remote-tracking branch 'origin/develop' into work/T-734-journal-recording 2026-10-16 02:19:24 +09:00
Hare 81af1f085c feat: record confirmed public Session history through durable Journal Jobs 2026-10-16 02:19:24 +09:00
Hare b67aa2de7d fix: simplify Ticket reporting and speed up browser lists
Remove model-supplied assignment and retry identities from implementation reports; retain Host idempotency, authentication and notification evidence.

Add a SQL-only browser projection and decouple auxiliary page loads. Preserve authoritative MR evidence queries. Include regression tests and component benchmarks, documenting the remaining no-match search cost.
2026-10-16 01:58:15 +09:00
Hare 9f1cbbd385 fix: inherit parent profile for default subworkers 2026-10-16 01:57:58 +09:00
Hare 8a3d9d67d2 fix: guard deferred state when leaving workspace routes 2026-10-16 01:57:58 +09:00
Hare 6218c27fb6 fix: separate account registration from authenticated passkey addition
Bind enrollment purpose and allocated/current account to verifier state. Reject existing-handle registration, derive additions from live transport authentication, recheck revocation/expiry in the insert-only transaction, and consume challenges atomically across connections. Derive stable account WebAuthn handles without rewriting legacy credentials; reject implicit-user login and unsafe legacy ceremonies.

Align account callers, DTO/OpenAPI/generated artifacts and migration/recovery rationale. Preserve credential-less legacy identities rather than allowing anonymous reclaim.

Validation: 14 actual-verifier passkey tests; full workspace-server 1086 library + 11 binary passed (Drive ignored case explicitly selected separately and passed); server-api default 99 and TypeScript feature 108 passed; isolated auth/Runtime E2E 1+2 passed; Web 645 + component 394 passed; root cargo check, docs/doctests, Server/Runtime builds, fmt, naming and generated consistency passed. Software authenticator only; hosted CI/hardware login not claimed.
2026-10-16 00:55:16 +09:00
Hare b744efd104 fix: align isolated runtime harness with current trust contract
Keep Workspace trust enrollment and owner-authenticated registration, then verify each Runtime connection with a fresh signed request and match its binding ID. Remove obsolete workspace_trust_id input and cached connection_state assertions.

Validation: cargo test -p yoi-e2e --features e2e --test worker_environment --test auth_passkeys -- --nocapture (2 Runtime harness tests and 1 Passkey product E2E passed); root cargo check.
2026-10-16 00:35:23 +09:00
Hare 18384482f6 feat: integrate durable Journal storage APIs 2026-10-15 20:51:51 +09:00
Hare 885e8045fe docs: map notification routing and verification boundaries 2026-10-15 19:41:40 +09:00
Hare 7b52bea23b feat: integrate MR notifications and implementation results 2026-10-15 19:40:29 +09:00
Hare a31f002a0a fix: retain MR source observations and notification actors 2026-10-15 19:13:03 +09:00
Hare 8ab7ee05db chore: retain committed Ticket sidebar changes during notification integration 2026-10-15 18:51:14 +09:00
Hare 62d71ed17e fix: retain preparation diagnostics and source-owned cleanup 2026-10-15 18:51:00 +09:00
Hare cafa2fa6ea fix: integrate recoverable and fair Job notification retries 2026-10-15 18:44:20 +09:00
Hare 95ed9e8719 fix: fairly schedule undecided Job notification retries 2026-10-15 18:43:23 +09:00
Hare 84ea23c2e6 feat: route MR events and authenticated implementation results 2026-10-15 18:38:27 +09:00
Hare ac49740a50 fix: simplify Ticket sidebar and surface repository targets 2026-10-15 18:35:39 +09:00
Hare bdc7c73143 fix: retry Job notification source preparation failures 2026-10-15 18:33:23 +09:00
Hare 011dbb9a98 fix: align MR evidence fixtures with canonical Ticket posts 2026-10-15 18:29:40 +09:00
Hare b9e7d5675b fix: project Job receipts and pinned notification recipients 2026-10-15 18:28:11 +09:00
Hare c155259c8e chore: integrate backend Job notifications with diagnostics 2026-10-15 18:16:29 +09:00
Hare e33729647c fix: preserve notification uncertainty and target provenance 2026-10-15 18:15:47 +09:00
Hare e71177900e feat: route backend job results through common notifications 2026-10-15 18:12:07 +09:00
Hare f50fa47a48 fix: await DB cleanup in cancelled Runtime metadata test 2026-10-15 17:55:54 +09:00
Hare b71fc9ee54 feat: expose read-only notification routing and delivery diagnostics 2026-10-15 17:55:54 +09:00
Hare e70cba4618 chore: integrate case permission fixes for notification work 2026-10-15 16:58:21 +09:00
Hare d115819f51 fix: honor Profile case permissions and expose Ticket API errors 2026-10-15 16:55:09 +09:00
Hare 822a084b31 fix: centralize durable Ticket notification routing 2026-10-15 02:20:40 +09:00
Hare c2df3041f9 fix: pin Journal source pagination to saved coverage content 2026-10-14 22:18:02 +09:00
Hare aabc502f30 feat: add durable subject Journal operations and bounded reads 2026-10-14 21:57:35 +09:00
Hare 65ead5fffd fix: keep page errors local and preserve Workspace error details 2026-10-14 21:19:42 +09:00
Hare 63d581914c fix: restrict console image loading to the Normal viewport
Hide attachments in Overview and aggregate image tool calls normally. Load previews near the console scroll viewport, guard stale observer callbacks, and offer explicit loading without IntersectionObserver. Cover visibility and request timing in component and Chromium tests.
2026-10-14 14:51:30 +09:00
Hare da378fc902 fix: complete Ticket post integration and expose targets
Preserve ordered targets in Workspace ShowTicket and native reads. Complete authenticated post-edit transport, shared dispatch, generated contracts and regression fixtures. Keep canonical content-edit delivery inputs transactional and separate from discussion posts.
2026-10-14 14:49:20 +09:00
Hare 8611078058 feat: unify Ticket posts and simplify detail layout
Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
2026-10-14 02:42:58 +09:00
Hare 26ab6130e0 fix: navigate before loading page data 2026-10-13 19:56:38 +09:00
Hare a82e70047a feat: replace Ticket kanban with query-based list 2026-10-13 19:08:51 +09:00
Hare 12d1588c05 chore: merge develop preserving documentation structure and console UX
Integrate develop at 523bade3 while retaining concise agent guidance, rustdoc-owned design rationale, and current console history/rewind behavior. Adapt incoming tests to public entry-ID rewind and synchronize OpenAPI/TypeScript artifacts.

Validation: default-member cargo test --all-features (4936 passed, 23 ignored), root cargo check, all-feature rustdoc with broken links denied, format/diff/name guards, OpenAPI and 11 TypeScript freshness checks. Web: 613 unit, 327 component, 7 Ticket browser and 3 Console browser tests; check/build passed. Merged-state Ticket visual review at 1440/768/390px in light/dark; no matching pre-merge image baseline. E2E/live-provider checks not run.
2026-10-13 02:46:50 +09:00
Hare 523bade3a6 fix: compose bounded execution with Ticket public callers 2026-10-12 18:25:42 +09:00
Hare e436b2ae58 fix(server): own remaining request callers and stop continuation 2026-10-12 17:36:08 +09:00
Hare 05ba6a43ad docs: correct T743 standalone scope and recover validation evidence 2026-10-12 15:55:29 +09:00
Hare 88811466f2 docs: streamline agent guidance and standardize rustdoc links 2026-10-12 15:54:18 +09:00
Hare 4d98119440 fix(server): bound embedded protocol and Feature shutdown ownership 2026-10-12 15:07:27 +09:00
Hare c6d73e8286 revert: remove withdrawn T748 composition from T743 source
Mainline corrective revert of 5c5727e3; preserve published history and report bodies. Implementation, public contracts and T743 semantic tests return exactly to 9b0d42fd against develop56795fb9. T748 platform, only-dependent adapter and execution fixtures are not T743 return gates. Independent T743 review and fixes continue on this same branch/MR.
2026-10-12 15:03:44 +09:00
Hare 4efb0ace1f docs: consolidate design documentation and standardize crate English 2026-10-12 14:58:00 +09:00
Hare 5873f25073 fix(server): own residual Ticket MR and worker request stages 2026-10-12 14:46:05 +09:00
Hare ebce7ab7b1 docs: record cancellation-safe Runtime trust revocation evidence 2026-10-12 12:18:31 +09:00
Hare 1a065db0fc fix(server): retain trust-key revoke publication after cancellation 2026-10-12 12:12:50 +09:00
Hare 6d8c49b72d docs: record Runtime authority and cancellation-safe publication stages 2026-10-12 11:30:03 +09:00
Hare 925bce2a40 fix: own Runtime management authority stages and commit publication workflows 2026-10-12 11:24:12 +09:00
Hare b63bdb4402 docs: record discovery and Runtime metadata boundary verification 2026-10-12 10:31:48 +09:00
Hare dbcea6c34b fix: await bounded Worker discovery and Runtime resource summary owners 2026-10-12 10:25:59 +09:00
Hare 27b6c688e4 docs: explain independent mandatory callback ownership and causal regressions 2026-10-12 09:47:06 +09:00
Hare a2487b575d fix: keep mandatory Subject callbacks independent of waiting Host parents 2026-10-12 09:41:50 +09:00
Hare b10b731531 docs: record review boundary fixes and exact-source verification 2026-10-12 09:06:36 +09:00
Hare 5c5727e3f8 merge: compose T748 bounded execution with T743 public callers 2026-10-12 09:00:39 +09:00
Hare c0223a0dea fix: await owned cold protocol signing and propagate authorization failure 2026-10-12 08:58:34 +09:00
Hare d3fcfb2977 fix: keep mixed Subjektiv Runtime waits off the sole DB worker 2026-10-12 08:58:31 +09:00
Hare 66bb9d0b82 docs: record bounded DB boundaries and parallel suite causal regressions 2026-10-12 08:14:36 +09:00
Hare 49bfbfb235 test: diagnose early command rejection instead of waiting forever for provider dispatch 2026-10-12 08:09:15 +09:00
Hare f597f1b57a fix: acquire SQLite write ownership before restore and deletion authority reads 2026-10-12 07:57:46 +09:00
Hare df9d056d42 test: observe SQL admission and publication before replacing authority or teardown 2026-10-12 07:55:07 +09:00
Hare f3d4aba54e fix: isolate finite DB and Host execution ownership in fixtures 2026-10-12 07:55:07 +09:00
Hare 9b0d42fdbc feat(ticket): expose case tags and independent assignment handling 2026-10-12 07:33:11 +09:00
Hare 61533fadb6 feat(backend): compose Assign routing with bounded async callers 2026-10-12 06:57:34 +09:00
Hare b1076f1d65 feat(backend): bound synchronous database and host execution ownership 2026-10-12 06:43:16 +09:00
Hare a633a53274 docs: delete unmaintained docs 2026-10-12 05:42:59 +09:00
Hare 3aaef94bfe docs: record documentation audit findings 2026-10-12 05:42:08 +09:00
Hare ce2ee11ce6 docs: define test suite ownership and execution rules 2026-10-12 05:42:08 +09:00
Hare 56795fb92c fix: drain concurrent assignment inputs and discard stale pending retries 2026-10-12 05:39:33 +09:00
Hare 26434bb867 feat: route Orchestrator delivery and Worker starts from Ticket Assign 2026-10-12 05:13:53 +09:00
Hare a6a97bca74 feat: integrate durable Ticket assignment tracking with current develop 2026-10-12 03:26:15 +09:00
Hare 27bf74a051 fix: integrate bounded Worker protocol transport with reviewed deadline fixes 2026-10-12 03:20:05 +09:00
Hare 96e5ceb4fb fix(ticket): replay Assign receipts before new work constraints 2026-10-12 03:20:02 +09:00
Hare cc9177728d fix: integrate MCP stdio deadlines with reviewed Agen fixes 2026-10-12 03:16:48 +09:00
Hare ae26cdfbef fix: integrate signal-aware Agen policies with Codex refresh bounds 2026-10-12 03:14:25 +09:00
Hare 45d8f4d69f docs: normalize Worker protocol contract formatting 2026-10-12 02:57:11 +09:00
Hare 21df210a7f fix: bound Worker protocol backpressure and connection ownership 2026-10-12 02:56:42 +09:00
Hare 2a55bef036 feat(ticket): persist Orchestrator assignment tracking and audit 2026-10-12 02:54:45 +09:00
Hare e19742d509 fix: preserve pause reclamation deadline during post-tool policy 2026-10-12 02:54:12 +09:00
Hare 2b1f68a3ea test: fence policy interruptions across tool ownership and restore 2026-10-12 02:38:38 +09:00
Hare 74d0af93e3 fix: interrupt pending tool policies before provider admission 2026-10-12 02:38:38 +09:00
Hare 520a898bd8 fix: bound MCP stdio requests and shutdown across stdin waits 2026-10-12 02:35:42 +09:00
Hare 9e1de93a04 fix: bound Codex OAuth refresh bodies and deadline 2026-10-12 02:30:16 +09:00
Hare 2bf68a5bc8 Merge branch 'docs/develop-async-audit-20261010' into hare/develop 2026-10-12 02:02:22 +09:00
Hare 6f86d11000 docs: report develop async and locking audit 2026-10-12 02:01:53 +09:00
Hare 98023b9872 refactor: remove Workdir cleanup content hashing 2026-10-12 01:50:30 +09:00
Hare 5ac3b1e482 feat: select rewind points directly from Console history 2026-10-11 14:50:49 +09:00
Hare 7f03db9d79 Preserve verified case judgment identity and canonical route authentication 2026-10-11 03:36:27 +09:00
Hare 4dd5207d1e merge: compose classification tags with Ticket case migration 2026-10-11 02:57:45 +09:00
Hare 04bdf1da23 fix: keep Console composer editable in every state 2026-10-11 02:33:34 +09:00
Hare a558c7656a merge: compose current auth migration with Ticket case schema 91 2026-10-11 02:21:52 +09:00
Hare b1fe562e15 fix: reconcile Ticket tags with current develop contracts and Runtime auth 2026-10-11 02:03:44 +09:00
Hare ba3ebaa68c feat: separate Ticket case state and immutable closure judgments 2026-10-11 01:41:46 +09:00
Hare 71ab4c06b9 fix: reconcile approved compaction result with develop updates 2026-10-11 01:37:13 +09:00
Hare e9397fa0dd feat: add shared Workspace Ticket classification tags 2026-10-11 01:23:30 +09:00
Hare a9a67fd6ad docs: report Workdir removal mount and timeout findings 2026-10-11 01:11:53 +09:00
Hare 536c593539 fix: normalize SSH migration timestamp comparisons 2026-10-11 01:11:53 +09:00
Hare 4099359560 fix: authenticate Runtime requests without persisted verification
Use the current Workspace signing identity for each request and remove binding key copies and verification handshake gates. Preserve explicit trust and revocation, migrate historical evidence, and align Runtime settings and connection tests. Retain request scope and durable replay protection with regression coverage.
2026-10-11 00:52:24 +09:00
Hare 298f8c1330 fix: carry full Host summary into compaction worker input 2026-10-11 00:47:36 +09:00
Hare 6a12b6cc34 fix: complete state-contract naming cleanup 2026-10-10 17:48:59 +09:00
Hare 3263d50125 Create 2026-10-10-compaction-user-directive-loss.md 2026-10-10 16:35:30 +09:00
Hare c78f7f7d80 feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
2026-10-10 15:55:20 +09:00
Hare 72ce817de4 fix: integrate structured Workdir refusal diagnostics
Merge independently approved T-731 source 8af492ff into current develop after T-732. No conflicts or source edits. Integration root locked/offline check, eight affected crates, Workdir default/http-client, Server 742+11 (one existing ignored), API schema freshness, fmt/diff passed. Historical Workdir403 remains unidentified; HTTP-only feature-gating limitation remains separate.
2026-10-10 14:40:05 +09:00
Hare ac01f64f4d fix: integrate concurrent Workspace request admission
Merge independently approved T-732 source defaf8f3 into develop. Integration root locked/offline cargo check, full Server 737 library and 11 binary tests (one existing ignored), fmt/diff passed. No source changes or conflict resolution.
2026-10-10 14:25:37 +09:00
Hare 8af492ff84 Merge develop into T-731 source and align scoped authorization logging fixture 2026-10-10 14:20:32 +09:00
Hare defaf8f369 fix: allow concurrent Workspace requests with deletion admission fencing 2026-10-10 13:19:52 +09:00
Hare fc57716344 docs: record workdir tooling and runtime feature blockers 2026-10-10 12:04:56 +09:00
Hare dc8e586a0b fix: keep runtime responsive during workdir cleanup 2026-10-10 12:04:56 +09:00
Hare 7321d6c7c3 fix: forward scoped Workdir delegation checks
Forward provider authorization and overlap checks through ScopedWorkdirSession while preserving capability ceilings, path rebasing, write leases, and revocation. Exercise stacked wrappers, External providers, and remote HTTP sessions without weakening resolved symlink policy.
2026-10-10 02:12:29 +09:00
Hare fd759545c1 fix(workdir): preserve mutation and command safety through diagnostics 2026-10-10 00:00:43 +09:00
Hare 6ac87c885e fix(workdir): keep diagnostic context transparent to Write creation 2026-10-09 23:21:50 +09:00
Hare 10c55874e3 fix: preserve tool classification and accurate Workdir failure stages 2026-10-09 22:55:53 +09:00
Hare f9d70e2ae2 fix: retain structured Workdir refusal diagnostics across provider boundaries 2026-10-09 22:23:22 +09:00
Hare 2060e8f2c1 fix: expose compatibility input contracts and guide WIP usage 2026-10-09 19:49:06 +09:00
Hare 471113fbcf fix: inspect only the requested WIP entity 2026-10-09 19:17:05 +09:00
Hare e8828cd1b5 feat: improve Drive file browsing and actions 2026-10-09 18:39:27 +09:00
Hare 73ac13e802 fix: restore compact Workdirs table and icon actions 2026-10-09 18:21:25 +09:00
Hare e7b1aa2a6a chore: merge develop into hare/develop 2026-10-09 18:03:58 +09:00
Hare c550abb11b fix: preserve Workdir operation errors in API logs 2026-10-09 18:03:37 +09:00
Hare caac1c18e0 fix: remove legacy ticket conclusion reason limits 2026-10-09 18:03:37 +09:00
Hare 5831190012 fix: integrate safe Workdir removal recovery
Merge approved T-730 source b1dc8f9f into develop. Resolve the test-registration conflict by preserving both Drive and Workdir-removal tests. Integration root check, Runtime/Server/Worker suites, real Web Drive adapter, generated Workdir contract, Web check/unit/components/build/browser, fmt and diff checks passed.
2026-10-09 17:43:19 +09:00
Hare b1dc8f9f1c fix: guard mount-safe Workdir cleanup and current-condition recovery 2026-10-09 17:20:51 +09:00
Hare 4d266420fc fix: retain concrete Workdir observation failure causes 2026-10-09 17:02:38 +09:00
Hare b02eb7bee3 feat: integrate Workspace Web Drive management
Merge approved T-724 source e4daa4aa into current develop a91eec29 without conflicts. Root check, Server714+11 and explicit real SQLite/LocalFileSystem/Web HTTP adapter, API TypeScript91 and generated contracts, Web check0/0/unit569/component305/build/browser9/responsive1 all passed. Fixture-browser results are distinct from real storage/auth tests; no live deployment update.
2026-10-09 16:59:19 +09:00
Hare ed50847afe fix: fence removal while permitting safe Workdir reconnection 2026-10-09 16:51:59 +09:00
Hare ef642b34be fix: explain safe WorkdirDelete recovery in tool output 2026-10-09 16:41:43 +09:00
Hare e4daa4aaa7 feat(workspace): add shared Drive browsing editing and Worker grants 2026-10-09 16:40:50 +09:00
Hare c889c9a68d fix: show Workdir removal causes and refresh ordinary retries 2026-10-09 16:35:26 +09:00
Hare a91eec295f fix: align API logging tests with worker assignment names 2026-10-09 16:28:14 +09:00
Hare 4337f415f9 fix: revalidate durable Workdir removal on ordinary retry 2026-10-09 16:26:01 +09:00
Hare 34f9e70ce8 chore: merge develop into hare/develop 2026-10-09 16:25:49 +09:00
Hare e6aa56ff6f feat: integrate authorized Worker Drive operations
Merge approved T-723 source e94af40d into develop3a2994cd. Resolve overlapping configuration WIP test migration by retaining source typed result assertions and target live Tree/Inspect publication checks. Runtime implementation merged without conflicts. Root check; full Manifest, Tools, Worker935+110, Runtime349+34, Server710+11, FS and Workdir suites; Workdir HTTP; API83/TypeScript91; WIP0.2.0 resolution and fmt/diff checks passed.
2026-10-09 16:11:07 +09:00
Hare 3a2994cdd7 feat: integrate bounded checkout discovery
Merge approved T-714 source b8faa83c into develop c79506b6. Preserve the target's already-integrated public Tree/Inspect/Invoke configuration fixture migration in the overlapping test helper; runtime implementation merged without conflicts. Root check, complete FS/Workdir/Tools/Worker/Server/API suites, HTTP Workdir suite, WIP 0.2.0 resolution, formatting and diff checks passed.
2026-10-09 15:45:41 +09:00
Hare e94af40dab fix(worker): reconcile invalid Drive completions and default pages
Accept the Backend shared default page maxima (list 200/search 128), not an unsent 100 limit. Classify semantically invalid completions before receipt reconciliation; query the original identity once and accept only validated committed results, never resend or upgrade revisions.

Regressions fail on 61afdb77 and pass after the fix: real API 201-entry default Tools/native WIP pagination; mismatched completion ID/invalid entry metadata recovery and unresolved/invalid receipt matrices. Root cargo check; Worker 920 unit +110 integration; Server 709 lib +11 binary; Drive unit18/API18; fmt and diff checks passed.
2026-10-09 15:36:06 +09:00
Hare b8faa83c47 fix: preserve unknown mutation outcomes after connection loss
Address POST_COMMIT_CONNECTION_FENCE from independent review of af655ae6. Post-success publication must not claim pre-effect rejection if Write/Edit/Create may have committed before attachment loss. Keep read-only validator mismatch and lazy entry mapping unchanged.

Add deterministic real checked provider/Tools commit -> detach -> production publication -> WIP Invoke/Client tests for all three mutations, asserting committed effects, OutcomeUnknown audit and exactly one dispatch. Narrow race9, Worker903+110, root check, dependent Server config19/proxy1, SDK resolution and fmt/diff checks passed.
2026-10-09 15:30:21 +09:00
Hare c79506b60f chore(ticket): preserve integrated authentication and WIP test fixes
# Conflicts:
#	crates/workspace-server/src/server_workspace_config_wip_tests.rs
2026-10-09 03:10:39 +09:00
Hare e90e2ab391 fix(ticket): validate configuration adapters through structured WIP 2026-10-09 03:04:47 +09:00
Hare 61afdb7773 feat: expose authorized Worker Drive tools and native WIP entries
Use T-722 identity-bound API for paged discovery, bounded text read/create/write/edit, folders, relocate/delete, revision-fixed images, Workdir artifact publication, and mutation receipts. Keep IDs as Workspace-bound decimal strings, observed CAS preconditions and Client validators hidden from model arguments, and never substitute revisions or blindly replay uncertain effects.

Activate via host-bound Feature/Profile/schema and centralized instructions. Publish only the Drive entrance as indexable; resolve selected stable IDs directly with coherent self-scoped WIP publication. Internal SubWorkers and Reviewer children deny undelegated Drive access at feature and client boundaries; no child grant or local storage authority is synthesized.

T-723 validation: root cargo check; manifest 190; tools 78 unit +39 integration +1 doctest; Worker 918 unit +110 integration; Runtime 348 unit +34 binary; Server 708 library +11 binary including 17 real DB+LocalFileSystem signed-API adapter tests. Native image capture/replay, multichunk revision bounds, Workdir import, concurrent CAS, current grants, response-loss reconciliation and dependent config WIP0.2 regressions pass. WIP crates.io 0.2.0 resolution verifier, fmt and diff checks pass.
2026-10-09 03:01:41 +09:00
Hare 8affa46bce feat: add bounded Workspace binary transport and native WIP attachments
Keep JSON and raw-byte authority paths distinct. Runtime signs exact method/query/worker/body; binary requests are bounded, reject target normalization and redirects, and sanitize transport errors. Native attachments accompany the validated WIP value and exact validator through existing ToolOutput capture/replay. Reuse scoped Workdir binary reads with complete SHA256 verification.

T-723 validation: binary transport/reviewer denial, native Invoke image capture and history replay, and bounded Workdir image helpers pass. Complete changed-crate suites and root cargo check passed on the combined Ticket implementation.
2026-10-09 03:01:14 +09:00
Hare cb8784c108 fix: integrate safe runtime proof diagnostics 2026-10-09 03:00:40 +09:00
Hare e67d75615d chore(ticket): incorporate integrated Drive and structured WIP contracts
# Conflicts:
#	openapi/server-api.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
2026-10-09 02:54:09 +09:00
Hare 7bb4096373 fix: retain safe runtime proof rejection diagnostics
T-729: distinguish issued_in_future from expired with exact checked times and overflow-safe deltas; retain request-binding mismatch reasons and proof verification progress. Correlate claimed Runtime/Worker/JTI via fixed-size fingerprints and server-generated rejection IDs, bound request metadata, omit query/raw claims/error/body/header/key material. Preserve allow/deny ordering and generic 401 responses; document diagnostic limits and T-728 policy ownership. Reported same-machine incident remains unconfirmed.

Validation: worker-runtime auth filter 3 passed; worker-runtime crate 345 lib + 17 per binary passed; Server auth logging 3 passed (17 rejection cases on both routers plus bounded metadata and generic repository401); workspace_config_integration 12 passed; Server crate 692 lib + 11 bin passed; root cargo check; cargo fmt --all -- --check; rustfmt included config tests --check; git diff --check HEAD. Existing no-default Runtime feature-gate breakage remains documented and out of scope.
2026-10-09 02:53:23 +09:00
Hare 5244308c0d feat(ticket): align Web actions and instructions with intent-led work 2026-10-09 02:51:22 +09:00
Hare 717ff2555f fix: migrate server config test helpers to WIP object API
T-729 validation prerequisite approved in Ticket seq7. Preserve Backend authority, attachment, mutation, stale observation and unknown-outcome assertions using public Tree, path-centered Inspect and structured InterfaceReference Invoke. No production API expansion.

Validation: cargo test -p yoi-workspace-server --lib workspace_config_integration (12 passed); git diff --check HEAD.
2026-10-09 02:47:56 +09:00
Hare d4ed6192ea fix: align ticket web consumers with generic worker assignments 2026-10-09 02:36:59 +09:00
Hare ea1badc0be chore: merge develop into hare/develop
# Conflicts:
#	crates/api-macros/src/axum.rs
2026-10-09 02:28:08 +09:00
Hare f1287f27e7 fix: restore console run accounting from persisted logs 2026-10-09 02:27:27 +09:00
Hare af655ae632 feat: limit checkout indexing to entrances and expose bounded typed discovery
Content directories always have no indexable children; direct authorized paths remain resolvable. Add List using provider Read/enumeration authority and separate List permission, dirs-first lexical live continuation, bounded retention/path bytes, scoped result coordinates and External DTO validation. List/Glob/Grep return Descriptor-typed entries without eager acquisition. Bind content Interfaces coherently to the live entrance scope.

Validation: root cargo check; FS 58 unit+6 pagination, Workdir 125+5 (default and http-client), Tools 84 unit+39 integration+1 doctest, Worker 901 unit+110 integration, Server 691 unit+11 CLI, Server API 83 tests; exact crates.io WIP 0.2.0 resolution; format/diff checks. Focused model/local/remote/provider/race/scope checks passed. Commands and semantics in docs/development/wip-integration-validation.md.

Publication/review paused: continuation ShowTicket returned HTTP 401. No Backend bypass, source push, MR approval, target integration or live environment update is claimed.
2026-10-09 02:22:52 +09:00
Hare 6445999120 fix: migrate dependent server WIP tests to public object tools
Dependent test compilation exposed removed discover, opaque Interface strings and private call usage. Select structured references from actual path Inspect and invoke through the public WIP 0.2.0 API.

Validation: complete Workspace Server suite (691 unit + 11 CLI), Server API 83 tests including generated artifact freshness, root cargo check and SDK resolution verification passed.
2026-10-09 02:22:18 +09:00
Hare 37a13e3566 fix: resolve target-qualified ancestor interfaces through live publications
Add a provider-owned routing hint without granting authority. Fetch checks provider ownership and re-resolves one coherent target/scope publication; default self/shared scope behavior remains unchanged.

Validation: 7 ancestor regression tests, 901 Worker unit and 110 integration tests, root cargo check, formatting and diff checks passed.
2026-10-09 02:22:18 +09:00
Hare 32c2227e8e fix: integrate guarded SSE image recovery 2026-10-09 01:51:43 +09:00
Hare edf5368e09 fix: (agen) keep malformed SSE errors diagnostic-only
Resolve MR finding raw_error_data_authorizes_image_correction. Unparseable error frames retain bounded raw_data diagnostics under a neutral base message; echoed image wording cannot authorize attachment removal. Parser-to-Engine regressions assert one request, unchanged history/images, and retained raw data for malformed numeric message and non-JSON error payloads.

Validation passed: focused unparseable_stream_error_data test; all 18 image_recovery_test tests; worker image_recovery_tests (2 route matrices); root cargo check; full cargo test -p agen, agen --features codex, and worker; cargo fmt --all -- --check; git diff --check HEAD.
2026-10-09 01:46:06 +09:00
Hare f9d4c97f1e feat: integrate WIP 0.2.0 object-centered tools 2026-10-09 01:34:42 +09:00
Hare 5480421fcc fix: (agen) recover pre-output SSE image size rejections
Handle OpenAI Responses error and response.failed diagnostics without broadening status-less HTTP retry authority. Reuse the durable ViewImage correction gate and replace the stream pump only before output/tool admission; partial streams retain terminal cleanup and are never replayed.

Regression coverage: parser-to-engine correction, pairing/largest candidate, exhaustion/no image, unrelated invalid_value, partial text/reasoning and exactly-once side effects, commit failure, filesystem persistence before retry, cancellation, restore/reseed.

Validation: cargo test -p agen --test image_recovery_test (17 passed); cargo test -p worker --lib image_recovery_tests (2 route-matrix tests passed); root cargo check; cargo test -p agen; cargo test -p agen --features codex; cargo test -p worker; cargo fmt --all -- --check; git diff --check HEAD. All passed.
2026-10-09 01:33:41 +09:00
Hare c1b3b95c5d test: wip validate scope replacement and target compatibility 2026-10-09 01:13:15 +09:00
Hare 23cf6c4f88 fix: wip bind live ancestor scopes and adopt 0.2.0 2026-10-09 01:00:12 +09:00
Hare 85c9b218dd feat(wip): integrate scoped SDK and object-centered tools
Vendor the reproducible 1cbe03b4 Client/HTTP/Protocol/Text View snapshot with Cargo checksums and credential-free Nix dependency closure. Migrate native and compatibility publications to structured references and scope_ref, preserve target-first preconditions and frozen call descriptors, and expose Tree/Inspect/Invoke with complete edge coverage and official signatures.

Validation: worker 888 unit + 110 integration tests; pinned upstream Client63, HTTP39, Protocol23, TextView20 tests/doctests; root cargo check --locked --offline; format/diff checks; 43-file vendor checksum verification; nix build --no-link .#yoi.cargoDeps. Details and provider migration seams: docs/development/wip-integration-validation.md.
2026-10-09 00:12:38 +09:00
Hare b5ea162f83 feat(drive): expose authorized typed Workspace Drive API
T-722: reuse T-721 storage/CAS/receipts with clone-bound transaction authorization against attached Server authority. Add migration 84 audited Worker read_only/read_write grants and removal/deletion fencing, member-facing ordinary access, owner-controlled grant settings, Workspace-bound decimal entry references, bounded text/binary chunks, streamed HTTP receive/download with integrity and safe headers, typed errors and generated Rust/OpenAPI/TypeScript contracts.

Validation: root cargo check; server-api 83 default / 91 typescript tests; FeatureStorage 16; Drive 31 unit + 12 integration; Workspace Server 691 library + 11 binary tests (--test-threads=4), including 9 real DB/LocalFileSystem HTTP tests for two Workers/non-owner usage, revoke during reception, concurrent CAS, response-loss receipts, chunk expiry and safe binary media. cargo fmt --all -- --check and git diff --check HEAD passed. Existing OpenAPI schemas/routes semantically unchanged; dependent TS digest projections regenerated.
2026-10-09 00:02:09 +09:00
Hare 9da79ab9f0 feat(api): support typed binary success response bodies
Preserve byte-exact BinaryBody responses in generated Axum/Reqwest adapters, declared response headers, metadata and inline binary OpenAPI schemas. Request and JSON/error behavior stay unchanged. Validated all-feature api-macros/api-macros-impl suites including 5 binary response tests, existing HTTP adapters, OpenAPI and compile-fail contracts.
2026-10-09 00:02:09 +09:00
Hare 60ec815237 chore: merge approved Ticket Worker launch into develop 2026-10-08 23:06:49 +09:00
Hare 3637824c8c fix(storage): persist metadata ancestor links before SQLite admission
Address MR finding drive-metadata-directory-durability. Sync every configured and resolved metadata directory ancestor before initializing/migrating SQLite; re-sync existing links on retry and propagate failures without admitting/caching a usable database.

Validation: metadata ordering/failure/retry tests; FeatureStorage16, Drive29unit+12integration, Subjektiv36, Server649lib+11bin (threads4); root cargo check; fmt/diff checks. strace of first-use CRUD confirms fsync(metadata/ws/features), fsync(metadata/ws), fsync(metadata) before DB initialization. No physical power-cut claim.
2026-10-08 21:53:34 +09:00
Hare bb1b05ed20 fix(ticket): preserve uncertain launch claims on local replay rejection 2026-10-08 21:53:28 +09:00
Hare 633522278b feat(drive): add durable Workspace document storage and lifecycle
Use object_store 0.13.2 immutable local blobs and FeatureStorage SQLite trees, revision CAS, same-transaction receipts, bounded reads/search, staged/orphan GC and retryable deletion. Attach Server authority to serialize cached Drive operations with deletion reservation. Document fsync compensation, Linux guarantees and offline DB-plus-blob restore.

Validation: root cargo check; workspace-drive 29 unit + 12 integration tests; feature-storage 13 tests; workspace-server 649 library + 11 binary tests with test-threads=4; cargo fmt --all -- --check; git diff --check HEAD. Default-parallel Server run hit the existing ticket_checker_burst timeout assertion (648 passed); the isolated filter and bounded-parallel full suite passed. Fault tests are not physical power-cut evidence.
2026-10-08 21:39:51 +09:00
Hare 761de69e18 feat(ticket): generalize Worker launch and durable resource claims 2026-10-08 21:36:33 +09:00
Hare e5ea7544d4 Merge branch 'develop' into hare/develop 2026-10-08 20:46:36 +09:00
Hare 1a095315ee chore: merge approved optional workspace creation into develop 2026-10-08 19:58:04 +09:00
Hare 148da0b3d9 test(workspace): preserve member-facing access without repositories
Apply T-717 item6/thread7 user decision: settings stay owner-controlled, ordinary non-owner viewing remains permitted under existing authentication. Add real HTTP assertions for non-owner workspace reads and absent management permissions; existing owner-only repository/deletion mutations and anonymous rejection remain exercised. Replace obsolete pending-policy documentation; no production authorization policy change.

Validation: targeted repository-free HTTP test, root cargo check, yoi-workspace-server 639 lib + 11 bin tests, canonical OpenAPI freshness, cargo fmt and baseline/HEAD diff checks pass. Rust tests/documentation only; rendered UI and previously reviewed browser/visual evidence are unchanged.
2026-10-08 19:41:41 +09:00
Hare df390f02e9 feat(ticket): decouple audited lifecycle decisions from MR integration
T-718: unify state, completion, close and reopen with explicit CAS and operation receipts; keep MR integration proofs independent. Queue only the explicitly selected Ticket and fence target edits against active Worker grants. Align REST, Worker/WIP, evidence projection and generated contracts.

Validation: root cargo check; full ticket, merge-request, client, worker, yoi-workspace-server and server-api tests; server-api typescript feature; OpenAPI and six generated API checkers; Svelte check and 36 narrow Web tests; fmt and diff checks.
2026-10-08 19:12:39 +09:00
Hare f8a9555626 docs(workspace): specify optional creation and replay semantics
Document current-key replay, saved receipt compatibility, explicit entry points, zero-Repository lifecycle guards, and the unresolved viewing-policy decision. Normalize visual review evidence EOF.
2026-10-08 18:35:50 +09:00
Hare 7919616db5 feat(workspace): allow creation without an initial repository
Keep bootstrap, signing identity, config and resource-key allocation atomic with optional Repository intent. Preserve repository-present fingerprints for saved receipts; replay reads current authority at the initial key and never recreates missing assets.

Provide explicit repository-free CLI creation, optional TUI/Web creation inputs, nullable generated contracts, and valid empty catalog/Worker paths. Retain existing authenticated non-owner viewing policy pending the T-717 authorization decision.

Validation: root cargo check; server tests 639 lib + 11 bin; server-api 70 default / 77 typescript; client 52 lib + 2 integration; tui 337; yoi 11 lib + 120 bin. OpenAPI/TS freshness, cargo fmt and diff checks pass. Web check/build, 18 model tests, 268 component tests and Chromium 8 width/theme combinations pass; before/after visual evidence is documented in web/workspace/test/visual-review. Full Web suite has one independently reproduced baseline Ticket source-policy failure.
2026-10-08 18:34:44 +09:00
Hare e939cd4e4b fix(engine): keep tool truncation notices in tool results
Stop emitting a user-facing Engine warning for ordinary output limiting while preserving structured size diagnostics and the model-visible truncation marker. Keep the Worker warning bridge for genuine warnings.

Regression coverage proves bounded ASCII/UTF-8 results, per-tool limits, attachments, dispositions and commit/model agreement, plus absence of live/buffered truncation alerts and preservation of other Engine alerts.

Validation: cargo check; cargo test -p agen; cargo test -p worker; targeted callback test with codex; cargo fmt --all -- --check; git diff --check HEAD.
2026-10-08 17:33:15 +09:00
Hare b715b1ee37 fix(web): label current Ticket approval from requirement attestation
T-716 MR seq3 CURRENT_REQUIREMENT_APPROVAL_USES_INTEGRATION_PROOF: derive the requirement approval label from review_after_rescope rather than approved_current_subject. Preserve separate persisted integration/source approval display. Strengthen the Web regression for approved integration with stale current requirements and recovery by exact current attestation; require the Ticket route to render that tested status projection.

Validation: 28 focused Deno decoder/status/resource/route-reuse tests; deno task check (0 errors/warnings); root cargo check; cargo fmt --all -- --check; git diff --check HEAD. Rust contracts unchanged from validated source14fea520.
2026-10-08 16:34:37 +09:00
Hare 14fea520df merge(develop): retain active-work guards with immutable Ticket evidence
T-716: incorporate T-715 (b6de908e) into the existing Ticket source branch only. Resolve MR read/readiness conflicts by keeping persisted merged-result evaluation and active assignment selection for open source observation. Preserve responsibility records and terminal work releases. Update temporary API regression fixtures to distinguish retained identity from active work.

Narrow validation: merge-request store 21 tests; Workspace Server Ticket evidence API 5 tests, including done/merged remote evidence without active assignment and unavailable open source with retained released responsibility.
2026-10-08 16:20:38 +09:00
Hare 5424280348 fix(ticket): project immutable results and typed source observations
T-716: merged evidence uses recorded result approval; open source reads use Runtime provider authority and preserve unavailability. Current requirements use the shared exact snapshot evaluator. Remove primary-MR/report SQL heuristics; continue bounded query scans through nonmatching candidates. Align Worker/Web projections and explain lifecycle versus integration/attestation. Synchronize checker test on delivered inputs, not pre-delivery attempt state.

Validation: root cargo check; cargo test -p merge-request (30), -p server-api (70), -p worker (949), -p yoi-workspace-server (620 lib + 11 binary); server-api typescript feature check; 32 focused Deno tests; deno task check (0 errors/warnings); cargo fmt --all -- --check; git diff --check HEAD; canonical OpenAPI/generated Ticket artifact checks.
2026-10-08 15:44:27 +09:00
Hare 4d0f2afc1e fix(merge-request): share immutable integration and requirement evidence
T-716: validate recorded source/approval/request identity, chronology and revocation independently of mutable selectors. Reuse exact revision and complete linked-source attestation in CompleteTicket. Assignment and lifecycle mutation are unchanged.\n\nValidation: cargo test -p merge-request (9 evidence and 21 store tests).
2026-10-08 15:44:26 +09:00
Hare b6de908e9f fix(ticket): separate retained responsibility from unfinished work
T-715: end assignment work identities atomically on done/close without deleting responsibility. Reopen never reactivates old authority. Schema 82 preserves legacy closed assignments, historical removal plans and Worker attribution; active work drives admission, auth/routing, retention and cleanup while independent lifecycle/attachment fences remain.

Validation: root cargo check; cargo test -p yoi-workspace-server (630 lib + 11 bin), -p merge-request (20), -p worker (947); worker-runtime restore/removal filters (2); Deno ticket responsibility/runtime UI contracts (12); cargo fmt --all -- --check; git diff --check HEAD.
2026-10-08 15:35:16 +09:00
Hare e723afe5f5 fix: authorize restore coordination with worker input capability 2026-10-08 15:33:38 +09:00
Hare 56ab8c22ba fix: preserve generated API error details in logs 2026-10-08 15:33:38 +09:00
Hare b0d8c7b786 chore: merge develop into hare/develop 2026-10-07 18:03:33 +09:00
Hare cfacf04260 fix: prevent subject startup deadlocks and diagnose auth rejection 2026-10-07 17:56:48 +09:00
Hare f086cf3375 feat(profiles): seal builtin import provenance and expose read-only editor imports
Retain the evaluated JSON launch entrypoint, seal observed builtin source bytes and digests as verified provenance, and fail on missing/corrupt dependencies without Runtime builtin reevaluation. Cover real HTTP commits and launch resolution, including worker mode and Workspace Config feature composition.

Expose read-only builtin import completions/diagnostics in the editor and rebuild wasm32 release assets with wasm-bindgen 0.2.117. Document public paths, ordinary composition, limits, resave semantics, and the lazy-cycle incident.

Validation: worker-runtime 344 library + 34 integration tests; workspace-server 609 library + 11 binary tests plus final builtin HTTP/launch tests (2); archive tests (16); native WASM tests (2); real-WASM/web tests (37), editor component tests (10); root cargo check, cargo fmt --all -- --check and git diff --check HEAD pass.
2026-10-07 03:38:49 +09:00
Hare 4343be86c1 feat(config-source): add confined read-only builtin Decodal imports
Share the explicit embedded Profile catalog with native and WASM evaluators. Preserve ordinary Decodal values, isolate builtin relative imports, reject reserved Workspace mutations, and capture the exact loaded builtin closure. Guard lazy import cycles and dependency depth before evaluation recurses.

Validation: config-source 48 tests, builtin-source 2 tests, manifest 185 tests; root cargo check, formatting and diff checks pass. Existing contract version/fingerprint stay unchanged; archive provenance binds builtin content instead of invalidating stored Workspace metadata.
2026-10-07 03:35:59 +09:00
Hare a64707ee69 feat: integrate value-based Workspace Profiles 2026-10-07 03:04:07 +09:00
Hare e24fbabe54 fix: align Profile authoring with compaction and reasoning contracts (T-710)
Resolve MR seq9 supported-compaction-fields and numeric-extraction-reasoning findings. Cover all canonical CompactionConfigPartial settings, token aliases and ratio helpers; accept string effort and signed integer extraction budgets without materializing editor defaults. Audit the remaining Profile field groups against their real serde contracts.

Share an actual-provider value corpus across native, generated WASM, and shared save/projection/verified Runtime archive consumption. Prove inline/import/patch exact values and effective Manifest settings; gate canonical compaction field coverage against serialization of the actual partial type. Keep prior scope union and worker/feature diagnostics fixes intact.

Validation: root cargo check; config-source40; unfiltered server644+11; Runtime340+17+17; API typescript77; Deno39; deno task check0/0; wasm32 release regeneration byte-identical with wasm-bindgen0.2.117/Binaryen132; cargo fmt/diff checks. Logs target/t710-contract-{check,config-source,server,api,runtime}.log. Reviewer seq9 independently confirmed seq3/5/7 resolution and correct main/relative-cwd routing. No target integration/ref mutations.
2026-10-07 02:51:29 +09:00
Hare 61f0469986 fix: preserve string and table Profile scope authoring (T-710)
Resolve MR sequence 5 profile_authoring_rejects_valid_string_scope_intents. Decodal 0.4 has no union constraint: retain an opaque scope range with non-materialized table completion hints so string intents remain valid. Keep shared Backend semantic validation authoritative for scope forms/types/unknown keys without changing persisted values or weakening worker/feature editor diagnostics.

Prove actual-provider native/WASM scalar/table, inline/import/patch, nested intent/deny_write/symlink_policy completion and evaluation invariance. Add real save/projection/Runtime scope equivalence and invalid-scope save non-loss coverage. Regenerated WASM artifact is byte-identical; the changed shape is transported via the fingerprint-bound Backend schema metadata.

Validation: root cargo check; config-source39; value_profiles4; unfiltered server643+11; worker-runtime340+17+17; server-api --features typescript77; wasm32 release/wasm-bindgen0.2.117/Binaryen132 -Oz; Deno39; deno task check0/0; fmt/diff checks. Logs target/t710-scope-{check,server,server-api,runtime}.log. No target/ref integration changes.
2026-10-07 02:25:16 +09:00
Hare 728ea3d915 feat: integrate standalone subjektiv support 2026-10-07 02:04:30 +09:00
Hare 24ae6288a5 feat(subjektiv): share domain and add explicit standalone Subject hosts 2026-10-07 01:52:06 +09:00
Hare 3558e4aae1 fix: diagnose nested partial Profile values in the editor (T-710)
Resolve MR sequence 3 profile_nested_editor_diagnostics_missing with a separate, non-materializing authoring constraint pass. Preserve omission and the persisted evaluation schema, and map nested/imported failures to supplied virtual-source spans. Exercise actual Backend schema in native and regenerated WASM tests for empty/partial values, worker and feature types, unknown fields, imports and composition.

Validation: root cargo check; config-source 38; config-source-wasm native/doc; unfiltered yoi-workspace-server 642 lib + 11 bin; worker-runtime 340 lib + 17 per binary; server-api --features typescript 77; focused Deno 39; deno task check; wasm32 release build, wasm-bindgen 0.2.117 + Binaryen 132 -Oz regeneration; cargo fmt/diff checks. Earlier checker failure did not recur: Reviewer independently passed full source suite and exact original baseline checker filter, recorded in MR sequence 3.
2026-10-07 00:36:16 +09:00
Hare ed3bcf35bd fix(worker): remove Wip prefix from gateway tool names (T-712)
Expose Discover, Inspect, and Call in WIP mode and update schema descriptions and current documentation. Preserve execution, argument schemas, internal types, and ordinary Tools mode. Add a registered-tool discovery/inspection/call regression.

Validation: cargo test -p worker --lib wip::tests:: (43 passed); cargo test -p worker (827 unit + 110 integration passed); root cargo check; cargo fmt --all -- --check; git diff --check HEAD.
2026-10-07 00:31:33 +09:00
Hare 58462d5844 feat: register Workspace Profiles as Decodal values (T-710)
Project the evaluated Workspace Profile value into a revision-bound JSON archive without source-path registration or Decodal re-evaluation. Preserve partial-value omissions and explicit overrides, validate before save, and reject legacy registrations with a documented explicit migration.

Fix array-element schema completion and provide fingerprint-bound authoring shapes independently of materialization defaults. Carry metadata through REST, regenerate TypeScript/OpenAPI/WASM artifacts, and exercise save/projection/Runtime consumption and persisted Manifest restore boundaries.

Validation: root cargo check; cargo test -p config-source; cargo test -p worker-runtime; cargo test -p server-api --features typescript; focused profile_settings/value_profiles/archive tests; 39 web config-source/profile API tests; deno task check; cargo fmt --all -- --check; git diff --check HEAD.

Full workspace-server test: 641 passed, one pre-existing ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output assertion failure. The same filter fails at base 3c424fd17d in a separately archived source tree and isolated target directory. Remaining server crate tests pass with only that filter skipped (641 lib + 11 bin); unrelated checker code is unchanged.
2026-10-07 00:14:51 +09:00
Hare 13055d7ee1 feat: execute durable standalone Jobs through Host-owned Internal Workers (T-708) 2026-10-07 00:08:01 +09:00
Hare 5c95859d39 chore: merge develop into hare/develop 2026-10-06 20:45:19 +09:00
Hare 3c424fd17d fix: budget subjektiv batches for compact verified Job results (T-704) 2026-10-06 19:58:11 +09:00
Hare d12cdc9106 feat: caller-selected Backend Jobs and bounded subjektiv consolidation (T-704) 2026-10-06 19:45:37 +09:00
Hare 6f9621ca2e fix: retain Restore recovery intent and retire stale caller conflicts (T-701) 2026-10-06 18:15:31 +09:00
Hare fdce12a47b feat: guarded Worker Restore from shared Web actions (T-701) 2026-10-06 17:45:54 +09:00
Hare 1a6d391f83 refactor: integrate registry-bound worker operations 2026-10-06 14:58:49 +09:00
Hare 849a6b4c1c fix(server): route embedded WorkerRemove through registry-bound reception 2026-10-06 14:50:31 +09:00
Hare 7d8e595270 docs(report): record command output readback limitation 2026-10-06 14:24:23 +09:00
Hare 4892a82a28 refactor(server): unify registry-bound workspace worker operations 2026-10-06 14:24:23 +09:00
Hare 4f38f7d5b0 refactor(worker): share file contracts across Tools and WIP providers
Flatten shared typed Tool arguments and derive WIP parameters and checkout allowlists from core schemas. Workspace settings uses shared read/write/edit processing within its captured-read and canonical tree-CAS commit path. Identical settings edits now reject before read or commit; provider response contracts and unknown-outcome handling stay intact.

T-707 validation: root cargo check; fs-operation (58); tools (74 lib, 39 integration, 1 doctest); worker (802 lib, 109 integration); workdir --features http-client (125); yoi-workspace-server --lib workspace_config (19); cargo fmt --all -- --check; git diff --check HEAD.
2026-10-06 14:18:34 +09:00
Hare 4511d4f8ac refactor(fs-operation): extract provider-neutral text operations
Share typed read/edit/write arguments, Operation schemas, decoding, pure outcomes, byte bounds, replacement policy and numbered rendering. Keep filesystem CAS and descriptor-protected mutation within the provider.

T-707: cargo test -p fs-operation passes (58 tests); overflow, Unicode, limits and local checked-save regressions are covered.
2026-10-06 14:18:34 +09:00
Hare 4ceabef40e docs: require reading test design rules before editing tests 2026-10-06 13:31:37 +09:00
Hare 3029e77c7a fix: remove tests that pin mutable profile defaults 2026-10-06 13:31:37 +09:00
Hare b96bd110c6 refactor(worker-runtime): route by WorkerRef and reconcile owned execution cleanup 2026-10-06 09:13:46 +09:00
Hare 0e2a6f2860 fix(worker): bind protocol lifetime to controller endpoints and join startup observers
T-700 prerequisite: expose real method-channel closure and endpoint identity without introducing WorkerRef routing tokens or catalog liveness mirrors. Delay command observer creation until fallible head/recovery/runtime writes finish; abort and join it on socket startup failure.

Validation: cargo test -p worker (909 tests including integration targets); focused controller startup ordering regression; git diff --check HEAD. Full Runtime/Server validation and MR publication follow after the dependent T-700 implementation is clean.
2026-10-06 08:26:09 +09:00
Hare 2c023ac2be fix(workspace-config): fence user editors from runtime source proofs
Resolve MR finding WC-AUTH-001: reject authored source-tree and Profile editor requests from Runtime/Worker proofs in both scoped and single-Workspace middleware. Keep legitimate UI editing and independent Prompt/Skill/config-bundle consumption contracts. Add signed-router authority-state matrix and authenticated UI/WIP positive regressions.
2026-10-06 07:14:31 +09:00
Hare 0a471242cb feat(workspace-config): project grant-bound logical settings through WIP 2026-10-06 06:56:51 +09:00
Hare 7a96c42130 fix(checkouts): preserve scoped descriptor ignore rules
T-699 rev20, MR 01a10d66-db02-70c2-b152-197aca92d6b7 seq3 native_glob_ignore_regression. Load authorized root/nested ignore sources through FsAccessPolicy descriptor opens, retain only bounded ancestor matchers, preserve source precedence/negation/anchors and prune ignored directories before enumeration. Expanded parity also exposed Grep source-class precedence and ignored-directory rescue errors; use the shared matcher without relaxing Grep's existing ignore-open/Git-marker provider contract. No ambient global/linked-worktree administration pointer traversal or unsafe WalkBuilder ignore loading.

Add 7 fs boundary tests, 4 actual Local/scoped provider parity tests and 1 native WIP search/link regression. Current corrected source passes cargo check --workspace --all-targets; cargo test -p fs-operation -p workdir -p tools -p worker (fs37 Workdir125 Tools72+10+29+1doc Worker780+109integration); cargo test -p worker-runtime -p yoi-workspace-server (Runtime279+17+17 Backend576+11); fmt/diff. Logs target/validation/T-699/rereview-{check,core,hosts}.log. Earlier intermediate parity/WIP assertion failures retained, not passing evidence. No target/ref integration, RO source changes, environment update or completion.
2026-10-06 04:24:35 +09:00
Hare b952e56bc2 test: use required Bash cwd in compatibility regressions 2026-10-06 03:44:03 +09:00
Hare ae19426298 fix: preserve sanitized dangling-symlink read diagnostics 2026-10-06 03:42:06 +09:00
Hare 603a814b80 Merge commit '5670c20a6a50077985168269dfc15a6776c66ba6' into work/T-699-checkout-projection 2026-10-06 03:37:07 +09:00
Hare 149853bb0e feat: project attached checkouts with scoped native AI operations 2026-10-06 03:37:07 +09:00
Hare 5670c20a6a fix: integrate develop and preserve invocation notification recovery 2026-10-05 22:25:32 +09:00
Hare c847c2ffad fix: cancel pending uploads when composer chips are removed 2026-10-05 20:55:17 +09:00
Hare 9812e68b4d fix: synchronize native picker interception in Feature browser tests 2026-10-05 20:14:19 +09:00
Hare 9146331450 fix: echo REST completion nonce and argument context 2026-10-05 20:14:19 +09:00
Hare 1a45542842 fix: preserve invocation recovery across rewind and revalidate replay authority 2026-10-05 20:14:19 +09:00
Hare 3deef49897 fix: reconcile T-697 generated contracts with latest develop 2026-10-05 19:35:04 +09:00
Hare c57e236768 fix: update builtin Sol and Luna model defaults 2026-10-05 19:15:02 +09:00
Hare f5c6b0aeb1 fix: deduplicate console entries and contain worker rendering errors 2026-10-05 19:13:15 +09:00
Hare 31f7847d1f fix: preserve multiple positional invocation arguments and update API schema 2026-10-05 19:07:23 +09:00
Hare fa1169c556 docs(report): record reviewer severity schema mismatch 2026-10-05 19:01:13 +09:00
Hare 6feed0f22f fix(wip): page complete workdir catalogs and fence full connection sets 2026-10-05 19:01:13 +09:00
Hare 6972928612 feat: integrate persistent Subject behavior context 2026-10-05 18:48:12 +09:00
Hare a3c3bda3fa feat: add enlarged previews for console images 2026-10-05 18:45:23 +09:00
Hare f7783b3d2a feat: add declarative Feature chat invocations and typed composers 2026-10-05 18:44:41 +09:00
Hare 0fe61df25d fix: integrate serialized orchestrator attention 2026-10-05 18:10:12 +09:00
Hare 01156f4c78 docs: report SubWorker cwd attachment-root ambiguity 2026-10-05 18:10:01 +09:00
Hare 42e4ce5550 feat: publish Repository and Workdir WIP objects
Use Host root namespace ownership and disjoint Feature operation contributions. Separate read-only workdir_catalog from manage_workdir, encode all existing identities canonically, publish safe bounded inventory and lifetime-bound attachment Objects, and reuse normal lifecycle/session router/lock authority.

Contextual interfaces resolve the current permission/capability intersection; object and interface validators reject stale cached access. Cover fresh workspace WIP-only discovery/create/attach/read/detach, readonly and unsupported targets, collision/revocation/alias reuse, active cleanup exclusion and terminal unknown outcomes without retries.

Validation: WIP 40 tests; worker 730 unit + 96 integration tests serial; manifest 182 tests; workspace-root cargo check; cargo fmt --all -- --check; git diff --check HEAD.
2026-10-05 18:10:01 +09:00
Hare bbfbf92264 feat: bind Workdir attachments to durable connection lifetimes
Add caller-scoped bounded connection inventory and conditional detach on the existing ledger/session lifecycle. Preserve lifetime identity during attenuation and compensation, migrate persisted rows, and bind self operations to verified Runtime/Worker proof.

Validation: workspace-root cargo check; Backend 561 unit + 11 CLI tests; server-api 62 default + 69 typescript tests. Canonical OpenAPI and generated TypeScript source digests regenerated; browser declarations unchanged.
2026-10-05 18:10:01 +09:00
Hare 0a8f1f3b1b fix(workspace-server): drain Restore rechecks after reserved no-op checks
T-703 review follow-up: retire only the exact pre-Restore accepted ID, preserve pending deliveries, and retain an explicit deferred Restore recheck when another caller owns the reservation. Atomically release/take deferred work at completion and drain it without holding a Runtime lock.

Regression fixes the independently identified 3174142b interleaving: pause the real reservation before fingerprint comparison and after a no-op match, accept an actual fixture Restore, and verify exactly one new send after reservation completion. Also verify unknown delivery ID/content survive Restore.

Validation: cargo check; cargo test -p yoi-workspace-server (566 lib +11 bin); cargo test -p worker --lib notification_ (14); cargo fmt --all -- --check; git diff --check HEAD. Previous full Worker serial 703 lib+96 integration remains applicable (Worker unchanged in this fix). Tool schema rejection/Reviewer one-call barrier recorded in docs/report.
2026-10-05 18:01:10 +09:00
Hare 3174142bd1 fix(workspace-server): serialize explicit orchestrator backlog attention
T-703: snapshots establish baselines; only observed Running -> Idle rechecks backlog. Scope reservation and success/pending records to the exact Orchestrator; preserve payload and request ID across bounded retries and Restore races. Drop the launch lock before attention delivery.

Regression evidence: Barrier/parallel start, reconnect snapshots, retry payload identity, Restore/replacement; real Runtime adapter + Worker Controller + canonical Session fixture proves start/Restore send/receipt/commit counts and post-acceptance response-loss dedupe. Worker FsStore regression proves same-ID retry after restore versus legitimate new-ID same-body notification.

Validation: cargo check; cargo test -p yoi-workspace-server (564 lib + 11 bin); cargo test -p worker -- --test-threads=1 (703 lib + 96 integration); cargo fmt --all -- --check; git diff --check HEAD. Default parallel worker test reproduced existing pending_restore_reuses_persisted_session_and_recovers_unknown_attribution ScopeLock NotFound; isolated test and full serial suite pass.
2026-10-05 17:43:11 +09:00
Hare 5e4902db2b Merge latest develop after WIP namespace integration
Normal merge of develop@5a4c823c preserves integrated T-698 controller/native projections alongside the T-692/T-693 reconciliation. Target branch remains untouched.

Validation after this target movement: root cargo check; full worker(serial) and workspace-server tests; cargo fmt --all -- --check; git diff --check HEAD. Earlier reconciliation passed session-store, server-api generated/OpenAPI, full Web and production browser-shell checks.
2026-10-05 17:34:47 +09:00
Hare a4af26e8fd Merge develop into T-693 and reconcile Subject behavior with Memory information
Normal merge of develop@b0bce50b retains T-692 state hierarchy, diagnostics disclosures, ready-empty and candidate-only regressions alongside T-693 behavior/CAS/route fences. ResidentContext load and refresh retain the five-second timeout from 685b8aa6, with a seven-request regression.

Validation: root cargo check; full worker(serial), session-store, workspace-server and server-api(typescript) suites; frontend check with zero diagnostics; 464 Web tests and 171 component tests (19 Memory); production Memory browser-shell test/build; fmt/diff/OpenAPI checks.
2026-10-05 17:31:17 +09:00
Hare 5a4c823c40 feat: integrate host-owned WIP namespaces 2026-10-05 17:17:27 +09:00
Hare d3a1788445 fix: pin WIP cancellation to the active operation provider
Resolve WIP_CANCEL_ROUTING from MR 01a10ac8-c63a-7e33-bc27-07a52f87abe9. Retain the dispatched handler instance and selected operation per active execution; forward cancellation only to that contributor, not unrelated providers or shared-handler aliases. Keep cancellation terminal confirmation and failure classification unchanged.

Validation: contributed_cancellation tests (2 passed), all WIP tests (23 passed), root cargo check, cargo test -p worker -- --test-threads=1 (713 unit and 96 integration passed), cargo fmt --all -- --check, and git diff --check HEAD. Static/dynamic pending-call regressions cover unrelated cancel errors, shared handler duplication, exact dynamic handler identity, unknown IDs, terminal cleanup and cancelled audit.
2026-10-05 17:08:39 +09:00
Hare b0bce50bb4 chore: integrate Subject Memory information improvements 2026-10-05 17:08:32 +09:00
Hare 06480b9c7c Fix Subject behavior editor isolation across reused routes
Reset editor state for each loader identity and bind saves to their captured Subject and Workspace. Fence late success, error, and finally updates using the loader generation, including navigation back to the same Subject.

Validation: 14 Memory component tests (5 new route/workspace/in-flight isolation cases); frontend check with zero diagnostics; production Memory browser shell test; cargo check; cargo fmt --all -- --check; git diff --check HEAD. Restored implementation full Rust and frontend suites were independently rerun before this Web-only fix.
2026-10-05 16:59:13 +09:00
Hare b981e60231 test(web): document Subject Memory information decisions 2026-10-05 16:58:35 +09:00
Hare e17e81a9aa fix: enforce WIP namespace ownership and preserve collection identity
Restrict compatibility mounts to single tool routes, reject foreign dynamic Object owners, and prefer explicit Objects over synthetic namespaces during discovery. Document subsequent provider registration and keep reference resolution independent from enabled management operations.

Validation: cargo test -p worker --lib wip::tests (21 passed); root cargo check passed; cargo test -p worker -- --test-threads=1 passed (711 unit, 96 integration tests); cargo fmt --all -- --check and git diff --check HEAD passed. Initial parallel worker suite hit ScopeLock NotFound in pending_restore_reuses_persisted_session_and_recovers_unknown_attribution; the isolated test and full serial suite both passed.
2026-10-05 16:53:39 +09:00
Hare 8891236db3 Add persistent Subject behavior context 2026-10-05 16:27:23 +09:00
Hare 0c644c13e5 fix: preserve failed reviewer handoff evidence 2026-10-05 15:43:06 +09:00
Hare bc7b2f894c feat: expose WIP objects from host namespaces 2026-10-05 15:33:58 +09:00
Hare a00a0575f6 chore: merge develop into hare/develop 2026-10-05 14:06:04 +09:00
Hare 90c74df879 fix: enable indentation keys in Decodal editor 2026-10-05 14:05:45 +09:00
Hare b489a59057 fix: handle configuration schema bodies and editor load failures 2026-10-05 14:05:45 +09:00
Hare 4cf6c130cd fix(web): distinguish empty resident context 2026-10-05 13:59:39 +09:00
Hare fbb09e6597 feat(web): clarify Subject Memory status 2026-10-05 13:56:33 +09:00
Hare cc97987482 fix: preserve ambiguous legacy session attribution 2026-10-05 12:50:47 +09:00
Hare d2a0b75045 fix: finalize subjektiv attribution at session lifecycle 2026-10-05 12:25:20 +09:00
Hare fec85dd38d fix: simplify console mode controls with a compact footer toggle 2026-10-05 01:58:22 +09:00
Hare 685b8aa65f fix: prevent subject worker launch stalls 2026-10-05 01:22:58 +09:00
Hare bd79b93c1d chore: merge develop into hare/develop 2026-10-04 23:28:46 +09:00
Hare e741643717 fix: accept submission completion state in worker API parser 2026-10-04 23:27:50 +09:00
Hare 74a00f9edc fix: recover from provider image size rejections 2026-10-04 23:17:43 +09:00
Hare c6d2ad1f4c fix(test): format Subject browser evidence 2026-10-04 22:52:37 +09:00
Hare c8caa5518a fix(test): use shared Subject singleton key 2026-10-04 22:42:44 +09:00
Hare 3a9def7b80 Merge remote-tracking branch 'origin/develop' into work/T-689-web-subject-create 2026-10-04 22:35:43 +09:00
Hare 1c5966c535 feat(web): add Subject creation to Memory index 2026-10-04 22:35:21 +09:00
Hare 08881fa27a chore: merge develop into hare/develop 2026-10-04 21:03:16 +09:00
Hare 8a2a82d13c fix: close mobile sidebar after navigation 2026-10-04 21:03:16 +09:00
Hare b0d4973717 fix: align console metadata and details controls 2026-10-04 21:03:16 +09:00
Hare 728f03952e fix: recover job cleanup across runtime restart 2026-10-04 15:03:04 +09:00
Hare c7ae825a02 fix: persist worker run completion fence 2026-10-04 14:08:44 +09:00
Hare 8ad52e36c1 test: update worker state fixtures 2026-10-04 13:49:12 +09:00
Hare d8f3e46126 Merge remote-tracking branch 'origin/develop' into work/T-690-backend-job-worker-cleanup
# Conflicts:
#	web/workspace/src/lib/generated/protocol-validator.ts
2026-10-04 13:44:01 +09:00
Hare 20109b88df fix: fence cleanup on tracked run completion 2026-10-04 13:40:10 +09:00
Hare 773907462b Resolve Console images across session lineage 2026-10-04 13:14:36 +09:00
Hare 1fbf93c8c1 fix: fence job cleanup on run teardown 2026-10-04 13:12:39 +09:00
Hare 2eb9418a4e Separate Console image bodies from session snapshots 2026-10-04 12:56:40 +09:00
Hare d3eb7bc66e feat: clean up terminal backend job workers 2026-10-04 12:45:19 +09:00
Hare f7b722246d feat: configure subject attachment on worker launch 2026-10-04 08:40:30 +09:00
Hare 1bbc48dad0 feat(worker): project merge requests as native WIP objects 2026-10-04 03:10:42 +09:00
Hare 983c20c3f7 fix(worker): preserve Ticket WIP outcome semantics 2026-10-04 02:38:55 +09:00
Hare 31ee6b3c6a feat(worker): project tickets as native WIP objects 2026-10-04 02:22:45 +09:00
Hare 28c9d752e3 fix(worker): preserve Objective WIP authority semantics 2026-10-04 01:49:16 +09:00
Hare e5f255b6fd feat(worker): project objectives as native WIP objects 2026-10-04 01:28:45 +09:00
Hare e2181e9052 fix(worker): preserve typed native WIP contracts 2026-10-04 00:46:51 +09:00
Hare e44637519e fix(worker): forward native WIP arguments by descriptor name 2026-10-04 00:31:49 +09:00
Hare 4a05aad508 feat: add explicit Worker WIP mode 2026-10-04 00:26:15 +09:00
Hare 85a8944a6c fix: integrate feature-owned subjektiv restore refresh 2026-10-03 23:49:38 +09:00
Hare 8399d4f79c fix: integrate sidebar workspace alerts 2026-10-03 23:43:27 +09:00
Hare eea873e7dc fix: keep subjektiv restore refresh feature-owned 2026-10-03 23:37:23 +09:00
Hare bf918d1a11 fix: route sidebar failures through workspace alerts 2026-10-03 23:35:43 +09:00
Hare 29ed19fc8c Merge branch 'fix/compact-result-warning' into hare/develop 2026-10-03 21:57:18 +09:00
Hare 75fc7c921b fix: warn instead of failing on oversized compact results 2026-10-03 21:56:58 +09:00
Hare 3875c8e675 fix: preserve system item identity in live events 2026-10-03 21:44:36 +09:00
Hare 00c7e223b1 fix: preserve workdir link columns during schema migration 2026-10-03 21:01:55 +09:00
Hare f637667384 chore: merge develop into hare/develop 2026-10-03 19:14:17 +09:00
Hare 13bce155aa fix subject authority and bounded memory browsing 2026-10-03 01:18:24 +09:00
Hare f4966b78ea feat: cut over Memory to subjektiv product surfaces 2026-10-03 00:06:47 +09:00
Hare 07fcc249fe fix: resume exact reads within large segments 2026-10-02 18:11:51 +09:00
Hare ec7a6f59c8 fix: complete subject session paging 2026-10-02 18:00:30 +09:00
Hare c491cf8a48 fix: make subject session reads resumable 2026-10-02 17:36:10 +09:00
Hare ce841cfee0 Merge remote-tracking branch 'origin/develop' into work/T-673-subjektiv-session-history 2026-10-02 16:52:02 +09:00
Hare ee6aa9de41 feat: discover subject session history 2026-10-02 16:41:29 +09:00
Hare 3ae9b1c6d1 test(workspace-server): update greeting fixture 2026-10-02 16:36:06 +09:00
Hare 07cbff9a5f fix(console): keep live context metadata race-free 2026-10-02 16:25:43 +09:00
Hare 3d37f056ad fix: synchronize live worker context metadata 2026-10-02 16:04:55 +09:00
Hare cf3cf4b766 feat: expose worker model and context metadata 2026-10-02 15:29:45 +09:00
Hare 43e3b42055 feat: integrate bounded subject memory surfaces 2026-10-02 15:11:12 +09:00
Hare dfe837655a fix: preserve resident surface startup states 2026-10-02 15:01:09 +09:00
Hare aad200258c fix: integrate explicit Workspace repository key requirement 2026-10-02 14:43:57 +09:00
Hare 7b13303724 fix: preserve repository key for backend validation 2026-10-02 14:34:51 +09:00
Hare e662e2f465 fix: refresh memory surfaces on restore 2026-10-02 14:32:37 +09:00
Hare 138a8f4cec fix: stabilize persisted run transition identities 2026-10-02 14:31:04 +09:00
Hare 013f909b9b fix: require explicit Workspace repository key 2026-10-02 14:27:52 +09:00
Hare 504ea00ae3 fix: harden memory surface generation 2026-10-02 14:01:27 +09:00
Hare 860252c22d fix: report actual worker execution unavailability 2026-10-02 14:00:04 +09:00
Hare 539163acf3 feat: generate bounded subject memory surfaces 2026-10-02 13:25:40 +09:00
Hare 2d28c05447 chore: use 6-luna and raise compaction turn limit 2026-10-02 11:52:39 +09:00
Hare b74f1b495d Merge current develop into T-678 2026-10-02 11:31:52 +09:00
Hare 16fa3f14c9 Scope Worker resource keys by Runtime 2026-10-02 11:31:46 +09:00
Hare c631310192 Scope durable Worker identity by Runtime 2026-10-02 11:13:38 +09:00
Hare ba98a6afe6 fix: harden subject memory consolidation 2026-10-02 11:13:11 +09:00
Hare 046e606a38 feat: connect subject memory consolidation 2026-10-02 10:46:12 +09:00
Hare 2c329072a2 Replace Worker list revisions with ordered subscriptions 2026-10-02 10:41:02 +09:00
Hare 8f8c2676b4 fix: keep memory reads globally bounded 2026-10-02 09:52:32 +09:00
Hare 860c34b8da fix: bound memory read continuations 2026-10-02 09:26:53 +09:00
Hare f50a0caea9 fix: harden subject memory delivery 2026-10-02 09:05:11 +09:00
Hare 07f2b0e8a3 Merge remote-tracking branch 'origin/develop' into work/T-669-subjektiv-memory-tools 2026-10-02 07:39:25 +09:00
Hare b391392246 feat: add subject-scoped memory tools 2026-10-02 07:39:04 +09:00
Hare a0460f107d chore: upgrade frontend dependencies to SvelteKit 3 2026-10-02 07:36:25 +09:00
Hare 6d7eac940c fix(web): restore in-flight blocks through live projection 2026-10-02 07:18:58 +09:00
Hare 1f26a7f2bb fix: fence subjektiv completion side effects 2026-10-02 06:33:30 +09:00
Hare fede0fb51b chore: refresh subjektiv API artifacts 2026-10-02 06:18:02 +09:00
Hare 6e293d57cd test: cover subjektiv lifecycle boundaries 2026-10-02 06:18:02 +09:00
Hare 9a7bfba507 Merge remote-tracking branch 'origin/develop' into work/T-668-subjektiv-memory-lifecycle 2026-10-02 06:00:49 +09:00
Hare 6091fafcac feat: connect subjektiv extraction lifecycle 2026-10-02 06:00:31 +09:00
Hare d10783c0d6 fix: honor compaction cancellation and replay identity 2026-10-02 05:38:42 +09:00
Hare 88338229a1 fix: deduplicate compaction terminal failures 2026-10-02 05:30:17 +09:00
Hare d0ea9f598d fix: include idle-only input in runtime API contract 2026-10-02 03:33:30 +09:00
Hare d5f49cbf00 fix: restrict worker send input to idle admission 2026-10-02 02:39:10 +09:00
Hare 4ac448d66c fix: separate console pending groups and align row typography 2026-10-02 01:55:54 +09:00
Hare 17b7bdc769 chore: merge develop into hare/develop 2026-10-02 01:02:37 +09:00
Hare 62b0f87477 feat: integrate subjektiv revisioned memory store 2026-10-01 17:23:49 +09:00
Hare 1e59a07ea8 feat: integrate durable run transitions 2026-10-01 17:17:44 +09:00
Hare 014b948990 Add subjektiv revisioned memory store 2026-10-01 17:16:25 +09:00
Hare e43410f5e3 Fence lifecycle capture on cancelled runs 2026-10-01 17:12:02 +09:00
Hare 64ed50f6c0 Treat intentional cancellation as a non-failure terminal 2026-10-01 17:04:05 +09:00
Hare 0ec2b3ad86 Persist logical run yield and resume transitions 2026-10-01 16:44:11 +09:00
Hare 2f75176648 Fence feature operations during teardown 2026-10-01 16:37:46 +09:00
Hare 7797a65d54 Add server-managed feature SQLite storage 2026-10-01 16:27:30 +09:00
Hare f5d8c94713 fix: bind singleton retries to caller intent 2026-10-01 15:57:05 +09:00
Hare f351996d2c fix: harden singleton retries and authority 2026-10-01 15:46:18 +09:00
Hare 1afddc148d feat: enforce arbitrary worker singleton ownership 2026-10-01 15:27:05 +09:00
Hare d066f3963f fix: separate workspace settings display and editing 2026-10-01 15:14:57 +09:00
Hare 8e0dad98e8 fix: replace home navigation with work dashboard 2026-10-01 14:25:21 +09:00
Hare f742362b03 fix: capture ticket edit preimages atomically 2026-10-01 14:06:33 +09:00
Hare d101b4bacb fix: queue ticket checks with exact snapshots 2026-10-01 13:52:57 +09:00
Hare 696e6a51e7 fix: rebuild workspace home around daily navigation 2026-10-01 13:52:16 +09:00
Hare b9aaff4052 fix: synchronize sidebar pin and main layout animations 2026-10-01 13:36:19 +09:00
Hare f99da27891 fix: tighten sidebar footer spacing and inset divider 2026-10-01 13:30:22 +09:00
Hare d3c3f6f4d5 fix: exclude temporary Vitest configs from dev watcher 2026-10-01 13:23:32 +09:00
Hare 9579f3e245 feat: animate main layout and separate sidebar controls 2026-10-01 13:12:13 +09:00
Hare ab8b4ab1ef feat: check saved ticket requirements asynchronously 2026-10-01 13:06:57 +09:00
Hare 101b021c5f fix: anchor sidebar pin button at bottom left 2026-10-01 12:54:09 +09:00
Hare ee83fca95b fix: keep sidebar stationary when changing pin mode 2026-10-01 12:43:22 +09:00
Hare 5c98dbb208 feat: add pinned and hover sidebar modes 2026-10-01 12:36:39 +09:00
Hare b061ab68de Merge remote-tracking branch 'origin/work/T-674-backend-job-runner' into develop 2026-10-01 12:33:46 +09:00
Hare 63951398f0 fix durable job notification receipts 2026-10-01 12:26:30 +09:00
Hare 027a346e9d fix tracked job dispatch and delivery recovery 2026-10-01 11:58:49 +09:00
Hare 3ee1698957 feat: slide sidebar when expanding and folding 2026-10-01 11:48:50 +09:00
Hare a07d70b107 feat: persist sidebar fold preference across logins 2026-10-01 11:42:49 +09:00
Hare 213ed76061 fix backend job execution fencing and delivery 2026-10-01 11:18:14 +09:00
Hare eb4c7959a7 chore: replace GPT-6 Sol with GPT-6.1 Sol 2026-10-01 11:16:45 +09:00
Hare b5ae7dcd64 feat: animate console jump to latest button 2026-10-01 11:07:50 +09:00
Hare 1bc1f54ea0 feat: add console jump to latest icon 2026-10-01 10:59:13 +09:00
Hare 2ddf547308 fix: remove composer footer error messages 2026-10-01 10:09:52 +09:00
Hare ca838e4acf feat: add durable backend job runner 2026-10-01 10:06:55 +09:00
Hare 6b2f40a2a2 fix: isolate legacy external grants from workdir inventory 2026-09-30 14:42:29 +09:00
Hare 13f01b2495 style: apply workspace rustfmt 2026-09-30 14:28:42 +09:00
Hare 44d6d740be feat: default tool calls to early dispatch 2026-09-30 14:28:42 +09:00
Hare 3d87c3860a fix: remove overridden turn boundary line height 2026-09-30 14:16:58 +09:00
Hare 8627d45085 fix: reconcile console history before grouping activity 2026-09-30 14:16:58 +09:00
Hare cbf1b83599 fix: constrain external grep ignore ancestor matching 2026-09-30 13:58:39 +09:00
Hare 699a3c051c fix: restore centered turn navigation 2026-09-30 13:50:21 +09:00
Hare 5f8ae508fc fix: top-align turn bars and constrain history boundary 2026-09-30 13:45:45 +09:00
Hare 657cae1d4b feat: add composer completion and mini task controls 2026-09-30 13:11:28 +09:00
Hare c0fd476d8b refactor: remove worker capability summary flags 2026-09-30 13:11:28 +09:00
Hare eb222c8d62 fix: honor disabled workspace request timeouts 2026-09-30 13:10:49 +09:00
Hare 5560a88f43 fix: preserve turn anchors when prepending history 2026-09-30 12:23:27 +09:00
Hare 58c5c43061 chore: merge develop while preserving console interactions 2026-09-30 12:17:49 +09:00
Hare c73e20be17 fix(console): preserve exact adopted history boundaries 2026-09-30 11:40:45 +09:00
Hare 709f3c5530 fix(console): reconcile history around lineage anchors 2026-09-30 11:16:23 +09:00
Hare 4976b6fd00 fix(console): bound retained history reconciliation 2026-09-30 10:56:01 +09:00
Hare aa144ee6fa test: verify console history in production browser 2026-09-30 09:36:02 +09:00
Hare 6d0eaef6bc Merge remote-tracking branch 'origin/develop' into work/T-659-console-history-pagination 2026-09-30 09:20:23 +09:00
Hare 065812bc39 fix(web): cover deep document headings 2026-09-30 09:09:24 +09:00
Hare 6d5d979012 feat: add console turn navigation and pending previews 2026-09-30 09:08:04 +09:00
Hare 22c2e25c8f Merge remote-tracking branch 'origin/develop' into work/T-661-memory-document 2026-09-30 08:39:27 +09:00
Hare 717d28839d fix: exercise production CLI image provider 2026-09-30 08:32:43 +09:00
Hare 4e8a7fc118 fix: address ViewImage external review findings 2026-09-30 08:16:06 +09:00
Hare 34ed988b75 feat: page console history across compaction 2026-09-30 08:13:07 +09:00
Hare a0944f66c8 feat(web): redesign Memory document reader 2026-09-30 07:48:28 +09:00
Hare 05d6da45fb fix: add bounded binary reads for ViewImage 2026-09-30 07:44:59 +09:00
Hare 2006b390b4 fix: restore console input and attachment workflows
Restore durable busy Submit queues and TUI Enter delivery, expose Web Queue beside Notify, and separate the Composer toolbar from its editor without persistent success notices.

Support pasted image attachments and restore canonical Worker Session uploaded-file operations, including retention, binding, cleanup and authenticated upload context.

Validated Worker, Protocol and Session Store suites, 278 TUI tests, 59 Web tests and Svelte check. Runtime suite could not complete due to external Workdir transport timeouts.
2026-09-30 05:16:57 +09:00
Hare cafbb73a22 fix: refresh generated Workdir API contract 2026-09-30 02:07:24 +09:00
Hare 39ecd1b239 fix: enforce hierarchical external Workdir permissions 2026-09-30 02:01:12 +09:00
Hare 677c7cfed9 fix: accept external Workdirs in Browser subscriptions 2026-09-30 01:14:24 +09:00
Hare 5f19dcc93f fix: use browser-safe WebSocket close code 2026-09-29 15:38:12 +09:00
Hare 39b4a019c9 fix: preserve external Workdir Workers in sidebar 2026-09-29 15:29:10 +09:00
Hare 8c78e55b28 chore: format Worker command telemetry 2026-09-29 15:19:01 +09:00
Hare 60137e0aad chore: merge develop into hare/develop 2026-09-29 15:15:02 +09:00
Hare 3023a270f4 fix: isolate Worker command telemetry 2026-09-29 15:10:57 +09:00
Hare 4ffa9e418e chore: refresh generated API contract hashes 2026-09-29 14:49:13 +09:00
Hare 7a783fcf4e chore: merge current develop into T-656 2026-09-29 14:41:58 +09:00
Hare e9e0368db4 fix: preserve external command attachment authority 2026-09-29 14:41:36 +09:00
Hare 38c2a78b1c feat: integrate early tool call dispatch 2026-09-29 14:02:11 +09:00
Hare 8ff7df7edf fix: preserve response-complete stream EOF 2026-09-29 13:55:07 +09:00
Hare d81c9a8154 fix: authorize signed workers for Workdir removal 2026-09-29 13:51:03 +09:00
Hare 36295edc08 fix: require terminal stream completion 2026-09-29 13:43:36 +09:00
Hare c18e3ea0d6 fix: align Ticket review revision checks 2026-09-29 13:22:28 +09:00
Hare 4ab2a2b88a style: format merged workspace server changes 2026-09-29 13:02:16 +09:00
Hare 0440e64975 chore: merge updated develop into T-656 2026-09-29 12:59:11 +09:00
Hare 1980da2880 fix: close early dispatch recovery gaps 2026-09-29 12:37:13 +09:00
Hare dd2bc41ab8 fix: harden external command lifecycle 2026-09-29 12:03:02 +09:00
Hare dcf14ef565 fix: harden early tool call dispatch 2026-09-29 12:00:36 +09:00
Hare 8b951d53e6 feat: add external workdir command permissions 2026-09-29 11:26:25 +09:00
Hare cc2f8b3422 feat: add opt-in early tool call dispatch 2026-09-29 11:07:59 +09:00
Hare d8067c9789 fix: preserve external Workdirs in Worker projections 2026-09-29 10:22:41 +09:00
Hare 12d1d0e2d9 fix: include initial snapshot in Worker subscriptions 2026-09-29 09:01:24 +09:00
Hare 09f8cfb718 fix: reject unresolved worker console targets 2026-09-29 07:36:03 +09:00
Hare c979883463 test: align merge request review fixtures 2026-09-29 06:51:09 +09:00
Hare 21962d7a6a chore: merge develop into hare/develop 2026-09-29 06:44:40 +09:00
Hare 08cbf93f7b fix: reject unavailable retained sessions 2026-09-29 06:44:27 +09:00
Hare d1303ab638 fix: enforce complete external edit result contract 2026-09-29 06:30:05 +09:00
Hare 60e0bf1d86 fix: fence external mutation preimages and bounds 2026-09-29 06:21:03 +09:00
Hare 6cf7e57309 feat: clarify external filesystem write scope 2026-09-29 06:07:55 +09:00
Hare 73b1f97e02 feat: format incorporated runtime baseline 2026-09-29 06:04:06 +09:00
Hare 56822570f6 feat: finalize external workdir implementation formatting 2026-09-29 06:03:49 +09:00
Hare 35691071d0 feat: preserve runtime fences in workdir migration 2026-09-29 06:02:13 +09:00
Hare e96715dd56 feat: synchronize generated web contract digests 2026-09-29 05:58:52 +09:00
Hare 544204b24e feat: update external workdir generated contracts 2026-09-29 05:57:58 +09:00
Hare 00e0472807 Merge remote-tracking branch 'origin/develop' into work/T-652-external-workdir-interactive-rw 2026-09-29 05:56:28 +09:00
Hare 59f391e0cd Merge remote-tracking branch 'origin/develop' into work/T-652-external-workdir-interactive-rw 2026-09-29 05:53:39 +09:00
Hare 24be69918d feat: add interactive read-write external workdir sharing 2026-09-29 05:53:32 +09:00
Hare 59388f9c35 feat: incorporate T-653 Console baseline 2026-09-29 05:48:12 +09:00
Hare 26f1de976a feat: model Worker Console display states 2026-09-29 05:48:05 +09:00
Hare ebc98e3de5 fix: read retained sessions from canonical aggregate 2026-09-29 05:43:01 +09:00
Hare f33e737d5c fix: align browser validation with Rust wire shape 2026-09-29 05:15:59 +09:00
Hare df3251b2a0 feat: validate workspace protocol frames in browser 2026-09-29 05:02:56 +09:00
Hare 54c1174698 Allow fresh requirement review after integration 2026-09-29 02:01:27 +09:00
Hare ad08110c96 Bind ticket completion to current review evidence 2026-09-29 01:47:01 +09:00
Hare 590abf3142 Support multiple merge requests per ticket 2026-09-29 01:08:46 +09:00
Hare ac7a6027bd fix: restore backend workers from resume 2026-09-28 04:28:35 +09:00
Hare e1234e5d1c chore: merge develop into hare/develop
# Conflicts:
#	web/workspace/deno.json
#	web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte
#	web/workspace/test/repository-access/ui.test.ts
2026-09-28 03:45:13 +09:00
Hare ab63a06677 docs: align worker run lifecycle authority 2026-09-26 22:50:23 +09:00
Hare 2e5a95b842 fix: isolate restore candidate observations 2026-09-26 22:48:01 +09:00
Hare 343af9ea3c fix: reconcile worker restores by operation 2026-09-26 22:35:43 +09:00
Hare a9c7f79012 fix: journal worker stop operations 2026-09-26 22:21:53 +09:00
Hare c8cf91655e fix: journal worker restore operations 2026-09-26 22:08:07 +09:00
Hare 7df2e20cf3 fix: map uncertain runtime store commits 2026-09-26 22:08:00 +09:00
Hare 91d1b1fedd fix: make worker aggregate persistence atomic 2026-09-26 21:40:21 +09:00
Hare 4d978b8cd8 fix: separate worker observation freshness 2026-09-26 07:32:43 +09:00
Hare e3587be3bd docs: report worker lifecycle atomicity gaps 2026-09-26 07:32:36 +09:00
Hare 0aa3752bf8 fix: retry rejected automatic worker restores 2026-09-24 21:41:43 +09:00
Hare 1e4fcd170c fix: require explicit workdir command cwd 2026-09-24 20:17:19 +09:00
Hare 4c653a939f fix: scope runtime workdir attachments independently 2026-09-24 20:04:45 +09:00
Hare 0b0669b77b fix: keep current worker visible in collapsed sidebar 2026-09-24 19:58:09 +09:00
Hare 1649e1ad89 fix: restore standalone logical workspace scope 2026-09-24 19:58:03 +09:00
Hare a948c2708d fix(web): invalidate stale rotated public keys 2026-09-24 19:45:08 +09:00
Hare 54264e6e3b fix(web): fence credential lifecycle handlers 2026-09-24 19:37:25 +09:00
Hare 92fe0ab9b6 fix(web): serialize credential key actions 2026-09-24 19:28:44 +09:00
Hare 0537894768 fix(web): fence repository access route state 2026-09-24 19:13:43 +09:00
Hare bec3ed9515 chore: format merged develop changes 2026-09-24 18:48:44 +09:00
Hare ecb0989e14 merge develop into T-651 repository access UI 2026-09-24 18:44:45 +09:00
Hare d5508a7e35 fix(web): scope repository access operations 2026-09-24 18:44:23 +09:00
Hare 3cd535cc8a fix: retain repository access for workdir sessions 2026-09-24 18:37:11 +09:00
Hare 58d43af3e6 fix: align runtime attachment authorization 2026-09-24 18:37:11 +09:00
Hare e69aa03f5d feat: make image attachments request-local 2026-09-24 18:21:29 +09:00
Hare e6608dd42f fix: allow remote ticket targets without server checkout 2026-09-24 05:04:19 +09:00
Hare 70eb1120a2 fix: serve frontend deep links through SPA shell 2026-09-24 04:42:44 +09:00
Hare 75bbdfc56e fix: migrate legacy ticket target key evidence 2026-09-24 04:38:23 +09:00
Hare 3802549e48 chore: remove old .yoi directory 2026-09-24 03:51:33 +09:00
Hare 9b22dcc190 chore: merge develop into hare/develop 2026-09-24 01:01:35 +09:00
Hare 593e521e12 fix: isolate worker command telemetry 2026-09-24 00:58:02 +09:00
Hare 0baec54ae9 fix: guard manual coder target bindings 2026-09-23 23:49:31 +09:00
Hare 4c458cbf74 feat(web): redesign repository access settings 2026-09-23 23:30:28 +09:00
Hare 9ccf2fbc3b feat: support permissioned multi-repository ticket targets 2026-09-23 23:11:37 +09:00
Hare 9c107eaf74 feat(api): declare typed WebSocket operations 2026-09-23 21:07:20 +09:00
Hare ed99be3e24 fix: type runtime config and test passkey lifecycle 2026-09-23 20:15:23 +09:00
Hare 237ab0e592 feat: add typed conditional API responses 2026-09-23 19:32:05 +09:00
Hare 97822cf691 feat: add bounded binary API request bodies 2026-09-23 18:29:54 +09:00
Hare 2766e5b41b fix: enforce ServerApi as the wire authority 2026-09-23 17:27:27 +09:00
Hare f25c8d8b6d fix: preserve generated runtime request boundaries 2026-09-23 11:08:54 +09:00
Hare 515a19bbc3 feat: generate ticket browser contract from ServerApi 2026-09-23 10:51:50 +09:00
Hare 6f9dc7ab41 fix: complete generated runtime route wiring 2026-09-23 10:03:59 +09:00
Hare 89024d3b01 feat: migrate workspace worker and companion domains to ServerApi 2026-09-23 10:00:56 +09:00
Hare ad12d77ad4 feat: migrate runtime worker domains to ServerApi 2026-09-23 08:24:39 +09:00
Hare 7a975765ac feat: migrate repository and workdir domains to ServerApi 2026-09-23 06:51:05 +09:00
Hare 6d65d50ec1 feat: migrate ticket and objective domains to ServerApi 2026-09-23 05:42:50 +09:00
Hare 6c53854f9b feat: add GPT-6 Sol and Luna to model catalog 2026-09-23 04:55:38 +09:00
Hare 6cbc3a6988 feat: migrate workspace configuration domains to ServerApi 2026-09-23 04:27:52 +09:00
Hare 7905ea4054 fix: grant default repository credential write access 2026-09-23 02:57:48 +09:00
Hare c8766e9fb9 feat: migrate auth and workspace catalog to ServerApi 2026-09-23 02:40:51 +09:00
Hare 91f356698b chore: merge develop into hare/develop 2026-09-23 01:40:55 +09:00
Hare 327d2189c9 docs: report live Svelte validation route churn 2026-09-23 01:29:31 +09:00
Hare bd3cc65e19 fix: fence shared chrome overrides by route 2026-09-23 01:29:21 +09:00
Hare 9857bf04f7 fix: require idle workers for submit 2026-09-23 00:36:54 +09:00
Hare 10b3740696 docs: record final authority validation 2026-09-22 23:57:37 +09:00
Hare f18eef4766 docs: finalize authority removal audit 2026-09-22 23:45:50 +09:00
Hare 617c56bd85 Merge remote-tracking branch 'origin/develop' into work/T-452-remove-repository-local-yoi 2026-09-22 23:41:17 +09:00
Hare a689072756 fix(e2e): close worker environment review gaps 2026-09-22 23:28:28 +09:00
Hare d8444e49bd fix: render compaction activity in web console 2026-09-22 22:57:44 +09:00
Hare 0219cb1d32 test: align symlink listing with resolved scope 2026-09-22 22:52:51 +09:00
Hare 5fb3cb20cd refactor: remove repository-local yoi authority 2026-09-22 22:52:41 +09:00
Hare 0869ef0cc6 test(e2e): isolate worker server runtime environments 2026-09-22 22:26:02 +09:00
Hare 043cb7bf22 fix: confine external glob ignore handling 2026-09-22 19:07:28 +09:00
Hare 757432dac4 fix: close external workdir review gaps 2026-09-22 18:57:47 +09:00
Hare b5ee6b5804 fix: harden external workdir lifecycle 2026-09-22 07:56:20 +09:00
Hare ce9534f3a2 feat: add client-hosted external workdirs 2026-09-22 06:49:15 +09:00
Hare dc135ee1de Merge branch 'develop' into hare/develop 2026-09-22 03:01:55 +09:00
Hare e7ebfdb964 chore: add rustfmt to devshell 2026-09-22 02:35:42 +09:00
Hare aa41250beb fix: decouple workdir attachments from materialization 2026-09-22 02:35:42 +09:00
Hare 2cdc35f3e2 fix: allow singleton worker deletion 2026-09-22 00:42:41 +09:00
Hare 67b7b2cb80 test(workspace-web): cover workdir label edge cases 2026-09-21 23:02:40 +09:00
Hare e085814502 chore: refresh T-642 onto current develop 2026-09-21 22:59:43 +09:00
Hare d7240470d1 fix: avoid unsupported workdir subscription 2026-09-21 22:52:19 +09:00
Hare 8e6bd1be5c feat: show worker workdirs as repo branches 2026-09-21 22:23:15 +09:00
Hare 769a3398fa test(workspace-server): exercise remote spawn HTTP boundary 2026-09-21 20:14:21 +09:00
Hare 36e9ee5060 test(workspace-server): cover guarded attachment spawn 2026-09-21 20:02:24 +09:00
Hare 3194729038 fix: use workdir attachments for coder spawn 2026-09-21 17:50:26 +09:00
Hare 5525640ef6 test: align no-workdir routed tool contract 2026-09-21 04:35:52 +09:00
Hare 14d21c4642 feat: merge develop into hare/develop 2026-09-21 04:30:02 +09:00
Hare 0df9913286 feat: integrate multi-workdir tool routing 2026-09-20 22:17:38 +09:00
Hare 74fbadb1cd fix: complete multi-workdir isolation and launch UI 2026-09-20 22:07:59 +09:00
Hare c102119412 feat: route tools across workdir attachments 2026-09-20 19:38:20 +09:00
Hare 4e7f48a389 fix: integrate post-compact usage boundary 2026-09-20 19:36:48 +09:00
Hare b2ddab6d7e fix: fence pending usage across segment forks 2026-09-20 19:31:45 +09:00
Hare 2dda4b90b4 fix: preserve pending usage across pause and compact 2026-09-20 19:17:33 +09:00
Hare cd648a9abd fix: persist post-compact usage at request boundary 2026-09-20 18:57:11 +09:00
Hare c492cd9a94 feat: integrate multiple workdir attachments
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-09-20 18:48:15 +09:00
Hare 904304f1fb fix: complete worker spawn compensation 2026-09-20 18:31:56 +09:00
Hare 37df3ec545 feat: support multiple worker workdir attachments 2026-09-19 23:27:57 +09:00
Hare 0c80f84e9c fix(workspace-server): fence cleanup removal races 2026-09-19 01:53:22 +09:00
Hare b272bc37e8 fix(workspace-server): make worker removal catalog commits atomic 2026-09-19 01:34:19 +09:00
Hare 5503b9b977 fix: allow cross-runtime workdir projection 2026-09-18 23:29:10 +09:00
Hare 308ae19f5e fix: restore sidebar workdir projection 2026-09-18 22:05:00 +09:00
Hare e3034e4465 Merge branch 'work/T-572-workspace-web-ux-demo' into hare/develop 2026-09-18 21:40:03 +09:00
Hare b06e2860ca feat: merge develop into hare/develop 2026-09-18 21:36:04 +09:00
Hare fba12b89bc fix: fail closed on lossy Repository schemas 2026-09-18 21:02:37 +09:00
Hare b0ad3689d0 fix(repository-api): reject overlapping oneof literals 2026-09-18 20:53:48 +09:00
Hare e6d3905dc6 fix(repository-api): preserve optional fields and schema combinations 2026-09-18 20:44:45 +09:00
Hare 8ba89e7cee fix(repository-api): reject unsupported schema keywords 2026-09-18 20:32:44 +09:00
Hare f558ca9b1f feat: generate Repository frontend types from OpenAPI 2026-09-18 20:26:20 +09:00
Hare ca305f0ff1 fix(repository-api): validate operation reference siblings 2026-09-18 20:16:20 +09:00
Hare 9588b1e2c8 fix(repository-api): reject lossy schemas and revision ranges 2026-09-18 20:10:52 +09:00
Hare 50ba3ab2d5 feat(workspace): generate repository API contracts from OpenAPI 2026-09-18 20:01:53 +09:00
Hare f0114a0390 fix: make compaction Segment activation fail closed 2026-09-18 19:45:12 +09:00
Hare d7917512ac feat: make tool result pruning opt-in 2026-09-18 19:43:29 +09:00
Hare 57fa8f6c76 fix: apply rustfmt to T-620 changes 2026-09-18 19:35:04 +09:00
Hare 8435236862 feat: migrate Repository REST to generated Server API 2026-09-18 19:30:32 +09:00
Hare 79bafa2a64 fix: align sidebar worker action spacing 2026-09-18 19:30:29 +09:00
Hare 19ee757880 feat: configure role-specific models 2026-09-18 19:22:37 +09:00
Hare 72f0b82ae5 worker: persist child completion before wakeup 2026-09-18 19:12:55 +09:00
Hare 852a405182 Normalize deleting-workspace repository conflicts 2026-09-18 19:12:42 +09:00
Hare cc06b41322 fix: skip stopped worker live protocol subscriptions 2026-09-18 19:04:52 +09:00
Hare 19b04a8d8f worker: verify completion durability on shutdown 2026-09-18 18:58:05 +09:00
Hare 7a4a988208 Document media type and deletion conflicts 2026-09-18 18:56:06 +09:00
Hare ceecbbfe6f test(workspace): cover partial streaming links 2026-09-18 18:54:26 +09:00
Hare 72a61e4952 worker: persist compaction-time notifications 2026-09-18 18:53:47 +09:00
Hare d1161bd1b5 test(workspace): preserve markdown compatibility boundaries 2026-09-18 18:50:57 +09:00
Hare bbd949e57f Digest rendered OpenAPI contract 2026-09-18 18:37:55 +09:00
Hare 092aea62d2 fix(workspace): stream incremental markdown rendering 2026-09-18 18:35:51 +09:00
Hare d59879cc4b worker: cover activation race boundaries 2026-09-18 18:35:38 +09:00
Hare 103fee0ba4 Complete repository contract error coverage 2026-09-18 18:34:29 +09:00
Hare d1d391e511 worker: exercise activation recovery faults 2026-09-18 18:15:33 +09:00
Hare 6214e88fac worker: test activation fault recovery 2026-09-18 17:52:06 +09:00
Hare 986540411e feat: merge workspace web UX demo 2026-09-18 17:41:10 +09:00
Hare 82c191d637 Route repository aliases through ServerApi 2026-09-18 17:35:27 +09:00
Hare ff87ccf855 worker: harden activation recovery 2026-09-18 17:26:39 +09:00
Hare 91f6589e1f Cover generated repository client request 2026-09-18 17:23:27 +09:00
Hare 8ef7c8e854 Update ServerApi metadata coverage 2026-09-18 17:20:26 +09:00
Hare 8dbc1629ff fix: enforce workspace typography scale 2026-09-18 17:20:16 +09:00
Hare e12b555f43 Migrate repository routes to ServerApi 2026-09-18 17:17:02 +09:00
Hare 1b153ba144 feat: unify frontend Worker registry projection 2026-09-18 16:46:32 +09:00
Hare b62b7c8f2d worker: await cleanup on automatic shutdown 2026-09-18 16:45:40 +09:00
Hare 103ef40f57 worker: fence controller during repair 2026-09-18 06:55:23 +09:00
Hare 7551576b08 fix(protocol): export worker availability binding 2026-09-18 06:53:16 +09:00
Hare e7c50dc519 worker: keep cleanup failures non-idle 2026-09-18 06:40:46 +09:00
Hare 179bf60e9f fix(workspace): preserve worker projection convergence 2026-09-18 06:40:12 +09:00
Hare bd07f3a66f refactor: rename Workspace API crate to server API 2026-09-18 06:35:33 +09:00
Hare fc24cb672e worker: retain failed activation authority 2026-09-18 06:27:11 +09:00
Hare c6cb038683 refactor(api): rename workspace-api authority to server-api
Absorb the Server HTTP DTO catalog and generators into server-api, remove the obsolete workspace-api package/path, preserve the separate runtime transport authority through explicit boundary DTO conversions, and rename the aggregate frontend projection as a bounded legacy artifact.
2026-09-18 06:21:46 +09:00
Hare 95636b3d53 feat(workspace): persist runtime worker projection 2026-09-18 06:20:09 +09:00
Hare 34085b121d worker: make segment activation atomic 2026-09-18 06:08:35 +09:00
Hare 991adc2fa1 feat: generate OpenAPI from API contracts 2026-09-18 06:05:11 +09:00
Hare 648421e454 feat(api): project bearer authorization into OpenAPI 2026-09-18 05:54:41 +09:00
Hare 9566ad3482 feat: add retained Worker session observation 2026-09-18 05:53:53 +09:00
Hare f32b354f9b fix(api): close OpenAPI schema fidelity gaps 2026-09-18 05:43:43 +09:00
Hare 9d8f294d75 fix: import generated worker summary type 2026-09-18 05:41:12 +09:00
Hare f513cfca10 feat(api): generate deterministic OpenAPI 3.1 contracts 2026-09-18 05:29:13 +09:00
Hare 195aeb69be fix: format notification batching changes 2026-09-18 05:27:21 +09:00
Hare 0d2b3152b1 Keep notifications queued while paused 2026-09-18 05:19:46 +09:00
Hare 96e04bbc32 fix: preserve auth and response bounds for session reads 2026-09-18 05:16:20 +09:00
Hare 9cde75b70f Make notifications realtime and coalesced 2026-09-18 05:04:47 +09:00
Hare 74981f8217 fix: harden retained session observation 2026-09-18 04:59:05 +09:00
Hare 7a61a26670 feat: observe retained worker sessions without restore 2026-09-18 04:27:38 +09:00
Hare 520c2f70c8 fix: simplify workspace borders and design rules 2026-09-18 04:25:39 +09:00
Hare ae7f0f0446 refactor: migrate Runtime Worker REST to generated API 2026-09-18 03:08:37 +09:00
Hare 7aef6f094e Strengthen runtime route authority regression tests 2026-09-18 02:53:36 +09:00
Hare 6baa313684 Unify manual route inventory and retention semantics 2026-09-18 02:47:05 +09:00
Hare f967c1b392 Close runtime contract acceptance gaps 2026-09-18 02:31:37 +09:00
Hare fbdb6ccc22 Address runtime API review findings 2026-09-18 02:11:20 +09:00
Hare d837684a33 Complete runtime API coverage and roundtrip proof 2026-09-18 01:38:50 +09:00
Hare 5038033c3a Migrate runtime management REST to generated API 2026-09-18 01:20:03 +09:00
Hare 7a2834c95c feat: harden Worker restore reconciliation 2026-09-18 01:16:05 +09:00
Hare bd47d064b5 fix: apply rustfmt to restore transaction changes 2026-09-18 01:12:38 +09:00
Hare 20dfc168d5 Preflight persisted restore resources 2026-09-18 01:02:32 +09:00
Hare 0d86ae66ad Harden restore preflight and retry reconciliation 2026-09-18 00:48:56 +09:00
Hare fd5199e8bd fix: show Web compacting status for tagged Worker states 2026-09-18 00:31:43 +09:00
Hare ecc0602f53 test(web): cover compacting status presentation 2026-09-18 00:26:40 +09:00
Hare 68c59afbe7 Preserve restore subscription and cleanup evidence 2026-09-18 00:25:56 +09:00
Hare 6d9c9cdb48 fix(web): gate compaction status by worker state 2026-09-18 00:14:34 +09:00
Hare 8ac8b2d50c Document missing rustfmt validation tool 2026-09-18 00:08:08 +09:00
Hare 1d7cd9339c Implement transactional Worker restore outcomes 2026-09-18 00:08:00 +09:00
Hare 01f5c560c7 feat(api): generate reqwest and axum adapters 2026-09-18 00:01:41 +09:00
Hare 01bddf2177 feat: add API operation metadata contract 2026-09-17 23:32:50 +09:00
Hare 019ab57e8f fix: make operation marker naming total 2026-09-17 23:28:20 +09:00
Hare 633f43530c fix: harden API macro expansion hygiene 2026-09-17 23:21:18 +09:00
Hare 9590cf332d feat: add shared API operation metadata macro 2026-09-17 23:05:11 +09:00
Hare 5ae1bab4ee Improve Objective list and detail display 2026-09-17 22:51:07 +09:00
Hare 063748c7ff fix: default runtime workers to in-process transport 2026-09-17 22:31:59 +09:00
Hare 86b2a5fd81 fix: make zero-input workers durably restorable 2026-09-17 21:36:17 +09:00
Hare e722d21d98 fix: log sanitized worker spawn failures 2026-09-17 19:37:07 +09:00
Hare f4b34a289e fix: archive orphaned workers during migration 2026-09-17 05:12:47 +09:00
Hare a4aeb6e7d3 fix: preserve schema 6 runtime migration path 2026-09-17 05:01:53 +09:00
Hare 96592b1a24 fix: project Worker delete persistence diagnostics 2026-09-17 04:16:33 +09:00
Hare d70dee77a9 fix: retain Worker identity after delete persistence failure 2026-09-17 04:13:45 +09:00
Hare 272cd1bb03 feat: refine workspace bevel hierarchy 2026-09-17 04:09:12 +09:00
Hare 1402240ef0 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-612-persisted-worker-identity 2026-09-17 03:54:59 +09:00
Hare decd1311cb fix: recover interrupted Runtime store migrations 2026-09-17 03:47:26 +09:00
Hare c4622e9e3a refactor: separate Worker identity from execution state 2026-09-17 03:25:40 +09:00
Hare 360387c986 fix: classify file content conflicts as retryable 2026-09-17 02:54:37 +09:00
Hare bc0342b03e fix: discard legacy top-level run generation 2026-09-17 02:07:55 +09:00
Hare 10ebac142e refactor: make worker state snapshots authoritative 2026-09-17 02:02:51 +09:00
Hare d124b03fc8 chore: preserve markdown prose wrapping 2026-09-17 01:32:00 +09:00
Hare ab6450b9fe feat: establish workspace web design language 2026-09-17 01:31:54 +09:00
Hare d6bef5d1c7 fix: address lifecycle review findings 2026-09-17 00:36:08 +09:00
Hare 9d8e0114f5 fix: serialize runtime worker lifecycle 2026-09-17 00:14:33 +09:00
Hare 93070a3a93 fix(runtime): reject duplicate removal revisions 2026-09-16 21:04:30 +09:00
Hare d1b94625b1 fix(runtime): fence removed internal worker activity 2026-09-16 20:57:17 +09:00
Hare fbbea1bf91 fix: scope compaction suppression to one run 2026-09-16 07:27:21 +09:00
Hare a0f8ee51a4 fix: guard automatic compaction retries 2026-09-16 07:16:31 +09:00
Hare ffc1933f06 fix: separate compaction metric correlation 2026-09-16 06:34:15 +09:00
Hare b6960878a6 fix: align compaction metric schema 2026-09-16 06:24:45 +09:00
Hare fef3b6f4a0 fix: preserve compaction metric precision and CAS category 2026-09-16 06:13:32 +09:00
Hare c0a73c12ec feat: record compaction lifecycle metrics 2026-09-16 06:01:04 +09:00
Hare f40714efc2 feat: add explicit session metrics export 2026-09-16 06:00:57 +09:00
Hare 07ed908ae6 test: cover compaction restart boundaries 2026-09-16 05:14:16 +09:00
Hare cfe772ea7d fix: fence compaction commit and status 2026-09-16 04:58:38 +09:00
Hare c0fe20e8a2 fix: serialize compaction pointer commits 2026-09-16 04:41:27 +09:00
Hare df22526a0d fix: render compaction on worker status 2026-09-16 04:25:37 +09:00
Hare 1d06d4b08a fix: keep compaction lifecycle runtime-only 2026-09-16 04:19:02 +09:00
Hare 4e7a314a00 fix: make compaction activation atomic 2026-09-16 03:44:48 +09:00
Hare c8eb767d63 doc: rewrite design doc 2026-09-15 12:51:39 +09:00
Hare 016dbd7cb1 fix: preserve worker run generations across restore 2026-09-15 01:17:30 +09:00
Hare 7210d3c202 fix: reject failed worker restores before attach 2026-09-15 00:47:12 +09:00
Hare 572204b49a fix: resolve CLI connections before async runtime 2026-09-15 00:28:25 +09:00
Hare 86dd67a24c fix: skip stopped worker protocol subscriptions 2026-09-14 23:31:14 +09:00
Hare beeba1fdfc fix: migrate legacy worker snapshots 2026-09-14 22:28:01 +09:00
Hare 41b7b289d0 fix: serialize Workdir lease admission with writes 2026-09-14 20:32:21 +09:00
Hare 24237249d1 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 20:05:34 +09:00
Hare e448073b98 fix: fence resolved Workdir lease aliases 2026-09-14 20:05:27 +09:00
Hare c08152d069 fix: share workspace creation wire contract 2026-09-14 19:45:18 +09:00
Hare 3995561220 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-468-remove-local-workspace-authority 2026-09-14 19:26:44 +09:00
Hare aea51caeb4 fix: resolve workspaces through backend repositories 2026-09-14 19:24:44 +09:00
Hare 3eca380bd8 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 19:12:17 +09:00
Hare 8a3e06bc81 feat: add selective Workdir symlink policies 2026-09-14 19:09:12 +09:00
Hare c4274c42cb fix: align plugin help with offline commands 2026-09-14 18:52:00 +09:00
Hare a61ad15767 refactor: remove ambient plugin authority 2026-09-14 18:40:42 +09:00
Hare 7f1e374fd7 fix: remove repository-local workspace selection 2026-09-14 18:31:04 +09:00
Hare e3f5445a02 fix: move workspace metadata authority to server database 2026-09-14 18:31:04 +09:00
Hare d2cb50d081 fix: fence runtime removal races and retries 2026-09-13 02:54:01 +09:00
Hare 6c609808c9 fix: use authoritative runtime removal result 2026-09-13 02:30:24 +09:00
Hare 2d4c7b383a feat: add guarded runtime removal operation 2026-09-13 02:30:15 +09:00
Hare c21ed7dff2 fix: preserve credential candidates across Workdir retries 2026-09-13 01:24:26 +09:00
Hare 448e392a0e fix: persist Workdir credential candidate snapshots 2026-09-13 01:14:39 +09:00
Hare d97c40d6af fix: reject plain HTTP repository sources 2026-09-13 01:12:01 +09:00
Hare 2d512b6be6 fix: remove stale HTTP repository access branch 2026-09-13 01:04:46 +09:00
Hare f061a95b48 fix: validate repository URI before source kind 2026-09-13 00:45:43 +09:00
Hare eefdef1bef fix: lock verified runtime public keys 2026-09-13 00:32:44 +09:00
Hare 7675f81999 fix: reject plain HTTP repository sources 2026-09-13 00:29:16 +09:00
Hare 8fb592071f fix: fence stale repository SSH probes 2026-09-13 00:27:32 +09:00
Hare 2528312142 fix: lock verified Runtime public keys in settings 2026-09-13 00:23:25 +09:00
Hare 08d7965ea8 fix: centralize repository access expiry 2026-09-13 00:20:25 +09:00
Hare e0badad91f fix: invalidate stale repository SSH probes 2026-09-13 00:17:12 +09:00
Hare f0a91ce2d8 fix: centralize repository access expiry 2026-09-13 00:11:03 +09:00
Hare 24cab83f48 test: cover repository credential rotate visibility 2026-09-13 00:05:14 +09:00
Hare 33a2b5d702 fix: unify workspace request routing 2026-09-12 23:49:13 +09:00
Hare 7f807004ad Update AGENTS.md 2026-09-12 22:47:35 +09:00
Hare 5564425488 fix: retry retained workdir removal 2026-09-12 13:30:25 +09:00
Hare 4a89c04732 fix: allow corrupted workdir removal 2026-09-12 01:43:37 +09:00
Hare ec5a403ec6 feat: replace linked worktrees with runtime clones 2026-09-12 01:43:21 +09:00
Hare f6ce1df766 feat: support workspace-managed SSH repository access 2026-09-11 22:55:06 +09:00
Hare 9d7ddcc04a fix: enforce Workspace Runtime binding lifecycle 2026-09-09 13:20:00 +09:00
Hare 3df611636b feat: complete Workspace Runtime management flow 2026-09-09 09:38:26 +09:00
Hare d0999326bd fix: use Backend audience for embedded Runtime requests 2026-09-09 09:38:26 +09:00
Hare 6fbc65476c fix: migrate standalone Worker manifest snapshots 2026-09-09 01:22:59 +09:00
Hare fcc7d79d80 feat: authorize scoped symlink paths lexically 2026-09-09 00:51:21 +09:00
Hare 18fd6a1f5e fix: restore remote Runtime management contracts 2026-09-09 00:26:04 +09:00
Hare a072562034 chore: merge develop into hare/develop 2026-09-08 13:04:10 +09:00
Hare 2b4a2bc688 fix: fail closed on missing workspace capability 2026-09-08 12:40:04 +09:00
Hare 3344d9f8b2 refactor: remove server-global runtime trust 2026-09-08 12:19:39 +09:00
Hare fae36d220d fix: complete Runtime verification cutover 2026-09-08 10:11:18 +09:00
Hare 7b6a84a550 feat: project Runtime verification state 2026-09-08 09:22:44 +09:00
Hare f29c343879 feat: verify Workspace-signed Runtime bindings 2026-09-08 08:13:58 +09:00
Hare f5e9f49a13 fix: bind Runtime WebSockets to egress policy 2026-09-08 05:29:53 +09:00
Hare 73a35599d2 fix: complete configured Runtime onboarding 2026-09-08 05:15:14 +09:00
Hare 5080d7860e fix: preserve Runtime binding trust boundaries 2026-09-08 04:54:23 +09:00
Hare 7fb1d4056c feat: add manual Runtime trust setup UI 2026-09-08 04:22:45 +09:00
Hare 243a081874 feat: add configured Workspace Runtime bindings 2026-09-08 04:22:32 +09:00
Hare 04924cf796 fix: bound Runtime issuer trust surfaces 2026-09-08 02:31:58 +09:00
Hare 8f0917b8bc docs: remove obsolete local profile override guidance 2026-09-08 02:13:26 +09:00
Hare d4ad46127a feat: add GPT-6 Astra model catalog entry 2026-09-08 02:13:26 +09:00
Hare fba5ecf54c fix: harden Workspace issuer bootstrap 2026-09-08 02:00:19 +09:00
Hare e035df9e7b feat: add Runtime Workspace issuer trust 2026-09-08 01:54:10 +09:00
Hare 3baf0b6358 feat: integrate Workspace signing identity authority 2026-09-08 00:48:45 +09:00
Hare 4de04e42b5 fix: harden identity publication recovery 2026-09-08 00:30:38 +09:00
Hare ebec98a14c feat: add Workspace signing identity authority 2026-09-07 23:52:20 +09:00
Hare f966470d33 fix: acknowledge durable submissions before run preparation 2026-09-07 21:38:19 +09:00
Hare c76ede2ab4 fix: allow slow initial input commits 2026-09-07 20:21:22 +09:00
Hare 2fd043b634 fix: expand worker creation timeout budgets 2026-09-07 19:43:26 +09:00
Hare fb6bbe9145 fix: wait for worker shutdown before deletion 2026-09-07 19:31:32 +09:00
Hare d1585d7483 fix: route worker failure logs through tracing 2026-09-07 18:14:07 +09:00
Hare 13d853217d fix: log failed worker creations to stdout 2026-09-07 04:56:54 +09:00
Hare cc27d57e4a fix: reclaim failed worker creations 2026-09-07 04:44:19 +09:00
Hare f5c5ea5a0b fix: route worker consoles through logical resources 2026-09-07 03:53:34 +09:00
Hare f1baea1705 feat: distribute latest workspace config to runtimes 2026-09-07 02:40:03 +09:00
Hare 31f7d39647 feat: add Server database migration command 2026-09-06 16:17:48 +09:00
Hare c5a834bfd2 fix: restore composable Workspace schema migrations 2026-09-06 16:17:40 +09:00
Hare 1eef9b75ee Merge branch 'develop' into hare/develop 2026-09-06 13:39:37 +09:00
Hare ba9c885f52 feat: integrate Worker state authority 2026-09-06 11:00:00 +09:00
Hare 88683a8d8f test: provide authoritative websocket snapshot 2026-09-06 10:41:20 +09:00
Hare 0c48c5dee3 fix: fail closed without worker snapshots 2026-09-06 10:33:45 +09:00
Hare 101a0acb6b fix: terminalize removed worker reservations 2026-09-06 10:32:45 +09:00
Hare 2d1956b653 fix: separate worker state from runtime lifecycle 2026-09-06 10:05:54 +09:00
Hare b7bba8b53a fix: preserve workspace deletion checkpoints 2026-09-06 10:02:18 +09:00
Hare 7b25b767f8 fix: checkpoint workspace cleanup children 2026-09-06 09:35:44 +09:00
Hare 4631b95144 fix: fence workspace deletion side effects 2026-09-06 09:21:37 +09:00
Hare 38627c498b fix: harden workspace deletion recovery 2026-09-06 08:57:54 +09:00
Hare 282a8d31b5 fix: enforce monotonic worker state projection 2026-09-06 07:56:25 +09:00
Hare ab4fb4c1ee fix: enforce workspace deletion blockers 2026-09-06 07:54:52 +09:00
Hare e3e9e83bc1 feat: add guarded workspace deletion workflow 2026-09-06 07:22:33 +09:00
Hare 4269ebec04 chore: merge develop into worker state snapshot 2026-09-06 07:17:40 +09:00
Hare e8b9adcde4 feat: add revisioned worker execution state 2026-09-06 06:54:29 +09:00
Hare 668a9062b3 fix: fence runtime detail route state 2026-09-06 06:11:23 +09:00
Hare 5fd2ccf084 fix: gate runtime key reveal and revoke confirmation 2026-09-06 05:50:29 +09:00
Hare 5686bbc9fd fix: preview runtime trust rotation fingerprint 2026-09-06 05:25:03 +09:00
Hare 2cd57a32b2 fix: align runtime trust schema and built-in controls 2026-09-06 05:17:27 +09:00
Hare 89f4f99622 Merge branch 'develop' into work/T-595-runtime-trust-key-management
# Conflicts:
#	web/workspace/deno.json
2026-09-06 05:07:26 +09:00
Hare 78d571ed14 feat: add workspace runtime trust key management 2026-09-06 05:06:37 +09:00
Hare e5332f4a7f feat: route SubWorker workdir execution through parent 2026-09-06 04:28:52 +09:00
Hare 3ed1545c3c feat: integrate worker submit queue 2026-09-06 04:24:07 +09:00
Hare 9da20d15da fix: compose passive notify with queued submit 2026-09-06 04:15:30 +09:00
Hare 052d60bd7d fix: fence late command events and spawn rollback 2026-09-06 04:10:23 +09:00
Hare 2456d6fda5 fix: preserve pending activation order on restore 2026-09-06 04:00:02 +09:00
Hare e7803d1aba fix: qualify runtime binding helper 2026-09-06 03:52:42 +09:00
Hare ca5fddf89b fix: fence recursive SubWorker shutdown 2026-09-06 03:48:54 +09:00
Hare 88fad3893e Merge commit '5e9f7a7dc3d1169118412376f16b72850aab63f2' into work/T-593-workspace-runtime-bindings
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-09-06 03:46:09 +09:00
Hare 82f9b0e48c chore: merge current develop into T-588 2026-09-06 03:45:30 +09:00
Hare ddb4c1454d fix: enable running composer delivery 2026-09-06 03:32:23 +09:00
Hare 7b1cf854f2 fix: serialize scoped command teardown 2026-09-06 03:29:50 +09:00
Hare 51c6d7f835 fix: require authenticated runtime bindings 2026-09-06 03:28:19 +09:00
Hare 9d55ce0a87 fix: reconcile interrupted upload pins 2026-09-06 03:15:44 +09:00
Hare 0f8d61188a fix: order SubWorker cleanup before Workdir release 2026-09-06 03:12:55 +09:00
Hare 7363dffb9d fix: gate runtime projection broadcasts 2026-09-06 03:00:36 +09:00
Hare 8e4b7deaa4 fix: preserve authenticated submit source 2026-09-06 02:56:51 +09:00
Hare ec845cbc25 fix: fence revoked runtime bindings 2026-09-06 02:52:15 +09:00
Hare e7079e223f fix: close scoped SubWorker command authority 2026-09-06 02:37:13 +09:00
Hare dea5bd581d fix: secure submit activation handoff 2026-09-06 02:31:10 +09:00
Hare 75b85b46d1 feat: scope runtime bindings by workspace 2026-09-06 02:30:13 +09:00
Hare 68f00bc948 refactor: broker SubWorker Workdir tools through parent 2026-09-06 02:17:44 +09:00
Hare 5e9f7a7dc3 feat: add versioned runtime connection ping 2026-09-06 02:17:26 +09:00
Hare b038f022d3 fix: harden submit queue durability 2026-09-06 01:35:26 +09:00
Hare cf7515fb35 fix: keep tool overview neutral on failures 2026-09-06 01:20:39 +09:00
Hare bb4c1dfe4f docs: update flow submit protocol example 2026-09-05 23:25:25 +09:00
Hare 72b56964c3 chore: refresh T-588 onto develop 2026-09-05 23:19:52 +09:00
Hare 5b0a6691f8 fix: fence pending queue controls 2026-09-05 23:16:19 +09:00
Hare 1239c638a5 Merge branch 'develop' into hare/develop 2026-09-05 22:39:57 +09:00
Hare d2fa0787d8 fix: retain child-local bash output scope 2026-09-05 22:29:19 +09:00
Hare a7056702e8 fix: keep worker-host paths out of remote delegations 2026-09-05 22:18:46 +09:00
Hare bb56283063 feat: persist worker submit activation queue 2026-09-05 22:06:59 +09:00
Hare 130ef1f0fe fix: align Backend worker picker columns 2026-09-05 21:19:18 +09:00
Hare 724205b1df feat: create Backend workers from bare TUI launch 2026-09-05 21:19:12 +09:00
Hare 69824ea45d fix: defer resident Memory loading until first run 2026-09-05 07:52:47 +09:00
Hare 15bc299987 fix: align integrated validation contracts 2026-09-05 06:49:06 +09:00
Hare 87bdb0c6ed chore: integrate develop into hare/develop candidate 2026-09-05 04:15:47 +09:00
Hare aa96bbedbc fix: harden Memory restore and scheduling 2026-09-05 00:24:00 +09:00
Hare 4df277c81f fix: preserve Memory consolidation enablement 2026-09-04 23:53:59 +09:00
Hare 12646b6ca0 refactor: install Memory prompt contributions through Feature 2026-09-04 23:40:12 +09:00
Hare 1e674d70c2 refactor: unify Memory feature configuration authority 2026-09-04 22:55:37 +09:00
Hare 5ee77698db fix: gate workspace memory lifecycle authority 2026-09-04 20:53:27 +09:00
Hare d1f5661881 test: cover memory lifecycle outcomes 2026-09-04 20:11:29 +09:00
Hare 532d078720 test: exercise memory lifecycle background behavior 2026-09-04 19:41:04 +09:00
Hare 27e5df106f fix: complete memory lifecycle feature boundaries 2026-09-04 19:16:01 +09:00
Hare 33d98868c3 refactor: move memory extraction into lifecycle feature 2026-09-04 18:44:50 +09:00
Hare fb13e53cb5 test: cover start race at rewrite barrier 2026-09-04 16:53:35 +09:00
Hare 60a5495ccd fix: hold feature task barrier across rewrites 2026-09-04 16:34:15 +09:00
Hare f1dc90621c fix: fence feature task shutdown races 2026-09-04 16:13:35 +09:00
Hare eecb116709 fix: bound feature lifecycle execution 2026-09-04 16:04:10 +09:00
Hare 783d25b1c4 fix: project ticket blockers from current DTO shape 2026-09-04 15:47:11 +09:00
Hare af06eecfd0 feat: add executable feature lifecycle hooks 2026-09-04 14:50:44 +09:00
Hare 9bd08a3a5b fix: bound Skill API response handling 2026-09-04 13:55:13 +09:00
Hare 3d66247e11 test: include Skill API contract suite 2026-09-04 13:36:36 +09:00
Hare 4390554477 feat: share Skill REST DTO authority 2026-09-04 13:30:39 +09:00
Hare 74457db4eb Merge commit '89856eb7c3dd6b659ff952d7e3be4a8c0885bf98' into work/T-584-agen-typed-interceptor 2026-09-04 13:01:55 +09:00
Hare 5d61da481b fix: consume internal tool batch cancellation 2026-09-04 13:01:37 +09:00
Hare 89856eb7c3 chore: replace legacy SQLite migrations with baselines 2026-09-04 12:48:45 +09:00
Hare 64c268582d fix: keep runtime SSH sockets below path limits 2026-09-04 12:48:25 +09:00
Hare bb6558e7bf fix: log Worker execution restore failures 2026-09-04 12:48:15 +09:00
Hare 42d109cae3 Merge commit '4ca8ea1694d88db501c48dd4947fdbd6f2828136' into work/T-584-agen-typed-interceptor 2026-09-04 12:28:30 +09:00
Hare 9b48b1ff5d fix: terminalize parallel tool siblings on interceptor stop 2026-09-04 12:27:59 +09:00
Hare 4ca8ea1694 feat: merge REST DTO updates from develop 2026-09-03 19:09:12 +09:00
Hare c10d6c6914 fix: keep Cargo artifacts local to workdirs 2026-09-03 19:06:57 +09:00
Hare 3a94c845cf fix: harden auth response validation 2026-09-03 18:08:43 +09:00
Hare bc810beb3b feat: centralize auth REST contracts 2026-09-03 17:35:24 +09:00
Hare eac4a0c071 fix: harden interceptor lifecycle contracts 2026-09-03 16:46:02 +09:00
Hare c1dfb1add5 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-566-memory-rest-dto 2026-09-03 16:07:53 +09:00
Hare e67f9bee08 Merge branch 'develop' into hare/develop 2026-09-03 16:00:05 +09:00
Hare 7c1d81cee9 chore: optimize build 2026-09-03 15:59:40 +09:00
Hare 5cec2eef60 feat: unify Memory REST DTO authority 2026-09-03 15:54:48 +09:00
Hare e62c7cf4f5 Merge commit '7abc6aca45a82665d33115612148536f3a5dd275' into work/T-584-agen-typed-interceptor 2026-09-03 15:47:21 +09:00
Hare 0245980ea5 refactor: finalize interceptor lifecycle boundaries 2026-09-03 15:33:15 +09:00
Hare 7abc6aca45 Merge commit '80221289935e227820aec902988a4f987273e05e' into work/T-549-provider-published-ref
# Conflicts:
#	crates/worker-runtime/src/runtime.rs
2026-09-03 15:25:34 +09:00
Hare f1bcd41ad9 fix: route embedded repository ref observation 2026-09-03 15:05:30 +09:00
Hare 8022128993 Merge branch 'refs/heads/develop' into work/T-565-worker-launch-rest-dto 2026-09-03 14:54:19 +09:00
Hare 68b1aa64e9 feat: type async interceptor failures 2026-09-03 14:46:07 +09:00
Hare f3af8f21dc fix: validate canonical Worker launch segments 2026-09-03 14:43:53 +09:00
Hare 7c056b1db8 fix: publish merge request ref diagnostic DTO 2026-09-03 14:34:09 +09:00
Hare da14c82f71 fix: expose provider ref blockers without failing reads 2026-09-03 14:25:51 +09:00
Hare 1f32c693df refactor: share Worker launch REST DTOs 2026-09-03 14:23:33 +09:00
Hare 74bfbe941e fix: detach stopped worker execution handles 2026-09-03 13:50:25 +09:00
Hare 2884c08466 fix: harden provider ref review boundaries 2026-09-03 13:49:20 +09:00
Hare 85e1ea320a feat: unify worker lifecycle restore semantics 2026-09-03 13:23:31 +09:00
Hare 7be428d8bf fix: require provider-published merge request refs 2026-09-03 12:56:17 +09:00
Hare 56798f9fb4 feat: observe repository refs through runtime providers 2026-09-03 12:56:06 +09:00
Hare 6b1b8a8846 fix: migrate legacy repository references 2026-09-03 11:09:55 +09:00
Hare 9fb1b90856 Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop 2026-09-03 09:47:53 +09:00
Hare 30d4023475 fix: preserve blocked TUI submissions 2026-09-03 08:57:28 +09:00
Hare 70432f3d12 fix: retain attachments through TUI retry 2026-09-03 08:44:28 +09:00
Hare 7ee6c307fc fix: require exact attachment acceptance 2026-09-03 08:38:15 +09:00
Hare 36cfbbe6d2 fix: preserve attachment replay evidence 2026-09-03 08:21:11 +09:00
Hare a2e1a3d939 fix: preserve attachment lifecycle boundaries 2026-09-03 08:10:35 +09:00
Hare 9dc8d9a77a fix: fence attachment grants and lifecycle cleanup 2026-09-03 07:22:07 +09:00
Hare 3f6bb65eb1 test: align fork checkpoint regression 2026-09-03 05:55:48 +09:00
Hare 8a70f3cb26 fix: harden uploaded attachment retention and replay 2026-09-03 05:50:47 +09:00
Hare 6c5b8315a3 chore: remove generated test bindings 2026-09-03 05:07:14 +09:00
Hare 690ed0f121 fix: fence uploaded attachment lifecycle 2026-09-03 05:07:03 +09:00
Hare fd60c2b8be fix: render TUI attachment notices with supported alert level 2026-09-03 05:03:49 +09:00
Hare e27b4feb25 feat: upload client-local files from Web and TUI 2026-09-03 04:59:35 +09:00
Hare 09a33e7283 feat: add session-owned uploaded file attachments 2026-09-03 04:58:11 +09:00
Hare d87441448e test: normalize formatted workdir TypeScript 2026-09-03 03:18:09 +09:00
Hare f783f10f6e Merge branch 'work/companion' into hare/develop 2026-09-03 03:06:10 +09:00
Hare 70bdb2d723 test: align client workdir fixture with repository key 2026-09-03 03:03:32 +09:00
Hare 4c1ef04378 fix: show workspace list throughout global sidebar 2026-09-03 02:49:58 +09:00
Hare a595af133c feat: merge durable Workdir updates from develop
# Conflicts:
#	docs/README.md
#	docs/design/durable-operations.md
#	web/workspace/deno.json
2026-09-03 02:47:50 +09:00
Hare f74f3cd133 fix: reject conflicting repository import intent 2026-09-03 02:44:24 +09:00
Hare bcd4848458 Merge branch 'work/companion' into hare/develop 2026-09-03 02:39:54 +09:00
Hare fc05bf9711 fix: restore cursor after inline terminal menus 2026-09-03 02:34:27 +09:00
Hare 63ad590262 fix: remove implicit repository startup selection 2026-09-02 20:00:12 +09:00
Hare 0fd1193b6b fix: harden repository key projections 2026-09-02 19:53:18 +09:00
Hare d996822957 feat: list accessible workspaces in global sidebar 2026-09-02 19:22:45 +09:00
Hare 96349721cb feat: project repository keys across public clients 2026-09-02 19:03:55 +09:00
Hare 8344921b65 feat: add Composer input history navigation 2026-09-02 18:33:43 +09:00
Hare c97b3b7b77 feat: introduce workspace-scoped repository keys 2026-09-02 16:43:28 +09:00
Hare e00e675ed1 feat: add Worker actions to workspace sidebar 2026-09-02 16:24:29 +09:00
Hare 538da1f2b2 fix: validate workspace profile responses 2026-09-02 15:24:33 +09:00
Hare bad37ddc7d feat: centralize workspace profile DTOs 2026-09-02 15:24:12 +09:00
Hare 175eda9f29 fix: authenticate remote worker removal through workspace router 2026-09-02 14:43:49 +09:00
Hare 14c806d38f docs: define durable operation boundaries 2026-09-02 14:43:49 +09:00
Hare 925100fb82 fix: stabilize configuration editor completion 2026-09-02 14:18:51 +09:00
Hare b29b003ea3 feat: define orchestrator cleanup ownership 2026-09-02 02:26:35 +09:00
Hare faa727965b fix: fence orphan recovery claims 2026-09-02 01:59:32 +09:00
Hare d2ffbf2c40 fix: guard Workdir removal recovery ownership 2026-09-02 01:40:48 +09:00
Hare 5418fad7d7 fix: serialize Workdir removal attempts 2026-09-02 01:00:24 +09:00
Hare 510795f1c5 fix: fence Workdir removal retries 2026-09-02 00:25:21 +09:00
Hare 9e0d499987 Merge remote-tracking branch 'origin/develop' into work/T-578-durable-workdir-removal 2026-09-02 00:01:26 +09:00
Hare e96fde0632 feat: add durable Workdir removal authority 2026-09-02 00:01:16 +09:00
Hare eea79dead4 fix: bound Web UX review evidence and cleanup 2026-09-01 23:44:24 +09:00
Hare c4a3f4ba1e fix: harden Web UX capture profiles 2026-09-01 23:00:28 +09:00
Hare 4a4a01b730 feat: add repeatable Web UX inspection workbench 2026-09-01 22:48:26 +09:00
Hare a664e72488 feat: merge Composer paste handling from develop 2026-09-01 22:06:01 +09:00
Hare 21317123a4 fix: deduplicate CodeMirror completion state 2026-09-01 22:03:25 +09:00
Hare 409245cb52 fix: fence disabled Composer mutations 2026-09-01 21:00:26 +09:00
Hare 1aeb6fdb35 fix: enforce Composer read-only state 2026-09-01 20:48:16 +09:00
Hare 816fa96e07 fix: restore workers with remote workdir attachments 2026-09-01 20:41:37 +09:00
Hare 8fbe4218c6 fix: delete complete Composer selections 2026-09-01 20:34:23 +09:00
Hare 00c8df0fc9 fix: preserve exact short paste text 2026-09-01 20:19:53 +09:00
Hare c52c7ead19 feat: add atomic paste chips to Web Composer 2026-09-01 19:50:26 +09:00
Hare d1e8a827c2 fix: preserve configuration completion context 2026-09-01 18:59:16 +09:00
Hare 12d96fb03d fix: preserve short paste line endings 2026-09-01 18:49:44 +09:00
Hare 171a191873 test: run shared composer paste contract 2026-09-01 18:27:21 +09:00
Hare 47dabd8793 fix: keep short composer pastes as text 2026-09-01 18:23:23 +09:00
Hare 2bb661f1cf fix: complete paste artifact storage contract 2026-09-01 17:51:24 +09:00
Hare 04e296a4ef feat: store large paste inputs as artifacts 2026-09-01 17:26:58 +09:00
Hare 4bba227af5 fix: remove legacy resume flag 2026-09-01 16:10:59 +09:00
Hare 5cc78d63c6 fix: merge Companion repository response validation 2026-09-01 13:21:13 +09:00
Hare 323f5dc09c fix: validate repository settings wire response 2026-09-01 13:13:01 +09:00
Hare fb97edfe95 fix: validate workspace schema before owner migration 2026-09-01 12:41:14 +09:00
Hare 070f62ef12 fix: harden workspace owner migration preflight 2026-09-01 12:22:29 +09:00
Hare 981749aa3d feat: require account owners for workspaces 2026-09-01 12:10:52 +09:00
Hare e01b46b30a chore: shard Cargo build cache by workspace 2026-09-01 09:33:47 +09:00
Hare a1b659c45d chore: merge develop into hare/develop
# Conflicts:
#	crates/client/src/lib.rs
#	web/workspace/deno.json
2026-09-01 09:15:10 +09:00
Hare 37a012ef92 chore: configure shared Cargo build caching 2026-09-01 09:04:00 +09:00
Hare 4927e8a843 chore: merge current develop into workspace catalog DTO source
# Conflicts:
#	crates/workspace-api/src/lib.rs
#	crates/workspace-server/src/server.rs
#	web/workspace/deno.json
#	web/workspace/src/lib/workspace/sidebar/types.ts
2026-09-01 08:17:25 +09:00
Hare 025d6ddb47 refactor: make workspace API own workdir projections 2026-09-01 03:16:31 +09:00
Hare 01a4dfd5d3 fix: remove unused workspace API dependency 2026-09-01 02:52:11 +09:00
Hare 1d7158a0bf chore: merge current develop into T-563 source 2026-09-01 02:39:08 +09:00
Hare 9de2afbfc6 fix: validate ticket repository summaries 2026-09-01 02:37:44 +09:00
Hare 9013754a3a refactor: name the workspace catalog list response 2026-09-01 02:24:11 +09:00
Hare 21eea0b104 fix: consume shared workdir create contract 2026-09-01 02:14:57 +09:00
Hare 15e8d7365c test: run workspace DTO boundary regressions 2026-09-01 02:05:07 +09:00
Hare 88e3bf7065 fix: preserve optional workdir wire fields 2026-09-01 02:00:19 +09:00
Hare 2765138bf3 chore: merge current develop into Companion DTO work
# Conflicts:
#	web/workspace/deno.json
2026-09-01 01:59:39 +09:00
Hare 6b20ceac46 fix: validate workspace repository browser payloads 2026-09-01 01:49:38 +09:00
Hare d748274905 refactor: centralize workspace repository response DTOs 2026-09-01 01:49:25 +09:00
Hare e1578217d5 fix: preserve workdir status display contract 2026-09-01 01:45:32 +09:00
Hare 3481682cb4 feat: validate shared workdir REST contracts in web 2026-09-01 01:42:17 +09:00
Hare 996b7f2468 refactor: centralize workdir REST DTOs 2026-09-01 01:42:05 +09:00
Hare 879993b9b1 fix: accept zero-length Companion transcripts 2026-09-01 01:41:16 +09:00
Hare 6604154e3f test: cover repository access loader failures 2026-09-01 01:39:55 +09:00
Hare a9ad42a970 fix: align Companion REST DTO contracts 2026-09-01 01:29:04 +09:00
Hare 8b3d1302c6 fix: share repository access API contracts 2026-09-01 01:22:27 +09:00
Hare ac9269d6ce fix: omit local bash spill path from remote commands 2026-08-31 23:14:33 +09:00
Hare 8ffb716817 fix: preserve workdir operation error classifications 2026-08-31 23:14:23 +09:00
Hare 23f671fa48 fix: remove redundant settings overview 2026-08-31 20:09:29 +09:00
Hare d7cdcde443 fix: preserve standalone scope conflict details 2026-08-31 19:28:09 +09:00
Hare 95a81faf63 chore: merge hare/develop into companion work 2026-08-31 18:45:32 +09:00
Hare bb8bb6d099 feat: restore interactive standalone profile selection 2026-08-31 18:43:46 +09:00
Hare 310801a29b feat: add repository settings registration 2026-08-31 18:20:17 +09:00
Hare 7d09b20445 feat: add workspace repository registration API 2026-08-31 18:12:08 +09:00
Hare 456a06f194 fix: propagate browser actor to workspace routes 2026-08-31 16:55:45 +09:00
Hare 62eaefb1fa feat: spill long bash output to worker temp storage 2026-08-31 16:52:27 +09:00
Hare 10264b4019 Merge branch 'work/companion' into hare/develop 2026-08-31 15:51:36 +09:00
Hare ab9765d91d feat: refine workspace navigation chrome 2026-08-31 15:50:49 +09:00
Hare e7f4c6864f fix: make WorkerId the standalone primary identity 2026-08-31 14:37:46 +09:00
Hare bde1dea2a5 fix: require confirmation before Ctrl-X shutdown 2026-08-31 13:07:39 +09:00
Hare 13a021c480 feat: add TUI run status spinner 2026-08-31 12:46:47 +09:00
Hare a7f09fad98 fix: route standalone protocol through client transports 2026-08-31 12:11:35 +09:00
Hare 10eaf4a5fb refactor: name worker event channels by role 2026-08-31 11:21:14 +09:00
Hare bcada300e3 fix: mark standalone TUI connection ready 2026-08-31 09:44:13 +09:00
Hare 9756174676 chore: merge standalone feature into companion integration
# Conflicts:
#	crates/client/src/target.rs
#	crates/client/src/ticket_role.rs
#	crates/manifest/src/profile.rs
#	crates/tui/src/dashboard/tests.rs
#	crates/tui/src/worker_list.rs
#	crates/workspace-server/src/hosts.rs
#	crates/yoi/src/main.rs
2026-08-31 02:01:38 +09:00
Hare 6dd8461a46 chore: merge develop into companion integration
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-08-31 01:51:13 +09:00
Hare 12cc2eb0e9 feat: unify workspace settings and runtime resources 2026-08-31 01:32:31 +09:00
Hare 38dd717aa6 test: lock discovery control grant separation 2026-08-30 23:07:27 +09:00
Hare 6640c902de feat: add privileged workspace worker discovery 2026-08-30 22:48:26 +09:00
Hare 583c343d08 fix: reject duplicate backend selectors 2026-08-30 21:57:50 +09:00
Hare dfc48f7a05 fix: preserve login backend selection 2026-08-30 21:48:07 +09:00
Hare cd9d854595 fix: reject standalone runtime selectors 2026-08-30 21:36:33 +09:00
Hare 766cbd17e5 refactor: centralize worker control routing 2026-08-30 21:26:29 +09:00
Hare 6df95bf981 fix: make workers help backend specific 2026-08-30 21:26:27 +09:00
Hare 6398ca0893 docs: clarify standalone resume help 2026-08-30 21:16:45 +09:00
Hare 62372a48cc fix: parse product help before target resolution 2026-08-30 21:08:08 +09:00
Hare 733632509a fix: keep product help target independent 2026-08-30 20:57:16 +09:00
Hare e1c13ec314 chore: merge final develop into T-556 2026-08-30 20:49:02 +09:00
Hare 928ff0eabe fix: isolate standalone target resolution 2026-08-30 20:45:40 +09:00
Hare 406559b13d refactor: remove legacy local client authority 2026-08-30 20:35:26 +09:00
Hare 20c16aa6fd chore: merge current develop into T-556 2026-08-30 20:32:20 +09:00
Hare 44ba5fd6d4 fix: expose canonical SubWorker control tools 2026-08-30 20:25:17 +09:00
Hare 745c6adbf2 fix: apply Grep filters to direct files 2026-08-30 20:15:01 +09:00
Hare a9aa09636f fix: preserve Workdir operation error classification 2026-08-30 20:07:46 +09:00
Hare 6945986dd1 fix: use canonical standalone session path 2026-08-30 19:53:31 +09:00
Hare 80c1f48f0e fix: allow Grep to target a file 2026-08-30 19:50:57 +09:00
Hare 31e18205f0 fix: fail closed on unknown standalone leases 2026-08-30 19:44:15 +09:00
Hare e84a9d3f9b fix: synchronize Resume busy state from protocol events 2026-08-30 14:11:12 +09:00
Hare 133feb8c76 feat: add standalone resume picker 2026-08-30 13:46:24 +09:00
Hare c5fd9c01e5 feat: persist standalone sessions for restore 2026-08-30 13:46:16 +09:00
Hare a7bf5ceac3 test: update client session snapshot fixture 2026-08-30 13:23:56 +09:00
Hare 74139aeb7e chore: merge develop into hare/develop 2026-08-30 13:19:24 +09:00
Hare 0cae4fd05c fix: retry device login code collisions 2026-08-30 12:55:12 +09:00
Hare 4b3b4fda61 feat: connect standalone host to local TUI 2026-08-30 12:40:22 +09:00
Hare adb684a6bf fix: preserve typed user input snapshots 2026-08-30 12:33:15 +09:00
Hare 8493472983 refactor: require annotated session log history 2026-08-30 12:18:44 +09:00
Hare 862eeb7add fix: route runtime workers through shared bootstrap 2026-08-30 11:54:20 +09:00
Hare 4d9b211d69 feat: add in-process standalone worker host 2026-08-30 11:29:59 +09:00
Hare ebb272324c Merge branch 'work/T-552-ticket-relation-projection' into hare/develop 2026-08-30 10:15:33 +09:00
Hare c0290512b3 fix: align Ticket relation projection 2026-08-30 10:15:23 +09:00
Hare 4ec56fe41e Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot 2026-08-30 09:42:57 +09:00
Hare f8a7c46cf9 fix(session): preflight migration and retain timestamps 2026-08-30 09:42:46 +09:00
Hare 2bab8a9bb6 fix: distinguish Ticket activity notifications 2026-08-30 09:00:48 +09:00
Hare 89e6a6215a Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot
# Conflicts:
#	web/workspace/src/lib/workspace/console/model.ts
2026-08-30 00:42:29 +09:00
Hare 88be87e03e feat(session): expose canonical public snapshots 2026-08-30 00:41:51 +09:00
Hare 22867faa9c test: align Profile consumers with explicit default 2026-08-30 00:37:52 +09:00
Hare 16c0fc704d feat: unify builtin Profile resolution authority 2026-08-30 00:28:58 +09:00
Hare 32fdd076bf feat: authenticate Backend target requests 2026-08-29 23:41:55 +09:00
Hare 402ae0d466 fix: replay annotated history in Web Console 2026-08-29 23:10:32 +09:00
Hare acb3c6d68b fix: separate Ticket keys from assignment identity 2026-08-29 22:52:03 +09:00
Hare 40ac83e632 chore: merge develop into hare/develop 2026-08-29 13:25:32 +09:00
Hare 58da395941 fix: merge workdir registry cleanup 2026-08-29 13:21:40 +09:00
Hare 0e3ef94c9e feat: merge web console tool call presentation 2026-08-29 13:21:35 +09:00
Hare 1f68dfc2b5 fix: group grep content output by file 2026-08-28 02:05:12 +09:00
Hare 84977a464c feat: improve web console tool call presentation 2026-08-28 00:05:00 +09:00
Hare 62ada5eaa4 fix: bound safe-boundary pause escalation 2026-08-27 23:57:20 +09:00
Hare f5ff0b7c13 fix: confirm bash cancellation cleanup 2026-08-27 23:01:06 +09:00
Hare 3337cafcdf feat: own cancellable tool execution lifecycle 2026-08-27 23:01:00 +09:00
Hare e87784118b fix: preserve resumable paused tool calls 2026-08-27 21:48:59 +09:00
Hare 40fada28ea fix: preserve confirmed output on interceptor abort 2026-08-27 21:30:04 +09:00
Hare 58cc94d4b7 fix: terminalize interrupted tool executions 2026-08-27 20:54:13 +09:00
Hare ccabea59c9 fix: terminalize parallel tool outputs on completion 2026-08-27 19:16:49 +09:00
Hare 8cc1dc042d fix: remove deleted workdirs from registry 2026-08-27 18:36:17 +09:00
Hare 183c37446e fix: fence running snapshots on input commit 2026-08-27 15:42:20 +09:00
Hare 7aa06afc45 chore: preserve reviewed source lineage
# Conflicts:
#	crates/agen/README.md
#	crates/agen/examples/engine_cancel_demo.rs
#	crates/agen/examples/engine_cli.rs
#	crates/agen/src/engine.rs
#	crates/agen/tests/annotated_history_test.rs
#	crates/agen/tests/callback_test.rs
#	crates/agen/tests/engine_fixtures.rs
#	crates/agen/tests/engine_state_test.rs
#	crates/agen/tests/parallel_execution_test.rs
#	crates/agen/tests/reasoning_round_trip_test.rs
#	crates/session-store/tests/session_test.rs
#	crates/worker/src/worker.rs
2026-08-27 15:13:27 +09:00
Hare 1515a2fb86 fix: reconcile typed history with run exits 2026-08-27 15:12:05 +09:00
Hare ec798c58d7 fix: preserve annotated history through rewind 2026-08-27 14:54:24 +09:00
Hare e365189276 feat: add provenance-aware worker history 2026-08-27 14:54:24 +09:00
Hare 116d610ad0 fix: project Ticket mutation outputs to human keys 2026-08-27 14:18:48 +09:00
Hare 75c570962d Merge commit '7edc588202dfbfd4c834f677f510ddda7f3d6451' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:46:19 +09:00
Hare cae8ac1799 fix: allow missing Objective query snippets 2026-08-27 13:46:10 +09:00
Hare 917cc222a3 fix: resolve relation summaries to Ticket keys 2026-08-27 13:22:08 +09:00
Hare 7edc588202 chore: refresh T-528 after T-541 2026-08-27 13:09:44 +09:00
Hare c83461508b Merge commit '21b3dd1da1b1bbf18799a0623bf67dbe6266067c' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:09:15 +09:00
Hare 651d64f34d Merge branch 'work/00001M10N3Z0H-ticket-detail-route-sync' into hare/develop 2026-08-27 12:57:52 +09:00
Hare b98d4b59f5 fix: synchronize reused ticket detail routes 2026-08-27 12:55:01 +09:00
Hare 374449e663 feat: add provenance-aware worker history 2026-08-27 12:51:01 +09:00
Hare 4c876a201b fix: validate projected resource keys canonically 2026-08-27 12:49:59 +09:00
Hare 21b3dd1da1 Merge commit '0496cd907bc7bb96e9aa1c6d385bedb616bf3233' into work/00001M10FJVA2-orchestrator-queue-notice 2026-08-27 12:46:27 +09:00
Hare 5ca0ea9228 fix: sanitize orchestrator queue attention 2026-08-27 12:46:10 +09:00
Hare d5c3a68a37 fix: use Ticket resource keys in handoffs 2026-08-27 12:45:08 +09:00
Hare 2b33b9158d chore: refresh T-528 against develop 2026-08-27 12:45:01 +09:00
Hare b31642e284 feat: project Ticket and Objective human references 2026-08-27 12:39:28 +09:00
Hare 3a7a3307ef fix: map internal worker terminal lifecycles 2026-08-27 12:27:55 +09:00
Hare 0496cd907b fix: use Ticket resource keys in handoffs 2026-08-27 12:24:19 +09:00
Hare 975b4fa700 feat: add typed engine run exits 2026-08-27 11:42:49 +09:00
Hare 060f280fdf fix: allow parent commands during write delegation 2026-08-27 11:27:27 +09:00
Hare 7aaf189247 fix: scope delegated commands by cwd 2026-08-27 11:11:06 +09:00
Hare df6d99c07d Merge branch 'develop' into hare/develop 2026-08-27 09:21:58 +09:00
Hare 63306cf017 feat: integrate runtime Git cache materialization 2026-08-27 08:30:38 +09:00
Hare 08be5e85e4 fix: preserve command sessions across access refresh 2026-08-27 08:09:22 +09:00
Hare 9843510e1f fix: remove conflicting coder worker control provider 2026-08-26 23:52:25 +09:00
Hare 83bda3dfb2 fix: restore companion subworker control 2026-08-26 22:06:34 +09:00
Hare 0ab15aa227 chore: merge hare/develop into develop 2026-08-26 19:44:31 +09:00
Hare 29c2fb8e06 fix: refresh repository access for worker workdirs 2026-08-26 19:27:10 +09:00
Hare 4ebc465e8d fix: constrain repository SSH commands to authorized source 2026-08-26 19:02:20 +09:00
Hare 4b132a21e9 fix: bind repository SSH secrets to one-shot resources 2026-08-26 18:28:22 +09:00
Hare b644971d45 fix: preauthorize repository access without persisting secrets 2026-08-26 17:23:43 +09:00
Hare df34533765 fix: broker read-only repository SSH operations 2026-08-26 16:48:37 +09:00
Hare 6f4efb36bb fix: order optional feature service bindings 2026-08-26 16:28:54 +09:00
Hare 108d5b14d7 fix: withhold write-capable credentials from read-only sessions 2026-08-26 16:23:48 +09:00
Hare f633b86b35 chore: integrate current develop for review 2026-08-26 16:12:48 +09:00
Hare 1873e18f8e fix: enforce attachment access transitions 2026-08-26 16:10:39 +09:00
Hare 3cdcbb47bf fix: preserve selector retries and workdir observations 2026-08-26 16:05:14 +09:00
Hare aaa9c7987c fix: reject ambiguous feature service providers 2026-08-26 16:01:06 +09:00
Hare 048007a042 test: complete tool output fixture 2026-08-26 15:47:34 +09:00
Hare cb35b40b9d feat: plan feature service installation 2026-08-26 15:47:27 +09:00
Hare 3b71fe03b4 fix: include merge request status regression in web tests 2026-08-26 15:27:19 +09:00
Hare 471db64bcc fix: preserve repository access across retries 2026-08-26 15:21:52 +09:00
Hare 5e2234763c fix: distinguish completed review requests in merge request status 2026-08-26 15:20:13 +09:00
Hare 1d140be715 feat: align merge request review workflow with selector refs 2026-08-26 15:03:26 +09:00
Hare ffb2a34ae5 fix: enforce repository access and cache boundaries 2026-08-26 14:39:37 +09:00
Hare ccf7de1a55 fix: preserve run budget across segment forks 2026-08-26 14:13:43 +09:00
Hare ccf3c80d29 fix: clear abandoned run budget before compaction 2026-08-26 14:03:07 +09:00
Hare 3a3c89e0b4 feat: materialize repositories through runtime Git cache 2026-08-26 13:54:25 +09:00
Hare 17c629136a fix: scope max turns to logical runs 2026-08-26 13:47:35 +09:00
Hare 52a5c4141f chore: merge current develop into Ticket source 2026-08-26 12:24:14 +09:00
Hare c9ba27c333 fix: update RepositoryRecord test URI access 2026-08-26 11:50:41 +09:00
Hare 46f6e2c58b fix: complete Repository access management contracts 2026-08-26 11:38:23 +09:00
Hare d1f47e5a22 fix: align Repository access DCDL fields 2026-08-26 11:06:19 +09:00
Hare 65de94bad3 feat: expose compaction as an internal worker 2026-08-26 10:39:43 +09:00
Hare 4e935c6203 test: refresh clean-build fixtures and assertions 2026-08-26 10:16:18 +09:00
Hare eac07cf5a8 fix: provide subworker control service 2026-08-26 10:16:10 +09:00
Hare 260259d461 fix: return ticket queue outcomes to web clients 2026-08-26 10:16:03 +09:00
Hare a9fb092834 feat: add Repository access settings UI 2026-08-26 08:51:23 +09:00
Hare 3a21a68792 feat: add Workspace Repository SSH secret authority 2026-08-26 08:51:15 +09:00
Hare 9d572d18bc fix: accept reserved workers during initial flow resolution 2026-08-26 07:52:04 +09:00
Hare f7852e8034 fix: remove misleading runtime capability projections 2026-08-26 06:19:33 +09:00
Hare 8c075de147 fix: allow remote runtime workdir creation 2026-08-26 05:13:21 +09:00
Hare 864367f4f5 fix: remove repository-local server configuration paths 2026-08-26 03:56:35 +09:00
Hare 33db2ea7f4 fix: unify browser origin configuration 2026-08-26 02:37:14 +09:00
Hare 8396d09891 fix: limit coder ticket comments to handoffs 2026-08-25 13:21:47 +09:00
Hare bf7171924d feat: integrate dependency queue planning 2026-08-25 13:08:09 +09:00
Hare 097c363fbc fix: block internal dependency cycles in projections 2026-08-25 12:54:47 +09:00
Hare 7dd8809e38 fix: fail closed without queue target authority 2026-08-25 12:44:03 +09:00
Hare 1749757036 fix: align queue eligibility with target authority 2026-08-25 12:24:31 +09:00
Hare 5857e6121c fix: preserve dependency queue atomicity 2026-08-25 12:05:43 +09:00
Hare a41147916b fix: confirm queue closures across clients 2026-08-25 11:51:40 +09:00
Hare cabe38db1d fix: align queue projections with dependency closure 2026-08-25 11:19:27 +09:00
Hare f079479160 feat: queue ready dependency closures atomically 2026-08-25 10:53:31 +09:00
Hare 87e160a01a test: track current migration version in dry run 2026-08-25 10:29:48 +09:00
Hare f94d829bf8 feat: integrate hare/develop 2026-08-25 10:29:41 +09:00
Hare 9a05bfa0c3 feat: allow ticket implementation cancellation 2026-08-25 09:52:21 +09:00
Hare c1d46859a3 fix: block cleanup for assigned workers 2026-08-25 09:39:33 +09:00
Hare 87ecbcb113 fix: validate non-worker ticket assignments 2026-08-25 08:09:31 +09:00
Hare 1fb2949561 fix: give reviewer write-scoped command tools 2026-08-25 04:45:17 +09:00
Hare 11be777fc0 fix: verify signed Workspace query targets 2026-08-25 01:12:24 +09:00
Hare ff94161fc0 test: authenticate workdir route fixtures 2026-08-25 00:54:28 +09:00
Hare c2ab9a950f feat: integrate Workspace API authorization 2026-08-25 00:54:13 +09:00
Hare 8e26a0f5a8 chore: merge latest develop into Ticket source
# Conflicts:
#	crates/workspace-server/src/store.rs
2026-08-24 23:24:12 +09:00
Hare d1c15ee295 fix: protect direct auth mutation routes 2026-08-24 14:58:34 +09:00
Hare 00a96234c6 fix: enforce CSRF on server mutations 2026-08-24 14:50:15 +09:00
Hare fc3b663510 fix: authorize Runtime profile archive fetches 2026-08-24 14:39:16 +09:00
Hare e4e045d059 fix: preserve Workdir provider rejection codes 2026-08-24 14:30:34 +09:00
Hare 436feaf33d fix: close remaining Workspace auth gaps 2026-08-24 14:28:44 +09:00
Hare 29f450b962 fix: map missing default Runtime to bad request 2026-08-24 14:16:38 +09:00
Hare db343893c8 fix: secure HTTPS browser session cookies 2026-08-24 14:11:51 +09:00
Hare 554906ec02 fix: treat legacy Runtime config as unconfigured 2026-08-24 14:07:42 +09:00
Hare 18c37f4842 fix: persist Workdir Runtime failure classifications 2026-08-24 13:59:52 +09:00
Hare 83382b824a fix: protect legacy Workspace API routes 2026-08-24 13:56:46 +09:00
Hare 6203316aa1 fix: defer local repository validation to runtimes 2026-08-24 13:46:02 +09:00
Hare d57b4d1d5e fix: align ticket queue projection test 2026-08-24 13:42:39 +09:00
Hare 379ae214fc feat: queue tickets with dependency context 2026-08-24 13:40:33 +09:00
Hare 163a403636 feat: support typed remote repository sources 2026-08-24 13:35:19 +09:00
Hare 53edaadc3a feat: authenticate Workspace API requests 2026-08-24 13:32:55 +09:00
Hare 3c2664c3ce feat: sign Runtime workspace requests 2026-08-24 13:32:49 +09:00
Hare d9048954a5 fix: reject unsupported Workdir runtimes before delegation 2026-08-24 13:24:59 +09:00
Hare 4f84dfd73f feat: resolve default Runtime for Workdir creation 2026-08-24 13:15:18 +09:00
1610 changed files with 664234 additions and 107915 deletions
-4
View File
@@ -1,4 +0,0 @@
/memory/
tickets/.ticket-backend.lock
/workspace.db*
tickets
-60
View File
@@ -1,60 +0,0 @@
---
title: "E2E テスト戦略"
state: "active"
created_at: "2026-06-09T07:09:26Z"
updated_at: "2026-06-09T07:09:26Z"
linked_tickets: ["00001KSKBP9YG"]
---
## Goal
Yoi の実プロセス・実 socket・実 provider 境界をまたぐ振る舞いを、通常の crate 内 unit / integration test だけに頼らず検証できる E2E テスト戦略を確立する。
最初の到達点は、実 `pod` / product binary を spawn し、protocol 経由で最小シナリオを実行し、graceful shutdown まで確認できる opt-in E2E harness を持つこと。その上で、permission、resume/fork、spawned Pod、provider stream、TUI/Panel などの重要境界を段階的に増やせる状態にする。
## Motivation / background
現状のテストは crate 内の in-process coverage が厚い一方で、以下の性質は単体テストだけでは十分に確認しづらい。
- 実プロセス spawn と runtime dir / socket / env の相互作用。
- Pod controller / protocol client / session store / metadata / restore の統合挙動。
- provider endpoint、streaming、auth/token、tool call、continuation、retry の実接続に近い振る舞い。
- permission deny、scope、manifest/profile 解決、child Pod delegation など、複数 crate と実 runtime state をまたぐ policy。
- TUI / Panel が前提にする Pod lifecycle や Ticket orchestration の外形。
E2E は常時実行の軽いテストではなく、dogfooding 中に「この機能は実 runtime でも壊れていない」と確認するための opt-in 検証基盤として必要。
## Strategy / design direction
- E2E は通常の `cargo test --workspace` からは外し、明示 feature / 専用 package / 独立 job で opt-in 実行する。
- まずはワークスペース直下の専用 E2E package / harness として設計し、個別 crate の unit test に押し込めない。
- protocol を喋る側は TUI の PTY 操作ではなく、typed client / protocol client を使う方向を優先する。
- provider 依存は最初から全部を対象にしない。
- 最小 harness では canned / fixture / mock HTTP server を使う。
- provider 差分は代表 provider から段階的に増やす。
- env / runtime dir / socket path は test ごとに隔離し、並列実行方針を明示する。
- 必要なら最初は `--test-threads=1` 相当で安全側に倒す。
- 将来的には per-test runtime dir と typed launch config で並列性を上げる。
- E2E は「全シナリオを大量に持つ」より、重要な runtime seam ごとに少数の高価値 scenario を置く。
- 失敗時 diagnostics は、secret を出さずに process phase、socket path、session id、log path、provider fixture id を辿れる形にする。
- E2E harness 自体が flaky にならないよう、network / time / external auth への依存は明示 opt-in に分ける。
## Success criteria / exit conditions
- `cargo test --workspace` では E2E が走らず、通常開発の feedback loop を重くしない。
- 明示コマンドで E2E harness を実行できる。
- 例: `cargo test -p e2e --features e2e` または後続で決める同等コマンド。
- 最小 scenario が実 `pod` / product binary を spawn し、protocol 経由で 1 turn 実行し、graceful shutdown まで通る。
- E2E 実行は専用 runtime/data dir を使い、通常の user/workspace state を汚さない。
- fixture / mock provider の設計があり、少なくとも 1 provider 相当の canned response を実 HTTP 経由で返せる。
- failure diagnostics から、spawn 失敗・socket 接続失敗・provider fixture 失敗・protocol 失敗・shutdown 失敗を区別できる。
- 後続 Ticket が permission / resume / fork / spawned Pod / provider streaming / Panel などを追加できる harness boundary がある。
## Decision context
- linked Ticket `00001KSKBP9YG` は、E2E harness の最初の concrete implementation Ticket として扱う。
- この Objective は E2E 全体の中長期方針・判断軸を保持する。個別 scenario の実装や harness の細部は concrete Ticket に分ける。
- TUI を直接 PTY で叩く方針は初期 harness では避け、protocol/client 経由を優先する。
- provider 全対応は初期 scope にしない。fixture / mock HTTP server を基礎にし、代表 provider から段階的に広げる。
- E2E は flakiness と実行コストが高いため、既定 CI / 既定 workspace test には入れず、opt-in 検証として始める。
- Objective context は判断材料であり、実装 authority は各 Ticket の body/thread/artifacts と明示的な Ticket relation / OrchestrationPlan に置く。
-73
View File
@@ -1,73 +0,0 @@
---
title: "ネイティブGUIアプリケーション"
state: "active"
created_at: "2026-06-10T07:41:18Z"
updated_at: "2026-07-15T21:18:00Z"
linked_tickets: []
---
## Goal
Yoi の Pod / Ticket / Orchestrator / Skill・prompt resource 操作を、TUI だけでなくネイティブ GUI から扱えるようにする。
最初の到達点は、既存の runtime / Ticket backend / Pod protocol / Profile / Skill/prompt resource authority を再実装せずに、workspace の状態を視覚的に把握し、選択した Pod・Ticket・role action に対して安全に操作できる desktop GUI client を持つこと。GUI は core authority ではなく client surface とし、既存 CLI/TUI と同じ durable state・同じ protocol・同じ permission/prompt/resource 境界を使う。
## Motivation / background
現在の TUI / Panel は dogfooding 可能な状態まで進んでいるが、複数 Pod・複数 Ticket・Orchestrator 状態・session output・role launch・review/merge dossier を同時に扱うには、terminal UI の表示密度・視覚的状態表現・スクロール/選択/比較操作に限界が出ている。
ネイティブ GUI があると、以下をより自然に扱える。
- live / stored Pod、Ticket lane、Orchestrator progress、Companion/Intake 状態の同時表示。
- Ticket body/thread/artifacts、Pod output、validation evidence、diff/report の並列閲覧。
- composer target、role action、queue/routing/attach/restore の明確な affordance。
- long-running orchestration の通知、状態変化、失敗診断の視覚化。
- 将来的な review / merge-ready dossier / plan board / settings editor の専用 UI。
一方で、GUI を理由に runtime authority を分散させたり、Ticket/Pod state を独自 DB として二重管理したり、prompt/resource 文字列を GUI code に直書きしたりしてはいけない。
## Strategy / design direction
- GUI は Yoi core の上に乗る client として作る。
- Pod lifecycle、session/history、Ticket storage、Profile resolution、resource/prompt authority は既存 core を正とする。
- GUI 固有 state は selection、layout、local UI preference などに限定する。
- 最初に toolkit / architecture の小さな spike を置く。
- 評価軸は Rust code reuse、async/runtime 統合、native packaging、Linux dogfooding しやすさ、testability、accessibility、long-running log/output 表示、将来の cross-platform 余地。
- toolkit 選定は Objective では固定しない。候補比較と採用理由を Ticket に残す。
- 実装は段階的に進める。
1. read-only workspace dashboard: Pod / Ticket / Orchestrator 状態を既存 backend から表示する。
2. attach / restore / open など、既存 protocol に乗る低リスク操作を追加する。
3. composer と role action: Companion / Intake / Orchestrator / coder / reviewer launch を既存 launcher 経由で扱う。
4. Ticket body/thread/artifacts、Pod output、validation evidence、review report を閲覧しやすくする。
5. 必要に応じて settings/profile/config editor や merge-ready dossier UI を追加する。
- TUI は廃止前提にしない。
- GUI 導入後も CLI/TUI は fallback / automation / terminal-first operation flow として維持する。
- GUI で見つかった state model の改善は、TUI と共有できる pure data model / client API に寄せる。
- Prompt / resource / role guidance は GUI code に直書きしない。
- LLM-facing prompt は `resources/prompts` または `.yoi/skills` / configured resources を正とする。
- GUI は prompt 文言を所有せず、選択・起動・runtime context の入力面を担当する。
- Security / privacy / authority boundary を保つ。
- secret-like data は UI diagnostics / logs / model context に漏らさない。
- permission / scope / profile の authority は既存 resolver/policy に従う。
- GUI convenience action は durable Ticket/Pod state transition と対応付け、暗黙の side effect を避ける。
## Success criteria / exit conditions
- 明示コマンドまたは binary で native GUI を起動できる。
- GUI は既存 workspace config、Profile、Ticket backend、Pod registry/protocol を使い、独自の authority store を持たない。
- 最小 dashboard で live/stored Pod、Ticket lane/state、Orchestrator/role session の概況を確認できる。
- GUI から少なくとも attach/restore/open 相当の安全な Pod 操作ができる。
- GUI から Ticket Intake または既存 role launcher を使った role action を実行でき、既存の prompt/resource 境界を壊さない。
- Pod output / Ticket body/thread/artifacts を、TUI より見通しよく閲覧できる最小 UI がある。
- GUI 固有 state と core durable state の境界が文書化されている。
- toolkit / architecture 選定理由、採用しなかった選択肢、packaging 方針が Ticket artifact または design note として残っている。
- GUI で使う state transformation / action eligibility は pure model として test 可能で、主要な selection/action state の unit test がある。
- GUI 実装は CLI/TUI の既存 operation flow を破壊せず、必要な targeted validation が定義されている。
## Decision context
- この Objective は中長期の方向性・判断軸を保持する。具体的な toolkit 選定、crate 構成、初期 dashboard 実装、role action 実装、packaging は個別 Ticket に分ける。
- GUI は TUI の単純な置換ではなく、複数 Pod / Ticket / Orchestrator を扱う workspace cockpit として設計する。
- authority は既存 core に残す。GUI は client/view/controller surface であり、Pod/Ticket/resource/prompt の正本を所有しない。
- Prompt 直書き禁止方針を守る。GUI 実装中に LLM-facing 文言が必要になった場合は、`resources/prompts` または Skill/resource 側に置く。
- 初期 target は dogfooding しやすい desktop GUI とし、public release / cross-platform polish / installer は後続段階で扱う。
-111
View File
@@ -1,111 +0,0 @@
---
title: 'MCP local stdio integration roadmap'
state: 'active'
created_at: '2026-06-10T07:48:45Z'
updated_at: '2026-06-20T05:34:00Z'
linked_tickets: ['00001KTR81P9X', '00001KV0SP0TY', '00001KVHR3WRF', '00001KVHR3WRY', '00001KVHR3WS6', '00001KVHR3WSD', '00001KVHR3WSN', '00001KVHR3WSW']
---
## Goal
Add MCP local stdio integration to Yoi without weakening Worker history, prompt-context, scoped tool permission, or Plugin/Feature layering invariants.
MCP is a protocol-backed integration layer on top of `pod::feature`. `pod::feature` supplies contribution/lifecycle/runtime-discovered registration substrate; MCP owns its own enablement, local server trust model, command/env/secret policy, and MCP-specific permission decisions. MCP is not the Plugin model, and Plugin permission policy is not implemented by feature-layer authority grants.
## Motivation / background
Yoi needs to integrate with external capability providers without turning them into hidden context sources or bypassing ordinary Tool/Worker safety rules. MCP is useful because it can expose tools, resources, and prompts from local protocol servers, but those server-provided declarations and results are untrusted and must be normalized through Yoi's existing authority boundaries.
The first MCP slice should focus on local stdio servers because they are concrete enough to implement and debug while keeping remote auth, OAuth, Streamable HTTP, registry distribution, sampling, and elicitation out of the initial trust boundary.
A configured local MCP server runs as a local executable. Yoi feature authority does not sandbox that executable's OS-level side effects, so command/env/secret handling and explicit local trust policy are MCP-layer responsibilities rather than generic `pod::feature` grants.
## Strategy / design direction
- Baseline the initial implementation on MCP specification `2025-11-25`.
- Start with local stdio MCP servers only.
- Treat MCP server metadata, tools, resources, prompts, and results as untrusted content.
- Do not allow MCP resources/prompts to become hidden context injection.
- They must be explicit tool operations with history records.
- Use the normal Yoi ToolRegistry, PreToolCall permission, history, and bounded result paths.
- Do not add private MCP-only bypasses around Worker/tool invariants.
- Keep sampling and elicitation fail-closed initially.
- Keep Streamable HTTP, remote auth, OAuth, and MCP Registry/distribution out of the first slice.
- Treat local stdio server execution as an explicit MCP config/trust decision, not as a `pod::feature` authority grant.
- Document clearly that a configured local MCP server runs as a local executable; Yoi feature authority does not sandbox its OS-level side effects.
### Layering decisions
- `pod::feature` is an API/contribution substrate.
- It owns contribution declarations, provider/service lifecycle hooks, diagnostics, runtime-discovered registration plumbing, and integration with normal Worker/ToolRegistry paths.
- It does not own Plugin permission policy or MCP server trust policy.
- Plugin is a user-facing package/config/runtime layer over `pod::feature`.
- Plugin permissions are Plugin-layer policy.
- Plugin package discovery/enablement must not be conflated with MCP local server execution.
- MCP is a separate feature-backed integration layer.
- MCP enablement, command/env/secret handling, server trust, and MCP-specific permission decisions live in MCP config/implementation.
- MCP provider-discovered tools/resources/prompts are exposed through the feature API and ordinary Yoi tool paths.
### Concrete implementation tickets
Completed prerequisites:
- `00001KTR81P9X` — Extend `pod::feature` API for external protocol-backed capability providers.
- `00001KV0SP0TY` — Remove feature-layer HostAuthority model.
Concrete MCP implementation sequence:
1. `00001KVHR3WRF` — MCP local stdio server config and trust policy.
- explicit config, command/env/secret redaction, local executable trust boundary, no auto-start.
2. `00001KVHR3WRY` — MCP stdio JSON-RPC lifecycle client.
- subprocess lifecycle, initialize/capability negotiation, diagnostics, shutdown.
3. `00001KVHR3WS6` — MCP tools/list registration into ToolRegistry.
- provider-discovered tools, stable namespacing, schema validation, untrusted metadata normalization, no tools/call yet.
4. `00001KVHR3WSD` — MCP tools/call execution through ordinary Tool path.
- PreToolCall gate before server call, bounded result serialization, history path.
5. `00001KVHR3WSN` — MCP resources/prompts as explicit tool operations.
- resources/list/read and prompts/list/get without hidden context injection.
6. `00001KVHR3WSW` — MCP list_changed notification handling.
- deterministic safe refresh/diagnostic behavior without breaking tool schema or prompt-cache invariants.
The old broad implementation Ticket `00001KTR82RB7` is superseded by this sequence and should not be used as an implementation work item.
### Terminology
Use `runtime-discovered` or `provider-discovered` for MCP tools/resources/prompts discovered from `tools/list`, `resources/list`, or `prompts/list`. Avoid `dynamic tools` / `dynamic registry` in new MCP design prose because those phrases imply that model-visible tool schemas may change during an active LLM run.
The intended invariant is:
```text
provider-discovered at startup / provider initialization;
registered into the ordinary ToolRegistry before model exposure;
run-stable for the duration of a model request/run;
refreshed only at a safe boundary or reported as a diagnostic.
```
### Later follow-ups
- Richer MCP task/task-support integration if ordinary tool-call fallback is insufficient.
- Streamable HTTP transport.
- OAuth / remote auth.
- Registry/package distribution.
- Explicit MCP/Plugin bridge only if separately approved; do not conflate Plugin packages with MCP local server execution.
## Success criteria / exit conditions
- A local mock MCP server can be configured explicitly and initialized.
- Discovered MCP tools appear as ordinary Yoi tools with stable namespacing.
- Tool calls go through ordinary permission and history paths.
- MCP resources/prompts are explicit operations, not hidden context injections.
- MCP result forms are bounded and safely serialized.
- Secret values, command/env details, and server diagnostics are redacted where required.
- Local server trust boundary is documented: Yoi does not sandbox the configured executable through feature authority.
- Feature, Plugin, and MCP permission/trust responsibilities are documented as separate layers.
## Decision context
- MCP is not the Plugin model; it is a protocol-backed integration layer using `pod::feature` substrate.
- `pod::feature` should provide contribution/lifecycle/runtime-discovered registration plumbing, not MCP server trust policy or Plugin package permission policy.
- MCP resources and prompts must never be hidden context injection. They are explicit operations recorded through ordinary history/tool paths.
- Provider-discovered tools are discovered at startup/provider initialization and registered before model exposure; model-visible schemas remain run-stable during a request/run.
- Local stdio server execution is a user/config trust decision. Yoi does not sandbox the local executable merely because it is configured through MCP.
-101
View File
@@ -1,101 +0,0 @@
---
title: "Plugin platform roadmap"
state: "active"
created_at: "2026-06-19T13:18:58Z"
updated_at: "2026-06-24T19:55:00Z"
linked_tickets: ["00001KV5R5V2S", "00001KV5W3PHA", "00001KV5W3PHW", "00001KV5W3PJ3", "00001KVFD3YSV", "00001KVFDX9AF", "00001KVFDX9AY", "00001KVG0HR96", "00001KVXHVCR5", "00001KVXK0WD3", "00001KVXK0WDH", "00001KVXK0WDQ", "00001KVXK0WDX", "00001KVXK0WE4", "00001KVXK0WEA"]
---
## Goal
Build Yoi's Plugin platform as a coherent extension system: packages are discovered and inspected safely, enabled explicitly, registered through typed Plugin surfaces, executed in a sandboxed runtime, constrained by Plugin-layer grants, and authored through SDK/templates rather than raw runtime ABI details.
The long-term platform goal is not merely to run Wasm. It is to make Plugin packages a durable, inspectable, permissioned, and authorable extension layer for Tools first, then host APIs (`https`, `fs`), and later Service / Ingress surfaces when concrete needs justify them.
## Motivation / background
The current Plugin foundation is already substantial:
- package discovery and explicit enablement resolver;
- Tool surface registration through the ordinary ToolRegistry/model-visible schema path;
- minimal sandboxed WASM Tool execution;
- Plugin permission grant enforcement;
- follow-up Tickets for read-only inspection CLI, `https`, `fs`, and Component Model migration.
The remaining work must be kept as one roadmap because the pieces constrain each other:
- Plugin authoring needs an SDK/PDK and examples, not raw pointer/length Wasm ABI hand-coding.
- `https` and `fs` host APIs must be grant-gated and shaped so they can move cleanly to typed Component Model interfaces.
- Diagnostics (`yoi plugin list/show`) are needed before the system becomes harder to debug.
- Component Model adoption should guide new host API design before a custom raw ABI becomes entrenched.
- Service / Ingress are useful for bridge-style integrations, but should come after Tool runtime, diagnostics, and host API policy are stable.
Research of common Wasm extension systems points to the same pattern: mature systems combine a package manifest, explicit capabilities, a sandbox runtime, host-provided capability APIs, language SDK/PDK bindings, templates/examples, inspection/check tooling, and versioned interfaces.
## Strategy / design direction
- Keep Plugin as a user-facing package/config/runtime layer above lower-level `pod::feature` substrate.
- `pod::feature` provides contribution/registration substrate.
- Plugin owns package discovery, enablement, grant policy, runtime selection, authoring UX, and user-facing diagnostics.
- Preserve authority boundaries.
- Package discovery is read-only inventory.
- Package presence never registers a Tool/Hook, executes Wasm, starts a Service, reads files, opens network, or injects context.
- Explicit enablement and Plugin grants are required before registration/execution/host API use.
- Tool calls/results continue through ordinary ToolRegistry and Worker history paths.
- Treat Component Model as the active Plugin runtime shape before public release.
- New typed Plugin host APIs should be designed in WIT-compatible terms.
- `runtime.kind = "wasm-component"` is the current Plugin runtime authority for new work.
- The earlier `yoi-plugin-wasm-1` raw core-Wasm compatibility bridge is retired from the active roadmap because Plugin has not been publicly released and compatibility would preserve the wrong boundary.
- Sequence the platform in usable layers:
1. Package discovery / explicit enablement / digest-pinned restore. Completed foundation.
2. Tool surface registration. Completed foundation.
3. Minimal WASM Tool execution. Completed foundation.
4. Permission grants. Completed foundation.
5. Read-only Plugin CLI inspection (`yoi plugin list/show`) for debugging discovery/enablement/grants/runtime metadata.
6. `https` and `fs` host APIs for Tool Plugins, grant-gated and WIT-compatible.
7. Remove the raw core-Wasm compatibility bridge and reject legacy runtime manifests.
8. Component Model runtime and authoring model become the only active Plugin runtime path.
9. Guest SDK/PDK, examples, `check`/`pack`/`new` authoring tooling target Component Model only.
10. Service / Ingress runtime is developed as host-managed lifecycle, event queue, output command, and diagnostics slices.
11. WebSocket support for long-lived integrations is host-owned connection driver + ingress event delivery + output command, not Plugin-owned polling with `recv(timeout)`.
- Keep Discord-style bridge goals split into two stages.
- Outbound Discord/webhook Tool is possible after `https`.
- Bidirectional Discord bridge requires Service + Ingress + WebSocket or inbound HTTP and host routing policy.
## Current implementation split
The broad Plugin runtime redesign is tracked by `00001KVXHVCR5` as context only; implementation should proceed through concrete Tickets instead of routing that umbrella as a single coding task.
1. `00001KVXK0WD3` Remove legacy raw WASM Plugin runtime.
- Deletes the active `LegacyToolAdapter` / raw-Wasm execution path.
2. `00001KVXK0WDH` Reject legacy Plugin runtime in manifest and CLI diagnostics.
- Makes `plugin.toml`, `yoi plugin check/list/show`, docs, and fixtures reflect Component Model only runtime authority.
3. `00001KVXK0WDQ` Define Plugin Service lifecycle and ingress queue runtime.
- Adds host-managed lifecycle, bounded queue, serial dispatch, backpressure, timeout, and diagnostics.
4. `00001KVXK0WDX` Add Plugin service output command model.
- Lets service handlers request side effects as grant-checked commands rather than ambient authority.
5. `00001KVXK0WE4` Add host-owned WebSocket driver for Plugin services.
- Converts incoming WS frames into ingress events and sends outbound frames through output commands.
6. `00001KVXK0WEA` Update Plugin WIT PDK templates for service event runtime.
- Aligns authoring API, WIT, PDK, templates, and docs with the new event/command execution model.
## Success criteria / exit conditions
- Users can inspect Plugin discovery/enablement/grant/runtime state through a read-only CLI without executing Plugin code.
- Plugin authors can build a Tool Plugin without writing raw memory/pointer ABI plumbing.
- Tool Plugins can safely call grant-gated `https` and `fs` host APIs.
- Component Model is the only active Plugin runtime path, with WIT-compatible host API types and measured packaging/runtime impact.
- Plugin grants remain authoritative over registration, execution, and host API calls.
- Plugin diagnostics explain missing package, invalid manifest, digest/version mismatch, missing grant, rejected schema, runtime mismatch, legacy runtime rejection, and unsupported host API cases safely.
- Raw core-Wasm Plugin compatibility is removed before public release; tests and docs no longer treat it as a current runtime.
- Documentation covers package format, Component Model runtime, host API authority, authoring SDK/templates, Service/Ingress event runtime, and operational debugging.
- Service/Ingress work is host-managed: services have lifecycle/status, ingress uses bounded queues, side effects are output commands, and WebSocket integrations use host-owned connection drivers.
## Decision context
- This Objective is roadmap context, not Ticket authority. Implementation still requires reading concrete Ticket bodies, threads, artifacts, and relations.
- Component Model direction supersedes Yoi's custom raw ABI as both long-term and current active Plugin runtime authority before public release.
- `https` / `fs` work should avoid choices that conflict with later WIT typed interfaces.
- Guest SDK work targets Component Model directly; raw ABI wrappers are not a supported transitional authoring path.
- Plugin and MCP remain separate. Component Model adoption for Plugin does not imply MCP server execution, MCP prompt/resource injection, or MCP trust policy changes.
- Plugin surfaces remain Tool / Hook / Service / Ingress; outbound side effects are Tool metadata and host API grants, not a separate surface.
-287
View File
@@ -1,287 +0,0 @@
---
title: "Team workspace control plane and runtime architecture"
state: "active"
created_at: "2026-06-20T14:26:29Z"
updated_at: "2026-07-15T21:18:00Z"
linked_tickets: ["00001KVMFFYVX", "00001KWMBAA6V"]
---
## Goal
Yoi を、単一のローカル開発ディレクトリで動くエージェント実行ツールから、チームで作業・判断・実行結果を管理できるワークスペース基盤へ発展させる。
この Objective の中心は、Web から扱える管理システムを作り、その管理システムにローカル Runtime・リモート Runtime・将来のクラウド Runtime を接続できるようにすることである。管理システムは Ticket、Objective、Memory、Skill catalog、Artifact、Policy、Actor、Repository、Runtime state の正本を持つ。Runtime はその管理システムから Worker launch request / config bundle / repository target / authority を受け取り、作業環境を用意して Worker を実行し、結果・イベント・証跡を返す。
この Objective は Git ホスティングサービスを作るものではない。Git は重要な Repository provider として扱うが、Yoi の Workspace は Git Repository root と同じものにしない。Yoi が作るべきものは、コード・ドキュメント・データ・成果物などの Repository と Runtime を接続しながら、人間とエージェントの作業、Ticket lifecycle、Memory、Skill catalog、検証証跡、実行環境配置を管理するチームワークスペースである。
## Glossary
この Objective では、以下の語をこの意味で使う。
- Workspace: チームまたはプロジェクトの管理単位。Ticket、Objective、Memory、Skill catalog、Artifact、Policy、Actor、Repository、Runtime state を持つ。Git Repository root ではない。
- Control plane: Workspace の正本を持ち、Web UI / API / CLI から操作される管理システム。
- Runtime: Worker 群を束ねる実行基盤。Worker lifecycle、sandbox、mount、cache、checkout/worktree/container filesystem などの working directory materialization、event/control plane を管理する。将来的には 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。
- Worker: Runtime が管理する 1 つの agent/session/process。Runtime が用意した working directory と authority の中で動く。
- Repository: Workspace に接続される source/storage。コード、ドキュメント、local directory、object storage、artifact store、dataset などを含む。Git Repository も Repository の一種であり、基本的には filesystem path ではなく URI / URL で識別する。
- RepositoryId: Workspace 内でどの Repository を対象にするかを指す安定 identifier。Git hash、branch、path ではない。
- Repository provider: Repository の種類ごとの実装。Git、local filesystem、object store、artifact store、将来の non-Git VCS など。
- RepositorySelector: Repository provider に渡す未解決の地点指定。branch/tag/PR/revspec/bookmark/revset/path@revision/object version/latest など provider-specific な symbolic / mutable / query-like locator であり、それ自体は再現性の authority ではない。
- RepositoryPoint: RepositorySelector をある時点で解決した具体地点。Git commit/tree、Mercurial changeset、SVN revision、object store version/manifest digest、file snapshot など provider ごとの immutable / reproducible point を表し、Artifact/evidence に残す。
- working directory: Runtime が Worker のために作る作業環境。1 つ以上の RepositoryPoint から materialize される作業用ディレクトリ、container filesystem、sandbox mount の集合であり、Git worktree、clone、sparse checkout などはこれを作る手段である。Browser-facing UI/API では product `Workspace` と混同しないよう、この呼称に寄せる。`Volume` は storage backing の候補名に留め、作業領域そのものの呼称にはしない。
- Ticket: チームで扱う作業単位。目的、要件、判断、議論、完了条件、関係、証跡を持つ。
- Objective: 複数の Ticket を束ねる長期目標や設計方針。
- Artifact: Ticket や Worker 実行に紐づく成果物や証跡。diff、log、validation result、review result、report など。
- Memory: エージェントやユーザーが再利用するための要約された文脈。Ticket や Artifact の正本ではない。
- Skill catalog: `.yoi/skills` / builtin skills から Workspace backend が解決する procedural guidance catalog。外部状態 authority は持たず、Ticket / Worker / workdir などの操作は typed feature/tool surface が担う。
- Actor: 人間、エージェント、システム、外部サービスなど、Workspace 上で操作や発言を行う主体。
## Motivation / background
現在の Yoi は、ローカルの `.yoi` ディレクトリ、ローカルプロセス、Ticket ファイル、ワークツリー運用によって、自分自身の開発に使えるエージェント実行環境になっている。しかし、チーム利用、Web UI、リモート実行、クラウド実行、最終的な SaaS 提供を考えると、次の前提を変える必要がある。
- Workspace を Git Repository root と同一視しない。
- ローカル filesystem 上の `.yoi` を、長期的なチーム用正本 store にしない。
- Ticket をローカル作業メモではなく、チームの作業調整 record にする。
- 実行証跡は Ticket thread、Artifact、WorkerRef snapshot、Runtime event として扱い、独立した実行単位概念を先に増やさない。
- 管理システムと Runtime を分ける。
- まず Web から Ticket、Objective、Memory、Skill catalog、Artifact、Runtime / Worker state を見られるようにする。
- 最初はローカル Runtime を使い、後でリモート Runtime、クラウド Runtime、runtime pool、resource allocation、quota、billing、sandboxing に拡張する。
- Git ホスティング機能を取り込むのではなく、Git Repository / worktree / clone は Repository provider と working directory materialization の手段として扱う。
OSS として Control plane、Runtime、Web frontend、protocol を公開しつつ、managed service では hosted control plane、runtime fleet、リソース柔軟性、team auth、backup、audit、availability、multi-tenant operations で価値を出す。
## Strategy / design direction
### 1. Control plane を先に作る
Team Workspace の正本は server-side control plane に置く。`.yoi` は local backend、single-user/self-hosted compatibility、offline/export/import、local projection、migration bridge として残せるが、multi-user SaaS の正本とはみなさない。
Control plane は Ticket、Objective、Memory、Skill catalog、Artifact、Actor、Permission、Audit、Repository、Runtime / Worker state を管理する。Web UI、CLI、TUI、将来の desktop client は、この Control plane を操作する client であり、別の正本 store を持たない。
### 2. Workspace と Repository を同一視しない
Workspace はチームまたはプロジェクトの作業管理単位である。Repository は Workspace に接続される source/storage である。Git Repository は Repository の一種にすぎない。
1 つの Workspace は複数の Repository を持てる。Repository は filesystem path ではなく URI / URL で識別する。例として `git+https://...`、`file://...`、`s3://...`、`artifact://...`、将来の VCS provider URI などを扱えるようにする。
Ticket と Objective は Repository 配下に置かず、Workspace 配下に平たく持つ。Ticket は必要に応じて対象 RepositoryId、RepositorySelector、path scope、必要 capability を持つ。Objective は複数 Ticket にまたがる target default / scope hint を持てるが、Repository の所有物にはしない。
RepositorySelector は Git branch/tag の抽象化ではない。Selector は provider-specific な未解決 locator であり、Git provider なら branch/tag/ref/revspec/PR/commit、Mercurial provider なら bookmark/revset/changeset、SVN provider なら path/revision、object store provider なら prefix/version/latest などを解釈する。実行時には Control plane または Repository provider が Selector を RepositoryPoint に解決し、Runtime はその RepositoryPoint を materialize する。
Worker launch request は Ticket の target selector を concrete RepositoryPoint に解決し、その RepositoryPoint から Runtime が working directory を materialize する。Git worktree 相当の機能は、この working directory を作るための実装戦略として扱う。
Backend は cwd や `--workspace` を暗黙の Repository として扱わない。`--workspace` は当面 workspace config root / local descriptor root を指すだけであり、Repository registry は明示設定された Workspace config から構築する。短期的には `.yoi/workspace-backend.local.toml` の `[[repositories]]` を local descriptor として使い、`uri = "."` のような local repository も明示 entry として登録する。`./` を暗黙 Repository として自動採用しない。
`.yoi` は現在の local backend / fs-store / compatibility surface として残るが、long-term Backend store ではない。将来的には `~/.yoi` 側に Backend store と Workspace registry を置き、1 Backend process が複数 Workspace を扱える形へ移行する。`.yoi/workspace-backend.local.toml` はその移行までの workspace-local descriptor / override surface として扱う。
短期的には Git を主な Repository provider とする。ただし Yoi の authority model を Git object、Git branch、Git Repository root、worktree path に固定しない。Orchestration は Git そのものではなく、`resolve_ref`、`materialize`、`diff`、`patch`、`commit`、`merge` などの Repository capability に依存する。
### 3. Ticket を team coordination record にする
Ticket は実行そのものではない。Ticket は「何を、なぜ、どの条件で完了とみなすか」を持つ。Ticket は Workspace に平たく所属し、Repository には所属しない。コードやドキュメントを対象にする Ticket は、対象 Repository / ref selector / path / intent を target として持つ。
Ticket target は intent/selector であり、実行再現性のための immutable point ではない。Worker launch request が target selector を concrete RepositoryPoint に解決し、Runtime が実際にどの revision/snapshot を materialize したかを Artifact / evidence として記録する。
```text
Ticket
-> target selectors: Repository + ref selector + path + intent
-> resolved RepositoryPoint
-> working directory
-> WorkerRef / Artifact / Evidence
-> Review / Decision
-> Audit / Notification
```
Target 例:
```text
Ticket targets:
- repository: main-code
role: primary
ref: develop
paths: ["crates/pod/"]
intent: change
- repository: docs
role: related
ref: main
paths: ["docs/development/"]
intent: read
Worker launch materialization:
- repository: main-code
requested_ref: develop
resolved_point: git commit abc123
mount: /workspace/main-code
```
Ticket には次の概念が必要になる。
- Actor identity: human / agent / system / service account.
- Assignment / owner / reviewer / watcher.
- Typed thread events: comment, decision, plan, review, implementation report, state transition.
- Linked Objective / Artifact / WorkerRef / Repository / RepositoryPoint / working directory.
- Permission / visibility.
- Audit trail.
- Notification / mention.
- Board / queue / planning / review / done / archived views.
- Conflict handling and concurrent editing policy.
### 4. Memory / Skill catalog の本格再設計は後回しにする
Memory は Ticket / Artifact のコピーではない。再利用可能な文脈、方針、学習された制約を扱うが、Ticket や Artifact の authority を置き換えない。Skill catalog は procedural guidance の catalog であり、外部状態 authority を持たない。Knowledge record kind は削除方針なので、この Objective では separate Knowledge storage を新しい control plane entity として増やさない。
理由は、Memory と Skill catalog の正しい設計が Workspace control plane の record model、Actor / visibility / permission、Ticket、Artifact / evidence、RepositoryPoint、Runtime に渡す context の監査方法に依存するためである。これらが固まる前に Memory schema や Skill API だけを作ると、local `.yoi` 前提や現行 agent runtime 前提に引っ張られ、後で再設計が必要になる。
この Objective では、Memory / Skill catalog について以下の platform contract だけを維持する。
- Memory は Control plane が扱う record だが、Ticket / Artifact の authority を置き換えない。
- Skill catalog は Workspace backend が扱う prompt/resource catalog だが、Ticket / Worker / workdir / queue の authority を持たない。
- 将来、Memory と Skill catalog の canonical storage / API は Workspace control plane 側に置く。
- local `.yoi` memory と `.yoi/skills` は compatibility、offline/export/import、local projection、migration bridge として扱う。
- Personal Memory、Workspace Memory、Worker Summary、Skill catalog は分離が必要である。
- Generated Memory には provenance、visibility、approval、audit が必要である。
- Runtime / Worker に渡した Memory / Skill context は、将来 ContextPack などとして Artifact/evidence に記録できる必要がある。
本格的な Memory 再設計は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。それまでは低リスクな観察、問題例の収集、既存 local memory の互換維持に留める。
### 5. Control plane / Runtime を分離する
Control plane は正本と調整を持つ。Runtime は Worker 群と実行基盤を管理する。初期実装では local backend と runtime process が同じマシン上にあり、役割が近く見えるが、設計上は分ける。
初期形:
```text
Web UI / Control Plane
-> Runtime registry / local backend
-> Runtime process
-> Workers
-> Existing Yoi tools, working copy, build/test commands
```
この段階では、現在ローカル管理画面が行っている Ticket 選択、エージェント起動、レビュー起動、作業用 checkout 作成、検証実行、結果表示を、Web/control plane から local Runtime に対して実行できるようにする。
長期的には Runtime が作業環境の用意まで請け負う。Runtime は Worker launch request / config bundle / repository target / authority を受け取り、必要な checkout、worktree、container filesystem、sandbox mount、cache、secret boundary を準備して Worker を起動する。Runtime 起動時に特定 Workspace path を必須にする形は暫定であり、Workspace / Repository 情報は Runtime process 起動引数ではなく Worker launch request 側の入力に寄せる。
Sandbox と authority 分離が成立している前提では、1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。したがって Runtime identity は Git repository root や single workspace directory と同一視しない。Runtime は execution substrate、Workspace は作業管理 record の正本として扱う。
Worker の作業環境を用意する経路は次のようにまとめる。
```text
Ticket / user intent
-> RepositoryId + RepositorySelector + path scope + required authority
-> resolved RepositoryPoint
-> WorkerLaunchRequest / ConfigBundle / AuthorityBundle
-> Runtime WorkingDirectoryMaterializer
-> working directory allocation
-> Worker process
-> WorkerRef + Artifact/evidence
```
Control plane は Workspace authority、Ticket target、Repository registry、Actor permission、設定 bundle の正本を持つ。Control plane または Repository provider は RepositorySelector を RepositoryPoint に解決し、どの地点を対象にしたかを evidence に残す。Runtime は RepositoryPoint、materialization policy、sandbox policy、mount/cache/secret policy、Worker config を受け取り、Runtime-local な working directory を確保して Worker を起動する。
この境界では、Worker は host filesystem path や Repository credential を自分で発見しない。Worker は Runtime が materialize した working directory root、mount、環境変数、tool authority、config bundle だけを見る。Runtime は working directory の lifecycle、cleanup、cache reuse、namespace、quota、sandbox boundary、event collection を管理する。Control plane / Browser-facing API は raw host path、secret、socket、internal runtime path を authority-bearing internals として扱い、必要な evidence だけを Artifact として公開する。
v0 materializer は existing local root を明示的な working directory として返してよい。ただし型と呼び出し順序は、後で Git worktree、clone、sparse checkout、container filesystem、remote object snapshot、multi-repository mount に置き換えられる形にする。Runtime process 起動時の `--workspace` はこの v0 materializer の legacy bootstrap input であり、Runtime identity や long-term workspace binding ではない。
その後で、remote Runtime、self-hosted Runtime、hosted cloud runtime fleet、runtime pool、resource allocation、quota、billing、sandbox、network policy、secret distribution を追加する。
```text
Phase 1: Web control plane + local Runtime
Phase 2: Remote/self-hosted Runtime
Phase 3: Hosted cloud runtime fleet
Phase 4: Resource allocation / scheduling / quotas / billing / isolation
```
### 6. Web frontend を先に作る
Desktop app は対応コストが高いので、まず Web frontend を primary UI とする。
- Web: チームで使う主要 UI。
- CLI: automation、scripting、local operations。
- TUI/local panel: fallback、dogfooding surface。
- Future desktop: Web/control-plane model が安定した後に検討する optional client。
Web UI は Ticket、Objective、Memory、Skill catalog、Runtime、Worker、Artifact を扱う。UI の都合で正本を二重化しない。
### 7. 多重起動コストと runtime placement を見直す
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical Worker session と Runtime process/resource placement を分ける。Runtime は Git Repository root や Workspace path に固定されず、request ごとに必要な working directory を materialize できる実行基盤として扱う。
初期 Workspace DB では、Worker を canonical table として永続化しない。Runtime / Worker 一覧は backend-local runtime inspection や将来の Runtime protocol から逐次取得する live view とし、Ticket に関わった Worker は Ticket thread events と WorkerRef snapshot / TicketWorkerLink として記録する。
Worker の一元管理、データ永続化、アーカイブは将来的には必要になる。これは Runtime protocol、remote/self-hosted/hosted runtime lifecycle、worker identity、retention policy、audit requirements が固まった後に、dedicated Worker registry / archive model として追加する。v0 で Pod metadata の代替として Worker table を作らない。
検討対象:
- Worker identity と Runtime process/resource placement の分離。
- 1 Runtime が複数 Workspace / Repository の Worker を抱える場合の namespace、quota、cleanup、audit boundary。
- Runtime-side working directory materialization、sandbox、mount、checkout/worktree/container filesystem の責務。
- Provider client、tool registry、resource cache の共有可能性。
- Prompt/resource/profile/config bundle resolution cache。
- Model call multiplexing and scheduling。
- Tool execution sandbox reuse。
- Plugin instance / Service runtime との統合。
- Session/event stream と runtime lifecycle の分離。
- Runtime-local cache、checkout reuse、build cache、dependency cache。
## Initial phases / candidate tickets
1. **Vocabulary / architecture record**
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / working directory / Runtime / Worker / Control Plane / Ticket / Memory の用語と境界を固める。
2. **Team-space canonical data model**
- Ticket / Objective / Target / Artifact / Actor / Permission / Audit / Memory の entity/event model を設計する。
3. **Ticket evidence model**
- Ticket lifecycle、WorkerRef、Artifact、validation evidence、review evidence、Ticket thread の責務を明確化する。
4. **Memory storage migration boundary**
- Memory の本格再設計は後回しにし、まずは Workspace backend に移す時の platform contract、compatibility/cache/export 方針、将来の provenance / visibility / approval 要件だけを固定する。
5. **Control plane backend architecture**
- local `.yoi` backend と server-side canonical backend の境界、migration/export/import、compatibility mode を設計する。
6. **Web control plane MVP design**
- read-only Ticket / Objective / Memory / Runtime / Worker state UI/API の範囲を決める。
7. **Local Runtime protocol design**
- Web/control plane から local Runtime に安全な操作を送り、Runtime が Worker lifecycle と working directory materialization を担う protocol と authority boundary を設計する。
8. **Repository and working directory materialization model**
- Repository URI、Repository provider capability、RepositorySelector resolution、RepositoryPoint evidence、Git worktree / clone / sparse checkout / future source backend を Runtime-side materialization strategy として抽象化する。
9. **Remote/hosted runtime foundation**
- runtime registration, heartbeat, capability advertisement, job assignment, logs/events, secrets, sandbox/resource policy を設計する。
## Non-goals
- Git hosting service を作ること。
- `.yoi` filesystem をそのまま SaaS canonical store にすること。
- 最初から full hosted cloud execution を作ること。
- local execution / CLI / TUI / local panel を捨てること。
- Ticket を単なる issue tracker clone にすること。
- Memory を Ticket/Artifact audit log の代替にすること。
- Web UI のために core authority を二重化すること。
- hidden server state を LLM context に直接注入すること。
- multi-tenant auth/billing/secret/security を shortcut して実装すること。
## Success criteria / exit conditions
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / working directory / Runtime / Worker / Control Plane / Ticket / Memory の境界が文書化されている。
- Ticket が team coordination record として、target selector / Artifact / Actor / Permission / Audit と分離された model を持つ。
- `.yoi` local backend は compatibility/local backend として整理され、server-side canonical backend の設計を阻害しない。
- Web UI/API が Ticket / Objective / Runtime / Worker state を中心とした read-only view を提供できる設計または MVP を持つ。Memory は既存 record の表示または将来 placeholder に留め、本格再設計をこの段階の必須条件にしない。
- Control plane から local Runtime に対して、現在のローカル管理画面相当の安全な操作を実行できる design/protocol がある。
- Runtime は single Workspace / Git repository root 専用 process ではなく、sandbox/authority が成立すれば複数 Workspace / Repository の Worker を抱えられる execution substrate として設計されている。
- Git Repository root に依存しない Workspace model があり、Git Repository は Repository provider の一種として扱われている。
- Ticket と Objective は Workspace 配下に平たく存在し、Repository への所属ではなく RepositoryId / RepositorySelector / path scope / intent で対象を表現する。
- Git worktree 相当は working directory materialization strategy として扱われ、Artifact/evidence が concrete RepositoryPoint を記録する。
- Memory は Ticket / Artifact の authority を置き換えない record として platform contract だけを持つ。本格的な意味論・抽出・承認・検索・staleness 処理は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。
- Hosted Runtime / resource allocation / SaaS offering に進むための後続 Ticket が切れる状態になっている。
- 既存 local dogfooding runtime を壊さず、local use と remote-capable architecture が両立している。
## Decision context
- Yoi は hosted Git tool ではなく、team workspace control plane + Runtime execution environment として設計する。
- Team-space の長期 canonical authority は server-side control plane に置く。local `.yoi` は互換/local/offline/export/import surface だが、multi-user SaaS の正本ではない。
- 実行環境と管理システムは弱結合にする。まず管理システムを独立させ、local Runtime を実行環境として接続する。その後に remote/self-hosted/hosted runtime fleet へ進む。
- Runtime は Worker 群を束ねる実行基盤であり、将来的には作業環境の用意、sandbox、mount、checkout/worktree/container filesystem、cache、secret boundary を Worker launch request ごとに準備する。Runtime process は single Workspace / Git repository root 専用に固定しない。RepositorySelector は provider-specific な未解決 locator、RepositoryPoint は解決済み evidence として扱う。
- Web frontend を最初の primary team UI とする。Desktop app は web/control-plane model が安定した後に検討する。
- Git は重要な Repository provider / materialization backend として使うが、Workspace identity と authority を Git Repository root に固定しない。
- Ticket と Objective は Workspace 配下に平たく持つ。対象コードベースや地点指定は RepositoryId / RepositorySelector / path scope / intent として表現し、Worker launch materialization が concrete RepositoryPoint に解決する。
- Backend Repository は cwd inspection ではなく、Workspace config の明示 Repository registry から構築する。`--workspace` は Repository ではなく workspace config root / local descriptor root を指す。
- `./.yoi` は local descriptor / fs-store / compatibility surface であり、将来の Backend canonical store と Workspace registry は `~/.yoi` 側へ寄せる。
- Memory の本格再設計は後回しにする。先に Workspace / Ticket / Repository / Runtime/Worker live view / Control plane の基盤を固め、Memory の保存先を Workspace backend に移すタイミングで、意味論・抽出・承認・検索・staleness 処理をまとめて回収する。
- Worker の一元管理・データ永続化・アーカイブも後続設計に回す。初期 DB では Worker を Pod metadata の代替として永続化せず、live view と Ticket-linked WorkerRef 記録に留める。
-289
View File
@@ -1,289 +0,0 @@
---
title: "効果的な Memory システム設計・検証"
state: "active"
created_at: "2026-06-20T15:16:00Z"
updated_at: "2026-07-17T23:10:00Z"
linked_tickets: ["00001KSKBPHRG", "00001KT02TCCG", "00001KTGCAFXG", "00001KSKBPTHR", "00001KXMEZNYC", "00001KXMK7YMC", "00001KXNYXNM6", "00001KXRM6G0G", "00001KXS56AS5", "00001KXMK846H"]
---
## Goal
Yoi の Memory / Knowledge / Skills / generated context / resident context / retrieval / usage metrics を、実際の開発・設計・レビュー・オーケストレーションに効く sensemaking substrate として再設計・検証する。Memory は短期・変化前提の context、Knowledge は育てる long-term note、Skill は移植可能な workflow として分け、この Objective ではそれらと authority record / docs / typed tools の境界を再整理する。
この Objective でいう「効果的な Memory システム」は、単に多く保存する仕組みではなく、作業中の問いに対して relevant material を集め、根拠を検証可能にし、再表現・仮説形成・反証探索・意思決定・成果物への反映を低コストにする仕組みである。
暫定的な定義:
- foraging cost を下げる: Ticket / Objective / current question に対して、関連する memory / docs / tickets / session evidence / code references を探しやすい。
- evidence を失わない: Memory が authority そのものにならず、Ticket / docs / git history / session logs / user instruction への検証可能な入口になる。
- schema 化を支援する: raw summary ではなく、subsystem、invariant、risk、authority boundary、open question、rejected alternative、hypothesis など推論しやすい形へ再表現できる。
- hypothesis loop を支援する: 支持証拠だけでなく、代替仮説・棄却理由・反証 evidence・stale assumption を扱える。
- product に戻る: Memory に保存して終わりではなく、Ticket、review、docs、implementation、decision、report に影響を戻せる。
- stale / contradiction を扱う: 古い前提、矛盾、適用範囲外の memory を検出・降格・更新できる。
- usage を成果基準で測る: resident exposure や read count ではなく、判断・レビュー・実装・docs に効いたかを観測できる。
## Motivation / background
現在の Memory システムは「墓場化」している。保存された情報はあるが、後続の作業で自然に使われにくく、使われたとしても根拠・適用範囲・鮮度・反証可能性が弱い。結果として Memory は、作業場ではなく古い結論の倉庫になりやすい。
Pirolli & Card 2005 の sensemaking model では、分析作業は単なる保存ではなく、次の変換として捉えられる。
```text
external data sources
-> shoebox
-> evidence file
-> schema / representation
-> hypotheses
-> presentation / product
```
Yoi の現行 Memory は、この流れのうち「保存」と「一部の検索」には対応しているが、少なくとも以下が弱い。
- Ticket / task / question ごとの shoebox がない。
- shoebox から evidence snippets を切り出し、source / provenance / applicability / confidence と共に扱う evidence file がない。
- `summary`, `decision`, `request` は durable memory storage taxonomy であり、sensemaking 用 schema としては粗い。Knowledge は古い record kind をそのまま残すのではなく、育てる long-term note subsystem として再設計する。再利用可能な手順は Skill、保守された設計資料は Knowledge / docs / Ticket decisions に寄せる。
- decision は残るが、hypothesis space、alternative、rejected reason、disconfirming evidence が残りにくい。
- reviewer / orchestrator が confirmation bias を避けるための反証探索導線が弱い。関連する手順誘導は旧 Workflow ではなく Skill と role prompt / typed tools へ寄せる。
- resident exposure と explicit retrieval は観測できても、Memory が product に効いたかは測りにくい。
この Objective は、Memory 関連の設計・検証・検討・考察を一元化し、個別 Ticket がばらばらに storage、prompt、retrieval、metrics を改善して再び墓場を増やすことを防ぐための判断背景である。
## Strategy / design direction
Memory を「長期保存領域」ではなく、Yoi の multi-agent 開発における sensemaking loop の支援機構として設計する。
### 1. Pirolli & Card の stage に合わせて責務を分ける
- external data sources: Tickets、docs、git history、session logs、reports、code、user instructions。
- shoebox: 特定 Ticket / Objective / design question に対して関連しそうな材料を集めた task-bound working set。
- evidence file: shoebox から抜き出した根拠 snippet。source anchor、支持/反証、適用範囲、confidence、staleness を持つ。
- schema / representation: subsystem、invariant、risk、authority boundary、open question、hypothesis、alternative、contradiction など、推論しやすい再表現。
- hypotheses: 採用前の設計仮説、代替案、棄却条件、反証 evidence。
- product: Ticket、review、docs、implementation、decision、report、orchestration plan などの成果物。
### 2. 最初の重点は task-bound shoebox と evidence file
Memory 墓場化の最初の原因は、保存情報が現在の問いに集まらないことである。まずは Orchestrator / Intake / Reviewer が Ticket を扱う時に、関連 memory / docs / tickets / reports / prior decisions を shoebox として束ねる導線を作る。
この段階では大きな永続 schema 追加に飛びつかず、report / Ticket artifact / bounded generated context として検証してよい。
### 3. Session Overview を extract の足場にする
現在の extract は、tool call / tool result summary を含む flat slice から意味を復元しようとして断片化しやすい。改善方針は、main Worker が通常 Assistant Message として Progress message を残し、user messages + Assistant messages を Session Overview として先に読む形にする。
- Progress message は専用 Tool ではなく通常 Message とし、ユーザーへの進捗報告と extract 用 semantic summary を兼ねる。
- extract worker には専用の read-only evidence tools を渡し、Overview で重要そうに見えた箇所だけ session range / tool summaries / source anchors を探索させる。
- extract worker は Memory / Knowledge / Skill を直接更新しない。output は必ず staging を挟み、source / provenance を host 側で機械的に保持する。
- trigger は初期実装では現行通り Worker run cycle 完了後の threshold 判定にする。LLM call 単位や Run 中の Overview accumulation trigger は含めない。
### 4. Memory を authority にしない
Memory は Ticket、docs、git history、session logs、user instruction の代替ではない。Memory は authority record への evidence index / schema / reasoning aid として扱う。
したがって、改善案は次の性質を持つべきである。
- source / provenance を辿れる。
- stale / superseded / contradicted を扱える。
- Memory の断定をそのまま authority として使わない。
- Ticket body/thread/artifacts を読まずに Objective や Memory だけで実装判断できる状態を作らない。
### 5. 反証探索を first-class にする
より効果的な Memory は、過去方針を思い出すだけでなく、現在案を疑うために使える必要がある。
Reviewer / Orchestrator / Intake の導線では、次を探せるようにする。
- supporting evidence
- contradicting evidence
- stale decisions
- rejected alternatives
- unresolved questions
- authority boundary risks
- prior failures / reports
### 6. Metrics は exposure から product impact へ寄せる
Memory が prompt に入った、または query されたことは成功ではない。評価は次を区別する。
- resident exposure
- explicit retrieval
- cited in response
- cited in Ticket / review / report
- changed requirement
- changed implementation
- contradicted / invalidated
- led to docs or decision update
### 7. 後続 Ticket は concrete slice に分割する
この Objective は中期的な設計・検証の一元化 record であり、umbrella Ticket ではない。実装や調査は、単独で実装・レビュー・close できる concrete Ticket に分割する。
候補 slice:
- Memory sensemaking 分析 report を `docs/report/` に作る。
- Ticket routing 用 Memory shoebox artifact を試作する。
- evidence snippet schema / source resolver を設計する。
- hypothesis / rejected alternative / disconfirming evidence の表現を追加する。
- Reviewer Skill / review process に反証探索を入れる。
- Memory usage metrics を product impact oriented に拡張する。
- stale / contradiction / renewal の検出・表示を設計する。
- turn 中の Progress message を通常 Assistant Message として残す prompt/guidance を追加する (`00001KXMEZNYC`)。
- Session Overview + Evidence index を使う extract input を設計・実装する。
- extract worker 専用の read-only evidence search/read/source-anchor tools を設計する。
- extract output を staging に限定し、source range と output entry を結びつける schema を設計する。
## Success criteria / exit conditions
- Memory システムの目的が「保存」ではなく「sensemaking loop 支援」として project records / docs / prompts / Skills で一貫して説明されている。
- Pirolli & Card の `shoebox -> evidence file -> schema -> hypotheses -> product` に対応する Yoi 内の責務と非責務が整理されている。
- Ticket / Objective / docs / session logs / Memory / Skills の authority boundary が明確で、Memory が authority を僭称せず、Skill は手順資源として外部状態 authority を持たない。
- 少なくとも一つの実作業 routing / review / design analysis で、task-bound shoebox または evidence file が生成・利用され、作業品質にどう効いたかが確認されている。
- Memory records または関連 artifacts が source / provenance / applicability / staleness / supports-or-refutes のいずれかを扱えるようになっている。
- extract が User / Assistant messages 由来の Session Overview を primary input とし、tool logs を evidence として探索できる設計になっている。
- extract worker 専用の read-only evidence tools が設計され、main Worker の tool surface を増やさない方針になっている。
- extract output は direct Memory / Knowledge / Skill write ではなく staging を挟む方針になっている。
- Reviewer / Orchestrator が supporting evidence だけでなく、contradicting evidence / stale assumptions / rejected alternatives を探す導線を持っている。
- Memory usage metrics が resident exposure と product impact を区別している。
- 古い Memory が放置されるのではなく、stale / superseded / contradicted / needs-review として扱える方針がある。
- 後続の実装 Ticket が concrete slice として分割され、Objective が Ticket dependency や進捗 container として使われていない。
この Objective は、Memory が少なくとも一つの中規模設計・実装・レビュー作業で「関連情報を見つける」「根拠を確認する」「代替案/反証を検討する」「成果物へ反映する」流れを実証し、その設計方針が docs / Skills / metrics に反映された時点で `done` を検討できる。
## Decision context
- ユーザー指摘: 「Memoryシステムが完全に墓場化している」。これは保存量不足ではなく、保存情報が現在の問い・根拠・仮説・成果物に接続されない問題として扱う。
- ユーザー指示: Memory システムの設計・検証・検討・考察を Objective にまとめ、より効果的な Memory システムを作成する目標のもとで情報を一元化する。
- 「効果的」の定義は未確定だが、当面は Pirolli & Card の sensemaking process に沿って、foraging cost、evidence quality、schema usefulness、hypothesis/disconfirmation support、product impact、staleness handling を評価軸にする。
- Memory は durable project authority ではない。Ticket、docs、git history、session logs、明示 user instruction の代替として使わない。
- Objective context は判断背景であり、個別実装の authority は各 Ticket body/thread/artifacts と明示的な Ticket relations / OrchestrationPlan records にある。
- `history` に残らない context-only injection を改善案にしない。新しい context input は history に commit する原則を守る。
- Knowledge は古い unused record kind をそのまま残すのではなく、育てる long-term Markdown note subsystem として再設計する。再利用可能な手順・作法は Agent Skills (`.yoi/skills/<skill>/SKILL.md`) へ、durable policy/rationale は Knowledge / maintained docs / Ticket decisions へ、外部状態 authority は typed feature/tool surface へ分ける。
- Generated memory / Ticket / docs / report / Skill の境界を再定義する場合は、authority boundary と migration/staleness を明示する。
- 関連する既存 Ticket:
- `00001KSKBPHRG` — Prompt / Workflow 評価メトリクスと改善 Offer
- `00001KT02TCCG` — Memory prompt: conditional guidance and proactive lookup
- `00001KTGCAFXG` — Use .yoi/memory marker for repo-local memory root
- `00001KSKBPTHR` — ワークスペースのメモリーをLintするヘッドレスCLI
- `00001KXMEZNYC` — ターン中のProgress messageを残す指示を追加する
## Historical references / prior design sources
現在の Memory システムの初期設計時には、Codex Memories / Chronicle と HermesAgent を明示的な参考事例として調査していた。関連する調査・設計記録は、現在は主に以下に退避されている。
- `docs/.local/old-docs/ref/memory-systems.md`
- `docs/.local/old-docs/plan/memory.md`
- 初期設計 commit: `ca5a3d11` — `2026-04-21 メモリシステムの設計`
- 関連 commit:
- `0c1276b7` — `Memoryシステムの整理・Promptカタログチケット`
- `3d04f793` — `memoryを抽出する仕組みの実装`
- `f1b7af62` — `docs: memoryシステムの仕様変更と、動的Tool・VCSの話`
- `a2aecbf0` — `update: memoryシステムの"Phase"表記を撤廃`
### Codex Memories / Chronicle から得た設計要素
旧設計では、Codex Memories / Chronicle を `extract -> staging -> consolidation -> durable Markdown memory` の非同期パイプラインとして捉えていた。
主な参照点:
- extract と consolidation の 2 段構成。
- extract は JSON schema / structured output で分類ブレを抑える。
- consolidation は reasoning model / agentic rewrite によって、既存 memory と staging entries を統合・整理する。
- staging と durable memory を分ける。
- `MEMORY.md` は retrieval-oriented handbook として扱う。
- `memory_summary.md` は prompt-loaded high-signal context として扱う。
- `raw_memories.md` は routing layer / task inventory 的な中間層として扱う。
- workspace diff や usage 情報を使い、stale / deleted evidence / noisy entries を整理する。
- consolidation は append だけでなく、rewrite / merge / split / trim / drop / cleanup を担う。
Yoi 初期設計では、これを参考に以下を意図していた。
- activity token 閾値で extract を発火する。
- compact より前に session log range を抽出する。
- extract は `decisions`, `discussions`, `attempts`, `requests` などの候補を staging に保存する。
- 抽出時点では durable policy / Skill / docs へ早期分類せず、純粋な「起きたこと」に寄せる。
- consolidation が summary / decisions / requests と、必要に応じた docs / Skill / Ticket decision 更新候補を整理する。
- consolidation 入力に linter warnings / usage metrics / stale cleanup 候補を含める。
- stale / superseded / unused / noisy な情報を整理する。
この Objective での再解釈:
- Codex の `raw_memories.md` は、Pirolli & Card の sensemaking model では `shoebox` または `evidence file` に近い。
- Yoi は extract / consolidation という pipeline だけを継承しても不十分であり、task-bound shoebox / evidence file / hypothesis loop / product feedback がなければ Memory は再び墓場化する。
- 特に、staging を consolidation の一時入力としてだけ扱うと、後続 Ticket / Objective / review が使う探索入口にならない。
- Yoi では `raw memories` 相当の中間層を、現在の問いに紐づく working set / evidence index として再設計する必要がある。
### HermesAgent から得た設計要素
旧設計では、Nous Research HermesAgent を 3 層の memory system として整理していた。
- Persistent Memory:
- `MEMORY.md` / `USER.md`
- Markdown + SQLite / FTS5 session search
- 起動時 system prompt snapshot
- bounded character limits
- Skill Library:
- procedural memory
- `~/.hermes/skills/<name>/SKILL.md`
- `skill_manage` tool による agentic CRUD
- User Model / Honcho:
- dialectic user modeling
- 外部 service 連携
HermesAgent で特に重要だった点:
- memory / skill review は一定 turn / tool iteration ごとに background agent として起動する。
- 保存すべきものがなければ `Nothing to save.` で NOP として終了する。
- Yoi extract の「空配列許容」はこの設計からも影響を受けている。
- memory は session start 時の frozen snapshot として system prompt に入り、mid-session write で prompt cache を壊さない。
- persistent memory は bounded で、limit 超過時は deterministic eviction ではなく、agent に replace / remove を促す。
- procedural memory / skills は一般 memory から分離されている。
- SQLite FTS5 + LLM summarization による cross-session recall がある。
この Objective での再解釈:
- HermesAgent の `MEMORY.md` / `USER.md` / `skills` の分離は、Yoi の Memory / Knowledge / Skills / prompt resources / docs / Ticket decision / generated memory の責務再整理に使える。
- Yoi は foreground isolation 自体をすでに持つため、取り入れるべきなのは isolation そのものではなく、Overview を足場にした maintenance / extraction の質改善である。
- main Worker が通常 Assistant Message として残す Progress message は、人間向け進捗報告と machine-readable session overview を兼ねられる。
- extract worker は専用 read-only evidence tools で必要箇所だけ探索し、direct write ではなく staging に出力する。
- reusable procedure, reviewer focus, orchestration tactic, project preference, user preference, design invariant を同じ Memory bucket に入れると墓場化しやすい。
- `Nothing to save.` / empty extraction allowed は重要だが、保存抑制だけでは効果的な Memory にはならない。保存されたものが task-bound shoebox / evidence / schema / hypothesis / product に接続される必要がある。
- frozen snapshot / prompt cache 配慮は Yoi の history/context 加工原則と整合するが、それだけでは retrieval / resurfacing / disconfirmation は解決しない。
### Lessons for the next design iteration
Codex と HermesAgent の調査から、Yoi が継承すべきものと、継承するだけでは足りないものを分ける。
継承すべきもの:
- structured extract と agentic consolidation の分離。
- staging / raw memories / durable memory の分離。
- 保存対象がなければ NOP にする発火設計。
- prompt-loaded summary と durable retrieval-oriented memory の分離。
- stale / noisy / unused entries の cleanup。
- procedural memory と declarative memory の分離。
- session search / usage metrics / linter feedback を consolidation に入れる設計。
- user / Assistant messages から作る Session Overview を semantic guide にし、tool logs を evidence として探索する設計。
足りないもの:
- Pirolli & Card の sensemaking stage における各 record の役割定義。
- Ticket / Objective / current question に紐づく task-bound shoebox。
- authority record へ戻れる evidence file / provenance / source anchor。
- hypothesis, alternative hypothesis, rejected reason, disconfirming evidence の first-class 表現。
- reviewer / orchestrator が confirmation bias を避けるための反証探索導線。
- resident exposure や read count ではなく product impact を測る metrics。
- stale / contradiction / renewal を作業中に resurfacing する導線。
したがって、次の Memory 設計は Codex / HermesAgent の単純なコピーではなく、以下を満たす必要がある。
```text
external data / sessions / tickets / docs / code
-> task-bound shoebox
-> evidence file with provenance
-> schema / representation
-> hypotheses and disconfirmation
-> Ticket / review / docs / implementation / decision product
-> product impact and stale-feedback metrics
```
この Objective では、以後の Memory 関連 Ticket / report / implementation をこの historical reference と sensemaking model の両方に照らして判断する。
@@ -1,916 +0,0 @@
---
created_at: "2026-07-15T21:33:00Z"
updated_at: "2026-07-16T18:20:00Z"
objective: "00001KVJSMQXZ"
status: "architecture-draft"
notes: "Memory / Knowledge / Skills を別々の workspace resource として再設計するための draft architecture。この文書は Objective resource であり、実装 authority ではない。"
---
# Memory / Knowledge / Skills architecture overview
## 1. Position
Yoi は **Memory**、**Knowledge**、**Skills** を 1 つの汎用 record store に押し込めず、別々の resource class として扱う。
最初に固めるべきなのは、成果物が人間に読める形で成長できる workspace resource model である。Pirolli & Card の sensemaking process は有用な背景知識だが、storage taxonomy を shoebox / evidence / hypothesis として先に固定しない。sensemaking は Memory / Knowledge / Skills の上に乗る usage pattern として扱う。
Target split:
- **Memory**: 短期 fact、嗜好、現在の focus、進行中の context。変化する前提で書く。
- **Knowledge**: 長期的に育てる note。人間と agent が改訂し、相互リンクで mesh を形成し、durable project understanding として読めるもの。
- **Skill**: Agent Skills format に従う、移植可能で確立された手順・workflow。
この文書の中核は次の 2 つである。
1. resource class の境界を明確にする。
2. session から extract / staging / consolidation を経て resource に至る pipeline の責務を明確にする。
## 2. Design goals
- 人間が読め、成長できる artifact を作る。
- 一時的な model summary だけでは足りない。
- 有用な成果は Knowledge note、Skill、Ticket decision、doc、report に育てられるべき。
- 揮発的なものと durable なものを分ける。
- 短期 context が長期 note を汚染しないようにする。
- 長期 note が session extraction のたびに上書きされないようにする。
- 手順と note を分ける。
- 繰り返し使える作業方法は Knowledge note ではなく Skill にする。
- authority boundary を明示する。
- Ticket は work authority を定義する。
- docs と Objective resources は maintained design context を持つ。
- Knowledge notes は育てる project understanding を持つ。
- Skills は execution を guide する。
- Memory は変化する working context と preferences を追跡する。
- typed feature/tool surfaces が external state changes を所有する。
- 可能な限り Workspace backend を resource API の共有 authority にする。
- `WorkspaceClient::Http` が使えるときに、Worker ごとに local view が分岐してはいけない。
## 3. Resource model
### 3.1 Memory
Memory は、agent が作業を継続する助けになる volatile / short-to-medium-term な情報を扱う。長期的な project truth のふりはしない。
Memory record に入るもの:
- 現在の focus。
- user preferences。
- working assumptions。
- authority が別にある recent decisions の要約や pointer。
- 進行中の constraints。
- あとで Ticket / doc / session を再確認するための reminder。
- session から得た observations。
- 変化することが前提の personal / workspace context。
Memory は provisional に書く:
- いつ / なぜ learned したかを書く。
- どの前提で learned したかを必要な範囲で書く。
- staleness / supersession を許す。
- authoritative records を verbatim にコピーしない。
- 可能なら Tickets / docs / Knowledge notes への pointer を優先する。
Memory は resident context と lightweight lookup には有用だが、permanent note system として最適化しない。
#### 3.1.1 Memory storage profile: bounded H2 Markdown file
Memory の初期 storage profile は、bounded な single Markdown file にする。
Memory は Knowledge のような note bundle ではない。1〜3 行程度の short items を H2 section ごとに並べる resident context surface として扱う。
初期 filesystem shape:
```text
.yoi/memory/
memory.md
_staging/
_resolutions.jsonl
```
`memory.md` は H2 section を基本単位にする。
```md
# Workspace Memory
## Current focus
- Memory extract redesign is focused on Overview-first extraction and staging resolution.
Source: Objective 00001KVJSMQXZ. Stale when related tickets close.
## Preferences
- User prefers implementation Tickets, not design-only Tickets.
Source: 2026-07-16 session.
## Working assumptions
- Knowledge should be OKF-compatible, while volatile Memory and staging should not be OKF.
Source: architecture objective.
## Reminders
- Re-check extract/consolidation prompts after staging resolution is implemented.
```
Recommended H2 sections:
- `## Current focus`
- `## Preferences`
- `## Working assumptions`
- `## Constraints`
- `## Reminders`
- `## Stale or superseded`
Each item should stay short. When useful, include `Source` and `Stale when` inline. Long explanations, evidence-heavy analysis, durable rationale, citations, and cross-linked concepts should be routed to Knowledge rather than expanded inside Memory.
The single-file layout is an initial storage profile, not an API contract. Workers, Web, Runtime, and CLI should use the Workspace Memory API view rather than depending on the exact file layout, so storage can later split or evolve without changing the model-visible contract.
#### Memory examples
```text
User preference: prefers direct commits only when explicitly requested.
Source: repeated user corrections in sessions around git operations.
Staleness: revisit if user changes repo workflow.
```
```text
Current focus: web Workspace console and Workspace-backed Ticket/Skill authority.
Source: recent Tickets and Objective updates.
Expected to change after current milestone.
```
### 3.2 Knowledge
Knowledge は long-term note system。人間と agent が育て、改訂し、link し、split / merge しながら読むもの。抽出 snippet の山ではなく、project understanding の mesh を形成する。
Target Knowledge は、古い未使用の Knowledge feature をそのまま残すものではない。legacy implementation は先に削除してよい。置き換えは proper workspace note subsystem として設計する。
Knowledge notes の要件:
- Markdown-first で人間が読める。
- OKF-compatible な concept document として扱える。
- stable path / slug を持ち、OKF concept ID として参照できる。
- Yoi 内部で move / rename に強い identity が必要な場合は、extension frontmatter として `yoi_id` を持てる。
- bidirectional links / backlinks を support する。
- Obsidian-style wiki links (`[[slug]]`, `[[slug|label]]`) を support し、Knowledge mesh の authoring shorthand として使える。
- 必要なら tags や typed relations を support する。
- 重要な claim には provenance / citations を残す。
- Tickets、Objectives、docs、commits、reports、Skills、他 Knowledge notes に link できる。
- review / staleness / supersession を support する。
- 自動生成だけに頼らず、意図的に maintain される。
Knowledge は、長期 architecture note、conceptual model、subsystem explanation、decision context、recurring constraints、domain understanding を育てる場所である。
#### 3.2.1 Knowledge format profile: OKF-compatible bundle
Yoi Knowledge は、可能な限り Open Knowledge Format (OKF) compatible な bundle として設計する。
OKF から採用する baseline:
- Knowledge bundle は Markdown file tree とする。
- non-reserved `.md` file は concept document とする。
- concept document は YAML frontmatter + Markdown body とする。
- path without `.md` を OKF concept ID として扱う。
- `type` は required field とする。
- `title`, `description`, `resource`, `tags`, `timestamp` は recommended field とする。
- normal Markdown links を OKF-compatible graph edges として扱う。
- Obsidian-style wiki links (`[[slug]]`, `[[slug|label]]`) も graph edges として扱い、Markdown links へ解決・export できるようにする。
- `index.md` は progressive disclosure のための directory listing として使える。
- `log.md` は agent-readable update history として使える。
- `# Citations` section は external source / authority reference を示す convention として使う。
- consumers は unknown frontmatter fields、unknown `type`、broken links を tolerant に扱う。
Yoi は OKF に extension frontmatter を足してよい。候補:
```yaml
yoi_id: 00001...
status: draft | active | stale | superseded
source_refs: []
authority_refs: []
objective_refs: []
ticket_refs: []
skill_refs: []
reviewed_at: 2026-07-16T00:00:00Z
staleness: "Revisit when ..."
supersedes: []
superseded_by: null
```
Filesystem shape の例:
```text
.yoi/knowledge/
index.md
log.md
architecture/
index.md
memory-architecture.md
workspace-authority.md
references/
pirolli-card-2005-sensemaking.md
```
OKF compatibility は Knowledge の exchange / storage profile であり、Memory / staging / Skill の format ではない。
- Memory は短期・変化前提の resident context store なので OKF にしない。
- staging は extract / consolidation の審査キューなので OKF にしない。
- Skill は Agent Skills format を維持する。OKF `type: Playbook` に吸収しない。
#### Knowledge examples
- `workspace-authority-model`
- Workspace backend が Tickets / Skills / Runtime views の authority である理由を説明する。
- Ticket backend API design、Skill support ticket、Workspace control plane Objective に link する。
- `memory-knowledge-skill-boundary`
- Memory / Knowledge / Skills の boundary を定義する。
- この architecture resource と将来の implementation Tickets に link する。
- `ticket-lifecycle-authority`
- Ticket state authority と transition graph の rationale を説明する。
- 関連 decisions と code locations に link する。
### 3.3 Skill
Skill は、ある種類の task に対して確立された、portable な workflow / procedure。Agent Skills format に従う。
```text
.yoi/skills/<skill-name>/
SKILL.md
scripts/
references/
assets/
```
Skill は state machine ではなく、external authority も所有しない。agent が available tools を使って task をどう実行するかを示す prompt/resource guidance である。
Skill に含めるもの:
- いつその Skill を使うか。
- step-by-step procedure。
- expected inputs。
- expected outputs / report shape。
- examples。
- edge cases。
- optional references / scripts / assets。
Skill は、project-specific assumptions が少なく、別 workspace に移しても使えるとき portable と言える。
#### Skill examples
- `coder-review-cycle`
- Coder が実装、検証、review request、feedback 対応、dossier 作成をどう行うか。
- `ticket-intake`
- 曖昧な user request を accepted Ticket requirements に変換する方法。
- `architecture-review`
- design proposals、alternatives、authority boundaries を評価する方法。
## 4. Resource boundaries and authority
### 4.1 Memory vs Knowledge
Memory は provisional / change-oriented。Knowledge は maintained / growth-oriented。
Memory を使うべきとき:
- 情報が短命。
- preference や working assumption である。
- resident context として有用。
- 長期的な置き場所がまだ明確でない。
Knowledge を使うべきとき:
- 時間をかけて読み直し、改訂するべき情報。
- durable project concept を説明する情報。
- 複数の future tasks から link されるべき情報。
- backlinks / mesh structure が有用な note。
- 人間が project understanding として browse できるべきもの。
Promotion path:
```text
Memory observation -> candidate note/update -> Knowledge note / docs / Ticket decision
```
Promotion は明示的に行う。すべての Memory item が Knowledge になるわけではない。
### 4.2 Knowledge vs Docs
Docs は public / project-facing な maintained exposition。Knowledge は internal で、link され、発展する understanding。
Knowledge note は後で doc になり得るが、threshold は違う:
- Knowledge は uncertainty、partial models、evidence links を含められる。
- Docs は settled explanations または user/developer guidance を提示するべき。
### 4.3 Knowledge vs Ticket decisions
Ticket decisions は work item history と state の authority。Knowledge notes は複数 Ticket をまたいだ synthesis。
ある decision が Ticket の requirement、state、acceptance criteria を変えるなら、それは Ticket に記録する。Knowledge はそこに link し、より広い pattern を説明できる。
### 4.4 Skill vs Knowledge
Knowledge は「何が true か」「project をどう理解するか」を説明する。Skill は「recurring task をどう実行するか」を説明する。
Skill を使うべきとき:
- review process。
- implementation process。
- release checklist。
- architecture evaluation method。
Knowledge を使うべきとき:
- Workspace authority model。
- Memory architecture。
- Ticket lifecycle rationale。
### 4.5 Skill vs Feature/Plugin
Skill は prompt/resource guidance。Feature/Plugin は executable authority と tool surface。
Skill は「何をどう進めるか」を書けるが、Ticket、Workspace、Memory、外部状態を変更する権限そのものは持たない。その権限は Feature/Plugin や typed tool/API が持つ。
例: Skill は「review 前に Ticket shoebox を作る」と指示できる。実際に作成する authority は Workspace / Memory feature の typed tool/API が提供する。
## 5. Workspace API authority
Target architecture は Workspace-backed にする。
### 5.1 Memory API
Workspace backend が最終的に提供するもの:
- Memory list / search / read / write / edit / delete。
- resident memory summary の生成または取得。
- staleness / supersession markers。
- sessions や artifacts からの Memory candidate proposal。
- provenance と audit events。
- preference / current-focus surfaces。
移行期間中は local `.yoi/memory` を compatibility / offline storage として残してよい。初期 storage profile は H2 section ベースの bounded `.yoi/memory/memory.md` だが、これは API contract ではない。
### 5.2 Knowledge API
Workspace backend は、raw filesystem layout を唯一の interface にするのではなく、proper note API を提供する。
- Knowledge catalog / list / search。
- note read / write / edit / delete。
- OKF-compatible frontmatter validation / normalization。
- link / backlink extraction from Markdown links and wiki links (`[[slug]]`)。
- relation / tag metadata。
- staleness / supersession markers。
- note diagnostics / lint。
- source / provenance refs and citations。
- Markdown files からの import / export。
- OKF bundle import / export profile。
Filesystem representation は `.yoi/knowledge/` 配下の OKF-compatible bundle を第一候補にする。ただし Worker / Runtime / Web / CLI は、利用可能なら raw filesystem ではなく Workspace API view に収束する。
### 5.3 Skill API
Skill support は separate Skill Ticket の方針に従う。
- Workspace backend が discovery / lint / catalog / activation を所有する。
- `.yoi/skills/<skill>/SKILL.md` は workspace storage convention。
- `WorkspaceClient::Http` が使えるとき、Workers は Workspace API から Skill metadata / body を使う。
- Skill references / assets は backend-resolved authority または Skill resource APIs 経由で access する。
## 6. Session-to-resource pipeline
この章が extract 改善の中心である。`extract -> staging -> consolidate` の分割は維持する。3 つは同じ memory maintenance pipeline の一部だが、判断の種類が違う。
```text
Session history
-> Overview + Evidence index
-> extract
-> staging
-> consolidation
-> Memory / Knowledge candidate / Skill candidate / Ticket-doc candidate / discard
```
役割の要約:
- **extract**: session から候補を拾う。recall 寄り。Memory 化はしない。
- **staging**: provenance 付き候補キュー。まだ Memory ではない。
- **consolidation**: staging を審査・剪定・統合する。precision 寄り。Memory 肥大化と陳腐化を防ぐ。
### 6.1 Overview: transcript as semantic backbone
Overview は、committed history にある user messages と normal Assistant text outputs から作る。Assistant text outputs には、最終応答だけでなく、長い作業中の Progress message も含める。
Progress message は専用 Tool ではなく、ordinary user-visible prose response として残す。Tool surface を増やさず、ユーザーへの進捗報告と extract 用 semantic summary を兼ねるためである。
Overview に含めるもの:
- user requests / corrections / approvals。
- Assistant progress messages。
- Assistant final responses。
- parent delegation や Ticket context のように、history に commit された task context。
- tool evidence への bounded index。
Overview に含めないもの:
- raw reasoning / chain-of-thought。
- raw tool-result content 全文。
- secret-like data。
- history に commit されていない hidden context injection。
Overview の意図は、extract worker に「何のための探索だったか」「どの判断が節目だったか」「どこが未解決か」を伝えることである。Overview は authority ではない。durable output には evidence と source anchors が必要である。
### 6.2 Extract: candidate generation
Extract は candidate generation であり、Memory 化ではない。runtime 側の extract worker が Overview と session evidence を読んで、後で記憶化を検討すべき **flat candidate records** を staging に出す。
Extract の責務:
- Overview を読んで、記憶化を検討すべき candidate を見つける。
- 必要な箇所だけ evidence tools で確認する。
- candidate ごとに bounded evidence snippets / source anchors を選ぶ。
- candidate ごとに `stage_candidate` を呼び、1 candidate = 1 staging record として保存する。
- 最後に `finish_extraction` を呼び、staged count または NOP reason を残す。
Extract がしてはいけないこと:
- Memory / Knowledge / Skill / Ticket / docs を直接変更しない。
- batch payload として複数 candidate を 1 staging record にまとめない。
- tool result 全文や reasoning を無制限に取り込まない。
- local slice だけから根拠を推測しない。
- tool call chronology、generic progress、current focus update を抽出しない。
- 「進捗報告があった」こと自体を Memory 化しない。
Staging candidate は、Consolidation がそれ単体で discard / merge / promote / defer を判断できる最小単位にする。
旧 `decisions` / `discussions` / `attempts` / `requests` batch schema は維持しなくてよい。互換性よりも、flat candidate records と clear responsibility を優先する。
#### 6.2.1 Extract candidate taxonomy
Extract が抽出する対象は activity log ではない。抽出対象は次の candidate kinds に絞る。
| kind | extract で見る観点 | consolidation での扱い |
| --- | --- | --- |
| `preference` | ユーザーまたは workspace の継続的な好み・作法。単発指示ではなく、今後の agent behavior に効くもの。 | Memory に短く merge / replace する候補。既存 preference と重複するなら統合。Ticket/docs の要件そのものなら mirror せず authority link へ。曖昧なら discard / defer。 |
| `working_assumption` | 現時点で仮に置いている設計・実装前提。future work に影響し、変更条件や反証条件があり得るもの。 | Memory に入れる場合は short-lived assumption として stale condition 必須。長期設計なら Knowledge candidate。すでに authority record に反映済みなら Memory には pointer だけ、または discard。 |
| `constraint` | 今後守るべき境界・禁止・invariant。実装や review でチェック可能なもの。 | active implementation に効くなら Memory。durable policy なら Ticket decision / Knowledge / docs candidate。破られた既存 Memory があれば mark_stale / replace。 |
| `decision` | alternatives / chosen / rationale がある判断。単なる事実確認や作業進行ではないもの。 | まず authority routing を判断する。Ticket 要件・state・acceptance に関係するなら Ticket decision/comment candidate。長期設計なら Knowledge/Objective candidate。短期実装判断だけ Memory に短く置く。会話中の一時結論なら discard。 |
| `open_question` | 未解決で後続作業に影響する問い。next action が書けるもの。単なる会話中の疑問ではない。 | Memory reminder にするか、Ticket follow-up / planning item に送る。解決済みなら discard。長く残る概念的問いなら Knowledge candidate。TTL / defer reason を持たせる。 |
| `lesson` | 検証・失敗・試行から得た再利用価値のある学び。同じ失敗を避ける、作業方法を改善する、Skill 化できる可能性があるもの。 | Memory に短く残すか、recurring / portable なら Skill candidate。単なる tool execution result は discard。validation evidence として Ticket/report に残すべきものは authority link。 |
抽出しないもの:
- `current_focus` update。これは resident summary surface であり、extract candidate kind ではない。
- tool call chronology。
- file read/write history。
- generic progress updates。
- one-off chit-chat。
- resolved local confusion。
- assistant self-corrections without durable consequence。
- authoritative Ticket/docs/git facts copied verbatim。
- validation results unless they imply a reusable lesson, active blocker, or authority evidence。
- implementation details that belong only in commit diff。
### 6.3 Extract worker tools
extract worker には `session-explore` feature の constrained tools だけを渡す。これは main Worker の tool 数を増やすものではない。
Tool surface:
- `search_evidence`: session snapshot / tool summaries / message index から query で候補 range を探す。
- `read_evidence`: bounded な session entry range、tool call/result summary、host が許す bounded excerpt を読む。
- `stage_candidate`: 1 candidate を staging record として保存する。
- `finish_extraction`: extract run を終了し、staged count または NOP reason を記録する。
`stage_candidate` は direct Memory write ではない。これは staging write だけを行う output tool である。
`stage_candidate` input の概念形:
```json
{
"kind": "decision",
"claim": "Overview is runtime-only extract projection, not staging data.",
"why_useful": "Clarifies extract/consolidate responsibility boundary.",
"staleness": "Revisit if Workspace can directly explore runtime sessions.",
"evidence_ids": ["E001", "E002"]
}
```
Host wrapper は `evidence_ids` を runtime reference environment から解決し、bounded evidence snippets と source anchors を staging record に機械的に付与する。LLM に自由な source anchor を書かせない。
`finish_extraction` の概念形:
```json
{
"staged_count": 0,
"reason": "Only local progress and tool chronology; no durable candidates."
}
```
候補が無い場合は `stage_candidate` を呼ばず、`finish_extraction` で NOP を明示する。tool call なし終了も host 側では NOP fallback として扱ってよいが、基本は `finish_extraction` を要求する。
制約:
- read-only evidence access。file write、Ticket mutation、Memory/Knowledge/Skill direct write は持たせない。
- bounded output。large tool result は summary / excerpt / pointer に留める。
- provenance first。extract worker が根拠を推測せず、読んだ evidence ids を `stage_candidate` に渡す。
### 6.4 Staging: flat provenance-backed candidate records
Staging は Memory ではない。Staging は、extract が切り出した candidate を provenance 付きで保管し、consolidation が後で審査できるようにする queue である。
Staging は flat records にする。
```text
1 extract run = 0..N staging records
1 staging record = 1 candidate = 1 consolidation decision unit
```
1 record の概念形:
```json
{
"schema_version": 2,
"id": "stg_...",
"extract_run_id": "er_...",
"source": {
"segment_id": "segment-1",
"range": [120, 180]
},
"kind": "constraint",
"claim": "Extract worker must not direct-write Memory/Knowledge/Skill; it only writes staging.",
"why_useful": "Preserves runtime/embedded responsibility boundary.",
"staleness": "Revisit if extract and consolidation move into the same Workspace execution context.",
"evidence": [
{
"id": "E001",
"kind": "message",
"entry_range": [132, 133],
"excerpt": "extract worker は Memory / Knowledge / Skill を直接更新しない",
"summary": "User and architecture discussion fixed staging-only extract boundary."
}
],
"source_refs": [
{
"evidence_id": "E001",
"evidence_kind": "message",
"entry_range": [132, 133]
}
]
}
```
Staging の責務:
- candidate kind / claim / hints を保持する。
- extract が選んだ bounded evidence snippets を保持する。
- source anchors を保持する。
- extract と consolidation を decouple する。
- duplicate / defer / discard / consumed の追跡対象になる。
- crash / cancel / long-running work の途中でも、後から審査できる候補を残す。
Staging がしてはいけないこと:
- Memory として resident context に直接入らない。
- Knowledge note の代替にならない。
- raw session log や Overview 全体の保存場所にならない。
- extract run 単位の batch file として複数 candidate を抱え込まない。
- staging entry をすべて Memory 化する前提にしない。
Pirolli & Card 的には、staging は shoebox / evidence file に近い。ただし Yoi の storage taxonomy そのものを shoebox にするのではなく、審査前の evidence-backed candidate queue として扱う。
### 6.5 Staging resolution: the important filter
Staging から Memory 化する段階が、Memory 肥大化と陳腐化を防ぐ中核 filter である。
Consolidation は staging entry ごとに resolution / disposition を決めるべきである。
Disposition action 候補:
- `discard`: 保存価値なし。discard reason を残す。
- `merge_memory`: 既存 Memory に統合する。
- `replace_memory`: 古い Memory を新しい内容で置換する。
- `mark_memory_stale`: 既存 Memory が古くなったことを記録する。
- `delete_memory`: 邪魔または誤った Memory を削除する。
- `defer`: 価値判断できないため短期保留する。TTL / defer reason を持つ。
- `promote_to_knowledge_candidate`: long-term note に育てる候補へ送る。
- `promote_to_skill_candidate`: recurring / portable procedure の候補へ送る。
- `link_to_authority`: Ticket / doc / commit / Objective への pointer だけ残す。
- `create_ticket_or_doc_candidate`: authority / public guidance にすべき候補として送る。
Resolution に残すべき情報:
- staging entry id。
- action。
- reason。
- source anchors。
- target record / candidate destination。
- consolidation run id / consumed_by。
- reviewed_at。
- discard / defer / stale reason。
Consumed staging を削除する場合も、resolution は残す。これにより「何を Memory にしなかったか」が改善材料として残る。
### 6.6 Consolidation: memoryization, routing, and gardening
Consolidation は precision-oriented pass である。staging を読んで、Memory にするか、別 resource candidate に送るか、捨てるかを決める。
Consolidation の責務:
- staging entry を candidate kind ごとの扱いに沿って評価する。
- Memory 化するなら usefulness / staleness / source を要求する。
- 既存 Memory と merge / replace / mark stale / delete する。
- decision / constraint / lesson などを authority / Knowledge / Skill / Ticket / docs candidate に routing する。
- discard reason / defer reason を残す。
- linter feedback / usage evidence / tidy hints を使う。
- Memory bloat と stale records を防ぐ。
Consolidation がしてはいけないこと:
- staging entry を無条件に append しない。
- Ticket / docs / git / session log の mirror を Memory に作らない。
- Knowledge / Skill / docs を background review だけで無制限に rewrite しない。
- source / provenance のない claim を durable Memory にしない。
Memory 化に必要な minimum fields / prose:
```text
content: 何を覚えるか
why_useful: 今後なぜ役立つか
source: staging / evidence anchor
staleness: 何が起きたら古くなるか
destination_reason: なぜ Knowledge / Skill / Ticket / docs ではなく Memory なのか
```
Consolidation は append worker ではなく garden worker である。新規作成より、既存 record の統合・置換・陳腐化・削除を優先する。
### 6.7 Trigger policy
発火単位は LLM call 単位にしない。LLM call 単位では文脈が薄く、断片的な extraction になりやすい。
初期方針:
- 現行通り、Worker run cycle が完了してから threshold を判定し、超えていれば extract を発火する。
- extract 開始時点で immutable snapshot / Overview projection / Evidence index を作る。
- LLM call ごとには発火しない。
- Run 中の Overview accumulation trigger / mid-run extract は初期実装に含めない。
- 将来 long-running 中に mid-run 発火を入れる場合も、direct update ではなく staging/checkpoint extraction に限定する。
この trigger は、まず既存の post-run memory job model を保ち、extract の入力品質と staging record 粒度の改善に集中する。
## 7. Sensemaking interpretation
Sensemaking は storage taxonomy ではなく、pipeline の見方として使う。
Pirolli & Card の flow を Yoi に対応させるとこうなる:
```text
External data sources
= session log, tool results, Tickets, docs, code, web refs
Shoebox
= Overview + Evidence index + selected candidate ranges
Evidence file
= source anchors 付き staging entries
Schemas / hypotheses
= Memory 化すべきか、Knowledge/Skill に送るべきか、stale かの判断
Product
= Memory update, Knowledge candidate, Skill candidate,
Ticket/doc update, review evidence, discard resolution
```
重要なのは、Memory 化を evidence から product へ進める審査の一形態として扱うこと。Memory record は「将来の作業に効く」という hypothesis なので、why_useful、staleness、source が必要である。
初期 sensemaking support は lightweight でよい:
- task-bound collected references as Ticket/Objective artifacts。
- provenance 付き evidence summaries。
- review Skills における explicit contradictory evidence sections。
- recurring patterns を synthesize する Knowledge notes。
- staging resolution に discard / defer / promote reason を残す。
## 8. Human-readable growth paths
中核要件は、有用な material が人間に読める形へ成長できること。
Typical paths:
```text
Session observation
-> staging candidate
-> Memory record
-> Knowledge note candidate
-> Knowledge note with links/backlinks
-> maintained doc or Ticket decision if it becomes authority
```
```text
Repeated successful procedure
-> staging candidate / Memory observation / Ticket comment
-> Skill candidate
-> `.yoi/skills/<skill>/SKILL.md`
-> builtin or shared Skill if portable
```
```text
Design discussion
-> Objective resource
-> Knowledge note synthesis
-> implementation Tickets
-> docs after stabilization
```
Architecture は、これらの promotion を explicit and reviewable にする。
## 9. External reference lessons
### 9.1 Current Yoi pipeline
現在の Yoi extraction は activity-log pipeline:
- `build_extract_input` が conversation slice を flat Markdown として render する。
- user / assistant text は保持する。
- tool-call names を含める。
- raw tool-result content ではなく tool-result summaries のみを含める。
- reasoning は落とす。
- extract worker の tool は `write_extracted` 1 つだけ。
- `write_extracted` は `decisions`、`discussions`、`attempts`、`requests` を持つ structured `ExtractedPayload` を 1 件受け取る。
- LLM は provenance を作らない。Worker が `StagingRecord` を書くときに `source` を機械的に付与する。
- empty payload は valid で、no-op として扱える。
- consolidation は後で staging entries、full current Memory records、usage evidence、tidy hints を consume する。
- consolidation は Memory tools 経由で write し、linter feedback に対応し、record を merge / replace し、outdated / superseded / unused / noisy records を clean up する。
残す価値がある点:
- foreground 応答生成から memory maintenance が隔離されている。
- provenance が host 側で機械付与される。
- extract が direct write せず staging を挟む。
- empty / no-op が許される。
- consolidation が linter feedback と tidy hints を使う。
変えるべき点:
- flat slice ではなく Overview-first / Evidence-index-second にする。
- extract worker に read-only evidence tools を持たせる。
- staging を一時バッファではなく審査キューとして扱う。
- consolidation に entry-level disposition / resolution を要求する。
- legacy Knowledge as generated memory 前提を外す。
### 9.2 HermesAgent
HermesAgent は background review により、conversation snapshot から Memory / Skill update を直接判断する。
参考になる点:
- maintenance を foreground interaction から隔離する。
- 保存すべきものがなければ NOP にする。
- Memory と Skill を分ける。
- prompt snapshot / drift / injection guard を重視する。
そのまま採用しない点:
- direct write-only review を主経路にしない。
- aggressive Skill update bias を避ける。
- `MEMORY.md` / `USER.md` two-file model を Yoi 全体の architecture にしない。
Yoi では、Hermes 的な direct maintenance は bounded Memory update lane では参考になるが、extract worker 自体は direct write しない。
### 9.3 Codex
Codex は session / rollout を durable source として扱い、background pipeline が後から claim / extract / consolidate する。
参考になる点:
- durable source。
- phase separation。
- claim / lease / retry / global lock。
- workspace diff / baseline guard。
- extraction と consolidation の分離。
Yoi では、Codex 的な durable job / phase pipeline は staging resolution と consolidation scheduling に取り入れる価値がある。ただし、この architecture phase ではまず Overview-first extract と staging resolution を優先する。
## 10. Implementation posture
現在の実装は redesign してよい。既存の Memory / Knowledge shape があるからという理由で残さない。
ただし big-bang rewrite は避ける。この architecture が accepted されてから分割する。
Recommended implementation sequence:
1. **Knowledge removal 後の current Memory を clarify する**
- short-term / resident Memory を維持する。
- Memory が Knowledge を代替しなければならない、という前提を外す。
- legacy `knowledge/*` を generated memory として扱う stale consolidation prompt language を削除する。
2. **Progress message guidance を追加する**
- 長い作業や tool loop の節目で、main Worker が ordinary user-visible prose response として短い Progress message を残す。
- 専用 Tool は追加しない。
- Progress message は public に見せられる作業状態、確認済み事実、判断、未解決点、次の作業に限定する。
3. **Overview-first extract input を実装する**
- user messages + Assistant text outputs を semantic Overview として優先する。
- tool calls / tool results は Evidence index として分離する。
- committed history だけから Overview を作り、hidden context injection を避ける。
4. **Extract worker 専用 evidence tools と staging output tools を実装する**
- extract worker に read-only Evidence search / Evidence read を渡す。
- main Worker の tool surface は増やさない。
- output tools は `stage_candidate` / `finish_extraction` にする。
- `stage_candidate` は 1 candidate = 1 flat staging record を書く。
- source range と candidate record を結びつけられる schema / staging format を実装する。
5. **Staging-first extract を維持する**
- extract worker は direct Memory / Knowledge / Skill write をしない。
- overview accumulation / evidence growth / run or task boundary で extract を予約する。
- mid-run 発火を入れる場合も staging/checkpoint extraction に限定する。
6. **Staging resolution / disposition を実装する**
- staging entry ごとに discard / merge / replace / stale / delete / defer / promote / link を記録する。
- consumed staging を削除する前に resolution log / archive を残す。
- discard reason と defer reason を extract prompt 改善に使えるようにする。
7. **Target Knowledge note model を設計する**
- OKF-compatible Markdown concept document / bundle profile。
- Yoi extension frontmatter。
- link / backlink model。
- provenance / citations / staleness metadata。
- Workspace API surface。
- reviewable Knowledge updates の candidate / staging format。
8. **Consolidation / tidy lane を更新する**
- staging entries、existing Memory、usage evidence、linter feedback、stale/noisy hints を統合する。
- legacy Knowledge as generated memory 前提を外す。
- Memory update、Knowledge candidate、Skill candidate を分けて扱う。
9. **Minimal Knowledge catalog/read/write を実装する**
- OKF-compatible Markdown files と Workspace API から始める。
- frontmatter validation / lint と backlinks を追加する。
- `index.md` progressive disclosure と `# Citations` の扱いを実装する。
10. **Skill support を別に実装する**
- Agent Skills standard と Workspace authority に従う。
- Skill と Knowledge note schema を混ぜない。
- automatic Skill modification は recurrence と portability で gate する。
11. **Promotion workflows/tools を追加する**
- Memory -> Knowledge candidate。
- Knowledge -> docs / Ticket decision candidate。
- repeated procedure -> Skill candidate。
12. **Resource classes が安定してから sensemaking helpers を追加する**
- collected refs。
- evidence extraction。
- contradiction / staleness views。
- product-impact metrics。
## 11. Non-goals
- Memory を唯一の long-term knowledge store として扱うこと。
- Knowledge を名前だけ変えた generated memory として扱うこと。
- Skill を Workflow tracker や state machine として扱うこと。
- Worker history / tool results の外で context injection を隠すこと。
- Knowledge notes を Tickets / docs / git history より authoritative にすること。
- review なしに Knowledge / Skills / docs を自動 rewrite すること。
- extract worker に direct resource mutation authority を持たせること。
- extract run 単位の batch staging record に複数 candidate を抱え込ませること。
- staging entry をすべて Memory 化すること。
- human-readable artifact model が安定する前に vector database を設計すること。
- volatile Memory や staging queue を OKF concept document として扱うこと。
- H2 Markdown single-file layout を Workspace Memory API の長期 contract として固定すること。
- Agent Skills format を OKF Playbook documents で置き換えること。
## 12. Open decisions
- Knowledge bundle の exact directory organization: flat files、nested directories、domain directories のどれを初期推奨にするか。
- Yoi extension frontmatter の exact fields。
- Yoi stable `yoi_id` を必須にするか optional にするか。
- OKF `type` values の初期 convention をどうするか。
- Link syntax は OKF-compatible Markdown links と Obsidian-style wiki links (`[[slug]]`, `[[slug|label]]`) を support する。canonical internal representation と export normalization をどうするか。
- Knowledge note IDs / slugs と titles の関係。
- Memory は local-first のままにするか、Knowledge と同じ phase で Workspace API-first にするか。
- Memory / Knowledge APIs は同じ crate にするか、別 domain crates にするか。
- Memory -> Knowledge の promotion UI/tool をどうするか。
- personal Memory と workspace Memory をどう区別するか。
- Knowledge drafts の auto-generation をどこまで許すか。
- extract worker 専用 evidence tools の exact API: search/read の引数、上限、evidence id format。
- `stage_candidate` / `finish_extraction` の exact tool schema。
- flat staging record の exact fields: `claim`, `why_useful`, `staleness`, `evidence`, `source_refs`, `extract_run_id` など。
- Overview trigger を overview token count、Assistant message count、evidence growth、run/task boundary のどれで制御するか。
- mid-run extract をどこまで許すか。初期は staging/checkpoint extraction に限定する方針。
- staging resolution log / archive の保持期間と compact policy。
- どの Memory sidecar writes を direct に許し、どれを staged proposals にするか。extract worker 自体は direct write しない。
- Skill sidecar output は patches/proposals だけから始めるか、low-risk Skill edits を auto-apply してよいか。
- Memory / Knowledge / Skill Workspace APIs をまたぐ sidecar audit events をどう表現するか。
- prompt-cache-aware sidecar input で full replay と digest-plus-tail をどう選ぶか。
## 13. Exit criteria for architecture phase
この architecture は、次が満たされたら Tickets に分割できる。
- Memory / Knowledge / Skill boundary が accepted される。
- OKF-compatible bundle / Markdown concept documents としての target Knowledge が accepted される。
- Memory / Knowledge / Skills の Workspace backend authority が accepted される。
- Overview-first extract、extract worker 専用 evidence tools、staging-first output の方針が accepted される。
- staging resolution / disposition と、staging -> Memory 化 filter の方針が accepted される。
- 最初の implementation slice が選ばれる。
- non-goals が accepted され、old Workflow tracking や old unused Knowledge をそのまま再作成しないことが確認される。
@@ -1,137 +0,0 @@
---
source_url: "https://andymatuschak.org/files/papers/Pirolli%2C%20Card%20-%202005%20-%20The%20sensemaking%20process%20and%20leverage%20points%20for%20analyst%20technology%20as.pdf"
fetched_at: "2026-07-15T21:24:00Z"
content_type: "application/pdf"
notes: "Untrusted external reference captured as local Objective resource for Memory sensemaking design. This is a summary/extraction for design discussion, not project authority."
---
# Pirolli & Card (2005): The sensemaking process and leverage points for analyst technology
## Citation / source
Peter Pirolli and Stuart Card, PARC. "The Sensemaking Process and Leverage Points for Analyst Technology as Identified Through Cognitive Task Analysis" (2005).
Source PDF: <https://andymatuschak.org/files/papers/Pirolli%2C%20Card%20-%202005%20-%20The%20sensemaking%20process%20and%20leverage%20points%20for%20analyst%20technology%20as.pdf>
## Core model
The paper frames intelligence analysis as a sensemaking task:
```text
Information -> Schema -> Insight -> Product
```
The analyst transforms raw data into progressively more structured representations so expertise can apply and so results can be communicated.
The paper's notional data flow is especially relevant to Yoi Memory design:
```text
External data sources
-> shoebox
-> evidence file
-> schemas
-> hypotheses
-> presentation / work product
```
- **External data sources**: raw material, mostly text in the studied setting.
- **Shoebox**: the smaller subset collected as relevant for the task.
- **Evidence file**: extracted snippets / nuggets from the shoebox, plus low-level inferences.
- **Schemas**: re-representations that organize information for analysis.
- **Hypotheses**: tentative conclusions with supporting or disconfirming evidence.
- **Product**: report / presentation / action suited for communication.
## Two major loops
The process has two interacting loops rather than a simple linear pipeline.
### Foraging loop
Activities aimed at finding and selecting information:
- search and filter external data sources;
- collect potentially relevant material into a shoebox;
- read and extract evidence snippets;
- follow up on questions generated by extracted evidence.
The paper highlights the exploration / enrichment / exploitation tradeoff:
- **Exploration**: monitor or search more of the information space; increases recall.
- **Enrichment**: narrow the collected set into smaller, higher-precision subsets.
- **Exploitation**: read/extract/analyze the chosen material more thoroughly.
Analyst tooling can help by changing the cost structure of search, scanning, assessment, selection, attention shifting, and follow-up searches.
### Sensemaking loop
Activities aimed at structuring and reasoning:
- schematize evidence;
- build a case;
- generate / manage hypotheses;
- marshal evidence for and against hypotheses;
- tell a story / produce a report;
- re-evaluate based on feedback or new evidence.
The paper emphasizes opportunistic mixing of bottom-up and top-down processing:
- **Bottom-up**: data triggers schemas, relations, hypotheses, and products.
- **Top-down**: hypotheses / client feedback trigger new searches, re-reading, and re-organization.
## Leverage points
### Foraging loop leverage
- Cost structure of exploration / enrichment / exploitation.
- Cost structure of scanning, recognizing, and selecting items for attention.
- Cost of shifting attentional control to a new domain or task.
- Cost of follow-up searches generated by extracted information.
- Broad-band low-fidelity assessment plus narrow-band high-fidelity processing is a useful design pattern.
### Sensemaking loop leverage
- Span of attention for evidence, hypotheses, and evidentiary relations.
- Generation of alternative hypotheses.
- Confirmation bias and failure to seek disconfirming evidence.
- External representations can expand working memory for evidence/hypothesis structures.
- Tools should help distribute attention toward diagnostic evidence and disconfirming relations.
## Implications for Yoi Memory Objective
This paper directly supports the Objective's direction that effective Memory is not just durable storage or retrieval count.
Useful design implications:
1. **Task-bound shoebox**
- For each Ticket / Objective / question, provide a bounded collection of potentially relevant materials.
- Include provenance and why each item was collected.
2. **Evidence file**
- Extract snippets from source material with source, applicability, confidence, and context.
- Evidence should be usable for support and disconfirmation, not only recall.
3. **Schema / representation layer**
- The system should help create intermediate structures: timelines, entity/relation maps, alternatives, checklists, hypothesis spaces, decision tables.
- These are separate from raw Memory records.
4. **Hypotheses and alternatives**
- Record competing explanations, rejected alternatives, open questions, and evidence gaps.
- Avoid only storing final decisions.
5. **Disconfirming evidence**
- Reviewer / Orchestrator support should explicitly search for contradiction and diagnostic evidence.
- This belongs in Skill guidance and typed review tooling, not in a separate Knowledge store.
6. **Metrics**
- Measure whether Memory changes product quality, review quality, decision quality, or time-to-evidence.
- Avoid treating exposure/retrieval counts alone as success.
7. **Product connection**
- The loop should end in a product: Ticket decision, implementation report, review, design doc, Skill update, or validated artifact.
- Memory that never reaches a product is likely a graveyard.
## Boundary with current Yoi direction
- Knowledge as a separate record kind is being removed; this paper's reusable representations should map to Memory artifacts, Ticket artifacts, maintained docs, or Skills depending on authority.
- Workflow tracking is being removed; procedural guidance such as "generate alternatives" or "seek disconfirming evidence" should live in Skills / role prompts and be enforced by typed tools where authority is needed.
- Workspace backend should eventually be the authority for task-bound shoebox / evidence artifacts when Memory moves from local compatibility storage to control-plane records.
-343
View File
@@ -1,343 +0,0 @@
---
title: "Runtime working directory materialization and sandboxed agent environments"
state: "active"
created_at: "2026-07-06T16:28:12Z"
updated_at: "2026-07-10T16:50:00Z"
linked_tickets: ["00001KWPC13WQ", "00001KWMBAA6V", "00001KX6BPY7M", "00001KX6CRVBE"]
---
## Goal
Runtime が Worker ごとに安全で安価な作業環境を用意できるようにする。Yoi の Runtime は、単に既存ディレクトリで Worker process を起動する launcher ではなく、RepositoryPoint から working directory を materialize し、sandbox / mount / cache / cleanup / evidence を管理する実行基盤になる。
この Objective の中心は、Worker 用 working directory の materialization、Repository cache、working directory allocation、sandboxed agent environment の境界を設計し、将来的に 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられるようにすることである。
初期実装では Git/local repository を主対象にしてよい。ただし設計は Git worktree 固定にしない。Git worktree、bare object cache、sparse checkout、copy-on-write snapshot、reflink copy、APFS clonefile、btrfs snapshot、overlay filesystem、container filesystem、remote object snapshot は、すべて working directory materialization strategy の候補として扱う。
## Motivation / background
現在の Runtime は `--workspace` で与えられた単一 root を Worker の workspace scope として使う。この形では次の問題がある。
- 複数 Worker が同じ repository root を scope として要求すると allocation conflict が起きる。
- Runtime が single Workspace / Git repository root 専用 process になってしまう。
- Worker が source repository root を直接触るため、sandbox / cleanup / quota / evidence の境界が曖昧になる。
- Worker ごとに full clone すると容量と時間のコストが大きすぎる。
- `.yoi` / Backend fs-store / Workspace descriptor と、Worker 実行用 checkout / scratch / build output の lifecycle が混ざりやすい。
Yoi は Workspace / Repository / Runtime を分ける方針になっている。Backend は Repository registry、RepositorySelector、RepositoryPoint、Ticket/Artifact evidence の authority を持つ。一方 Runtime は RepositoryPoint を受け取り、Worker が使う working directory を用意する責務を持つべきである。
したがって、Repository を持ってくる実体ディレクトリは Backend / Workspace store ではなく Runtime 管理領域に置く。`./.yoi` は local descriptor / compatibility / project record surface であり、Worker ごとの checkout、worktree、snapshot、sandbox root、build cache、dependency cache を置く場所ではない。
参考になる方向性として、Rift のような copy-on-write workspace snapshot / reflink / btrfs snapshot / APFS clonefile による安価な workspace creation がある。ただし Yoi は Rift を Git worktree 代替 CLI として直接前提にするのではなく、Runtime materializer backend の一候補として扱う。
## Glossary
- Runtime root: Runtime が自身の store、cache、Worker metadata、working directory allocation を管理する root。長期的には `~/.yoi/runtimes/<runtime-id>/` 配下など、Workspace backend store とは別に置く。
- Repository cache: Runtime-local の共有 source/cache。Git なら bare mirror / object cache / packfile cache など。重く、長寿命で、複数 Worker allocation から共有される。
- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI では `workspace` と混同しないよう `workdir` と表示してよい。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。
- Worker record: 作業単位として保存する record。profile、Runtime、RepositoryPoint / workdir evidence、session/transcript refs、status、diagnostics、summary、pinned flag を束ねる。Session 単体ではなく Worker を保存・削除の単位にする。
- Materialization strategy: RepositoryPoint から working directory を作る実装戦略。Git detached worktree、sparse checkout、CoW snapshot、reflink copy、overlay、container filesystem など。
- WorkingDirectoryAllocation: Runtime が払い出した作業環境の record。allocation id、worker id、workspace id、repository point、materializer kind、root、mounts、cleanup policy、status を持つ。
- Sandbox policy: Worker が見られる filesystem、network、process、secret、tool authority の境界を表す policy。
- Dirty state policy: local uncommitted changes を Worker environment に含めるかどうかの方針。clean point only、patch artifact apply、snapshot current worktree など。
## Strategy / design direction
### 1. Backend store と Runtime execution storage を分ける
Workspace/backend store は canonical or local descriptor records を扱う。
```text
Backend / Control plane store
- Workspace registry
- Repository registry
- Ticket / Objective
- Artifact metadata / evidence
- Actor / Permission / Audit
- Runtime registry / observed state
```
Runtime execution storage は Worker 実行のための materialized filesystem と cache を扱う。
```text
Runtime root
- runtime catalog / runtime-local DB
- config bundles
- worker metadata / transcript references
- repository-cache
- working-directories
- sandbox state
- build/dependency cache
- cleanup ledger
```
この 2 つを混ぜない。特に `.yoi` や workspace config root に Worker ごとの checkout/worktree/sandbox root を置かない。
推奨配置の方向:
```text
~/.yoi/
workspace-server/
backend.db
workspaces/
<workspace-id>/
workspace.db
artifacts/
runtimes/
<runtime-id>/
runtime.db
config-bundles/
workers/
<worker-id>/
metadata/
repository-cache/
git/
<repository-cache-key>/
bare.git
working-directories/
<allocation-id>/
root/
mounts/
scratch/
materialization.json
build-cache/
tmp/
```
### 2. clone ではなく materialize と呼ぶ
Runtime は Repository を毎回 clone するのではない。Runtime は RepositoryPoint を Worker 用の working directory として materialize する。
```text
RepositoryId + RepositorySelector
-> resolved RepositoryPoint
-> Runtime repository-cache
-> WorkingDirectoryMaterializer
-> WorkingDirectoryAllocation
-> Worker process
```
Git の場合でも materialization strategy は複数あり得る。
- shared bare cache + detached Git worktree
- sparse checkout
- partial clone / blob filter
- reflink copy
- btrfs writable snapshot
- APFS clonefile
- overlay filesystem
- external tool backed snapshot, e.g. Rift-like CoW workspace creation
### 3. Repository cache と Worker working directory を分離する
full clone を Worker ごとに作らない。重い source/object data は Runtime-local repository cache に集約し、Worker ごとの working directory は cheap allocation にする。
Git v0 の方向:
```text
repository-cache/git/<repo-key>/bare.git
working-directories/<allocation-id>/root/<repository-id>/
```
初回:
```text
git clone --mirror <uri> repository-cache/git/<repo-key>/bare.git
```
次回以降:
```text
git -C repository-cache/git/<repo-key>/bare.git fetch --prune
```
Worker allocation:
```text
git --git-dir=<bare.git> worktree add --detach <execution-root>/<repository-id> <commit>
```
Git branch 名を直接 checkout しない。Git worktree は同一 branch を複数 worktree に checkout しづらいため、RepositorySelector を RepositoryPoint に解決し、resolved commit を detached worktree として materialize する。Worker が branch を必要とする場合は Worker/Task ごとの synthetic branch を別途作る。
### 4. Dirty state を明示 policy にする
local dirty changes を暗黙に Worker に見せない。
可能な policy:
- `clean_point_only`: dirty workspace は materialization 拒否。再現性が高く v0 の default 候補。
- `patch_artifact`: clean RepositoryPoint を materialize し、Backend が保存した dirty diff artifact を apply する。
- `current_worktree_snapshot`: CoW/reflink/Rift-like snapshot で現在の working tree を snapshot として materialize する。便利だが evidence と cleanup の扱いを明示する必要がある。
- `direct_legacy_mount`: 既存 root をそのまま渡す。debug/legacy only。通常 Worker creation の default にしない。
Dirty state を含める場合、Artifact/evidence には source RepositoryPoint、patch/snapshot digest、created_at、materializer kind を残す。
### 5. Sandbox を materialization と同じ境界で扱う
working directory は単なる directory path ではなく、Worker が見てよい filesystem view である。Runtime は working directory allocation と同時に sandbox/mount/authority を構築する。
Worker に渡すもの:
- workspace root
- repository mounts
- scratch/cache dirs
- tool authority
- env vars
- config bundle
- secret handles, not raw secrets
Worker が自分で発見してはいけないもの:
- host repository root
- Backend store path
- `.yoi` authority-bearing internals
- raw credentials
- Runtime socket/store/cache internals
- sibling Worker working directories
Sandbox v0 は strong isolation でなくてもよい。ただし型と lifecycle は、後で container sandbox、namespace, mount filtering, network policy, secret boundary に拡張できる形にする。
### 6. working directory registry を持つ
Runtime は materialized workspace を filesystem だけでなく registry でも管理する。
必要な record:
```text
WorkingDirectoryAllocation
id
runtime_id
worker_id
workspace_id
repository_points[]
materializer_kind
root
mounts[]
scratch
source_cache_refs[]
sandbox_policy
dirty_state_policy
cleanup_policy
status: active | stopped | cleanup_pending | removed | failed
created_at
stopped_at
diagnostics[]
```
この registry は Runtime root 側に置く。Backend は必要な evidence と summary だけを受け取る。Browser-facing API は raw host paths を原則漏らさない。
### 7. Heavy regenerable artifacts は policy で除外または cache 化する
Worker working directory creation では、`node_modules`, `target`, `.venv`, framework cache, dist, build, coverage などを無条件に full copy しない。
Materialization policy は以下を持てるようにする。
- exclude regenerable artifacts
- include manifests / lockfiles
- share dependency cache
- use build cache
- path scope / sparse checkout
- per-repository materialization options
Rift の filtered CoW creation のように、重い artifacts を除外しながら source tree を高速に用意できる strategy を将来取り込めるようにする。
## Initial implementation phases
### Phase 1: Materialization boundary and legacy allocation record
- `CreateWorkerRequest` に working directory request / target placeholder を追加する。
- `WorkingDirectoryMaterializer` trait を `worker-runtime` に追加する。
- `WorkerRuntimeExecutionBackend` が Worker spawn 前に materializer を呼ぶ順序にする。
- v0 materializer は existing local root を explicit allocation として返してよいが、`direct_legacy_mount` として明示し、通常設計の final form と混同しない。
- Allocation record / cleanup policy / diagnostics の型を先に作る。
- Worker が source repository root を直接 scope として要求する経路を deprecated/legacy に閉じ込める。
### Phase 2: Runtime root and working directory storage
- `--runtime-root` を導入し、Runtime state / repository-cache / working-directories / worker metadata / worker runtime dirs を Runtime root 配下へ寄せる。
- `--workspace` は legacy bootstrap input としてだけ扱い、Runtime identity / long-term workspace binding から外す。
- Runtime root default は user data 配下にする。
- working directory allocation registry を Runtime root に保存する。
### Phase 3: Git cached detached worktree materializer
- Git repository cache を Runtime root に作る。
- RepositorySelector を RepositoryPoint に解決する呼び出し境界を作る。
- resolved commit/tree を evidence として残す。
- Worker ごとに detached worktree を `working-directories/<allocation-id>/root/<repository-id>` に作る。
- Worker stop / cleanup 時に `git worktree remove` と registry cleanup を行う。
- dirty state は v0 では `clean_point_only` を default にし、dirty local workspace は明示 diagnostic で拒否する。
### Phase 4: Path scope / sparse checkout / cache policies
- Ticket target / Worker launch request の path scope を materialization に渡す。
- Git sparse checkout を materializer strategy として追加する。
- heavy artifact exclude / dependency cache / build cache policy を導入する。
### Phase 5: CoW / snapshot materializer
- btrfs snapshot、Linux reflink、macOS APFS clonefile、Rift-like snapshot backend を materializer strategy として検討・実装する。
- `current_worktree_snapshot` policy を evidence と cleanup 付きで扱う。
- source root と generated workspace の registry / ancestor / cleanup model を設計する。
### Phase 6: Strong sandbox and remote Runtime support
- container filesystem / mount namespace / network policy / secret handle / tool authority を Runtime allocation と統合する。
- Runtime が複数 Workspace / Repository の Worker を同時に抱える場合の namespace、quota、cleanup、audit boundary を固める。
- remote/self-hosted/hosted Runtime fleet で repository cache と working directory storage をどう扱うかを設計する。
### 7. Worker / Session / workdir retention and cleanup policy
- Worker を保存・削除単位にする。Session / transcript は Worker に内包または参照される履歴として扱い、Session 単体を長期保存 authority にしない。
- Worker lifecycle は `running -> stopped -> delete` を基本にする。`archived` を lifecycle state として導入しない。
- Worker には `pinned` flag を持たせる。Pinned Worker は manual delete / cleanup / future automatic prune から守られる。
- Worker delete は Worker record と内包する Session / transcript history の削除を意味する。Workdir files は自動削除しない。
- Session / transcript は削除可能にする。圧縮 archive storage や高度な summarized retention は必要になった時点で別の storage policy として設計する。
- Workdir 実ファイルは durable history ではなく再現可能 cache として扱う。RepositoryPoint / resolved commit から再現でき、dirty/uncommitted state が無いなら、running Worker に紐づかない workdir files は manual cleanup eligible とする。
- Dirty Workdir は活動中または要判断状態として扱う。削除は可能だが、changes ごと消す explicit confirmation を要求する。Dirty orphan は recovery Worker 起動または explicit discard の判断対象であり、通常の clean cleanup と区別する。
- Workdir record は materialization evidence として扱う。実ファイルが削除済みなら `removed`、外部要因で欠落しているなら stale/missing materialization として診断可能にする。
- Worker と Workdir の関係は Backend registry の link table を authority にする。Worker record は最後に活動した RepositoryPoint / resolved commit / workdir binding summary を保持し、Stopped Worker + Removed Workdir でも必要に応じて再 materialize できるようにする。
- Cleanup/delete は当面 manual-first にする。削除前に対象、理由、削除される bytes、blocking conditions(running Worker、pinned Worker、dirty state、unreachable commit など)を plan として提示する。
- 将来的には容量/期限ベースの automatic prune を設定可能にする予定。ただし automatic prune は user-configured policy と pinned protection を前提にし、初期の manual cleanup/delete とは別段階で扱う。
- UI 表示は `workdir` に寄せる。内部型/API の互換名 `working_directory` は移行中に残ってよいが、Browser-facing navigation では Runtime 管理配下の `Workdirs` として扱う。
## Non-goals
- v0 で完全な container sandbox を実装すること。
- Worker ごとに full clone すること。
- `.yoi` や Backend workspace store に Worker working directory を置くこと。
- Git worktree を唯一の materialization strategy として固定すること。
- Dirty local changes を暗黙に Worker に渡すこと。
- Browser-facing API に raw host path、secret、Runtime internal store path を公開すること。
- Repository credential / secret distribution の本格設計をこの Objective だけで完了させること。
## Success criteria / exit conditions
- Runtime root、Repository cache、working directory、Backend/Workspace store の境界が文書化されている。
- Runtime が Worker spawn 前に working directory materializer を呼ぶ型と順序を持つ。
- Worker は source repository root ではなく materialized working directory を scope として起動する。
- working directory allocation が Runtime registry に記録され、cleanup policy を持つ。
- Git/local repository の v0 materializer が full clone 連発ではなく shared cache / detached worktree / cheap allocation の方向に進んでいる。
- dirty state policy が明示され、clean point、patch artifact、snapshot のどれを使ったか evidence に残せる。
- Runtime process 起動時の `--workspace` は legacy bootstrap input として隔離され、Runtime identity や single workspace binding とみなされない。
- Worker ごとの scope allocation conflict が、同一 source root を直接渡す設計ではなく materialized workspace allocation によって解消される。
- Sandbox / mount / cache / secret boundary を後続実装で強化できる model になっている。
- Worker record が Session / transcript refs と pinned flag を束ね、`pinned` Worker を cleanup/delete/future automatic prune から守れる。
- Workdir 実ファイルは再現可能 cache として manual cleanup/delete でき、削除前に plan-first で linked Worker、session retention、commit reachability、dirty/missing 状態を確認できる。
- 将来的に容量/期限ベースの automatic prune を user-configured policy として追加できる設計余地がある。
## References
- [Rift: Worktree alternative for fast copy-on-write workspaces](https://github.com/anomalyco/rift) — CoW snapshot / reflink / btrfs snapshot / APFS clonefile による高速 workspace creation、heavy regenerable artifacts の除外、workspace registry などを Runtime materializer backend 設計の参考にする。
## Decision context
- Runtime は Worker 群を束ねる実行基盤であり、Worker 用の作業環境を用意する責務を持つ。
- Repository は clone されるものではなく、RepositoryPoint から Worker 用 working directory として materialize される。
- Runtime execution storage は Backend/Workspace store と分離する。`.yoi` は Worker working directory 置き場ではない。
- full clone を Worker ごとに作らない。Runtime-local repository cache と Worker-local cheap workspace allocation を分ける。
- Git detached worktree は v0 materialization strategy として有力だが、Git worktree 固定の設計にはしない。
- CoW snapshot / reflink / btrfs snapshot / APFS clonefile / Rift-like workspace creation は、将来の materializer backend として検討する。
- Dirty local state は暗黙に渡さず、policy と evidence を持って扱う。
- Sandbox は後付けの別機能ではなく、working directory allocation と同じ境界で扱う。
-3
View File
@@ -1,3 +0,0 @@
default = "builtin:companion"
[profile]
-329
View File
@@ -1,329 +0,0 @@
---
description: worktree と sibling の coder / reviewer Pod を使い、下位 orchestrator が concrete Ticket 群の実装・外部レビュー・修正・完了準備を管理する orchestration フロー
model_invokation: true
user_invocable: true
requires: []
---
# Multi-agent Worktree Workflow
yoi を yoi で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
worktree の機械的作成手順は `$user/worktree-workflow`、ユーザー依頼の Ticket 化は `$user/ticket-intake-workflow`、Ticket の next action 分類は `$user/ticket-orchestrator-routing`、実装前の planning/requirements sync は compatibility canonical id `$user/ticket-preflight-workflow` に分ける。
この Workflow は、対象 ticket が implementation-ready であることを前提にする。implementation-ready は full implementation plan ではなく、recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / escalation conditions に基づいて coder が bounded investigation を進め、reviewer が判断できる状態を指す。設計境界・仕様・authority boundary が未同期の場合は、worktree 作成や coder Pod 起動の前に planning/requirements sync 互換入口として `ticket-preflight-workflow` を通す。
## 目的
- 実装差分を ticket ごとの child worktree に隔離する。
- coder Pod に narrow write scope を渡して実装させる。
- reviewer Pod を coder の子ではなく **同じ orchestrator 配下の sibling** として立て、外部レビューを行わせる。
- orchestrator は coder / reviewer のやり取り、修正 loop、orchestration branch への integration、validation、Ticket 記録、child worktree cleanup に責任を持つ。
- 最上位 orchestrator は、コードを直接理解し切ることではなく、委譲した intent / 要件 / invariant に沿って下位 orchestrator が完了まで運んだかを acceptance する。
## Pod coordination model
基本形は以下。
```text
最上位 orchestrator Pod
- 人間との会話相手
- intent / 要件 / invariant / escalation 条件を定義
- 複数の作業群を並列管理
- root/original workspace での read/write/validation/cleanup/git 操作を行う
- 原則として line-by-line code review を主業務にしない
下位 orchestrator Pod(area / concrete-ticket-set coordinator)
- 連続した複数 concrete Ticket または大きめの concrete Ticket を完了状態まで運ぶ
- worktree / branch / coder / reviewer / validation / 修正 loop を管理する
- coder と reviewer を sibling として扱う
- orchestration branch 上の integration 結果と残論点だけを返す
coder Pod
- 指定 worktree / branch に実装する
- ticket 外判断や設計衝突は orchestrator に戻す
- reviewer に直接反論・修正依頼を完結させず、orchestrator に報告する
reviewer Pod
- 原則 read-only
- ticket / intent packet / diff / validation 結果を読む
- 実際のコード変更が概念的に何を変えたかを説明する
- intent / 要件 / invariant に反する blocker を分類して返す
```
一段だけで足りる小さい concrete Ticket では、最上位 orchestrator が直接 coder / reviewer sibling を扱ってよい。複数 concrete Ticket や設計境界をまたぐ作業では、最上位の下に下位 orchestrator を挟む。
この Pod coordination model は runtime delegation の形であり、Ticket hierarchy を作る根拠ではない。複数 Ticket を扱う場合も各 Ticket は concrete work item として独立に実装・レビュー・検証・close できる必要がある。broad effort の進捗コンテナとして umbrella Ticket、parent/child Ticket、sub-ticket、part-of、contains などを作らない。中期的な目的や戦略は Objective context に置き、typed Ticket relations が利用可能な場合も dependency / related / blocking / replacement などの非階層メタデータに限る。
明示的な queue review や routing kick の中で、独立した queued Ticket が複数あり coder/reviewer capacity が残っているなら、最初の Ticket の完了を待つことを default にしない。Orchestrator は各 Ticket の relation metadata、OrchestrationPlan records、Ticket thread、workspace/worktree dirty state、visible Pods、既存 branch、conflict notes を確認し、blocking relation や `do_not_parallelize` がなく、write surface が disjoint または conflict risk が小さく機械的に処理できるものを、別 worktree・別 branch・別 narrow write scope で並列開始する。これは background scheduler や queue drain loop ではなく、人間が queued にした Ticket だけを、その場の明示的な acceptance 判断で `queued -> inprogress` に進める運用である。
## 開始条件
以下が揃っている時に使う。
- 対象 concrete Ticket または concrete Ticket 群が決まっている。
- Ticket lifecycle を使う場合、対象はすでに `inprogress` であるか、worktree 作成・Pod spawn・coder routing の前に Orchestrator が個別に `queued -> inprogress` acceptance を記録できる `queued` Ticket に限る。unqueued Ticket は capacity 埋めの対象にしない。
- ticket の背景・意図・制約・受け入れ条件から、実装調査と局所 tactic 選択を coder に委ねても product / API / UX / authority / design-boundary decision を silently 固定しないと判断できる。
- worktree 作成と git 書き込み操作について、人間の許可がある。
- 下位 orchestrator に渡す binding decisions / invariants、implementation latitude、escalation conditions を短く書ける。
- 設計境界・仕様・authority boundary に不確定要素があり、bounded project-context checks 後も concrete missing decision / information が残る場合、planning/requirements sync 互換入口 `ticket-preflight-workflow` の結果が ticket thread に記録されている。
product / API / UX / authority / design-boundary 方針が複数自然に導ける場合、ticket 自体の再定義が必要な場合、または protocol / scope / permission / history persistence に触れる作業で bounded project-context checks 後も concrete missing decision / information が残る場合は、実装委譲前に planning/requirements sync 互換入口 `ticket-preflight-workflow` を通し、必要なら人間へ戻す。scope / Pod / permission / persistence のような authority-adjacent domain は reviewer focus と context lookup の signal であり、intent / binding decisions / invariants / implementation latitude / acceptance criteria / escalation conditions が明確なら coder に委ねてよい。実装ファイルの探索、既存コード読解、局所的な構成選択のような bounded implementation uncertainty も同様に coder に委ねてよい。
## Intent packet
階層化すると人間の意図が劣化しやすい。下位 orchestrator や coder / reviewer へは、自然文の依頼だけでなく intent packet を渡す。
標準形:
```text
Intent:
- 何を実現するか。
Binding decisions / invariants:
- 人間/Orchestrator/Ticket に記録済みで coder / reviewer が従うべき decision。
- 壊してはいけない設計境界、authority boundary、明示制約。
- 残してはいけない旧概念や互換層。
Requirements / acceptance criteria:
- 完了時に満たすべき observable な要件と reviewer が判断できる基準。
Implementation latitude:
- Coder が調査しながら選んでよい local tactic / file-local organization / bounded uncertainty。
Escalate if:
- 親へ戻すべき判断条件。特に product / API / UX / authority boundary / explicit design constraint を変える必要が出た場合。
Validation:
- 実行すべき format / build / test / doctor。
Reviewer focus:
- reviewer が確認すべき critical risks。
```
reviewer には coder の実装方針ではなく、この intent packet と diff を中心に読ませる。reviewer は recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions に照らして判断し、不記録の preferred tactic を基準にしない。
## orchestrator の責務
下位 orchestrator を挟まない場合は、以下を最上位 orchestrator が行う。下位 orchestrator を挟む場合は、最上位は intent packet を渡し、以下の実務を下位に委譲する。
1. 状態確認
- `git status --short --branch`
- 対象 ticket / ticket 群
- relation metadata / OrchestrationPlan records / do_not_parallelize / conflict or dependency notes
- visible Pods、既存 worktree/branch、coder/reviewer follow-up capacity
- 関連 TODO / docs / 既存 worktree
- planning sync が必要な ticket では、互換入口 `ticket-preflight-workflow` の分類・要件同期・critical risks
2. worktree 作成
- 対象 Ticket が `queued` なら、この step の前に typed Ticket backend/tool path で `queued -> inprogress` を記録する。これより前に branch 作成、worktree 作成、Pod spawn、実装調査依頼などの implementation side effect を行わない。
- Orchestrator が dedicated orchestration worktree で動く場合、作業対象は Orchestrator workspace/orchestration branch と child implementation worktree に限定する。root/original workspace は read/write/validation/cleanup/git 操作の対象ではない。
- implementation worktree は記録済み implementation worktree root の `.worktree` 配下に置く。root/original workspace は配置基準としてだけ扱い、作業対象にしない。
- implementation branch は Orchestrator workspace の current HEAD/orchestration branch HEAD から作り、reviewer approve 後は orchestration branch へ自動 integration する。
- `.yoi` 自体は除外しない。tracked project records は child worktree に存在してよく、`.yoi/memory` と local/runtime/log/lock/secret-like paths だけを sparse checkout で除外する。
3. coder Pod spawn
- read scope: original workspace root 全体、または実装に必要な bounded read scope。
- write scope: child worktree、または必要最小 directory。
- task には以下を明示する。
- child worktree path / branch
- 対象 ticket path
- intent packet
- SpawnPod の `cwd` は child worktree に設定すること(`cwd` は process/tool default cwd であり、scope/authority ではない)
- Orchestrator workspace / recorded Ticket backend の `TODO.md` / Ticket records / `docs/report/` / `.yoi` は編集しないこと
- child worktree 内の tracked `.yoi` project records は実装対象に必要な branch-local artifacts/dossiers として編集してよいが、`.yoi/memory` や local/runtime/secret-like files は作らないこと
- active orchestration progress、review、integration、Ticket lifecycle update は Orchestrator workspace または recorded Ticket backend の責任として残すこと
- 遵守すべき binding decisions / invariants と escalation conditions
- 実行すべき build / test / format
- 完了報告項目
4. coder 完了確認
- `ReadPodOutput` で報告を読む。
- 通知が来ない場合でも、worktree の `git status` / `git diff` / test で完了状態を確認する。
- coder が止まった場合、worktree 状態を見て再 spawn / rollback / 親 escalation を判断する。
5. reviewer Pod spawn
- reviewer は coder の子ではなく orchestrator 配下の sibling として立てる。
- 原則 read-only scope にする。
- reviewer に渡すもの:
- ticket / intent packet
- branch / commit / diff の読み方
- 実行済み validation
- blocker / non-blocker / acceptable の分類基準
- reviewer の主目的は「コードの詳細を親の代わりに説明し、intent / invariant 違反を見つけること」。
6. review → 修正 loop
- reviewer finding を orchestrator が読む。
- blocker は coder に修正依頼する。
- reviewer の指摘を却下する場合は、orchestrator が理由を dossier に残す。
- 修正後は focused validation を実行し、必要なら reviewer に再確認させる。
- reviewer の blocker が未解決のまま親に提出しない。
7. orchestration branch integration
- 親がコードを直接理解しなくても判断できるよう、変更の概念的説明と evidence をまとめる。
8. integration / lifecycle
- reviewer approve と blocker 解消を確認し、implementation branch を Orchestrator workspace の orchestration branch へ integration する。
- Orchestrator workspace で必要な validation を実行し、Ticket thread に integration と validation の結果を記録する。
- Ticket を完了処理して commit する。TODO cleanup が対象 Ticket の明示要件なら child implementation worktree/branch に限定して行う。root/original workspace は触らない。
## coder Pod の責務
- child worktree 内でのみ実装する。
- root/original workspace の管理ファイルを読まない・書かない。
- child worktree 内の tracked `.yoi` project records は ticket 要件に必要な branch-local artifact/dossier として扱ってよい。
- `.yoi/memory`、local/runtime state、logs、locks、secret-like files を child worktree に作らない。
- intent / requirements / acceptance criteria / binding decisions / invariants / implementation latitude / escalation conditions を読んでから実装する。
- 実装ファイルの探索、既存コード読解、局所的な tactic 選択は、intent packet の implementation latitude 内で行ってよい。
- 指定された build / test / format を実行する。
- ticket 要件外または binding decisions/invariants 外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて orchestrator に報告する。
- 完了時に以下を報告する。
- worktree path / branch
- commit hash(commit した場合)
- 変更ファイル
- 実装概要
- 実行した build / test / format
- 未解決事項
- review に回せるか
## reviewer Pod の責務
reviewer は coder の subordinate ではない。orchestrator 配下の sibling として、実装 diff を外部から読む。
- 原則 read-only で作業する。
- ticket / intent packet / diff / validation 結果を読む。
- 実際のコード変更が概念的に何を変えたかを説明する。
- 親や上位 orchestrator が line-by-line diff を読まずに判断できるよう、以下を整理する。
- 変更の概念モデル
- recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions との対応
- binding decisions / invariants 違反の有無
- 旧概念・禁止語彙・不要な互換層の残存
- validation の妥当性
- blocker / non-blocker / follow-up
- reviewer は直接 merge しない。
- reviewer は coder に直接作業指示を出さず、orchestrator に finding を返す。
## commit 方針
coder Pod には child worktree 内での commit を許可してよい。
- commit は ticket 内で意味のある粒度にする。
- 例: `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`
- coder Pod は merge / push / branch deletion / worktree remove をしない。
- coder Pod は recorded Ticket backend の Ticket 完了処理 commit、最終 review/approval/close をしない。child worktree 側には branch-local dossier や実装証跡を残してよい。
- orchestrator は review 時に commit 粒度も確認する。
- 必要な修正は、原則追加 commit として積む。履歴改変や squash は人間の明示指示がある時だけ行う。
## Review → 修正 → 完了の標準形
### Approve
reviewer が approve し blocker が残っていない場合、Orchestrator workspace の orchestration branch への integration、validation、Ticket 記録、child worktree cleanup まで自動的に進める。root/original workspace への read/write/validation/cleanup/git 操作は行わない。
1. coder Pod / reviewer Pod を停止し、scope を回収する。
2. orchestrator が Ticket、child worktree/branch、commits、reviewer verdict、validation evidence を確認する。
3. 最上位 orchestrator が必要最小限の spot check を行う。
4. Orchestrator workspace の orchestration branch で implementation branch を merge または project-agreed method で integration する。
5. Ticket を完了処理して commit する。TODO cleanup が対象 Ticket の明示要件なら child implementation worktree/branch に限定して行う。
6. Orchestrator workspace/orchestration branch で検証コマンドを再実行する。root/original workspace では実行しない。
7. 変更内容・commit・検証結果・残 dirty changes を報告する。
### Request changes
1. reviewer finding または orchestrator finding を blocker / non-blocker に分ける。
2. blocker はファイル / 行 / 理由 / 修正方針つきで coder に戻す。
3. coder が停止済みなら、同じ worktree / branch / scope で再 spawn する。
4. 修正後に focused test と必要な broader test を再実行する。
5. 必要なら reviewer に再確認させる。
6. blocker が解消したら dossier を更新する。
### Non-blocking comments
- ticket 要件外の改善はその場で混ぜない。
- 必要なら後続 ticket / docs/report にする。
- non-blocking を理由に completion を遅らせない。
## 並列実装時の注意
- 1 ticket = 1 worktree = 1 branch を基本にする。
- 複数 Pod に同じ write scope を渡さない。parallel Coder Pod は別 worktree と narrow write scope を持つ。
- parent / orchestrator は coder の write scope 配下を直接編集しない。
- reviewer は read-only を基本にする。明示的に別 scope を与える判断がない限り、review artifact を書かせる場合も ticket artifacts など限定 scope にする。
- independent queued Ticket は、blocking relation/dependency がなく、`do_not_parallelize` や relevant `conflicts_with` がなく、source/write surface が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、workspace records を side effect 前に commit でき、別 worktree/branch/scope を切れる場合に並列開始を優先する。
- 依存関係がある ticket、同一 migration boundary や同一 schema/tool/panel surface を大きく触る ticket、または reviewer/coder bottleneck がある ticket は、土台 branch を merge してから次 worktree を切るか、bounded defer reason を記録して idle にする。
- capacity が見えるのに queued Ticket を開始しない場合、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかを bounded reason として Ticket thread または `TicketOrchestrationPlanRecord` に残す。
- parallel に走らせた Pod の完了通知は取りこぼしうるため、`ReadPodOutput` と worktree 状態で確認する。
- この節は自動 scheduler、background runner、resource graph solver、automatic queue drain loop を導入しない。parallel start は明示的な routing/acceptance の結果であり、unqueued Ticket を開始する根拠ではない。
## orchestration integration dossier の標準形
```text
Status:
- completed / blocked / needs parent decision
Scope:
- ticket(s): <path>
- branch: <name>
- commits:
- <hash> <subject>
Intent check:
- invariant 1: ok / violated / not applicable, evidence ...
- invariant 2: ok / violated / not applicable, evidence ...
Implementation summary:
- 変更の概念的説明: ...
- 主要変更ファイル: ...
- compatibility / migration: ...
Coder loop:
- coder pod: <name>
- produced commits: ...
- unresolved coder notes: ...
External review:
- reviewer pod: <name>
- blockers found: ...
- blockers fixed: ...
- rejected findings and reasons: ...
- non-blocking follow-ups: ...
Validation:
- cargo fmt --check
- cargo check --workspace
- cargo test ...
- yoi ticket doctor
Parent decision needed:
- none / specific question
Residual risk:
- ...
Dirty state:
- ...
```
## 最上位 orchestrator の acceptance
最上位 orchestrator は、下位 orchestrator の成果を code review するのではなく acceptance する。
確認するもの:
- intent packet が保持されているか。
- reviewer が coder と独立した sibling として機能したか。
- blocker が未解決のまま握りつぶされていないか。
- 変更の概念的説明が要件と対応しているか。
- validation が ticket のリスクに対して十分か。
- escalation すべき判断を下位が勝手に決めていないか。
必要なら spot check するが、常態的な line-by-line review に戻らない。
## この Workflow で扱わないもの
以下は `$user/ticket-intake-workflow`、`$user/ticket-orchestrator-routing`、または別の設計相談で扱う。
- ユーザー依頼を Ticket 化すること。
- Ticket の next action を分類すること。
- QA feedback / AI feedback を Ticket / report / workflow に落とす判断。
- 長期 maintainer loop / scheduler / LeaseStore の設計。
- reviewer Pod の品質評価を機械的に採点する仕組み。
-350
View File
@@ -1,350 +0,0 @@
---
description: ユーザーの曖昧な依頼を要件同期し、合意済みの Ticket として作成・更新する Intake workflow
model_invokation: true
user_invocable: true
requires: []
---
# Ticket Intake Workflow
Yoi の multi-agent 運用で、ユーザーの依頼をいきなり実装委譲せず、まず **合意済み Ticket** または「まだ Ticket 化しない」判断に変換するための Workflow。
この workspace workflow は bundled `resources/workflows/ticket-intake-workflow.md` を dogfooding 用に詳述した override である。Objective / split policy / local Ticket 運用の説明を追加するが、bundled workflow の調査ゲート、Ticket 作成前の user agreement、Intake の非 scheduler 境界を弱めてはならない。
Intake の目的は、ユーザーの意図・要件・制約・受け入れ条件・未決定点を明確にし、Orchestrator が次の routing を判断できる Ticket を作ることである。Intake は scheduler ではなく、coder / reviewer / read-only investigation helper Pod を起動しない。
## 位置づけ
```text
User request / conversation
-> Ticket Intake Workflow
-> TicketCreate / TicketComment
-> Orchestrator routing
-> planning sync / spike / implementation / review / blocked / close
```
- `Ticket` は durable orchestration record。
- `Objective` は medium-term goal / motivation / strategy / success criteria / decision context の project record。Objective context は判断背景であり、Ticket body/thread/artifacts を読む代替ではない。
- `Task` は session-local progress tracking。
- `Assignment` は Orchestrator から coder / reviewer Pod、または task-specific helper Pod への具体的委譲。
- `IntentPacket` は Ticket から抽出して Assignment に渡す短い実装・レビュー契約。
Intake は、要件同期と Ticket 化を担当する。実装の起動・worktree 作成・review 委譲・merge 判断は Orchestrator 側の責務である。`ready` は Orchestrator が routing できる状態を意味し、実装戦術がすべて事前固定されていることを意味しない。
Ticket に残す内容は、binding decisions / invariants、implementation latitude、escalation conditions を区別する。Coder が調査しながら局所的な実装手段を選べる余地は残してよいが、product / API / UX / authority boundary / explicit design constraint を silently 決める余地を残してはならない。
## Intake の責務
Intake は以下を行う。
- ユーザー依頼の主語と目的を確認する。
- 既存 Ticket を確認し、duplicate / related work を探す。
- 曖昧な依頼、現在挙動への claim、authority boundary、workflow/source-of-truth 変更では、Ticket 化前の最小調査ゲートとして関連 docs / code / workflow / history を読む。
- 不足している要件を質問する。
- 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。
- 広い依頼を分割する場合は、進捗コンテナとしての umbrella Ticket ではなく、concrete Ticket / Objective context / split decision record に責務を分ける。
- Objective-to-Ticket links を提案する場合は canonical opaque Ticket ID だけを使い、dependency / blocking / ordering relation として扱わない。
- Ticket の title / body/request snapshot / acceptance criteria / priority / readiness / risk flags を、現在の要件として意味がある範囲で提案する。
- canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。
- ユーザー主張、Intake が確認した事実、未確認仮説、未決定点を分けて整理する。
- background / requirements / acceptance criteria / escalation conditions を整理する。
- binding decisions / invariants と implementation latitude を分けて書く。
- 具体的な除外や触れてはいけない境界が binding decision である場合は、generic な除外リストではなく invariant / escalation condition として明記する。
- readiness / open questions / risk flags を明示する。
- ユーザー合意後にだけ official Ticket を作成する。
- 既存 Ticket の refinement を求められた場合は、TicketComment で経緯を残す。
## Intake がしないこと
- coder / reviewer Pod や read-only investigation helper Pod を起動しない。
- worktree を作らない。
- merge / close / branch cleanup をしない。
- implementation-ready でない Ticket を実装に投げない。
- unattended scheduler として自動実行しない。
- ユーザー合意なしに official Ticket を作らない。
- broad effort の進捗を保持するためだけの long-lived umbrella / progress-container Ticket を作らない。
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split の代替として提案しない。
- secrets / private context を Ticket body / thread / artifacts / diagnostics に保存しない。
- arbitrary filesystem write で `work-items/` を編集しない。Ticket 操作は Ticket tools を使う。
## 使用する Ticket tools
利用可能なら、以下の typed Ticket tools を使う。
- `QueryTicket`: 既存 Ticket の一覧・重複確認。
- `ShowTicket`: 関連 Ticket の詳細確認。
- `TicketCreate`: 合意済み Ticket の作成。
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
## 手順
### 1. 依頼を受け取る
ユーザー依頼を短く言い換え、以下を分ける。
- 何を変えたいか。
- なぜ必要か。
- 影響を受けるユーザー / Pod / workflow / crate / docs。
- 既に決まっていること。
- まだ未決定のこと。
この段階では Ticket を作らない。ユーザー発話は request snapshot / claim として扱い、確認済み requirements と混同しない。
### 2. 既存 Ticket を確認する
`QueryTicket` / `ShowTicket` で duplicate / related work を探す。
確認観点:
- 同じ目的の未完了 Ticket がないか。
- closed Ticket の判断・resolution と矛盾しないか。
- 既存の umbrella/progress-container Ticket が、superseded/decomposed として退役できる状態か。
- 既存 concrete follow-up Ticket や Objective context で足りるか、新規 concrete Ticket が必要か。
既存 Ticket の更新で足りる場合、新規 Ticket を作らず、ユーザーに更新案を提示する。
### 2.1. Ticket 化前の最小調査ゲート
`TicketCreate` または material な `TicketComment` の前に、以下の gate を通す。
必ず行うこと:
- duplicate / related / blocking-looking Ticket を確認する。
- 既存 Ticket を更新するなら、その Ticket の item/thread/artifacts を読む。
- ユーザー claim と、Intake が読んで確認した fact を分ける。
次のいずれかに当たる場合は、Ticket 作成前に関連 docs / code / workflow / prompt / config / history を読む。
- 依頼が曖昧、または複数の concrete work item を含む。
- 「現在の挙動」「既存仕様」「壊れている」「既にある」など、事実確認を要する claim がある。
- scope / permission / history / prompt context / persistence / public API など authority boundary に触れる。
- prompt / workflow resource、Ticket schema、source-of-truth 境界の変更に触れる。
- 既存実装の map がないと requirements / acceptance criteria を誤って固定しそうである。
Gate output は draft に以下を分けて残す。
- User claims / request snapshot: ユーザーが述べたこと。
- Confirmed facts / sources: Intake が読んで確認したことと source。
- Unverified hypotheses: ありそうだが未確認の推測。
- Undecided points / open questions: ユーザーまたは Orchestrator の判断が必要なこと。
調査が大きい、current-code map がない、または仕様同期が足りない場合は、official Ticket を作らず draft で止める。readiness は `spike_needed` / `requirements_sync_needed` / `blocked` のいずれかを付け、次に必要な調査や質問を報告する。確認できない claim を requirements / acceptance criteria として保存しない。
### 2.5. Broad request の split policy
1つの依頼が複数の implementable work item を含む場合、Intake は以下を提案する。
- broad effort を表す umbrella/progress-container Ticket は新規作成しない。
- concrete slice ごとに、単独で実装・レビュー・検証・close できる Ticket を作る。
- split した理由と残した範囲は、関連 Ticket の thread、必要なら Objective context に記録する。
- Objective は中期的な目的・方針・success criteria を保持するために使う。Objective record の実装や schema 追加はこの workflow の責務ではない。
- typed Ticket relation が利用可能になった場合も、dependency / related / blocking / replacement / supersedes などの非階層メタデータに限る。
- parent/child、sub-ticket、part-of、contains のような hierarchy/container relation を作らない。
ユーザーが「まず調査 Ticket」「設計 Ticket」「計画 Ticket」を concrete work item として求める場合は作成してよい。禁止されるのは、他の concrete Ticket の進捗を持つためだけに長期 open となる container Ticket である。
### 3. 要件を同期する
最低限、以下を確認する。
- ユーザー claim のうち、どれが確認済み fact で、どれが未確認仮説か。
- observable な完了条件は何か。
- 作業の種類・影響範囲は prose として body に書けばよいが、current Ticket core metadata として扱わない。
- 受け入れ条件は何か。
- binding decision として残す具体的な除外・authority boundary はあるか。
- 後方互換が必要か。
- authority boundary / scope / permission / history / prompt context に触れるか。
- validation は何で確認できるか。
- 人間判断が必要な論点は何か。
不足がある場合は、Ticket 作成前に質問する。質問は多すぎず、Ticket 作成に必要な最小限に絞る。調査が先に必要な場合は `spike_needed`、仕様同期が先に必要な場合は `requirements_sync_needed` として draft に留める。
### 4. readiness を分類する
Ticket 作成または更新前に、readiness を明示する。
```text
implementation_ready:
- 意図・受け入れ条件・binding decisions / invariants / implementation latitude が明確。
- Reviewer が判断できる基準と escalation conditions が明確。
- 実装調査や局所的な tactic 選択は残っていてよいが、product / API / UX / authority boundary / explicit design constraint を coder が silently 決める余地はない。
- validation が書ける。
requirements_sync_needed:
- 目的は見えているが、仕様・用語・UX・責務境界・受け入れ条件が未同期。
- ユーザー claim を requirements として固定するには合意や確認が足りない。
spike_needed:
- 技術調査、依存関係、性能、license、diagnostics、現在コード map が先に必要。
- どの files/workflows/Tickets を読むべきかは見えているが、Intake の最小調査では実装可能な要件まで確定できない。
blocked:
- 人間判断、外部イベント、別 Ticket の完了が必要。
unspecified:
- どうしても分類不能な時だけ使う。理由を Ticket に書く。
```
### 5. open questions / risk flags を付ける
以下に触れる Ticket は risk flags と reviewer/orchestrator focus を Ticket body に短く明記する。これは stop gate ではない。具体的な未決定 decision / information がある場合だけ、blocking open question として記録する。
- profile / manifest / scope / permission。
- session / history / Pod metadata / persistence。
- prompt context 加工原則。
- public API / plugin / feature boundary。
- security / secret / credential handling。
- storage migration / backward compatibility。
- 複数の自然な設計方針があるもの。
- reviewer が diff だけでは見落としやすい設計リスク。
risk flags は短い語でよい。missing boundary がすでに人間/Orchestrator の Ticket-recorded decision で補われている場合は、その decision を根拠に Orchestrator が routing できる。単に risk があるだけなら Orchestrator は Ticket を戻さず、IntentPacket に escalation / reviewer focus を明記して進める。
例:
```text
risk_flags: [authority-boundary, persistence, prompt-context, public-api]
```
### 6. Ticket draft を提示する
ユーザーに作成前 draft を提示する。
標準 draft:
```text
Title:
Priority:
Readiness:
Next Ticket operation: draft_only | create_after_user_agreement | update_existing_after_user_agreement | no_ticket
Risk flags:
User claims / request snapshot:
Confirmed facts / sources:
Unverified hypotheses:
Undecided points / open questions:
Background:
Requirements:
Acceptance criteria:
Binding decisions / invariants:
Implementation latitude:
Escalation conditions:
Validation:
Related tickets/docs/files:
```
canonical ID は作成時に storage が opaque/path-derived value として割り当てるため、draft では提案しない。
この時点ではまだ Ticket を作らない。`Next Ticket operation` が `draft_only` / `no_ticket` の場合は、ユーザー合意があっても `TicketCreate` ではなく追加同期または調査へ戻す。
### 7. ユーザー合意を取る
以下のどちらかがあるまで official Ticket を作らない。
- ユーザーが draft を明示的に承認する。
- ユーザーが「作って」「切って」「記録して」など、作成を明示する。
未決定のまま記録する場合は、`requirements_sync_needed` / `spike_needed` / `blocked` として未決定点を明示する。ユーザー合意は「この未決定状態で記録する」ことへの合意であり、未確認仮説を requirements 化する許可ではない。
### 8. Ticket を作成または更新する
新規 Ticket の場合:
- `TicketCreate` を使う。
- title / priority / body と、必要な readiness / risk flags を指定する。canonical ID は storage が割り当てる。
- body に readiness / open questions / risk flags と、binding decisions / invariants、implementation latitude、escalation conditions を Markdown で明記する。
- user claims、confirmed facts、unverified hypotheses、undecided points / open questions を分けて書き、未確認 claim を requirements / acceptance criteria として保存しない。
既存 Ticket refinement の場合:
- `TicketComment` を使う。
- role は内容に応じて `comment`, `plan`, `decision` を選ぶ。
- 既存 `item.md` の大幅変更が必要なら、Orchestrator / maintainer に戻す。
### 9. 作成後の報告
ユーザーへ以下を返す。
- 作成/更新した Ticket の system-assigned id / title。
- readiness。
- open questions / risk flags。
- 次に Orchestrator が取るべき routing 候補。
- 未決定点があれば、そのまま明示する。
Intake はここで止まる。implementation / worktree / coder / reviewer 起動は Orchestrator routing の責務である。
## Ticket body の推奨形
```markdown
## User claims / request snapshot
## Confirmed facts / sources
## Unverified hypotheses
## Undecided points / open questions
## Background
## Requirements
## Acceptance criteria
## Binding decisions / invariants
## Implementation latitude
## Readiness
- readiness: implementation_ready | requirements_sync_needed | spike_needed | blocked | unspecified
- risk_flags: [...]
## Escalation conditions
## Validation
## Related work
```
Ticket の body は Markdown/freeform を維持する。すべてを strict schema に押し込まない。
## secret / private context policy
以下は Ticket に保存しない。
- API keys / tokens / credentials。
- secret file contents。
- private prompts / model responses that should not become project records。
- user private context not needed for project history。
- raw logs containing secrets。
必要なら、保存せずに「secret/config が必要」とだけ書く。
## 完了条件
この Workflow の完了条件は次のいずれかである。
- ユーザー合意済みの新規 Ticket が作成され、Orchestrator が routing できる情報が揃っている。
- 既存 Ticket に refinement / decision / plan が記録され、次の routing が明確である。
- Ticket 化しない判断がユーザーに説明され、未決定の問いまたは関連 Ticket が明確になっている。
## 他 Workflow への接続
- `ticket-preflight-workflow`: legacy compatibility planning/requirements sync 入口。新規 routing は standalone preflight ではなく planning return/requirements sync として扱う。
- `multi-agent-workflow`: Orchestrator が implementation_ready と判断した後に接続する。
- `ticket-orchestrator-routing`: この Workflow が作った Ticket を routing する後続 Workflow。
@@ -1,419 +0,0 @@
---
description: Ticket を読み、Orchestrator が planning return / spike / implementation / review / blocked / close へ明示的に routing する workflow
model_invokation: true
user_invocable: true
requires: []
---
# Ticket Orchestrator Routing Workflow
Yoi の multi-agent 運用で、Intake や人間が作成した Ticket を Orchestrator が読み、次に取るべき action を明示的に分類・記録するための Workflow。
これは scheduler ではない。目的は、Ticket の fields / body / thread / artifacts / 現在の repository/Pod 状態を明示的に確認し、隠れた会話状態ではなく Ticket に基づいて routing 判断を残すことである。
Panel Queue / queued notification は、人間が Orchestrator に routing を開始してよいと許可した signal であり、unattended scheduler ではない。implementation side effect に進む場合は、Orchestrator が Ticket と workspace state を再確認し、unblocked と判断してから `queued -> inprogress` を記録する必要がある。明示的な routing / queue review / panel kick の中で、独立した queued Ticket と coder/reviewer capacity が複数あるなら、1件ずつ完了を待つことを default にせず、安全確認を満たす範囲で追加 Ticket の並列開始を優先する。
`ready` は Orchestrator routing に十分な状態であり、実装戦術が事前にすべて固定されている状態ではない。Orchestrator は、recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / escalation conditions が揃っていれば、bounded implementation uncertainty を残したまま implementation-ready と判断してよい。
`ready` / `queued` を `planning` に戻す判断は、Ticket text や risk flags だけで決めない。Orchestrator は、Ticket thread/artifacts、関連 Ticket / orchestration plan、関連 workflow/docs/code、durable project context、現在の repository/Pod/worktree 状態のうち relevant なものを bounded に確認し、実装前に必要な concrete missing decision / information が残っている場合だけ planning return とする。risk flags と risky domain は context lookup と reviewer focus の signal であり、automatic stop gate ではない。
## 位置づけ
```text
TicketCreate / TicketComment
-> Ticket Orchestrator Routing Workflow
-> planning return / requirements sync / spike / implementation / review / blocked / close
-> 必要に応じて他 Workflow へ接続
```
- Intake は Ticket の materialization と planning/clarification を担当する role であり、state 名ではない。
- state は `planning -> ready -> queued -> inprogress -> done` を基本遷移とする。
- `ticket-preflight-workflow` は legacy canonical id 互換の planning/requirements sync entry であり、`preflight` を独立 state / lane / long-lived operation として扱わない。
- `ready -> queued` は人間が Orchestrator routing を許可した状態であり、worktree 作成や Pod 起動の許可そのものではない。
- `multi-agent-workflow` は coder / reviewer Pod と worktree を使う実装・レビュー loop。
- この Workflow は自動 scheduler / lease / unattended maintainer ではない。
## Orchestrator の責務
Orchestrator は以下を行う。
- Ticket を `ShowTicket` で読む。
- 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
- Ticket が Objective context と結びついている場合は、Objective を medium-term goal / motivation / strategy / success criteria / decision context として読む。ただし Objective context は判断背景であり、Ticket body/thread/artifacts や explicit Ticket relations / OrchestrationPlan records を読む代替ではない。
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
- queued notification を受けた場合も、Ticket と workspace state を再確認してから routing する。
- next action を routing classification として決める。
- routing decision を `TicketComment` で Ticket thread に記録する。
- broad request や split/refinement では、long-lived umbrella/progress-container Ticket ではなく concrete implementable Ticket、Objective context、split decision record を使う。
- Objective-to-Ticket links は canonical opaque Ticket ID による non-blocking context link として扱い、dependency / blocking / ordering / ownership / scheduling relation と解釈しない。
- 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。
- implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。
- implementation-ready かつ Ticket が `queued` の場合は、worktree 作成 / implementation Pod `SpawnPod` / coder routing などの side effect の前に、既存の typed Ticket backend/tool path で `queued -> inprogress` を記録する。
- 人間による `ready -> queued` は、記録済み Ticket scopeについて、実装、current MRのguarded merge、completion記録、Ticket closeまでをWorkspace Orchestratorへ委任するdurable gateである。Orchestratorは`queued -> inprogress`を受理した後、current-ref approval、repository evidence、blocking relations、merge CASを確認して完了まで進め、別のmerge確認を待たない。Ticketがseparate approval gateを明記する場合、またはqueued scope外の新しい判断が必要な場合だけ停止する。
- 明示的な queue review 中に、他にも queued Ticket が見え、capacity が空いている場合は、各 Ticket について relation / orchestration-plan / dirty state / visible Pods / worktree / conflict risk を確認し、独立して受理できるものを同じ routing pass で追加の `queued -> inprogress` 候補にする。
- queued Ticket を capacity が見える状態で idle のまま残す場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかに絞った bounded reason を Ticket thread または `TicketOrchestrationPlanRecord` に残す。
- `ready` または `queued` に concrete missing decision / information がある場合だけ、typed state-change/routing event 付きで `planning` に戻す。その event/comment には missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。
## Orchestrator がしないこと
- 自動 scheduler として unattended に実行しない。
- Panel Queue / queued notification だけを unattended scheduler trigger として扱わない。
- `queued -> inprogress` acceptance なしに worktree 作成、implementation Pod `SpawnPod`、coder/reviewer routing を行わない。
- 人間/上位 Orchestrator の許可または明示的な routing acceptance なしに coder / reviewer Pod や read-only investigation helper Pod を起動しない。
- unqueued Ticket を capacity 埋めのために開始しない。parallel start の候補は、個別に `queued` であり、人間が routing を許可済みの Ticket に限る。
- 設計境界の未決定を勝手に implementation-ready として固定しない。
- Ticketが`ready -> queued`されておらず完了権限を委任されていない場合、またはTicketがseparate approval gateを明記する場合に、勝手にmerge / close / cleanupしない。queued delegationとguarded completion evidenceが揃っている場合は、追加のhuman gateを作らず完了まで進める。
- Ticket tools があるからといって arbitrary filesystem write を行わない。
- broad multi-Ticket effort のために新しい umbrella/progress-container Ticket を作らない。
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split/refinement の代替として扱わない。
- Notification だけを完了証拠にしない。Pod output / diff / validation / Ticket evidence を確認する。
- 具体的な不足項目を言語化できない場合に、単に risky という理由だけで `planning` に戻さない。その場合は IntentPacket に escalation / reviewer focus を明記して進める。
- risk flags / risky domain / authority-adjacent domain を automatic stop gate として扱わない。これらは bounded context lookup、IntentPacket invariant、reviewer focus、escalation condition に反映する signal である。
## 使用する Ticket tools
利用可能なら、以下を使う。
- `QueryTicket`: routing 候補、関連 Ticket、project-level forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) と derived blocker summary を bounded filter/projection で確認する。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。
- `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する。
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
- `TicketDependencyCheck`: queue/acceptance 直前の typed dependency readiness guard に使う。
- `TicketRelationRecord` / `TicketRelationRemove`: ユーザー合意済みの durable project relation を明示的に更新する場合だけ使う。
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
## Queued acceptance contract
- `queued -> inprogress` acceptance の直前に `ShowTicket` / `QueryTicket` の relation blocker projection を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
- Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。
`state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。
- unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。
- `before` / `after` / `blocked_by` / `blocks` / `conflicts_with` / `do_not_parallelize` / waiting-capacity 記録がある場合、それを acceptance 判断の入力にする。記録は自動 scheduler ではないため、実際に進めるかどうかは Orchestrator が読んだうえで明示的に決める。
- risk flags / risky domain がある場合は、IntentPacket に invariants / reviewer focus / escalation conditions を入れる。risk flag だけを `queued -> planning` の理由にしない。
- concrete missing decision / information がある場合: `TicketWorkflowState` で `queued -> planning` を記録し、reason/body と `TicketComment` に不足項目、checked context、なぜ coder の implementation latitude では解決できないか、次の planning question/action を残す。既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。
- external action 待ちなど planning では解決しない blocker の場合: concise な理由を Ticket thread に記録し、必要に応じて attention / action-required frontmatter や `TicketOrchestrationPlanRecord` の blocker/waiting-capacity 記録で明示する。lifecycle 外の storage bucket を routing target にしない。
Parallel acceptance pass:
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、QueryTicket の relation/blocker projection、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
- 追加で開始してよいのは、blocking relation/dependency がなく、`do_not_parallelize` または applicable conflict record がなく、source/write surfaces が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、acceptance basis となる Ticket thread/plan/workspace records を side effect 前に記録・commit でき、別 worktree/branch/scope を切れる Ticket だけである。
- capacity が見えるのに queued Ticket を idle にする場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかの bounded reason を記録する。
- この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。
Invariant:
- `queued -> inprogress` は Orchestrator acceptance marker であり、coder Pod が既に動いていることの後追い記録ではない。
- `queued -> inprogress` に失敗した場合、implementation/review Pod を spawn しない。
- `inprogress` acceptance 後に worktree/spawn/first-run が失敗した場合は、queued に黙って戻さず、in-progress Ticket に failure/block/recovery note を記録する。
- Queue notification だけを根拠に blind spawn しない。必ず Ticket と workspace state を再確認する。
## Routing classification
Orchestrator は対象 Ticket を以下のいずれかに分類する。複数に見える場合は、次に必要な action が最も早いものを選ぶ。
### `requirements_sync_needed`
まだ `planning` に留めるべき、または `planning -> ready` に進める前に clarification が必要な状態。
条件:
- Ticket の目的は見えるが、observable な完了条件が書けない。
- ユーザー判断がないと product/API/UX を固定してしまう。
- acceptance criteria、binding decisions / invariants、または escalation conditions が不足している。
- existing decisions / docs / closed Tickets と矛盾する可能性がある。
Action:
- Intake / human / Planning sync に戻す。
- `TicketComment` で不足情報と質問を記録する。
- coder Pod は起動しない。
### `return_to_planning`
`ready` または `queued` とされているが、bounded project-context checks の後でも、実装 side effect 前に解決すべき concrete missing decision / information が残っている状態。
条件:
- product / API / UX / authority boundary / storage migration / security / secrets などについて、実装前に決めなければならない具体項目がある。
- 複数の自然な方針があり、human / Orchestrator decision なしでは固定できない。
- acceptance criteria、binding decisions、または escalation conditions に、実装可否を左右する具体的欠落がある。
- Ticket text / risk flags だけではなく、関連 Ticket/thread/artifacts、orchestration plan、関連 workflow/docs/code、durable project context、現在の workspace evidence の relevant subset を確認しても、既存 recorded decision が見つからない。
- その不足は coder の bounded implementation latitude / local tactic selection では安全に解消できない。
Action:
- `TicketWorkflowState` で `ready -> planning` または `queued -> planning` を記録する。
- reason/body と `TicketComment` に以下を明記する。
- concrete missing decision / information。
- context checked(Ticket thread/artifacts、関連 Ticket/plan、docs/workflow/code/durable context/workspace state のうち実際に確認したもの)。
- why implementation latitude is insufficient(なぜ coder/reviewer の local tactic や escalation condition では足りないか)。
- next planning question/action。
- risk flag / risky domain だけを return reason にしない。
- 既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。実用的な経路がない場合は follow-up として report する。
- planning が再度 `ready` にするまで coder Pod は起動しない。
### `spike_needed`
実装前に read-only 調査が必要。
条件:
- 技術的実現性が不明。
- dependency / license / packaging / portability / performance / diagnostics の確認が必要。
- current code map が不足している。
- bug の再現条件や観測 evidence が不足している。
Action:
- read-only investigation を提案する。
- 許可があれば task-specific read-only helper Pod を普通の scoped Pod として起動できる。
- `TicketComment` に調査問い・scope・完了条件を記録する。
- 実装 worktree はまだ作らない。
### `implementation_ready`
既存 multi-agent workflow に渡せる状態。
条件:
- intent / binding decisions / invariants / implementation latitude / acceptance criteria が明確。
- binding decisions / invariants と implementation latitude が区別されている。
- reviewer が判断する basis と escalation conditions が明確。
- validation が書ける。
- design / authority boundary の未決定がない、または planning return / human decision で補われている。
- risk flags / risky domain は context lookup と reviewer focus に反映済みで、bounded context check 後に concrete missing decision / information が残っていない。
- 残る不確実性が bounded implementation investigation / local tactic selection に閉じている。
- IntentPacket を短く書ける。
Action:
- `IntentPacket` を作る。risky-but-specified Ticket では、risk を stop reason にせず、binding invariants / implementation latitude / escalation conditions / Critical risks / reviewer focus として記録する。
- project-relevant な ordering / blocker / conflict / capacity decision や accepted work plan がある場合は、`TicketOrchestrationPlanRecord` に bounded typed record として残す。local session/socket/raw model output は入れない。
- `TicketComment` に routing decision と IntentPacket を記録する。
- 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。
### `review_needed`
実装はあるが、review / acceptance が未完了。
条件:
- implementation report / branch / commit / diff がある。
- external review がない、または reviewer blocker が未解決。
- validation evidence が足りない。
Action:
- reviewer Pod 起動または追加 validation を提案する。
- reviewer は recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions に照らして判断する。不記録の好みや Orchestrator の未共有 preferred tactic を基準にしない。
- `TicketComment` に review target と確認観点を記録する。
- blocker 未解決のまま merge-ready としない。
### `blocked_by_dependency_or_missing_authority`
人間判断または外部イベント待ち。
条件:
- design/product/security 判断が必要だが、planning で同期すれば進められる種類ではない。
- credential / secret / environment / external service が必要。
- 別 Ticket / branch / upstream change の完了待ち。
- scope/permission が不足している。
Action:
- 必要な判断・外部 action を短く書く。
- `TicketComment` に blocked reason と next question を記録する。
- 必要に応じて typed relation metadata や orchestration plan の blocker/waiting-capacity 記録で、待ち理由を current state とは別に表す。lifecycle 外の storage bucket へ移す route は使わない。
### `close_ready`
完了処理に進める状態。
条件:
- requirements / acceptance criteria が満たされている。
- review / validation / merge / cleanup evidence が揃っている。
- resolution を書ける。
- close 権限がある。
- 既存 umbrella/progress-container Ticket については、concrete follow-up Ticket と必要な Objective context が存在し、container role を superseded/decomposed として退役できる。
Action:
- `TicketClose` または既存 close workflow で resolution を記録する。
- umbrella/progress-container Ticket を退役する close resolution では、関連作業がすべて完了したという意味ではなく container role を retired したことを明記し、完了済み concrete Ticket と残る follow-up Ticket / Objective を列挙する。
- close 権限がない場合は merge-ready / close-ready dossier を親/人間に提出する。
### `closed_or_noop`
routing 不要。
条件:
- closed Ticket。
- duplicate として既存 Ticket に統合済み。
- Ticket 自体が不要と判断済み。
Action:
- 追加 action なし。
- 必要なら related Ticket へ comment する。
## Routing 手順
### 1. 状態確認
- `git state --short --branch`
- `ShowTicket <target>`
- 関連 Ticket の `QueryTicket` / `ShowTicket`
- 必要に応じて docs/code/workflow/history
- 必要に応じて visible Pods / worktrees / branches
この段階で unrelated dirty changes がある場合、実装/merge には進まず、routing decision の記録だけに留めるかユーザーに確認する。
### 2. Ticket evidence を読む
最低限、以下を確認する。
- Background
- Requirements
- Acceptance criteria
- Binding decisions / invariants
- Implementation latitude
- Readiness / open questions / risk flags
- Escalation conditions
- Validation
- Thread の plan / decision / implementation_report / review
- Artifacts / branch / commit references
- risk flags が示す domain(authority-boundary / scope / permission / Pod / persistence / prompt-context / public-api など)
### 3. Bounded project-context checks を行う
Ticket evidence だけで planning return を決めない。risk flags や risky domain がある場合は、必要な最小限の project context を確認する。
代表的な確認先:
- Ticket thread/artifacts/resolution と関連 Ticket。
- `TicketOrchestrationPlanQuery` の accepted-plan / ordering / blocker / conflict 記録。
- 関連 workflow/docs/design/development docs。
- 現在の code map や既存 implementation pattern。
- durable memory/Knowledge/project decisions(利用可能で、判断に関係する場合)。
- repository / branch / worktree / visible Pod state。
目的は broad repository archaeology ではなく、以下を区別できるだけの bounded check である。
- genuinely missing binding decision / requirement / invariant / acceptance criterion。
- Ticket thread、関連 Ticket、docs/workflows/code、durable context にすでに記録された decision。
- coder が implementation latitude の中で選べる local tactic / bounded investigation。
### 4. Classification を決める
例:
- implementation-ready に見えるが authority boundary の explicit decision がなく、bounded context check でも recorded decision がない → concrete missing decision として `return_to_planning`; routing record に missing item / checked context / implementation latitude では足りない理由 / next question を書く。
- implementation-ready に見えるが単に risk が高い → `implementation_ready` とし、IntentPacket に escalation / reviewer focus を明記する。
- `allow-spawnpod-child-workspace-cwd` のように scope / Pod / authority-adjacent domain に触れるが、intent、authority invariants、implementation latitude、escalation conditions が指定済み → `implementation_ready`。context check で既存 SpawnPod scope/cwd policy と workflow invariants を確認し、reviewer focus に authority leakage / workspace-cwd separation / delegation-scope preservation を入れる。risk domain だけを理由に `planning` へ戻さない。
- 実装済みだが review がない → `review_needed`
- 要件が曖昧で spike も必要そう → `requirements_sync_needed` を優先し、調査問いを明確化する
- 完了しているが close 権限がない → `close_ready` として dossier を返す
### 5. Routing decision を Ticket に記録する
`TicketComment` の role は通常 `decision` または `plan` を使う。
標準形:
```markdown
Routing decision: <classification>
Reason:
- ...
Evidence checked:
- Ticket body / thread / artifacts
- related Ticket(s) / orchestration plan records
- code/docs/workflow paths
- branch/worktree/Pod state if relevant
If returning to planning:
- Missing decision/information: ...
- Context checked: summarize the relevant subset from Evidence checked above
- Why implementation latitude is insufficient: ...
- Next planning question/action: ...
Next action:
- ...
Escalate if:
- ...
```
### 6. IntentPacket を作る(implementation_ready の場合)
`multi-agent-workflow` に渡す前に、以下を短くまとめる。
```text
Intent:
- 何を実現するか。
Binding decisions / invariants:
- 人間/Orchestrator/Ticket に記録済みで coder / reviewer が従うべき decision と、壊してはいけない design / authority boundary。
- 具体的な除外・触れてはいけない場所が binding decision である場合はここに書く。
Requirements / acceptance criteria:
- 完了時に満たす observable な要件と reviewer が判断できる基準。
Implementation latitude:
- Coder が調査しながら選んでよい local tactic / file-local organization / bounded uncertainty。
Escalate if:
- 親/人間に戻す判断条件。特に product / API / UX / authority boundary / explicit design constraint を変える必要が出た場合。
Validation:
- 実行すべき format / build / test / doctor。
Current code map:
- 実装対象と触ってはいけない場所。
Critical risks / reviewer focus:
- reviewer にも見てほしい失敗パターン。reviewer は recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions に照らして判断し、不記録の preferred tactic を基準にしない。
```
IntentPacket が短く書けない場合、`implementation_ready` ではなく `return_to_planning` または `requirements_sync_needed` に戻す。
### 7. 後続 Workflow へ接続する
- `requirements_sync_needed` → `ticket-intake-workflow` / human / planning sync
- `return_to_planning` → `ticket-preflight-workflow`(legacy compatibility canonical id の planning sync entry)
- `spike_needed` → read-only investigation plan / Pod(許可後)
- `implementation_ready` → `multi-agent-workflow`
- `review_needed` → reviewer Pod / review workflow
- concrete blocker / missing decision → human / parent Orchestrator
- `close_ready` → close workflow / maintainer decision
## 完了条件
この Workflow の完了条件は次のいずれかである。
- routing decision が Ticket に記録され、次に接続する Workflow / human action が明確である。
- `ready` / `queued` を `planning` に戻した場合、typed state-change/routing event に concrete missing decision / information、checked context、implementation latitude では足りない理由、次の planning question/action が残っている。
- implementation-ready Ticket について IntentPacket が Ticket に記録され、`multi-agent-workflow` に渡せる。
- requirements-sync / planning return / spike / blocked / review / close-ready の理由と次 action が Ticket に記録されている。
- routing 不要と判断され、その理由が明確である。
## この Workflow で固定しないもの
- unattended scheduler。
- LeaseStore / queue persistence。
- queue/dashboard UI。
- automatic Pod spawning policy。
- TicketUpdate tool の導入。
- external tracker integration。
これらは routing decision を人間/Orchestrator が明示的に扱えるようになった後の follow-up とする。
@@ -1,89 +0,0 @@
---
description: 互換 canonical id を残した Ticket planning / requirements sync workflow。preflight を独立 lane や state として扱わない。
model_invokation: true
user_invocable: true
requires: []
---
# Ticket Planning / Requirements Sync Workflow
このファイル名は既存の workflow discovery / durable references 互換のために残す。新しい概念としての `preflight` state / lane / long-lived operation は作らない。ここで扱う作業は、Ticket を `planning` に戻して不足している決定・情報・受け入れ条件を同期するための planning activity である。
## 目的
実装に入る前または Orchestrator routing 中に、bounded project-context checks 後も concrete missing decision / information / acceptance condition が残る Ticket を planning に戻し、Ticket thread に監査可能な形で同期内容を残す。
この workflow は次をしてはいけない。
- `preflight` を state として扱う。
- `preflight` vocabulary を current Ticket metadata として新規に書く。
- 「リスクがある」だけで Ticket を戻す。
- broad effort の進捗を保持するためだけの umbrella/progress-container Ticket を作る。
- parent/child、sub-ticket、part-of、contains などの hierarchy/container relation を split/refinement の代替として設計する。
- Coder / Reviewer / worktree mechanics を再設計する。
## 適用条件
次のいずれかを満たす場合に使う。
- `planning` Ticket の要件・受け入れ条件・制約を明確化する。
- ユーザーが concrete work item として求めた initial planning / design / investigation Ticket の scope と acceptance criteria を明確化する。
- `ready` または `queued` Ticket について、Orchestrator が Ticket/thread/artifacts、関連 Ticket/plan、関連 workflow/docs/code、durable project context、workspace evidence の relevant subset を bounded に確認したうえで、実装開始前に concrete missing decision / information を特定した。
- 既存 Ticket に obsolete state vocabulary が残っており、planning terminology へ整理する必要がある。
適用しない条件:
- Orchestrator が具体的な不足項目、checked context、implementation latitude では足りない理由を言語化できない。
- 単に変更範囲が広い、リスクが高い、またはレビュー観点が多いだけである。
- すでに `queued -> inprogress` が記録され、実装 side effect が始まっている。
この場合は Ticket を戻さず、IntentPacket に escalation / reviewer focus を明記して進める。
## 手順
1. Ticket の current frontmatter と recent thread を読む。
2. `ready` / `queued` から planning に戻す場合は、関連 Ticket/plan、docs/workflow/code、durable project context、workspace state の relevant subset を bounded に確認する。
3. 不足している decision / information / acceptance condition を箇条書きで特定し、なぜ coder/reviewer の implementation latitude や escalation condition では足りないかを書く。
4. `ready` または `queued` から戻す場合は、typed state change で `to = planning` を記録する。reason/body には concrete missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。
5. 既存の claimed live/restorable Intake/Planning Pod があり、利用可能な通知経路がある場合は、その Pod に同じ不足理由を通知する。実用的な経路が無い場合は follow-up として report する。
6. Ticket body または thread に requirements sync 結果を残す。広い依頼を分割する場合は、concrete follow-up Ticket / Objective context / split decision record へ責務を分け、umbrella container や hierarchy relation を作らないことを記録する。
7. Ticket が queue 可能になったら `planning -> ready` を typed state change / `TicketIntakeReady` で記録する。
## 記録テンプレート
```markdown
## Planning sync
Missing decisions / information:
- ...
Context checked:
- Ticket/thread/artifacts: ...
- related Ticket/plan: ...
- docs/workflow/code/durable/workspace context: ...
Why implementation latitude is insufficient:
- ...
Next planning question/action:
- ...
Decisions made:
- ...
Acceptance criteria changes:
- ...
Risk / reviewer focus:
- ...
Readiness:
- Keep in planning because ...
- or mark ready because ...
```
## 完了条件
- Ticket に具体的な不足項目または解決済み decision が記録されている。
- `planning` に戻した場合、state_changed event または thread に concrete missing decision / information、checked context、implementation latitude では足りない理由、次の planning question/action が残っている。
- `ready` に進める場合、未解決の blocking attention/action が残っていない。
-171
View File
@@ -1,171 +0,0 @@
---
description: yoi プロジェクトで child git worktree を作成・管理するための機械的手順。coder Pod に作らせず、orchestrator Pod が Orchestrator workspace/orchestration branch から実行する。
model_invokation: true
user_invocable: true
requires: []
---
# Worktree Workflow
yoi プロジェクトで実装差分を root/original workspace から分離するため、記録済み implementation worktree root の `.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、coder / reviewer sibling の起動、外部レビュー、orchestration branch への integration は `/multi-agent-workflow` 側で扱う。
yoi では Pod の write scope が排他的に委譲されるため、child Pod の write scope は child worktree に限定する。child worktree は Yoi project records marker として tracked `.yoi` records を含んでよいが、generated/personal memory root `.yoi/memory`、local override、runtime state、logs、locks、secret-like files は出さない。Orchestrator workspace or recorded Ticket backend remains the place for active orchestration progress, review evidence, integration records, and Ticket lifecycle updates.
## 適用範囲
この Workflow は親 Pod / 下位 orchestrator が Orchestrator workspace/orchestration branch から実行する。root/original workspace は read/write/validation/cleanup/git 操作の対象にしない。implementation worktree は記録済み implementation worktree root 配下に作る。
- coder Pod にこの Workflow を渡して worktree を作らせない。
- coder Pod は、orchestrator が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
- reviewer Pod は、coder Pod の子ではなく orchestrator 配下の sibling として、原則 read-only で child worktree と Orchestrator workspace の Ticket/context を読む。
- ticket 作成、active orchestration progress、review evidence、integration、Ticket lifecycle update は Orchestrator workspace または記録済み Ticket backend 側で扱う。
- branch-local artifacts / dossiers / docs/report / tracked `.yoi` project records は、実装対象に必要なら child worktree 内で扱ってよい。
## 原則
- 1 ticket / 1 実装 task につき 1 worktree を作る。
- 複数 ticket を下位 orchestrator に任せる場合も、実装差分は ticket / bounded task ごとに worktree を分ける。
- worktree path は `<implementation-worktree-root>/.worktree/<task-name>`。
- branch 名は原則 `<task-name>` と同じ kebab-case。
- child worktree には `.yoi` project records を出してよい。
- child worktree では `.yoi/memory`、local/runtime/log/lock/secret-like paths を sparse checkout で除外する。
- active orchestration progress、review evidence、integration、Ticket lifecycle update は Orchestrator workspace または記録済み Ticket backend 側で扱う。branch-local artifacts/dossiers は child worktree 内に置いてよい。
- push はしない。
## 事前確認
作成前に以下を確認する。
1. 対象 ticket / task が決まっているか。
2. `<task-name>` が branch / path 名に使える kebab-case か。
3. `git worktree add` を実行してよい許可があるか。
4. root/original workspace で実行しようとしていないか。
5. 同名 branch / worktree が既に存在しないか。
6. coder / reviewer を sibling として扱う orchestrator が誰か明確か。
同名 branch がある場合は、既存 branch を使うか、人間に確認する。`git worktree add -b` で上書きしない。
## 作成手順
Orchestrator workspace で実行する。root/original workspace では実行しない。
```bash
git -C <orchestrator-workspace-root> worktree add <implementation-worktree-root>/.worktree/<task-name> -b <task-name> HEAD
git -C <implementation-worktree-root>/.worktree/<task-name> sparse-checkout init --no-cone
git -C <implementation-worktree-root>/.worktree/<task-name> sparse-checkout set --no-cone \
'/*' \
'!/.yoi/memory/' \
'!/.yoi/memory/**' \
'!/.yoi/logs/' \
'!/.yoi/logs/**' \
'!/.yoi/_logs/' \
'!/.yoi/_logs/**' \
'!/.yoi/**/_logs/' \
'!/.yoi/**/_logs/**' \
'!/.yoi/locks/' \
'!/.yoi/locks/**' \
'!/.yoi/**/*.log' \
'!/.yoi/**/*.lock' \
'!/.yoi/**/.lock' \
'!/.yoi/override.local.toml' \
'!/.yoi/**/*.local' \
'!/.yoi/**/*.local.*' \
'!/.yoi/local/' \
'!/.yoi/local/**' \
'!/.yoi/runtime/' \
'!/.yoi/runtime/**' \
'!/.yoi/pods/' \
'!/.yoi/pods/**' \
'!/.yoi/sessions/' \
'!/.yoi/sessions/**' \
'!/.yoi/sockets/' \
'!/.yoi/sockets/**' \
'!/.yoi/tmp/' \
'!/.yoi/tmp/**' \
'!/.yoi/cache/' \
'!/.yoi/cache/**' \
'!/.yoi/secrets/' \
'!/.yoi/secrets/**' \
'!/.yoi/**/*.secret' \
'!/.yoi/**/*.secret.*'
```
この sparse-checkout は `.yoi` 自体を除外しない。`.yoi/memory` は generated/personal memory marker として child worktree から外し、generated log trees は root `_logs` だけでなく recursive `.yoi/**/_logs/**` も外す。memory root detection の実装変更はこの Workflow では扱わない。
確認する。
```bash
git -C <implementation-worktree-root>/.worktree/<task-name> status --short --branch
test ! -e <implementation-worktree-root>/.worktree/<task-name>/.yoi/memory
if test -d <implementation-worktree-root>/.worktree/<task-name>/.yoi; then
test ! -e <implementation-worktree-root>/.worktree/<task-name>/.yoi/override.local.toml
test -z "$(find <implementation-worktree-root>/.worktree/<task-name>/.yoi \
\( -path '*/_logs' -o -path '*/logs' -o -path '*/locks' \
-o -path '*/local' -o -path '*/runtime' -o -path '*/pods' \
-o -path '*/sessions' -o -path '*/sockets' -o -path '*/tmp' \
-o -path '*/cache' -o -path '*/secrets' -o -name '*.log' \
-o -name '*.lock' -o -name '.lock' -o -name '*.local' \
-o -name '*.local.*' -o -name '*.secret' -o -name '*.secret.*' \) \
-print -quit)"
fi
```
この確認は `.yoi` project records の存在を失敗扱いしない。`.yoi/memory` と local/runtime/log/lock/secret-like paths が出ていないことを確認する。
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
## Pod へ渡す scope
Pod を使う場合、coder Pod の SpawnPod `cwd` は child worktree に設定する。`cwd` は child process/tool default cwd だけを変え、runtime workspace root・project record root・scope/authority は変えないため、下の明示 scope は別途渡す。
coder Pod 推奨 scope:
```text
read: <implementation-worktree-root>/.worktree/<task-name>
write: <implementation-worktree-root>/.worktree/<task-name>
```
reviewer Pod 推奨 scope:
```text
read: <implementation-worktree-root>/.worktree/<task-name>
read: <orchestrator-workspace-root> # Ticket/intent/review context が必要な範囲に限定する
```
reviewer は原則 write scope を持たない。review artifact を書かせる必要がある場合だけ、ticket artifacts など限定 directory を write scope として渡す。
より狭く切れる場合は、coder の write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
## child worktree 内の禁止事項
- `.yoi/memory` を作らない / コピーしない / 復元しない。
- local overrides、runtime sockets/state、Pod session mirrors、cache/tmp、logs、locks、secret-like files を作らない / コピーしない / commit しない。
- root/original workspace の `TODO.md` / `tickets/` / `docs/report/` / `.yoi` を読まない・編集しない。
- active orchestration progress、review evidence、integration、Ticket lifecycle update を child worktree 内だけで完結させない。
- 実装対象に必要な tracked `.yoi` project records、branch-local artifacts / dossiers / docs/report は child worktree 内で扱ってよい。
- merge / push / branch deletion / worktree remove をしない。
- scope / permission / history persistence / prompt context 加工原則に関わる設計変更を無断で行わない。
## 完了時の扱い
worktree 作成 Workflow としては、完了時に integration しない。orchestration branch への integration、Ticket 完了、child worktree cleanup は `/multi-agent-workflow` 側で Orchestrator workspace に対して行う。root/original workspace は対象にしない。
coder Pod へ渡す完了報告項目の標準形:
- worktree path
- branch 名
- commit hash(coder Pod に commit を許可した場合)
- 変更ファイル
- 実装概要
- 実行した build / test / format
- 未解決事項
- review に回せるか
reviewer Pod へ渡す完了報告項目の標準形:
- 読んだ ticket / intent packet / diff
- 実際のコード変更の概念的説明
- intent / requirements / invariant との対応
- blocker / non-blocker / follow-up
- validation の妥当性
- 親または上位 orchestrator が判断すべき残論点
-22
View File
@@ -1,22 +0,0 @@
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
created_at = "2026-06-23T00:00:00Z"
display_name = "yoi"
[ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local"
root = ".yoi/tickets"
[ticket.roles.intake]
profile = "builtin:intake"
[ticket.roles.orchestrator]
profile = "builtin:orchestrator"
[ticket.roles.coder]
profile = "builtin:coder"
[ticket.roles.reviewer]
profile = "builtin:reviewer"
+16 -62
View File
@@ -1,68 +1,22 @@
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
# 開発時の入口
## このシステムに置ける設計要旨
変更量を最小にするために設計を歪めたり、不必要な後方互換性を作ったりしない。長期的な保守性と型安全性を優先する。
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
## 作業条件に応じて読む規則
### LLM コンテキストの加工原則
該当する作業の前に、次の文書を読み、その規則に従う。
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
- 文書・説明コメントを追加、変更、削除する → [文書管理規則](docs/README.md)
- 正本、権限、状態の競合制御、LLM入力・履歴を扱う → [正本・権限・実行の原則](docs/principle/authority-and-history.md)
- 永続操作、外部副作用、再試行・復旧を設計する → [永続操作の原則](docs/principle/durable-operations.md)
- テストを追加・変更する → [テスト設計規則](docs/development/rust-testing-strategy.md)
- 変更を検証する・作業を完了する → [検証規則](docs/development/validation.md)
- 保存schemaやmigrationを変更する → [保存schema変更の規則](docs/development/workspace-schema-migrations.md)
- Webの画面・操作を設計する → [UX設計](docs/principle/ux-design.md)・[Design Language](docs/principle/design-language.md)。描画を変更したら[目視確認規則](docs/development/ui-ux/visual-review.md)にも従う。
- Ticket・Objective・作業記録を扱う → [作業記録と判断の規則](docs/development/work-items.md)
- 稼働環境に関わる作業や、開発中の障壁を報告する → [ドッグフーディングの安全規則](docs/development/dogfooding.md)
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
## 常に守ること
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
---
## 検証
開発中は、変更した契約を証明する最小の target / filter から実行する。
```sh
cargo test -p <crate> --lib <test-or-module-filter>
cargo test -p <crate> --test <test-target> <test-filter>
```
完了前には、workspace rootで必ず`cargo check`を実行する。rootの`cargo check`は
`default-members`に含まれるTUIやServerを含む通常のcompile closureを確認するため、公開型の
変更ごとにLLMがreverse dependencyを推測して`-p`を列挙する運用にはしない。
```sh
cargo check
cargo test -p <changed-crate>
cargo fmt --all -- --check
git diff --check HEAD
```
変更したcrate全体のtestに加え、影響するfeature構成やtest-only targetがある場合は、その検証を
追加する。`cargo check`はtestを実行せず、通常有効でないfeatureまでは確認しないため、semanticな
証明とfeature境界の検証はtargeted test/checkで補う。
workspace全体のtest、`--all-targets`、E2E、Nix/Docker buildなどの重い検証は、変更した境界を
通常のroot checkと狭い検証では証明できない場合や、明示的に要求された場合に選ぶ。実行した検証が
何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
---
## ドッグフーディング時の Ticket 境界
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
system instructions と、その Worker に提供された typed Ticket tools に従うこと。
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
Yoi Worker に委ねる。
---
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
---
絶対に自身が動作しているプロセスを止めないこと。
マージ後のドッグフーディング環境の更新は必ずユーザーの操作で行う。
- 自身を実行しているプロセスを停止しない。マージ後の稼働環境の更新は利用者が行う。
- Ticket操作は提供されたtyped toolsとsystem instructionsに従い、CLIや保存先の直接編集で代替しない。
Generated
+1169 -656
View File
File diff suppressed because it is too large Load Diff
+62 -3
View File
@@ -4,12 +4,18 @@ members = [
"crates/client",
"crates/agen",
"crates/agen-macros",
"crates/api-macros",
"crates/api-macros-impl",
"crates/session-store",
"crates/standalone",
"crates/job",
"crates/secrets",
"crates/manifest",
"crates/mcp",
"crates/worker",
"crates/worker-runtime",
"crates/runtime-api",
"crates/server-api",
"crates/plugin-pdk",
"crates/yoi",
"crates/protocol",
@@ -19,15 +25,18 @@ members = [
"crates/tools",
"crates/fs-operation",
"crates/flow",
"crates/builtin-source",
"crates/config-source",
"crates/config-source-wasm",
"crates/workdir",
"crates/tui",
"crates/memory",
"crates/feature-storage",
"crates/workspace-drive",
"crates/subjektiv",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
"crates/workspace-api",
"crates/workspace-server",
"tests/e2e",
]
@@ -35,12 +44,18 @@ default-members = [
"crates/client",
"crates/agen",
"crates/agen-macros",
"crates/api-macros",
"crates/api-macros-impl",
"crates/session-store",
"crates/standalone",
"crates/job",
"crates/secrets",
"crates/manifest",
"crates/mcp",
"crates/worker",
"crates/worker-runtime",
"crates/runtime-api",
"crates/server-api",
"crates/plugin-pdk",
"crates/yoi",
"crates/protocol",
@@ -50,45 +65,73 @@ default-members = [
"crates/tools",
"crates/fs-operation",
"crates/flow",
"crates/builtin-source",
"crates/config-source",
"crates/config-source-wasm",
"crates/workdir",
"crates/tui",
"crates/memory",
"crates/feature-storage",
"crates/workspace-drive",
"crates/subjektiv",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
"crates/workspace-api",
"crates/workspace-server",
]
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.88"
[profile.dev]
debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
# Keep the real SQLite engine in schema/transaction tests without paying for
# an unoptimized C interpreter on every statement. Rust test assertions stay enabled.
[profile.test.package.libsqlite3-sys]
opt-level = 2
# Schema/authority integration tests execute substantial parsing and validation.
# Optimize that code without disabling test/debug assertions or overflow checks.
[profile.test.package.yoi-workspace-server]
opt-level = 1
[workspace.dependencies]
# Internal crates
client = { path = "crates/client" }
agen = { path = "crates/agen", version = "0.2" }
agen-macros = { path = "crates/agen-macros", version = "0.2" }
api-macros = { path = "crates/api-macros" }
manifest = { path = "crates/manifest" }
mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" }
feature-storage = { path = "crates/feature-storage" }
workspace-drive = { path = "crates/workspace-drive" }
subjektiv = { path = "crates/subjektiv" }
merge-request = { path = "crates/merge-request" }
ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" }
worker = { path = "crates/worker" }
worker-runtime = { path = "crates/worker-runtime" }
workspace-api = { path = "crates/workspace-api" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
yoi = { path = "crates/yoi" }
protocol = { path = "crates/protocol" }
session-metrics = { path = "crates/session-metrics" }
session-analytics = { path = "crates/session-analytics" }
session-store = { path = "crates/session-store" }
standalone = { path = "crates/standalone" }
job = { path = "crates/job" }
runtime-api = { path = "crates/runtime-api" }
server-api = { path = "crates/server-api" }
secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" }
builtin-source = { path = "crates/builtin-source" }
config-source = { path = "crates/config-source" }
fs-operation = { path = "crates/fs-operation" }
workdir = { path = "crates/workdir" }
@@ -101,11 +144,13 @@ yoi-workspace-server = { path = "crates/workspace-server" }
async-trait = "0.1"
axum = "0.8"
base64 = "0.22.1"
bytes = "1"
decodal = "0.4.0"
decodal-language-service = "0.4.0"
decodal-language-tools = "0.4.0"
fs4 = "0.13"
futures = "0.3"
http = "1"
libc = "0.2"
schemars = "1.2"
serde = "1.0"
@@ -115,6 +160,7 @@ tar = "0.4"
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
ring = "0.17.14"
sha2 = "0.11"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
tempfile = "3.27"
thiserror = "2.0"
tokio = "1.52"
@@ -122,6 +168,19 @@ tokio-tungstenite = "0.29"
tower = "0.5"
toml = "1.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
url = "2.5"
uuid = "1.23"
# Released structured scope/lifetime contract and canonical Text View, one registry source.
wip-client = "=0.2.0"
wip-http = "=0.2.0"
wip-protocol = "=0.2.0"
wip-text-view = "=0.2.0"
zeroize = "1"
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
[workspace.metadata.wip-upstream]
repository = "https://gitea.hareworks.net/Hare/wip-rs"
version = "0.2.0"
reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
source = "crates.io"
-8
View File
@@ -1,8 +0,0 @@
# Known Issues
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
- `crates/worker/src/controller.rs:1453-1461` — `worker_error_code` で `WorkerError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
- `crates/session-store/src/fs_store.rs:200-210` — `FsStore::read_entry_count` が `fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。
- `crates/session-store/src/segment.rs:143-172` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/worker/src/worker.rs:2032` `Worker::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Worker state / fork 周辺を次に触るときに統合を検討。
- `crates/worker/src/worker.rs` / `crates/worker/src/spawn/registry.rs:84-174` — restore 時の spawned child prune/reclaim が Worker restore path と spawned registry load path の両方に残っている。現状は安全側の重複チェックだが、Worker state / spawned registry 周辺を次に触るときに責務境界を再整理。
+11 -80
View File
@@ -1,85 +1,16 @@
# 夜居 / Yoi agent
# 夜居 / Yoi
Yoi is an agent runtime for building, running, and orchestrating LLM Workers while preserving explicit history, scoped capabilities, and developer-controlled workflows.
Yoiは、LLM Workerの実行履歴と明示的な権限を管理するエージェント実行基盤である。Workspaceの記録と操作権限をBackendが管理し、RuntimeがWorkerと作業ディレクトリの実行資源を管理する。単一プロセスで実行するStandaloneは、Workspaceの権限やRuntime Workerの登録とは分離している。
## 1. Yoi agent
## 実装の入口
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Worker can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Workers, and be inspected or restored through CLI/TUI surfaces.
- [agen](crates/agen/README.md):LLMターン、履歴、ツール呼出し。
- [worker](crates/worker/README.md):セッション、機能、ツールと実行権限の接続。
- [workspace-server](crates/workspace-server/README.md):Workspaceの正本とBackend操作。
- [worker-runtime](crates/worker-runtime/README.md):Workerの実行とRuntime資源。
- [standalone](crates/standalone/README.md):プロセス内の独立した実行ホスト。
- [yoi](crates/yoi/README.md)・[tui](crates/tui/README.md)・[Web](web/workspace/README.md):利用者との接点。
Main highlights:
各crateのREADMEをcrateドキュメントとして使い、具体的な設計理由はmodule・itemのrustdocに置く。文書の追加・維持・削除は[文書管理規則](docs/README.md)、変更の検証は[検証規則](docs/development/validation.md)に従う。
- Named long-running **Workers** with durable session and metadata records.
- Explicit tool permissions and filesystem scopes.
- Multi-agent orchestration with scoped coder/reviewer Workers.
- Profile, Manifest, and prompt-based runtime configuration.
- Local Tickets and workflow files for auditable project coordination.
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Worker Console.
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
## 2. Quick Install
From source:
```sh
cargo build --release -p yoi
./target/release/yoi --help
```
With Nix:
```sh
nix build .#yoi
./result/bin/yoi --help
```
## 3. Getting Started
```sh
yoi --help
yoi
yoi panel
yoi --worker <name>
yoi worker --help
```
Typical flow:
1. Configure providers, models, profiles, prompts, and scopes.
2. Start or attach to a named Worker in the Console, or inspect workspace activity in the Dashboard.
3. Use explicit tools and scoped delegation for multi-agent work.
4. Record project work through Tickets, workflow files, and git history.
Runtime surfaces use `yoi`, `.yoi`, `~/.yoi`, and `YOI_*`.
## 4. Documentation
Start with [`docs/README.md`](docs/README.md).
Key docs:
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
- [`docs/design/worker-session-state.md`](docs/design/worker-session-state.md) — Worker identity, metadata, and session logs.
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.
- [`docs/development/validation.md`](docs/development/validation.md) — validation expectations.
## 5. Development
This repository dogfoods Yoi to develop Yoi. Work is tracked through `.yoi/tickets/` and `yoi ticket ...`; git history plus Ticket files are the authoritative project record.
Common checks:
```sh
yoi ticket doctor
git diff --check
cargo fmt --check
cargo check --workspace --all-targets
cargo test --workspace
```
Real-process E2E testing is opt-in. Do not design or implement E2E coverage unless the work explicitly requires E2E; otherwise protect the narrower parser, API, protocol, authority, or runtime boundary.
License: MIT. See [`LICENSE`](LICENSE).
ライセンスは[MIT](LICENSE)。
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- "8787"
volumes:
- server-data:/server-data
- ./docker/workspace:/workspace:ro
- /etc/yoi/server.toml:/server-config/server.toml:ro
webui:
image: yoi-webui:latest
+5 -28
View File
@@ -1,32 +1,9 @@
# agen-macros
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
`agen-macros` generates `agen` tool definitions and implementations from Rust
method declarations. It isolates procedural macro expansion from `agen`, which
owns tool registration and execution.
Applications should normally depend only on `agen` and import its re-exports:
## Design entry points
```rust
use agen::tool_registry;
#[derive(Clone)]
struct Tools;
#[tool_registry]
impl Tools {
/// Returns the supplied text.
#[tool]
async fn echo(
&self,
#[description = "Text to return"] text: String,
) -> Result<String, std::io::Error> {
Ok(text)
}
}
```
`#[tool_registry]` generates the argument schema, a `Tool` implementation, and an `<method>_definition` constructor. It rejects arguments of its own, duplicate `#[tool]` markers, malformed or duplicate `#[description = "..."]` attributes, and non-identifier argument patterns.
Generated code targets the canonical `::agen` path and uses implementation dependencies re-exported by `agen`; consumers do not need direct `serde`, `schemars`, `serde_json`, or `async-trait` dependencies. Renaming the `agen` dependency in `Cargo.toml` is not currently supported.
This companion package is published before the matching `agen` release. Its public contract is the generated API consumed by `agen`, and its minor version compatibility follows the `agen` 0.2 series.
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
- [`tool_registry`]: declaration validation, argument schemas, and execution wrappers
+9 -28
View File
@@ -1,9 +1,4 @@
//! Procedural macros for declaring [`agen`](https://docs.rs/agen) tools.
//!
//! [`tool_registry`] expands methods marked with `#[tool]` into `agen::tool::Tool`
//! implementations and tool definitions. Applications normally use the re-exports from
//! `agen`; this companion crate exists so those macros can be published and versioned
//! independently.
#![doc = include_str!("../README.md")]
use proc_macro::TokenStream;
use quote::{format_ident, quote};
@@ -12,30 +7,16 @@ use syn::{
spanned::Spanned,
};
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
/// Validates tool declarations and generates JSON Schema and `agen` execution wrappers.
///
/// Method doc comments become the tool description. An argument can use
/// `#[description = "..."]` to supply its JSON Schema description.
/// Method doc comments and argument `description` attributes provide schema descriptions.
/// Invalid attributes, duplicate markers, and non-identifier argument patterns produce
/// `compile_error!` diagnostics rather than panicking during macro expansion.
///
/// ```ignore
/// #[derive(Clone)]
/// struct MyApp;
///
/// #[agen::tool_registry]
/// impl MyApp {
/// /// Retrieves a user by ID.
/// #[tool]
/// async fn get_user(
/// &self,
/// #[description = "The user ID"] user_id: String,
/// ) -> Result<String, std::io::Error> {
/// todo!()
/// }
/// }
/// ```
///
/// This generates a `ToolGetUser` wrapper, a `GetUserArgs` schema type, and
/// `MyApp::get_user_definition()`.
/// Generated code references helper dependencies through `::agen::__private` so
/// downstream crates do not need to depend on implementation details directly.
/// The expansion uses the canonical `::agen` path and does not resolve renamed dependencies.
/// `ToolExecutionContext` is supplied by the executor and excluded from model input schemas.
#[proc_macro_attribute]
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
let attr = proc_macro2::TokenStream::from(attr);
+3 -1
View File
@@ -11,7 +11,7 @@ homepage = "https://gitea.hareworks.net/Hare/yoi"
documentation = "https://docs.rs/agen"
keywords = ["llm", "agent", "tools", "streaming", "orchestration"]
categories = ["api-bindings", "asynchronous"]
include = ["src/**", "tests/**", "examples/*.rs", "docs/**", "README.md", "LICENSE"]
include = ["src/**", "tests/**", "examples/*.rs", "README.md", "LICENSE"]
autoexamples = false
[features]
@@ -32,10 +32,12 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
eventsource-stream = "0.2"
zstd = "0.13"
base64 = "0.22.1"
imagesize = "0.14"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
agen-macros = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
clap = { version = "4.5", features = ["derive", "env"] }
tempfile = { workspace = true }
dotenv = "0.15"
+10 -86
View File
@@ -1,90 +1,14 @@
# agen
`agen` is a provider-neutral Rust engine for streaming LLM applications that use tools. It owns the turn loop, typed conversation history, provider wire-format adapters, tool execution, interceptors, usage accounting, and cache-aware state transitions.
`agen` integrates streaming LLM responses and tool execution in an agent engine.
It manages turns, history appends, pause/resume, and retries, while the LLM client
layer handles wire formats and authentication. The caller owns history storage,
worker lifecycle control, and compaction.
> `agen` is pre-1.0. Public APIs may change between minor releases.
## Design entry points
## Installation
```toml
[dependencies]
agen = "0.2.1"
```
The default feature set is intentionally empty. Enable the experimental Codex/ChatGPT authentication adapter when needed:
```toml
agen = { version = "0.2.1", features = ["codex"] }
```
`agen` requires Rust 1.86 or newer. The companion `agen-macros` package requires Rust 1.85 or newer.
## Quick start
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
```no_run
use agen::{Engine, EngineError};
use agen::llm_client::LlmClient;
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
let output = Engine::new(client)
.system_prompt("You are a concise assistant.")
.run("Explain typed state in one sentence.")
.await?;
let mut engine = output.engine;
let _result = engine.run("Give a Rust example.").await?;
Ok(())
}
```
## Declaring tools
The tool macros are re-exported by `agen`; applications do not need direct dependencies on `serde`, `schemars`, `serde_json`, or `async-trait` for generated code.
```rust
use agen::tool_registry;
#[derive(Clone)]
struct Tools;
#[tool_registry]
impl Tools {
/// Returns the supplied text.
#[tool]
async fn echo(
&self,
#[description = "Text to return"] text: String,
) -> Result<String, std::io::Error> {
Ok(text)
}
}
let definition = Tools.echo_definition();
assert_eq!(definition().0.name, "echo");
```
The generated API uses the canonical crate name `agen`. Renaming the `agen` dependency in `Cargo.toml` is not currently supported by these macros.
## Features
| Feature | Default | Adds |
|---|---:|---|
| `codex` | No | Experimental Codex/ChatGPT auth-file loading and token refresh support |
The base crate includes provider-neutral transport and Anthropic, OpenAI-compatible, Gemini, and Ollama wire-format schemes. See [`llm_client`](https://docs.rs/agen/latest/agen/llm_client/) for the client boundary.
## Architecture and API scope
The current public modules cover the engine, typed history, client transport/schemes, timeline events, tools, interceptors, pruning, token estimation, and usage records. Their relationships are described in [Architecture](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/architecture.md); behavioral requirements are summarized in [Requirements](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/requirements.md).
Low-level modules remain public in the 0.2 series because downstream Yoi components implement custom clients, event handlers, pruning policies, and tool registries against them. This surface is versioned as pre-1.0 API rather than declared stable.
## Packaging and security
The published package contains source, public documentation, curated examples, and deterministic tests/fixtures. Credentialed fixture-recording utilities are intentionally excluded. Examples that contact a provider read credentials from environment variables and never embed production credentials.
## License
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
- [`Engine`]: turn execution, interruption, and resumption
- [`History`]: model input paired with caller-owned annotations
- [`state`] and [`prune`]: prefix protection and request-only history projections
- [`llm_client`] and [`timeline`]: wire-format conversion and event processing
- [`interceptor`]: host-owned control decisions
-62
View File
@@ -1,62 +0,0 @@
# agen architecture
`agen` separates orchestration, event projection, and provider transport so applications can replace an LLM client without changing the turn loop or tool model.
```text
┌────────────────────────────────────────────┐
│ Engine │
│ turn loop · interceptors · tool execution │
│ typed state: Mutable → Locked → Mutable │
└─────────────────────┬──────────────────────┘
│
┌─────────────────────▼──────────────────────┐
│ Timeline │
│ event dispatch · block collectors │
└─────────────────────┬──────────────────────┘
│
┌─────────────────────▼──────────────────────┐
│ LlmClient │
│ transport · provider wire-format schemes │
└────────────────────────────────────────────┘
```
## Main modules
| Module | Responsibility |
|---|---|
| `engine` | Turn execution, pause/resume, retries, tool integration, and callbacks |
| `state` | Sealed `Mutable` and `Locked` type-state markers |
| `interceptor` | Application-owned control decisions at orchestration boundaries |
| `tool` / `tool_server` | Tool metadata, registration, execution, and bounded output |
| `timeline` | Streaming event dispatch, handlers, and block assembly |
| `llm_client` | Provider-neutral request, response, auth, transport, and scheme contracts |
| `providers` | Optional higher-level provider adapters such as the `codex` feature |
| `prune` / `token_counter` | Cache-aware history reduction and token estimation |
| `usage_record` | Request and token usage accounting |
## Request flow
```text
Engine history
→ provider-neutral Request
→ Scheme::build_request
→ Provider transport
```
## Response flow
```text
streaming response bytes
→ Scheme event parsing
→ unified Event values
→ Timeline handlers and collectors
→ Engine history/tool decisions
```
## Type state and cache protection
`Engine<C, Mutable>` permits configuration and history editing. `Engine::run` or `Engine::lock` commits the current prefix and produces `Engine<C, Locked>`. The locked engine may append turns without mutating the committed prefix. `Engine::unlock` explicitly returns to mutable state when an application accepts losing that cache guarantee.
## Public surface
The 0.2 series exposes the low-level client, timeline, tool, pruning, and usage modules because custom clients and orchestration hosts build directly on them. These APIs are intentionally provider-neutral but remain pre-1.0 and may change in later minor releases.
-39
View File
@@ -1,39 +0,0 @@
# agen requirements
## R1: Turn execution and continuation
- `Engine::run` starts a turn and loops through provider output and tool calls.
- An `Interceptor` may continue, cancel, or pause work at defined orchestration boundaries.
- `Engine::resume` continues paused generation without fabricating another user message.
- Cancellation and provider errors are represented as typed `EngineError` values.
## R2: Explicit cache-preserving state
- `Engine<C, Mutable>` permits configuration and history edits.
- `Engine::run` or `Engine::lock` transitions to `Engine<C, Locked>` and records the committed prefix.
- A locked engine appends turns but cannot mutate that prefix through mutable-only APIs.
- `Engine::unlock` explicitly abandons the lock before configuration or history changes.
## R3: Tool declarations and execution
- `#[tool_registry]` generates a schema and `Tool` implementation for methods marked `#[tool]`.
- `#[description = "..."]` supplies argument descriptions in generated JSON Schema.
- Generated code resolves its runtime and helper dependencies through `::agen`.
- Invalid and duplicate marker attributes produce compile errors rather than panics.
- Tools execute through `ToolServer` with typed context, errors, and output limits.
## R4: Provider-neutral orchestration
- `LlmClient` is the boundary between the engine and provider-specific transport.
- Request/response schemes translate provider wire formats into shared request and event types.
- Interceptors, tool execution, timeline collection, and pruning stay above the provider transport.
- Provider-specific capabilities are optional features when they require additional policy or dependencies.
## R5: Publication quality
- crates.io metadata includes license, repository, documentation, README, categories, keywords, and MSRV.
- The default feature set and each optional feature compile and test independently.
- Macro expansion compiles in a downstream-style integration test without direct helper dependencies.
- rustdoc builds without dependency documentation.
- Package contents are explicitly bounded and exclude credentialed fixture-recording utilities.
- `cargo package` and `cargo publish --dry-run` are run for `agen-macros` before `agen` because the main package depends on its companion package.
+10 -10
View File
@@ -4,7 +4,7 @@
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
use agen::{Engine, EngineResult};
use agen::{Engine, EngineRunExit, RunInterruptionReason};
use std::time::Duration;
#[tokio::main]
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let base_url = scheme.default_base_url().to_string();
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
let engine = Engine::new(client);
let mut history = agen::History::new();
println!("🚀 Starting Engine...");
println!("💡 Will cancel after 2 seconds\n");
@@ -45,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("📡 Sending request to LLM...");
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
Ok(out) => match out.result {
EngineResult::Finished => println!("✅ Task completed normally"),
EngineResult::Paused => println!("⏸️ Task paused"),
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
EngineResult::Yielded => println!("↩️ Task yielded"),
},
Err(e) => {
println!("❌ Task error: {}", e);
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
match output.result {
EngineRunExit::Finished => println!("✅ Task completed normally"),
EngineRunExit::Paused => println!("⏸️ Task paused"),
EngineRunExit::Yielded => println!("↩️ Task yielded"),
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
println!("🔒 Turn limit reached")
}
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
}
println!("\n✨ Demo complete!");
+19 -23
View File
@@ -39,8 +39,8 @@ use tracing::info;
use tracing_subscriber::EnvFilter;
use agen::{
Engine,
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
Engine, EngineRunExit, RunInterruptionReason,
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
llm_client::{
LlmClient,
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
#[async_trait]
impl Interceptor for ToolResultPrinterPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
let name = self
.call_names
.lock()
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
println!(" Result ({}): ✅ {}", name, info.result.summary);
}
PostToolAction::Continue
Ok(PostToolAction::Continue)
}
}
@@ -365,7 +368,7 @@ fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
ResolvedAuth::ApiKey(api_key),
)),
Provider::Ollama => {
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
// Ollama uses the Anthropic scheme with a custom base_url and no authentication.
let scheme = AnthropicScheme::new();
let cap = default_capability();
Ok(Box::new(HttpTransport::new(
@@ -451,6 +454,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create Engine
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
@@ -476,12 +480,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// One-shot mode
if let Some(prompt) = args.prompt {
match engine.run(&prompt).await {
Ok(_) => {}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
std::process::exit(1);
}
let output = engine.run(&mut history, &prompt).await;
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) = output.result
{
eprintln!("\n❌ Error: {error}");
}
return Ok(());
@@ -500,13 +502,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
let mut locked = match engine.run(first_input).await {
Ok(out) => out.engine,
Err(e) => {
eprintln!("\n❌ Error: {}", e);
return Ok(());
}
};
let output = engine.run(&mut history, first_input).await;
let mut locked = output.engine;
loop {
print!("\n👤 You: ");
@@ -525,11 +522,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break;
}
match locked.run(input).await {
Ok(_) => {}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
}
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) =
locked.run(&mut history, input).await
{
eprintln!("\n❌ Error: {error}");
}
}
@@ -108,7 +108,7 @@ async fn run_scenario_with_ollama(
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
// Ollama uses the Anthropic scheme with a custom base_url and no authentication.
let model = model.as_deref().unwrap_or("llama3");
let client = HttpTransport::new(
AnthropicScheme::new(),
+2672 -378
View File
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
//! History that pairs model input with host-owned annotations.
//!
//! Only [`Item`] is projected to providers; annotation `A` holds storage identifiers
//! and input provenance. Cloning, truncation, and restoration keep each pair together.
//! The caller's append callback owns persistence; this container does not.
use serde::{Deserialize, Serialize};
use crate::Item;
/// One conversation-history entry with host-owned annotation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HistoryEntry<A = ()> {
/// Provider/model-visible conversation item.
pub item: Item,
/// Host-domain metadata kept with the item and never projected to providers.
pub annotation: A,
}
impl<A> HistoryEntry<A> {
/// Build an entry from an item and its annotation.
pub fn new(item: Item, annotation: A) -> Self {
Self { item, annotation }
}
/// Split the entry into its item and annotation.
pub fn into_parts(self) -> (Item, A) {
(self.item, self.annotation)
}
}
impl HistoryEntry<()> {
/// Build a unit-annotated entry.
pub fn from_item(item: Item) -> Self {
Self {
item,
annotation: (),
}
}
}
/// Ordered history of model input and host-owned annotations.
///
/// [`History::append_with`] runs the callback before adding an item. A host that
/// persists in that callback keeps failed writes out of subsequent model requests.
/// Batch appends commit one item at a time; later failures do not roll back earlier
/// items. Direct append and replacement APIs do not persist, so the host must
/// distinguish restoration from normal appends.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct History<A = ()> {
entries: Vec<HistoryEntry<A>>,
}
impl<A> History<A> {
/// Create an empty history.
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Build history from already annotated entries, preserving order.
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
Self { entries }
}
/// Replace all entries as one restore/rebuild operation and return the old entries.
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
std::mem::replace(&mut self.entries, entries)
}
/// Borrow annotated entries.
pub fn entries(&self) -> &[HistoryEntry<A>] {
&self.entries
}
/// Mutably borrow annotated entries for host-owned rebuild operations.
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
&mut self.entries
}
/// Consume the history into annotated entries.
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
self.entries
}
/// Number of entries.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the history is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Iterate over annotated entries.
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
self.entries.iter()
}
/// Iterate over provider-visible items only.
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
self.entries.iter().map(|entry| &entry.item)
}
/// Clone provider-visible items into a request-local projection.
pub fn items_cloned(&self) -> Vec<Item> {
self.items().cloned().collect()
}
/// Append an already annotated entry.
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
self.entries.push(entry);
}
/// Append many already annotated entries.
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
self.entries.extend(entries);
}
/// Commit one item through a trusted annotation callback before it becomes live.
///
/// The callback may durably persist the item and returns the annotation that
/// must be stored with it. If the callback fails, the history is left unchanged.
pub fn append_with(
&mut self,
item: Item,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), String> {
let annotation = annotate(&item)?;
self.entries.push(HistoryEntry { item, annotation });
Ok(())
}
/// Commit items through a trusted annotation callback before they become live.
///
/// Items before a failure remain appended; the failing item and later items do
/// not enter history. This mirrors append-only durable logs where each accepted
/// item is already committed before the next item is attempted.
pub fn extend_with(
&mut self,
items: impl IntoIterator<Item = Item>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), String> {
for item in items {
self.append_with(item, annotate)?;
}
Ok(())
}
/// Truncate entries, preserving item+annotation pairing for retained entries.
pub fn truncate(&mut self, len: usize) {
self.entries.truncate(len);
}
/// Clear all entries.
pub fn clear(&mut self) {
self.entries.clear();
}
}
impl History<()> {
/// Build unit-annotated history from provider-visible items.
pub fn from_items(items: Vec<Item>) -> Self {
Self {
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
}
}
/// Replace history from provider-visible items using unit annotations.
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
}
/// Append one item with unit annotation.
pub fn push(&mut self, item: Item) {
self.entries.push(HistoryEntry::from_item(item));
}
/// Append items with unit annotations.
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
self.entries
.extend(items.into_iter().map(HistoryEntry::from_item));
}
}
impl<A> IntoIterator for History<A> {
type Item = HistoryEntry<A>;
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl<'a, A> IntoIterator for &'a History<A> {
type Item = &'a HistoryEntry<A>;
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Recovery is driven by provider rejection, never a locally chosen image limit.
use crate::{History, Item, Role, ToolResultDisposition, llm_client::Request, tool::Attachment};
pub(crate) const REJECTION_MESSAGE: &str = "ViewImage image omitted from model input after the provider rejected image size. This is a recovery candidate, not proof that this particular image caused the rejection. Resize or crop the image and retry ViewImage; do not read the unchanged image again.";
/// Keep the most recent response's tool results eligible, including early tool
/// results interleaved with that response's later text/calls. Empty boundaries
/// from older versions' failed requests are not evidence of accepted input.
pub(crate) fn accepted_input_end<'a>(items: impl Iterator<Item = &'a Item>) -> Option<usize> {
let mut response_start = None;
let mut accepted_prefix = None;
for (index, item) in items.enumerate() {
match item {
Item::AssistantResponseBoundary { .. } => response_start = Some(index),
Item::Message {
role: Role::Assistant,
..
}
| Item::ToolCall { .. }
| Item::Reasoning { .. } => {
accepted_prefix = Some(response_start.unwrap_or(index));
}
Item::Message {
role: Role::User, ..
} => response_start = None,
_ => {}
}
}
accepted_prefix
}
/// Select only a ViewImage result actually sent in the rejected request. The
/// tool contract returns exactly one image, so each correction removes one
/// image and preserves the tool call/result pair. Other tools and user uploads
/// are deliberately not rewritten by this recovery policy.
pub(crate) fn largest_candidate<A>(
history: &History<A>,
request: &Request,
) -> Option<(usize, usize)> {
let start = accepted_input_end(history.items()).unwrap_or(0);
let mut largest = None;
for (index, entry) in history.entries().iter().enumerate().skip(start) {
let Item::ToolResult {
call_id,
attachments,
..
} = &entry.item
else {
continue;
};
let [Attachment::Image(image)] = attachments.as_slice() else {
continue;
};
let from_view_image = history.items().any(|item| {
matches!(
item,
Item::ToolCall { call_id: id, name, .. } if id == call_id && name == "ViewImage"
)
});
if !from_view_image {
continue;
}
let sent = request.items.iter().position(|item| {
matches!(
item,
Item::ToolResult { call_id: id, attachments: sent, .. }
if id == call_id && sent == attachments
)
});
let Some(request_index) = sent else { continue };
// Header inspection only; no raster allocation or size threshold.
// Unknown dimensions sort by bytes as a deterministic fallback.
let pixels = imagesize::blob_size(image.data())
.ok()
.map(|size| (size.width as u64).saturating_mul(size.height as u64))
.unwrap_or(0);
let rank = (pixels, image.data().len(), index);
if largest
.as_ref()
.is_none_or(|(previous, _)| rank > *previous)
{
largest = Some((rank, (index, request_index)));
}
}
largest.map(|(_, candidate)| candidate)
}
pub(crate) fn rejected_result(item: &Item) -> Item {
let mut item = item.clone();
if let Item::ToolResult {
summary,
content,
attachments,
disposition,
is_error,
..
} = &mut item
{
*summary = REJECTION_MESSAGE.to_owned();
*content = None;
attachments.clear();
*disposition = ToolResultDisposition::Error;
*is_error = true;
}
item
}
+274 -32
View File
@@ -1,16 +1,218 @@
//! Interceptor - control flow delegation for the Engine execution loop
//! Host-owned control decisions at Engine execution boundaries.
//!
//! Defines the [`Interceptor`] trait that callers implement to inject
//! orchestration decisions (approval, skip, pause, abort) into the Engine's
//! turn loop without the Engine knowing about host-application concepts.
//! [`Interceptor`] delegates approval, skip, pause, and abort decisions to the host,
//! keeping host-specific policy out of the Engine. Additional model input must go
//! through history appends: inserting new facts only into an in-flight request
//! would make its input impossible to reconstruct from stored history.
//!
//! Tool pre/post policy and assistant-turn-end waits are interruptible by Cancel or Pause;
//! Cancel wins when both are observed. Pending policy futures may be dropped, not replayed, so
//! hosts must not depend on their completion for cleanup of already-owned effects. The Engine
//! drains owned tool attempts through their original fences. Terminal tool history and result
//! publication precede post-tool policy; interruption cannot erase that result. Pause draining
//! bounds post-tool waits by the existing safe-boundary deadline.
use std::sync::Arc;
use async_trait::async_trait;
use crate::Item;
use crate::engine::EngineRunExit;
use crate::history::HistoryEntry;
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
// =============================================================================
// Typed lifecycle metadata and failures
// =============================================================================
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
/// Stable category for the source of an interceptor failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterceptorErrorCategory {
Policy,
Dependency,
ContractViolation,
Internal,
}
impl std::fmt::Display for InterceptorErrorCategory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Policy => "policy",
Self::Dependency => "dependency",
Self::ContractViolation => "contract_violation",
Self::Internal => "internal",
})
}
}
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{category}: {diagnostic}")]
pub struct InterceptorError {
category: InterceptorErrorCategory,
diagnostic: String,
}
impl InterceptorError {
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
let mut diagnostic = diagnostic.into();
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
while !diagnostic.is_char_boundary(end) {
end -= 1;
}
diagnostic.truncate(end);
}
Self {
category,
diagnostic,
}
}
pub fn category(&self) -> InterceptorErrorCategory {
self.category
}
pub fn diagnostic(&self) -> &str {
&self.diagnostic
}
}
/// The lifecycle phase at which an interceptor callback executes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InterceptorPhase {
#[default]
PromptSubmit,
PendingHistoryAppends,
PreLlmRequest,
PreToolCall,
PostToolCall,
AssistantTurnEnd,
RunExit,
}
impl std::fmt::Display for InterceptorPhase {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::PromptSubmit => "prompt_submit",
Self::PendingHistoryAppends => "pending_history_appends",
Self::PreLlmRequest => "pre_llm_request",
Self::PreToolCall => "pre_tool_call",
Self::PostToolCall => "post_tool_call",
Self::AssistantTurnEnd => "assistant_turn_end",
Self::RunExit => "run_exit",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct InterceptorRunId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct InterceptorTurnId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InterceptorCallId {
Llm(u64),
Tool(String),
}
/// Saturating public counter used by interceptor contexts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct InterceptorCounter(u32);
impl InterceptorCounter {
pub fn from_usize(value: usize) -> Self {
Self(u32::try_from(value).unwrap_or(u32::MAX))
}
pub fn get(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct InterceptorCounters {
pub invocation: InterceptorCounter,
pub engine_turn: InterceptorCounter,
pub run_turn: InterceptorCounter,
pub llm_call: InterceptorCounter,
pub tool_batch: InterceptorCounter,
pub tool_call: InterceptorCounter,
}
/// Identity, phase, and bounded counters common to every lifecycle callback.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InterceptorInvocation {
pub run_id: InterceptorRunId,
pub turn_id: Option<InterceptorTurnId>,
pub call_id: Option<InterceptorCallId>,
pub phase: InterceptorPhase,
pub counters: InterceptorCounters,
}
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{phase} interceptor failed: {error}")]
pub struct InterceptorFailure {
phase: InterceptorPhase,
#[source]
error: InterceptorError,
}
impl InterceptorFailure {
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
Self { phase, error }
}
pub fn phase(&self) -> InterceptorPhase {
self.phase
}
pub fn error(&self) -> &InterceptorError {
&self.error
}
}
pub type InterceptorResult<T> = Result<T, InterceptorError>;
// =============================================================================
// Lifecycle Contexts
// =============================================================================
pub struct PromptSubmitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub item: &'a mut Item,
pub history: &'a [HistoryEntry<A>],
}
pub struct PendingHistoryAppendsContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
}
pub struct PreLlmRequestContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub items: &'a mut Vec<Item>,
pub history: &'a [HistoryEntry<A>],
}
pub struct AssistantTurnEndContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub assistant_entries: &'a [HistoryEntry<A>],
pub history: &'a [HistoryEntry<A>],
pub tool_calls: &'a [ToolCall],
}
pub struct RunExitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub exit: &'a EngineRunExit,
pub history: &'a [HistoryEntry<A>],
}
// =============================================================================
// Action Enums
// =============================================================================
@@ -86,9 +288,9 @@ pub enum PostToolAction {
/// Action at the end of a turn (when LLM produces no tool calls).
#[derive(Debug, Clone)]
pub enum TurnEndAction {
/// Turn is finished, return to caller.
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
Finish,
/// Continue with additional messages injected into history.
/// Commit additional messages, then continue through the natural next phase.
ContinueWithMessages(Vec<Item>),
/// Pause execution (can be resumed later).
Pause,
@@ -99,8 +301,9 @@ pub enum TurnEndAction {
// =============================================================================
/// Context for pre-tool-call decisions.
pub struct ToolCallInfo {
/// Tool call information (modifiable).
pub struct ToolCallInfo<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall,
/// Tool meta information.
pub meta: ToolMeta,
@@ -111,10 +314,11 @@ pub struct ToolCallInfo {
}
/// Context for post-tool-call decisions.
pub struct ToolResultInfo {
/// Original tool call.
pub struct ToolResultInfo<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall,
/// Tool execution result (modifiable).
/// Committed terminal tool execution result.
pub result: ToolResult,
/// Tool meta information.
pub meta: ToolMeta,
@@ -130,14 +334,30 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points.
///
/// All methods have default implementations that let the Engine
/// proceed without intervention. Callers provide richer implementations for
/// approval flows, permission checks, etc.
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
/// keeping implementation failure separate from the method's control-flow
/// action. The Engine reports a failure as a typed run interruption annotated
/// with the exact [`InterceptorPhase`] that failed.
///
/// All methods have default implementations that let the Engine proceed
/// without intervention. Callers provide richer implementations for approval
/// flows, permission checks, and other trusted host adaptation.
#[async_trait]
pub trait Interceptor: Send + Sync {
/// Called after receiving user input, before adding to history.
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
PromptAction::Continue
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
///
/// The conservative default preserves the historical whole-response gate.
/// Interceptors that do not use turn-end policy may opt in explicitly.
fn supports_early_tool_dispatch(&self) -> bool {
false
}
/// Called after receiving user input, before adding it to Engine history.
async fn on_prompt_submit(
&self,
_context: PromptSubmitContext<'_, A>,
) -> InterceptorResult<PromptAction> {
Ok(PromptAction::Continue)
}
/// Items that should be **committed to `engine.history`** just
@@ -158,7 +378,10 @@ pub trait Interceptor: Send + Sync {
/// reproducible per-request transformations (pruning, content
/// trimming, cache anchors) that depend only on the existing
/// history.
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, A>,
) -> InterceptorResult<Vec<Item>> {
Ok(Vec::new())
}
@@ -170,27 +393,42 @@ pub trait Interceptor: Send + Sync {
/// If an interceptor derives a human/model-visible nudge from the current
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
/// commits it to history before the request is sent.
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
PreRequestAction::Continue
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
Ok(PreRequestAction::Continue)
}
/// Called before each tool is executed.
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
PreToolAction::Continue
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::Continue)
}
/// Called after each tool completes.
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Continue
/// Called after each tool reaches one terminal result and that result is committed.
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, A>,
) -> InterceptorResult<PostToolAction> {
Ok(PostToolAction::Continue)
}
/// Called when a turn ends with no tool calls.
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
TurnEndAction::Finish
/// Called after every terminal assistant response is committed and before
/// the Engine decides whether to execute tools, continue, or finish.
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, A>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Finish)
}
/// Called when execution is interrupted (abort or cancel).
async fn on_abort(&self, _reason: &str) {}
/// Called once for the terminal outcome of each public run or resume call.
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
Ok(())
}
}
/// Default interceptor: no intervention. Engine proceeds through the loop
@@ -198,4 +436,8 @@ pub trait Interceptor: Send + Sync {
pub(crate) struct DefaultInterceptor;
#[async_trait]
impl Interceptor for DefaultInterceptor {}
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {
fn supports_early_tool_dispatch(&self) -> bool {
true
}
}
+19 -7
View File
@@ -2,6 +2,8 @@
mod engine;
mod handler;
mod history;
mod image_recovery;
mod message;
pub(crate) mod callback;
@@ -20,19 +22,29 @@ pub mod usage_record;
pub use agen_macros::{description, tool, tool_registry};
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
pub use engine::{
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
ToolRegistryError,
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
LlmRetryNotice, RunInterruptionReason, ToolCallDispatchMode, ToolRegistryError,
};
pub use handler::ToolUseBlockStart;
pub use interceptor::Interceptor;
pub use history::{History, HistoryEntry};
pub use interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
};
pub use message::{ContentPart, Item, Message, Role};
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
ToolResultDisposition,
};
pub use usage_record::UsageRecord;
/// Implementation dependencies used by code generated from `agen` macros.
/// Implementation dependencies referenced by macro-generated code.
///
/// This module is not a stable user-facing API. It is public only because macro expansion
/// happens in the downstream crate.
/// Public for access from downstream macro expansions, not a stable user-facing API.
#[doc(hidden)]
pub mod __private {
pub use async_trait;
+20 -20
View File
@@ -1,43 +1,43 @@
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。
//! Authentication requirements for `Scheme` implementations and the transport layer,
//! plus dynamic authentication providers.
//!
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
//! 期待するか」のランタイム記述で、設定ファイルや環境変数などから
//! [`super::transport::ResolvedAuth`] を組み立てる責務は呼び出し側にある。
//! `AuthRequirement` describes the authentication a scheme expects at runtime.
//! The caller is responsible for building [`super::transport::ResolvedAuth`]
//! from configuration files, environment variables, or other sources.
//!
//! リクエスト毎にトークンが変わり得る認証は [`AuthProvider`] trait を
//! 実装し、[`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
//! Authentication whose tokens may change per request implements [`AuthProvider`]
//! and is passed to the transport via [`super::transport::ResolvedAuth::Custom`].
use async_trait::async_trait;
use reqwest::header::{HeaderName, HeaderValue};
use super::error::ClientError;
/// `Scheme::required_auth()` が返す認証要件。
/// Authentication requirements returned by `Scheme::required_auth()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthRequirement {
/// 認証を行わない(Ollama など)
/// No authentication (e.g. Ollama).
None,
/// `Authorization: Bearer <token>` ヘッダ(token は API key 相当)
/// `Authorization: Bearer <token>` header (the token acts as an API key).
Bearer,
/// `x-api-key: <token>` ヘッダ(Anthropic 形式)
/// `x-api-key: <token>` header (Anthropic format).
XApiKey,
/// クエリパラメータ `?<name>=<token>`(Gemini 形式)
/// Query parameter `?<name>=<token>` (Gemini format).
QueryParam { name: &'static str },
/// 複合ヘッダ(呼び出し側が [`AuthProvider`] で解決)
/// Combined headers (resolved by the caller through [`AuthProvider`]).
Custom,
}
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
/// Provider that builds authentication headers dynamically for each request.
///
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
/// trait を知るだけ。
/// Use this when access tokens are refreshed or multiple headers must be injected
/// together. The implementation belongs to the caller; agen only knows the trait.
///
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
/// 実装側でセットすること。
/// Returned headers are inserted directly into `HeaderMap`. The scheme's default
/// authentication headers, including `Authorization`, are not sent; the
/// implementation must set them if needed.
#[async_trait]
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
/// Return authentication headers for one request, refreshing internally if needed.
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
}
+26 -26
View File
@@ -1,16 +1,16 @@
//! モデル能力メタデータ
//! Model capability metadata.
//!
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
//! から分離して保持する。
//! `ModelCapability` represents differences in model features. Reasoning support
//! and prompt caching strategies may vary even within one scheme, so this
//! metadata is kept separate from the scheme.
//!
//! 値の供給経路は 2 通り:
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
//! The client construction layer supplies model-specific capabilities and overrides.
//! Schemes provide only wire-level defaults through `Scheme::default_capability`;
//! this module does not own a model registry or application configuration format.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// モデル能力メタデータ
/// Model capability metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelCapability {
pub tool_calling: ToolCallingSupport,
@@ -23,7 +23,7 @@ pub struct ModelCapability {
}
impl ModelCapability {
/// 何もサポートしない安全側デフォルト。未知モデルのフォールバック用。
/// Conservative default with no supported features, used for unknown models.
pub const fn minimal() -> Self {
Self {
tool_calling: ToolCallingSupport::None,
@@ -35,55 +35,55 @@ impl ModelCapability {
}
}
/// ツール呼び出しサポート
/// Tool calling support.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallingSupport {
/// 非サポート
/// Unsupported.
None,
/// 1 回のレスポンスで 1 ツールのみ
/// Only one tool per response.
Sequential,
/// 1 回のレスポンスで複数ツール並行
/// Multiple parallel tools per response.
Parallel,
}
/// Structured output サポート
/// Structured output support.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StructuredOutput {
None,
/// `json_object` モード(スキーマなし JSON 強制)
/// `json_object` mode (enforces JSON without a schema).
JsonObject,
/// JSON Schema 指定で構造化出力
/// Structured output with a JSON Schema.
JsonSchema,
}
/// Reasoning(extended thinking)サポート
/// Reasoning (extended thinking) support.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningSupport {
/// OpenAI 形式: `reasoning.effort` (low/medium/high)
/// OpenAI format: `reasoning.effort` (low/medium/high).
Effort,
/// Anthropic 形式: `thinking.budget_tokens`
/// Anthropic format: `thinking.budget_tokens`.
BudgetTokens,
/// 両対応(内部では共通 `ReasoningControl` として扱い、各 scheme で投影)
/// Both (handled internally as shared `ReasoningControl`, projected by each scheme).
Both,
}
/// Prompt caching 戦略
/// Prompt caching strategy.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CacheStrategy {
/// Anthropic: `cache_control` マーカーを明示挿入
/// Anthropic: insert explicit `cache_control` markers.
Explicit { max_breakpoints: u8 },
/// それ以外: サーバ側自動 prefix、または未サポート
/// Other providers: automatic server-side prefix caching, or unsupported.
Auto,
}
/// Reasoning 制御(共通型、scheme 側で各社形式に投影)。
/// Shared reasoning control, projected into each provider's format by the scheme.
///
/// 文字列は provider-native な effort label、数値は provider-native な
/// thinking budget token として扱う。どちらか一方だけを型で表現する。
/// Strings are provider-native effort labels; numbers are provider-native
/// thinking token budgets. The type represents only one at a time.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ReasoningControl {
+37 -18
View File
@@ -1,4 +1,4 @@
//! LLMクライアント共通trait定義
//! Shared LLM client trait definitions.
use std::pin::Pin;
@@ -6,19 +6,19 @@ use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
use async_trait::async_trait;
use futures::Stream;
/// 設定に関する警告
/// Configuration warning.
///
/// プロバイダがサポートしていない設定を使用した場合に返される。
/// Returned when using a configuration option unsupported by the provider.
#[derive(Debug, Clone)]
pub struct ConfigWarning {
/// 設定オプション名
/// Configuration option name.
pub option_name: &'static str,
/// 警告メッセージ
/// Warning message.
pub message: String,
}
impl ConfigWarning {
/// 新しい警告を作成
/// Create a new warning.
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
Self {
option_name,
@@ -38,19 +38,29 @@ impl std::fmt::Display for ConfigWarning {
pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
/// LLMクライアントのtrait
/// Whether normalized `BlockStop(ToolUse)` events prove one call is complete.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ToolCallCompletionSupport {
/// Individual tool-call completion events are provider-authoritative.
PerBlock,
/// Tool calls are complete only once the response stream itself completes.
#[default]
ResponseComplete,
}
/// LLM client trait.
///
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
/// Each provider implements this trait to expose a unified interface.
#[async_trait]
pub trait LlmClient: Send + Sync {
/// ストリーミングリクエストを送信し、Eventストリームを返す
/// Send a streaming request and return an Event stream.
///
/// # Arguments
/// * `request` - リクエスト情報
/// * `request` - Request information.
///
/// # Returns
/// * `Ok(Stream)` - イベントストリーム
/// * `Err(ClientError)` - エラー
/// * `Ok(Stream)` - Event stream.
/// * `Err(ClientError)` - Error.
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError>;
/// Clone this client into a new `Box<dyn LlmClient>`.
@@ -59,15 +69,20 @@ pub trait LlmClient: Send + Sync {
/// compaction) without access to the original construction parameters.
fn clone_boxed(&self) -> Box<dyn LlmClient>;
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
/// Describe the strongest safe tool-call completion boundary.
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
ToolCallCompletionSupport::ResponseComplete
}
/// Validate configuration and return warnings for unsupported options.
///
/// # Arguments
/// * `config` - バリデーション対象の設定
/// * `config` - Configuration to validate.
///
/// # Returns
/// サポートされていない設定に対する警告のリスト
/// List of warnings for unsupported options.
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
// デフォルト実装: 全ての設定をサポート
// Default implementation: all options are supported.
let _ = config;
Vec::new()
}
@@ -79,9 +94,9 @@ impl Clone for Box<dyn LlmClient> {
}
}
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
/// `LlmClient` implementation for `Box<dyn LlmClient>`.
///
/// これにより、動的ディスパッチを使用するクライアントも `Engine` で利用可能になる。
/// Allows clients using dynamic dispatch to be used with `Engine`.
#[async_trait]
impl LlmClient for Box<dyn LlmClient> {
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
@@ -92,6 +107,10 @@ impl LlmClient for Box<dyn LlmClient> {
(**self).clone_boxed()
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
(**self).tool_call_completion_support()
}
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
(**self).validate_config(config)
}
+67 -16
View File
@@ -1,29 +1,37 @@
//! LLMクライアントエラー型
//! Report provider communication failures to turn control.
//!
//! Context limit rejections are classified from structured provider codes;
//! fuzzy body matches must not imply success or retryability. The Engine maps
//! client errors to typed execution results, which the host records as the Run's
//! terminal outcome. Live diagnostic display and persistence are separate concerns.
use std::{fmt, time::Duration};
/// LLMクライアントのエラー
/// LLM client error.
#[derive(Debug)]
pub enum ClientError {
/// HTTPリクエストエラー
/// HTTP request error.
Http(reqwest::Error),
/// JSONパースエラー
/// JSON parse error.
Json(serde_json::Error),
/// SSEパースエラー
/// SSE parse error.
Sse(String),
/// APIエラー (プロバイダからのエラーレスポンス)
/// API error (an error response from the provider).
Api {
status: Option<u16>,
code: Option<String>,
message: String,
retry_after: Option<Duration>,
},
/// The provider rejected the request because it exceeded the model context window.
/// Classified only from a structured provider error code, never message text.
ContextWindowExceeded,
/// A request lifecycle phase exceeded its hard timeout.
Timeout {
phase: &'static str,
timeout: Duration,
},
/// 設定エラー
/// Configuration error.
Config(String),
}
@@ -48,6 +56,7 @@ impl fmt::Display for ClientError {
}
write!(f, ": {}", message)
}
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
ClientError::Timeout { phase, timeout } => {
write!(f, "{phase} timed out after {}s", timeout.as_secs())
}
@@ -86,6 +95,22 @@ impl ClientError {
}
}
/// A provider-declared image size rejection, not a generic invalid input.
/// Some providers (including OpenAI) expose only `invalid_value` here, so
/// narrowly recognize their image-size diagnostic without hardcoded limits.
pub fn is_image_size_rejection(&self) -> bool {
let Self::Api {
status: Some(400 | 413 | 422),
code,
message,
..
} = self
else {
return false;
};
is_image_size_diagnostic(code.as_deref(), message)
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
ClientError::Api { retry_after, .. } => *retry_after,
@@ -94,16 +119,39 @@ impl ClientError {
}
}
/// transient な失敗としてリトライ対象になるかを判定する。
/// Shared diagnostic policy for HTTP rejections and provider Error events.
/// The caller must establish the rejection path and whether replay is safe;
/// this predicate alone never authorizes a retry.
pub(crate) fn is_image_size_diagnostic(code: Option<&str>, message: &str) -> bool {
if matches!(code, Some("image_too_large" | "image_size_exceeded")) {
return true;
}
// OpenAI Responses appends structured diagnostic details for observation.
// Extra fields (including echoed input) are not the error message itself.
let message = message
.split(" | diagnostic=")
.next()
.unwrap_or(message)
.to_ascii_lowercase();
message.contains("image")
&& ((message.contains("patches")
&& message.contains("exceed")
&& message.contains("limit"))
|| message.contains("image is too large")
|| message.contains("image too large")
|| (message.contains("image dimensions") && message.contains("exceed")))
}
/// Determine whether a failure is transient and eligible for retry.
///
/// 対象:
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
/// - `Timeout { .. }` の lifecycle hard timeout
/// Eligible failures:
/// - `Api { status }` with 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529.
/// - `Http(reqwest::Error)` with `is_connect()` or `is_timeout()`.
/// - Lifecycle hard timeouts from `Timeout { .. }`.
///
/// それ以外(Json、Sse、Config、上記以外の Api ステータス)は false。
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
/// ここで対象外にしておけば自動的に弾かれる。
/// Other failures (Json, Sse, Config, and other Api statuses) return false.
/// Callers propagate failures after SSE reading begins as `Sse`, so excluding
/// them here automatically prevents retries.
pub fn is_retryable(error: &ClientError) -> bool {
match error {
ClientError::Api {
@@ -112,7 +160,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
ClientError::Api { status: None, .. } => false,
ClientError::Timeout { .. } => true,
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
ClientError::ContextWindowExceeded
| ClientError::Json(_)
| ClientError::Sse(_)
| ClientError::Config(_) => false,
}
}
+107 -99
View File
@@ -1,6 +1,6 @@
//! LLMクライアント層のイベント型
//! Event types for the LLM client layer.
//!
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
//! Events representing streaming responses from LLM providers.
use serde::{Deserialize, Serialize};
@@ -8,43 +8,42 @@ use serde::{Deserialize, Serialize};
// Core Event Types (from llm_client layer)
// =============================================================================
/// LLMからのストリーミングイベント
/// Streaming event from an LLM.
///
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
/// 統一的に処理されます。
/// Responses from all LLM providers are handled uniformly as streams of `Event`.
///
/// # イベントの種類
/// # Event types
///
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
/// - **Meta events**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
/// - **Block events**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # ブロックのライフサイクル
/// # Block lifecycle
///
/// テキスト、thinking、ツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
/// の順序でイベントが発生します。thinking の round-trip metadata は
/// `BlockStop.reasoning` に載ります。
/// Text, thinking, and tool calls emit `BlockStart` → `BlockDelta` (multiple) →
/// `BlockStop` in order. Thinking round-trip metadata is carried in
/// `BlockStop.reasoning`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// ハートビート
/// Heartbeat.
Ping(PingEvent),
/// トークン使用量
/// Token usage.
Usage(UsageEvent),
/// ストリームのステータス変化
/// Stream status change.
Status(StatusEvent),
/// エラー発生
/// Error occurred.
Error(ErrorEvent),
/// Scheme が生成内容として解釈しない未対応 SSE イベント。
/// Unsupported SSE event that the Scheme does not interpret as generated content.
///
/// stream trace 用の観測イベントであり、timeline / history には反映しない。
/// An observation event for stream tracing, not reflected in the timeline or history.
UnhandledSse(UnhandledSseEvent),
/// ブロック開始(テキスト、ツール使用等)
/// Block start (text, tool use, etc.).
BlockStart(BlockStart),
/// ブロックの差分データ
/// Block delta data.
BlockDelta(BlockDelta),
/// ブロック正常終了
/// Normal block completion.
BlockStop(BlockStop),
/// ブロック中断
/// Block abort.
BlockAbort(BlockAbort),
}
@@ -52,67 +51,76 @@ pub enum Event {
// Meta Events
// =============================================================================
/// Pingイベント(ハートビート)
/// Ping event (heartbeat).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// 使用量イベント
/// Usage event.
///
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
/// Token accounting received from the provider for one LLM request.
/// Each scheme normalizes it; field meanings are shared across providers:
///
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
/// 料金会計用。占有量からは差し引かない。
/// - `input_tokens` is the **occupancy of the entire submitted prompt prefix**
/// (full prompt length), including cache hits. Anthropic's raw API returns only
/// uncached tokens as `input_tokens`, so `AnthropicScheme::convert_usage` adds
/// `cache_read + cache_creation` to follow this convention.
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` are breakdowns
/// for billing. They are not subtracted from occupancy.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
/// Total tokens in the submitted prompt prefix (occupancy, including cached tokens).
pub input_tokens: Option<u64>,
/// このリクエストで生成された出力トークン数
/// Output tokens generated by this request.
pub output_tokens: Option<u64>,
/// `input_tokens + output_tokens`
pub total_tokens: Option<u64>,
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
/// Portion of `input_tokens` read from cache (discounted rate).
pub cache_read_input_tokens: Option<u64>,
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分(割増料金、Anthropic)
/// Portion of `input_tokens` written to cache by this request (premium rate, Anthropic).
pub cache_creation_input_tokens: Option<u64>,
}
/// ステータスイベント
/// Status event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// レスポンスステータス
/// Response status.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// ストリーム開始
/// Stream started.
Started,
/// 正常完了
/// Completed normally.
Completed,
/// キャンセルされた
/// Cancelled.
Cancelled,
/// エラー発生
/// Error occurred.
Failed,
}
/// エラーイベント
/// Error event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
/// 未対応 SSE イベントの観測用メタイベント。
impl ErrorEvent {
/// A provider Error event has no HTTP status. Classify its diagnostic
/// explicitly, rather than making every status-less ClientError eligible.
/// The engine must separately establish that no response output was seen.
pub(crate) fn is_image_size_rejection(&self) -> bool {
super::error::is_image_size_diagnostic(self.code.as_deref(), &self.message)
}
}
/// Observation-only meta event for unsupported SSE events.
///
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
/// `data_len` は preview 前の raw data byte length。
/// `data_preview` is a bounded preview of raw SSE data received from the provider;
/// `data_len` is the raw data's byte length before preview truncation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnhandledSseEvent {
pub provider: String,
@@ -125,27 +133,27 @@ pub struct UnhandledSseEvent {
// Block Types
// =============================================================================
/// ブロックの種別
/// Block type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// テキスト生成
/// Text generation.
Text,
/// 思考 (Claude Extended Thinking等)
/// Thinking (e.g. Claude Extended Thinking).
Thinking,
/// ツール呼び出し
/// Tool call.
ToolUse,
/// ツール結果
/// Tool result.
ToolResult,
}
/// ブロック開始イベント
/// Block start event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// ブロックの種別
/// Block type.
pub block_type: BlockType,
/// ブロック固有のメタデータ
/// Block-specific metadata.
pub metadata: BlockMetadata,
}
@@ -155,7 +163,7 @@ impl BlockStart {
}
}
/// ブロックのメタデータ
/// Block metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
@@ -164,28 +172,28 @@ pub enum BlockMetadata {
ToolResult { tool_use_id: String },
}
/// ブロックデルタイベント
/// Block delta event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// デルタの内容
/// Delta content.
pub delta: DeltaContent,
}
/// デルタの内容
/// Delta content.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// テキストデルタ
/// Text delta.
Text(String),
/// 思考デルタ
/// Thinking delta.
Thinking(String),
/// ツール引数のJSON部分文字列
/// Partial JSON string for tool arguments.
InputJson(String),
}
impl DeltaContent {
/// デルタのブロック種別を取得
/// Get the delta's block type.
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
@@ -195,20 +203,20 @@ impl DeltaContent {
}
}
/// ブロック停止イベント
/// Block stop event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// ブロックの種別
/// Block type.
pub block_type: BlockType,
/// 停止理由
/// Stop reason.
pub stop_reason: Option<StopReason>,
/// Thinking block の停止時に確定した reasoning round-trip metadata。
/// Reasoning round-trip metadata finalized when a Thinking block stops.
///
/// `None` の Thinking block は live streaming / trace 用で、history に
/// `Item::Reasoning` として永続化しない。`Some` の場合は block lifecycle
/// が永続化の authoritative source になる。
/// Thinking blocks with `None` are for live streaming / tracing and are not
/// persisted in history as `Item::Reasoning`. With `Some`, the block lifecycle
/// is the authoritative source for persistence.
pub reasoning: Option<ReasoningBlockData>,
}
@@ -218,14 +226,14 @@ impl BlockStop {
}
}
/// ブロック中断イベント
/// Block abort event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// ブロックのインデックス
/// Block index.
pub index: usize,
/// ブロックの種別
/// Block type.
pub block_type: BlockType,
/// 中断理由
/// Abort reason.
pub reason: String,
}
@@ -235,36 +243,36 @@ impl BlockAbort {
}
}
/// Thinking block stop で確定した reasoning material。
/// Reasoning material finalized at Thinking block stop.
///
/// `Item::Reasoning` の round-trip に必要な provider material を保持する。
/// `text` は deltas から収集した本文を上書きするために使う(metadata-only
/// reasoning block や provider completion event で全文が届くケース)。
/// Holds provider material needed to round-trip `Item::Reasoning`.
/// `text` overrides the body collected from deltas (for metadata-only reasoning
/// blocks or provider completion events that deliver the full text).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ReasoningBlockData {
/// scheme 側で観測した item id(OpenAI Responses の `id`)。
/// Item ID observed by the scheme (OpenAI Responses `id`).
pub id: Option<String>,
/// reasoning 本体テキスト。`None` の場合は block delta 収集結果を使う。
/// Reasoning body text. `None` uses the collected block deltas.
pub text: Option<String>,
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
/// Summary (OpenAI Responses `summary_text[]`); empty for other schemes.
pub summary: Vec<String>,
/// 暗号化された opaque blob(Anthropic `redacted_thinking.data` /
/// OpenAI Responses `encrypted_content`)。
/// Encrypted opaque blob (Anthropic `redacted_thinking.data` /
/// OpenAI Responses `encrypted_content`).
pub encrypted_content: Option<String>,
/// Anthropic extended thinking signature。round-trip 必須。
/// Anthropic extended thinking signature, required for round-tripping.
pub signature: Option<String>,
}
/// 停止理由
/// Stop reason.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// 自然終了
/// Natural end of turn.
EndTurn,
/// 最大トークン数到達
/// Maximum token count reached.
MaxTokens,
/// ストップシーケンス到達
/// Stop sequence reached.
StopSequence,
/// ツール使用
/// Tool use.
ToolUse,
}
@@ -273,7 +281,7 @@ pub enum StopReason {
// =============================================================================
impl Event {
/// テキストブロック開始イベントを作成
/// Create a text block start event.
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
@@ -282,7 +290,7 @@ impl Event {
})
}
/// テキストデルタイベントを作成
/// Create a text delta event.
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
@@ -290,7 +298,7 @@ impl Event {
})
}
/// テキストブロック停止イベントを作成
/// Create a text block stop event.
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
@@ -300,7 +308,7 @@ impl Event {
})
}
/// ツール使用ブロック開始イベントを作成
/// Create a tool use block start event.
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
@@ -312,7 +320,7 @@ impl Event {
})
}
/// ツール引数デルタイベントを作成
/// Create a tool argument delta event.
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
@@ -320,7 +328,7 @@ impl Event {
})
}
/// ツール使用ブロック停止イベントを作成
/// Create a tool use block stop event.
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
@@ -330,7 +338,7 @@ impl Event {
})
}
/// 使用量イベントを作成
/// Create a usage event.
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
@@ -341,7 +349,7 @@ impl Event {
})
}
/// Pingイベントを作成
/// Create a Ping event.
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
+8 -15
View File
@@ -1,20 +1,13 @@
//! LLMクライアント層
//! Boundary between the Engine and provider communication.
//!
//! 各LLMプロバイダと通信し、統一された[`Event`]
//! ストリームを出力します。
//! [`LlmClient`] accepts a shared [`Request`] and returns normalized [`Event`]s.
//! Provider-specific bodies, authentication requirements, and stream parsing are
//! separated into [`scheme`] and [`transport`] to limit how API changes affect
//! turn progression and tool execution semantics.
//!
//! # サポートするプロバイダ
//!
//! - Anthropic (Claude)
//! - OpenAI (GPT-4, etc.)
//! - Google (Gemini)
//! - Ollama (ローカルLLM)
//!
//! # アーキテクチャ
//!
//! - [`LlmClient`] - プロバイダ共通のtrait
//! - `providers`: プロバイダ固有のクライアント実装
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
//! Scheme capabilities describe what the wire format can safely handle.
//! The client construction layer owns per-model capability tables and alias
//! resolution; the Engine does not depend on that selection process.
pub mod auth;
pub mod capability;
+17 -17
View File
@@ -1,25 +1,25 @@
//! LLM response stream を開く前の transient error 向けリトライポリシー。
//! Retry policy for transient errors before opening an LLM response stream.
//!
//! `LlmClient::stream` の open error に対して `is_retryable` を見て
//! retry / backoff / cancellation をまとめて管理する。
//! SSE 読み出し開始後の失敗は対象外。
//! Uses `is_retryable` on `LlmClient::stream` open errors to manage retries,
//! backoff, and cancellation together. Failures after SSE reading begins
//! are excluded.
use std::time::Duration;
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
/// Policy combining exponential backoff, jitter, and a cumulative timeout.
///
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
/// 必要になったら拡張する。
/// `Default` returns fixed values shared across agen. Extend this if caller
/// overrides become necessary.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
/// フルジッターで実際の wait を抽選する。
/// Exponential base. Full jitter samples the actual wait from an upper bound
/// of `base * 2^attempt`, capped at `cap`.
pub base: Duration,
/// 1 回あたりの wait の上限。
/// Maximum wait per attempt.
pub cap: Duration,
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
/// Total attempts (initial attempt + retries). `1` disables retries.
pub max_attempts: u32,
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
/// Cumulative timeout from the first send. Waits exceeding it are cut short.
pub total_timeout: Duration,
}
@@ -35,8 +35,8 @@ impl Default for RetryPolicy {
}
impl RetryPolicy {
/// `attempt` 回目の失敗(0-indexed)後に待つ時間を返す。
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
/// Return the wait after failure number `attempt` (zero-indexed).
/// For a `Retry-After` override, use its value directly instead of calling this.
pub fn backoff(&self, attempt: u32) -> Duration {
let shift = attempt.min(20);
let base_nanos = self.base.as_nanos() as u64;
@@ -47,9 +47,9 @@ impl RetryPolicy {
}
}
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
/// 持たないがフルジッターのぶつかり回避には十分。
/// Sample a pseudorandom value from `[0, max_nanos]`. This lightweight
/// implementation mixes the low bits of `SystemTime` with splitmix64.
/// It is not cryptographically random, but suffices to avoid full-jitter collisions.
fn jitter_nanos(max_nanos: u64) -> u64 {
if max_nanos == 0 {
return 0;
@@ -1,17 +1,17 @@
//! Anthropic scheme の wire-level 既定 capability。
//! Wire-level default capability for the Anthropic scheme.
//!
//! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
//! の責務。ここでは未知モデルでも「この wire で
//! 安全に送れる最小共通項」を返すだけに留める。
//! Model ID tables (e.g. `claude-*`) belong to the client construction layer.
//! This module returns only the minimum common features safe to send over
//! this wire, even for unknown models.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
/// Scheme 既定の capability。
/// Scheme default capability.
///
/// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない
/// `CacheStrategy::Auto` にする。
/// Uses `CacheStrategy::Auto` to avoid sending `cache_control`, allowing reuse
/// with Ollama's `/v1/messages` endpoint.
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -1,6 +1,6 @@
//! Anthropic SSEイベントパース
//! Anthropic SSE event parsing.
//!
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
//! Parse Anthropic Messages API SSE events and convert them to the unified Event type.
use crate::llm_client::{
ClientError,
@@ -14,7 +14,7 @@ use serde::Deserialize;
use super::AnthropicScheme;
use super::scheme_impl::{AnthropicState, PendingThinking};
/// Anthropic SSEイベントタイプ
/// Anthropic SSE event type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnthropicEventType {
MessageStart,
@@ -28,7 +28,7 @@ pub(crate) enum AnthropicEventType {
}
impl AnthropicEventType {
/// イベントタイプ文字列からパース
/// Parse an event type string.
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"message_start" => Some(Self::MessageStart),
@@ -45,10 +45,10 @@ impl AnthropicEventType {
}
// ============================================================================
// SSEイベントのJSON構造
// SSE event JSON structures
// ============================================================================
/// message_start イベント
/// message_start event.
#[derive(Debug, Deserialize)]
pub(crate) struct MessageStartEvent {
pub message: MessageStartMessage,
@@ -62,7 +62,7 @@ pub(crate) struct MessageStartMessage {
pub usage: Option<UsageData>,
}
/// content_block_start イベント
/// content_block_start event.
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStartEvent {
pub index: usize,
@@ -79,15 +79,15 @@ pub(crate) enum ContentBlock {
Thinking {
#[serde(default)]
thinking: String,
/// 非ストリーミングレスポンス由来の初期 signature(通常はストリームでは
/// 空 → `signature_delta` で埋まる)。
/// Initial signature from a non-streaming response (usually empty in
/// streams, then filled by `signature_delta`).
#[serde(default)]
signature: Option<String>,
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化された opaque blob。signature ではなく、まるごと
/// `redacted_thinking.data` として送り返す必要がある。
/// Encrypted opaque blob, not a signature. Must be sent back in full
/// as `redacted_thinking.data`.
#[serde(default)]
data: String,
},
@@ -99,7 +99,7 @@ pub(crate) enum ContentBlock {
},
}
/// content_block_delta イベント
/// content_block_delta event.
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockDeltaEvent {
pub index: usize,
@@ -120,13 +120,13 @@ pub(crate) enum DeltaBlock {
SignatureDelta { signature: String },
}
/// content_block_stop イベント
/// content_block_stop event.
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStopEvent {
pub index: usize,
}
/// message_delta イベント
/// message_delta event.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageDeltaEvent {
@@ -141,7 +141,7 @@ pub(crate) struct MessageDeltaData {
pub stop_sequence: Option<String>,
}
/// 使用量データ
/// Usage data.
#[derive(Debug, Deserialize)]
pub(crate) struct UsageData {
pub input_tokens: Option<u64>,
@@ -150,7 +150,7 @@ pub(crate) struct UsageData {
pub cache_creation_input_tokens: Option<u64>,
}
/// エラーイベント
/// Error event.
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEventData {
pub error: ErrorDetail,
@@ -164,20 +164,20 @@ pub(crate) struct ErrorDetail {
}
// ============================================================================
// イベント変換
// Event conversion
// ============================================================================
impl AnthropicScheme {
/// SSEイベントをEvent型に変換
/// Convert an SSE event to Event.
///
/// # Arguments
/// * `event_type` - SSEイベントタイプ
/// * `data` - イベントデータJSON文字列
/// * `event_type` - SSE event type.
/// * `data` - Event data as a JSON string.
///
/// # Returns
/// * `Ok(Some(Event))` - 変換成功
/// * `Ok(None)` - イベントを無視(unknown event等)
/// * `Err(ClientError)` - パースエラー
/// * `Ok(Some(Event))` - Conversion succeeded.
/// * `Ok(None)` - Event ignored (e.g. an unknown event).
/// * `Err(ClientError)` - Parse error.
pub(crate) fn parse_event(
&self,
event_type: &str,
@@ -191,11 +191,11 @@ impl AnthropicScheme {
match event_type {
AnthropicEventType::MessageStart => {
let event: MessageStartEvent = serde_json::from_str(data)?;
// message_start時にUsageイベントがあれば出力
// Emit a Usage event at message_start if available.
if let Some(usage) = event.message.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
// Statusイベントとして開始を通知
// Notify start via a Status event.
Ok(Some(Event::Status(StatusEvent {
status: ResponseStatus::Started,
})))
@@ -210,18 +210,18 @@ impl AnthropicScheme {
}
AnthropicEventType::ContentBlockStop => {
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
// Note: BlockStopにはblock_typeが必要だが、AnthropicはStopイベントに含めない
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
// Note: BlockStop requires block_type, but Anthropic omits it from Stop events.
// The stateful parser tracks BlockStart to restore the correct block_type.
Ok(Some(Event::BlockStop(BlockStop {
index: event.index,
block_type: BlockType::Text, // Timeline層で上書きされる
block_type: BlockType::Text, // Placeholder for the stateful parser.
stop_reason: None,
reasoning: None,
})))
}
AnthropicEventType::MessageDelta => {
let event: MessageDeltaEvent = serde_json::from_str(data)?;
// Usage情報があれば出力
// Emit Usage information if available.
if let Some(usage) = event.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
@@ -271,7 +271,7 @@ impl AnthropicScheme {
DeltaContent::InputJson(partial_json.clone())
}
DeltaBlock::SignatureDelta { .. } => {
// signature_delta は無視
// Ignore signature_delta.
return None;
}
};
@@ -282,15 +282,14 @@ impl AnthropicScheme {
}))
}
/// state を持ち回す上位パース。
/// Higher-level parser carrying state across events.
///
/// `parse_event` の単発 Event に加えて、以下を行う:
/// - `content_block_stop` の `block_type` を直前の Start 値で書き戻す
/// - `thinking` / `redacted_thinking` ブロックの本体・signature・data を
/// `state.pending_thinking` に蓄積し、`content_block_stop` の Thinking
/// BlockStop metadata に載せる
/// - `signature_delta` を蓄積(Stream channel には流さず、reasoning metadata
/// にだけ反映する)
/// In addition to the single Event from `parse_event`, it:
/// - Writes the preceding Start's `block_type` into `content_block_stop`.
/// - Accumulates the body, signature, and data of `thinking` /
/// `redacted_thinking` blocks in `state.pending_thinking` and attaches them
/// to Thinking BlockStop metadata at `content_block_stop`.
/// - Accumulates `signature_delta` only in reasoning metadata, not the Stream channel.
pub(crate) fn parse_with_state(
&self,
event_type: &str,
@@ -301,8 +300,8 @@ impl AnthropicScheme {
return Ok(Vec::new());
};
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
// それ以外は parse_event で標準 Event 化する。
// Accumulate signature_delta in state only, not in the stream display.
// Convert everything else to standard Events via parse_event.
let mut emitted: Vec<Event> = Vec::new();
match parsed_event_type {
@@ -351,7 +350,7 @@ impl AnthropicScheme {
}
DeltaBlock::SignatureDelta { signature } => {
if let Some(pending) = state.pending_thinking.as_mut() {
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
// Usually received once, but concatenate multiple fragments if present.
match &mut pending.signature {
Some(acc) => acc.push_str(signature),
None => pending.signature = Some(signature.clone()),
@@ -391,7 +390,7 @@ impl AnthropicScheme {
reasoning,
}));
}
// 残りは state を必要としない。既存 parse_event に委譲。
// The rest needs no state; delegate to the existing parse_event.
_ => {
if let Some(event) = self.parse_event(event_type, data)? {
emitted.push(event);
@@ -403,13 +402,13 @@ impl AnthropicScheme {
}
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
// Anthropic の `input_tokens` は **キャッシュ外** の入力トークンのみで、
// プロンプト全長は input_tokens + cache_read + cache_creation。
// UsageEvent の `input_tokens` には「占有量(プロンプト全長)」を載せる
// 規約に合わせて、ここでキャッシュ分を足し込む。
// cache_read_input_tokens / cache_creation_input_tokens は内訳として
// 別フィールドに残るので、料金計算側で `input - cache_read - cache_creation`
// により非キャッシュ入力分は逆算可能。
// Anthropic's `input_tokens` counts only **uncached** input tokens.
// Full prompt length is input_tokens + cache_read + cache_creation.
// Add cached tokens here to follow UsageEvent's convention of reporting
// occupancy (full prompt length) in `input_tokens`.
// cache_read_input_tokens / cache_creation_input_tokens remain separate
// breakdown fields, so billing can recover uncached input with
// `input - cache_read - cache_creation`.
let raw_input = usage.input_tokens.unwrap_or(0);
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
@@ -438,7 +437,7 @@ mod tests {
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
match event {
Event::Usage(u) => {
// キャッシュなしなので input_total = raw_input = 10
// No cache, so input_total = raw_input = 10.
assert_eq!(u.input_tokens, Some(10));
}
_ => panic!("Expected Usage event"),
@@ -447,9 +446,9 @@ mod tests {
#[test]
fn test_convert_usage_includes_cache_in_input_total() {
// Anthropic の input_tokens はキャッシュ外のみで、占有量は
// input + cache_read + cache_creation。
// UsageEvent.input_tokens は占有量に正規化される。
// Anthropic's input_tokens counts only uncached tokens; occupancy is
// input + cache_read + cache_creation.
// UsageEvent.input_tokens is normalized to occupancy.
let scheme = AnthropicScheme::new();
let usage = UsageData {
input_tokens: Some(100),
@@ -531,7 +530,7 @@ mod tests {
#[test]
fn thinking_block_stop_carries_reasoning_with_signature() {
// thinking ブロックが完了したら reasoning metadata に text+signature が乗ること
// Completed thinking blocks carry text+signature in reasoning metadata.
let scheme = AnthropicScheme::new();
let mut state = AnthropicState::default();
@@ -1,7 +1,7 @@
//! Anthropic Messages API スキーマ
//! Anthropic Messages API scheme.
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
//! - Request JSON generation.
//! - SSE event parsing → Event conversion.
mod capability;
mod events;
@@ -10,14 +10,14 @@ mod scheme_impl;
pub use scheme_impl::AnthropicState;
/// Anthropicスキーマ
/// Anthropic scheme.
///
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
/// Handles request/response conversion for the Anthropic Messages API.
#[derive(Debug, Clone)]
pub struct AnthropicScheme {
/// APIバージョン
/// API version.
pub api_version: String,
/// 細粒度ツールストリーミングを有効にするか
/// Whether to enable fine-grained tool streaming.
pub fine_grained_tool_streaming: bool,
}
@@ -31,12 +31,12 @@ impl Default for AnthropicScheme {
}
impl AnthropicScheme {
/// 新しいスキーマを作成
/// Create a new scheme.
pub fn new() -> Self {
Self::default()
}
/// 細粒度ツールストリーミングを有効/無効にする
/// Enable or disable fine-grained tool streaming.
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
self.fine_grained_tool_streaming = enabled;
self
@@ -41,7 +41,7 @@ pub(crate) struct AnthropicRequest {
pub thinking: Option<AnthropicThinking>,
}
/// Anthropic extended thinking 指示。
/// Anthropic extended thinking instructions.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum AnthropicThinking {
@@ -90,8 +90,7 @@ pub(crate) enum AnthropicContentPart {
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化済み reasoning blob。`Item::Reasoning::encrypted_content`
/// から渡る。
/// Encrypted reasoning blob from `Item::Reasoning::encrypted_content`.
data: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
@@ -181,9 +180,9 @@ pub(crate) struct AnthropicTool {
impl AnthropicScheme {
/// Build Anthropic request from Request.
///
/// `capability.prompt_caching` が [`CacheStrategy::Auto`] のときは
/// `cache_control` マーカーを一切挿入しない(Ollama の `/v1/messages`
/// 流用時など、サーバ側が `cache_control` を受け付けないケース)。
/// No `cache_control` markers are inserted when `capability.prompt_caching`
/// is [`CacheStrategy::Auto`] (e.g. servers that reject `cache_control`,
/// such as Ollama when reusing `/v1/messages`).
pub(crate) fn build_request(
&self,
model: &str,
@@ -198,9 +197,8 @@ impl AnthropicScheme {
let messages = self.convert_items_to_messages(&request.items, &breakpoints);
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
// Reasoning の投影: capability が BudgetTokens / Both をサポート
// していて、request 側で budget_tokens が指定されているときだけ
// thinking フィールドを付ける。
// Project reasoning: add the thinking field only when the capability
// supports BudgetTokens / Both and the request specifies budget_tokens.
let supports_budget_tokens = matches!(
capability.reasoning,
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
@@ -263,6 +261,7 @@ impl AnthropicScheme {
for (i, item) in items.iter().enumerate() {
match item {
Item::AssistantResponseBoundary { .. } => {}
Item::Message { role, content, .. } => {
let parts: Vec<AnthropicContentPart> = content
.iter()
@@ -360,23 +359,23 @@ impl AnthropicScheme {
..
} => {
flush_pending(&mut messages, &mut pending_user, "user", &mut locations);
// Anthropic はアシスタントターン中の `thinking` /
// `redacted_thinking` ブロックを必ず assistant role の
// content_part として送り返す必要がある。
// Anthropic requires `thinking` / `redacted_thinking` blocks
// from an assistant turn to be sent back as assistant-role
// content_parts.
//
// - signature あり: `thinking` content_part を投影
// - signature 無し + encrypted_content あり:
// `redacted_thinking` content_part を投影
// - どちらも無い: 他 scheme(OpenAI 等)から流入した
// 素の reasoning text。Anthropic に投げる意味も
// round-trip の根拠も無いので drop。
// - With signature: project a `thinking` content_part.
// - No signature, but encrypted_content present:
// project a `redacted_thinking` content_part.
// - Neither: plain reasoning text from another scheme
// (e.g. OpenAI). Drop it: there is no reason to send it to
// Anthropic and no basis for round-tripping it.
if let Some(sig) = signature.clone() {
pending_assistant
.push((i, AnthropicContentPart::thinking(text.clone(), sig)));
} else if let Some(data) = encrypted_content.clone() {
pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data)));
}
// どちらも None なら何も pend せず、本 item は無視。
// If both are None, queue nothing and ignore this item.
}
}
}
@@ -485,7 +484,7 @@ mod tests {
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
};
/// cache_control が有効になる既定の capability。
/// Default capability with cache_control enabled.
fn cap_explicit() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -496,7 +495,7 @@ mod tests {
}
}
/// cache_control を送らない capability(Ollama 等)。
/// Capability that does not send cache_control (e.g. Ollama).
fn cap_auto() -> ModelCapability {
ModelCapability {
prompt_caching: CacheStrategy::Auto,
@@ -900,8 +899,8 @@ mod tests {
#[test]
fn cache_auto_does_not_add_cache_control() {
// Ollama のように `CacheStrategy::Auto` のときは cache_control
// マーカーを一切付けない。breakpoint 計算も走らないこと。
// With `CacheStrategy::Auto` (e.g. Ollama), add no cache_control markers.
// Breakpoint calculation must not run either.
let scheme = AnthropicScheme::new();
let mut request = Request::new().user("hello");
request.cache_anchor = Some(0);
@@ -932,8 +931,8 @@ mod tests {
#[test]
fn reasoning_with_signature_projects_thinking_part() {
// Item::Reasoning に signature があれば assistant role の
// `thinking` content_part として送る。
// Send Item::Reasoning with a signature as an assistant-role
// `thinking` content_part.
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
@@ -975,21 +974,21 @@ mod tests {
#[test]
fn reasoning_without_signature_or_encrypted_is_dropped() {
// 他 scheme から流入した素の reasoning は Anthropic に投げない。
// Do not send plain reasoning from other schemes to Anthropic.
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
.item(Item::reasoning("plain text"))
.item(Item::assistant_message("done"));
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
// thinking part は 1 つも乗らない
// No thinking parts are included.
assert!(collect_assistant_thinking_parts(&req).is_empty());
}
#[test]
fn thinking_part_lands_in_assistant_role_message() {
// wire 構造の position 検証: thinking part は assistant role の
// message 配列に並ぶ(user role には絶対に入らない)。
// Verify wire positions: thinking parts belong in the assistant-role
// message array (never in the user role).
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("question?")
@@ -999,7 +998,7 @@ mod tests {
.user("follow up");
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
// 全 thinking part が assistant role の message に存在すること
// All thinking parts must be in assistant-role messages.
let mut thinking_msg_indices = Vec::new();
for (i, msg) in req.messages.iter().enumerate() {
if let AnthropicContent::Parts(parts) = &msg.content {
@@ -1022,9 +1021,9 @@ mod tests {
req.messages,
);
// thinking part を含む assistant message は、それに続く tool_use を含む
// assistant message より前 (= 先頭側) に位置すること
// (Anthropic 仕様: 同一論理ターン内で thinking → tool_use の順)
// Assistant messages containing thinking parts must precede the
// subsequent assistant messages containing tool_use.
// (Anthropic requires thinking → tool_use within one logical turn.)
let mut tool_use_msg_indices = Vec::new();
for (i, msg) in req.messages.iter().enumerate() {
if let AnthropicContent::Parts(parts) = &msg.content {
@@ -1049,7 +1048,7 @@ mod tests {
#[test]
fn redacted_thinking_part_lands_in_assistant_role_message() {
// RedactedThinking も同様に assistant role に置かれること。
// RedactedThinking must also be placed in the assistant role.
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("ask")
@@ -1,7 +1,7 @@
//! `impl Scheme for AnthropicScheme`
//!
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
//! Implements the URLs, headers, SSE parsing, and request body generation needed
//! for the Anthropic Messages API wire format through the shared `Scheme` trait.
use serde_json::Value;
@@ -9,6 +9,7 @@ use crate::llm_client::{
ClientError,
auth::AuthRequirement,
capability::ModelCapability,
client::ToolCallCompletionSupport,
event::{BlockType, Event, ReasoningBlockData},
scheme::Scheme,
types::Request,
@@ -16,22 +17,20 @@ use crate::llm_client::{
use super::AnthropicScheme;
/// Anthropic の SSE パースで必要な状態。
/// State required for Anthropic SSE parsing.
///
/// 1. `content_block_stop` イベントは `block_type` を持たない仕様なので、
/// 直前の `content_block_start` で観測した `block_type` を保持して
/// `BlockStop` に書き戻す。
/// 2. `thinking` ブロック中の `thinking_delta` テキストと `signature_delta`
/// 署名、および `redacted_thinking` ブロックの `data` を蓄積し、
/// `content_block_stop` の Thinking block metadata として返す
/// (round-trip 永続化のため)。
/// 1. `content_block_stop` events lack `block_type`, so retain the type observed
/// in the preceding `content_block_start` and write it back into `BlockStop`.
/// 2. Accumulate `thinking_delta` text and `signature_delta` signatures from
/// `thinking` blocks, plus `data` from `redacted_thinking` blocks. Return them
/// as Thinking block metadata at `content_block_stop` for round-trip persistence.
#[derive(Debug, Default)]
pub struct AnthropicState {
pub(crate) current_block_type: Option<BlockType>,
pub(crate) pending_thinking: Option<PendingThinking>,
}
/// 1 つの `thinking` または `redacted_thinking` content_block の蓄積バッファ。
/// Accumulation buffer for one `thinking` or `redacted_thinking` content_block.
#[derive(Debug, Default)]
pub(crate) struct PendingThinking {
pub(crate) text: String,
@@ -63,11 +62,9 @@ impl Scheme for AnthropicScheme {
}
fn required_auth(&self) -> AuthRequirement {
// Ollama の `/v1/messages` 互換では認証が要らないが、それは
// `AuthRef::None` + `build_headers` 側の「ResolvedAuth::None
// なら何もしない」分岐で吸収する(`accepts` 判定で弾かれない
// よう、現状は XApiKey を要求しつつ、None 側でもパスするよう
// にする戦略)。
// Anthropic uses XApiKey. Compatible endpoints such as Ollama can opt out
// through ResolvedAuth::None, which the transport accepts for any auth
// requirement and handles without sending authentication headers.
AuthRequirement::XApiKey
}
@@ -104,4 +101,8 @@ impl Scheme for AnthropicScheme {
fn default_capability(&self) -> ModelCapability {
super::capability::default_capability()
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
ToolCallCompletionSupport::PerBlock
}
}
@@ -1,14 +1,14 @@
//! Gemini scheme の wire-level 既定 capability。
//! Wire-level default capability for the Gemini scheme.
//!
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
//! client construction layer の責務。ここでは wire の
//! 保守的 default のみ。
//! Model ID tables (reasoning support by `gemini-*` version) belong to the
//! client construction layer. This module provides only conservative wire
//! defaults.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
/// Scheme 既定の capability(未知モデル / 未明示モデル用)。
/// Scheme default capability (for unknown or unspecified models).
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -1,6 +1,6 @@
//! Gemini SSEイベントパース
//! Gemini SSE event parsing.
//!
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
//! Parse Google Gemini API SSE events and convert them to the unified Event type.
use crate::llm_client::{
ClientError,
@@ -11,117 +11,117 @@ use serde::Deserialize;
use super::GeminiScheme;
// ============================================================================
// SSEイベントのJSON構造
// SSE event JSON structures
// ============================================================================
/// Gemini GenerateContentResponse (ストリーミングチャンク)
/// Gemini GenerateContentResponse (streaming chunk).
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateContentResponse {
/// 候補
/// Candidates.
pub candidates: Option<Vec<Candidate>>,
/// 使用量メタデータ
/// Usage metadata.
pub usage_metadata: Option<UsageMetadata>,
/// プロンプトフィードバック
/// Prompt feedback.
pub prompt_feedback: Option<PromptFeedback>,
/// モデルバージョン
/// Model version.
pub model_version: Option<String>,
}
/// 候補
/// Candidate.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Candidate {
/// コンテンツ
/// Content.
pub content: Option<CandidateContent>,
/// 完了理由
/// Finish reason.
pub finish_reason: Option<String>,
/// インデックス
/// Index.
pub index: Option<usize>,
/// 安全性評価
/// Safety rating.
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 候補コンテンツ
/// Candidate content.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct CandidateContent {
/// パーツ
/// Parts.
pub parts: Option<Vec<CandidatePart>>,
/// ロール
/// Role.
pub role: Option<String>,
}
/// 候補パーツ
/// Candidate part.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CandidatePart {
/// テキスト
/// Text.
pub text: Option<String>,
/// 関数呼び出し
/// Function call.
pub function_call: Option<FunctionCall>,
}
/// 関数呼び出し
/// Function call.
#[derive(Debug, Deserialize)]
pub(crate) struct FunctionCall {
/// 関数名
/// Function name.
pub name: String,
/// 引数
/// Arguments.
pub args: Option<serde_json::Value>,
}
/// 使用量メタデータ
/// Usage metadata.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageMetadata {
/// プロンプトトークン数
/// Prompt token count.
pub prompt_token_count: Option<u64>,
/// 候補トークン数
/// Candidate token count.
pub candidates_token_count: Option<u64>,
/// 合計トークン数
/// Total token count.
pub total_token_count: Option<u64>,
}
/// プロンプトフィードバック
/// Prompt feedback.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PromptFeedback {
/// ブロック理由
/// Block reason.
pub block_reason: Option<String>,
/// 安全性評価
/// Safety rating.
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 安全性評価
/// Safety rating.
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct SafetyRating {
/// カテゴリ
/// Category.
pub category: Option<String>,
/// 確率
/// Probability.
pub probability: Option<String>,
}
// ============================================================================
// イベント変換
// Event conversion
// ============================================================================
impl GeminiScheme {
/// SSEデータをEvent型に変換
/// Convert SSE data to Event.
///
/// # Arguments
/// * `data` - SSEイベントデータJSON文字列
/// * `data` - SSE event data as a JSON string.
///
/// # Returns
/// * `Ok(Some(Vec<Event>))` - 変換成功
/// * `Ok(None)` - イベントを無視
/// * `Err(ClientError)` - パースエラー
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
/// * `Ok(None)` - Event ignored.
/// * `Err(ClientError)` - Parse error.
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
// データが空または無効な場合はスキップ
// Skip empty or invalid data.
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
@@ -136,12 +136,12 @@ impl GeminiScheme {
let mut events = Vec::new();
// 使用量メタデータ
// Usage metadata.
if let Some(usage) = response.usage_metadata {
events.push(self.convert_usage(&usage));
}
// 候補を処理
// Process candidates.
if let Some(candidates) = response.candidates {
for candidate in candidates {
let candidate_index = candidate.index.unwrap_or(0);
@@ -149,26 +149,26 @@ impl GeminiScheme {
if let Some(content) = candidate.content {
if let Some(parts) = content.parts {
for (part_index, part) in parts.iter().enumerate() {
// テキストデルタ
// Text delta.
if let Some(text) = &part.text {
if !text.is_empty() {
// Geminiは明示的なBlockStartを送らないため、
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
// Gemini sends no explicit BlockStart, so emit TextDelta
// directly (Timeline handles the implicit start).
events.push(Event::text_delta(part_index, text.clone()));
}
}
// 関数呼び出し
// Function call.
if let Some(function_call) = &part.function_call {
// 関数呼び出しの開始
// Geminiでは関数呼び出しは一度に送られることが多い
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
// Function call start.
// Gemini usually sends function calls all at once, but may
// send partial calls when streaming arguments are enabled.
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
// Gemini has no function call IDs, so use the name as the ID.
let function_id = format!("call_{}", function_call.name);
events.push(Event::BlockStart(BlockStart {
index: candidate_index * 10 + part_index, // 複合インデックス
index: candidate_index * 10 + part_index, // Composite index.
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: function_id,
@@ -176,7 +176,7 @@ impl GeminiScheme {
},
}));
// 引数がある場合はデルタとして送る
// Send arguments as a delta if present.
if let Some(args) = &function_call.args {
let args_str = serde_json::to_string(args).unwrap_or_default();
if !args_str.is_empty() && args_str != "null" {
@@ -191,7 +191,7 @@ impl GeminiScheme {
}
}
// 完了理由
// Finish reason.
if let Some(finish_reason) = candidate.finish_reason {
let stop_reason = match finish_reason.as_str() {
"STOP" => Some(StopReason::EndTurn),
@@ -200,7 +200,7 @@ impl GeminiScheme {
_ => None,
};
// テキストブロックの停止
// Text block stop.
events.push(Event::BlockStop(BlockStop {
index: candidate_index,
block_type: BlockType::Text,
@@ -262,7 +262,7 @@ mod tests {
let events = scheme.parse_event(data).unwrap().unwrap();
// Usageイベントが含まれるはず
// Should contain a Usage event.
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
assert!(usage_event.is_some());
@@ -280,7 +280,7 @@ mod tests {
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStartイベントがあるはず
// Should contain a BlockStart event.
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
assert!(start_event.is_some());
@@ -293,7 +293,7 @@ mod tests {
}
}
// 引数デルタもあるはず
// Should also contain an argument delta.
let delta_event = events.iter().find(|e| {
if let Event::BlockDelta(d) = e {
matches!(d.delta, DeltaContent::InputJson(_))
@@ -311,7 +311,7 @@ mod tests {
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStopイベントがあるはず
// Should contain a BlockStop event.
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
assert!(stop_event.is_some());
@@ -1,29 +1,29 @@
//! Google Gemini API スキーマ
//! Google Gemini API scheme.
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
//! - Request JSON generation.
//! - SSE event parsing → Event conversion.
mod capability;
mod events;
mod request;
mod scheme_impl;
/// Geminiスキーマ
/// Gemini scheme.
///
/// Google Gemini APIのリクエスト/レスポンス変換を担当
/// Handles request/response conversion for the Google Gemini API.
#[derive(Debug, Clone, Default)]
pub struct GeminiScheme {
/// ストリーミング関数呼び出し引数を有効にするか
/// Whether to enable streaming function call arguments.
pub stream_function_call_arguments: bool,
}
impl GeminiScheme {
/// 新しいスキーマを作成
/// Create a new scheme.
pub fn new() -> Self {
Self::default()
}
/// ストリーミング関数呼び出し引数を有効/無効にする
/// Enable or disable streaming function call arguments.
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
self.stream_function_call_arguments = enabled;
self
@@ -140,12 +140,12 @@ pub(crate) struct GeminiGenerationConfig {
/// Stop sequences
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
/// Thinking / reasoning 設定(Gemini 2.5 以降)。
/// Thinking / reasoning settings (Gemini 2.5 and later).
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_config: Option<GeminiThinkingConfig>,
}
/// Gemini thinking config (gemini-2.5 以降)
/// Gemini thinking config (gemini-2.5 and later).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiThinkingConfig {
@@ -193,8 +193,8 @@ impl GeminiScheme {
None
};
// Reasoning の投影: capability が BudgetTokens / Both をサポートし、
// request 側で budget_tokens が指定されているときだけ thinking_config を付ける。
// Project reasoning: add thinking_config only when the capability
// supports BudgetTokens / Both and the request specifies budget_tokens.
let supports_budget = matches!(
capability.reasoning,
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
@@ -242,6 +242,7 @@ impl GeminiScheme {
for item in items {
match item {
Item::AssistantResponseBoundary { .. } => {}
Item::Message { role, content, .. } => {
// Flush pending parts
self.flush_pending_parts(
+66 -37
View File
@@ -1,11 +1,12 @@
//! APIスキーマ定義
//! API scheme definitions.
//!
//! 各APIスキーマごとの変換ロジック
//! - リクエスト変換: Request → プロバイダ固有JSON
//! - レスポンス変換: SSEイベント → Event
//! Conversion logic for each API scheme:
//! - Request conversion: Request → provider-specific JSON.
//! - Response conversion: SSE events → Event.
//!
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
//! The [`Scheme`] trait delegates all scheme-specific differences from
//! `HttpTransport<S>` (paths, headers, authentication requirements, body
//! generation, and SSE parsing).
pub mod anthropic;
pub mod gemini;
@@ -16,47 +17,45 @@ use serde_json::Value;
use super::auth::AuthRequirement;
use super::capability::ModelCapability;
use super::client::ConfigWarning;
use super::client::{ConfigWarning, ToolCallCompletionSupport};
use super::error::ClientError;
use super::event::Event;
use super::types::{Request, RequestConfig};
/// wire scheme の抽象。各プロバイダの API 仕様ごとに 1 つ実装する。
/// Wire scheme abstraction, implemented once per provider API specification.
///
/// `HttpTransport<S: Scheme>` が URL 組立・認証ヘッダ挿入・SSE パース
/// のループを担い、`Scheme` 実装は各仕様固有の差分のみ提供する。
/// `HttpTransport<S: Scheme>` handles URL assembly, authentication header
/// insertion, and the SSE parsing loop; `Scheme` implementations supply only
/// specification-specific differences.
///
/// # 状態
/// # State
///
/// SSE パースでフレーム間に状態を保つ必要がある scheme(Anthropic の
/// `BlockStop` に `block_type` が載らない仕様の補完など)は
/// [`Scheme::State`] に中間状態を表す型を置く。
/// 状態を持たない scheme は `type State = ()` とする。
/// Schemes requiring state across SSE frames (e.g. to compensate for Anthropic
/// omitting `block_type` from `BlockStop`) define an intermediate state type in
/// [`Scheme::State`]. Stateless schemes use `type State = ()`.
pub trait Scheme: Clone + Send + Sync + 'static {
/// SSE パースのフレーム間で共有する状態。`HttpTransport` が
/// ストリーム開始時に `Default::default()` を一度だけ作り、
/// フレームごとに `&mut` で渡す。
/// State shared across SSE frames. `HttpTransport` creates it once via
/// `Default::default()` at stream start and passes it by `&mut` for each frame.
type State: Default + Send + 'static;
/// scheme のベース URL(`ModelConfig::base_url` 未指定時のデフォルト)
/// Default base URL for callers that do not supply an override.
fn default_base_url(&self) -> &'static str;
/// リクエスト先の相対パス。Gemini のようにモデル名をパスに埋め込む
/// プロバイダもあるため、モデル ID を受け取る。
/// Relative request path. Accepts a model ID because some providers, such as
/// Gemini, embed the model name in the path.
fn path(&self, model_id: &str) -> String;
/// この scheme が要求する認証形式。呼び出し側は client 構築時に
/// 設定された認証情報と照合する。
/// Authentication format required by this scheme. The caller checks it
/// against configured credentials when constructing the client.
fn required_auth(&self) -> AuthRequirement;
/// `Content-Type` 以外の追加ヘッダ。`anthropic-version` / `anthropic-beta` 等。
/// Additional headers beyond `Content-Type`, e.g. `anthropic-version` / `anthropic-beta`.
fn additional_headers(&self) -> Vec<(&'static str, String)> {
Vec::new()
}
/// リクエスト body を生成する。`capability` は `CacheStrategy` や
/// `ReasoningSupport` を参照して scheme 側の挙動を分岐させるため
/// に渡される。
/// Build the request body. `capability` lets the scheme select behavior
/// based on `CacheStrategy` and `ReasoningSupport`.
fn build_request_body(
&self,
model_id: &str,
@@ -64,11 +63,11 @@ pub trait Scheme: Clone + Send + Sync + 'static {
capability: &ModelCapability,
) -> Value;
/// SSE イベント 1 件を 0 個以上の [`Event`] に変換する。
/// Convert one SSE event to zero or more [`Event`]s.
///
/// `event_type` は SSE フレームの `event:` フィールド、`data` は
/// `data:` フィールド。`[DONE]` 等の終端マーカーは実装側で判定する。
/// `state` はストリーム単位で共有される可変状態。
/// `event_type` is the SSE frame's `event:` field; `data` is its `data:` field.
/// Implementations detect terminal markers such as `[DONE]`.
/// `state` is mutable state shared for the stream.
fn parse_sse(
&self,
event_type: &str,
@@ -76,16 +75,46 @@ pub trait Scheme: Clone + Send + Sync + 'static {
state: &mut Self::State,
) -> Result<Vec<Event>, ClientError>;
/// scheme 既定の capability。モデル ID に関係なく、この wire で
/// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは
/// 高レベルの client 構築層の責務で、scheme はここには関与しない。
/// Scheme default capability: the minimum common features safe to send over
/// this wire, regardless of model ID. Known-model capability tables belong
/// to the higher-level client construction layer, not the scheme.
fn default_capability(&self) -> ModelCapability;
/// scheme 側でサポートしていない `RequestConfig` フィールドを
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
/// デフォルトは空 Vec。
/// Strongest safe completion boundary for streamed tool calls.
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
ToolCallCompletionSupport::ResponseComplete
}
/// Return warnings for `RequestConfig` fields unsupported by the scheme
/// (e.g. OpenAI Chat does not support `top_k`). Defaults to an empty Vec.
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let _ = config;
Vec::new()
}
}
#[cfg(test)]
mod completion_support_tests {
use super::*;
use crate::llm_client::ToolCallCompletionSupport;
#[test]
fn only_schemes_with_authoritative_per_call_done_events_enable_early_dispatch() {
assert_eq!(
anthropic::AnthropicScheme::new().tool_call_completion_support(),
ToolCallCompletionSupport::PerBlock
);
assert_eq!(
openai_responses::OpenAIResponsesScheme::new().tool_call_completion_support(),
ToolCallCompletionSupport::PerBlock
);
assert_eq!(
openai_chat::OpenAIScheme::new().tool_call_completion_support(),
ToolCallCompletionSupport::ResponseComplete
);
assert_eq!(
gemini::GeminiScheme::new().tool_call_completion_support(),
ToolCallCompletionSupport::ResponseComplete
);
}
}
@@ -1,14 +1,14 @@
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
//! Wire-level default capability for the OpenAI Chat Completions scheme.
//!
//! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
//! の責務。ここでは wire の保守的 default のみ。
//! Model ID tables (e.g. the `gpt-5` family) belong to the client construction
//! layer. This module provides only conservative wire defaults.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
/// で未知モデル ID を受けたときのフォールバックに使う。
/// Scheme default capability, used as a fallback for unknown model IDs on
/// OpenAI-compatible routers (xAI / Groq / OpenRouter, etc.).
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
@@ -1,4 +1,4 @@
//! OpenAI SSEイベントパース
//! OpenAI SSE event parsing.
use crate::llm_client::{
ClientError,
@@ -61,10 +61,10 @@ pub(crate) struct ChunkUsage {
}
impl OpenAIScheme {
/// SSEデータのパースとEventへの変換
/// Parse SSE data and convert it to Event.
///
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
/// Timeline層が暗黙的なBlockStartを処理する。
/// The OpenAI API does not explicitly send BlockStart events.
/// The Timeline layer handles implicit BlockStart.
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
if data == "[DONE]" {
return Ok(None);
@@ -94,8 +94,8 @@ impl OpenAIScheme {
for choice in chunk.choices {
// Text Content Delta
if let Some(content) = choice.delta.content {
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
// Timeline層が暗黙的なBlockStartを処理する
// Emit only deltas because the OpenAI API sends no BlockStart.
// The Timeline layer handles implicit BlockStart.
events.push(Event::text_delta(choice.index, content));
}
@@ -136,11 +136,11 @@ impl OpenAIScheme {
finish_reason == "tool_calls" || finish_reason == "function_call";
if is_tool_finish {
// ツール呼び出し終了
// Note: OpenAIはどのツールが終了したか明示しないため、
// Timeline層で適切に処理する必要がある
// Tool call ended.
// Note: OpenAI does not specify which tool ended, so the
// Timeline layer must handle this appropriately.
} else {
// テキスト終了
// Text ended.
events.push(Event::text_block_stop(choice.index, stop_reason));
}
}
@@ -165,7 +165,7 @@ mod tests {
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// OpenAIはBlockStartを発行しないため、デルタのみ
// Deltas only, since OpenAI does not emit BlockStart.
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
@@ -1,31 +1,32 @@
//! OpenAI Chat Completions API スキーマ
//! OpenAI Chat Completions API scheme.
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
//! - Request JSON generation.
//! - SSE event parsing → Event conversion.
pub(crate) mod capability;
mod events;
mod request;
mod scheme_impl;
/// OpenAIスキーマ
/// OpenAI scheme.
///
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
/// Handles request/response conversion for the OpenAI Chat Completions API
/// (and compatible APIs).
#[derive(Debug, Clone, Default)]
pub struct OpenAIScheme {
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
/// Model name (supplied per request, but may also be stored as a default).
pub model: Option<String>,
/// レガシーなmax_tokensを使用するか (Ollama互換用)
/// Whether to use legacy max_tokens (for Ollama compatibility).
pub use_legacy_max_tokens: bool,
}
impl OpenAIScheme {
/// 新しいスキーマを作成
/// Create a new scheme.
pub fn new() -> Self {
Self::default()
}
/// レガシーなmax_tokensを使用するか設定
/// Set whether to use legacy max_tokens.
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
self.use_legacy_max_tokens = use_legacy;
self
@@ -38,7 +38,7 @@ pub(crate) struct OpenAIRequest {
pub tools: Vec<OpenAITool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<String>,
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
/// Reasoning effort (supported by the o1 / o3 / o4 / gpt-5 families).
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
@@ -147,8 +147,8 @@ impl OpenAIScheme {
(None, request.config.max_tokens)
};
// Reasoning の投影: capability が Effort / Both をサポートし、
// request 側で effort が指定されているときだけ reasoning_effort を付ける。
// Project reasoning: add reasoning_effort only when the capability
// supports Effort / Both and the request specifies an effort.
let supports_effort = matches!(
capability.reasoning,
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
@@ -221,6 +221,7 @@ impl OpenAIScheme {
);
}
match item {
Item::AssistantResponseBoundary { .. } => {}
Item::Message { role, content, .. } => {
// Flush pending tool calls
self.flush_pending_assistant(
@@ -45,7 +45,7 @@ impl Scheme for OpenAIScheme {
data: &str,
_state: &mut Self::State,
) -> Result<Vec<Event>, ClientError> {
// `data: [DONE]` は終端マーカー
// `data: [DONE]` is a terminal marker.
if data.trim() == "[DONE]" {
return Ok(Vec::new());
}
@@ -58,7 +58,7 @@ impl Scheme for OpenAIScheme {
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
// OpenAI Chat Completions API は top_k を受け付けない
// The OpenAI Chat Completions API does not accept top_k.
if config.top_k.is_some() {
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI Chat"));
}
@@ -1,7 +1,7 @@
//! OpenAI Responses scheme の wire-level 既定 capability。
//! Wire-level default capability for the OpenAI Responses scheme.
//!
//! モデル ID 固有の能力テーブルは高レベルの client 構築層の責務。
//! ここでは wire の保守的 default のみ。
//! Model ID capability tables belong to the higher-level client construction
//! layer. This module provides only conservative wire defaults.
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
@@ -1,9 +1,9 @@
//! OpenAI Responses API の SSE イベントパース
//! OpenAI Responses API SSE event parsing.
//!
//! `response.*` 名前空間の SSE を共通の [`Event`](crate::llm_client::event::Event)
//! に変換する。Responses の (output_index, content_index) 2 次元座標と
//! この crate の 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
//! [`OpenAIResponsesState`] が保持する。
//! Converts SSE events in the `response.*` namespace to shared [`Event`]s.
//! [`OpenAIResponsesState`] holds the mapping from Responses' two-dimensional
//! (output_index, content_index) coordinates to this crate's one-dimensional
//! `BlockStart/Delta/Stop::index`.
use std::collections::{BTreeMap, HashMap};
@@ -18,31 +18,69 @@ use crate::llm_client::{
},
};
/// SSE パース中の座標 → flat block index マップ。
/// Coordinate → flat block index map used during SSE parsing.
#[derive(Debug, Default)]
pub struct OpenAIResponsesState {
slots: HashMap<SlotKey, SlotInfo>,
next_index: usize,
/// 蓄積中の reasoning output_item。`output_item.added`(Reasoning) で
/// 確保し、`reasoning_text.delta` / `reasoning_summary_text.delta` で
/// 蓄積、`output_item.done`(Reasoning) で既存 reasoning_text block または
/// metadata-only Thinking block に reasoning persistence material を載せる。
/// Reasoning output_items being accumulated. Allocated at
/// `output_item.added` (Reasoning), filled by `reasoning_text.delta` /
/// `reasoning_summary_text.delta`, then attached as reasoning persistence
/// material to an existing reasoning_text block or metadata-only Thinking
/// block at `output_item.done` (Reasoning).
pending_reasoning: HashMap<usize, PendingReasoning>,
/// Tool input deltas and authoritative final values, keyed by output index.
pending_tool_inputs: HashMap<usize, PendingToolInput>,
}
/// 1 つの reasoning output_item の蓄積バッファ。
/// A tool input stream accumulated until an authoritative done payload arrives.
#[derive(Debug)]
struct PendingToolInput {
kind: ToolInputKind,
streamed: String,
saw_delta: bool,
final_value: Option<String>,
}
impl PendingToolInput {
fn new(kind: ToolInputKind) -> Self {
Self {
kind,
streamed: String::new(),
saw_delta: false,
final_value: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolInputKind {
FunctionCall,
CustomToolCall,
}
impl ToolInputKind {
fn label(self) -> &'static str {
match self {
Self::FunctionCall => "function-call arguments",
Self::CustomToolCall => "custom-tool input",
}
}
}
/// Accumulation buffer for one reasoning output_item.
#[derive(Debug, Default)]
struct PendingReasoning {
id: Option<String>,
/// `reasoning_text.delta` の累積。複数 content_part あれば順に concat。
/// Accumulated `reasoning_text.delta`, concatenating multiple content_parts in order.
text: String,
/// `reasoning_summary_text.delta` を summary_index 順に蓄積。
/// Accumulates `reasoning_summary_text.delta` in summary_index order.
summary: Vec<String>,
/// `response.content_part.done` が先に到着した reasoning/thinking block。
/// Reasoning/thinking blocks whose `response.content_part.done` arrived first.
///
/// `response.output_item.done` まで待たないと encrypted_content や最終
/// summary が揃わないため、live-visible な余分な synthetic Thinking block
/// を作らず、既存 block の stop に persistence metadata を載せる。
/// encrypted_content and final summaries are not complete until
/// `response.output_item.done`. Attach persistence metadata to the existing
/// block's stop rather than creating an extra live-visible synthetic Thinking block.
deferred_thinking_stops: Vec<SlotInfo>,
}
@@ -69,9 +107,9 @@ impl OpenAIResponsesState {
info
}
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
/// 抜けたときの防御。
/// Get an existing slot or implicitly allocate one with `block_type`, also
/// returning whether it was newly allocated. Defends against deltas arriving
/// first or missing content_part.added events.
fn get_or_allocate(&mut self, key: SlotKey, block_type: BlockType) -> (SlotInfo, bool) {
if let Some(info) = self.slots.get(&key).copied() {
(info, false)
@@ -120,11 +158,11 @@ impl OpenAIResponsesState {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum SlotKey {
/// tool_use (function_call / custom_tool_call) — output_item 全体で 1 block
/// tool_use (function_call / custom_tool_call) — one block for the entire output_item.
OutputItem(usize),
/// message の output_text / reasoning item の reasoning_text
/// Message output_text / reasoning item reasoning_text.
ContentPart { output: usize, content: usize },
/// reasoning item の summary_text (summary_index)
/// Reasoning item summary_text (summary_index).
Summary { output: usize, summary: usize },
}
@@ -135,7 +173,7 @@ struct SlotInfo {
}
// ============================================================================
// SSE イベントの JSON 構造
// SSE event JSON structures
// ============================================================================
#[derive(Debug, Deserialize)]
@@ -151,7 +189,7 @@ struct OutputItemDone {
item: OutputItem,
}
/// `response.output_item.added/done` の `item`。
/// `item` in `response.output_item.added/done`.
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum OutputItem {
@@ -162,8 +200,8 @@ enum OutputItem {
Reasoning {
#[serde(default)]
id: Option<String>,
/// `output_item.done` で初めて埋まる。`include=["reasoning.encrypted_content"]`
/// 指定時に opaque blob が乗る。
/// First populated at `output_item.done`. Carries an opaque blob when
/// `include=["reasoning.encrypted_content"]` is specified.
#[serde(default)]
encrypted_content: Option<String>,
},
@@ -173,6 +211,8 @@ enum OutputItem {
id: Option<String>,
call_id: String,
name: String,
#[serde(default)]
arguments: Option<String>,
},
CustomToolCall {
#[allow(dead_code)]
@@ -180,6 +220,8 @@ enum OutputItem {
id: Option<String>,
call_id: String,
name: String,
#[serde(default)]
input: Option<String>,
},
#[serde(other)]
Other,
@@ -200,7 +242,7 @@ struct ContentPartDone {
part: ContentPart,
}
/// `response.content_part.added/done` の `part`。
/// `part` in `response.content_part.added/done`.
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentPart {
@@ -260,12 +302,24 @@ struct FunctionCallArgumentsDelta {
delta: String,
}
#[derive(Debug, Deserialize)]
struct FunctionCallArgumentsDone {
output_index: usize,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct CustomToolCallInputDelta {
output_index: usize,
delta: String,
}
#[derive(Debug, Deserialize)]
struct CustomToolCallInputDone {
output_index: usize,
input: String,
}
#[derive(Debug, Deserialize)]
struct ResponseCompleted {
response: CompletedResponse,
@@ -285,7 +339,7 @@ struct ResponsesUsage {
output_tokens: Option<u64>,
#[serde(default)]
total_tokens: Option<u64>,
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
/// Breakdown of `input_tokens`; `cached_tokens` counts prompt cache hits.
#[serde(default)]
input_tokens_details: Option<InputTokensDetails>,
}
@@ -346,11 +400,11 @@ struct TopLevelError {
// parse entry point
// ============================================================================
/// SSE フレーム 1 件をパースし、0 個以上の [`Event`] に変換する。
/// Parse one SSE frame and convert it to zero or more [`Event`]s.
///
/// `event_type` は SSE の `event:` フィールド。未対応の event type は
/// [`Event::UnhandledSse`] として観測可能にする。`data` が JSON でない /
/// 必要なフィールドが抜けている等は [`ClientError::Api`] で返す。
/// `event_type` is the SSE `event:` field. Unsupported event types are observable
/// as [`Event::UnhandledSse`]. Non-JSON `data`, missing required fields, and similar
/// failures return [`ClientError::Api`].
pub(crate) fn parse_sse(
event_type: &str,
data: &str,
@@ -374,7 +428,7 @@ pub(crate) fn parse_sse(
cache_read_input_tokens: usage
.input_tokens_details
.and_then(|d| d.cached_tokens),
// Responses API は cache 書き込みを別計上しない(input_tokens に含まれる)
// The Responses API does not account for cache writes separately (included in input_tokens).
cache_creation_input_tokens: None,
}));
}
@@ -398,19 +452,23 @@ pub(crate) fn parse_sse(
"response.output_item.added" => {
let ev: OutputItemAdded = from_json(data)?;
match ev.item {
OutputItem::FunctionCall { call_id, name, .. }
| OutputItem::CustomToolCall { call_id, name, .. } => {
let info =
state.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
Ok(vec![Event::BlockStart(BlockStart {
index: info.flat_index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse { id: call_id, name },
})])
}
OutputItem::FunctionCall { call_id, name, .. } => start_tool_output_item(
state,
ev.output_index,
ToolInputKind::FunctionCall,
call_id,
name,
),
OutputItem::CustomToolCall { call_id, name, .. } => start_tool_output_item(
state,
ev.output_index,
ToolInputKind::CustomToolCall,
call_id,
name,
),
OutputItem::Reasoning { id, .. } => {
// wrapper を確保。中身の content_part / summary_part は
// 別 SlotKey で扱われ続ける(Streaming 表示は維持)。
// Allocate the wrapper. Its content_parts / summary_parts continue
// to use separate SlotKeys (preserving the streaming display).
let entry = state.ensure_reasoning(ev.output_index);
if id.is_some() {
entry.id = id;
@@ -423,27 +481,27 @@ pub(crate) fn parse_sse(
"response.output_item.done" => {
let ev: OutputItemDone = from_json(data)?;
// Reasoning wrapper の done で蓄積分を既存 reasoning_text block の
// stop に載せる。content_part.done が先に来た場合は stop を defer
// しておき、ここで encrypted_content / summary と一緒に完了させる。
// reasoning_text が無い metadata-only item だけ synthetic block を作る。
// On Reasoning wrapper done, attach accumulated material to the existing
// reasoning_text block's stop. If content_part.done arrived first, defer
// its stop and complete it here with encrypted_content / summary.
// Create synthetic blocks only for metadata-only items without reasoning_text.
if let OutputItem::Reasoning {
id,
encrypted_content,
..
} = ev.item
} = &ev.item
{
let mut pending = state
.pending_reasoning
.remove(&ev.output_index)
.unwrap_or_default();
if pending.id.is_none() {
pending.id = id;
pending.id = id.clone();
}
let mut stop_blocks = std::mem::take(&mut pending.deferred_thinking_stops);
stop_blocks.extend(state.take_active_reasoning_slots(ev.output_index));
let reasoning = pending.into_reasoning_data(encrypted_content);
let reasoning = pending.into_reasoning_data(encrypted_content.clone());
if stop_blocks.is_empty() {
let info =
@@ -478,15 +536,45 @@ pub(crate) fn parse_sse(
})
.collect());
}
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
Ok(vec![Event::BlockStop(BlockStop {
index: info.flat_index,
block_type: info.block_type,
stop_reason: None,
reasoning: None,
})])
} else {
Ok(Vec::new())
match ev.item {
OutputItem::FunctionCall {
call_id,
name,
arguments,
..
} => finish_tool_output_item(
state,
ev.output_index,
ToolInputKind::FunctionCall,
call_id,
name,
arguments,
),
OutputItem::CustomToolCall {
call_id,
name,
input,
..
} => finish_tool_output_item(
state,
ev.output_index,
ToolInputKind::CustomToolCall,
call_id,
name,
input,
),
_ => {
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
Ok(vec![Event::BlockStop(BlockStop {
index: info.flat_index,
block_type: info.block_type,
stop_reason: None,
reasoning: None,
})])
} else {
Ok(Vec::new())
}
}
}
}
@@ -551,7 +639,7 @@ pub(crate) fn parse_sse(
"response.reasoning_text.delta" => {
let ev: ReasoningTextDelta = from_json(data)?;
// round-trip 用に蓄積
// Accumulate for round-tripping.
state
.ensure_reasoning(ev.output_index)
.text
@@ -586,7 +674,7 @@ pub(crate) fn parse_sse(
"response.reasoning_summary_text.delta" => {
let ev: ReasoningSummaryTextDelta = from_json(data)?;
// round-trip 用に蓄積
// Accumulate for round-tripping.
state.extend_reasoning_summary(ev.output_index, ev.summary_index, &ev.delta);
Ok(ensure_and_delta(
state,
@@ -619,53 +707,262 @@ pub(crate) fn parse_sse(
"response.function_call_arguments.delta" => {
let ev: FunctionCallArgumentsDelta = from_json(data)?;
Ok(ensure_and_delta(
record_tool_input_delta(
state,
SlotKey::OutputItem(ev.output_index),
BlockType::ToolUse,
ev.output_index,
ToolInputKind::FunctionCall,
ev.delta,
)
}
"response.function_call_arguments.done" => {
let ev: FunctionCallArgumentsDone = from_json(data)?;
reconcile_tool_input(
state,
ev.output_index,
ToolInputKind::FunctionCall,
ev.arguments,
BlockMetadata::ToolUse {
id: String::new(),
name: String::new(),
},
DeltaContent::InputJson(ev.delta),
))
)
}
"response.custom_tool_call_input.delta" => {
let ev: CustomToolCallInputDelta = from_json(data)?;
Ok(ensure_and_delta(
record_tool_input_delta(
state,
SlotKey::OutputItem(ev.output_index),
BlockType::ToolUse,
ev.output_index,
ToolInputKind::CustomToolCall,
ev.delta,
)
}
"response.custom_tool_call_input.done" => {
let ev: CustomToolCallInputDone = from_json(data)?;
reconcile_tool_input(
state,
ev.output_index,
ToolInputKind::CustomToolCall,
ev.input,
BlockMetadata::ToolUse {
id: String::new(),
name: String::new(),
},
DeltaContent::InputJson(ev.delta),
))
)
}
"error" => {
let ev = from_json::<TopLevelErrorEnvelope>(data).unwrap_or_else(|_| {
TopLevelErrorEnvelope {
// Responses documents flat error frames (code/message/param),
// while providers also send nested error envelopes. Normalize both
// structurally so classification never scans arbitrary raw JSON.
let ev = from_json::<TopLevelErrorEnvelope>(data)
.or_else(|_| {
from_json::<TopLevelError>(data).map(|error| TopLevelErrorEnvelope {
error,
extra: BTreeMap::new(),
})
})
.unwrap_or_else(|_| TopLevelErrorEnvelope {
error: TopLevelError {
message: Some(data.to_string()),
// Raw data is observation, not a provider diagnostic that
// can authorize request recovery. Keep it out of the base
// message even when it contains image-size wording.
message: Some("OpenAI Responses error with unparseable data".to_string()),
error_type: None,
code: None,
extra: BTreeMap::new(),
extra: BTreeMap::from([(
"raw_data".to_string(),
Value::String(data.to_string()),
)]),
},
extra: BTreeMap::new(),
}
});
});
let (code, message) = top_level_error_diagnostic(ev);
Ok(vec![Event::Error(ErrorEvent { code, message })])
}
// 未対応 / 情報系 event type は生成 semantics からは無視しつつ trace に残す。
// Ignore unsupported / informational event types for generation semantics, but retain them in traces.
_ => Ok(vec![unhandled_sse_event(event_type, data)]),
}
}
fn start_tool_output_item(
state: &mut OpenAIResponsesState,
output_index: usize,
kind: ToolInputKind,
call_id: String,
name: String,
) -> Result<Vec<Event>, ClientError> {
ensure_tool_input_kind(state, output_index, kind)?;
let (info, just_created) =
state.get_or_allocate(SlotKey::OutputItem(output_index), BlockType::ToolUse);
if just_created {
Ok(vec![Event::BlockStart(BlockStart {
index: info.flat_index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse { id: call_id, name },
})])
} else {
Ok(Vec::new())
}
}
fn record_tool_input_delta(
state: &mut OpenAIResponsesState,
output_index: usize,
kind: ToolInputKind,
delta: String,
) -> Result<Vec<Event>, ClientError> {
let pending = ensure_tool_input_kind(state, output_index, kind)?;
if pending.final_value.is_some() {
return Err(tool_input_error(
"tool_input_delta_after_done",
format!(
"OpenAI Responses {} delta arrived after its final value at output index {output_index}",
kind.label()
),
));
}
pending.saw_delta = true;
pending.streamed.push_str(&delta);
Ok(ensure_and_delta(
state,
SlotKey::OutputItem(output_index),
BlockType::ToolUse,
BlockMetadata::ToolUse {
id: String::new(),
name: String::new(),
},
DeltaContent::InputJson(delta),
))
}
fn reconcile_tool_input(
state: &mut OpenAIResponsesState,
output_index: usize,
kind: ToolInputKind,
final_value: String,
metadata: BlockMetadata,
) -> Result<Vec<Event>, ClientError> {
let pending = ensure_tool_input_kind(state, output_index, kind)?;
if pending
.final_value
.as_ref()
.is_some_and(|existing| existing != &final_value)
{
return Err(tool_input_error(
"tool_input_final_mismatch",
format!(
"OpenAI Responses authoritative {} values disagree at output index {output_index}",
kind.label()
),
));
}
if pending.saw_delta && pending.streamed != final_value {
return Err(tool_input_error(
"tool_input_delta_mismatch",
format!(
"OpenAI Responses streamed {} does not match its authoritative final value at output index {output_index}",
kind.label()
),
));
}
let should_emit = !pending.saw_delta;
if should_emit {
pending.saw_delta = true;
pending.streamed.clone_from(&final_value);
}
pending.final_value = Some(final_value.clone());
if should_emit {
Ok(ensure_and_delta(
state,
SlotKey::OutputItem(output_index),
BlockType::ToolUse,
metadata,
DeltaContent::InputJson(final_value),
))
} else {
Ok(Vec::new())
}
}
fn finish_tool_output_item(
state: &mut OpenAIResponsesState,
output_index: usize,
kind: ToolInputKind,
call_id: String,
name: String,
final_value: Option<String>,
) -> Result<Vec<Event>, ClientError> {
let metadata = BlockMetadata::ToolUse { id: call_id, name };
let mut out = if let Some(final_value) = final_value {
reconcile_tool_input(state, output_index, kind, final_value, metadata.clone())?
} else {
let pending = ensure_tool_input_kind(state, output_index, kind)?;
if pending.final_value.is_none() {
return Err(tool_input_error(
"tool_input_final_missing",
format!(
"OpenAI Responses {} ended without an authoritative final value at output index {output_index}",
kind.label()
),
));
}
Vec::new()
};
let (info, just_created) =
state.get_or_allocate(SlotKey::OutputItem(output_index), BlockType::ToolUse);
if just_created {
out.push(Event::BlockStart(BlockStart {
index: info.flat_index,
block_type: BlockType::ToolUse,
metadata,
}));
}
state.slots.remove(&SlotKey::OutputItem(output_index));
state.pending_tool_inputs.remove(&output_index);
out.push(Event::BlockStop(BlockStop {
index: info.flat_index,
block_type: info.block_type,
stop_reason: None,
reasoning: None,
}));
Ok(out)
}
fn ensure_tool_input_kind(
state: &mut OpenAIResponsesState,
output_index: usize,
kind: ToolInputKind,
) -> Result<&mut PendingToolInput, ClientError> {
let pending = state
.pending_tool_inputs
.entry(output_index)
.or_insert_with(|| PendingToolInput::new(kind));
if pending.kind != kind {
return Err(tool_input_error(
"tool_input_kind_mismatch",
format!("OpenAI Responses tool input kind changed at output index {output_index}"),
));
}
Ok(pending)
}
fn tool_input_error(code: &str, message: String) -> ClientError {
ClientError::Api {
status: None,
code: Some(code.to_string()),
message,
retry_after: None,
}
}
fn response_failure_diagnostic(event_type: &str, ev: ResponseFailed) -> (Option<String>, String) {
let mut diagnostic = Map::new();
diagnostic.insert("event".to_string(), Value::String(event_type.to_string()));
@@ -807,8 +1104,8 @@ fn append_diagnostic(message: String, diagnostic: Map<String, Value>) -> String
format!("{} | diagnostic={}", message, Value::Object(diagnostic))
}
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
/// Emit a delta, first emitting its BlockStart if not already sent.
/// Allows recovery from a delta alone when content_part.added was missed.
fn ensure_and_delta(
state: &mut OpenAIResponsesState,
key: SlotKey,
@@ -855,6 +1152,26 @@ mod tests {
parse_sse(event_type, data, state).unwrap()
}
fn input_json_deltas(events: &[Event]) -> Vec<&str> {
events
.iter()
.filter_map(|event| match event {
Event::BlockDelta(BlockDelta {
delta: DeltaContent::InputJson(delta),
..
}) => Some(delta.as_str()),
_ => None,
})
.collect()
}
fn api_error_code(error: ClientError) -> Option<String> {
match error {
ClientError::Api { code, .. } => code,
other => panic!("expected ClientError::Api, got {other:?}"),
}
}
#[test]
fn created_emits_status_started() {
let (events, _) = run("response.created", r#"{"response":{}}"#);
@@ -903,14 +1220,14 @@ mod tests {
assert_eq!(u.output_tokens, Some(50));
assert_eq!(u.total_tokens, Some(12395));
assert_eq!(u.cache_read_input_tokens, Some(11000));
// OpenAI Responses は cache 書き込みを別計上しない
// OpenAI Responses does not account for cache writes separately.
assert_eq!(u.cache_creation_input_tokens, None);
}
#[test]
fn text_stream_start_delta_stop() {
let mut state = OpenAIResponsesState::default();
// output_item.added (message) → 無視
// output_item.added (message) → ignored.
with(
&mut state,
"response.output_item.added",
@@ -981,6 +1298,19 @@ mod tests {
if let Event::BlockDelta(d) = &ev[0] {
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "{\"x\":"));
}
let ev = with(
&mut state,
"response.function_call_arguments.delta",
r#"{"output_index":1,"item_id":"fc1","delta":"1}"}"#,
);
assert_eq!(ev.len(), 1);
assert!(matches!(
&ev[0],
Event::BlockDelta(BlockDelta {
delta: DeltaContent::InputJson(j),
..
}) if j == "1}"
));
// output_item.done → BlockStop
let ev = with(
&mut state,
@@ -1012,6 +1342,155 @@ mod tests {
}
}
#[test]
fn function_call_done_payload_emits_missing_final_input_before_stop() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
);
let events = with(
&mut state,
"response.output_item.done",
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"city\":\"Tokyo\"}"}}"#,
);
assert_eq!(input_json_deltas(&events), vec![r#"{"city":"Tokyo"}"#]);
assert!(matches!(events.last(), Some(Event::BlockStop(_))));
}
#[test]
fn function_call_arguments_done_accepts_exact_stream_without_duplication() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
);
let delta = with(
&mut state,
"response.function_call_arguments.delta",
r#"{"output_index":0,"delta":"{\"city\":\"Tokyo\"}"}"#,
);
assert_eq!(input_json_deltas(&delta), vec![r#"{"city":"Tokyo"}"#]);
let done = with(
&mut state,
"response.function_call_arguments.done",
r#"{"output_index":0,"arguments":"{\"city\":\"Tokyo\"}"}"#,
);
assert!(done.is_empty());
let output_done = with(
&mut state,
"response.output_item.done",
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"city\":\"Tokyo\"}"}}"#,
);
assert!(input_json_deltas(&output_done).is_empty());
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
}
#[test]
fn function_call_arguments_done_rejects_stream_mismatch() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
);
with(
&mut state,
"response.function_call_arguments.delta",
r#"{"output_index":0,"delta":"{\"city\":\"Kyoto\"}"}"#,
);
let error = parse_sse(
"response.function_call_arguments.done",
r#"{"output_index":0,"arguments":"{\"city\":\"Tokyo\"}"}"#,
&mut state,
)
.unwrap_err();
assert_eq!(
api_error_code(error).as_deref(),
Some("tool_input_delta_mismatch")
);
}
#[test]
fn custom_tool_input_done_event_emits_missing_final_input_once() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
);
let done = with(
&mut state,
"response.custom_tool_call_input.done",
r#"{"output_index":0,"input":"echo ready"}"#,
);
assert_eq!(input_json_deltas(&done), vec!["echo ready"]);
let output_done = with(
&mut state,
"response.output_item.done",
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
);
assert!(input_json_deltas(&output_done).is_empty());
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
}
#[test]
fn custom_tool_done_payload_accepts_exact_stream_without_duplication() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
);
let delta = with(
&mut state,
"response.custom_tool_call_input.delta",
r#"{"output_index":0,"delta":"echo ready"}"#,
);
assert_eq!(input_json_deltas(&delta), vec!["echo ready"]);
let output_done = with(
&mut state,
"response.output_item.done",
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
);
assert!(input_json_deltas(&output_done).is_empty());
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
}
#[test]
fn custom_tool_done_payload_rejects_stream_mismatch() {
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
);
with(
&mut state,
"response.custom_tool_call_input.delta",
r#"{"output_index":0,"delta":"echo stale"}"#,
);
let error = parse_sse(
"response.output_item.done",
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
&mut state,
)
.unwrap_err();
assert_eq!(
api_error_code(error).as_deref(),
Some("tool_input_delta_mismatch")
);
}
#[test]
fn reasoning_text_delta_emits_thinking() {
let mut state = OpenAIResponsesState::default();
@@ -1059,7 +1538,7 @@ mod tests {
#[test]
fn delta_without_prior_start_recovers() {
// 防御: content_part.added が落ちても delta 単独で BlockStart+Delta を発行
// Defensive: emit BlockStart+Delta from a delta alone if content_part.added was lost.
let mut state = OpenAIResponsesState::default();
let ev = with(
&mut state,
@@ -1073,8 +1552,8 @@ mod tests {
#[test]
fn parallel_output_items_get_distinct_indices() {
// 2 つの function_call が並列で output_item.added される場合、
// flat index が別々になる(Parallel tool calling の基本)。
// Two parallel function_call output_item.added events get distinct
// flat indices (the basis of parallel tool calling).
let mut state = OpenAIResponsesState::default();
let ev1 = with(
&mut state,
@@ -1197,17 +1676,17 @@ mod tests {
#[test]
fn reasoning_output_item_completes_metadata_thinking_block_with_text_summary_encrypted() {
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
// Thinking BlockStop metadata として届くこと。
// A completed reasoning wrapper must deliver text + summary[] +
// encrypted_content as Thinking BlockStop metadata.
let mut state = OpenAIResponsesState::default();
// wrapper added (id だけ持つ)
// Wrapper added (with only an id).
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1"}}"#,
);
// 内側の reasoning_text 用 content_part
// Inner content_part for reasoning_text.
let start = with(
&mut state,
"response.content_part.added",
@@ -1233,7 +1712,7 @@ mod tests {
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":"hello world"}}"#,
);
assert!(part_done.is_empty());
// summary 1 件
// One summary.
with(
&mut state,
"response.reasoning_summary_part.added",
@@ -1250,7 +1729,7 @@ mod tests {
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
);
// wrapper done (encrypted_content が乗る)
// Wrapper done (includes encrypted_content).
let evs = with(
&mut state,
"response.output_item.done",
@@ -1267,7 +1746,7 @@ mod tests {
assert_eq!(reasoning.summary, vec!["sum-A".to_string()]);
assert_eq!(reasoning.encrypted_content.as_deref(), Some("ENC-XYZ"));
assert!(reasoning.signature.is_none());
// pending_reasoning は drain されていること
// pending_reasoning must be drained.
assert!(state.pending_reasoning.is_empty());
}
@@ -1357,8 +1836,8 @@ mod tests {
#[test]
fn reasoning_wrapper_without_inner_content_emits_empty_text() {
// encrypted_content だけ届く(reasoning_text 無し)ケースでも
// reasoning metadata は届く。
// Reasoning metadata must arrive even when only encrypted_content
// is received (no reasoning_text).
let mut state = OpenAIResponsesState::default();
with(
&mut state,
@@ -1,11 +1,11 @@
//! OpenAI Responses API スキーマ (`/v1/responses`)
//! OpenAI Responses API scheme (`/v1/responses`).
//!
//! Chat Completions とは別物の item-based wire format。reasoning item と
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
//! 流れる。
//! An item-based wire format distinct from Chat Completions. Reasoning and
//! function_call items are first-class, and SSE events use the `response.*`
//! namespace.
//!
//! - リクエスト JSON 生成: `request`
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
//! - Request JSON generation: `request`.
//! - SSE event parsing → [`Event`](crate::llm_client::event::Event) conversion: `events`.
mod capability;
mod events;
@@ -14,29 +14,29 @@ mod scheme_impl;
pub use scheme_impl::OpenAIResponsesState;
/// OpenAI Responses scheme 本体。
/// OpenAI Responses scheme.
///
/// `store` / `include_encrypted_content` / `send_max_output_tokens` /
/// `send_sampling_params` は scheme 固定の wire 設定で、デフォルトは
/// 公式 OpenAI Responses API 向け (stateless + ZDR + `max_output_tokens`
/// / `temperature` / `top_p` 送出可)。受理パラメータが subset の
/// 互換 backend では client 構築層で `send_max_output_tokens=false` /
/// `send_sampling_params=false` に上書きする。`ModelCapability` には
/// 入れない(モデル能力ではなく wire policy)。
/// `send_sampling_params` are scheme-level wire settings. Defaults target the
/// official OpenAI Responses API (stateless + ZDR, with `max_output_tokens` /
/// `temperature` / `top_p` allowed). For compatible backends accepting only a
/// subset of parameters, the client construction layer overrides
/// `send_max_output_tokens=false` / `send_sampling_params=false`.
/// These do not belong in `ModelCapability`: they are wire policy, not model capabilities.
#[derive(Debug, Clone)]
pub struct OpenAIResponsesScheme {
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
/// Whether to store responses on the server. `false` for ZDR/stateless operation.
pub store: bool,
/// `include: ["reasoning.encrypted_content"]` を付けるか。
/// `store=false` で reasoning を使うなら必須。
/// Whether to add `include: ["reasoning.encrypted_content"]`.
/// Required when using reasoning with `store=false`.
pub include_encrypted_content: bool,
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
/// 受理するが、互換 backend によっては `Unsupported parameter` で
/// 400 を返すため、その経路では `false` にする。
/// Whether to include `max_output_tokens` in the body. The official OpenAI
/// Responses API accepts it, but some compatible backends return 400 with
/// `Unsupported parameter`; set this to `false` for those routes.
pub send_max_output_tokens: bool,
/// `temperature` / `top_p` を body に載せるか。公式 OpenAI Responses API
/// は受理するが、互換 backend によっては `Unsupported parameter` で
/// 400 を返すため、その経路では `false` にする。
/// Whether to include `temperature` / `top_p` in the body. The official OpenAI
/// Responses API accepts them, but some compatible backends return 400 with
/// `Unsupported parameter`; set this to `false` for those routes.
pub send_sampling_params: bool,
}
@@ -52,31 +52,31 @@ impl Default for OpenAIResponsesScheme {
}
impl OpenAIResponsesScheme {
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`, `send_sampling_params=true`)。
/// Default settings (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`, `send_sampling_params=true`).
pub fn new() -> Self {
Self::default()
}
/// `store` を上書き。
/// Override `store`.
pub fn with_store(mut self, store: bool) -> Self {
self.store = store;
self
}
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
/// Override whether to include `include: ["reasoning.encrypted_content"]`.
pub fn with_include_encrypted_content(mut self, include: bool) -> Self {
self.include_encrypted_content = include;
self
}
/// `max_output_tokens` を body に載せるかを上書き。
/// Override whether to include `max_output_tokens` in the body.
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
self.send_max_output_tokens = send;
self
}
/// `temperature` / `top_p` を body に載せるかを上書き。
/// Override whether to include `temperature` / `top_p` in the body.
pub fn with_send_sampling_params(mut self, send: bool) -> Self {
self.send_sampling_params = send;
self
@@ -1,8 +1,8 @@
//! OpenAI Responses API リクエスト body 生成
//! OpenAI Responses API request body generation.
//!
//! Chat Completions の `messages` と違い、Responses は `input[]` の
//! item 配列で reasoning / function_call / function_call_output が
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
//! Unlike Chat Completions `messages`, Responses uses an `input[]` item array
//! with first-class reasoning / function_call / function_call_output items.
//! `Item` can be projected into `input[]` with little transformation.
use serde::{Serialize, Serializer};
use serde_json::Value;
@@ -32,93 +32,92 @@ pub(crate) enum FunctionCallOutputContentItem {
InputImage { image_url: String },
}
/// `/v1/responses` のリクエスト body。
/// Request body for `/v1/responses`.
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesRequest {
pub model: String,
/// システムプロンプト相当。`input[]` とは別フィールド。
/// System prompt equivalent, separate from `input[]`.
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub input: Vec<InputItem>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ResponseTool>,
/// 常時 `"auto"` を送る。scheme 固定値。
/// Always sends `"auto"`, a scheme-level constant.
pub tool_choice: &'static str,
/// 常時 `true` を送る。scheme 固定値。
/// Always sends `true`, a scheme-level constant.
pub parallel_tool_calls: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
/// ZDR / stateless 運用では `false`。
/// `false` for ZDR / stateless operation.
pub store: bool,
/// 常時 `true`。
/// Always `true`.
pub stream: bool,
/// `["reasoning.encrypted_content"]` 等。
/// E.g. `["reasoning.encrypted_content"]`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub include: Vec<&'static str>,
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
/// 400 で弾く。scheme の `send_max_output_tokens` が `false` のときは
/// `None` のまま送る (skip_serializing_if で除外)。
/// Accepted by the official OpenAI Responses API, but some compatible backends
/// reject it with 400. When the scheme's `send_max_output_tokens` is `false`,
/// leave it as `None` (excluded by skip_serializing_if).
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
/// `temperature` / `top_p` を 400 で弾く。scheme の
/// `send_sampling_params` が `false` のときは `None` のまま送る。
/// Accepted by the official OpenAI Responses API, but some compatible backends
/// reject `temperature` / `top_p` with 400. When the scheme's
/// `send_sampling_params` is `false`, leave them as `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// 会話単位の安定キー。明示キーを必要とする backend では、
/// 呼び出し側が安定した conversation identifier を渡す。
/// `Request::cache_key` が `None` のときはキー自体を送らない。
/// Stable conversation key. For backends requiring an explicit key, the caller
/// supplies a stable conversation identifier. The key is omitted entirely
/// when `Request::cache_key` is `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
}
/// reasoning 制御。
/// Reasoning control.
#[derive(Debug, Serialize)]
pub(crate) struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
/// Summary output control, fixed to `"auto"` to receive summary_text.
pub summary: &'static str,
}
/// `input[]` の 1 要素。
/// One element of `input[]`.
///
/// Responses API の item 型を素に近い形で投影する。未対応 type は
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
/// Projects Responses API item types with little transformation. Unsupported types
/// are ignored (reasoning with `content: []` is rejected as `None` when sending).
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputItem {
/// 会話メッセージ。user / assistant / developer のいずれか。
/// `Role::System` items は `developer` として投影する。OpenAI
/// Responses 互換 backend の一部は `role: "system"` を拒否するため、
/// system 相当の挿入には `role: "developer"` を使う。
/// Conversation message: user / assistant / developer.
/// `Role::System` items are projected as `developer`. Some OpenAI Responses
/// compatible backends reject `role: "system"`, so system-equivalent
/// insertions use `role: "developer"`.
Message {
role: &'static str,
content: Vec<InputContent>,
},
/// 過去の function tool 呼び出し(assistant 側)。
/// Previous function tool call (assistant side).
FunctionCall {
call_id: String,
name: String,
/// JSON 文字列(object でなくても正規化済み)。
/// JSON string (normalized even if the original was not an object).
arguments: String,
},
/// function tool の結果(user 側)。
/// Function tool result (user side).
FunctionCallOutput {
call_id: String,
output: FunctionCallOutputBody,
},
/// reasoning item。`encrypted_content` があれば必ず添える。
/// Reasoning item, always including `encrypted_content` when present.
Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
/// Responses API は reasoning item に `summary` フィールドを必須で
/// 要求する(中身が空でも `[]` として送る必要がある)。GPT-5 など
/// summary を返さないモデル + reasoning effort 指定なしのターンでは
/// summary text が一切付かないので、ここを skip すると 400
/// "Missing required parameter: 'input[N].summary'" で弾かれる。
/// The Responses API requires `summary` on reasoning items, even if sent
/// as an empty `[]`. Models such as GPT-5 may return no summary text on
/// turns without a specified reasoning effort. Omitting this field then
/// causes a 400 `"Missing required parameter: 'input[N].summary'"` rejection.
summary: Vec<ReasoningSummaryPart>,
#[serde(skip_serializing_if = "Vec::is_empty")]
content: Vec<ReasoningContentPart>,
@@ -127,14 +126,13 @@ pub(crate) enum InputItem {
},
}
/// メッセージ content_part。role で input/output を使い分ける。
/// Message content_part, using input/output according to role.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputContent {
/// user / developer 側のテキスト
/// User / developer text.
InputText { text: String },
/// user 側の画像
/// assistant 側のテキスト
/// Assistant text.
OutputText { text: String },
}
@@ -150,8 +148,8 @@ pub(crate) enum ReasoningContentPart {
ReasoningText { text: String },
}
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
/// トップレベルに `name` / `parameters` が載る。
/// Tool definition for Responses. Unlike Chat, `name` / `parameters` are
/// top-level fields, not nested under a function key.
#[derive(Debug, Serialize)]
pub(crate) struct ResponseTool {
#[serde(rename = "type")]
@@ -159,13 +157,12 @@ pub(crate) struct ResponseTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// OpenAI Responses API は `type:"object"` のパラメータスキーマに
/// `properties` が存在することを要求する。schemars は引数なし struct
/// から `properties` を含まない最小スキーマを出すので、serialize
/// 時に空オブジェクトを補う。
/// The OpenAI Responses API requires `properties` in `type:"object"` parameter
/// schemas. schemars emits minimal schemas without `properties` for structs
/// with no arguments, so serialization supplies an empty object.
#[serde(serialize_with = "serialize_parameters")]
pub parameters: Value,
/// Structured output モード制御。デフォルト false。
/// Structured output mode control. Defaults to false.
pub strict: bool,
}
@@ -182,7 +179,7 @@ fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::
}
impl OpenAIResponsesScheme {
/// `Request` から wire 形式の body を組み立てる。
/// Build a wire-format body from `Request`.
pub(crate) fn build_request(
&self,
model: &str,
@@ -192,8 +189,8 @@ impl OpenAIResponsesScheme {
let input = convert_items_to_input(&request.items, capability.vision);
let tools = request.tools.iter().map(convert_tool).collect();
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
// request 側で effort が指定されているときだけ reasoning を付ける。
// Project reasoning: add reasoning only when the capability supports
// Effort / Both and the request specifies an effort.
let supports_effort = matches!(
capability.reasoning,
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
@@ -249,11 +246,12 @@ impl OpenAIResponsesScheme {
}
}
/// `Item` 列を `input[]` に変換する。
/// Convert an `Item` sequence to `input[]`.
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
Item::AssistantResponseBoundary { .. } => {}
Item::Message { role, content, .. } => {
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
match role {
@@ -279,7 +277,7 @@ fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputIte
arguments,
..
} => {
// 非 object / 旧形式の "null" を "{}" に正規化。
// Normalize non-objects / legacy "null" to "{}".
let normalized = parse_tool_arguments(arguments).to_string();
out.push(InputItem::FunctionCall {
call_id: call_id.clone(),
@@ -553,11 +551,10 @@ mod tests {
#[test]
fn reasoning_summary_field_is_always_serialized() {
// Responses API は reasoning item に `summary` を必須で要求する。
// summary が空でも wire 上に `summary: []` として残らないと、
// backend によっては missing required parameter として拒否される。
// reasoning effort 未指定のターンでは summary text が付かないことが
// あるため、空のままでも skip しないこと。
// The Responses API requires `summary` on reasoning items. Even an empty
// summary must remain as `summary: []` on the wire, or some backends
// reject it as a missing required parameter. Turns without a specified
// reasoning effort may have no summary text; do not skip empty summaries.
let scheme = OpenAIResponsesScheme::new();
let item = Item::reasoning("").with_encrypted_content("ENC");
let req = Request::new().user("hi").item(item);
@@ -666,9 +663,9 @@ mod tests {
#[test]
fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを
// 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
// 送る直前に空オブジェクトを補うのを確認。
// schemars emits only `type:"object"` for structs with no arguments.
// OpenAI Responses rejects missing `properties` with 400, so verify that
// an empty object is supplied just before sending.
let scheme = OpenAIResponsesScheme::new();
let raw_schema = serde_json::json!({ "type": "object" });
let req = Request::new().tool(
@@ -706,7 +703,7 @@ mod tests {
#[test]
fn serialized_body_has_expected_shape() {
// wire 形式が崩れていないかのスモークテスト
// Smoke test for an intact wire format.
let scheme = OpenAIResponsesScheme::new();
let req = Request::new()
.system("sys")
@@ -6,7 +6,7 @@ use crate::llm_client::{
ClientError,
auth::AuthRequirement,
capability::ModelCapability,
client::ConfigWarning,
client::{ConfigWarning, ToolCallCompletionSupport},
event::Event,
scheme::Scheme,
types::{Request, RequestConfig},
@@ -20,8 +20,8 @@ impl Scheme for OpenAIResponsesScheme {
type State = OpenAIResponsesState;
fn default_base_url(&self) -> &'static str {
// `/v1` は base_url 側に寄せる。互換 backend を使う場合も、
// base URL を差し替えるだけで同じ `/responses` path を使える。
// Keep `/v1` in base_url. Compatible backends can reuse the same
// `/responses` path by replacing only the base URL.
"https://api.openai.com/v1"
}
@@ -56,6 +56,10 @@ impl Scheme for OpenAIResponsesScheme {
super::capability::default_capability()
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
ToolCallCompletionSupport::PerBlock
}
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
// Some compatible backends reject `max_output_tokens` with HTTP 400.
+32 -31
View File
@@ -1,8 +1,8 @@
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
//! HTTP クライアントで扱う。
//! `HttpTransport<S: Scheme>` handles all LLM wire schemes with one shared
//! HTTP client.
//!
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
//! HTTP policy は [`TransportPolicy`] で明示的に差し込む。
//! Scheme-specific differences are delegated to [`Scheme`] implementations;
//! backend-specific HTTP policy is explicitly injected via [`TransportPolicy`].
use std::pin::Pin;
use std::sync::Arc;
@@ -28,11 +28,11 @@ use super::types::{Request, RequestConfig};
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(20);
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
/// 認証設定をリクエスト時に使える形へ解決したランタイム表現。
/// Runtime representation of authentication resolved for use in requests.
///
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out)
/// - `ApiKey`: 静的な API key 文字列
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる
/// - `None`: send no authentication headers (opt-out for Ollama, etc.).
/// - `ApiKey`: static API key string.
/// - `Custom`: build headers dynamically for each request.
#[derive(Debug, Clone)]
pub enum ResolvedAuth {
None,
@@ -41,13 +41,13 @@ pub enum ResolvedAuth {
}
impl ResolvedAuth {
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
/// Check whether resolved authentication matches the requirement at construction.
///
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
/// 宣言なので、scheme が要求する形式によらず受け入れる
/// - `ResolvedAuth::None` declares no authentication, so any `AuthRequirement`
/// is accepted (Ollama reuses the Anthropic scheme with
/// `required_auth = XApiKey`, but works without authentication headers).
/// - `ResolvedAuth::Custom` declares that all header construction is handled
/// externally, so it is accepted regardless of the scheme's required format.
pub fn matches(&self, req: AuthRequirement) -> bool {
match (self, req) {
(Self::None, _) => true,
@@ -177,7 +177,7 @@ fn sse_error_context(status: u16, headers: &serde_json::Value, source: &str) ->
)
}
/// scheme 共通の HTTP 通信層。
/// Shared HTTP transport layer for schemes.
pub struct HttpTransport<S: Scheme> {
http_client: reqwest::Client,
scheme: S,
@@ -189,8 +189,8 @@ pub struct HttpTransport<S: Scheme> {
}
impl<S: Scheme> HttpTransport<S> {
/// 新しい transport を作る。`base_url` は末尾スラッシュの有無を
/// どちらでも受け付ける(内部で正規化)。
/// Create a transport. `base_url` accepts an optional trailing slash
/// (normalized internally).
pub fn new(
scheme: S,
model_id: impl Into<String>,
@@ -217,7 +217,7 @@ impl<S: Scheme> HttpTransport<S> {
self
}
/// カスタム HTTP クライアントを差し込む(テスト等)。
/// Inject a custom HTTP client (e.g. for tests).
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client;
self
@@ -226,7 +226,7 @@ impl<S: Scheme> HttpTransport<S> {
fn build_url(&self) -> String {
let path = self.scheme.path(&self.model_id);
let url = format!("{}{}", self.base_url, path);
// Gemini のようにクエリパラメータで認証する場合は URL にキーを追記する
// Append the key to the URL for query-parameter authentication, as in Gemini.
if let (AuthRequirement::QueryParam { name }, ResolvedAuth::ApiKey(key)) =
(self.scheme.required_auth(), &self.auth)
{
@@ -262,11 +262,11 @@ impl<S: Scheme> HttpTransport<S> {
headers.insert("x-api-key", val);
}
(_, AuthRequirement::QueryParam { .. }) => {
// クエリパラメータは `build_url` で付与済み
// Query parameters were already added by `build_url`.
}
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
// (`matches()` で弾かれる)。安全側で何もしない
// ApiKey should never reach a scheme requiring Custom (`matches()`
// rejects it). Do nothing as a conservative fallback.
}
}
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
}
fn is_context_length_exceeded(error: &ClientError) -> bool {
match error {
ClientError::Api { code, message, .. } => {
code.as_deref() == Some("context_length_exceeded")
|| message.contains("context_length_exceeded")
}
_ => false,
}
matches!(error, ClientError::ContextWindowExceeded)
}
async fn response_with_timeout(
@@ -465,7 +459,7 @@ impl<S: Scheme + Clone> Clone for HttpTransport<S> {
}
}
/// エラーレスポンスを `ClientError::Api` に変換する。
/// Convert an error response to `ClientError::Api`.
async fn classify_error_response(resp: reqwest::Response) -> ClientError {
let status = resp.status().as_u16();
let retry_after = resp
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
if code.as_deref() == Some("context_length_exceeded") {
return ClientError::ContextWindowExceeded;
}
ClientError::Api {
status: Some(status),
code,
@@ -513,6 +510,10 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
self.scheme.validate_config(config)
}
fn tool_call_completion_support(&self) -> super::ToolCallCompletionSupport {
self.scheme.tool_call_completion_support()
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let total_started = Instant::now();
let path = self.scheme.path(&self.model_id);
@@ -712,7 +713,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
let event_stream = byte_stream.eventsource();
// scheme 固有のパース状態をストリーム単位で保持する
// Keep scheme-specific parsing state for each stream.
let mut state = <S::State as Default>::default();
let stream = event_stream
+105 -24
View File
@@ -9,7 +9,7 @@
use std::{fmt, sync::Arc};
use crate::tool::Attachment;
use crate::tool::{Attachment, ToolResultDisposition};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
@@ -93,6 +93,12 @@ pub enum Item {
status: Option<ItemStatus>,
},
/// Internal durable boundary used to reconstruct one assistant response when
/// early ToolResults are committed before the response stream has ended.
/// Provider request projections remove this item before serialization.
#[doc(hidden)]
AssistantResponseBoundary { response_id: String },
/// Tool call from the assistant
ToolCall {
/// Optional item ID
@@ -104,7 +110,15 @@ pub enum Item {
name: String,
/// Tool arguments as JSON string
arguments: String,
/// Item status
/// Provider/model order among tool calls in this assistant response.
#[serde(default, skip_serializing_if = "Option::is_none")]
call_index: Option<usize>,
/// Durable identity of an execution or synthetic terminalization that
/// owns this call. Once present, restore must never infer the call pending.
#[serde(default, skip_serializing_if = "Option::is_none")]
execution_id: Option<String>,
/// Item status. `InProgress` on a persisted ToolCall means execution or
/// terminalization ownership was transferred and must never be inferred pending.
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
},
@@ -121,6 +135,9 @@ pub enum Item {
/// Detailed output (removed by pruning when old enough)
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
/// Typed terminal state used for replay and recovery.
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
disposition: ToolResultDisposition,
/// Whether the tool result represents an execution error.
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
@@ -134,21 +151,20 @@ pub enum Item {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
/// Reasoning text (reasoning body, accumulated `reasoning_text.delta`).
text: String,
/// Reasoning summary(OpenAI Responses の `summary_text[]` を格納。
/// 他 scheme は空)
/// Reasoning summary (OpenAI Responses `summary_text[]`; empty for other schemes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
summary: Vec<String>,
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
/// 運用で stateless に再送するときそのまま添える必要がある。
/// Anthropic の `redacted_thinking.data` もここに格納する。
/// Encrypted reasoning blob returned by the server. Must be included unchanged
/// when resending statelessly under ZDR / `store=false`.
/// Also holds Anthropic `redacted_thinking.data`.
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
/// Anthropic extended thinking の `signature`。新世代 Claude
/// (Opus 4.5+/Sonnet 4.6+) では同一論理ターン内の `thinking`
/// ブロックを送り返す際に必須。改ざん検知に使われる。他 scheme
/// では `None`。
/// Anthropic extended thinking `signature`, used for tamper detection.
/// Newer Claude models (Opus 4.5+/Sonnet 4.6+) require it when sending
/// `thinking` blocks back within the same logical turn.
/// `None` for other schemes.
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
/// Item status
@@ -216,6 +232,14 @@ impl Item {
}
}
/// Create an internal durable assistant-response boundary.
#[doc(hidden)]
pub fn assistant_response_boundary(response_id: impl Into<String>) -> Self {
Self::AssistantResponseBoundary {
response_id: response_id.into(),
}
}
// ========================================================================
// Tool call constructors
// ========================================================================
@@ -231,6 +255,8 @@ impl Item {
call_id: call_id.into(),
name: name.into(),
arguments: arguments.into(),
call_index: None,
execution_id: None,
status: None,
}
}
@@ -244,6 +270,25 @@ impl Item {
Self::tool_call(call_id, name, arguments.to_string())
}
/// Attach response-local ordering and, when execution has started, its
/// durable attempt identity to a tool call.
pub fn with_tool_execution_metadata(
mut self,
call_index: usize,
execution_id: Option<String>,
) -> Self {
if let Self::ToolCall {
call_index: item_call_index,
execution_id: item_execution_id,
..
} = &mut self
{
*item_call_index = Some(call_index);
*item_execution_id = execution_id;
}
self
}
/// Create a tool result item with summary only (no content).
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
Self::tool_result_item(call_id, summary, None, false)
@@ -261,7 +306,17 @@ impl Item {
content: Option<String>,
is_error: bool,
) -> Self {
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
Self::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
content,
if is_error {
ToolResultDisposition::Error
} else {
ToolResultDisposition::Success
},
Vec::new(),
)
}
/// Create a tool result item with durable, prunable structured attachments.
@@ -272,11 +327,33 @@ impl Item {
is_error: bool,
attachments: Vec<Attachment>,
) -> Self {
Self::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
content,
if is_error {
ToolResultDisposition::Error
} else {
ToolResultDisposition::Success
},
attachments,
)
}
pub fn tool_result_item_with_disposition_and_attachments(
call_id: impl Into<String>,
summary: impl Into<String>,
content: Option<String>,
disposition: ToolResultDisposition,
attachments: Vec<Attachment>,
) -> Self {
let is_error = !disposition.is_success();
Self::ToolResult {
id: None,
call_id: call_id.into(),
summary: summary.into(),
content,
disposition,
is_error,
attachments,
}
@@ -342,6 +419,7 @@ impl Item {
pub fn with_id(mut self, id: impl Into<String>) -> Self {
match &mut self {
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
Self::AssistantResponseBoundary { .. } => {}
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
@@ -353,6 +431,7 @@ impl Item {
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
match &mut self {
Self::Message { status, .. } => *status = Some(new_status),
Self::AssistantResponseBoundary { .. } => {}
Self::ToolCall { status, .. } => *status = Some(new_status),
Self::ToolResult { .. } => {} // Result items don't have status
Self::Reasoning { status, .. } => *status = Some(new_status),
@@ -368,6 +447,7 @@ impl Item {
pub fn id(&self) -> Option<&str> {
match self {
Self::Message { id, .. } => id.as_deref(),
Self::AssistantResponseBoundary { .. } => None,
Self::ToolCall { id, .. } => id.as_deref(),
Self::ToolResult { id, .. } => id.as_deref(),
Self::Reasoning { id, .. } => id.as_deref(),
@@ -378,6 +458,7 @@ impl Item {
pub fn item_type(&self) -> &'static str {
match self {
Self::Message { .. } => "message",
Self::AssistantResponseBoundary { .. } => "assistant_response_boundary",
Self::ToolCall { .. } => "tool_call",
Self::ToolResult { .. } => "tool_result",
Self::Reasoning { .. } => "reasoning",
@@ -542,12 +623,11 @@ pub struct Request {
/// (Anthropic today) can place a long-lived cache breakpoint there.
/// Providers without prompt caching ignore the field.
pub cache_anchor: Option<usize>,
/// 会話単位の安定キー。`prompt_cache_key` として送られる
/// (OpenAI Responses)。明示キーを必要とする backend では、
/// 呼び出し側が安定した conversation identifier を渡す。
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
/// 別の概念。`cache_anchor` を読まない provider と同じく、
/// `prompt_cache_key` を持たない provider は無視する。
/// Stable conversation key, sent as `prompt_cache_key` (OpenAI Responses).
/// For backends requiring an explicit key, the caller supplies a stable
/// conversation identifier. Unlike `cache_anchor`, this is a namespace key,
/// separate from a `prefix anchor`. Just as some providers ignore
/// `cache_anchor`, providers without `prompt_cache_key` ignore this field.
pub cache_key: Option<String>,
/// Request-local diagnostics sink for transport lifecycle tracing.
#[doc(hidden)]
@@ -643,7 +723,7 @@ impl Request {
/// Set the conversation cache key.
///
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
/// See the [`Request::cache_key`] field documentation for details.
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
self.cache_key = Some(key.into());
self
@@ -708,10 +788,11 @@ pub struct RequestConfig {
pub top_k: Option<u32>,
/// Stop sequences
pub stop_sequences: Vec<String>,
/// Reasoning / extended-thinking 制御(共通型、scheme 側で各社形式に投影)。
/// Shared reasoning / extended-thinking control, projected by each scheme
/// into the provider's format.
///
/// `None` のときは何も送らない。`Some` でも scheme の
/// `ModelCapability::reasoning` が `None` なら無視される。
/// `None` sends nothing. Even `Some` is ignored if the scheme's
/// `ModelCapability::reasoning` is `None`.
#[serde(default)]
pub reasoning: Option<crate::llm_client::capability::ReasoningControl>,
}
@@ -769,7 +850,7 @@ mod parse_tool_arguments_tests {
#[test]
fn literal_null_normalizes_to_object() {
// 既存セッションに残っている "null" が resume 時に復旧できること
// Ensure "null" retained in existing sessions can be recovered on resume.
assert_eq!(parse_tool_arguments("null"), empty_object());
}
+40 -25
View File
@@ -1,9 +1,9 @@
//! `~/.codex/auth.json` の読み書き。
//! Reading and writing `~/.codex/auth.json`.
//!
//! Codex CLI と schema を共有するが、知らないフィールドを失わないよう
//! ファイル全体を `serde_json::Value` で保持し、必要箇所
//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、
//! ファイルロックは取らない(manager 側で guarded reload)。
//! Shares the schema with Codex CLI, but retains the entire file as
//! `serde_json::Value` and accesses only the required fields to preserve
//! unknown fields. Writes reapply `mode 0o600` (as in Codex CLI),
//! without file locking (the manager performs a guarded reload).
use std::fs::OpenOptions;
use std::io::Write;
@@ -16,7 +16,7 @@ use serde_json::Value;
use super::error::CodexAuthError;
/// auth.json から取り出した使い回す情報のスナップショット。
/// Snapshot of reusable information extracted from auth.json.
#[derive(Debug, Clone)]
pub struct AuthSnapshot {
pub access_token: String,
@@ -24,12 +24,12 @@ pub struct AuthSnapshot {
pub account_id: String,
pub id_token: String,
pub last_refresh: Option<DateTime<Utc>>,
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
/// Retain the entire file to preserve other fields when writing back.
pub raw: Value,
}
impl AuthSnapshot {
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
/// Extract required fields from `Value`. Missing fields or type mismatches yield `MalformedAuthJson`.
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
let tokens = raw
.get("tokens")
@@ -55,7 +55,7 @@ impl AuthSnapshot {
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
.to_string();
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
// Prefer tokens.account_id; fall back to the id_token JWT if absent
let account_id = tokens
.get("account_id")
.and_then(Value::as_str)
@@ -84,7 +84,7 @@ impl AuthSnapshot {
}
}
/// auth.json を読む。存在しなければ `NotLoggedIn`。
/// Read auth.json. Return `NotLoggedIn` if it does not exist.
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
@@ -103,18 +103,26 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
AuthSnapshot::from_value(raw)
}
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
/// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}`
/// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`.
///
/// 並行する Codex CLI / 別プロセスが先に refresh していた場合の
/// fields を保護するため、書込前に再 load して merge する。
/// Reload and merge before writing to preserve fields updated by a concurrent
/// Codex CLI / another process. Account and refresh token must still match the
/// request's input: merge unrelated fields, but return the current snapshot
/// without writing if another login or token rotation is observed.
pub async fn persist_refreshed(
path: &Path,
refresh_input: &AuthSnapshot,
new_id_token: Option<String>,
new_access_token: Option<String>,
new_refresh_token: Option<String>,
) -> Result<AuthSnapshot, CodexAuthError> {
let mut current = load(path).await?;
if current.account_id != refresh_input.account_id
|| current.refresh_token != refresh_input.refresh_token
{
return Ok(current);
}
let raw = &mut current.raw;
let tokens = raw
.get_mut("tokens")
@@ -161,8 +169,8 @@ fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
file.flush()
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
// OpenOptions の `mode()` は新規作成時しか効かないため。
// Reapply 0o600 in case the existing file had permissive permissions.
// OpenOptions `mode()` only takes effect when creating a new file.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -204,7 +212,7 @@ mod tests {
assert_eq!(snap.refresh_token, "ref");
assert_eq!(snap.account_id, "acc-1");
assert!(snap.last_refresh.is_some());
// 未知フィールドが raw に保持されている
// Unknown fields are retained in raw
assert_eq!(
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
Some("sk-extra")
@@ -214,7 +222,7 @@ mod tests {
#[tokio::test]
async fn account_id_falls_back_to_jwt_claim() {
let dir = tempfile::tempdir().unwrap();
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
// Omit tokens.account_id and obtain it from a claim in the id_token JWT
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
let path = write_auth_json(
dir.path(),
@@ -243,13 +251,19 @@ mod tests {
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
}"#,
);
let updated =
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
.await
.unwrap();
let input = load(&path).await.unwrap();
let updated = persist_refreshed(
&path,
&input,
None,
Some("new-acc".into()),
Some("new-ref".into()),
)
.await
.unwrap();
assert_eq!(updated.access_token, "new-acc");
assert_eq!(updated.refresh_token, "new-ref");
// 未知フィールド agent_identity が保たれる
// The unknown field agent_identity is preserved
let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(on_disk.get("agent_identity").is_some());
assert!(on_disk.get("last_refresh").is_some());
@@ -264,9 +278,10 @@ mod tests {
dir.path(),
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
);
// 既存ファイルを 644 に変えてから persist → 600 に直るか
// Change the existing file to 644, then persist → check that it returns to 600
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
persist_refreshed(&path, None, Some("a2".into()), None)
let input = load(&path).await.unwrap();
persist_refreshed(&path, &input, None, Some("a2".into()), None)
.await
.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
+6 -6
View File
@@ -1,8 +1,8 @@
//! Codex OAuth サブモジュール内部のエラー型。
//! Internal error types for the Codex OAuth submodule.
//!
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の
//! 再実行案内付きメッセージにする。
//! Convert to `ClientError` using `to_client_error` at the `LlmClient`
//! boundary. `Permanent` errors (invalidated refresh_token) include
//! a message instructing the user to run `codex login` again.
use std::path::PathBuf;
@@ -25,7 +25,7 @@ pub enum CodexAuthError {
#[error("token refresh failed (transient): {0}")]
RefreshTransient(String),
/// refresh_token が永続的に失効。再ログインが必要。
/// refresh_token is permanently invalidated. Logging in again is required.
#[error("token refresh failed permanently ({reason:?}): {message}")]
RefreshPermanent {
reason: PermanentReason,
@@ -45,7 +45,7 @@ pub enum PermanentReason {
}
impl CodexAuthError {
/// `LlmClient` トランスポート境界向けに変換する。
/// Convert for the `LlmClient` transport boundary.
pub fn to_client_error(self) -> ClientError {
match self {
CodexAuthError::NotLoggedIn(_)
+5 -5
View File
@@ -1,13 +1,13 @@
//! JWT payload の最小限のパース(署名検証なし)。
//! Minimal JWT payload parsing (without signature verification).
//!
//! Codex CLI と同じく、access_token / id_token の payload を base64url
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
//! As in Codex CLI, used only to base64url-decode access_token / id_token
//! payloads and extract `exp` and ChatGPT-specific claims.
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::Deserialize;
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
/// Read `exp` from the access_token JWT sent in `Authorization: Bearer`.
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
#[derive(Deserialize)]
struct Claims {
@@ -17,7 +17,7 @@ pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
}
/// id_token JWT から ChatGPT 固有 claims を取り出す。
/// Extract ChatGPT-specific claims from the id_token JWT.
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
#[derive(Deserialize)]
struct IdClaims {
+56 -36
View File
@@ -1,22 +1,24 @@
//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` /
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる
//! [`AuthProvider`] 実装。
//! [`AuthProvider`] implementation that reads ChatGPT OAuth credentials
//! (`~/.codex/auth.json`) and builds `Authorization` /
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` headers.
//!
//! 設計:
//! Design:
//!
//! - HTTP transport は [`AuthProvider`] trait だけを見て、実体である
//! [`CodexAuthProvider`] はこの optional module に置く
//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh
//! (Codex CLI と同じバッファなし)
//! - 並行する Codex CLI / 別プロセスの refresh と取り違えないよう、
//! refresh 直前に再 load して account_id 一致を確認(guarded reload)
//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収
//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内
//! - The HTTP transport sees only the [`AuthProvider`] trait; the concrete
//! [`CodexAuthProvider`] lives in this optional module
//! - Read the access_token JWT's `exp` and proactively refresh when `exp` <= `now`
//! (no buffer, as in Codex CLI)
//! - Reload immediately before refreshing and check that account_id matches
//! to avoid confusing concurrent Codex CLI / other-process refreshes (guarded reload)
//! - No file locking; reload and merge differences before writing
//! - Codex Keyring storage is not supported. If auth.json is missing, return an error with guidance
mod auth_json;
mod error;
mod jwt;
mod refresh;
#[cfg(test)]
mod refresh_tests;
use std::path::PathBuf;
use std::sync::Arc;
@@ -32,14 +34,17 @@ use error::CodexAuthError;
pub use error::PermanentReason;
/// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。
/// Codex CLI's `last_refresh`-based fallback interval (8 days, matching Codex CLI).
const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8;
/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。
/// Provider that reads `~/.codex/auth.json` and returns Codex-compatible headers.
pub struct CodexAuthProvider {
auth_path: PathBuf,
refresh_endpoint: String,
http: reqwest::Client,
refresh_limits: refresh::RefreshLimits,
#[cfg(test)]
before_guarded_reload: Option<Arc<tokio::sync::Barrier>>,
state: Arc<Mutex<State>>,
}
@@ -58,7 +63,7 @@ struct State {
}
impl CodexAuthProvider {
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
/// Locate auth.json using `CODEX_HOME`, then `$HOME/.codex` as a fallback.
pub fn from_default_home() -> Result<Self, ClientError> {
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
PathBuf::from(p)
@@ -70,24 +75,27 @@ impl CodexAuthProvider {
Ok(Self::new(codex_home))
}
/// 任意の codex_home から構築する。
/// Construct from a custom codex_home.
pub fn new(codex_home: PathBuf) -> Self {
Self {
auth_path: codex_home.join("auth.json"),
refresh_endpoint: refresh::REFRESH_URL.to_string(),
http: reqwest::Client::new(),
refresh_limits: refresh::RefreshLimits::default(),
#[cfg(test)]
before_guarded_reload: None,
state: Arc::new(Mutex::new(State::default())),
}
}
/// テスト用に refresh エンドポイントを差し替える。
/// Override the refresh endpoint for tests.
#[cfg(test)]
fn with_refresh_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.refresh_endpoint = endpoint.into();
self
}
/// テスト用に HTTP クライアントを差し替える。
/// Override the HTTP client for tests.
#[cfg(test)]
fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = client;
@@ -97,39 +105,46 @@ impl CodexAuthProvider {
async fn ensure_fresh(&self) -> Result<AuthSnapshot, CodexAuthError> {
let mut state = self.state.lock().await;
// 1. 常にディスクから最新を読む(他プロセスの更新を反映)
// 1. Always read the latest from disk (reflect updates by other processes)
let snap = auth_json::load(&self.auth_path).await?;
// 2. stale でなければそのまま返す
// 2. Return as-is if not stale
if !is_stale(&snap) {
state.cached = Some(snap.clone());
return Ok(snap);
}
// 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。
// 一致しなければ他プロセスが先に更新済 → 自分は refresh しない
// 3. Reload immediately before refreshing (guarded reload).
// Skip refresh if another process supplied fresh tokens or changed account_id.
#[cfg(test)]
if let Some(barrier) = &self.before_guarded_reload {
barrier.wait().await;
barrier.wait().await;
}
let pre_refresh = auth_json::load(&self.auth_path).await?;
if !is_stale(&pre_refresh) {
state.cached = Some(pre_refresh.clone());
return Ok(pre_refresh);
}
if pre_refresh.account_id != snap.account_id {
// account が切り替わった → 新しい auth を採用、refresh はしない
// The account has changed → use the new auth without refreshing
state.cached = Some(pre_refresh.clone());
return Ok(pre_refresh);
}
// 4. Refresh 実行
// 4. Perform the refresh
let refreshed = refresh::request_refresh(
&self.http,
&self.refresh_endpoint,
&pre_refresh.refresh_token,
&self.refresh_limits,
)
.await?;
// 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施)
// 5. Write back (persist_refreshed reloads and merges before writing)
let new_snap = auth_json::persist_refreshed(
&self.auth_path,
&pre_refresh,
refreshed.id_token,
refreshed.access_token,
refreshed.refresh_token,
@@ -152,9 +167,9 @@ impl CodexAuthProvider {
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
out.push((HeaderName::from_static("chatgpt-account-id"), acc_val));
// Cloudflare WAF は互換 backend アクセス元を `originator` /
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
// HTML challenge (403) を返されて SSE に到達できない。
// Cloudflare WAF identifies clients accessing the compatible backend by `originator` /
// `User-Agent`. Without the fixed values sent by Codex CLI,
// it returns an HTML challenge (403), preventing access to SSE.
out.push((
HeaderName::from_static("originator"),
HeaderValue::from_static("codex_cli_rs"),
@@ -164,7 +179,7 @@ impl CodexAuthProvider {
HeaderValue::from_static("codex_cli_rs/0.60.0"),
));
// FedRAMP 組織は id_token JWT 内の claim で判定
// Identify FedRAMP organizations using a claim in the id_token JWT
if jwt::parse_chatgpt_claims(&snap.id_token)
.map(|c| c.is_fedramp)
.unwrap_or(false)
@@ -190,8 +205,8 @@ impl AuthProvider for CodexAuthProvider {
}
}
/// `access_token` の JWT `exp` を見て、期限切れなら true。
/// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。
/// Return true if the `access_token` JWT's `exp` indicates expiration.
/// If `exp` cannot be read, use `last_refresh + 8 days` (as in Codex CLI).
fn is_stale(snap: &AuthSnapshot) -> bool {
if let Some(exp) = jwt::parse_exp(&snap.access_token) {
return exp <= Utc::now();
@@ -210,13 +225,18 @@ mod tests {
use wiremock::matchers::{method, path as url_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_jwt(payload: serde_json::Value) -> String {
pub(super) fn make_jwt(payload: serde_json::Value) -> String {
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap());
format!("h.{p}.s")
}
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
pub(super) fn write_auth(
dir: &std::path::Path,
exp: i64,
fedramp: bool,
refresh: &str,
) -> PathBuf {
let id_token = make_jwt(json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-xyz",
@@ -241,7 +261,7 @@ mod tests {
#[tokio::test]
async fn returns_headers_for_fresh_token() {
let dir = tempfile::tempdir().unwrap();
// 期限を遠い未来に
// Set expiration far in the future
write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt");
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
@@ -285,7 +305,7 @@ mod tests {
"old-refresh",
);
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
// Mock the refresh endpoint to return a new JWT (future exp)
let server = MockServer::start().await;
let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 }));
let new_refresh = "new-refresh-token";
@@ -310,7 +330,7 @@ mod tests {
.unwrap();
assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}"));
// ファイルに新しい refresh_token が書き戻されている
// The new refresh_token has been written back to the file
let on_disk: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(
+103 -61
View File
@@ -1,7 +1,7 @@
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
//! HTTP calls to refresh ChatGPT OAuth tokens.
//!
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
//! 401 + `error.code` で永続失敗を分類する。
//! Uses the same `POST https://auth.openai.com/oauth/token` format as Codex CLI.
//! Classifies permanent failures using 401 + `error.code`.
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -10,7 +10,26 @@ use super::error::{CodexAuthError, PermanentReason};
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
/// Limits cover send, both response bodies, and decode, not auth file I/O.
/// OAuth responses contain only a few tokens; 64 KiB leaves ample JWT headroom.
#[derive(Clone)]
pub(super) struct RefreshLimits {
pub timeout: Duration,
pub max_body_bytes: usize,
#[cfg(test)]
pub body_started: Option<std::sync::Arc<tokio::sync::Notify>>,
}
impl Default for RefreshLimits {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
max_body_bytes: 64 * 1024,
#[cfg(test)]
body_started: None,
}
}
}
#[derive(Serialize)]
struct RefreshRequest<'a> {
@@ -29,62 +48,102 @@ pub struct RefreshResponse {
pub refresh_token: Option<String>,
}
/// refresh_token を使って新しいトークン群を取得する。
/// Obtain new tokens using refresh_token.
///
/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
/// Permanent failures (401 + `refresh_token_(expired|reused|invalidated)`) return
/// `RefreshPermanent`; all others return `RefreshTransient`.
pub async fn request_refresh(
client: &reqwest::Client,
endpoint: &str,
refresh_token: &str,
limits: &RefreshLimits,
) -> Result<RefreshResponse, CodexAuthError> {
let deadline = tokio::time::Instant::now() + limits.timeout;
let result = tokio::time::timeout_at(
deadline,
request_refresh_bounded(client, endpoint, refresh_token, limits),
)
.await;
// JSON decode is synchronous. The byte bound limits its work, and this
// post-check prevents a decode that crosses the deadline from being persisted.
if tokio::time::Instant::now() >= deadline {
return Err(CodexAuthError::RefreshTransient(
"oauth_token_refresh timed out".into(),
));
}
result.map_err(|_| CodexAuthError::RefreshTransient("oauth_token_refresh timed out".into()))?
}
async fn request_refresh_bounded(
client: &reqwest::Client,
endpoint: &str,
refresh_token: &str,
limits: &RefreshLimits,
) -> Result<RefreshResponse, CodexAuthError> {
let body = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token,
};
let response = response_with_timeout(
client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&body)
.send(),
DEFAULT_REFRESH_TIMEOUT,
)
.await?;
let mut response = client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|_| CodexAuthError::RefreshTransient("send failed".into()))?;
let status = response.status();
if status.is_success() {
response
.json::<RefreshResponse>()
.await
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
} else {
let body = response.text().await.unwrap_or_default();
if status == reqwest::StatusCode::UNAUTHORIZED {
let (reason, message) = classify_permanent(&body);
Err(CodexAuthError::RefreshPermanent { reason, message })
} else {
Err(CodexAuthError::RefreshTransient(format!(
"{status}: {body}"
)))
if response
.content_length()
.is_some_and(|len| len > limits.max_body_bytes as u64)
{
return Err(body_too_large());
}
#[cfg(test)]
if let Some(started) = &limits.body_started {
started.notify_one();
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| CodexAuthError::RefreshTransient("read response body failed".into()))?
{
// Content-Length can be absent or untrustworthy. Bound actual bytes too,
// before copying a chunk into the accumulated body.
if chunk.len() > limits.max_body_bytes.saturating_sub(bytes.len()) {
return Err(body_too_large());
}
bytes.extend_from_slice(&chunk);
}
if status.is_success() {
let tokens: RefreshResponse = serde_json::from_slice(&bytes).map_err(|_| {
// Do not expose untrusted body values (which can contain secrets).
CodexAuthError::RefreshTransient("decode response failed".into())
})?;
if tokens.access_token.as_deref().is_none_or(str::is_empty)
|| tokens.id_token.as_deref().is_some_and(str::is_empty)
|| tokens.refresh_token.as_deref().is_some_and(str::is_empty)
{
return Err(CodexAuthError::RefreshTransient(
"incomplete token response".into(),
));
}
Ok(tokens)
} else if status == reqwest::StatusCode::UNAUTHORIZED {
let (reason, message) = classify_permanent(&String::from_utf8_lossy(&bytes));
Err(CodexAuthError::RefreshPermanent { reason, message })
} else {
// Error bodies are consumed within the same limits but never echoed.
Err(CodexAuthError::RefreshTransient(format!(
"refresh endpoint returned {status}"
)))
}
}
async fn response_with_timeout(
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
timeout: Duration,
) -> Result<reqwest::Response, CodexAuthError> {
tokio::time::timeout(timeout, future)
.await
.map_err(|_| {
CodexAuthError::RefreshTransient(format!(
"oauth_token_refresh timed out after {}s",
timeout.as_secs()
))
})?
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
fn body_too_large() -> CodexAuthError {
CodexAuthError::RefreshTransient("response body exceeds size limit".into())
}
fn classify_permanent(body: &str) -> (PermanentReason, String) {
@@ -99,7 +158,7 @@ fn classify_permanent(body: &str) -> (PermanentReason, String) {
PermanentReason::Expired => "Your refresh token has expired".to_string(),
PermanentReason::Reused => "Your refresh token was already used".to_string(),
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
PermanentReason::Other => "Unknown 401 from refresh endpoint".to_string(),
};
(reason, message)
}
@@ -126,23 +185,6 @@ fn extract_error_code(body: &str) -> Option<String> {
mod tests {
use super::*;
#[tokio::test]
async fn refresh_response_timeout_is_transient() {
let err = match response_with_timeout(
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
Duration::from_millis(5),
)
.await
{
Ok(_) => panic!("expected refresh timeout"),
Err(err) => err,
};
assert!(
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
);
}
#[test]
fn classify_expired() {
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
@@ -0,0 +1,431 @@
//! Real AuthProvider calls against owned auth files and a loopback HTTP peer.
//! Gates place callers at response-body / reload boundaries; paused Tokio time
//! advances the injected HTTP deadline only after headers have been received.
use super::tests::{make_jwt, write_auth};
use super::*;
use serde_json::{Value, json};
use std::time::Duration as StdDuration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{Notify, mpsc};
use tokio::task::{JoinHandle, JoinSet};
struct Reply {
status: u16,
body: Vec<u8>,
chunked: bool,
gate: Option<Arc<Notify>>,
truncate: bool,
}
impl Reply {
fn body(status: u16, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
body: body.into(),
chunked: false,
gate: None,
truncate: false,
}
}
fn gated(status: u16, body: impl Into<Vec<u8>>, gate: &Arc<Notify>) -> Self {
Self {
gate: Some(gate.clone()),
..Self::body(status, body)
}
}
}
struct Stub {
endpoint: String,
requests: mpsc::UnboundedReceiver<Value>,
task: JoinHandle<()>,
}
impl Drop for Stub {
fn drop(&mut self) {
// Dropping the server's JoinSet also aborts stalled connection handlers.
self.task.abort();
}
}
impl Stub {
async fn start(replies: Vec<Reply>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}/oauth/token", listener.local_addr().unwrap());
let (tx, requests) = mpsc::unbounded_channel();
let task = tokio::spawn(async move {
let mut handlers = JoinSet::new();
for reply in replies {
let (mut stream, _) = listener.accept().await.unwrap();
let tx = tx.clone();
handlers.spawn(async move {
let mut request = Vec::new();
let header_end = loop {
let byte = stream.read_u8().await.unwrap();
request.push(byte);
if request.ends_with(b"\r\n\r\n") { break request.len(); }
assert!(request.len() < 16 * 1024, "fixture request headers too large");
};
let headers = std::str::from_utf8(&request).unwrap();
assert!(headers.starts_with("POST /oauth/token HTTP/1.1\r\n"));
let len: usize = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
}).unwrap();
request.resize(header_end + len, 0);
stream.read_exact(&mut request[header_end..]).await.unwrap();
tx.send(serde_json::from_slice(&request[header_end..]).unwrap()).unwrap();
let framing = if reply.chunked {
"Transfer-Encoding: chunked".to_string()
} else {
format!("Content-Length: {}", reply.body.len())
};
stream.write_all(format!(
"HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\n{framing}\r\nConnection: close\r\n\r\n", reply.status
).as_bytes()).await.unwrap();
if let Some(gate) = reply.gate { gate.notified().await; }
let body = if reply.truncate { &reply.body[..reply.body.len() / 2] } else { &reply.body[..] };
if reply.chunked {
for chunk in body.chunks(512) {
let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()).await;
let _ = stream.write_all(chunk).await;
let _ = stream.write_all(b"\r\n").await;
}
let _ = stream.write_all(b"0\r\n\r\n").await;
} else {
let _ = stream.write_all(body).await;
}
});
}
while let Some(result) = handlers.join_next().await {
result.unwrap();
}
});
Self {
endpoint,
requests,
task,
}
}
}
fn rotated_body() -> Vec<u8> {
serde_json::to_vec(&json!({
"access_token": make_jwt(json!({"exp": Utc::now().timestamp() + 3600})),
"refresh_token": "fixture-rotated-secret",
}))
.unwrap()
}
fn provider(dir: &std::path::Path, stub: &Stub, started: &Arc<Notify>) -> Arc<CodexAuthProvider> {
let mut provider = CodexAuthProvider::new(dir.to_path_buf())
.with_refresh_endpoint(&stub.endpoint)
.with_http_client(reqwest::Client::builder().no_proxy().build().unwrap());
provider.refresh_limits = refresh::RefreshLimits {
timeout: StdDuration::from_secs(10),
max_body_bytes: 1024,
body_started: Some(started.clone()),
};
Arc::new(provider)
}
async fn bounded<F: std::future::Future>(future: F) -> F::Output {
tokio::time::timeout(StdDuration::from_secs(5), future)
.await
.expect("fixture/caller did not progress")
}
fn assert_rotated(path: &std::path::Path, headers: &[(HeaderName, HeaderValue)]) {
let value: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(value["tokens"]["refresh_token"], "fixture-rotated-secret");
assert_eq!(value["fixture_extra"], "preserve me");
let auth = headers
.iter()
.find(|(name, _)| name == "authorization")
.unwrap();
assert_eq!(
auth.1.to_str().unwrap(),
format!(
"Bearer {}",
value["tokens"]["access_token"].as_str().unwrap()
)
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
fn stale_auth(dir: &std::path::Path) -> PathBuf {
let path = write_auth(
dir,
Utc::now().timestamp() - 60,
false,
"fixture-old-secret",
);
let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
value["fixture_extra"] = json!("preserve me");
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
path
}
#[tokio::test]
async fn stalled_success_and_error_bodies_timeout_without_persist_and_release_waiting_caller() {
for status in [200, 401, 503] {
let dir = tempfile::tempdir().unwrap();
let path = stale_auth(dir.path());
let original = std::fs::read(&path).unwrap();
let gate = Arc::new(Notify::new());
let started = Arc::new(Notify::new());
let mut stub = Stub::start(vec![
Reply::gated(status, b"unfinished".to_vec(), &gate),
Reply::body(200, rotated_body()),
])
.await;
let provider = provider(dir.path(), &stub, &started);
let first = tokio::spawn({
let provider = provider.clone();
async move { provider.headers().await }
});
bounded(started.notified()).await; // provider has received headers and is consuming the body
let request = bounded(stub.requests.recv()).await.unwrap();
assert_eq!(request["refresh_token"], "fixture-old-secret");
let second = provider.headers();
tokio::pin!(second);
assert!(futures::poll!(&mut second).is_pending());
assert!(provider.state.try_lock().is_err());
assert!(
stub.requests.try_recv().is_err(),
"waiter must not refresh concurrently"
);
tokio::time::pause();
tokio::time::advance(StdDuration::from_secs(11)).await;
let error = first.await.unwrap().unwrap_err();
tokio::time::resume();
assert!(error.to_string().contains("timed out"), "{error}");
assert_eq!(std::fs::read(&path).unwrap(), original);
let headers = bounded(second).await.unwrap();
assert_rotated(&path, &headers);
let retry = bounded(stub.requests.recv()).await.unwrap();
assert_eq!(retry["refresh_token"], "fixture-old-secret");
}
}
#[tokio::test]
async fn oversized_malformed_and_incomplete_bodies_do_not_persist_and_allow_later_refresh() {
let mut cases = vec![
(Reply::body(200, vec![b'x'; 1025]), "size limit"),
(
Reply {
chunked: true,
..Reply::body(200, vec![b'x'; 1025])
},
"size limit",
),
(
Reply {
chunked: true,
..Reply::body(401, vec![b'x'; 1025])
},
"size limit",
),
(Reply::body(503, vec![b'x'; 1025]), "size limit"),
(
Reply::body(
200,
br#"{"access_token": {"secret":"fixture-body-secret"}}"#.to_vec(),
),
"decode",
),
(Reply::body(200, b"{".to_vec()), "decode"),
(Reply::body(200, b"{}".to_vec()), "incomplete"),
(
Reply::body(
200,
br#"{"access_token":"","refresh_token":"fixture-body-secret"}"#.to_vec(),
),
"incomplete",
),
(
Reply {
truncate: true,
..Reply::body(200, rotated_body())
},
"read response body",
),
(
Reply {
truncate: true,
..Reply::body(401, b"fixture-body-secret".to_vec())
},
"read response body",
),
(
Reply::body(401, b"fixture-body-secret".to_vec()),
"codex login",
),
(Reply::body(503, b"fixture-body-secret".to_vec()), "503"),
];
for (reply, expected) in cases.drain(..) {
let dir = tempfile::tempdir().unwrap();
let path = stale_auth(dir.path());
let original = std::fs::read(&path).unwrap();
let started = Arc::new(Notify::new());
let mut stub = Stub::start(vec![reply, Reply::body(200, rotated_body())]).await;
let provider = provider(dir.path(), &stub, &started);
let error = bounded(provider.headers()).await.unwrap_err();
let message = error.to_string();
assert!(message.contains(expected), "expected {expected}: {message}");
assert!(!message.contains("fixture-body-secret"));
assert!(!message.contains("fixture-old-secret"));
assert_eq!(std::fs::read(&path).unwrap(), original);
let headers = bounded(provider.headers()).await.unwrap();
assert_rotated(&path, &headers);
for _ in 0..2 {
assert_eq!(
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
"fixture-old-secret"
);
}
}
}
#[tokio::test]
async fn cancelled_body_wait_leaves_auth_unchanged_and_releases_waiting_caller() {
let dir = tempfile::tempdir().unwrap();
let path = stale_auth(dir.path());
let original = std::fs::read(&path).unwrap();
let started = Arc::new(Notify::new());
let gate = Arc::new(Notify::new());
let mut stub = Stub::start(vec![
Reply::gated(200, rotated_body(), &gate),
Reply::body(200, rotated_body()),
])
.await;
let provider = provider(dir.path(), &stub, &started);
let first = tokio::spawn({
let provider = provider.clone();
async move { provider.headers().await }
});
bounded(started.notified()).await;
bounded(stub.requests.recv()).await.unwrap();
let second = provider.headers();
tokio::pin!(second);
assert!(futures::poll!(&mut second).is_pending());
assert!(stub.requests.try_recv().is_err());
first.abort();
assert!(first.await.unwrap_err().is_cancelled());
assert_eq!(std::fs::read(&path).unwrap(), original);
assert_rotated(&path, &bounded(second).await.unwrap());
assert_eq!(
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
"fixture-old-secret"
);
}
#[tokio::test]
async fn shared_callers_refresh_once_and_use_persisted_rotated_tokens() {
let dir = tempfile::tempdir().unwrap();
let path = stale_auth(dir.path());
let started = Arc::new(Notify::new());
let gate = Arc::new(Notify::new());
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
let provider = provider(dir.path(), &stub, &started);
let first = tokio::spawn({
let provider = provider.clone();
async move { provider.headers().await }
});
bounded(started.notified()).await;
let second = provider.headers();
tokio::pin!(second);
assert!(futures::poll!(&mut second).is_pending());
assert_eq!(
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
"fixture-old-secret"
);
let mut external: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
external["concurrent_extra"] = json!("merge me");
std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap();
gate.notify_one();
let first_headers = bounded(first).await.unwrap().unwrap();
let second_headers = bounded(second).await.unwrap();
assert_eq!(first_headers, second_headers);
assert_rotated(&path, &second_headers);
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(persisted["concurrent_extra"], "merge me");
assert!(
stub.requests.try_recv().is_err(),
"single-flight must make only one request"
);
}
#[tokio::test]
async fn account_switch_before_guarded_reload_skips_old_account_refresh() {
let dir = tempfile::tempdir().unwrap();
let path = stale_auth(dir.path());
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let mut provider = CodexAuthProvider::new(dir.path().to_path_buf());
let mut stub = Stub::start(vec![]).await;
provider.refresh_endpoint = stub.endpoint.clone();
provider.before_guarded_reload = Some(barrier.clone());
let task = tokio::spawn(async move { provider.headers().await });
bounded(barrier.wait()).await;
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
switched["tokens"]["account_id"] = json!("switched-account");
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
let original = std::fs::read(&path).unwrap();
barrier.wait().await;
let headers = bounded(task).await.unwrap().unwrap();
assert!(
headers
.iter()
.any(|(name, value)| name == "chatgpt-account-id" && value == "switched-account")
);
assert_eq!(std::fs::read(&path).unwrap(), original);
assert!(stub.requests.try_recv().is_err());
}
#[tokio::test]
async fn account_or_token_switch_during_refresh_is_not_overwritten_by_old_response() {
for account_changed in [false, true] {
let dir = tempfile::tempdir().unwrap();
let path = stale_auth(dir.path());
let started = Arc::new(Notify::new());
let gate = Arc::new(Notify::new());
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
let provider = provider(dir.path(), &stub, &started);
let task = tokio::spawn({
let provider = provider.clone();
async move { provider.headers().await }
});
bounded(started.notified()).await;
bounded(stub.requests.recv()).await.unwrap();
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
if account_changed {
switched["tokens"]["account_id"] = json!("switched-account");
} else {
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
}
switched["tokens"]["access_token"] =
json!(make_jwt(json!({"exp": Utc::now().timestamp() + 7200})));
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
let original = std::fs::read(&path).unwrap();
gate.notify_one();
let headers = bounded(task).await.unwrap().unwrap();
assert_eq!(std::fs::read(&path).unwrap(), original);
let expected = format!(
"Bearer {}",
switched["tokens"]["access_token"].as_str().unwrap()
);
assert!(headers.iter().any(|(name, value)| name == "authorization" && value.to_str().unwrap() == expected));
assert_eq!(bounded(provider.headers()).await.unwrap(), headers);
assert!(stub.requests.try_recv().is_err());
}
}
+99 -30
View File
@@ -1,19 +1,17 @@
//! Prune — context projection for old tool-result content.
//! Request-only omission of details from old tool results.
//!
//! LLM 送信時のコンテキストから古い [`Item::ToolResult`] の `content` を
//! 省略して、コンテキスト窓のトークンを回収する。`summary` は残すので
//! 「何が起きたか」の痕跡は保たれる。
//! [`prunable_indices`] selects candidates while protecting a token budget at the
//! history tail; [`project`] removes detailed text and attachments. Summaries remain
//! visible to the model. Projection operates on a request-history clone, leaving
//! stored history and annotations unchanged.
//!
//! # 設計方針
//! This module does not own token measurements. The host supplies prefix and savings
//! estimates; unavailable estimates or invalid boundaries produce no candidates.
//! The Engine also checks savings to avoid invalidating the cache for little benefit.
//!
//! Prune は **コンテキスト射影** であり、history の変換ではない。
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
//! 射影の適用は LLM に送る一時コンテキストに対してだけ行う。
//! Engine の永続履歴は決して変更されない。
//!
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
//! 所有しない。prefix ごとの token 推定値と savings 推定は上位層から
//! callback で注入される。
//! Compaction, which stores a summary as a new segment, is a separate responsibility.
//! Omitting already-consumed images likewise affects only the request projection,
//! never the stored results.
use serde::{Deserialize, Serialize};
@@ -36,27 +34,26 @@ pub type TokenEstimator = Box<dyn Fn(&[Item]) -> Vec<TokenEstimate> + Send + Syn
/// Engine can make `min_savings` decisions without knowing about usage
/// measurement sources. Return `0` to signal "no data / refuse to prune".
///
/// 推定対象は「drop する範囲全体」ではなく「content を None にする差分」
/// であることに注意。item 自体(summary 等)は残るので、この callback は
/// 実際の projection と一致する savings を返す必要がある。
/// Estimate the savings from removing details, not from dropping entire items.
/// Items and their summaries remain, so this callback must match the actual projection.
pub type SavingsEstimator = Box<dyn Fn(&[Item], &[usize]) -> u64 + Send + Sync>;
/// Result of one prune evaluation pass, surfaced to the optional
/// [`PruneObserver`] for instrumentation.
///
/// Engine は LLM リクエストごとに 1 回 prune の評価をし、その結果を
/// (observer が登録されていれば)この値で通知する。fire/skip の判定
/// 結果と、判定材料になった候補数 / 推定 savings / 保護領域の先頭 index を持つ。
/// The Engine evaluates pruning once per LLM request and reports this value if
/// an observer is registered. It includes the decision and its inputs: candidate
/// count, estimated savings, and the start index of the protected suffix.
#[derive(Debug, Clone)]
pub struct PruneEvaluation {
/// `prunable_indices` の長さ。`Skipped::NoCandidates` の時は 0。
/// Length of `prunable_indices`; zero for `SkippedNoCandidates`.
pub candidate_count: usize,
/// 推定された savings (tokens)。`NoCandidates` の時は 0。
/// Estimated token savings; zero when there are no candidates.
pub estimated_savings: u64,
/// Token budget で保護される suffix の先頭 item index。
/// usage 推定が `NoData` で境界が決まらない場合は `None`。
/// First item in the suffix protected by the token budget.
/// `None` when `NoData` estimates prevent determining the boundary.
pub protected_start_index: Option<usize>,
/// 判定結果。
/// Evaluation outcome.
pub decision: PruneDecision,
}
@@ -64,13 +61,13 @@ pub struct PruneEvaluation {
/// "fire vs skip" decision tree the Engine walks before each LLM request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PruneDecision {
/// `prunable_indices` が空 → 何もしない。
/// No projection: `prunable_indices` is empty.
SkippedNoCandidates,
/// 候補はあったが推定 savings が `min_savings` 未満 → 何もしない。
/// No projection: candidates exist, but estimated savings are below `min_savings`.
SkippedBelowMinSavings,
/// 候補があり savings >= min_savings → projection を適用した。
/// `pruned_count` は `project()` が実際に書き換えた item 数
/// (既に content=None だった候補は 0 計上)。
/// Applied projection because candidates exist and savings meet `min_savings`.
/// `pruned_count` counts items actually changed by `project()`;
/// candidates that already lack details contribute zero.
Fired { pruned_count: usize },
}
@@ -133,6 +130,32 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
count
}
/// Remove binary attachments after the first model request that could consume them.
///
/// Attachments on tool results following the latest model output are fresh outputs for the
/// immediate follow-up request and remain visible. With early dispatch, later
/// text/calls in the same response have not consumed its tool results: use the
/// confirmed response boundary, not that response's last individual item.
/// Subsequent requests keep the durable tool summary and can call the originating
/// tool again when the image is still needed. Only the request-context clone is modified.
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
let mut count = 0;
for (index, item) in items.iter_mut().enumerate() {
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
if is_fresh {
continue;
}
if let Item::ToolResult { attachments, .. } = item
&& !attachments.is_empty()
{
attachments.clear();
count += 1;
}
}
count
}
/// Indices of detailed `Item::ToolResult` values that lie before
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
///
@@ -412,6 +435,52 @@ mod tests {
));
}
fn image_result(call_id: &str, bytes: &[u8]) -> Item {
Item::tool_result_item_with_attachments(
call_id,
"Attached image.png",
None,
false,
vec![crate::tool::Attachment::Image(
crate::tool::ImageAttachment::new("image/png", bytes.to_vec()),
)],
)
}
#[test]
fn consumed_attachment_projection_keeps_only_fresh_tool_outputs() {
let mut immediate = vec![
Item::tool_call("image-1", "ViewImage", r#"{"path":"first.png"}"#),
image_result("image-1", b"first"),
];
assert_eq!(project_consumed_attachments(&mut immediate), 0);
assert!(matches!(
&immediate[1],
Item::ToolResult { attachments, .. } if attachments.len() == 1
));
let mut consumed = immediate.clone();
consumed.push(Item::assistant_message("I inspected the image."));
consumed.push(Item::user_message("continue"));
assert_eq!(project_consumed_attachments(&mut consumed), 1);
assert!(matches!(
&consumed[1],
Item::ToolResult { attachments, .. } if attachments.is_empty()
));
consumed.push(Item::tool_call(
"image-2",
"ViewImage",
r#"{"path":"second.png"}"#,
));
consumed.push(image_result("image-2", b"second"));
assert_eq!(project_consumed_attachments(&mut consumed), 0);
assert!(matches!(
consumed.last(),
Some(Item::ToolResult { attachments, .. }) if attachments.len() == 1
));
}
#[test]
fn project_skips_already_pruned_items() {
// indices points at an item whose content is already None.
@@ -441,7 +510,7 @@ mod tests {
let estimates = uniform_estimates(&items, 10);
let candidates = prunable_indices(&items, 20, &estimates);
assert_eq!(project(&mut items, &candidates), 1);
// 2 周目: 候補は一度の prunable_indices 結果を使い回しても 0 件。
// Second pass: reusing the same prunable_indices result changes zero items.
assert_eq!(project(&mut items, &candidates), 0);
}
+14 -38
View File
@@ -1,58 +1,34 @@
//! Engine State
//! Type states that restrict changes to Engine configuration.
//!
//! State marker types for cache protection using the Type-state pattern.
//! Engine has state transitions from `Mutable` → `Locked`.
//! Transitioning from [`Mutable`] to [`Locked`] fixes the configuration and history
//! prefix length, restricting changes to system prompts and tool definitions during
//! execution. Changes require an explicit unlock because altering the prefix may
//! prevent prompt-cache reuse. History itself belongs to the caller: these states
//! neither prevent external history mutations nor guarantee cache hits.
/// Marker trait representing Engine state
/// Sealed trait for Engine type states.
///
/// This trait is sealed and cannot be implemented externally.
/// External implementations are prohibited so the crate controls the separation
/// between configuration and execution APIs.
pub trait EngineState: private::Sealed + Send + Sync + 'static {}
mod private {
pub trait Sealed {}
}
/// Mutable state (editable)
/// State in which configuration can be edited.
///
/// In this state, the following operations are available:
/// - Setting/changing system prompt
/// - Editing message history (add, delete, clear)
/// - Registering tools and hooks
///
/// Can transition to [`Locked`] state via `Engine::lock()`.
///
/// # Examples
///
/// ```ignore
/// use agen::Engine;
///
/// let mut engine = Engine::new(client)
/// .system_prompt("You are helpful.");
///
/// // History can be edited
/// engine.push_message(Message::user("Hello"));
/// engine.clear_history();
///
/// // Lock to protected state
/// let locked = engine.lock();
/// ```
/// Transitions to [`Locked`] through `Engine::lock` or the first `run`.
#[derive(Debug, Clone, Copy, Default)]
pub struct Mutable;
impl private::Sealed for Mutable {}
impl EngineState for Mutable {}
/// Cache locked state (cache protected)
/// State with fixed configuration and history prefix length.
///
/// In this state, the following restrictions apply:
/// - System prompt cannot be changed
/// - Existing message history cannot be modified (only appending to the end)
///
/// To ensure LLM API KV cache hits,
/// using this state during execution is recommended.
///
/// Can return to [`Mutable`] state via `Engine::unlock()`,
/// but note that cache protection will be released.
/// `Engine::unlock` returns to [`Mutable`] and permits configuration changes,
/// but requests made after those changes may no longer reuse the previous cache.
#[derive(Debug, Clone, Copy, Default)]
pub struct Locked;
+2 -2
View File
@@ -1,5 +1,5 @@
//! Timeline層のイベント型
//! Event types for the Timeline layer.
//!
//! llm_client層のイベント型をそのまま使用する。
//! Uses the event types from the llm_client layer directly.
pub use crate::llm_client::event::*;
+8 -12
View File
@@ -1,14 +1,9 @@
//! Timeline層
//! Dispatch normalized LLM events and collect blocks.
//!
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
//!
//! # 主要コンポーネント
//!
//! - [`Timeline`] - イベントストリームの管理とディスパッチ
//! - [`Handler`] - イベントを処理するトレイト
//! - [`TextBlockCollector`] - テキストブロックを収集するHandler
//! - [`ThinkingBlockCollector`] - reasoning material を Thinking block から収集するHandler
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
//! [`Timeline`] dispatches common events to handlers without parsing provider protocols.
//! Block assembly and display callbacks are separate from turn decisions. The Engine
//! uses collected responses to update history and execute tools. Provisional streaming
//! output is distinct from committed history.
pub mod event;
mod text_block_collector;
@@ -16,14 +11,15 @@ mod thinking_block_collector;
mod timeline;
mod tool_call_collector;
// 公開API
// Public API
pub use event::*;
pub use text_block_collector::TextBlockCollector;
pub use thinking_block_collector::ThinkingBlockCollector;
pub use timeline::Timeline;
pub(crate) use tool_call_collector::CollectedToolCall;
pub use tool_call_collector::ToolCallCollector;
// 型定義からのre-export
// Re-exports from type definitions
pub use crate::handler::{
// Meta Kinds
ErrorKind,
@@ -1,53 +1,51 @@
//! TextBlockCollector - テキストブロック収集用ハンドラ
//! TextBlockCollector - a handler for collecting text blocks.
//!
//! TimelineのTextBlockHandler として登録され、
//! ストリーム中のテキストブロックを収集する。
//! Registered as a TextBlockHandler on Timeline to collect streaming text blocks.
use crate::handler::{Handler, TextBlockEvent, TextBlockKind};
use std::sync::{Arc, Mutex};
/// TextBlockから収集したテキスト情報を保持
/// Holds text collected from TextBlock events.
#[derive(Debug, Default)]
pub struct TextCollectorState {
/// 蓄積中のテキスト
/// Text being accumulated.
buffer: String,
}
/// TextBlockCollector - テキストブロックハンドラ
/// TextBlockCollector - a text block handler.
///
/// Timelineに登録してTextBlockイベントを受信し、
/// 完了したテキストブロックを収集する。
/// Register with Timeline to receive TextBlock events and collect completed text blocks.
#[derive(Clone)]
pub struct TextBlockCollector {
/// 収集されたテキストブロック
/// Collected text blocks.
collected: Arc<Mutex<Vec<String>>>,
}
impl TextBlockCollector {
/// 新しいTextBlockCollectorを作成
/// Create a new TextBlockCollector.
pub fn new() -> Self {
Self {
collected: Arc::new(Mutex::new(Vec::new())),
}
}
/// 収集されたテキストを取得してクリア
/// Take the collected text and clear the collection.
pub fn take_collected(&self) -> Vec<String> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集されたテキストの参照を取得
/// Access the collected text.
pub fn collected(&self) -> Vec<String> {
self.collected.lock().unwrap().clone()
}
/// 収集されたテキストがあるかどうか
/// Whether any text has been collected.
pub fn has_content(&self) -> bool {
!self.collected.lock().unwrap().is_empty()
}
/// 収集をクリア
/// Clear the collection.
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
@@ -71,7 +69,7 @@ impl Handler<TextBlockKind> for TextBlockCollector {
scope.buffer.push_str(text);
}
TextBlockEvent::Stop(_) => {
// ブロック完了時にテキストを確定
// Finalize the text when the block completes.
if !scope.buffer.is_empty() {
let text = std::mem::take(&mut scope.buffer);
self.collected.lock().unwrap().push(text);
@@ -87,38 +85,38 @@ mod tests {
use crate::timeline::Timeline;
use crate::timeline::event::Event;
/// TextBlockCollectorが単一のテキストブロックを正しく収集することを確認
/// Verify that TextBlockCollector correctly collects a single text block.
#[test]
fn test_collect_single_text_block() {
let collector = TextBlockCollector::new();
let mut timeline = Timeline::new();
timeline.on_text_block(collector.clone());
// テキストブロックのイベントシーケンスをディスパッチ
// Dispatch a sequence of text block events.
timeline.dispatch(&Event::text_block_start(0));
timeline.dispatch(&Event::text_delta(0, "Hello, "));
timeline.dispatch(&Event::text_delta(0, "World!"));
timeline.dispatch(&Event::text_block_stop(0, None));
// 収集されたテキストを確認
// Check the collected text.
let texts = collector.take_collected();
assert_eq!(texts.len(), 1);
assert_eq!(texts[0], "Hello, World!");
}
/// TextBlockCollectorが複数のテキストブロックを正しく収集することを確認
/// Verify that TextBlockCollector correctly collects multiple text blocks.
#[test]
fn test_collect_multiple_text_blocks() {
let collector = TextBlockCollector::new();
let mut timeline = Timeline::new();
timeline.on_text_block(collector.clone());
// 1つ目のテキストブロック
// First text block
timeline.dispatch(&Event::text_block_start(0));
timeline.dispatch(&Event::text_delta(0, "First"));
timeline.dispatch(&Event::text_block_stop(0, None));
// 2つ目のテキストブロック
// Second text block
timeline.dispatch(&Event::text_block_start(1));
timeline.dispatch(&Event::text_delta(1, "Second"));
timeline.dispatch(&Event::text_block_stop(1, None));
@@ -20,13 +20,13 @@ impl ThinkingBlockCollector {
Self::default()
}
/// 収集済み item を取り出してクリア
/// Take the collected items and clear the collection.
pub fn take_collected(&self) -> Vec<ReasoningBlockData> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集をクリア
/// Clear the collection.
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
@@ -107,7 +107,7 @@ mod tests {
assert_eq!(items[0].signature.as_deref(), Some("sig1"));
assert_eq!(items[1].text.as_deref(), Some("second"));
// take は drain なので 2 度目は空
// Taking drains the collection, so the second call is empty.
assert!(collector.take_collected().is_empty());
}
+174 -116
View File
@@ -1,7 +1,7 @@
//! Timeline層
//! Timeline layer.
//!
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
//! 通常はEngine経由で使用しますが、直接使用することも可能です。
//! Receives an LLM event stream and dispatches it to registered handlers.
//! Usually used through the Engine, but can also be used directly.
use std::{collections::HashMap, marker::PhantomData};
@@ -12,11 +12,11 @@ use crate::handler::*;
// Helpers
// =============================================================================
/// 1リクエスト内で受信した複数 UsageEvent をマージする。
/// 各フィールドについて新しい値が `Some` ならそれで上書き。
/// プロバイダによっては input/cache 系を最初の event だけに載せ、
/// output_tokens を後続 event で更新するため、最後の値だけを取るのではなく
/// フィールド単位で latest-non-None を取る。
/// Merge multiple UsageEvents received within one request.
/// Overwrite each field when the new value is `Some`.
/// Some providers send input/cache fields only in the first event and update
/// output_tokens in later events, so use the latest non-None value per field
/// rather than taking only the last event.
fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
if new.input_tokens.is_some() {
acc.input_tokens = new.input_tokens;
@@ -39,19 +39,19 @@ fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
// Type-erased Handler
// =============================================================================
/// 型消去された`Handler` trait
/// Type-erased `Handler` trait.
///
/// 各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要です。
/// 通常は直接使用せず、`Timeline::on_text_block()`などのメソッド経由で
/// 自動的にラップされます。
/// Each handler has its own Scope type, so Timeline needs type erasure to store them.
/// Usually wrapped automatically by methods such as `Timeline::on_text_block()`
/// rather than used directly.
pub trait ErasedHandler<K: Kind>: Send + Sync {
/// イベントをディスパッチ
/// Dispatch an event.
fn dispatch(&mut self, event: &K::Event);
/// スコープを開始
/// Start a scope.
fn start_scope(&mut self);
}
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
/// Wrapper that exposes `Handler<K>` as `ErasedHandler<K>`.
pub struct HandlerWrapper<H, K>
where
H: Handler<K>,
@@ -59,7 +59,7 @@ where
{
handler: H,
scope: Option<H::Scope>,
// fn() -> K は常にSend+Syncなので、Kの制約に関係なくSendを満たせる
// fn() -> K is always Send+Sync, so Send does not depend on K's bounds.
_kind: PhantomData<fn() -> K>,
}
@@ -98,7 +98,7 @@ where
// Block Handler Registry
// =============================================================================
/// ブロックハンドラーの型消去trait
/// Type-erased trait for block handlers.
trait ErasedBlockHandler: Send + Sync {
fn dispatch_start(&mut self, start: &BlockStart);
fn dispatch_delta(&mut self, delta: &BlockDelta);
@@ -106,11 +106,11 @@ trait ErasedBlockHandler: Send + Sync {
fn dispatch_abort(&mut self, abort: &BlockAbort);
fn start_scope(&mut self);
fn end_scope(&mut self);
/// スコープがアクティブかどうか
/// Whether a scope is active.
fn has_scope(&self) -> bool;
}
/// TextBlockKind用のラッパー
/// Wrapper for TextBlockKind.
struct TextBlockHandlerWrapper<H>
where
H: Handler<TextBlockKind>,
@@ -167,7 +167,7 @@ where
}
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
// TextBlockはabortを特別扱いしない(スコープ終了のみ)
// TextBlock needs no special abort handling (only scope cleanup).
}
fn start_scope(&mut self) {
@@ -183,7 +183,7 @@ where
}
}
/// ThinkingBlockKind用のラッパー
/// Wrapper for ThinkingBlockKind.
struct ThinkingBlockHandlerWrapper<H>
where
H: Handler<ThinkingBlockKind>,
@@ -250,14 +250,14 @@ where
}
}
/// ToolUseBlockKind用のラッパー
/// Wrapper for ToolUseBlockKind.
struct ToolUseBlockHandlerWrapper<H>
where
H: Handler<ToolUseBlockKind>,
{
handler: H,
scope: Option<H::Scope>,
current_tool: Option<(String, String)>, // (id, name)
scopes: HashMap<usize, H::Scope>,
tools: HashMap<usize, (String, String)>, // index -> (id, name)
}
impl<H> ToolUseBlockHandlerWrapper<H>
@@ -267,8 +267,8 @@ where
fn new(handler: H) -> Self {
Self {
handler,
scope: None,
current_tool: None,
scopes: HashMap::new(),
tools: HashMap::new(),
}
}
}
@@ -279,60 +279,55 @@ where
H::Scope: Send + Sync,
{
fn dispatch_start(&mut self, start: &BlockStart) {
if let Some(scope) = &mut self.scope {
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
self.current_tool = Some((id.clone(), name.clone()));
self.handler.on_event(
scope,
&ToolUseBlockEvent::Start(ToolUseBlockStart {
index: start.index,
id: id.clone(),
name: name.clone(),
}),
);
}
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
let scope = self.scopes.entry(start.index).or_default();
self.tools.insert(start.index, (id.clone(), name.clone()));
self.handler.on_event(
scope,
&ToolUseBlockEvent::Start(ToolUseBlockStart {
index: start.index,
id: id.clone(),
name: name.clone(),
}),
);
}
}
fn dispatch_delta(&mut self, delta: &BlockDelta) {
if let Some(scope) = &mut self.scope {
if let DeltaContent::InputJson(json) = &delta.delta {
self.handler
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
}
if let DeltaContent::InputJson(json) = &delta.delta {
let scope = self.scopes.entry(delta.index).or_default();
self.handler
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
}
}
fn dispatch_stop(&mut self, stop: &BlockStop) {
if let Some(scope) = &mut self.scope {
if let Some((id, name)) = self.current_tool.take() {
self.handler.on_event(
scope,
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
index: stop.index,
id,
name,
}),
);
}
if let (Some(mut scope), Some((id, name))) = (
self.scopes.remove(&stop.index),
self.tools.remove(&stop.index),
) {
self.handler.on_event(
&mut scope,
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
index: stop.index,
id,
name,
}),
);
}
}
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
self.current_tool = None;
fn dispatch_abort(&mut self, abort: &BlockAbort) {
self.scopes.remove(&abort.index);
self.tools.remove(&abort.index);
}
fn start_scope(&mut self) {
self.scope = Some(H::Scope::default());
}
fn start_scope(&mut self) {}
fn end_scope(&mut self) {
self.scope = None;
self.current_tool = None;
}
fn end_scope(&mut self) {}
fn has_scope(&self) -> bool {
self.scope.is_some()
!self.scopes.is_empty()
}
}
@@ -340,10 +335,10 @@ where
// Timeline
// =============================================================================
/// イベントストリームの管理とハンドラへのディスパッチ
/// Manages an event stream and dispatches events to handlers.
///
/// LLMからのイベントを受信し、登録されたハンドラに振り分けます。
/// ブロック系イベントはスコープ管理付きで処理されます。
/// Receives events from the LLM and routes them to registered handlers.
/// Block events are handled with scope management.
///
/// # Examples
///
@@ -364,28 +359,30 @@ where
/// timeline.on_text_block(MyHandler);
/// ```
///
/// # サポートするイベント種別
/// # Supported event kinds
///
/// - **メタ系**: Usage, Ping, Status, Error
/// - **ブロック系**: TextBlock, ThinkingBlock, ToolUseBlock
/// - **Meta**: Usage, Ping, Status, Error
/// - **Block**: TextBlock, ThinkingBlock, ToolUseBlock
pub struct Timeline {
// Meta系ハンドラー
// Meta handlers
usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
// Block系ハンドラー(BlockTypeごとにグループ化)
// Block handlers (grouped by BlockType)
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
// 現在アクティブなブロック
// Currently active blocks. Providers can interleave multiple scopes by index,
// so track all scopes rather than a single current block.
active_blocks: HashMap<(BlockType, usize), ()>,
current_block: Option<BlockType>,
// 1リクエスト内で受信した Usage event の集約バッファ。
// Anthropic は message_start と message_delta、Gemini は各チャンクと、
// 多くのプロバイダが複数 Usage を発行するため、リクエスト境界で
// 1度だけ発火するためにここでマージする。flush_usage() で発火する。
// Aggregation buffer for Usage events received within one request.
// Many providers emit multiple Usage events: Anthropic in message_start and
// message_delta, Gemini in each chunk. Merge them here to dispatch only once
// at the request boundary via flush_usage().
pending_usage: Option<UsageEvent>,
}
@@ -405,6 +402,7 @@ impl Timeline {
text_block_handlers: Vec::new(),
thinking_block_handlers: Vec::new(),
tool_use_block_handlers: Vec::new(),
active_blocks: HashMap::new(),
current_block: None,
pending_usage: None,
}
@@ -414,20 +412,20 @@ impl Timeline {
// Handler Registration
// =========================================================================
/// UsageKind用のHandlerを登録
/// Register a Handler for UsageKind.
pub fn on_usage<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<UsageKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
// Meta系はデフォルトでスコープを開始しておく
// Meta handlers start with a scope by default.
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.usage_handlers.push(Box::new(wrapper));
self
}
/// PingKind用のHandlerを登録
/// Register a Handler for PingKind.
pub fn on_ping<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<PingKind> + Send + Sync + 'static,
@@ -439,7 +437,7 @@ impl Timeline {
self
}
/// StatusKind用のHandlerを登録
/// Register a Handler for StatusKind.
pub fn on_status<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<StatusKind> + Send + Sync + 'static,
@@ -451,7 +449,7 @@ impl Timeline {
self
}
/// ErrorKind用のHandlerを登録
/// Register a Handler for ErrorKind.
pub fn on_error<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ErrorKind> + Send + Sync + 'static,
@@ -463,7 +461,7 @@ impl Timeline {
self
}
/// TextBlockKind用のHandlerを登録
/// Register a Handler for TextBlockKind.
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<TextBlockKind> + Send + Sync + 'static,
@@ -474,7 +472,7 @@ impl Timeline {
self
}
/// ThinkingBlockKind用のHandlerを登録
/// Register a Handler for ThinkingBlockKind.
pub fn on_thinking_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ThinkingBlockKind> + Send + Sync + 'static,
@@ -485,7 +483,7 @@ impl Timeline {
self
}
/// ToolUseBlockKind用のHandlerを登録
/// Register a Handler for ToolUseBlockKind.
pub fn on_tool_use_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ToolUseBlockKind> + Send + Sync + 'static,
@@ -500,10 +498,10 @@ impl Timeline {
// Event Dispatch
// =========================================================================
/// メインのディスパッチエントリポイント
/// Main dispatch entry point.
pub fn dispatch(&mut self, event: &Event) {
match event {
// Meta系: 即時ディスパッチ(登録順)
// Buffer usage until flush; dispatch other handled meta events immediately.
Event::Usage(u) => self.dispatch_usage(u),
Event::Ping(p) => self.dispatch_ping(p),
Event::Status(s) => self.dispatch_status(s),
@@ -511,7 +509,7 @@ impl Timeline {
// Observability-only event: stream trace records it before timeline dispatch.
Event::UnhandledSse(_) => {}
// Block系: スコープ管理しながらディスパッチ
// Block events: dispatch with scope management.
Event::BlockStart(s) => self.handle_block_start(s),
Event::BlockDelta(d) => self.handle_block_delta(d),
Event::BlockStop(s) => self.handle_block_stop(s),
@@ -519,9 +517,9 @@ impl Timeline {
}
}
/// Usage event を即時には dispatch せず、pending_usage にマージする。
/// 1リクエスト内で複数の Usage event が来ても、ハンドラには 1 度だけ
/// 最終値を渡したいため。flush_usage() で発火する。
/// Merge Usage events into pending_usage instead of dispatching immediately.
/// Even if a request emits multiple Usage events, handlers receive the final
/// values only once, when flush_usage() dispatches them.
fn dispatch_usage(&mut self, event: &UsageEvent) {
match &mut self.pending_usage {
Some(acc) => merge_usage(acc, event),
@@ -529,9 +527,9 @@ impl Timeline {
}
}
/// pending_usage を usage_handlers に発火し、バッファをクリアする。
/// 1リクエスト分のストリーム終了時に1回だけ呼ぶ想定。
/// pending_usage が空ならば何もしない。
/// Dispatch pending_usage to usage_handlers and clear the buffer.
/// Intended to be called once at the end of a request's stream.
/// Does nothing if pending_usage is empty.
pub fn flush_usage(&mut self) {
if let Some(event) = self.pending_usage.take() {
for handler in &mut self.usage_handlers {
@@ -559,6 +557,8 @@ impl Timeline {
}
fn handle_block_start(&mut self, start: &BlockStart) {
self.active_blocks
.insert((start.block_type, start.index), ());
self.current_block = Some(start.block_type);
let handlers = self.get_block_handlers_mut(start.block_type);
@@ -571,15 +571,16 @@ impl Timeline {
fn handle_block_delta(&mut self, delta: &BlockDelta) {
let block_type = delta.delta.block_type();
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
// Deltaが来たときにスコープがなければ暗黙的に開始する
if self.current_block.is_none() {
self.current_block = Some(block_type);
}
// Providers such as OpenAI may omit BlockStart, so implicitly start a scope
// for this index when a Delta arrives without one.
self.active_blocks
.entry((block_type, delta.index))
.or_insert(());
self.current_block = Some(block_type);
let handlers = self.get_block_handlers_mut(block_type);
for handler in handlers {
// スコープがなければ暗黙的に開始
// Implicitly start a scope if none exists.
if !handler.has_scope() {
handler.start_scope();
}
@@ -593,7 +594,12 @@ impl Timeline {
handler.dispatch_stop(stop);
handler.end_scope();
}
self.current_block = None;
self.active_blocks.remove(&(stop.block_type, stop.index));
self.current_block = self
.active_blocks
.keys()
.next()
.map(|(block_type, _)| *block_type);
}
fn handle_block_abort(&mut self, abort: &BlockAbort) {
@@ -602,7 +608,12 @@ impl Timeline {
handler.dispatch_abort(abort);
handler.end_scope();
}
self.current_block = None;
self.active_blocks.remove(&(abort.block_type, abort.index));
self.current_block = self
.active_blocks
.keys()
.next()
.map(|(block_type, _)| *block_type);
}
fn get_block_handlers_mut(
@@ -613,28 +624,31 @@ impl Timeline {
BlockType::Text => &mut self.text_block_handlers,
BlockType::Thinking => &mut self.thinking_block_handlers,
BlockType::ToolUse => &mut self.tool_use_block_handlers,
BlockType::ToolResult => &mut self.text_block_handlers, // ToolResultはTextとして扱う
BlockType::ToolResult => &mut self.text_block_handlers, // Treat ToolResult as Text.
}
}
/// 現在アクティブなブロックタイプを取得
/// Get the currently active block type.
pub fn current_block(&self) -> Option<BlockType> {
self.current_block
}
/// 現在アクティブなブロックを中断する
/// Abort all currently active blocks.
///
/// キャンセルやエラー時に呼び出し、進行中のブロックに対して
/// BlockAbortイベントを発火してスコープをクリーンアップする。
/// Call on cancellation or error to emit BlockAbort events for all in-progress
/// blocks by index and clean up their scopes.
pub fn abort_current_block(&mut self) {
if let Some(block_type) = self.current_block {
let active = self.active_blocks.keys().copied().collect::<Vec<_>>();
for (block_type, index) in active {
let abort = crate::timeline::event::BlockAbort {
index: 0, // インデックスは不明なので0
index,
block_type,
reason: "Cancelled".to_string(),
};
self.handle_block_abort(&abort);
}
self.active_blocks.clear();
self.current_block = None;
}
}
@@ -679,9 +693,53 @@ mod tests {
assert!(calls.lock().unwrap().is_empty());
}
#[test]
fn abort_clears_every_indexed_scope_before_indices_are_reused() {
#[derive(Default)]
struct Scope {
started: bool,
}
struct IndexedToolHandler {
reused_cleanly: Arc<Mutex<Vec<bool>>>,
}
impl Handler<ToolUseBlockKind> for IndexedToolHandler {
type Scope = Scope;
fn on_event(&mut self, scope: &mut Scope, event: &ToolUseBlockEvent) {
if matches!(event, ToolUseBlockEvent::Start(_)) {
self.reused_cleanly.lock().unwrap().push(!scope.started);
scope.started = true;
}
}
}
let reused_cleanly = Arc::new(Mutex::new(Vec::new()));
let mut timeline = Timeline::new();
timeline.on_tool_use_block(IndexedToolHandler {
reused_cleanly: reused_cleanly.clone(),
});
timeline.dispatch(&Event::tool_use_start(0, "first", "tool"));
timeline.dispatch(&Event::tool_use_start(1, "second", "tool"));
// Stopping one interleaved block must not hide its still-active sibling.
timeline.dispatch(&Event::tool_use_stop(0));
assert_eq!(timeline.current_block(), Some(BlockType::ToolUse));
timeline.abort_current_block();
assert!(timeline.current_block().is_none());
// A later provider response may reuse the same content-block index.
timeline.dispatch(&Event::tool_use_start(1, "replacement", "tool"));
timeline.dispatch(&Event::tool_use_stop(1));
assert_eq!(
reused_cleanly.lock().unwrap().as_slice(),
[true, true, true]
);
}
#[test]
fn test_meta_event_dispatch() {
// シンプルなテスト用構造体
// Simple test struct
struct TestUsageHandler {
calls: Arc<Mutex<Vec<UsageEvent>>>,
}
@@ -702,10 +760,10 @@ mod tests {
timeline.on_usage(handler);
timeline.dispatch(&Event::usage(100, 50));
// pending_usage に積まれているだけなのでまだ未発火
// Only buffered in pending_usage; not yet dispatched.
assert_eq!(calls.lock().unwrap().len(), 0);
// flush で 1 度だけ発火
// Flush dispatches once.
timeline.flush_usage();
let recorded = calls.lock().unwrap();
assert_eq!(recorded.len(), 1);
@@ -730,7 +788,7 @@ mod tests {
calls: calls.clone(),
});
// Anthropic 風: message_start で input + 暫定 output
// Anthropic-style: input + provisional output in message_start.
timeline.dispatch(&Event::Usage(UsageEvent {
input_tokens: Some(409),
output_tokens: Some(1),
@@ -738,7 +796,7 @@ mod tests {
cache_read_input_tokens: Some(0),
cache_creation_input_tokens: Some(0),
}));
// message_delta で最終 output
// Final output in message_delta.
timeline.dispatch(&Event::Usage(UsageEvent {
input_tokens: Some(409),
output_tokens: Some(71),
@@ -747,7 +805,7 @@ mod tests {
cache_creation_input_tokens: Some(0),
}));
// 未 flush の段階では発火しない
// No dispatch before flushing.
assert_eq!(calls.lock().unwrap().len(), 0);
timeline.flush_usage();
@@ -756,7 +814,7 @@ mod tests {
assert_eq!(recorded[0].input_tokens, Some(409));
assert_eq!(recorded[0].output_tokens, Some(71));
// flush 後にもう一度 flush しても何も起きない
// Flushing again after a flush does nothing.
drop(recorded);
timeline.flush_usage();
assert_eq!(calls.lock().unwrap().len(), 1);
+114 -27
View File
@@ -1,62 +1,89 @@
//! ToolCallCollector - ツール呼び出し収集用ハンドラ
//! ToolCallCollector - a handler for collecting tool calls.
//!
//! TimelineのToolUseBlockHandler として登録され、
//! ストリーム中のToolUseブロックを収集する。
//! Registered as a ToolUseBlockHandler on Timeline to collect streaming ToolUse blocks.
use crate::{
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
llm_client::types::parse_tool_arguments,
tool::ToolCall,
};
use std::sync::{Arc, Mutex};
/// ToolUseブロックから収集したツール呼び出し情報を保持
/// Holds tool call information collected from ToolUse blocks.
///
/// ToolCallCollectorのHandler実装で使用するスコープ型
/// Scope type for ToolCallCollector's Handler implementation.
#[derive(Debug, Default)]
pub struct CollectorState {
/// 現在のツール呼び出し情報 (ブロック進行中)
/// Current tool call information (while the block is in progress).
current_id: Option<String>,
current_name: Option<String>,
/// 蓄積中のJSON入力
/// Zero-based order among tool calls in this assistant response.
call_index: Option<usize>,
/// JSON input being accumulated.
input_json_buffer: String,
}
/// ToolCallCollector - ToolUseブロックハンドラ
/// One provider-completed call together with its stable model order.
#[derive(Debug, Clone)]
pub(crate) struct CollectedToolCall {
pub call_index: usize,
pub call: ToolCall,
}
/// ToolCallCollector - a ToolUse block handler.
///
/// Timelineに登録してToolUseブロックイベントを受信し、
/// 完了したToolCallを収集する。
/// Register with Timeline to receive ToolUse block events and collect completed ToolCalls.
#[derive(Clone)]
pub struct ToolCallCollector {
/// 収集されたToolCall
collected: Arc<Mutex<Vec<ToolCall>>>,
/// Collected ToolCalls.
collected: Arc<Mutex<Vec<CollectedToolCall>>>,
next_call_index: Arc<Mutex<usize>>,
}
impl ToolCallCollector {
/// 新しいToolCallCollectorを作成
/// Create a new ToolCallCollector.
pub fn new() -> Self {
Self {
collected: Arc::new(Mutex::new(Vec::new())),
next_call_index: Arc::new(Mutex::new(0)),
}
}
/// 収集されたToolCallを取得してクリア
/// Take the collected ToolCalls and clear the collection.
pub fn take_collected(&self) -> Vec<ToolCall> {
self.take_collected_with_index()
.into_iter()
.map(|collected| collected.call)
.collect()
}
/// Drain completed calls while preserving their model-returned order.
pub(crate) fn take_collected_with_index(&self) -> Vec<CollectedToolCall> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集されたToolCallの参照を取得
/// Access the collected ToolCalls.
pub fn collected(&self) -> Vec<ToolCall> {
self.collected.lock().unwrap().clone()
self.collected
.lock()
.unwrap()
.iter()
.map(|collected| collected.call.clone())
.collect()
}
/// 収集されたToolCallがあるかどうか
/// Start one response-local tool-call ordering scope.
pub(crate) fn begin_response(&self) {
self.collected.lock().unwrap().clear();
*self.next_call_index.lock().unwrap() = 0;
}
/// Whether any ToolCalls have been collected.
pub fn has_pending_calls(&self) -> bool {
!self.collected.lock().unwrap().is_empty()
}
/// 収集をクリア
/// Clear the collection.
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
@@ -76,20 +103,43 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
ToolUseBlockEvent::Start(start) => {
scope.current_id = Some(start.id.clone());
scope.current_name = Some(start.name.clone());
if scope.call_index.is_none() {
let mut next = self.next_call_index.lock().unwrap();
scope.call_index = Some(*next);
*next += 1;
}
scope.input_json_buffer.clear();
}
ToolUseBlockEvent::InputJsonDelta(delta) => {
scope.input_json_buffer.push_str(delta);
}
ToolUseBlockEvent::Stop(_stop) => {
// ブロック完了時にToolCallを確定
// Finalize the ToolCall when the block completes.
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
{
let input = parse_tool_arguments(&scope.input_json_buffer);
// A Stop event proves the provider finished the block, but
// malformed/non-object arguments are still not executable.
// Preserve an explicit null sentinel so the engine can commit
// a terminal validation error without guessing `{}`.
let input = if scope.input_json_buffer.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str::<serde_json::Value>(&scope.input_json_buffer)
.ok()
.filter(serde_json::Value::is_object)
.unwrap_or(serde_json::Value::Null)
};
let tool_call = ToolCall { id, name, input };
let call_index = scope
.call_index
.take()
.expect("tool-use stop follows a started collector scope");
self.collected.lock().unwrap().push(tool_call);
self.collected.lock().unwrap().push(CollectedToolCall {
call_index,
call: tool_call,
});
}
scope.input_json_buffer.clear();
}
@@ -109,13 +159,13 @@ mod tests {
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// ToolUseブロックのイベントシーケンスをディスパッチ
// Dispatch a sequence of ToolUse block events.
timeline.dispatch(&Event::tool_use_start(0, "tool_123", "get_weather"));
timeline.dispatch(&Event::tool_input_delta(0, r#"{"city":"#));
timeline.dispatch(&Event::tool_input_delta(0, r#""Tokyo"}"#));
timeline.dispatch(&Event::tool_use_stop(0));
// 収集されたToolCallを確認
// Check the collected ToolCalls.
let calls = collector.take_collected();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tool_123");
@@ -125,7 +175,7 @@ mod tests {
#[test]
fn test_collect_empty_buffer_returns_object() {
// 引数なしツール呼び出し: input_json_delta が一度も来ないケース
// A tool call without arguments: no input_json_delta arrives.
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
@@ -144,18 +194,55 @@ mod tests {
);
}
#[test]
fn test_collect_interleaved_tool_call_indexes_and_ignore_duplicate_stop() {
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
timeline.dispatch(&Event::tool_use_start(0, "call_0", "tool_0"));
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":"#));
timeline.dispatch(&Event::tool_use_start(1, "call_1", "tool_1"));
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
timeline.dispatch(&Event::tool_input_delta(0, r#"1}"#));
timeline.dispatch(&Event::tool_use_stop(1));
timeline.dispatch(&Event::tool_use_stop(1));
timeline.dispatch(&Event::tool_use_stop(0));
let calls = collector.take_collected();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].input, serde_json::json!({"b": 2}));
assert_eq!(calls[1].id, "call_0");
assert_eq!(calls[1].input, serde_json::json!({"a": 1}));
}
#[test]
fn malformed_completed_arguments_are_not_normalized_to_executable_object() {
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
timeline.dispatch(&Event::tool_use_start(0, "bad", "mutate"));
timeline.dispatch(&Event::tool_input_delta(0, r#"{"unterminated":"#));
timeline.dispatch(&Event::tool_use_stop(0));
let calls = collector.take_collected();
assert_eq!(calls.len(), 1);
assert!(calls[0].input.is_null());
}
#[test]
fn test_collect_multiple_tool_calls() {
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// 1つ目のToolCall
// First ToolCall
timeline.dispatch(&Event::tool_use_start(0, "call_1", "tool_a"));
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":1}"#));
timeline.dispatch(&Event::tool_use_stop(0));
// 2つ目のToolCall
// Second ToolCall
timeline.dispatch(&Event::tool_use_start(1, "call_2", "tool_b"));
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
timeline.dispatch(&Event::tool_use_stop(1));
+63 -32
View File
@@ -1,41 +1,40 @@
//! Usage 履歴ベースのトークン会計(汎用部分)。
//! Token estimates based on provider measurements and history growth.
//!
//! `UsageRecord` の列(プロバイダ実測値)と現在の history から、
//! 任意の history index 時点のプロンプト全長トークン数を pure に計算する。
//! Uses the full input token count from [`UsageRecord`], interpolating between
//! measured boundaries by byte count and extrapolating beyond the latest boundary
//! from text bytes. Input totals include cache reads rather than inventing a
//! system/history breakdown.
//!
//! # 方針
//!
//! - ローカルトークナイザは持たない。実測値があればそれを採用し、
//! measurement 間はバイト数で按分、最新 measurement より先は byte/4
//! fallback で外挿する
//! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。
//! 課金判断には使えないが、compact / prune / memory extract trigger 等の
//! 閾値判定には十分な精度
//! - `records` は `history_len` 昇順を仮定する(呼び出し側がそのように積む)
//! [`EstimateSource`] distinguishes measurements from estimates. Estimates inform
//! threshold decisions; they neither determine billing nor guarantee that a request
//! fits the provider's context limit. Attachment base64 is a storage representation,
//! not text input, and is excluded from byte counts. Provider measurements for
//! requests that actually sent images remain valid. The caller supplies records in
//! ascending `history_len` order.
use crate::{Item, UsageRecord};
/// 推定の出どころ。
/// Source of a token estimate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EstimateSource {
/// measurement の境界にちょうど一致(実測値そのもの)
/// Exact match with a measurement boundary; uses the measured value.
Measured,
/// 連続する 2 つの measurement の間をバイト按分で計算
/// Interpolated between consecutive measurements in proportion to byte count.
Interpolated,
/// 最後の measurement より新しい区間を byte/4 fallback で外挿
/// Extrapolated beyond the latest measurement using the bytes/4 fallback.
Extrapolated,
/// measurement が 1 件も無く、バイト数のみのフォールバック
/// Byte-count fallback with no measurements available.
NoData,
}
/// トークン数の推定値。
/// Estimated token count.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TokenEstimate {
pub tokens: u64,
pub source: EstimateSource,
}
/// `items[..i]` までの累積バイト数(`prefix[i]`)を返す。長さは `items.len()+1`。
/// Returns cumulative bytes for `items[..i]` in `prefix[i]`, with length `items.len() + 1`.
pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
let mut prefix = Vec::with_capacity(items.len() + 1);
let mut acc: u64 = 0;
@@ -47,20 +46,27 @@ pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
prefix
}
/// 1 Item の大きさ。JSON シリアライズ長を使う粗い近似。
/// トークン数との絶対変換ではなく区間の按分にしか使わないので、
/// プロバイダごとの overhead は比率でキャンセルされる。
/// Approximate text size of one Item, using its JSON serialization length.
///
/// Binary attachments are deliberately projected out. Their durable base64 encoding is a
/// storage representation, not provider text, so charging it at the text `bytes / 4` fallback
/// rate produces meaningless estimates. Exact provider usage measurements remain authoritative
/// when an attachment was actually sent.
pub fn item_bytes(item: &Item) -> u64 {
serde_json::to_string(item)
let mut projected = item.clone();
if let Item::ToolResult { attachments, .. } = &mut projected {
attachments.clear();
}
serde_json::to_string(&projected)
.map(|s| s.len() as u64)
.unwrap_or(0)
}
/// `history[..index]` までのトークン数を推定する。
/// Estimates the token count of `history[..index]`.
///
/// `prefix` は [`prefix_bytes`] で得た `history.len() + 1` 長の累積バイト列。
/// 呼び出し側が 1 度だけ計算して使い回すことで、線形探索や複数回の推定が
/// O(n) シリアライズで済む(内部で毎回再計算すると O(n²) になる)。
/// `prefix` contains `history.len() + 1` cumulative byte counts from [`prefix_bytes`].
/// Computing it once and reusing it keeps serialization for a scan or repeated
/// estimates O(n), rather than O(n²) from recomputing it for every estimate.
pub fn tokens_at(
history: &[Item],
records: &[UsageRecord],
@@ -84,7 +90,7 @@ pub fn tokens_at(
};
}
// exact match(rev 走査で一番新しい record を採用)
// Exact match: reverse iteration selects the most recent record.
if let Some(r) = records.iter().rev().find(|r| r.history_len == index) {
return TokenEstimate {
tokens: r.input_total_tokens,
@@ -146,15 +152,15 @@ pub fn tokens_at(
}
}
/// 現在の history 全体の推定トークン数。
/// Estimated token count for the entire current history.
pub fn total_tokens(history: &[Item], records: &[UsageRecord]) -> TokenEstimate {
let prefix = prefix_bytes(history);
tokens_at(history, records, history.len(), &prefix)
}
/// 任意の history index 時点でのプロンプト全長推定。
/// `history_len == 0` で 0 を返す。delta 計算 (extract trigger 等) で
/// `total_tokens_at(now) - total_tokens_at(pointer)` の形で使う。
/// Estimates the full prompt token count at an arbitrary history boundary.
/// Returns zero for `history_len == 0`. Differences such as
/// `total_tokens_at(now) - total_tokens_at(pointer)` can drive extraction triggers.
pub fn total_tokens_at(
history: &[Item],
records: &[UsageRecord],
@@ -265,6 +271,31 @@ mod tests {
assert_eq!(est.tokens, 30_000 + delta_bytes / 4);
}
#[test]
fn fallback_does_not_count_durable_image_bytes_as_text_tokens() {
let small = Item::tool_result_item_with_attachments(
"call",
"Attached image.png",
None,
false,
vec![crate::tool::Attachment::Image(
crate::tool::ImageAttachment::new("image/png", vec![1_u8; 8]),
)],
);
let large = Item::tool_result_item_with_attachments(
"call",
"Attached image.png",
None,
false,
vec![crate::tool::Attachment::Image(
crate::tool::ImageAttachment::new("image/png", vec![1_u8; 1_000_000]),
)],
);
assert_eq!(item_bytes(&small), item_bytes(&large));
assert_eq!(total_tokens(&[small], &[]), total_tokens(&[large], &[]));
}
#[test]
fn total_zero_history_is_zero() {
let est = total_tokens(&[], &[]);
+230 -2
View File
@@ -3,7 +3,14 @@
//! Traits for defining tools callable by LLM.
//! Usually auto-implemented using the `#[tool]` macro.
use std::{collections::HashMap, fmt, sync::Arc};
use std::{
collections::HashMap,
fmt,
future::Future,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -23,6 +30,15 @@ pub enum ToolError {
/// Internal error
#[error("Internal error: {0}")]
Internal(String),
/// Machine-readable domain conflict that a tool adapter may project as structured output.
#[error("{code}: {message}")]
StructuredConflict { code: String, message: String },
/// Cooperative cancellation completed with bounded terminal output.
#[error("Tool execution cancelled")]
Cancelled(ToolOutput),
/// Execution was interrupted with a confirmed bounded terminal output.
#[error("Tool execution interrupted")]
Interrupted(ToolOutput),
}
// =============================================================================
@@ -158,6 +174,28 @@ pub enum Attachment {
Image(ImageAttachment),
}
/// Terminal disposition of one started tool call.
///
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
/// execution stopped without confirmation, so neither completion nor side
/// effects may be inferred.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolResultDisposition {
#[default]
Success,
Error,
Interrupted,
Cancelled,
OutcomeUnknown,
}
impl ToolResultDisposition {
pub const fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
}
/// Tool execution result.
///
/// Every output has a mandatory `summary` (1-2 lines) that persists in
@@ -322,6 +360,12 @@ impl ToolExecutionContext {
}
}
/// Identifies one live execution attempt without making the batch id a durable
/// replay or idempotency authority.
pub fn execution_id(&self) -> String {
format!("{}:{}", self.batch_id, self.call_id)
}
/// Context for direct, non-engine calls in unit tests and low-level callers.
pub fn direct() -> Self {
Self::new("direct", "direct", 0)
@@ -334,6 +378,142 @@ impl Default for ToolExecutionContext {
}
}
/// The provider-confirmed terminal result of one started tool execution.
///
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
/// or failed before the provider could confirm its terminal result.
#[derive(Debug)]
pub enum ToolExecutionTerminal {
Confirmed(Result<ToolOutput, ToolError>),
OutcomeUnknown,
}
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
/// future does not drop the provider execution: the spawned execution remains
/// owned by its handle until it completes or is explicitly force-closed.
pub struct ToolExecutionTerminalFuture {
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
}
impl Future for ToolExecutionTerminalFuture {
type Output = ToolExecutionTerminal;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.task).poll(cx) {
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
Poll::Pending => Poll::Pending,
}
}
}
/// Live ownership and control for one started tool execution.
///
/// Execution, cancellation, and terminal confirmation remain provider-owned:
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
/// `Tool::cancel_execution`, and treats execution-future completion as the
/// provider's terminal confirmation. Agen may force-close only after its caller's
/// deadline expires, at which point the outcome is necessarily unknown.
#[derive(Clone)]
pub struct ToolExecutionHandle {
inner: Arc<ToolExecutionHandleInner>,
}
struct ToolExecutionHandleInner {
tool: Arc<dyn Tool>,
context: ToolExecutionContext,
abort: tokio::task::AbortHandle,
}
impl Drop for ToolExecutionHandleInner {
fn drop(&mut self) {
// Losing the final live owner is an explicit forced close, never a
// best-effort detached provider future.
self.abort.abort();
}
}
impl fmt::Debug for ToolExecutionHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolExecutionHandle")
.field("call_id", &self.inner.context.call_id)
.field("batch_id", &self.inner.context.batch_id)
.finish_non_exhaustive()
}
}
impl ToolExecutionHandle {
pub fn start(
tool: Arc<dyn Tool>,
input_json: String,
context: ToolExecutionContext,
) -> (Self, ToolExecutionTerminalFuture) {
let execution_tool = Arc::clone(&tool);
let execution_context = context.clone();
let task =
tokio::spawn(
async move { execution_tool.execute(&input_json, execution_context).await },
);
let abort = task.abort_handle();
(
Self {
inner: Arc::new(ToolExecutionHandleInner {
tool,
context,
abort,
}),
},
ToolExecutionTerminalFuture { task },
)
}
pub fn context(&self) -> &ToolExecutionContext {
&self.inner.context
}
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
match tokio::time::timeout_at(
deadline,
self.inner.tool.cancel_execution(&self.inner.context),
)
.await
{
Ok(result) => result,
Err(_) => Err(ToolError::Internal(format!(
"tool cancellation request exceeded its deadline for call {}",
self.inner.context.call_id
))),
}
}
pub fn force_close(&self) {
self.inner.abort.abort();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolExecutionPolicy {
/// Time a pause waits for already-started providers to reach a natural safe
/// boundary before escalating to explicit cooperative cancellation.
pub pause_safe_boundary_timeout: std::time::Duration,
/// Maximum time allowed for a provider to accept one cooperative
/// cancellation request.
pub cancellation_request_timeout: std::time::Duration,
/// Maximum time allowed for all providers to confirm terminal results after
/// cancellation has been requested.
pub terminal_confirmation_timeout: std::time::Duration,
}
impl Default for ToolExecutionPolicy {
fn default() -> Self {
Self {
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
cancellation_request_timeout: std::time::Duration::from_millis(100),
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
}
}
}
// =============================================================================
// Tool trait
// =============================================================================
@@ -402,6 +582,26 @@ pub trait Tool: Send + Sync {
input_json: &str,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError>;
/// Request cooperative cancellation for one started call.
///
/// Implementations that own cancellable provider operations should signal
/// every live execution identified by `call_id`, then let `execute` return
/// the confirmed bounded terminal output. Direct callers may use this
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
/// can bind cancellation to one exact live attempt.
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
Ok(())
}
/// Request cooperative cancellation for one exact started execution.
///
/// The default preserves existing tools by delegating to `cancel(call_id)`.
/// Providers with their own execution registry should override this method
/// and key cancellation by [`ToolExecutionContext::execution_id`].
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
self.cancel(&ctx.call_id).await
}
}
// =============================================================================
@@ -429,6 +629,9 @@ pub struct ToolCall {
pub struct ToolResult {
/// Corresponding tool call ID
pub tool_use_id: String,
/// Typed terminal state.
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
pub disposition: ToolResultDisposition,
/// Short summary (always kept in history)
pub summary: String,
/// Detailed output (prunable)
@@ -445,11 +648,20 @@ pub struct ToolResult {
impl ToolResult {
/// Create a success result from a [`ToolOutput`].
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
}
pub fn from_output_with_disposition(
tool_use_id: impl Into<String>,
output: ToolOutput,
disposition: ToolResultDisposition,
) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition,
summary: output.summary,
content: output.content,
is_error: false,
is_error: !disposition.is_success(),
attachments: output.attachments,
}
}
@@ -458,12 +670,28 @@ impl ToolResult {
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition: ToolResultDisposition::Error,
summary: message.into(),
content: None,
is_error: true,
attachments: Vec::new(),
}
}
/// Close an execution whose completion and side effects cannot be confirmed.
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition: ToolResultDisposition::OutcomeUnknown,
summary: "Tool execution outcome unknown".to_string(),
content: Some(
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
.to_string(),
),
is_error: true,
attachments: Vec::new(),
}
}
}
#[cfg(test)]
+10 -11
View File
@@ -1,21 +1,20 @@
//! Per-LLM-request Usage measurement snapshot.
//! Per-LLM-request usage measurement snapshot.
//!
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
//! 受けて呼び出し側が組み立て、永続化層に流したり、token accounting
//! (`token_counter`) で履歴として参照したりする。
//! Records measured token usage for the history prefix included in one request.
//! The caller builds it from provider `UsageEvent`s and passes it to persistence
//! or to token accounting (`token_counter`).
/// LLM リクエスト送信時点での占有量スナップショット。
/// Usage snapshot for the history prefix sent with an LLM request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecord {
/// 送信時の history.len()
/// `history.len()` when the request was sent.
pub history_len: usize,
/// history[..history_len] の占有量(プロンプト全長、実測)
/// Measured total prompt tokens for `history[..history_len]`.
pub input_total_tokens: u64,
/// 上記のうちキャッシュから読み出された分
/// Portion of the input tokens read from cache.
pub cache_read_tokens: u64,
/// 上記のうちこのリクエストでキャッシュに書かれた分
/// Portion of the input tokens written to cache by this request.
pub cache_write_tokens: u64,
/// このリクエストで生成された出力トークン数
/// Output tokens generated by this request.
pub output_tokens: u64,
}
+211
View File
@@ -0,0 +1,211 @@
mod common;
use agen::interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use async_trait::async_trait;
use common::MockLlmClient;
use std::sync::{Arc, Mutex};
fn completed_text_events(text: &str) -> Vec<Event> {
vec![
Event::text_block_start(0),
Event::text_delta(0, text),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
#[tokio::test]
async fn run_preserves_item_annotations_without_projecting_them() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = match item {
Item::Message { role, .. } => match role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
},
Item::AssistantResponseBoundary { .. } => "assistant_response_boundary",
Item::ToolCall { .. } => "tool_call",
Item::ToolResult { .. } => "tool_result",
Item::Reasoning { .. } => "reasoning",
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
assert_eq!(history.len(), 2);
assert_eq!(history.entries()[0].annotation, "1:user");
assert_eq!(history.entries()[1].annotation, "2:assistant");
assert_eq!(history.items_cloned().len(), 2);
}
#[derive(Clone)]
struct AnnotationObservingInterceptor {
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
}
impl AnnotationObservingInterceptor {
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
self.observed.lock().unwrap().push((
invocation.clone(),
history
.iter()
.map(|entry| entry.annotation.clone())
.collect(),
));
}
}
#[async_trait]
impl Interceptor<String> for AnnotationObservingInterceptor {
async fn on_prompt_submit(
&self,
context: PromptSubmitContext<'_, String>,
) -> InterceptorResult<PromptAction> {
self.record(&context.invocation, context.history);
Ok(PromptAction::Continue)
}
async fn pending_history_appends(
&self,
context: PendingHistoryAppendsContext<'_, String>,
) -> InterceptorResult<Vec<Item>> {
self.record(&context.invocation, context.history);
Ok(Vec::new())
}
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, String>,
) -> InterceptorResult<PreRequestAction> {
self.record(&context.invocation, context.history);
Ok(PreRequestAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, String>,
) -> InterceptorResult<TurnEndAction> {
assert_eq!(context.assistant_entries.len(), 1);
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
self.record(&context.invocation, context.history);
Ok(TurnEndAction::Finish)
}
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
self.record(&context.invocation, context.history);
Ok(())
}
}
#[tokio::test]
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let observed = Arc::new(Mutex::new(Vec::new()));
engine.set_interceptor(AnnotationObservingInterceptor {
observed: observed.clone(),
});
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = if item.is_assistant_message() {
"assistant"
} else {
"user"
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
let observed = observed.lock().unwrap();
let phases: Vec<_> = observed
.iter()
.map(|(invocation, _)| invocation.phase)
.collect();
assert_eq!(
phases,
[
InterceptorPhase::PromptSubmit,
InterceptorPhase::PendingHistoryAppends,
InterceptorPhase::PreLlmRequest,
InterceptorPhase::AssistantTurnEnd,
InterceptorPhase::RunExit,
]
);
assert!(
observed
.iter()
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
);
assert_eq!(
observed
.iter()
.map(|(invocation, _)| invocation.counters.invocation.get())
.collect::<Vec<_>>(),
[0, 1, 2, 3, 4]
);
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[1].1, ["1:user"]);
assert_eq!(observed[2].1, ["1:user"]);
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
}
#[test]
fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
let mut history = History::<usize>::new();
let mut fail = |_item: &Item| Err("commit failed".to_string());
let err = engine
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
.unwrap_err();
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
assert!(history.is_empty());
}
#[test]
fn replacement_keeps_items_and_annotations_together() {
let mut history = History::from_entries(vec![
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
]);
history.truncate(1);
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
assert_eq!(history.entries()[0].annotation, "old-ann");
let previous = history.replace_entries(vec![HistoryEntry::new(
Item::user_message("restored"),
"restored-ann".to_string(),
)]);
assert_eq!(previous.len(), 1);
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
assert_eq!(history.entries()[0].annotation, "restored-ann");
}
+203 -14
View File
@@ -16,6 +16,182 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use common::MockLlmClient;
/// A per-run tracing sink: no global subscriber or output-body logging.
#[derive(Clone)]
struct DiagnosticLog(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for DiagnosticLog {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn output_limits_keep_partial_results_without_warning_callbacks() {
use agen::tool::{Attachment, ImageAttachment, ToolOutputLimits, ToolResult};
use agen::{History, Item, ToolResultDisposition};
use tracing::instrument::WithSubscriber;
let limit = 256;
let cases = [
("below", "small result".to_string(), limit),
("exact", "x".repeat(limit), limit),
("Grep", "matched line\n".repeat(200), limit),
("Read", "あいうえお".repeat(200), 120),
];
let mut events = Vec::new();
for (index, name) in cases.iter().map(|c| c.0).chain(["erroring"]).enumerate() {
events.extend([
Event::tool_use_start(index, name, name),
Event::tool_input_delta(index, "{}"),
Event::tool_use_stop(index),
]);
}
events.push(Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
}));
let client = MockLlmClient::with_responses(vec![
events,
vec![Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
})],
]);
let probe = client.clone();
let mut engine = Engine::new(client);
engine.set_tool_call_dispatch_mode(agen::ToolCallDispatchMode::AfterResponse);
engine.set_tool_output_limits(Some(ToolOutputLimits {
default_max_bytes: limit,
per_tool: [("Read".to_string(), 120)].into(),
}));
let attachments = vec![Attachment::Image(ImageAttachment::new(
"image/png",
b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec(),
))];
for (name, content, _) in &cases {
engine.register_tool(fixed_tool(
name,
ToolOutput {
summary: format!("result from {name}"),
content: Some(content.clone()),
attachments: attachments.clone(),
},
));
}
engine.register_tool(erroring_tool("erroring", "execution failed"));
let warnings = Arc::new(Mutex::new(Vec::new()));
let sink = warnings.clone();
engine.on_warning(move |warning| sink.lock().unwrap().push(warning.to_owned()));
let published = Arc::new(Mutex::new(Vec::<ToolResult>::new()));
let sink = published.clone();
engine.on_tool_result(move |result| sink.lock().unwrap().push(result.clone()));
let committed = Arc::new(Mutex::new(Vec::new()));
let sink = committed.clone();
engine.on_history_append(move |item| {
if matches!(item, Item::ToolResult { .. }) {
sink.lock().unwrap().push(item.clone());
}
Ok(())
});
let log = DiagnosticLog(Arc::new(Mutex::new(Vec::new())));
let writer = log.clone();
let subscriber = tracing_subscriber::fmt()
.without_time()
.with_ansi(false)
.with_max_level(tracing::Level::WARN)
.with_writer(move || writer.clone())
.finish();
let mut history = History::new();
let run = engine
.run(&mut history, "run tools")
.with_subscriber(subscriber)
.await;
assert!(matches!(run.result, agen::EngineRunExit::Finished));
assert!(warnings.lock().unwrap().is_empty());
let requests = probe.requests();
assert_eq!(requests.len(), 2);
let saved: Vec<_> = history
.iter()
.filter_map(|entry| matches!(entry.item, Item::ToolResult { .. }).then_some(&entry.item))
.collect();
assert_eq!(saved.len(), cases.len() + 1);
let committed = committed.lock().unwrap();
let published = published.lock().unwrap();
let logs = String::from_utf8(log.0.lock().unwrap().clone()).unwrap();
for item in saved {
let Item::ToolResult {
call_id,
summary,
content,
disposition,
is_error,
attachments: saved_attachments,
..
} = item
else {
unreachable!()
};
assert!(committed.iter().any(|candidate| candidate == item));
assert!(requests[1].items.iter().any(|candidate| candidate == item));
let result = published
.iter()
.find(|result| result.tool_use_id == *call_id)
.unwrap();
assert_eq!(&result.summary, summary);
assert_eq!(&result.content, content);
assert_eq!(&result.disposition, disposition);
assert_eq!(&result.is_error, is_error);
assert_eq!(&result.attachments, saved_attachments);
if call_id == "erroring" {
assert_eq!(*disposition, ToolResultDisposition::Error);
assert!(*is_error);
assert!(content.is_none());
continue;
}
assert_eq!(*disposition, ToolResultDisposition::Success);
assert!(!*is_error);
assert_eq!(*summary, format!("result from {call_id}"));
assert_eq!(saved_attachments, &attachments);
let (_, original, cap) = cases.iter().find(|c| c.0 == call_id).unwrap();
let content = content.as_ref().unwrap();
assert!(content.len() <= *cap);
if original.len() <= *cap {
assert_eq!(content, original);
} else {
let (body, marker) = content.split_once("\n\n[truncated: ").unwrap();
assert!(original.starts_with(body));
assert_eq!(
marker,
format!(
"{} bytes dropped, refine your query]",
original.len() - body.len()
)
);
let diagnostic = logs
.lines()
.find(|line| line.contains(&format!("tool={call_id}")))
.unwrap();
assert!(diagnostic.contains(&format!("before_bytes={}", original.len())));
assert!(diagnostic.contains(&format!("after_bytes={}", content.len())));
assert!(diagnostic.contains(&format!("limit_bytes={cap}")));
assert!(!diagnostic.contains(body));
}
}
assert_eq!(
logs.lines()
.filter(|line| line.contains("Tool output exceeded byte limit"))
.count(),
2
);
}
#[derive(Clone)]
struct FailOnceClient {
calls: Arc<AtomicUsize>,
@@ -58,6 +234,7 @@ async fn test_callback_llm_retry_event() {
max_attempts: 2,
total_timeout: Duration::from_secs(1),
});
let mut history = agen::History::new();
let notices = Arc::new(Mutex::new(Vec::new()));
let sink = notices.clone();
@@ -65,8 +242,11 @@ async fn test_callback_llm_retry_event() {
sink.lock().unwrap().push((llm_call, notice.clone()));
});
let result = engine.run("retry once").await;
assert!(result.is_ok(), "engine should succeed after one retry");
let result = engine.run(&mut history, "retry once").await;
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"engine should succeed after one retry"
);
let notices = notices.lock().unwrap();
assert_eq!(notices.len(), 1);
@@ -91,6 +271,7 @@ async fn test_callback_text_block_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let text_deltas = Arc::new(Mutex::new(Vec::new()));
let text_completes = Arc::new(Mutex::new(Vec::new()));
@@ -108,9 +289,12 @@ async fn test_callback_text_block_events() {
});
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Greet me").await;
assert!(result.is_ok(), "Engine should complete");
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run(&mut history, "Greet me").await;
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
let deltas = text_deltas.lock().unwrap();
assert_eq!(deltas.len(), 2);
@@ -137,6 +321,7 @@ async fn test_callback_tool_call_complete() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let tool_completes = Arc::new(Mutex::new(Vec::new()));
@@ -154,8 +339,8 @@ async fn test_callback_tool_call_complete() {
});
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let _ = engine.run("Weather please").await;
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run(&mut history, "Weather please").await;
let starts = tool_starts.lock().unwrap();
assert_eq!(starts.len(), 1);
@@ -183,6 +368,7 @@ async fn test_callback_turn_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let turn_starts = Arc::new(Mutex::new(Vec::new()));
let turn_ends = Arc::new(Mutex::new(Vec::new()));
@@ -197,9 +383,9 @@ async fn test_callback_turn_events() {
ends.lock().unwrap().push(turn);
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Do something").await;
assert!(result.is_ok());
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run(&mut history, "Do something").await;
assert!(matches!(result.result, agen::EngineRunExit::Finished));
let starts = turn_starts.lock().unwrap();
let ends = turn_ends.lock().unwrap();
@@ -254,6 +440,7 @@ async fn test_callback_tool_result_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
engine.register_tool(fixed_tool(
"fixed",
@@ -276,7 +463,7 @@ async fn test_callback_tool_result_events() {
));
});
let _ = engine.run("call it").await;
let _ = engine.run(&mut history, "call it").await;
let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1);
@@ -330,6 +517,7 @@ async fn test_callback_tool_result_error_path() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
engine.register_tool(erroring_tool("erroring", "boom"));
@@ -345,7 +533,7 @@ async fn test_callback_tool_result_error_path() {
));
});
let _ = engine.run("fail it").await;
let _ = engine.run(&mut history, "fail it").await;
let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1);
@@ -374,6 +562,7 @@ async fn test_callback_usage_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let usage_events = Arc::new(Mutex::new(Vec::new()));
@@ -382,8 +571,8 @@ async fn test_callback_usage_events() {
usages.lock().unwrap().push(event.clone());
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let _ = engine.run("Hello").await;
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run(&mut history, "Hello").await;
let usages = usage_events.lock().unwrap();
assert_eq!(usages.len(), 1);
+20 -2
View File
@@ -7,7 +7,7 @@ use std::pin::Pin;
use std::sync::{Arc, Mutex};
use agen::llm_client::event::{BlockType, DeltaContent, Event};
use agen::llm_client::{ClientError, LlmClient, Request};
use agen::llm_client::{ClientError, LlmClient, Request, ToolCallCompletionSupport};
use agen::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
use async_trait::async_trait;
use futures::Stream;
@@ -19,6 +19,8 @@ use std::sync::atomic::{AtomicUsize, Ordering};
pub struct MockLlmClient {
responses: Arc<Vec<Vec<Event>>>,
call_count: Arc<AtomicUsize>,
requests: Arc<Mutex<Vec<Request>>>,
completion_support: ToolCallCompletionSupport,
}
impl MockLlmClient {
@@ -30,6 +32,8 @@ impl MockLlmClient {
Self {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
requests: Arc::new(Mutex::new(Vec::new())),
completion_support: ToolCallCompletionSupport::ResponseComplete,
}
}
@@ -41,6 +45,15 @@ impl MockLlmClient {
pub fn event_count(&self) -> usize {
self.responses.iter().map(|v| v.len()).sum()
}
pub fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
pub fn with_completion_support(mut self, support: ToolCallCompletionSupport) -> Self {
self.completion_support = support;
self
}
}
#[async_trait]
@@ -49,10 +62,15 @@ impl LlmClient for MockLlmClient {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
self.completion_support
}
async fn stream(
&self,
_request: Request,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
self.requests.lock().unwrap().push(request);
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count >= self.responses.len() {
return Err(ClientError::Api {
+16 -5
View File
@@ -134,11 +134,15 @@ async fn test_engine_simple_text_response() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let engine = Engine::new(client);
let mut history = agen::History::new();
// Send a simple message (Mutable::run consumes self, returns tuple)
let result = engine.run("Hello").await;
let result = engine.run(&mut history, "Hello").await;
assert!(result.is_ok(), "Engine should complete successfully");
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
}
/// Verify that Engine can correctly process responses containing tool calls
@@ -156,6 +160,7 @@ async fn test_engine_tool_call() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let mut engine = Engine::new(client);
let mut history = agen::History::new();
// Register tool
let weather_tool = MockWeatherTool::new();
@@ -163,7 +168,9 @@ async fn test_engine_tool_call() {
engine.register_tool(weather_tool.definition());
// Send message (Mutable::run consumes self, returns tuple)
let _result = engine.run("What's the weather in Tokyo?").await;
let _result = engine
.run(&mut history, "What's the weather in Tokyo?")
.await;
// Verify tool was called
// Note: max_turns=1 so no request is sent after tool result
@@ -195,11 +202,15 @@ async fn test_engine_with_programmatic_events() {
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let mut history = agen::History::new();
// Mutable::run consumes self, returns tuple
let result = engine.run("Greet me").await;
let result = engine.run(&mut history, "Greet me").await;
assert!(result.is_ok(), "Engine should complete successfully");
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
}
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
File diff suppressed because it is too large Load Diff
+738
View File
@@ -0,0 +1,738 @@
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition};
use async_trait::async_trait;
use futures::StreamExt;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
fn rejection() -> ClientError {
ClientError::Api { status: Some(400), code: Some("invalid_value".into()), message: "The image you provided requires 52150 patches after processing, exceeding the limit of 30000. Please resize the image and try again.".into(), retry_after: None }
}
fn image_result(id: &str, width: u32, height: u32, padding: usize) -> Item {
// PNG header: dimension inspection never decodes/allocates the pixel plane.
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
bytes.extend(width.to_be_bytes());
bytes.extend(height.to_be_bytes());
bytes.resize(40 + padding, 0);
Item::ToolResult {
id: None,
call_id: id.into(),
summary: "Attached image".into(),
content: None,
disposition: ToolResultDisposition::Success,
is_error: false,
attachments: vec![Attachment::Image(ImageAttachment::new("image/png", bytes))],
}
}
fn history() -> History {
History::from_items(vec![
Item::user_message("inspect"),
Item::tool_call("small", "ViewImage", "{}"),
Item::tool_call("large", "ViewImage", "{}"),
image_result("small", 10, 10, 2000),
image_result("large", 1600, 33349, 0),
])
}
fn images(request: &Request) -> Vec<String> {
request
.items
.iter()
.filter_map(|item| match item {
Item::ToolResult {
call_id,
attachments,
..
} if !attachments.is_empty() => Some(call_id.clone()),
_ => None,
})
.collect()
}
#[derive(Clone, Copy)]
enum RejectionPath {
Http,
SseError,
SseNestedError,
ResponseFailed,
}
const STREAM_PATHS: [RejectionPath; 3] = [
RejectionPath::SseError,
RejectionPath::SseNestedError,
RejectionPath::ResponseFailed,
];
#[derive(Clone)]
struct Client {
requests: Arc<Mutex<Vec<Request>>>,
failures: usize,
other_error: bool,
path: RejectionPath,
prefix: Vec<(&'static str, Value)>,
wait_before_error: Option<Arc<tokio::sync::Notify>>,
rejection_data: Option<&'static str>,
}
impl Client {
fn new(failures: usize) -> Self {
Self {
requests: Arc::default(),
failures,
other_error: false,
path: RejectionPath::Http,
prefix: Vec::new(),
wait_before_error: None,
rejection_data: None,
}
}
fn streamed(failures: usize, path: RejectionPath) -> Self {
Self {
path,
..Self::new(failures)
}
}
fn reject(&self, error: ClientError) -> Result<ResponseStream, ClientError> {
if matches!(self.path, RejectionPath::Http) {
return Err(error);
}
let ClientError::Api { code, message, .. } = error else {
unreachable!()
};
let error = json!({"type":"invalid_request_error", "code":code, "message":message, "param":"input"});
let (kind, data) = match self.path {
RejectionPath::SseError => (
"error",
json!({"type":"error", "code":code, "message":message, "param":"input", "error_type":"invalid_request_error"}),
),
RejectionPath::SseNestedError => ("error", json!({"type":"error", "error":error})),
RejectionPath::ResponseFailed => (
"response.failed",
json!({"type":"response.failed", "response":{"id":"rejected", "error":error}}),
),
RejectionPath::Http => unreachable!(),
};
let scheme = OpenAIResponsesScheme::new();
let mut state = Default::default();
let mut events = scheme.parse_sse("response.created", r#"{"response":{}}"#, &mut state)?;
for (kind, data) in &self.prefix {
events.extend(scheme.parse_sse(kind, &data.to_string(), &mut state)?);
}
let encoded = data.to_string();
events.extend(scheme.parse_sse(
kind,
self.rejection_data.unwrap_or(&encoded),
&mut state,
)?);
let wait = self.wait_before_error.clone();
Ok(Box::pin(futures::stream::iter(events).then(move |event| {
let wait = wait.clone();
async move {
if matches!(event, Event::Error(_))
&& let Some(wait) = wait
{
wait.notified().await;
}
Ok(event)
}
})))
}
}
#[async_trait]
impl LlmClient for Client {
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
agen::llm_client::ToolCallCompletionSupport::PerBlock
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let mut requests = self.requests.lock().unwrap();
requests.push(request);
if requests.len() <= self.failures {
return self.reject(rejection());
}
if self.other_error {
return self.reject(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "invalid tool arguments".into(),
retry_after: None,
});
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(Event::text_block_start(0)),
Ok(Event::text_delta(0, "resize and retry")),
Ok(Event::text_block_stop(0, None)),
Ok(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
#[tokio::test]
async fn removes_largest_pixels_not_largest_file_and_keeps_pairing() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
let mut engine = Engine::new(client);
let committed = Arc::new(Mutex::new(Vec::new()));
let commits = committed.clone();
engine.set_image_rejection_handler(move |original, replacement| {
commits
.lock()
.unwrap()
.push((original.item.clone(), replacement.clone()));
Ok(())
});
let output = engine.run(&mut history, "continue").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(images(&requests[0]), ["small", "large"]);
assert_eq!(images(&requests[1]), ["small"]);
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("retry ViewImage"))));
assert_eq!(committed.lock().unwrap().len(), 1);
assert_eq!(
history
.items()
.filter(|item| matches!(item, Item::ToolResult {call_id, ..} if call_id == "large"))
.count(),
1
);
}
#[tokio::test]
async fn multiple_rejections_remove_one_image_at_a_time_and_stop_at_exhaustion() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["small"]);
assert!(images(&requests[2]).is_empty());
}
#[tokio::test]
async fn unrelated_error_stops_recovery_without_removing_another_image() {
let mut client = Client::new(1);
client.other_error = true;
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(observer.requests.lock().unwrap().len(), 2);
assert!(history.items().any(|item| matches!(item, Item::ToolResult {call_id, attachments, ..} if call_id == "small" && !attachments.is_empty())));
}
#[tokio::test]
async fn persistence_failure_does_not_change_history_or_retry() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let original = history.items_cloned();
let mut engine = Engine::new(client);
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
let out = engine.run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
assert_eq!(observer.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn previously_accepted_images_are_not_recovery_candidates() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_message("I saw those images"));
history.push(Item::tool_call("new", "ViewImage", "{}"));
history.push(image_result("new", 1, 1, 0));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
// Already-consumed images are projected out, but their durable records
// must not be rewritten as failures.
assert!(images(&requests[1]).is_empty());
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult {call_id, is_error: false, attachments, ..} if (call_id == "small" || call_id == "large") && !attachments.is_empty())).count(), 2);
}
#[tokio::test]
async fn legacy_empty_failed_request_boundaries_do_not_hide_candidates() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_response_boundary("failed-1"));
history.push(Item::assistant_response_boundary("failed-2"));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(images(&observer.requests.lock().unwrap()[1]), ["small"]);
}
#[tokio::test]
async fn early_results_remain_candidates_despite_later_text_in_same_response() {
let client = Client::new(1);
let observer = client.clone();
let mut history = History::from_items(vec![
Item::user_message("inspect"),
Item::assistant_response_boundary("response-1"),
Item::tool_call("image", "ViewImage", "{}"),
image_result("image", 99, 99, 0),
Item::assistant_message("waiting for image"),
]);
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}; requests: {:?}",
out.result,
observer.requests.lock().unwrap()
);
assert!(images(&observer.requests.lock().unwrap()[1]).is_empty());
}
#[derive(Clone)]
struct CountingTool {
image: bool,
count: Arc<std::sync::atomic::AtomicUsize>,
executed: Option<Arc<tokio::sync::Notify>>,
}
#[async_trait]
impl agen::tool::Tool for CountingTool {
async fn execute(
&self,
_: &str,
_: agen::tool::ToolExecutionContext,
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if let Some(executed) = &self.executed {
executed.notify_one();
}
let attachments = if self.image {
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
unreachable!()
};
attachments
} else {
Vec::new()
};
Ok(agen::tool::ToolOutput {
summary: "executed once".into(),
content: None,
attachments,
})
}
}
#[derive(Clone)]
struct ToolClient(Client);
#[async_trait]
impl LlmClient for ToolClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
agen::llm_client::ToolCallCompletionSupport::PerBlock
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
if self.0.requests.lock().unwrap().is_empty() {
self.0.requests.lock().unwrap().push(request);
return Ok(Box::pin(futures::stream::iter(
vec![
Event::tool_use_start(0, "image", "ViewImage"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::tool_use_start(1, "side-effect", "Bash"),
Event::tool_input_delta(1, "{}"),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
.into_iter()
.map(Ok),
)));
}
self.0.stream(request).await
}
}
#[tokio::test]
async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
for mode in [
agen::ToolCallDispatchMode::AfterResponse,
agen::ToolCallDispatchMode::OnToolCallComplete,
] {
let client = Client::new(2);
let mut engine = Engine::new(ToolClient(client.clone()));
engine.set_tool_call_dispatch_mode(mode);
let mut counts = Vec::new();
for (name, image) in [("ViewImage", true), ("Bash", false)] {
let tool = CountingTool {
image,
count: Arc::default(),
executed: None,
};
counts.push(tool.count.clone());
engine.register_tool(Arc::new(move || {
(
agen::tool::ToolMeta::new(name)
.description("test")
.input_schema(serde_json::json!({"type":"object"})),
Arc::new(tool.clone()),
)
}));
}
let mut history = History::new();
let out = engine.run(&mut history, "inspect").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}",
out.result
);
assert!(
counts
.iter()
.all(|count| count.load(std::sync::atomic::Ordering::SeqCst) == 1)
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["image"]);
assert!(images(&requests[2]).is_empty());
}
}
#[test]
fn image_error_classifier_is_not_a_generic_400_retry() {
assert!(rejection().is_image_size_rejection());
for (status, message) in [
(400, "invalid image format"),
(400, "image URL not found"),
(400, "context window exceeds token limit"),
(401, "image is too large"),
(500, "image is too large"),
] {
assert!(
!ClientError::Api {
status: Some(status),
code: Some("invalid_value".into()),
message: message.into(),
retry_after: None
}
.is_image_size_rejection()
);
}
}
#[tokio::test]
async fn streamed_image_rejections_correct_largest_candidate_before_next_request() {
for path in STREAM_PATHS {
let client = Client::streamed(1, path);
let mut history = history();
let mut engine = Engine::new(client.clone());
let committed = Arc::new(Mutex::new(Vec::new()));
let commits = committed.clone();
engine.set_image_rejection_handler(move |original, replacement| {
commits
.lock()
.unwrap()
.push((original.item.clone(), replacement.clone()));
Ok(())
});
let out = engine.run(&mut history, "continue").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}",
out.result
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(images(&requests[0]), ["small", "large"]);
assert_eq!(images(&requests[1]), ["small"]);
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("Resize or crop") && summary.contains("retry ViewImage"))));
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolCall { call_id, name, .. } if call_id == "large" && name == "ViewImage")));
assert_eq!(committed.lock().unwrap().len(), 1);
assert_eq!(
history
.items()
.filter(
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "large")
)
.count(),
1
);
}
}
#[tokio::test]
async fn streamed_rejections_stop_when_images_are_absent_or_candidates_exhausted() {
for path in STREAM_PATHS {
for (mut history, expected_requests) in [(History::new(), 1), (history(), 3)] {
let client = Client::streamed(99, path);
let out = Engine::new(client.clone())
.run(&mut history, "continue")
.await;
assert!(
matches!(out.result, EngineRunExit::Interrupted(_)),
"{:?}",
out.result
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), expected_requests);
assert!(images(requests.last().unwrap()).is_empty());
}
}
}
#[tokio::test]
async fn unrelated_streamed_invalid_value_preserves_images_and_diagnostic() {
for path in STREAM_PATHS {
let mut client = Client::streamed(0, path);
client.other_error = true;
let mut history = history();
let original = history.items_cloned();
let out = Engine::new(client.clone())
.run(&mut history, "continue")
.await;
let EngineRunExit::Interrupted(reason) = out.result else {
panic!("{:?}", out.result)
};
let reason = format!("{reason:?}");
assert!(
reason.contains("invalid_value")
&& reason.contains("invalid tool arguments")
&& reason.contains("diagnostic=")
&& reason.contains("input"),
"{reason}"
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
}
}
#[tokio::test]
async fn streamed_correction_commit_failure_does_not_mutate_or_resend() {
for path in STREAM_PATHS {
let client = Client::streamed(99, path);
let mut history = history();
let original = history.items_cloned();
let mut engine = Engine::new(client.clone());
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
let out = engine.run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
}
#[tokio::test]
async fn streamed_rejection_after_output_never_replays_or_corrects_input() {
for path in STREAM_PATHS {
// Start alone closes the replay window, as do partial/completed text,
// reasoning and unrecognized events. None may change accepted input.
for prefix in [
vec![(
"response.content_part.added",
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
)],
vec![(
"response.output_text.delta",
json!({"output_index":0,"content_index":0,"delta":"partial"}),
)],
vec![
(
"response.output_text.delta",
json!({"output_index":0,"content_index":0,"delta":"completed"}),
),
(
"response.content_part.done",
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
),
],
vec![(
"response.reasoning_text.delta",
json!({"output_index":0,"content_index":0,"delta":"thinking"}),
)],
vec![("future.unknown", json!({"value":"unknown semantics"}))],
] {
let mut client = Client::streamed(1, path);
client.prefix = prefix;
let mut history = history();
let original = history.items_cloned();
let out = Engine::new(client.clone())
.run(&mut history, "continue")
.await;
assert!(
matches!(out.result, EngineRunExit::Interrupted(_)),
"{:?}",
out.result
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
}
}
}
#[test]
fn statusless_api_errors_are_not_image_retry_authority() {
let ClientError::Api { code, message, .. } = rejection() else {
unreachable!()
};
assert!(
!ClientError::Api {
status: None,
code,
message,
retry_after: None
}
.is_image_size_rejection()
);
}
#[tokio::test]
async fn streamed_rejection_after_tool_completion_never_reexecutes_side_effects() {
for path in STREAM_PATHS {
for mode in [
agen::ToolCallDispatchMode::AfterResponse,
agen::ToolCallDispatchMode::OnToolCallComplete,
] {
let executed = Arc::new(tokio::sync::Notify::new());
let mut client = Client::streamed(1, path);
client.prefix = vec![
(
"response.output_item.added",
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
),
(
"response.output_item.done",
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
),
];
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
client.wait_before_error = Some(executed.clone());
}
let tool = CountingTool {
image: false,
count: Arc::default(),
executed: Some(executed),
};
let count = tool.count.clone();
let mut engine = Engine::new(client.clone());
engine.set_tool_call_dispatch_mode(mode);
engine.register_tool(Arc::new(move || {
(
agen::tool::ToolMeta::new("Bash")
.description("side effect")
.input_schema(json!({"type":"object"})),
Arc::new(tool.clone()),
)
}));
let mut history = history();
let original = history.items_cloned();
let out = tokio::time::timeout(
std::time::Duration::from_secs(5),
engine.run(&mut history, "continue"),
)
.await
.expect("tool admission/error cleanup must terminate");
assert!(
matches!(out.result, EngineRunExit::Interrupted(_)),
"{:?}",
out.result
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "side-effect")).count(), 1);
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "side-effect")).count(), 1);
} else {
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
assert!(!history.items().any(|item| matches!(item, Item::ToolCall { call_id, .. } | Item::ToolResult { call_id, .. } if call_id == "side-effect")));
}
}
}
}
#[test]
fn streamed_error_extra_fields_do_not_turn_unrelated_message_into_image_rejection() {
let scheme = OpenAIResponsesScheme::new();
let mut state = Default::default();
let events = scheme.parse_sse("error", r#"{"type":"error","code":"invalid_value","message":"invalid tool arguments","param":"input","input":"image too large"}"#, &mut state).unwrap();
let Event::Error(error) = &events[0] else {
panic!("{events:?}")
};
assert_eq!(error.code.as_deref(), Some("invalid_value"));
assert!(
error
.message
.starts_with("invalid tool arguments | diagnostic=")
);
// HTTP uses the same diagnostic policy, but still requires status authority.
assert!(
!ClientError::Api {
status: Some(400),
code: error.code.clone(),
message: error.message.clone(),
retry_after: None
}
.is_image_size_rejection()
);
}
#[tokio::test]
async fn unparseable_stream_error_data_preserves_images_and_terminates_with_diagnostic() {
for data in [
r#"{"type":"error","code":"invalid_value","message":123,"input":"image too large"}"#,
"unparseable provider error with echoed input: image too large",
] {
let mut client = Client::streamed(1, RejectionPath::SseError);
client.rejection_data = Some(data);
let mut history = history();
let original = history.items_cloned();
let out = Engine::new(client.clone())
.run(&mut history, "continue")
.await;
let EngineRunExit::Interrupted(agen::RunInterruptionReason::Unexpected(
agen::EngineError::Client(ClientError::Api { code, message, .. }),
)) = out.result
else {
panic!(
"malformed provider error must terminate, got {:?}",
out.result
);
};
assert_eq!(code, None);
let (base, diagnostic) = message
.split_once(" | diagnostic=")
.expect("raw data remains diagnostic-only");
assert!(!base.contains("image"));
let diagnostic: Value = serde_json::from_str(diagnostic).unwrap();
assert_eq!(diagnostic["error_extra"]["raw_data"], data);
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
}
}
File diff suppressed because it is too large Load Diff
+47 -47
View File
@@ -1,24 +1,20 @@
//! Reasoning history round-trip 統合テスト
//! Integration tests for reasoning history round trips.
//!
//! Engine のストリーム → history append → 次リクエスト送出までの
//! ライフサイクルで `Item::Reasoning` が脱落せず保持されることを確認する。
//! Verifies that `Item::Reasoning` survives the lifecycle from Engine streaming
//! through history append to the next outgoing request.
//!
//! 検証点:
//! - Anthropic 由来の thinking + signature が `Item::Reasoning::signature` として
//! history に残る
//! - OpenAI Responses 由来の reasoning text + summary + encrypted_content が
//! `Item::Reasoning` の各フィールドに展開される
//! - 直前の reasoning は次の outgoing request の `request.items` の先頭付近に
//! 含まれる(assistant メッセージの先頭、Anthropic 仕様)
//! Covers Anthropic thinking and signatures, OpenAI Responses reasoning text,
//! summaries and encrypted content, and ordering reasoning before other assistant
//! output as required by Anthropic.
mod common;
use agen::Engine;
use agen::Item;
use agen::llm_client::event::{
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
StatusEvent,
};
use agen::{Engine, History};
use common::MockLlmClient;
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
@@ -41,8 +37,8 @@ fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Eve
]
}
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
/// Engine history に Reasoning(signature 付き) → assistant_message が並ぶ。
/// Anthropic-style sequence: thinking block, text, completion.
/// History contains Reasoning with a signature followed by assistant_message.
#[tokio::test]
async fn anthropic_thinking_round_trips_signature_into_history() {
let mut events = reasoning_block(
@@ -65,15 +61,15 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
]);
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let out = engine.run("question?").await.expect("run ok");
let engine = out.engine;
let mut history: History = History::new();
let _out = engine.run(&mut history, "question?").await;
let history = engine.history();
let entries = history.entries();
// user / reasoning / assistant_message
assert_eq!(history.len(), 3, "history: {history:?}");
assert!(matches!(history[0], Item::Message { .. }));
match &history[1] {
assert!(matches!(entries[0].item, Item::Message { .. }));
match &entries[1].item {
Item::Reasoning {
text, signature, ..
} => {
@@ -82,11 +78,11 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
}
other => panic!("expected Reasoning, got {other:?}"),
}
assert_eq!(history[2].as_text(), Some("Here's the answer"));
assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
}
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
/// `Item::Reasoning` のフィールドに展開されること。
/// OpenAI Responses reasoning with encrypted_content and summary is projected
/// into the corresponding `Item::Reasoning` fields.
#[tokio::test]
async fn openai_reasoning_round_trips_encrypted_and_summary() {
let mut events = reasoning_block(
@@ -109,11 +105,11 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
]);
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok");
let engine = out.engine;
let mut history: History = History::new();
let _out = engine.run(&mut history, "q").await;
let history = engine.history();
match &history[1] {
let entries = history.entries();
match &entries[1].item {
Item::Reasoning {
text,
summary,
@@ -132,13 +128,13 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
}
}
/// Reasoning は assistant ターン内で text/tool_call より先に並ぶこと(Anthropic
/// が thinking を assistant メッセージの先頭に要求するため)。
/// Reasoning precedes text/tool_call in the assistant turn because Anthropic
/// requires thinking at the start of the assistant message.
#[tokio::test]
async fn reasoning_precedes_text_in_assistant_burst() {
let mut events = vec![
// text/tool_call とは独立に、reasoning block が中盤で完了しても、
// history append 時には assistant items の先頭に置かれる。
// Even if the reasoning block finishes after text/tool_call output,
// it is placed first among assistant items when appended to history.
Event::text_block_start(0),
Event::text_delta(0, "intermediate"),
Event::text_block_stop(0, None),
@@ -155,18 +151,18 @@ async fn reasoning_precedes_text_in_assistant_burst() {
}));
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok");
let engine = out.engine;
let mut history: History = History::new();
let _out = engine.run(&mut history, "q").await;
let history = engine.history();
// user / reasoning(先頭) / assistant_message
assert!(matches!(history[1], Item::Reasoning { .. }));
assert_eq!(history[2].as_text(), Some("intermediate"));
let entries = history.entries();
// user / reasoning (first assistant item) / assistant_message
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
}
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
/// 注入して run しても、次の outgoing request の `Request::items` にそのまま
/// 載って LLM へ渡る(engine は items を改変しない契約)。
/// Restored history: inject an Item::Reasoning with a signature as if restored
/// from history.json. Running the Engine preserves it in the next `Request::items`
/// sent to the LLM without modifying the reasoning item.
#[tokio::test]
async fn injected_reasoning_survives_into_outgoing_request() {
use async_trait::async_trait;
@@ -176,7 +172,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
use agen::llm_client::{ClientError, LlmClient, Request};
/// Request を 1 度だけキャプチャして空ストリームを返す client。
/// Captures a Request and returns a stream containing only completion status.
#[derive(Clone)]
struct CapturingClient {
captured: Arc<Mutex<Option<Request>>>,
@@ -207,21 +203,25 @@ async fn injected_reasoning_survives_into_outgoing_request() {
};
let mut engine = Engine::new(client);
// resume: 既存 history を流し込む
engine.set_history(vec![
Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"),
]);
let mut history: History = History::new();
// Restore existing history before continuing.
engine.set_history(
&mut history,
vec![
Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"),
],
);
let _ = engine.run("follow up").await.expect("run ok");
let _ = engine.run(&mut history, "follow up").await;
let req = captured
.lock()
.unwrap()
.take()
.expect("client should have received a request");
// Reasoning item が outgoing items に保持されていること
// The reasoning item must remain in the outgoing items.
let mut found = false;
for item in &req.items {
if let Item::Reasoning {
@@ -0,0 +1,147 @@
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use agen::llm_client::{
ClientError, LlmClient, Request, ResponseStream, ToolCallCompletionSupport,
scheme::{Scheme, gemini::GeminiScheme, openai_chat::OpenAIScheme},
};
use agen::{Engine, EngineRunExit, History, ToolCallDispatchMode};
use async_trait::async_trait;
#[derive(Clone)]
struct SseFrame {
event_type: &'static str,
data: &'static str,
}
#[derive(Clone)]
struct SchemeReplayClient<S> {
scheme: S,
responses: Arc<Vec<Vec<SseFrame>>>,
stream_calls: Arc<AtomicUsize>,
}
impl<S> SchemeReplayClient<S> {
fn new(scheme: S, responses: Vec<Vec<SseFrame>>) -> Self {
Self {
scheme,
responses: Arc::new(responses),
stream_calls: Arc::new(AtomicUsize::new(0)),
}
}
fn stream_count(&self) -> usize {
self.stream_calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl<S: Scheme> LlmClient for SchemeReplayClient<S> {
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
let response_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
let frames = self
.responses
.get(response_index)
.ok_or_else(|| ClientError::Config("scheme replay responses exhausted".to_string()))?;
let mut state = S::State::default();
let mut output = Vec::new();
for frame in frames {
match self
.scheme
.parse_sse(frame.event_type, frame.data, &mut state)
{
Ok(events) => output.extend(events.into_iter().map(Ok)),
Err(error) => {
output.push(Err(error));
break;
}
}
}
Ok(Box::pin(futures::stream::iter(output)))
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
self.scheme.tool_call_completion_support()
}
}
async fn assert_response_complete_scheme_accepts_clean_eof<S: Scheme>(
scheme: S,
frames: Vec<SseFrame>,
) {
assert_eq!(
scheme.tool_call_completion_support(),
ToolCallCompletionSupport::ResponseComplete
);
for (label, mode) in [
("default on_tool_call_complete fallback", None),
(
"explicit after_response",
Some(ToolCallDispatchMode::AfterResponse),
),
] {
let client = SchemeReplayClient::new(scheme.clone(), vec![frames.clone()]);
let probe = client.clone();
let mut engine = Engine::new(client);
if let Some(mode) = mode {
engine.set_tool_call_dispatch_mode(mode);
}
let mut history = History::new();
let output = engine.run(&mut history, "hello").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{label} should accept the response-complete scheme's clean EOF, got {:?}",
output.result
);
assert_eq!(
probe.stream_count(),
1,
"{label} must not open a continuation request after the adapter's normal EOF"
);
}
}
#[tokio::test]
async fn openai_chat_clean_eof_completes_in_default_and_explicit_after_response_modes() {
assert_response_complete_scheme_accepts_clean_eof(
OpenAIScheme::new(),
vec![
SseFrame {
event_type: "message",
data: r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":"stop"}]}"#,
},
SseFrame {
event_type: "message",
data: "[DONE]",
},
],
)
.await;
}
#[tokio::test]
async fn gemini_clean_eof_completes_in_default_and_explicit_after_response_modes() {
assert_response_complete_scheme_accepts_clean_eof(
GeminiScheme::new(),
vec![
SseFrame {
event_type: "message",
data: r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"finishReason":"STOP","index":0}]}"#,
},
SseFrame {
event_type: "message",
data: "[DONE]",
},
],
)
.await;
}
+5 -5
View File
@@ -1,7 +1,7 @@
//! HTTP transport の単発 request / error classification テスト。
//! Tests for single-attempt HTTP requests and error classification.
//!
//! Retry/backoff は Engine の lifecycle 管理に属するため、transport は 1 回だけ
//! request を送り、HTTP status / Retry-After を `ClientError` に載せて返す。
//! Retry and backoff belong to the Engine lifecycle. The transport sends one
//! request and returns the HTTP status and Retry-After information in `ClientError`.
use agen::llm_client::LlmClient;
use agen::llm_client::auth::AuthRequirement;
@@ -17,8 +17,8 @@ use std::time::Duration;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// SSE 本体は触らないテスト用 scheme。`parse_fail` を立てると
/// stream 消費中で `ClientError::Sse` を返す。
/// Test scheme that does not inspect SSE payloads. With `parse_fail` set,
/// it returns `ClientError::Sse` while consuming the stream.
#[derive(Clone)]
struct DummyScheme {
parse_fail: bool,
+3 -2
View File
@@ -1,4 +1,4 @@
use agen::Engine;
use agen::{Engine, History};
use agen::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
@@ -22,7 +22,8 @@ fn main() {
cap,
);
let engine = Engine::new(client);
let mut locked = engine.lock();
let history = History::new();
let mut locked = engine.lock(&history);
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
let _ = locked.register_tool(def);
}
@@ -1,8 +1,8 @@
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
--> tests/ui/locked_register_tool.rs:27:20
--> tests/ui/locked_register_tool.rs:28:20
|
27 | let _ = locked.register_tool(def);
28 | let _ = locked.register_tool(def);
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
|
= note: the method was found for
- `Engine<C>`
- `Engine<C, Mutable, A>`
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "api-macros-impl"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[lib]
proc-macro = true
[features]
default = []
openapi = []
[dependencies]
proc-macro-crate = "3"
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
+7
View File
@@ -0,0 +1,7 @@
# api-macros-impl
Procedural macro implementation for `api-macros`. It validates and normalizes trait declarations before generating metadata and adapters. It does not own runtime HTTP handling or application authorization decisions.
## Main entry points
- [`api`] — declaration normalization and code generation
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "api-macros"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[features]
default = []
axum = ["dep:axum", "dep:serde"]
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
reqwest = [
"dep:futures",
"dep:reqwest",
"dep:serde",
"dep:serde_json",
"dep:serde_urlencoded",
"dep:url",
]
[dependencies]
api-macros-impl = { path = "../api-macros-impl" }
bytes.workspace = true
axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] }
schemars = { version = "1.2", optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
serde_urlencoded = { version = "0.7", optional = true }
url = { version = "2", optional = true }
[dev-dependencies]
oas3 = { version = "0.22", default-features = false }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["net"] }
tower = { workspace = true, features = ["util"] }
trybuild = "1"
+9
View File
@@ -0,0 +1,9 @@
# api-macros
Defines transport-independent API metadata from public traits. The same declarations drive optional HTTP adapters and OpenAPI generation, while callers retain authentication, authorization, and application state.
## Main entry points
- [`api`] — API declaration and validation
- [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts
- `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features

Some files were not shown because too many files have changed in this diff Show More