diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index d8b3a9f6..9462bc4d 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -12,6 +12,7 @@ on: - acp - coder - console + - context-manager - database - email - harness diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfd129b8..0bf89907 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: - 'acp/v*' - 'coder/v*' - 'console/v*' + - 'context-manager/v*' - 'database/v*' - 'email/v*' - 'harness/v*' diff --git a/README.md b/README.md index afab3e69..ea3eb6cd 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ npx skills add iii-hq/iii --all | [`acp`](acp/) | Rust | Agent Client Protocol surface — stdio JSON-RPC, exposes iii agents as ACP sessions. | | [`harness`](harness/) | Node | TS port of the iii harness stack — bundles `harness` (provider registry + credentials/settings/permissions via the `configuration` worker), `turn-orchestrator`, `approval-gate`, `hook-fanout`, `models-catalog`, the `provider-*` workers, `llm-budget`, and `context-compaction` as one pnpm monorepo. Conversations persist in `session-manager`. See [`harness/README.md`](harness/README.md). | | [`session-manager`](session-manager/) | Rust | Durable, reactive, branching conversation store — fourteen `session::*` functions plus six trigger types; the transcript backend for `harness` and `console`. See [`session-manager/architecture/`](session-manager/architecture/). | +| [`context-manager`](context-manager/) | Rust | Model-ready context assembly — four `context::*` functions for token counting, function-result pruning, and history compaction over caller-supplied messages. Storage-agnostic; summarisation via `llm-router` when installed. | | [`database`](database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. | | [`iii-directory`](iii-directory/) | Rust | Engine introspection (functions / triggers / workers), workers-registry proxy, and filesystem-backed skill + prompt reader. | | [`iii-lsp`](iii-lsp/) | Rust | Language Server for iii function ids, trigger configs, and worker discovery. Autocomplete / hover across JS/TS, Python, Rust. | diff --git a/console/README.md b/console/README.md index 67da94bb..90d6a3ac 100644 --- a/console/README.md +++ b/console/README.md @@ -33,7 +33,7 @@ A purpose-built agentic chat UI on top of [Lexical](https://lexical.dev). Lives - **Three modes** — `plan`, `ask`, and `agent` toggle right in the composer - **Live model picker** — provider-grouped from `models::list`; static fallback (OpenAI, Anthropic, Google) when the catalog is unreachable - **`@`-mentions** — fuzzy-search every function registered against the engine -- **`/compact` slash command** — collapses conversation history via `context-compaction::compact_session` +- **`/compact` slash command** — summarises conversation history via `context-compaction::compact_session` (a thin wrapper over the `context-manager` worker's `context::compact`); the durable transcript is untouched — a compaction bookkeeping entry is added and the summary anchors future turns - **Attachments** — multi-file picker with text/image previews - **Function calls** — running / pending / error cards, consecutive calls grouped, with **approve/deny** gating for pending approvals (`approval::resolve`) - **Streaming** — abortable mid-flight; thinking shimmer; collapsible thought messages diff --git a/console/web/src/lib/functions.ts b/console/web/src/lib/functions.ts index a19c863e..e9d116f4 100644 --- a/console/web/src/lib/functions.ts +++ b/console/web/src/lib/functions.ts @@ -17,10 +17,6 @@ export const STATIC_FUNCTIONS: FunctionEntry[] = [ id: 'context-compaction::compact_session', description: 'compact a session now', }, - { - id: 'context-compaction::prune_tool_outputs', - description: 'prune old tool outputs', - }, { id: 'session::messages', description: 'read a session transcript', diff --git a/context-manager/Cargo.lock b/context-manager/Cargo.lock new file mode 100644 index 00000000..65b486ab --- /dev/null +++ b/context-manager/Cargo.lock @@ -0,0 +1,3161 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "context-manager" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "cucumber", + "dirs", + "futures", + "iii-sdk", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "which", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cucumber" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cbb27bc2064274afa3a3d8bc9a0e71333589850573aa632ec4520e4af14d94" +dependencies = [ + "anyhow", + "clap", + "console", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1afaf9c422380861111c6be56f39b324e351fd9efc07a1486268798bf79cfd" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools", + "proc-macro2", + "quote", + "regex", + "syn", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom", + "nom_locate", + "regex", + "regex-syntax", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gherkin" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70197ce7751bfe8bc828e3a855502d3a869a1e9416b58b10c4bde5cf8a0a3cb3" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn", + "textwrap", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "iii-observability" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11586fcd304a563c143837f67b2e5f3cb73d23f6c8452c9734ff18e4a1402bf" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8490f2ad470d54cf7e0bc2f4105aca71bdb4c24752fcb406183f24b8dd328f80" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-observability", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror 2.0.18", + "tokio", + "tokio-stream", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/context-manager/Cargo.toml b/context-manager/Cargo.toml new file mode 100644 index 00000000..ebee31f9 --- /dev/null +++ b/context-manager/Cargo.toml @@ -0,0 +1,60 @@ +[workspace] + +[package] +name = "context-manager" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "context-manager" +path = "src/main.rs" + +[lib] +name = "context_manager" +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.19.2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive"] } +async-trait = "0.1" +# Must stay on the same schemars major as iii-sdk so the derived +# `JsonSchema` impls satisfy `RegisterFunction::new_async`. +schemars = "0.8" +uuid = { version = "1", features = ["v4"] } +# Default compaction lease key = sha256 of the message set; must be +# stable across processes, so no std hasher. +sha2 = "0.10" +# Home-dir resolution for the `~/`-prefixed lease_dir (same as +# session-manager's data_dir). +dirs = "5" + +[dev-dependencies] +serde_json = "1" +tempfile = "3" +which = "8" +# BDD test runner. Drives Gherkin .feature files under tests/features/. +cucumber = "0.22" +futures = "0.3" +uuid = { version = "1", features = ["v4"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } + +# Single BDD entry point. cucumber needs full control of the runner, so +# `harness = false` is required. All .feature files under +# `tests/features/**` are discovered at runtime; tags (`@pure`, +# `@engine`, ...) let you filter to a subset: +# +# cargo test --test bdd # everything (engine scenarios +# # soft-skip without an engine) +# cargo test --test bdd -- --tags @pure # no engine required +[[test]] +name = "bdd" +harness = false diff --git a/context-manager/README.md b/context-manager/README.md new file mode 100644 index 00000000..b87294b2 --- /dev/null +++ b/context-manager/README.md @@ -0,0 +1,98 @@ +# context-manager + +Turns a raw conversation history plus a target model into a **model-ready +context**: a system prompt and an ordered message list that fits inside the +model's usable token budget. It owns *what the model sees this turn* — token +counting, function-result pruning, and history compaction (summarisation) — +and nothing else. It is stateless with respect to conversation storage: pass +messages in, get results back, persist them yourself (or with +[`session-manager`](../session-manager/)). Summarisation and model-limit +lookups go through `llm-router` when installed; token counting and pruning +work standalone. + +## Install + +```bash +iii worker add context-manager +``` + +`iii worker add` fetches the binary and the engine starts the worker on the +next `iii start`. Runtime configuration lives in the `configuration` worker +(enabled by default in the engine): at boot the worker registers its schema and +fetches the authoritative value, and it hot-reloads on change (the `lease_dir` +and `summarizer_timeout_ms` fields are read once at boot and need a restart). An +optional `--config .yaml` only seeds the first registration. + +## Quickstart + +Build a budgeted context before every model call: + +```rust +use iii_sdk::{register_worker, InitOptions, TriggerRequest}; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let iii = register_worker("ws://localhost:49134", InitOptions::default()); + + let result = iii.trigger(TriggerRequest { + function_id: "context::assemble".into(), + payload: json!({ + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "hi" }], "timestamp": 1 } + ], + // Inline limits keep the call standalone; pass only `id`/`provider` + // to resolve limits through llm-router instead. + "model": { "id": "claude-sonnet-4", + "limits": { "context_window": 200000, "max_output_tokens": 8000 } }, + "system_prompt": "You are a helpful assistant." + }), + action: None, + timeout_ms: Some(30_000), + }).await?; + + // -> { system_prompt, messages, token_count, usable, model_resolved, + // applied: { pruned, pruned_tokens, compacted, summary?, tail_start_index? } } + println!("{result:#?}"); + Ok(()) +} +``` + +The compaction round trip: when `applied.compacted` is true, **persist +`applied.summary`** and the boundary your storage maps `applied.tail_start_index` +to. On later calls pass only the post-compaction window as `messages` plus the +stored summary as `options.previous_summary` — the summary is rendered into the +system prompt under `# Conversation summary`, and any further compaction +*updates* it instead of starting over. Callers that skip persistence stay +correct at the cost of one summariser call per over-budget request. + +The other three functions: `context::count-tokens` (estimate messages + tools + +system prompt vs a model), `context::prune` (replace verbose function outputs +with `[output pruned: was ~N tokens]` placeholders, no LLM involved), and +`context::compact` (summarise the head, keep a recent tail verbatim — returns +`ok | busy | empty | overflow`). + +## Configuration + +```yaml +reserved_tokens_cap: 20000 # default reserve = min(cap, pct% of context window) +reserved_pct: 10 +tail_turns: 2 # user+assistant pairs kept verbatim by compaction +protect_recent_tokens: 40000 # newest function-output tokens never pruned +min_free_tokens: 20000 # skip pruning when it would free less +max_output_chars: 2000 # outputs at or under this size are never pruned +lease_ttl_secs: 300 # compaction mutual-exclusion lease TTL +allow_fallback_limits: true # conservative 8192/1024 when limits can't resolve +summarizer_timeout_ms: 320000 # outer budget for one router::chat summariser call +``` + +Other details (and the defaults' single source of truth) live in +[`src/config.rs`](src/config.rs). + +## Local development & testing + +```bash +cargo test # unit + BDD (engine scenarios soft-skip) +cargo test --test bdd -- --tags @pure # no engine required +UPDATE_GOLDENS=1 cargo test --test schemas # regenerate wire-schema goldens +``` diff --git a/context-manager/architecture/README.md b/context-manager/architecture/README.md new file mode 100644 index 00000000..f735b8bc --- /dev/null +++ b/context-manager/architecture/README.md @@ -0,0 +1,87 @@ +# context-manager architecture + +Reference documentation for the `context-manager` worker — the stateless, +model-aware engine that turns a raw conversation history into a model-ready +context, specified in +[tech-specs/2026-06-agentic/context-manager.md](../../tech-specs/2026-06-agentic/context-manager.md). +These documents are written to be sufficient on their own: a reader (human or +LLM) should be able to maintain the worker or integrate against it without +opening the source. + +## Document map + +| Document | Audience | Read it when | +|---|---|---| +| [internals.md](internals.md) | Maintainers of this worker | You are changing context-manager itself: fixing the budget math, the prune/compaction pipeline, the lease protocol, the summariser adapter, or adding a function. | +| [integration.md](integration.md) | Authors of other workers / clients | You are building something that calls `context::*` — the harness pre-flight, a batch summariser, a cost-estimating gate, a RAG pipeline. This file is the handoff contract. | + +The BDD suite under [../tests/features/](../tests/features) is the executable +companion to both: every behavioural claim made here is pinned by a scenario, +each annotated (`# Prevents:`) with the regression it guards against. + +## The system in one paragraph + +context-manager is a **pure transform over caller-supplied messages**. You +hand it an `AgentMessage[]` plus a target model; it returns a system prompt +and a budgeted `AgentMessage[]` that fits the model's usable token window. +It owns exactly three policies — token counting, function-result pruning, and +history compaction (LLM summarisation) — and nothing else. It is **stateless +with respect to conversation storage**: it never reads or writes a session, +never decides *when* to compact a live conversation, and never talks to a +provider directly. The only state it keeps is operational, not conversational: +short-lived compaction **leases** written as files under its own `lease_dir` +(the same on-disk strategy as `session-manager`), so two callers can't +summarise the same logical history at once. Summarisation +and model-limit lookups go through `llm-router` when installed; token counting +and pruning are fully standalone. Its own runtime configuration is registered +with and fetched from the `configuration` worker and hot-reloads on change +(except `lease_dir` / `summarizer_timeout_ms`, which are restart-required). +That deliberate statelessness is what makes +it reusable — a chat harness, a document summariser, a RAG pre-flight, or +another team's bespoke agent can all call `context::assemble` without adopting +`session-manager` or any particular storage model. + +## The system in one diagram + +```mermaid +flowchart LR + subgraph callers [Callers] + harness[harness / batch summariser / RAG pre-flight] + end + subgraph worker [context-manager] + fns["4 context::* functions"] + pipe["assemble pipeline: count -> prune -> compact"] + core["pure core: budget · estimate · prune · selection · summary · lease"] + ports["ports: ModelResolver · Summarizer · LeaseStore · Clock"] + end + subgraph deps [Soft dependency] + router["llm-router: router::models::get, router::chat"] + end + leases[("lease_dir: one file per lease")] + cfg["configuration worker: schema + value"] + harness -->|"iii.trigger(context::*)"| fns + fns --> pipe --> core + pipe --> ports + ports -->|model limits + summariser| router + ports -->|compaction leases only| leases + cfg -->|"authoritative config + configuration:updated"| worker + fns -->|"system_prompt + budgeted messages + applied{}"| harness +``` + +## Vocabulary + +| Term | Meaning | +|---|---| +| **Model-ready context** | The output of `context::assemble`: a `system_prompt` string plus an ordered `AgentMessage[]` that fits the model's `usable` budget, ready to hand to `router::chat`. | +| **`usable` budget** | The token ceiling one call may fill: `max(0, (input_limit ?? context_window - max_output_tokens) - reserved - thinking_budget)`. Model-adaptive, not a flat constant. | +| **`reserved`** | Headroom held back from the input budget for response framing; defaults to `min(20000, 10% of context_window)`, overridable per call. | +| **Prune** | The cheap first pass: replace verbose `function_result` outputs with `[output pruned: was ~N tokens]` placeholders. No LLM, no removal — content is rewritten in place. | +| **Compaction** | The expensive pass: summarise the **head** of the history into one Markdown summary via the summariser LLM, keeping a recent **tail** verbatim. | +| **Head / tail** | Compaction splits the history at a boundary: everything before it (the head) is summarised; everything from it on (the tail) is kept verbatim. | +| **Safe cut** | A boundary the tail may start at without orphaning a `function_result` from its `function_call`: a user or assistant message, never a result (see structural invariants). | +| **Summary anchor / round trip** | A prior summary passed back as `previous_summary`. The summariser *updates* it in place instead of starting over, so summaries converge instead of growing. The caller persists the summary; the worker never does. | +| **`tail_start_index`** | Index into the **request** `messages` array where the verbatim tail begins (`null` = everything was summarised). The worker never sees storage ids; the caller maps this onto its own ids. | +| **Compaction lease** | A `{ nonce, ts }` claim stored as a file under `lease_dir` (scope `context_lease`), keyed by `lease_key` (e.g. a session id) or a hash of the message set. Mutual exclusion so one logical history is summarised by one caller at a time; TTL-expiring so a crash never deadlocks it. | +| **`model_resolved`** | How limits were obtained: `inline` (caller supplied), `router` (`router::models::get`), or `fallback` (conservative 8192/1024 default). Echoed so a silent fallback is detectable. | +| **Estimator** | The token counter behind a trait. v1 ships the `chars/4` heuristic for every model; responses report `estimator: "heuristic"` so a future per-model tokenizer is a visible swap. | +| **`custom` message** | A `role: "custom"` transcript item (app-facing: UI markers, notices). It has no provider wire mapping, so `assemble` excludes it from the model-facing list and its token count — but `count-tokens` still counts what it is given. | diff --git a/context-manager/architecture/integration.md b/context-manager/architecture/integration.md new file mode 100644 index 00000000..cf9c4799 --- /dev/null +++ b/context-manager/architecture/integration.md @@ -0,0 +1,413 @@ +# Integrating with context-manager + +The handoff contract for workers and clients that build on context-manager — +the harness pre-flight, batch summarisers, cost-estimating gates, RAG +pipelines, bespoke agents. It is self-contained: everything needed to integrate +is here, with the +[spec](../../tech-specs/2026-06-agentic/context-manager.md) as the design +rationale and [internals.md](internals.md) as the implementation deep-dive. + +Contents: [mental model](#1-mental-model) · [conventions](#2-conventions) · +[data types](#3-data-types) · [functions](#4-function-catalog) · +[the compaction round trip](#5-the-compaction-round-trip) · +[structural invariants](#6-structural-invariants-what-you-can-rely-on) · +[errors](#7-error-contract) · [patterns](#8-canonical-patterns) · +[dependencies & degraded modes](#9-dependencies-and-degraded-modes) · +[boundaries](#10-boundaries) · [harness notes](#11-notes-for-the-harness) + +## 1. Mental model + +context-manager is a **pure function from `(messages, model)` to a model-ready +context**. You pass the full candidate history and a target model; it returns a +system prompt and a budgeted `AgentMessage[]` that fits the model's usable +token window, plus an `applied` report of what it did to get there (pruned, +compacted, neither). It holds no conversation state, so **you own everything +durable**: deciding *when* to call it, persisting any compaction summary it +produces, and storing the transcript itself. + +Integration is always some subset of four moves: + +1. **Fit a context** with `context::assemble` before a model call — the main + entry point. It prunes and/or compacts as needed. +2. **Persist the round trip** when `applied.compacted` is true (store the + summary + boundary, pass them back next time) so summarisation stays cheap + and convergent. +3. **Estimate** with `context::count-tokens` to gate model choice or cost with + no LLM and no `llm-router`. +4. **Use a single pass directly** — `context::prune` (no LLM) or + `context::compact` (LLM) — when you want only that step, not the whole + pipeline. + +## 2. Conventions + +- **Invocation**: every function is a **sync** `iii.trigger({ function_id, + payload, timeout_ms })` call; responses are the JSON shapes in §4. Give + `assemble`/`compact` a generous `timeout_ms` (they may make a summariser LLM + call — default outer budget is 320s). +- **Model input**: functions that need limits take a `ModelInput`. Supply + inline `limits` to stay fully standalone (no `llm-router` needed); supply + only `id`/`provider` to have the worker resolve limits via + `router::models::get`. Resolution order: inline → router → conservative + fallback (`8192`/`1024`); the response's `model_resolved` tells you which ran. +- **Tokens are estimates.** v1 uses a `chars/4` heuristic for every model + (`estimator: "heuristic"`). Treat `token_count`/`tokens` as approximate and + rely on the built-in `reserved` cushion rather than counting to the byte. +- **Timestamps** inside `AgentMessage` are caller-supplied integer ms since + epoch. The worker reads them only incidentally — order is array order + (oldest first), never timestamp. +- **Errors** are strings beginning with a stable code: `context/: + message`. Match on the code substring. Only `assemble`/`compact` validation + and unresolved-model throw; pipeline degradations do **not** error (§7). +- **Indices, not ids.** `tail_start_index` is an index into the `messages` + array *you sent*. The worker never sees your storage ids; you map the index + onto your own (§5). +- **Agent exposure is cost-gated, not secret-gated.** Every function is a pure + transform of caller input — nothing to leak — but `assemble`/`compact` can + spend a summariser call. Deny those two to agents in cost-sensitive + deployments; `count-tokens`/`prune` are always safe (§9). + +## 3. Data types + +The cross-cutting agentic contracts (TypeScript notation; the wire is plain +JSON, byte-compatible with session-manager and llm-router): + +```typescript +type Role = "user" | "assistant" | "function_result" | "custom"; +type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; + +type ContentBlock = + | { type: "text"; text: string } + | { type: "image"; mime: string; data: string } // base64 + | { type: "thinking"; text: string; signature?: string } + | { type: "function_call"; id: string; function_id: string; arguments: unknown } + | { type: "function_result"; function_call_id: string; content: ContentBlock[]; is_error?: boolean }; + +type AgentMessage = + | { role: "user"; content: ContentBlock[]; timestamp: number } + | { role: "assistant"; content: ContentBlock[]; + stop_reason: "end" | "length" | "function_call" | "aborted" | "error"; + native_stop_reason?: string; error_message?: string; + error_kind?: "auth_expired" | "rate_limited" | "context_overflow" | "transient" | "permanent"; + warnings?: string[]; usage?: Usage; model: string; provider: string; timestamp: number } + | { role: "function_result"; function_call_id: string; function_id: string; + content: ContentBlock[]; details: unknown; is_error: boolean; timestamp: number } + | { role: "custom"; custom_type: string; content: ContentBlock[]; + display?: string; details?: unknown; timestamp: number }; + +// How you name the target model. Inline limits = standalone; id/provider = router lookup. +type ModelInput = { + id: string; + provider?: string; + limits?: { context_window: number; max_output_tokens: number; input_limit?: number }; +}; +``` + +`role: "custom"` messages are app-facing transcript items (UI markers, system +notices, bookkeeping). They have **no provider wire mapping**, so `assemble` +drops them from the model-facing list and its token count — but `count-tokens` +still counts what you give it, including customs (use its `by_role.custom` +bucket to see or subtract that share). + +## 4. Function catalog + +All four are registered with JSON Schemas (`iii worker info context-manager` / +`get function info`); the shapes below are the contract. All are **sync**. + +### `context::assemble` — build the model-ready context + +The main entry point. Pipeline: count → (if over budget) prune function +outputs → (if still over) compact the head → return the budgeted list. + +```typescript +{ + messages: AgentMessage[]; // full candidate history, oldest first (required) + model: ModelInput; + system_prompt?: string; // base prompt; the summary is merged under it + options?: { + reserved_tokens?: number; // override the default reserve + tail_turns?: number; // user+assistant pairs kept verbatim (default 2) + allow_compaction?: boolean; // default true + allow_prune?: boolean; // default true + protected_functions?: string[]; // function_ids whose outputs are never pruned + thinking_level?: ThinkingLevel; // reserve the model's thinking budget for this tier + lease_key?: string; // compaction mutual-exclusion key (e.g. a session id) + previous_summary?: string; // persisted summary from a prior compaction (round trip) + }; +} +-> { + system_prompt: string; // base + "# Conversation summary" section when applicable + messages: AgentMessage[]; // budgeted, ready for router::chat (no custom roles) + token_count: number; // estimated tokens of the returned context + usable: number; // the budget it was fit into + model_resolved: "inline" | "router" | "fallback"; + applied: { + pruned: boolean; pruned_tokens: number; + compacted: boolean; + summary?: string; // present iff compacted — PERSIST THIS (§5) + tail_start_index?: number | null; // index into REQUEST messages where the tail begins + tokens_before?: number; // estimated tokens of the summarised head + }; +} +``` + +Throws only: `context/invalid_request` (`messages is required`), +`context/model_unresolved` (`could not resolve model limits` — only when no +inline limits, no router, and `allow_fallback_limits` is off). Everything else +— busy lease, failed/absent summariser, disabled steps — is **best effort**: +the context still returns, possibly with `token_count > usable`. + +### `context::count-tokens` — estimate usage + +Pure and router-free; safe for cost-sensitive callers with no `llm-router`. + +```typescript +{ + messages: AgentMessage[]; // required + system_prompt?: string; // counted on top of messages + tools?: AgentFunction[]; // invocation schema(s), typically [agent_trigger] + model: ModelInput; // tokenizer selection (v1: always heuristic) +} +-> { + tokens: number; // messages + system_prompt + tools + by_role?: { user; assistant; function_result; custom }; // message buckets only + estimator: "tokenizer" | "heuristic"; +} +``` + +`tokens` equals the sum of `by_role` plus the system-prompt and tools tokens +(which belong to no role bucket). Counts customs, unlike `assemble`. + +### `context::prune` — placeholder verbose function outputs + +The cheap pass alone: rewrite verbose `function_result` outputs to +`[output pruned: was ~N tokens]`. No LLM, no state, no removal. + +```typescript +{ + messages: AgentMessage[]; // required + model?: ModelInput; // only for token math; optional (heuristic needs none) + options?: { + protect_recent_tokens?: number; // newest output tokens never pruned (default 40000) + min_free_tokens?: number; // skip the pass if it frees less (default 20000) + max_output_chars?: number; // outputs at/under this are not "verbose" (default 2000) + protected_functions?: string[]; // function_ids never pruned + }; +} +-> { messages: AgentMessage[]; pruned_tokens; pruned_parts; scanned_parts } +``` + +The two most recent user turns are always exempt (a hard guard, independent of +the window). The pass is idempotent. + +### `context::compact` — summarise the head, keep a tail + +The expensive pass alone. Transient and storage-agnostic: it returns the +summary for *you* to persist; no session is touched. Most callers never call +this directly — `assemble` applies compaction inline. + +```typescript +{ + messages: AgentMessage[]; // required + model: ModelInput; + options?: { + tail_turns?: number; // default 2 + previous_summary?: string; // anchor so summaries converge instead of growing + preserve_recent_tokens?: number; // override the adaptive verbatim-tail budget + lease_key?: string; // mutual-exclusion key; default: hash of the message set + }; +} +-> // discriminated on `status`: + | { status: "ok"; summary: string; tail_start_index: number | null; + tokens_before: number; tokens_after: number; used_prior_summary: boolean } + | { status: "busy" } // a compaction lease is held; retry later + | { status: "empty" } // nothing to compact (empty history / empty summary) + | { status: "overflow" }; // summariser unavailable (no llm-router) or it failed +``` + +The summary follows a fixed Markdown template (Goal / Constraints & Preferences +/ Progress / Key Decisions / Actions Taken / Next Steps / Critical Context / +Relevant Files). With `previous_summary` it is **updated in place**, not +restarted. Requires `llm-router`; without it you get `overflow`. + +## 5. The compaction round trip + +The single most important integration contract. **context-manager never +persists a summary — you must, or every over-budget call re-summarises from +scratch** (one extra LLM call per request) and summaries never converge. + +```mermaid +sequenceDiagram + participant You as your worker + participant CM as context::assemble + participant Store as your store + You->>CM: assemble(messages, model, system_prompt) + CM-->>You: applied.compacted=true, summary, tail_start_index, tokens_before + You->>Store: persist {summary, boundary = your_id_at(tail_start_index)} + Note over You,Store: a later turn + You->>CM: assemble(window_from_boundary, model, options.previous_summary=stored) + CM-->>You: summary rendered into system_prompt; if over again, summary UPDATED +``` + +The contract, step by step: + +1. When `applied.compacted` is true, **persist `applied.summary`** and whatever + your storage maps `applied.tail_start_index` to. `tail_start_index` is an + index into the `messages` array you sent (customs included); resolve it to + your own id (e.g. the entry id at that position). +2. On later calls, pass **only the post-compaction window** as `messages` (the + verbatim tail and everything after it) **plus** the stored summary as + `options.previous_summary`. +3. `assemble` renders `previous_summary` into the returned `system_prompt` + under a `# Conversation summary` heading. If compaction triggers again, the + summariser **updates** that summary instead of starting over — so it + converges instead of growing. + +`tail_start_index` is `null` when everything was summarised (no verbatim tail). +A caller that skips step 1 stays correct but pays one summariser call per +over-budget request. + +## 6. Structural invariants — what you can rely on + +Whatever pruning or compaction does, the returned context is always +provider-legal. Build on these: + +- **Call/result pairing is never split.** A `function_call` and its + `function_result` always land on the same side of any boundary; the + compaction tail only ever starts at a user/assistant turn boundary (never + between a call and its result, never at a user message carrying an inline + result block whose call sits earlier). Orphaned results — which providers + reject — cannot appear. +- **Prune replaces, never removes.** A pruned output's content becomes a single + `[output pruned: was ~N tokens]` text block; the message, its + `function_call_id`, and the message ordering all survive. Message counts are + stable across a prune. +- **`custom` messages never reach the model.** `assemble` excludes + `role: "custom"` from the returned `messages` and from `token_count`. A huge + custom entry can't trigger a phantom overflow, and customs never leak to a + provider with no wire mapping for them. +- **`tail_start_index` indexes the request array** you sent (customs included), + so it maps cleanly onto your storage even though the model-facing list + dropped customs. + +## 7. Error contract + +| Code | Meaning / trigger | Functions | +|---|---|---| +| `context/invalid_request` | `messages` missing or `null` (`messages is required`); a `model` missing where required; malformed shapes serde can't coerce. | all | +| `context/model_unresolved` | No inline limits, router can't resolve, and `allow_fallback_limits` is off (`could not resolve model limits`). | assemble, compact, count-tokens | +| `context/state` | A backing lease filesystem write hard-failed (rare; lease problems usually degrade to `busy`). | compact, assemble | + +**Not errors — degradations you must read, not catch:** + +- `assemble` over budget with prune/compaction disabled, a busy lease, or an + unavailable summariser → returns normally with `applied.compacted: false` and + `token_count > usable`. Inspect `token_count` vs `usable` to know it didn't fit. +- `compact` → `{ status: "busy" | "empty" | "overflow" }` are normal outcomes, + not thrown errors. `overflow` specifically means "compaction unavailable" + (no `llm-router`, or the summariser failed) — treat it as such. +- Unknown **extra** request fields are tolerated (ignored), so additive API + evolution never breaks older callers. + +## 8. Canonical patterns + +### Pre-flight before every model call (the driver loop) + +```mermaid +sequenceDiagram + participant D as driver (e.g. harness) + participant CM as context-manager + participant R as llm-router + D->>CM: assemble(history, model, system_prompt, options{lease_key, previous_summary}) + CM-->>D: {system_prompt, messages, applied} + alt applied.compacted + D->>D: persist applied.summary + boundary at tail_start_index + end + D->>R: router::chat(system_prompt, messages) +``` + +Pass `options.lease_key` = your session id so concurrent turns of the *same* +session serialise their compaction; pass `previous_summary` from your last +persisted summary; send only the post-compaction window as `messages`. + +### Standalone cost gate (no llm-router) + +Call `context::count-tokens` with inline `model.limits` (or just an id — the +heuristic ignores it) to decide whether a cheaper/larger model is needed before +committing. Include the `agent_trigger` schema in `tools` so tool tokens count. +No LLM, no router, no state — works on a bare engine. + +### Prune-only first pass + +If you maintain your own compaction elsewhere and only want to reclaim verbose +tool output, call `context::prune` directly and persist the rewritten +`messages`. Idempotent, so re-running is safe. + +### Direct compaction with explicit mutual exclusion + +A batch summariser that isn't going through `assemble` calls `context::compact` +with an explicit `lease_key` (so parallel workers on the same logical input see +`busy` instead of double-summarising), handles the `ok | busy | empty | +overflow` union, and persists `summary` + `tail_start_index` itself. + +## 9. Dependencies and degraded modes + +| Dependency | Used for | Without it | +|---|---|---| +| `configuration` (required) | the worker's own runtime config: schema registration + the authoritative value, hot-reloaded on change | the worker **cannot boot** — `register`/`get` run at startup and a failure aborts it. Not a per-request dependency: once booted, `context::*` calls never touch it. | +| local filesystem (`lease_dir`) | compaction leases only | a filesystem error makes `compact`/`assemble` treat the lease as busy → compaction is skipped (best effort); `count-tokens`/`prune` unaffected. | +| `llm-router` (soft) | model-limit resolution (`router::models::get`) + the summariser (`router::chat`) | Limits fall back to `8192`/`1024` (`model_resolved: "fallback"`) unless you pass inline `limits`; `compact` returns `overflow`; `assemble` can prune but not summarise. | + +The fully standalone *request* path — inline `limits` + `count-tokens`/`prune`, +or `assemble` with compaction off — needs no `llm-router` and writes no lease +files (the `configuration` dependency is a one-time boot cost, not per call). +Cost note: only `assemble` and `compact` can trigger a summariser LLM call. + +## 10. Boundaries + +context-manager does **not**: + +- store conversations — pass messages in, persist results yourself (or use + [session-manager](../../session-manager/architecture/integration.md)); +- decide *when* to compact a live session — that is your policy (a pre-flight, + or the optional reactive trigger in §11); +- talk to LLM providers directly — summarisation goes through `llm-router`; +- guarantee exact token counts — v1 is a heuristic estimator (§2); +- implement long-term / vector memory — that belongs in a dedicated sibling + worker (not in v1). + +## 11. Notes for the harness + +The integration the spec was designed around — context-manager replaces a +bespoke in-harness compaction side-car with a reusable worker. + +- **Hot-path pre-flight.** On each turn, before calling `router::chat`, call + `context::assemble` with the session's candidate history, the target model + (id + provider so limits resolve through the router; or inline limits), the + base `system_prompt`, `options.lease_key` = the session id, and + `options.previous_summary` = the last persisted summary. Send the returned + `system_prompt` + `messages` straight to the model. +- **Compaction persistence (the round trip, §5).** When `applied.compacted`, + write a session bookkeeping record — the established pattern is a + `session::append` with `custom: { custom_type: "compaction", data: { summary, + tail_start_entry_id, ... } }`, mapping `applied.tail_start_index` onto the + entry id at that position in the history you sent. On the next turn, read it + back (`session::messages { include_custom: true }`, scan for the latest + `compaction` entry), pass its `summary` as `previous_summary` and the + messages from `tail_start_entry_id` onward as `messages`. +- **Why a lease.** Two harness instances (or a retried turn) on the same + session must not double-summarise. The lease keyed on the session id makes + the second caller skip compaction (`assemble`) or see `busy` (`compact`) + without burning a model call. Crashed holders expire after `lease_ttl_secs`. +- **Optional reactive pre-warm.** To pre-warm or surface a token-usage metric + off the hot path, bind a handler to `session::message-added` and call + `context::count-tokens` (cheap, no LLM) there. This is opt-in and lives in + the harness — context-manager binds no triggers and never reaches into a + session itself, which is exactly what keeps it store-agnostic. +- **Agent exposure.** All functions are pure transforms (nothing to leak), but + deny `context::assemble` and `context::compact` to in-run agents in + cost-sensitive deployments — they can trigger a summariser call. + `context::count-tokens` and `context::prune` are always safe. +- **Degraded engine.** With `llm-router` absent the harness still gets budgeted + output: limits fall back (or use inline `limits`), prune runs, and an + over-budget context returns visibly over (`token_count > usable`) instead of + erroring the turn. diff --git a/context-manager/architecture/internals.md b/context-manager/architecture/internals.md new file mode 100644 index 00000000..58cb5942 --- /dev/null +++ b/context-manager/architecture/internals.md @@ -0,0 +1,527 @@ +# context-manager internals + +Maintainer documentation. Everything here describes the implementation as it +is; the consumer-facing contract lives in [integration.md](integration.md). +Spec of record: +[tech-specs/2026-06-agentic/context-manager.md](../../tech-specs/2026-06-agentic/context-manager.md). + +## 1. Crate layout + +One cargo crate, one `[[bin]]`, plus a `[lib]` so tests drive production code +in-process. The cut that keeps everything testable: **handlers are thin, the +`core` modules are pure logic with no I/O, and every outside dependency is a +port** wired to a production adapter in the binary and to a deterministic fake +in tests. + +| Path | Responsibility | +|---|---| +| [src/main.rs](../src/main.rs) | Boot: CLI (`--config` seed, `--url`, `--manifest`), engine connect, **register the config schema (+ optional seed) with the `configuration` worker and fetch the authoritative value** (boot-fatal on failure), build adapters from it, `register_all`, then bind the `configuration` hot-reload trigger; Ctrl+C → `shutdown_async`. | +| [src/lib.rs](../src/lib.rs) | Module tree only. | +| [src/types.rs](../src/types.rs) | Wire contracts shared with the agentic family: `Role`, `ContentBlock` (5 variants), `AgentMessage` (4 roles), `ModelInput`, `ModelLimits`, `Model`, `ThinkingLevel`, `AgentFunction`. Serde renames keep the JSON byte-compatible with the TypeScript spec and session-manager's Rust copy. | +| [src/config.rs](../src/config.rs) | `WorkerConfig` (10 budget/prune/lease knobs incl. `lease_dir`, `~/`-expanded via `resolved_lease_dir`), each with a serde default; `deny_unknown_fields` so a typo'd key fails loudly. Also the JSON-Schema source (`json_schema`/`to_json`/`from_json`, derived `JsonSchema`) and the env-expanding seed parser (`from_file`/`from_yaml`); `boot_signature` names the restart-required fields. | +| [src/configuration.rs](../src/configuration.rs) | The `configuration` worker client: `register_config` (schema + seed), `fetch_config` (authoritative, env-expanded), the `ConfigCell` snapshot + `apply_config`, the `reloadable` restart-required gate, and the `context::on-config-change` trigger handler. | +| [src/error.rs](../src/error.rs) | `ContextError` → `code: message` on the bus (`context/invalid_request`, `context/model_unresolved`, `context/state`). The two spec strings are kept verbatim. | +| [src/ports.rs](../src/ports.rs) | The four seams: `ModelResolver`, `Summarizer`, `LeaseStore`, `Clock`, plus the `Deps` struct every handler receives. | +| [src/manifest.rs](../src/manifest.rs) | `--manifest` JSON for the registry publish pipeline; `default_config` mirrors `WorkerConfig::default()` field-for-field (unit-tested). | +| [src/functions/mod.rs](../src/functions/mod.rs) | Function ids + descriptions, `resolve_model` (the spec's resolution order), the generic typed `register` helper, `register_all`, and the schema `catalog()` (golden-tested). | +| [src/functions/<verb>.rs](../src/functions) | One file per function: request/response structs (serde + `JsonSchema`, doc comments become schema descriptions) and a `pub async fn handle(deps, req)`. BDD calls these `handle` fns directly, so engine-free tests exercise the exact production path. | +| [src/core/budget.rs](../src/core/budget.rs) | `ResolvedModel`, the `usable` math, `default_reserved`, `preserve_recent_budget`, `fallback_model`. | +| [src/core/estimate.rs](../src/core/estimate.rs) | `Estimator` trait + `HeuristicEstimator` (`chars/4`), `estimator_for_model`, per-role tallies. | +| [src/core/prune.rs](../src/core/prune.rs) | The prune algorithm (newest-first scan, protected window, `min_free_tokens` guard, in-place placeholder rewrite). | +| [src/core/selection.rs](../src/core/selection.rs) | Turn partitioning and token-aware verbatim-tail selection with the safe-cut invariant. | +| [src/core/summary.rs](../src/core/summary.rs) | Summariser prompt construction (template, previous-summary anchoring), `strip_media`, and the `# Conversation summary` system-prompt rendering. | +| [src/core/lease.rs](../src/core/lease.rs) | Compaction lease acquire/release protocol + the default sha256 lease key. | +| [src/adapters/router.rs](../src/adapters/router.rs) | `RouterModelResolver` (`router::models::get`) and `RouterSummarizer` (`router::chat` over an SDK channel). | +| [src/adapters/fs_lease.rs](../src/adapters/fs_lease.rs) | `FsLeaseStore`: one JSON file per lease key under `lease_dir//`, a process-local `Mutex` cache, and atomic `tmp + rename` writes (session-manager's `FsStore` strategy). | +| [tests/](../tests) | Cucumber BDD (`tests/bdd.rs`, `harness = false`) + schema goldens + manifest subprocess test. See §13. | + +## 2. Request lifecycle + +```mermaid +flowchart LR + bus[engine bus] -->|"typed request (serde rejects malformed)"| handler["functions::<verb>::handle"] + handler -->|resolve_model| resolver[ModelResolver port] + handler --> core["core:: budget / estimate / prune / selection / summary"] + core -->|"compaction only"| lease[LeaseStore + Clock ports] + core -->|"compaction only"| summarizer[Summarizer port] + handler -->|response or ContextError| bus +``` + +- **Input validation is two-layered.** Serde shape validation at the boundary + (unknown enum values, wrong types) fails before any logic runs; then the + handler checks the one thing serde can't (`messages` present and non-null → + else `context/invalid_request: messages is required`). +- **Handlers orchestrate; `core` decides.** A handler resolves the model, + computes the budget, calls the pure `core` functions, and (for compaction) + acquires/releases the lease and invokes the summariser port. No `core` + module performs I/O. +- **`Deps` is the whole world.** `Deps { config, resolver, summarizer, leases, + clock }` — every handler takes `&Deps`, so swapping the four ports for fakes + reproduces the exact production code path without an engine. +- **Config is a hot-swappable snapshot.** `Deps.config` is a `ConfigCell` + (`Arc>>`) sourced from the `configuration` worker; + handlers call `deps.config().await` once per request. A `configuration:updated` + trigger ([configuration.rs](../src/configuration.rs)) re-fetches and swaps it + live for the per-call tuning knobs, but refuses changes to `lease_dir` / + `summarizer_timeout_ms` (consumed once at boot for the `FsLeaseStore` / + `RouterSummarizer`) with a "restart required" log. + +## 3. The assemble pipeline + +`context::assemble` is the worker's reason to exist; the other three functions +are either steps of it or probes into it. The pipeline, all in +[functions/assemble.rs](../src/functions/assemble.rs): + +```mermaid +flowchart TD + A[resolve model limits] --> B[compute usable budget] + B --> C["build model-facing view: drop role:custom, record view_to_orig"] + C --> D["render system prompt: base + optional previous_summary"] + D --> E[count tokens] + E --> F{over usable AND allow_prune?} + F -->|yes| G[prune verbose function outputs] --> H[recount] + F -->|no| H + H --> I{still over AND allow_compaction?} + I -->|yes| J["try_compact under lease: select tail, summarise head"] + I -->|no| K[assemble response] + J -->|summary produced| L["replace system prompt with summary, drop head, recount"] --> K + J -->|busy / failed / empty| K +``` + +Load-bearing details: + +1. **Order is fixed: count → prune → compact.** Prune is cheap (no LLM) and + often enough, so it runs first; compaction is the expensive fallback. Each + step re-counts, and each is gated on *still being over budget*, so a context + that already fits passes through byte-identical with `applied` all-false and + no summariser cost (`assemble.feature` "under budget passes through + untouched"). +2. **The model-facing view excludes `role: "custom"`.** Custom messages have + no provider wire mapping, so they are filtered out of `working` *before* + counting (a huge custom entry must not trigger a phantom overflow) and never + appear in the returned `messages`. `view_to_orig: Vec` records, for + each surviving message, its index in the original request array — so + `tail_start_index` can be reported against the **request** array the caller + holds, customs included (`invariants.feature` "tail_start_index accounts for + excluded custom messages"). +3. **Degradation is best effort and visible, never an error.** A busy lease, a + failed/unavailable summariser, or disabled steps all leave the turn alive: + the response still returns, `applied.compacted` is false, and + `token_count > usable` is the visible signal that the context didn't fit. + `assemble` only throws for `messages is required` or `could not resolve + model limits` (fallback disabled). +4. **`applied` is the audit trail.** `{ pruned, pruned_tokens, compacted, + summary?, tail_start_index?, tokens_before? }` reports exactly what ran. + `summary`/`tail_start_index`/`tokens_before` appear only when + `compacted` — `tail_start_index` is `Some(Some(i))` for a real cut and + `Some(None)` for "everything summarised", serialised as a number or `null`. +5. **The compaction lease key defaults to a hash of the *request* messages** — + the same derivation `context::compact` uses — so a caller hitting both + functions with the same history contends on the same claim. + +## 4. Token budget model + +[core/budget.rs](../src/core/budget.rs). The usable input budget is +model-adaptive: + +```text +base = input_limit ?? (context_window - max_output_tokens) +usable = max(0, base - reserved - thinking_budget) +``` + +- **All arithmetic saturates.** A model whose output budget exceeds its window, + or a reserve larger than the base, clamps `usable` to 0 — never wraps around + `u64` (`model_and_budget.feature` "a model smaller than its own output budget + clamps to zero"). +- **`reserved`** defaults to `default_reserved = min(reserved_tokens_cap, + context_window * reserved_pct / 100)` = `min(20000, 10%)` with stock config. + This scales the reserve down for small models (a flat 20k would eat a third + of a 32k window) while capping it for huge ones. Overridable per call via + `options.reserved_tokens`. +- **`thinking_budget`** is `thinking_budgets[thinking_level]` *only* when the + caller passes `options.thinking_level` **and** the resolved model declares a + budget for that tier; otherwise 0. Inline limits carry no budgets, so a + thinking level is a no-op for standalone callers. This is how assemble leaves + room for the reasoning tokens a thinking tier will consume. +- Worked: a 200k/8k model with defaults → `200000 - 8000 - 20000 = 172000` + usable; a 32k/16k model → `32000 - 16000 - 3200 = 12800`; the conservative + fallback (8192/1024) → `8192 - 1024 - 819 = 6349`. + +`preserve_recent_budget(usable, override)` is the **separate** budget that caps +the verbatim tail during compaction: `clamp(usable / 4, 2000, 8000)`, or the +caller's `preserve_recent_tokens` verbatim. It is intentionally small — the +tail is "the last little bit kept raw", not a second copy of the window. + +## 5. Token estimation + +[core/estimate.rs](../src/core/estimate.rs). Estimation sits behind the +`Estimator` trait with three methods (`message`, `text`, `function`) and a +`kind()` the response echoes. + +- **v1 ships one implementation: `HeuristicEstimator` = serialized-JSON + `chars / 4`.** It counts the *full serialized message*, so structure and + metadata weigh in roughly as they do on the wire, and it is deterministic and + model-independent. `estimator_for_model` ignores the model id today and always + returns the heuristic; `count-tokens` reports `estimator: "heuristic"`. +- The trait is the seam for a real per-model tokenizer later: slot it into + `estimator_for_model`, return `EstimatorKind::Tokenizer`, and every count — + budget math, prune sizing, tail selection — picks it up with no caller + change. The reported `estimator` field makes the swap observable. +- `estimate_by_role` partitions a message set into `{ user, assistant, + function_result, custom }`; the buckets sum exactly to the message total + (`count_tokens.feature`), which is the contract that lets callers subtract + the custom share. + +## 6. Pruning + +[core/prune.rs](../src/core/prune.rs). One pass, newest-to-oldest, that +rewrites verbose `function_result` outputs to `[output pruned: was ~N tokens]` +and **never removes anything** — the message, its `function_call_id`, and the +ordering all survive (the structural invariant providers depend on). + +Eligibility, applied while scanning from the newest message backward: + +1. **The two most recent user turns are always exempt** (`PROTECTED_USER_TURNS + = 2`, a hard prior-art constant, not operator-tunable): the scan counts + `user` messages and skips everything until it has passed two of them. +2. **Protected token window:** accumulate each scanned output's tokens into + `window_tokens`; while `window_tokens <= protect_recent_tokens` the output + is inside the newest window and kept. Because the scan is newest-first, the + freshest outputs fill the window and push older ones out of it. +3. **`protected_functions`** (by `function_id`) are never pruned and never even + counted as scanned. +4. **Verbosity threshold:** an output whose text is `<= max_output_chars` is + not "verbose" — pruning it would free almost nothing — so it stays. +5. **`min_free_tokens` guard:** sum what the eligible outputs *would* free; if + that total is below `min_free_tokens`, **nothing is rewritten at all**. A + no-op beats a destroyed-but-still-over context. This guard fires before any + mutation, so a skipped pass leaves `messages` untouched. + +`PruneStats { pruned_tokens, pruned_parts, scanned_parts }` distinguishes +"examined" from "rewritten". The pass is idempotent: a second run sees only the +tiny placeholders, which are under `max_output_chars`, so nothing is verbose +(`prune.feature` "pruning twice is idempotent"). On the assemble path the same +`core::prune::prune` runs with config-derived params plus the call's +`protected_functions`. + +## 7. Compaction — tail selection + +[core/selection.rs](../src/core/selection.rs) decides the head/tail boundary; +it is pure and the most invariant-sensitive code in the worker. + +- **Turns** partition the history: a turn starts at each `user` message and + runs to the next one. Messages before the first user message belong to no + turn and are always head material. +- **`select(messages, budget, tail_turns, estimator)`** keeps up to the last + `tail_turns` whole turns that fit `budget`, newest-first; when a whole turn + won't fit, it falls back to a **safe partial cut** inside that turn via + `split_turn`. Everything before the kept tail is the head to summarise. +- **The safe-cut invariant** is the whole point. A `function_call` and its + `function_result` must land on the same side of the boundary, or providers + reject the orphaned result. So the tail may only *start* at: + - an `assistant` message, or + - a `user` message that carries **no inline `function_result` block** + (Anthropic-style tool results travel inside user messages; a user message + that opens with one has its call in the previous turn). + + A `function_result` message, or such a user message, is **not** a safe cut. + When the newest turn starts unsafely, selection accumulates it and defers the + boundary to an older safe turn, carrying the unsafe turn tail-side with its + call (`selection.rs` tests + `invariants.feature`). +- `Selection { head_len, tail_start_index }`. `tail_start_index` is `None` when + nothing could be safely kept verbatim (whole history is head); the handler + maps a `Some(view_index)` back through `view_to_orig` before returning it. +- `tail_turns: 0` summarises everything (`tail_start_index: null`). + +## 8. Compaction — summariser prompts and the round trip + +[core/summary.rs](../src/core/summary.rs) builds what the summariser sees and +renders what the caller gets back. + +- **`build_system_prompt(previous_summary)`** — instructs either "Create a new + anchored summary" (no prior) or "Update the anchored summary below …" + wrapping the prior in `` tags (anchored). Either way it + appends `SUMMARY_TEMPLATE`, the fixed Markdown structure (Goal / Constraints + & Preferences / Progress {Done, In Progress, Blocked} / Key Decisions / + Actions Taken / Next Steps / Critical Context / Relevant Files). Update mode + is how summaries **converge instead of growing**. +- **`render_user_prompt(head)`** — the head messages inside a `` + block: text verbatim, `function_call`s as terse `[tool_call] ` + one-liners; other block kinds dropped. +- **`strip_media(head, max_output_chars)`** — a copy fed to the summariser with + images replaced by `[image stripped]` everywhere and `function_result` text + truncated to `max_output_chars` (+ `... [truncated]`). The original + transcript the caller holds is never mutated. This keeps base64 blobs and + one giant tool dump from dominating the summariser's input. +- **`render_system_prompt(base, summary)`** — what assemble returns: the base + prompt, then a `# Conversation summary` section with the summary. Empty base + → just the section; no summary → just the base. + +The compaction round trip, the worker's only stateful-feeling contract (the +state is the *caller's*, not the worker's): + +```mermaid +sequenceDiagram + participant C as caller (e.g. harness) + participant A as context::assemble + participant Store as caller's store + C->>A: assemble(messages, model) + A->>A: over budget -> compact head + A-->>C: applied.compacted, summary, tail_start_index + C->>Store: persist summary + boundary (caller maps index -> own id) + Note over C,Store: later turn + C->>A: assemble(post-compaction window, previous_summary = stored summary) + A->>A: render summary into system prompt; if over again, UPDATE the anchor + A-->>C: new summary (converged), new tail_start_index +``` + +The worker **never persists**. A caller that skips persistence stays correct +but pays one summariser call per over-budget request, because every call +re-summarises from scratch (`assemble_round_trip.feature` "skipping persistence +costs one summariser call per request"). Persisting `summary` + +`tail_start_index` and passing them back as `previous_summary` + a trimmed +`messages` window is what makes it cheap and convergent. + +## 9. Compaction leases and concurrency + +[core/lease.rs](../src/core/lease.rs) over the `LeaseStore` port. The only +state the worker writes: a `{ nonce, ts }` claim per lease, stored as one JSON +file at `//.json` (scope `context_lease`), keyed by +`options.lease_key` or `default_lease_key` (sha256 over the serialized message +set, separator byte between messages so `["ab"]` and `["a","b"]` never +collide). The key derivation is deliberately `sha2`, not a std hasher, so it is +**stable across processes and Rust versions** — two callers of the same logical +history always derive the same file. + +Storage is the [`FsLeaseStore`](../src/adapters/fs_lease.rs) adapter, which +copies session-manager's `FsStore` strategy: a process-local `Mutex` cache plus +atomic `tmp + rename` writes. Atomicity is therefore **per-process** — the +single-writer-per-`lease_dir` assumption session-manager also makes — which is +sufficient for one context-manager instance. The protocol below holds within +that process: `swap` runs the whole read-modify-write under the cache mutex, so +concurrent acquirers serialise. + +The protocol (ported from harness `runtime/lease.ts`): + +- **TTL is enforced by readers.** A claim whose `ts` is older than `ttl_ms` + reads as free, folding crash recovery into acquisition — a dead holder's + lease simply expires (`lease_ttl_secs`, default 300). The boundary is strict: + 299999 ms old still wins, 300001 ms is taken over. +- **`acquire`**: read the key; if a live claim exists, return `None` (busy). + Otherwise write a fresh `{ nonce, ts }` via the atomic **`swap`** and inspect + the prior value it returns; if we clobbered a *still-live* claim (a racing + acquirer beat us), restore it and bow out. Exactly one concurrent acquirer + sees a free/expired prior and wins. +- **`release`** only clears the key **if it still holds our nonce** — a holder + that lost the lease to TTL takeover must never wipe the new holder's claim. +- **Store failures read as busy, never as a win.** A `get`/`swap` error returns + `None` (busy) so a filesystem outage can't let every contender summarise at + once; a transient busy is retryable (`compact_lease.feature` "a state outage + reads as busy, never as a win"). + +`compact` returns `status: "busy"` on a held lease; `assemble` silently skips +compaction (best effort) and leaves the holder's claim intact. The cache mutex +serialises acquirers within the process, and the per-history key keeps unrelated +histories from interfering; the worker is otherwise stateless. + +## 10. Ports and adapters + +The four ports in [ports.rs](../src/ports.rs), production adapters in +[adapters/](../src/adapters): + +| Port | Production adapter | Backed by | Failure posture | +|---|---|---|---| +| `ModelResolver` | `RouterModelResolver` | `router::models::get` (5s timeout) | `Ok(None)` = router up but model unknown; `Err` = router absent/unreachable. Both → fallback when allowed. | +| `Summarizer` | `RouterSummarizer` | `router::chat` over an SDK channel | `Unavailable` (router not routable) vs `Failed` (provider/stream error) vs `Empty`. | +| `LeaseStore` | `FsLeaseStore` | one JSON file per key under `lease_dir` (atomic `tmp + rename`; process-local `Mutex` for `swap`) | Errors surface as busy via `core::lease`. | +| `Clock` | `SystemClock` | wall clock (ms since epoch) | — | + +`RouterSummarizer` details worth knowing before touching it: + +- It creates an SDK channel, passes `writer_ref` to `router::chat`, and reads + the streamed `AssistantMessageEvent` frames. Frames may arrive as WS text + (callback) or binary (`read_all`); it collects both because the router's + framing is provider-defined. +- `extract_summary` folds frames: the terminal `done` event's message text + wins; accumulated `text_delta`s are the fallback; a terminal `error` (or a + `stop` with `stop_reason: "error"`) fails. Empty result → `SummarizeError::Empty`. +- `summarizer_timeout_ms` (default 320000) is the **outer** budget for the + whole call and must exceed the router's own stream budget; `READER_DRAIN_MS` + is a 2s grace for the socket to drain after the trigger returns. +- Unroutable detection is string-sniffing the error (`function_not_found` / + `not found` / `no function`) → `Unavailable`, which `compact` maps to + `status: "overflow"` with `error_kind: "permanent"` logged. This is the + defined degraded mode when `llm-router` is absent. + +`FsLeaseStore::swap` holds the cache `Mutex` across the whole read-modify-write: +it reads the current claim (replaying the key's file on a cold cache), writes the +new `{ nonce, ts }` atomically (`tmp + rename`), and returns the prior — so two +acquirers in the process serialise and exactly one sees a free/expired prior. A +missing file, or a file whose contents aren't a `{ nonce, ts }` record, reads as +`None` (free) — the same tolerant posture as the harness lease. All filesystem +I/O is synchronous and never crosses an `.await` while the guard is held. + +## 11. Model resolution + +`functions::resolve_model` implements the spec order exactly: + +1. `input.limits` present → `ResolvedModel::from_inline` (`model_resolved: + "inline"`); no router call. This is the standalone path. +2. else `router::models::get(provider, id)`: + - `Ok(Some(model))` → `from_router` (`"router"`), carrying the model's + `thinking_budgets`. + - `Ok(None)` (unknown) or `Err` (router down/absent) → fall through. +3. fallback: if `allow_fallback_limits` (default true), `fallback_model()` + (8192/1024, `"fallback"`, logged with the reason). Else + `ContextError::ModelUnresolved` → `context/model_unresolved: could not + resolve model limits (...)`. + +A dead router and an unknown model are deliberately indistinguishable to the +caller — both yield the same detectable `fallback`. Inline limits never carry +`thinking_budgets`, so inline callers can't accidentally pay a thinking tax. + +## 12. Error model + +`ContextError` (in [error.rs](../src/error.rs)) renders as `code: message`; +the code before the colon is the stable contract. One mapping point: +`From for IIIError` (always `IIIError::Handler`). + +| Variant | Code | When | +|---|---|---| +| `InvalidRequest` | `context/invalid_request` | `messages` absent/null (`messages is required`); serde-survivable shape problems. | +| `ModelUnresolved` | `context/model_unresolved` | No inline limits, router can't resolve, fallback disabled (`could not resolve model limits`). | +| `State` | `context/state` | A filesystem call backing the lease failed (reserved; lease failures normally degrade to busy rather than throw). | + +`messages is required` and `could not resolve model limits` are kept word-for-word +because callers match on them (`errors.feature`). Adding a variant means: add +the code, a `Display` test in `error.rs`, and an `errors.feature` scenario. + +## 13. Configuration, boot, and the wire surface + +Runtime config is **registered with and fetched from the `configuration` +worker** ([configuration.rs](../src/configuration.rs)); a `--config` YAML file +only SEEDS the first registration. The shape below (shipped defaults, also the +registered schema's `example`) is what `WorkerConfig::json_schema()` describes: + +```yaml +reserved_tokens_cap: 20000 # default reserve = min(cap, reserved_pct% of context_window) +reserved_pct: 10 +tail_turns: 2 # user+assistant pairs kept verbatim by compaction +protect_recent_tokens: 40000 # newest function-output tokens never pruned +min_free_tokens: 20000 # skip pruning when it would free less +max_output_chars: 2000 # outputs at/under this are not "verbose"; also the summariser truncation cap +lease_ttl_secs: 300 # compaction mutual-exclusion lease TTL +allow_fallback_limits: true # conservative 8192/1024 when limits can't resolve +summarizer_timeout_ms: 320000 # outer budget for one router::chat summariser call +``` + +Boot and reload rules (`main.rs` / `configuration.rs`): + +- `configuration` is a **required boot dependency**: `register_config` then + `fetch_config` run over the live connection, and a failure aborts startup. A + missing stored value seeds defaults (or the `--config` seed); a `null` value + reads as defaults. +- The config **hot-reloads**: a `configuration:updated` trigger re-fetches the + authoritative value and swaps the snapshot. `lease_dir` and + `summarizer_timeout_ms` are consumed once at boot (the `FsLeaseStore` and + `RouterSummarizer` are built then and never rebuilt), so a change to either is + refused on reload (logged "restart required", previous snapshot kept) — every + other field applies live. The handler ignores its trigger payload and + re-fetches, so a forged call can at most reload the already-stored value. +- A `--config` file is only a SEED for the first registration; an unparseable + seed WARNS and is skipped (the authoritative value comes from the worker). +- Every config field is per-call-overridable where the spec allows + (`reserved_tokens`, `tail_turns`, the prune thresholds, `lease_key`, + `preserve_recent_tokens`); request options take precedence over config. +- `llm-router` is soft: the worker serves `count-tokens` and `prune` without it; + only compaction and router-based model resolution degrade. + +**The wire surface is golden-tested.** `functions::catalog()` returns each +function's id, description, and schemars-derived request/response schemas, in +registration order; `tests/schemas.rs` diffs them against +[tests/golden/schemas/](../tests/golden/schemas) (regenerate with +`UPDATE_GOLDENS=1`). `schema_of` mirrors iii-sdk's internal generator +(`SchemaSettings::draft07()`), so a golden pins exactly what `register_async` +emits. Keep `catalog()` in lockstep with `register_all`. + +## 14. Determinism and testing + +Everything a test needs to pin behaviour is injectable: the four ports +(`ModelResolver`, `Summarizer`, `LeaseStore`, `Clock`). The production binary +is one composition of them; the BDD world is another. + +- **`tests/bdd.rs`** (cucumber, `harness = false`). Tags: `@pure` needs nothing + (calls `handle` fns directly with fake ports — see `tests/common/world.rs`, + `tests/common/fakes.rs`); `@engine` registers the production surface in-process + against a live engine and soft-skips when none is reachable. +- The `@pure` world wires fake ports: a scriptable resolver (known models, + unavailable, declared thinking budgets), a recording summariser (returns / + fails / unavailable / empty, captures the prompts + model it ran on), an + in-memory lease store (+ "foreign lease", "unavailable"), and a manually + advanced `FakeClock` to drive TTL expiry. +- **`engine_roundtrip.feature`** is the proof that the registered wire surface + matches the handlers: real triggers, production adapters, `llm-router` absent + — i.e. the spec's degraded mode (limits fall back, prune/count work, compact + overflows after a real filesystem lease acquire/release cycle). +- Unit tests live next to what they pin: budget math in `budget.rs`, the + heuristic in `estimate.rs`, prune eligibility in `prune.rs`, the safe-cut + matrix in `selection.rs`, prompt construction in `summary.rs`, lease key + stability and TTL in `lease.rs`, frame folding in `adapters/router.rs`, + config defaults in `config.rs`. +- Convention: every scenario carries a `# Prevents:` comment naming the + regression it catches. + +Verification commands (CI parity): + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features # @engine soft-skips without iii +cargo test --test bdd -- --tags @pure # no engine required +UPDATE_GOLDENS=1 cargo test --test schemas # regenerate wire-schema goldens +./target/debug/context-manager --manifest | jq . +``` + +## 15. Sharp edges and known limitations + +- **The estimator is `chars/4`, not a real tokenizer.** Every budget decision, + prune sizing, and tail fit is an *estimate*; `token_count` can differ from + what the provider bills. The trait is ready for a per-model tokenizer; until + then treat all counts as approximate and keep `reserved` as the cushion. +- **Compaction needs `llm-router`.** Without it, `compact` returns `overflow` + and `assemble` can prune but not summarise — an over-budget context comes + back over budget (visibly, via `token_count > usable`). Pure token/prune + calls are unaffected. +- **One summariser call per over-budget request if the caller doesn't persist.** + The round trip (§8) is the fix; skipping it is correct but not cheap. +- **`assemble`/`compact` can spend money.** They may trigger a summariser LLM + call; `count-tokens` and `prune` never do. Deny the former two in + cost-sensitive agent deployments (see [integration.md §9](integration.md)). +- **Lease key is content-derived by default.** Two callers compacting the + *same* history contend even without a shared `lease_key`; conversely, a + single logical session whose message set changes between calls gets a + different default key, so pass an explicit `lease_key` (e.g. the session id) + to serialise a live session's compactions. +- **No long-term / vector memory.** v1 compresses one history for one turn; + durable cross-session memory belongs in a dedicated sibling worker (spec + Boundaries). + +## 16. How to extend + +- **New function:** add `src/functions/.rs` with typed request/response + (+ doc comments → schema descriptions) and a `pub async fn handle(deps, + req)`; add the id/description consts, a `register(...)` line in + `register_all`, and a `spec::(...)` line in `catalog()` (then + regenerate the schema golden); add a dispatch arm in `tests/common/world.rs` + and a feature file. +- **Real tokenizer:** implement `Estimator`, return `EstimatorKind::Tokenizer`, + and key it off the model in `estimator_for_model`. Nothing else changes; the + reported `estimator` field flips to `"tokenizer"` and pins the swap. +- **New budget/prune knob:** add a field to `WorkerConfig` (with a `default_*` + fn and the `Default` impl), mirror it in `manifest.rs::default_config` (a + unit test enforces parity), thread it through the relevant `core` params, and + expose a per-call override in the function's `Options` if the spec allows it. +- **New port-backed dependency:** add a trait to `ports.rs`, a production + adapter in `adapters/`, a field on `Deps`, a fake in `tests/common/fakes.rs`, + and wiring in `main.rs`. Keep `core` pure — it should depend on the trait, + never the adapter. diff --git a/context-manager/build.rs b/context-manager/build.rs new file mode 100644 index 00000000..81caa36d --- /dev/null +++ b/context-manager/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/context-manager/config.yaml b/context-manager/config.yaml new file mode 100644 index 00000000..86acad2b --- /dev/null +++ b/context-manager/config.yaml @@ -0,0 +1,40 @@ +# context-manager runtime configuration. +# +# Token-budget knobs. The usable input budget per call is +# usable = (input_limit ?? context_window - max_output_tokens) +# - reserved - thinking_budget +# where reserved defaults to min(reserved_tokens_cap, +# context_window * reserved_pct / 100) and is overridable per call. + +# Cap on the default reserve (tokens). +reserved_tokens_cap: 20000 +# Percentage of the context window reserved by default. +reserved_pct: 10 + +# user+assistant turn pairs always kept verbatim by compaction when the +# request omits options.tail_turns. +tail_turns: 2 + +# Prune defaults (overridable per call via options). +protect_recent_tokens: 40000 # newest function-output tokens never pruned +min_free_tokens: 20000 # skip pruning when it would free less +max_output_chars: 2000 # outputs at or under this size are not "verbose" + +# Compaction mutual-exclusion lease TTL (seconds). A crashed holder's +# claim expires after this long. +lease_ttl_secs: 300 + +# Directory holding compaction-lease files (one `/.json` per +# lease, written atomically). A leading `~/` expands to the home +# directory. Mirrors session-manager's data_dir; this worker is the +# single writer of this directory. +lease_dir: ~/.iii/data/context-manager + +# When the request has no inline limits and llm-router is absent, fall +# back to a conservative model (context_window 8192 / max_output 1024) +# instead of failing. Set false to make unresolvable models an error. +allow_fallback_limits: true + +# Outer budget for one summariser call through router::chat (ms). Must +# exceed the router's own stream budget. +summarizer_timeout_ms: 320000 diff --git a/context-manager/iii.worker.yaml b/context-manager/iii.worker.yaml new file mode 100644 index 00000000..f42bc6c7 --- /dev/null +++ b/context-manager/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: context-manager +language: rust +deploy: binary +manifest: Cargo.toml +bin: context-manager +description: Turns a raw conversation history plus a target model into a model-ready context — token counting, function-result pruning, and history compaction. diff --git a/context-manager/src/adapters/fs_lease.rs b/context-manager/src/adapters/fs_lease.rs new file mode 100644 index 00000000..c1506b2e --- /dev/null +++ b/context-manager/src/adapters/fs_lease.rs @@ -0,0 +1,322 @@ +//! Filesystem-backed compaction-lease storage, mirroring +//! `session-manager`'s `FsStore` strategy (`session-manager/src/store/fs.rs`): +//! one file per lease key under `//`, a process-local +//! `Mutex` cache, and atomic `tmp + rename` writes. +//! +//! Atomicity is **process-local** — the same single-writer-per-dir +//! assumption session-manager makes — which is sufficient for a single +//! context-manager instance. `swap` (the atomic read-modify-write +//! `core::lease` relies on) holds the cache mutex across the whole +//! read/decide/write, so two concurrent acquirers in this process +//! serialize: exactly one sees a free/expired prior and wins. +//! +//! Each lease file holds exactly one `{nonce, ts}` value, so every write +//! is a full O(1) rewrite (session-manager's `persist_snapshot` +//! mechanism); there is no append/replay-merge to do. A missing file — +//! or a file whose contents aren't a `{nonce, ts}` record — reads as free +//! (`None`), matching the state-backed store's `parse_record` and +//! session-manager's malformed-skip posture. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::ports::{LeaseRecord, LeaseStore}; + +/// Encode a lease key into a safe filename stem: `[A-Za-z0-9._-]` pass +/// through, everything else becomes `%XX` (uppercase hex). Keys are +/// caller-supplied (`options.lease_key`, e.g. a session id) or a sha256 +/// hex digest, so the encoding both keeps filenames portable and blocks +/// path traversal. Ported from session-manager's `encode_session_id`. +fn encode_key(key: &str) -> String { + let mut out = String::with_capacity(key.len()); + for byte in key.bytes() { + match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-' => out.push(byte as char), + other => out.push_str(&format!("%{other:02X}")), + } + } + out +} + +/// Inverse of [`encode_key`]; `None` for malformed escapes. Only the +/// encode direction is needed in production (keys never round-trip back +/// from filenames), so this is the exact session-manager inverse kept for +/// the encoding-safety test. +#[cfg(test)] +fn decode_key(stem: &str) -> Option { + let bytes = stem.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = bytes.get(i + 1..i + 3)?; + let hex = std::str::from_utf8(hex).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// `(scope, key) -> Option` cache. A present `None` means +/// "loaded and free"; an absent entry means "not yet read from disk" +/// (cold), mirroring session-manager's `contains_key` cold check. +type Cache = HashMap<(String, String), Option>; + +pub struct FsLeaseStore { + dir: PathBuf, + cache: Mutex, +} + +impl FsLeaseStore { + /// Open (and create if needed) the lease directory. + pub fn new(dir: impl Into) -> Result { + let dir = dir.into(); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("create lease_dir {}: {e}", dir.display()))?; + Ok(Self { + dir, + cache: Mutex::new(HashMap::new()), + }) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Cache> { + self.cache + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + fn scope_dir(&self, scope: &str) -> PathBuf { + self.dir.join(encode_key(scope)) + } + + fn file_path(&self, scope: &str, key: &str) -> PathBuf { + self.scope_dir(scope) + .join(format!("{}.json", encode_key(key))) + } + + /// Read the on-disk record for a key. Missing file -> `None` (free); + /// contents that don't parse as a `{nonce, ts}` record -> `None` too. + fn read_file(&self, scope: &str, key: &str) -> Result, String> { + let path = self.file_path(scope, key); + match std::fs::read_to_string(&path) { + Ok(contents) => Ok(serde_json::from_str::(&contents).ok()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("read {}: {e}", path.display())), + } + } + + /// Atomically write one record: tmp + rename, creating the scope dir + /// on demand. Mirrors session-manager's `persist_snapshot`. + fn write_file(&self, scope: &str, key: &str, record: &LeaseRecord) -> Result<(), String> { + let dir = self.scope_dir(scope); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("create lease scope dir {}: {e}", dir.display()))?; + let path = self.file_path(scope, key); + let body = + serde_json::to_string(record).map_err(|e| format!("serialize lease record: {e}"))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, body).map_err(|e| format!("write {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, &path) + .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?; + Ok(()) + } + + /// Remove a key's file. A missing file is a no-op. + fn remove_file(&self, scope: &str, key: &str) -> Result<(), String> { + let path = self.file_path(scope, key); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("remove {}: {e}", path.display())), + } + } + + /// Populate the cache entry for `(scope, key)` from disk on a cold + /// miss. Must be called with `cache` already locked. + fn ensure_loaded(&self, cache: &mut Cache, scope: &str, key: &str) -> Result<(), String> { + use std::collections::hash_map::Entry; + if let Entry::Vacant(slot) = cache.entry((scope.to_string(), key.to_string())) { + let record = self.read_file(scope, key)?; + slot.insert(record); + } + Ok(()) + } +} + +#[async_trait] +impl LeaseStore for FsLeaseStore { + async fn get(&self, scope: &str, key: &str) -> Result, String> { + let mut cache = self.lock(); + self.ensure_loaded(&mut cache, scope, key)?; + Ok(cache + .get(&(scope.to_string(), key.to_string())) + .cloned() + .flatten()) + } + + async fn set(&self, scope: &str, key: &str, value: Option) -> Result<(), String> { + let mut cache = self.lock(); + let k = (scope.to_string(), key.to_string()); + // Disk first, cache second: a failed write must never leave the + // cache claiming state the file doesn't have. + match value { + Some(record) => { + self.write_file(scope, key, &record)?; + cache.insert(k, Some(record)); + } + None => { + self.remove_file(scope, key)?; + cache.insert(k, None); + } + } + Ok(()) + } + + async fn swap( + &self, + scope: &str, + key: &str, + value: LeaseRecord, + ) -> Result, String> { + // The whole read-modify-write runs under the cache mutex, so two + // concurrent acquirers serialize and exactly one observes the + // free/expired prior. No `.await` is taken while the guard is + // held (all I/O below is synchronous), so the future stays Send. + let mut cache = self.lock(); + self.ensure_loaded(&mut cache, scope, key)?; + let k = (scope.to_string(), key.to_string()); + let prior = cache.get(&k).cloned().flatten(); + self.write_file(scope, key, &value)?; + cache.insert(k, Some(value)); + Ok(prior) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCOPE: &str = "context_lease"; + + fn rec(nonce: &str, ts: i64) -> LeaseRecord { + LeaseRecord { + nonce: nonce.into(), + ts, + } + } + + #[tokio::test] + async fn set_get_roundtrip_and_restart_replay() { + let dir = tempfile::tempdir().unwrap(); + { + let store = FsLeaseStore::new(dir.path()).unwrap(); + store.set(SCOPE, "k1", Some(rec("n1", 1000))).await.unwrap(); + assert_eq!(store.get(SCOPE, "k1").await.unwrap(), Some(rec("n1", 1000))); + } + // Fresh store over the same dir = worker restart: cache is cold, + // so the value must replay from disk. + let store = FsLeaseStore::new(dir.path()).unwrap(); + assert_eq!(store.get(SCOPE, "k1").await.unwrap(), Some(rec("n1", 1000))); + } + + #[tokio::test] + async fn set_none_removes_the_file() { + let dir = tempfile::tempdir().unwrap(); + let store = FsLeaseStore::new(dir.path()).unwrap(); + store.set(SCOPE, "k1", Some(rec("n1", 1))).await.unwrap(); + let path = store.file_path(SCOPE, "k1"); + assert!(path.exists()); + + store.set(SCOPE, "k1", None).await.unwrap(); + assert!(!path.exists()); + assert_eq!(store.get(SCOPE, "k1").await.unwrap(), None); + } + + #[tokio::test] + async fn swap_returns_prior_and_stores_new() { + let dir = tempfile::tempdir().unwrap(); + let store = FsLeaseStore::new(dir.path()).unwrap(); + + // First swap on a free key: prior is None. + assert_eq!(store.swap(SCOPE, "k1", rec("n1", 1)).await.unwrap(), None); + // Second swap: prior is the first claim, new is stored. + assert_eq!( + store.swap(SCOPE, "k1", rec("n2", 2)).await.unwrap(), + Some(rec("n1", 1)) + ); + assert_eq!(store.get(SCOPE, "k1").await.unwrap(), Some(rec("n2", 2))); + } + + #[tokio::test] + async fn cold_swap_sees_the_on_disk_prior() { + let dir = tempfile::tempdir().unwrap(); + { + let store = FsLeaseStore::new(dir.path()).unwrap(); + store.set(SCOPE, "k1", Some(rec("n1", 1))).await.unwrap(); + } + // A fresh store (cold cache) must read the prior from disk so a + // racing acquirer after restart can't be treated as a free win. + let store = FsLeaseStore::new(dir.path()).unwrap(); + assert_eq!( + store.swap(SCOPE, "k1", rec("n2", 2)).await.unwrap(), + Some(rec("n1", 1)) + ); + } + + #[test] + fn key_encoding_roundtrip_and_safety() { + let hostile = "../etc/passwd: weird key?"; + let encoded = encode_key(hostile); + assert!(!encoded.contains('/')); + assert!(!encoded.contains(' ')); + assert!(!encoded.contains(':')); + assert_eq!(decode_key(&encoded).as_deref(), Some(hostile)); + + // sha256-hex / session-id-style keys pass through unchanged. + assert_eq!(encode_key("s_abc-123.x"), "s_abc-123.x"); + // Malformed escapes are rejected, not mangled. + assert_eq!(decode_key("%ZZ"), None); + assert_eq!(decode_key("%4"), None); + } + + #[tokio::test] + async fn hostile_key_file_stays_inside_dir() { + let dir = tempfile::tempdir().unwrap(); + let store = FsLeaseStore::new(dir.path()).unwrap(); + let key = "../escape/чат 42"; + store.set(SCOPE, key, Some(rec("n", 1))).await.unwrap(); + + // Exactly one file landed under the scope dir, encoded — no + // traversal out of `lease_dir`. + let scope_dir = store.scope_dir(SCOPE); + let files: Vec<_> = std::fs::read_dir(&scope_dir).unwrap().collect(); + assert_eq!(files.len(), 1); + assert_eq!(store.get(SCOPE, key).await.unwrap(), Some(rec("n", 1))); + } + + #[tokio::test] + async fn malformed_or_foreign_value_reads_as_free() { + let dir = tempfile::tempdir().unwrap(); + let store = FsLeaseStore::new(dir.path()).unwrap(); + + // A stored value that isn't a `{nonce, ts}` record reads as free. + let path = store.file_path(SCOPE, "k1"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "\"just a string\"").unwrap(); + assert_eq!(store.get(SCOPE, "k1").await.unwrap(), None); + } + + #[tokio::test] + async fn unknown_key_reads_as_none() { + let dir = tempfile::tempdir().unwrap(); + let store = FsLeaseStore::new(dir.path()).unwrap(); + assert_eq!(store.get(SCOPE, "nope").await.unwrap(), None); + } +} diff --git a/context-manager/src/adapters/mod.rs b/context-manager/src/adapters/mod.rs new file mode 100644 index 00000000..166ea80b --- /dev/null +++ b/context-manager/src/adapters/mod.rs @@ -0,0 +1,5 @@ +//! Production adapters behind the ports: `llm-router` calls over the +//! iii bus and filesystem-backed lease storage. + +pub mod fs_lease; +pub mod router; diff --git a/context-manager/src/adapters/router.rs b/context-manager/src/adapters/router.rs new file mode 100644 index 00000000..e8b9c5a9 --- /dev/null +++ b/context-manager/src/adapters/router.rs @@ -0,0 +1,303 @@ +//! `llm-router` adapters: model-limit resolution via +//! `router::models::get` and the summariser via `router::chat`. +//! +//! Both are soft dependencies — when the router is absent the resolver +//! errors (callers degrade to the conservative fallback) and the +//! summariser reports `Unavailable` (compaction maps it to +//! `status: "overflow"`). + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use iii_sdk::helpers::create_channel; +use iii_sdk::{IIIError, TriggerRequest, III}; +use serde_json::{json, Value}; + +use crate::ports::{ModelResolver, SummarizeError, SummarizeRequest, Summarizer}; +use crate::types::Model; + +const MODELS_GET_TIMEOUT_MS: u64 = 5_000; +/// Grace period for the stream reader to drain after `router::chat` +/// returns; the terminal `done`/`error` event lands before or with the +/// trigger response, so this only covers socket latency. +const READER_DRAIN_MS: u64 = 2_000; + +fn is_unroutable(err: &IIIError) -> bool { + let msg = err.to_string().to_lowercase(); + msg.contains("function_not_found") || msg.contains("not found") || msg.contains("no function") +} + +/// `router::models::get` — `{ model: Model } | null`. +pub struct RouterModelResolver { + iii: Arc, +} + +impl RouterModelResolver { + pub fn new(iii: Arc) -> Self { + Self { iii } + } +} + +#[async_trait] +impl ModelResolver for RouterModelResolver { + async fn get_model(&self, provider: Option<&str>, id: &str) -> Result, String> { + let mut payload = json!({ "id": id }); + if let Some(p) = provider { + payload["provider"] = json!(p); + } + let resp = self + .iii + .trigger(TriggerRequest { + function_id: "router::models::get".to_string(), + payload, + action: None, + timeout_ms: Some(MODELS_GET_TIMEOUT_MS), + }) + .await + .map_err(|e| e.to_string())?; + + if resp.is_null() { + return Ok(None); + } + let model = resp + .get("model") + .cloned() + .ok_or_else(|| "router::models::get response has no `model` field".to_string())?; + if model.is_null() { + return Ok(None); + } + serde_json::from_value::(model) + .map(Some) + .map_err(|e| format!("router::models::get returned an unparseable model: {e}")) + } +} + +/// `router::chat` — one summariser turn streamed over a channel the +/// adapter creates; the summary text comes from the terminal `done` +/// event (accumulated `text_delta`s as fallback). +pub struct RouterSummarizer { + iii: Arc, + timeout_ms: u64, +} + +impl RouterSummarizer { + pub fn new(iii: Arc, timeout_ms: u64) -> Self { + Self { iii, timeout_ms } + } +} + +#[async_trait] +impl Summarizer for RouterSummarizer { + async fn summarize(&self, req: SummarizeRequest) -> Result { + let channel = create_channel(&self.iii, None) + .await + .map_err(|e| SummarizeError::Unavailable(format!("create_channel failed: {e}")))?; + + // Frames may arrive as WS text messages (dispatched to the + // callback) or as binary chunks (returned by read_all); collect + // both since the router's framing is provider-defined. + let text_frames: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = text_frames.clone(); + let reader = channel.reader; + reader + .on_message(move |frame| { + sink.lock() + .unwrap_or_else(|poison| poison.into_inner()) + .push(frame); + }) + .await; + let read_task = tokio::spawn(async move { reader.read_all().await }); + + let mut payload = json!({ + "writer_ref": channel.writer_ref, + "model": req.model, + "system_prompt": req.system_prompt, + "messages": [{ + "role": "user", + "content": [{ "type": "text", "text": req.user_prompt }], + "timestamp": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0), + }], + "tools": [], + }); + if let Some(p) = &req.provider { + payload["provider"] = json!(p); + } + + let trigger_result = self + .iii + .trigger(TriggerRequest { + function_id: "router::chat".to_string(), + payload, + action: None, + timeout_ms: Some(self.timeout_ms), + }) + .await; + + // The terminal event precedes the trigger response; give the + // socket a short drain window, then stop reading either way. + let abort = read_task.abort_handle(); + let binary = + match tokio::time::timeout(Duration::from_millis(READER_DRAIN_MS), read_task).await { + Ok(Ok(Ok(bytes))) => bytes, + _ => { + abort.abort(); + Vec::new() + } + }; + + let response = match trigger_result { + Ok(v) => v, + Err(e) if is_unroutable(&e) => { + return Err(SummarizeError::Unavailable(e.to_string())); + } + Err(e) => return Err(SummarizeError::Failed(e.to_string())), + }; + if response.get("ok").and_then(Value::as_bool) == Some(false) { + let detail = response + .get("error") + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown router error".to_string()); + return Err(SummarizeError::Failed(detail)); + } + + let mut frames: Vec = text_frames + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone(); + frames.extend(String::from_utf8_lossy(&binary).lines().map(str::to_string)); + + let summary = extract_summary(&frames)?; + if summary.is_empty() { + return Err(SummarizeError::Empty); + } + Ok(summary) + } +} + +/// Fold a stream of `AssistantMessageEvent` JSON frames into the final +/// summary text: the `done` event's message wins; accumulated +/// `text_delta`s are the fallback; a terminal `error` event fails. +fn extract_summary(frames: &[String]) -> Result { + let mut deltas = String::new(); + let mut done_text: Option = None; + + for frame in frames { + let Ok(event) = serde_json::from_str::(frame) else { + continue; + }; + match event.get("type").and_then(Value::as_str) { + Some("text_delta") => { + if let Some(d) = event.get("delta").and_then(Value::as_str) { + deltas.push_str(d); + } + } + Some("done") => { + done_text = Some(text_of_message(event.get("message"))); + } + Some("error") => { + return Err(SummarizeError::Failed(format!( + "summariser stream error: {}", + text_of_message(event.get("error")) + ))); + } + Some("stop") if event.get("stop_reason").and_then(Value::as_str) == Some("error") => { + let detail = event + .get("error_message") + .and_then(Value::as_str) + .unwrap_or("unknown provider error"); + return Err(SummarizeError::Failed(format!( + "summariser stream error: {detail}" + ))); + } + _ => {} + } + } + + Ok(done_text.filter(|t| !t.is_empty()).unwrap_or(deltas)) +} + +/// Concatenated text blocks of an `AssistantMessage`-shaped value. +fn text_of_message(message: Option<&Value>) -> String { + let Some(blocks) = message + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + else { + return String::new(); + }; + let mut out = String::new(); + for block in blocks { + if block.get("type").and_then(Value::as_str) == Some("text") { + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(v: Value) -> String { + v.to_string() + } + + #[test] + fn done_event_text_wins_over_deltas() { + let frames = vec![ + frame(json!({ "type": "text_delta", "partial": {}, "delta": "par" })), + frame(json!({ "type": "text_delta", "partial": {}, "delta": "tial" })), + frame(json!({ "type": "done", "message": { + "role": "assistant", + "content": [{ "type": "text", "text": "## Goal\n- finish" }] + }})), + ]; + assert_eq!(extract_summary(&frames).unwrap(), "## Goal\n- finish"); + } + + #[test] + fn deltas_are_the_fallback_without_done() { + let frames = vec![ + frame(json!({ "type": "text_delta", "delta": "a" })), + frame(json!({ "type": "text_delta", "delta": "b" })), + frame(json!({ "type": "ping" })), + ]; + assert_eq!(extract_summary(&frames).unwrap(), "ab"); + } + + #[test] + fn terminal_error_event_fails() { + let frames = vec![frame(json!({ "type": "error", "error": { + "content": [{ "type": "text", "text": "boom" }] + }}))]; + let err = extract_summary(&frames).unwrap_err(); + assert!(matches!(err, SummarizeError::Failed(_))); + assert!(err.to_string().contains("boom")); + } + + #[test] + fn stop_with_error_reason_fails() { + let frames = vec![frame( + json!({ "type": "stop", "stop_reason": "error", "error_message": "ctx overflow" }), + )]; + let err = extract_summary(&frames).unwrap_err(); + assert!(err.to_string().contains("ctx overflow")); + } + + #[test] + fn unparseable_frames_are_skipped() { + let frames = vec![ + "not-json".to_string(), + frame(json!({ "type": "text_delta", "delta": "ok" })), + ]; + assert_eq!(extract_summary(&frames).unwrap(), "ok"); + } +} diff --git a/context-manager/src/config.rs b/context-manager/src/config.rs new file mode 100644 index 00000000..c110f54c --- /dev/null +++ b/context-manager/src/config.rs @@ -0,0 +1,416 @@ +//! Operator-facing runtime configuration. +//! +//! The authoritative value comes from the `configuration` worker at boot +//! (see [`crate::configuration`]); a `--config` YAML file, when passed, +//! only SEEDS the initial registration. Every field has a serde default +//! so an empty object yields a fully-populated config; per-call request +//! options override these defaults where the spec allows it (reserved +//! tokens, tail turns, prune thresholds). + +use std::path::PathBuf; + +use anyhow::Result; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Root config shape. Unknown keys are rejected so a typo'd field +/// (e.g. `tail_truns: 3`) fails loudly instead of silently running the +/// default. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerConfig { + /// Cap on the default reserve: `min(cap, context_window * pct/100)`. + #[serde(default = "default_reserved_tokens_cap")] + pub reserved_tokens_cap: u64, + + /// Percentage of the context window reserved by default. + #[serde(default = "default_reserved_pct")] + pub reserved_pct: u64, + + /// user+assistant turn pairs kept verbatim by compaction when the + /// request omits `options.tail_turns`. + #[serde(default = "default_tail_turns")] + pub tail_turns: usize, + + /// Newest function-output tokens never pruned (prune default). + #[serde(default = "default_protect_recent_tokens")] + pub protect_recent_tokens: u64, + + /// Skip pruning entirely when it would free fewer tokens than this. + #[serde(default = "default_min_free_tokens")] + pub min_free_tokens: u64, + + /// Per-output verbosity threshold (chars): outputs at or under this + /// size are never considered verbose enough to prune. + #[serde(default = "default_max_output_chars")] + pub max_output_chars: usize, + + /// Compaction lease TTL in seconds. + #[serde(default = "default_lease_ttl_secs")] + pub lease_ttl_secs: u64, + + /// Fall back to conservative limits (8192/1024) when neither inline + /// limits nor `llm-router` are available. When false the same + /// situation errors with `could not resolve model limits`. + #[serde(default = "default_allow_fallback_limits")] + pub allow_fallback_limits: bool, + + /// Outer budget for one summariser call through `router::chat` (ms). + #[serde(default = "default_summarizer_timeout_ms")] + pub summarizer_timeout_ms: u64, + + /// Directory holding compaction-lease files (`/.json`). + /// A leading `~/` expands to the home directory. Mirrors + /// session-manager's `data_dir`. + #[serde(default = "default_lease_dir")] + pub lease_dir: String, +} + +impl WorkerConfig { + /// The lease directory with a leading `~/` expanded to `$HOME`. + pub fn resolved_lease_dir(&self) -> PathBuf { + expand_tilde(&self.lease_dir) + } + + /// Parse a seed config from YAML, expanding `${NAME}` against the + /// process env FIRST (the seed file is the only path that needs + /// expansion — values fetched from `configuration::get` are already + /// env-expanded by the configuration worker), then deserializing. + pub fn from_yaml(yaml: &str) -> Result { + let expanded = expand_env(yaml); + serde_yaml::from_str(&expanded).map_err(|e| format!("yaml parse: {e}")) + } + + /// Read and parse a YAML seed file (env-expanded — see [`Self::from_yaml`]). + pub fn from_file(path: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; + Self::from_yaml(&raw) + } + + /// Parse a config from a JSON value already env-expanded by the + /// configuration worker. Does NOT run `expand_env` (double expansion + /// would be a bug) and tolerates a zero-field object (serde defaults + /// fill in). + pub fn from_json(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}")) + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).expect("WorkerConfig serializes") + } + + /// The JSON Schema registered with the `configuration` worker. Field + /// doc-comments become property descriptions; the shipped defaults + /// are attached as a top-level `example`. + pub fn json_schema() -> Value { + let root = schemars::schema_for!(WorkerConfig); + let mut schema = + serde_json::to_value(&root.schema).expect("WorkerConfig JSON Schema serializes"); + if let Some(obj) = schema.as_object_mut() { + if !root.definitions.is_empty() { + obj.insert( + "definitions".into(), + serde_json::to_value(&root.definitions).expect("definitions serialize"), + ); + } + obj.insert("example".into(), WorkerConfig::default().to_json()); + } + schema + } + + /// The restart-required fields: everything consumed ONCE at boot + /// rather than per call. `summarizer_timeout_ms` is baked into the + /// `RouterSummarizer` and `lease_dir` into the `FsLeaseStore`; a live + /// config update that changes either is refused on hot-reload (logged + /// "restart required", the previous snapshot kept). Every OTHER field + /// is a per-call tuning knob that hot-applies. + pub fn boot_signature(&self) -> BootSignature { + BootSignature { + lease_dir: self.lease_dir.clone(), + summarizer_timeout_ms: self.summarizer_timeout_ms, + } + } +} + +/// Signature of the config fields consumed at boot (see +/// [`WorkerConfig::boot_signature`]). Two configs with an equal signature +/// differ only in per-call tuning knobs that can be hot-applied; any +/// other difference requires a worker restart. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct BootSignature { + pub lease_dir: String, + pub summarizer_timeout_ms: u64, +} + +fn default_reserved_tokens_cap() -> u64 { + 20_000 +} + +fn default_reserved_pct() -> u64 { + 10 +} + +fn default_tail_turns() -> usize { + 2 +} + +fn default_protect_recent_tokens() -> u64 { + 40_000 +} + +fn default_min_free_tokens() -> u64 { + 20_000 +} + +fn default_max_output_chars() -> usize { + 2_000 +} + +fn default_lease_ttl_secs() -> u64 { + 300 +} + +fn default_allow_fallback_limits() -> bool { + true +} + +fn default_summarizer_timeout_ms() -> u64 { + 320_000 +} + +fn default_lease_dir() -> String { + "~/.iii/data/context-manager".to_string() +} + +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(path) +} + +/// Expand `${NAME}` occurrences against the process environment. Unknown +/// variables expand to the empty string and emit a tracing warning. Only +/// the `--config` seed path uses this — values from `configuration::get` +/// are already expanded by the worker. An unterminated `${` is a literal. +fn expand_env(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find('}') { + Some(end) => { + let name = &after[..end]; + match std::env::var(name) { + Ok(v) => out.push_str(&v), + Err(_) => tracing::warn!(var = %name, "config references undefined env var"), + } + rest = &after[end + 1..]; + } + None => { + out.push_str("${"); + rest = after; + } + } + } + out.push_str(rest); + out +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + reserved_tokens_cap: default_reserved_tokens_cap(), + reserved_pct: default_reserved_pct(), + tail_turns: default_tail_turns(), + protect_recent_tokens: default_protect_recent_tokens(), + min_free_tokens: default_min_free_tokens(), + max_output_chars: default_max_output_chars(), + lease_ttl_secs: default_lease_ttl_secs(), + allow_fallback_limits: default_allow_fallback_limits(), + summarizer_timeout_ms: default_summarizer_timeout_ms(), + lease_dir: default_lease_dir(), + } + } +} + +pub fn load_config(path: &str) -> Result { + let contents = std::fs::read_to_string(path)?; + let cfg: WorkerConfig = serde_yaml::from_str(&contents)?; + Ok(cfg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_from_empty_yaml() { + let cfg: WorkerConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(cfg.reserved_tokens_cap, 20_000); + assert_eq!(cfg.reserved_pct, 10); + assert_eq!(cfg.tail_turns, 2); + assert_eq!(cfg.protect_recent_tokens, 40_000); + assert_eq!(cfg.min_free_tokens, 20_000); + assert_eq!(cfg.max_output_chars, 2_000); + assert_eq!(cfg.lease_ttl_secs, 300); + assert!(cfg.allow_fallback_limits); + assert_eq!(cfg.summarizer_timeout_ms, 320_000); + assert_eq!(cfg.lease_dir, "~/.iii/data/context-manager"); + } + + #[test] + fn custom_yaml_overrides_every_field() { + let yaml = "reserved_tokens_cap: 1\nreserved_pct: 2\ntail_turns: 3\n\ + protect_recent_tokens: 4\nmin_free_tokens: 5\nmax_output_chars: 6\n\ + lease_ttl_secs: 7\nallow_fallback_limits: false\nsummarizer_timeout_ms: 8\n\ + lease_dir: /tmp/leases"; + let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.reserved_tokens_cap, 1); + assert_eq!(cfg.reserved_pct, 2); + assert_eq!(cfg.tail_turns, 3); + assert_eq!(cfg.protect_recent_tokens, 4); + assert_eq!(cfg.min_free_tokens, 5); + assert_eq!(cfg.max_output_chars, 6); + assert_eq!(cfg.lease_ttl_secs, 7); + assert!(!cfg.allow_fallback_limits); + assert_eq!(cfg.summarizer_timeout_ms, 8); + assert_eq!(cfg.lease_dir, "/tmp/leases"); + assert_eq!(cfg.resolved_lease_dir(), PathBuf::from("/tmp/leases")); + } + + #[test] + fn lease_dir_tilde_expands_to_home() { + let cfg = WorkerConfig::default(); + let resolved = cfg.resolved_lease_dir(); + if let Some(home) = dirs::home_dir() { + assert!(resolved.starts_with(home)); + assert!(resolved.ends_with(".iii/data/context-manager")); + } + } + + #[test] + fn impl_default_matches_yaml_defaults() { + let from_yaml: WorkerConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(from_yaml, WorkerConfig::default()); + } + + #[test] + fn unknown_root_key_is_rejected_at_parse() { + let err = serde_yaml::from_str::("tail_truns: 3").unwrap_err(); + assert!(err.to_string().contains("unknown field"), "got: {err}"); + } + + #[test] + fn committed_config_yaml_parses_to_defaults() { + let cfg = load_config(concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml")).unwrap(); + assert_eq!(cfg, WorkerConfig::default()); + } + + #[test] + fn json_schema_has_every_property_with_descriptions_and_example() { + let schema = WorkerConfig::json_schema(); + let props = schema + .get("properties") + .and_then(|p| p.as_object()) + .expect("schema has a properties object"); + for field in [ + "reserved_tokens_cap", + "reserved_pct", + "tail_turns", + "protect_recent_tokens", + "min_free_tokens", + "max_output_chars", + "lease_ttl_secs", + "allow_fallback_limits", + "summarizer_timeout_ms", + "lease_dir", + ] { + assert!( + props.get(field).is_some(), + "missing schema property {field}" + ); + } + // Field doc-comments survive as schema descriptions. + assert!(props["lease_dir"].get("description").is_some()); + // The shipped defaults are attached as a top-level example. + assert_eq!( + schema.get("example"), + Some(&WorkerConfig::default().to_json()) + ); + } + + #[test] + fn from_json_round_trips_from_default() { + let cfg = WorkerConfig::default(); + let back = WorkerConfig::from_json(&cfg.to_json()).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn from_json_tolerates_empty_object() { + let back = WorkerConfig::from_json(&serde_json::json!({})).unwrap(); + assert_eq!(back, WorkerConfig::default()); + } + + #[test] + fn from_json_round_trips_custom_values() { + let json = serde_json::json!({ "tail_turns": 5, "lease_dir": "/tmp/x" }); + let cfg = WorkerConfig::from_json(&json).unwrap(); + assert_eq!(cfg.tail_turns, 5); + assert_eq!(cfg.lease_dir, "/tmp/x"); + // Unspecified fields fall back to serde defaults. + assert_eq!(cfg.summarizer_timeout_ms, 320_000); + } + + #[test] + fn from_json_rejects_garbage() { + let err = WorkerConfig::from_json(&serde_json::json!({ "tail_turns": "nan" })).unwrap_err(); + assert!(err.contains("json parse"), "got: {err}"); + let err = WorkerConfig::from_json(&serde_json::json!("garbage")).unwrap_err(); + assert!(err.contains("json parse"), "got: {err}"); + } + + #[test] + fn from_yaml_expands_env_var() { + std::env::set_var("CTX_MGR_TEST_LEASE_DIR", "/tmp/expanded-leases"); + let cfg = WorkerConfig::from_yaml("lease_dir: \"${CTX_MGR_TEST_LEASE_DIR}\"\n").unwrap(); + assert_eq!(cfg.lease_dir, "/tmp/expanded-leases"); + std::env::remove_var("CTX_MGR_TEST_LEASE_DIR"); + } + + #[test] + fn boot_signature_equal_when_only_tuning_knobs_differ() { + let base = WorkerConfig::default(); + let tuned = WorkerConfig { + tail_turns: base.tail_turns + 1, + reserved_pct: base.reserved_pct + 1, + protect_recent_tokens: base.protect_recent_tokens + 1, + ..base.clone() + }; + assert_eq!(base.boot_signature(), tuned.boot_signature()); + } + + #[test] + fn boot_signature_differs_on_restart_required_fields() { + let base = WorkerConfig::default(); + let moved_dir = WorkerConfig { + lease_dir: "/tmp/other".to_string(), + ..base.clone() + }; + let new_timeout = WorkerConfig { + summarizer_timeout_ms: base.summarizer_timeout_ms + 1, + ..base.clone() + }; + assert_ne!(base.boot_signature(), moved_dir.boot_signature()); + assert_ne!(base.boot_signature(), new_timeout.boot_signature()); + } +} diff --git a/context-manager/src/configuration.rs b/context-manager/src/configuration.rs new file mode 100644 index 00000000..e1b234fe --- /dev/null +++ b/context-manager/src/configuration.rs @@ -0,0 +1,289 @@ +//! Integration with the `configuration` worker — register the schema, +//! fetch the authoritative value at boot, and hot-reload it when it +//! changes. Mirrors [`shell`](../../shell/src/configuration.rs) / +//! [`database`](../../database/src/configuration.rs) / +//! [`coder`](../../coder/src/configuration.rs). +//! +//! context-manager's config splits into two halves on a live update: +//! +//! - The BOOT SIGNATURE (`lease_dir` + `summarizer_timeout_ms`) is +//! everything consumed ONCE at startup: `lease_dir` builds the +//! `FsLeaseStore` and `summarizer_timeout_ms` the `RouterSummarizer`. +//! A config change that alters either is REFUSED on hot-reload (logged +//! "restart required", the previous snapshot kept) — those adapters are +//! built once at boot and never rebuilt. +//! - Every OTHER field is a per-call tuning knob (token reserves, prune +//! thresholds, compaction tail, lease TTL). When a freshly-fetched +//! config's boot signature matches the boot-time signature, the +//! snapshot is swapped live; handlers read the current snapshot per +//! call via [`Deps::config`](crate::ports::Deps::config). + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, TriggerRequest, III}; +use serde_json::{json, Value}; +use tokio::sync::RwLock; + +use crate::config::{BootSignature, WorkerConfig}; + +/// Hot-swappable config snapshot shared with every handler. The +/// `Arc>>` shape lets a handler take a +/// `read().await` and `clone()` the inner `Arc` out (a cheap refcount +/// bump) without holding the lock across its work, while `apply_config` +/// whole-snapshot replaces the inner `Arc` under the write lock. +pub type ConfigCell = Arc>>; + +pub const CONFIG_ID: &str = "context-manager"; +const CONFIG_FN_ID: &str = "context::on-config-change"; +const CONFIG_TIMEOUT_MS: u64 = 5_000; +const CONFIG_RETRIES: u32 = 3; +/// Base backoff between configuration RPC retries; multiplied by the +/// attempt number for a linear backoff (250ms, 500ms, …). +const CONFIG_RETRY_BACKOFF_MS: u64 = 250; + +/// Register the `context-manager` configuration schema with the +/// configuration worker. When `seed` is present, its value is installed +/// as `initial_value`. Otherwise, the built-in default is seeded only +/// when no stored value exists yet (re-registration preserves the stored +/// value, so this is safe to call every boot). +pub async fn register_config(iii: &III, seed: Option<&WorkerConfig>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "Context Manager", + "description": "Model-ready context assembly tuning: token-budget reserves, \ + function-result prune thresholds, compaction tail size, the \ + compaction lease TTL and directory, and the summariser timeout.", + "schema": WorkerConfig::json_schema(), + }); + if let Some(seed) = seed { + payload["initial_value"] = seed.to_json(); + } else if should_seed_default_value(iii).await? { + payload["initial_value"] = WorkerConfig::default().to_json(); + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// Read the live `context-manager` configuration (env-expanded by the +/// configuration worker — `from_json` does NOT re-expand). +pub async fn fetch_config(iii: &III) -> Result { + let value = get_config_value(iii).await?; + if value.is_null() { + tracing::info!("no configuration value found; using built-in default configuration"); + return Ok(WorkerConfig::default()); + } + WorkerConfig::from_json(&value) +} + +async fn should_seed_default_value(iii: &III) -> Result { + match try_get_config_value(iii).await? { + None => Ok(true), + Some(value) if value.is_null() => Ok(true), + Some(_) => Ok(false), + } +} + +async fn get_config_value(iii: &III) -> Result { + try_get_config_value(iii) + .await? + .ok_or_else(|| format!("configuration `{CONFIG_ID}` not found")) +} + +/// Returns `Ok(None)` when the entry does not exist. The engine's +/// missing-entry codes vary in case (`function_not_found`, +/// `STATEMENT_NOT_FOUND`, `NOT_FOUND`), so match case-insensitively. +async fn try_get_config_value(iii: &III) -> Result, String> { + match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { + Ok(resp) => Ok(resp.get("value").cloned()), + Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +/// Swap the config snapshot under the write lock. No adapter rebuild — +/// the boot signature is unchanged by construction (the caller has +/// already passed [`reloadable`]). +pub async fn apply_config(cell: &ConfigCell, cfg: WorkerConfig) { + *cell.write().await = Arc::new(cfg); +} + +/// Decide whether a freshly-fetched config can be hot-applied. Returns +/// the config when its boot signature matches the boot-time signature +/// (only per-call tuning knobs changed), or an error describing the +/// restart-required change. +fn reloadable(cfg: WorkerConfig, boot_sig: &BootSignature) -> Result { + if cfg.boot_signature() != *boot_sig { + return Err( + "configuration change alters lease_dir or summarizer_timeout_ms — these are \ + consumed once at boot (the FsLeaseStore and RouterSummarizer are built then and \ + never rebuilt); a worker restart is required to apply them" + .to_string(), + ); + } + Ok(cfg) +} + +/// Register the internal config-change handler and bind a `configuration` +/// trigger. `boot_sig` is the signature captured at startup; any reload +/// that would change it is refused (those require a worker restart). +pub fn register_config_trigger( + iii: &III, + cell: ConfigCell, + boot_sig: BootSignature, +) -> Result<(), IIIError> { + let cell_for_fn = cell.clone(); + let engine = iii.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: Value| { + let cell = cell_for_fn.clone(); + let engine = engine.clone(); + let boot_sig = boot_sig.clone(); + async move { + on_config_change(&engine, &cell, &boot_sig).await; + Ok::(json!({ "ok": true })) + } + }) + .description( + "Internal: reload context-manager's tuning knobs from the authoritative \ + configuration when it changes; lease_dir / summarizer_timeout_ms changes require \ + a restart.", + ), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +/// Reload tuning knobs from the AUTHORITATIVE configuration. +/// +/// The caller-supplied trigger payload is intentionally ignored: +/// `context::on-config-change` is a discoverable bus function, so trusting +/// `payload.new_value` would let any caller inject arbitrary config without +/// updating persisted state. Re-fetch the stored value via +/// `configuration::get` instead. A restart-required change is refused; the +/// previous snapshot is always kept on any failure path. +async fn on_config_change(iii: &III, cell: &ConfigCell, boot_sig: &BootSignature) { + let cfg = match fetch_config(iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::error!( + error = %e, + "config-change: failed to fetch authoritative configuration; keeping previous config" + ); + return; + } + }; + let cfg = match reloadable(cfg, boot_sig) { + Ok(cfg) => cfg, + Err(reason) => { + tracing::warn!( + reason = %reason, + "config-change refused: restart required; keeping previous config" + ); + return; + } + }; + apply_config(cell, cfg).await; + tracing::info!("context-manager tuning knobs reloaded (boot signature unchanged)"); +} + +async fn trigger_with_retry(iii: &III, function_id: &str, payload: Value) -> Result { + let mut last_err = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(CONFIG_TIMEOUT_MS), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + if attempt < CONFIG_RETRIES { + tracing::warn!( + function_id, + attempt, + error = %last_err, + "configuration RPC failed; retrying" + ); + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * u64::from(attempt), + )) + .await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reloadable_allows_tuning_only_change() { + let boot = WorkerConfig::default(); + let boot_sig = boot.boot_signature(); + let next = WorkerConfig { + tail_turns: boot.tail_turns + 1, + reserved_pct: boot.reserved_pct + 1, + ..boot.clone() + }; + let applied = reloadable(next, &boot_sig).expect("tuning-only change is reloadable"); + assert_eq!(applied.tail_turns, boot.tail_turns + 1); + } + + #[test] + fn reloadable_refuses_lease_dir_change() { + let boot = WorkerConfig::default(); + let boot_sig = boot.boot_signature(); + let moved = WorkerConfig { + lease_dir: "/tmp/somewhere-else".to_string(), + ..boot.clone() + }; + assert!(reloadable(moved, &boot_sig).is_err()); + } + + #[test] + fn reloadable_refuses_summarizer_timeout_change() { + let boot = WorkerConfig::default(); + let boot_sig = boot.boot_signature(); + let retimed = WorkerConfig { + summarizer_timeout_ms: boot.summarizer_timeout_ms + 1, + ..boot.clone() + }; + assert!(reloadable(retimed, &boot_sig).is_err()); + } + + #[tokio::test] + async fn apply_config_swaps_snapshot() { + let cell: ConfigCell = Arc::new(RwLock::new(Arc::new(WorkerConfig::default()))); + assert_eq!( + cell.read().await.tail_turns, + WorkerConfig::default().tail_turns + ); + + let tuned = WorkerConfig { + tail_turns: 9, + ..WorkerConfig::default() + }; + apply_config(&cell, tuned).await; + assert_eq!(cell.read().await.tail_turns, 9); + } +} diff --git a/context-manager/src/core/budget.rs b/context-manager/src/core/budget.rs new file mode 100644 index 00000000..fd2b70af --- /dev/null +++ b/context-manager/src/core/budget.rs @@ -0,0 +1,213 @@ +//! The model-adaptive token budget (context-manager.md § Token budget +//! model): +//! +//! ```text +//! usable = max(0, (input_limit ?? (context_window - max_output_tokens)) +//! - reserved - thinking_budget) +//! ``` +//! +//! `reserved` defaults to `min(cap, pct% of context_window)`; the +//! thinking budget comes from the model's declared budget for the +//! requested tier (0 when the caller passes no tier or the model +//! declares none). + +use crate::config::WorkerConfig; +use crate::types::{Model, ModelLimits, ThinkingLevel}; + +/// How the limits in a [`ResolvedModel`] were obtained — echoed to +/// callers as `model_resolved` so a silent fallback is detectable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelResolved { + Inline, + Router, + Fallback, +} + +impl ModelResolved { + pub fn as_str(&self) -> &'static str { + match self { + ModelResolved::Inline => "inline", + ModelResolved::Router => "router", + ModelResolved::Fallback => "fallback", + } + } +} + +/// Limits plus the metadata budget math needs, with provenance. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedModel { + pub limits: ModelLimits, + /// Declared per-tier reasoning budgets (router-resolved models + /// only; inline limits carry none). + pub thinking_budgets: Option>, + pub resolved: ModelResolved, +} + +/// The conservative fallback used when neither inline limits nor the +/// router can resolve the model. +pub fn fallback_model() -> ResolvedModel { + ResolvedModel { + limits: ModelLimits { + context_window: 8_192, + max_output_tokens: 1_024, + input_limit: None, + }, + thinking_budgets: None, + resolved: ModelResolved::Fallback, + } +} + +impl ResolvedModel { + pub fn from_inline(limits: ModelLimits) -> Self { + Self { + limits, + thinking_budgets: None, + resolved: ModelResolved::Inline, + } + } + + pub fn from_router(model: &Model) -> Self { + Self { + limits: ModelLimits { + context_window: model.context_window, + max_output_tokens: model.max_output_tokens, + input_limit: model.input_limit, + }, + thinking_budgets: model.thinking_budgets.clone(), + resolved: ModelResolved::Router, + } + } + + /// The model's declared budget for `level`, else 0. + pub fn thinking_budget(&self, level: Option) -> u64 { + match (level, &self.thinking_budgets) { + (Some(level), Some(budgets)) => budgets.get(&level).copied().unwrap_or(0), + _ => 0, + } + } +} + +/// Default reserve: `min(cap, context_window * pct / 100)`. +pub fn default_reserved(cfg: &WorkerConfig, context_window: u64) -> u64 { + cfg.reserved_tokens_cap + .min(context_window.saturating_mul(cfg.reserved_pct) / 100) +} + +/// The usable input budget for one call. Saturating throughout — a +/// tiny model with a large reserve clamps to 0 instead of wrapping. +pub fn usable(limits: &ModelLimits, reserved: u64, thinking_budget: u64) -> u64 { + let base = limits.input_limit.unwrap_or_else(|| { + limits + .context_window + .saturating_sub(limits.max_output_tokens) + }); + base.saturating_sub(reserved) + .saturating_sub(thinking_budget) +} + +/// Adaptive verbatim-tail budget for compaction: 25% of `usable`, +/// clamped to [2_000, 8_000]; the caller's `preserve_recent_tokens` +/// overrides it (same policy as the harness prior art). +pub fn preserve_recent_budget(usable: u64, override_tokens: Option) -> u64 { + const MIN_PRESERVE_RECENT_TOKENS: u64 = 2_000; + const MAX_PRESERVE_RECENT_TOKENS: u64 = 8_000; + if let Some(ovr) = override_tokens { + return ovr; + } + (usable / 4).clamp(MIN_PRESERVE_RECENT_TOKENS, MAX_PRESERVE_RECENT_TOKENS) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limits( + context_window: u64, + max_output_tokens: u64, + input_limit: Option, + ) -> ModelLimits { + ModelLimits { + context_window, + max_output_tokens, + input_limit, + } + } + + #[test] + fn reserved_defaults_to_ten_pct_capped_at_20k() { + let cfg = WorkerConfig::default(); + // 10% of 200k = 20k, equal to the cap. + assert_eq!(default_reserved(&cfg, 200_000), 20_000); + // 10% of 32k = 3.2k, under the cap. + assert_eq!(default_reserved(&cfg, 32_000), 3_200); + // 10% of 1M = 100k, capped at 20k. + assert_eq!(default_reserved(&cfg, 1_000_000), 20_000); + } + + #[test] + fn spec_examples_hold() { + let cfg = WorkerConfig::default(); + // "A 200k model with defaults yields ~180k usable" + let l = limits(200_000, 8_000, None); + let u = usable(&l, default_reserved(&cfg, l.context_window), 0); + assert_eq!(u, 172_000); // 200k - 8k output - 20k reserved + + // "a 32k model yields ~12k" (with a 16k output budget) + let l = limits(32_000, 16_000, None); + let u = usable(&l, default_reserved(&cfg, l.context_window), 0); + assert_eq!(u, 12_800); // 32k - 16k - 3.2k + } + + #[test] + fn input_limit_takes_precedence_over_derivation() { + let l = limits(200_000, 8_000, Some(100_000)); + assert_eq!(usable(&l, 20_000, 0), 80_000); + } + + #[test] + fn thinking_budget_subtracts() { + let l = limits(200_000, 8_000, None); + assert_eq!(usable(&l, 20_000, 16_000), 156_000); + } + + #[test] + fn tiny_models_clamp_to_zero() { + let l = limits(1_000, 2_000, None); // output exceeds window + assert_eq!(usable(&l, 0, 0), 0); + let l = limits(8_192, 1_024, None); + assert_eq!(usable(&l, 10_000, 0), 0); // reserve exceeds base + } + + #[test] + fn fallback_is_8192_1024() { + let f = fallback_model(); + assert_eq!(f.limits.context_window, 8_192); + assert_eq!(f.limits.max_output_tokens, 1_024); + assert_eq!(f.resolved, ModelResolved::Fallback); + } + + #[test] + fn thinking_budget_requires_both_level_and_declaration() { + let inline = ResolvedModel::from_inline(limits(100, 10, None)); + assert_eq!(inline.thinking_budget(Some(ThinkingLevel::High)), 0); + + let mut budgets = std::collections::BTreeMap::new(); + budgets.insert(ThinkingLevel::High, 9_000); + let routed = ResolvedModel { + limits: limits(100, 10, None), + thinking_budgets: Some(budgets), + resolved: ModelResolved::Router, + }; + assert_eq!(routed.thinking_budget(Some(ThinkingLevel::High)), 9_000); + assert_eq!(routed.thinking_budget(Some(ThinkingLevel::Low)), 0); + assert_eq!(routed.thinking_budget(None), 0); + } + + #[test] + fn preserve_recent_budget_clamps_and_overrides() { + assert_eq!(preserve_recent_budget(40_000, None), 8_000); // 10k capped + assert_eq!(preserve_recent_budget(20_000, None), 5_000); // within band + assert_eq!(preserve_recent_budget(1_000, None), 2_000); // floor + assert_eq!(preserve_recent_budget(40_000, Some(123)), 123); + } +} diff --git a/context-manager/src/core/estimate.rs b/context-manager/src/core/estimate.rs new file mode 100644 index 00000000..f01b0e05 --- /dev/null +++ b/context-manager/src/core/estimate.rs @@ -0,0 +1,148 @@ +//! Token estimation. v1 ships the chars/4 heuristic (the harness prior +//! art) behind a trait so a real tokenizer can slot in per model later; +//! responses report which estimator ran. + +use crate::types::{AgentFunction, AgentMessage, Role}; + +/// Which estimator produced a count — `count-tokens` echoes this. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EstimatorKind { + Tokenizer, + Heuristic, +} + +impl EstimatorKind { + pub fn as_str(&self) -> &'static str { + match self { + EstimatorKind::Tokenizer => "tokenizer", + EstimatorKind::Heuristic => "heuristic", + } + } +} + +pub trait Estimator: Send + Sync { + fn kind(&self) -> EstimatorKind; + + /// Tokens of one message (full serialized form, so structure and + /// metadata weigh in, matching what actually crosses the wire). + fn message(&self, message: &AgentMessage) -> u64; + + /// Tokens of a bare string (system prompts, summaries). + fn text(&self, text: &str) -> u64; + + /// Tokens of one invocation-schema entry. + fn function(&self, function: &AgentFunction) -> u64; +} + +/// `serialized JSON chars / 4` — deterministic and model-independent. +pub struct HeuristicEstimator; + +impl Estimator for HeuristicEstimator { + fn kind(&self) -> EstimatorKind { + EstimatorKind::Heuristic + } + + fn message(&self, message: &AgentMessage) -> u64 { + let chars = serde_json::to_string(message).map(|s| s.len()).unwrap_or(0); + (chars / 4) as u64 + } + + fn text(&self, text: &str) -> u64 { + (text.len() / 4) as u64 + } + + fn function(&self, function: &AgentFunction) -> u64 { + let chars = serde_json::to_string(function) + .map(|s| s.len()) + .unwrap_or(0); + (chars / 4) as u64 + } +} + +/// Estimator selection. v1: always the heuristic; a future tokenizer +/// keys off the resolved model here. +pub fn estimator_for_model(_model_id: &str) -> &'static dyn Estimator { + static HEURISTIC: HeuristicEstimator = HeuristicEstimator; + &HEURISTIC +} + +/// Sum of message estimates. +pub fn estimate_messages(est: &dyn Estimator, messages: &[AgentMessage]) -> u64 { + messages.iter().map(|m| est.message(m)).sum() +} + +/// Per-role breakdown (`count-tokens.by_role`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ByRole { + pub user: u64, + pub assistant: u64, + pub function_result: u64, + pub custom: u64, +} + +pub fn estimate_by_role(est: &dyn Estimator, messages: &[AgentMessage]) -> ByRole { + let mut by_role = ByRole::default(); + for m in messages { + let tokens = est.message(m); + match m.role() { + Role::User => by_role.user += tokens, + Role::Assistant => by_role.assistant += tokens, + Role::FunctionResult => by_role.function_result += tokens, + Role::Custom => by_role.custom += tokens, + } + } + by_role +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn msg(v: serde_json::Value) -> AgentMessage { + serde_json::from_value(v).unwrap() + } + + #[test] + fn heuristic_is_serialized_chars_over_four() { + let m = msg(json!({ + "role": "user", "content": [{ "type": "text", "text": "hello" }], "timestamp": 1 + })); + let chars = serde_json::to_string(&m).unwrap().len(); + let est = HeuristicEstimator; + assert_eq!(est.message(&m), (chars / 4) as u64); + assert_eq!(est.kind(), EstimatorKind::Heuristic); + } + + #[test] + fn text_estimate_is_chars_over_four() { + assert_eq!(HeuristicEstimator.text("x".repeat(400).as_str()), 100); + assert_eq!(HeuristicEstimator.text(""), 0); + } + + #[test] + fn by_role_partitions_every_role() { + let messages = vec![ + msg( + json!({ "role": "user", "content": [{ "type": "text", "text": "q" }], "timestamp": 1 }), + ), + msg( + json!({ "role": "assistant", "content": [], "stop_reason": "end", + "model": "m", "provider": "p", "timestamp": 2 }), + ), + msg( + json!({ "role": "function_result", "function_call_id": "c", "function_id": "f", + "content": [], "timestamp": 3 }), + ), + msg(json!({ "role": "custom", "custom_type": "t", "content": [], "timestamp": 4 })), + ]; + let est = HeuristicEstimator; + let by_role = estimate_by_role(&est, &messages); + assert!(by_role.user > 0 && by_role.assistant > 0); + assert!(by_role.function_result > 0 && by_role.custom > 0); + assert_eq!( + by_role.user + by_role.assistant + by_role.function_result + by_role.custom, + estimate_messages(&est, &messages) + ); + } +} diff --git a/context-manager/src/core/lease.rs b/context-manager/src/core/lease.rs new file mode 100644 index 00000000..05e3bf05 --- /dev/null +++ b/context-manager/src/core/lease.rs @@ -0,0 +1,146 @@ +//! Compaction mutual exclusion (context-manager.md § State): a +//! `{nonce, ts}` claim under scope `context_lease`, keyed by +//! `options.lease_key` (e.g. a session id) or a hash of the message +//! set. TTL is enforced by readers — a claim older than the TTL reads +//! as free, folding crash recovery into acquisition. Ported from +//! harness `runtime/lease.ts`. + +use sha2::{Digest, Sha256}; + +use crate::ports::{Clock, LeaseRecord, LeaseStore}; +use crate::types::AgentMessage; + +/// The single lease scope this worker writes (a subdirectory under the +/// configured `lease_dir`). +pub const LEASE_SCOPE: &str = "context_lease"; + +/// Default lease key: sha256 over the serialized message set, so two +/// callers compacting the same logical history contend even without an +/// explicit `lease_key`, while different histories never block each +/// other. sha2 (not a std hasher) keeps the key stable across +/// processes and Rust versions. +pub fn default_lease_key(messages: &[AgentMessage]) -> String { + let mut hasher = Sha256::new(); + for message in messages { + hasher.update(serde_json::to_string(message).unwrap_or_default()); + hasher.update([0u8]); // message separator + } + format!("{:x}", hasher.finalize()) +} + +fn is_active(record: Option<&LeaseRecord>, now_ms: i64, ttl_ms: i64) -> bool { + record.is_some_and(|r| now_ms - r.ts < ttl_ms) +} + +/// Try to acquire the lease. Returns the nonce to release with, or +/// `None` when another holder owns a live claim. Store failures read +/// as "not acquired" (never a win) — an outage must not let every +/// contender through, and a transient `busy` is retryable. +pub async fn acquire( + store: &dyn LeaseStore, + clock: &dyn Clock, + key: &str, + ttl_ms: i64, +) -> Option { + let now = clock.now_ms(); + match store.get(LEASE_SCOPE, key).await { + Ok(existing) if is_active(existing.as_ref(), now, ttl_ms) => return None, + Ok(_) => {} + Err(e) => { + tracing::warn!(error = %e, key, "lease get failed; treating as busy"); + return None; + } + } + + let nonce = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4().simple()); + let claim = LeaseRecord { + nonce: nonce.clone(), + ts: now, + }; + let prior = match store.swap(LEASE_SCOPE, key, claim).await { + Ok(prior) => prior, + Err(e) => { + tracing::warn!(error = %e, key, "lease swap failed; never treating as a win"); + return None; + } + }; + + // We clobbered a still-valid claim (always someone else's — our + // nonce is fresh): restore it and bow out. + if is_active(prior.as_ref(), now, ttl_ms) { + if let Err(e) = store.set(LEASE_SCOPE, key, prior).await { + tracing::warn!(error = %e, key, "lease restore failed"); + } + return None; + } + Some(nonce) +} + +/// Release only our own claim: a holder that lost its lease to TTL +/// takeover must not clear the new holder's claim. +pub async fn release(store: &dyn LeaseStore, key: &str, nonce: &str) { + match store.get(LEASE_SCOPE, key).await { + Ok(Some(stored)) if stored.nonce == nonce => { + if let Err(e) = store.set(LEASE_SCOPE, key, None).await { + tracing::warn!(error = %e, key, "lease clear failed"); + } + } + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, key, "lease release read failed"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn default_key_is_stable_and_input_sensitive() { + let a: AgentMessage = serde_json::from_value(json!({ + "role": "user", "content": [{ "type": "text", "text": "hi" }], "timestamp": 1 + })) + .unwrap(); + let b: AgentMessage = serde_json::from_value(json!({ + "role": "user", "content": [{ "type": "text", "text": "bye" }], "timestamp": 1 + })) + .unwrap(); + + let key_a1 = default_lease_key(std::slice::from_ref(&a)); + let key_a2 = default_lease_key(std::slice::from_ref(&a)); + let key_b = default_lease_key(std::slice::from_ref(&b)); + assert_eq!(key_a1, key_a2, "same messages must contend"); + assert_ne!( + key_a1, key_b, + "different messages must not block each other" + ); + assert_eq!(key_a1.len(), 64, "sha256 hex"); + } + + #[test] + fn concatenation_is_not_ambiguous() { + // ["ab"] vs ["a", "b"]-style boundary games must not collide; + // the separator byte keeps message boundaries in the hash. + let one: Vec = serde_json::from_value(json!([ + { "role": "user", "content": [{ "type": "text", "text": "ab" }], "timestamp": 1 } + ])) + .unwrap(); + let two: Vec = serde_json::from_value(json!([ + { "role": "user", "content": [{ "type": "text", "text": "a" }], "timestamp": 1 }, + { "role": "user", "content": [{ "type": "text", "text": "b" }], "timestamp": 1 } + ])) + .unwrap(); + assert_ne!(default_lease_key(&one), default_lease_key(&two)); + } + + #[test] + fn activity_is_ttl_relative() { + let rec = LeaseRecord { + nonce: "n".into(), + ts: 1_000, + }; + assert!(is_active(Some(&rec), 1_500, 1_000)); // 500ms old, 1s TTL + assert!(!is_active(Some(&rec), 2_500, 1_000)); // expired + assert!(!is_active(None, 0, 1_000)); + } +} diff --git a/context-manager/src/core/mod.rs b/context-manager/src/core/mod.rs new file mode 100644 index 00000000..0f38b275 --- /dev/null +++ b/context-manager/src/core/mod.rs @@ -0,0 +1,8 @@ +//! Pure context logic: no I/O, no engine, fully unit/BDD-testable. + +pub mod budget; +pub mod estimate; +pub mod lease; +pub mod prune; +pub mod selection; +pub mod summary; diff --git a/context-manager/src/core/prune.rs b/context-manager/src/core/prune.rs new file mode 100644 index 00000000..4e71cb34 --- /dev/null +++ b/context-manager/src/core/prune.rs @@ -0,0 +1,282 @@ +//! Function-output pruning (context-manager.md § context::prune): +//! replace verbose `function_result` outputs with placeholders, newest +//! to oldest, freeing tokens outside a protected recent window. +//! +//! Structural invariant (§ Structural invariants): **prune replaces, +//! never removes** — the message, its `function_call_id` linkage, and +//! the message order all survive; only the content is rewritten to a +//! single text placeholder carrying the freed size. +//! +//! Eligibility (ported from harness `context-compaction/prune.ts`): +//! - the most recent two user turns are never touched; +//! - outputs inside the newest `protect_recent_tokens` window are kept; +//! - `protected_functions` are never pruned; +//! - outputs whose text is at or under `max_output_chars` are not +//! "verbose" and stay (pruning them frees almost nothing); +//! - when everything prunable frees under `min_free_tokens`, nothing is +//! touched at all (a no-op beats a destroyed-but-still-over context). + +use crate::core::estimate::Estimator; +use crate::types::{AgentMessage, ContentBlock, Role}; + +/// Recent user turns that are always exempt, independent of the token +/// window (prior-art constant, not operator-tunable). +const PROTECTED_USER_TURNS: usize = 2; + +#[derive(Debug, Clone)] +pub struct PruneParams { + /// Newest function-output tokens kept verbatim. + pub protect_recent_tokens: u64, + /// Skip the whole pass when it would free less than this. + pub min_free_tokens: u64, + /// Outputs at or under this many chars are not considered verbose. + pub max_output_chars: usize, + /// `function_id`s whose outputs are never pruned. + pub protected_functions: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PruneStats { + /// Estimated tokens freed. + pub pruned_tokens: u64, + /// Number of outputs replaced with placeholders. + pub pruned_parts: u64, + /// Number of prunable outputs examined (excludes protected + /// functions and the always-exempt recent turns). + pub scanned_parts: u64, +} + +/// The spec's placeholder shape: `[output pruned: was ~N tokens]`. +pub fn placeholder(tokens: u64) -> String { + format!("[output pruned: was ~{tokens} tokens]") +} + +fn text_of(blocks: &[ContentBlock]) -> String { + let mut out = String::new(); + for block in blocks { + if let ContentBlock::Text { text } = block { + out.push_str(text); + } + } + out +} + +/// Rewrite verbose outputs in place. Returns the stats; `messages` is +/// mutated only when the pass actually runs (the `min_free_tokens` +/// guard fires before any rewrite). +pub fn prune( + messages: &mut [AgentMessage], + params: &PruneParams, + estimator: &dyn Estimator, +) -> PruneStats { + let mut scanned: u64 = 0; + let mut window_tokens: u64 = 0; + let mut user_turns = 0usize; + // (message index, estimated tokens of its text content) + let mut queue: Vec<(usize, u64)> = Vec::new(); + + for idx in (0..messages.len()).rev() { + let message = &messages[idx]; + if message.role() == Role::User { + user_turns += 1; + continue; + } + if user_turns < PROTECTED_USER_TURNS { + continue; + } + let AgentMessage::FunctionResult { + function_id, + content, + .. + } = message + else { + continue; + }; + if params.protected_functions.iter().any(|f| f == function_id) { + continue; + } + + let text = text_of(content); + let tokens = estimator.text(&text); + scanned += 1; + window_tokens += tokens; + if window_tokens <= params.protect_recent_tokens { + continue; + } + if text.len() <= params.max_output_chars { + continue; + } + queue.push((idx, tokens)); + } + + // Net tokens freed: each pruned output is replaced by a placeholder + // that itself costs a few tokens, so the real saving is the original + // size minus that placeholder. Gauge `min_free_tokens` on the net. + let pruned_tokens: u64 = queue + .iter() + .map(|(_, tokens)| tokens.saturating_sub(estimator.text(&placeholder(*tokens)))) + .sum(); + if pruned_tokens < params.min_free_tokens { + return PruneStats { + pruned_tokens: 0, + pruned_parts: 0, + scanned_parts: scanned, + }; + } + + for (idx, tokens) in &queue { + messages[*idx].set_content(vec![ContentBlock::Text { + text: placeholder(*tokens), + }]); + } + + PruneStats { + pruned_tokens, + pruned_parts: queue.len() as u64, + scanned_parts: scanned, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::estimate::HeuristicEstimator; + use serde_json::json; + + fn user(text: &str, ts: i64) -> AgentMessage { + serde_json::from_value(json!({ + "role": "user", "content": [{ "type": "text", "text": text }], "timestamp": ts + })) + .unwrap() + } + + fn result(function_id: &str, chars: usize, ts: i64) -> AgentMessage { + serde_json::from_value(json!({ + "role": "function_result", "function_call_id": format!("c{ts}"), + "function_id": function_id, + "content": [{ "type": "text", "text": "x".repeat(chars) }], + "timestamp": ts + })) + .unwrap() + } + + fn params() -> PruneParams { + PruneParams { + protect_recent_tokens: 100, + min_free_tokens: 1, + max_output_chars: 100, + protected_functions: vec![], + } + } + + /// History: old verbose output, then two user turns (exempt zone). + fn history() -> Vec { + vec![ + user("first", 1), + result("shell::run", 8_000, 2), // ~2000 tokens, old + user("second", 3), + result("shell::run", 8_000, 4), // inside the 2-user-turn exemption + user("third", 5), + ] + } + + #[test] + fn prunes_old_verbose_output_and_keeps_recent_turns() { + let mut messages = history(); + let stats = prune(&mut messages, ¶ms(), &HeuristicEstimator); + assert_eq!(stats.pruned_parts, 1); + // Net of the placeholder written back: 2000 minus the tokens of + // "[output pruned: was ~2000 tokens]" (33 chars / 4 = 8). + assert_eq!(stats.pruned_tokens, 1_992); + // Oldest output replaced... + assert_eq!( + messages[1].content(), + &[ContentBlock::Text { + text: placeholder(2_000) + }] + ); + // ...the one inside the last two user turns untouched. + assert_eq!(text_of(messages[3].content()).len(), 8_000); + } + + #[test] + fn replaces_but_never_removes() { + let mut messages = history(); + let before = messages.len(); + prune(&mut messages, ¶ms(), &HeuristicEstimator); + assert_eq!(messages.len(), before); + let AgentMessage::FunctionResult { + function_call_id, .. + } = &messages[1] + else { + panic!("message kind changed"); + }; + assert_eq!(function_call_id, "c2"); + } + + #[test] + fn min_free_guard_skips_everything() { + let mut messages = history(); + let mut p = params(); + p.min_free_tokens = 1_000_000; + let stats = prune(&mut messages, &p, &HeuristicEstimator); + assert_eq!(stats.pruned_parts, 0); + assert_eq!(stats.pruned_tokens, 0); + assert_eq!(stats.scanned_parts, 1); + assert_eq!(text_of(messages[1].content()).len(), 8_000); + } + + #[test] + fn protected_functions_are_exempt() { + let mut messages = history(); + let mut p = params(); + p.protected_functions = vec!["shell::run".into()]; + let stats = prune(&mut messages, &p, &HeuristicEstimator); + assert_eq!(stats.pruned_parts, 0); + assert_eq!(stats.scanned_parts, 0); + } + + #[test] + fn small_outputs_are_not_verbose() { + let mut messages = vec![ + user("first", 1), + result("shell::run", 50, 2), // tiny, under max_output_chars + user("second", 3), + user("third", 4), + ]; + let mut p = params(); + p.protect_recent_tokens = 0; + let stats = prune(&mut messages, &p, &HeuristicEstimator); + assert_eq!(stats.pruned_parts, 0); + assert_eq!(stats.scanned_parts, 1); + assert_eq!(text_of(messages[1].content()).len(), 50); + } + + #[test] + fn protect_window_counts_newest_first() { + // Two old outputs; the window covers the newer one only. + let mut messages = vec![ + user("a", 1), + result("f", 4_000, 2), // older: outside window once newer fills it + result("f", 4_000, 3), // newer: inside the 1000-token window + user("b", 4), + user("c", 5), + ]; + let mut p = params(); + p.protect_recent_tokens = 1_000; + let stats = prune(&mut messages, &p, &HeuristicEstimator); + assert_eq!(stats.pruned_parts, 1); + assert_eq!(text_of(messages[2].content()).len(), 4_000); // newer kept + assert!(text_of(messages[1].content()).starts_with("[output pruned")); + } + + #[test] + fn idempotent_re_prune_is_a_no_op() { + let mut messages = history(); + let first = prune(&mut messages, ¶ms(), &HeuristicEstimator); + assert_eq!(first.pruned_parts, 1); + let second = prune(&mut messages, ¶ms(), &HeuristicEstimator); + // The placeholder is tiny, so nothing is verbose any more. + assert_eq!(second.pruned_parts, 0); + } +} diff --git a/context-manager/src/core/selection.rs b/context-manager/src/core/selection.rs new file mode 100644 index 00000000..ab3de21f --- /dev/null +++ b/context-manager/src/core/selection.rs @@ -0,0 +1,310 @@ +//! Token-aware verbatim-tail selection for compaction. Pure logic. +//! +//! Ported from harness `context-compaction/selection.ts`, hardened for +//! the spec's structural invariant (context-manager.md § Structural +//! invariants): a `function_call` and its `function_result` always land +//! on the same side of the head/tail boundary, so the tail may only +//! start at a *safe cut* — a user or assistant message (never a +//! `function_result` message, and never a user message carrying inline +//! `function_result` blocks). + +use crate::core::estimate::Estimator; +use crate::types::{AgentMessage, Role}; + +/// One conversation turn: starts at a user message, ends right before +/// the next one (exclusive). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Turn { + pub start: usize, + pub end: usize, +} + +/// Head/tail split. `tail_start_index` is `None` when nothing could be +/// safely kept verbatim — the whole history is the head (and the spec's +/// `tail_start_index` is `null`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection { + pub head_len: usize, + pub tail_start_index: Option, +} + +/// Partition into turns; messages before the first user message belong +/// to no turn (they are always head material). +pub fn turns(messages: &[AgentMessage]) -> Vec { + let mut result: Vec = Vec::new(); + for (i, m) in messages.iter().enumerate() { + if m.role() == Role::User { + result.push(Turn { + start: i, + end: messages.len(), + }); + } + } + for i in 0..result.len().saturating_sub(1) { + result[i].end = result[i + 1].start; + } + result +} + +/// A boundary the tail may start at without orphaning a result: +/// user/assistant messages only, and the user message must not carry +/// inline `function_result` blocks (providers reject results whose +/// call landed in the summarised head). +fn is_safe_cut(message: &AgentMessage) -> bool { + match message.role() { + Role::Assistant => true, + Role::User => !message.has_function_result_block(), + Role::FunctionResult | Role::Custom => false, + } +} + +/// Find a partial tail inside `turn` that fits `budget`, scanning from +/// the oldest in-turn position forward; only safe cuts qualify. +fn split_turn( + messages: &[AgentMessage], + turn: Turn, + budget: u64, + estimator: &dyn Estimator, +) -> Option { + if budget == 0 || turn.end.saturating_sub(turn.start) <= 1 { + return None; + } + for start in (turn.start + 1)..turn.end { + if !is_safe_cut(&messages[start]) { + continue; + } + let size: u64 = messages[start..turn.end] + .iter() + .map(|m| estimator.message(m)) + .sum(); + if size > budget { + continue; + } + return Some(start); + } + None +} + +/// Select the verbatim tail: keep up to the last `tail_turns` whole +/// turns that fit `budget` (newest first); when a whole turn does not +/// fit, fall back to a safe partial cut inside it. Everything before +/// the kept tail is the head to summarise. +pub fn select( + messages: &[AgentMessage], + budget: u64, + tail_turns: usize, + estimator: &dyn Estimator, +) -> Selection { + let whole_head = Selection { + head_len: messages.len(), + tail_start_index: None, + }; + if tail_turns == 0 { + return whole_head; + } + let all = turns(messages); + if all.is_empty() { + return whole_head; + } + let recent = &all[all.len().saturating_sub(tail_turns)..]; + + let mut total: u64 = 0; + let mut keep: Option = None; + for turn in recent.iter().rev() { + let size: u64 = messages[turn.start..turn.end] + .iter() + .map(|m| estimator.message(m)) + .sum(); + if total + size <= budget { + total += size; + // A turn whose user message carries inline function_result + // blocks is not a safe boundary; accumulate it and let an + // older turn (or a split) cover it — the tail is a suffix, + // so the unsafe turn still lands tail-side with its call. + if is_safe_cut(&messages[turn.start]) { + keep = Some(turn.start); + } + continue; + } + let remaining = budget.saturating_sub(total); + if let Some(split) = split_turn(messages, *turn, remaining, estimator) { + keep = Some(split); + } + break; + } + + match keep { + None | Some(0) => whole_head, + Some(start) => Selection { + head_len: start, + tail_start_index: Some(start), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::estimate::HeuristicEstimator; + use serde_json::json; + + fn user(text: &str, ts: i64) -> AgentMessage { + serde_json::from_value(json!({ + "role": "user", "content": [{ "type": "text", "text": text }], "timestamp": ts + })) + .unwrap() + } + + fn assistant(text: &str, ts: i64) -> AgentMessage { + serde_json::from_value(json!({ + "role": "assistant", "content": [{ "type": "text", "text": text }], + "stop_reason": "end", "model": "m", "provider": "p", "timestamp": ts + })) + .unwrap() + } + + fn assistant_call(call_id: &str, ts: i64) -> AgentMessage { + serde_json::from_value(json!({ + "role": "assistant", + "content": [{ "type": "function_call", "id": call_id, "function_id": "f::g", + "arguments": {} }], + "stop_reason": "function_call", "model": "m", "provider": "p", "timestamp": ts + })) + .unwrap() + } + + fn result(call_id: &str, chars: usize, ts: i64) -> AgentMessage { + serde_json::from_value(json!({ + "role": "function_result", "function_call_id": call_id, "function_id": "f::g", + "content": [{ "type": "text", "text": "x".repeat(chars) }], "timestamp": ts + })) + .unwrap() + } + + #[test] + fn turns_start_at_user_messages() { + let messages = vec![ + user("a", 1), + assistant("r", 2), + user("b", 3), + assistant("r", 4), + ]; + let t = turns(&messages); + assert_eq!( + t, + vec![Turn { start: 0, end: 2 }, Turn { start: 2, end: 4 }] + ); + } + + #[test] + fn keeps_whole_recent_turns_within_budget() { + let messages = vec![ + user("old question", 1), + assistant("old answer", 2), + user("recent question", 3), + assistant("recent answer", 4), + ]; + let sel = select(&messages, 1_000, 1, &HeuristicEstimator); + assert_eq!(sel.tail_start_index, Some(2)); + assert_eq!(sel.head_len, 2); + } + + #[test] + fn zero_tail_turns_summarises_everything() { + let messages = vec![user("a", 1), assistant("b", 2)]; + let sel = select(&messages, 1_000, 0, &HeuristicEstimator); + assert_eq!(sel.tail_start_index, None); + assert_eq!(sel.head_len, 2); + } + + #[test] + fn tail_covering_everything_means_no_compaction_head() { + // Both turns fit: keep.start == 0 → whole history is "head", + // i.e. the caller sees nothing to summarise... actually the + // selection reports the whole history as head (no tail), which + // compact turns into tokens_before over everything. + let messages = vec![user("a", 1), assistant("b", 2)]; + let sel = select(&messages, u64::MAX, 2, &HeuristicEstimator); + assert_eq!(sel.tail_start_index, None); + assert_eq!(sel.head_len, 2); + } + + #[test] + fn split_inside_oversized_turn_never_cuts_at_a_function_result() { + // One huge turn: user, assistant(call), result(big), assistant. + // The only cut that fits the budget straddles the result — the + // split must land on the closing assistant message instead. + let messages = vec![ + user("q", 1), + assistant_call("c1", 2), + result("c1", 4_000, 3), + assistant("done", 4), + ]; + let sel = select(&messages, 100, 1, &HeuristicEstimator); + // Tail = just the final assistant message (index 3): the cut at + // index 2 (the function_result) is unsafe even though it fits. + assert_eq!(sel.tail_start_index, Some(3)); + } + + #[test] + fn user_message_with_inline_result_block_is_not_a_cut() { + let inline_result_user: AgentMessage = serde_json::from_value(json!({ + "role": "user", + "content": [{ "type": "function_result", "function_call_id": "c9", "content": [] }], + "timestamp": 5 + })) + .unwrap(); + assert!(!is_safe_cut(&inline_result_user)); + assert!(is_safe_cut(&user("plain", 1))); + assert!(is_safe_cut(&assistant("a", 2))); + assert!(!is_safe_cut(&result("c", 10, 3))); + } + + #[test] + fn unsafe_turn_start_defers_to_an_older_safe_turn() { + // The newest turn starts at a user message carrying an inline + // function_result whose call sits in the previous turn. Cutting + // there would orphan the result, so the kept tail must start at + // the older (safe) turn and carry both turns together. + let inline_result_user: AgentMessage = serde_json::from_value(json!({ + "role": "user", + "content": [{ "type": "function_result", "function_call_id": "c7", "content": [] }], + "timestamp": 5 + })) + .unwrap(); + let messages = vec![ + user("old", 1), // 0: turn 1 + assistant("r0", 2), // 1 + user("follow", 3), // 2: turn 2 (safe) + assistant_call("c7", 4), // 3 + inline_result_user, // 4: turn 3 (UNSAFE start) + assistant("done", 5), // 5 + ]; + let sel = select(&messages, 1_000, 2, &HeuristicEstimator); + assert_eq!( + sel.tail_start_index, + Some(2), + "must cut at the safe turn 2 start" + ); + } + + #[test] + fn nothing_fits_summarises_everything() { + let messages = vec![ + user("q1", 1), + assistant("a1", 2), + user("q2", 3), + assistant("a2", 4), + ]; + let sel = select(&messages, 0, 2, &HeuristicEstimator); + assert_eq!(sel.tail_start_index, None); + assert_eq!(sel.head_len, 4); + } + + #[test] + fn history_without_user_messages_is_all_head() { + let messages = vec![assistant("a", 1), assistant("b", 2)]; + let sel = select(&messages, 1_000, 2, &HeuristicEstimator); + assert_eq!(sel.tail_start_index, None); + } +} diff --git a/context-manager/src/core/summary.rs b/context-manager/src/core/summary.rs new file mode 100644 index 00000000..8e975fb1 --- /dev/null +++ b/context-manager/src/core/summary.rs @@ -0,0 +1,292 @@ +//! Summariser prompt construction (context-manager.md § +//! context::compact): the fixed Markdown template, previous-summary +//! anchoring (update-in-place so summaries converge instead of +//! growing), media stripping, and the `# Conversation summary` system +//! prompt section `assemble` renders. + +use crate::types::{AgentMessage, ContentBlock}; + +/// The fixed summary structure (spec: Goal / Constraints / Progress / +/// Key Decisions / Actions Taken / Next Steps / Critical Context / +/// Relevant Files). +pub const SUMMARY_TEMPLATE: &str = r#"Output exactly the Markdown structure shown inside