diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 59018ea3..42f9c0ba 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -12,6 +12,7 @@ on: - acp - claude-code - coder + - codex - console - context-manager - database diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae3adff6..e140aa15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: - 'acp/v*' - 'claude-code/v*' - 'coder/v*' + - 'codex/v*' - 'console/v*' - 'context-manager/v*' - 'database/v*' diff --git a/README.md b/README.md index 3af30dae..29ce8917 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,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). | +| [`codex`](codex/) | Rust | OpenAI Codex as an iii worker — `codex::*` spawn the codex CLI for headless turns, mirror raw thread events onto `codex::events`, and stream AgentEvent frames onto `agent::events`. | | [`claude-code`](claude-code/) | Node | Claude Code as an iii worker — `claude::*` runs headless Claude Code turns, mirrors raw messages onto `claude::events`, and streams AgentEvent frames onto `agent::events`. | | [`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. | diff --git a/codex/.gitignore b/codex/.gitignore new file mode 100644 index 00000000..69c37d35 --- /dev/null +++ b/codex/.gitignore @@ -0,0 +1,2 @@ +/target +*.tsbuildinfo diff --git a/codex/Cargo.lock b/codex/Cargo.lock new file mode 100644 index 00000000..dcfdc9a0 --- /dev/null +++ b/codex/Cargo.lock @@ -0,0 +1,2536 @@ +# 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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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", +] + +[[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 = "codex" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "iii-observability", + "iii-sdk", + "once_cell", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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 = "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 = "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-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[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-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-core", + "futures-macro", + "futures-sink", + "futures-task", + "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 = "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 = "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 = "iii-observability" +version = "0.19.1-next.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7169861d75f52022881edf09657763c84299e935e60abe19faf98308dc4122e6" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.19.1-next.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619bbf68e82f91fa54d23986f00958a5bd573a08751c89b6528d2e35916a8642" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-observability", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "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 = "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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +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 = "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 = "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", + "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", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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", + "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", + "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 = "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 = "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 = "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", + "uuid", +] + +[[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 = "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_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "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 = "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 = "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 = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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 = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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", + "utf-8", +] + +[[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-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 = "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.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +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 = "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-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.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.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.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.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.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.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.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.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.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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[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/codex/Cargo.toml b/codex/Cargo.toml new file mode 100644 index 00000000..e52ec972 --- /dev/null +++ b/codex/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] + +[package] +name = "codex" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "codex" +path = "src/main.rs" + +[lib] +name = "codex" +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.19.1-next.1" +iii-observability = "=0.19.1-next.1" +schemars = { version = "0.8", features = ["uuid1"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "process", "time", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +toml = "0.8" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive", "env"] } +uuid = { version = "1", features = ["v4"] } +once_cell = "1" +tempfile = "3" + +[dev-dependencies] +tempfile = "3" diff --git a/codex/README.md b/codex/README.md new file mode 100644 index 00000000..7b89747d --- /dev/null +++ b/codex/README.md @@ -0,0 +1,174 @@ +# codex + +OpenAI Codex as an iii worker: the Codex API exposed as functions and streams on the iii bus, nothing else. The worker spawns the same `codex` binary the user runs in their terminal, with the same login (ChatGPT or API key), the same filesystem, and the same sandbox. `codex::run` executes one headless turn and returns the result; the raw Codex thread events mirror verbatim onto the `codex::events` stream, and a translated AgentEvent view lands on `agent::events`, so the iii console and any sibling worker observe a Codex run exactly like a native harness turn. + +## Install + +```bash +iii worker add codex +``` + +Requires the [Codex CLI](https://github.com/openai/codex) on the host (`npm i -g @openai/codex`) and either an existing `codex login` or `OPENAI_API_KEY` in the worker environment. + +## Skills + +Install the `codex` agent skill for Claude Code, Cursor, and 30+ other agents: + +```bash +npx skills add iii-hq/workers --skill codex +``` + +## Quickstart + +From zero to a Codex turn over the bus: + +```bash +curl -fsSL https://install.iii.dev/iii/main/install.sh | sh +iii worker add codex +iii # starts the engine + worker +``` + +Then talk to it like any other function: from `iii trigger codex::run`, or from any SDK: + +```ts +import { registerWorker } from 'iii-sdk'; + +const iii = registerWorker('ws://127.0.0.1:49134', { workerName: 'demo' }); + +const res = await iii.trigger({ + function_id: 'codex::run', + payload: { + prompt: 'Add a /health endpoint to server.ts and run the tests', + cwd: '/path/to/repo', + sandbox_mode: 'workspace-write', + }, + timeout_ms: 600_000, +}); +// { session_id, codex_thread_id, result, stop_reason, usage } +``` + +Or straight from the terminal with the `iii trigger` CLI: + +```bash +# one full turn (raise the timeout; the default 30s is too short for agent turns) +iii trigger codex::run --timeout-ms 600000 \ + --json '{"prompt":"add a /health endpoint and run the tests","cwd":"/path/to/repo"}' + +# quick reads use key=value syntax +iii trigger codex::sessions::list +iii trigger codex::status session_id= + +# background turn + control +iii trigger codex::start --json '{"prompt":"...","cwd":"/path/to/repo"}' +iii trigger codex::stop session_id= + +# ask the running engine for a function's description and parameter table +iii trigger codex::run --help +``` + +A turn from the CLI and the published schema: + +![iii trigger codex::run returning the result with usage and reasoning tokens](assets/cli-run.png) + +![iii trigger codex::run --help printing the published request schema as a parameter table](assets/cli-help.png) + +Call `codex::run` again with the returned `session_id` to continue the same conversation: the worker maps iii session ids to Codex thread ids in engine state and resumes automatically (threads persist in `~/.codex/sessions`). + +Two ids come back from every run. `session_id` is the iii session id: the key for `codex::status`, `codex::stop`, resume, and the stream group. `codex_thread_id` is Codex's internal thread id (what the worker passes to `resumeThread` under the hood) — returned for reference, not a lookup key. + +## Functions + +| Function | Purpose | +| --- | --- | +| `codex::run` | Run one turn, wait, return the final result | +| `codex::start` | Fire-and-forget turn; progress arrives on the streams | +| `codex::stop` | Interrupt a live run | +| `codex::status` | Session state, live flag, usage | +| `codex::sessions::list` | All sessions this worker has run | + +`codex::run` accepts either a bare `prompt` string or a `messages` array (`[{ role: 'user', content: [{ type: 'text', text }] }]`) — the same input contract as the claude-code worker and `run::start_and_wait`, so the acp worker drives Codex with `--brain-fn codex::run` — plus `model`, `cwd`, `sandbox_mode`, `approval_policy`, `reasoning_effort`, `skip_git_repo_check`, `output_schema`, `images` (local image paths), `additional_directories` (extra writable roots), and `codex_config`. + +### Raw API pass-through + +The worker drives the `codex exec --json` CLI, so anything the CLI exposes as a `config.toml` override goes through the `codex_config` field untouched — MCP servers, model providers, profiles, network access, web search, and the rest. Each key/value becomes a `--config key=value` on the spawn: + +```jsonc +{ + "prompt": "...", + "codex_config": { + "sandbox_workspace_write": { "network_access": true }, + "web_search": "live", + "mcp_servers": { "github": { "command": "gh-mcp" } } + } +} +``` + +Extra writable directories use the named `additional_directories` field (the CLI's `--add-dir`). + +And the full output side is available raw: every event Codex emits (`thread.started`, `turn.started`, `item.started/updated/completed` for commands, patches, MCP tool calls, web searches, reasoning, agent messages, `turn.completed` with usage, `turn.failed`) is mirrored verbatim onto the `codex::events` stream, group_id = session_id. Consumers that want the exact Codex wire format read `codex::events`; consumers that want harness-shaped frames read `agent::events`. Same turn, two views. + +## Configuration + +```yaml +engine_url: ws://127.0.0.1:49134 + +defaults: + model: "" # empty = Codex default + sandbox_mode: workspace-write # read-only | workspace-write | danger-full-access + approval_policy: never # never | on-request | on-failure | untrusted + reasoning_effort: "" # empty = Codex default + cwd: "" # default working directory for runs + skip_git_repo_check: true + +events_stream: agent::events # translated AgentEvent frames +raw_events_stream: codex::events # verbatim Codex thread events +codex_executable: "" # path to the codex CLI; empty = PATH resolution +``` + +Sandboxing is Codex's own: `read-only` blocks writes, `workspace-write` allows edits inside `cwd`, `danger-full-access` disables the sandbox. `approval_policy` is forwarded to Codex per turn from the payload or the configured default; headless callers leave it at `never` so a command the sandbox blocks fails instead of prompting, but any other value is passed through and respected. + +## The agent on the bus + +By default every turn carries the iii runtime context, delivered through Codex's native `developer_instructions` config (a developer-role message in the turn context, the channel Codex itself uses for standing instructions): the same engine-grounded rules as the harness identity prompts, retargeted to the `iii` CLI the agent reaches through its sandboxed shell. The agent discovers capabilities from the live engine instead of memory — `iii trigger engine::functions::list` to find function ids, `iii trigger --help` as the contract before every first call, the registry flow (`directory::registry::workers::list/info`, `worker::add`) when nothing registered fits — plus the calling rules and error-handling discipline that go with them. Local file edits stay on Codex's native tools; backend actions go through registered functions. + +```bash +# the agent answers this by querying the live engine itself +iii trigger codex::run --timeout-ms 300000 \ + --json '{"prompt":"List every worker connected to this engine and what each one does.","cwd":"/tmp"}' +``` + +The block rides the turn context on every call, resumes included, without touching your prompt or Codex's base instructions. Turn it off per call with `"iii_context": false` or globally in `config.yaml`; a caller-supplied `developer_instructions` in `codex_config` wins over it. + +## Plan-then-execute with sandbox modes + +Codex has no named plan mode; the equivalent is a planning prompt under the `read-only` sandbox. The guarantee is OS-level (Seatbelt on macOS, Landlock on Linux), so writes physically fail rather than being policy-declined. Because the worker resumes threads, plan-then-execute is two calls against the same `session_id`: + +```bash +# 1. plan (OS-enforced read-only) +iii trigger codex::run --timeout-ms 600000 \ + --json '{"prompt":"Plan how to add rate limiting to the REST API. Do not implement.","cwd":"/path/to/repo","sandbox_mode":"read-only"}' + +# 2. execute the plan with full context, same thread +iii trigger codex::run --timeout-ms 600000 \ + --json '{"session_id":"","prompt":"Implement the plan.","sandbox_mode":"workspace-write","cwd":"/path/to/repo"}' +``` + +The approval step is whatever sits between the two calls — a human reading the plan, another worker, or a trigger. + +## Observability + +Every `codex::run` is an ordinary traced invocation on the engine: the trace carries the full input payload and the output (result, usage) as span events, with per-function p50/p95/p99 in the console's trace explorer — no extra instrumentation in the worker. + +![codex::run invocations in the iii console trace explorer, with input and output payloads](assets/console-traces.png) + +## How it maps + +| Codex | iii | +| --- | --- | +| SDK `runStreamed()` turn | `codex::run` invocation | +| every thread event, verbatim | `codex::events` stream frame | +| agent_message / reasoning item | `message_complete` frame on `agent::events` | +| command_execution / file_change / mcp_tool_call / web_search | `function_execution_start` / `function_execution_end` frames | +| turn end | `turn_end` + `agent_end` frames, function return value | +| thread resume | engine state scope `codex_sessions`, keyed by iii session_id | +| extra capability | another iii worker on the bus (`shell`, `database`, `storage`, ...) | diff --git a/codex/assets/cli-help.png b/codex/assets/cli-help.png new file mode 100644 index 00000000..6ebd6763 Binary files /dev/null and b/codex/assets/cli-help.png differ diff --git a/codex/assets/cli-run.png b/codex/assets/cli-run.png new file mode 100644 index 00000000..16092c47 Binary files /dev/null and b/codex/assets/cli-run.png differ diff --git a/codex/assets/console-traces.png b/codex/assets/console-traces.png new file mode 100644 index 00000000..40319b57 Binary files /dev/null and b/codex/assets/console-traces.png differ diff --git a/codex/build.rs b/codex/build.rs new file mode 100644 index 00000000..33143a5e --- /dev/null +++ b/codex/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap_or_default() + ); +} diff --git a/codex/config.yaml b/codex/config.yaml new file mode 100644 index 00000000..5303c125 --- /dev/null +++ b/codex/config.yaml @@ -0,0 +1,33 @@ +# Seed installed as `initial_value` with the configuration worker on first +# registration; the live value is authoritative thereafter. The engine URL is +# bootstrap and lives on the --url flag, not here. + +defaults: + model: "" + sandbox_mode: workspace-write # read-only | workspace-write | danger-full-access + approval_policy: never # never | on-request | on-failure | untrusted + reasoning_effort: "" # empty = Codex default; minimal|low|medium|high|xhigh + cwd: "" + skip_git_repo_check: true + +# Stream AgentEvent frames here, grouped by session_id. The console and +# acp worker both read this stream. +events_stream: agent::events + +# Raw Codex thread events (exact SDK shapes: thread.started, turn.started, +# item.started/updated/completed, turn.completed, turn.failed, error) +# mirrored verbatim here, grouped by session_id. +raw_events_stream: codex::events + +# Deliver the iii runtime context as Codex developer_instructions: teaches +# the agent live discovery against the engine catalog through the iii CLI +# (a localhost engine is reachable under the default sandbox). Per-turn +# override via the iii_context payload field; a caller-supplied +# developer_instructions in codex_config wins. +iii_context: true + +# Path to the Codex CLI binary. Empty = the `codex` binary on PATH. +codex_executable: "" + +# Override the API base URL (passed to the SDK as baseUrl). Empty = default. +base_url: "" diff --git a/codex/iii-permissions.yaml b/codex/iii-permissions.yaml new file mode 100644 index 00000000..56e1a6dc --- /dev/null +++ b/codex/iii-permissions.yaml @@ -0,0 +1,14 @@ +# Agent permissions for the codex worker. +# Spec: docs/sops/new-worker.md § 7. First-match-wins. +# +# codex::run / codex::start spawn a full Codex coding agent with the host's +# filesystem and shell — an agent invoking those without human approval is a +# privilege escalation, so they are NOT allow-listed and stay at the +# needs_approval default. Read-only introspection is safe to allow. +version: 1 + +rules: + # internal config-reload callback — bus-internal, never agent-callable + - '!codex::on-config-change' + - codex::status + - codex::sessions::list diff --git a/codex/iii.worker.yaml b/codex/iii.worker.yaml new file mode 100644 index 00000000..02e0d962 --- /dev/null +++ b/codex/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: codex +language: rust +deploy: binary +manifest: Cargo.toml +bin: codex +description: OpenAI Codex as an iii worker; codex::run/start/stop/status/sessions::list spawn the codex CLI for headless turns, mirror raw thread events onto codex::events, and stream AgentEvent frames onto agent::events. diff --git a/codex/skills/SKILL.md b/codex/skills/SKILL.md new file mode 100644 index 00000000..a911eff1 --- /dev/null +++ b/codex/skills/SKILL.md @@ -0,0 +1,84 @@ +--- +name: codex +description: >- + Run headless OpenAI Codex turns over the iii bus — sandboxed shell, file + edits, and web search against any host directory — with verbatim event + streaming, thread resume, and full SDK option pass-through. +--- + +# codex + +The codex worker exposes the OpenAI Codex API as iii functions. One +`codex::run` call executes one headless Codex turn — the same agent the user +runs in their terminal, with the same login, filesystem, and sandbox — in a +chosen working directory, and returns the final result and token usage. The +worker is a pure pass-through: named payload fields cover the common path, +the `options` field forwards any Codex SDK ThreadOption verbatim, and every +thread event mirrors untouched onto the `codex::events` stream. A translated +AgentEvent view lands on `agent::events`, which is what the iii console +renders. + +Requires the `codex` CLI on the host with an existing `codex login` or +`OPENAI_API_KEY` in the worker environment. When a turn needs a capability +beyond Codex itself, add another iii worker to the bus instead of bolting +anything onto this one. + +## When to Use + +- Delegate a whole coding task ("add an endpoint and run the tests") in one + call, instead of orchestrating individual `coder::*` / `shell::*` calls + yourself: `codex::run` with `prompt` and `cwd`. +- Continue a conversation across calls: pass the same `session_id` again and + the worker resumes the underlying Codex thread with full context. +- Run long jobs without holding the call open: `codex::start` returns + `{session_id, started}` immediately; follow `codex::events` (group_id = + session_id) for raw progress or `agent::events` for the rendered view; + interrupt with `codex::stop`. +- Act on the whole backend: turns carry the iii runtime context by default + (delivered as Codex `developer_instructions`), so the agent discovers and + calls any registered function through the iii CLI + (engine::functions::list, `iii trigger --help`); disable per turn + with `iii_context: false`. +- Plan before touching anything: run the planning prompt with + `sandbox_mode: read-only` (writes physically fail), read the plan, then + send "implement the plan" on the same `session_id` with + `sandbox_mode: workspace-write`. +- Get structured final output: pass `output_schema` (JSON schema) and the + final agent message is JSON matching it. +- Attach screenshots or diagrams: `images: ["/path/a.png"]` adds local + images to the prompt. +- Wire MCP servers or model providers into one turn: `codex_config` + forwards any `config.toml` override, e.g. + `{"codex_config": {"mcp_servers": {"github": {"command": "gh-mcp"}}}}`. +- Reach past the named payload fields: anything the SDK ThreadOptions + accept goes through `options` unchanged, e.g. + `{"options": {"networkAccessEnabled": true, "webSearchMode": "live"}}`. + +## Boundaries + +- Spawns the host `codex` CLI per turn — needs Codex installed and + authenticated; not available inside a bare container without it. +- Execution safety is Codex's own sandbox (`sandbox_mode`), not the + engine's: `read-only` blocks writes, `workspace-write` allows edits in + `cwd`, `danger-full-access` disables the sandbox. Headless turns run + `approval_policy: never`, so blocked commands fail instead of prompting. +- One turn per session at a time: check `codex::status` (`live: true`) + before sending another `codex::run` for the same `session_id`; parallel + runs against one session race on the underlying thread resume. +- `agent::events` carries whole-message frames; per-item progress detail + (command output as it accumulates, todo lists) exists only on + `codex::events`. + +## Functions + +- `codex::run` — run one Codex turn and wait; accepts `prompt` (or a + `messages` array whose last user entry becomes the prompt), plus `model`, + `cwd`, `sandbox_mode`, `approval_policy`, `reasoning_effort`, + `skip_git_repo_check`, `output_schema`, and raw `options`; returns + `{session_id, codex_thread_id, result, stop_reason, usage}`. +- `codex::start` — same payload, returns `{session_id, started}` + immediately; progress arrives on the streams. +- `codex::stop` — interrupt the live run for a session. +- `codex::status` — point-in-time session view: live flag, status, turns, + usage. +- `codex::sessions::list` — every session this worker has run. diff --git a/codex/src/codex/args.rs b/codex/src/codex/args.rs new file mode 100644 index 00000000..5aa144cd --- /dev/null +++ b/codex/src/codex/args.rs @@ -0,0 +1,192 @@ +//! Build the `codex exec --json` argv from a run request, mirroring the TS +//! SDK's exec.ts. Derived settings and the caller's `codex_config` pass-through +//! serialize through `--config key=value` with TOML-literal values. + +use serde_json::{Map, Value}; + +use crate::functions::types::RunRequest; + +/// Resolved per-turn settings after merging payload over config defaults. +pub struct ResolvedOptions { + pub model: String, + pub cwd: String, + pub sandbox_mode: String, + pub approval_policy: String, + pub reasoning_effort: String, + pub skip_git_repo_check: bool, + pub base_url: String, + /// developer_instructions + any caller codex_config, already merged. + pub config: Map, + pub images: Vec, + pub additional_directories: Vec, +} + +/// Serialize a JSON value as a TOML literal for `--config key=value`. +pub fn toml_value(v: &Value) -> String { + match v { + Value::String(s) => toml::Value::String(s.clone()).to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => { + // Arrays/objects: TOML inline via the toml crate; fall back to a + // quoted JSON string if it cannot be represented. + match json_to_toml(other) { + Some(tv) => tv.to_string(), + None => toml::Value::String(other.to_string()).to_string(), + } + } + } +} + +fn json_to_toml(v: &Value) -> Option { + match v { + Value::String(s) => Some(toml::Value::String(s.clone())), + Value::Bool(b) => Some(toml::Value::Boolean(*b)), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Some(toml::Value::Integer(i)) + } else { + n.as_f64().map(toml::Value::Float) + } + } + Value::Array(a) => { + let items: Option> = a.iter().map(json_to_toml).collect(); + items.map(toml::Value::Array) + } + Value::Object(o) => { + let mut t = toml::map::Map::new(); + for (k, val) in o { + t.insert(k.clone(), json_to_toml(val)?); + } + Some(toml::Value::Table(t)) + } + Value::Null => None, + } +} + +/// Build argv for `codex exec`. `output_schema_path` is a temp file path when a +/// schema was supplied. `resume_thread` continues an existing thread. +pub fn build_args( + opts: &ResolvedOptions, + resume_thread: Option<&str>, + output_schema_path: Option<&str>, +) -> Vec { + let mut a: Vec = vec!["exec".into(), "--json".into()]; + + // --config overrides first (base_url + the merged config map). + if !opts.base_url.is_empty() { + a.push("--config".into()); + a.push(format!( + "openai_base_url={}", + toml_value(&Value::String(opts.base_url.clone())) + )); + } + if !opts.reasoning_effort.is_empty() { + a.push("--config".into()); + a.push(format!( + "model_reasoning_effort={}", + toml_value(&Value::String(opts.reasoning_effort.clone())) + )); + } + if !opts.approval_policy.is_empty() { + a.push("--config".into()); + a.push(format!( + "approval_policy={}", + toml_value(&Value::String(opts.approval_policy.clone())) + )); + } + for (k, v) in &opts.config { + a.push("--config".into()); + a.push(format!("{k}={}", toml_value(v))); + } + + if !opts.model.is_empty() { + a.push("--model".into()); + a.push(opts.model.clone()); + } + if !opts.sandbox_mode.is_empty() { + a.push("--sandbox".into()); + a.push(opts.sandbox_mode.clone()); + } + if !opts.cwd.is_empty() { + a.push("--cd".into()); + a.push(opts.cwd.clone()); + } + if opts.skip_git_repo_check { + a.push("--skip-git-repo-check".into()); + } + if let Some(path) = output_schema_path { + a.push("--output-schema".into()); + a.push(path.to_string()); + } + for dir in &opts.additional_directories { + a.push("--add-dir".into()); + a.push(dir.clone()); + } + for img in &opts.images { + a.push("--image".into()); + a.push(img.clone()); + } + if let Some(thread) = resume_thread { + a.push("resume".into()); + a.push(thread.to_string()); + } + a +} + +/// Merge payload over config defaults into ResolvedOptions, injecting the iii +/// developer_instructions block unless the caller supplied one. +pub fn resolve( + req: &RunRequest, + cfg: &crate::config::Config, + prior_model: Option<&str>, + prior_cwd: Option<&str>, + iii_prompt: Option<&str>, +) -> ResolvedOptions { + let d = &cfg.defaults; + let model = req + .model + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| prior_model.filter(|s| !s.is_empty()).map(str::to_string)) + .unwrap_or_else(|| d.model.clone()); + let cwd = req + .cwd + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| prior_cwd.filter(|s| !s.is_empty()).map(str::to_string)) + .unwrap_or_else(|| d.cwd.clone()); + + let mut config: Map = req + .codex_config + .clone() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + // developer_instructions: caller wins; otherwise inject the iii block. + if let Some(prompt) = iii_prompt { + config + .entry("developer_instructions".to_string()) + .or_insert_with(|| Value::String(prompt.to_string())); + } + + // An empty string is treated as unset: a caller must not be able to wipe + // the operator's configured sandbox / approval defaults with "". + let or_default = |v: &Option, dflt: &str| { + v.clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| dflt.to_string()) + }; + + ResolvedOptions { + model, + cwd, + sandbox_mode: or_default(&req.sandbox_mode, &d.sandbox_mode), + approval_policy: or_default(&req.approval_policy, &d.approval_policy), + reasoning_effort: or_default(&req.reasoning_effort, &d.reasoning_effort), + skip_git_repo_check: req.skip_git_repo_check.unwrap_or(d.skip_git_repo_check), + base_url: cfg.base_url.clone(), + config, + images: req.images.clone().unwrap_or_default(), + additional_directories: req.additional_directories.clone().unwrap_or_default(), + } +} diff --git a/codex/src/codex/events_types.rs b/codex/src/codex/events_types.rs new file mode 100644 index 00000000..d6649945 --- /dev/null +++ b/codex/src/codex/events_types.rs @@ -0,0 +1,163 @@ +//! Serde mirror of the JSONL events `codex exec --json` writes to stdout. +//! Mirrors `codex-rs/exec/src/exec_events.rs` (the codex crates are +//! unpublished, so the types are reproduced here rather than depended on). +//! Parsing is lenient: unknown event/item types fall through to `Unknown` +//! and are logged + skipped rather than failing the turn — the JSONL format +//! carries no version marker and is `--experimental-json`-adjacent. + +use serde::Deserialize; +use serde_json::Value; + +/// Top-level event, externally tagged on `type`. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum ThreadEvent { + #[serde(rename = "thread.started")] + ThreadStarted(ThreadStartedEvent), + #[serde(rename = "turn.started")] + TurnStarted(Value), + #[serde(rename = "turn.completed")] + TurnCompleted(TurnCompletedEvent), + #[serde(rename = "turn.failed")] + TurnFailed(TurnFailedEvent), + #[serde(rename = "item.started")] + ItemStarted(ItemEvent), + #[serde(rename = "item.updated")] + ItemUpdated(ItemEvent), + #[serde(rename = "item.completed")] + ItemCompleted(ItemEvent), + #[serde(rename = "error")] + Error(ThreadErrorEvent), + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadStartedEvent { + pub thread_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TurnCompletedEvent { + pub usage: Usage, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Usage { + #[serde(default)] + pub input_tokens: i64, + #[serde(default)] + pub cached_input_tokens: i64, + #[serde(default)] + pub output_tokens: i64, + #[serde(default)] + pub reasoning_output_tokens: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TurnFailedEvent { + pub error: ThreadErrorEvent, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadErrorEvent { + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ItemEvent { + pub item: ThreadItem, +} + +/// `{ id, ...details }` — details are flattened and tagged on `type`. +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadItem { + pub id: String, + #[serde(flatten)] + pub details: ThreadItemDetails, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThreadItemDetails { + AgentMessage { + text: String, + }, + Reasoning { + text: String, + }, + CommandExecution(CommandExecutionItem), + FileChange(FileChangeItem), + McpToolCall(McpToolCallItem), + WebSearch(WebSearchItem), + TodoList(Value), + Error { + message: String, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CommandExecutionItem { + #[serde(default)] + pub command: String, + #[serde(default)] + pub aggregated_output: String, + #[serde(default)] + pub exit_code: Option, + #[serde(default)] + pub status: ItemStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FileChangeItem { + #[serde(default)] + pub changes: Value, + #[serde(default)] + pub status: ItemStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct McpToolCallItem { + #[serde(default)] + pub server: String, + #[serde(default)] + pub tool: String, + #[serde(default)] + pub arguments: Value, + #[serde(default)] + pub result: Value, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub status: ItemStatus, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct McpError { + #[serde(default)] + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebSearchItem { + #[serde(default)] + pub query: String, +} + +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ItemStatus { + #[default] + InProgress, + Completed, + Failed, + Declined, +} + +impl ItemStatus { + pub fn is_failure(&self) -> bool { + matches!(self, ItemStatus::Failed) + } +} diff --git a/codex/src/codex/mod.rs b/codex/src/codex/mod.rs new file mode 100644 index 00000000..4677fa25 --- /dev/null +++ b/codex/src/codex/mod.rs @@ -0,0 +1,395 @@ +//! The Codex turn: spawn `codex exec --json`, write the prompt to stdin, parse +//! the JSONL event stream, mirror it verbatim onto `codex::events`, translate +//! it onto `agent::events`, and persist the session record. + +pub mod args; +pub mod events_types; +pub mod translate; + +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; + +use iii_sdk::III; +use once_cell::sync::Lazy; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::Mutex; +use tokio_util_compat::CancellationToken; + +use crate::config::Config; +use crate::events::emit; +use crate::functions::types::{extract_prompt, RunRequest}; +use crate::iii_prompt::III_CONTEXT_PROMPT; +use crate::state::{load_session, save_session}; +use crate::wire::{assistant_message, now_ms, ContentBlock, SessionRecord, Status}; +use events_types::ThreadEvent; + +/// A minimal cancellation token (avoids pulling tokio-util just for this). +mod tokio_util_compat { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + #[derive(Clone, Default)] + pub struct CancellationToken(Arc); + impl CancellationToken { + pub fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } +} + +pub struct LiveRun { + cancel: CancellationToken, +} + +/// session_id -> live run. The single handle codex::stop targets. +static LIVE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +pub async fn is_live(session_id: &str) -> bool { + LIVE.lock().await.contains_key(session_id) +} + +/// Atomically reserve the single live slot for a session: returns false if one +/// is already active. Check + insert happen under one lock so two concurrent +/// runs for the same session can't both pass. +async fn try_reserve(session_id: &str, cancel: CancellationToken) -> bool { + let mut live = LIVE.lock().await; + if live.contains_key(session_id) { + return false; + } + live.insert(session_id.to_string(), LiveRun { cancel }); + true +} + +async fn release(session_id: &str) { + LIVE.lock().await.remove(session_id); +} + +pub async fn stop(session_id: &str) -> bool { + if let Some(run) = LIVE.lock().await.get(session_id) { + run.cancel.cancel(); + true + } else { + false + } +} + +fn codex_bin(cfg: &Config) -> String { + if cfg.codex_executable.is_empty() { + "codex".to_string() + } else { + cfg.codex_executable.clone() + } +} + +/// Run one Codex turn and return the result map. `iii_context_default` is the +/// config-level toggle; the payload field overrides it. +pub async fn run(iii: III, cfg: Arc, req: RunRequest) -> Value { + let session_id = req + .session_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + // One live run per session: reserve the slot atomically up front. The + // in-process handle is what codex::stop targets, so a second concurrent run + // would clobber it and race the record. Every early return past this point + // must `release` the slot. + let cancel = CancellationToken::default(); + if !try_reserve(&session_id, cancel.clone()).await { + return json!({ + "session_id": session_id, + "busy": true, + "reason": "a run is already active for this session" + }); + } + + let prompt = match extract_prompt(&req) { + Ok(p) => p, + Err(e) => { + release(&session_id).await; + return json!({ "session_id": session_id, "is_error": true, "result": e.to_string() }); + } + }; + + // A load failure is corruption/transient, not "no prior session": log it + // and proceed fresh rather than silently masking it. + let prior = match load_session(&iii, &session_id).await { + Ok(p) => p, + Err(e) => { + tracing::warn!(session_id, error = %e, "load_session failed; proceeding without resume"); + None + } + }; + let prior_thread = prior.as_ref().and_then(|r| r.codex_thread_id.clone()); + let want_ctx = req.iii_context.unwrap_or(cfg.iii_context); + + let opts = args::resolve( + &req, + &cfg, + prior.as_ref().map(|r| r.model.as_str()), + prior.as_ref().map(|r| r.cwd.as_str()), + if want_ctx { + Some(III_CONTEXT_PROMPT) + } else { + None + }, + ); + + // output_schema → temp file (codex takes a path, not inline). Held until + // the child exits. + let schema_file = match &req.output_schema { + Some(schema) => match write_schema(schema) { + Ok(f) => Some(f), + Err(e) => { + release(&session_id).await; + return json!({ "session_id": session_id, "is_error": true, "result": format!("output_schema temp file: {e}") }); + } + }, + None => None, + }; + let schema_path = schema_file + .as_ref() + .map(|f| f.path().to_string_lossy().to_string()); + + let argv = args::build_args(&opts, prior_thread.as_deref(), schema_path.as_deref()); + + let mut record = prior.unwrap_or(SessionRecord { + session_id: session_id.clone(), + codex_thread_id: None, + cwd: opts.cwd.clone(), + model: opts.model.clone(), + status: Status::Working, + turns: 0, + usage: None, + updated_at_ms: now_ms(), + }); + record.cwd = opts.cwd.clone(); + record.model = opts.model.clone(); + + // Spawn first; only persist `working` once the child + live handle exist, + // so a spawn failure never leaves a stuck `working` record. + let mut child = match Command::new(codex_bin(&cfg)) + .args(&argv) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + release(&session_id).await; + return json!({ "session_id": session_id, "is_error": true, "stop_reason": "error", "result": format!("failed to spawn codex: {e}") }); + } + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(prompt.as_bytes()).await; + let _ = stdin.shutdown().await; + } + + // Drain stderr in the background so a chatty child can't fill the pipe + // buffer and block. Tail is captured for the error path. + let stderr_tail = drain_stderr(child.stderr.take()); + + record.status = Status::Working; + record.updated_at_ms = now_ms(); + let _ = save_session(&iii, &record).await; + + let mut outcome = stream_turn(&iii, &cfg, &session_id, &mut child, &cancel, &mut record).await; + + release(&session_id).await; + drop(schema_file); + + // On error with no result text, surface the captured stderr tail. + if outcome.is_error && outcome.result_text.is_empty() { + if let Ok(tail) = stderr_tail.await { + let tail = tail.trim(); + if !tail.is_empty() { + outcome.result_text = tail.chars().take(2000).collect(); + } + } + } + + record.status = if outcome.is_error { + Status::Error + } else { + Status::Done + }; + record.turns += 1; + if outcome.usage.is_some() { + record.usage = outcome.usage.clone(); + } + record.updated_at_ms = now_ms(); + let _ = save_session(&iii, &record).await; + + // turn_end + agent_end on the translated stream. + let final_msg = assistant_message( + vec![ContentBlock::Text { + text: outcome.result_text.clone(), + }], + &record.model, + record.usage.clone(), + &outcome.stop_reason, + ); + emit( + &iii, + &cfg.events_stream, + &session_id, + json!({ "type": "turn_end", "message": final_msg, "function_results": [] }), + ) + .await; + emit( + &iii, + &cfg.events_stream, + &session_id, + json!({ "type": "agent_end", "messages": [] }), + ) + .await; + + json!({ + "session_id": session_id, + "codex_thread_id": record.codex_thread_id, + "result": outcome.result_text, + "stop_reason": outcome.stop_reason, + "is_error": outcome.is_error, + "num_turns": record.turns, + "usage": record.usage, + }) +} + +struct Outcome { + result_text: String, + stop_reason: String, + is_error: bool, + usage: Option, +} + +async fn stream_turn( + iii: &III, + cfg: &Config, + session_id: &str, + child: &mut tokio::process::Child, + cancel: &CancellationToken, + record: &mut SessionRecord, +) -> Outcome { + let mut outcome = Outcome { + result_text: String::new(), + stop_reason: "end".to_string(), + is_error: false, + usage: None, + }; + let stdout = match child.stdout.take() { + Some(s) => s, + None => { + outcome.is_error = true; + outcome.stop_reason = "error".to_string(); + outcome.result_text = "codex produced no stdout".to_string(); + return outcome; + } + }; + let mut lines = BufReader::new(stdout).lines(); + let mut state = translate::TurnState::new(record.model.clone()); + + loop { + if cancel.is_cancelled() { + let _ = child.start_kill(); + outcome.stop_reason = "aborted".to_string(); + break; + } + let line = tokio::select! { + l = lines.next_line() => l, + _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => continue, + }; + let line = match line { + Ok(Some(l)) => l, + Ok(None) => break, // stdout closed + Err(e) => { + outcome.is_error = true; + outcome.stop_reason = "error".to_string(); + outcome.result_text = format!("stdout read error: {e}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let raw: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + tracing::debug!(session_id, error = %e, "skipping non-JSON line from codex exec"); + continue; + } + }; + // verbatim onto the raw stream + emit(iii, &cfg.raw_events_stream, session_id, raw.clone()).await; + + let event: ThreadEvent = match serde_json::from_value(raw) { + Ok(e) => e, + Err(_) => continue, + }; + let had_thread = state.thread_id.is_some(); + let frames = translate::step(&mut state, event); + // persist the thread id the first time it appears (enables resume) + if !had_thread { + if let Some(tid) = &state.thread_id { + record.codex_thread_id = Some(tid.clone()); + let _ = save_session(iii, record).await; + } + } + for frame in frames { + emit(iii, &cfg.events_stream, session_id, frame).await; + } + } + + let exit = child.wait().await; + // fold the accumulated turn state into the outcome (cancel sets aborted above) + if !outcome.is_error && outcome.stop_reason != "aborted" { + outcome.is_error = state.is_error; + outcome.stop_reason = state.stop_reason; + outcome.result_text = state.result_text; + } + outcome.usage = state.usage; + // Backstop: a non-zero exit with no error event observed (e.g. the CLI + // crashed) must not be reported as success. The aborted path is expected + // to exit non-zero and is left as-is. + if !outcome.is_error && outcome.stop_reason != "aborted" { + let bad = matches!(&exit, Ok(s) if !s.success()) || exit.is_err(); + if bad { + outcome.is_error = true; + outcome.stop_reason = "error".to_string(); + if outcome.result_text.is_empty() { + outcome.result_text = match &exit { + Ok(s) => format!("codex exited with {s}"), + Err(e) => format!("codex wait failed: {e}"), + }; + } + } + } + outcome +} + +/// Drain a child's stderr in the background into a captured tail, so a chatty +/// process can't block on a full pipe. Returns a handle yielding the text. +fn drain_stderr(stderr: Option) -> tokio::task::JoinHandle { + tokio::spawn(async move { + let mut buf = String::new(); + if let Some(mut e) = stderr { + use tokio::io::AsyncReadExt; + let _ = e.read_to_string(&mut buf).await; + } + buf + }) +} + +fn write_schema(schema: &Value) -> anyhow::Result { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new()?; + f.write_all(serde_json::to_string(schema)?.as_bytes())?; + f.flush()?; + Ok(f) +} diff --git a/codex/src/codex/translate.rs b/codex/src/codex/translate.rs new file mode 100644 index 00000000..b4fc1b4e --- /dev/null +++ b/codex/src/codex/translate.rs @@ -0,0 +1,114 @@ +//! Pure translation of the Codex JSONL event stream into the AgentEvent frames +//! emitted on `agent::events`, plus the running turn state (thread id, usage, +//! result text, stop reason). Kept free of I/O so the full per-turn sequence is +//! unit-testable without a live engine — the stream loop in `mod.rs` only does +//! the reads/writes around `step`. + +use std::collections::HashSet; + +use serde_json::{json, Value}; + +use super::events_types::{ThreadEvent, ThreadItemDetails}; +use crate::map; +use crate::wire::{assistant_message, ContentBlock, Usage}; + +#[derive(Default)] +pub struct TurnState { + started: HashSet, + pub thread_id: Option, + pub usage: Option, + pub result_text: String, + pub stop_reason: String, + pub is_error: bool, + /// Model id stamped onto message_complete frames. + pub model: String, +} + +impl TurnState { + pub fn new(model: String) -> Self { + Self { + stop_reason: "end".to_string(), + model, + ..Default::default() + } + } +} + +/// Advance the turn by one event, returning the `agent::events` frames to emit +/// (in order). Updates `state` in place. +pub fn step(state: &mut TurnState, event: ThreadEvent) -> Vec { + match event { + ThreadEvent::ThreadStarted(e) => { + state.thread_id = Some(e.thread_id); + vec![] + } + ThreadEvent::ItemStarted(ev) | ThreadEvent::ItemUpdated(ev) => { + if map::is_exec_item(&ev.item.details) && state.started.insert(ev.item.id.clone()) { + vec![exec_start(&ev.item.id, &ev.item.details)] + } else { + vec![] + } + } + ThreadEvent::ItemCompleted(ev) => item_completed(state, &ev.item.id, &ev.item.details), + ThreadEvent::TurnCompleted(e) => { + state.usage = Some(map::map_usage(&e.usage)); + vec![] + } + ThreadEvent::TurnFailed(e) => { + state.is_error = true; + state.stop_reason = "error".to_string(); + state.result_text = e.error.message; + vec![] + } + ThreadEvent::Error(e) => { + state.is_error = true; + state.stop_reason = "error".to_string(); + state.result_text = e.message; + vec![] + } + ThreadEvent::TurnStarted(_) | ThreadEvent::Unknown => vec![], + } +} + +fn exec_start(id: &str, details: &ThreadItemDetails) -> Value { + json!({ + "type": "function_execution_start", + "function_call_id": id, + "function_id": map::function_id(details), + "args": map::args_for(details), + }) +} + +fn item_completed(state: &mut TurnState, id: &str, details: &ThreadItemDetails) -> Vec { + match details { + ThreadItemDetails::AgentMessage { text } | ThreadItemDetails::Reasoning { text } => { + let is_agent = matches!(details, ThreadItemDetails::AgentMessage { .. }); + let block = if is_agent { + ContentBlock::Text { text: text.clone() } + } else { + ContentBlock::Thinking { text: text.clone() } + }; + if is_agent { + state.result_text = text.clone(); + } + let msg = assistant_message(vec![block], &state.model, None, "end"); + vec![json!({ "type": "message_complete", "message": msg })] + } + d if map::is_exec_item(d) => { + let mut frames = Vec::new(); + if state.started.insert(id.to_string()) { + frames.push(exec_start(id, d)); + } + frames.push(json!({ + "type": "function_execution_end", + "function_call_id": id, + "function_id": map::function_id(d), + "result": { "content": map::result_content(d), "details": null }, + "is_error": map::is_error_item(d), + "duration_ms": 0, + })); + frames + } + _ => vec![], + } +} diff --git a/codex/src/config.rs b/codex/src/config.rs new file mode 100644 index 00000000..ece88c72 --- /dev/null +++ b/codex/src/config.rs @@ -0,0 +1,85 @@ +//! Runtime config managed by the `configuration` worker. `config.yaml` is the +//! seed installed as `initial_value` on first registration; the live value from +//! the configuration worker is authoritative thereafter and hot-reloads. +//! +//! `engine_url` is intentionally NOT here — it is bootstrap (you need it to +//! reach the configuration worker), so it stays on the `--url` CLI flag. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct Defaults { + pub model: String, + pub sandbox_mode: String, + pub approval_policy: String, + pub reasoning_effort: String, + pub cwd: String, + pub skip_git_repo_check: bool, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + model: String::new(), + sandbox_mode: "workspace-write".to_string(), + approval_policy: "never".to_string(), + reasoning_effort: String::new(), + cwd: String::new(), + skip_git_repo_check: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct Config { + pub defaults: Defaults, + pub events_stream: String, + pub raw_events_stream: String, + pub codex_executable: String, + pub base_url: String, + pub iii_context: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + defaults: Defaults::default(), + events_stream: "agent::events".to_string(), + raw_events_stream: "codex::events".to_string(), + codex_executable: String::new(), + base_url: String::new(), + iii_context: true, + } + } +} + +impl Config { + /// Load the seed from a YAML file. Missing file yields defaults; a parse + /// error propagates so a typo fails the worker fast. + pub fn load(path: &str) -> anyhow::Result { + match std::fs::read_to_string(path) { + Ok(text) => Ok(serde_yaml::from_str(&text)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), + Err(e) => Err(e.into()), + } + } + + pub fn json_schema() -> Value { + let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::(); + serde_json::to_value(root).expect("config schema serializes") + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).expect("config serializes") + } + + /// Parse a value fetched from the configuration worker (already env-expanded + /// by the worker; this does not re-expand). + pub fn from_json(value: &Value) -> anyhow::Result { + Ok(serde_json::from_value(value.clone())?) + } +} diff --git a/codex/src/configuration.rs b/codex/src/configuration.rs new file mode 100644 index 00000000..3a6424d6 --- /dev/null +++ b/codex/src/configuration.rs @@ -0,0 +1,153 @@ +//! Integration with the `configuration` worker — register the codex config +//! schema, fetch the live value, and hot-reload it on change. Every field is a +//! runtime tuning knob (model/sandbox defaults, stream names, executable path, +//! iii-context toggle), so a change hot-swaps the whole snapshot — there is no +//! security topology to refuse like the path-jail workers. + +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::Config; + +/// Hot-swappable config snapshot shared with every handler. A handler takes a +/// `read().await` and clones the inner `Arc` out (a cheap refcount bump) so it +/// never holds the lock across a turn; `apply_config` whole-snapshot replaces +/// the inner `Arc` under the write lock. +pub type ConfigCell = Arc>>; + +pub const CONFIG_ID: &str = "codex"; +const CONFIG_FN_ID: &str = "codex::on-config-change"; +const CONFIG_TIMEOUT_MS: u64 = 5_000; +const CONFIG_RETRIES: u32 = 3; + +/// Register the `codex` configuration schema. 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. +pub async fn register_config(iii: &III, seed: Option<&Config>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "Codex", + "description": "OpenAI Codex worker: per-turn defaults (model, sandbox mode, approval policy, reasoning effort, working directory), the agent::events / codex::events stream names, the codex CLI path, an optional API base URL, and whether to inject the iii runtime context as developer_instructions.", + "schema": Config::json_schema(), + }); + if let Some(seed) = seed { + payload["initial_value"] = seed.to_json(); + } else if should_seed_default(iii).await? { + payload["initial_value"] = Config::default().to_json(); + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// Read the live `codex` configuration; built-in default when none stored. +pub async fn fetch_config(iii: &III) -> Result { + match try_get_value(iii).await? { + Some(v) if !v.is_null() => Config::from_json(&v).map_err(|e| e.to_string()), + _ => { + tracing::info!("no codex configuration value found; using built-in defaults"); + Ok(Config::default()) + } + } +} + +async fn should_seed_default(iii: &III) -> Result { + Ok(matches!( + try_get_value(iii).await?, + None | Some(Value::Null) + )) +} + +/// `Ok(None)` when the entry does not exist (`NOT_FOUND`). +async fn try_get_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.contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +pub async fn apply_config(cell: &ConfigCell, cfg: Config) { + *cell.write().await = Arc::new(cfg); +} + +/// Register the internal config-change handler and bind the `configuration` +/// trigger that wakes it. +pub fn register_config_trigger(iii: &III, cell: ConfigCell) -> Result<(), IIIError> { + let engine = iii.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: Value| { + let cell = cell.clone(); + let engine = engine.clone(); + async move { + on_config_change(&engine, &cell).await; + Ok::(json!({ "ok": true })) + } + }) + .description("Internal: reload codex configuration when it changes."), + ); + + 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(()) +} + +/// Re-fetch the authoritative value after trigger registration to close the +/// boot race (an update that landed between the initial fetch and the trigger +/// binding has no other listener). +pub async fn reconcile(iii: &III, cell: &ConfigCell) { + on_config_change(iii, cell).await; +} + +/// The trigger payload is intentionally ignored — `codex::on-config-change` is +/// a discoverable bus function, so trusting `payload.new_value` would let any +/// caller inject config without updating persisted state. Re-fetch the stored +/// value instead. The previous snapshot is kept on any failure. +async fn on_config_change(iii: &III, cell: &ConfigCell) { + match fetch_config(iii).await { + Ok(cfg) => { + apply_config(cell, cfg).await; + tracing::info!("codex configuration reloaded"); + } + Err(e) => { + tracing::error!(error = %e, "config-change: fetch failed; keeping previous config"); + } + } +} + +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 { + tokio::time::sleep(Duration::from_millis(250 * u64::from(attempt))).await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} diff --git a/codex/src/events.rs b/codex/src/events.rs new file mode 100644 index 00000000..10e18176 --- /dev/null +++ b/codex/src/events.rs @@ -0,0 +1,44 @@ +//! Stream emitter: writes frames via the engine's `stream::set` builtin with a +//! per-process epoch + per-session monotonic sequence so item_ids never +//! collide across restarts (matches the harness emitter). Failures are logged, +//! not propagated — the streams are best-effort observability; the function's +//! return value and the session record are the source of truth. + +use std::collections::HashMap; +use std::sync::Mutex; + +use iii_sdk::{TriggerRequest, III}; +use once_cell::sync::Lazy; +use serde_json::{json, Value}; +use uuid::Uuid; + +static EPOCH: Lazy = Lazy::new(|| Uuid::new_v4().to_string()); +static SEQ: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +fn next_item_id(session_id: &str) -> String { + let mut map = SEQ.lock().expect("seq mutex"); + let n = map.entry(session_id.to_string()).or_insert(0); + let seq = *n; + *n += 1; + format!("{session_id}-{}-{:08}", &*EPOCH, seq) +} + +pub async fn emit(iii: &III, stream_name: &str, session_id: &str, data: Value) { + let item_id = next_item_id(session_id); + let res = iii + .trigger(TriggerRequest { + function_id: "stream::set".to_string(), + payload: json!({ + "stream_name": stream_name, + "group_id": session_id, + "item_id": item_id, + "data": data, + }), + action: None, + timeout_ms: Some(5_000), + }) + .await; + if let Err(e) = res { + tracing::warn!(stream_name, session_id, error = %e, "stream::set failed"); + } +} diff --git a/codex/src/functions/mod.rs b/codex/src/functions/mod.rs new file mode 100644 index 00000000..31aa26f4 --- /dev/null +++ b/codex/src/functions/mod.rs @@ -0,0 +1,147 @@ +//! Register the codex::* surface. Handlers parse at the unknown boundary and +//! delegate to the codex turn loop; schemas are published via request_format +//! so `iii trigger codex::run --help` prints the parameter table. + +pub mod types; + +use iii_sdk::{IIIError, RegisterFunction, III}; +use serde_json::{json, Value}; + +use crate::codex; +use crate::configuration::ConfigCell; +use crate::state::{list_sessions, load_session, mark_error}; +use types::{RunRequest, SessionIdRequest}; + +fn schema_value() -> Value { + let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::(); + serde_json::to_value(root).expect("schema serializes") +} + +pub fn register_all(iii: &III, cell: ConfigCell) { + // codex::run — run a turn and wait for the result. + { + let iii_h = iii.clone(); + let cell_h = cell.clone(); + iii.register_function( + "codex::run", + RegisterFunction::new_async(move |req: RunRequest| { + let iii_h = iii_h.clone(); + let cell_h = cell_h.clone(); + async move { + let cfg = { cell_h.read().await.clone() }; + Ok::(codex::run(iii_h, cfg, req).await) + } + }) + .request_format(schema_value::()) + .description( + "Run one Codex turn and wait for the result. Accepts `prompt` or a `messages` \ + array plus a raw SDK `codex_config` pass-through; streams raw Codex events onto \ + codex::events, AgentEvent frames onto agent::events, and returns \ + {session_id, result, usage}.", + ), + ); + } + + // codex::start — fire-and-forget; progress on the streams. + { + let iii_h = iii.clone(); + let cell_h = cell.clone(); + iii.register_function( + "codex::start", + RegisterFunction::new_async(move |req: RunRequest| { + let iii_h = iii_h.clone(); + let cell_h = cell_h.clone(); + async move { + let session_id = req + .session_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut started = req; + started.session_id = Some(session_id.clone()); + let bg_iii = iii_h.clone(); + let bg_id = session_id.clone(); + let cfg = { cell_h.read().await.clone() }; + // Supervise the run: run() persists terminal state itself, + // but if the task panics that never happens — the + // supervisor catches the JoinError and marks the session + // error so it can't stay stuck in `working`. + tokio::spawn(async move { + let run_iii = bg_iii.clone(); + let inner = tokio::spawn(codex::run(run_iii, cfg, started)); + match inner.await { + Ok(res) => { + if res.get("is_error").and_then(Value::as_bool) == Some(true) { + mark_error(&bg_iii, &bg_id).await; + } + } + Err(e) => { + tracing::error!(session_id = %bg_id, error = %e, "codex::start task panicked"); + mark_error(&bg_iii, &bg_id).await; + } + } + }); + Ok::(json!({ "session_id": session_id, "started": true })) + } + }) + .request_format(schema_value::()) + .description( + "Start a Codex turn and return immediately; watch codex::events / agent::events \ + (group_id = session_id) for progress and turn_end.", + ), + ); + } + + // codex::stop — interrupt a live run. + iii.register_function( + "codex::stop", + RegisterFunction::new_async(move |req: SessionIdRequest| async move { + let stopped = codex::stop(&req.session_id).await; + Ok::(json!({ + "session_id": req.session_id, + "stopped": stopped, + "reason": if stopped { Value::Null } else { json!("no live run") }, + })) + }) + .request_format(schema_value::()) + .description("Interrupt a live Codex run for a session."), + ); + + // codex::status — point-in-time session view. + { + let iii_h = iii.clone(); + iii.register_function( + "codex::status", + RegisterFunction::new_async(move |req: SessionIdRequest| { + let iii_h = iii_h.clone(); + async move { + let record = load_session(&iii_h, &req.session_id).await.ok().flatten(); + let live = codex::is_live(&req.session_id).await; + Ok::(json!({ + "session_id": req.session_id, + "live": live, + "record": record, + })) + } + }) + .request_format(schema_value::()) + .description("Point-in-time status of a Codex session."), + ); + } + + // codex::sessions::list — every session this worker has run. + { + let iii_h = iii.clone(); + iii.register_function( + "codex::sessions::list", + RegisterFunction::new_async(move |_req: Value| { + let iii_h = iii_h.clone(); + async move { + let sessions = list_sessions(&iii_h).await.unwrap_or_default(); + Ok::(json!({ "sessions": sessions })) + } + }) + .request_format(json!({ "type": "object", "properties": {} })) + .description("List every Codex session this worker has run."), + ); + } +} diff --git a/codex/src/functions/types.rs b/codex/src/functions/types.rs new file mode 100644 index 00000000..f316599b --- /dev/null +++ b/codex/src/functions/types.rs @@ -0,0 +1,80 @@ +//! Request payloads. `RunRequest` derives `JsonSchema` so the engine publishes +//! the parameter table for `iii trigger codex::run --help`, and `Deserialize` +//! so the handler parses at the unknown boundary. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct Message { + pub role: String, + /// Either a plain string or an array of content blocks. + pub content: Value, +} + +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +#[serde(default)] +pub struct RunRequest { + /// iii session id; reuse to resume the same Codex thread. + pub session_id: Option, + /// The user prompt for this turn. + pub prompt: Option, + /// Alternative to prompt: role/content messages; the last user entry becomes the prompt. + pub messages: Option>, + /// Model id; empty = Codex default. + pub model: Option, + /// Working directory the turn runs in. + pub cwd: Option, + /// Codex sandbox mode: read-only | workspace-write | danger-full-access. + pub sandbox_mode: Option, + /// Codex approval policy; headless callers leave it at never. + pub approval_policy: Option, + /// Model reasoning effort: minimal | low | medium | high | xhigh. + pub reasoning_effort: Option, + /// Allow running outside a git repository. + pub skip_git_repo_check: Option, + /// JSON schema for structured final output. + pub output_schema: Option, + /// Paths to local images attached to the prompt. + pub images: Option>, + /// Additional writable directories alongside the working root (--add-dir). + pub additional_directories: Option>, + /// Codex config.toml overrides for this turn (e.g. mcp_servers, model_providers, profiles). + pub codex_config: Option, + /// Inject the iii runtime discovery prompt as developer_instructions (default true via config). + pub iii_context: Option, + /// Reserved for callers; not forwarded. + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SessionIdRequest { + /// iii session id returned by codex::run / codex::start. + pub session_id: String, +} + +/// Extract the prompt: explicit `prompt` (incl. empty string) wins; otherwise +/// the last user message's text. +pub fn extract_prompt(req: &RunRequest) -> anyhow::Result { + if let Some(p) = &req.prompt { + return Ok(p.clone()); + } + let messages = req.messages.as_ref().ok_or_else(|| { + anyhow::anyhow!("codex::run requires `prompt` or a user message in `messages`") + })?; + let last = messages.iter().rfind(|m| m.role == "user").ok_or_else(|| { + anyhow::anyhow!("codex::run requires `prompt` or a user message in `messages`") + })?; + match &last.content { + Value::String(s) => Ok(s.clone()), + Value::Array(blocks) => Ok(blocks + .iter() + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n")), + _ => Err(anyhow::anyhow!( + "unsupported message content: expected a string or an array of content blocks" + )), + } +} diff --git a/codex/src/iii_prompt.rs b/codex/src/iii_prompt.rs new file mode 100644 index 00000000..28d535c7 --- /dev/null +++ b/codex/src/iii_prompt.rs @@ -0,0 +1,76 @@ +//! iii runtime context delivered as Codex `developer_instructions` (a +//! developer-role message in the turn context) when `iii_context` is enabled. +//! Same engine-grounded rules as the harness identity prompts, retargeted from +//! `agent_trigger` to the `iii` CLI the agent reaches through its sandboxed +//! shell. A caller-supplied `developer_instructions` in `codex_config` wins. + +pub const III_CONTEXT_PROMPT: &str = r#"# iii runtime + +This machine runs an iii engine: a WebSocket-routed worker mesh whose single engine process +holds a live registry of every connected worker, every function those workers expose, and every +trigger bound to them. Every call routes worker -> engine -> worker, so the language, runtime, +and location of a worker are invisible to its callers. The function id is the ONLY contract +between two workers. + +You act on iii ONLY through the `iii` CLI on PATH, via your shell: + + iii trigger [key=value ...] [--json ''] [--timeout-ms ] + +Function ids are namespaced with `::` (e.g. `engine::functions::list`). Simple arguments go +as `key=value` pairs; structured payloads go as `--json` with a single-quoted JSON OBJECT. + +IMPORTANT: NEVER invent function ids or argument names from memory. Discover them from the live +engine and trust it over memory or this prompt. + +## Discovery + +The live engine is the single source of truth. Ask it — never assume: + +- `iii trigger engine::functions::list --json '{"search":""}'` — every function across + all workers; optional filters `prefix` / `search` / `worker`. Use it to FIND a function + id. +- `iii trigger --help` — that function's description and request schema, served by + the engine. THIS IS THE API REFERENCE for every call you make. Fetch it BEFORE the first call + to any function; a one-line description from `list` is a hint, not the contract. +- `iii trigger engine::workers::list` — every connected worker; + `iii trigger engine::workers::info name=` — one worker's full surface. +- `iii trigger engine::triggers::list` — every trigger TYPE; + `iii trigger engine::registered-triggers::list` — every trigger INSTANCE already bound. + +Need a backend capability? Check what is already registered FIRST — it is usually one call +away. When nothing fits, search the public registry before building anything: +`iii trigger directory::registry::workers::list --json '{"search":""}'` pages the +published catalogue and `iii trigger directory::registry::workers::info name=` returns +one worker's full detail. Say what you are about to install and why, install with +`iii trigger worker::add --json '{"source":{"kind":"registry","name":""}}'`, then +confirm the new ids appear via `engine::functions::list` with that prefix and fetch each +contract with `--help` as usual. + +## Calling rules + +- `--json` takes a JSON OBJECT in single quotes: `--json '{"path":"/tmp"}'`. Never pass a + JSON-encoded string where the engine expects an object — workers reject it with + `invalid_arguments` / `serialization error`. +- Long-running functions need `--timeout-ms` well above the default 30000. +- Triggers are the engine's push channel: NEVER poll (a loop re-reading a queue, file, or + table) when a trigger type fits — bind a trigger instead. A trigger registration succeeds + even when its type's provider is absent or the config keys are wrong — the binding lands but + never fires — so copy config keys from `engine::triggers::info`, not from memory. + +## Error handling + +When a call errors, READ the error and CHANGE something before the next call. NEVER resend the +same function + payload unchanged. `invalid_arguments` / `missing field` means YOUR payload +is wrong: re-read the contract via `--help` and fix the object, keeping the same function. +`function_not_found` means the id is wrong: re-check via `engine::functions::list`. A +repeating timeout means the approach is wrong, not the arguments: simplify, split the work, or +report the blocker and stop. + +## Boundaries + +- Files in your working directory: use your native tools (read, edit, search). The bus is not + for local file edits. +- Backend actions beyond the working directory — email, databases, storage, queues, schedules, + other services — go through registered iii functions, never ad-hoc processes or foreign + patterns carried in from other ecosystems. If you reach for a tool that is not an iii + function for a backend action, stop and re-check the engine's surface first."#; diff --git a/codex/src/lib.rs b/codex/src/lib.rs new file mode 100644 index 00000000..873f6e68 --- /dev/null +++ b/codex/src/lib.rs @@ -0,0 +1,13 @@ +//! codex worker library surface — re-exported so integration tests can drive +//! the modules in process. + +pub mod codex; +pub mod config; +pub mod configuration; +pub mod events; +pub mod functions; +pub mod iii_prompt; +pub mod manifest; +pub mod map; +pub mod state; +pub mod wire; diff --git a/codex/src/main.rs b/codex/src/main.rs new file mode 100644 index 00000000..4c77466c --- /dev/null +++ b/codex/src/main.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use iii_observability::OtelConfig; +use iii_sdk::{register_worker, InitOptions}; +use tokio::sync::RwLock; + +use codex::config::Config; +use codex::configuration; +use codex::functions::register_all; +use codex::manifest; + +#[derive(Parser, Debug)] +#[command(name = "codex", about = "OpenAI Codex worker for iii agents")] +struct Cli { + /// Seed config registered as `initial_value` with the `configuration` + /// worker on first registration. Defaults to ./config.yaml. The live value + /// from the configuration worker is authoritative once an entry exists. + #[arg(long, default_value = "./config.yaml")] + config: String, + + #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] + url: String, + + /// Print the registry manifest as JSON and exit. + #[arg(long)] + manifest: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.manifest { + let m = manifest::build_manifest(); + println!( + "{}", + serde_json::to_string_pretty(&m).expect("manifest serializes") + ); + return Ok(()); + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + tracing::info!(url = %cli.url, seed_config = %cli.config, "connecting to III engine"); + + let iii = register_worker( + &cli.url, + InitOptions { + otel: Some(OtelConfig::default()), + ..Default::default() + }, + ); + + // Seed from config.yaml when present; a parse error fails fast. + let seed = match Config::load(&cli.config) { + Ok(cfg) => Some(cfg), + Err(e) => { + tracing::warn!(path = %cli.config, error = %e, "failed to load seed config; relying on the configuration worker"); + None + } + }; + + // Config registration is best-effort: codex has no security policy, so if + // the configuration worker is unreachable (or absent, as in interface + // collection on a bare engine) the worker still serves with the seed / + // built-in defaults. Never fatal — registering codex::* must not depend on + // the configuration worker being up. + if let Err(e) = configuration::register_config(&iii, seed.as_ref()).await { + tracing::warn!(error = %e, "configuration::register failed; continuing with the seed"); + } + let cfg = match configuration::fetch_config(&iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!(error = %e, "configuration fetch failed; using seed/default config"); + seed.clone().unwrap_or_default() + } + }; + + let cell: configuration::ConfigCell = Arc::new(RwLock::new(Arc::new(cfg))); + + // Bind the config-change trigger and reconcile so a value that landed + // during boot is applied before the first turn. Best-effort. + if let Err(e) = configuration::register_config_trigger(&iii, cell.clone()) { + tracing::warn!(error = %e, "configuration change trigger registration failed"); + } + configuration::reconcile(&iii, &cell).await; + + register_all(&iii, cell); + tracing::info!("codex worker registered all functions, ready"); + + wait_for_shutdown_signal().await?; + tracing::info!("codex worker shutting down"); + iii.shutdown_async().await; + Ok(()) +} + +async fn wait_for_shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate())?; + tokio::select! { + r = tokio::signal::ctrl_c() => r, + _ = sigterm.recv() => Ok(()), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} diff --git a/codex/src/manifest.rs b/codex/src/manifest.rs new file mode 100644 index 00000000..65d9859f --- /dev/null +++ b/codex/src/manifest.rs @@ -0,0 +1,38 @@ +//! Manifest emitted by `codex --manifest` for the registry publish pipeline. + +use serde::Serialize; + +const DESCRIPTION: &str = "OpenAI Codex as an iii worker — codex::* run headless Codex turns, mirror raw thread events onto codex::events, and stream AgentEvent frames onto agent::events."; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: DESCRIPTION.to_string(), + default_config: serde_json::json!({ + "defaults": { + "model": "", + "sandbox_mode": "workspace-write", + "approval_policy": "never", + "reasoning_effort": "", + "cwd": "", + "skip_git_repo_check": true, + }, + "events_stream": "agent::events", + "raw_events_stream": "codex::events", + "codex_executable": "", + "base_url": "", + "iii_context": true, + }), + supported_targets: vec![env!("TARGET").to_string()], + } +} diff --git a/codex/src/map.rs b/codex/src/map.rs new file mode 100644 index 00000000..6d802c87 --- /dev/null +++ b/codex/src/map.rs @@ -0,0 +1,90 @@ +//! Translate Codex thread items to the AgentEvent wire subset emitted on +//! `agent::events`. Command/patch/mcp/web items become function_execution +//! frames; agent_message/reasoning become message_complete. + +use serde_json::{json, Value}; + +use crate::codex::events_types::{CommandExecutionItem, McpToolCallItem, ThreadItemDetails}; +use crate::wire::Usage as WireUsage; + +/// Bus-style function id for an execution-type item. +pub fn function_id(details: &ThreadItemDetails) -> String { + match details { + ThreadItemDetails::CommandExecution(_) => "codex::shell".to_string(), + ThreadItemDetails::FileChange(_) => "codex::apply_patch".to_string(), + ThreadItemDetails::WebSearch(_) => "codex::web_search".to_string(), + ThreadItemDetails::McpToolCall(m) => format!("{}::{}", server_or(m, "mcp"), tool_or(m)), + _ => "codex::item".to_string(), + } +} + +fn server_or(m: &McpToolCallItem, fallback: &str) -> String { + if m.server.is_empty() { + fallback.to_string() + } else { + m.server.clone() + } +} + +fn tool_or(m: &McpToolCallItem) -> String { + if m.tool.is_empty() { + "tool".to_string() + } else { + m.tool.clone() + } +} + +/// True for the item types that map to function_execution_start/end. +pub fn is_exec_item(details: &ThreadItemDetails) -> bool { + matches!( + details, + ThreadItemDetails::CommandExecution(_) + | ThreadItemDetails::FileChange(_) + | ThreadItemDetails::McpToolCall(_) + | ThreadItemDetails::WebSearch(_) + ) +} + +pub fn args_for(details: &ThreadItemDetails) -> Value { + match details { + ThreadItemDetails::CommandExecution(c) => json!({ "command": c.command }), + ThreadItemDetails::FileChange(f) => json!({ "changes": f.changes }), + ThreadItemDetails::WebSearch(w) => json!({ "query": w.query }), + ThreadItemDetails::McpToolCall(m) => m.arguments.clone(), + _ => json!({}), + } +} + +pub fn result_content(details: &ThreadItemDetails) -> Value { + let text = match details { + ThreadItemDetails::CommandExecution(c) => c.aggregated_output.clone(), + ThreadItemDetails::FileChange(f) => f.changes.to_string(), + ThreadItemDetails::WebSearch(w) => w.query.clone(), + ThreadItemDetails::McpToolCall(m) => match &m.error { + Some(e) => e.message.clone(), + None => m.result.to_string(), + }, + _ => String::new(), + }; + json!([{ "type": "text", "text": text }]) +} + +pub fn is_error_item(details: &ThreadItemDetails) -> bool { + match details { + ThreadItemDetails::CommandExecution(CommandExecutionItem { + status, exit_code, .. + }) => status.is_failure() || matches!(exit_code, Some(code) if *code != 0), + ThreadItemDetails::FileChange(f) => f.status.is_failure(), + ThreadItemDetails::McpToolCall(m) => m.status.is_failure() || m.error.is_some(), + _ => false, + } +} + +pub fn map_usage(u: &crate::codex::events_types::Usage) -> WireUsage { + WireUsage { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + cache_read_tokens: u.cached_input_tokens, + reasoning_tokens: u.reasoning_output_tokens, + } +} diff --git a/codex/src/state.rs b/codex/src/state.rs new file mode 100644 index 00000000..2ea1bc92 --- /dev/null +++ b/codex/src/state.rs @@ -0,0 +1,83 @@ +//! Session registry on engine state. Scope `codex_sessions`, key = iii +//! session_id. Maps iii sessions to Codex thread ids so a run with the same +//! session_id resumes the underlying Codex thread (persisted by the CLI under +//! ~/.codex/sessions). Trigger errors propagate (fail-fast); swallowing them +//! would silently start a fresh conversation instead of resuming. + +use iii_sdk::{TriggerRequest, III}; +use serde_json::json; + +use crate::wire::SessionRecord; + +const SCOPE: &str = "codex_sessions"; +const TIMEOUT_MS: u64 = 5_000; + +pub async fn load_session(iii: &III, session_id: &str) -> anyhow::Result> { + let v = iii + .trigger(TriggerRequest { + function_id: "state::get".to_string(), + payload: json!({ "scope": SCOPE, "key": session_id }), + action: None, + timeout_ms: Some(TIMEOUT_MS), + }) + .await + .map_err(|e| anyhow::anyhow!("state::get failed: {e}"))?; + // The engine returns the stored value directly (no envelope); null when absent. + if v.is_null() { + return Ok(None); + } + // A decode failure means a corrupt or version-drifted record we wrote — a + // real problem, not a missing session. Surface it instead of silently + // starting a fresh conversation. + let rec = serde_json::from_value::(v) + .map_err(|e| anyhow::anyhow!("corrupt session record for {session_id}: {e}"))?; + Ok(Some(rec)) +} + +pub async fn save_session(iii: &III, record: &SessionRecord) -> anyhow::Result<()> { + iii.trigger(TriggerRequest { + function_id: "state::set".to_string(), + payload: json!({ "scope": SCOPE, "key": record.session_id, "value": record }), + action: None, + timeout_ms: Some(TIMEOUT_MS), + }) + .await + .map_err(|e| anyhow::anyhow!("state::set failed: {e}"))?; + Ok(()) +} + +pub async fn list_sessions(iii: &III) -> anyhow::Result> { + let v = iii + .trigger(TriggerRequest { + function_id: "state::list".to_string(), + payload: json!({ "scope": SCOPE }), + action: None, + timeout_ms: Some(TIMEOUT_MS), + }) + .await + .map_err(|e| anyhow::anyhow!("state::list failed: {e}"))?; + let arr = match v.as_array() { + Some(a) => a, + None => return Ok(vec![]), + }; + Ok(arr + .iter() + .filter_map(|item| serde_json::from_value::(item.clone()).ok()) + .collect()) +} + +/// Best-effort flip to `error` so a failed background run never leaves a +/// record stuck in `working`. Swallows its own failure. +pub async fn mark_error(iii: &III, session_id: &str) { + match load_session(iii, session_id).await { + Ok(Some(mut rec)) if matches!(rec.status, crate::wire::Status::Working) => { + rec.status = crate::wire::Status::Error; + rec.updated_at_ms = crate::wire::now_ms(); + if let Err(e) = save_session(iii, &rec).await { + tracing::error!(session_id, error = %e, "failed to mark session error"); + } + } + Ok(_) => {} + Err(e) => tracing::error!(session_id, error = %e, "mark_error load failed"), + } +} diff --git a/codex/src/wire.rs b/codex/src/wire.rs new file mode 100644 index 00000000..4fc84cb6 --- /dev/null +++ b/codex/src/wire.rs @@ -0,0 +1,79 @@ +//! Wire types emitted onto `agent::events` (translated AgentEvent subset) and +//! the persisted session record. Mirrors the harness AgentEvent shape so the +//! console and acp worker render Codex turns like any other agent worker. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Usage { + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub reasoning_tokens: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Working, + Done, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionRecord { + pub session_id: String, + pub codex_thread_id: Option, + pub cwd: String, + pub model: String, + pub status: Status, + pub turns: i64, + pub usage: Option, + pub updated_at_ms: u64, +} + +/// One block of assistant/tool content on the translated stream. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + Text { text: String }, + Thinking { text: String }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AssistantMessage { + pub role: &'static str, // "assistant" + pub content: Vec, + pub stop_reason: String, + pub model: String, + pub provider: &'static str, // "codex" + pub usage: Option, + pub timestamp: u64, +} + +pub fn now_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +pub fn assistant_message( + content: Vec, + model: &str, + usage: Option, + stop_reason: &str, +) -> Value { + serde_json::to_value(AssistantMessage { + role: "assistant", + content, + stop_reason: stop_reason.to_string(), + model: model.to_string(), + provider: "codex", + usage, + timestamp: now_ms(), + }) + .unwrap_or(Value::Null) +} diff --git a/codex/tests/args.rs b/codex/tests/args.rs new file mode 100644 index 00000000..70bc65f3 --- /dev/null +++ b/codex/tests/args.rs @@ -0,0 +1,140 @@ +//! Arg + config serialization tests. + +use codex::codex::args::{build_args, resolve, toml_value, ResolvedOptions}; +use codex::config::Config; +use codex::functions::types::RunRequest; +use serde_json::{json, Map, Value}; + +fn opts() -> ResolvedOptions { + ResolvedOptions { + model: String::new(), + cwd: String::new(), + sandbox_mode: "workspace-write".into(), + approval_policy: "never".into(), + reasoning_effort: String::new(), + skip_git_repo_check: true, + base_url: String::new(), + config: Map::new(), + images: vec![], + additional_directories: vec![], + } +} + +#[test] +fn builds_base_exec_json_invocation() { + let a = build_args(&opts(), None, None); + assert_eq!(a[0], "exec"); + assert!(a.contains(&"--json".to_string())); + assert!(a.contains(&"--sandbox".to_string())); + assert!(a.contains(&"workspace-write".to_string())); + assert!(a.contains(&"--skip-git-repo-check".to_string())); + // approval_policy goes through --config + assert!(a.iter().any(|s| s.starts_with("approval_policy="))); +} + +#[test] +fn resume_appends_subcommand() { + let a = build_args(&opts(), Some("th-1"), None); + let i = a + .iter() + .position(|s| s == "resume") + .expect("resume present"); + assert_eq!(a[i + 1], "th-1"); +} + +#[test] +fn output_schema_path_passed() { + let a = build_args(&opts(), None, Some("/tmp/schema.json")); + let i = a.iter().position(|s| s == "--output-schema").unwrap(); + assert_eq!(a[i + 1], "/tmp/schema.json"); +} + +#[test] +fn model_cwd_images_mapped() { + let mut o = opts(); + o.model = "gpt-5.2-codex".into(); + o.cwd = "/repo".into(); + o.images = vec!["/tmp/a.png".into()]; + let a = build_args(&o, None, None); + let m = a.iter().position(|s| s == "--model").unwrap(); + assert_eq!(a[m + 1], "gpt-5.2-codex"); + let c = a.iter().position(|s| s == "--cd").unwrap(); + assert_eq!(a[c + 1], "/repo"); + let img = a.iter().position(|s| s == "--image").unwrap(); + assert_eq!(a[img + 1], "/tmp/a.png"); +} + +#[test] +fn toml_value_quotes_and_escapes_multiline_strings() { + let v = toml_value(&Value::String("line1\nline2 \"q\"".into())); + // toml string literal: round-trips through the toml parser + let parsed: toml::Value = format!("k = {v}").parse().unwrap(); + assert_eq!(parsed["k"].as_str().unwrap(), "line1\nline2 \"q\""); +} + +#[test] +fn config_overrides_serialize_as_key_value() { + let mut o = opts(); + o.config.insert( + "mcp_servers".into(), + json!({ "github": { "command": "gh-mcp" } }), + ); + let a = build_args(&o, None, None); + assert!(a.iter().any(|s| s.starts_with("mcp_servers="))); +} + +#[test] +fn resolve_injects_developer_instructions_when_context_on() { + let req = RunRequest::default(); + let cfg = Config::default(); + let o = resolve(&req, &cfg, None, None, Some("IIICTX")); + assert_eq!( + o.config + .get("developer_instructions") + .and_then(Value::as_str), + Some("IIICTX") + ); +} + +#[test] +fn resolve_caller_developer_instructions_wins() { + let req = RunRequest { + codex_config: Some(json!({ "developer_instructions": "house rules" })), + ..Default::default() + }; + let cfg = Config::default(); + let o = resolve(&req, &cfg, None, None, Some("IIICTX")); + assert_eq!( + o.config + .get("developer_instructions") + .and_then(Value::as_str), + Some("house rules") + ); +} + +#[test] +fn resolve_empty_safety_fields_fall_back_to_defaults() { + // an empty string must not wipe the operator's sandbox / approval defaults + let req = RunRequest { + sandbox_mode: Some(String::new()), + approval_policy: Some(String::new()), + ..Default::default() + }; + let cfg = Config::default(); + let o = resolve(&req, &cfg, None, None, None); + assert_eq!(o.sandbox_mode, "workspace-write"); + assert_eq!(o.approval_policy, "never"); +} + +#[test] +fn resolve_honors_per_turn_overrides_over_prior() { + let req = RunRequest { + cwd: Some("/new".into()), + model: Some("new-model".into()), + ..Default::default() + }; + let cfg = Config::default(); + let o = resolve(&req, &cfg, Some("old-model"), Some("/old"), None); + assert_eq!(o.cwd, "/new"); + assert_eq!(o.model, "new-model"); +} diff --git a/codex/tests/map.rs b/codex/tests/map.rs new file mode 100644 index 00000000..4d9c4104 --- /dev/null +++ b/codex/tests/map.rs @@ -0,0 +1,160 @@ +//! Event -> AgentEvent translation tests. + +use codex::codex::events_types::{ + CommandExecutionItem, ItemStatus, McpError, McpToolCallItem, ThreadEvent, ThreadItemDetails, + Usage, WebSearchItem, +}; +use codex::map; +use serde_json::{json, Value}; + +#[test] +fn function_ids_for_builtin_items() { + assert_eq!( + map::function_id(&ThreadItemDetails::CommandExecution(cmd( + "ls", + 0, + ItemStatus::Completed + ))), + "codex::shell" + ); + assert_eq!( + map::function_id(&ThreadItemDetails::WebSearch(WebSearchItem { + query: "q".into() + })), + "codex::web_search" + ); + assert_eq!( + map::function_id(&ThreadItemDetails::McpToolCall(mcp( + "github", + "create_issue", + false + ))), + "github::create_issue" + ); +} + +#[test] +fn exec_classification() { + assert!(map::is_exec_item(&ThreadItemDetails::CommandExecution( + cmd("x", 0, ItemStatus::Completed) + ))); + assert!(!map::is_exec_item(&ThreadItemDetails::AgentMessage { + text: "hi".into() + })); +} + +#[test] +fn error_classification() { + assert!(map::is_error_item(&ThreadItemDetails::CommandExecution( + cmd("x", 1, ItemStatus::Failed) + ))); + assert!(map::is_error_item(&ThreadItemDetails::CommandExecution( + cmd("x", 2, ItemStatus::Completed) + ))); + assert!(!map::is_error_item(&ThreadItemDetails::CommandExecution( + cmd("x", 0, ItemStatus::Completed) + ))); + assert!(map::is_error_item(&ThreadItemDetails::McpToolCall(mcp( + "s", "t", true + )))); +} + +#[test] +fn command_args_and_result() { + let d = ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls -la".into(), + aggregated_output: "total 0".into(), + exit_code: Some(0), + status: ItemStatus::Completed, + }); + assert_eq!(map::args_for(&d), json!({ "command": "ls -la" })); + assert_eq!( + map::result_content(&d), + json!([{ "type": "text", "text": "total 0" }]) + ); +} + +#[test] +fn usage_maps_and_defaults_zero() { + let u = Usage { + input_tokens: 10, + cached_input_tokens: 100, + output_tokens: 5, + reasoning_output_tokens: 7, + }; + let w = map::map_usage(&u); + assert_eq!(w.input_tokens, 10); + assert_eq!(w.cache_read_tokens, 100); + assert_eq!(w.reasoning_tokens, 7); + // absent fields default to 0 via serde default + let z: Usage = serde_json::from_value(json!({})).unwrap(); + let wz = map::map_usage(&z); + assert_eq!(wz.input_tokens, 0); + assert_eq!(wz.cache_read_tokens, 0); +} + +#[test] +fn parses_a_full_turn_jsonl_sequence() { + let lines = [ + json!({ "type": "thread.started", "thread_id": "th-1" }), + json!({ "type": "turn.started" }), + json!({ "type": "item.started", "item": { "id": "i1", "type": "command_execution", "command": "ls", "aggregated_output": "", "status": "in_progress" } }), + json!({ "type": "item.completed", "item": { "id": "i1", "type": "command_execution", "command": "ls", "aggregated_output": "files", "exit_code": 0, "status": "completed" } }), + json!({ "type": "item.completed", "item": { "id": "i2", "type": "agent_message", "text": "done" } }), + json!({ "type": "turn.completed", "usage": { "input_tokens": 5, "cached_input_tokens": 0, "output_tokens": 2, "reasoning_output_tokens": 0 } }), + ]; + for line in lines { + let ev: ThreadEvent = + serde_json::from_value(line.clone()).unwrap_or_else(|e| panic!("parse {line}: {e}")); + assert!( + !matches!(ev, ThreadEvent::Unknown), + "unexpected Unknown for {line}" + ); + } +} + +#[test] +fn unknown_event_and_item_types_fall_through() { + let ev: ThreadEvent = serde_json::from_value(json!({ "type": "future.event" })).unwrap(); + assert!(matches!(ev, ThreadEvent::Unknown)); + let item: ThreadEvent = serde_json::from_value( + json!({ "type": "item.completed", "item": { "id": "x", "type": "future_item" } }), + ) + .unwrap(); + match item { + ThreadEvent::ItemCompleted(e) => { + assert!(matches!(e.item.details, ThreadItemDetails::Unknown)) + } + _ => panic!("expected ItemCompleted"), + } +} + +fn cmd(command: &str, exit: i32, status: ItemStatus) -> CommandExecutionItem { + CommandExecutionItem { + command: command.into(), + aggregated_output: String::new(), + exit_code: Some(exit), + status, + } +} + +fn mcp(server: &str, tool: &str, err: bool) -> McpToolCallItem { + McpToolCallItem { + server: server.into(), + tool: tool.into(), + arguments: Value::Null, + result: Value::Null, + error: if err { + Some(McpError { + message: "boom".into(), + }) + } else { + None + }, + status: if err { + ItemStatus::Failed + } else { + ItemStatus::Completed + }, + } +} diff --git a/codex/tests/prompt_config.rs b/codex/tests/prompt_config.rs new file mode 100644 index 00000000..0199b0a2 --- /dev/null +++ b/codex/tests/prompt_config.rs @@ -0,0 +1,96 @@ +//! Prompt extraction + config loading tests. + +use codex::config::Config; +use codex::functions::types::{extract_prompt, RunRequest}; +use serde_json::json; + +#[test] +fn explicit_prompt_wins_including_empty() { + let req = RunRequest { + prompt: Some(String::new()), + ..Default::default() + }; + assert_eq!(extract_prompt(&req).unwrap(), ""); +} + +#[test] +fn extracts_last_user_message_text() { + let req: RunRequest = serde_json::from_value(json!({ + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "first" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "reply" }] }, + { "role": "user", "content": [{ "type": "text", "text": "second" }] } + ] + })) + .unwrap(); + assert_eq!(extract_prompt(&req).unwrap(), "second"); +} + +#[test] +fn plain_string_message_content() { + let req: RunRequest = serde_json::from_value(json!({ + "messages": [{ "role": "user", "content": "plain" }] + })) + .unwrap(); + assert_eq!(extract_prompt(&req).unwrap(), "plain"); +} + +#[test] +fn no_prompt_no_user_message_errors() { + let req: RunRequest = serde_json::from_value(json!({ + "messages": [{ "role": "assistant", "content": "x" }] + })) + .unwrap(); + assert!(extract_prompt(&req).is_err()); +} + +#[test] +fn unsupported_message_content_errors() { + let req: RunRequest = serde_json::from_value(json!({ + "messages": [{ "role": "user", "content": { "unexpected": "object" } }] + })) + .unwrap(); + assert!(extract_prompt(&req).is_err()); +} + +#[test] +fn config_defaults_when_file_missing() { + let cfg = Config::load("/nonexistent/config.yaml").unwrap(); + assert_eq!(cfg.defaults.sandbox_mode, "workspace-write"); + assert_eq!(cfg.defaults.approval_policy, "never"); + assert!(cfg.defaults.skip_git_repo_check); + assert_eq!(cfg.raw_events_stream, "codex::events"); + assert!(cfg.iii_context); + assert_eq!(cfg.codex_executable, ""); +} + +#[test] +fn config_parse_error_is_fatal() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write(&path, "defaults: [unclosed\n bad: {").unwrap(); + assert!(Config::load(path.to_str().unwrap()).is_err()); +} + +#[test] +fn config_round_trips_through_json_for_the_configuration_worker() { + // to_json (initial_value) -> from_json (fetched value) preserves the config + let cfg = Config::default(); + let json = cfg.to_json(); + let back = Config::from_json(&json).unwrap(); + assert_eq!(back.defaults.sandbox_mode, cfg.defaults.sandbox_mode); + assert_eq!(back.raw_events_stream, cfg.raw_events_stream); + assert_eq!(back.iii_context, cfg.iii_context); + // the published schema is a JSON-schema object + assert_eq!(Config::json_schema()["type"], "object"); +} + +#[test] +fn config_merges_partial_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write(&path, "defaults:\n sandbox_mode: read-only\n").unwrap(); + let cfg = Config::load(path.to_str().unwrap()).unwrap(); + assert_eq!(cfg.defaults.sandbox_mode, "read-only"); + assert_eq!(cfg.defaults.approval_policy, "never"); // default retained +} diff --git a/codex/tests/translate.rs b/codex/tests/translate.rs new file mode 100644 index 00000000..09b42b46 --- /dev/null +++ b/codex/tests/translate.rs @@ -0,0 +1,120 @@ +//! Full per-turn translation: feed a scripted Codex event sequence through the +//! pure stepper and assert the agent::events frame sequence + accumulated turn +//! state (thread id, usage, result, error). This is the orchestration the +//! stream loop runs, exercised without a live engine. + +use codex::codex::events_types::ThreadEvent; +use codex::codex::translate::{step, TurnState}; +use serde_json::{json, Value}; + +fn run(events: &[Value]) -> (TurnState, Vec) { + let mut state = TurnState::new("gpt-5.2-codex".into()); + let mut frames = Vec::new(); + for ev in events { + let parsed: ThreadEvent = serde_json::from_value(ev.clone()).unwrap(); + frames.extend(step(&mut state, parsed)); + } + (state, frames) +} + +fn types(frames: &[Value]) -> Vec { + frames + .iter() + .map(|f| f["type"].as_str().unwrap_or("?").to_string()) + .collect() +} + +#[test] +fn full_command_turn_produces_ordered_frames_and_state() { + let (state, frames) = run(&[ + json!({ "type": "thread.started", "thread_id": "th-1" }), + json!({ "type": "turn.started" }), + json!({ "type": "item.started", "item": { "id": "i1", "type": "command_execution", "command": "ls", "aggregated_output": "", "status": "in_progress" } }), + json!({ "type": "item.completed", "item": { "id": "i1", "type": "command_execution", "command": "ls", "aggregated_output": "files", "exit_code": 0, "status": "completed" } }), + json!({ "type": "item.completed", "item": { "id": "i2", "type": "agent_message", "text": "done" } }), + json!({ "type": "turn.completed", "usage": { "input_tokens": 5, "cached_input_tokens": 3, "output_tokens": 2, "reasoning_output_tokens": 1 } }), + ]); + + assert_eq!( + types(&frames), + vec![ + "function_execution_start", + "function_execution_end", + "message_complete", + ] + ); + // exec frames carry the mapped function id + args/result + assert_eq!(frames[0]["function_id"], "codex::shell"); + assert_eq!(frames[0]["args"]["command"], "ls"); + assert_eq!(frames[1]["is_error"], false); + assert_eq!(frames[1]["result"]["content"][0]["text"], "files"); + // message_complete stamps the model + assert_eq!(frames[2]["message"]["model"], "gpt-5.2-codex"); + assert_eq!(frames[2]["message"]["provider"], "codex"); + + assert_eq!(state.thread_id.as_deref(), Some("th-1")); + assert_eq!(state.result_text, "done"); + assert!(!state.is_error); + let u = state.usage.unwrap(); + assert_eq!(u.input_tokens, 5); + assert_eq!(u.cache_read_tokens, 3); + assert_eq!(u.reasoning_tokens, 1); +} + +#[test] +fn started_then_completed_emits_single_start() { + // item.started already emitted the start; item.completed must not repeat it. + let (_s, frames) = run(&[ + json!({ "type": "item.started", "item": { "id": "i1", "type": "command_execution", "command": "x", "status": "in_progress" } }), + json!({ "type": "item.completed", "item": { "id": "i1", "type": "command_execution", "command": "x", "aggregated_output": "ok", "exit_code": 0, "status": "completed" } }), + ]); + assert_eq!( + types(&frames), + vec!["function_execution_start", "function_execution_end"] + ); +} + +#[test] +fn completed_without_prior_start_synthesizes_start() { + // a completed exec item with no preceding started event still gets a start. + let (_s, frames) = run(&[ + json!({ "type": "item.completed", "item": { "id": "i9", "type": "command_execution", "command": "x", "aggregated_output": "", "exit_code": 1, "status": "failed" } }), + ]); + assert_eq!( + types(&frames), + vec!["function_execution_start", "function_execution_end"] + ); + assert_eq!(frames[1]["is_error"], true); +} + +#[test] +fn reasoning_maps_to_thinking_message() { + let (_s, frames) = run(&[ + json!({ "type": "item.completed", "item": { "id": "r1", "type": "reasoning", "text": "hmm" } }), + ]); + assert_eq!(types(&frames), vec!["message_complete"]); + assert_eq!(frames[0]["message"]["content"][0]["type"], "thinking"); +} + +#[test] +fn turn_failed_sets_error_state_and_emits_no_frame() { + let (state, frames) = run(&[ + json!({ "type": "thread.started", "thread_id": "th-1" }), + json!({ "type": "turn.failed", "error": { "message": "model exploded" } }), + ]); + assert!(frames.is_empty()); + assert!(state.is_error); + assert_eq!(state.stop_reason, "error"); + assert_eq!(state.result_text, "model exploded"); +} + +#[test] +fn mcp_tool_call_maps_to_server_tool_id() { + let (_s, frames) = run(&[ + json!({ "type": "item.completed", "item": { "id": "m1", "type": "mcp_tool_call", "server": "github", "tool": "create_issue", "arguments": { "title": "x" }, "status": "completed" } }), + ]); + assert_eq!( + frames.last().unwrap()["function_id"], + "github::create_issue" + ); +}