From 2527efac3fcdbfcbb4ba3ec189f4c747b42595a2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 15:03:43 +1000 Subject: [PATCH 01/20] feat(buzz-agent-core): drive buzz-agent's ACP surface on the goose library Feasibility spike: keep buzz-agent's ACP wire contract exactly as-is, but replace the hand-written agent loop with goose used as a Rust library. 13,259 -> 1,562 src LOC (88% cut), plus ~5,800 test LOC that covered the deleted loop. Deleted, now goose's: llm.rs 3846 -> goose::providers (superset of the 4 providers) mcp.rs 1139 -> goose::agents::extension_manager auth.rs 845 -> goose::providers (incl. Databricks OAuth) hints.rs 726 -> prompt_manager::with_hints catalog.rs 631 -> goose::providers::init builtin.rs 575 -> goose skills platform extension handoff.rs 430 -> goose::context_mgmt (auto-compaction) Kept deliberately (the contract buzz-acp depends on): wire.rs 293 verbatim; agentInfo.name = "buzz-agent" (kind-44200 harness attribution); 6-method surface; activeRunId with _meta nested inside update; usage_update before the session/prompt response; keepalive ticker; error -> JSON-RPC code mapping; single-flight; size caps. This is NOT "goose as the harness". Picking Goose from the harness gallery still shells out to a user-installed goose CLI. This is buzz-agent's own identity with a goose-powered loop. Why a separate crate excluded from the workspace: crates/buzz-agent is a library linked into sprig AND desktop/src-tauri, so goose's ~700-crate graph would land in the Tauri build and the workspace lockfile. Own Cargo.lock, same isolation trick as PR #1526. Notable: driving the library is what makes the persona work at all. Goose's own ACP server never reads systemPrompt (zero hits in goose/crates/goose/src) and both PRs that would have wired it -- buzz#1290, goose#9971 -- are closed unmerged. tests/stdio_turn.rs asserts Fizz's prompt reaches the provider. Also: GOOSE_MODE is left at goose's default rather than forced to "auto" (auto-approve every tool call), which is what the desktop catalog ships for the external goose runtime. Signed-off-by: Michael Neale --- Cargo.toml | 2 +- buzz-agent-core/.gitignore | 1 + buzz-agent-core/Cargo.lock | 6229 +++++++++++++++++++++++++++ buzz-agent-core/Cargo.toml | 71 + buzz-agent-core/src/agent.rs | 357 ++ buzz-agent-core/src/config.rs | 154 + buzz-agent-core/src/lib.rs | 688 +++ buzz-agent-core/src/main.rs | 3 + buzz-agent-core/src/types.rs | 107 + buzz-agent-core/src/wire.rs | 293 ++ buzz-agent-core/tests/stdio_turn.rs | 346 ++ 11 files changed, 8250 insertions(+), 1 deletion(-) create mode 100644 buzz-agent-core/.gitignore create mode 100644 buzz-agent-core/Cargo.lock create mode 100644 buzz-agent-core/Cargo.toml create mode 100644 buzz-agent-core/src/agent.rs create mode 100644 buzz-agent-core/src/config.rs create mode 100644 buzz-agent-core/src/lib.rs create mode 100644 buzz-agent-core/src/main.rs create mode 100644 buzz-agent-core/src/types.rs create mode 100644 buzz-agent-core/src/wire.rs create mode 100644 buzz-agent-core/tests/stdio_turn.rs diff --git a/Cargo.toml b/Cargo.toml index 3499285f91..a72b97f5d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ members = [ "crates/buzz-dev-mcp", "examples/countdown-bot", ] -exclude = ["desktop/src-tauri"] +exclude = ["desktop/src-tauri", "buzz-agent-core"] resolver = "2" [workspace.package] diff --git a/buzz-agent-core/.gitignore b/buzz-agent-core/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/buzz-agent-core/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/buzz-agent-core/Cargo.lock b/buzz-agent-core/Cargo.lock new file mode 100644 index 0000000000..e88e70b005 --- /dev/null +++ b/buzz-agent-core/Cargo.lock @@ -0,0 +1,6229 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agent-client-protocol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "schemars 1.2.1", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b94934d118a69e921d14e94d4af5b3076563318c52662c89a0ecb3f139e1da" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "agent-client-protocol-http" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ff55efe7a6bb92a5d0226f6055c2f277b7591060d5095c7764f445289ebda3" +dependencies = [ + "agent-client-protocol", + "async-stream", + "axum", + "futures", + "serde_json", + "tokio", + "tower-http 0.7.0", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.2", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.2", + "http-body 1.1.0", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "buzz-agent-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "goose", + "goose-provider-types", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum 0.27.2", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "goose" +version = "1.44.0" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-http", + "agent-client-protocol-schema", + "anyhow", + "arboard", + "async-stream", + "async-trait", + "aws-lc-rs", + "axum", + "axum-server", + "base64", + "blake3", + "chrono", + "clap", + "dirs", + "etcetera 0.11.0", + "fs-err", + "fs2", + "futures", + "gethostname", + "goose-acp-macros", + "goose-download-manager", + "goose-providers", + "goose-sdk-types", + "icu_calendar", + "icu_locale", + "ignore", + "image", + "include_dir", + "indexmap 2.14.0", + "indoc", + "jsonschema", + "jsonwebtoken", + "libc", + "lru", + "minijinja", + "nanoid", + "oauth2", + "once_cell", + "pastey", + "pem", + "process-wrap", + "pulldown-cmark", + "rand 0.10.2", + "rayon", + "rcgen", + "regex", + "reqwest 0.13.4", + "rmcp", + "rustls", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "serde_yaml", + "sha2 0.11.0", + "shell-words", + "shellexpand", + "smithy-transport-reqwest", + "sqlx", + "strum 0.28.0", + "subtle", + "sys-info", + "tempfile", + "thiserror 2.0.19", + "tiktoken-rs", + "tokio", + "tokio-cron-scheduler", + "tokio-stream", + "tokio-util", + "tower-http 0.7.0", + "tracing", + "tracing-appender", + "tracing-futures", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin-ng", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", + "unicode-normalization", + "url", + "urlencoding", + "uuid", + "v_htmlescape", + "webbrowser", + "which", + "winapi", +] + +[[package]] +name = "goose-acp-macros" +version = "1.44.0" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "goose-download-manager" +version = "0.1.0-alpha.5" +dependencies = [ + "anyhow", + "once_cell", + "reqwest 0.13.4", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "goose-provider-types" +version = "0.1.0-alpha.5" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64", + "chrono", + "futures", + "once_cell", + "rand 0.10.2", + "regex", + "reqwest 0.13.4", + "rmcp", + "serde", + "serde_json", + "strum 0.28.0", + "thiserror 2.0.19", + "tokio", + "tracing", + "unicode-normalization", + "uuid", +] + +[[package]] +name = "goose-providers" +version = "0.1.0-alpha.5" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "futures", + "goose-provider-types", + "include_dir", + "reqwest 0.13.4", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "goose-sdk-types" +version = "0.1.0-alpha.5" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "schemars 1.2.1", + "serde", + "serde_json", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[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 = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.2", + "http-body 1.1.0", + "httparse", + "httpdate", + "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 1.4.2", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[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 1.4.2", + "http-body 1.1.0", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_locale_core", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_provider", + "potential_utf", + "tinystr", + "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", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[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", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-traits", + "png", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[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 = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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 = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" + +[[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 = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nanoid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" +dependencies = [ + "rand 0.9.5", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[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 = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.17", + "http 1.4.2", + "rand 0.8.7", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[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 = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap 2.14.0", + "nix", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +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 1.4.2", + "http-body 1.1.0", + "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 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[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 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http 1.4.2", + "http-body-util", + "hyper", + "hyper-util", + "oauth2", + "pastey", + "pin-project-lite", + "process-wrap", + "reqwest 0.13.4", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "rmcp-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[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 = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "smithy-transport-reqwest" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566dc85be03a09c384f77a122188d9af000e1f1bd23551b346a9b555838da7e1" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", + "parking_lot", + "reqwest 0.13.4", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.19", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.19", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.19", + "tracing", + "url", +] + +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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 2.0.119", +] + +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiktoken-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex 0.17.0", + "lazy_static", + "regex", + "rustc-hash", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-cron-scheduler" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" +dependencies = [ + "chrono", + "chrono-tz", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "http 1.4.2", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.19", + "time", + "tracing-subscriber", +] + +[[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 2.0.119", +] + +[[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-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-ng" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[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.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.2", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[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.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "v_escape-base" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1212fce830b75af194b578e55b3db9049f2c8c45f58d397fb25602fdb50fb3d" + +[[package]] +name = "v_htmlescape" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "befb3d53c9e3ec641417685896cbc8cc5bd264d6a2e190c56aaef1af24740d99" +dependencies = [ + "v_escape-base", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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 = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", +] + +[[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-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[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 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "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 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/buzz-agent-core/Cargo.toml b/buzz-agent-core/Cargo.toml new file mode 100644 index 0000000000..c7fe9cc408 --- /dev/null +++ b/buzz-agent-core/Cargo.toml @@ -0,0 +1,71 @@ +# buzz-agent-core — buzz-agent's ACP wire layer on top of the Goose Rust +# library (used as an SDK, not as a subprocess). +# +# WHY A SEPARATE CRATE, EXCLUDED FROM THE WORKSPACE +# +# `crates/buzz-agent` is a *library* linked into both `crates/sprig` and +# `desktop/src-tauri` (which uses only `catalog`/`config` for the Databricks +# model picker). Adding goose to that crate would pull goose's ~700-crate +# dependency graph into the Tauri app and into the workspace lockfile. +# +# So this crate is excluded from the workspace (see the root Cargo.toml +# `exclude` list) and carries its own Cargo.lock, exactly as PR #1526 did for +# `goose-acp`. Goose's ACP/MCP dependency versions move independently. +# +# Build: cargo build --release --manifest-path buzz-agent-core/Cargo.toml +# +# THIS IS NOT "GOOSE AS THE HARNESS". The binary keeps buzz-agent's identity +# (`agentInfo.name = "buzz-agent"`), its 6-method ACP surface, its +# `activeRunId` steering contract and its `usage_update` ordering. Only the +# agent loop underneath is Goose's instead of hand-written. Picking "Goose" +# from the harness gallery remains a separate thing: that shells out to a +# user-installed `goose` CLI. + +[package] +name = "buzz-agent-core" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "buzz_agent_core" +path = "src/lib.rs" + +[[bin]] +name = "buzz-agent-core" +path = "src/main.rs" + +[dependencies] +# Pinned to a release commit deliberately: goose's public `Agent` surface is +# not semver-stable (many fields are `pub(super)`) and its MSRV moves quickly. +# 305849b71 == goose 1.44.0. +# +# default-features = false drops: telemetry, otel, aws-providers, +# local-inference (candle/whisper), code-mode (pctx/V8), system-keyring, nostr. +# We name only rustls-tls, which is mandatory — `goose` pulls reqwest/sqlx/rmcp +# with default-features = false, so a TLS backend only arrives via this feature +# (and `rustls-tls` + `native-tls` are mutually exclusive: see the +# compile_error! at goose/src/lib.rs:3-4). +goose = { path = "../../goose/crates/goose", default-features = false, features = ["rustls-tls"] } +goose-provider-types = { path = "../../goose/crates/goose-provider-types" } + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] } +tokio-util = { version = "0.7", features = ["rt"] } +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3" + +# Size-optimized. PR #1526 measured 32.5 MiB (macOS arm64) for a comparable +# goose-linked binary; re-measure rather than trusting that number. +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true diff --git a/buzz-agent-core/src/agent.rs b/buzz-agent-core/src/agent.rs new file mode 100644 index 0000000000..a1732aad23 --- /dev/null +++ b/buzz-agent-core/src/agent.rs @@ -0,0 +1,357 @@ +//! The turn driver: Goose's agent loop, translated onto buzz-agent's ACP wire. +//! +//! This replaces roughly 8,000 lines of `crates/buzz-agent`: +//! +//! | buzz-agent file | lines | who owns it now | +//! |---|---:|---| +//! | `llm.rs` | 3846 | `goose::providers` (superset of the 4 providers) | +//! | `mcp.rs` | 1139 | `goose::agents::extension_manager` | +//! | `auth.rs` | 845 | `goose::providers` (incl. Databricks OAuth) | +//! | `agent.rs` | 746 | `goose::agents::Agent::reply` | +//! | `hints.rs` | 726 | `goose`'s hint loader (`prompt_manager::with_hints`) | +//! | `catalog.rs` | 631 | `goose::providers::init` model discovery | +//! | `builtin.rs` | 575 | `goose`'s `skills` platform extension | +//! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) | +//! +//! What this file keeps is the part Goose does *not* know about: the mapping +//! from `AgentEvent` onto the exact `session/update` notifications `buzz-acp` +//! consumes (`acp.rs:1528-1627`), and the `keepalive` ticker that resets the +//! harness idle clock. + +use std::sync::Arc; + +use futures::StreamExt; +use serde_json::{json, Value}; +use tokio_util::sync::CancellationToken; + +use goose::agents::{Agent, AgentEvent, SessionConfig}; +use goose_provider_types::conversation::message::{Message, MessageContent}; + +use crate::types::{AgentError, ContentBlock, StopReason}; +use crate::wire::{self, WireSender}; + +/// How often to emit `keepalive` while waiting on the provider. +/// +/// NOT part of ACP. `buzz-acp` runs an idle-timeout clock that is reset on +/// every line of valid JSON (`acp.rs:1197-1199`); a silent agent is killed. +/// buzz-agent emitted this from inside its provider `select!` +/// (`agent.rs:122-127`) and `buzz-acp` treats it as a no-op that only resets +/// the clock (`acp.rs:1623`). +/// +/// Goose streams, so during generation the chunks themselves keep the clock +/// alive — but there is no traffic during a long *pre-first-token* wait (big +/// prompt processing, provider queueing, reasoning models). So the ticker +/// stays. +const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Per-turn token accounting, mirroring buzz-agent's contract. +#[derive(Debug, Default, Clone, Copy)] +pub struct TurnTokens { + pub input: Option, + pub output: Option, +} + +impl TurnTokens { + pub fn observed(&self) -> bool { + self.input.is_some() || self.output.is_some() + } +} + +/// Flatten ACP prompt blocks into the text Goose's `Message` carries. +pub fn prompt_to_text(blocks: &[ContentBlock]) -> String { + let mut out = String::new(); + for b in blocks { + match b { + ContentBlock::Text { text } => { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); + } + ContentBlock::ResourceLink { uri } => { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(uri); + } + ContentBlock::Unsupported => {} + } + } + out +} + +/// Drive one `session/prompt` turn to completion. +/// +/// Returns the ACP stop reason plus the turn's token counts. The caller is +/// responsible for emitting `usage_update` *before* the `session/prompt` +/// response — that ordering is load-bearing for kind-44200 metrics +/// (`buzz-agent/src/lib.rs:701-706`). +#[allow(clippy::too_many_arguments)] +pub async fn run_turn( + agent: Arc, + session_id: &str, + prompt: Vec, + max_rounds: Option, + wire_tx: &WireSender, + cancel: CancellationToken, +) -> (Result, TurnTokens) { + let text = prompt_to_text(&prompt); + let mut tokens = TurnTokens::default(); + + let session_config = SessionConfig { + id: session_id.to_string(), + schedule_id: None, + max_turns: max_rounds, + retry_config: None, + }; + + let mut stream = match agent + .reply( + Message::user().with_text(text), + session_config, + Some(cancel.clone()), + ) + .await + { + Ok(s) => s, + Err(e) => return (Err(AgentError::Llm(e.to_string())), tokens), + }; + + let mut keepalive = tokio::time::interval(KEEPALIVE_INTERVAL); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + keepalive.tick().await; // first tick is immediate; discard it + + let mut stop = StopReason::EndTurn; + + loop { + tokio::select! { + // Keep the harness idle clock alive during provider silence. + _ = keepalive.tick() => { + wire::send( + wire_tx, + wire::session_update(session_id, json!({ "sessionUpdate": "keepalive" })), + ) + .await; + } + + _ = cancel.cancelled() => { + stop = StopReason::Cancelled; + break; + } + + next = stream.next() => { + let Some(event) = next else { break }; + match event { + Ok(ev) => { + if let Some(reason) = handle_event(ev, session_id, wire_tx, &mut tokens).await { + stop = reason; + break; + } + } + Err(e) => { + let msg = e.to_string(); + // Preserve buzz-agent's error taxonomy so the harness's + // JSON-RPC code mapping stays meaningful. + let err = if msg.contains("auth") || msg.contains("401") { + AgentError::LlmAuth(msg) + } else if msg.contains("model") && msg.contains("not found") { + AgentError::LlmModelNotFound(msg) + } else { + AgentError::Llm(msg) + }; + return (Err(err), tokens); + } + } + } + } + } + + if cancel.is_cancelled() { + stop = StopReason::Cancelled; + } + + (Ok(stop), tokens) +} + +/// Translate one `AgentEvent` into ACP notifications. +/// +/// Returns `Some(StopReason)` if the event terminates the turn. +async fn handle_event( + event: AgentEvent, + session_id: &str, + wire_tx: &WireSender, + tokens: &mut TurnTokens, +) -> Option { + match event { + AgentEvent::Message(msg) => { + for content in &msg.content { + emit_content(content, session_id, wire_tx).await; + } + None + } + + // Usage arrives per provider chunk, already cost-enriched. Accumulate + // rather than overwrite: `MessageUsage` is suppressed when the last + // assistant message has no user-visible content (goose + // `agent.rs:299`), so it is not a reliable sole source. + AgentEvent::Usage(usage) => { + let u = &usage.usage; + if let Some(i) = u.input_tokens { + tokens.input = Some(tokens.input.unwrap_or(0) + i.max(0) as u64); + } + if let Some(o) = u.output_tokens { + tokens.output = Some(tokens.output.unwrap_or(0) + o.max(0) as u64); + } + None + } + + // Compaction happened. buzz-agent surfaced this as a `[Context + // Handoff]` history rewrite; here Goose has already done it and we + // only need to note it. + AgentEvent::HistoryReplaced(_) => { + tracing::info!(target: "buzz_agent::compaction", "history compacted"); + None + } + + _ => None, + } +} + +/// Map message content onto the nine `sessionUpdate` variants `buzz-acp` +/// recognises (`acp.rs:1528-1627`). Anything else it debug-logs and drops. +async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &WireSender) { + match content { + MessageContent::Text(t) if !t.text.is_empty() => { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": t.text }, + }), + ), + ) + .await; + } + + MessageContent::Thinking(t) if !t.thinking.is_empty() => { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "agent_thought_chunk", + "content": { "type": "text", "text": t.thinking }, + }), + ), + ) + .await; + } + + // Tool lifecycle. buzz-agent guaranteed every announced tool reached a + // terminal state, or the desktop UI shows a stuck spinner + // (`agent.rs:470-477`). Goose emits request and response as separate + // messages, so the pairing is by tool-call id. + MessageContent::ToolRequest(req) => { + let (name, raw) = match &req.tool_call { + Ok(call) => ( + call.name.to_string(), + serde_json::to_value(&call.arguments).unwrap_or(Value::Null), + ), + Err(e) => (String::from("unknown"), json!({ "error": e.to_string() })), + }; + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call", + "toolCallId": req.id, + "title": name, + "kind": "other", + "status": "in_progress", + "rawInput": raw, + }), + ), + ) + .await; + } + + MessageContent::ToolResponse(resp) => { + let failed = match &resp.tool_result { + Ok(r) => r.is_error.unwrap_or(false), + Err(_) => true, + }; + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": resp.id, + "status": if failed { "failed" } else { "completed" }, + }), + ), + ) + .await; + } + + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_flattens_text_blocks() { + let blocks = vec![ + ContentBlock::Text { + text: "hello".into(), + }, + ContentBlock::Text { + text: "world".into(), + }, + ]; + assert_eq!(prompt_to_text(&blocks), "hello\nworld"); + } + + #[test] + fn prompt_includes_resource_links() { + let blocks = vec![ + ContentBlock::Text { text: "see".into() }, + ContentBlock::ResourceLink { + uri: "file:///a".into(), + }, + ]; + assert_eq!(prompt_to_text(&blocks), "see\nfile:///a"); + } + + #[test] + fn prompt_skips_unsupported() { + let blocks = vec![ + ContentBlock::Unsupported, + ContentBlock::Text { + text: "only".into(), + }, + ]; + assert_eq!(prompt_to_text(&blocks), "only"); + } + + #[test] + fn turn_tokens_observed_requires_a_count() { + assert!(!TurnTokens::default().observed()); + assert!(TurnTokens { + input: Some(1), + output: None + } + .observed()); + assert!(TurnTokens { + input: None, + output: Some(1) + } + .observed()); + } +} diff --git a/buzz-agent-core/src/config.rs b/buzz-agent-core/src/config.rs new file mode 100644 index 0000000000..5eeac6acde --- /dev/null +++ b/buzz-agent-core/src/config.rs @@ -0,0 +1,154 @@ +//! Environment configuration, translated into Goose's native vocabulary. +//! +//! This replaces `crates/buzz-agent/src/config.rs` (2,709 lines). Most of that +//! file existed to *implement* provider configuration: the `Provider` enum, +//! per-provider base URLs, model-name normalization, Databricks host parsing, +//! OpenAI auto-upgrade rules, and the resolution order between them. +//! +//! Goose already owns all of that (`goose::config`, `goose::providers`). So +//! what remains here is a translation table: read the `BUZZ_AGENT_*` variables +//! `buzz-acp` injects (see `desktop/src-tauri/src/managed_agents/runtime.rs`), +//! and set the `GOOSE_*` / provider variables Goose reads. +//! +//! The mapping is applied to the *process* environment before the Goose +//! `Config` singleton is first touched, because `Config::global()` reads env at +//! initialization. + +/// ACP protocol version. Buzz squats on v2 ahead of the upstream RFD; see the +/// note in `lib.rs::initialize`. +pub const PROTOCOL_VERSION: u32 = 2; + +/// Hard caps preserved from buzz-agent's wire contract. These are protocol +/// limits (rejections are `invalid_params`), not loop tuning, so they stay. +pub const MAX_PROMPT_BYTES: usize = 1024 * 1024; +pub const MAX_SYSTEM_PROMPT_BYTES: usize = 512 * 1024; +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct Config { + /// Model id, if pinned by the harness. `None` lets Goose resolve its own + /// default from `GOOSE_MODEL` / its config file. + pub model: Option, + /// Max provider round-trips per turn. Maps to `SessionConfig.max_turns`. + pub max_rounds: Option, + /// Concurrent sessions this process will hold. + pub max_sessions: usize, + /// Default system prompt, used only when `session/new` omits one. + pub system_prompt: Option, + /// Per-turn wall-clock budget for a single provider request. + pub llm_timeout_secs: u64, +} + +fn env_str(key: &str) -> Option { + std::env::var(key).ok().filter(|s| !s.trim().is_empty()) +} + +fn env_parse(key: &str) -> Option { + env_str(key).and_then(|s| s.parse().ok()) +} + +impl Config { + /// Read `BUZZ_AGENT_*` from the environment and, as a side effect, project + /// the provider-shaped ones onto the `GOOSE_*` names Goose reads. + pub fn from_env() -> Self { + Self::project_goose_env(); + + let system_prompt = env_str("BUZZ_AGENT_SYSTEM_PROMPT").or_else(|| { + env_str("BUZZ_AGENT_SYSTEM_PROMPT_FILE") + .and_then(|p| std::fs::read_to_string(p).ok()) + .filter(|s| !s.trim().is_empty()) + }); + + Self { + model: env_str("BUZZ_AGENT_MODEL"), + max_rounds: env_parse::("BUZZ_AGENT_MAX_ROUNDS").filter(|n| *n > 0), + max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(8), + system_prompt, + llm_timeout_secs: env_parse("BUZZ_AGENT_LLM_TIMEOUT_SECS").unwrap_or(600), + } + } + + /// Translate Buzz's provider configuration into Goose's environment. + /// + /// Mirrors `goose_env.rs` from PR #1526, with one deliberate difference: + /// **`GOOSE_MODE` is not forced to `auto` here.** The desktop catalog + /// currently ships `default_env: &[("GOOSE_MODE", "auto")]` + /// (`discovery.rs:89`), i.e. auto-approve every tool call + /// (`goose_mode.rs:22-31`). Because this binary drives the agent in-process + /// we can gate tool calls ourselves later without inheriting that default; + /// leaving it unset means Goose falls back to its own default rather than + /// Buzz silently widening it. Callers that genuinely want auto-approve can + /// still set `GOOSE_MODE` explicitly. + fn project_goose_env() { + // Provider: Buzz's `openai-compat` and `relay-mesh` are both + // OpenAI-wire-compatible, and Goose knows them as plain `openai`. + if let Some(provider) = env_str("BUZZ_AGENT_PROVIDER") { + let goose_provider = match provider.as_str() { + "openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => "openai", + other => other, + }; + set_if_absent("GOOSE_PROVIDER", goose_provider); + } + + if let Some(model) = env_str("BUZZ_AGENT_MODEL") { + set_if_absent("GOOSE_MODEL", &model); + } + + // Key/base-url aliasing: native Goose names win if already present. + if let Some(key) = env_str("OPENAI_COMPAT_API_KEY") { + set_if_absent("OPENAI_API_KEY", &key); + } + if let Some(base) = env_str("OPENAI_COMPAT_BASE_URL") { + set_if_absent("OPENAI_BASE_URL", &base); + } + + if let Some(effort) = env_str("BUZZ_AGENT_THINKING_EFFORT") { + set_if_absent("GOOSE_THINKING_EFFORT", &effort); + } + if let Some(max_tokens) = env_str("BUZZ_AGENT_MAX_OUTPUT_TOKENS") { + set_if_absent("GOOSE_MAX_TOKENS", &max_tokens); + } + if let Some(ctx) = env_str("BUZZ_AGENT_MAX_CONTEXT_TOKENS") { + set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx); + } + } +} + +fn set_if_absent(key: &str, value: &str) { + if std::env::var_os(key).is_none() { + std::env::set_var(key, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Env is process-global; these tests set disjoint keys and assert only on + // the pure mapping helpers where possible. + + #[test] + fn set_if_absent_does_not_clobber() { + std::env::set_var("BUZZ_TEST_EXISTING", "native"); + set_if_absent("BUZZ_TEST_EXISTING", "translated"); + assert_eq!(std::env::var("BUZZ_TEST_EXISTING").unwrap(), "native"); + std::env::remove_var("BUZZ_TEST_EXISTING"); + } + + #[test] + fn set_if_absent_fills_missing() { + std::env::remove_var("BUZZ_TEST_MISSING"); + set_if_absent("BUZZ_TEST_MISSING", "translated"); + assert_eq!(std::env::var("BUZZ_TEST_MISSING").unwrap(), "translated"); + std::env::remove_var("BUZZ_TEST_MISSING"); + } + + #[test] + fn stop_reason_wire_strings_are_stable() { + use crate::types::StopReason; + // buzz-acp parses these; drift breaks turn completion. + assert_eq!(StopReason::EndTurn.as_wire(), "end_turn"); + assert_eq!(StopReason::Cancelled.as_wire(), "cancelled"); + assert_eq!(StopReason::MaxTurnRequests.as_wire(), "max_turn_requests"); + } +} diff --git a/buzz-agent-core/src/lib.rs b/buzz-agent-core/src/lib.rs new file mode 100644 index 0000000000..4e12339b86 --- /dev/null +++ b/buzz-agent-core/src/lib.rs @@ -0,0 +1,688 @@ +//! buzz-agent's ACP server, with Goose as the agent loop. +//! +//! This is the layer `buzz-acp` actually talks to, and it is deliberately +//! unchanged in contract from `crates/buzz-agent/src/lib.rs` (960 lines): +//! the same six JSON-RPC methods, the same `agentInfo.name = "buzz-agent"`, +//! the same `activeRunId` steering handshake, the same `usage_update` +//! ordering. Only the loop underneath is Goose's. +//! +//! Not standard ACP, and preserved here on purpose (each of these is +//! something `buzz-acp` or the desktop UI depends on): +//! +//! * `_goose/unstable/session/steer` with `expectedRunId` optimistic +//! concurrency, and `activeRunId` advertised via +//! `params.update._meta.goose.activeRunId` — note the `_meta` nests *inside* +//! `update` (`buzz-acp/src/acp.rs:1607-1613`). Get the depth wrong and the +//! harness silently falls back to cancel+re-prompt forever. +//! * `usage_update` on the `_goose/unstable/session/update` channel, emitted +//! *before* the `session/prompt` response, suppressed when no tokens were +//! seen (`buzz-agent/src/lib.rs:701-712`). +//! * `keepalive` — see `agent.rs`. + +pub mod agent; +pub mod config; +pub mod types; +pub mod wire; + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform}; +use goose::config::PermissionManager; +use goose::session::session_manager::{SessionManager, SessionType}; +use goose_provider_types::goose_mode::GooseMode; + +use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; +use types::{AgentError, McpServerStdio}; +use wire::{ + classify, goose_session_update, Inbound, InitializeParams, SessionCancelParams, + SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireSender, + INVALID_PARAMS, METHOD_NOT_FOUND, +}; + +/// One live ACP session, wrapping a Goose `Agent`. +struct Session { + agent: Arc, + /// Goose's own session id (the SQLite-backed conversation). + goose_session_id: String, + busy: bool, + /// Set for the duration of a turn; advertised to steer-capable clients. + active_run_id: Option, + cancel: Option, + model_override: Option, + accumulated_input_tokens: u64, + accumulated_output_tokens: u64, +} + +pub struct App { + cfg: Config, + sessions: Mutex>, +} + +/// Build a Goose agent for one ACP session. +/// +/// Note `Agent::with_config` loads **zero** extensions — a tool-free agent is +/// the default, not something we switch off (goose `agent.rs:362-420`). Tools +/// arrive only via the `mcpServers` the harness declares in `session/new`, +/// which is how `buzz-dev-mcp` (shell/read_file/str_replace/todo + the +/// `_Stop`/`_PostCompact` hooks + the `buzz`/`rg`/`tree` PATH shim) is wired. +async fn build_agent( + cfg: &Config, + cwd: &str, + system_prompt: Option<&str>, + mcp_servers: &[McpServerStdio], +) -> Result<(Arc, String), AgentError> { + let session_manager = Arc::new(SessionManager::instance()); + let permission_manager = PermissionManager::instance(); + + let agent = Agent::with_config(AgentConfig::new( + session_manager.clone(), + permission_manager, + None, + // Deliberately Goose's own default rather than forcing `Auto`. The + // desktop catalog ships GOOSE_MODE=auto for the *external* goose + // runtime (`discovery.rs:89`), which auto-approves every tool call; + // driving the agent in-process means we don't have to inherit that. + GooseMode::default(), + // Session naming is a Goose UX affordance; the harness names sessions. + true, + GoosePlatform::GooseCli, + )); + + let session = session_manager + .create_session( + std::path::PathBuf::from(cwd), + "buzz-agent".to_string(), + SessionType::Acp, + GooseMode::default(), + ) + .await + .map_err(|e| AgentError::Llm(format!("session create: {e}")))?; + + // Provider. `Agent::with_config` starts with no provider set, so this is + // mandatory before the first `reply()` — otherwise the turn fails with + // "Provider not set". + // + // Provider/model names come from the `GOOSE_*` variables `Config::from_env` + // projected out of the `BUZZ_AGENT_*` ones; goose's registry owns base-url + // and credential resolution from there. + let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let model_name = cfg + .model + .clone() + .or_else(|| std::env::var("GOOSE_MODEL").ok()) + .ok_or_else(|| AgentError::Llm("no model configured".into()))?; + + let provider = goose::providers::create(&provider_name, Vec::new()) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + let model_config = + goose::model_config::model_config_from_user_config(&provider_name, &model_name) + .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; + + agent + .update_provider(provider, model_config, &session.id) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + + // Persona. This is the seam that does NOT work over plain ACP: goose's own + // ACP server never reads `systemPrompt` (rg finds zero hits in + // goose/crates/goose/src), and both PRs that would have wired it — + // buzz#1290 and goose#9971 — are closed unmerged. Driving the library + // directly is what makes Fizz (and `[Base]`) actually arrive. + if let Some(sp) = system_prompt.or(cfg.system_prompt.as_deref()) { + if !sp.trim().is_empty() { + agent.override_system_prompt(sp.to_string()).await; + } + } + + for server in mcp_servers { + let envs: HashMap = server + .env + .iter() + .map(|e| (e.name.clone(), e.value.clone())) + .collect(); + + let ext = ExtensionConfig::Stdio { + name: server.name.clone(), + description: String::new(), + cmd: server.command.clone(), + args: server.args.clone(), + envs: goose::agents::extension::Envs::new(envs), + env_keys: Vec::new(), + timeout: Some(300), + cwd: Some(cwd.to_string()), + bundled: None, + available_tools: Vec::new(), + }; + + if let Err(e) = agent.add_extension(ext, &session.id).await { + // Match buzz-agent: a failed MCP server is a hard session error, + // not a silently tool-less agent. + return Err(AgentError::Mcp(format!("{}: {e}", server.name))); + } + } + + Ok((Arc::new(agent), session.id)) +} + +pub async fn serve(cfg: Config) -> anyhow::Result<()> { + let (wire_tx, wire_rx) = tokio::sync::mpsc::channel(256); + tokio::spawn(wire::writer_task(wire_rx)); + + let app = Arc::new(App { + cfg, + sessions: Mutex::new(HashMap::new()), + }); + + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + loop { + match wire::read_bounded_line(&mut stdin, config::MAX_LINE_BYTES).await { + Ok(None) => break, + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(msg) => dispatch(&app, msg, &wire_tx).await, + Err(e) => { + wire::send( + &wire_tx, + wire::err( + Value::Null, + wire::PARSE_ERROR, + &format!("jsonrpc: parse: {e}"), + ), + ) + .await; + } + } + } + Err(e) => { + tracing::error!("io: {e}"); + break; + } + } + } + Ok(()) +} + +async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { + match classify(&msg) { + Inbound::Request { id, method, params } => { + handle_request(app, id, method, params, wire_tx).await + } + Inbound::Notification { method, params } => { + if method == "session/cancel" { + cancel_session(app, params).await; + } + } + Inbound::Ignored => {} + Inbound::Invalid { id, code, message } => { + wire::send(wire_tx, wire::err(id, code, &message)).await + } + } +} + +async fn handle_request( + app: &Arc, + id: Value, + method: String, + params: Value, + wire_tx: &WireSender, +) { + match method.as_str() { + "initialize" => initialize(id, params, wire_tx).await, + "session/new" => { + let app = app.clone(); + let wire_tx = wire_tx.clone(); + // Spawned so a slow MCP init can't block the read loop — otherwise + // `session/cancel` can't be received. + tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await }); + } + "session/prompt" => { + let app = app.clone(); + let wire_tx = wire_tx.clone(); + tokio::spawn(async move { session_prompt(&app, id, params, &wire_tx).await }); + } + "session/set_model" => set_model(app, id, params, wire_tx).await, + "session/cancel" => { + cancel_session(app, params).await; + wire::send(wire_tx, wire::ok(id, Value::Null)).await; + } + "_goose/unstable/session/steer" => steer(app, id, params, wire_tx).await, + _ => { + wire::send( + wire_tx, + wire::err( + id, + METHOD_NOT_FOUND, + &format!("jsonrpc: method not found: {method}"), + ), + ) + .await + } + } +} + +async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { + let p: InitializeParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("initialize: {e}")), + ) + .await + } + }; + // Honest negotiation: min(client, ours). Buzz squats on v2 ahead of the + // upstream ACP RFD (#1237); see buzz-agent/src/lib.rs:279-283. + let negotiated = p.protocol_version.min(PROTOCOL_VERSION); + wire::send( + wire_tx, + wire::ok( + id, + json!({ + "protocolVersion": negotiated, + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + }, + // Identity is unchanged on purpose: this is still buzz-agent. + // The `harness` field of the encrypted kind-44200 turn metric + // derives from this (`buzz-acp/src/pool.rs:3350`), so changing + // it would blank any dashboard filtering on it. + "agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") }, + }), + ), + ) + .await; +} + +async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionNewParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("session/new: {e}")), + ) + .await + } + }; + + if !std::path::Path::new(&p.cwd).is_absolute() { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/new: cwd must be absolute"), + ) + .await; + } + + if let Some(sp) = &p.system_prompt { + if sp.len() > MAX_SYSTEM_PROMPT_BYTES { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/new: systemPrompt too large"), + ) + .await; + } + } + + if app.sessions.lock().await.len() >= app.cfg.max_sessions { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/new: max sessions reached"), + ) + .await; + } + + let (agent, goose_session_id) = + match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { + Ok(v) => v, + Err(e) => { + return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await + } + }; + + let sid = format!("ses_{}", goose_session_id); + app.sessions.lock().await.insert( + sid.clone(), + Session { + agent, + goose_session_id, + busy: false, + active_run_id: None, + cancel: None, + model_override: None, + accumulated_input_tokens: 0, + accumulated_output_tokens: 0, + }, + ); + + wire::send(wire_tx, wire::ok(id, json!({ "sessionId": sid }))).await; +} + +async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionPromptParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("session/prompt: {e}")), + ) + .await + } + }; + + let prompt_bytes: usize = p + .prompt + .iter() + .map(|b| match b { + types::ContentBlock::Text { text } => text.len(), + types::ContentBlock::ResourceLink { uri } => uri.len(), + types::ContentBlock::Unsupported => 0, + }) + .sum(); + if prompt_bytes > MAX_PROMPT_BYTES { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/prompt: prompt too large"), + ) + .await; + } + + let run_id = format!("run_{}", uuid_like()); + let cancel = CancellationToken::new(); + + // Single-flight per session, and capture the agent handle. + let (agent, goose_session_id) = { + let mut sessions = app.sessions.lock().await; + let Some(s) = sessions.get_mut(&p.session_id) else { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/prompt: unknown session"), + ) + .await; + }; + if s.busy { + return wire::send( + wire_tx, + wire::err( + id, + INVALID_PARAMS, + "session/prompt: prompt already in flight", + ), + ) + .await; + } + s.busy = true; + s.active_run_id = Some(run_id.clone()); + s.cancel = Some(cancel.clone()); + (s.agent.clone(), s.goose_session_id.clone()) + }; + + // Advertise the run id so steer-capable clients can target this turn. + // `_meta` nests INSIDE `update` — see the module docs. + wire::send( + wire_tx, + wire::session_update_with_goose_meta( + &p.session_id, + json!({ "sessionUpdate": "session_info_update" }), + json!({ "activeRunId": run_id }), + ), + ) + .await; + + let (result, tokens) = agent::run_turn( + agent, + &goose_session_id, + p.prompt, + app.cfg.max_rounds, + wire_tx, + cancel, + ) + .await; + + // Clear run state so a late steer can't queue into a finished turn. + let accumulated = { + let mut sessions = app.sessions.lock().await; + sessions.get_mut(&p.session_id).map(|s| { + s.busy = false; + s.active_run_id = None; + s.cancel = None; + s.accumulated_input_tokens = s + .accumulated_input_tokens + .saturating_add(tokens.input.unwrap_or(0)); + s.accumulated_output_tokens = s + .accumulated_output_tokens + .saturating_add(tokens.output.unwrap_or(0)); + (s.accumulated_input_tokens, s.accumulated_output_tokens) + }) + }; + + wire::send( + wire_tx, + wire::session_update_with_goose_meta( + &p.session_id, + json!({ "sessionUpdate": "session_info_update" }), + json!({ "activeRunId": Value::Null }), + ), + ) + .await; + + // ORDERING IS LOAD-BEARING: emit usage BEFORE the prompt response. + // buzz-acp's UsageTracker processes this while the turn is still in + // flight; the response triggers take_turn_usage(). Reversing these + // produces zero kind-44200 events, silently. + if tokens.observed() { + if let Some((acc_in, acc_out)) = accumulated { + let model = { + let sessions = app.sessions.lock().await; + sessions + .get(&p.session_id) + .and_then(|s| s.model_override.clone()) + .or_else(|| app.cfg.model.clone()) + .unwrap_or_default() + }; + wire::send( + wire_tx, + goose_session_update( + &p.session_id, + json!({ + "sessionUpdate": "usage_update", + "used": acc_in.saturating_add(acc_out), + "contextLimit": 0u64, + "accumulatedInputTokens": acc_in, + "accumulatedOutputTokens": acc_out, + "model": model, + }), + ), + ) + .await; + } + } + + match result { + Ok(stop) => { + wire::send( + wire_tx, + wire::ok(id, json!({ "stopReason": stop.as_wire() })), + ) + .await + } + Err(e) => wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await, + } +} + +async fn set_model(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionSetModelParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("session/set_model: {e}")), + ) + .await + } + }; + if p.model_id.trim().is_empty() { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/set_model: empty modelId"), + ) + .await; + } + let mut sessions = app.sessions.lock().await; + match sessions.get_mut(&p.session_id) { + Some(s) => { + s.model_override = Some(p.model_id); + drop(sessions); + wire::send(wire_tx, wire::ok(id, Value::Null)).await; + } + None => { + drop(sessions); + wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/set_model: unknown session"), + ) + .await + } + } +} + +/// Non-cancelling mid-turn steering. +/// +/// Goose's own `steer()` has exactly buzz-agent's semantics: the message is +/// queued and drained at the *round boundary* (goose `agent.rs:1951-1974`), +/// the turn is not restarted, and a pending steer even prevents the turn from +/// ending (`agent.rs:2876-2878`). +async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionSteerParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("steer: {e}")), + ) + .await + } + }; + + let (agent, goose_session_id) = { + let sessions = app.sessions.lock().await; + let Some(s) = sessions.get(&p.session_id) else { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: unknown session"), + ) + .await; + }; + // Optimistic concurrency: the harness distinguishes "no active run" + // from "run id mismatch" to decide whether to fire its cancel+merge + // fallback (`buzz-acp/src/pool.rs:329-366`). + let Some(active) = s.active_run_id.as_deref() else { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: no active run"), + ) + .await; + }; + if active != p.expected_run_id { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: run id mismatch"), + ) + .await; + } + (s.agent.clone(), s.goose_session_id.clone()) + }; + + let text = agent::prompt_to_text(&p.prompt); + if text.trim().is_empty() { + // Best-effort by contract: a whitespace-only steer is dropped, never + // fatal to the turn (buzz-agent `agent.rs:266-278`). + return wire::send(wire_tx, wire::ok(id, json!({ "runId": p.expected_run_id }))).await; + } + + let message_id = format!("steer_{}", uuid_like()); + agent + .steer( + &goose_session_id, + goose_provider_types::conversation::message::Message::user().with_text(text), + ) + .await; + + wire::send( + wire_tx, + wire::ok( + id, + json!({ "runId": p.expected_run_id, "messageId": message_id }), + ), + ) + .await; +} + +async fn cancel_session(app: &Arc, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + return; + }; + let sessions = app.sessions.lock().await; + if let Some(s) = sessions.get(&p.session_id) { + if let Some(c) = &s.cancel { + // Goose plumbs this token down into MCP tool calls and sends + // `notifications/cancelled` per in-flight request + // (goose `mcp_client.rs:687-690`) — a cooperative drain rather + // than a hard abort, matching buzz-agent's contract. + c.cancel(); + } + } +} + +/// Preserve buzz-agent's error taxonomy across provider construction, so the +/// harness's JSON-RPC code mapping (-32001 auth, -32002 model-not-found) keeps +/// its meaning. +fn map_provider_error(msg: &str) -> AgentError { + let lower = msg.to_ascii_lowercase(); + if lower.contains("auth") || lower.contains("401") || lower.contains("api key") { + AgentError::LlmAuth(msg.to_string()) + } else if lower.contains("model") && lower.contains("not found") { + AgentError::LlmModelNotFound(msg.to_string()) + } else { + AgentError::Llm(msg.to_string()) + } +} + +/// Short random id. Not a UUID; just needs to be unguessable enough that a +/// stale steer can't collide with a live run. +fn uuid_like() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +pub fn run() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cfg = Config::from_env(); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(serve(cfg))?; + Ok(()) +} diff --git a/buzz-agent-core/src/main.rs b/buzz-agent-core/src/main.rs new file mode 100644 index 0000000000..3b2c49e90f --- /dev/null +++ b/buzz-agent-core/src/main.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), Box> { + buzz_agent_core::run() +} diff --git a/buzz-agent-core/src/types.rs b/buzz-agent-core/src/types.rs new file mode 100644 index 0000000000..de5e50b3a2 --- /dev/null +++ b/buzz-agent-core/src/types.rs @@ -0,0 +1,107 @@ +//! Wire-facing types shared with `wire.rs`. +//! +//! This is a deliberate subset of `crates/buzz-agent/src/types.rs` (353 lines). +//! The original carried the hand-written loop's internal vocabulary — +//! `HistoryItem`, `ToolCall`, `ToolResult`, `LlmResponse`, `ProviderStop`, +//! `ToolDef`, plus byte-accounting helpers (`estimated_bytes`, +//! `context_pressure_bytes`) that fed the bespoke handoff heuristic. +//! +//! Goose owns all of that now: conversation state is `goose::conversation`, +//! tool plumbing is `rmcp`, and compaction is `goose::context_mgmt`. What +//! survives here is only what crosses the ACP wire. + +use serde::Deserialize; + +/// A stdio MCP server declaration from `session/new`. +#[derive(Debug, Deserialize, Clone)] +pub struct McpServerStdio { + pub name: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct EnvVar { + pub name: String, + pub value: String, +} + +/// A single block of an ACP `session/prompt` payload. +#[derive(Debug, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + Text { + text: String, + }, + ResourceLink { + uri: String, + }, + #[serde(other)] + Unsupported, +} + +/// ACP stop reasons. Wire strings are load-bearing: `buzz-acp` parses +/// `stopReason` off the `session/prompt` response and errors without it +/// (`acp.rs:1757-1761`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopReason { + EndTurn, + Cancelled, + MaxTokens, + MaxTurnRequests, + Refusal, +} + +impl StopReason { + pub fn as_wire(self) -> &'static str { + match self { + Self::EndTurn => "end_turn", + Self::Cancelled => "cancelled", + Self::MaxTokens => "max_tokens", + Self::MaxTurnRequests => "max_turn_requests", + Self::Refusal => "refusal", + } + } +} + +/// Errors surfaced to the client as JSON-RPC error responses. +#[derive(Debug)] +pub enum AgentError { + InvalidParams(String), + Llm(String), + LlmAuth(String), + LlmModelNotFound(String), + Mcp(String), + Cancelled, +} + +impl std::fmt::Display for AgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidParams(s) => write!(f, "invalid params: {s}"), + Self::Llm(s) => write!(f, "llm: {s}"), + Self::LlmAuth(s) => write!(f, "llm auth: {s}"), + Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), + Self::Mcp(s) => write!(f, "mcp: {s}"), + Self::Cancelled => write!(f, "cancelled"), + } + } +} + +impl std::error::Error for AgentError {} + +impl AgentError { + /// Preserved verbatim from buzz-agent: the harness's error taxonomy keys + /// off these codes. + pub fn json_rpc_code(&self) -> i32 { + match self { + Self::InvalidParams(_) => -32602, + Self::LlmAuth(_) => -32001, + Self::LlmModelNotFound(_) => -32002, + _ => -32000, + } + } +} diff --git a/buzz-agent-core/src/wire.rs b/buzz-agent-core/src/wire.rs new file mode 100644 index 0000000000..7b50e7982a --- /dev/null +++ b/buzz-agent-core/src/wire.rs @@ -0,0 +1,293 @@ +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; + +use crate::types::{ContentBlock, McpServerStdio}; + +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; + +pub enum WireMsg { + Notify(Value), +} + +pub type WireSender = mpsc::Sender; + +#[derive(Debug)] +pub enum Inbound { + Request { + id: Value, + method: String, + params: Value, + }, + Notification { + method: String, + params: Value, + }, + Ignored, + Invalid { + id: Value, + code: i32, + message: String, + }, +} + +#[derive(Debug, Deserialize)] +pub struct InitializeParams { + #[serde(rename = "protocolVersion")] + pub protocol_version: u32, + #[serde(default, rename = "clientCapabilities")] + pub _client_capabilities: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionNewParams { + pub cwd: String, + #[serde(default)] + pub mcp_servers: Vec, + #[serde(default)] + pub system_prompt: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPromptParams { + pub session_id: String, + pub prompt: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCancelParams { + pub session_id: String, +} + +/// Params for goose's non-standard `_goose/unstable/session/steer` request: +/// inject user input into the *currently active* prompt without starting a new +/// one. `expected_run_id` must match the run id buzz-agent advertised via +/// `params.update._meta.goose.activeRunId` on a `session/update`, so a steer +/// can't race a turn that already ended or hasn't started. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSteerParams { + pub session_id: String, + #[serde(default)] + pub prompt: Vec, + pub expected_run_id: String, +} + +/// Params for `session/set_model`: override the active model for an existing +/// session without respawning. Applied immediately; subsequent prompts on this +/// session use `model_id` instead of the configured `BUZZ_AGENT_MODEL`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSetModelParams { + pub session_id: String, + pub model_id: String, +} + +pub fn classify(msg: &Value) -> Inbound { + if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Inbound::Invalid { + id: msg.get("id").cloned().unwrap_or(Value::Null), + code: INVALID_REQUEST, + message: "jsonrpc: missing or invalid version".into(), + }; + } + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(Value::as_str).map(str::to_owned); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + + match (method, id) { + (Some(m), Some(id)) => Inbound::Request { + id, + method: m, + params, + }, + (Some(m), None) => Inbound::Notification { method: m, params }, + // Bare responses (id present, no method) are unexpected — buzz-agent + // does not issue requests to the client. Ignore silently. + (None, Some(_)) => Inbound::Ignored, + (None, None) => Inbound::Invalid { + id: Value::Null, + code: INVALID_REQUEST, + message: "jsonrpc: missing method and id".into(), + }, + } +} + +pub fn ok(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +pub fn err(id: Value, code: i32, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +pub fn session_update(sid: &str, update: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": sid, "update": update }, + }) +} + +/// A `_goose/unstable/session/update` notification — the separate top-level +/// method goose uses for custom usage and status events. Used by buzz-agent +/// to emit the `usage_update` payload so buzz-acp's `UsageTracker` can treat +/// buzz-agent and goose symmetrically. +pub fn goose_session_update(sid: &str, update: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "method": "_goose/unstable/session/update", + "params": { "sessionId": sid, "update": update }, + }) +} + +/// A `session/update` notification carrying a `update._meta.goose.` field. +/// Used to advertise `activeRunId` (so steer-capable clients can target the +/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer +/// with the chunk that later picks it up) — matching goose's wire layout where +/// `_meta` is nested inside the `update` object (per the ACP `SessionInfoUpdate` +/// schema), not alongside it at the params level. +pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Value) -> Value { + let mut update = update; + update["_meta"] = json!({ "goose": goose_meta }); + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": sid, + "update": update, + }, + }) +} + +pub async fn send(wire: &WireSender, msg: Value) { + let _ = wire.send(WireMsg::Notify(msg)).await; +} + +pub async fn read_bounded_line( + stdin: &mut R, + max: usize, +) -> std::io::Result> { + let mut buf: Vec = Vec::new(); + loop { + let chunk = stdin.fill_buf().await?; + if chunk.is_empty() { + if !buf.is_empty() { + tracing::error!( + "io: unterminated frame at EOF ({} bytes dropped)", + buf.len() + ); + } + return Ok(None); + } + let take = chunk + .iter() + .position(|b| *b == b'\n') + .map_or(chunk.len(), |i| i + 1); + if buf.len().saturating_add(take) > max { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("io: line exceeds max ({max} bytes)"), + )); + } + buf.extend_from_slice(&chunk[..take]); + stdin.consume(take); + if buf.ends_with(b"\n") { + buf.pop(); + if buf.ends_with(b"\r") { + buf.pop(); + } + match String::from_utf8(buf) { + Ok(s) => return Ok(Some(s)), + Err(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "io: frame contains invalid UTF-8", + )) + } + } + } + } +} + +pub async fn writer_task(mut rx: mpsc::Receiver) { + let mut stdout = tokio::io::stdout(); + while let Some(msg) = rx.recv().await { + let WireMsg::Notify(v) = msg; + let mut s = match serde_json::to_string(&v) { + Ok(s) => s, + Err(e) => { + tracing::error!("io: serialize: {e}"); + continue; + } + }; + s.push('\n'); + if stdout.write_all(s.as_bytes()).await.is_err() { + return; + } + let _ = stdout.flush().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_new_params_deserializes_system_prompt() { + let json = serde_json::json!({ + "cwd": "/tmp/test", + "mcpServers": [], + "systemPrompt": "You are a helpful agent." + }); + let params: SessionNewParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.cwd, "/tmp/test"); + assert_eq!( + params.system_prompt.as_deref(), + Some("You are a helpful agent.") + ); + } + + #[test] + fn session_new_params_system_prompt_defaults_to_none() { + let json = serde_json::json!({ + "cwd": "/tmp/test", + "mcpServers": [] + }); + let params: SessionNewParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.cwd, "/tmp/test"); + assert!(params.system_prompt.is_none()); + } + + #[test] + fn session_new_params_ignores_unknown_fields() { + // Backward compat: old agents with new harness — unknown fields are ignored. + let json = serde_json::json!({ + "cwd": "/tmp/test", + "mcpServers": [], + "unknownField": "should be ignored" + }); + let params: SessionNewParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.cwd, "/tmp/test"); + assert!(params.system_prompt.is_none()); + } + + #[test] + fn session_new_params_empty_string_system_prompt() { + // An explicit empty string is distinct from absent — deserializes to Some(""). + let json = serde_json::json!({ + "cwd": "/tmp/test", + "mcpServers": [], + "systemPrompt": "" + }); + let params: SessionNewParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.system_prompt, Some(String::new())); + } +} diff --git a/buzz-agent-core/tests/stdio_turn.rs b/buzz-agent-core/tests/stdio_turn.rs new file mode 100644 index 0000000000..1d19f8b17c --- /dev/null +++ b/buzz-agent-core/tests/stdio_turn.rs @@ -0,0 +1,346 @@ +//! End-to-end stdio test: drive the binary over ACP exactly as `buzz-acp` +//! does, against a fake OpenAI-compatible provider. +//! +//! This is the test that proves the integration, not just the compile: +//! +//! 1. `initialize` negotiates and self-identifies as `buzz-agent`. +//! 2. `session/new` accepts a `systemPrompt` (Fizz) and MCP servers. +//! 3. **The persona actually reaches the model** — the fake provider asserts +//! Fizz's text is present in the `system` field of the request body. This +//! is the whole reason for driving goose as a library: goose's own ACP +//! server never reads `systemPrompt` (zero hits in goose/crates/goose/src), +//! so over plain ACP the persona is silently dropped. +//! 4. `session/prompt` runs a turn and returns a `stopReason`. +//! 5. `usage_update` arrives on the `_goose/unstable/session/update` channel +//! BEFORE the `session/prompt` response — ordering that kind-44200 metrics +//! depend on (`buzz-acp/src/lib.rs:701-706`). + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::{json, Value}; + +const FIZZ_PROMPT: &str = "You are Fizz, an energetic maker who turns ideas into action. Be upbeat, practical, and decisive."; + +/// Minimal OpenAI-compatible `/chat/completions` server. +/// +/// Returns the captured `system` prompt of the first request over a channel so +/// the test can assert the persona survived the trip. +fn spawn_fake_provider() -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let tx = tx.clone(); + + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + + // Read headers, find content-length. + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let trimmed = line.trim_end(); + if trimmed.is_empty() { + break; + } + if let Some(v) = trimmed.strip_prefix("content-length: ") { + content_length = v.parse().unwrap_or(0); + } else if let Some(v) = trimmed.strip_prefix("Content-Length: ") { + content_length = v.parse().unwrap_or(0); + } + } + + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + // Models endpoint: return a tiny catalog. + if request_line.contains("/models") { + let payload = json!({ + "object": "list", + "data": [{ "id": "fake-model", "object": "model" }], + }) + .to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + continue; + } + + // Chat completions: capture the system prompt, then answer. + if let Ok(req) = serde_json::from_slice::(&body) { + let system = req["messages"] + .as_array() + .and_then(|msgs| { + msgs.iter() + .find(|m| m["role"] == "system") + .and_then(|m| m["content"].as_str()) + .map(str::to_owned) + }) + .unwrap_or_default(); + let _ = tx.send(system); + } + + // Goose sets `stream: true` on the OpenAI provider + // (goose-providers/src/openai.rs:650), so answer with SSE. A + // non-streaming JSON body parses as an empty response and the turn + // ends with zero tokens — which correctly suppresses + // `usage_update`, but proves nothing. + let chunk = |delta: Value, finish: Value, usage: Value| { + let mut c = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": delta, "finish_reason": finish }], + }); + if !usage.is_null() { + c["usage"] = usage; + } + format!("data: {c}\n\n") + }; + + let body = format!( + "{}{}{}data: [DONE]\n\n", + chunk( + json!({ "role": "assistant", "content": "Buzzing along!" }), + Value::Null, + Value::Null + ), + chunk(json!({ "content": " Done. 🐝" }), Value::Null, Value::Null), + chunk( + json!({}), + json!("stop"), + json!({ "prompt_tokens": 42, "completion_tokens": 7, "total_tokens": 49 }) + ), + ); + + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + + (format!("http://{addr}"), rx) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let exe = env!("CARGO_BIN_EXE_buzz-agent-core"); + let mut child = Command::new(exe) + // Buzz-shaped config; config.rs translates these onto GOOSE_*. + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "test-key") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("BUZZ_AGENT_MAX_ROUNDS", "2") + // Isolate goose's config/session state from the developer's real one. + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn buzz-agent-core"); + + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + writeln!(self.stdin, "{msg}").expect("write"); + self.stdin.flush().expect("flush"); + id + } + + /// Read lines until the response with `id` arrives, collecting every + /// notification seen along the way (so ordering can be asserted). + fn await_response(&mut self, id: i64) -> (Value, Vec) { + let mut notifications = Vec::new(); + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout before responding to id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return (msg, notifications); + } + if msg.get("method").is_some() { + notifications.push(msg); + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn full_turn_over_stdio_carries_the_persona() { + let (base_url, system_rx) = spawn_fake_provider(); + let home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + // 1. initialize + let id = h.request( + "initialize", + json!({ + "protocolVersion": 2, + "clientCapabilities": { "_meta": { "goose": { "customNotifications": true } } }, + }), + ); + let (resp, _) = h.await_response(id); + let result = &resp["result"]; + assert_eq!(result["protocolVersion"], 2, "negotiates min(client, 2)"); + assert_eq!( + result["agentInfo"]["name"], "buzz-agent", + "identity must stay buzz-agent — kind-44200 `harness` attribution keys off this" + ); + + // 2. session/new carrying Fizz + let id = h.request( + "session/new", + json!({ + "cwd": cwd.path().to_str().unwrap(), + "mcpServers": [], + "systemPrompt": FIZZ_PROMPT, + }), + ); + let (resp, _) = h.await_response(id); + let session_id = resp["result"]["sessionId"] + .as_str() + .expect("sessionId in session/new result") + .to_string(); + assert!(session_id.starts_with("ses_")); + + // 3. session/prompt + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "say hello" }], + }), + ); + let (resp, notifications) = h.await_response(id); + + assert!( + resp["result"]["stopReason"].is_string(), + "buzz-acp errors without stopReason: got {resp}" + ); + + // 4. THE point of the whole exercise: the persona reached the provider. + let captured = system_rx + .recv_timeout(Duration::from_secs(30)) + .expect("provider never received a chat completion request"); + assert!( + captured.contains("You are Fizz"), + "persona did not reach the model. system prompt was:\n{captured}" + ); + + // 5. activeRunId advertised with _meta nested INSIDE update. + let run_id_update = notifications.iter().find(|n| { + n["method"] == "session/update" + && n["params"]["update"]["sessionUpdate"] == "session_info_update" + && n["params"]["update"]["_meta"]["goose"]["activeRunId"].is_string() + }); + assert!( + run_id_update.is_some(), + "no activeRunId advertised — steering silently degrades to cancel+re-prompt. saw: {:#?}", + notifications + .iter() + .map(|n| n["method"].as_str().unwrap_or("?")) + .collect::>() + ); + + // 6. usage_update present, on the goose-namespaced channel. + let usage_idx = notifications.iter().position(|n| { + n["method"] == "_goose/unstable/session/update" + && n["params"]["update"]["sessionUpdate"] == "usage_update" + }); + let usage_idx = + usage_idx.expect("no usage_update — kind-44200 turn metrics would silently vanish"); + + let usage = ¬ifications[usage_idx]["params"]["update"]; + assert!( + usage["accumulatedInputTokens"].as_u64().unwrap_or(0) > 0, + "expected non-zero accumulated input tokens, got {usage}" + ); + + // Ordering is implicitly proven: `notifications` only contains messages + // seen strictly BEFORE the session/prompt response was read. +} + +#[test] +fn unknown_method_gets_method_not_found() { + let (base_url, _rx) = spawn_fake_provider(); + let home = tempfile::tempdir().expect("tempdir"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("does/not/exist", json!({})); + let (resp, _) = h.await_response(id); + assert_eq!( + resp["error"]["code"], -32601, + "silence would hang the harness waiting for a response" + ); +} + +#[test] +fn session_new_rejects_relative_cwd() { + let (base_url, _rx) = spawn_fake_provider(); + let home = tempfile::tempdir().expect("tempdir"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request( + "session/new", + json!({ "cwd": "relative/path", "mcpServers": [] }), + ); + let (resp, _) = h.await_response(id); + assert_eq!(resp["error"]["code"], -32602); +} From 63efbb24b67e0679c28e0740a271fa70d2a9467b Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 15:12:23 +1000 Subject: [PATCH 02/20] feat(buzz-agent-core): pin goose by git rev, wire session/set_model Makes the spike reproducible off this machine and closes the model-switch stub. - Depend on aaif-goose/goose @ 305849b71 (v1.44.0, ancestor of origin/main) instead of a local path. Verified: cargo fetch + full test run from a clean git checkout of the dep. - session/set_model now takes effect. The id is staged in `pending_model` and consumed by the next session/prompt, matching buzz-agent's "applies from the next prompt" contract (lib.rs:494-502) so an in-flight turn is never mutated. Rebuilds the provider and hot-swaps it via update_provider; SharedProvider is an Arc>> for exactly this. - New stdio test covers unknown-session, empty-modelId, and a real switch followed by a completing turn. 15 tests green. Measured release binary (macOS arm64): new 32.5 MiB raw / 9.9 MiB gzip -9 old 9.8 MiB raw / 3.9 MiB gzip -9 delta +22.7 MiB raw / +6.0 MiB gzip Corroborates PR #1526's +22.9 MiB raw / +6.2 MiB gzip. Signed-off-by: Michael Neale --- buzz-agent-core/Cargo.lock | 6 +++ buzz-agent-core/Cargo.toml | 4 +- buzz-agent-core/src/lib.rs | 60 +++++++++++++++++++++++++++-- buzz-agent-core/tests/stdio_turn.rs | 60 +++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/buzz-agent-core/Cargo.lock b/buzz-agent-core/Cargo.lock index e88e70b005..eeb3a8f8ba 100644 --- a/buzz-agent-core/Cargo.lock +++ b/buzz-agent-core/Cargo.lock @@ -1872,6 +1872,7 @@ dependencies = [ [[package]] name = "goose" version = "1.44.0" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -1972,6 +1973,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" version = "1.44.0" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ "quote", "syn 2.0.119", @@ -1980,6 +1982,7 @@ dependencies = [ [[package]] name = "goose-download-manager" version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ "anyhow", "once_cell", @@ -1992,6 +1995,7 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ "anyhow", "async-stream", @@ -2017,6 +2021,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ "anyhow", "async-stream", @@ -2040,6 +2045,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", diff --git a/buzz-agent-core/Cargo.toml b/buzz-agent-core/Cargo.toml index c7fe9cc408..fdfc62bc92 100644 --- a/buzz-agent-core/Cargo.toml +++ b/buzz-agent-core/Cargo.toml @@ -46,8 +46,8 @@ path = "src/main.rs" # with default-features = false, so a TLS backend only arrives via this feature # (and `rustls-tls` + `native-tls` are mutually exclusive: see the # compile_error! at goose/src/lib.rs:3-4). -goose = { path = "../../goose/crates/goose", default-features = false, features = ["rustls-tls"] } -goose-provider-types = { path = "../../goose/crates/goose-provider-types" } +goose = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab", default-features = false, features = ["rustls-tls"] } +goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/buzz-agent-core/src/lib.rs b/buzz-agent-core/src/lib.rs index 4e12339b86..e7eccce5b5 100644 --- a/buzz-agent-core/src/lib.rs +++ b/buzz-agent-core/src/lib.rs @@ -53,7 +53,14 @@ struct Session { /// Set for the duration of a turn; advertised to steer-capable clients. active_run_id: Option, cancel: Option, + /// Model id currently in force for this session (`session/set_model`). + /// Reported back on `usage_update`. model_override: Option, + /// Set by `session/set_model`, consumed by the next `session/prompt`. + /// Applying it at prompt time rather than immediately matches buzz-agent: + /// the override takes effect "from the next prompt" and never mutates a + /// turn already in flight (`buzz-agent/src/lib.rs:494-502`). + pending_model: Option, accumulated_input_tokens: u64, accumulated_output_tokens: u64, } @@ -361,6 +368,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen active_run_id: None, cancel: None, model_override: None, + pending_model: None, accumulated_input_tokens: 0, accumulated_output_tokens: 0, }, @@ -402,7 +410,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire let cancel = CancellationToken::new(); // Single-flight per session, and capture the agent handle. - let (agent, goose_session_id) = { + let (agent, goose_session_id, pending_model) = { let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { return wire::send( @@ -425,9 +433,30 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire s.busy = true; s.active_run_id = Some(run_id.clone()); s.cancel = Some(cancel.clone()); - (s.agent.clone(), s.goose_session_id.clone()) + // Take the pending override so a `session/set_model` applies exactly + // once, from the next prompt onward (buzz-agent `lib.rs:494-502`). + ( + s.agent.clone(), + s.goose_session_id.clone(), + s.pending_model.take(), + ) }; + // Apply a pending `session/set_model` before the turn starts. + if let Some(model_id) = pending_model { + if let Err(e) = apply_model(&agent, &goose_session_id, &model_id).await { + { + let mut sessions = app.sessions.lock().await; + if let Some(s) = sessions.get_mut(&p.session_id) { + s.busy = false; + s.active_run_id = None; + s.cancel = None; + } + } + return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await; + } + } + // Advertise the run id so steer-capable clients can target this turn. // `_meta` nests INSIDE `update` — see the module docs. wire::send( @@ -542,7 +571,8 @@ async fn set_model(app: &Arc, id: Value, params: Value, wire_tx: &WireSende let mut sessions = app.sessions.lock().await; match sessions.get_mut(&p.session_id) { Some(s) => { - s.model_override = Some(p.model_id); + s.model_override = Some(p.model_id.clone()); + s.pending_model = Some(p.model_id); drop(sessions); wire::send(wire_tx, wire::ok(id, Value::Null)).await; } @@ -645,6 +675,30 @@ async fn cancel_session(app: &Arc, params: Value) { } } +/// Swap the model for an existing session. +/// +/// Rebuilds the provider (base-url/credential resolution lives in goose's +/// registry) and installs it with the new `ModelConfig`. `SharedProvider` is an +/// `Arc>>` precisely so this is hot-swappable +/// (goose `agents/types.rs:11-12`). +async fn apply_model( + agent: &Arc, + session_id: &str, + model_id: &str, +) -> Result<(), AgentError> { + let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let provider = goose::providers::create(&provider_name, Vec::new()) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + let model_config = goose::model_config::model_config_from_user_config(&provider_name, model_id) + .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; + agent + .update_provider(provider, model_config, session_id) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + Ok(()) +} + /// Preserve buzz-agent's error taxonomy across provider construction, so the /// harness's JSON-RPC code mapping (-32001 auth, -32002 model-not-found) keeps /// its meaning. diff --git a/buzz-agent-core/tests/stdio_turn.rs b/buzz-agent-core/tests/stdio_turn.rs index 1d19f8b17c..cd985d4731 100644 --- a/buzz-agent-core/tests/stdio_turn.rs +++ b/buzz-agent-core/tests/stdio_turn.rs @@ -317,6 +317,66 @@ fn full_turn_over_stdio_carries_the_persona() { // seen strictly BEFORE the session/prompt response was read. } +#[test] +fn set_model_applies_from_the_next_prompt() { + let (base_url, system_rx) = spawn_fake_provider(); + let home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ "cwd": cwd.path().to_str().unwrap(), "mcpServers": [] }), + ); + let (resp, _) = h.await_response(id); + let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string(); + + // Unknown session and empty modelId are both invalid_params. + let id = h.request( + "session/set_model", + json!({ "sessionId": "ses_nope", "modelId": "x" }), + ); + let (resp, _) = h.await_response(id); + assert_eq!(resp["error"]["code"], -32602, "unknown session"); + + let id = h.request( + "session/set_model", + json!({ "sessionId": session_id, "modelId": " " }), + ); + let (resp, _) = h.await_response(id); + assert_eq!(resp["error"]["code"], -32602, "empty modelId"); + + // A real switch is accepted... + let id = h.request( + "session/set_model", + json!({ "sessionId": session_id, "modelId": "fake-model-2" }), + ); + let (resp, _) = h.await_response(id); + assert!(resp["error"].is_null(), "set_model rejected: {resp}"); + + // ...and the next turn still completes (the override is applied at prompt + // time, not mid-flight). + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "hi" }], + }), + ); + let (resp, _) = h.await_response(id); + assert!( + resp["result"]["stopReason"].is_string(), + "turn failed after set_model: {resp}" + ); + + let _ = system_rx + .recv_timeout(Duration::from_secs(30)) + .expect("provider never received a request after set_model"); +} + #[test] fn unknown_method_gets_method_not_found() { let (base_url, _rx) = spawn_fake_provider(); From 46124528ae1a82a81fddd6ca2398699d5a3920f8 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 15:20:24 +1000 Subject: [PATCH 03/20] fix(buzz-agent-core): advertise the model catalog on session/new session/new returned only `sessionId`, so the desktop ModelPicker degraded to "current model only" and buzz-acp could not resolve session/set_model targets (resolve_model_switch_method, buzz-acp/src/acp.rs:1876). This was a regression I introduced by deleting buzz-agent's catalog.rs without replacing what it fed -- not a limitation of driving goose as a library. The picker is the same UI and the same buzz-acp code path for every agent; goose's CLI fills it via build_model_state, buzz-agent filled it via Databricks discovery, and this crate filled it with nothing. Cannot reuse goose's builder: build_model_state is pub(super) (acp/response_builder.rs:130), invisible outside goose::acp. The underlying data is public, so discover_models() rebuilds the same shape from Provider::fetch_supported_models (goose-provider-types/base.rs:425) via Agent::provider(), including goose's rule that the current model is prepended when the provider's list omits it. Absent catalog stays degraded UX, never a session failure -- matching buzz-agent's Databricks fallback (catalog.rs:52-80). New stdio test asserts the shape buzz-acp actually parses: currentModelId plus availableModels entries keyed by `modelId`. 16 tests green. Signed-off-by: Michael Neale --- buzz-agent-core/src/lib.rs | 64 ++++++++++++++++++++++++++++- buzz-agent-core/tests/stdio_turn.rs | 43 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/buzz-agent-core/src/lib.rs b/buzz-agent-core/src/lib.rs index e7eccce5b5..bb8b94c2d9 100644 --- a/buzz-agent-core/src/lib.rs +++ b/buzz-agent-core/src/lib.rs @@ -359,6 +359,16 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen }; let sid = format!("ses_{}", goose_session_id); + + // Keep a handle for catalog discovery below; the Session takes ownership. + let session_agent = agent.clone(); + let current_model = app + .cfg + .model + .clone() + .or_else(|| std::env::var("GOOSE_MODEL").ok()) + .unwrap_or_default(); + app.sessions.lock().await.insert( sid.clone(), Session { @@ -374,7 +384,59 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen }, ); - wire::send(wire_tx, wire::ok(id, json!({ "sessionId": sid }))).await; + // Advertise the model catalog. `buzz-acp` reads `models.availableModels` + // off this response (`acp.rs:1866`, `:1900`) to drive the desktop + // ModelPicker and to resolve `session/set_model` targets + // (`resolve_model_switch_method`, `acp.rs:1876`). Omitting it degrades the + // picker to "current model only" — which is what buzz-agent's `catalog.rs` + // existed to prevent. + // + // Goose builds the same structure internally (`build_model_state`, + // acp/response_builder.rs:130) but it is `pub(super)`, so an embedder + // cannot call it. The underlying data is public, though: + // `Provider::fetch_supported_models` (goose-provider-types/base.rs:425). + let mut result = json!({ "sessionId": sid }); + if let Some(models) = discover_models(&session_agent, ¤t_model).await { + result["models"] = models; + } + + wire::send(wire_tx, wire::ok(id, result)).await; +} + +/// Build the `{currentModelId, availableModels}` object for `session/new`. +/// +/// Mirrors goose's own `build_model_state`, including its rule that the +/// current model is prepended when the provider's list omits it — otherwise +/// `buzz-acp` cannot resolve a switch back to it. +/// +/// Returns `None` when the provider cannot enumerate models (many can't; +/// `fetch_supported_models` defaults to an empty list). A missing catalog is +/// degraded UX, never a session failure — buzz-agent's Databricks discovery +/// made the same choice (`catalog.rs:52-80`). +async fn discover_models(agent: &Arc, current_model: &str) -> Option { + let provider = agent.provider().await.ok()?; + let ids = provider.fetch_supported_models().await.ok()?; + + let mut available: Vec = ids + .iter() + .map(|id| json!({ "modelId": id, "name": id })) + .collect(); + + if !ids.iter().any(|id| id == current_model) { + available.insert( + 0, + json!({ "modelId": current_model, "name": current_model }), + ); + } + + if available.is_empty() { + return None; + } + + Some(json!({ + "currentModelId": current_model, + "availableModels": available, + })) } async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { diff --git a/buzz-agent-core/tests/stdio_turn.rs b/buzz-agent-core/tests/stdio_turn.rs index cd985d4731..c2a46860ba 100644 --- a/buzz-agent-core/tests/stdio_turn.rs +++ b/buzz-agent-core/tests/stdio_turn.rs @@ -317,6 +317,49 @@ fn full_turn_over_stdio_carries_the_persona() { // seen strictly BEFORE the session/prompt response was read. } +#[test] +fn session_new_advertises_the_model_catalog() { + let (base_url, _rx) = spawn_fake_provider(); + let home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ "cwd": cwd.path().to_str().unwrap(), "mcpServers": [] }), + ); + let (resp, _) = h.await_response(id); + let result = &resp["result"]; + + // buzz-acp reads `models.availableModels` to drive the desktop + // ModelPicker and to resolve session/set_model targets + // (`buzz-acp/src/acp.rs:1866`, `:1900`). Without it the picker degrades + // to "current model only". + let models = &result["models"]; + assert!( + models.is_object(), + "session/new must advertise a model catalog, got: {result}" + ); + assert_eq!(models["currentModelId"], "fake-model"); + + let available = models["availableModels"] + .as_array() + .expect("availableModels array"); + assert!(!available.is_empty()); + // Key must be `modelId` — that is what resolve_model_switch_method matches. + assert!( + available.iter().any(|m| m["modelId"] == "fake-model"), + "current model must appear in availableModels, got: {available:#?}" + ); + assert!( + available.iter().all(|m| m["modelId"].is_string()), + "every entry needs a modelId key" + ); +} + #[test] fn set_model_applies_from_the_next_prompt() { let (base_url, system_rx) = spawn_fake_provider(); From e9aca45048de0ab11e8bf89dcf4a6c8c9fb3607f Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:07:02 +1000 Subject: [PATCH 04/20] feat(buzz-agent-core): implement the _Stop veto and _PostCompact re-injection Closes the last behavioural gap. buzz-agent's most load-bearing non-standard behaviour -- the agent may not end its turn while its todo list has open items -- now works, with no changes to goose. Why not goose's hook system. Goose has a blocking Stop hook with exactly these semantics (agent.rs:2891-2917), but it is unreachable: hook_manager is private with a #[cfg(test)] setter, and hooks are otherwise discovered as subprocesses from /.goose/plugins/*/hooks/hooks.json. The subprocess route looked viable until you notice where the answer lives -- buzz-dev-mcp's todo list is in-process state (todo.rs:49, a Mutex>) and that process's stdio is owned by goose. A hook binary goose spawns cannot see it, so a materialised hooks.json would produce a hook that always answers "no objection": worse than no hook, because it looks like it works. Instead we own the outer loop, so we ask the tool ourselves. Agent::dispatch_tool_call is public (agent.rs:1059). Between rounds we call _Stop on the same extension the model uses; on objection we re-enter reply() with the objection as an agent-visible/user-invisible message. Capped at 3 consecutive vetoes, mirroring goose's own stop_hook_block_cap. _PostCompact is wired to AgentEvent::HistoryReplaced and re-injects via steer(), which goose drains at the round boundary (agent.rs:1951-1974). Extension name is discovered by "___Stop" suffix rather than hardcoded -- buzz-acp derives it from the MCP binary's file stem (buzz-acp/src/lib.rs:4145), so it is not a fixed string. KNOWN DEVIATION: buzz-agent hid _-prefixed tools from the model (agent.rs:328-336) while still calling them itself. Goose's available_tools allowlist gates advertising and dispatch through the same cache (extension_manager.rs:1421, :1698), so hiding them would make them undispatchable and break the veto. They stay visible; a system-prompt extension tells the model not to call them. 4 new tests against the real fake-mcp binary (copied from crates/buzz-agent) counting provider generations: 2 objections => 3 calls, permanent objection capped at 4, no hook => 1 call, and discovery under a non-obvious extension name. 21 tests green. Signed-off-by: Michael Neale --- buzz-agent-core/Cargo.lock | 1 + buzz-agent-core/Cargo.toml | 7 + buzz-agent-core/src/agent.rs | 135 ++++++++++- buzz-agent-core/src/hooks.rs | 144 +++++++++++ buzz-agent-core/src/lib.rs | 57 ++++- buzz-agent-core/tests/bin/fake_mcp.rs | 301 +++++++++++++++++++++++ buzz-agent-core/tests/stop_hook.rs | 337 ++++++++++++++++++++++++++ 7 files changed, 966 insertions(+), 16 deletions(-) create mode 100644 buzz-agent-core/src/hooks.rs create mode 100644 buzz-agent-core/tests/bin/fake_mcp.rs create mode 100644 buzz-agent-core/tests/stop_hook.rs diff --git a/buzz-agent-core/Cargo.lock b/buzz-agent-core/Cargo.lock index eeb3a8f8ba..a4d07d7b4f 100644 --- a/buzz-agent-core/Cargo.lock +++ b/buzz-agent-core/Cargo.lock @@ -750,6 +750,7 @@ dependencies = [ "futures", "goose", "goose-provider-types", + "rmcp", "serde", "serde_json", "tempfile", diff --git a/buzz-agent-core/Cargo.toml b/buzz-agent-core/Cargo.toml index fdfc62bc92..8e32304b42 100644 --- a/buzz-agent-core/Cargo.toml +++ b/buzz-agent-core/Cargo.toml @@ -35,6 +35,12 @@ path = "src/lib.rs" name = "buzz-agent-core" path = "src/main.rs" +# Test-only fake MCP server, copied from crates/buzz-agent. Built +# unconditionally because cargo can't gate bins on `cfg(test)`. +[[bin]] +name = "fake-mcp" +path = "tests/bin/fake_mcp.rs" + [dependencies] # Pinned to a release commit deliberately: goose's public `Agent` surface is # not semver-stable (many fields are `pub(super)`) and its MSRV moves quickly. @@ -57,6 +63,7 @@ serde_json = "1" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +rmcp = { version = "1.8", default-features = false, features = ["schemars"] } [dev-dependencies] tempfile = "3" diff --git a/buzz-agent-core/src/agent.rs b/buzz-agent-core/src/agent.rs index a1732aad23..8a29a5eced 100644 --- a/buzz-agent-core/src/agent.rs +++ b/buzz-agent-core/src/agent.rs @@ -86,6 +86,10 @@ pub fn prompt_to_text(blocks: &[ContentBlock]) -> String { /// responsible for emitting `usage_update` *before* the `session/prompt` /// response — that ordering is load-bearing for kind-44200 metrics /// (`buzz-agent/src/lib.rs:701-706`). +/// +/// `hook_extension` names the MCP extension carrying `_Stop`/`_PostCompact` +/// (buzz-dev-mcp). When set, the turn is not allowed to end while `_Stop` +/// objects — see [`crate::hooks`]. #[allow(clippy::too_many_arguments)] pub async fn run_turn( agent: Arc, @@ -94,10 +98,92 @@ pub async fn run_turn( max_rounds: Option, wire_tx: &WireSender, cancel: CancellationToken, + hook_extension: Option<&str>, ) -> (Result, TurnTokens) { - let text = prompt_to_text(&prompt); let mut tokens = TurnTokens::default(); + let mut next_message = Message::user().with_text(prompt_to_text(&prompt)); + let mut stop_blocks: u32 = 0; + + // Outer loop exists solely for the `_Stop` veto: goose's `reply()` stream + // ends when the model stops, so continuing means re-entering `reply()` + // with the objection appended. Goose's own Stop hook does the equivalent + // internally (`agent.rs:2891-2917`); we do it here because its + // `hook_manager` is private. + loop { + let stop = match drive_stream( + &agent, + session_id, + next_message, + max_rounds, + wire_tx, + &cancel, + &mut tokens, + hook_extension, + ) + .await + { + Ok(s) => s, + Err(e) => return (Err(e), tokens), + }; + + // Only a clean end-of-turn is vetoable. Cancellation and refusals pass + // through untouched. + if !matches!(stop, StopReason::EndTurn) || cancel.is_cancelled() { + return (Ok(stop), tokens); + } + + let Some(extension) = hook_extension else { + return (Ok(stop), tokens); + }; + if stop_blocks >= crate::hooks::MAX_STOP_BLOCKS { + tracing::warn!( + blocks = stop_blocks, + "_Stop veto cap reached; ending turn anyway" + ); + return (Ok(stop), tokens); + } + + let Some(session) = current_session(session_id).await else { + return (Ok(stop), tokens); + }; + let Some(objection) = crate::hooks::stop_objection(&agent, &session, extension).await + else { + return (Ok(stop), tokens); + }; + + stop_blocks += 1; + tracing::info!(blocks = stop_blocks, "_Stop hook vetoed end of turn"); + + // Agent-visible, user-invisible — the objection steers the model + // without appearing in the channel, matching both buzz-agent and + // goose's own Deny handling. + next_message = Message::user() + .with_text(format!("[Stop] {objection}")) + .with_visibility(false, true); + } +} + +/// Look up the goose `Session` record needed to dispatch a hook tool. +async fn current_session(session_id: &str) -> Option { + goose::session::session_manager::SessionManager::instance() + .get_session(session_id, false) + .await + .ok() +} + +/// Drive a single `reply()` stream to completion. +#[allow(clippy::too_many_arguments)] +async fn drive_stream( + agent: &Arc, + session_id: &str, + message: Message, + max_rounds: Option, + wire_tx: &WireSender, + cancel: &CancellationToken, + tokens: &mut TurnTokens, + hook_extension: Option<&str>, +) -> Result { let session_config = SessionConfig { id: session_id.to_string(), schedule_id: None, @@ -106,15 +192,11 @@ pub async fn run_turn( }; let mut stream = match agent - .reply( - Message::user().with_text(text), - session_config, - Some(cancel.clone()), - ) + .reply(message, session_config, Some(cancel.clone())) .await { Ok(s) => s, - Err(e) => return (Err(AgentError::Llm(e.to_string())), tokens), + Err(e) => return Err(AgentError::Llm(e.to_string())), }; let mut keepalive = tokio::time::interval(KEEPALIVE_INTERVAL); @@ -122,6 +204,7 @@ pub async fn run_turn( keepalive.tick().await; // first tick is immediate; discard it let mut stop = StopReason::EndTurn; + let mut compacted = false; loop { tokio::select! { @@ -143,7 +226,9 @@ pub async fn run_turn( let Some(event) = next else { break }; match event { Ok(ev) => { - if let Some(reason) = handle_event(ev, session_id, wire_tx, &mut tokens).await { + if let Some(reason) = + handle_event(ev, session_id, wire_tx, tokens, &mut compacted).await + { stop = reason; break; } @@ -159,18 +244,41 @@ pub async fn run_turn( } else { AgentError::Llm(msg) }; - return (Err(err), tokens); + return Err(err); } } } } } + // Goose compacted history mid-turn. Re-inject the todo list so it survives + // the truncation — this is what buzz-agent's `_PostCompact` existed for + // (`handoff.rs:73-81`). Steering is the right channel: goose drains it at + // the next round boundary (`agent.rs:1951-1974`). + if compacted && !cancel.is_cancelled() { + if let Some(extension) = hook_extension { + if let Some(session) = current_session(session_id).await { + if let Some(state) = + crate::hooks::post_compact_state(agent, &session, extension).await + { + agent + .steer( + session_id, + Message::user() + .with_text(format!("[PostCompact] {state}")) + .with_visibility(false, true), + ) + .await; + } + } + } + } + if cancel.is_cancelled() { stop = StopReason::Cancelled; } - (Ok(stop), tokens) + Ok(stop) } /// Translate one `AgentEvent` into ACP notifications. @@ -181,6 +289,7 @@ async fn handle_event( session_id: &str, wire_tx: &WireSender, tokens: &mut TurnTokens, + compacted: &mut bool, ) -> Option { match event { AgentEvent::Message(msg) => { @@ -206,10 +315,12 @@ async fn handle_event( } // Compaction happened. buzz-agent surfaced this as a `[Context - // Handoff]` history rewrite; here Goose has already done it and we - // only need to note it. + // Handoff]` history rewrite; Goose has already done the rewrite, so we + // only flag it — the caller then asks `_PostCompact` for state to + // re-inject once the stream settles. AgentEvent::HistoryReplaced(_) => { tracing::info!(target: "buzz_agent::compaction", "history compacted"); + *compacted = true; None } diff --git a/buzz-agent-core/src/hooks.rs b/buzz-agent-core/src/hooks.rs new file mode 100644 index 0000000000..6fdb8db5be --- /dev/null +++ b/buzz-agent-core/src/hooks.rs @@ -0,0 +1,144 @@ +//! buzz-agent's `_Stop` / `_PostCompact` lifecycle hooks, driven from our own +//! turn loop rather than from goose's. +//! +//! # Why not goose's hook system +//! +//! Goose has a blocking Stop hook with exactly buzz-agent's semantics +//! (`agent.rs:2891-2917`): on `HookDecision::Deny` the objection is pushed +//! into the conversation as an agent-visible / user-invisible message and the +//! loop continues, bounded by a block cap. But it is unreachable for us: +//! +//! * `Agent::hook_manager` is private, and the only setter is +//! `#[cfg(test)]`-gated (`agent.rs:430`). +//! * Hooks are otherwise discovered as *subprocesses* from +//! `/.goose/plugins/*/hooks/hooks.json` (`hooks/mod.rs:238-253`). +//! +//! The subprocess route looked viable until you notice where the answer lives: +//! `buzz-dev-mcp`'s todo list is **in-process state** (`todo.rs:49`, a +//! `Mutex>`), and that process's stdio is owned by goose. A separate +//! hook binary spawned by goose cannot see it. Writing a `hooks.json` would +//! give us a hook that always answers "no objection" — worse than no hook, +//! because it would look like it worked. +//! +//! # What we do instead +//! +//! We own the outer loop, so we ask the tool ourselves. +//! `Agent::dispatch_tool_call` is public (`agent.rs:1059`), so between rounds +//! we call `_Stop` on the same extension the model uses, and if it objects we +//! feed the objection back via `Agent::steer` — which goose drains at the +//! round boundary (`agent.rs:1951-1974`) and which also prevents the turn from +//! ending while a steer is pending (`agent.rs:2876-2878`). +//! +//! Net effect is buzz-agent's behaviour, with no goose changes: the agent does +//! not get to stop while its todo list has open items. + +use std::sync::Arc; + +use goose::agents::Agent; +use goose::session::session_manager::Session; +use rmcp::model::CallToolRequestParams; + +/// Hard cap on consecutive vetoes, mirroring goose's own `stop_hook_block_cap` +/// (`agent.rs:108-119`). A tool that always objects must not trap the turn. +pub const MAX_STOP_BLOCKS: u32 = 3; + +/// Tools are advertised to the model prefixed with their extension name +/// (`extension_manager.rs:1425`), and dispatch resolves the same prefixed +/// form. `_Stop` is hidden from the model but still callable by us. +fn qualified(extension: &str, tool: &str) -> String { + format!("{extension}__{tool}") +} + +/// Ask `_Stop` whether the turn may end. +/// +/// Returns `Some(objection)` when the agent should keep working. Any failure — +/// no such extension, tool error, malformed result — is `None`. A broken hook +/// must never trap a turn; that is also goose's rule for its own hooks +/// ("a misbehaving hook MUST NOT block", `hooks/mod.rs:361`). +pub async fn stop_objection( + agent: &Arc, + session: &Session, + extension: &str, +) -> Option { + let text = call_hook(agent, session, extension, "_Stop").await?; + let trimmed = text.trim(); + // Empty response is the documented "no objection" signal + // (`buzz-dev-mcp/src/todo.rs:100`). + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +/// Ask `_PostCompact` for state to re-inject after goose compacted history. +/// +/// buzz-agent called this so the todo list survives truncation; goose emits +/// `AgentEvent::HistoryReplaced` when it compacts, which is our trigger. +pub async fn post_compact_state( + agent: &Arc, + session: &Session, + extension: &str, +) -> Option { + let text = call_hook(agent, session, extension, "_PostCompact").await?; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +/// Dispatch a zero-argument hook tool and flatten its result to text. +async fn call_hook( + agent: &Arc, + session: &Session, + extension: &str, + tool: &str, +) -> Option { + let params = CallToolRequestParams::new(qualified(extension, tool)) + .with_arguments(serde_json::Map::new()); + + let (_id, result) = agent + .dispatch_tool_call( + params, + format!("buzz_hook_{tool}"), + // Deliberately not cancellable: this runs at a turn boundary and + // must resolve either way. + None, + session, + ) + .await; + + let call = match result { + Ok(c) => c, + Err(e) => { + tracing::debug!(tool, error = %e, "hook tool unavailable"); + return None; + } + }; + + let out = match call.result.await { + Ok(o) => o, + Err(e) => { + tracing::debug!(tool, error = ?e, "hook tool failed"); + return None; + } + }; + + if out.is_error.unwrap_or(false) { + tracing::debug!(tool, "hook tool returned is_error"); + return None; + } + + let mut text = String::new(); + for content in out.content.iter() { + if let Some(raw) = content.as_text() { + text.push_str(&raw.text); + } + } + Some(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qualifies_tool_names_with_the_extension_prefix() { + // Must match extension_manager.rs:1425 or dispatch cannot resolve it. + assert_eq!(qualified("developer", "_Stop"), "developer___Stop"); + } +} diff --git a/buzz-agent-core/src/lib.rs b/buzz-agent-core/src/lib.rs index bb8b94c2d9..fbad500847 100644 --- a/buzz-agent-core/src/lib.rs +++ b/buzz-agent-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod agent; pub mod config; +pub mod hooks; pub mod types; pub mod wire; @@ -61,6 +62,9 @@ struct Session { /// the override takes effect "from the next prompt" and never mutates a /// turn already in flight (`buzz-agent/src/lib.rs:494-502`). pending_model: Option, + /// Name of the MCP extension carrying `_Stop`/`_PostCompact`, if any. + /// See [`crate::hooks`] for why we dispatch these ourselves. + hook_extension: Option, accumulated_input_tokens: u64, accumulated_output_tokens: u64, } @@ -82,7 +86,7 @@ async fn build_agent( cwd: &str, system_prompt: Option<&str>, mcp_servers: &[McpServerStdio], -) -> Result<(Arc, String), AgentError> { +) -> Result<(Arc, String, Option), AgentError> { let session_manager = Arc::new(SessionManager::instance()); let permission_manager = PermissionManager::instance(); @@ -174,7 +178,49 @@ async fn build_agent( } } - Ok((Arc::new(agent), session.id)) + let agent = Arc::new(agent); + + // Find the extension carrying `_Stop`, by asking rather than assuming the + // name — `buzz-acp` derives it from the MCP binary's file stem + // (`buzz-acp/src/lib.rs:4145-4149`), so it is not a fixed string. + // + // KNOWN DEVIATION: buzz-agent hid `_`-prefixed tools from the model + // (`agent.rs:328-336`) while still calling them itself. Goose's + // `available_tools` allowlist gates advertising *and* dispatch through the + // same cache (`extension_manager.rs:1421`, `:1698`), so hiding them would + // also make them undispatchable — which would break the veto. They stay + // visible; `hook_tool_guidance()` tells the model to leave them alone. + let mut hook_extension = None; + for tool in agent.list_tools(&session.id, None).await { + if tool.name.ends_with("___Stop") { + hook_extension = tool + .name + .strip_suffix("___Stop") + .map(|prefix| prefix.to_string()); + break; + } + } + + if let Some(ext) = &hook_extension { + agent + .extend_system_prompt("buzz_hook_tools".to_string(), hook_tool_guidance()) + .await; + tracing::info!(extension = %ext, "lifecycle hooks available"); + } + + Ok((agent, session.id, hook_extension)) +} + +/// Keep the model's hands off the lifecycle hooks. +/// +/// They are ordinary MCP tools on the wire, so a generic harness advertises +/// them. buzz-agent solved this by hiding them; we cannot (see `build_agent`), +/// so we ask instead. +fn hook_tool_guidance() -> String { + "Tools whose names begin with an underscore (`_Stop`, `_PostCompact`) are \ + lifecycle hooks invoked automatically by the runtime. Never call them \ + yourself. Use the `todo` tool to manage your task list." + .to_string() } pub async fn serve(cfg: Config) -> anyhow::Result<()> { @@ -350,7 +396,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen .await; } - let (agent, goose_session_id) = + let (agent, goose_session_id, hook_extension) = match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { Ok(v) => v, Err(e) => { @@ -379,6 +425,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen cancel: None, model_override: None, pending_model: None, + hook_extension, accumulated_input_tokens: 0, accumulated_output_tokens: 0, }, @@ -472,7 +519,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire let cancel = CancellationToken::new(); // Single-flight per session, and capture the agent handle. - let (agent, goose_session_id, pending_model) = { + let (agent, goose_session_id, pending_model, hook_extension) = { let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { return wire::send( @@ -501,6 +548,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire s.agent.clone(), s.goose_session_id.clone(), s.pending_model.take(), + s.hook_extension.clone(), ) }; @@ -538,6 +586,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire app.cfg.max_rounds, wire_tx, cancel, + hook_extension.as_deref(), ) .await; diff --git a/buzz-agent-core/tests/bin/fake_mcp.rs b/buzz-agent-core/tests/bin/fake_mcp.rs new file mode 100644 index 0000000000..0bbd1d3478 --- /dev/null +++ b/buzz-agent-core/tests/bin/fake_mcp.rs @@ -0,0 +1,301 @@ +//! Tiny fake MCP server for integration tests. +//! +//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by +//! environment variables so tests can simulate misbehavior: +//! +//! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout) +//! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout) +//! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1) +//! FAKE_MCP_HUGE_DESC=1 — every tool description is 100 KB +//! FAKE_MCP_DESC_SIZE=N — every tool description is N bytes (overrides HUGE_DESC) +//! FAKE_MCP_TOOL_DELAY=N — `tools/call` sleeps N seconds before replying +//! (use a large value, e.g. 999, to simulate hang) +//! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result +//! (default: the literal "ok"); grows history +//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup +//! (for tests that want to verify the child died) +//! FAKE_MCP_SPAWN_GRANDCHILD=1 +//! — on `tools/call`, spawn a `sleep 999` +//! grandchild before hanging. Its PID is +//! written to FAKE_MCP_GRANDCHILD_PID_FILE +//! so a test can verify the entire process +//! tree dies on timeout. +//! FAKE_MCP_GRANDCHILD_PID_FILE=path +//! — path to write the grandchild PID to. +//! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool +//! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") +//! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying +//! (use a large value to simulate hang/timeout) +//! FAKE_MCP_STOP_COUNT=N — `_Stop` returns STOP_TEXT for the first N +//! invocations; empty string thereafter. If +//! unset, every call returns STOP_TEXT. +//! FAKE_MCP_POSTCOMPACT_HOOK=1 +//! — expose a `_PostCompact` hook tool +//! FAKE_MCP_POSTCOMPACT_TEXT=text +//! — `_PostCompact` returns this (default: "") + +use std::io::{BufRead, Write}; + +use serde_json::{json, Value}; + +fn env_flag(k: &str) -> bool { + std::env::var(k).map(|v| v != "0").unwrap_or(false) +} + +fn env_usize(k: &str, default: usize) -> usize { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn env_u64(k: &str, default: u64) -> u64 { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn write_response(id: Value, result: Value) { + let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result }); + let mut s = serde_json::to_string(&msg).expect("serialize"); + s.push('\n'); + let mut out = std::io::stdout().lock(); + out.write_all(s.as_bytes()).expect("write"); + out.flush().expect("flush"); +} + +fn hang_forever() -> ! { + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } +} + +fn make_tools( + count: usize, + desc: &str, + include_stop_hook: bool, + include_post_compact_hook: bool, +) -> Vec { + let mut tools: Vec = (0..count) + .map(|i| { + json!({ + "name": format!("tool_{i}"), + "description": desc, + "inputSchema": { "type": "object", "properties": {} }, + }) + }) + .collect(); + if include_stop_hook { + tools.push(json!({ + "name": "_Stop", + "description": "stop hook", + "inputSchema": { "type": "object", "properties": {} }, + })); + } + if include_post_compact_hook { + tools.push(json!({ + "name": "_PostCompact", + "description": "post compact hook", + "inputSchema": { "type": "object", "properties": {} }, + })); + } + tools +} + +fn main() { + // Optional: write our own PID so a test can later check the process is gone. + if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") { + let pid = std::process::id().to_string(); + let _ = std::fs::write(&path, pid); + } + + let hang_init = env_flag("FAKE_MCP_HANG_INIT"); + let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS"); + let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1); + // FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set. + let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + { + "x".repeat(n) + } else if env_flag("FAKE_MCP_HUGE_DESC") { + "x".repeat(100_000) + } else { + "fake tool".to_owned() + }; + let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0); + // Tool-call result text size in bytes (default: the literal "ok"). Lets a + // test grow session history by a controlled amount via a tool result. + let result_size = env_u64("FAKE_MCP_RESULT_SIZE", 0) as usize; + let stop_hook = env_flag("FAKE_MCP_STOP_HOOK"); + let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned()); + let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0); + // 0 means "unset" → unlimited; any positive value caps the number of + // calls that return STOP_TEXT before flipping to empty string. + let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); + let mut stop_calls_seen: usize = 0; + let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); + let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); + + // Use a channel-based stdin reader so notifications (which carry no id) + // are captured even while the main thread is sleeping during a tool call. + let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok(); + let (tx, rx) = std::sync::mpsc::channel::<(Value, Option)>(); + let cancel_log_for_thread = cancel_log_path.clone(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + let lines = stdin.lock().lines(); + for line in lines { + let line = match line { + Ok(l) => l, + Err(_) => return, + }; + if line.trim().is_empty() { + continue; + } + let msg: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = msg.get("id").cloned(); + // Notifications carry no id. Log cancellations if configured. + if id.is_none() || id == Some(Value::Null) { + if method == "notifications/cancelled" { + if let Some(ref path) = cancel_log_for_thread { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(f, "{}", line.trim()); + } + } + } + continue; + } + // Send requests (with id) to the main processing loop. + let _ = tx.send((msg, id)); + } + }); + + while let Ok((msg, id_opt)) = rx.recv() { + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = id_opt.unwrap_or(Value::Null); + + match method { + "initialize" => { + if hang_init { + hang_forever(); + } + write_response( + id, + json!({ + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "fake-mcp", "version": "0.0.0" }, + }), + ); + } + "tools/list" => { + if hang_tools { + hang_forever(); + } + write_response( + id, + json!({ + "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) + }), + ); + } + "tools/call" => { + // Signal that the request was received (for tests that + // need to wait until the call is in-flight before cancelling). + // Write the request id so tests can correlate with cancel. + if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") { + let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into()); + let _ = std::fs::write(&path, id_str); + } + let called_name = msg + .get("params") + .and_then(|p| p.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + // Optionally spawn a long-sleeping grandchild so the test + // can verify process-group killing reaches the whole tree. + if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") { + let child = std::process::Command::new("sleep") + .arg("999") + .spawn() + .expect("spawn grandchild"); + if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") { + let _ = std::fs::write(&path, child.id().to_string()); + } + std::mem::forget(child); + } + if called_name == "_Stop" { + if stop_delay_secs > 0 { + std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs)); + } + // Once we exceed the configured count, return empty + // text so the agent treats it as no objection. This + // lets a test exercise the "objected then cleared" + // path without relying on the rejection budget. + let payload = if stop_calls_seen < stop_count_limit { + stop_text.clone() + } else { + String::new() + }; + stop_calls_seen = stop_calls_seen.saturating_add(1); + write_response( + id, + json!({ + "content": [{ "type": "text", "text": payload }], + "isError": false, + }), + ); + continue; + } + if called_name == "_PostCompact" { + write_response( + id, + json!({ + "content": [{ "type": "text", "text": post_compact_text }], + "isError": false, + }), + ); + continue; + } + if tool_delay_secs > 0 { + std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs)); + } + let result_text = if result_size > 0 { + "x".repeat(result_size) + } else { + "ok".to_owned() + }; + write_response( + id, + json!({ + "content": [{ "type": "text", "text": result_text }], + "isError": false, + }), + ); + } + _ => { + // Unknown method: respond with an error so rmcp doesn't hang. + let err = json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32601, "message": format!("method not found: {method}") }, + }); + let mut s = serde_json::to_string(&err).unwrap(); + s.push('\n'); + let mut out = std::io::stdout().lock(); + let _ = out.write_all(s.as_bytes()); + let _ = out.flush(); + } + } + } +} diff --git a/buzz-agent-core/tests/stop_hook.rs b/buzz-agent-core/tests/stop_hook.rs new file mode 100644 index 0000000000..42b56b7d9f --- /dev/null +++ b/buzz-agent-core/tests/stop_hook.rs @@ -0,0 +1,337 @@ +//! Proves the `_Stop` veto: the turn must not end while the hook objects. +//! +//! This is buzz-agent's most load-bearing non-standard behaviour — it is why +//! the agent finishes its todo list instead of stopping halfway. Goose has the +//! same mechanism internally, but its `hook_manager` is private and its hooks +//! are subprocesses that cannot see buzz-dev-mcp's in-process todo state, so +//! we drive it from our own loop (see `src/hooks.rs`). +//! +//! Setup: a fake MCP server exposing `_Stop`, plus a fake provider that counts +//! how many times it is asked to generate. One user prompt with a hook that +//! objects twice must produce three provider calls, not one. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Fake OpenAI-compatible provider that always answers "done" and counts calls. +fn spawn_counting_provider() -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let calls = Arc::new(AtomicUsize::new(0)); + let counter = calls.clone(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + if request_line.contains("/models") { + let payload = json!({ "object": "list", "data": [] }).to_string(); + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), payload + ).as_bytes(), + ); + let _ = stream.flush(); + continue; + } + + counter.fetch_add(1, Ordering::SeqCst); + + let chunk = |delta: Value, finish: Value, usage: Value| { + let mut c = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": delta, "finish_reason": finish }], + }); + if !usage.is_null() { + c["usage"] = usage; + } + format!("data: {c}\n\n") + }; + let sse = format!( + "{}{}data: [DONE]\n\n", + chunk( + json!({ "role": "assistant", "content": "done" }), + Value::Null, + Value::Null + ), + chunk( + json!({}), + json!("stop"), + json!({ "prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7 }) + ), + ); + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + sse.len(), sse + ).as_bytes(), + ); + let _ = stream.flush(); + } + }); + + (format!("http://{addr}"), calls) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + writeln!( + self.stdin, + "{}", + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + id + } + + fn await_response(&mut self, id: i64) -> Value { + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return msg; + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Declare the fake MCP server, with `_Stop` objecting `objections` times. +fn mcp_servers(objections: usize) -> Value { + json!([{ + "name": "devtools", + "command": env!("CARGO_BIN_EXE_fake-mcp"), + "args": [], + "env": [ + { "name": "FAKE_MCP_STOP_HOOK", "value": "1" }, + { "name": "FAKE_MCP_STOP_TEXT", "value": "1 item still open" }, + { "name": "FAKE_MCP_STOP_COUNT", "value": objections.to_string() }, + ], + }]) +} + +fn new_session(h: &mut Harness, cwd: &std::path::Path, objections: usize) -> String { + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ + "cwd": cwd.to_str().unwrap(), + "mcpServers": mcp_servers(objections), + }), + ); + let resp = h.await_response(id); + resp["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {resp}")) + .to_string() +} + +#[test] +fn stop_hook_veto_extends_the_turn() { + let (base_url, calls) = spawn_counting_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + // Object twice, then allow. + let session_id = new_session(&mut h, cwd.path(), 2); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "do the work" }], + }), + ); + let resp = h.await_response(id); + + assert_eq!( + resp["result"]["stopReason"], "end_turn", + "turn should still end cleanly once the hook stops objecting: {resp}" + ); + + // The model says "done" immediately, so without the veto this is 1. + // Two objections must force two more generations. + let n = calls.load(Ordering::SeqCst); + assert_eq!( + n, 3, + "expected 3 provider calls (initial + 2 vetoes), got {n} — the _Stop veto did not fire" + ); +} + +#[test] +fn stop_hook_veto_is_capped() { + let (base_url, calls) = spawn_counting_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + // Always objects. Must not trap the turn forever. + let session_id = new_session(&mut h, cwd.path(), usize::MAX); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "do the work" }], + }), + ); + let resp = h.await_response(id); + + assert_eq!( + resp["result"]["stopReason"], "end_turn", + "a permanently objecting hook must not hang the turn: {resp}" + ); + + // MAX_STOP_BLOCKS = 3, so: initial + 3 vetoes = 4, then give up. + let n = calls.load(Ordering::SeqCst); + assert_eq!( + n, 4, + "expected the veto to stop at MAX_STOP_BLOCKS (1 + 3 = 4 calls), got {n}" + ); +} + +#[test] +fn no_stop_hook_means_no_extra_rounds() { + let (base_url, calls) = spawn_counting_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + // No MCP servers at all: nothing to veto with. + let id = h.request( + "session/new", + json!({ "cwd": cwd.path().to_str().unwrap(), "mcpServers": [] }), + ); + let resp = h.await_response(id); + let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string(); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "hi" }], + }), + ); + let resp = h.await_response(id); + assert_eq!(resp["result"]["stopReason"], "end_turn"); + + let n = calls.load(Ordering::SeqCst); + assert_eq!(n, 1, "no hook should mean exactly one generation, got {n}"); +} + +#[test] +fn stop_hook_tools_are_discovered_by_suffix() { + // The extension name comes from buzz-acp's MCP binary file stem + // (`buzz-acp/src/lib.rs:4145`), so discovery must not hardcode a name. + // Here the server is called "devtools", not "developer" or "buzz-dev-mcp". + let (base_url, calls) = spawn_counting_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let session_id = new_session(&mut h, cwd.path(), 1); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "go" }], + }), + ); + let resp = h.await_response(id); + assert_eq!(resp["result"]["stopReason"], "end_turn"); + + let n = calls.load(Ordering::SeqCst); + assert_eq!( + n, 2, + "one objection under a non-obvious extension name should yield 2 calls, got {n}" + ); + + let _ = Duration::from_secs(0); +} From 22f5e70cf20fa569cb238fc0147a45f36aae4d40 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:09:57 +1000 Subject: [PATCH 05/20] feat(buzz-agent-core): inject [Reflect] on failed tool results Last known behavioural gap. buzz-agent appended a reflection nudge to every failed tool result (agent.rs:21-22, :364) so the model diagnoses the failure instead of blindly retrying. Goose gives no interception point for that: PostToolUseFailure is fire-and-forget and its output is discarded (agent.rs:589-620). So we deliver the same text via steer(), which goose drains at the round boundary (agent.rs:1951-1974) -- exactly when the model would next act on the failed result. Agent-visible, user-invisible. Capped at 8 per turn so a tool failing in a loop cannot flood the conversation. Tested against the real provider wire rather than trusting that steer() was called: the fake provider records every chat-completions body, and the test asserts [Reflect] is absent from the first generation and present in a later one. Negative test confirms a successful tool call injects nothing. Adds FAKE_MCP_TOOL_ERROR to the fake MCP server. 23 tests green. Signed-off-by: Michael Neale --- buzz-agent-core/src/agent.rs | 50 +++- buzz-agent-core/tests/bin/fake_mcp.rs | 8 +- buzz-agent-core/tests/reflect.rs | 354 ++++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 buzz-agent-core/tests/reflect.rs diff --git a/buzz-agent-core/src/agent.rs b/buzz-agent-core/src/agent.rs index 8a29a5eced..82817cd1a0 100644 --- a/buzz-agent-core/src/agent.rs +++ b/buzz-agent-core/src/agent.rs @@ -44,6 +44,22 @@ use crate::wire::{self, WireSender}; /// stays. const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +/// Appended by buzz-agent to every failed tool result (`agent.rs:21-22`) so +/// the model diagnoses a failure instead of blindly retrying it. +/// +/// buzz-agent mutated the tool result itself. Goose gives no interception +/// point for that: its `PostToolUseFailure` hook is fire-and-forget and its +/// output is discarded (`agent.rs:589-620`). So we deliver the same text as a +/// steer instead — goose drains pending steers at the round boundary +/// (`agent.rs:1951-1974`), which is exactly when the model would next act on +/// the failed result. Agent-visible, user-invisible. +const ERROR_REFLECTION: &str = + "[Reflect] Before retrying, identify the cause and change your approach."; + +/// Cap on reflections per turn, so a tool failing in a loop cannot flood the +/// conversation. +const MAX_REFLECTIONS: usize = 8; + /// Per-turn token accounting, mirroring buzz-agent's contract. #[derive(Debug, Default, Clone, Copy)] pub struct TurnTokens { @@ -205,6 +221,7 @@ async fn drive_stream( let mut stop = StopReason::EndTurn; let mut compacted = false; + let mut reflections = 0usize; loop { tokio::select! { @@ -226,8 +243,16 @@ async fn drive_stream( let Some(event) = next else { break }; match event { Ok(ev) => { - if let Some(reason) = - handle_event(ev, session_id, wire_tx, tokens, &mut compacted).await + if let Some(reason) = handle_event( + ev, + agent, + session_id, + wire_tx, + tokens, + &mut compacted, + &mut reflections, + ) + .await { stop = reason; break; @@ -284,17 +309,38 @@ async fn drive_stream( /// Translate one `AgentEvent` into ACP notifications. /// /// Returns `Some(StopReason)` if the event terminates the turn. +#[allow(clippy::too_many_arguments)] async fn handle_event( event: AgentEvent, + agent: &Arc, session_id: &str, wire_tx: &WireSender, tokens: &mut TurnTokens, compacted: &mut bool, + reflections: &mut usize, ) -> Option { match event { AgentEvent::Message(msg) => { for content in &msg.content { emit_content(content, session_id, wire_tx).await; + + if let MessageContent::ToolResponse(resp) = content { + let failed = match &resp.tool_result { + Ok(r) => r.is_error.unwrap_or(false), + Err(_) => true, + }; + if failed && *reflections < MAX_REFLECTIONS { + *reflections += 1; + agent + .steer( + session_id, + Message::user() + .with_text(ERROR_REFLECTION) + .with_visibility(false, true), + ) + .await; + } + } } None } diff --git a/buzz-agent-core/tests/bin/fake_mcp.rs b/buzz-agent-core/tests/bin/fake_mcp.rs index 0bbd1d3478..0275fff619 100644 --- a/buzz-agent-core/tests/bin/fake_mcp.rs +++ b/buzz-agent-core/tests/bin/fake_mcp.rs @@ -22,6 +22,7 @@ //! tree dies on timeout. //! FAKE_MCP_GRANDCHILD_PID_FILE=path //! — path to write the grandchild PID to. +//! FAKE_MCP_TOOL_ERROR=1 — `tools/call` returns isError: true //! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool //! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") //! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying @@ -271,7 +272,10 @@ fn main() { if tool_delay_secs > 0 { std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs)); } - let result_text = if result_size > 0 { + let tool_error = env_flag("FAKE_MCP_TOOL_ERROR"); + let result_text = if tool_error { + "boom: the tool failed".to_owned() + } else if result_size > 0 { "x".repeat(result_size) } else { "ok".to_owned() @@ -280,7 +284,7 @@ fn main() { id, json!({ "content": [{ "type": "text", "text": result_text }], - "isError": false, + "isError": tool_error, }), ); } diff --git a/buzz-agent-core/tests/reflect.rs b/buzz-agent-core/tests/reflect.rs new file mode 100644 index 0000000000..0ddb40e779 --- /dev/null +++ b/buzz-agent-core/tests/reflect.rs @@ -0,0 +1,354 @@ +//! Proves `[Reflect]`: a failed tool result must nudge the model to diagnose +//! before retrying. +//! +//! buzz-agent appended the text to the tool result itself (`agent.rs:364`). +//! Goose gives no interception point for that — `PostToolUseFailure` is +//! fire-and-forget and its output is discarded (`agent.rs:589-620`) — so we +//! deliver the same text via `steer()`, which goose drains at the round +//! boundary. +//! +//! Observable consequence: the reflection must appear in the *next* request +//! the provider receives. That is what this asserts, rather than trusting that +//! `steer()` was called. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Fake provider: first generation emits a tool call, later ones answer "done". +/// Every request body is forwarded so the test can inspect what the model saw. +fn spawn_tool_calling_provider(tool_name: &str) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + let tool_name = tool_name.to_string(); + + std::thread::spawn(move || { + let mut generation = 0usize; + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + let respond = |stream: &mut std::net::TcpStream, ct: &str, payload: String| { + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ) + .as_bytes(), + ); + let _ = stream.flush(); + }; + + if request_line.contains("/models") { + respond( + &mut stream, + "application/json", + json!({ "object": "list", "data": [] }).to_string(), + ); + continue; + } + + if let Ok(req) = serde_json::from_slice::(&body) { + let _ = tx.send(req); + } + generation += 1; + + let usage = json!({ "prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7 }); + let sse = if generation == 1 { + // Ask for a tool the fake MCP server will fail. + let call = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": { "name": tool_name, "arguments": "{}" }, + }], + }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }], + "usage": usage, + }); + format!("data: {call}\n\ndata: {done}\n\ndata: [DONE]\n\n") + } else { + let text = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": "done" }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }], + "usage": usage, + }); + format!("data: {text}\n\ndata: {done}\n\ndata: [DONE]\n\n") + }; + + respond(&mut stream, "text/event-stream", sse); + } + }); + + (format!("http://{addr}"), rx) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + writeln!( + self.stdin, + "{}", + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + id + } + + fn await_response(&mut self, id: i64) -> (Value, Vec) { + let mut notifications = Vec::new(); + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return (msg, notifications); + } + if msg.get("method").is_some() { + notifications.push(msg); + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn failing_mcp() -> Value { + json!([{ + "name": "devtools", + "command": env!("CARGO_BIN_EXE_fake-mcp"), + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_ERROR", "value": "1" }], + }]) +} + +/// Concatenate every message body in a captured chat-completions request. +fn all_text(req: &Value) -> String { + req["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .map(|m| m.to_string()) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} + +#[test] +fn failed_tool_result_injects_a_reflection() { + let (base_url, requests) = spawn_tool_calling_provider("devtools__tool_0"); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ "cwd": cwd.path().to_str().unwrap(), "mcpServers": failing_mcp() }), + ); + let (resp, _) = h.await_response(id); + let session_id = resp["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {resp}")) + .to_string(); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "use the tool" }], + }), + ); + let (resp, notifications) = h.await_response(id); + assert!( + resp["result"]["stopReason"].is_string(), + "turn failed: {resp}" + ); + + // The tool must actually have been reported as failed on the wire, or the + // rest of this test proves nothing. + let failed_update = notifications.iter().any(|n| { + n["params"]["update"]["sessionUpdate"] == "tool_call_update" + && n["params"]["update"]["status"] == "failed" + }); + assert!( + failed_update, + "expected a failed tool_call_update; saw {:#?}", + notifications + .iter() + .map(|n| n["params"]["update"]["sessionUpdate"].clone()) + .collect::>() + ); + + // Collect what the provider saw. First request is the original prompt; + // the reflection must appear in a later one. + let mut seen = Vec::new(); + while let Ok(req) = requests.recv_timeout(Duration::from_secs(5)) { + seen.push(req); + } + assert!( + seen.len() >= 2, + "expected a follow-up generation after the tool call, got {}", + seen.len() + ); + + assert!( + !all_text(&seen[0]).contains("[Reflect]"), + "reflection must not appear before the tool has failed" + ); + + let later = seen[1..] + .iter() + .map(all_text) + .collect::>() + .join("\n"); + assert!( + later.contains("[Reflect]"), + "no [Reflect] nudge reached the model after a failed tool call.\nsaw:\n{later}" + ); +} + +#[test] +fn successful_tool_result_injects_nothing() { + let (base_url, requests) = spawn_tool_calling_provider("devtools__tool_0"); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + // Same flow, but the tool succeeds. + let servers = json!([{ + "name": "devtools", + "command": env!("CARGO_BIN_EXE_fake-mcp"), + "args": [], + "env": [], + }]); + let id = h.request( + "session/new", + json!({ "cwd": cwd.path().to_str().unwrap(), "mcpServers": servers }), + ); + let (resp, _) = h.await_response(id); + let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string(); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "use the tool" }], + }), + ); + let (resp, _) = h.await_response(id); + assert!(resp["result"]["stopReason"].is_string()); + + let mut seen = Vec::new(); + while let Ok(req) = requests.recv_timeout(Duration::from_secs(5)) { + seen.push(req); + } + let all = seen.iter().map(all_text).collect::>().join("\n"); + assert!( + !all.contains("[Reflect]"), + "a successful tool call must not trigger a reflection" + ); +} From 737bde425494a09681fb2d747059c43b56a4841b Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:12:29 +1000 Subject: [PATCH 06/20] test(buzz-agent-core): validate against the real buzz-dev-mcp Every other test in this crate drives `fake-mcp`, which answers whatever the test tells it to. That proves our plumbing, not that it matches the server buzz actually ships. These two drive the real binary: real tool names, real schemas, real in-process todo state, real _Stop/_PostCompact semantics. real_dev_mcp_stop_hook_blocks_end_of_turn scripts the exact scenario the veto exists for -- the model records an open todo item via the real `todo` tool, then tries to stop. Asserts the turn is extended and that buzz-dev-mcp's own objection text ("open todo items") reaches the model. real_dev_mcp_advertises_its_tools pins the shipped tool surface (shell, read_file, str_replace, todo) and locks in the KNOWN DEVIATION: _Stop stays visible to the model, with system-prompt guidance not to call it. Both skip cleanly if buzz-dev-mcp isn't built. 25 tests green, fmt + clippy -D warnings clean. Signed-off-by: Michael Neale --- buzz-agent-core/tests/real_dev_mcp.rs | 381 ++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 buzz-agent-core/tests/real_dev_mcp.rs diff --git a/buzz-agent-core/tests/real_dev_mcp.rs b/buzz-agent-core/tests/real_dev_mcp.rs new file mode 100644 index 0000000000..734145b847 --- /dev/null +++ b/buzz-agent-core/tests/real_dev_mcp.rs @@ -0,0 +1,381 @@ +//! End-to-end against the **real** `buzz-dev-mcp`, not the fake. +//! +//! Everything else in this crate's test suite uses `fake-mcp`, which answers +//! whatever the test tells it to. That proves our plumbing but not that it +//! matches the server buzz actually ships. This test drives the real binary: +//! real tool names, real schemas, real in-process todo state, real +//! `_Stop`/`_PostCompact` semantics. +//! +//! The scenario is the one buzz-agent's `_Stop` veto exists for: the model +//! records an open todo item and then tries to stop. It must not be allowed +//! to. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Locate the real `buzz-dev-mcp` binary in the workspace target dir. +/// +/// It lives in the outer workspace, which this crate is deliberately excluded +/// from, so `CARGO_BIN_EXE_` is unavailable and we resolve it by path. +fn buzz_dev_mcp() -> Option { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent()? + .to_path_buf(); + for profile in ["debug", "release"] { + let candidate = root.join("target").join(profile).join("buzz-dev-mcp"); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Provider that drives a scripted conversation: +/// generation 1 → call `todo` to record one open item +/// generation 2+ → plain text (i.e. "I'm done") +/// +/// Returns every request body so the test can see what the model was shown. +fn spawn_scripted_provider() -> (String, mpsc::Receiver, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + let calls = Arc::new(AtomicUsize::new(0)); + let counter = calls.clone(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + let respond = |stream: &mut std::net::TcpStream, ct: &str, payload: String| { + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ) + .as_bytes(), + ); + let _ = stream.flush(); + }; + + if request_line.contains("/models") { + respond( + &mut stream, + "application/json", + json!({ "object": "list", "data": [] }).to_string(), + ); + continue; + } + + if let Ok(req) = serde_json::from_slice::(&body) { + let _ = tx.send(req); + } + let n = counter.fetch_add(1, Ordering::SeqCst) + 1; + + let usage = json!({ "prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7 }); + let sse = if n == 1 { + // Record one OPEN todo item via the real `todo` tool. + let args = + json!({ "todos": [{ "text": "finish the work", "done": false }] }).to_string(); + let call = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": { "name": "devtools__todo", "arguments": args }, + }], + }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }], + "usage": usage, + }); + format!("data: {call}\n\ndata: {done}\n\ndata: [DONE]\n\n") + } else { + let text = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": "All finished!" }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }], + "usage": usage, + }); + format!("data: {text}\n\ndata: {done}\n\ndata: [DONE]\n\n") + }; + + respond(&mut stream, "text/event-stream", sse); + } + }); + + (format!("http://{addr}"), rx, calls) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + writeln!( + self.stdin, + "{}", + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + id + } + + fn await_response(&mut self, id: i64) -> (Value, Vec) { + let mut notifications = Vec::new(); + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return (msg, notifications); + } + if msg.get("method").is_some() { + notifications.push(msg); + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn real_dev_mcp_stop_hook_blocks_end_of_turn() { + let Some(mcp) = buzz_dev_mcp() else { + eprintln!("skipping: buzz-dev-mcp not built (run `cargo build -p buzz-dev-mcp`)"); + return; + }; + + let (base_url, requests, calls) = spawn_scripted_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ + "cwd": cwd.path().to_str().unwrap(), + "mcpServers": [{ + "name": "devtools", + "command": mcp.to_str().unwrap(), + "args": [], + "env": [], + }], + }), + ); + let (resp, _) = h.await_response(id); + let session_id = resp["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed against real buzz-dev-mcp: {resp}")) + .to_string(); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "do the work" }], + }), + ); + let (resp, notifications) = h.await_response(id); + assert_eq!( + resp["result"]["stopReason"], "end_turn", + "turn should end cleanly once the veto budget is spent: {resp}" + ); + + // The real `todo` tool must have run. + let todo_ran = notifications.iter().any(|n| { + n["params"]["update"]["sessionUpdate"] == "tool_call" + && n["params"]["update"]["title"] + .as_str() + .is_some_and(|t| t.contains("todo")) + }); + assert!( + todo_ran, + "the real `todo` tool never ran: {notifications:#?}" + ); + + let mut seen = Vec::new(); + while let Ok(req) = requests.recv_timeout(Duration::from_secs(5)) { + seen.push(req); + } + + // The model tried to stop at generation 2 with an item still open. The + // real `_Stop` hook must have objected, forcing further generations. + let n = calls.load(Ordering::SeqCst); + assert!( + n >= 3, + "real _Stop hook did not block end-of-turn: only {n} generations \ + (expected the todo item to force at least one more)" + ); + + // And the objection the real server produces must have reached the model. + let all = seen + .iter() + .map(|r| r["messages"].to_string()) + .collect::>() + .join("\n"); + assert!( + all.contains("open todo items"), + "buzz-dev-mcp's objection text never reached the model.\nsaw:\n{all}" + ); +} + +#[test] +fn real_dev_mcp_advertises_its_tools() { + let Some(mcp) = buzz_dev_mcp() else { + eprintln!("skipping: buzz-dev-mcp not built"); + return; + }; + + let (base_url, requests, _) = spawn_scripted_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ + "cwd": cwd.path().to_str().unwrap(), + "mcpServers": [{ + "name": "devtools", + "command": mcp.to_str().unwrap(), + "args": [], + "env": [], + }], + }), + ); + let (resp, _) = h.await_response(id); + let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string(); + + let id = h.request( + "session/prompt", + json!({ "sessionId": session_id, "prompt": [{ "type": "text", "text": "hi" }] }), + ); + let _ = h.await_response(id); + + let first = requests + .recv_timeout(Duration::from_secs(10)) + .expect("no provider request"); + let tools = first["tools"].to_string(); + + // The shipped tool surface, prefixed by extension name. + for tool in ["shell", "read_file", "str_replace", "todo"] { + assert!( + tools.contains(&format!("devtools__{tool}")), + "real buzz-dev-mcp tool `{tool}` was not advertised to the model" + ); + } + + // Known deviation from buzz-agent, asserted so it cannot regress silently: + // goose's allowlist gates advertising and dispatch together, so the hooks + // stay visible and we instruct the model not to call them instead. + assert!( + tools.contains("devtools___Stop"), + "expected _Stop to remain visible (see build_agent's KNOWN DEVIATION)" + ); + let system = first["messages"][0]["content"].as_str().unwrap_or_default(); + assert!( + system.contains("lifecycle hooks"), + "hook guidance missing from the system prompt: {system}" + ); +} From b631552fa40366c3296333d091f7ca262ea43362 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:16:30 +1000 Subject: [PATCH 07/20] fix(buzz-agent-core): drain on cancel instead of dropping the stream The new cancel test caught two real bugs, both from breaking out of the select loop the moment the token fired. Dropping `stream` drops the futures goose is awaiting, so `mcp_client.rs:688` never reaches its `cancel_token.cancelled()` arm and never sends `notifications/cancelled`. Consequences: the MCP child keeps running its tool after the turn is over, and any announced `tool_call` never reaches a terminal state -- the desktop renders that as a spinner forever, which is the invariant buzz-agent held at agent.rs:470-477. Cancellation is cooperative, so treat it that way: keep polling the stream and let goose unwind (emit tool responses, send the MCP cancellations, end the stream), bounded by CANCEL_DRAIN_TIMEOUT = 5s. Track announced-minus-resolved tool call ids and synthesise terminal updates for any stragglers if the drain times out -- a wrong status beats a stuck spinner. 3 new tests: cancel mid-tool-call returns stopReason=cancelled with every tool call resolved and activeRunId cleared; notifications/cancelled actually reaches the MCP server (asserted via FAKE_MCP_CANCEL_LOG); cancel for an unknown session doesn't kill the process. 28 tests green, fmt + clippy -D warnings clean. Signed-off-by: Michael Neale --- buzz-agent-core/src/agent.rs | 67 +++++- buzz-agent-core/tests/cancel.rs | 355 ++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 buzz-agent-core/tests/cancel.rs diff --git a/buzz-agent-core/src/agent.rs b/buzz-agent-core/src/agent.rs index 82817cd1a0..f3ece95198 100644 --- a/buzz-agent-core/src/agent.rs +++ b/buzz-agent-core/src/agent.rs @@ -60,6 +60,15 @@ const ERROR_REFLECTION: &str = /// conversation. const MAX_REFLECTIONS: usize = 8; +/// How long to let goose unwind after `session/cancel` before giving up. +/// +/// Cancellation is cooperative: goose has to notice the token, send +/// `notifications/cancelled` to each in-flight MCP request +/// (`mcp_client.rs:687-690`), emit the resulting tool responses, and end the +/// stream. Dropping the stream instead skips all of that. This bounds the +/// wait so a wedged MCP child cannot hold the turn open indefinitely. +const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + /// Per-turn token accounting, mirroring buzz-agent's contract. #[derive(Debug, Default, Clone, Copy)] pub struct TurnTokens { @@ -223,6 +232,14 @@ async fn drive_stream( let mut compacted = false; let mut reflections = 0usize; + // Set once cancellation is observed; bounds how long we let goose unwind. + let mut drain: Option>> = None; + + // Tool calls announced to the harness, minus those that reached a terminal + // state. Anything still here when the stream ends gets a synthetic + // terminal update — see the cancel arm below. + let mut open_tool_calls: Vec = Vec::new(); + loop { tokio::select! { // Keep the harness idle clock alive during provider silence. @@ -234,8 +251,28 @@ async fn drive_stream( .await; } - _ = cancel.cancelled() => { + // Cancellation is a cooperative DRAIN, not an abort. + // + // Breaking here drops `stream`, which drops the futures goose is + // awaiting — so `mcp_client.rs:688` never reaches its + // `cancel_token.cancelled()` arm and never sends + // `notifications/cancelled`. The MCP child keeps running its tool + // after the turn is over, and any announced `tool_call` never + // reaches a terminal state, leaving the desktop UI spinning + // (the invariant buzz-agent held at `agent.rs:470-477`). + // + // So we keep polling the stream and let goose unwind: it emits the + // tool responses, sends the MCP cancellations, and ends the stream + // itself. `drain` only bounds how long we are willing to wait. + _ = cancel.cancelled(), if drain.is_none() => { stop = StopReason::Cancelled; + drain = Some(Box::pin(tokio::time::sleep(CANCEL_DRAIN_TIMEOUT))); + } + + // Goose did not unwind in time. Give up and synthesise terminal + // states, because a stuck spinner is worse than a wrong status. + _ = async { drain.as_mut().unwrap().await }, if drain.is_some() => { + tracing::warn!("cancel drain timed out; forcing terminal tool states"); break; } @@ -251,6 +288,7 @@ async fn drive_stream( tokens, &mut compacted, &mut reflections, + &mut open_tool_calls, ) .await { @@ -276,6 +314,26 @@ async fn drive_stream( } } + // Anything announced but never resolved gets a synthetic terminal update. + // buzz-agent guaranteed this invariant (`agent.rs:470-477`) because the + // desktop renders an unresolved `tool_call` as a spinner forever. Normally + // the drain above means this list is already empty; it only fires when + // goose failed to unwind inside CANCEL_DRAIN_TIMEOUT. + for tool_call_id in open_tool_calls.drain(..) { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": tool_call_id, + "status": "failed", + }), + ), + ) + .await; + } + // Goose compacted history mid-turn. Re-inject the todo list so it survives // the truncation — this is what buzz-agent's `_PostCompact` existed for // (`handoff.rs:73-81`). Steering is the right channel: goose drains it at @@ -318,13 +376,20 @@ async fn handle_event( tokens: &mut TurnTokens, compacted: &mut bool, reflections: &mut usize, + open_tool_calls: &mut Vec, ) -> Option { match event { AgentEvent::Message(msg) => { for content in &msg.content { emit_content(content, session_id, wire_tx).await; + if let MessageContent::ToolRequest(req) = content { + open_tool_calls.push(req.id.clone()); + } + if let MessageContent::ToolResponse(resp) = content { + open_tool_calls.retain(|id| id != &resp.id); + let failed = match &resp.tool_result { Ok(r) => r.is_error.unwrap_or(false), Err(_) => true, diff --git a/buzz-agent-core/tests/cancel.rs b/buzz-agent-core/tests/cancel.rs new file mode 100644 index 0000000000..a811f6af9d --- /dev/null +++ b/buzz-agent-core/tests/cancel.rs @@ -0,0 +1,355 @@ +//! `session/cancel` — the last ACP method without coverage. +//! +//! buzz-acp sends this as a *notification* (no id, no response expected) and +//! then waits for the in-flight `session/prompt` to return. Two things must +//! hold or the harness wedges: +//! +//! 1. The prompt response arrives, with `stopReason: "cancelled"`. +//! 2. Every announced tool call reaches a terminal state — buzz-agent +//! guaranteed this (`agent.rs:470-477`) because the desktop UI otherwise +//! shows a spinner forever. +//! +//! Goose plumbs the cancellation token into MCP tool calls and emits +//! `notifications/cancelled` per in-flight request (`mcp_client.rs:687-690`), +//! so this is a cooperative drain rather than a hard abort. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +/// Provider whose first generation calls a tool that will hang. +fn spawn_hanging_tool_provider() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + let respond = |stream: &mut std::net::TcpStream, ct: &str, payload: String| { + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ) + .as_bytes(), + ); + let _ = stream.flush(); + }; + + if request_line.contains("/models") { + respond( + &mut stream, + "application/json", + json!({ "object": "list", "data": [] }).to_string(), + ); + continue; + } + + let call = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": { "name": "devtools__tool_0", "arguments": "{}" }, + }], + }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }], + "usage": { "prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7 }, + }); + respond( + &mut stream, + "text/event-stream", + format!("data: {call}\n\ndata: {done}\n\ndata: [DONE]\n\n"), + ); + } + }); + + format!("http://{addr}") +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + writeln!( + self.stdin, + "{}", + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + id + } + + /// Fire-and-forget, exactly as buzz-acp sends `session/cancel`. + fn notify(&mut self, method: &str, params: Value) { + writeln!( + self.stdin, + "{}", + json!({ "jsonrpc": "2.0", "method": method, "params": params }) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + } + + fn await_response(&mut self, id: i64) -> (Value, Vec) { + let mut notifications = Vec::new(); + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return (msg, notifications); + } + if msg.get("method").is_some() { + notifications.push(msg); + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn new_session(h: &mut Harness, cwd: &std::path::Path, cancel_log: &std::path::Path) -> String { + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ + "cwd": cwd.to_str().unwrap(), + "mcpServers": [{ + "name": "devtools", + "command": env!("CARGO_BIN_EXE_fake-mcp"), + "args": [], + "env": [ + // Tool call blocks ~forever, so cancel lands mid-flight. + { "name": "FAKE_MCP_TOOL_DELAY", "value": "999" }, + { "name": "FAKE_MCP_CANCEL_LOG", "value": cancel_log.to_str().unwrap() }, + ], + }], + }), + ); + let (resp, _) = h.await_response(id); + resp["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {resp}")) + .to_string() +} + +#[test] +fn cancel_mid_tool_call_returns_cancelled() { + let base_url = spawn_hanging_tool_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let logs = tempfile::tempdir().expect("logs"); + let cancel_log = logs.path().join("cancelled.jsonl"); + + let mut h = Harness::start(&base_url, home.path()); + let session_id = new_session(&mut h, cwd.path(), &cancel_log); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "run the slow tool" }], + }), + ); + + // Let the turn reach the hanging tool, then cancel. + std::thread::sleep(Duration::from_secs(2)); + h.notify("session/cancel", json!({ "sessionId": session_id })); + + let started = Instant::now(); + let (resp, notifications) = h.await_response(id); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_secs(30), + "cancel took {elapsed:?}; the tool sleeps 999s, so this did not unblock" + ); + assert_eq!( + resp["result"]["stopReason"], "cancelled", + "expected stopReason=cancelled: {resp}" + ); + + // Every announced tool call must reach a terminal state, or the desktop + // spins forever (buzz-agent agent.rs:470-477). + let announced: Vec = notifications + .iter() + .filter(|n| n["params"]["update"]["sessionUpdate"] == "tool_call") + .filter_map(|n| { + n["params"]["update"]["toolCallId"] + .as_str() + .map(String::from) + }) + .collect(); + let terminal: Vec = notifications + .iter() + .filter(|n| n["params"]["update"]["sessionUpdate"] == "tool_call_update") + .filter_map(|n| { + n["params"]["update"]["toolCallId"] + .as_str() + .map(String::from) + }) + .collect(); + + for id in &announced { + assert!( + terminal.contains(id), + "tool call {id} was announced but never resolved — UI would hang.\n\ + announced={announced:?} terminal={terminal:?}" + ); + } + + // activeRunId must be cleared, or a later steer targets a dead run. + let cleared = notifications.iter().any(|n| { + n["params"]["update"]["sessionUpdate"] == "session_info_update" + && n["params"]["update"]["_meta"]["goose"]["activeRunId"].is_null() + }); + assert!(cleared, "activeRunId was never cleared after cancel"); +} + +#[test] +fn cancel_propagates_notifications_cancelled_to_mcp() { + let base_url = spawn_hanging_tool_provider(); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let logs = tempfile::tempdir().expect("logs"); + let cancel_log = logs.path().join("cancelled.jsonl"); + + let mut h = Harness::start(&base_url, home.path()); + let session_id = new_session(&mut h, cwd.path(), &cancel_log); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "run the slow tool" }], + }), + ); + + std::thread::sleep(Duration::from_secs(2)); + h.notify("session/cancel", json!({ "sessionId": session_id })); + let _ = h.await_response(id); + + // Goose should tell the MCP server to stop (mcp_client.rs:687-690). + // Give it a moment to flush. + let mut saw = false; + for _ in 0..20 { + if let Ok(contents) = std::fs::read_to_string(&cancel_log) { + if contents.contains("notifications/cancelled") { + saw = true; + break; + } + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + saw, + "MCP server never received notifications/cancelled — a hung tool would \ + keep running after the turn ended" + ); +} + +#[test] +fn cancel_for_unknown_session_is_ignored() { + let base_url = spawn_hanging_tool_provider(); + let home = tempfile::tempdir().expect("home"); + let mut h = Harness::start(&base_url, home.path()); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + // Must not crash the agent: it is a notification, so there is nothing to + // reply to, and buzz-acp would hang if the process died here. + h.notify("session/cancel", json!({ "sessionId": "ses_nope" })); + + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let (resp, _) = h.await_response(id); + assert_eq!(resp["result"]["agentInfo"]["name"], "buzz-agent"); +} From fbe120fa18ba996b5a2c602cc5ae6e969de262d0 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:18:31 +1000 Subject: [PATCH 08/20] test(buzz-agent-core): cover session/steer, the last untested ACP method Steering injects a message into a live turn without cancelling it. buzz-acp prefers it over cancel+re-prompt because the latter throws away the model's in-progress work, and it guards the call with optimistic concurrency. Four tests, all against the real stdio wire: - steer_injects_without_cancelling_the_turn: asserts the turn still ends with end_turn AND that the steered text reached the provider. Goose's steer() is drained at the round boundary (agent.rs:1951-1974), same as buzz-agent's. - steer_with_stale_run_id_is_rejected / steer_outside_a_turn_is_rejected: both must be errors so buzz-acp can fall back to cancel+merge (buzz-acp/src/pool.rs:329-366) rather than silently steering the wrong turn. - active_run_id_is_cleared_when_the_turn_ends: asserts an explicit trailing null, then that a later steer is rejected. await_active_run_id() reads params.update._meta.goose.activeRunId at exactly the depth buzz-acp parses (acp.rs:1607-1613) -- a _meta one level too high silently degrades steering to cancel+re-prompt forever, with no error anywhere. All 6 ACP methods now have end-to-end coverage. 32 tests green. Signed-off-by: Michael Neale --- buzz-agent-core/tests/steer.rs | 423 +++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 buzz-agent-core/tests/steer.rs diff --git a/buzz-agent-core/tests/steer.rs b/buzz-agent-core/tests/steer.rs new file mode 100644 index 0000000000..335bf10de8 --- /dev/null +++ b/buzz-agent-core/tests/steer.rs @@ -0,0 +1,423 @@ +//! `_goose/unstable/session/steer` — the last ACP method without coverage. +//! +//! Steering injects a message into a turn *without* cancelling it. buzz-acp +//! prefers this over cancel+re-prompt because the latter loses the model's +//! in-progress work, and it guards the call with optimistic concurrency: +//! +//! * `activeRunId` is advertised at `params.update._meta.goose.activeRunId` +//! — `_meta` nests INSIDE `update` (`buzz-acp/src/acp.rs:1607-1613`). +//! * A steer carries `expectedRunId`. Mismatch or no active run must be an +//! error, so the harness can fall back to cancel+merge +//! (`buzz-acp/src/pool.rs:329-366`). +//! +//! Goose's own `steer()` has the same semantics as buzz-agent's: the message +//! is queued and drained at the round boundary (`agent.rs:1951-1974`), and a +//! pending steer prevents the turn from ending (`agent.rs:2876-2878`). + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Provider whose first generation calls a slow tool (giving the test a window +/// to steer), and whose later generations answer with text. +/// +/// Every request body is forwarded so the test can prove the steered message +/// actually reached the model. +fn spawn_provider(tool_name: &str) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + let tool_name = tool_name.to_string(); + + std::thread::spawn(move || { + let mut generation = 0usize; + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + let respond = |stream: &mut std::net::TcpStream, ct: &str, payload: String| { + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ) + .as_bytes(), + ); + let _ = stream.flush(); + }; + + if request_line.contains("/models") { + respond( + &mut stream, + "application/json", + json!({ "object": "list", "data": [] }).to_string(), + ); + continue; + } + + if let Ok(req) = serde_json::from_slice::(&body) { + let _ = tx.send(req); + } + generation += 1; + + let usage = json!({ "prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7 }); + let sse = if generation == 1 { + let call = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": { "name": tool_name, "arguments": "{}" }, + }], + }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }], + "usage": usage, + }); + format!("data: {call}\n\ndata: {done}\n\ndata: [DONE]\n\n") + } else { + let text = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": "ok" }, + "finish_reason": null, + }], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }], + "usage": usage, + }); + format!("data: {text}\n\ndata: {done}\n\ndata: [DONE]\n\n") + }; + + respond(&mut stream, "text/event-stream", sse); + } + }); + + (format!("http://{addr}"), rx) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + writeln!( + self.stdin, + "{}", + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + id + } + + fn await_response(&mut self, id: i64) -> (Value, Vec) { + let mut notifications = Vec::new(); + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return (msg, notifications); + } + if msg.get("method").is_some() { + notifications.push(msg); + } + } + } + + /// Read notifications until `activeRunId` is advertised, returning it. + /// + /// Also proves the nesting depth: buzz-acp reads + /// `params.update._meta.goose.activeRunId`, and a `_meta` placed one level + /// too high silently degrades steering to cancel+re-prompt forever. + fn await_active_run_id(&mut self) -> String { + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout before advertising activeRunId"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(run_id) = msg["params"]["update"]["_meta"]["goose"]["activeRunId"].as_str() + { + return run_id.to_string(); + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn new_session(h: &mut Harness, cwd: &std::path::Path) -> String { + let id = h.request("initialize", json!({ "protocolVersion": 2 })); + let _ = h.await_response(id); + + let id = h.request( + "session/new", + json!({ + "cwd": cwd.to_str().unwrap(), + "mcpServers": [{ + "name": "devtools", + "command": env!("CARGO_BIN_EXE_fake-mcp"), + "args": [], + // Slow enough that a steer lands mid-turn, short enough that + // the turn still completes on its own. + "env": [{ "name": "FAKE_MCP_TOOL_DELAY", "value": "3" }], + }], + }), + ); + let (resp, _) = h.await_response(id); + resp["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {resp}")) + .to_string() +} + +#[test] +fn steer_injects_without_cancelling_the_turn() { + let (base_url, requests) = spawn_provider("devtools__tool_0"); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + let session_id = new_session(&mut h, cwd.path()); + + let prompt_id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "start working" }], + }), + ); + + let run_id = h.await_active_run_id(); + assert!(run_id.starts_with("run_"), "unexpected runId: {run_id}"); + + let steer_id = h.request( + "_goose/unstable/session/steer", + json!({ + "sessionId": session_id, + "expectedRunId": run_id, + "prompt": [{ "type": "text", "text": "ALSO check the logs" }], + }), + ); + let (steer_resp, _) = h.await_response(steer_id); + assert!( + steer_resp["error"].is_null(), + "steer was rejected: {steer_resp}" + ); + assert_eq!(steer_resp["result"]["runId"], run_id); + + // The turn must complete normally — steering is NOT a cancel. + let (resp, _) = h.await_response(prompt_id); + assert_eq!( + resp["result"]["stopReason"], "end_turn", + "steering must not cancel the turn: {resp}" + ); + + // And the steered text must have reached the model. + let mut seen = Vec::new(); + while let Ok(req) = requests.recv_timeout(Duration::from_secs(5)) { + seen.push(req); + } + let all = seen + .iter() + .map(|r| r["messages"].to_string()) + .collect::>() + .join("\n"); + assert!( + all.contains("ALSO check the logs"), + "steered message never reached the model.\nsaw:\n{all}" + ); +} + +#[test] +fn steer_with_stale_run_id_is_rejected() { + let (base_url, _requests) = spawn_provider("devtools__tool_0"); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + let session_id = new_session(&mut h, cwd.path()); + + let prompt_id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "start working" }], + }), + ); + let _run_id = h.await_active_run_id(); + + // Optimistic concurrency: a stale id must fail so buzz-acp can fall back + // to cancel+merge rather than silently steering the wrong turn. + let steer_id = h.request( + "_goose/unstable/session/steer", + json!({ + "sessionId": session_id, + "expectedRunId": "run_stale", + "prompt": [{ "type": "text", "text": "too late" }], + }), + ); + let (steer_resp, _) = h.await_response(steer_id); + assert_eq!( + steer_resp["error"]["code"], -32602, + "stale runId must be rejected: {steer_resp}" + ); + + let (resp, _) = h.await_response(prompt_id); + assert!(resp["result"]["stopReason"].is_string()); +} + +#[test] +fn steer_outside_a_turn_is_rejected() { + let (base_url, _requests) = spawn_provider("devtools__tool_0"); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + let session_id = new_session(&mut h, cwd.path()); + + // No prompt in flight: there is nothing to steer. + let steer_id = h.request( + "_goose/unstable/session/steer", + json!({ + "sessionId": session_id, + "expectedRunId": "run_anything", + "prompt": [{ "type": "text", "text": "hello?" }], + }), + ); + let (resp, _) = h.await_response(steer_id); + assert_eq!( + resp["error"]["code"], -32602, + "steer with no active run must be an error: {resp}" + ); +} + +#[test] +fn active_run_id_is_cleared_when_the_turn_ends() { + let (base_url, _requests) = spawn_provider("devtools__tool_0"); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + let session_id = new_session(&mut h, cwd.path()); + + let prompt_id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "go" }], + }), + ); + let (_resp, notifications) = h.await_response(prompt_id); + + // A trailing null clears it; without this a later steer would target a + // dead run and the harness would never fall back. + let cleared = notifications.iter().any(|n| { + n["params"]["update"]["sessionUpdate"] == "session_info_update" + && n["params"]["update"]["_meta"]["goose"]["activeRunId"].is_null() + && n["params"]["update"]["_meta"]["goose"] + .as_object() + .is_some_and(|m| m.contains_key("activeRunId")) + }); + assert!( + cleared, + "activeRunId was never explicitly cleared after the turn" + ); + + // And a steer afterwards must now be rejected. + let steer_id = h.request( + "_goose/unstable/session/steer", + json!({ + "sessionId": session_id, + "expectedRunId": "run_whatever", + "prompt": [{ "type": "text", "text": "late" }], + }), + ); + let (resp, _) = h.await_response(steer_id); + assert_eq!(resp["error"]["code"], -32602); +} From 3596e8e2a66bbba57b123b82af4ae45b600c6f6c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:23:20 +1000 Subject: [PATCH 09/20] fix(buzz-agent-core): correct a false claim about GooseMode::default() My own comment said GooseMode::default() was "deliberately not Auto" and therefore did not auto-approve tool calls. That is wrong: GooseMode derives Default with #[default] on Auto (goose_mode.rs:23-25), i.e. every tool call is approved without asking. The comment documented a security posture the code did not have -- the most dangerous kind of wrong comment. Behaviour is unchanged and deliberately so: auto-approve is what buzz ships today (buzz-acp/src/acp.rs:1671-1712 auto-approves every permission request, and the desktop catalog sets GOOSE_MODE=auto for the external goose runtime, discovery.rs:89). Flipping it here would silently change how every existing agent behaves. What changes is that it is now a knob instead of a hardcode. BUZZ_AGENT_APPROVAL selects approve / smart_approve / chat / auto, threaded through AgentConfig and create_session. Unknown values warn and fall back to auto -- a typo must not take an agent off the air, and must not silently tighten either. Nothing in buzz drives this yet. Wiring it to a real human affordance is the first step of the isolation work, and this is the seam that work will use. 4 new unit tests pin the mapping and the fallback. 35 tests green. Signed-off-by: Michael Neale --- buzz-agent-core/src/config.rs | 77 +++++++++++++++++++++++++++++++---- buzz-agent-core/src/lib.rs | 9 +--- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/buzz-agent-core/src/config.rs b/buzz-agent-core/src/config.rs index 5eeac6acde..58045fbd31 100644 --- a/buzz-agent-core/src/config.rs +++ b/buzz-agent-core/src/config.rs @@ -14,6 +14,8 @@ //! `Config` singleton is first touched, because `Config::global()` reads env at //! initialization. +use goose_provider_types::goose_mode::GooseMode; + /// ACP protocol version. Buzz squats on v2 ahead of the upstream RFD; see the /// note in `lib.rs::initialize`. pub const PROTOCOL_VERSION: u32 = 2; @@ -37,6 +39,40 @@ pub struct Config { pub system_prompt: Option, /// Per-turn wall-clock budget for a single provider request. pub llm_timeout_secs: u64, + /// Tool-call approval policy. + /// + /// `GooseMode::default()` is **`Auto`** (`goose_mode.rs:23-25`) — every + /// tool call is approved without asking. That matches what buzz ships + /// today (`buzz-acp/src/acp.rs:1671-1712` auto-approves every permission + /// request, and the desktop catalog sets `GOOSE_MODE=auto` for the + /// external goose runtime, `discovery.rs:89`), so it stays the default + /// here to avoid changing behaviour. + /// + /// It is now a knob rather than a hardcode: `BUZZ_AGENT_APPROVAL=approve` + /// makes goose ask before every tool call, `smart_approve` only for + /// sensitive ones, `chat` disables tools entirely. Nothing in buzz drives + /// this yet — wiring it to a real human affordance is the point of the + /// isolation work, and this is the seam it will use. + pub goose_mode: GooseMode, +} + +/// Map `BUZZ_AGENT_APPROVAL` onto goose's tool-approval policy. +/// +/// Unknown values fall back to the current shipped behaviour (`Auto`) rather +/// than failing the process: a typo in an env var must not take an agent off +/// the air, and silently tightening would be just as surprising as silently +/// loosening. +fn parse_approval(raw: Option<&str>) -> GooseMode { + match raw.map(str::trim).map(str::to_ascii_lowercase).as_deref() { + Some("approve") => GooseMode::Approve, + Some("smart_approve") | Some("smart-approve") => GooseMode::SmartApprove, + Some("chat") => GooseMode::Chat, + Some("auto") | None => GooseMode::Auto, + Some(other) => { + tracing::warn!(value = other, "unknown BUZZ_AGENT_APPROVAL; using auto"); + GooseMode::Auto + } + } } fn env_str(key: &str) -> Option { @@ -65,20 +101,13 @@ impl Config { max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(8), system_prompt, llm_timeout_secs: env_parse("BUZZ_AGENT_LLM_TIMEOUT_SECS").unwrap_or(600), + goose_mode: parse_approval(env_str("BUZZ_AGENT_APPROVAL").as_deref()), } } /// Translate Buzz's provider configuration into Goose's environment. /// - /// Mirrors `goose_env.rs` from PR #1526, with one deliberate difference: - /// **`GOOSE_MODE` is not forced to `auto` here.** The desktop catalog - /// currently ships `default_env: &[("GOOSE_MODE", "auto")]` - /// (`discovery.rs:89`), i.e. auto-approve every tool call - /// (`goose_mode.rs:22-31`). Because this binary drives the agent in-process - /// we can gate tool calls ourselves later without inheriting that default; - /// leaving it unset means Goose falls back to its own default rather than - /// Buzz silently widening it. Callers that genuinely want auto-approve can - /// still set `GOOSE_MODE` explicitly. + /// Mirrors `goose_env.rs` from PR #1526. fn project_goose_env() { // Provider: Buzz's `openai-compat` and `relay-mesh` are both // OpenAI-wire-compatible, and Goose knows them as plain `openai`. @@ -143,6 +172,36 @@ mod tests { std::env::remove_var("BUZZ_TEST_MISSING"); } + #[test] + fn approval_defaults_to_auto() { + // Matches what buzz ships today; changing this silently would alter + // the security posture of every existing agent. + assert_eq!(parse_approval(None), GooseMode::Auto); + assert_eq!(parse_approval(Some("auto")), GooseMode::Auto); + } + + #[test] + fn approval_parses_the_stricter_modes() { + assert_eq!(parse_approval(Some("approve")), GooseMode::Approve); + assert_eq!(parse_approval(Some(" APPROVE ")), GooseMode::Approve); + assert_eq!( + parse_approval(Some("smart_approve")), + GooseMode::SmartApprove + ); + assert_eq!( + parse_approval(Some("smart-approve")), + GooseMode::SmartApprove + ); + assert_eq!(parse_approval(Some("chat")), GooseMode::Chat); + } + + #[test] + fn approval_falls_back_to_auto_on_garbage() { + // A typo must not take an agent off the air, and must not silently + // tighten either — both would be surprising. + assert_eq!(parse_approval(Some("yolo")), GooseMode::Auto); + } + #[test] fn stop_reason_wire_strings_are_stable() { use crate::types::StopReason; diff --git a/buzz-agent-core/src/lib.rs b/buzz-agent-core/src/lib.rs index fbad500847..690ef41678 100644 --- a/buzz-agent-core/src/lib.rs +++ b/buzz-agent-core/src/lib.rs @@ -35,7 +35,6 @@ use tokio_util::sync::CancellationToken; use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform}; use goose::config::PermissionManager; use goose::session::session_manager::{SessionManager, SessionType}; -use goose_provider_types::goose_mode::GooseMode; use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; use types::{AgentError, McpServerStdio}; @@ -94,11 +93,7 @@ async fn build_agent( session_manager.clone(), permission_manager, None, - // Deliberately Goose's own default rather than forcing `Auto`. The - // desktop catalog ships GOOSE_MODE=auto for the *external* goose - // runtime (`discovery.rs:89`), which auto-approves every tool call; - // driving the agent in-process means we don't have to inherit that. - GooseMode::default(), + cfg.goose_mode, // Session naming is a Goose UX affordance; the harness names sessions. true, GoosePlatform::GooseCli, @@ -109,7 +104,7 @@ async fn build_agent( std::path::PathBuf::from(cwd), "buzz-agent".to_string(), SessionType::Acp, - GooseMode::default(), + cfg.goose_mode, ) .await .map_err(|e| AgentError::Llm(format!("session create: {e}")))?; From c12853ed915fd86a78b5fd67f7015c5df511df09 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:35:20 +1000 Subject: [PATCH 10/20] build(buzz-agent-core): emit the binary as `buzz-agent` buzz-acp derives harness identity from the command's BASENAME (normalize_agent_command_identity, buzz-acp/src/config.rs:600-615) and already has a "buzz-agent" arm meaning "no extra args" (default_agent_args, :617-624). Naming the binary `buzz-agent` makes this a drop-in swap: point BUZZ_ACP_AGENT_COMMAND at the built path and buzz-acp cannot tell the difference -- same identity, same args, same ACP contract, goose underneath. That is the whole premise, so the artifact should reflect it. Crate stays buzz-agent-core (it is workspace-excluded and owns its lockfile); only the emitted binary is renamed. 35 tests green. Signed-off-by: Michael Neale --- buzz-agent-core/Cargo.toml | 9 ++++++++- buzz-agent-core/tests/cancel.rs | 2 +- buzz-agent-core/tests/real_dev_mcp.rs | 2 +- buzz-agent-core/tests/reflect.rs | 2 +- buzz-agent-core/tests/stdio_turn.rs | 2 +- buzz-agent-core/tests/steer.rs | 2 +- buzz-agent-core/tests/stop_hook.rs | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/buzz-agent-core/Cargo.toml b/buzz-agent-core/Cargo.toml index 8e32304b42..5c348783c8 100644 --- a/buzz-agent-core/Cargo.toml +++ b/buzz-agent-core/Cargo.toml @@ -31,8 +31,15 @@ publish = false name = "buzz_agent_core" path = "src/lib.rs" +# Emitted as `buzz-agent`, not `buzz-agent-core`, on purpose. buzz-acp derives +# a harness identity from the command's BASENAME +# (`normalize_agent_command_identity`, buzz-acp/src/config.rs:600-615) and +# already has a `"buzz-agent"` arm meaning "no extra args" +# (`default_agent_args`, :617-624). Naming the binary this way makes it a +# drop-in swap: point BUZZ_ACP_AGENT_COMMAND at it and buzz-acp cannot tell the +# difference. [[bin]] -name = "buzz-agent-core" +name = "buzz-agent" path = "src/main.rs" # Test-only fake MCP server, copied from crates/buzz-agent. Built diff --git a/buzz-agent-core/tests/cancel.rs b/buzz-agent-core/tests/cancel.rs index a811f6af9d..601db76e9c 100644 --- a/buzz-agent-core/tests/cancel.rs +++ b/buzz-agent-core/tests/cancel.rs @@ -119,7 +119,7 @@ struct Harness { impl Harness { fn start(base_url: &str, home: &std::path::Path) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent")) .env("BUZZ_AGENT_PROVIDER", "openai-compat") .env("BUZZ_AGENT_MODEL", "fake-model") .env("OPENAI_COMPAT_API_KEY", "k") diff --git a/buzz-agent-core/tests/real_dev_mcp.rs b/buzz-agent-core/tests/real_dev_mcp.rs index 734145b847..4f9d90f0ef 100644 --- a/buzz-agent-core/tests/real_dev_mcp.rs +++ b/buzz-agent-core/tests/real_dev_mcp.rs @@ -169,7 +169,7 @@ struct Harness { impl Harness { fn start(base_url: &str, home: &std::path::Path) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent")) .env("BUZZ_AGENT_PROVIDER", "openai-compat") .env("BUZZ_AGENT_MODEL", "fake-model") .env("OPENAI_COMPAT_API_KEY", "k") diff --git a/buzz-agent-core/tests/reflect.rs b/buzz-agent-core/tests/reflect.rs index 0ddb40e779..b64308cedf 100644 --- a/buzz-agent-core/tests/reflect.rs +++ b/buzz-agent-core/tests/reflect.rs @@ -146,7 +146,7 @@ struct Harness { impl Harness { fn start(base_url: &str, home: &std::path::Path) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent")) .env("BUZZ_AGENT_PROVIDER", "openai-compat") .env("BUZZ_AGENT_MODEL", "fake-model") .env("OPENAI_COMPAT_API_KEY", "k") diff --git a/buzz-agent-core/tests/stdio_turn.rs b/buzz-agent-core/tests/stdio_turn.rs index c2a46860ba..4f3983c449 100644 --- a/buzz-agent-core/tests/stdio_turn.rs +++ b/buzz-agent-core/tests/stdio_turn.rs @@ -154,7 +154,7 @@ struct Harness { impl Harness { fn start(base_url: &str, home: &std::path::Path) -> Self { - let exe = env!("CARGO_BIN_EXE_buzz-agent-core"); + let exe = env!("CARGO_BIN_EXE_buzz-agent"); let mut child = Command::new(exe) // Buzz-shaped config; config.rs translates these onto GOOSE_*. .env("BUZZ_AGENT_PROVIDER", "openai-compat") diff --git a/buzz-agent-core/tests/steer.rs b/buzz-agent-core/tests/steer.rs index 335bf10de8..6328f0afd4 100644 --- a/buzz-agent-core/tests/steer.rs +++ b/buzz-agent-core/tests/steer.rs @@ -151,7 +151,7 @@ struct Harness { impl Harness { fn start(base_url: &str, home: &std::path::Path) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent")) .env("BUZZ_AGENT_PROVIDER", "openai-compat") .env("BUZZ_AGENT_MODEL", "fake-model") .env("OPENAI_COMPAT_API_KEY", "k") diff --git a/buzz-agent-core/tests/stop_hook.rs b/buzz-agent-core/tests/stop_hook.rs index 42b56b7d9f..cdfdf061fd 100644 --- a/buzz-agent-core/tests/stop_hook.rs +++ b/buzz-agent-core/tests/stop_hook.rs @@ -116,7 +116,7 @@ struct Harness { impl Harness { fn start(base_url: &str, home: &std::path::Path) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent-core")) + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent")) .env("BUZZ_AGENT_PROVIDER", "openai-compat") .env("BUZZ_AGENT_MODEL", "fake-model") .env("OPENAI_COMPAT_API_KEY", "k") From 1bd2d945ba0f26d1b490061b4e38484664f0a612 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 16:37:07 +1000 Subject: [PATCH 11/20] docs(buzz-agent-core): add HANDTEST.md and a `just agent-core` recipe Hand-testing this is a swap, not a new setup: `just agent-core` is `just goose` with BUZZ_ACP_AGENT_COMMAND repointed at the built binary (and MCP_COMMAND at buzz-dev-mcp). Because buzz-acp identifies a harness by command BASENAME and the binary is emitted as `buzz-agent`, buzz-acp cannot tell it apart from the old one -- so the old `just goose` still works for A/B against the same relay. HANDTEST.md lists the seven things only a human can check, ordered by risk: persona arrival, the _Stop veto, streaming feel (the most likely source of "something feels off" -- goose streams token-by-token where buzz-agent emitted one chunk per round), cancel-mid-tool leaving no stuck spinner, steering not restarting the turn, the model picker, and whether the model calls the now- visible _ tools it has been told to leave alone. Also records what is NOT done: never run against a real provider (Databricks OAuth is entirely goose's code path now and completely unexercised), never run inside the desktop app, nothing wired into packaging or the catalog. Signed-off-by: Michael Neale --- buzz-agent-core/HANDTEST.md | 129 ++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 buzz-agent-core/HANDTEST.md diff --git a/buzz-agent-core/HANDTEST.md b/buzz-agent-core/HANDTEST.md new file mode 100644 index 0000000000..b24f722313 --- /dev/null +++ b/buzz-agent-core/HANDTEST.md @@ -0,0 +1,129 @@ +# Hand-testing `buzz-agent-core` + +The premise: this binary **is** `buzz-agent`. Same name, same ACP contract, +goose underneath. So hand-testing is a *swap*, not a new setup — you run the +normal buzz-acp flow and repoint one environment variable. + +`buzz-acp` derives harness identity from the command's **basename** +(`normalize_agent_command_identity`, `buzz-acp/src/config.rs:600-615`) and +already has a `"buzz-agent"` arm meaning "no extra args" +(`default_agent_args`, `:617-624`). The binary is emitted as `buzz-agent` +precisely so buzz-acp cannot tell the difference. + +## 0. Build + +```bash +. ./bin/activate-hermit +cargo build --release -p buzz-acp -p buzz-dev-mcp -p buzz-cli +cargo build --release --manifest-path buzz-agent-core/Cargo.toml +``` + +Takes a few minutes the first time (goose pulls ~700 crates into its own +lockfile). + +## 1. The swap + +```bash +just relay # terminal 1 + +# terminal 2 +just agent-core # new: goose-backed buzz-agent +just goose # old: for A/B comparison +``` + +`just agent-core` is `just goose` with two lines changed: + +```diff +- BUZZ_ACP_AGENT_COMMAND=goose +- BUZZ_ACP_AGENT_ARGS=acp ++ BUZZ_ACP_AGENT_COMMAND=/buzz-agent-core/target/release/buzz-agent ++ BUZZ_ACP_MCP_COMMAND=/target/release/buzz-dev-mcp +``` + +Everything else — relay URL, keys, agent count, heartbeat — is identical. + +## 2. What to actually check + +Ordered by how likely it is to be broken and how badly it would matter. +The automated suite covers each of these at the stdio layer; this is about +the parts only a human can see. + +### A. Persona actually arrives — the whole reason for embedding + +``` +@fizz who are you? +``` + +Should answer *as Fizz*, and should know the `buzz` CLI exists. If it answers +as generic goose, `systemPrompt` was dropped — the exact failure that makes +plain-ACP embedding impossible (goose's own ACP server never reads +`systemPrompt`; both PRs that would have wired it, buzz#1290 and goose#9971, +are closed unmerged). + +### B. `_Stop` veto — buzz-agent's most load-bearing behaviour + +``` +@fizz make a todo list with 3 items, then stop immediately without doing them +``` + +It must **refuse to stop** while items are open, and keep working. Capped at 3 +vetoes, so it will eventually end regardless. Watch for +`_Stop hook vetoed end of turn` in the buzz-acp log. + +### C. Streaming feel + +buzz-agent emitted one chunk per round; goose streams token-by-token. The +automated tests prove the relay isn't write-amplified (chunks are coalesced by +identity key, flushed at 500ms, paced at 167ms/90-per-minute), but **only a +human can tell you whether it feels better or worse in the desktop app.** +This is the single most likely source of "something feels off". + +### D. Cancel mid-tool + +Ask for something long (`count slowly to 100 with a shell sleep`), then hit +stop. Check: the turn ends promptly, and **no tool call is left spinning** in +the UI. Cancellation is a cooperative drain (5s budget) — dropping the stream +instead leaves the MCP child running and the spinner stuck forever. That bug +existed and is fixed; this is the visual confirmation. + +### E. Steering + +Send a second message while the agent is working. It should be absorbed into +the running turn, *not* cancel-and-restart it. If the desktop visibly restarts +the turn, `activeRunId` is at the wrong nesting depth — it must be at +`params.update._meta.goose.activeRunId`, with `_meta` **inside** `update` +(`buzz-acp/src/acp.rs:1607-1613`). Wrong depth degrades silently to +cancel+re-prompt with no error anywhere. + +### F. Model picker + +Open the model picker in the desktop app. It should list models, not just the +current one. Absent catalog is degraded UX, never a session failure. + +### G. Tool hygiene — known deviation + +The model can now *see* `_Stop` and `_PostCompact` (goose's allowlist gates +advertising and dispatch together, so hiding them would break the veto). A +system-prompt extension tells it not to call them. Watch for the agent calling +`_Stop` on its own — if that happens, the guidance text needs strengthening. + +## 3. A/B against the old agent + +The old binary still exists, so run both against the same relay and compare: + +```bash +just goose # old, terminal 2 +just agent-core # new, terminal 3 +``` + +Two agents, two identities, same channel. Ask both the same thing. + +## 4. Known not-done + +* Never run against a **real** provider — all automated coverage uses a fake + SSE server. Databricks OAuth in particular is entirely goose's code path now + and completely unexercised. +* Never run inside the **desktop app** — only via buzz-acp on the CLI. +* `buzz-agent` (old) is not deleted, nothing is wired into packaging or the + harness catalog. +* Binary is +22.7 MiB raw / +6.0 MiB gzip vs the old one. From 1f6b75a7dcd559a42f44f01ab7c3fb24b0c2a77d Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 17:28:16 +1000 Subject: [PATCH 12/20] refactor(buzz-agent): swap the agent loop onto the goose library buzz-agent keeps its name, its binary, its ACP contract and its place in the workspace. Only the guts change: ~11k lines of hand-written agent loop are replaced by the goose crate used as a Rust library. Deleted, now goose's: llm.rs 3846 (providers), mcp.rs 1139 (extension manager), hints.rs 726, builtin.rs 575 (skills), handoff.rs 430 (context_mgmt), plus most of config.rs 2709. Kept: wire.rs verbatim, and the parts goose does not know about -- the exact session/update shapes buzz-acp parses, the keepalive ticker, usage_update ordering, activeRunId, and the error taxonomy. Behaviour preserved, each with an end-to-end stdio test: the Fizz persona (the reason for embedding -- goose's own ACP server never reads systemPrompt, so this only works via the library API), the _Stop end-turn veto, _PostCompact re-injection, [Reflect] on failed tool calls, the model catalog, set_model, cancel and steer. Validated against the real buzz-dev-mcp, not just a fake. Two dependency conflicts had to be solved to get goose into the workspace: 1. goose pins icu_locale "=2.1.1" (needs icu_collections ~2.1.1) while url 2.5.x -> idna -> idna_adapter 1.2.2 pulls icu_normalizer 2.2.0 (needs icu_collections ~2.2.0). Only one 2.x icu_collections can be selected. Fixed by pinning idna_adapter "=1.2.0", the last release on ICU4X 1.x, which keeps IDNA off that line entirely. 2. The desktop could no longer link buzz-agent at all: goose pulls sqlx-sqlite -> libsqlite3-sys 0.30, desktop has rusqlite 0.37 -> libsqlite3-sys 0.35, and both declare links = "sqlite3". Cargo forbids that and no pin resolves it. But the desktop only ever used Databricks model discovery and WINDOWS_SHELL_RESOLUTION_ENV, so those moved to a new buzz-model-catalog crate (no goose, no sqlite, same API). The desktop dependency is renamed in place, so no desktop source changes. Also fixes a test that had been silently skipping: real_dev_mcp.rs located buzz-dev-mcp by a hardcoded parent depth, so after the move it returned early and reported 0.00s. It now searches upward and panics on a miss. Workspace, sprig, and desktop all build. 41 tests green, fmt + clippy clean. Signed-off-by: Michael Neale --- Cargo.lock | 2698 ++++++- Cargo.toml | 3 +- buzz-agent-core/.gitignore | 1 - buzz-agent-core/Cargo.lock | 6236 ----------------- buzz-agent-core/Cargo.toml | 85 - buzz-agent-core/src/agent.rs | 579 -- buzz-agent-core/src/config.rs | 213 - buzz-agent-core/src/lib.rs | 848 --- buzz-agent-core/src/main.rs | 3 - buzz-agent-core/src/types.rs | 107 - buzz-agent-core/src/wire.rs | 293 - buzz-agent-core/tests/bin/fake_mcp.rs | 305 - crates/buzz-agent/Cargo.toml | 12 + .../buzz-agent}/HANDTEST.md | 0 crates/buzz-agent/src/agent.rs | 1191 ++-- crates/buzz-agent/src/builtin.rs | 575 -- crates/buzz-agent/src/config.rs | 2830 +------- crates/buzz-agent/src/handoff.rs | 430 -- crates/buzz-agent/src/hints.rs | 726 -- .../buzz-agent}/src/hooks.rs | 0 crates/buzz-agent/src/lib.rs | 1404 ++-- crates/buzz-agent/src/llm.rs | 3846 ---------- crates/buzz-agent/src/mcp.rs | 1139 --- crates/buzz-agent/src/types.rs | 328 +- crates/buzz-agent/tests/bin/fake_mcp.rs | 8 +- .../buzz-agent}/tests/cancel.rs | 0 crates/buzz-agent/tests/databricks_oauth.rs | 940 --- crates/buzz-agent/tests/fake_llm.rs | 1125 --- crates/buzz-agent/tests/golden_transcripts.rs | 810 --- crates/buzz-agent/tests/hints_integration.rs | 574 -- .../buzz-agent/tests/openai_auto_upgrade.rs | 229 - .../buzz-agent}/tests/real_dev_mcp.rs | 41 +- .../buzz-agent}/tests/reflect.rs | 0 crates/buzz-agent/tests/regressions.rs | 1821 ----- .../buzz-agent}/tests/stdio_turn.rs | 0 .../buzz-agent}/tests/steer.rs | 0 .../buzz-agent}/tests/stop_hook.rs | 0 crates/buzz-model-catalog/Cargo.toml | 39 + .../src/auth.rs | 0 .../src/catalog.rs | 42 +- crates/buzz-model-catalog/src/config.rs | 47 + crates/buzz-model-catalog/src/lib.rs | 44 + crates/buzz-model-catalog/src/types.rs | 30 + desktop/src-tauri/Cargo.lock | 45 +- desktop/src-tauri/Cargo.toml | 12 +- 45 files changed, 4093 insertions(+), 25566 deletions(-) delete mode 100644 buzz-agent-core/.gitignore delete mode 100644 buzz-agent-core/Cargo.lock delete mode 100644 buzz-agent-core/Cargo.toml delete mode 100644 buzz-agent-core/src/agent.rs delete mode 100644 buzz-agent-core/src/config.rs delete mode 100644 buzz-agent-core/src/lib.rs delete mode 100644 buzz-agent-core/src/main.rs delete mode 100644 buzz-agent-core/src/types.rs delete mode 100644 buzz-agent-core/src/wire.rs delete mode 100644 buzz-agent-core/tests/bin/fake_mcp.rs rename {buzz-agent-core => crates/buzz-agent}/HANDTEST.md (100%) delete mode 100644 crates/buzz-agent/src/builtin.rs delete mode 100644 crates/buzz-agent/src/handoff.rs delete mode 100644 crates/buzz-agent/src/hints.rs rename {buzz-agent-core => crates/buzz-agent}/src/hooks.rs (100%) delete mode 100644 crates/buzz-agent/src/llm.rs delete mode 100644 crates/buzz-agent/src/mcp.rs rename {buzz-agent-core => crates/buzz-agent}/tests/cancel.rs (100%) delete mode 100644 crates/buzz-agent/tests/databricks_oauth.rs delete mode 100644 crates/buzz-agent/tests/fake_llm.rs delete mode 100644 crates/buzz-agent/tests/golden_transcripts.rs delete mode 100644 crates/buzz-agent/tests/hints_integration.rs delete mode 100644 crates/buzz-agent/tests/openai_auto_upgrade.rs rename {buzz-agent-core => crates/buzz-agent}/tests/real_dev_mcp.rs (93%) rename {buzz-agent-core => crates/buzz-agent}/tests/reflect.rs (100%) delete mode 100644 crates/buzz-agent/tests/regressions.rs rename {buzz-agent-core => crates/buzz-agent}/tests/stdio_turn.rs (100%) rename {buzz-agent-core => crates/buzz-agent}/tests/steer.rs (100%) rename {buzz-agent-core => crates/buzz-agent}/tests/stop_hook.rs (100%) create mode 100644 crates/buzz-model-catalog/Cargo.toml rename crates/{buzz-agent => buzz-model-catalog}/src/auth.rs (100%) rename crates/{buzz-agent => buzz-model-catalog}/src/catalog.rs (93%) create mode 100644 crates/buzz-model-catalog/src/config.rs create mode 100644 crates/buzz-model-catalog/src/lib.rs create mode 100644 crates/buzz-model-catalog/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 9d0190868d..5b1894467b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,85 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "schemars 1.2.1", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b94934d118a69e921d14e94d4af5b3076563318c52662c89a0ecb3f139e1da" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "agent-client-protocol-http" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ff55efe7a6bb92a5d0226f6055c2f277b7591060d5095c7764f445289ebda3" +dependencies = [ + "agent-client-protocol", + "async-stream", + "axum", + "futures", + "serde_json", + "tokio", + "tower-http 0.7.0", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -52,6 +131,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -156,6 +250,23 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -195,6 +306,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -307,6 +457,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -403,7 +575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64", - "http", + "http 1.4.0", "log", "rustls", "serde", @@ -441,6 +613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -465,6 +638,68 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + [[package]] name = "axum" version = "0.8.9" @@ -477,8 +712,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -510,8 +745,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -532,6 +767,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "backon" version = "1.6.0" @@ -555,6 +812,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -608,6 +875,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -743,6 +1019,42 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -812,7 +1124,7 @@ dependencies = [ "nostr", "rustls", "serde_json", - "sqlx", + "sqlx 0.9.0", "tokio", "tracing", "url", @@ -822,12 +1134,17 @@ dependencies = [ name = "buzz-agent" version = "0.1.0" dependencies = [ + "anyhow", "arc-swap", "async-trait", "axum", "base64", + "futures", "getrandom 0.4.3", + "goose", + "goose-provider-types", "hex", + "idna_adapter", "nix 0.31.3", "reqwest 0.13.4", "rmcp", @@ -837,6 +1154,7 @@ dependencies = [ "sha2 0.11.0", "tempfile", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "urlencoding", @@ -854,7 +1172,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "sqlx", + "sqlx 0.9.0", "thiserror 2.0.18", "tokio", "tracing", @@ -952,7 +1270,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "sqlx", + "sqlx 0.9.0", "thiserror 2.0.18", "tokio", "tracing", @@ -969,13 +1287,13 @@ dependencies = [ "git-credential-nostr", "git-sign-nostr", "ignore", - "image", + "image 0.25.10", "nix 0.31.3", "nostr", "reqwest 0.13.4", "rmcp", "rustls", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "similar", @@ -1000,7 +1318,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "image", + "image 0.25.10", "imagesize", "infer", "mp4", @@ -1018,6 +1336,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-model-catalog" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "getrandom 0.4.3", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "tokio", + "tracing", + "urlencoding", + "webbrowser", +] + [[package]] name = "buzz-pair-relay" version = "0.1.0" @@ -1106,11 +1444,11 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "sqlx", + "sqlx 0.9.0", "thiserror 2.0.18", "tokio", "tower", - "tower-http", + "tower-http 0.6.11", "tracing", "tracing-subscriber", "url", @@ -1168,7 +1506,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", - "sqlx", + "sqlx 0.9.0", "subtle", "tempfile", "thiserror 2.0.18", @@ -1176,7 +1514,7 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -1224,7 +1562,7 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", - "sqlx", + "sqlx 0.9.0", "thiserror 2.0.18", "tokio", "uuid", @@ -1250,7 +1588,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "sqlx", + "sqlx 0.9.0", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", @@ -1303,6 +1641,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1327,6 +1671,26 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1441,6 +1805,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1492,6 +1866,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1584,9 +1967,12 @@ version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ + "brotli", "compression-core", "flate2", "memchr", + "zstd", + "zstd-safe", ] [[package]] @@ -1616,6 +2002,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1672,6 +2064,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cordyceps" version = "0.3.4" @@ -1708,6 +2129,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "countdown-bot" version = "0.1.0" @@ -1793,10 +2223,21 @@ checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" dependencies = [ "chrono", "once_cell", - "phf", + "phf 0.11.3", "winnow 0.7.15", ] +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum 0.27.2", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1963,7 +2404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", ] [[package]] @@ -2172,7 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2241,17 +2682,42 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2349,6 +2815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2360,7 +2827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2477,12 +2944,12 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", + "der 0.8.0", "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 3.0.0", + "spki 0.8.0", "zeroize", ] @@ -2492,9 +2959,9 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2508,7 +2975,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2535,14 +3002,23 @@ dependencies = [ "ff", "group", "hybrid-array", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", "rand_core 0.10.1", "sec1", "subtle", "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2624,6 +3100,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if 1.0.4", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -2691,6 +3184,28 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -2789,6 +3304,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -2853,6 +3379,36 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2897,6 +3453,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset 0.5.7", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -3019,6 +3588,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3070,6 +3649,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.2" @@ -3149,60 +3738,253 @@ dependencies = [ ] [[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +name = "goose" +version = "1.44.0" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ - "ff", - "rand_core 0.10.1", + "agent-client-protocol", + "agent-client-protocol-http", + "agent-client-protocol-schema", + "anyhow", + "arboard", + "async-stream", + "async-trait", + "aws-lc-rs", + "axum", + "axum-server", + "base64", + "blake3", + "chrono", + "clap", + "dirs", + "etcetera 0.11.0", + "fs-err", + "fs2", + "futures", + "gethostname", + "goose-acp-macros", + "goose-download-manager", + "goose-providers", + "goose-sdk-types", + "icu_calendar", + "icu_locale", + "ignore", + "image 0.24.9", + "include_dir", + "indexmap 2.14.0", + "indoc", + "jsonschema", + "jsonwebtoken", + "libc", + "lru 0.18.0", + "minijinja", + "nanoid", + "oauth2", + "once_cell", + "pastey", + "pem", + "process-wrap", + "pulldown-cmark", + "rand 0.10.1", + "rayon", + "rcgen", + "regex", + "reqwest 0.13.4", + "rmcp", + "rustls", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "serde_yaml", + "sha2 0.11.0", + "shell-words", + "shellexpand", + "smithy-transport-reqwest", + "sqlx 0.8.6", + "strum 0.28.0", "subtle", -] - -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", + "sys-info", + "tempfile", + "thiserror 2.0.18", + "tiktoken-rs", "tokio", + "tokio-cron-scheduler", + "tokio-stream", "tokio-util", + "tower-http 0.7.0", "tracing", + "tracing-appender", + "tracing-futures", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin-ng", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", + "unicode-normalization", + "url", + "urlencoding", + "uuid", + "v_htmlescape", + "webbrowser", + "which", + "winapi", ] [[package]] -name = "hashbag" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +name = "goose-acp-macros" +version = "1.44.0" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +name = "goose-download-manager" +version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" dependencies = [ - "foldhash 0.1.5", + "anyhow", + "once_cell", + "reqwest 0.13.4", + "serde", + "tokio", + "tracing", ] [[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" +name = "goose-provider-types" +version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64", + "chrono", + "futures", + "once_cell", + "rand 0.10.1", + "regex", + "reqwest 0.13.4", + "rmcp", + "serde", + "serde_json", + "strum 0.28.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicode-normalization", + "uuid", +] + +[[package]] +name = "goose-providers" +version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "futures", + "goose-provider-types", + "include_dir", + "reqwest 0.13.4", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "goose-sdk-types" +version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "schemars 1.2.1", + "serde", + "serde_json", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", @@ -3221,6 +4003,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hashlink" version = "0.11.0" @@ -3297,7 +4088,7 @@ checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" dependencies = [ "async-trait", "bytes", - "http", + "http 1.4.0", "more-asserts", "serde", "thiserror 2.0.18", @@ -3326,7 +4117,7 @@ dependencies = [ "futures-util", "h2", "hickory-proto", - "http", + "http 1.4.0", "idna", "ipnet", "jni 0.22.4", @@ -3433,6 +4224,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3443,6 +4245,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3450,7 +4263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3461,8 +4274,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -3512,8 +4325,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3529,7 +4342,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -3579,8 +4392,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -3618,18 +4431,57 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_locale_core", + "icu_provider 2.2.0", + "tinystr 0.8.3", + "zerovec 0.11.6", +] + [[package]] name = "icu_collections" -version = "2.2.0" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke 0.7.5", + "zerofrom", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", - "yoke", + "yoke 0.8.2", "zerofrom", - "zerovec", + "zerovec 0.11.6", +] + +[[package]] +name = "icu_locale" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +dependencies = [ + "icu_collections 2.1.1", + "icu_locale_core", + "icu_provider 2.2.0", + "potential_utf", + "tinystr 0.8.3", + "zerovec 0.11.6", ] [[package]] @@ -3639,51 +4491,107 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "litemap 0.8.2", + "serde", + "tinystr 0.8.3", + "writeable 0.6.3", + "zerovec 0.11.6", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap 0.7.5", + "tinystr 0.7.6", + "writeable 0.5.5", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider 1.5.0", + "tinystr 0.7.6", + "zerovec 0.10.4", ] +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ - "icu_collections", + "displaydoc", + "icu_collections 1.5.0", "icu_normalizer_data", "icu_properties", - "icu_provider", + "icu_provider 1.5.0", "smallvec", - "zerovec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec 0.10.4", ] [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" -version = "2.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ - "icu_collections", - "icu_locale_core", + "displaydoc", + "icu_collections 1.5.0", + "icu_locid_transform", "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "icu_provider 1.5.0", + "tinystr 0.7.6", + "zerovec 0.10.4", ] [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr 0.7.6", + "writeable 0.5.5", + "yoke 0.7.5", + "zerofrom", + "zerovec 0.10.4", +] [[package]] name = "icu_provider" @@ -3693,11 +4601,24 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "writeable", - "yoke", + "serde", + "stable_deref_trait", + "writeable 0.6.3", + "yoke 0.8.2", "zerofrom", "zerotrie", - "zerovec", + "zerovec 0.11.6", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3725,9 +4646,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", @@ -3752,7 +4673,7 @@ dependencies = [ "attohttpc", "bytes", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -3779,6 +4700,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif 0.13.3", + "jpeg-decoder", + "num-traits", + "png 0.17.16", +] + [[package]] name = "image" version = "0.25.10" @@ -3788,11 +4724,11 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.1", "zune-core", "zune-jpeg", ] @@ -3813,6 +4749,36 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -3821,6 +4787,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -3915,7 +4883,7 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hickory-resolver", - "http", + "http 1.4.0", "ipnet", "iroh-base", "iroh-dns", @@ -3939,7 +4907,7 @@ dependencies = [ "rustls-pki-types", "serde", "smallvec", - "strum", + "strum 0.28.0", "time", "tokio", "tokio-stream", @@ -3986,7 +4954,7 @@ dependencies = [ "rand 0.10.1", "rustls", "simple-dns", - "strum", + "strum 0.28.0", "tokio", "tracing", "url", @@ -4032,7 +5000,7 @@ dependencies = [ "derive_more", "getrandom 0.4.3", "hickory-resolver", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -4053,7 +5021,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_bytes", - "strum", + "strum 0.28.0", "tokio", "tokio-rustls", "tokio-util", @@ -4170,6 +5138,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.98" @@ -4193,19 +5167,73 @@ dependencies = [ ] [[package]] -name = "kasuari" -version = "0.4.12" +name = "jsonschema" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "keyring" -version = "3.6.3" + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature 2.2.0", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "keyring" +version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ @@ -4248,6 +5276,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "left-right" @@ -4298,7 +5329,10 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ + "bitflags 2.13.0", "libc", + "plain", + "redox_syscall 0.8.1", ] [[package]] @@ -4307,6 +5341,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -4332,6 +5367,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + [[package]] name = "litemap" version = "0.8.2" @@ -4444,6 +5485,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if 1.0.4", + "digest 0.10.7", +] + [[package]] name = "md-5" version = "0.11.0" @@ -4487,6 +5538,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "memoffset" version = "0.9.1" @@ -4644,7 +5701,7 @@ dependencies = [ "futures-util", "hex", "hf-hub", - "http", + "http 1.4.0", "http-body-util", "httparse", "if-addrs", @@ -4688,7 +5745,7 @@ dependencies = [ "rmcp", "rpassword", "rustls", - "schemars", + "schemars 1.2.1", "semver", "serde", "serde_json", @@ -4773,7 +5830,7 @@ dependencies = [ "prost-build", "protoc-bin-vendored", "rmcp", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "tokio", @@ -4942,7 +5999,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap", + "indexmap 2.14.0", "ipnet", "metrics", "metrics-util", @@ -4963,7 +6020,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "metrics", "ordered-float 5.3.0", "quanta", @@ -4974,6 +6031,12 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -5005,6 +6068,16 @@ dependencies = [ "rxml", ] +[[package]] +name = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5208,6 +6281,15 @@ dependencies = [ "n0-future", ] +[[package]] +name = "nanoid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" +dependencies = [ + "rand 0.9.4", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -5574,6 +6656,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -5639,6 +6743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -5682,6 +6787,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.17", + "http 1.4.0", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc2" version = "0.6.4" @@ -5691,6 +6816,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -5704,6 +6841,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-wlan" version = "0.3.2" @@ -5747,6 +6897,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-security" version = "0.3.2" @@ -5782,6 +6943,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5916,7 +7086,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http", + "http 1.4.0", "opentelemetry 0.31.0", "reqwest 0.12.28", ] @@ -5927,7 +7097,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http", + "http 1.4.0", "opentelemetry 0.31.0", "opentelemetry-http", "opentelemetry-proto 0.31.0", @@ -5943,7 +7113,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http", + "http 1.4.0", "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", @@ -6070,6 +7240,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.14.0" @@ -6141,7 +7317,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if 1.0.4", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -6185,6 +7361,25 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6250,7 +7445,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -6270,7 +7465,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -6280,7 +7484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -6289,7 +7493,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] @@ -6300,7 +7504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.117", @@ -6315,6 +7519,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -6341,6 +7554,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -6352,14 +7571,35 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -6368,6 +7608,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.9.0" @@ -6375,12 +7621,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64", - "indexmap", + "indexmap 2.14.0", "quick-xml 0.39.4", "serde", "time", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.1" @@ -6499,7 +7758,9 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "zerovec", + "serde_core", + "writeable 0.6.3", + "zerovec 0.11.6", ] [[package]] @@ -6590,7 +7851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", - "indexmap", + "indexmap 2.14.0", "nix 0.31.3", "tokio", "tracing", @@ -6731,6 +7992,22 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -7031,7 +8308,7 @@ dependencies = [ "lru 0.18.0", "palette", "serde", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -7095,7 +8372,7 @@ dependencies = [ "line-clipping", "ratatui-core", "serde", - "strum", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width", @@ -7110,6 +8387,40 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redb" version = "3.1.3" @@ -7160,12 +8471,21 @@ dependencies = [ ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "redox_syscall" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "getrandom 0.2.17", + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", "libredox", "thiserror 2.0.18", ] @@ -7190,6 +8510,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -7238,8 +8575,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -7263,7 +8600,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -7281,12 +8618,15 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -7309,7 +8649,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -7326,7 +8666,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http", + "http 1.4.0", "reqwest 0.13.4", "thiserror 2.0.18", "tower-service", @@ -7358,7 +8698,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -7373,16 +8713,19 @@ dependencies = [ "bytes", "chrono", "futures", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", + "hyper", + "hyper-util", + "oauth2", "pastey", "pin-project-lite", "process-wrap", "rand 0.10.1", "reqwest 0.13.4", "rmcp-macros", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "sse-stream", @@ -7392,6 +8735,7 @@ dependencies = [ "tokio-util", "tower-service", "tracing", + "url", "uuid", ] @@ -7418,6 +8762,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -7443,7 +8807,7 @@ dependencies = [ "futures-util", "hex", "hmac 0.12.1", - "http", + "http 1.4.0", "log", "maybe-async", "md5", @@ -7478,6 +8842,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -7578,7 +8951,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7657,6 +9030,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -7715,7 +9100,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", "ctutils", - "der", + "der 0.8.0", "hybrid-array", "subtle", "zeroize", @@ -7895,6 +9280,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -7945,13 +9331,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -8037,6 +9455,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.2" @@ -8085,6 +9509,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -8135,6 +9569,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -8255,6 +9701,19 @@ dependencies = [ "serde", ] +[[package]] +name = "smithy-transport-reqwest" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566dc85be03a09c384f77a122188d9af000e1f1bd23551b346a9b555838da7e1" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", + "parking_lot", + "reqwest 0.13.4", +] + [[package]] name = "socket-pktinfo" version = "0.3.2" @@ -8308,6 +9767,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -8315,7 +9784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] [[package]] @@ -8327,17 +9796,67 @@ dependencies = [ "buzz-dev-mcp", ] +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core 0.8.6", + "sqlx-macros 0.8.6", + "sqlx-mysql 0.8.6", + "sqlx-postgres 0.8.6", + "sqlx-sqlite 0.8.6", +] + [[package]] name = "sqlx" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", + "sqlx-core 0.9.0", + "sqlx-macros 0.9.0", + "sqlx-mysql 0.9.0", + "sqlx-postgres 0.9.0", + "sqlx-sqlite 0.9.0", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", ] [[package]] @@ -8359,8 +9878,8 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.16.1", - "hashlink", - "indexmap", + "hashlink 0.11.0", + "indexmap 2.14.0", "log", "memchr", "percent-encoding", @@ -8378,6 +9897,19 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core 0.8.6", + "sqlx-macros-core 0.8.6", + "syn 2.0.117", +] + [[package]] name = "sqlx-macros" version = "0.9.0" @@ -8386,11 +9918,36 @@ checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", - "sqlx-core", - "sqlx-macros-core", + "sqlx-core 0.9.0", + "sqlx-macros-core 0.9.0", "syn 2.0.117", ] +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core 0.8.6", + "sqlx-mysql 0.8.6", + "sqlx-postgres 0.8.6", + "sqlx-sqlite 0.8.6", + "syn 2.0.117", + "tokio", + "url", +] + [[package]] name = "sqlx-macros-core" version = "0.9.0" @@ -8407,16 +9964,59 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", + "sqlx-core 0.9.0", + "sqlx-mysql 0.9.0", + "sqlx-postgres 0.9.0", + "sqlx-sqlite 0.9.0", "syn 2.0.117", "thiserror 2.0.18", "tokio", "url", ] +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "smallvec", + "sqlx-core 0.8.6", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami 1.6.1", +] + [[package]] name = "sqlx-mysql" version = "0.9.0" @@ -8439,12 +10039,50 @@ dependencies = [ "serde", "sha1 0.11.0", "sha2 0.11.0", - "sqlx-core", + "sqlx-core 0.9.0", "thiserror 2.0.18", "tracing", "uuid", ] +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core 0.8.6", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami 1.6.1", +] + [[package]] name = "sqlx-postgres" version = "0.9.0" @@ -8458,7 +10096,7 @@ dependencies = [ "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.11.0", "futures-channel", "futures-core", "futures-util", @@ -8467,19 +10105,44 @@ dependencies = [ "hmac 0.13.0", "itoa", "log", - "md-5", + "md-5 0.11.0", "memchr", "rand 0.10.1", "serde", "serde_json", "sha2 0.11.0", "smallvec", - "sqlx-core", + "sqlx-core 0.9.0", "stringprep", "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 2.1.2", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume 0.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core 0.8.6", + "thiserror 2.0.18", + "tracing", + "url", ] [[package]] @@ -8501,7 +10164,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sqlx-core", + "sqlx-core 0.9.0", "thiserror 2.0.18", "tracing", "url", @@ -8516,7 +10179,7 @@ checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -8543,6 +10206,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "stringprep" version = "0.1.5" @@ -8560,13 +10229,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -8635,6 +10325,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "sysinfo" version = "0.37.2" @@ -8744,7 +10444,7 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -8766,7 +10466,7 @@ dependencies = [ "anyhow", "base64", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -8781,7 +10481,7 @@ dependencies = [ "ordered-float 4.6.0", "pest", "pest_derive", - "phf", + "phf 0.11.3", "sha2 0.10.9", "signal-hook", "siphasher", @@ -8848,6 +10548,21 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "tiktoken-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex 0.17.0", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "time" version = "0.3.47" @@ -8891,6 +10606,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec 0.10.4", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -8898,7 +10623,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", - "zerovec", + "serde_core", + "zerovec 0.11.6", ] [[package]] @@ -8933,6 +10659,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" +dependencies = [ + "chrono", + "chrono-tz", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -9039,6 +10781,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "pin-project-lite", @@ -9056,7 +10799,7 @@ dependencies = [ "futures-core", "futures-sink", "getrandom 0.4.3", - "http", + "http 1.4.0", "httparse", "rand 0.10.1", "ring", @@ -9074,7 +10817,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", @@ -9089,7 +10832,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", @@ -9122,7 +10865,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", @@ -9155,8 +10898,8 @@ dependencies = [ "base64", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -9204,7 +10947,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -9226,8 +10969,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -9244,6 +10987,21 @@ dependencies = [ "url", ] +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http 1.4.0", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -9269,95 +11027,218 @@ dependencies = [ ] [[package]] -name = "tracing-appender" -version = "0.2.5" +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[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 2.0.117", +] + +[[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-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[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-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry 0.32.0", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "tree-sitter-kotlin-ng" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tree-sitter-language" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" dependencies = [ - "once_cell", - "valuable", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "tree-sitter-ruby" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" dependencies = [ - "log", - "once_cell", - "tracing-core", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-opentelemetry" -version = "0.33.0" +name = "tree-sitter-rust" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" dependencies = [ - "js-sys", - "opentelemetry 0.32.0", - "smallvec", - "tracing", - "tracing-core", - "tracing-log", - "tracing-subscriber", - "web-time", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-serde" -version = "0.2.0" +name = "tree-sitter-swift" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" dependencies = [ - "serde", - "tracing-core", + "cc", + "tree-sitter-language", ] [[package]] -name = "tracing-subscriber" -version = "0.3.23" +name = "tree-sitter-typescript" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", + "cc", + "tree-sitter-language", ] [[package]] @@ -9374,7 +11255,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -9393,7 +11274,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -9466,6 +11347,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -9532,6 +11419,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -9563,6 +11456,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -9588,6 +11487,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "v_escape-base" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1212fce830b75af194b578e55b3db9049f2c8c45f58d397fb25602fdb50fb3d" + +[[package]] +name = "v_htmlescape" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "befb3d53c9e3ec641417685896cbc8cc5bd264d6a2e190c56aaef1af24740d99" +dependencies = [ + "v_escape-base", +] + [[package]] name = "valuable" version = "0.1.1" @@ -9643,6 +11567,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vtparse" version = "0.6.2" @@ -9704,6 +11634,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasite" version = "1.0.2" @@ -9935,6 +11871,25 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite 0.1.0", +] + [[package]] name = "whoami" version = "2.1.2" @@ -9944,7 +11899,7 @@ dependencies = [ "libc", "libredox", "objc2-system-configuration", - "wasite", + "wasite 1.0.2", "web-sys", ] @@ -10186,6 +12141,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -10237,6 +12201,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -10294,6 +12273,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -10312,6 +12297,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -10330,6 +12321,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -10360,6 +12357,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -10378,6 +12381,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -10396,6 +12405,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -10414,6 +12429,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -10476,6 +12497,18 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "writeable" version = "0.6.3" @@ -10501,6 +12534,41 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -10534,7 +12602,7 @@ dependencies = [ "clap", "crc32fast", "futures", - "http", + "http 1.4.0", "hyper", "lazy_static", "more-asserts", @@ -10608,7 +12676,7 @@ dependencies = [ "chrono", "clap", "gearhash", - "http", + "http 1.4.0", "itertools", "lazy_static", "more-asserts", @@ -10664,7 +12732,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "whoami", + "whoami 2.1.2", "winapi", ] @@ -10689,6 +12757,28 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.7.5", + "zerofrom", +] + [[package]] name = "yoke" version = "0.8.2" @@ -10696,10 +12786,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.8.2", "zerofrom", ] +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "yoke-derive" version = "0.8.2" @@ -10836,8 +12938,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke", + "zerovec 0.11.6", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke 0.7.5", "zerofrom", + "zerovec-derive 0.10.3", ] [[package]] @@ -10846,9 +12958,21 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "yoke", + "serde", + "yoke 0.8.2", "zerofrom", - "zerovec-derive", + "zerovec-derive 0.11.3", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -10873,7 +12997,7 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", "thiserror 2.0.18", "zopfli", @@ -10897,6 +13021,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index a72b97f5d5..7cbf64d9f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/buzz-audit", "crates/buzz-acp", "crates/buzz-agent", + "crates/buzz-model-catalog", "crates/sprig", "crates/buzz-test-client", "crates/buzz-ws-client", @@ -28,7 +29,7 @@ members = [ "crates/buzz-dev-mcp", "examples/countdown-bot", ] -exclude = ["desktop/src-tauri", "buzz-agent-core"] +exclude = ["desktop/src-tauri"] resolver = "2" [workspace.package] diff --git a/buzz-agent-core/.gitignore b/buzz-agent-core/.gitignore deleted file mode 100644 index 2f7896d1d1..0000000000 --- a/buzz-agent-core/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/buzz-agent-core/Cargo.lock b/buzz-agent-core/Cargo.lock deleted file mode 100644 index a4d07d7b4f..0000000000 --- a/buzz-agent-core/Cargo.lock +++ /dev/null @@ -1,6236 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "agent-client-protocol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" -dependencies = [ - "agent-client-protocol-derive", - "agent-client-protocol-schema", - "async-process", - "blocking", - "futures", - "futures-concurrency", - "rustc-hash", - "schemars 1.2.1", - "serde", - "serde_json", - "shell-words", - "tracing", - "uuid", - "windows-sys 0.61.2", -] - -[[package]] -name = "agent-client-protocol-derive" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b94934d118a69e921d14e94d4af5b3076563318c52662c89a0ecb3f139e1da" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "agent-client-protocol-http" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31ff55efe7a6bb92a5d0226f6055c2f277b7591060d5095c7764f445289ebda3" -dependencies = [ - "agent-client-protocol", - "async-stream", - "axum", - "futures", - "serde_json", - "tokio", - "tower-http 0.7.0", - "tracing", - "uuid", -] - -[[package]] -name = "agent-client-protocol-schema" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" -dependencies = [ - "anyhow", - "derive_more", - "schemars 1.2.1", - "serde", - "serde_json", - "serde_with", - "strum 0.28.0", - "tracing", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[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.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "x11rb", -] - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.19", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "aws-smithy-async" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api-macros", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.4.2", - "pin-project-lite", - "tokio", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "aws-smithy-types" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "http 0.2.12", - "http 1.4.2", - "http-body 0.4.6", - "http-body 1.1.0", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http 1.4.2", - "http-body 1.1.0", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.2", - "http-body 1.1.0", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "axum-server" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" -dependencies = [ - "arc-swap", - "bytes", - "either", - "fs-err", - "http 1.4.2", - "http-body 1.1.0", - "hyper", - "hyper-util", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[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 = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "borrow-or-share" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" - -[[package]] -name = "brotli" -version = "8.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bstr" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" -dependencies = [ - "memchr", - "regex-automata", - "serde_core", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "buzz-agent-core" -version = "0.1.0" -dependencies = [ - "anyhow", - "futures", - "goose", - "goose-provider-types", - "rmcp", - "serde", - "serde_json", - "tempfile", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] - -[[package]] -name = "calendrical_calculations" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" -dependencies = [ - "core_maths", - "displaydoc", -] - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "chrono-tz" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf", -] - -[[package]] -name = "clap" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", - "zstd", - "zstd-safe", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[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 = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "croner" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" -dependencies = [ - "chrono", - "derive_builder", - "strum 0.27.2", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[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 = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.119", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.1", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" -dependencies = [ - "serde", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[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 = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fancy-regex" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fancy-regex" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fluent-uri" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" -dependencies = [ - "borrow-or-share", - "ref-cast", - "serde", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[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 = "fraction" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" -dependencies = [ - "lazy_static", - "num", -] - -[[package]] -name = "fs-err" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" -dependencies = [ - "autocfg", - "tokio", -] - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-concurrency" -version = "7.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" -dependencies = [ - "fixedbitset", - "futures-core", - "futures-lite", - "pin-project", - "smallvec", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-executor" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link", -] - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "goose" -version = "1.44.0" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" -dependencies = [ - "agent-client-protocol", - "agent-client-protocol-http", - "agent-client-protocol-schema", - "anyhow", - "arboard", - "async-stream", - "async-trait", - "aws-lc-rs", - "axum", - "axum-server", - "base64", - "blake3", - "chrono", - "clap", - "dirs", - "etcetera 0.11.0", - "fs-err", - "fs2", - "futures", - "gethostname", - "goose-acp-macros", - "goose-download-manager", - "goose-providers", - "goose-sdk-types", - "icu_calendar", - "icu_locale", - "ignore", - "image", - "include_dir", - "indexmap 2.14.0", - "indoc", - "jsonschema", - "jsonwebtoken", - "libc", - "lru", - "minijinja", - "nanoid", - "oauth2", - "once_cell", - "pastey", - "pem", - "process-wrap", - "pulldown-cmark", - "rand 0.10.2", - "rayon", - "rcgen", - "regex", - "reqwest 0.13.4", - "rmcp", - "rustls", - "schemars 1.2.1", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "serde_yaml", - "sha2 0.11.0", - "shell-words", - "shellexpand", - "smithy-transport-reqwest", - "sqlx", - "strum 0.28.0", - "subtle", - "sys-info", - "tempfile", - "thiserror 2.0.19", - "tiktoken-rs", - "tokio", - "tokio-cron-scheduler", - "tokio-stream", - "tokio-util", - "tower-http 0.7.0", - "tracing", - "tracing-appender", - "tracing-futures", - "tracing-subscriber", - "tree-sitter", - "tree-sitter-go", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-kotlin-ng", - "tree-sitter-python", - "tree-sitter-ruby", - "tree-sitter-rust", - "tree-sitter-swift", - "tree-sitter-typescript", - "unicode-normalization", - "url", - "urlencoding", - "uuid", - "v_htmlescape", - "webbrowser", - "which", - "winapi", -] - -[[package]] -name = "goose-acp-macros" -version = "1.44.0" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "goose-download-manager" -version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" -dependencies = [ - "anyhow", - "once_cell", - "reqwest 0.13.4", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "goose-provider-types" -version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "base64", - "chrono", - "futures", - "once_cell", - "rand 0.10.2", - "regex", - "reqwest 0.13.4", - "rmcp", - "serde", - "serde_json", - "strum 0.28.0", - "thiserror 2.0.19", - "tokio", - "tracing", - "unicode-normalization", - "uuid", -] - -[[package]] -name = "goose-providers" -version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "chrono", - "futures", - "goose-provider-types", - "include_dir", - "reqwest 0.13.4", - "rmcp", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "urlencoding", -] - -[[package]] -name = "goose-sdk-types" -version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" -dependencies = [ - "agent-client-protocol", - "agent-client-protocol-schema", - "schemars 1.2.1", - "serde", - "serde_json", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.2", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[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 = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http 1.4.2", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.2", - "http-body 1.1.0", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http 1.4.2", - "http-body 1.1.0", - "httparse", - "httpdate", - "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 1.4.2", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.9", -] - -[[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 1.4.2", - "http-body 1.1.0", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_calendar" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_locale_core", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_provider", - "potential_utf", - "tinystr", - "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", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[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", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "image" -version = "0.24.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "gif", - "jpeg-decoder", - "num-traits", - "png", -] - -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[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 = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.19", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "jsonschema" -version = "0.46.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" -dependencies = [ - "ahash", - "bytecount", - "data-encoding", - "email_address", - "fancy-regex 0.18.0", - "fraction", - "getrandom 0.3.4", - "idna", - "itoa", - "jsonschema-regex", - "num-cmp", - "num-traits", - "percent-encoding", - "referencing", - "regex", - "serde", - "serde_json", - "unicode-general-category", - "uuid-simd", -] - -[[package]] -name = "jsonschema-regex" -version = "0.46.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "aws-lc-rs", - "base64", - "getrandom 0.2.17", - "js-sys", - "pem", - "serde", - "serde_json", - "signature", - "simple_asn1", - "zeroize", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "bitflags 2.13.1", - "libc", - "plain", - "redox_syscall 0.9.0", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[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 = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" - -[[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 = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memo-map" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" - -[[package]] -name = "micromap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minijinja" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" -dependencies = [ - "memo-map", - "serde", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nanoid" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" -dependencies = [ - "rand 0.9.5", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[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 = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.7", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-cmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "oauth2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" -dependencies = [ - "base64", - "chrono", - "getrandom 0.2.17", - "http 1.4.2", - "rand 0.8.7", - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_path_to_error", - "sha2 0.10.9", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-graphics", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[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 = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_shared" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "process-wrap" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" -dependencies = [ - "futures", - "indexmap 2.14.0", - "nix", - "tokio", - "tracing", - "windows", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.1", - "memchr", - "unicase", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.19", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.19", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[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 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[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 = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "rcgen" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" -dependencies = [ - "aws-lc-rs", - "pem", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_syscall" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.19", -] - -[[package]] -name = "ref-cast" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "referencing" -version = "0.46.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" -dependencies = [ - "ahash", - "fluent-uri", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "itoa", - "micromap", - "parking_lot", - "percent-encoding", - "serde_json", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -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 1.4.2", - "http-body 1.1.0", - "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 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.9", -] - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", - "bytes", - "cookie", - "cookie_store", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 1.4.2", - "http-body 1.1.0", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[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 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rmcp" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "http 1.4.2", - "http-body-util", - "hyper", - "hyper-util", - "oauth2", - "pastey", - "pin-project-lite", - "process-wrap", - "reqwest 0.13.4", - "rmcp-macros", - "schemars 1.2.1", - "serde", - "serde_json", - "sse-stream", - "thiserror 2.0.19", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", -] - -[[package]] -name = "rmcp-macros" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.119", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" -dependencies = [ - "aws-lc-rs", - "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.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "chrono", - "dyn-clone", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.119", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "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.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[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 2.0.119", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[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_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[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 = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs", -] - -[[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 = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.19", - "time", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[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" -dependencies = [ - "serde", -] - -[[package]] -name = "smithy-transport-reqwest" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "566dc85be03a09c384f77a122188d9af000e1f1bd23551b346a9b555838da7e1" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "http 1.4.2", - "parking_lot", - "reqwest 0.13.4", -] - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap 2.14.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rustls", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "thiserror 2.0.19", - "tokio", - "tokio-stream", - "tracing", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.119", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags 2.13.1", - "byteorder", - "bytes", - "chrono", - "crc", - "digest 0.10.7", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.7", - "rsa", - "serde", - "sha1", - "sha2 0.10.9", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.19", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags 2.13.1", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera 0.8.0", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.7", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.19", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.19", - "tracing", - "url", -] - -[[package]] -name = "sse-stream" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" -dependencies = [ - "bytes", - "futures-util", - "http-body 1.1.0", - "http-body-util", - "pin-project-lite", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -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 2.0.119", -] - -[[package]] -name = "sys-info" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl 2.0.19", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tiktoken-rs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" -dependencies = [ - "anyhow", - "base64", - "bstr", - "fancy-regex 0.17.0", - "lazy_static", - "regex", - "rustc-hash", -] - -[[package]] -name = "time" -version = "0.3.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-cron-scheduler" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" -dependencies = [ - "chrono", - "chrono-tz", - "croner", - "num-derive", - "num-traits", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-io", - "futures-sink", - "futures-util", - "libc", - "pin-project-lite", - "tokio", -] - -[[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", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "async-compression", - "bitflags 2.13.1", - "bytes", - "futures-core", - "futures-util", - "http 1.4.2", - "http-body 1.1.0", - "http-body-util", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-http" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" -dependencies = [ - "bitflags 2.13.1", - "bytes", - "http 1.4.2", - "percent-encoding", - "pin-project-lite", - "tower-layer", - "tower-service", -] - -[[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 = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.19", - "time", - "tracing-subscriber", -] - -[[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 2.0.119", -] - -[[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-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "futures", - "futures-task", - "pin-project", - "tracing", -] - -[[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-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "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", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "time", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "tree-sitter" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" -dependencies = [ - "cc", - "regex", - "regex-syntax", - "serde_json", - "streaming-iterator", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-go" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-java" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-javascript" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-kotlin-ng" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" - -[[package]] -name = "tree-sitter-python" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-ruby" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-rust" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-swift" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-typescript" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[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.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http 1.4.2", - "httparse", - "log", - "rand 0.9.5", - "sha1", - "thiserror 2.0.19", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[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.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[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", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[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.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "uuid-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "v_escape-base" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1212fce830b75af194b578e55b3db9049f2c8c45f58d397fb25602fdb50fb3d" - -[[package]] -name = "v_htmlescape" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befb3d53c9e3ec641417685896cbc8cc5bd264d6a2e190c56aaef1af24740d99" -dependencies = [ - "v_escape-base", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -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 = "webbrowser" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" -dependencies = [ - "core-foundation 0.10.1", - "jni", - "log", - "ndk-context", - "objc2", - "objc2-foundation", - "url", - "web-sys", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "which" -version = "8.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" -dependencies = [ - "libc", -] - -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 2.0.119", -] - -[[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-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "aws-lc-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.19", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec 0.9.1", - "time", -] - -[[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 2.0.119", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "serde", - "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 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/buzz-agent-core/Cargo.toml b/buzz-agent-core/Cargo.toml deleted file mode 100644 index 5c348783c8..0000000000 --- a/buzz-agent-core/Cargo.toml +++ /dev/null @@ -1,85 +0,0 @@ -# buzz-agent-core — buzz-agent's ACP wire layer on top of the Goose Rust -# library (used as an SDK, not as a subprocess). -# -# WHY A SEPARATE CRATE, EXCLUDED FROM THE WORKSPACE -# -# `crates/buzz-agent` is a *library* linked into both `crates/sprig` and -# `desktop/src-tauri` (which uses only `catalog`/`config` for the Databricks -# model picker). Adding goose to that crate would pull goose's ~700-crate -# dependency graph into the Tauri app and into the workspace lockfile. -# -# So this crate is excluded from the workspace (see the root Cargo.toml -# `exclude` list) and carries its own Cargo.lock, exactly as PR #1526 did for -# `goose-acp`. Goose's ACP/MCP dependency versions move independently. -# -# Build: cargo build --release --manifest-path buzz-agent-core/Cargo.toml -# -# THIS IS NOT "GOOSE AS THE HARNESS". The binary keeps buzz-agent's identity -# (`agentInfo.name = "buzz-agent"`), its 6-method ACP surface, its -# `activeRunId` steering contract and its `usage_update` ordering. Only the -# agent loop underneath is Goose's instead of hand-written. Picking "Goose" -# from the harness gallery remains a separate thing: that shells out to a -# user-installed `goose` CLI. - -[package] -name = "buzz-agent-core" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -name = "buzz_agent_core" -path = "src/lib.rs" - -# Emitted as `buzz-agent`, not `buzz-agent-core`, on purpose. buzz-acp derives -# a harness identity from the command's BASENAME -# (`normalize_agent_command_identity`, buzz-acp/src/config.rs:600-615) and -# already has a `"buzz-agent"` arm meaning "no extra args" -# (`default_agent_args`, :617-624). Naming the binary this way makes it a -# drop-in swap: point BUZZ_ACP_AGENT_COMMAND at it and buzz-acp cannot tell the -# difference. -[[bin]] -name = "buzz-agent" -path = "src/main.rs" - -# Test-only fake MCP server, copied from crates/buzz-agent. Built -# unconditionally because cargo can't gate bins on `cfg(test)`. -[[bin]] -name = "fake-mcp" -path = "tests/bin/fake_mcp.rs" - -[dependencies] -# Pinned to a release commit deliberately: goose's public `Agent` surface is -# not semver-stable (many fields are `pub(super)`) and its MSRV moves quickly. -# 305849b71 == goose 1.44.0. -# -# default-features = false drops: telemetry, otel, aws-providers, -# local-inference (candle/whisper), code-mode (pctx/V8), system-keyring, nostr. -# We name only rustls-tls, which is mandatory — `goose` pulls reqwest/sqlx/rmcp -# with default-features = false, so a TLS backend only arrives via this feature -# (and `rustls-tls` + `native-tls` are mutually exclusive: see the -# compile_error! at goose/src/lib.rs:3-4). -goose = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab", default-features = false, features = ["rustls-tls"] } -goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab" } - -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] } -tokio-util = { version = "0.7", features = ["rt"] } -futures = "0.3" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -anyhow = "1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -rmcp = { version = "1.8", default-features = false, features = ["schemars"] } - -[dev-dependencies] -tempfile = "3" - -# Size-optimized. PR #1526 measured 32.5 MiB (macOS arm64) for a comparable -# goose-linked binary; re-measure rather than trusting that number. -[profile.release] -opt-level = "z" -lto = "fat" -codegen-units = 1 -panic = "abort" -strip = true diff --git a/buzz-agent-core/src/agent.rs b/buzz-agent-core/src/agent.rs deleted file mode 100644 index f3ece95198..0000000000 --- a/buzz-agent-core/src/agent.rs +++ /dev/null @@ -1,579 +0,0 @@ -//! The turn driver: Goose's agent loop, translated onto buzz-agent's ACP wire. -//! -//! This replaces roughly 8,000 lines of `crates/buzz-agent`: -//! -//! | buzz-agent file | lines | who owns it now | -//! |---|---:|---| -//! | `llm.rs` | 3846 | `goose::providers` (superset of the 4 providers) | -//! | `mcp.rs` | 1139 | `goose::agents::extension_manager` | -//! | `auth.rs` | 845 | `goose::providers` (incl. Databricks OAuth) | -//! | `agent.rs` | 746 | `goose::agents::Agent::reply` | -//! | `hints.rs` | 726 | `goose`'s hint loader (`prompt_manager::with_hints`) | -//! | `catalog.rs` | 631 | `goose::providers::init` model discovery | -//! | `builtin.rs` | 575 | `goose`'s `skills` platform extension | -//! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) | -//! -//! What this file keeps is the part Goose does *not* know about: the mapping -//! from `AgentEvent` onto the exact `session/update` notifications `buzz-acp` -//! consumes (`acp.rs:1528-1627`), and the `keepalive` ticker that resets the -//! harness idle clock. - -use std::sync::Arc; - -use futures::StreamExt; -use serde_json::{json, Value}; -use tokio_util::sync::CancellationToken; - -use goose::agents::{Agent, AgentEvent, SessionConfig}; -use goose_provider_types::conversation::message::{Message, MessageContent}; - -use crate::types::{AgentError, ContentBlock, StopReason}; -use crate::wire::{self, WireSender}; - -/// How often to emit `keepalive` while waiting on the provider. -/// -/// NOT part of ACP. `buzz-acp` runs an idle-timeout clock that is reset on -/// every line of valid JSON (`acp.rs:1197-1199`); a silent agent is killed. -/// buzz-agent emitted this from inside its provider `select!` -/// (`agent.rs:122-127`) and `buzz-acp` treats it as a no-op that only resets -/// the clock (`acp.rs:1623`). -/// -/// Goose streams, so during generation the chunks themselves keep the clock -/// alive — but there is no traffic during a long *pre-first-token* wait (big -/// prompt processing, provider queueing, reasoning models). So the ticker -/// stays. -const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); - -/// Appended by buzz-agent to every failed tool result (`agent.rs:21-22`) so -/// the model diagnoses a failure instead of blindly retrying it. -/// -/// buzz-agent mutated the tool result itself. Goose gives no interception -/// point for that: its `PostToolUseFailure` hook is fire-and-forget and its -/// output is discarded (`agent.rs:589-620`). So we deliver the same text as a -/// steer instead — goose drains pending steers at the round boundary -/// (`agent.rs:1951-1974`), which is exactly when the model would next act on -/// the failed result. Agent-visible, user-invisible. -const ERROR_REFLECTION: &str = - "[Reflect] Before retrying, identify the cause and change your approach."; - -/// Cap on reflections per turn, so a tool failing in a loop cannot flood the -/// conversation. -const MAX_REFLECTIONS: usize = 8; - -/// How long to let goose unwind after `session/cancel` before giving up. -/// -/// Cancellation is cooperative: goose has to notice the token, send -/// `notifications/cancelled` to each in-flight MCP request -/// (`mcp_client.rs:687-690`), emit the resulting tool responses, and end the -/// stream. Dropping the stream instead skips all of that. This bounds the -/// wait so a wedged MCP child cannot hold the turn open indefinitely. -const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - -/// Per-turn token accounting, mirroring buzz-agent's contract. -#[derive(Debug, Default, Clone, Copy)] -pub struct TurnTokens { - pub input: Option, - pub output: Option, -} - -impl TurnTokens { - pub fn observed(&self) -> bool { - self.input.is_some() || self.output.is_some() - } -} - -/// Flatten ACP prompt blocks into the text Goose's `Message` carries. -pub fn prompt_to_text(blocks: &[ContentBlock]) -> String { - let mut out = String::new(); - for b in blocks { - match b { - ContentBlock::Text { text } => { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(text); - } - ContentBlock::ResourceLink { uri } => { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(uri); - } - ContentBlock::Unsupported => {} - } - } - out -} - -/// Drive one `session/prompt` turn to completion. -/// -/// Returns the ACP stop reason plus the turn's token counts. The caller is -/// responsible for emitting `usage_update` *before* the `session/prompt` -/// response — that ordering is load-bearing for kind-44200 metrics -/// (`buzz-agent/src/lib.rs:701-706`). -/// -/// `hook_extension` names the MCP extension carrying `_Stop`/`_PostCompact` -/// (buzz-dev-mcp). When set, the turn is not allowed to end while `_Stop` -/// objects — see [`crate::hooks`]. -#[allow(clippy::too_many_arguments)] -pub async fn run_turn( - agent: Arc, - session_id: &str, - prompt: Vec, - max_rounds: Option, - wire_tx: &WireSender, - cancel: CancellationToken, - hook_extension: Option<&str>, -) -> (Result, TurnTokens) { - let mut tokens = TurnTokens::default(); - let mut next_message = Message::user().with_text(prompt_to_text(&prompt)); - let mut stop_blocks: u32 = 0; - - // Outer loop exists solely for the `_Stop` veto: goose's `reply()` stream - // ends when the model stops, so continuing means re-entering `reply()` - // with the objection appended. Goose's own Stop hook does the equivalent - // internally (`agent.rs:2891-2917`); we do it here because its - // `hook_manager` is private. - loop { - let stop = match drive_stream( - &agent, - session_id, - next_message, - max_rounds, - wire_tx, - &cancel, - &mut tokens, - hook_extension, - ) - .await - { - Ok(s) => s, - Err(e) => return (Err(e), tokens), - }; - - // Only a clean end-of-turn is vetoable. Cancellation and refusals pass - // through untouched. - if !matches!(stop, StopReason::EndTurn) || cancel.is_cancelled() { - return (Ok(stop), tokens); - } - - let Some(extension) = hook_extension else { - return (Ok(stop), tokens); - }; - - if stop_blocks >= crate::hooks::MAX_STOP_BLOCKS { - tracing::warn!( - blocks = stop_blocks, - "_Stop veto cap reached; ending turn anyway" - ); - return (Ok(stop), tokens); - } - - let Some(session) = current_session(session_id).await else { - return (Ok(stop), tokens); - }; - let Some(objection) = crate::hooks::stop_objection(&agent, &session, extension).await - else { - return (Ok(stop), tokens); - }; - - stop_blocks += 1; - tracing::info!(blocks = stop_blocks, "_Stop hook vetoed end of turn"); - - // Agent-visible, user-invisible — the objection steers the model - // without appearing in the channel, matching both buzz-agent and - // goose's own Deny handling. - next_message = Message::user() - .with_text(format!("[Stop] {objection}")) - .with_visibility(false, true); - } -} - -/// Look up the goose `Session` record needed to dispatch a hook tool. -async fn current_session(session_id: &str) -> Option { - goose::session::session_manager::SessionManager::instance() - .get_session(session_id, false) - .await - .ok() -} - -/// Drive a single `reply()` stream to completion. -#[allow(clippy::too_many_arguments)] -async fn drive_stream( - agent: &Arc, - session_id: &str, - message: Message, - max_rounds: Option, - wire_tx: &WireSender, - cancel: &CancellationToken, - tokens: &mut TurnTokens, - hook_extension: Option<&str>, -) -> Result { - let session_config = SessionConfig { - id: session_id.to_string(), - schedule_id: None, - max_turns: max_rounds, - retry_config: None, - }; - - let mut stream = match agent - .reply(message, session_config, Some(cancel.clone())) - .await - { - Ok(s) => s, - Err(e) => return Err(AgentError::Llm(e.to_string())), - }; - - let mut keepalive = tokio::time::interval(KEEPALIVE_INTERVAL); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - keepalive.tick().await; // first tick is immediate; discard it - - let mut stop = StopReason::EndTurn; - let mut compacted = false; - let mut reflections = 0usize; - - // Set once cancellation is observed; bounds how long we let goose unwind. - let mut drain: Option>> = None; - - // Tool calls announced to the harness, minus those that reached a terminal - // state. Anything still here when the stream ends gets a synthetic - // terminal update — see the cancel arm below. - let mut open_tool_calls: Vec = Vec::new(); - - loop { - tokio::select! { - // Keep the harness idle clock alive during provider silence. - _ = keepalive.tick() => { - wire::send( - wire_tx, - wire::session_update(session_id, json!({ "sessionUpdate": "keepalive" })), - ) - .await; - } - - // Cancellation is a cooperative DRAIN, not an abort. - // - // Breaking here drops `stream`, which drops the futures goose is - // awaiting — so `mcp_client.rs:688` never reaches its - // `cancel_token.cancelled()` arm and never sends - // `notifications/cancelled`. The MCP child keeps running its tool - // after the turn is over, and any announced `tool_call` never - // reaches a terminal state, leaving the desktop UI spinning - // (the invariant buzz-agent held at `agent.rs:470-477`). - // - // So we keep polling the stream and let goose unwind: it emits the - // tool responses, sends the MCP cancellations, and ends the stream - // itself. `drain` only bounds how long we are willing to wait. - _ = cancel.cancelled(), if drain.is_none() => { - stop = StopReason::Cancelled; - drain = Some(Box::pin(tokio::time::sleep(CANCEL_DRAIN_TIMEOUT))); - } - - // Goose did not unwind in time. Give up and synthesise terminal - // states, because a stuck spinner is worse than a wrong status. - _ = async { drain.as_mut().unwrap().await }, if drain.is_some() => { - tracing::warn!("cancel drain timed out; forcing terminal tool states"); - break; - } - - next = stream.next() => { - let Some(event) = next else { break }; - match event { - Ok(ev) => { - if let Some(reason) = handle_event( - ev, - agent, - session_id, - wire_tx, - tokens, - &mut compacted, - &mut reflections, - &mut open_tool_calls, - ) - .await - { - stop = reason; - break; - } - } - Err(e) => { - let msg = e.to_string(); - // Preserve buzz-agent's error taxonomy so the harness's - // JSON-RPC code mapping stays meaningful. - let err = if msg.contains("auth") || msg.contains("401") { - AgentError::LlmAuth(msg) - } else if msg.contains("model") && msg.contains("not found") { - AgentError::LlmModelNotFound(msg) - } else { - AgentError::Llm(msg) - }; - return Err(err); - } - } - } - } - } - - // Anything announced but never resolved gets a synthetic terminal update. - // buzz-agent guaranteed this invariant (`agent.rs:470-477`) because the - // desktop renders an unresolved `tool_call` as a spinner forever. Normally - // the drain above means this list is already empty; it only fires when - // goose failed to unwind inside CANCEL_DRAIN_TIMEOUT. - for tool_call_id in open_tool_calls.drain(..) { - wire::send( - wire_tx, - wire::session_update( - session_id, - json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": tool_call_id, - "status": "failed", - }), - ), - ) - .await; - } - - // Goose compacted history mid-turn. Re-inject the todo list so it survives - // the truncation — this is what buzz-agent's `_PostCompact` existed for - // (`handoff.rs:73-81`). Steering is the right channel: goose drains it at - // the next round boundary (`agent.rs:1951-1974`). - if compacted && !cancel.is_cancelled() { - if let Some(extension) = hook_extension { - if let Some(session) = current_session(session_id).await { - if let Some(state) = - crate::hooks::post_compact_state(agent, &session, extension).await - { - agent - .steer( - session_id, - Message::user() - .with_text(format!("[PostCompact] {state}")) - .with_visibility(false, true), - ) - .await; - } - } - } - } - - if cancel.is_cancelled() { - stop = StopReason::Cancelled; - } - - Ok(stop) -} - -/// Translate one `AgentEvent` into ACP notifications. -/// -/// Returns `Some(StopReason)` if the event terminates the turn. -#[allow(clippy::too_many_arguments)] -async fn handle_event( - event: AgentEvent, - agent: &Arc, - session_id: &str, - wire_tx: &WireSender, - tokens: &mut TurnTokens, - compacted: &mut bool, - reflections: &mut usize, - open_tool_calls: &mut Vec, -) -> Option { - match event { - AgentEvent::Message(msg) => { - for content in &msg.content { - emit_content(content, session_id, wire_tx).await; - - if let MessageContent::ToolRequest(req) = content { - open_tool_calls.push(req.id.clone()); - } - - if let MessageContent::ToolResponse(resp) = content { - open_tool_calls.retain(|id| id != &resp.id); - - let failed = match &resp.tool_result { - Ok(r) => r.is_error.unwrap_or(false), - Err(_) => true, - }; - if failed && *reflections < MAX_REFLECTIONS { - *reflections += 1; - agent - .steer( - session_id, - Message::user() - .with_text(ERROR_REFLECTION) - .with_visibility(false, true), - ) - .await; - } - } - } - None - } - - // Usage arrives per provider chunk, already cost-enriched. Accumulate - // rather than overwrite: `MessageUsage` is suppressed when the last - // assistant message has no user-visible content (goose - // `agent.rs:299`), so it is not a reliable sole source. - AgentEvent::Usage(usage) => { - let u = &usage.usage; - if let Some(i) = u.input_tokens { - tokens.input = Some(tokens.input.unwrap_or(0) + i.max(0) as u64); - } - if let Some(o) = u.output_tokens { - tokens.output = Some(tokens.output.unwrap_or(0) + o.max(0) as u64); - } - None - } - - // Compaction happened. buzz-agent surfaced this as a `[Context - // Handoff]` history rewrite; Goose has already done the rewrite, so we - // only flag it — the caller then asks `_PostCompact` for state to - // re-inject once the stream settles. - AgentEvent::HistoryReplaced(_) => { - tracing::info!(target: "buzz_agent::compaction", "history compacted"); - *compacted = true; - None - } - - _ => None, - } -} - -/// Map message content onto the nine `sessionUpdate` variants `buzz-acp` -/// recognises (`acp.rs:1528-1627`). Anything else it debug-logs and drops. -async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &WireSender) { - match content { - MessageContent::Text(t) if !t.text.is_empty() => { - wire::send( - wire_tx, - wire::session_update( - session_id, - json!({ - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": t.text }, - }), - ), - ) - .await; - } - - MessageContent::Thinking(t) if !t.thinking.is_empty() => { - wire::send( - wire_tx, - wire::session_update( - session_id, - json!({ - "sessionUpdate": "agent_thought_chunk", - "content": { "type": "text", "text": t.thinking }, - }), - ), - ) - .await; - } - - // Tool lifecycle. buzz-agent guaranteed every announced tool reached a - // terminal state, or the desktop UI shows a stuck spinner - // (`agent.rs:470-477`). Goose emits request and response as separate - // messages, so the pairing is by tool-call id. - MessageContent::ToolRequest(req) => { - let (name, raw) = match &req.tool_call { - Ok(call) => ( - call.name.to_string(), - serde_json::to_value(&call.arguments).unwrap_or(Value::Null), - ), - Err(e) => (String::from("unknown"), json!({ "error": e.to_string() })), - }; - wire::send( - wire_tx, - wire::session_update( - session_id, - json!({ - "sessionUpdate": "tool_call", - "toolCallId": req.id, - "title": name, - "kind": "other", - "status": "in_progress", - "rawInput": raw, - }), - ), - ) - .await; - } - - MessageContent::ToolResponse(resp) => { - let failed = match &resp.tool_result { - Ok(r) => r.is_error.unwrap_or(false), - Err(_) => true, - }; - wire::send( - wire_tx, - wire::session_update( - session_id, - json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": resp.id, - "status": if failed { "failed" } else { "completed" }, - }), - ), - ) - .await; - } - - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prompt_flattens_text_blocks() { - let blocks = vec![ - ContentBlock::Text { - text: "hello".into(), - }, - ContentBlock::Text { - text: "world".into(), - }, - ]; - assert_eq!(prompt_to_text(&blocks), "hello\nworld"); - } - - #[test] - fn prompt_includes_resource_links() { - let blocks = vec![ - ContentBlock::Text { text: "see".into() }, - ContentBlock::ResourceLink { - uri: "file:///a".into(), - }, - ]; - assert_eq!(prompt_to_text(&blocks), "see\nfile:///a"); - } - - #[test] - fn prompt_skips_unsupported() { - let blocks = vec![ - ContentBlock::Unsupported, - ContentBlock::Text { - text: "only".into(), - }, - ]; - assert_eq!(prompt_to_text(&blocks), "only"); - } - - #[test] - fn turn_tokens_observed_requires_a_count() { - assert!(!TurnTokens::default().observed()); - assert!(TurnTokens { - input: Some(1), - output: None - } - .observed()); - assert!(TurnTokens { - input: None, - output: Some(1) - } - .observed()); - } -} diff --git a/buzz-agent-core/src/config.rs b/buzz-agent-core/src/config.rs deleted file mode 100644 index 58045fbd31..0000000000 --- a/buzz-agent-core/src/config.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Environment configuration, translated into Goose's native vocabulary. -//! -//! This replaces `crates/buzz-agent/src/config.rs` (2,709 lines). Most of that -//! file existed to *implement* provider configuration: the `Provider` enum, -//! per-provider base URLs, model-name normalization, Databricks host parsing, -//! OpenAI auto-upgrade rules, and the resolution order between them. -//! -//! Goose already owns all of that (`goose::config`, `goose::providers`). So -//! what remains here is a translation table: read the `BUZZ_AGENT_*` variables -//! `buzz-acp` injects (see `desktop/src-tauri/src/managed_agents/runtime.rs`), -//! and set the `GOOSE_*` / provider variables Goose reads. -//! -//! The mapping is applied to the *process* environment before the Goose -//! `Config` singleton is first touched, because `Config::global()` reads env at -//! initialization. - -use goose_provider_types::goose_mode::GooseMode; - -/// ACP protocol version. Buzz squats on v2 ahead of the upstream RFD; see the -/// note in `lib.rs::initialize`. -pub const PROTOCOL_VERSION: u32 = 2; - -/// Hard caps preserved from buzz-agent's wire contract. These are protocol -/// limits (rejections are `invalid_params`), not loop tuning, so they stay. -pub const MAX_PROMPT_BYTES: usize = 1024 * 1024; -pub const MAX_SYSTEM_PROMPT_BYTES: usize = 512 * 1024; -pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; - -#[derive(Debug, Clone)] -pub struct Config { - /// Model id, if pinned by the harness. `None` lets Goose resolve its own - /// default from `GOOSE_MODEL` / its config file. - pub model: Option, - /// Max provider round-trips per turn. Maps to `SessionConfig.max_turns`. - pub max_rounds: Option, - /// Concurrent sessions this process will hold. - pub max_sessions: usize, - /// Default system prompt, used only when `session/new` omits one. - pub system_prompt: Option, - /// Per-turn wall-clock budget for a single provider request. - pub llm_timeout_secs: u64, - /// Tool-call approval policy. - /// - /// `GooseMode::default()` is **`Auto`** (`goose_mode.rs:23-25`) — every - /// tool call is approved without asking. That matches what buzz ships - /// today (`buzz-acp/src/acp.rs:1671-1712` auto-approves every permission - /// request, and the desktop catalog sets `GOOSE_MODE=auto` for the - /// external goose runtime, `discovery.rs:89`), so it stays the default - /// here to avoid changing behaviour. - /// - /// It is now a knob rather than a hardcode: `BUZZ_AGENT_APPROVAL=approve` - /// makes goose ask before every tool call, `smart_approve` only for - /// sensitive ones, `chat` disables tools entirely. Nothing in buzz drives - /// this yet — wiring it to a real human affordance is the point of the - /// isolation work, and this is the seam it will use. - pub goose_mode: GooseMode, -} - -/// Map `BUZZ_AGENT_APPROVAL` onto goose's tool-approval policy. -/// -/// Unknown values fall back to the current shipped behaviour (`Auto`) rather -/// than failing the process: a typo in an env var must not take an agent off -/// the air, and silently tightening would be just as surprising as silently -/// loosening. -fn parse_approval(raw: Option<&str>) -> GooseMode { - match raw.map(str::trim).map(str::to_ascii_lowercase).as_deref() { - Some("approve") => GooseMode::Approve, - Some("smart_approve") | Some("smart-approve") => GooseMode::SmartApprove, - Some("chat") => GooseMode::Chat, - Some("auto") | None => GooseMode::Auto, - Some(other) => { - tracing::warn!(value = other, "unknown BUZZ_AGENT_APPROVAL; using auto"); - GooseMode::Auto - } - } -} - -fn env_str(key: &str) -> Option { - std::env::var(key).ok().filter(|s| !s.trim().is_empty()) -} - -fn env_parse(key: &str) -> Option { - env_str(key).and_then(|s| s.parse().ok()) -} - -impl Config { - /// Read `BUZZ_AGENT_*` from the environment and, as a side effect, project - /// the provider-shaped ones onto the `GOOSE_*` names Goose reads. - pub fn from_env() -> Self { - Self::project_goose_env(); - - let system_prompt = env_str("BUZZ_AGENT_SYSTEM_PROMPT").or_else(|| { - env_str("BUZZ_AGENT_SYSTEM_PROMPT_FILE") - .and_then(|p| std::fs::read_to_string(p).ok()) - .filter(|s| !s.trim().is_empty()) - }); - - Self { - model: env_str("BUZZ_AGENT_MODEL"), - max_rounds: env_parse::("BUZZ_AGENT_MAX_ROUNDS").filter(|n| *n > 0), - max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(8), - system_prompt, - llm_timeout_secs: env_parse("BUZZ_AGENT_LLM_TIMEOUT_SECS").unwrap_or(600), - goose_mode: parse_approval(env_str("BUZZ_AGENT_APPROVAL").as_deref()), - } - } - - /// Translate Buzz's provider configuration into Goose's environment. - /// - /// Mirrors `goose_env.rs` from PR #1526. - fn project_goose_env() { - // Provider: Buzz's `openai-compat` and `relay-mesh` are both - // OpenAI-wire-compatible, and Goose knows them as plain `openai`. - if let Some(provider) = env_str("BUZZ_AGENT_PROVIDER") { - let goose_provider = match provider.as_str() { - "openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => "openai", - other => other, - }; - set_if_absent("GOOSE_PROVIDER", goose_provider); - } - - if let Some(model) = env_str("BUZZ_AGENT_MODEL") { - set_if_absent("GOOSE_MODEL", &model); - } - - // Key/base-url aliasing: native Goose names win if already present. - if let Some(key) = env_str("OPENAI_COMPAT_API_KEY") { - set_if_absent("OPENAI_API_KEY", &key); - } - if let Some(base) = env_str("OPENAI_COMPAT_BASE_URL") { - set_if_absent("OPENAI_BASE_URL", &base); - } - - if let Some(effort) = env_str("BUZZ_AGENT_THINKING_EFFORT") { - set_if_absent("GOOSE_THINKING_EFFORT", &effort); - } - if let Some(max_tokens) = env_str("BUZZ_AGENT_MAX_OUTPUT_TOKENS") { - set_if_absent("GOOSE_MAX_TOKENS", &max_tokens); - } - if let Some(ctx) = env_str("BUZZ_AGENT_MAX_CONTEXT_TOKENS") { - set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx); - } - } -} - -fn set_if_absent(key: &str, value: &str) { - if std::env::var_os(key).is_none() { - std::env::set_var(key, value); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Env is process-global; these tests set disjoint keys and assert only on - // the pure mapping helpers where possible. - - #[test] - fn set_if_absent_does_not_clobber() { - std::env::set_var("BUZZ_TEST_EXISTING", "native"); - set_if_absent("BUZZ_TEST_EXISTING", "translated"); - assert_eq!(std::env::var("BUZZ_TEST_EXISTING").unwrap(), "native"); - std::env::remove_var("BUZZ_TEST_EXISTING"); - } - - #[test] - fn set_if_absent_fills_missing() { - std::env::remove_var("BUZZ_TEST_MISSING"); - set_if_absent("BUZZ_TEST_MISSING", "translated"); - assert_eq!(std::env::var("BUZZ_TEST_MISSING").unwrap(), "translated"); - std::env::remove_var("BUZZ_TEST_MISSING"); - } - - #[test] - fn approval_defaults_to_auto() { - // Matches what buzz ships today; changing this silently would alter - // the security posture of every existing agent. - assert_eq!(parse_approval(None), GooseMode::Auto); - assert_eq!(parse_approval(Some("auto")), GooseMode::Auto); - } - - #[test] - fn approval_parses_the_stricter_modes() { - assert_eq!(parse_approval(Some("approve")), GooseMode::Approve); - assert_eq!(parse_approval(Some(" APPROVE ")), GooseMode::Approve); - assert_eq!( - parse_approval(Some("smart_approve")), - GooseMode::SmartApprove - ); - assert_eq!( - parse_approval(Some("smart-approve")), - GooseMode::SmartApprove - ); - assert_eq!(parse_approval(Some("chat")), GooseMode::Chat); - } - - #[test] - fn approval_falls_back_to_auto_on_garbage() { - // A typo must not take an agent off the air, and must not silently - // tighten either — both would be surprising. - assert_eq!(parse_approval(Some("yolo")), GooseMode::Auto); - } - - #[test] - fn stop_reason_wire_strings_are_stable() { - use crate::types::StopReason; - // buzz-acp parses these; drift breaks turn completion. - assert_eq!(StopReason::EndTurn.as_wire(), "end_turn"); - assert_eq!(StopReason::Cancelled.as_wire(), "cancelled"); - assert_eq!(StopReason::MaxTurnRequests.as_wire(), "max_turn_requests"); - } -} diff --git a/buzz-agent-core/src/lib.rs b/buzz-agent-core/src/lib.rs deleted file mode 100644 index 690ef41678..0000000000 --- a/buzz-agent-core/src/lib.rs +++ /dev/null @@ -1,848 +0,0 @@ -//! buzz-agent's ACP server, with Goose as the agent loop. -//! -//! This is the layer `buzz-acp` actually talks to, and it is deliberately -//! unchanged in contract from `crates/buzz-agent/src/lib.rs` (960 lines): -//! the same six JSON-RPC methods, the same `agentInfo.name = "buzz-agent"`, -//! the same `activeRunId` steering handshake, the same `usage_update` -//! ordering. Only the loop underneath is Goose's. -//! -//! Not standard ACP, and preserved here on purpose (each of these is -//! something `buzz-acp` or the desktop UI depends on): -//! -//! * `_goose/unstable/session/steer` with `expectedRunId` optimistic -//! concurrency, and `activeRunId` advertised via -//! `params.update._meta.goose.activeRunId` — note the `_meta` nests *inside* -//! `update` (`buzz-acp/src/acp.rs:1607-1613`). Get the depth wrong and the -//! harness silently falls back to cancel+re-prompt forever. -//! * `usage_update` on the `_goose/unstable/session/update` channel, emitted -//! *before* the `session/prompt` response, suppressed when no tokens were -//! seen (`buzz-agent/src/lib.rs:701-712`). -//! * `keepalive` — see `agent.rs`. - -pub mod agent; -pub mod config; -pub mod hooks; -pub mod types; -pub mod wire; - -use std::collections::HashMap; -use std::sync::Arc; - -use serde_json::{json, Value}; -use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; - -use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform}; -use goose::config::PermissionManager; -use goose::session::session_manager::{SessionManager, SessionType}; - -use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; -use types::{AgentError, McpServerStdio}; -use wire::{ - classify, goose_session_update, Inbound, InitializeParams, SessionCancelParams, - SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireSender, - INVALID_PARAMS, METHOD_NOT_FOUND, -}; - -/// One live ACP session, wrapping a Goose `Agent`. -struct Session { - agent: Arc, - /// Goose's own session id (the SQLite-backed conversation). - goose_session_id: String, - busy: bool, - /// Set for the duration of a turn; advertised to steer-capable clients. - active_run_id: Option, - cancel: Option, - /// Model id currently in force for this session (`session/set_model`). - /// Reported back on `usage_update`. - model_override: Option, - /// Set by `session/set_model`, consumed by the next `session/prompt`. - /// Applying it at prompt time rather than immediately matches buzz-agent: - /// the override takes effect "from the next prompt" and never mutates a - /// turn already in flight (`buzz-agent/src/lib.rs:494-502`). - pending_model: Option, - /// Name of the MCP extension carrying `_Stop`/`_PostCompact`, if any. - /// See [`crate::hooks`] for why we dispatch these ourselves. - hook_extension: Option, - accumulated_input_tokens: u64, - accumulated_output_tokens: u64, -} - -pub struct App { - cfg: Config, - sessions: Mutex>, -} - -/// Build a Goose agent for one ACP session. -/// -/// Note `Agent::with_config` loads **zero** extensions — a tool-free agent is -/// the default, not something we switch off (goose `agent.rs:362-420`). Tools -/// arrive only via the `mcpServers` the harness declares in `session/new`, -/// which is how `buzz-dev-mcp` (shell/read_file/str_replace/todo + the -/// `_Stop`/`_PostCompact` hooks + the `buzz`/`rg`/`tree` PATH shim) is wired. -async fn build_agent( - cfg: &Config, - cwd: &str, - system_prompt: Option<&str>, - mcp_servers: &[McpServerStdio], -) -> Result<(Arc, String, Option), AgentError> { - let session_manager = Arc::new(SessionManager::instance()); - let permission_manager = PermissionManager::instance(); - - let agent = Agent::with_config(AgentConfig::new( - session_manager.clone(), - permission_manager, - None, - cfg.goose_mode, - // Session naming is a Goose UX affordance; the harness names sessions. - true, - GoosePlatform::GooseCli, - )); - - let session = session_manager - .create_session( - std::path::PathBuf::from(cwd), - "buzz-agent".to_string(), - SessionType::Acp, - cfg.goose_mode, - ) - .await - .map_err(|e| AgentError::Llm(format!("session create: {e}")))?; - - // Provider. `Agent::with_config` starts with no provider set, so this is - // mandatory before the first `reply()` — otherwise the turn fails with - // "Provider not set". - // - // Provider/model names come from the `GOOSE_*` variables `Config::from_env` - // projected out of the `BUZZ_AGENT_*` ones; goose's registry owns base-url - // and credential resolution from there. - let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); - let model_name = cfg - .model - .clone() - .or_else(|| std::env::var("GOOSE_MODEL").ok()) - .ok_or_else(|| AgentError::Llm("no model configured".into()))?; - - let provider = goose::providers::create(&provider_name, Vec::new()) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; - let model_config = - goose::model_config::model_config_from_user_config(&provider_name, &model_name) - .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; - - agent - .update_provider(provider, model_config, &session.id) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; - - // Persona. This is the seam that does NOT work over plain ACP: goose's own - // ACP server never reads `systemPrompt` (rg finds zero hits in - // goose/crates/goose/src), and both PRs that would have wired it — - // buzz#1290 and goose#9971 — are closed unmerged. Driving the library - // directly is what makes Fizz (and `[Base]`) actually arrive. - if let Some(sp) = system_prompt.or(cfg.system_prompt.as_deref()) { - if !sp.trim().is_empty() { - agent.override_system_prompt(sp.to_string()).await; - } - } - - for server in mcp_servers { - let envs: HashMap = server - .env - .iter() - .map(|e| (e.name.clone(), e.value.clone())) - .collect(); - - let ext = ExtensionConfig::Stdio { - name: server.name.clone(), - description: String::new(), - cmd: server.command.clone(), - args: server.args.clone(), - envs: goose::agents::extension::Envs::new(envs), - env_keys: Vec::new(), - timeout: Some(300), - cwd: Some(cwd.to_string()), - bundled: None, - available_tools: Vec::new(), - }; - - if let Err(e) = agent.add_extension(ext, &session.id).await { - // Match buzz-agent: a failed MCP server is a hard session error, - // not a silently tool-less agent. - return Err(AgentError::Mcp(format!("{}: {e}", server.name))); - } - } - - let agent = Arc::new(agent); - - // Find the extension carrying `_Stop`, by asking rather than assuming the - // name — `buzz-acp` derives it from the MCP binary's file stem - // (`buzz-acp/src/lib.rs:4145-4149`), so it is not a fixed string. - // - // KNOWN DEVIATION: buzz-agent hid `_`-prefixed tools from the model - // (`agent.rs:328-336`) while still calling them itself. Goose's - // `available_tools` allowlist gates advertising *and* dispatch through the - // same cache (`extension_manager.rs:1421`, `:1698`), so hiding them would - // also make them undispatchable — which would break the veto. They stay - // visible; `hook_tool_guidance()` tells the model to leave them alone. - let mut hook_extension = None; - for tool in agent.list_tools(&session.id, None).await { - if tool.name.ends_with("___Stop") { - hook_extension = tool - .name - .strip_suffix("___Stop") - .map(|prefix| prefix.to_string()); - break; - } - } - - if let Some(ext) = &hook_extension { - agent - .extend_system_prompt("buzz_hook_tools".to_string(), hook_tool_guidance()) - .await; - tracing::info!(extension = %ext, "lifecycle hooks available"); - } - - Ok((agent, session.id, hook_extension)) -} - -/// Keep the model's hands off the lifecycle hooks. -/// -/// They are ordinary MCP tools on the wire, so a generic harness advertises -/// them. buzz-agent solved this by hiding them; we cannot (see `build_agent`), -/// so we ask instead. -fn hook_tool_guidance() -> String { - "Tools whose names begin with an underscore (`_Stop`, `_PostCompact`) are \ - lifecycle hooks invoked automatically by the runtime. Never call them \ - yourself. Use the `todo` tool to manage your task list." - .to_string() -} - -pub async fn serve(cfg: Config) -> anyhow::Result<()> { - let (wire_tx, wire_rx) = tokio::sync::mpsc::channel(256); - tokio::spawn(wire::writer_task(wire_rx)); - - let app = Arc::new(App { - cfg, - sessions: Mutex::new(HashMap::new()), - }); - - let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); - loop { - match wire::read_bounded_line(&mut stdin, config::MAX_LINE_BYTES).await { - Ok(None) => break, - Ok(Some(line)) => { - if line.trim().is_empty() { - continue; - } - match serde_json::from_str::(&line) { - Ok(msg) => dispatch(&app, msg, &wire_tx).await, - Err(e) => { - wire::send( - &wire_tx, - wire::err( - Value::Null, - wire::PARSE_ERROR, - &format!("jsonrpc: parse: {e}"), - ), - ) - .await; - } - } - } - Err(e) => { - tracing::error!("io: {e}"); - break; - } - } - } - Ok(()) -} - -async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { - match classify(&msg) { - Inbound::Request { id, method, params } => { - handle_request(app, id, method, params, wire_tx).await - } - Inbound::Notification { method, params } => { - if method == "session/cancel" { - cancel_session(app, params).await; - } - } - Inbound::Ignored => {} - Inbound::Invalid { id, code, message } => { - wire::send(wire_tx, wire::err(id, code, &message)).await - } - } -} - -async fn handle_request( - app: &Arc, - id: Value, - method: String, - params: Value, - wire_tx: &WireSender, -) { - match method.as_str() { - "initialize" => initialize(id, params, wire_tx).await, - "session/new" => { - let app = app.clone(); - let wire_tx = wire_tx.clone(); - // Spawned so a slow MCP init can't block the read loop — otherwise - // `session/cancel` can't be received. - tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await }); - } - "session/prompt" => { - let app = app.clone(); - let wire_tx = wire_tx.clone(); - tokio::spawn(async move { session_prompt(&app, id, params, &wire_tx).await }); - } - "session/set_model" => set_model(app, id, params, wire_tx).await, - "session/cancel" => { - cancel_session(app, params).await; - wire::send(wire_tx, wire::ok(id, Value::Null)).await; - } - "_goose/unstable/session/steer" => steer(app, id, params, wire_tx).await, - _ => { - wire::send( - wire_tx, - wire::err( - id, - METHOD_NOT_FOUND, - &format!("jsonrpc: method not found: {method}"), - ), - ) - .await - } - } -} - -async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { - let p: InitializeParams = match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, &format!("initialize: {e}")), - ) - .await - } - }; - // Honest negotiation: min(client, ours). Buzz squats on v2 ahead of the - // upstream ACP RFD (#1237); see buzz-agent/src/lib.rs:279-283. - let negotiated = p.protocol_version.min(PROTOCOL_VERSION); - wire::send( - wire_tx, - wire::ok( - id, - json!({ - "protocolVersion": negotiated, - "agentCapabilities": { - "loadSession": false, - "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, - "mcpCapabilities": { "http": false, "sse": false }, - }, - // Identity is unchanged on purpose: this is still buzz-agent. - // The `harness` field of the encrypted kind-44200 turn metric - // derives from this (`buzz-acp/src/pool.rs:3350`), so changing - // it would blank any dashboard filtering on it. - "agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") }, - }), - ), - ) - .await; -} - -async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionNewParams = match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, &format!("session/new: {e}")), - ) - .await - } - }; - - if !std::path::Path::new(&p.cwd).is_absolute() { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/new: cwd must be absolute"), - ) - .await; - } - - if let Some(sp) = &p.system_prompt { - if sp.len() > MAX_SYSTEM_PROMPT_BYTES { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/new: systemPrompt too large"), - ) - .await; - } - } - - if app.sessions.lock().await.len() >= app.cfg.max_sessions { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/new: max sessions reached"), - ) - .await; - } - - let (agent, goose_session_id, hook_extension) = - match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { - Ok(v) => v, - Err(e) => { - return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await - } - }; - - let sid = format!("ses_{}", goose_session_id); - - // Keep a handle for catalog discovery below; the Session takes ownership. - let session_agent = agent.clone(); - let current_model = app - .cfg - .model - .clone() - .or_else(|| std::env::var("GOOSE_MODEL").ok()) - .unwrap_or_default(); - - app.sessions.lock().await.insert( - sid.clone(), - Session { - agent, - goose_session_id, - busy: false, - active_run_id: None, - cancel: None, - model_override: None, - pending_model: None, - hook_extension, - accumulated_input_tokens: 0, - accumulated_output_tokens: 0, - }, - ); - - // Advertise the model catalog. `buzz-acp` reads `models.availableModels` - // off this response (`acp.rs:1866`, `:1900`) to drive the desktop - // ModelPicker and to resolve `session/set_model` targets - // (`resolve_model_switch_method`, `acp.rs:1876`). Omitting it degrades the - // picker to "current model only" — which is what buzz-agent's `catalog.rs` - // existed to prevent. - // - // Goose builds the same structure internally (`build_model_state`, - // acp/response_builder.rs:130) but it is `pub(super)`, so an embedder - // cannot call it. The underlying data is public, though: - // `Provider::fetch_supported_models` (goose-provider-types/base.rs:425). - let mut result = json!({ "sessionId": sid }); - if let Some(models) = discover_models(&session_agent, ¤t_model).await { - result["models"] = models; - } - - wire::send(wire_tx, wire::ok(id, result)).await; -} - -/// Build the `{currentModelId, availableModels}` object for `session/new`. -/// -/// Mirrors goose's own `build_model_state`, including its rule that the -/// current model is prepended when the provider's list omits it — otherwise -/// `buzz-acp` cannot resolve a switch back to it. -/// -/// Returns `None` when the provider cannot enumerate models (many can't; -/// `fetch_supported_models` defaults to an empty list). A missing catalog is -/// degraded UX, never a session failure — buzz-agent's Databricks discovery -/// made the same choice (`catalog.rs:52-80`). -async fn discover_models(agent: &Arc, current_model: &str) -> Option { - let provider = agent.provider().await.ok()?; - let ids = provider.fetch_supported_models().await.ok()?; - - let mut available: Vec = ids - .iter() - .map(|id| json!({ "modelId": id, "name": id })) - .collect(); - - if !ids.iter().any(|id| id == current_model) { - available.insert( - 0, - json!({ "modelId": current_model, "name": current_model }), - ); - } - - if available.is_empty() { - return None; - } - - Some(json!({ - "currentModelId": current_model, - "availableModels": available, - })) -} - -async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionPromptParams = match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, &format!("session/prompt: {e}")), - ) - .await - } - }; - - let prompt_bytes: usize = p - .prompt - .iter() - .map(|b| match b { - types::ContentBlock::Text { text } => text.len(), - types::ContentBlock::ResourceLink { uri } => uri.len(), - types::ContentBlock::Unsupported => 0, - }) - .sum(); - if prompt_bytes > MAX_PROMPT_BYTES { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/prompt: prompt too large"), - ) - .await; - } - - let run_id = format!("run_{}", uuid_like()); - let cancel = CancellationToken::new(); - - // Single-flight per session, and capture the agent handle. - let (agent, goose_session_id, pending_model, hook_extension) = { - let mut sessions = app.sessions.lock().await; - let Some(s) = sessions.get_mut(&p.session_id) else { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/prompt: unknown session"), - ) - .await; - }; - if s.busy { - return wire::send( - wire_tx, - wire::err( - id, - INVALID_PARAMS, - "session/prompt: prompt already in flight", - ), - ) - .await; - } - s.busy = true; - s.active_run_id = Some(run_id.clone()); - s.cancel = Some(cancel.clone()); - // Take the pending override so a `session/set_model` applies exactly - // once, from the next prompt onward (buzz-agent `lib.rs:494-502`). - ( - s.agent.clone(), - s.goose_session_id.clone(), - s.pending_model.take(), - s.hook_extension.clone(), - ) - }; - - // Apply a pending `session/set_model` before the turn starts. - if let Some(model_id) = pending_model { - if let Err(e) = apply_model(&agent, &goose_session_id, &model_id).await { - { - let mut sessions = app.sessions.lock().await; - if let Some(s) = sessions.get_mut(&p.session_id) { - s.busy = false; - s.active_run_id = None; - s.cancel = None; - } - } - return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await; - } - } - - // Advertise the run id so steer-capable clients can target this turn. - // `_meta` nests INSIDE `update` — see the module docs. - wire::send( - wire_tx, - wire::session_update_with_goose_meta( - &p.session_id, - json!({ "sessionUpdate": "session_info_update" }), - json!({ "activeRunId": run_id }), - ), - ) - .await; - - let (result, tokens) = agent::run_turn( - agent, - &goose_session_id, - p.prompt, - app.cfg.max_rounds, - wire_tx, - cancel, - hook_extension.as_deref(), - ) - .await; - - // Clear run state so a late steer can't queue into a finished turn. - let accumulated = { - let mut sessions = app.sessions.lock().await; - sessions.get_mut(&p.session_id).map(|s| { - s.busy = false; - s.active_run_id = None; - s.cancel = None; - s.accumulated_input_tokens = s - .accumulated_input_tokens - .saturating_add(tokens.input.unwrap_or(0)); - s.accumulated_output_tokens = s - .accumulated_output_tokens - .saturating_add(tokens.output.unwrap_or(0)); - (s.accumulated_input_tokens, s.accumulated_output_tokens) - }) - }; - - wire::send( - wire_tx, - wire::session_update_with_goose_meta( - &p.session_id, - json!({ "sessionUpdate": "session_info_update" }), - json!({ "activeRunId": Value::Null }), - ), - ) - .await; - - // ORDERING IS LOAD-BEARING: emit usage BEFORE the prompt response. - // buzz-acp's UsageTracker processes this while the turn is still in - // flight; the response triggers take_turn_usage(). Reversing these - // produces zero kind-44200 events, silently. - if tokens.observed() { - if let Some((acc_in, acc_out)) = accumulated { - let model = { - let sessions = app.sessions.lock().await; - sessions - .get(&p.session_id) - .and_then(|s| s.model_override.clone()) - .or_else(|| app.cfg.model.clone()) - .unwrap_or_default() - }; - wire::send( - wire_tx, - goose_session_update( - &p.session_id, - json!({ - "sessionUpdate": "usage_update", - "used": acc_in.saturating_add(acc_out), - "contextLimit": 0u64, - "accumulatedInputTokens": acc_in, - "accumulatedOutputTokens": acc_out, - "model": model, - }), - ), - ) - .await; - } - } - - match result { - Ok(stop) => { - wire::send( - wire_tx, - wire::ok(id, json!({ "stopReason": stop.as_wire() })), - ) - .await - } - Err(e) => wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await, - } -} - -async fn set_model(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionSetModelParams = match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, &format!("session/set_model: {e}")), - ) - .await - } - }; - if p.model_id.trim().is_empty() { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/set_model: empty modelId"), - ) - .await; - } - let mut sessions = app.sessions.lock().await; - match sessions.get_mut(&p.session_id) { - Some(s) => { - s.model_override = Some(p.model_id.clone()); - s.pending_model = Some(p.model_id); - drop(sessions); - wire::send(wire_tx, wire::ok(id, Value::Null)).await; - } - None => { - drop(sessions); - wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "session/set_model: unknown session"), - ) - .await - } - } -} - -/// Non-cancelling mid-turn steering. -/// -/// Goose's own `steer()` has exactly buzz-agent's semantics: the message is -/// queued and drained at the *round boundary* (goose `agent.rs:1951-1974`), -/// the turn is not restarted, and a pending steer even prevents the turn from -/// ending (`agent.rs:2876-2878`). -async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionSteerParams = match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, &format!("steer: {e}")), - ) - .await - } - }; - - let (agent, goose_session_id) = { - let sessions = app.sessions.lock().await; - let Some(s) = sessions.get(&p.session_id) else { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "steer: unknown session"), - ) - .await; - }; - // Optimistic concurrency: the harness distinguishes "no active run" - // from "run id mismatch" to decide whether to fire its cancel+merge - // fallback (`buzz-acp/src/pool.rs:329-366`). - let Some(active) = s.active_run_id.as_deref() else { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "steer: no active run"), - ) - .await; - }; - if active != p.expected_run_id { - return wire::send( - wire_tx, - wire::err(id, INVALID_PARAMS, "steer: run id mismatch"), - ) - .await; - } - (s.agent.clone(), s.goose_session_id.clone()) - }; - - let text = agent::prompt_to_text(&p.prompt); - if text.trim().is_empty() { - // Best-effort by contract: a whitespace-only steer is dropped, never - // fatal to the turn (buzz-agent `agent.rs:266-278`). - return wire::send(wire_tx, wire::ok(id, json!({ "runId": p.expected_run_id }))).await; - } - - let message_id = format!("steer_{}", uuid_like()); - agent - .steer( - &goose_session_id, - goose_provider_types::conversation::message::Message::user().with_text(text), - ) - .await; - - wire::send( - wire_tx, - wire::ok( - id, - json!({ "runId": p.expected_run_id, "messageId": message_id }), - ), - ) - .await; -} - -async fn cancel_session(app: &Arc, params: Value) { - let Ok(p) = serde_json::from_value::(params) else { - return; - }; - let sessions = app.sessions.lock().await; - if let Some(s) = sessions.get(&p.session_id) { - if let Some(c) = &s.cancel { - // Goose plumbs this token down into MCP tool calls and sends - // `notifications/cancelled` per in-flight request - // (goose `mcp_client.rs:687-690`) — a cooperative drain rather - // than a hard abort, matching buzz-agent's contract. - c.cancel(); - } - } -} - -/// Swap the model for an existing session. -/// -/// Rebuilds the provider (base-url/credential resolution lives in goose's -/// registry) and installs it with the new `ModelConfig`. `SharedProvider` is an -/// `Arc>>` precisely so this is hot-swappable -/// (goose `agents/types.rs:11-12`). -async fn apply_model( - agent: &Arc, - session_id: &str, - model_id: &str, -) -> Result<(), AgentError> { - let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); - let provider = goose::providers::create(&provider_name, Vec::new()) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; - let model_config = goose::model_config::model_config_from_user_config(&provider_name, model_id) - .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; - agent - .update_provider(provider, model_config, session_id) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; - Ok(()) -} - -/// Preserve buzz-agent's error taxonomy across provider construction, so the -/// harness's JSON-RPC code mapping (-32001 auth, -32002 model-not-found) keeps -/// its meaning. -fn map_provider_error(msg: &str) -> AgentError { - let lower = msg.to_ascii_lowercase(); - if lower.contains("auth") || lower.contains("401") || lower.contains("api key") { - AgentError::LlmAuth(msg.to_string()) - } else if lower.contains("model") && lower.contains("not found") { - AgentError::LlmModelNotFound(msg.to_string()) - } else { - AgentError::Llm(msg.to_string()) - } -} - -/// Short random id. Not a UUID; just needs to be unguessable enough that a -/// stale steer can't collide with a live run. -fn uuid_like() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("{nanos:x}") -} - -pub fn run() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_writer(std::io::stderr) - .init(); - - let cfg = Config::from_env(); - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()? - .block_on(serve(cfg))?; - Ok(()) -} diff --git a/buzz-agent-core/src/main.rs b/buzz-agent-core/src/main.rs deleted file mode 100644 index 3b2c49e90f..0000000000 --- a/buzz-agent-core/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() -> Result<(), Box> { - buzz_agent_core::run() -} diff --git a/buzz-agent-core/src/types.rs b/buzz-agent-core/src/types.rs deleted file mode 100644 index de5e50b3a2..0000000000 --- a/buzz-agent-core/src/types.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Wire-facing types shared with `wire.rs`. -//! -//! This is a deliberate subset of `crates/buzz-agent/src/types.rs` (353 lines). -//! The original carried the hand-written loop's internal vocabulary — -//! `HistoryItem`, `ToolCall`, `ToolResult`, `LlmResponse`, `ProviderStop`, -//! `ToolDef`, plus byte-accounting helpers (`estimated_bytes`, -//! `context_pressure_bytes`) that fed the bespoke handoff heuristic. -//! -//! Goose owns all of that now: conversation state is `goose::conversation`, -//! tool plumbing is `rmcp`, and compaction is `goose::context_mgmt`. What -//! survives here is only what crosses the ACP wire. - -use serde::Deserialize; - -/// A stdio MCP server declaration from `session/new`. -#[derive(Debug, Deserialize, Clone)] -pub struct McpServerStdio { - pub name: String, - pub command: String, - #[serde(default)] - pub args: Vec, - #[serde(default)] - pub env: Vec, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct EnvVar { - pub name: String, - pub value: String, -} - -/// A single block of an ACP `session/prompt` payload. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ContentBlock { - Text { - text: String, - }, - ResourceLink { - uri: String, - }, - #[serde(other)] - Unsupported, -} - -/// ACP stop reasons. Wire strings are load-bearing: `buzz-acp` parses -/// `stopReason` off the `session/prompt` response and errors without it -/// (`acp.rs:1757-1761`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StopReason { - EndTurn, - Cancelled, - MaxTokens, - MaxTurnRequests, - Refusal, -} - -impl StopReason { - pub fn as_wire(self) -> &'static str { - match self { - Self::EndTurn => "end_turn", - Self::Cancelled => "cancelled", - Self::MaxTokens => "max_tokens", - Self::MaxTurnRequests => "max_turn_requests", - Self::Refusal => "refusal", - } - } -} - -/// Errors surfaced to the client as JSON-RPC error responses. -#[derive(Debug)] -pub enum AgentError { - InvalidParams(String), - Llm(String), - LlmAuth(String), - LlmModelNotFound(String), - Mcp(String), - Cancelled, -} - -impl std::fmt::Display for AgentError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidParams(s) => write!(f, "invalid params: {s}"), - Self::Llm(s) => write!(f, "llm: {s}"), - Self::LlmAuth(s) => write!(f, "llm auth: {s}"), - Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), - Self::Mcp(s) => write!(f, "mcp: {s}"), - Self::Cancelled => write!(f, "cancelled"), - } - } -} - -impl std::error::Error for AgentError {} - -impl AgentError { - /// Preserved verbatim from buzz-agent: the harness's error taxonomy keys - /// off these codes. - pub fn json_rpc_code(&self) -> i32 { - match self { - Self::InvalidParams(_) => -32602, - Self::LlmAuth(_) => -32001, - Self::LlmModelNotFound(_) => -32002, - _ => -32000, - } - } -} diff --git a/buzz-agent-core/src/wire.rs b/buzz-agent-core/src/wire.rs deleted file mode 100644 index 7b50e7982a..0000000000 --- a/buzz-agent-core/src/wire.rs +++ /dev/null @@ -1,293 +0,0 @@ -use serde::Deserialize; -use serde_json::{json, Value}; -use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt}; -use tokio::sync::mpsc; - -use crate::types::{ContentBlock, McpServerStdio}; - -pub const PARSE_ERROR: i32 = -32700; -pub const INVALID_REQUEST: i32 = -32600; -pub const METHOD_NOT_FOUND: i32 = -32601; -pub const INVALID_PARAMS: i32 = -32602; - -pub enum WireMsg { - Notify(Value), -} - -pub type WireSender = mpsc::Sender; - -#[derive(Debug)] -pub enum Inbound { - Request { - id: Value, - method: String, - params: Value, - }, - Notification { - method: String, - params: Value, - }, - Ignored, - Invalid { - id: Value, - code: i32, - message: String, - }, -} - -#[derive(Debug, Deserialize)] -pub struct InitializeParams { - #[serde(rename = "protocolVersion")] - pub protocol_version: u32, - #[serde(default, rename = "clientCapabilities")] - pub _client_capabilities: Value, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionNewParams { - pub cwd: String, - #[serde(default)] - pub mcp_servers: Vec, - #[serde(default)] - pub system_prompt: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionPromptParams { - pub session_id: String, - pub prompt: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionCancelParams { - pub session_id: String, -} - -/// Params for goose's non-standard `_goose/unstable/session/steer` request: -/// inject user input into the *currently active* prompt without starting a new -/// one. `expected_run_id` must match the run id buzz-agent advertised via -/// `params.update._meta.goose.activeRunId` on a `session/update`, so a steer -/// can't race a turn that already ended or hasn't started. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionSteerParams { - pub session_id: String, - #[serde(default)] - pub prompt: Vec, - pub expected_run_id: String, -} - -/// Params for `session/set_model`: override the active model for an existing -/// session without respawning. Applied immediately; subsequent prompts on this -/// session use `model_id` instead of the configured `BUZZ_AGENT_MODEL`. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionSetModelParams { - pub session_id: String, - pub model_id: String, -} - -pub fn classify(msg: &Value) -> Inbound { - if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - return Inbound::Invalid { - id: msg.get("id").cloned().unwrap_or(Value::Null), - code: INVALID_REQUEST, - message: "jsonrpc: missing or invalid version".into(), - }; - } - let id = msg.get("id").cloned(); - let method = msg.get("method").and_then(Value::as_str).map(str::to_owned); - let params = msg.get("params").cloned().unwrap_or(Value::Null); - - match (method, id) { - (Some(m), Some(id)) => Inbound::Request { - id, - method: m, - params, - }, - (Some(m), None) => Inbound::Notification { method: m, params }, - // Bare responses (id present, no method) are unexpected — buzz-agent - // does not issue requests to the client. Ignore silently. - (None, Some(_)) => Inbound::Ignored, - (None, None) => Inbound::Invalid { - id: Value::Null, - code: INVALID_REQUEST, - message: "jsonrpc: missing method and id".into(), - }, - } -} - -pub fn ok(id: Value, result: Value) -> Value { - json!({ "jsonrpc": "2.0", "id": id, "result": result }) -} - -pub fn err(id: Value, code: i32, message: &str) -> Value { - json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) -} - -pub fn session_update(sid: &str, update: Value) -> Value { - json!({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { "sessionId": sid, "update": update }, - }) -} - -/// A `_goose/unstable/session/update` notification — the separate top-level -/// method goose uses for custom usage and status events. Used by buzz-agent -/// to emit the `usage_update` payload so buzz-acp's `UsageTracker` can treat -/// buzz-agent and goose symmetrically. -pub fn goose_session_update(sid: &str, update: Value) -> Value { - json!({ - "jsonrpc": "2.0", - "method": "_goose/unstable/session/update", - "params": { "sessionId": sid, "update": update }, - }) -} - -/// A `session/update` notification carrying a `update._meta.goose.` field. -/// Used to advertise `activeRunId` (so steer-capable clients can target the -/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer -/// with the chunk that later picks it up) — matching goose's wire layout where -/// `_meta` is nested inside the `update` object (per the ACP `SessionInfoUpdate` -/// schema), not alongside it at the params level. -pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Value) -> Value { - let mut update = update; - update["_meta"] = json!({ "goose": goose_meta }); - json!({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": sid, - "update": update, - }, - }) -} - -pub async fn send(wire: &WireSender, msg: Value) { - let _ = wire.send(WireMsg::Notify(msg)).await; -} - -pub async fn read_bounded_line( - stdin: &mut R, - max: usize, -) -> std::io::Result> { - let mut buf: Vec = Vec::new(); - loop { - let chunk = stdin.fill_buf().await?; - if chunk.is_empty() { - if !buf.is_empty() { - tracing::error!( - "io: unterminated frame at EOF ({} bytes dropped)", - buf.len() - ); - } - return Ok(None); - } - let take = chunk - .iter() - .position(|b| *b == b'\n') - .map_or(chunk.len(), |i| i + 1); - if buf.len().saturating_add(take) > max { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("io: line exceeds max ({max} bytes)"), - )); - } - buf.extend_from_slice(&chunk[..take]); - stdin.consume(take); - if buf.ends_with(b"\n") { - buf.pop(); - if buf.ends_with(b"\r") { - buf.pop(); - } - match String::from_utf8(buf) { - Ok(s) => return Ok(Some(s)), - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "io: frame contains invalid UTF-8", - )) - } - } - } - } -} - -pub async fn writer_task(mut rx: mpsc::Receiver) { - let mut stdout = tokio::io::stdout(); - while let Some(msg) = rx.recv().await { - let WireMsg::Notify(v) = msg; - let mut s = match serde_json::to_string(&v) { - Ok(s) => s, - Err(e) => { - tracing::error!("io: serialize: {e}"); - continue; - } - }; - s.push('\n'); - if stdout.write_all(s.as_bytes()).await.is_err() { - return; - } - let _ = stdout.flush().await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_new_params_deserializes_system_prompt() { - let json = serde_json::json!({ - "cwd": "/tmp/test", - "mcpServers": [], - "systemPrompt": "You are a helpful agent." - }); - let params: SessionNewParams = serde_json::from_value(json).unwrap(); - assert_eq!(params.cwd, "/tmp/test"); - assert_eq!( - params.system_prompt.as_deref(), - Some("You are a helpful agent.") - ); - } - - #[test] - fn session_new_params_system_prompt_defaults_to_none() { - let json = serde_json::json!({ - "cwd": "/tmp/test", - "mcpServers": [] - }); - let params: SessionNewParams = serde_json::from_value(json).unwrap(); - assert_eq!(params.cwd, "/tmp/test"); - assert!(params.system_prompt.is_none()); - } - - #[test] - fn session_new_params_ignores_unknown_fields() { - // Backward compat: old agents with new harness — unknown fields are ignored. - let json = serde_json::json!({ - "cwd": "/tmp/test", - "mcpServers": [], - "unknownField": "should be ignored" - }); - let params: SessionNewParams = serde_json::from_value(json).unwrap(); - assert_eq!(params.cwd, "/tmp/test"); - assert!(params.system_prompt.is_none()); - } - - #[test] - fn session_new_params_empty_string_system_prompt() { - // An explicit empty string is distinct from absent — deserializes to Some(""). - let json = serde_json::json!({ - "cwd": "/tmp/test", - "mcpServers": [], - "systemPrompt": "" - }); - let params: SessionNewParams = serde_json::from_value(json).unwrap(); - assert_eq!(params.system_prompt, Some(String::new())); - } -} diff --git a/buzz-agent-core/tests/bin/fake_mcp.rs b/buzz-agent-core/tests/bin/fake_mcp.rs deleted file mode 100644 index 0275fff619..0000000000 --- a/buzz-agent-core/tests/bin/fake_mcp.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Tiny fake MCP server for integration tests. -//! -//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by -//! environment variables so tests can simulate misbehavior: -//! -//! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout) -//! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout) -//! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1) -//! FAKE_MCP_HUGE_DESC=1 — every tool description is 100 KB -//! FAKE_MCP_DESC_SIZE=N — every tool description is N bytes (overrides HUGE_DESC) -//! FAKE_MCP_TOOL_DELAY=N — `tools/call` sleeps N seconds before replying -//! (use a large value, e.g. 999, to simulate hang) -//! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result -//! (default: the literal "ok"); grows history -//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup -//! (for tests that want to verify the child died) -//! FAKE_MCP_SPAWN_GRANDCHILD=1 -//! — on `tools/call`, spawn a `sleep 999` -//! grandchild before hanging. Its PID is -//! written to FAKE_MCP_GRANDCHILD_PID_FILE -//! so a test can verify the entire process -//! tree dies on timeout. -//! FAKE_MCP_GRANDCHILD_PID_FILE=path -//! — path to write the grandchild PID to. -//! FAKE_MCP_TOOL_ERROR=1 — `tools/call` returns isError: true -//! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool -//! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") -//! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying -//! (use a large value to simulate hang/timeout) -//! FAKE_MCP_STOP_COUNT=N — `_Stop` returns STOP_TEXT for the first N -//! invocations; empty string thereafter. If -//! unset, every call returns STOP_TEXT. -//! FAKE_MCP_POSTCOMPACT_HOOK=1 -//! — expose a `_PostCompact` hook tool -//! FAKE_MCP_POSTCOMPACT_TEXT=text -//! — `_PostCompact` returns this (default: "") - -use std::io::{BufRead, Write}; - -use serde_json::{json, Value}; - -fn env_flag(k: &str) -> bool { - std::env::var(k).map(|v| v != "0").unwrap_or(false) -} - -fn env_usize(k: &str, default: usize) -> usize { - std::env::var(k) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(default) -} - -fn env_u64(k: &str, default: u64) -> u64 { - std::env::var(k) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(default) -} - -fn write_response(id: Value, result: Value) { - let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result }); - let mut s = serde_json::to_string(&msg).expect("serialize"); - s.push('\n'); - let mut out = std::io::stdout().lock(); - out.write_all(s.as_bytes()).expect("write"); - out.flush().expect("flush"); -} - -fn hang_forever() -> ! { - loop { - std::thread::sleep(std::time::Duration::from_secs(60)); - } -} - -fn make_tools( - count: usize, - desc: &str, - include_stop_hook: bool, - include_post_compact_hook: bool, -) -> Vec { - let mut tools: Vec = (0..count) - .map(|i| { - json!({ - "name": format!("tool_{i}"), - "description": desc, - "inputSchema": { "type": "object", "properties": {} }, - }) - }) - .collect(); - if include_stop_hook { - tools.push(json!({ - "name": "_Stop", - "description": "stop hook", - "inputSchema": { "type": "object", "properties": {} }, - })); - } - if include_post_compact_hook { - tools.push(json!({ - "name": "_PostCompact", - "description": "post compact hook", - "inputSchema": { "type": "object", "properties": {} }, - })); - } - tools -} - -fn main() { - // Optional: write our own PID so a test can later check the process is gone. - if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") { - let pid = std::process::id().to_string(); - let _ = std::fs::write(&path, pid); - } - - let hang_init = env_flag("FAKE_MCP_HANG_INIT"); - let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS"); - let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1); - // FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set. - let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE") - .ok() - .and_then(|v| v.parse::().ok()) - { - "x".repeat(n) - } else if env_flag("FAKE_MCP_HUGE_DESC") { - "x".repeat(100_000) - } else { - "fake tool".to_owned() - }; - let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0); - // Tool-call result text size in bytes (default: the literal "ok"). Lets a - // test grow session history by a controlled amount via a tool result. - let result_size = env_u64("FAKE_MCP_RESULT_SIZE", 0) as usize; - let stop_hook = env_flag("FAKE_MCP_STOP_HOOK"); - let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned()); - let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0); - // 0 means "unset" → unlimited; any positive value caps the number of - // calls that return STOP_TEXT before flipping to empty string. - let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); - let mut stop_calls_seen: usize = 0; - let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); - let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); - - // Use a channel-based stdin reader so notifications (which carry no id) - // are captured even while the main thread is sleeping during a tool call. - let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok(); - let (tx, rx) = std::sync::mpsc::channel::<(Value, Option)>(); - let cancel_log_for_thread = cancel_log_path.clone(); - std::thread::spawn(move || { - let stdin = std::io::stdin(); - let lines = stdin.lock().lines(); - for line in lines { - let line = match line { - Ok(l) => l, - Err(_) => return, - }; - if line.trim().is_empty() { - continue; - } - let msg: Value = match serde_json::from_str(&line) { - Ok(v) => v, - Err(_) => continue, - }; - let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); - let id = msg.get("id").cloned(); - // Notifications carry no id. Log cancellations if configured. - if id.is_none() || id == Some(Value::Null) { - if method == "notifications/cancelled" { - if let Some(ref path) = cancel_log_for_thread { - use std::io::Write as _; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - let _ = writeln!(f, "{}", line.trim()); - } - } - } - continue; - } - // Send requests (with id) to the main processing loop. - let _ = tx.send((msg, id)); - } - }); - - while let Ok((msg, id_opt)) = rx.recv() { - let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); - let id = id_opt.unwrap_or(Value::Null); - - match method { - "initialize" => { - if hang_init { - hang_forever(); - } - write_response( - id, - json!({ - "protocolVersion": "2025-06-18", - "capabilities": { "tools": {} }, - "serverInfo": { "name": "fake-mcp", "version": "0.0.0" }, - }), - ); - } - "tools/list" => { - if hang_tools { - hang_forever(); - } - write_response( - id, - json!({ - "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) - }), - ); - } - "tools/call" => { - // Signal that the request was received (for tests that - // need to wait until the call is in-flight before cancelling). - // Write the request id so tests can correlate with cancel. - if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") { - let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into()); - let _ = std::fs::write(&path, id_str); - } - let called_name = msg - .get("params") - .and_then(|p| p.get("name")) - .and_then(Value::as_str) - .unwrap_or(""); - // Optionally spawn a long-sleeping grandchild so the test - // can verify process-group killing reaches the whole tree. - if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") { - let child = std::process::Command::new("sleep") - .arg("999") - .spawn() - .expect("spawn grandchild"); - if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") { - let _ = std::fs::write(&path, child.id().to_string()); - } - std::mem::forget(child); - } - if called_name == "_Stop" { - if stop_delay_secs > 0 { - std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs)); - } - // Once we exceed the configured count, return empty - // text so the agent treats it as no objection. This - // lets a test exercise the "objected then cleared" - // path without relying on the rejection budget. - let payload = if stop_calls_seen < stop_count_limit { - stop_text.clone() - } else { - String::new() - }; - stop_calls_seen = stop_calls_seen.saturating_add(1); - write_response( - id, - json!({ - "content": [{ "type": "text", "text": payload }], - "isError": false, - }), - ); - continue; - } - if called_name == "_PostCompact" { - write_response( - id, - json!({ - "content": [{ "type": "text", "text": post_compact_text }], - "isError": false, - }), - ); - continue; - } - if tool_delay_secs > 0 { - std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs)); - } - let tool_error = env_flag("FAKE_MCP_TOOL_ERROR"); - let result_text = if tool_error { - "boom: the tool failed".to_owned() - } else if result_size > 0 { - "x".repeat(result_size) - } else { - "ok".to_owned() - }; - write_response( - id, - json!({ - "content": [{ "type": "text", "text": result_text }], - "isError": tool_error, - }), - ); - } - _ => { - // Unknown method: respond with an error so rmcp doesn't hang. - let err = json!({ - "jsonrpc": "2.0", "id": id, - "error": { "code": -32601, "message": format!("method not found: {method}") }, - }); - let mut s = serde_json::to_string(&err).unwrap(); - s.push('\n'); - let mut out = std::io::stdout().lock(); - let _ = out.write_all(s.as_bytes()); - let _ = out.flush(); - } - } - } -} diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 7889ad34a7..b249300360 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -25,6 +25,18 @@ name = "fake-mcp" path = "tests/bin/fake_mcp.rs" [dependencies] +goose = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab", default-features = false, features = ["rustls-tls"] } +goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab" } +# Resolution guard: goose pins icu_locale "=2.1.1" (needs icu_collections +# ~2.1.1) while url 2.5.x -> idna -> idna_adapter 1.2.2 pulls icu_normalizer +# 2.2.0 (needs icu_collections ~2.2.0). Only one 2.x icu_collections can be +# selected. 1.2.0 is the last idna_adapter on ICU4X 1.x, which keeps IDNA off +# the icu_collections 2.x line entirely and lets both resolve. +idna_adapter = "=1.2.0" +tokio-util = { version = "0.7", features = ["rt"] } +futures = "0.3" +anyhow = "1" + tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/buzz-agent-core/HANDTEST.md b/crates/buzz-agent/HANDTEST.md similarity index 100% rename from buzz-agent-core/HANDTEST.md rename to crates/buzz-agent/HANDTEST.md diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 730e87b2e8..f3ece95198 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -1,746 +1,579 @@ +//! The turn driver: Goose's agent loop, translated onto buzz-agent's ACP wire. +//! +//! This replaces roughly 8,000 lines of `crates/buzz-agent`: +//! +//! | buzz-agent file | lines | who owns it now | +//! |---|---:|---| +//! | `llm.rs` | 3846 | `goose::providers` (superset of the 4 providers) | +//! | `mcp.rs` | 1139 | `goose::agents::extension_manager` | +//! | `auth.rs` | 845 | `goose::providers` (incl. Databricks OAuth) | +//! | `agent.rs` | 746 | `goose::agents::Agent::reply` | +//! | `hints.rs` | 726 | `goose`'s hint loader (`prompt_manager::with_hints`) | +//! | `catalog.rs` | 631 | `goose::providers::init` model discovery | +//! | `builtin.rs` | 575 | `goose`'s `skills` platform extension | +//! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) | +//! +//! What this file keeps is the part Goose does *not* know about: the mapping +//! from `AgentEvent` onto the exact `session/update` notifications `buzz-acp` +//! consumes (`acp.rs:1528-1627`), and the `keepalive` ticker that resets the +//! harness idle clock. + use std::sync::Arc; -use serde_json::json; -use tokio::sync::{mpsc, watch, Semaphore}; -use tokio::task::JoinSet; - -use crate::builtin; -use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; -use crate::handoff::HandoffOutcome; -use crate::hints::SkillEntry; -use crate::llm::Llm; -use crate::mcp::McpRegistry; -use crate::mcp::ResultBudget; - -use crate::types::{ - AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult, - ToolResultContent, -}; +use futures::StreamExt; +use serde_json::{json, Value}; +use tokio_util::sync::CancellationToken; + +use goose::agents::{Agent, AgentEvent, SessionConfig}; +use goose_provider_types::conversation::message::{Message, MessageContent}; + +use crate::types::{AgentError, ContentBlock, StopReason}; use crate::wire::{self, WireSender}; -const ERROR_REFLECTION_SUFFIX: &str = - "\n\n[Reflect] Before retrying, identify the cause and change your approach."; - -pub struct RunCtx<'a> { - pub cfg: &'a Config, - /// Effective model for this session. Usually equals `cfg.model`; overridden - /// per-session by `session/set_model`. All LLM calls use this value. - pub effective_model: &'a str, - pub session_id: &'a str, - pub system_prompt: &'a str, - pub llm: &'a Llm, - pub mcp: &'a Arc, - /// Skills discovered at session creation; used by the built-in `load_skill` tool. - pub skills: &'a [SkillEntry], - pub wire: &'a WireSender, - pub cancel: &'a mut watch::Receiver, - /// Mid-turn steer queue. Drained at each round boundary (before the next - /// LLM call): queued messages are appended to history as user turns so the - /// model sees them on its next request, without restarting the turn. Fed by - /// the `_goose/unstable/session/steer` handler. - pub steer: &'a mut mpsc::UnboundedReceiver>, - pub history: &'a mut Vec, - pub original_task: &'a mut Option, - pub handoff_count: &'a mut usize, - /// Cache-summed input tokens reported by the provider on this session's - /// most recent request (persists across `session/prompt` calls), or `None` - /// before the first response and immediately after a handoff resets the - /// context. The handoff gate reads this to compare against the token - /// budget; falls back to the byte heuristic when `None`. - pub last_request_input_tokens: &'a mut Option, - /// History byte size at the moment `last_request_input_tokens` was - /// measured. Paired with it so the gate can add a conservative token - /// estimate of history that has grown since (tool results, next prompt), - /// which the exact-but-stale token count would otherwise miss. Cleared and - /// preserved in lockstep with `last_request_input_tokens`. - pub last_request_history_bytes: &'a mut Option, - /// Accumulated input tokens across all LLM rounds in this turn, for - /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. - pub turn_input_tokens: &'a mut Option, - /// Accumulated output tokens across all LLM rounds in this turn, for - /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. - pub turn_output_tokens: &'a mut Option, +/// How often to emit `keepalive` while waiting on the provider. +/// +/// NOT part of ACP. `buzz-acp` runs an idle-timeout clock that is reset on +/// every line of valid JSON (`acp.rs:1197-1199`); a silent agent is killed. +/// buzz-agent emitted this from inside its provider `select!` +/// (`agent.rs:122-127`) and `buzz-acp` treats it as a no-op that only resets +/// the clock (`acp.rs:1623`). +/// +/// Goose streams, so during generation the chunks themselves keep the clock +/// alive — but there is no traffic during a long *pre-first-token* wait (big +/// prompt processing, provider queueing, reasoning models). So the ticker +/// stays. +const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Appended by buzz-agent to every failed tool result (`agent.rs:21-22`) so +/// the model diagnoses a failure instead of blindly retrying it. +/// +/// buzz-agent mutated the tool result itself. Goose gives no interception +/// point for that: its `PostToolUseFailure` hook is fire-and-forget and its +/// output is discarded (`agent.rs:589-620`). So we deliver the same text as a +/// steer instead — goose drains pending steers at the round boundary +/// (`agent.rs:1951-1974`), which is exactly when the model would next act on +/// the failed result. Agent-visible, user-invisible. +const ERROR_REFLECTION: &str = + "[Reflect] Before retrying, identify the cause and change your approach."; + +/// Cap on reflections per turn, so a tool failing in a loop cannot flood the +/// conversation. +const MAX_REFLECTIONS: usize = 8; + +/// How long to let goose unwind after `session/cancel` before giving up. +/// +/// Cancellation is cooperative: goose has to notice the token, send +/// `notifications/cancelled` to each in-flight MCP request +/// (`mcp_client.rs:687-690`), emit the resulting tool responses, and end the +/// stream. Dropping the stream instead skips all of that. This bounds the +/// wait so a wedged MCP child cannot hold the turn open indefinitely. +const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Per-turn token accounting, mirroring buzz-agent's contract. +#[derive(Debug, Default, Clone, Copy)] +pub struct TurnTokens { + pub input: Option, + pub output: Option, } -impl RunCtx<'_> { - pub async fn run(&mut self, prompt: Vec) -> Result { - let user_text = prompt_to_text(prompt)?; - if user_text.len() > MAX_PROMPT_BYTES { - return Err(AgentError::InvalidParams(format!( - "prompt: exceeds {MAX_PROMPT_BYTES} bytes" - ))); - } - if self.original_task.is_none() { - *self.original_task = Some(user_text.clone()); - } - self.history.push(HistoryItem::User(user_text)); - - // Reset per-turn token accumulators for this prompt. - *self.turn_input_tokens = None; - *self.turn_output_tokens = None; - - let mut round = 0u32; - // Per-prompt `_Stop` objection count. Bounded per prompt (not per - // session) so a stubborn exchange can't permanently disable the stop - // guard for a long-lived session; `max_rounds` still caps the loop. - let mut stop_rejections = 0u32; - loop { - if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { - return Ok(StopReason::MaxTurnRequests); - } - if *self.cancel.borrow() { - return Ok(StopReason::Cancelled); - } - // Round boundary: fold in any steer messages queued since the last - // round. They land as user turns so the model incorporates them on - // its next request — the turn continues, it is not restarted. Drain - // non-blocking; an empty queue is the common case. - self.drain_steers(); - match self.maybe_handoff().await { - HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled), - // Context was just reset — the prior request's token count no - // longer describes the (now much smaller) history. Clear both - // the token count and its byte baseline so a stale over- - // threshold reading can't immediately re-fire the handoff - // before the next response reports fresh usage. - HandoffOutcome::Performed => { - *self.last_request_input_tokens = None; - *self.last_request_history_bytes = None; +impl TurnTokens { + pub fn observed(&self) -> bool { + self.input.is_some() || self.output.is_some() + } +} + +/// Flatten ACP prompt blocks into the text Goose's `Message` carries. +pub fn prompt_to_text(blocks: &[ContentBlock]) -> String { + let mut out = String::new(); + for b in blocks { + match b { + ContentBlock::Text { text } => { + if !out.is_empty() { + out.push('\n'); } - HandoffOutcome::Skipped => { - truncate_history(self.history, self.cfg.max_history_bytes) + out.push_str(text); + } + ContentBlock::ResourceLink { uri } => { + if !out.is_empty() { + out.push('\n'); } + out.push_str(uri); } + ContentBlock::Unsupported => {} + } + } + out +} - let mut tools = self.mcp.tools(); - // Inject the built-in load_skill tool when skills are available. - if !self.skills.is_empty() { - tools.push(builtin::load_skill_def()); - } - round = round.saturating_add(1); - let response = tokio::select! { - biased; - _ = self.cancel.changed() => return Ok(StopReason::Cancelled), - r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r?, - _ = async { - // Keepalive ticker: emit a lightweight session update every 30s - // while waiting on the LLM provider. This resets the ACP harness - // idle clock so long provider responses don't trigger timeout. - let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); - interval.tick().await; // first tick fires immediately — skip it - loop { - interval.tick().await; - tracing::debug!("llm keepalive tick"); - wire::send( - self.wire, - wire::session_update( - self.session_id, - json!({ - "sessionUpdate": "keepalive", - }), - ), - ) - .await; - } - } => unreachable!(), - }; +/// Drive one `session/prompt` turn to completion. +/// +/// Returns the ACP stop reason plus the turn's token counts. The caller is +/// responsible for emitting `usage_update` *before* the `session/prompt` +/// response — that ordering is load-bearing for kind-44200 metrics +/// (`buzz-agent/src/lib.rs:701-706`). +/// +/// `hook_extension` names the MCP extension carrying `_Stop`/`_PostCompact` +/// (buzz-dev-mcp). When set, the turn is not allowed to end while `_Stop` +/// objects — see [`crate::hooks`]. +#[allow(clippy::too_many_arguments)] +pub async fn run_turn( + agent: Arc, + session_id: &str, + prompt: Vec, + max_rounds: Option, + wire_tx: &WireSender, + cancel: CancellationToken, + hook_extension: Option<&str>, +) -> (Result, TurnTokens) { + let mut tokens = TurnTokens::default(); + let mut next_message = Message::user().with_text(prompt_to_text(&prompt)); + let mut stop_blocks: u32 = 0; + + // Outer loop exists solely for the `_Stop` veto: goose's `reply()` stream + // ends when the model stops, so continuing means re-entering `reply()` + // with the objection appended. Goose's own Stop hook does the equivalent + // internally (`agent.rs:2891-2917`); we do it here because its + // `hook_manager` is private. + loop { + let stop = match drive_stream( + &agent, + session_id, + next_message, + max_rounds, + wire_tx, + &cancel, + &mut tokens, + hook_extension, + ) + .await + { + Ok(s) => s, + Err(e) => return (Err(e), tokens), + }; - // Record provider-reported input usage so the next loop iteration's - // handoff gate can compare it against the token budget. We capture - // it together with the history byte size AT THIS MOMENT — which is - // exactly the history that was just sent to `complete()` (the - // assistant response is appended below, after this point). Pairing - // them lets the gate add a conservative estimate for any history - // appended before the next request. Uses `context_pressure_bytes` - // (the same measure the gate's `current_bytes` uses) so the - // `grown` delta is coherent — an image contributes its visual- - // token equivalent here, not its base64 length. Preserve both when - // a response omits usage (`None`) rather than clobbering — a - // one-off missing field shouldn't blind the gate or zero the - // growth baseline. - if let Some(tokens) = response.input_tokens { - *self.last_request_input_tokens = Some(tokens); - *self.last_request_history_bytes = Some( - self.history - .iter() - .map(HistoryItem::context_pressure_bytes) - .sum(), - ); - // Accumulate per-turn input tokens for NIP-AM metric publishing. - *self.turn_input_tokens = - Some(self.turn_input_tokens.unwrap_or(0).saturating_add(tokens)); - } - // Accumulate per-turn output tokens for NIP-AM metric publishing. - if let Some(out) = response.output_tokens { - *self.turn_output_tokens = - Some(self.turn_output_tokens.unwrap_or(0).saturating_add(out)); - } + // Only a clean end-of-turn is vetoable. Cancellation and refusals pass + // through untouched. + if !matches!(stop, StopReason::EndTurn) || cancel.is_cancelled() { + return (Ok(stop), tokens); + } - if !response.reasoning.is_empty() { - wire::send( - self.wire, - wire::session_update( - self.session_id, - json!({ - "sessionUpdate": "agent_thought_chunk", - "content": { "type": "text", "text": &response.reasoning } - }), - ), - ) - .await; - } + let Some(extension) = hook_extension else { + return (Ok(stop), tokens); + }; - if !response.text.is_empty() { + if stop_blocks >= crate::hooks::MAX_STOP_BLOCKS { + tracing::warn!( + blocks = stop_blocks, + "_Stop veto cap reached; ending turn anyway" + ); + return (Ok(stop), tokens); + } + + let Some(session) = current_session(session_id).await else { + return (Ok(stop), tokens); + }; + let Some(objection) = crate::hooks::stop_objection(&agent, &session, extension).await + else { + return (Ok(stop), tokens); + }; + + stop_blocks += 1; + tracing::info!(blocks = stop_blocks, "_Stop hook vetoed end of turn"); + + // Agent-visible, user-invisible — the objection steers the model + // without appearing in the channel, matching both buzz-agent and + // goose's own Deny handling. + next_message = Message::user() + .with_text(format!("[Stop] {objection}")) + .with_visibility(false, true); + } +} + +/// Look up the goose `Session` record needed to dispatch a hook tool. +async fn current_session(session_id: &str) -> Option { + goose::session::session_manager::SessionManager::instance() + .get_session(session_id, false) + .await + .ok() +} + +/// Drive a single `reply()` stream to completion. +#[allow(clippy::too_many_arguments)] +async fn drive_stream( + agent: &Arc, + session_id: &str, + message: Message, + max_rounds: Option, + wire_tx: &WireSender, + cancel: &CancellationToken, + tokens: &mut TurnTokens, + hook_extension: Option<&str>, +) -> Result { + let session_config = SessionConfig { + id: session_id.to_string(), + schedule_id: None, + max_turns: max_rounds, + retry_config: None, + }; + + let mut stream = match agent + .reply(message, session_config, Some(cancel.clone())) + .await + { + Ok(s) => s, + Err(e) => return Err(AgentError::Llm(e.to_string())), + }; + + let mut keepalive = tokio::time::interval(KEEPALIVE_INTERVAL); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + keepalive.tick().await; // first tick is immediate; discard it + + let mut stop = StopReason::EndTurn; + let mut compacted = false; + let mut reflections = 0usize; + + // Set once cancellation is observed; bounds how long we let goose unwind. + let mut drain: Option>> = None; + + // Tool calls announced to the harness, minus those that reached a terminal + // state. Anything still here when the stream ends gets a synthetic + // terminal update — see the cancel arm below. + let mut open_tool_calls: Vec = Vec::new(); + + loop { + tokio::select! { + // Keep the harness idle clock alive during provider silence. + _ = keepalive.tick() => { wire::send( - self.wire, - wire::session_update( - self.session_id, - json!({ - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": &response.text } - }), - ), + wire_tx, + wire::session_update(session_id, json!({ "sessionUpdate": "keepalive" })), ) .await; } - if response.tool_calls.is_empty() { - if response.stop == ProviderStop::ToolUse { - return Err(AgentError::Llm( - "provider: stop=tool_use but zero tool_calls".into(), - )); - } - self.history.push(HistoryItem::Assistant { - text: response.text, - tool_calls: Vec::new(), - }); - let stop = map_stop(response.stop); - // Only gate genuine end_turn — don't override max_tokens/refusal. - if stop == StopReason::EndTurn { - if stop_rejections >= self.cfg.stop_max_rejections { - return Ok(stop); - } - let objections = self - .mcp - .call_hooks( - "_Stop", - &json!({}), - self.cfg.hook_timeout, - &self.cfg.hook_servers, - ) - .await; - if !objections.is_empty() { - stop_rejections = stop_rejections.saturating_add(1); - push_hook_outputs_as_tool_results(self.history, "_Stop", &objections); - continue; - } - } - return Ok(stop); + // Cancellation is a cooperative DRAIN, not an abort. + // + // Breaking here drops `stream`, which drops the futures goose is + // awaiting — so `mcp_client.rs:688` never reaches its + // `cancel_token.cancelled()` arm and never sends + // `notifications/cancelled`. The MCP child keeps running its tool + // after the turn is over, and any announced `tool_call` never + // reaches a terminal state, leaving the desktop UI spinning + // (the invariant buzz-agent held at `agent.rs:470-477`). + // + // So we keep polling the stream and let goose unwind: it emits the + // tool responses, sends the MCP cancellations, and ends the stream + // itself. `drain` only bounds how long we are willing to wait. + _ = cancel.cancelled(), if drain.is_none() => { + stop = StopReason::Cancelled; + drain = Some(Box::pin(tokio::time::sleep(CANCEL_DRAIN_TIMEOUT))); } - let mut calls = response.tool_calls; - if calls.len() > MAX_TOOL_CALLS_PER_TURN { - tracing::warn!( - "capping tool_calls {} -> {MAX_TOOL_CALLS_PER_TURN}", - calls.len() - ); - calls.truncate(MAX_TOOL_CALLS_PER_TURN); + // Goose did not unwind in time. Give up and synthesise terminal + // states, because a stuck spinner is worse than a wrong status. + _ = async { drain.as_mut().unwrap().await }, if drain.is_some() => { + tracing::warn!("cancel drain timed out; forcing terminal tool states"); + break; } - self.history.push(HistoryItem::Assistant { - text: response.text, - tool_calls: calls.clone(), - }); - if let Some(stop) = self.execute_calls(&calls).await { - return Ok(stop); + next = stream.next() => { + let Some(event) = next else { break }; + match event { + Ok(ev) => { + if let Some(reason) = handle_event( + ev, + agent, + session_id, + wire_tx, + tokens, + &mut compacted, + &mut reflections, + &mut open_tool_calls, + ) + .await + { + stop = reason; + break; + } + } + Err(e) => { + let msg = e.to_string(); + // Preserve buzz-agent's error taxonomy so the harness's + // JSON-RPC code mapping stays meaningful. + let err = if msg.contains("auth") || msg.contains("401") { + AgentError::LlmAuth(msg) + } else if msg.contains("model") && msg.contains("not found") { + AgentError::LlmModelNotFound(msg) + } else { + AgentError::Llm(msg) + }; + return Err(err); + } + } } } } - /// Non-blocking drain of the steer queue. Each queued steer is appended to - /// history as a user turn so the model picks it up on its next request. A - /// steer whose blocks all fail to render (e.g. unsupported content) is - /// skipped rather than aborting the turn — steering is best-effort - /// augmentation, not a hard input contract like the initial prompt. - fn drain_steers(&mut self) { - while let Ok(blocks) = self.steer.try_recv() { - match prompt_to_text(blocks) { - Ok(text) if !text.trim().is_empty() => { - self.history.push(HistoryItem::User(text)); - } - Ok(_) => { - tracing::debug!("dropping empty steer message"); - } - Err(e) => { - tracing::warn!("dropping unrenderable steer message: {e}"); - } - } - } + // Anything announced but never resolved gets a synthetic terminal update. + // buzz-agent guaranteed this invariant (`agent.rs:470-477`) because the + // desktop renders an unresolved `tool_call` as a spinner forever. Normally + // the drain above means this list is already empty; it only fires when + // goose failed to unwind inside CANCEL_DRAIN_TIMEOUT. + for tool_call_id in open_tool_calls.drain(..) { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": tool_call_id, + "status": "failed", + }), + ), + ) + .await; } - /// Unified tool-call execution. Three phases: - /// 1. Preflight (sequential): emit `pending`; unknown tools fail fast - /// with a synthetic result. Cancel here fills every still-empty - /// slot as cancelled. - /// 2. Execute: spawn runnable calls into a `JoinSet` bounded by a - /// `Semaphore(max_parallel_tools)`. `select!` between cancel and - /// `join_next`. On cancel: close semaphore, drain in-flight tasks - /// (each sends `notifications/cancelled` internally), synthesize - /// cancelled for unfilled slots and emit `failed`. - /// 3. Append: push results into history in original call order. - /// - /// `max_parallel_tools = 1` makes phase 2 effectively sequential - /// (one in-flight call at a time via the semaphore). Larger values - /// run that many calls concurrently. - async fn execute_calls(&mut self, calls: &[ToolCall]) -> Option { - let mut results: Vec> = vec![None; calls.len()]; - let mut runnable: Vec = Vec::with_capacity(calls.len()); - - for (idx, call) in calls.iter().enumerate() { - if *self.cancel.borrow() { - for (j, c) in calls.iter().enumerate() { - if results[j].is_none() { - // Calls 0..idx already had `pending` emitted; emit - // a terminal `failed` so the client doesn't see - // them stuck. - if j < idx { - emit_failed(self.wire, self.session_id, c, "cancelled").await; - } - results[j] = Some(synthetic_tool_result(c, "cancelled".into())); - } + // Goose compacted history mid-turn. Re-inject the todo list so it survives + // the truncation — this is what buzz-agent's `_PostCompact` existed for + // (`handoff.rs:73-81`). Steering is the right channel: goose drains it at + // the next round boundary (`agent.rs:1951-1974`). + if compacted && !cancel.is_cancelled() { + if let Some(extension) = hook_extension { + if let Some(session) = current_session(session_id).await { + if let Some(state) = + crate::hooks::post_compact_state(agent, &session, extension).await + { + agent + .steer( + session_id, + Message::user() + .with_text(format!("[PostCompact] {state}")) + .with_visibility(false, true), + ) + .await; } - self.append_results(calls, &mut results); - return Some(StopReason::Cancelled); } - emit_pending(self.wire, self.session_id, call).await; - - // Built-in load_skill: execute inline, no MCP round-trip. - if call.name == builtin::LOAD_SKILL_TOOL { - emit_in_progress(self.wire, self.session_id, call).await; - let mut result = builtin::call_load_skill(&call.arguments, self.skills).await; - result.provider_id = call.provider_id.clone(); - emit_completed(self.wire, self.session_id, call, &result).await; - results[idx] = Some(result); - continue; - } - - // Hook tools (bare name starts with `_`) are invisible to the - // LLM and only callable via `call_hooks`. Treat any direct - // invocation as if the tool didn't exist. - if !self.mcp.has(&call.name) || self.mcp.is_hook(&call.name) { - let err = format!("unknown tool: {}", call.name); - emit_failed(self.wire, self.session_id, call, &err).await; - results[idx] = Some(synthetic_tool_result(call, err)); - continue; - } - runnable.push(idx); - } - - self.execute_parallel(calls, &runnable, &mut results).await; - - self.append_results(calls, &mut results); - - if *self.cancel.borrow() { - Some(StopReason::Cancelled) - } else { - None } } - fn append_results(&mut self, calls: &[ToolCall], results: &mut [Option]) { - for (i, call) in calls.iter().enumerate() { - let mut result = results[i].take().unwrap_or_else(|| ToolResult { - provider_id: call.provider_id.clone(), - content: vec![ToolResultContent::Text( - "internal error: missing result".into(), - )], - is_error: true, - }); - // On tool error: append a reflection prompt so the LLM - // diagnoses the failure before blindly retrying. - if result.is_error { - result - .content - .push(ToolResultContent::Text(ERROR_REFLECTION_SUFFIX.to_string())); - } - self.history.push(HistoryItem::ToolResult(result)); - } + if cancel.is_cancelled() { + stop = StopReason::Cancelled; } - async fn execute_parallel( - &mut self, - calls: &[ToolCall], - runnable: &[usize], - results: &mut [Option], - ) { - let limit = self.cfg.max_parallel_tools.max(1); - let sem = Arc::new(Semaphore::new(limit)); - let mut set: JoinSet<(usize, InvokeOutcome)> = JoinSet::new(); - - for &i in runnable { - let call = calls[i].clone(); - let mcp = Arc::clone(self.mcp); - let wire = self.wire.clone(); - let session_id = self.session_id.to_owned(); - let timeout = self.cfg.tool_timeout; - let budget = ResultBudget { - total: MAX_TOOL_RESULT_BYTES, - text: self.cfg.max_tool_result_text_bytes, - }; - let cancel = self.cancel.clone(); - let sem = Arc::clone(&sem); - set.spawn(async move { - // Acquire a permit; if the semaphore is closed (cancel), - // emit a terminal wire update and skip the call. - let _permit = match sem.acquire_owned().await { - Ok(p) => p, - Err(_) => { - emit_failed(&wire, &session_id, &call, "cancelled").await; - return (i, InvokeOutcome::Failed("cancelled".into())); - } - }; - emit_in_progress(&wire, &session_id, &call).await; - let outcome = invoke_tool_inner(&mcp, &call, timeout, budget, cancel).await; - match &outcome { - InvokeOutcome::Done(result) => { - emit_completed(&wire, &session_id, &call, result).await; - } - InvokeOutcome::Failed(msg) => { - emit_failed(&wire, &session_id, &call, msg).await; - } - } - (i, outcome) - }); - } + Ok(stop) +} - let mut cancel_rx = self.cancel.clone(); - let mut cancelled = if *cancel_rx.borrow() { - sem.close(); - true - } else { - false - }; - while !cancelled { - tokio::select! { - biased; - _ = cancel_rx.changed() => { - // Cancel: stop accepting new permits. Do NOT abort - // tasks — each in-flight `mcp.call` observes the same - // cancel receiver via its internal `select!` and - // returns promptly with an "cancelled" error after - // sending `notifications/cancelled` to the server. - sem.close(); - cancelled = true; - break; - } - next = set.join_next() => { - match next { - Some(Ok((i, outcome))) => { - results[i] = Some(outcome_to_result(&calls[i], outcome)); - } - Some(Err(e)) => { - tracing::warn!("tool task join error: {e}"); - } - None => break, - } +/// Translate one `AgentEvent` into ACP notifications. +/// +/// Returns `Some(StopReason)` if the event terminates the turn. +#[allow(clippy::too_many_arguments)] +async fn handle_event( + event: AgentEvent, + agent: &Arc, + session_id: &str, + wire_tx: &WireSender, + tokens: &mut TurnTokens, + compacted: &mut bool, + reflections: &mut usize, + open_tool_calls: &mut Vec, +) -> Option { + match event { + AgentEvent::Message(msg) => { + for content in &msg.content { + emit_content(content, session_id, wire_tx).await; + + if let MessageContent::ToolRequest(req) = content { + open_tool_calls.push(req.id.clone()); } - } - } - // After cancel, drain in-flight tasks. Each task's internal - // `do_call` observes the cancel receiver and returns promptly - // after sending `notifications/cancelled`. We bound the drain - // to avoid hanging if a task is stuck in restart/reconnect. - if cancelled { - let drain_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - match tokio::time::timeout_at(drain_deadline, set.join_next()).await { - Ok(Some(Ok((i, outcome)))) => { - if results[i].is_none() { - results[i] = Some(outcome_to_result(&calls[i], outcome)); - } - } - Ok(Some(Err(e))) => { - tracing::warn!("tool task join error (drain): {e}"); - } - Ok(None) => break, // all tasks drained - Err(_) => { - // Drain timed out — abort remaining tasks. - set.abort_all(); - tracing::warn!("cancel drain timed out; aborting remaining tasks"); - break; + if let MessageContent::ToolResponse(resp) = content { + open_tool_calls.retain(|id| id != &resp.id); + + let failed = match &resp.tool_result { + Ok(r) => r.is_error.unwrap_or(false), + Err(_) => true, + }; + if failed && *reflections < MAX_REFLECTIONS { + *reflections += 1; + agent + .steer( + session_id, + Message::user() + .with_text(ERROR_REFLECTION) + .with_visibility(false, true), + ) + .await; } } } + None } - // Fill any remaining unfilled runnable slots as cancelled. Tasks - // that didn't complete (timed out in drain or never started) need - // a terminal wire update so the client doesn't see "pending" forever. - for &i in runnable { - if results[i].is_none() { - results[i] = Some(synthetic_tool_result(&calls[i], "cancelled".into())); - emit_failed(self.wire, self.session_id, &calls[i], "cancelled").await; + // Usage arrives per provider chunk, already cost-enriched. Accumulate + // rather than overwrite: `MessageUsage` is suppressed when the last + // assistant message has no user-visible content (goose + // `agent.rs:299`), so it is not a reliable sole source. + AgentEvent::Usage(usage) => { + let u = &usage.usage; + if let Some(i) = u.input_tokens { + tokens.input = Some(tokens.input.unwrap_or(0) + i.max(0) as u64); + } + if let Some(o) = u.output_tokens { + tokens.output = Some(tokens.output.unwrap_or(0) + o.max(0) as u64); } + None } - } -} - -/// Outcome of invoking a single tool. The wire notification is emitted by -/// the caller so the spawn loop and the (degenerate, max_parallel=1) path -/// share the same logic. -enum InvokeOutcome { - Done(ToolResult), - Failed(String), -} -/// Standalone tool invocation. Takes only owned/cloned handles so it can -/// run inside a spawned task. On timeout, kills the offending MCP server's -/// process group and marks it dead; the registry's lazy restart handles it -/// on the next call. -async fn invoke_tool_inner( - mcp: &Arc, - call: &ToolCall, - tool_timeout: std::time::Duration, - budget: ResultBudget, - mut cancel: watch::Receiver, -) -> InvokeOutcome { - if *cancel.borrow() { - return InvokeOutcome::Failed("cancelled".into()); - } - match tokio::time::timeout( - tool_timeout, - mcp.call( - &call.name, - &call.provider_id, - &call.arguments, - budget, - &mut cancel, - ), - ) - .await - { - Ok(Ok(result)) => InvokeOutcome::Done(result), - Ok(Err(AgentError::Cancelled)) => InvokeOutcome::Failed("cancelled".into()), - Ok(Err(e)) => InvokeOutcome::Failed(e.to_string()), - Err(_) => { - // If the session was cancelled, the timeout fired because - // do_call returned quickly with "cancelled" and the outer - // timeout raced. Don't kill a healthy server for that. - if *cancel.borrow() { - return InvokeOutcome::Failed("cancelled".into()); - } - if let Some(server) = mcp.server_of(&call.name) { - mcp.kill_server(server, "tool timeout"); - } - let msg = format!( - "tool: timeout after {}s. The command took too long. Try a faster approach.", - tool_timeout.as_secs() - ); - InvokeOutcome::Failed(msg) + // Compaction happened. buzz-agent surfaced this as a `[Context + // Handoff]` history rewrite; Goose has already done the rewrite, so we + // only flag it — the caller then asks `_PostCompact` for state to + // re-inject once the stream settles. + AgentEvent::HistoryReplaced(_) => { + tracing::info!(target: "buzz_agent::compaction", "history compacted"); + *compacted = true; + None } - } -} -fn outcome_to_result(call: &ToolCall, outcome: InvokeOutcome) -> ToolResult { - match outcome { - InvokeOutcome::Done(r) => r, - InvokeOutcome::Failed(m) => synthetic_tool_result(call, m), + _ => None, } } -async fn emit_pending(wire: &WireSender, sid: &str, call: &ToolCall) { - wire::send( - wire, - wire::session_update( - sid, - json!({ - "sessionUpdate": "tool_call", - "toolCallId": call.provider_id, - "title": call.name, - "kind": "other", - "status": "pending", - "rawInput": call.arguments, - }), - ), - ) - .await; -} - -async fn emit_in_progress(wire: &WireSender, sid: &str, call: &ToolCall) { - wire::send( - wire, - wire::session_update( - sid, - json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": call.provider_id, - "status": "in_progress", - }), - ), - ) - .await; -} - -async fn emit_completed(wire: &WireSender, sid: &str, call: &ToolCall, result: &ToolResult) { - wire::send( - wire, - wire::session_update( - sid, - json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": call.provider_id, - "status": "completed", - "content": [{ "type": "content", "content": { "type": "text", "text": result.text() } }], - "rawOutput": { "isError": result.is_error }, - }), - ), - ) - .await; -} - -async fn emit_failed(wire: &WireSender, sid: &str, call: &ToolCall, err: &str) { - wire::send( - wire, - wire::session_update( - sid, - json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": call.provider_id, - "status": "failed", - "rawOutput": { "error": err }, - }), - ), - ) - .await; -} +/// Map message content onto the nine `sessionUpdate` variants `buzz-acp` +/// recognises (`acp.rs:1528-1627`). Anything else it debug-logs and drops. +async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &WireSender) { + match content { + MessageContent::Text(t) if !t.text.is_empty() => { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": t.text }, + }), + ), + ) + .await; + } -fn prompt_to_text(prompt: Vec) -> Result { - let mut parts = Vec::with_capacity(prompt.len()); - for block in prompt { - match block { - ContentBlock::Text { text } => parts.push(text), - ContentBlock::ResourceLink { uri } => parts.push(format!("[resource: {uri}]")), - ContentBlock::Unsupported => { - return Err(AgentError::InvalidParams( - "prompt: unsupported content block (only text and resource_link are advertised)".into(), - )); - } + MessageContent::Thinking(t) if !t.thinking.is_empty() => { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "agent_thought_chunk", + "content": { "type": "text", "text": t.thinking }, + }), + ), + ) + .await; } - } - Ok(parts.join("\n")) -} -/// Format a single hook output as a structured tool-result body. -/// -/// We emit a JSON object rather than XML-style tags. JSON is unambiguous: -/// the inner `text` field is escaped, so a malicious hook cannot break -/// out by including a literal `` (or any other delimiter) -/// in its output. The LLM still sees the source attribution via the -/// `hook` and `server` fields. -fn format_hook_output_body(hook: &str, server: &str, text: &str) -> String { - // serde_json::to_string never fails on owned strings. - serde_json::to_string(&json!({ - "hook": hook, - "server": server, - "text": text, - })) - .unwrap_or_else(|_| String::from("{\"hook\":\"\",\"server\":\"\",\"text\":\"\"}")) -} + // Tool lifecycle. buzz-agent guaranteed every announced tool reached a + // terminal state, or the desktop UI shows a stuck spinner + // (`agent.rs:470-477`). Goose emits request and response as separate + // messages, so the pairing is by tool-call id. + MessageContent::ToolRequest(req) => { + let (name, raw) = match &req.tool_call { + Ok(call) => ( + call.name.to_string(), + serde_json::to_value(&call.arguments).unwrap_or(Value::Null), + ), + Err(e) => (String::from("unknown"), json!({ "error": e.to_string() })), + }; + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call", + "toolCallId": req.id, + "title": name, + "kind": "other", + "status": "in_progress", + "rawInput": raw, + }), + ), + ) + .await; + } -/// Synthetic provider id for an injected hook tool-call/result pair. Must -/// be unique per pair so the LLM wire format (which keys tool results by -/// id) stays valid across multiple objections in one session. -fn synthetic_hook_id(hook: &str, server: &str, ordinal: u64) -> String { - format!("buzz_hook_{hook}_{server}_{ordinal}") -} + MessageContent::ToolResponse(resp) => { + let failed = match &resp.tool_result { + Ok(r) => r.is_error.unwrap_or(false), + Err(_) => true, + }; + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": resp.id, + "status": if failed { "failed" } else { "completed" }, + }), + ), + ) + .await; + } -/// Append a synthetic Assistant tool-call + ToolResult pair for each hook -/// output. Modeling hook output as a tool result (rather than as a User -/// message) means a malicious hook can't impersonate the user or system -/// — the LLM treats tool results as lower-trust, structured data. -/// -/// Each pair uses the hook's qualified tool name (e.g. `fake___Stop`) so -/// attribution is preserved in the wire format. Empty arguments are sent -/// as `{}`. The `Assistant` turn carries no text (tool_calls only). -pub(crate) fn push_hook_outputs_as_tool_results( - history: &mut Vec, - hook: &str, - outputs: &[(String, String)], -) { - for (server, text) in outputs.iter() { - let provider_id = synthetic_hook_id(hook, server, unique_nonce()); - // Tool name is `__` — same shape as a real qname - // for that hook, so the LLM never sees an unknown synthetic name. - let tool_name = format!("{server}__{hook}"); - history.push(HistoryItem::Assistant { - text: String::new(), - tool_calls: vec![ToolCall { - provider_id: provider_id.clone(), - name: tool_name, - arguments: serde_json::json!({}), - }], - }); - history.push(HistoryItem::ToolResult(ToolResult { - provider_id, - content: vec![ToolResultContent::Text(format_hook_output_body( - hook, server, text, - ))], - is_error: false, - })); + _ => {} } } -/// Monotonic counter for synthetic hook ids within a single process. The -/// uniqueness target is "no collision within the lifetime of one history -/// vec", which a process-wide counter satisfies trivially. -fn unique_nonce() -> u64 { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - COUNTER.fetch_add(1, Ordering::Relaxed) -} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_flattens_text_blocks() { + let blocks = vec![ + ContentBlock::Text { + text: "hello".into(), + }, + ContentBlock::Text { + text: "world".into(), + }, + ]; + assert_eq!(prompt_to_text(&blocks), "hello\nworld"); + } -fn synthetic_tool_result(call: &ToolCall, msg: String) -> ToolResult { - ToolResult { - provider_id: call.provider_id.clone(), - content: vec![ToolResultContent::Text(msg)], - is_error: true, + #[test] + fn prompt_includes_resource_links() { + let blocks = vec![ + ContentBlock::Text { text: "see".into() }, + ContentBlock::ResourceLink { + uri: "file:///a".into(), + }, + ]; + assert_eq!(prompt_to_text(&blocks), "see\nfile:///a"); } -} -pub(crate) fn truncate_history(history: &mut Vec, max_bytes: usize) { - let mut total: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); - if total <= max_bytes { - return; + #[test] + fn prompt_skips_unsupported() { + let blocks = vec![ + ContentBlock::Unsupported, + ContentBlock::Text { + text: "only".into(), + }, + ]; + assert_eq!(prompt_to_text(&blocks), "only"); } - let original_len = history.len(); - while total > max_bytes && !history.is_empty() { - let mut end = 1usize; - while end < history.len() && !matches!(history[end], HistoryItem::User(_)) { - end += 1; + + #[test] + fn turn_tokens_observed_requires_a_count() { + assert!(!TurnTokens::default().observed()); + assert!(TurnTokens { + input: Some(1), + output: None } - if end >= history.len() { - break; + .observed()); + assert!(TurnTokens { + input: None, + output: Some(1) } - let dropped: usize = history[..end] - .iter() - .map(HistoryItem::estimated_bytes) - .sum(); - history.drain(..end); - total = total.saturating_sub(dropped); - } - if history.len() < original_len { - tracing::info!( - "history truncated {original_len} -> {} items ({total} bytes)", - history.len() - ); - } -} - -fn map_stop(p: ProviderStop) -> StopReason { - match p { - ProviderStop::EndTurn | ProviderStop::ToolUse | ProviderStop::Other => StopReason::EndTurn, - ProviderStop::MaxTokens => StopReason::MaxTokens, - ProviderStop::Refusal => StopReason::Refusal, + .observed()); } } diff --git a/crates/buzz-agent/src/builtin.rs b/crates/buzz-agent/src/builtin.rs deleted file mode 100644 index 9b604766d4..0000000000 --- a/crates/buzz-agent/src/builtin.rs +++ /dev/null @@ -1,575 +0,0 @@ -//! Built-in tools that run in-process, bypassing MCP. -//! -//! Currently: `load_skill` — reads a skill's full SKILL.md body from disk -//! and returns it so the agent can load skill content on demand rather than -//! having every skill inlined into the system prompt at session start. - -use serde_json::{json, Value}; - -use crate::hints::{strip_frontmatter, SkillEntry, MAX_SKILL_BODY_BYTES}; -use crate::mcp::truncate_at_boundary; -use crate::types::{ToolDef, ToolResult, ToolResultContent}; - -pub const LOAD_SKILL_TOOL: &str = "load_skill"; - -/// Return the `ToolDef` for `load_skill` to include in the LLM tool list. -pub fn load_skill_def() -> ToolDef { - ToolDef { - name: LOAD_SKILL_TOOL.to_owned(), - description: "Load the full content of a skill by name. \ - Call this before using a skill — the system prompt lists skill names \ - and descriptions only; the full instructions are loaded on demand. \ - To load a supporting file within a skill, use the form \ - \"skill-name/relative/path\" (e.g. \"my-skill/references/foo.md\")." - .to_owned(), - input_schema: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The skill name as listed in the Available Skills section, \ - or \"skill-name/relative/path\" to load a supporting file." - } - }, - "required": ["name"] - }), - } -} - -/// Execute a `load_skill` call. Returns a `ToolResult` on success or a -/// user-visible error result if the skill is not found or cannot be read. -pub async fn call_load_skill(arguments: &Value, skills: &[SkillEntry]) -> ToolResult { - let name = match arguments.get("name").and_then(Value::as_str) { - Some(n) => n, - None => { - return error_result("load_skill: missing required argument \"name\""); - } - }; - - // Two forms: - // "skill-name" → load SKILL.md body + ## Supporting Files section - // "skill-name/rel/path" → load a specific supporting file - if let Some((skill_name, rel_path)) = name.split_once('/') { - return load_supporting_file(skill_name, rel_path, skills).await; - } - - // Plain skill-name form: load SKILL.md body. - let entry = match skills.iter().find(|s| s.name == name) { - Some(e) => e, - None => { - let available: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); - return error_result(&format!( - "load_skill: skill {name:?} not found. Available: {available:?}" - )); - } - }; - - // Read the file off the async executor to avoid blocking a Tokio worker. - let skill_path = entry.path.clone(); - let raw = match tokio::task::spawn_blocking(move || std::fs::read_to_string(&skill_path)) - .await - .unwrap_or_else(|e| Err(std::io::Error::other(e))) - { - Ok(s) => s, - Err(e) => { - return error_result(&format!("load_skill: could not read {:?}: {e}", entry.path)); - } - }; - - // Strip the YAML frontmatter — the agent already knows name/description - // from the system prompt; return only the body. - let body = strip_frontmatter(&raw); - - let mut output = body.to_owned(); - - // Append ## Supporting Files section if this skill has any. - if !entry.supporting_files.is_empty() { - let skill_dir = entry.path.parent().unwrap_or(&entry.path); - output.push_str("\n\n## Supporting Files\n\n"); - for file in &entry.supporting_files { - if let Ok(rel) = file.strip_prefix(skill_dir) { - let rel_str = rel.to_string_lossy().replace('\\', "/"); - output.push_str(&format!( - "- {} (load_skill(name: \"{}/{}\"))\n", - rel_str, entry.name, rel_str - )); - } - } - } - - // Apply the size cap to the full output (body + Supporting Files section) - // so the total tool result stays within MAX_SKILL_BODY_BYTES. - let output = if output.len() > MAX_SKILL_BODY_BYTES { - truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned() - } else { - output - }; - - ToolResult { - provider_id: String::new(), - content: vec![ToolResultContent::Text(output)], - is_error: false, - } -} - -/// Load a supporting file identified by `skill_name/rel_path`. -/// Matches against the pre-enumerated `supporting_files` list and applies a -/// canonicalize-based traversal guard before reading. -async fn load_supporting_file( - skill_name: &str, - rel_path: &str, - skills: &[SkillEntry], -) -> ToolResult { - let rel_path = rel_path.replace('\\', "/"); - - let entry = match skills.iter().find(|s| s.name == skill_name) { - Some(e) => e, - None => { - let available: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); - return error_result(&format!( - "load_skill: skill {skill_name:?} not found. Available: {available:?}" - )); - } - }; - - let skill_dir = match entry.path.parent() { - Some(d) => d, - None => { - return error_result(&format!( - "load_skill: could not determine skill directory for {skill_name:?}" - )); - } - }; - - // Match rel_path against the pre-enumerated supporting_files list. - let matched = entry.supporting_files.iter().find(|f| { - f.strip_prefix(skill_dir) - .map(|r| r.to_string_lossy().replace('\\', "/") == rel_path) - .unwrap_or(false) - }); - - let file_path = match matched { - Some(p) => p, - None => { - let available: Vec = entry - .supporting_files - .iter() - .filter_map(|f| { - f.strip_prefix(skill_dir) - .ok() - .map(|r| r.to_string_lossy().replace('\\', "/")) - }) - .collect(); - if available.is_empty() { - return error_result(&format!( - "load_skill: skill {skill_name:?} has no supporting files." - )); - } - return error_result(&format!( - "load_skill: file {rel_path:?} not found in skill {skill_name:?}. \ - Available: {available:?}" - )); - } - }; - - // Traversal guard: canonicalize both paths and verify the file stays inside - // the skill directory. Fail hard if the skill directory itself can't be - // canonicalized — a degraded guard is worse than no guard. - let canonical_skill_dir = match skill_dir.canonicalize() { - Ok(p) => p, - Err(e) => { - return error_result(&format!( - "load_skill: could not canonicalize skill directory for {skill_name:?}: {e}" - )); - } - }; - - // Clone the path so we can move it into spawn_blocking. - let file_path = file_path.clone(); - let skill_name = skill_name.to_owned(); - let rel_path_owned = rel_path.clone(); - - match tokio::task::spawn_blocking(move || file_path.canonicalize().map(|c| (c, file_path))) - .await - .unwrap_or_else(|e| Err(std::io::Error::other(e))) - { - Ok((canonical_file, resolved_path)) if canonical_file.starts_with(&canonical_skill_dir) => { - match tokio::task::spawn_blocking(move || std::fs::read_to_string(&resolved_path)) - .await - .unwrap_or_else(|e| Err(std::io::Error::other(e))) - { - Ok(content) => { - let output = format!( - "# Loaded: {}/{}\n\n{}\n\n---\nFile loaded into context.", - skill_name, rel_path_owned, content - ); - let output = if output.len() > MAX_SKILL_BODY_BYTES { - truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned() - } else { - output - }; - ToolResult { - provider_id: String::new(), - content: vec![ToolResultContent::Text(output)], - is_error: false, - } - } - Err(e) => error_result(&format!( - "load_skill: could not read {skill_name:?}/{rel_path_owned}: {e}" - )), - } - } - Ok(_) => error_result(&format!( - "load_skill: refusing to load {skill_name:?}/{rel_path_owned}: \ - resolves outside the skill directory" - )), - Err(e) => error_result(&format!( - "load_skill: could not resolve {skill_name:?}/{rel_path_owned}: {e}" - )), - } -} - -fn error_result(msg: &str) -> ToolResult { - ToolResult { - provider_id: String::new(), - content: vec![ToolResultContent::Text(msg.to_owned())], - is_error: true, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - use tempfile::TempDir; - - fn text_content(result: &ToolResult) -> String { - match &result.content[0] { - ToolResultContent::Text(t) => t.clone(), - ToolResultContent::Image { .. } => panic!("unexpected Image content in test"), - } - } - - fn make_skill(name: &str, description: &str, path: PathBuf) -> SkillEntry { - SkillEntry { - name: name.to_owned(), - description: description.to_owned(), - path, - supporting_files: Vec::new(), - } - } - - fn make_skill_with_files( - name: &str, - description: &str, - path: PathBuf, - supporting_files: Vec, - ) -> SkillEntry { - SkillEntry { - name: name.to_owned(), - description: description.to_owned(), - path, - supporting_files, - } - } - - #[tokio::test] - async fn call_load_skill_missing_name_arg() { - let result = call_load_skill(&serde_json::json!({}), &[]).await; - assert!(result.is_error); - let text = text_content(&result); - assert!(text.contains("missing required argument"), "got: {text}"); - } - - #[tokio::test] - async fn call_load_skill_skill_not_found() { - let result = call_load_skill(&serde_json::json!({"name": "no-such"}), &[]).await; - assert!(result.is_error); - let text = text_content(&result); - assert!(text.contains("not found"), "got: {text}"); - } - - #[tokio::test] - async fn call_load_skill_returns_body_strips_frontmatter() { - let tmp = TempDir::new().unwrap(); - let skill_md = tmp.path().join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: test\ndescription: A test\n---\nSkill body here.\n", - ) - .unwrap(); - let skills = vec![make_skill("test", "A test", skill_md)]; - let result = call_load_skill(&serde_json::json!({"name": "test"}), &skills).await; - assert!(!result.is_error); - let text = text_content(&result); - assert!(text.contains("Skill body here."), "got: {text}"); - assert!( - !text.contains("---"), - "frontmatter should be stripped: {text}" - ); - } - - #[tokio::test] - async fn call_load_skill_appends_supporting_files_section() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - let skill_md = skill_dir.join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: my-skill\ndescription: desc\n---\nBody.\n", - ) - .unwrap(); - let refs_dir = skill_dir.join("references"); - std::fs::create_dir_all(&refs_dir).unwrap(); - let ref_file = refs_dir.join("foo.md"); - std::fs::write(&ref_file, "Reference content.").unwrap(); - - let skills = vec![make_skill_with_files( - "my-skill", - "desc", - skill_md, - vec![ref_file], - )]; - let result = call_load_skill(&serde_json::json!({"name": "my-skill"}), &skills).await; - assert!(!result.is_error); - let text = text_content(&result); - assert!(text.contains("Body."), "body missing: {text}"); - assert!( - text.contains("## Supporting Files"), - "missing Supporting Files section: {text}" - ); - assert!( - text.contains("references/foo.md"), - "missing file listing: {text}" - ); - assert!( - text.contains("load_skill(name: \"my-skill/references/foo.md\")"), - "missing load_skill hint: {text}" - ); - } - - #[tokio::test] - async fn call_load_skill_no_supporting_files_section_when_empty() { - let tmp = TempDir::new().unwrap(); - let skill_md = tmp.path().join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: bare\ndescription: desc\n---\nBody.\n", - ) - .unwrap(); - let skills = vec![make_skill("bare", "desc", skill_md)]; - let result = call_load_skill(&serde_json::json!({"name": "bare"}), &skills).await; - assert!(!result.is_error); - let text = text_content(&result); - assert!( - !text.contains("## Supporting Files"), - "should not have Supporting Files section when none: {text}" - ); - } - - #[tokio::test] - async fn call_load_skill_supporting_file_returns_content() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - let skill_md = skill_dir.join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: my-skill\ndescription: desc\n---\nBody.\n", - ) - .unwrap(); - let refs_dir = skill_dir.join("references"); - std::fs::create_dir_all(&refs_dir).unwrap(); - let ref_file = refs_dir.join("foo.md"); - std::fs::write(&ref_file, "Reference content here.").unwrap(); - - let skills = vec![make_skill_with_files( - "my-skill", - "desc", - skill_md, - vec![ref_file], - )]; - let result = call_load_skill( - &serde_json::json!({"name": "my-skill/references/foo.md"}), - &skills, - ) - .await; - assert!(!result.is_error, "expected success, got error"); - let text = text_content(&result); - assert!( - text.contains("Reference content here."), - "file content missing: {text}" - ); - assert!( - text.contains("# Loaded: my-skill/references/foo.md"), - "missing header: {text}" - ); - } - - #[tokio::test] - async fn call_load_skill_supporting_file_not_found_lists_available() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - let skill_md = skill_dir.join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: my-skill\ndescription: desc\n---\nBody.\n", - ) - .unwrap(); - let refs_dir = skill_dir.join("references"); - std::fs::create_dir_all(&refs_dir).unwrap(); - let ref_file = refs_dir.join("foo.md"); - std::fs::write(&ref_file, "content").unwrap(); - - let skills = vec![make_skill_with_files( - "my-skill", - "desc", - skill_md, - vec![ref_file], - )]; - let result = call_load_skill( - &serde_json::json!({"name": "my-skill/references/missing.md"}), - &skills, - ) - .await; - assert!(result.is_error); - let text = text_content(&result); - assert!(text.contains("not found"), "got: {text}"); - assert!( - text.contains("references/foo.md"), - "should list available: {text}" - ); - } - - #[tokio::test] - async fn call_load_skill_no_supporting_files_error_message() { - let tmp = TempDir::new().unwrap(); - let skill_md = tmp.path().join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: bare\ndescription: desc\n---\nBody.\n", - ) - .unwrap(); - let skills = vec![make_skill("bare", "desc", skill_md)]; - let result = - call_load_skill(&serde_json::json!({"name": "bare/anything.md"}), &skills).await; - assert!(result.is_error); - let text = text_content(&result); - assert!(text.contains("no supporting files"), "got: {text}"); - } - - #[tokio::test] - async fn call_load_skill_traversal_guard_rejects_escape() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path().join("my-skill"); - std::fs::create_dir_all(&skill_dir).unwrap(); - let skill_md = skill_dir.join("SKILL.md"); - std::fs::write( - &skill_md, - "---\nname: my-skill\ndescription: desc\n---\nBody.\n", - ) - .unwrap(); - - // Create a file outside the skill dir that we'll try to reference. - let outside_file = tmp.path().join("secret.txt"); - std::fs::write(&outside_file, "secret content").unwrap(); - - // Manually construct a SkillEntry with a supporting_files entry that - // points outside the skill dir — simulating a crafted/malicious entry. - // The traversal guard should catch this. - let skills = vec![make_skill_with_files( - "my-skill", - "desc", - skill_md.clone(), - vec![outside_file.clone()], - )]; - - // The slash form splits "my-skill/../secret.txt" into skill_name="my-skill" - // and rel_path="../secret.txt". strip_prefix(skill_dir) on outside_file - // fails, so it won't match any supporting_files entry — the pre-enumeration - // guard rejects it before the canonicalize guard even fires. - let result = call_load_skill( - &serde_json::json!({"name": "my-skill/../secret.txt"}), - &skills, - ) - .await; - assert!(result.is_error, "traversal attempt should be rejected"); - let text = text_content(&result); - assert!( - !text.contains("secret content"), - "secret content must not be returned: {text}" - ); - } - - #[tokio::test] - async fn call_load_skill_truncates_large_body() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - let skill_md = skill_dir.join("SKILL.md"); - // Build a body that exceeds MAX_SKILL_BODY_BYTES (32 KiB). - let large_body = "x".repeat(40 * 1024); - std::fs::write( - &skill_md, - format!("---\nname: big\ndescription: desc\n---\n{large_body}\n"), - ) - .unwrap(); - // Add a supporting file so the Supporting Files section is also appended - // before the cap is applied. - let refs_dir = skill_dir.join("references"); - std::fs::create_dir_all(&refs_dir).unwrap(); - let ref_file = refs_dir.join("extra.md"); - std::fs::write(&ref_file, "extra content").unwrap(); - - let skills = vec![make_skill_with_files( - "big", - "desc", - skill_md, - vec![ref_file], - )]; - let result = call_load_skill(&serde_json::json!({"name": "big"}), &skills).await; - assert!(!result.is_error); - let text = text_content(&result); - assert!( - text.len() <= MAX_SKILL_BODY_BYTES, - "output length {} exceeds MAX_SKILL_BODY_BYTES {}", - text.len(), - MAX_SKILL_BODY_BYTES - ); - } - - #[tokio::test] - async fn call_load_skill_truncates_large_supporting_file() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - let skill_md = skill_dir.join("SKILL.md"); - std::fs::write(&skill_md, "---\nname: big\ndescription: desc\n---\nBody.\n").unwrap(); - - let refs_dir = skill_dir.join("references"); - std::fs::create_dir_all(&refs_dir).unwrap(); - let ref_file = refs_dir.join("huge.md"); - std::fs::write(&ref_file, "x".repeat(MAX_SKILL_BODY_BYTES * 2)).unwrap(); - - let skills = vec![make_skill_with_files( - "big", - "desc", - skill_md, - vec![ref_file], - )]; - let result = call_load_skill( - &serde_json::json!({"name": "big/references/huge.md"}), - &skills, - ) - .await; - assert!(!result.is_error); - let text = text_content(&result); - assert!( - text.len() <= MAX_SKILL_BODY_BYTES, - "output length {} exceeds MAX_SKILL_BODY_BYTES {}", - text.len(), - MAX_SKILL_BODY_BYTES - ); - assert!( - text.starts_with("# Loaded: big/references/huge.md"), - "missing supporting-file header: {text}" - ); - } -} diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f3464fb903..add5684bba 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -1,2709 +1,263 @@ -use std::time::Duration; - +//! Environment configuration, translated into Goose's native vocabulary. +//! +//! This replaces `crates/buzz-agent/src/config.rs` (2,709 lines). Most of that +//! file existed to *implement* provider configuration: the `Provider` enum, +//! per-provider base URLs, model-name normalization, Databricks host parsing, +//! OpenAI auto-upgrade rules, and the resolution order between them. +//! +//! Goose already owns all of that (`goose::config`, `goose::providers`). So +//! what remains here is a translation table: read the `BUZZ_AGENT_*` variables +//! `buzz-acp` injects (see `desktop/src-tauri/src/managed_agents/runtime.rs`), +//! and set the `GOOSE_*` / provider variables Goose reads. +//! +//! The mapping is applied to the *process* environment before the Goose +//! `Config` singleton is first touched, because `Config::global()` reads env at +//! initialization. + +use goose_provider_types::goose_mode::GooseMode; + +/// ACP protocol version. Buzz squats on v2 ahead of the upstream RFD; see the +/// note in `lib.rs::initialize`. pub const PROTOCOL_VERSION: u32 = 2; -/// Reasoning/thinking effort level for providers that support it. -/// -/// Set via `BUZZ_AGENT_THINKING_EFFORT` (`none|minimal|low|medium|high|xhigh|max`). -/// When unset the provider's default behaviour is preserved — no thinking -/// config is sent in the request body. -/// -/// Provider support (doc-verified, July 2025): -/// - **Anthropic adaptive**: `low|medium|high|xhigh|max` (model-dependent; see `anthropic_thinking_config`). -/// `none`/`minimal` are not Anthropic values — rejected at startup. -/// - **Anthropic manual budget** (claude-3*, opus-4-5): `low|medium|high`; `xhigh`/`max` clamp to high budget. -/// - **OpenAI Responses / Chat Completions**: effort support is model-dependent and normalized at -/// request time; `max` is valid for documented max-supporting families such as GPT-5.6. -/// - **Databricks**: routed by model family (Claude → Anthropic mapping, GPT-5 → Responses, MLflow → Chat). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum ThinkingEffort { - None, - Minimal, - Low, - Medium, - High, - XHigh, - Max, -} - -impl ThinkingEffort { - /// Map level to an Anthropic `budget_tokens` value for legacy Claude 3.x / Opus 4.5 models. - /// `XHigh` and `Max` clamp to the high budget value; the answer-room reserve of 1024 tokens - /// is applied separately in `anthropic_thinking_config`. - pub fn anthropic_budget_tokens(self) -> u32 { - match self { - ThinkingEffort::Low => 1_024, - ThinkingEffort::Medium => 8_192, - ThinkingEffort::High | ThinkingEffort::XHigh | ThinkingEffort::Max => 32_768, - // None/Minimal are not valid for Anthropic (rejected at startup); treat as zero - // defensively so a misconfigured call doesn't accidentally enable thinking. - ThinkingEffort::None | ThinkingEffort::Minimal => 0, - } - } - - /// Map level to an OpenAI `reasoning.effort` / `reasoning_effort` string. - pub fn openai_effort_str(self) -> &'static str { - match self { - ThinkingEffort::None => "none", - ThinkingEffort::Minimal => "minimal", - ThinkingEffort::Low => "low", - ThinkingEffort::Medium => "medium", - ThinkingEffort::High => "high", - ThinkingEffort::XHigh => "xhigh", - ThinkingEffort::Max => "max", - } - } - - /// Map level to an Anthropic `output_config.effort` string. - /// Returns the level string if supported, or the highest supported level for the model. - /// Caller must apply model-level clamping via `clamp_for_anthropic_adaptive`. - pub fn anthropic_effort_str(self) -> &'static str { - match self { - ThinkingEffort::Low => "low", - ThinkingEffort::Medium => "medium", - ThinkingEffort::High => "high", - ThinkingEffort::XHigh => "xhigh", - ThinkingEffort::Max => "max", - // None/Minimal are rejected at startup for Anthropic; defensive fallback. - ThinkingEffort::None | ThinkingEffort::Minimal => "low", - } - } -} - -/// Strip any endpoint-naming prefix from a model name so the family classifiers -/// (`is_manual_budget_model`, `is_adaptive_thinking_model`, etc.) can match on the canonical -/// `claude-*` form regardless of how the model is stored in the Databricks catalog. -/// -/// Rather than maintaining an allowlist of known prefixes, this function finds the first -/// occurrence of a known model-family token (`claude-`, `gpt-`) and drops everything before -/// it. This handles any endpoint naming convention without needing to enumerate prefixes. -/// -/// Examples: -/// - `databricks-claude-fable-5` → `claude-fable-5` -/// - `goose-claude-fable-5` → `claude-fable-5` -/// - `team-x-claude-opus-4-7` → `claude-opus-4-7` -/// - `goose-gpt-5.5` → `gpt-5.5` -/// - `llama-3` → `llama-3` (no family token, returned unchanged) -/// -/// If no family token is present the name is returned unchanged. -fn strip_catalog_prefix(model: &str) -> &str { - const FAMILY_TOKENS: &[&str] = &["claude-", "gpt-"]; - let lower = model.to_ascii_lowercase(); - let first_idx = FAMILY_TOKENS.iter().filter_map(|tok| lower.find(tok)).min(); - match first_idx { - Some(idx) => &model[idx..], - None => model, - } -} - -/// Build the Anthropic thinking/effort request fields for the given model and effort level. -/// -/// API shape selection (per Anthropic extended-thinking support table, -/// https://platform.claude.com/docs/en/build-with-claude/extended-thinking, July 2025): -/// -/// **Adaptive families** — `thinking: {type:"adaptive"}` + `output_config: {effort}`. -/// These models use adaptive thinking; `thinking:{type:"adaptive"}` is required to enable -/// thinking — without it requests run without thinking even when `output_config.effort` is set. -/// Doc-verified (extended-thinking table): Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5.x, Sonnet 4.6. -/// Matched by explicit version strings (no wildcard over version numbers). -/// -/// **Manual-budget families** — `thinking: {type:"enabled", budget_tokens}`. -/// `budget_tokens` is clamped to `min(level_budget, max_output_tokens - 1024)` to preserve -/// at least 1024 answer tokens. If the result is < 1024 (i.e., `max_output_tokens <= 2047`), -/// thinking is omitted entirely with a `warn!`. -/// Doc-verified: claude-3* (legacy), claude-opus-4-5 (effort page: "uses manual thinking"). -/// -/// **Everything else** — omit both fields. This includes unknown/future `claude-*` names -/// not yet in the support table. Safer to omit than to guess an unverified shape. -/// -/// The Databricks `databricks-` and other endpoint-naming prefixes are stripped before -/// matching so that `databricks-claude-opus-4-7`, `goose-claude-fable-5`, and -/// `team-x-claude-opus-4-7` all route to the correct bucket. See `strip_catalog_prefix`. -/// -/// Returns `(thinking_field, output_config_field)` where each is `None` if not applicable. -pub fn anthropic_thinking_config( - effective_model: &str, - effort: ThinkingEffort, - max_output_tokens: u32, -) -> (Option, Option) { - use serde_json::json; - // Normalise the model name for matching: strip any endpoint-naming prefix - // (e.g. "databricks-claude-opus-4-7" → "claude-opus-4-7", - // "goose-claude-fable-5" → "claude-fable-5", - // "team-x-claude-opus-4-7" → "claude-opus-4-7"). - let model = strip_catalog_prefix(effective_model); - - if is_manual_budget_model(model) { - // Manual-budget shape: budget_tokens must be strictly < max_tokens AND must leave - // at least MIN_ANSWER_TOKENS (1024) for the visible answer. The Anthropic API - // requires budget_tokens < max_tokens AND budget_tokens >= 1024. - // - // Clamp: budget = min(level_budget, max_output_tokens - MIN_ANSWER_TOKENS). - // If result < MIN_ANSWER_TOKENS, thinking would starve the answer — omit thinking - // entirely and warn instead of emitting an invalid or answer-starving budget. - const MIN_ANSWER_TOKENS: u32 = 1024; - let level_budget = effort.anthropic_budget_tokens(); - let headroom = max_output_tokens.saturating_sub(MIN_ANSWER_TOKENS); - let budget = level_budget.min(headroom); - if budget < MIN_ANSWER_TOKENS { - tracing::warn!( - max_output_tokens, - level_budget, - headroom, - "BUZZ_AGENT_THINKING_EFFORT: max_output_tokens too small to fit thinking budget + answer headroom; omitting thinking fields" - ); - return (None, None); - } - ( - Some(json!({ "type": "enabled", "budget_tokens": budget })), - None, - ) - } else if is_adaptive_thinking_model(model) { - // Adaptive families: thinking must be explicitly enabled via type:"adaptive". - // output_config.effort controls the depth. Both fields are required together. - // Apply per-model effort clamping: if the requested level exceeds the model's - // doc-verified maximum, clamp down to the highest supported level with a warning. - let clamped = clamp_adaptive_effort(model, effort); - ( - Some(json!({ "type": "adaptive" })), - Some(json!({ "effort": clamped.anthropic_effort_str() })), - ) - } else { - // Unrecognised or unverified model name — omit both fields rather than guess. - // This includes unknown future claude-* names not yet in the support table. - (None, None) - } -} - -/// Returns true for adaptive Anthropic models that support the `xhigh` effort level. -/// -/// Used by both `clamp_adaptive_effort` (request-time) and `anthropic_efforts_for_model` -/// (UI capability table) to keep xhigh-support classification in a single place. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -fn anthropic_model_supports_xhigh(model: &str) -> bool { - model.starts_with("claude-opus-4-7") - || model.starts_with("claude-opus-4-8") - || model.starts_with("claude-sonnet-5") - || model.starts_with("claude-fable-5") - || model.starts_with("claude-mythos-5") -} - -/// Clamp the requested effort level to the highest doc-verified level for the given adaptive model. -/// -/// Doc-verified availability (Anthropic effort page, July 2025): -/// - `max`: Opus 4.8, 4.7, 4.6; Sonnet 5.x, 4.6; Fable 5; Mythos 5; Mythos Preview -/// - `xhigh`: Opus 4.8, 4.7; Sonnet 5.x; Fable 5; Mythos 5 -/// (NOT Opus 4.6, Sonnet 4.6, or Mythos Preview) -/// - `low|medium|high`: all adaptive families -/// -/// If the requested level is not available for the model, clamps down to the highest -/// supported level below the requested one, and logs a warning. This is dynamic (not -/// startup-time) because `session/set_model` can change the model after startup. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -pub fn clamp_adaptive_effort(model: &str, effort: ThinkingEffort) -> ThinkingEffort { - // Models that support all levels including xhigh (and max). - let supports_xhigh = anthropic_model_supports_xhigh(model); - - let clamped = if supports_xhigh { - effort // all levels pass through - } else if effort == ThinkingEffort::XHigh { - // xhigh not available for this model; clamp to high (the highest supported below xhigh). - ThinkingEffort::High - } else { - effort // low/medium/high/max all pass through for the other adaptive families - }; - - if clamped != effort { - tracing::warn!( - model, - requested = effort.openai_effort_str(), - clamped = clamped.openai_effort_str(), - "BUZZ_AGENT_THINKING_EFFORT is not available for this model; clamping to highest supported level" - ); - } - clamped -} - -/// Returns true if `lower_model` contains `token` as a bounded family segment — i.e., the -/// token is immediately followed by end-of-string or a `-` separator (not a digit or letter). -/// -/// This prevents: -/// - `gpt-5.1` from matching `gpt-5.10` (digit follows the `1`) -/// - `gpt-5-1` from matching `gpt-5-1106` (digit follows the `1`) -/// - `gpt-5-4` from matching `gpt-5-4o` (letter follows the `4`) -/// -/// Gateway prefixes (`databricks-`) and date/build suffixes (`-2025-04-01`) are allowed -/// because they start with `-` which is the only permitted boundary character. -fn gpt5_token_matches(lower_model: &str, token: &str) -> bool { - let mut start = 0; - while let Some(pos) = lower_model[start..].find(token) { - let abs = start + pos; - let after = abs + token.len(); - // The character immediately after the token must be end-of-string or '-'. - // Any alphanumeric character (digit OR letter) means this is a longer token, not - // the family we're looking for. - let safe_suffix = lower_model[after..].chars().next().is_none_or(|c| c == '-'); - if safe_suffix { - return true; - } - start = abs + 1; - } - false -} - -/// Like `gpt5_token_matches` but additionally rejects short version-like numeric suffixes — -/// used for the base `gpt-5` / `gpt5` token to avoid false-matching unrecognized versions. -/// -/// After a `-` separator: -/// - `-…` e.g. `-pro` → **accepted** (capability suffix, no digits) -/// - `digit_run == 1-3` AND the char right after the digits is a **letter** e.g. `-4o` → -/// **accepted** (real variant shape: digit + letter) -/// - `digit_run == 1-3` AND the char after the digits is end-of-string, `-`, `.`, or other -/// separator e.g. `-10`, `-10-preview` → **rejected** (version-like suffix) -/// - `digit_run >= 4` regardless of what follows e.g. `-1106`, `-1106-preview`, `-0514` → -/// **accepted** (date/build segment) -fn gpt5_base_matches(lower_model: &str, token: &str) -> bool { - let mut start = 0; - while let Some(pos) = lower_model[start..].find(token) { - let abs = start + pos; - let after = abs + token.len(); - let rest = &lower_model[after..]; - let safe_suffix = if rest.is_empty() { - // End of string — clean boundary. - true - } else if let Some(tail) = rest.strip_prefix('-') { - // Count leading digits in the suffix component. - let digit_run: usize = tail.chars().take_while(|c| c.is_ascii_digit()).count(); - if digit_run == 0 { - // No leading digit (e.g. '-pro'): capability suffix → accepted. - true - } else if digit_run >= 4 { - // 4+ digit run (e.g. '-1106', '-1106-preview', '-0514'): date/build → accepted. - true - } else { - // 1-3 digit run: accepted only if the char right after the digits is a letter - // (real variant shape like '-4o'). Separator/EOS after short digits is - // version-like (e.g. '-10', '-10-preview') → rejected. - tail[digit_run..] - .chars() - .next() - .is_some_and(|c| c.is_ascii_alphabetic()) - } - } else { - // Dot, letter, or other non-hyphen character directly after token → not base. - false - }; - if safe_suffix { - return true; - } - start = abs + 1; - } - false -} - -/// Returns the set of `reasoning.effort` values supported by a given OpenAI model family. -/// -/// Doc-verified availability (OpenAI model pages, July 2025): -/// -/// | Model | Supported effort values | -/// |-------------|-------------------------------------------| -/// | gpt-5-pro | `high` only | -/// | gpt-5.6 | `none, low, medium, high, xhigh, max` | -/// | gpt-5.5 | `none, low, medium, high, xhigh` | -/// | gpt-5.4 | `none, low, medium, high, xhigh` | -/// | gpt-5.1 | `none, low, medium, high` | -/// | gpt-5 (base)| `minimal, low, medium, high` | -/// | unknown | not doc-verified — `max` clamps to `xhigh` | -/// -/// Note the `none` vs `minimal` split: `gpt-5` (base) supports `minimal` but not `none`; -/// `gpt-5.1`/`gpt-5.4`/`gpt-5.5`/`gpt-5.6` support `none` but not `minimal`. These are matched via -/// nearest-supported fallback in `normalize_effort_for_openai_route`. -/// -/// Match order: `-pro` variant checked before versioned strings to prevent `gpt-5-pro` from -/// falling into the `gpt-5` base bucket (substring "gpt-5" is shared). -/// -/// `model` is a raw model name (may include Databricks gateway prefixes or date suffixes). -/// Unknown models return `None` — callers pass through values except `max`, which clamps to -/// `xhigh` until support is confirmed. -/// Versioned tokens use `gpt5_token_matches` (end-of-string or `-` boundary, blocking digit -/// and letter continuations). The base token uses `gpt5_base_matches`, which additionally -/// rejects short `-<1-3 digit>` suffixes that look like two-digit version numbers. -fn openai_efforts_for_model(model: &str) -> Option<&'static [ThinkingEffort]> { - // Effort ordered from lowest to highest for each family. - const GPT5_PRO: &[ThinkingEffort] = &[ThinkingEffort::High]; - const GPT5_6: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ]; - const GPT5_5_AND_5_4: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ]; - const GPT5_1: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ]; - const GPT5_BASE: &[ThinkingEffort] = &[ - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ]; - - let lower = model.to_ascii_lowercase(); - // Check gpt-5-pro before gpt-5.5 / gpt-5.4 etc. to avoid the `-pro` name - // matching the base "gpt-5" prefix first. - if gpt5_token_matches(&lower, "gpt-5-pro") || gpt5_token_matches(&lower, "gpt5-pro") { - Some(GPT5_PRO) - } else if gpt5_token_matches(&lower, "gpt-5.6") - || gpt5_token_matches(&lower, "gpt5.6") - || gpt5_token_matches(&lower, "gpt-5-6") - || gpt5_token_matches(&lower, "gpt5-6") - { - Some(GPT5_6) - } else if gpt5_token_matches(&lower, "gpt-5.5") - || gpt5_token_matches(&lower, "gpt5.5") - || gpt5_token_matches(&lower, "gpt-5-5") - || gpt5_token_matches(&lower, "gpt5-5") - || gpt5_token_matches(&lower, "gpt-5.4") - || gpt5_token_matches(&lower, "gpt5.4") - || gpt5_token_matches(&lower, "gpt-5-4") - || gpt5_token_matches(&lower, "gpt5-4") - { - // gpt-5.5 and gpt-5.4 share the same effort availability table. - Some(GPT5_5_AND_5_4) - } else if gpt5_token_matches(&lower, "gpt-5.1") - || gpt5_token_matches(&lower, "gpt5.1") - || gpt5_token_matches(&lower, "gpt-5-1") - || gpt5_token_matches(&lower, "gpt5-1") - { - Some(GPT5_1) - } else if gpt5_base_matches(&lower, "gpt-5") || gpt5_base_matches(&lower, "gpt5") { - // Base gpt-5 (no version suffix matching any of the above). - Some(GPT5_BASE) - } else { - // Unknown model — not doc-verified; server validates. - None - } -} - -/// Returns the effort capability set for a given Anthropic model. -/// -/// This is the single production source of truth for Anthropic family routing. -/// Both `anthropic_thinking_config` (request-time) and the effort-table UI -/// (`valid_effort_values_for_provider_model`, via its Anthropic branch) must -/// derive their behaviour from this helper so the two stay in sync. -/// -/// Returns `(valid_values, default)` where: -/// - `valid_values` is the static slice of `ThinkingEffort` values accepted -/// by this model family's effort dropdown. -/// - `default` is `None` for manual-budget models (no semantic default — -/// user must choose) or `Some(High)` for adaptive families. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -pub fn anthropic_efforts_for_model( - model: &str, -) -> (&'static [ThinkingEffort], Option) { - const MANUAL: &[ThinkingEffort] = &[ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ]; - const ADAPTIVE_XHIGH: &[ThinkingEffort] = &[ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ]; - const ADAPTIVE_NO_XHIGH: &[ThinkingEffort] = &[ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::Max, - ]; - - if is_manual_budget_model(model) { - return (MANUAL, None); - } - if is_adaptive_thinking_model(model) { - // Reuse `anthropic_model_supports_xhigh` (the single source of truth - // shared with `clamp_adaptive_effort`) — no side-effects, no duplication. - if anthropic_model_supports_xhigh(model) { - return (ADAPTIVE_XHIGH, Some(ThinkingEffort::High)); - } else { - return (ADAPTIVE_NO_XHIGH, Some(ThinkingEffort::High)); - } - } - // Unknown Anthropic model — assume full adaptive (xhigh-capable) as a safe default. - (ADAPTIVE_XHIGH, Some(ThinkingEffort::High)) -} - -/// Resolve the nearest supported effort level for a given OpenAI model. -/// -/// When the requested effort is not in the model's supported set, falls back to the -/// nearest supported level using this preference order: -/// -/// - `none` ↔ `minimal` are each other's first fallback (the none/minimal split across -/// model families means the "closest analogue" is the other form before jumping to `low`). -/// - Above that pair: upward clamp first, then downward (prefer more thinking over less). -/// - `xhigh` falls back to `high` when not supported (no model skips from `high` to `xhigh`). -/// - `max` passes through for model families whose table includes it; otherwise it resolves to -/// the nearest supported level. -/// -/// Logs a `warn!` on every substitution. -fn resolve_openai_effort( - model: &str, - requested: ThinkingEffort, - supported: &[ThinkingEffort], -) -> ThinkingEffort { - if supported.contains(&requested) { - return requested; - } - - // Build a candidate list ordered by preference: the "other" form of none/minimal first, - // then the levels sorted nearest to requested (ascending distance). - let candidates: Vec = { - // none ↔ minimal are each other's first fallback. - let peer = match requested { - ThinkingEffort::None => Some(ThinkingEffort::Minimal), - ThinkingEffort::Minimal => Some(ThinkingEffort::None), - _ => None, - }; - // All supported values sorted by distance (abs diff in ordinal), upward ties win. - let mut by_dist: Vec = supported.to_vec(); - by_dist.sort_by_key(|&e| { - let dist = (e as i32 - requested as i32).unsigned_abs(); - // Prefer upward (e > requested) to break ties between equidistant values. - let up = if e >= requested { 0u32 } else { 1 }; - (dist, up) - }); - // Peer first, then by distance. - let mut result = Vec::new(); - if let Some(p) = peer { - if supported.contains(&p) { - result.push(p); - } - } - for e in by_dist { - if !result.contains(&e) { - result.push(e); - } - } - result - }; - - let resolved = candidates - .into_iter() - .next() - .expect("supported is non-empty"); - - tracing::warn!( - %model, - requested = requested.openai_effort_str(), - resolved = resolved.openai_effort_str(), - "BUZZ_AGENT_THINKING_EFFORT={} is not supported by this OpenAI model; using nearest supported level", - requested.openai_effort_str(), - ); - resolved -} - -/// Normalize the effort value for an OpenAI-shaped request body (Chat Completions or Responses). -/// -/// Per-model effort availability is applied for doc-verified OpenAI model families. A requested -/// level not in the model's supported set is substituted with the nearest supported level (see -/// `resolve_openai_effort` for preference order). For unknown/unverified models, `max` is clamped -/// to `xhigh` because its support cannot be confirmed; all other values pass through unchanged. -/// -/// Applies to pure-OpenAI request paths AND DBv2 OpenAI-shaped routes. -/// -/// Doc-verified model table (July 2025): -/// - `gpt-5-pro`: `high` only -/// - `gpt-5.6`: `none, low, medium, high, xhigh, max` -/// - `gpt-5.5`, `gpt-5.4`: `none, low, medium, high, xhigh` -/// - `gpt-5.1`: `none, low, medium, high` -/// - `gpt-5` (base): `minimal, low, medium, high` -/// - unknown: `max` clamps to `xhigh`; other values pass through -pub fn normalize_effort_for_openai_route(effort: ThinkingEffort, model: &str) -> ThinkingEffort { - match openai_efforts_for_model(model) { - Some(supported) => resolve_openai_effort(model, effort, supported), - None if effort == ThinkingEffort::Max => { - tracing::warn!( - requested = "max", - resolved = "xhigh", - "BUZZ_AGENT_THINKING_EFFORT=max not confirmed for unknown OpenAI model; clamping to xhigh" - ); - ThinkingEffort::XHigh - } - None => effort, - } -} - -/// Normalize the effort value for an Anthropic-shaped request body (Messages API). -/// -/// Anthropic-shaped bodies (`anthropic_body`) do not have a `none` or `minimal` concept — -/// the thinking block is either present (with a level) or absent. When `none` or `minimal` -/// is configured, we omit the thinking fields entirely and log a warning (omission = provider -/// default; default-on/always-on adaptive models may still think). This handles `DatabricksV2` -/// sessions where the route can switch from GPT to Claude via `session/set_model` after startup. -/// -/// Returns `None` to signal "omit thinking fields", or the original effort if it is a valid -/// Anthropic level. -pub fn normalize_effort_for_anthropic_route(effort: ThinkingEffort) -> Option { - match effort { - ThinkingEffort::None | ThinkingEffort::Minimal => { - tracing::warn!( - requested = effort.openai_effort_str(), - "BUZZ_AGENT_THINKING_EFFORT={} is not expressible as an Anthropic thinking level; \ - omitting thinking fields (provider default; default-on/always-on adaptive models may still think)", - effort.openai_effort_str() - ); - None - } - other => Some(other), - } -} - -/// Returns true for Claude model families that use manual thinking budgets (doc-verified, July 2025). -/// -/// Source: https://platform.claude.com/docs/en/build-with-claude/extended-thinking (support table) -/// - claude-3*: legacy manual budget (all Claude 3.x variants). -/// - claude-opus-4-5: effort page states "uses manual thinking, where effort works alongside -/// the thinking token budget" — manual bucket, not adaptive. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -fn is_manual_budget_model(model: &str) -> bool { - model.starts_with("claude-3") || model == "claude-opus-4-5" -} - -/// Returns true for Claude model families that use adaptive thinking (doc-verified, July 2025). -/// -/// Sources: https://platform.claude.com/docs/en/build-with-claude/extended-thinking (support table) -/// https://platform.claude.com/docs/en/build-with-claude/effort (effort page) -/// -/// Adaptive thinking models (always-on or default-on): -/// Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5.x, Sonnet 4.6, -/// Fable 5 (always-on), Mythos 5 (always-on), Mythos Preview (default-on). -/// -/// Note: Opus 4.5 is NOT in this bucket — it uses manual budget (see `is_manual_budget_model`). -/// No prefix wildcards over version numbers; each entry is doc-verified explicitly. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -fn is_adaptive_thinking_model(model: &str) -> bool { - // Exact version strings for Opus 4.x adaptive models (4.6, 4.7, 4.8). - // Opus 4.5 is excluded — manual budget only. - model.starts_with("claude-opus-4-6") - || model.starts_with("claude-opus-4-7") - || model.starts_with("claude-opus-4-8") - // Sonnet 5.x (any patch/date suffix after "claude-sonnet-5"). - || model.starts_with("claude-sonnet-5") - // Sonnet 4.6 exactly (not Sonnet 4.5 or earlier — not in the adaptive table). - || model.starts_with("claude-sonnet-4-6") - // Fable 5 and Mythos 5 (always-on adaptive thinking, July 2025). - || model.starts_with("claude-fable-5") - || model.starts_with("claude-mythos-5") - // Mythos Preview (default-on adaptive thinking, July 2025). - // Note: xhigh is NOT available on Mythos Preview — clamp_adaptive_effort handles this. - || model.starts_with("claude-mythos-preview") -} - -/// Parse `BUZZ_AGENT_THINKING_EFFORT`. Pure (env-free) for testability. -pub fn parse_thinking_effort(raw: Option<&str>) -> Result, String> { - match raw.map(|s| s.trim().to_ascii_lowercase()).as_deref() { - None | Some("") => Ok(None), - Some("none") => Ok(Some(ThinkingEffort::None)), - Some("minimal") => Ok(Some(ThinkingEffort::Minimal)), - Some("low") => Ok(Some(ThinkingEffort::Low)), - Some("medium") => Ok(Some(ThinkingEffort::Medium)), - Some("high") => Ok(Some(ThinkingEffort::High)), - Some("xhigh") => Ok(Some(ThinkingEffort::XHigh)), - Some("max") => Ok(Some(ThinkingEffort::Max)), - Some(other) => Err(format!( - "config: BUZZ_AGENT_THINKING_EFFORT={other} not supported (use none|minimal|low|medium|high|xhigh|max)" - )), - } -} - +/// Hard caps preserved from buzz-agent's wire contract. These are protocol +/// limits (rejections are `invalid_params`), not loop tuning, so they stay. pub const MAX_PROMPT_BYTES: usize = 1024 * 1024; pub const MAX_SYSTEM_PROMPT_BYTES: usize = 512 * 1024; -/// Total per-result byte ceiling (text + images). Sized for image-bearing -/// results — view_image can legitimately return multi-MiB base64 payloads. -/// Text is governed by the much smaller `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`. -pub const MAX_TOOL_RESULT_BYTES: usize = 8 * 1024 * 1024; -/// Default cap on the *text* portion of a single tool result. Oversized text -/// is middle-elided before it enters history; without this, one fat `cat` -/// burns the context window and forces a lossy handoff. 50 KiB matches the -/// shell-output caps in sprout-dev-mcp, goose, and pi; codex defaults to -/// 10 KB. Tunable via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`. -pub const DEFAULT_TOOL_RESULT_TEXT_BYTES: usize = 50 * 1024; -pub const MAX_TOOL_CALLS_PER_TURN: usize = 64; +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; -pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192; - -pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024; - -pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; - -const DEFAULT_SYSTEM_PROMPT: &str = - "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; +#[derive(Debug, Clone)] +pub struct Config { + /// Model id, if pinned by the harness. `None` lets Goose resolve its own + /// default from `GOOSE_MODEL` / its config file. + pub model: Option, + /// Max provider round-trips per turn. Maps to `SessionConfig.max_turns`. + pub max_rounds: Option, + /// Concurrent sessions this process will hold. + pub max_sessions: usize, + /// Default system prompt, used only when `session/new` omits one. + pub system_prompt: Option, + /// Per-turn wall-clock budget for a single provider request. + pub llm_timeout_secs: u64, + /// Tool-call approval policy. + /// + /// `GooseMode::default()` is **`Auto`** (`goose_mode.rs:23-25`) — every + /// tool call is approved without asking. That matches what buzz ships + /// today (`buzz-acp/src/acp.rs:1671-1712` auto-approves every permission + /// request, and the desktop catalog sets `GOOSE_MODE=auto` for the + /// external goose runtime, `discovery.rs:89`), so it stays the default + /// here to avoid changing behaviour. + /// + /// It is now a knob rather than a hardcode: `BUZZ_AGENT_APPROVAL=approve` + /// makes goose ask before every tool call, `smart_approve` only for + /// sensitive ones, `chat` disables tools entirely. Nothing in buzz drives + /// this yet — wiring it to a real human affordance is the point of the + /// isolation work, and this is the seam it will use. + pub goose_mode: GooseMode, + + // ---- Databricks model-discovery fields ------------------------------- + // Goose owns provider auth for the agent loop, but the desktop model + // picker calls `discover_databricks_models` directly as a library + // (`desktop/src-tauri/src/commands/agent_models.rs:791`). That path is + // still ours, so these three fields survive the swap purely to feed it. + /// Provider family, for model discovery only. + pub provider: Provider, + /// Static bearer. Empty means "try the PKCE cache, no browser". + pub api_key: String, + /// Provider host, e.g. `DATABRICKS_HOST`. + pub base_url: String, +} -#[derive(Debug, Clone, Copy, PartialEq)] +/// Provider families the desktop model picker can discover models for. +/// +/// Retained verbatim from the pre-goose `config.rs` because +/// `desktop/src-tauri` matches on it by name +/// (`commands/agent_models.rs:755-761`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Provider { Anthropic, + #[default] OpenAi, - /// Databricks model serving. Routes to `{base_url}/serving-endpoints/{model}/invocations` - /// with a dynamically-acquired bearer (OAuth 2.0 PKCE, or static `DATABRICKS_TOKEN`). - /// Wire format is OpenAI-chat-compatible — reuses the same body builder and parser. + /// Databricks model serving (`api/2.0/serving-endpoints`). Databricks, - /// Databricks AI Gateway v2. Routes by model family through the gateway's - /// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths. + /// Databricks AI Gateway v2. DatabricksV2, } -/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API` -/// (`auto|chat|responses`); ignored when `provider = Anthropic`. `Auto` -/// picks Responses for `*.openai.com`, Chat Completions otherwise, and -/// permits a one-shot chat→responses upgrade on a "use /v1/responses" -/// provider error. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum OpenAiApi { - Chat, - Responses, - Auto, +/// Map `BUZZ_AGENT_APPROVAL` onto goose's tool-approval policy. +/// +/// Unknown values fall back to the current shipped behaviour (`Auto`) rather +/// than failing the process: a typo in an env var must not take an agent off +/// the air, and silently tightening would be just as surprising as silently +/// loosening. +fn parse_approval(raw: Option<&str>) -> GooseMode { + match raw.map(str::trim).map(str::to_ascii_lowercase).as_deref() { + Some("approve") => GooseMode::Approve, + Some("smart_approve") | Some("smart-approve") => GooseMode::SmartApprove, + Some("chat") => GooseMode::Chat, + Some("auto") | None => GooseMode::Auto, + Some(other) => { + tracing::warn!(value = other, "unknown BUZZ_AGENT_APPROVAL; using auto"); + GooseMode::Auto + } + } +} + +fn env_str(key: &str) -> Option { + std::env::var(key).ok().filter(|s| !s.trim().is_empty()) } -#[derive(Debug, Clone)] -pub struct Config { - pub provider: Provider, - pub system_prompt: String, - pub max_rounds: u32, - pub max_output_tokens: u32, - pub llm_timeout: Duration, - pub tool_timeout: Duration, - pub mcp_init_timeout: Duration, - pub mcp_max_restart_attempts: u32, - pub mcp_restart_base_ms: u64, - pub mcp_restart_max_ms: u64, - pub max_sessions: usize, - pub max_line_bytes: usize, - pub max_history_bytes: usize, - /// Per-tool-result cap on text content. Oversized text is middle-elided - /// (head + tail kept) before entering history. Images are exempt — they - /// are bounded by [`MAX_TOOL_RESULT_BYTES`] and accounted separately. - /// Set via `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES`. - pub max_tool_result_text_bytes: usize, - /// Provider context window in tokens used to gate handoff. The handoff - /// fires when the previous request's (cache-summed) input tokens cross the - /// handoff threshold for this budget, before the next request can exceed - /// the window and 400. Default 200_000 — matching Claude 4.x windows; - /// operators lower/raise it for other models. Set via - /// `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. - pub max_context_tokens: u64, - pub max_handoffs: usize, - pub max_parallel_tools: usize, - pub hook_timeout: Duration, - /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to - /// disable `_Stop` hooks entirely (agent always honors end_turn). - pub stop_max_rejections: u32, - /// Hook server allowlist. See [`HookServers`] for variant semantics. - /// Default (env unset/empty) is `None` — hooks are off unless the - /// operator explicitly opts in. - pub hook_servers: HookServers, - pub api_key: String, - pub model: String, - pub base_url: String, - pub anthropic_api_version: String, - /// OpenAI endpoint selection. See [`OpenAiApi`]. - pub openai_api: OpenAiApi, - /// Prefer mesh-llm's virtual `mesh` model when the configured/effective - /// OpenAI model is `auto` and the live model catalog advertises it. - /// Set by Buzz's relay-mesh provider via - /// `BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1`; other providers keep their - /// existing `auto` semantics. - pub prefer_mesh_for_auto: bool, - pub hints_enabled: bool, - /// Thinking/reasoning effort level. `None` = use provider default (no - /// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`. - pub thinking_effort: Option, +fn env_parse(key: &str) -> Option { + env_str(key).and_then(|s| s.parse().ok()) } impl Config { - pub fn from_env() -> Result { - let databricks_host = env("DATABRICKS_HOST"); - let databricks_model = env("DATABRICKS_MODEL"); - let provider = resolve_provider( - env("BUZZ_AGENT_PROVIDER").as_deref(), - env("ANTHROPIC_API_KEY").as_deref(), - env("OPENAI_COMPAT_API_KEY").as_deref(), - )?; - - // Universal model override — takes priority over provider-specific model - // env vars (ANTHROPIC_MODEL, OPENAI_COMPAT_MODEL, DATABRICKS_MODEL) when - // present. Set by the desktop from the persona/record to express explicit - // user intent; provider-specific vars serve as defaults for CLI/standalone use. - let buzz_agent_model = env("BUZZ_AGENT_MODEL"); + /// Read `BUZZ_AGENT_*` from the environment and, as a side effect, project + /// the provider-shaped ones onto the `GOOSE_*` names Goose reads. + pub fn from_env() -> Self { + Self::project_goose_env(); + + let system_prompt = env_str("BUZZ_AGENT_SYSTEM_PROMPT").or_else(|| { + env_str("BUZZ_AGENT_SYSTEM_PROMPT_FILE") + .and_then(|p| std::fs::read_to_string(p).ok()) + .filter(|s| !s.trim().is_empty()) + }); - // OPENAI_COMPAT_API is only read when provider=openai, so a stray - // bad value can't break an Anthropic-only deployment. - // - // Databricks borrows api_key as the *optional* `DATABRICKS_TOKEN` escape - // hatch — empty means "use OAuth PKCE." Legacy Databricks encodes the - // model in the URL path; Databricks v2 keeps it in the request body. - let (api_key, model, base_url, openai_api) = match provider { - Provider::Anthropic => ( - req("ANTHROPIC_API_KEY")?, - resolve_model( - buzz_agent_model.as_deref(), - env("ANTHROPIC_MODEL").as_deref(), - ) - .ok_or_else(|| "config: ANTHROPIC_MODEL required".to_string())?, - env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), - OpenAiApi::Auto, // unused for Anthropic - ), - Provider::OpenAi => ( - req("OPENAI_COMPAT_API_KEY")?, - resolve_model( - buzz_agent_model.as_deref(), - env("OPENAI_COMPAT_MODEL").as_deref(), - ) - .ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?, - env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), - parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, - ), - Provider::Databricks | Provider::DatabricksV2 => ( - env("DATABRICKS_TOKEN").unwrap_or_default(), - resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref()) - .ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?, - databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?, - OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch - ), - }; - let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { - (Some(_), Some(_)) => return Err( - "config: BUZZ_AGENT_SYSTEM_PROMPT and BUZZ_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()), - (Some(s), _) => s, - (_, Some(p)) => std::fs::read_to_string(&p).map_err(|e| format!("config: read {p}: {e}"))?, - _ => DEFAULT_SYSTEM_PROMPT.to_owned(), - }; - let cfg = Config { - provider, + Self { + model: env_str("BUZZ_AGENT_MODEL"), + max_rounds: env_parse::("BUZZ_AGENT_MAX_ROUNDS").filter(|n| *n > 0), + max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(8), system_prompt, - api_key, - model, - base_url, - anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"), - openai_api, - prefer_mesh_for_auto: parse_env("BUZZ_AGENT_PREFER_MESH_FOR_AUTO", 0u8)? != 0, - max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?, - max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 32_768)?, - llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?), - tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 660)?), - mcp_init_timeout: Duration::from_secs(parse_env( - "BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", - 30, - )?), - mcp_max_restart_attempts: parse_env("BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS", 3u32)?, - mcp_restart_base_ms: parse_env("BUZZ_AGENT_MCP_RESTART_BASE_MS", 500u64)?, - mcp_restart_max_ms: parse_env("BUZZ_AGENT_MCP_RESTART_MAX_MS", 30_000u64)?, - max_sessions: parse_env("BUZZ_AGENT_MAX_SESSIONS", usize::MAX)?, - max_line_bytes: parse_env("BUZZ_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?, - max_history_bytes: parse_env("BUZZ_AGENT_MAX_HISTORY_BYTES", 16 * 1024 * 1024)?, - max_tool_result_text_bytes: parse_env( - "BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES", - DEFAULT_TOOL_RESULT_TEXT_BYTES, - )?, - max_context_tokens: parse_env("BUZZ_AGENT_MAX_CONTEXT_TOKENS", 200_000u64)?, - max_handoffs: parse_env("BUZZ_AGENT_MAX_HANDOFFS", 10)?, - max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, - hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), - stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, - hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), - hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, - thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, - }; - cfg.validate()?; - Ok(cfg) + llm_timeout_secs: env_parse("BUZZ_AGENT_LLM_TIMEOUT_SECS").unwrap_or(600), + goose_mode: parse_approval(env_str("BUZZ_AGENT_APPROVAL").as_deref()), + // Discovery-only; the agent loop resolves providers through goose. + provider: Provider::default(), + api_key: String::new(), + base_url: String::new(), + } } - /// Construct a minimal `Config` for model-catalog discovery. + /// Minimal config for Databricks model discovery. /// - /// Only the fields used by [`build_token_source`](crate::llm::build_token_source) - /// and the catalog HTTP helpers are meaningful; all others are set to - /// inert defaults. Never call `from_env` for discovery — it requires - /// `DATABRICKS_MODEL` and other fields that are irrelevant here. + /// Signature preserved from the pre-goose crate — `desktop/src-tauri` + /// calls this directly (`commands/agent_models.rs:785`). pub fn for_discovery(provider: Provider, api_key: String, base_url: String) -> Self { Self { + model: None, + max_rounds: None, + max_sessions: 1, + system_prompt: None, + llm_timeout_secs: 30, + goose_mode: GooseMode::default(), provider, api_key, base_url, - model: String::new(), - system_prompt: String::new(), - anthropic_api_version: "2023-06-01".into(), - openai_api: OpenAiApi::Chat, - prefer_mesh_for_auto: false, - max_rounds: 0, - max_output_tokens: 1, - llm_timeout: Duration::from_secs(30), - tool_timeout: Duration::from_secs(30), - mcp_init_timeout: Duration::from_secs(30), - mcp_max_restart_attempts: 0, - mcp_restart_base_ms: 0, - mcp_restart_max_ms: 0, - max_sessions: 1, - max_line_bytes: 4 * 1024 * 1024, - max_history_bytes: 16 * 1024 * 1024, - max_tool_result_text_bytes: 50 * 1024, - max_context_tokens: 200_001, - max_handoffs: 0, - max_parallel_tools: 1, - hook_timeout: Duration::from_secs(1), - stop_max_rejections: 0, - hook_servers: HookServers::None, - hints_enabled: false, - thinking_effort: None, } } - fn validate(&self) -> Result<(), String> { - const MIN_HISTORY_BYTES: usize = 4096; - const MIN_LINE_BYTES: usize = 1024; - const MIN_TOOL_RESULT_TEXT_BYTES: usize = 1024; - const MIN_TIMEOUT: Duration = Duration::from_secs(1); - - if self.max_output_tokens < 1 { - return Err("config: BUZZ_AGENT_MAX_OUTPUT_TOKENS must be >= 1".into()); - } - if self.max_context_tokens <= u64::from(self.max_output_tokens) { - return Err(format!( - "config: BUZZ_AGENT_MAX_CONTEXT_TOKENS ({}) must be > BUZZ_AGENT_MAX_OUTPUT_TOKENS ({}) — the context window must leave room for the response", - self.max_context_tokens, self.max_output_tokens - )); - } - if self.max_history_bytes < MIN_HISTORY_BYTES { - return Err(format!( - "config: BUZZ_AGENT_MAX_HISTORY_BYTES must be >= {MIN_HISTORY_BYTES}" - )); - } - if self.max_history_bytes < MAX_PROMPT_BYTES { - return Err(format!( - "config: BUZZ_AGENT_MAX_HISTORY_BYTES ({}) must be >= MAX_PROMPT_BYTES ({MAX_PROMPT_BYTES})", - self.max_history_bytes - )); - } - if self.max_line_bytes < MIN_LINE_BYTES { - return Err(format!( - "config: BUZZ_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}" - )); - } - if self.max_tool_result_text_bytes < MIN_TOOL_RESULT_TEXT_BYTES - || self.max_tool_result_text_bytes > MAX_TOOL_RESULT_BYTES - { - return Err(format!( - "config: BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES must be in {MIN_TOOL_RESULT_TEXT_BYTES}..={MAX_TOOL_RESULT_BYTES}" - )); - } - if self.llm_timeout < MIN_TIMEOUT { - return Err("config: BUZZ_AGENT_LLM_TIMEOUT_SECS must be >= 1".into()); - } - if self.tool_timeout < MIN_TIMEOUT { - return Err("config: BUZZ_AGENT_TOOL_TIMEOUT_SECS must be >= 1".into()); - } - if self.mcp_init_timeout < MIN_TIMEOUT { - return Err("config: BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS must be >= 1".into()); - } - if self.max_parallel_tools < 1 { - return Err("config: BUZZ_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into()); - } - if self.mcp_max_restart_attempts < 1 { - return Err("config: BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into()); + /// Translate Buzz's provider configuration into Goose's environment. + /// + /// Mirrors `goose_env.rs` from PR #1526. + fn project_goose_env() { + // Provider: Buzz's `openai-compat` and `relay-mesh` are both + // OpenAI-wire-compatible, and Goose knows them as plain `openai`. + if let Some(provider) = env_str("BUZZ_AGENT_PROVIDER") { + let goose_provider = match provider.as_str() { + "openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => "openai", + other => other, + }; + set_if_absent("GOOSE_PROVIDER", goose_provider); } - if self.mcp_restart_base_ms < 1 { - return Err("config: BUZZ_AGENT_MCP_RESTART_BASE_MS must be >= 1".into()); + + if let Some(model) = env_str("BUZZ_AGENT_MODEL") { + set_if_absent("GOOSE_MODEL", &model); } - if self.mcp_restart_max_ms < self.mcp_restart_base_ms { - return Err( - "config: BUZZ_AGENT_MCP_RESTART_MAX_MS must be >= BUZZ_AGENT_MCP_RESTART_BASE_MS" - .into(), - ); + + // Key/base-url aliasing: native Goose names win if already present. + if let Some(key) = env_str("OPENAI_COMPAT_API_KEY") { + set_if_absent("OPENAI_API_KEY", &key); } - // Provider-level effort validation (fail-fast, clear error). - // `none`/`minimal` are not Anthropic values — rejected at startup. - // - // OpenAI, Databricks, and DatabricksV2 defer effort validation to request-time routing: - // availability is model-dependent, and `session/set_model` can change the effective model - // after startup. `normalize_effort_for_openai_route` / `normalize_effort_for_anthropic_route` - // apply route-aware normalization in `llm.rs` when building each request. - if let Some(effort) = self.thinking_effort { - let is_pure_anthropic = matches!(self.provider, Provider::Anthropic); - if is_pure_anthropic && matches!(effort, ThinkingEffort::None | ThinkingEffort::Minimal) - { - return Err(format!( - "config: BUZZ_AGENT_THINKING_EFFORT={} is not valid for Anthropic providers \ - (allowed: low|medium|high|xhigh|max)", - effort.openai_effort_str() - )); - } + if let Some(base) = env_str("OPENAI_COMPAT_BASE_URL") { + set_if_absent("OPENAI_BASE_URL", &base); } - Ok(()) - } -} - -fn env(k: &str) -> Option { - std::env::var(k).ok() -} - -fn env_or(k: &str, d: &str) -> String { - env(k).unwrap_or_else(|| d.into()) -} - -fn req(k: &str) -> Result { - env(k).ok_or_else(|| format!("config: {k} required")) -} - -/// Returns the first present value. `explicit_override` (BUZZ_AGENT_MODEL, -/// set by the desktop from the persona/record) wins over `provider_default` -/// (provider-specific env var that may be inherited from the shell). -/// Returns `None` when both are absent so the caller can supply a -/// provider-specific error message. -fn resolve_model( - explicit_override: Option<&str>, - provider_default: Option<&str>, -) -> Option { - explicit_override.or(provider_default).map(str::to_owned) -} - -fn present_nonempty(v: Option<&str>) -> bool { - v.map(str::trim).is_some_and(|s| !s.is_empty()) -} -fn resolve_provider( - requested: Option<&str>, - anthropic_key: Option<&str>, - openai_key: Option<&str>, -) -> Result { - match requested.map(str::trim).filter(|s| !s.is_empty()) { - Some(raw) => { - let normalized = raw.to_ascii_lowercase(); - match normalized.as_str() { - "anthropic" if present_nonempty(anthropic_key) => Ok(Provider::Anthropic), - "anthropic" => Err( - "config: ANTHROPIC_API_KEY required".into(), - ), - "openai" | "openai-compat" if present_nonempty(openai_key) => Ok(Provider::OpenAi), - "openai" | "openai-compat" => Err( - "config: OPENAI_COMPAT_API_KEY required".into(), - ), - "databricks" => Ok(Provider::Databricks), - "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), - _ => Err(format!( - "config: BUZZ_AGENT_PROVIDER={raw} not supported" - )), - } + if let Some(effort) = env_str("BUZZ_AGENT_THINKING_EFFORT") { + set_if_absent("GOOSE_THINKING_EFFORT", &effort); } - None => Err( - "config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, databricks)".into(), - ), - } -} - -/// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the -/// caller hands in the raw value. -fn parse_openai_api(raw: Option<&str>) -> Result { - match raw.unwrap_or("auto").trim().to_ascii_lowercase().as_str() { - "chat" | "chat-completions" | "chat_completions" => Ok(OpenAiApi::Chat), - "responses" => Ok(OpenAiApi::Responses), - "auto" | "" => Ok(OpenAiApi::Auto), - other => Err(format!( - "config: OPENAI_COMPAT_API={other} not supported (use auto|chat|responses)" - )), - } -} - -/// `true` when `base_url` is an official OpenAI host. Hosts on -/// `*.openai.com` get Responses under `Auto`; everything else (vLLM, -/// Ollama, OpenRouter, Block Gateway, …) gets Chat Completions. -/// Lookalike-safe: `api.openai.com.evil.example` returns `false`. -pub fn is_openai_host(base_url: &str) -> bool { - let rest = match base_url - .strip_prefix("https://") - .or_else(|| base_url.strip_prefix("http://")) - { - Some(r) => r, - None => return false, - }; - let host = &rest[..rest.find(['/', ':']).unwrap_or(rest.len())]; - host == "api.openai.com" || host.ends_with(".openai.com") -} - -fn parse_env(key: &str, default: T) -> Result -where - T::Err: std::fmt::Display, -{ - env(key) - .map(|v| v.parse().map_err(|e| format!("config: {key}: {e}"))) - .unwrap_or(Ok(default)) -} - -/// Hook-server allowlist parsed from a comma-separated env var. -/// - unset / empty / whitespace-only → `None` (no hooks enabled) -/// - `*` → `All` (every server eligible) -/// - `a,b,c` → `Only(["a","b","c"])` -#[derive(Debug, Clone)] -pub enum HookServers { - None, - All, - Only(Vec), -} - -impl HookServers { - /// Returns true iff `name` may receive hook calls. - pub fn allows(&self, name: &str) -> bool { - match self { - HookServers::None => false, - HookServers::All => true, - HookServers::Only(v) => v.iter().any(|s| s == name), + if let Some(max_tokens) = env_str("BUZZ_AGENT_MAX_OUTPUT_TOKENS") { + set_if_absent("GOOSE_MAX_TOKENS", &max_tokens); + } + if let Some(ctx) = env_str("BUZZ_AGENT_MAX_CONTEXT_TOKENS") { + set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx); } } - - /// True if no hooks should ever fire — used to short-circuit dispatch. - pub fn is_disabled(&self) -> bool { - matches!(self, HookServers::None) - } -} - -fn parse_hook_servers_env(key: &str) -> HookServers { - parse_hook_servers(env(key).as_deref()) } -/// Pure parser exposed for unit tests. `None` (env unset) and `Some("")` -/// (env set but empty) both yield `HookServers::None`. -fn parse_hook_servers(raw: Option<&str>) -> HookServers { - let raw = match raw { - Some(v) => v, - None => return HookServers::None, - }; - let names: Vec = raw - .split(',') - .map(|s| s.trim().to_owned()) - .filter(|s| !s.is_empty()) - .collect(); - if names.is_empty() { - return HookServers::None; - } - // `*` is the wildcard — only honored when it's the sole entry. A mixed - // value like "*,foo" falls through to `Only(["*","foo"])`; "*" is not a - // legal MCP server name (it can't pass `valid_name`), so it never matches - // an actual server. This avoids silently widening scope on typos. - if names.len() == 1 && names[0] == "*" { - return HookServers::All; +fn set_if_absent(key: &str, value: &str) { + if std::env::var_os(key).is_none() { + std::env::set_var(key, value); } - HookServers::Only(names) } #[cfg(test)] mod tests { use super::*; - #[test] - fn hook_servers_unset_is_none() { - assert!(matches!(parse_hook_servers(None), HookServers::None)); - } - - #[test] - fn hook_servers_empty_string_is_none() { - assert!(matches!(parse_hook_servers(Some("")), HookServers::None)); - } - - #[test] - fn hook_servers_whitespace_only_is_none() { - assert!(matches!( - parse_hook_servers(Some(" ,, ,")), - HookServers::None - )); - } - - #[test] - fn hook_servers_star_is_all() { - assert!(matches!(parse_hook_servers(Some("*")), HookServers::All)); - } - - #[test] - fn hook_servers_star_with_whitespace_is_all() { - assert!(matches!( - parse_hook_servers(Some(" * ")), - HookServers::All - )); - } - - #[test] - fn hook_servers_named_list() { - match parse_hook_servers(Some("foo,bar")) { - HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]), - other => panic!("expected Only, got {other:?}"), - } - } - - #[test] - fn hook_servers_trims_entries() { - match parse_hook_servers(Some(" foo , bar , ")) { - HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]), - other => panic!("expected Only, got {other:?}"), - } - } - - #[test] - fn hook_servers_star_mixed_is_literal() { - // `*,foo` is NOT a wildcard — it's a literal Only(["*","foo"]). - // No real server can be named `*`, so this never matches anything. - match parse_hook_servers(Some("*,foo")) { - HookServers::Only(v) => assert_eq!(v, vec!["*".to_owned(), "foo".to_owned()]), - other => panic!("expected Only, got {other:?}"), - } - } - - #[test] - fn hook_servers_allows_matches_named_only() { - let hs = parse_hook_servers(Some("foo,bar")); - assert!(hs.allows("foo")); - assert!(hs.allows("bar")); - assert!(!hs.allows("baz")); - } - - #[test] - fn hook_servers_allows_matches_all() { - assert!(parse_hook_servers(Some("*")).allows("anything")); - } + // Env is process-global; these tests set disjoint keys and assert only on + // the pure mapping helpers where possible. #[test] - fn hook_servers_allows_blocks_when_none() { - assert!(!parse_hook_servers(None).allows("foo")); + fn set_if_absent_does_not_clobber() { + std::env::set_var("BUZZ_TEST_EXISTING", "native"); + set_if_absent("BUZZ_TEST_EXISTING", "translated"); + assert_eq!(std::env::var("BUZZ_TEST_EXISTING").unwrap(), "native"); + std::env::remove_var("BUZZ_TEST_EXISTING"); } #[test] - fn hook_servers_star_mixed_does_not_match_real_server() { - let hs = parse_hook_servers(Some("*,foo")); - // The literal "*" entry exists in Only, but no real server can - // be named "*" (rejected by the MCP server name validator). - assert!(hs.allows("foo")); - assert!(!hs.allows("bar")); - // Allowed strictly only as a literal match — defense-in-depth - // expectation for callers. - assert!(hs.allows("*")); + fn set_if_absent_fills_missing() { + std::env::remove_var("BUZZ_TEST_MISSING"); + set_if_absent("BUZZ_TEST_MISSING", "translated"); + assert_eq!(std::env::var("BUZZ_TEST_MISSING").unwrap(), "translated"); + std::env::remove_var("BUZZ_TEST_MISSING"); } #[test] - fn parse_openai_api_values() { - use OpenAiApi::*; - for (raw, want) in [ - (None, Ok(Auto)), - (Some("auto"), Ok(Auto)), - (Some(" AUTO "), Ok(Auto)), - (Some(""), Ok(Auto)), - (Some("chat"), Ok(Chat)), - (Some("chat-completions"), Ok(Chat)), - (Some("Responses"), Ok(Responses)), - ] { - assert_eq!(parse_openai_api(raw), want, "raw={raw:?}"); - } - let err = parse_openai_api(Some("nope")).unwrap_err(); - assert!(err.contains("OPENAI_COMPAT_API=nope"), "{err}"); + fn approval_defaults_to_auto() { + // Matches what buzz ships today; changing this silently would alter + // the security posture of every existing agent. + assert_eq!(parse_approval(None), GooseMode::Auto); + assert_eq!(parse_approval(Some("auto")), GooseMode::Auto); } #[test] - fn resolve_provider_keeps_requested_provider_when_token_present() { - assert_eq!( - resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(), - Provider::Anthropic - ); + fn approval_parses_the_stricter_modes() { + assert_eq!(parse_approval(Some("approve")), GooseMode::Approve); + assert_eq!(parse_approval(Some(" APPROVE ")), GooseMode::Approve); assert_eq!( - resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(), - Provider::OpenAi + parse_approval(Some("smart_approve")), + GooseMode::SmartApprove ); - } - - #[test] - fn resolve_provider_errors_when_requested_provider_key_missing() { - // No fallback — missing key returns an error regardless of Databricks availability. - let err = resolve_provider(Some("anthropic"), None, None).unwrap_err(); - assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}"); - - let err = resolve_provider(Some("openai-compat"), None, Some(" ")).unwrap_err(); - assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); - } - - #[test] - fn resolve_provider_errors_when_provider_env_absent() { - // No implicit inference — absent BUZZ_AGENT_PROVIDER is an error. - let err = resolve_provider(None, None, None).unwrap_err(); - assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); - } - - #[test] - fn resolve_provider_requires_databricks_host_and_model_for_fallback() { - // Renamed: verify the explicit databricks provider path works correctly. - // When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless - // of DATABRICKS_HOST/MODEL (those are validated later in from_env()). assert_eq!( - resolve_provider(Some("databricks"), None, None).unwrap(), - Provider::Databricks + parse_approval(Some("smart-approve")), + GooseMode::SmartApprove ); - // Missing key for other providers still errors — no Databricks fallback. - let err = resolve_provider(Some("openai"), None, None).unwrap_err(); - assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); - let err = resolve_provider(None, None, None).unwrap_err(); - assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); - } - - #[test] - fn resolve_provider_unsupported_error_preserves_user_casing() { - let err = resolve_provider(Some("OpenAIish"), None, None).unwrap_err(); - assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish")); - } - - #[test] - fn is_openai_host_matrix() { - // Lookalike-safe: `api.openai.com.evil.example` and malformed URLs - // are treated as non-OpenAI (which falls back to Chat Completions). - for (url, want) in [ - ("https://api.openai.com/v1", true), - ("https://api.openai.com", true), - ("http://eu.api.openai.com/v1", true), - ("http://localhost:11434/v1", false), - ("https://openrouter.ai/api/v1", false), - ("https://gateway.block.example/v1", false), - ("https://api.openai.com.evil.example/v1", false), - ("not a url", false), - ] { - assert_eq!(is_openai_host(url), want, "url={url}"); - } - } - - #[test] - fn resolve_model_prefers_explicit_override() { - let result = resolve_model(Some("override-model"), Some("provider-model")); - assert_eq!(result.as_deref(), Some("override-model")); - } - - #[test] - fn resolve_model_falls_back_to_provider_default() { - let result = resolve_model(None, Some("provider-model")); - assert_eq!(result.as_deref(), Some("provider-model")); + assert_eq!(parse_approval(Some("chat")), GooseMode::Chat); } #[test] - fn resolve_model_returns_none_when_both_absent() { - let result = resolve_model(None, None); - assert!(result.is_none()); + fn approval_falls_back_to_auto_on_garbage() { + // A typo must not take an agent off the air, and must not silently + // tighten either — both would be surprising. + assert_eq!(parse_approval(Some("yolo")), GooseMode::Auto); } #[test] - fn parse_thinking_effort_round_trips_all_values() { - for (raw, expected) in [ - ("none", ThinkingEffort::None), - ("minimal", ThinkingEffort::Minimal), - ("low", ThinkingEffort::Low), - ("medium", ThinkingEffort::Medium), - ("high", ThinkingEffort::High), - ("xhigh", ThinkingEffort::XHigh), - ("max", ThinkingEffort::Max), - ] { - assert_eq!( - parse_thinking_effort(Some(raw)).unwrap(), - Some(expected), - "raw={raw:?}" - ); - } - } - - #[test] - fn parse_thinking_effort_none_and_empty_yield_none() { - assert_eq!(parse_thinking_effort(None).unwrap(), None); - assert_eq!(parse_thinking_effort(Some("")).unwrap(), None); - assert_eq!(parse_thinking_effort(Some(" ")).unwrap(), None); - } - - #[test] - fn parse_thinking_effort_is_case_insensitive() { - assert_eq!( - parse_thinking_effort(Some("HIGH")).unwrap(), - Some(ThinkingEffort::High) - ); - assert_eq!( - parse_thinking_effort(Some(" Medium ")).unwrap(), - Some(ThinkingEffort::Medium) - ); - } - - #[test] - fn parse_thinking_effort_rejects_unknown_value() { - let err = parse_thinking_effort(Some("extreme")).unwrap_err(); - assert!(err.contains("BUZZ_AGENT_THINKING_EFFORT=extreme"), "{err}"); - assert!( - err.contains("none|minimal|low|medium|high|xhigh|max"), - "{err}" - ); - } - - #[test] - fn thinking_effort_anthropic_budget_tokens_mapping() { - assert_eq!(ThinkingEffort::Low.anthropic_budget_tokens(), 1_024); - assert_eq!(ThinkingEffort::Medium.anthropic_budget_tokens(), 8_192); - assert_eq!(ThinkingEffort::High.anthropic_budget_tokens(), 32_768); - // XHigh and Max clamp to the high budget value for manual-budget models. - assert_eq!(ThinkingEffort::XHigh.anthropic_budget_tokens(), 32_768); - assert_eq!(ThinkingEffort::Max.anthropic_budget_tokens(), 32_768); - // None/Minimal are rejected at startup for Anthropic; defensive zero. - assert_eq!(ThinkingEffort::None.anthropic_budget_tokens(), 0); - assert_eq!(ThinkingEffort::Minimal.anthropic_budget_tokens(), 0); - } - - #[test] - fn thinking_effort_openai_effort_str_mapping() { - assert_eq!(ThinkingEffort::None.openai_effort_str(), "none"); - assert_eq!(ThinkingEffort::Minimal.openai_effort_str(), "minimal"); - assert_eq!(ThinkingEffort::Low.openai_effort_str(), "low"); - assert_eq!(ThinkingEffort::Medium.openai_effort_str(), "medium"); - assert_eq!(ThinkingEffort::High.openai_effort_str(), "high"); - assert_eq!(ThinkingEffort::XHigh.openai_effort_str(), "xhigh"); - assert_eq!(ThinkingEffort::Max.openai_effort_str(), "max"); - } - - #[test] - fn thinking_effort_anthropic_effort_str_mapping() { - assert_eq!(ThinkingEffort::Low.anthropic_effort_str(), "low"); - assert_eq!(ThinkingEffort::Medium.anthropic_effort_str(), "medium"); - assert_eq!(ThinkingEffort::High.anthropic_effort_str(), "high"); - assert_eq!(ThinkingEffort::XHigh.anthropic_effort_str(), "xhigh"); - assert_eq!(ThinkingEffort::Max.anthropic_effort_str(), "max"); - // Defensive fallback for invalid Anthropic values (caught at startup validation). - assert_eq!(ThinkingEffort::None.anthropic_effort_str(), "low"); - assert_eq!(ThinkingEffort::Minimal.anthropic_effort_str(), "low"); - } - - #[test] - fn thinking_effort_ord_ordering() { - // PartialOrd/Ord must reflect the ordered hierarchy. - assert!(ThinkingEffort::None < ThinkingEffort::Minimal); - assert!(ThinkingEffort::Minimal < ThinkingEffort::Low); - assert!(ThinkingEffort::Low < ThinkingEffort::Medium); - assert!(ThinkingEffort::Medium < ThinkingEffort::High); - assert!(ThinkingEffort::High < ThinkingEffort::XHigh); - assert!(ThinkingEffort::XHigh < ThinkingEffort::Max); - } - - // ---- anthropic_thinking_config helper — per-family tests ---- - - #[test] - fn anthropic_thinking_config_claude3_emits_budget_tokens() { - // Claude 3.x → `thinking.budget_tokens`; clamped to min(level_budget, max_output - 1024). - // max_output_tokens = 4096: headroom = 4096 - 1024 = 3072; High budget (32768) → 3072. - let (thinking, output_config) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 4096); - let t = thinking.expect("thinking field must be present for claude-3"); - assert_eq!(t["type"], "enabled"); - assert_eq!(t["budget_tokens"], 3072); // capped: min(32768, 4096-1024) - assert!( - output_config.is_none(), - "output_config must be absent for claude-3" - ); - } - - #[test] - fn anthropic_thinking_config_claude3_omits_thinking_when_max_output_too_small() { - // max_output_tokens = 2047: headroom = 2047 - 1024 = 1023 < 1024 → omit thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 2047); - assert!( - thinking.is_none(), - "thinking must be omitted when max_output_tokens - 1024 < 1024 (budget would starve answer)" - ); - assert!(output_config.is_none()); - } - - #[test] - fn anthropic_thinking_config_claude3_emits_thinking_at_boundary_2048() { - // max_output_tokens = 2048: headroom = 2048 - 1024 = 1024 ≥ 1024 → emit budget = 1024. - let (thinking, _) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 2048); - let t = thinking.expect("thinking must be present when max_output_tokens = 2048"); - assert_eq!(t["budget_tokens"], 1024); // min(32768, 2048-1024) = 1024 - } - - #[test] - fn anthropic_thinking_config_claude3_budget_uncapped_when_fits() { - // High budget fits comfortably under a large max_output_tokens. - let (thinking, _) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 65_536); - let t = thinking.unwrap(); - assert_eq!(t["budget_tokens"], 32_768); - } - - #[test] - fn anthropic_thinking_config_opus_4_8_emits_adaptive_and_effort() { - // Opus 4.8 — adaptive family. Requires thinking:{type:"adaptive"} to enable thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-8", ThinkingEffort::High, 32_768); - let t = thinking.expect("thinking must be present for claude-opus-4-8"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-opus-4-8"); - assert_eq!(oc["effort"], "high"); - } - - #[test] - fn anthropic_thinking_config_opus_4_7_emits_adaptive_and_effort() { - // Opus 4.7 — adaptive family. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-7", ThinkingEffort::Medium, 32_768); - let t = thinking.expect("thinking must be present for claude-opus-4-7"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-opus-4-7"); - assert_eq!(oc["effort"], "medium"); - } - - #[test] - fn anthropic_thinking_config_sonnet_5_emits_adaptive_and_effort() { - // Sonnet 5 — adaptive family. - let (thinking, output_config) = - anthropic_thinking_config("claude-sonnet-5-20250901", ThinkingEffort::Low, 32_768); - let t = thinking.expect("thinking must be present for claude-sonnet-5"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-sonnet-5"); - assert_eq!(oc["effort"], "low"); - } - - #[test] - fn anthropic_thinking_config_sonnet_4_6_emits_adaptive_and_effort() { - // Sonnet 4.6 — adaptive family. Docs explicitly list "Combine effort with adaptive thinking." - let (thinking, output_config) = - anthropic_thinking_config("claude-sonnet-4-6", ThinkingEffort::High, 32_768); - let t = thinking.expect("thinking must be present for claude-sonnet-4-6"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-sonnet-4-6"); - assert_eq!(oc["effort"], "high"); - } - - #[test] - fn anthropic_thinking_config_opus_4_5_emits_manual_budget() { - // Opus 4.5 — manual budget (NOT adaptive; effort page: "uses manual thinking"). - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::High, 65_536); - let t = thinking.expect("thinking must be present for claude-opus-4-5"); - assert_eq!(t["type"], "enabled"); - assert_eq!(t["budget_tokens"], 32_768); // High budget fits under 65536 - assert!( - output_config.is_none(), - "output_config must be absent for claude-opus-4-5 (manual budget)" - ); - } - - #[test] - fn anthropic_thinking_config_opus_4_5_budget_capped() { - // Opus 4.5 manual budget is clamped to min(level_budget, max_output_tokens - 1024). - // max_output_tokens = 4096: headroom = 4096 - 1024 = 3072; High budget (32768) → 3072. - let (thinking, _) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::High, 4096); - let t = thinking.unwrap(); - assert_eq!(t["budget_tokens"], 3072); // min(32768, 4096-1024) - } - - #[test] - fn anthropic_thinking_config_opus_4_5_omits_thinking_when_max_output_1025() { - // max_output_tokens = 1025: headroom = 1025 - 1024 = 1 < 1024 → omit thinking. - let (thinking, _) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::High, 1025); - assert!( - thinking.is_none(), - "thinking must be omitted when max_output_tokens - 1024 < 1024" - ); - } - - #[test] - fn anthropic_thinking_config_manual_budget_low_emits_1024_when_fits() { - // Low budget (1024 tokens) exactly fits when max_output_tokens = 2048. - // headroom = 2048 - 1024 = 1024; min(1024, 1024) = 1024 ≥ 1024 → emit. - let (thinking, _) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::Low, 2048); - let t = thinking.expect("Low budget (1024) must be emitted when max_output_tokens = 2048"); - assert_eq!(t["budget_tokens"], 1024); - } - - #[test] - fn anthropic_thinking_config_unknown_claude_omits_both_fields() { - // An unknown/future "claude-*" name that is not in the allowlist → omit both fields. - // This prevents sending an unverified shape to an unrecognized model. - // Includes Opus 4.9 (future version), which is NOT in the doc-verified adaptive list. - for model in &[ - "claude-haiku-4-5", - "claude-sonnet-4-5", - "claude-unknown-9-1", - "claude-future-model", - "claude-opus-4-9", - ] { - let (thinking, output_config) = - anthropic_thinking_config(model, ThinkingEffort::High, 32_768); - assert!( - thinking.is_none(), - "thinking must be absent for unverified claude model: {model}" - ); - assert!( - output_config.is_none(), - "output_config must be absent for unverified claude model: {model}" - ); - } - } - - #[test] - fn anthropic_thinking_config_non_claude_omits_both_fields() { - // Non-Anthropic model names (gpt-5, llama, etc.) → omit both fields. - let (thinking, output_config) = - anthropic_thinking_config("gpt-4o-mini", ThinkingEffort::High, 32_768); - assert!( - thinking.is_none(), - "thinking must be absent for non-claude model" - ); - assert!( - output_config.is_none(), - "output_config must be absent for non-claude model" - ); - } - - #[test] - fn anthropic_thinking_config_databricks_prefix_stripped_for_claude3() { - // Databricks gateway prefixes like "databricks-claude-3-..." must be stripped. - let (thinking, output_config) = - anthropic_thinking_config("databricks-claude-3-5-sonnet", ThinkingEffort::Low, 8_192); - let t = thinking.expect("thinking must be present after stripping databricks- prefix"); - assert_eq!(t["type"], "enabled"); - assert!(output_config.is_none()); - } - - #[test] - fn anthropic_thinking_config_databricks_prefix_stripped_for_opus_4_7() { - // Databricks gateway prefix stripping applies to adaptive Claude families too. - let (thinking, output_config) = - anthropic_thinking_config("databricks-claude-opus-4-7", ThinkingEffort::High, 32_768); - let t = thinking - .expect("thinking:{type:adaptive} must be present for databricks-claude-opus-4-7"); - assert_eq!(t["type"], "adaptive"); - let oc = - output_config.expect("output_config must be present for databricks-claude-opus-4-7"); - assert_eq!(oc["effort"], "high"); - } - - #[test] - fn anthropic_thinking_config_databricks_prefix_stripped_for_opus_4_8() { - // Databricks gateway prefix stripping applies to Opus 4.8 too. - let (thinking, output_config) = - anthropic_thinking_config("databricks-claude-opus-4-8", ThinkingEffort::Medium, 32_768); - let t = thinking - .expect("thinking:{type:adaptive} must be present for databricks-claude-opus-4-8"); - assert_eq!(t["type"], "adaptive"); - let oc = - output_config.expect("output_config must be present for databricks-claude-opus-4-8"); - assert_eq!(oc["effort"], "medium"); - } - - #[test] - fn anthropic_thinking_config_goose_prefix_stripped_for_fable_5() { - // "goose-" catalog prefix must be stripped so goose-claude-fable-5 routes to - // the adaptive + xhigh/max bucket, not the "unknown model → (None, None)" path. - let (thinking, output_config) = - anthropic_thinking_config("goose-claude-fable-5", ThinkingEffort::Max, 32_768); - let t = - thinking.expect("thinking:{type:adaptive} must be present for goose-claude-fable-5"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for goose-claude-fable-5"); - assert_eq!(oc["effort"], "max"); - } - - #[test] - fn anthropic_thinking_config_goose_prefix_stripped_for_sonnet_5() { - // Adaptive xhigh model via goose- prefix. - let (thinking, output_config) = - anthropic_thinking_config("goose-claude-sonnet-5", ThinkingEffort::XHigh, 32_768); - let t = - thinking.expect("thinking:{type:adaptive} must be present for goose-claude-sonnet-5"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for goose-claude-sonnet-5"); - assert_eq!(oc["effort"], "xhigh"); - } - - #[test] - fn anthropic_thinking_config_arbitrary_prefix_stripped_for_opus_4_7() { - // team-x-claude-opus-4-7: first claude- token at index 7 → strips "team-x-" - // Verifies the arbitrary-prefix normalization reaches anthropic_thinking_config - // end-to-end: UI exposes max as valid, and runtime must honor it. - let (thinking, output_config) = - anthropic_thinking_config("team-x-claude-opus-4-7", ThinkingEffort::Max, 32_768); - let t = - thinking.expect("thinking:{type:adaptive} must be present for team-x-claude-opus-4-7"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for team-x-claude-opus-4-7"); - assert_eq!(oc["effort"], "max"); - } - - // ---- clamp_adaptive_effort — per-model clamping tests ---- - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_opus_4_7() { - // Opus 4.7 supports xhigh — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-7", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_opus_4_8() { - // Opus 4.8 supports xhigh — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-8", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_sonnet_5() { - // Sonnet 5 supports xhigh — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-sonnet-5-20250901", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_clamped_to_high_for_opus_4_6() { - // Opus 4.6 does NOT support xhigh (only low/medium/high/max) — clamp to high. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-6", ThinkingEffort::XHigh), - ThinkingEffort::High - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_clamped_to_high_for_sonnet_4_6() { - // Sonnet 4.6 does NOT support xhigh — clamp to high. - assert_eq!( - clamp_adaptive_effort("claude-sonnet-4-6", ThinkingEffort::XHigh), - ThinkingEffort::High - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_opus_4_6() { - // Opus 4.6 supports max — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-6", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_opus_4_7() { - // Opus 4.7 supports max — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-7", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_opus_4_8() { - // Opus 4.8 supports max — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-8", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_low_medium_high_never_clamped() { - // low/medium/high pass through for all adaptive models. - for model in &[ - "claude-opus-4-6", - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-5-20250901", - "claude-sonnet-4-6", - ] { - for effort in [ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ] { - assert_eq!( - clamp_adaptive_effort(model, effort), - effort, - "model={model} effort={effort:?}" - ); - } - } - } - - // ---- anthropic_thinking_config — xhigh/max body-shape assertions ---- - - #[test] - fn anthropic_thinking_config_opus_4_8_xhigh_emits_xhigh_effort() { - // Opus 4.8 supports xhigh; output_config.effort must be "xhigh". - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-8", ThinkingEffort::XHigh, 32_768); - let t = thinking.expect("thinking must be present for claude-opus-4-8"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-opus-4-8"); - assert_eq!(oc["effort"], "xhigh"); - } - - #[test] - fn anthropic_thinking_config_opus_4_8_max_emits_max_effort() { - // Opus 4.8 supports max; output_config.effort must be "max". - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-8", ThinkingEffort::Max, 32_768); - let t = thinking.expect("thinking must be present for claude-opus-4-8"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-opus-4-8"); - assert_eq!(oc["effort"], "max"); - } - - #[test] - fn anthropic_thinking_config_opus_4_7_xhigh_emits_xhigh_effort() { - // Opus 4.7 supports xhigh. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-7", ThinkingEffort::XHigh, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.unwrap(); - assert_eq!(oc["effort"], "xhigh"); - } - - #[test] - fn anthropic_thinking_config_opus_4_6_xhigh_clamps_to_high() { - // Opus 4.6 does NOT support xhigh → clamp to high. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-6", ThinkingEffort::XHigh, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.unwrap(); - assert_eq!( - oc["effort"], "high", - "xhigh must clamp to high for claude-opus-4-6" - ); - } - - #[test] - fn anthropic_thinking_config_opus_4_6_max_passes_through() { - // Opus 4.6 supports max — passes through without clamping. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-6", ThinkingEffort::Max, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.unwrap(); - assert_eq!(oc["effort"], "max"); - } - - #[test] - fn anthropic_thinking_config_manual_bucket_xhigh_clamps_to_high_budget() { - // Manual-budget models (claude-3*, opus-4-5): xhigh clamps to high budget (32_768). - for model in &["claude-3-7-sonnet-20250219", "claude-opus-4-5"] { - let (thinking, output_config) = - anthropic_thinking_config(model, ThinkingEffort::XHigh, 65_536); - let t = thinking.expect("thinking must be present"); - assert_eq!(t["type"], "enabled"); - assert_eq!( - t["budget_tokens"], 32_768, - "xhigh must clamp to high budget for manual model {model}" - ); - assert!(output_config.is_none()); - } - } - - #[test] - fn anthropic_thinking_config_manual_bucket_max_clamps_to_high_budget() { - // Manual-budget models: max also clamps to high budget (32_768). - let (thinking, _) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::Max, 65_536); - let t = thinking.unwrap(); - assert_eq!(t["type"], "enabled"); - assert_eq!(t["budget_tokens"], 32_768); - } - - // ---- provider-level validation tests ---- - - /// Build a minimal Config with the given provider and thinking_effort, bypassing from_env(). - /// Uses `Config::for_discovery` as a base and patches the fields we care about. - fn make_config_for_validation( - provider: Provider, - thinking_effort: Option, - ) -> Config { - let mut cfg = Config::for_discovery(provider, "key".into(), "https://example.com".into()); - cfg.model = "some-model".into(); - cfg.thinking_effort = thinking_effort; - // for_discovery sets max_output_tokens=1 and max_context_tokens=200_001 which satisfies - // the context > output constraint. Adjust to something valid for further checks. - cfg.max_output_tokens = 1024; - cfg.max_context_tokens = 200_000 + 1024; - // Restore mandatory positive values that for_discovery zeroes out. - cfg.mcp_max_restart_attempts = 1; - cfg.mcp_restart_base_ms = 1; - cfg.mcp_restart_max_ms = 1; - cfg.max_parallel_tools = 1; - cfg.llm_timeout = Duration::from_secs(1); - cfg.tool_timeout = Duration::from_secs(1); - cfg.mcp_init_timeout = Duration::from_secs(1); - cfg - } - - #[test] - fn validate_rejects_none_effort_for_anthropic() { - let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::None)); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("BUZZ_AGENT_THINKING_EFFORT=none"), - "error must name the value: {err}" - ); - assert!( - err.contains("not valid for Anthropic"), - "error must name the provider: {err}" - ); - assert!( - err.contains("low|medium|high|xhigh|max"), - "error must name allowed values: {err}" - ); - } - - #[test] - fn validate_rejects_minimal_effort_for_anthropic() { - let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::Minimal)); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("BUZZ_AGENT_THINKING_EFFORT=minimal"), "{err}"); - assert!(err.contains("not valid for Anthropic"), "{err}"); - } - - #[test] - fn validate_accepts_all_efforts_for_databricks_v2() { - // DatabricksV2 dispatches across Anthropic/OpenAI/MLflow routes at request build time. - // No effort value is invalid for all three routes — startup rejects none. - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ] { - let cfg = make_config_for_validation(Provider::DatabricksV2, Some(effort)); - assert!( - cfg.validate().is_ok(), - "DatabricksV2 must accept {effort:?} at startup (route-aware normalization at request build)" - ); - } - } - - #[test] - fn validate_accepts_all_efforts_for_openai() { - // OpenAI effort support is model-dependent and normalized at request build time. - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ] { - let cfg = make_config_for_validation(Provider::OpenAi, Some(effort)); - assert!( - cfg.validate().is_ok(), - "OpenAI must accept {effort:?} at startup (route-aware normalization at request build)" - ); - } - } - - #[test] - fn validate_accepts_all_efforts_for_databricks() { - // Legacy Databricks effort support is model-dependent and normalized at request build time. - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ] { - let cfg = make_config_for_validation(Provider::Databricks, Some(effort)); - assert!( - cfg.validate().is_ok(), - "Databricks must accept {effort:?} at startup (route-aware normalization at request build)" - ); - } - } - - #[test] - fn validate_accepts_xhigh_for_anthropic() { - // xhigh is valid for Anthropic providers — model-level clamping is dynamic. - let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::XHigh)); - assert!( - cfg.validate().is_ok(), - "xhigh must be accepted at startup for Anthropic" - ); - } - - #[test] - fn validate_accepts_max_for_anthropic() { - // max is valid for Anthropic providers. - let cfg = make_config_for_validation(Provider::Anthropic, Some(ThinkingEffort::Max)); - assert!(cfg.validate().is_ok(), "max must be accepted for Anthropic"); - } - - #[test] - fn validate_accepts_xhigh_for_openai() { - // xhigh is valid for OpenAI providers (server-validated per-model). - let cfg = make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::XHigh)); - assert!(cfg.validate().is_ok(), "xhigh must be accepted for OpenAI"); - } - - #[test] - fn validate_accepts_none_and_minimal_for_openai() { - // none/minimal are valid OpenAI effort values. - let cfg_none = make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::None)); - assert!( - cfg_none.validate().is_ok(), - "none must be accepted for OpenAI" - ); - let cfg_minimal = - make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::Minimal)); - assert!( - cfg_minimal.validate().is_ok(), - "minimal must be accepted for OpenAI" - ); - } - - // ---- normalize_effort_for_openai_route ---- - - #[test] - fn normalize_openai_route_clamps_max_to_xhigh() { - // Use an unknown model so only the max→xhigh clamp fires, not per-model logic. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Max, "llama-4"), - ThinkingEffort::XHigh - ); - } - - #[test] - fn normalize_openai_route_passes_through_all_other_values_for_unknown_model() { - // Unknown/unverified models pass through unchanged (server-validated). - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ] { - assert_eq!( - normalize_effort_for_openai_route(effort, "unknown-future-model"), - effort, - "normalize_effort_for_openai_route must pass through {effort:?} for unknown model" - ); - } - } - - // ---- normalize_effort_for_anthropic_route ---- - - #[test] - fn normalize_anthropic_route_none_yields_none() { - assert_eq!( - normalize_effort_for_anthropic_route(ThinkingEffort::None), - None, - "none must yield None (omit thinking fields)" - ); - } - - #[test] - fn normalize_anthropic_route_minimal_yields_none() { - assert_eq!( - normalize_effort_for_anthropic_route(ThinkingEffort::Minimal), - None, - "minimal must yield None (omit thinking fields)" - ); - } - - #[test] - fn normalize_anthropic_route_passes_through_valid_values() { - for effort in [ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ] { - assert_eq!( - normalize_effort_for_anthropic_route(effort), - Some(effort), - "normalize_effort_for_anthropic_route must pass through {effort:?}" - ); - } - } - - // ---- F2: Fable 5 / Mythos 5 / Mythos Preview adaptive thinking ---- - - #[test] - fn anthropic_thinking_config_fable_5_emits_adaptive_and_effort() { - // Fable 5 — always-on adaptive thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-fable-5", ThinkingEffort::High, 32_768); - let t = thinking.expect("thinking must be present for claude-fable-5"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-fable-5"); - assert_eq!(oc["effort"], "high"); - } - - #[test] - fn anthropic_thinking_config_mythos_5_emits_adaptive_and_effort() { - // Mythos 5 — always-on adaptive thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-5", ThinkingEffort::Medium, 32_768); - let t = thinking.expect("thinking must be present for claude-mythos-5"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-mythos-5"); - assert_eq!(oc["effort"], "medium"); - } - - #[test] - fn anthropic_thinking_config_mythos_preview_emits_adaptive_and_effort() { - // Mythos Preview — default-on adaptive thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-preview", ThinkingEffort::Low, 32_768); - let t = thinking.expect("thinking must be present for claude-mythos-preview"); - assert_eq!(t["type"], "adaptive"); - let oc = output_config.expect("output_config must be present for claude-mythos-preview"); - assert_eq!(oc["effort"], "low"); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_fable_5() { - // Fable 5 supports xhigh. - assert_eq!( - clamp_adaptive_effort("claude-fable-5", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_mythos_5() { - // Mythos 5 supports xhigh. - assert_eq!( - clamp_adaptive_effort("claude-mythos-5", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_clamped_to_high_for_mythos_preview() { - // Mythos Preview does NOT support xhigh — clamp to high. - assert_eq!( - clamp_adaptive_effort("claude-mythos-preview", ThinkingEffort::XHigh), - ThinkingEffort::High - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_fable_5() { - // Fable 5 supports max. - assert_eq!( - clamp_adaptive_effort("claude-fable-5", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_mythos_5() { - // Mythos 5 supports max. - assert_eq!( - clamp_adaptive_effort("claude-mythos-5", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_mythos_preview() { - // Mythos Preview supports max. - assert_eq!( - clamp_adaptive_effort("claude-mythos-preview", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn anthropic_thinking_config_fable_5_xhigh_emits_xhigh() { - let (thinking, output_config) = - anthropic_thinking_config("claude-fable-5", ThinkingEffort::XHigh, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - assert_eq!(output_config.unwrap()["effort"], "xhigh"); - } - - #[test] - fn anthropic_thinking_config_mythos_5_xhigh_emits_xhigh() { - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-5", ThinkingEffort::XHigh, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - assert_eq!(output_config.unwrap()["effort"], "xhigh"); - } - - #[test] - fn anthropic_thinking_config_mythos_preview_xhigh_clamps_to_high() { - // Mythos Preview does NOT support xhigh → clamp to high. - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-preview", ThinkingEffort::XHigh, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - assert_eq!( - output_config.unwrap()["effort"], - "high", - "xhigh must clamp to high for claude-mythos-preview" - ); - } - - #[test] - fn anthropic_thinking_config_fable_5_max_passes_through() { - let (thinking, output_config) = - anthropic_thinking_config("claude-fable-5", ThinkingEffort::Max, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - assert_eq!(output_config.unwrap()["effort"], "max"); - } - - #[test] - fn anthropic_thinking_config_mythos_preview_max_passes_through() { - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-preview", ThinkingEffort::Max, 32_768); - let t = thinking.unwrap(); - assert_eq!(t["type"], "adaptive"); - assert_eq!(output_config.unwrap()["effort"], "max"); - } - - // ---- openai_efforts_for_model / normalize_effort_for_openai_route per-model table ---- - - #[test] - fn openai_efforts_for_model_gpt5_pro_high_only() { - // gpt-5-pro: high only — any other value must be substituted. - let supported = openai_efforts_for_model("gpt-5-pro").expect("gpt-5-pro must be in table"); - assert_eq!( - supported, - &[ThinkingEffort::High], - "gpt-5-pro supports only high" - ); - } - - #[test] - fn openai_efforts_for_model_gpt5_6_includes_max() { - let expected: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ]; - - for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5-6-sol", "goose-gpt-5-6-sol"] { - assert_eq!( - openai_efforts_for_model(model), - Some(expected), - "{model} must match the gpt-5.6 effort table" - ); - } - } - - #[test] - fn openai_efforts_for_model_gpt5_5_includes_xhigh() { - let supported = openai_efforts_for_model("gpt-5.5").expect("gpt-5.5 must be in table"); - assert!( - supported.contains(&ThinkingEffort::XHigh), - "gpt-5.5 must support xhigh" - ); - assert!( - supported.contains(&ThinkingEffort::None), - "gpt-5.5 must support none" - ); - } - - #[test] - fn openai_efforts_for_model_gpt5_1_excludes_xhigh_and_minimal() { - let supported = openai_efforts_for_model("gpt-5.1").expect("gpt-5.1 must be in table"); - assert!( - !supported.contains(&ThinkingEffort::XHigh), - "gpt-5.1 must NOT support xhigh" - ); - assert!( - !supported.contains(&ThinkingEffort::Minimal), - "gpt-5.1 must NOT support minimal" - ); - assert!( - supported.contains(&ThinkingEffort::None), - "gpt-5.1 must support none" - ); - } - - #[test] - fn openai_efforts_for_model_gpt5_base_excludes_none_includes_minimal() { - let supported = openai_efforts_for_model("gpt-5").expect("gpt-5 base must be in table"); - assert!( - !supported.contains(&ThinkingEffort::None), - "gpt-5 base must NOT support none" - ); - assert!( - supported.contains(&ThinkingEffort::Minimal), - "gpt-5 base must support minimal" - ); - } - - #[test] - fn openai_efforts_for_model_unknown_returns_none() { - // Unknown models are not doc-verified — caller treats as server-validated pass-through. - assert!(openai_efforts_for_model("llama-4").is_none()); - assert!(openai_efforts_for_model("claude-opus-4-8").is_none()); - assert!(openai_efforts_for_model("gpt-4o").is_none()); - } - - // ---- Boundary-safe matching: version digits must not false-match longer versions ---- - - #[test] - fn openai_efforts_for_model_boundary_dated_base_ids_are_not_versioned() { - // gpt-5-1106: the "-1" is not version 5.1 — it's a date segment on the base model. - // Must fall through to base table, not gpt-5.1. - let result = openai_efforts_for_model("gpt-5-1106"); - let base = openai_efforts_for_model("gpt-5").unwrap(); - assert_eq!( - result, - Some(base), - "gpt-5-1106 must match base table (not gpt-5.1): got {result:?}" - ); - // Crucially, must NOT support None (that's a gpt-5.1 property, not base). - assert!( - !result.unwrap().contains(&ThinkingEffort::None), - "gpt-5-1106 must NOT support none — base table only has minimal" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_gpt5_4o_is_base_not_5_4() { - // gpt-5-4o: the "-4" could false-match the gpt-5.4 family, but "4o" is a - // capability suffix on the base gpt-5 model, not version 5.4. - // Must fall through to base table. - let result = openai_efforts_for_model("gpt-5-4o"); - let base = openai_efforts_for_model("gpt-5").unwrap(); - assert_eq!( - result, - Some(base), - "gpt-5-4o must match base table (not gpt-5.4): got {result:?}" - ); - // Crucially, must NOT support XHigh (that's a gpt-5.4 property, not base). - assert!( - !result.unwrap().contains(&ThinkingEffort::XHigh), - "gpt-5-4o must NOT support xhigh — that's a gpt-5.4 property and would 400" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_multi_digit_versions_pass_through() { - // Dotted two-digit versions (gpt-5.10, gpt5.10, gpt-5.50) must not match any known - // single-digit family — the digit boundary check on dotted tokens blocks them. - // These return None (server-validated pass-through). - assert!( - openai_efforts_for_model("gpt-5.10").is_none(), - "gpt-5.10 must pass through (unknown future model)" - ); - assert!( - openai_efforts_for_model("gpt5.10").is_none(), - "gpt5.10 must pass through (unknown future model)" - ); - assert!( - openai_efforts_for_model("gpt-5.50").is_none(), - "gpt-5.50 must pass through (not gpt-5.5)" - ); - // Dash two-digit versions (gpt-5-10, databricks-gpt-5-10) look like short numeric - // version segments and must also pass through as unknown — not bucketed as base. - assert!( - openai_efforts_for_model("gpt-5-10").is_none(), - "gpt-5-10 must pass through (short numeric suffix = potential unrecognized version)" - ); - assert!( - openai_efforts_for_model("databricks-gpt-5-10").is_none(), - "databricks-gpt-5-10 must pass through (short numeric suffix)" - ); - // Short numeric suffix + textual continuation (e.g. a hypothetical 'gpt-5.10-preview') - // must also pass through — the digit count (1-3) determines version-like, regardless of - // what follows. - assert!( - openai_efforts_for_model("gpt-5-10-preview").is_none(), - "gpt-5-10-preview must pass through (short numeric version suffix with text tail)" - ); - assert!( - openai_efforts_for_model("databricks-gpt-5-10-preview").is_none(), - "databricks-gpt-5-10-preview must pass through (short numeric version suffix with text tail)" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_date_segment_with_suffix_is_base() { - // 4+ digit date segment followed by a textual suffix must still resolve to the base - // table — the date length (>=4) determines it's a build/date, not a version number. - let result = openai_efforts_for_model("gpt-5-1106-preview"); - assert!( - result.is_some(), - "gpt-5-1106-preview must match base table (4-digit date segment)" - ); - let supported = result.unwrap(); - assert!( - supported.contains(&ThinkingEffort::Minimal), - "gpt-5-1106-preview (base) must support minimal" - ); - assert!( - !supported.contains(&ThinkingEffort::None), - "gpt-5-1106-preview (base) must NOT support none" - ); - assert!( - !supported.contains(&ThinkingEffort::XHigh), - "gpt-5-1106-preview (base) must NOT support xhigh" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_databricks_prefixed_still_matches() { - // Databricks-prefixed names (gateway forwarding) must still resolve to the right table. - let result = openai_efforts_for_model("databricks-gpt-5-5"); - assert_eq!( - result, - openai_efforts_for_model("gpt-5.5"), - "databricks-gpt-5-5 must match gpt-5.5 family table" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_date_suffixed_still_matches() { - // Date-suffixed names (e.g. gpt-5.1-2025-04-01) must still resolve to the right family. - let result = openai_efforts_for_model("gpt-5.1-2025-04-01"); - assert_eq!( - result, - openai_efforts_for_model("gpt-5.1"), - "gpt-5.1-2025-04-01 must match gpt-5.1 family table" - ); - } - - #[test] - fn openai_efforts_for_model_pro_before_base_gpt5() { - // gpt-5-pro must match the -pro table, not the base gpt-5 table. - let pro = openai_efforts_for_model("gpt-5-pro").unwrap(); - let base = openai_efforts_for_model("gpt-5").unwrap(); - assert_ne!( - pro, base, - "gpt-5-pro and gpt-5 base must hit different table entries" - ); - assert_eq!(pro, &[ThinkingEffort::High]); - } - - #[test] - fn normalize_openai_route_gpt5_pro_high_passes_through() { - // gpt-5-pro: high is the only supported value → high passes through unchanged. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::High, "gpt-5-pro"), - ThinkingEffort::High - ); - } - - #[test] - fn normalize_openai_route_gpt5_pro_anything_but_high_becomes_high() { - // gpt-5-pro: any effort other than high must resolve to high. - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::XHigh, - ] { - assert_eq!( - normalize_effort_for_openai_route(effort, "gpt-5-pro"), - ThinkingEffort::High, - "gpt-5-pro: {effort:?} must resolve to high" - ); - } - } - - #[test] - fn normalize_openai_route_gpt5_base_none_becomes_minimal() { - // gpt-5 base supports minimal but not none. none → minimal (peer fallback). - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5"), - ThinkingEffort::Minimal, - "gpt-5 base: none must fall back to minimal (peer)" - ); - } - - #[test] - fn normalize_openai_route_passes_max_through_for_gpt5_6() { - for model in ["gpt-5.6", "gpt-5-6-sol"] { - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Max, model), - ThinkingEffort::Max, - "{model} must preserve max" - ); - } - } - - #[test] - fn normalize_openai_route_gpt5_5_max_becomes_xhigh() { - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"), - ThinkingEffort::XHigh, - "gpt-5.5 must clamp max to xhigh" - ); - } - - #[test] - fn normalize_openai_route_gpt5_5_minimal_becomes_none() { - // gpt-5.5 supports none but not minimal. minimal → none (peer fallback). - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Minimal, "gpt-5.5"), - ThinkingEffort::None, - "gpt-5.5: minimal must fall back to none (peer)" - ); - } - - #[test] - fn normalize_openai_route_gpt5_1_xhigh_becomes_high() { - // gpt-5.1 does not support xhigh → nearest supported below xhigh is high. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5.1"), - ThinkingEffort::High, - "gpt-5.1: xhigh must resolve to high" - ); - } - - #[test] - fn normalize_openai_route_gpt5_4_xhigh_passes_through() { - // gpt-5.4 supports xhigh → pass through unchanged. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5.4"), - ThinkingEffort::XHigh - ); - } - - #[test] - fn normalize_openai_route_gpt5_5_xhigh_passes_through() { - // gpt-5.5 supports xhigh → pass through unchanged. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5.5"), - ThinkingEffort::XHigh - ); - } - - #[test] - fn normalize_openai_route_gpt5_dash_suffix_variants_match_correctly() { - // Databricks-prefixed or date-suffixed names must still hit the right family. - // "gpt-5.5" and "gpt-5-5" are treated identically; ditto for other families. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5-5"), - ThinkingEffort::XHigh, - "gpt-5-5 (dash) must match gpt-5.5 table" - ); - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5-1"), - ThinkingEffort::None, - "gpt-5-1 (dash) must match gpt-5.1 table" - ); - } - - #[test] - fn normalize_openai_route_unknown_model_passthrough() { - // Unknown models: all values pass through without substitution (server-validated). - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ] { - assert_eq!( - normalize_effort_for_openai_route(effort, "llama-4"), - effort, - "unknown model: {effort:?} must pass through unchanged" - ); - } - } - - // ---- effort-table fixture sync guard ---------------------------------------- - // - // Loads `effortTable.fixture.json` (the single source of truth shared with - // the TS test in `buzzAgentConfig.test.mjs`) and verifies that this Rust - // implementation produces the same valid-effort-value sets and default values - // as the TS `getProviderEffortConfig` function. - // - // Drift (a new model family added to one side but not the other) fails CI here - // before it can silently diverge in production. - // ───────────────────────────────────────────────────────────────────────────── - - /// Compute the valid effort values for a provider/model pair, mirroring - /// `getProviderEffortConfig` in `buzzAgentConfig.ts`. - /// - /// Returns `(valid_values, default_value)` where `default_value` is `None` - /// for Anthropic manual-budget models (TS `defaultValue: null`), otherwise - /// `Some("medium")` or `Some("high")`. - fn valid_effort_values_for_provider_model( - provider: &str, - model: &str, - ) -> (Vec<&'static str>, Option<&'static str>) { - const ALL_7: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; - const ALL_EXCEPT_MAX: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh"]; - const GPT5_PRO: &[&str] = &["high"]; - const GPT5_1: &[&str] = &["none", "low", "medium", "high"]; - - let p = provider.to_ascii_lowercase(); - // Strip arbitrary endpoint-naming prefix before model matching, mirroring TS and - // strip_catalog_prefix: find the first known family token (claude-, gpt-) and - // drop everything before it. Handles any catalog naming convention. - let raw_model = model.trim(); - let lower_raw = raw_model.to_ascii_lowercase(); - const FAMILY_TOKENS: &[&str] = &["claude-", "gpt-"]; - let first_idx = FAMILY_TOKENS - .iter() - .filter_map(|tok| lower_raw.find(tok)) - .min(); - let stripped = match first_idx { - Some(idx) => &raw_model[idx..], - None => raw_model, - }; - let m = stripped.to_ascii_lowercase(); - - // Thin adapter: converts production helper output to the string-based - // return type used by this function. - fn anthropic_result(m: &str) -> (Vec<&'static str>, Option<&'static str>) { - let (values, default) = anthropic_efforts_for_model(m); - let strs: Vec<&'static str> = values.iter().map(|e| e.openai_effort_str()).collect(); - (strs, default.map(|e| e.openai_effort_str())) - } - - fn openai_result(m: &str) -> (Vec<&'static str>, Option<&'static str>) { - if let Some(values) = openai_efforts_for_model(m) { - let strs: Vec<&'static str> = - values.iter().map(|e| e.openai_effort_str()).collect(); - // Determine default from the family. - let default_val = if strs == GPT5_PRO { - Some("high") - } else if strs == GPT5_1 { - Some("none") - } else { - Some("medium") - }; - (strs, default_val) - } else { - // Unknown model → all-except-max, default medium. - (ALL_EXCEPT_MAX.to_vec(), Some("medium")) - } - } - - if p == "anthropic" { - return anthropic_result(&m); - } - if p == "openai" { - return openai_result(&m); - } - if p == "databricks_v2" { - if m.starts_with("claude-") { - return anthropic_result(&m); - } - // gpt-5 family check mirrors gpt5FamilyModel in TS. - let is_gpt5 = gpt5_token_matches(&m, "gpt-5-pro") - || gpt5_token_matches(&m, "gpt5-pro") - || gpt5_token_matches(&m, "gpt-5.6") - || gpt5_token_matches(&m, "gpt5.6") - || gpt5_token_matches(&m, "gpt-5-6") - || gpt5_token_matches(&m, "gpt5-6") - || gpt5_token_matches(&m, "gpt-5.5") - || gpt5_token_matches(&m, "gpt5.5") - || gpt5_token_matches(&m, "gpt-5.4") - || gpt5_token_matches(&m, "gpt5.4") - || gpt5_token_matches(&m, "gpt-5.1") - || gpt5_token_matches(&m, "gpt5.1") - || gpt5_base_matches(&m, "gpt-5") - || gpt5_base_matches(&m, "gpt5"); - if is_gpt5 { - return openai_result(&m); - } - if !m.is_empty() { - // Concrete non-claude, non-gpt5: MLflow path → all-except-max. - return openai_result(&m); - } - // Blank model: route unknown, all-7. - return (ALL_7.to_vec(), Some("medium")); - } - if p == "databricks" { - return openai_result(&m); - } - // openai-compat, unknown, empty → all-7, default medium. - (ALL_7.to_vec(), Some("medium")) - } - - #[derive(serde::Deserialize)] - struct FixtureEntry { - note: Option, - provider: String, - model: String, - #[serde(rename = "validValues")] - valid_values: Vec, - #[serde(rename = "defaultValue")] - default_value: Option, - } - - #[test] - fn effort_table_fixture_matches_rust_implementation() { - let fixture_json = - include_str!("../../../desktop/src/features/agents/ui/effortTable.fixture.json"); - let entries: Vec = - serde_json::from_str(fixture_json).expect("fixture must be valid JSON"); - - assert!( - !entries.is_empty(), - "fixture must contain at least one entry" - ); - - for entry in &entries { - let label = entry.note.as_deref().unwrap_or(entry.model.as_str()); - let (valid_values, default_value) = - valid_effort_values_for_provider_model(&entry.provider, &entry.model); - - let expected: Vec<&str> = entry.valid_values.iter().map(String::as_str).collect(); - assert_eq!( - valid_values, expected, - "validValues mismatch for fixture entry \"{label}\" \ - (provider={}, model={}): Rust side has {valid_values:?}, \ - fixture expects {expected:?}", - entry.provider, entry.model, - ); - - let expected_default: Option<&str> = entry.default_value.as_deref(); - assert_eq!( - default_value, expected_default, - "defaultValue mismatch for fixture entry \"{label}\" \ - (provider={}, model={}): Rust side has {default_value:?}, \ - fixture expects {expected_default:?}", - entry.provider, entry.model, - ); - } + fn stop_reason_wire_strings_are_stable() { + use crate::types::StopReason; + // buzz-acp parses these; drift breaks turn completion. + assert_eq!(StopReason::EndTurn.as_wire(), "end_turn"); + assert_eq!(StopReason::Cancelled.as_wire(), "cancelled"); + assert_eq!(StopReason::MaxTurnRequests.as_wire(), "max_turn_requests"); } } diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs deleted file mode 100644 index 9c27c6606d..0000000000 --- a/crates/buzz-agent/src/handoff.rs +++ /dev/null @@ -1,430 +0,0 @@ -use crate::agent::RunCtx; -use crate::config::{ - HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES, -}; -use crate::types::HistoryItem; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct HandoffTokenCounts { - before: u64, - after: u64, -} - -impl std::fmt::Display for HandoffTokenCounts { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} -> {} tokens", self.before, self.after) - } -} - -pub(crate) enum HandoffOutcome { - Performed, - Skipped, - Cancelled, -} - -const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \ -turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \ -you accomplished, key decisions made, what remains, and one concrete next step. Output plain \ -text only — no tool calls, no JSON. Stay under 8192 tokens."; - -impl RunCtx<'_> { - pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome { - if !self.should_handoff() { - return HandoffOutcome::Skipped; - } - if *self.handoff_count >= self.cfg.max_handoffs { - tracing::info!( - "handoff cap reached ({}); using truncation", - self.cfg.max_handoffs - ); - return HandoffOutcome::Skipped; - } - let prompt = self.build_handoff_prompt(); - let tokens_before = self.projected_handoff_input_tokens(); - let summary = tokio::select! { - biased; - _ = self.cancel.changed() => return HandoffOutcome::Cancelled, - r = self.llm.summarize( - self.cfg, - HANDOFF_SYSTEM_PROMPT, - &prompt, - HANDOFF_MAX_OUTPUT_TOKENS, - self.effective_model, - ) => match r { - Ok(s) if !s.trim().is_empty() => s, - Ok(_) => { - tracing::warn!("handoff returned empty summary; truncating"); - return HandoffOutcome::Skipped; - } - Err(e) => { - tracing::warn!("handoff failed: {e}; truncating"); - return HandoffOutcome::Skipped; - } - }, - }; - let current_prompt = self.history.iter().rev().find_map(|item| match item { - HistoryItem::User(s) => Some(s.clone()), - _ => None, - }); - let prior = self.history.len(); - // Reset history first; the _PostCompact hook is meant to inject - // state into the FRESH context, not the old one we're discarding. - self.history.clear(); - let post_compact = self - .mcp - .call_hooks( - "_PostCompact", - &serde_json::json!({}), - self.cfg.hook_timeout, - &self.cfg.hook_servers, - ) - .await; - // Handoff summary and hook output are injected as a synthetic user - // message in one block. This keeps `_PostCompact` untrusted while also - // avoiding orphan tool-result messages in the fresh context: OpenAI - // Chat/Responses require tool outputs to follow an assistant tool call, - // but handoff reset intentionally discards the old assistant turn. - let mut handoff_text = format!("[Context Handoff]\n{summary}"); - if !post_compact.is_empty() { - handoff_text.push_str("\n\n[Post-compact hook output — untrusted]\n"); - handoff_text.push_str(&hook_outputs_text(&post_compact)); - } - self.history.push(HistoryItem::User(handoff_text)); - if let Some(prompt) = current_prompt { - self.history.push(HistoryItem::User(prompt)); - } - *self.handoff_count += 1; - let token_counts = HandoffTokenCounts { - before: tokens_before, - after: estimate_history_tokens(self.history), - }; - tracing::info!( - "handoff #{} (history {prior} -> {} items; {token_counts})", - *self.handoff_count, - self.history.len() - ); - HandoffOutcome::Performed - } - - fn should_handoff(&self) -> bool { - match *self.last_request_input_tokens { - Some(_) => { - self.projected_handoff_input_tokens() - >= token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens) - } - None => { - let bytes: usize = self - .history - .iter() - .map(HistoryItem::context_pressure_bytes) - .sum(); - bytes - > byte_fallback_threshold( - self.cfg.max_context_tokens, - self.cfg.max_output_tokens, - self.cfg.max_history_bytes, - ) - } - } - } - - fn projected_handoff_input_tokens(&self) -> u64 { - let current_tokens = estimate_history_tokens(self.history); - match *self.last_request_input_tokens { - // Token-first: the provider told us exactly how many input tokens - // the PREVIOUS request used. But history has grown since that - // measurement — new assistant text, tool results, and the next - // user prompt are appended before the next `complete()`. The exact - // count alone would miss "previous request was under threshold, but - // newly appended content pushes the next one over" (the stale-usage - // cousin of the original stale-bytes bug). So we add a conservative - // token estimate of the bytes added since the measurement. - Some(measured_tokens) => { - let measured_bytes = self.last_request_history_bytes.unwrap_or(0); - let current_bytes: usize = self - .history - .iter() - .map(HistoryItem::context_pressure_bytes) - .sum(); - let grown = current_bytes.saturating_sub(measured_bytes); - measured_tokens.saturating_add(estimate_tokens_from_bytes(grown)) - } - // No usage yet (first request, or just after a handoff reset). - // Fall back to the byte heuristic, capped conservatively so a - // single pre-usage request can't blow the window. We map the token - // threshold to bytes using a deliberately LOW bytes/token ratio: - // a low ratio implies more tokens per byte, so the byte cap is - // small and the handoff fires early rather than late. Never raise - // the cap above the configured byte budget. - // - // Caveat: this can't shrink a single oversized current prompt, - // since a handoff re-adds the current prompt verbatim — that is a - // prompt-cap concern (MAX_PROMPT_BYTES), not this gate. - None => current_tokens, - } - } - - fn build_handoff_prompt(&self) -> String { - let mut head = String::new(); - head.push_str(&format!( - "[Internal handoff #{} — context reset]\n\n", - *self.handoff_count + 1 - )); - head.push_str("# Original Task\n"); - let task = self.original_task.as_deref().unwrap_or("(unknown)"); - head.push_str(&clamp_bytes(task, HANDOFF_ORIGINAL_TASK_MAX_BYTES)); - head.push_str("\n\n# Available Tools\n"); - let all_tools = self.mcp.tools(); - let total = all_tools.len(); - if total == 0 { - head.push_str("(none)\n"); - } else { - let shown = total.min(HANDOFF_MAX_TOOL_NAMES); - let names: Vec<&str> = all_tools[..shown].iter().map(|t| t.name.as_str()).collect(); - head.push_str(&names.join(", ")); - if shown < total { - head.push_str(&format!(", … (+{} more)", total - shown)); - } - head.push('\n'); - } - let tail = "\n# Instructions\n\ - Produce a context handoff summary covering: (1) original task, \ - (2) what was accomplished, (3) key decisions, (4) what remains, \ - (5) one concrete next step. Be concise but thorough. Plain text.\n"; - let history_header = "\n# Session History (oldest first)\n"; - let prompt_budget = handoff_prompt_budget_bytes( - self.cfg.max_context_tokens, - HANDOFF_MAX_OUTPUT_TOKENS, - head.len() + history_header.len() + tail.len(), - ); - - let mut snippets: Vec = Vec::new(); - let mut snippets_bytes = 0usize; - let mut dropped = 0usize; - for item in self.history.iter().rev() { - let mut snippet = String::new(); - push_history_snippet(&mut snippet, item); - let snippet_bytes = snippet.len(); - if snippets_bytes.saturating_add(snippet_bytes) > prompt_budget { - if snippets.is_empty() { - snippets.push(clamp_bytes(&snippet, prompt_budget)); - snippets_bytes = prompt_budget; - } - dropped += 1; - continue; - } - snippets_bytes += snippet_bytes; - snippets.push(snippet); - } - snippets.reverse(); - if dropped > 0 { - tracing::info!( - "handoff prompt budget, dropped {dropped} oldest snippets; kept {} bytes", - snippets_bytes - ); - } - - let mut out = String::with_capacity( - head.len() - + history_header.len() - + tail.len() - + snippets_bytes - + if dropped > 0 { 32 } else { 0 }, - ); - out.push_str(&head); - out.push_str(history_header); - if dropped > 0 { - out.push_str(&format!("(… {dropped} older items omitted)\n")); - } - for s in &snippets { - out.push_str(s); - } - out.push_str(tail); - out - } -} - -fn hook_outputs_text(outputs: &[(String, String)]) -> String { - outputs - .iter() - .map(|(name, text)| format!("[{name}]\n{text}")) - .collect::>() - .join("\n\n") -} - -fn push_history_snippet(out: &mut String, item: &HistoryItem) { - match item { - HistoryItem::User(s) => { - out.push_str("[user] "); - out.push_str(s); - out.push('\n'); - } - HistoryItem::Assistant { text, tool_calls } => { - out.push_str("[assistant] "); - if !text.is_empty() { - out.push_str(text); - } - for c in tool_calls { - out.push_str(&format!(" tool:{}", c.name)); - } - out.push('\n'); - } - HistoryItem::ToolResult(r) => { - out.push_str(if r.is_error { "[tool_err] " } else { "[tool] " }); - out.push_str(&r.text()); - out.push('\n'); - } - } -} - -/// Byte budget for session-history text inside the handoff prompt. The -/// summarizer uses the same provider/model config as normal completion, so -/// derive the input budget from the model context window instead of applying a -/// separate fixed prompt cap. We keep the same 1 byte/token upper-bound -/// estimate used by the handoff gate, which is conservative: it may drop old -/// history early for unusually large sessions, but it should not build a prompt -/// that exceeds the configured context window. -fn handoff_prompt_budget_bytes( - max_context_tokens: u64, - max_output_tokens: u32, - fixed_prompt_bytes: usize, -) -> usize { - max_context_tokens - .saturating_sub(u64::from(max_output_tokens)) - .saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN) - .saturating_sub(u64::try_from(fixed_prompt_bytes).unwrap_or(u64::MAX)) - .try_into() - .unwrap_or(usize::MAX) -} - -pub(crate) fn clamp_bytes(s: &str, max_bytes: usize) -> String { - if s.len() <= max_bytes { - return s.to_owned(); - } - if max_bytes < 4 { - let mut cut = max_bytes.min(s.len()); - while cut > 0 && !s.is_char_boundary(cut) { - cut -= 1; - } - return s[..cut].to_owned(); - } - let target = max_bytes - "…".len(); - let mut cut = target; - while cut > 0 && !s.is_char_boundary(cut) { - cut -= 1; - } - format!("{}…", &s[..cut]) -} - -/// Conservative bytes-per-token ratio used when estimating tokens from raw -/// history bytes. We use 1: a token is always at least one byte, so treating -/// every byte as a whole token is an unconditional UPPER bound on the true -/// token count — it can never undercount, regardless of content density (even -/// the densest real content sits at ~1.4 bytes/token). That over-estimate is -/// exactly what a fail-early preflight gate wants: it hands off sooner rather -/// than risk the next request exceeding the window. -const CONSERVATIVE_BYTES_PER_TOKEN: u64 = 1; - -fn estimate_history_tokens(history: &[HistoryItem]) -> u64 { - estimate_tokens_from_bytes( - history - .iter() - .map(HistoryItem::context_pressure_bytes) - .sum(), - ) -} - -/// Estimate tokens from a byte count at the conservative ratio (rounding up, -/// so a partial token still counts). At a 1:1 ratio this is just the byte -/// count — a guaranteed upper bound on tokens. -fn estimate_tokens_from_bytes(bytes: usize) -> u64 { - (bytes as u64).div_ceil(CONSERVATIVE_BYTES_PER_TOKEN) -} - -/// Input-token count at which to hand off. Caps at the configured fraction of -/// the window and also leaves room for `max_output_tokens`, so input + output -/// can't together exceed the window. Free function so the policy math is unit -/// testable without constructing a [`RunCtx`]. -fn token_threshold(max_context_tokens: u64, max_output_tokens: u32) -> u64 { - // Integer math: handoff threshold is 90%, i.e. window * 9 / 10. - let fractional = max_context_tokens / 10 * 9; - let output_reserved = max_context_tokens.saturating_sub(u64::from(max_output_tokens)); - fractional.min(output_reserved) -} - -/// Conservative byte cap used only before any usage is known. Maps the token -/// threshold to bytes at the conservative bytes/token ratio (so the cap is -/// small and the handoff fires early), clamped to the configured byte budget -/// so it can only ever be more conservative than the old byte-only behavior. -fn byte_fallback_threshold( - max_context_tokens: u64, - max_output_tokens: u32, - max_history_bytes: usize, -) -> usize { - let derived = token_threshold(max_context_tokens, max_output_tokens) - .saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN); - let byte_cap = max_history_bytes / 10 * 9; - usize::try_from(derived).unwrap_or(usize::MAX).min(byte_cap) -} - -#[cfg(test)] -mod tests { - use super::{ - byte_fallback_threshold, estimate_tokens_from_bytes, handoff_prompt_budget_bytes, - token_threshold, - }; - - #[test] - fn handoff_prompt_budget_reserves_summary_output_and_fixed_prompt() { - assert_eq!(handoff_prompt_budget_bytes(25_000, 8_192, 1_000), 15_808); - } - - #[test] - fn handoff_prompt_budget_saturates_when_fixed_prompt_exceeds_window() { - assert_eq!(handoff_prompt_budget_bytes(1_000, 2_000, 10_000), 0); - } - - #[test] - fn token_threshold_uses_fraction_when_output_is_small() { - // 200k window, 1k output. fractional = 0.9*200000 = 180000; - // output_reserved = 200000-1000 = 199000; min = 180000. - assert_eq!(token_threshold(200_000, 1_000), 180_000); - } - - #[test] - fn token_threshold_reserves_output_headroom() { - // Large output relative to window: the output-reserve term dominates, - // keeping input+output within the window. - // 100k window, 40k output: fractional=90k, reserved=60k -> 60k. - assert_eq!(token_threshold(100_000, 40_000), 60_000); - } - - #[test] - fn token_threshold_saturates_when_output_exceeds_window() { - // Degenerate (config validation forbids this, but math must not panic): - // reserved saturates to 0, so threshold is 0 -> always hand off. - assert_eq!(token_threshold(1000, 5000), 0); - } - - #[test] - fn byte_fallback_is_conservative_and_capped() { - // Derived = token_threshold * 1 (1 byte/token upper bound). For - // 200k/1k: 180000 bytes, well under a 16 MiB byte budget, so derived - // wins (early handoff). - let t = byte_fallback_threshold(200_000, 1_000, 16 * 1024 * 1024); - assert_eq!(t, 180_000); - // With a tiny byte budget the cap wins -> never exceeds it (window*90%). - let capped = byte_fallback_threshold(200_000, 1_000, 8192); - assert_eq!(capped, 8192 / 10 * 9); - } - - #[test] - fn estimate_tokens_is_upper_bound_on_tokens() { - // 1 byte/token: a token is always >= 1 byte, so byte count is an - // unconditional upper bound on the true token count. - assert_eq!(estimate_tokens_from_bytes(0), 0); - assert_eq!(estimate_tokens_from_bytes(1), 1); - assert_eq!(estimate_tokens_from_bytes(4), 4); - assert_eq!(estimate_tokens_from_bytes(5), 5); - } -} diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs deleted file mode 100644 index 9fb99f0ebc..0000000000 --- a/crates/buzz-agent/src/hints.rs +++ /dev/null @@ -1,726 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use crate::mcp::truncate_at_boundary; - -const MAX_HINTS_BYTES: usize = 128 * 1024; -pub const MAX_SKILL_BODY_BYTES: usize = 32 * 1024; -const SKILL_DIRS: &[&str] = &[".agents/skills", ".goose/skills", ".claude/skills"]; - -fn home_dir() -> Option { - std::env::var("HOME").ok().map(PathBuf::from) -} - -#[derive(Clone)] -pub struct SkillEntry { - pub name: String, - pub description: String, - /// Absolute path to the SKILL.md file; used by `load_skill` to read on demand. - pub path: PathBuf, - /// Absolute paths to every non-SKILL.md file in the skill directory tree. - /// Pre-enumerated at discovery time so `load_skill` can match by relative path - /// without doing arbitrary filesystem lookups at call time. - pub supporting_files: Vec, -} - -/// Handles both normal repos (`.git/` dir) and worktrees (`.git` file). -fn find_git_root(start: &Path) -> Option { - let mut current = start.to_path_buf(); - loop { - if current.join(".git").exists() { - return Some(current); - } - match current.parent() { - Some(parent) => current = parent.to_path_buf(), - None => return None, - } - } -} - -fn load_hint_files_impl(cwd: &Path, home: Option<&Path>) -> String { - let mut chain = match find_git_root(cwd) { - Some(root) => { - let mut c: Vec = cwd - .ancestors() - .take_while(|a| a.starts_with(&root)) - .map(|a| a.to_path_buf()) - .collect(); - // ancestors() yields cwd first, root last — reverse for root→cwd. - c.reverse(); - c - } - None => vec![cwd.to_path_buf()], - }; - - // Prepend ~/AGENTS.md as global layer, unless ~ is already in the chain. - if let Some(home) = home { - if !chain.iter().any(|d| d == home) { - chain.insert(0, home.to_path_buf()); - } - } - - let mut result = String::new(); - for dir in &chain { - let path = dir.join("AGENTS.md"); - let Ok(content) = std::fs::read_to_string(&path) else { - continue; - }; - if !result.is_empty() { - result.push_str("\n\n"); - } - let remaining = MAX_HINTS_BYTES.saturating_sub(result.len()); - if remaining == 0 { - break; - } - if content.len() <= remaining { - result.push_str(&content); - } else { - let truncated = truncate_at_boundary(&content, remaining); - result.push_str(truncated); - break; - } - } - result -} - -fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> { - // Must start with `---` - let rest = content.strip_prefix("---\n")?; - // Find the closing `---` - let close_pos = rest.find("\n---")?; - let yaml_block = &rest[..close_pos]; - - let map: HashMap = serde_yaml::from_str(yaml_block).ok()?; - let name = map - .get("name") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string)?; - let description = map - .get("description") - .and_then(|v| v.as_str()) - .map(str::trim) - .unwrap_or("") - .to_string(); - - Some((name, description)) -} - -fn scan_skill_dir(dir: &Path, seen: &mut HashSet, skills: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - let mut subdirs: Vec = entries - .filter_map(|e| e.ok()) - // Use std::fs::metadata (follows symlinks) rather than DirEntry::file_type - // (which returns FileType::Symlink for symlinks, causing is_dir() to return - // false even when the symlink target is a directory). - .filter(|e| { - std::fs::metadata(e.path()) - .map(|m| m.is_dir()) - .unwrap_or(false) - }) - .map(|e| e.path()) - .collect(); - subdirs.sort(); - - for subdir in subdirs { - let skill_md = subdir.join("SKILL.md"); - let Ok(content) = std::fs::read_to_string(&skill_md) else { - continue; - }; - let Some((name, description)) = parse_skill_frontmatter(&content) else { - continue; - }; - if seen.contains(&name) { - continue; - } - seen.insert(name.clone()); - - // Collect supporting files: every non-SKILL.md file in the skill dir tree. - // Don't descend into subdirs that themselves have a SKILL.md — those are - // separate skills with their own entries. - let supporting_files = collect_supporting_files(&subdir); - - skills.push(SkillEntry { - name, - description, - path: skill_md, - supporting_files, - }); - } -} - -/// Walk `skill_dir` recursively and return the absolute path of every file -/// that is not `SKILL.md`. Subdirectories that contain their own `SKILL.md` -/// are treated as separate skills and are not descended into. -fn collect_supporting_files(skill_dir: &Path) -> Vec { - let mut result = Vec::new(); - let mut visited_dirs = HashSet::new(); - collect_supporting_files_impl(skill_dir, &mut result, &mut visited_dirs); - result.sort(); - result -} - -fn collect_supporting_files_impl( - current: &Path, - out: &mut Vec, - visited_dirs: &mut HashSet, -) { - let Ok(canonical_current) = current.canonicalize() else { - return; - }; - if !visited_dirs.insert(canonical_current) { - return; - } - - let Ok(entries) = std::fs::read_dir(current) else { - return; - }; - let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect(); - items.sort_by_key(|e| e.path()); - - for entry in items { - let path = entry.path(); - // Use std::fs::metadata (follows symlinks) so symlinked subdirs and files - // inside a skill directory are handled correctly. - let ft = match std::fs::metadata(&path) { - Ok(m) => m, - Err(_) => continue, - }; - if ft.is_dir() { - // Don't descend into subdirs that are themselves skills. - if path.join("SKILL.md").is_file() { - continue; - } - collect_supporting_files_impl(&path, out, visited_dirs); - } else if ft.is_file() && path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") { - out.push(path); - } - } -} - -fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec { - let mut seen = HashSet::new(); - let mut skills = Vec::new(); - - for dir_suffix in SKILL_DIRS { - scan_skill_dir(&cwd.join(dir_suffix), &mut seen, &mut skills); - } - - if let Some(home) = home { - scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills); - } - - skills -} - -pub fn build_hints_section(cwd: &Path) -> (String, Vec) { - build_hints_section_impl(cwd, home_dir().as_deref()) -} - -fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec) { - let hints_text = load_hint_files_impl(cwd, home); - let skills = discover_skills_impl(cwd, home); - - if hints_text.is_empty() && skills.is_empty() { - return (String::new(), skills); - } - - let mut out = String::from("# Additional Instructions\n"); - - if !hints_text.is_empty() { - out.push_str("\n## Project Hints\n"); - out.push_str(&hints_text); - out.push('\n'); - } - - if !skills.is_empty() { - out.push_str("\n## Available Skills\n"); - for skill in &skills { - out.push_str(&format!("- {}: {}\n", skill.name, skill.description)); - } - out.push_str( - "\nUse the `load_skill` tool to read the full content of a skill before using it.\n", - ); - } - - (out, skills) -} - -/// Strip the YAML frontmatter block from a skill file's content and return -/// the body. If no valid frontmatter is found, returns the content unchanged. -pub(crate) fn strip_frontmatter(content: &str) -> &str { - let Some(rest) = content.strip_prefix("---\n") else { - return content; - }; - let Some(close_pos) = rest.find("\n---") else { - return content; - }; - let after = &rest[close_pos + 4..]; // skip "\n---" - after.strip_prefix('\n').unwrap_or(after) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn find_git_root_normal_repo() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - std::fs::create_dir(root.join(".git")).unwrap(); - assert_eq!(find_git_root(root), Some(root.to_path_buf())); - } - - #[test] - fn find_git_root_worktree() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - // .git as a file (worktree) - std::fs::write(root.join(".git"), "gitdir: ../main/.git/worktrees/wt").unwrap(); - assert_eq!(find_git_root(root), Some(root.to_path_buf())); - } - - #[test] - fn find_git_root_none() { - let tmp = TempDir::new().unwrap(); - // No .git anywhere under tmp - let result = find_git_root(tmp.path()); - // In a CI environment the test itself may live inside a real git repo, - // so only assert None when tmp is truly isolated (not a subpath of a git repo). - // We verify by checking that any found root is NOT inside tmp. - if let Some(found) = result { - assert!(!found.starts_with(tmp.path())); - } - } - - #[test] - fn find_git_root_from_subdirectory() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - std::fs::create_dir(root.join(".git")).unwrap(); - let deep = root.join("sub").join("deep"); - std::fs::create_dir_all(&deep).unwrap(); - assert_eq!(find_git_root(&deep), Some(root.to_path_buf())); - } - - #[test] - fn load_hint_files_single_at_cwd() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - // No .git → no git root discovery; only cwd is checked. - std::fs::write(cwd.join("AGENTS.md"), "cwd hints").unwrap(); - let result = load_hint_files_impl(cwd, None); - assert_eq!(result, "cwd hints"); - } - - #[test] - fn load_hint_files_git_root_and_cwd() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - std::fs::create_dir(root.join(".git")).unwrap(); - std::fs::write(root.join("AGENTS.md"), "root hints").unwrap(); - let sub = root.join("sub"); - std::fs::create_dir(&sub).unwrap(); - std::fs::write(sub.join("AGENTS.md"), "sub hints").unwrap(); - let result = load_hint_files_impl(&sub, None); - // Root hints must come first. - assert!( - result.starts_with("root hints"), - "expected root hints first, got: {result:?}" - ); - assert!(result.contains("sub hints"), "missing sub hints"); - let root_pos = result.find("root hints").unwrap(); - let sub_pos = result.find("sub hints").unwrap(); - assert!(root_pos < sub_pos, "root hints should precede sub hints"); - } - - #[test] - fn load_hint_files_missing_files() { - let tmp = TempDir::new().unwrap(); - let result = load_hint_files_impl(tmp.path(), None); - assert_eq!(result, ""); - } - - #[test] - fn discover_skills_finds_across_dirs() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - - // Skill in .agents/skills/ - let agents_skill = cwd.join(".agents/skills/my-skill"); - std::fs::create_dir_all(&agents_skill).unwrap(); - std::fs::write( - agents_skill.join("SKILL.md"), - "---\nname: my-skill\ndescription: A skill\n---\nSkill body here.\n", - ) - .unwrap(); - - // Skill in .goose/skills/ - let goose_skill = cwd.join(".goose/skills/other-skill"); - std::fs::create_dir_all(&goose_skill).unwrap(); - std::fs::write( - goose_skill.join("SKILL.md"), - "---\nname: other-skill\ndescription: Another skill\n---\nOther body.\n", - ) - .unwrap(); - - let skills = discover_skills_impl(cwd, None); - assert_eq!(skills.len(), 2); - let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); - assert!(names.contains(&"my-skill"), "missing my-skill"); - assert!(names.contains(&"other-skill"), "missing other-skill"); - // Paths should point to the SKILL.md files. - for skill in &skills { - assert!(skill.path.exists(), "path does not exist: {:?}", skill.path); - assert!(skill.path.ends_with("SKILL.md")); - } - } - - #[test] - fn discover_skills_dedup_by_name() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - - // Same name in .agents/skills/ (first) and .goose/skills/ (second) - let agents_skill = cwd.join(".agents/skills/shared"); - std::fs::create_dir_all(&agents_skill).unwrap(); - std::fs::write( - agents_skill.join("SKILL.md"), - "---\nname: shared\ndescription: from agents\n---\nAgents body.\n", - ) - .unwrap(); - - let goose_skill = cwd.join(".goose/skills/shared"); - std::fs::create_dir_all(&goose_skill).unwrap(); - std::fs::write( - goose_skill.join("SKILL.md"), - "---\nname: shared\ndescription: from goose\n---\nGoose body.\n", - ) - .unwrap(); - - let skills = discover_skills_impl(cwd, None); - assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); - assert_eq!( - skills[0].description, "from agents", - "first wins (.agents/)" - ); - // Path should point to the .agents/ version (first wins). - assert!(skills[0] - .path - .to_str() - .unwrap() - .contains(".agents/skills/shared")); - } - - #[test] - fn discover_skills_skips_missing_name() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - - let skill_dir = cwd.join(".agents/skills/no-name"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\ndescription: No name here\n---\nBody.\n", - ) - .unwrap(); - - let skills = discover_skills_impl(cwd, None); - assert!(skills.is_empty(), "entry without name should be skipped"); - } - - #[test] - fn build_hints_section_empty() { - let tmp = TempDir::new().unwrap(); - let (result, skills) = build_hints_section_impl(tmp.path(), None); - assert_eq!(result, ""); - assert!(skills.is_empty()); - } - - #[test] - fn build_hints_section_combined() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - - std::fs::write(cwd.join("AGENTS.md"), "Project-level hints.").unwrap(); - - let skill_dir = cwd.join(".agents/skills/buzz-cli"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: buzz-cli\ndescription: CLI reference for Buzz managed agents\n---\nUse `buzz` to manage agents.\n", - ) - .unwrap(); - - let (result, skills) = build_hints_section_impl(cwd, None); - - assert!( - result.contains("# Additional Instructions"), - "missing header" - ); - assert!(result.contains("## Project Hints"), "missing Project Hints"); - assert!( - result.contains("Project-level hints."), - "missing hints content" - ); - assert!( - result.contains("## Available Skills"), - "missing Available Skills" - ); - assert!( - result.contains("buzz-cli: CLI reference for Buzz managed agents"), - "missing skill bullet" - ); - // Body must NOT be inlined — lazy loading only. - assert!( - !result.contains("Use `buzz` to manage agents."), - "skill body must not be inlined in system prompt" - ); - // The load_skill instruction must be present. - assert!( - result.contains("load_skill"), - "missing load_skill instruction" - ); - // The old ### heading format must not appear. - assert!( - !result.contains("### buzz-cli"), - "skill body heading must not be inlined" - ); - // The returned skills list should contain the discovered skill. - assert_eq!(skills.len(), 1); - assert_eq!(skills[0].name, "buzz-cli"); - } - - #[test] - fn load_hint_files_global_loaded_first() { - let home = TempDir::new().unwrap(); - let cwd = TempDir::new().unwrap(); - std::fs::write(home.path().join("AGENTS.md"), "global hints").unwrap(); - std::fs::write(cwd.path().join("AGENTS.md"), "local hints").unwrap(); - let result = load_hint_files_impl(cwd.path(), Some(home.path())); - let global_pos = result.find("global hints").unwrap(); - let local_pos = result.find("local hints").unwrap(); - assert!( - global_pos < local_pos, - "global hints should precede local hints" - ); - } - - #[test] - fn load_hint_files_home_missing_agents_md() { - let home = TempDir::new().unwrap(); - let cwd = TempDir::new().unwrap(); - std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap(); - let result = load_hint_files_impl(cwd.path(), Some(home.path())); - assert_eq!(result, "local only"); - } - - #[test] - fn load_hint_files_no_home_dir() { - let cwd = TempDir::new().unwrap(); - std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap(); - let result = load_hint_files_impl(cwd.path(), None); - assert_eq!(result, "local only"); - } - - #[test] - fn load_hint_files_dedup_when_home_in_chain() { - let tmp = TempDir::new().unwrap(); - let home = tmp.path(); - std::fs::write(home.join("AGENTS.md"), "single load").unwrap(); - let result = load_hint_files_impl(home, Some(home)); - assert_eq!( - result.matches("single load").count(), - 1, - "AGENTS.md should be loaded exactly once when CWD is home" - ); - } - - #[test] - fn load_hint_files_dedup_when_home_is_git_root() { - let tmp = TempDir::new().unwrap(); - let home = tmp.path(); - std::fs::create_dir(home.join(".git")).unwrap(); - std::fs::write(home.join("AGENTS.md"), "root+home hints").unwrap(); - let sub = home.join("sub"); - std::fs::create_dir(&sub).unwrap(); - let result = load_hint_files_impl(&sub, Some(home)); - assert_eq!( - result.matches("root+home hints").count(), - 1, - "AGENTS.md should be loaded once when home is git root" - ); - } - - #[test] - fn discover_skills_global_skills_loaded() { - let home = TempDir::new().unwrap(); - let cwd = TempDir::new().unwrap(); - let skill_dir = home.path().join(".agents/skills/global-skill"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n", - ) - .unwrap(); - let skills = discover_skills_impl(cwd.path(), Some(home.path())); - assert_eq!(skills.len(), 1); - assert_eq!(skills[0].name, "global-skill"); - } - - #[test] - fn discover_skills_project_wins_over_global() { - let home = TempDir::new().unwrap(); - let cwd = TempDir::new().unwrap(); - - let project_skill = cwd.path().join(".agents/skills/shared"); - std::fs::create_dir_all(&project_skill).unwrap(); - std::fs::write( - project_skill.join("SKILL.md"), - "---\nname: shared\ndescription: from project\n---\nProject body.\n", - ) - .unwrap(); - - let global_skill = home.path().join(".agents/skills/shared"); - std::fs::create_dir_all(&global_skill).unwrap(); - std::fs::write( - global_skill.join("SKILL.md"), - "---\nname: shared\ndescription: from global\n---\nGlobal body.\n", - ) - .unwrap(); - - let skills = discover_skills_impl(cwd.path(), Some(home.path())); - assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); - assert_eq!( - skills[0].description, "from project", - "project-level should win over global" - ); - } - - #[test] - fn discover_skills_no_home_dir() { - let cwd = TempDir::new().unwrap(); - let skill_dir = cwd.path().join(".agents/skills/local"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: local\ndescription: Local skill\n---\nBody.\n", - ) - .unwrap(); - let skills = discover_skills_impl(cwd.path(), None); - assert_eq!(skills.len(), 1); - assert_eq!(skills[0].name, "local"); - } - - #[test] - fn collect_supporting_files_finds_non_skill_md_files() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - // SKILL.md should be excluded. - std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); - // A references subdir with files. - let refs = skill_dir.join("references"); - std::fs::create_dir_all(&refs).unwrap(); - std::fs::write(refs.join("foo.md"), "foo").unwrap(); - std::fs::write(refs.join("bar.md"), "bar").unwrap(); - // A script at the top level. - std::fs::write(skill_dir.join("setup.sh"), "#!/bin/sh").unwrap(); - - let files = collect_supporting_files(skill_dir); - let names: Vec = files - .iter() - .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) - .collect(); - assert!( - names.contains(&"foo.md".to_owned()), - "missing foo.md: {names:?}" - ); - assert!( - names.contains(&"bar.md".to_owned()), - "missing bar.md: {names:?}" - ); - assert!( - names.contains(&"setup.sh".to_owned()), - "missing setup.sh: {names:?}" - ); - assert!( - !names.contains(&"SKILL.md".to_owned()), - "SKILL.md should be excluded: {names:?}" - ); - } - - #[test] - fn collect_supporting_files_does_not_descend_into_nested_skills() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); - std::fs::write(skill_dir.join("helper.sh"), "#!/bin/sh").unwrap(); - - // A nested subdir that is itself a skill — should not be descended into. - let nested = skill_dir.join("nested-skill"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("SKILL.md"), "---\nname: nested\n---\n").unwrap(); - std::fs::write(nested.join("secret.md"), "should not appear").unwrap(); - - let files = collect_supporting_files(skill_dir); - let names: Vec = files - .iter() - .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) - .collect(); - assert!( - names.contains(&"helper.sh".to_owned()), - "missing helper.sh: {names:?}" - ); - assert!( - !names.contains(&"secret.md".to_owned()), - "nested skill's files should not appear: {names:?}" - ); - } - - #[cfg(unix)] - #[test] - fn collect_supporting_files_skips_symlink_cycles() { - let tmp = TempDir::new().unwrap(); - let skill_dir = tmp.path(); - std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); - - let refs = skill_dir.join("references"); - std::fs::create_dir_all(&refs).unwrap(); - let guide = refs.join("guide.md"); - std::fs::write(&guide, "guide").unwrap(); - std::os::unix::fs::symlink(&refs, refs.join("loop")).unwrap(); - - let files = collect_supporting_files(skill_dir); - assert_eq!(files, vec![guide]); - } - - #[test] - fn discover_skills_populates_supporting_files() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - let skill_dir = cwd.join(".agents/skills/with-refs"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: with-refs\ndescription: Has refs\n---\nBody.\n", - ) - .unwrap(); - let refs = skill_dir.join("references"); - std::fs::create_dir_all(&refs).unwrap(); - std::fs::write(refs.join("guide.md"), "guide content").unwrap(); - - let skills = discover_skills_impl(cwd, None); - assert_eq!(skills.len(), 1); - assert_eq!(skills[0].name, "with-refs"); - assert_eq!(skills[0].supporting_files.len(), 1); - assert!( - skills[0].supporting_files[0].ends_with("references/guide.md"), - "unexpected path: {:?}", - skills[0].supporting_files[0] - ); - } -} diff --git a/buzz-agent-core/src/hooks.rs b/crates/buzz-agent/src/hooks.rs similarity index 100% rename from buzz-agent-core/src/hooks.rs rename to crates/buzz-agent/src/hooks.rs diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index e141b9860f..0e7a7e26a4 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -1,207 +1,264 @@ -#![forbid(unsafe_code)] -mod agent; -pub mod auth; -mod builtin; -pub mod catalog; +//! buzz-agent's ACP server, with Goose as the agent loop. +//! +//! This is the layer `buzz-acp` actually talks to, and it is deliberately +//! unchanged in contract from `crates/buzz-agent/src/lib.rs` (960 lines): +//! the same six JSON-RPC methods, the same `agentInfo.name = "buzz-agent"`, +//! the same `activeRunId` steering handshake, the same `usage_update` +//! ordering. Only the loop underneath is Goose's. +//! +//! Not standard ACP, and preserved here on purpose (each of these is +//! something `buzz-acp` or the desktop UI depends on): +//! +//! * `_goose/unstable/session/steer` with `expectedRunId` optimistic +//! concurrency, and `activeRunId` advertised via +//! `params.update._meta.goose.activeRunId` — note the `_meta` nests *inside* +//! `update` (`buzz-acp/src/acp.rs:1607-1613`). Get the depth wrong and the +//! harness silently falls back to cancel+re-prompt forever. +//! * `usage_update` on the `_goose/unstable/session/update` channel, emitted +//! *before* the `session/prompt` response, suppressed when no tokens were +//! seen (`buzz-agent/src/lib.rs:701-712`). +//! * `keepalive` — see `agent.rs`. + +pub mod agent; pub mod config; -mod handoff; -mod hints; -mod llm; -mod mcp; +pub mod hooks; pub mod types; -mod wire; +pub mod wire; -pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; -pub use config::Provider; +// Databricks model discovery and the Windows shell-env contract moved to +// `buzz-model-catalog` when this crate took on goose: the desktop cannot link +// goose (native `sqlite3` collision with its own rusqlite), and it only ever +// needed those two things. See that crate's lib.rs for the full reasoning. pub use types::AgentError; -/// Environment keys the Windows Git Bash resolver may inspect. `spawn_one()` -/// forwards every key in this list into its otherwise-cleared MCP child; Doctor -/// uses the same contract so a ready agent can always start its shell tool. -#[cfg(windows)] -pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[ - "PATH", - "BUZZ_SHELL", - "GIT_BASH", - "SystemRoot", - "ProgramFiles", - "ProgramFiles(x86)", - "LOCALAPPDATA", -]; - use std::collections::HashMap; -use std::path::Path; use std::sync::Arc; use serde_json::{json, Value}; -use tokio::io::BufReader; -use tokio::sync::{mpsc, watch, Mutex}; - -use crate::agent::RunCtx; -use crate::config::{Config, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; -use crate::hints::SkillEntry; -use crate::llm::Llm; -use crate::mcp::McpRegistry; -use crate::types::{ContentBlock, HistoryItem}; -use crate::wire::{ +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform}; +use goose::config::PermissionManager; +use goose::session::session_manager::{SessionManager, SessionType}; + +use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; +use types::McpServerStdio; +use wire::{ classify, goose_session_update, Inbound, InitializeParams, SessionCancelParams, - SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireMsg, - WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR, + SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireSender, + INVALID_PARAMS, METHOD_NOT_FOUND, }; -struct App { - cfg: Config, - llm: Arc, - sessions: Mutex>, - /// Cached model catalog for Databricks providers. Populated lazily on the - /// first successful `session/new` discovery call. When discovery fails (e.g. - /// auth missing or a transient network error) the cell is intentionally left - /// empty so the next `session/new` call retries — a transient failure never - /// pins the degraded fallback catalog for the process lifetime. - models_cache: tokio::sync::OnceCell>, -} - +/// One live ACP session, wrapping a Goose `Agent`. struct Session { - id: String, - mcp: Arc, - /// Skills discovered at session creation; used by the built-in `load_skill` tool. - skills: Vec, - history: Vec, - cancel_tx: watch::Sender, + agent: Arc, + /// Goose's own session id (the SQLite-backed conversation). + goose_session_id: String, busy: bool, - /// Run id of the in-flight prompt, set when a prompt starts and cleared - /// when it ends. `None` means no active run — a steer request targeting - /// this session is rejected. Steer-capable clients learn this value from - /// the `params.update._meta.goose.activeRunId` field on `session/update`. + /// Set for the duration of a turn; advertised to steer-capable clients. active_run_id: Option, - /// Sender for mid-turn steer messages. Created fresh per prompt (like - /// `cancel_tx`); the running prompt loop holds the matching receiver and - /// drains queued steers at round boundaries. `None` when no prompt is in - /// flight. - steer_tx: Option>>, - original_task: Option, - handoff_count: usize, - /// Cache-summed input tokens the provider reported for this session's most - /// recent request, or `None` before the first response (or after a handoff - /// resets the context). Drives the token-based handoff gate; see - /// [`RunCtx::should_handoff`]. - last_request_input_tokens: Option, - /// History byte size when `last_request_input_tokens` was measured, paired - /// with it so the gate can account for history appended since. - last_request_history_bytes: Option, - effective_system_prompt: Arc, - /// Per-session model override set by `session/set_model`. When `Some`, - /// overrides `App::cfg.model` for all LLM calls on this session. Persists - /// across `session/prompt` calls until changed. - effective_model: Option, - /// Session-cumulative input tokens across all turns. Sent in the - /// `_goose/unstable/session/update` usage notification so buzz-acp's - /// `UsageTracker` can compute per-turn deltas symmetrically with goose. + cancel: Option, + /// Model id currently in force for this session (`session/set_model`). + /// Reported back on `usage_update`. + model_override: Option, + /// Set by `session/set_model`, consumed by the next `session/prompt`. + /// Applying it at prompt time rather than immediately matches buzz-agent: + /// the override takes effect "from the next prompt" and never mutates a + /// turn already in flight (`buzz-agent/src/lib.rs:494-502`). + pending_model: Option, + /// Name of the MCP extension carrying `_Stop`/`_PostCompact`, if any. + /// See [`crate::hooks`] for why we dispatch these ourselves. + hook_extension: Option, accumulated_input_tokens: u64, - /// Session-cumulative output tokens across all turns. accumulated_output_tokens: u64, } -fn die(msg: String) -> ! { - tracing::error!("{msg}"); - std::process::exit(2); +pub struct App { + cfg: Config, + sessions: Mutex>, } -pub fn run() -> Result<(), Box> { - let args: Vec = std::env::args().collect(); - if matches!(args.get(1).map(String::as_str), Some("auth")) { - return tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()? - .block_on(auth_subcommand(&args[2..])); +/// Build a Goose agent for one ACP session. +/// +/// Note `Agent::with_config` loads **zero** extensions — a tool-free agent is +/// the default, not something we switch off (goose `agent.rs:362-420`). Tools +/// arrive only via the `mcpServers` the harness declares in `session/new`, +/// which is how `buzz-dev-mcp` (shell/read_file/str_replace/todo + the +/// `_Stop`/`_PostCompact` hooks + the `buzz`/`rg`/`tree` PATH shim) is wired. +async fn build_agent( + cfg: &Config, + cwd: &str, + system_prompt: Option<&str>, + mcp_servers: &[McpServerStdio], +) -> Result<(Arc, String, Option), AgentError> { + let session_manager = Arc::new(SessionManager::instance()); + let permission_manager = PermissionManager::instance(); + + let agent = Agent::with_config(AgentConfig::new( + session_manager.clone(), + permission_manager, + None, + cfg.goose_mode, + // Session naming is a Goose UX affordance; the harness names sessions. + true, + GoosePlatform::GooseCli, + )); + + let session = session_manager + .create_session( + std::path::PathBuf::from(cwd), + "buzz-agent".to_string(), + SessionType::Acp, + cfg.goose_mode, + ) + .await + .map_err(|e| AgentError::Llm(format!("session create: {e}")))?; + + // Provider. `Agent::with_config` starts with no provider set, so this is + // mandatory before the first `reply()` — otherwise the turn fails with + // "Provider not set". + // + // Provider/model names come from the `GOOSE_*` variables `Config::from_env` + // projected out of the `BUZZ_AGENT_*` ones; goose's registry owns base-url + // and credential resolution from there. + let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let model_name = cfg + .model + .clone() + .or_else(|| std::env::var("GOOSE_MODEL").ok()) + .ok_or_else(|| AgentError::Llm("no model configured".into()))?; + + let provider = goose::providers::create(&provider_name, Vec::new()) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + let model_config = + goose::model_config::model_config_from_user_config(&provider_name, &model_name) + .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; + + agent + .update_provider(provider, model_config, &session.id) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + + // Persona. This is the seam that does NOT work over plain ACP: goose's own + // ACP server never reads `systemPrompt` (rg finds zero hits in + // goose/crates/goose/src), and both PRs that would have wired it — + // buzz#1290 and goose#9971 — are closed unmerged. Driving the library + // directly is what makes Fizz (and `[Base]`) actually arrive. + if let Some(sp) = system_prompt.or(cfg.system_prompt.as_deref()) { + if !sp.trim().is_empty() { + agent.override_system_prompt(sp.to_string()).await; + } } - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()? - .block_on(async_main()); - Ok(()) -} -/// `buzz-agent auth ` — run the interactive auth flow for a -/// provider and persist the result, then exit. Today this supports Databricks -/// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the -/// machine. -async fn auth_subcommand(args: &[String]) -> Result<(), Box> { - let provider = args.first().map(String::as_str); - match provider { - Some("databricks" | "databricks_v2" | "databricks-v2") => { - let host = std::env::var("DATABRICKS_HOST") - .map_err(|_| "auth databricks: DATABRICKS_HOST required")?; - let pkce = auth::PkceOAuthConfig { - discovery_url: format!( - "{}/oidc/.well-known/oauth-authorization-server", - host.trim_end_matches('/') - ), - client_id: "databricks-cli".into(), - scopes: vec!["all-apis".into(), "offline_access".into()], - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - let src = auth::PkceOAuthTokenSource::new(pkce)?; - src.interactive_login().await?; - eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); - Ok(()) + for server in mcp_servers { + let envs: HashMap = server + .env + .iter() + .map(|e| (e.name.clone(), e.value.clone())) + .collect(); + + let ext = ExtensionConfig::Stdio { + name: server.name.clone(), + description: String::new(), + cmd: server.command.clone(), + args: server.args.clone(), + envs: goose::agents::extension::Envs::new(envs), + env_keys: Vec::new(), + timeout: Some(300), + cwd: Some(cwd.to_string()), + bundled: None, + available_tools: Vec::new(), + }; + + if let Err(e) = agent.add_extension(ext, &session.id).await { + // Match buzz-agent: a failed MCP server is a hard session error, + // not a silently tool-less agent. + return Err(AgentError::Mcp(format!("{}: {e}", server.name))); + } + } + + let agent = Arc::new(agent); + + // Find the extension carrying `_Stop`, by asking rather than assuming the + // name — `buzz-acp` derives it from the MCP binary's file stem + // (`buzz-acp/src/lib.rs:4145-4149`), so it is not a fixed string. + // + // KNOWN DEVIATION: buzz-agent hid `_`-prefixed tools from the model + // (`agent.rs:328-336`) while still calling them itself. Goose's + // `available_tools` allowlist gates advertising *and* dispatch through the + // same cache (`extension_manager.rs:1421`, `:1698`), so hiding them would + // also make them undispatchable — which would break the veto. They stay + // visible; `hook_tool_guidance()` tells the model to leave them alone. + let mut hook_extension = None; + for tool in agent.list_tools(&session.id, None).await { + if tool.name.ends_with("___Stop") { + hook_extension = tool + .name + .strip_suffix("___Stop") + .map(|prefix| prefix.to_string()); + break; } - Some(other) => Err(format!("auth: unknown provider {other:?}").into()), - None => Err("auth: provider required (try: buzz-agent auth databricks)".into()), } + + if let Some(ext) = &hook_extension { + agent + .extend_system_prompt("buzz_hook_tools".to_string(), hook_tool_guidance()) + .await; + tracing::info!(extension = %ext, "lifecycle hooks available"); + } + + Ok((agent, session.id, hook_extension)) } -async fn async_main() { - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_ansi(false) - .init(); - let cfg = Config::from_env().unwrap_or_else(|e| die(e)); - let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string()))); - let max_line = cfg.max_line_bytes; +/// Keep the model's hands off the lifecycle hooks. +/// +/// They are ordinary MCP tools on the wire, so a generic harness advertises +/// them. buzz-agent solved this by hiding them; we cannot (see `build_agent`), +/// so we ask instead. +fn hook_tool_guidance() -> String { + "Tools whose names begin with an underscore (`_Stop`, `_PostCompact`) are \ + lifecycle hooks invoked automatically by the runtime. Never call them \ + yourself. Use the `todo` tool to manage your task list." + .to_string() +} + +pub async fn serve(cfg: Config) -> anyhow::Result<()> { + let (wire_tx, wire_rx) = tokio::sync::mpsc::channel(256); + tokio::spawn(wire::writer_task(wire_rx)); + let app = Arc::new(App { cfg, - llm, sessions: Mutex::new(HashMap::new()), - models_cache: tokio::sync::OnceCell::new(), }); - let (wire_tx, wire_rx) = mpsc::channel::(64); - let writer = tokio::spawn(wire::writer_task(wire_rx)); - if let Err(e) = read_loop( - BufReader::new(tokio::io::stdin()), - app.clone(), - wire_tx, - max_line, - ) - .await - { - tracing::error!("io: reader: {e}"); - } - for session in app.sessions.lock().await.values() { - let _ = session.cancel_tx.send(true); - } - let _ = writer.await; -} -async fn read_loop( - mut stdin: R, - app: Arc, - wire_tx: WireSender, - max_line: usize, -) -> std::io::Result<()> { - while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? { - if line.trim().is_empty() { - continue; - } - match serde_json::from_str::(&line) { - Ok(msg) => dispatch(&app, msg, &wire_tx).await, + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + loop { + match wire::read_bounded_line(&mut stdin, config::MAX_LINE_BYTES).await { + Ok(None) => break, + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(msg) => dispatch(&app, msg, &wire_tx).await, + Err(e) => { + wire::send( + &wire_tx, + wire::err( + Value::Null, + wire::PARSE_ERROR, + &format!("jsonrpc: parse: {e}"), + ), + ) + .await; + } + } + } Err(e) => { - wire::send( - &wire_tx, - wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")), - ) - .await; + tracing::error!("io: {e}"); + break; } } } @@ -213,7 +270,11 @@ async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { Inbound::Request { id, method, params } => { handle_request(app, id, method, params, wire_tx).await } - Inbound::Notification { method, params } => handle_notification(app, &method, params).await, + Inbound::Notification { method, params } => { + if method == "session/cancel" { + cancel_session(app, params).await; + } + } Inbound::Ignored => {} Inbound::Invalid { id, code, message } => { wire::send(wire_tx, wire::err(id, code, &message)).await @@ -233,23 +294,21 @@ async fn handle_request( "session/new" => { let app = app.clone(); let wire_tx = wire_tx.clone(); + // Spawned so a slow MCP init can't block the read loop — otherwise + // `session/cancel` can't be received. tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await }); } - "session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()), - "session/set_model" => { - set_model_session(app, id, params, wire_tx).await; + "session/prompt" => { + let app = app.clone(); + let wire_tx = wire_tx.clone(); + tokio::spawn(async move { session_prompt(&app, id, params, &wire_tx).await }); } + "session/set_model" => set_model(app, id, params, wire_tx).await, "session/cancel" => { cancel_session(app, params).await; wire::send(wire_tx, wire::ok(id, Value::Null)).await; } - // goose-compatible non-standard extension: inject user input into the - // currently active prompt without starting a new one. Mirrors goose's - // `_goose/unstable/session/steer` wire contract so a single client-side - // delivery path serves both agents. - "_goose/unstable/session/steer" => { - steer_session(app, id, params, wire_tx).await; - } + "_goose/unstable/session/steer" => steer(app, id, params, wire_tx).await, _ => { wire::send( wire_tx, @@ -264,35 +323,35 @@ async fn handle_request( } } -async fn handle_notification(app: &Arc, method: &str, params: Value) { - if method == "session/cancel" { - cancel_session(app, params).await; - } -} - async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { - let p: InitializeParams = match decode(params, "initialize") { + let p: InitializeParams = match serde_json::from_value(params) { Ok(p) => p, - Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("initialize: {e}")), + ) + .await + } }; - // Honest negotiation: respond with the minimum of what the client - // requested and what we support. - // NOTE: gating `[Base]` injection on `protocol_version < 2` is a deliberate - // temporary measure — we are squatting on ACP v2 ahead of the upstream ACP - // RFD. Revisit when that RFD merges; otherwise a genuine upstream-v2 agent - // would silently lose `[Base]`. - let negotiated_version = p.protocol_version.min(PROTOCOL_VERSION); + // Honest negotiation: min(client, ours). Buzz squats on v2 ahead of the + // upstream ACP RFD (#1237); see buzz-agent/src/lib.rs:279-283. + let negotiated = p.protocol_version.min(PROTOCOL_VERSION); wire::send( wire_tx, wire::ok( id, json!({ - "protocolVersion": negotiated_version, + "protocolVersion": negotiated, "agentCapabilities": { "loadSession": false, "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, "mcpCapabilities": { "http": false, "sse": false }, }, + // Identity is unchanged on purpose: this is still buzz-agent. + // The `harness` field of the encrypted kind-44200 turn metric + // derives from this (`buzz-acp/src/pool.rs:3350`), so changing + // it would blank any dashboard filtering on it. "agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") }, }), ), @@ -300,661 +359,496 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { .await; } -/// Resolve the Databricks model catalog for one `session/new` call. -/// -/// Tries to use a previously-cached successful discovery result. If the cache is empty, -/// runs `discover` and — on success — populates the cache for future calls. On failure -/// the cell is intentionally left empty so the next session retries; the provider-aware -/// fallback is returned for the immediate response only. -/// -/// Extracted from `session_new` so that tests can drive this path with an injected -/// discovery future without requiring a full `App` / transport stack. -async fn resolve_models_catalog( - cache: &tokio::sync::OnceCell>, - provider: crate::config::Provider, - model: &str, - discover: impl std::future::Future, AgentError>>, -) -> Vec { - match cache.get_or_try_init(|| discover).await { - Ok(cached) => cached.clone(), - Err(e) => { - tracing::warn!( - "model catalog discovery failed: {e}; using fallback (will retry next session)" - ); - crate::catalog::discovery_failure_fallback(provider, model) - } - } -} - async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionNewParams = match decode(params, "session/new") { + let p: SessionNewParams = match serde_json::from_value(params) { Ok(p) => p, - Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("session/new: {e}")), + ) + .await + } }; - if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() { - return reject( + + if !std::path::Path::new(&p.cwd).is_absolute() { + return wire::send( wire_tx, - id, - INVALID_PARAMS, - "session/new: cwd must be an absolute path", + wire::err(id, INVALID_PARAMS, "session/new: cwd must be absolute"), ) .await; } - // Check cap without holding lock across MCP spawn (which may be slow). - { - let sessions = app.sessions.lock().await; - if sessions.len() >= app.cfg.max_sessions { - return reject( + + if let Some(sp) = &p.system_prompt { + if sp.len() > MAX_SYSTEM_PROMPT_BYTES { + return wire::send( wire_tx, - id, - INVALID_PARAMS, - "session/new: max sessions reached", + wire::err(id, INVALID_PARAMS, "session/new: systemPrompt too large"), ) .await; } } - let (hints_text, skills) = if app.cfg.hints_enabled { - hints::build_hints_section(std::path::Path::new(&p.cwd)) - } else { - (String::new(), Vec::new()) - }; - let effective_system_prompt: Arc = { - // When the harness provides a systemPrompt (base_prompt + persona), use - // it as the primary content and suppress the default. The default is only - // a fallback for legacy harnesses that don't send systemPrompt. - let base = match p.system_prompt.as_deref() { - Some(client_prompt) if !client_prompt.trim().is_empty() => client_prompt.to_owned(), - _ => app.cfg.system_prompt.clone(), - }; - let prompt = if hints_text.is_empty() { - base - } else { - format!("{base}\n\n{hints_text}") - }; - // Reject combined prompts exceeding 512KB. - if prompt.len() > MAX_SYSTEM_PROMPT_BYTES { - return reject( - wire_tx, - id, - INVALID_PARAMS, - &format!( - "session/new: combined system prompt exceeds {}KB limit ({} bytes)", - MAX_SYSTEM_PROMPT_BYTES / 1024, - prompt.len() - ), - ) - .await; - } - Arc::from(prompt) - }; - let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { - Ok(m) => Arc::new(m), - Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, - }; - let session_id = match session_token() { - Ok(t) => format!("ses_{t}"), - Err(e) => return reject(wire_tx, id, -32000, &e).await, - }; - let (cancel_tx, _) = watch::channel(false); - let mut sessions = app.sessions.lock().await; - // Re-check cap (another session may have been created while we spawned MCP). - if sessions.len() >= app.cfg.max_sessions { - return reject( + + if app.sessions.lock().await.len() >= app.cfg.max_sessions { + return wire::send( wire_tx, - id, - INVALID_PARAMS, - "session/new: max sessions reached", + wire::err(id, INVALID_PARAMS, "session/new: max sessions reached"), ) .await; } - sessions.insert( - session_id.clone(), + + let (agent, goose_session_id, hook_extension) = + match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { + Ok(v) => v, + Err(e) => { + return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await + } + }; + + let sid = format!("ses_{}", goose_session_id); + + // Keep a handle for catalog discovery below; the Session takes ownership. + let session_agent = agent.clone(); + let current_model = app + .cfg + .model + .clone() + .or_else(|| std::env::var("GOOSE_MODEL").ok()) + .unwrap_or_default(); + + app.sessions.lock().await.insert( + sid.clone(), Session { - id: session_id.clone(), - mcp, - skills, - history: Vec::new(), - cancel_tx, + agent, + goose_session_id, busy: false, active_run_id: None, - steer_tx: None, - original_task: None, - handoff_count: 0, - last_request_input_tokens: None, - last_request_history_bytes: None, - effective_system_prompt, - effective_model: None, + cancel: None, + model_override: None, + pending_model: None, + hook_extension, accumulated_input_tokens: 0, accumulated_output_tokens: 0, }, ); - drop(sessions); - // Build a models catalog for the `session/new` response. For Databricks - // providers this advertises available models so the desktop ModelPicker and - // pool can resolve `session/set_model` switches. For Anthropic/OpenAI we - // report only the configured model — live switching on those providers - // effectively requires respawn. + // Advertise the model catalog. `buzz-acp` reads `models.availableModels` + // off this response (`acp.rs:1866`, `:1900`) to drive the desktop + // ModelPicker and to resolve `session/set_model` targets + // (`resolve_model_switch_method`, `acp.rs:1876`). Omitting it degrades the + // picker to "current model only" — which is what buzz-agent's `catalog.rs` + // existed to prevent. // - // `models_cache` caches only a successful discovery result (`get_or_try_init` - // leaves the cell empty on error so the next `session/new` call retries). On - // discovery failure the fallback is used for the immediate response without - // being written to the cell. - let available_models: Vec = { - use crate::config::Provider; - match app.cfg.provider { - Provider::Databricks | Provider::DatabricksV2 => { - let models = resolve_models_catalog( - &app.models_cache, - app.cfg.provider, - &app.cfg.model, - discover_databricks_models(&app.cfg), - ) - .await; - models - .iter() - .map(|m| json!({ "modelId": m.id, "name": m.name })) - .collect() - } - _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], - } - }; - - wire::send( - wire_tx, - wire::ok( - id, - json!({ - "sessionId": session_id, - "models": { - "currentModelId": app.cfg.model, - "availableModels": available_models, - }, - }), - ), - ) - .await; -} - -fn decode(params: Value, stage: &str) -> Result { - serde_json::from_value(params).map_err(|e| format!("{stage}: {e}")) -} - -async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) { - wire::send(wire_tx, wire::err(id, code, message)).await; -} - -async fn cancel_session(app: &Arc, params: Value) { - if let Ok(p) = serde_json::from_value::(params) { - if let Some(s) = app.sessions.lock().await.get(&p.session_id) { - let _ = s.cancel_tx.send(true); - } + // Goose builds the same structure internally (`build_model_state`, + // acp/response_builder.rs:130) but it is `pub(super)`, so an embedder + // cannot call it. The underlying data is public, though: + // `Provider::fetch_supported_models` (goose-provider-types/base.rs:425). + let mut result = json!({ "sessionId": sid }); + if let Some(models) = discover_models(&session_agent, ¤t_model).await { + result["models"] = models; } + + wire::send(wire_tx, wire::ok(id, result)).await; } -/// Handle `session/set_model`: apply a per-session model override immediately. +/// Build the `{currentModelId, availableModels}` object for `session/new`. /// -/// Validation: -/// - Unknown `sessionId` → `invalid_params`. -/// - Empty `modelId` → `invalid_params`. +/// Mirrors goose's own `build_model_state`, including its rule that the +/// current model is prepended when the provider's list omits it — otherwise +/// `buzz-acp` cannot resolve a switch back to it. /// -/// On success: stores `model_id` on the session and responds `{ sessionId, modelId }`. -/// The override is picked up by the next `session/prompt` call on this session. -async fn set_model_session(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionSetModelParams = match decode(params, "session/set_model") { - Ok(p) => p, - Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, - }; - if p.model_id.trim().is_empty() { - return reject( - wire_tx, - id, - INVALID_PARAMS, - "session/set_model: modelId must not be empty", - ) - .await; +/// Returns `None` when the provider cannot enumerate models (many can't; +/// `fetch_supported_models` defaults to an empty list). A missing catalog is +/// degraded UX, never a session failure — buzz-agent's Databricks discovery +/// made the same choice (`catalog.rs:52-80`). +async fn discover_models(agent: &Arc, current_model: &str) -> Option { + let provider = agent.provider().await.ok()?; + let ids = provider.fetch_supported_models().await.ok()?; + + let mut available: Vec = ids + .iter() + .map(|id| json!({ "modelId": id, "name": id })) + .collect(); + + if !ids.iter().any(|id| id == current_model) { + available.insert( + 0, + json!({ "modelId": current_model, "name": current_model }), + ); } - let mut sessions = app.sessions.lock().await; - let Some(s) = sessions.get_mut(&p.session_id) else { - return reject( - wire_tx, - id, - INVALID_PARAMS, - "session/set_model: unknown session", - ) - .await; - }; - s.effective_model = Some(p.model_id.clone()); - tracing::info!( - session_id = %p.session_id, - model_id = %p.model_id, - "session/set_model: model overridden" - ); - drop(sessions); - wire::send( - wire_tx, - wire::ok( - id, - json!({ "sessionId": p.session_id, "modelId": p.model_id }), - ), - ) - .await; + + if available.is_empty() { + return None; + } + + Some(json!({ + "currentModelId": current_model, + "availableModels": available, + })) } -/// Handle `_goose/unstable/session/steer`: queue user input into the in-flight -/// prompt. Validation mirrors goose's `on_steer_session`: -/// - empty prompt → `invalid_params` -/// - no active run (no prompt in flight) → `invalid_params` -/// - `expectedRunId` mismatch → `invalid_params` (caller is steering a turn -/// that already ended or rotated; it must fall back to cancel+merge) -/// -/// On success the message is queued for pickup at the next round boundary and -/// we reply `{ runId, messageId }`, then emit a `queuedSteer` session/update so -/// the client can correlate the accepted steer with its eventual pickup. -async fn steer_session(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { - let p: SessionSteerParams = match decode(params, "_goose/unstable/session/steer") { +async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionPromptParams = match serde_json::from_value(params) { Ok(p) => p, - Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("session/prompt: {e}")), + ) + .await + } }; - if p.prompt.is_empty() { - return reject( - wire_tx, - id, - INVALID_PARAMS, - "steer: prompt must not be empty", - ) - .await; - } - if p.expected_run_id.is_empty() { - return reject( + + let prompt_bytes: usize = p + .prompt + .iter() + .map(|b| match b { + types::ContentBlock::Text { text } => text.len(), + types::ContentBlock::ResourceLink { uri } => uri.len(), + types::ContentBlock::Unsupported => 0, + }) + .sum(); + if prompt_bytes > MAX_PROMPT_BYTES { + return wire::send( wire_tx, - id, - INVALID_PARAMS, - "steer: expectedRunId must not be empty", + wire::err(id, INVALID_PARAMS, "session/prompt: prompt too large"), ) .await; } - let message_id = format!("steer_{}", session_token().unwrap_or_else(|_| "x".into())); - let run_id = { - let sessions = app.sessions.lock().await; - let Some(s) = sessions.get(&p.session_id) else { - return reject(wire_tx, id, INVALID_PARAMS, "steer: unknown session").await; - }; - let Some(active) = s.active_run_id.as_deref() else { - return reject(wire_tx, id, INVALID_PARAMS, "steer: no active run to steer").await; + + let run_id = format!("run_{}", uuid_like()); + let cancel = CancellationToken::new(); + + // Single-flight per session, and capture the agent handle. + let (agent, goose_session_id, pending_model, hook_extension) = { + let mut sessions = app.sessions.lock().await; + let Some(s) = sessions.get_mut(&p.session_id) else { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/prompt: unknown session"), + ) + .await; }; - if active != p.expected_run_id { - return reject( + if s.busy { + return wire::send( wire_tx, - id, - INVALID_PARAMS, - &format!( - "steer: expected active run id `{}` but found `{active}`", - p.expected_run_id + wire::err( + id, + INVALID_PARAMS, + "session/prompt: prompt already in flight", ), ) .await; } - // A live run always has a steer_tx; if the channel is gone the run is - // tearing down — treat as no active run rather than queue into the void. - match &s.steer_tx { - Some(tx) if tx.send(p.prompt).is_ok() => active.to_owned(), - _ => return reject(wire_tx, id, INVALID_PARAMS, "steer: no active run to steer").await, - } + s.busy = true; + s.active_run_id = Some(run_id.clone()); + s.cancel = Some(cancel.clone()); + // Take the pending override so a `session/set_model` applies exactly + // once, from the next prompt onward (buzz-agent `lib.rs:494-502`). + ( + s.agent.clone(), + s.goose_session_id.clone(), + s.pending_model.take(), + s.hook_extension.clone(), + ) }; - wire::send( - wire_tx, - wire::ok(id, json!({ "runId": run_id, "messageId": message_id })), - ) - .await; - // Best-effort correlation hint for the client; mirrors goose's - // `send_queued_steer_update`. Not load-bearing for delivery. + + // Apply a pending `session/set_model` before the turn starts. + if let Some(model_id) = pending_model { + if let Err(e) = apply_model(&agent, &goose_session_id, &model_id).await { + { + let mut sessions = app.sessions.lock().await; + if let Some(s) = sessions.get_mut(&p.session_id) { + s.busy = false; + s.active_run_id = None; + s.cancel = None; + } + } + return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await; + } + } + + // Advertise the run id so steer-capable clients can target this turn. + // `_meta` nests INSIDE `update` — see the module docs. wire::send( wire_tx, wire::session_update_with_goose_meta( &p.session_id, json!({ "sessionUpdate": "session_info_update" }), - json!({ "queuedSteer": { "messageId": message_id, "runId": run_id } }), + json!({ "activeRunId": run_id }), ), ) .await; -} -fn spawn_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { - tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await }); -} + let (result, tokens) = agent::run_turn( + agent, + &goose_session_id, + p.prompt, + app.cfg.max_rounds, + wire_tx, + cancel, + hook_extension.as_deref(), + ) + .await; -async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { - let p: SessionPromptParams = match decode(params, "session/prompt") { - Ok(p) => p, - Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await, - }; - let ( - sid, - mcp, - skills, - mut history, - mut original_task, - mut handoff_count, - mut last_request_input_tokens, - mut last_request_history_bytes, - mut cancel_rx, - effective_system_prompt, - effective_model_override, - run_id, - mut steer_rx, - ) = match acquire_session(&app, &p.session_id).await { - Ok(v) => v, - Err(reason) => { - return reject( - &wire_tx, - id, - INVALID_PARAMS, - &format!("session/prompt: {reason}"), - ) - .await - } + // Clear run state so a late steer can't queue into a finished turn. + let accumulated = { + let mut sessions = app.sessions.lock().await; + sessions.get_mut(&p.session_id).map(|s| { + s.busy = false; + s.active_run_id = None; + s.cancel = None; + s.accumulated_input_tokens = s + .accumulated_input_tokens + .saturating_add(tokens.input.unwrap_or(0)); + s.accumulated_output_tokens = s + .accumulated_output_tokens + .saturating_add(tokens.output.unwrap_or(0)); + (s.accumulated_input_tokens, s.accumulated_output_tokens) + }) }; - // Advertise the active run id so steer-capable clients can target this turn - // via `expectedRunId`. Mirrors goose's `send_active_run_update`. + wire::send( - &wire_tx, + wire_tx, wire::session_update_with_goose_meta( - &sid, + &p.session_id, json!({ "sessionUpdate": "session_info_update" }), - json!({ "activeRunId": run_id }), + json!({ "activeRunId": Value::Null }), ), ) .await; - // Resolve effective model: session override wins over config default. - let effective_model_str = effective_model_override - .as_deref() - .unwrap_or(&app.cfg.model); - let mut turn_input_tokens: Option = None; - let mut turn_output_tokens: Option = None; - let mut ctx = RunCtx { - cfg: &app.cfg, - effective_model: effective_model_str, - session_id: &sid, - system_prompt: &effective_system_prompt, - llm: &app.llm, - mcp: &mcp, - skills: &skills, - wire: &wire_tx, - cancel: &mut cancel_rx, - steer: &mut steer_rx, - history: &mut history, - original_task: &mut original_task, - handoff_count: &mut handoff_count, - last_request_input_tokens: &mut last_request_input_tokens, - last_request_history_bytes: &mut last_request_history_bytes, - turn_input_tokens: &mut turn_input_tokens, - turn_output_tokens: &mut turn_output_tokens, - }; - let result = ctx.run(p.prompt).await; - if let Some(s) = app.sessions.lock().await.get_mut(&sid) { - s.busy = false; - // Clear run state so a late steer can't queue into a finished turn. - s.active_run_id = None; - s.steer_tx = None; - s.history = history; - s.original_task = original_task; - s.handoff_count = handoff_count; - s.last_request_input_tokens = last_request_input_tokens; - s.last_request_history_bytes = last_request_history_bytes; - } - // Update session-cumulative token counters and emit the usage notification - // BEFORE sending the session/prompt response. buzz-acp's UsageTracker - // processes the notification while the turn is still in-flight (i.e. before - // the response triggers take_turn_usage()), which is required for the - // begin_turn gate to recognise it as publishable. - // - // Only emit when at least one token count was observed — a turn with no - // provider response (validation failure, pre-response cancellation) carries - // no information and must not produce a kind 44200 record per NIP-AM. - if turn_input_tokens.is_some() || turn_output_tokens.is_some() { - let accumulated = { - let mut sessions = app.sessions.lock().await; - if let Some(s) = sessions.get_mut(&sid) { - s.accumulated_input_tokens = s - .accumulated_input_tokens - .saturating_add(turn_input_tokens.unwrap_or(0)); - s.accumulated_output_tokens = s - .accumulated_output_tokens - .saturating_add(turn_output_tokens.unwrap_or(0)); - Some((s.accumulated_input_tokens, s.accumulated_output_tokens)) - } else { - // Session is gone — the accumulated baseline no longer exists, so - // there is nothing correct to emit. Skip the usage notification. - None - } - }; - if let Some((accumulated_in, accumulated_out)) = accumulated { + + // ORDERING IS LOAD-BEARING: emit usage BEFORE the prompt response. + // buzz-acp's UsageTracker processes this while the turn is still in + // flight; the response triggers take_turn_usage(). Reversing these + // produces zero kind-44200 events, silently. + if tokens.observed() { + if let Some((acc_in, acc_out)) = accumulated { + let model = { + let sessions = app.sessions.lock().await; + sessions + .get(&p.session_id) + .and_then(|s| s.model_override.clone()) + .or_else(|| app.cfg.model.clone()) + .unwrap_or_default() + }; wire::send( - &wire_tx, + wire_tx, goose_session_update( - &sid, + &p.session_id, json!({ "sessionUpdate": "usage_update", - // used: total tokens as a context-usage proxy; - // contextLimit: 0 (buzz-agent has no context limit tracking). - "used": accumulated_in.saturating_add(accumulated_out), + "used": acc_in.saturating_add(acc_out), "contextLimit": 0u64, - "accumulatedInputTokens": accumulated_in, - "accumulatedOutputTokens": accumulated_out, - "model": effective_model_str, + "accumulatedInputTokens": acc_in, + "accumulatedOutputTokens": acc_out, + "model": model, }), ), ) .await; } } + match result { Ok(stop) => { wire::send( - &wire_tx, + wire_tx, wire::ok(id, json!({ "stopReason": stop.as_wire() })), ) .await } - Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await, + Err(e) => wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await, } } -async fn acquire_session( - app: &Arc, - session_id: &str, -) -> Result< - ( - String, - Arc, - Vec, - Vec, - Option, - usize, - Option, - Option, - watch::Receiver, - Arc, - Option, - String, - mpsc::UnboundedReceiver>, - ), - &'static str, -> { +async fn set_model(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionSetModelParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("session/set_model: {e}")), + ) + .await + } + }; + if p.model_id.trim().is_empty() { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/set_model: empty modelId"), + ) + .await; + } let mut sessions = app.sessions.lock().await; - let s = sessions.get_mut(session_id).ok_or("unknown session")?; - if s.busy { - return Err("prompt already in flight"); + match sessions.get_mut(&p.session_id) { + Some(s) => { + s.model_override = Some(p.model_id.clone()); + s.pending_model = Some(p.model_id); + drop(sessions); + wire::send(wire_tx, wire::ok(id, Value::Null)).await; + } + None => { + drop(sessions); + wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/set_model: unknown session"), + ) + .await + } } - s.busy = true; - let (tx, rx) = watch::channel(false); - s.cancel_tx = tx; - // Skills are read-only after session creation; clone the Vec so RunCtx - // can hold a reference without holding the sessions lock. - let skills = s.skills.clone(); - // Fresh run id + steer channel for this turn. The run id lets steer-capable - // clients target *this* turn (rejecting steers aimed at a turn that already - // ended); the channel carries mid-turn injections to the run loop. - let run_id = format!("run_{}", session_token().unwrap_or_else(|_| "x".into())); - s.active_run_id = Some(run_id.clone()); - let (steer_tx, steer_rx) = mpsc::unbounded_channel(); - s.steer_tx = Some(steer_tx); - let effective_model = s.effective_model.clone(); - Ok(( - s.id.clone(), - s.mcp.clone(), - skills, - std::mem::take(&mut s.history), - s.original_task.take(), - s.handoff_count, - s.last_request_input_tokens, - s.last_request_history_bytes, - rx, - Arc::clone(&s.effective_system_prompt), - effective_model, - run_id, - steer_rx, - )) } -fn session_token() -> Result { - let mut b = [0u8; 8]; - getrandom::fill(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?; - Ok(b.iter().map(|x| format!("{x:02x}")).collect()) -} +/// Non-cancelling mid-turn steering. +/// +/// Goose's own `steer()` has exactly buzz-agent's semantics: the message is +/// queued and drained at the *round boundary* (goose `agent.rs:1951-1974`), +/// the turn is not restarted, and a pending steer even prevents the turn from +/// ending (`agent.rs:2876-2878`). +async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionSteerParams = match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, &format!("steer: {e}")), + ) + .await + } + }; -#[cfg(test)] -mod tests { - use crate::catalog::{discovery_failure_fallback, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; - use crate::config::Provider; - use crate::types::AgentError; - - /// Regression: a discovery error must not pin the models_cache for the process lifetime. - /// - /// `resolve_models_catalog` uses `get_or_try_init` so an `Err` leaves the `OnceCell` - /// empty and the next `session/new` retries discovery. This test calls - /// `resolve_models_catalog` directly — the same function `session_new` calls — so - /// reverting `session_new` to `get_or_init` (or any other cache-on-error variant) would - /// break this test, not just the standalone `OnceCell` semantics. - #[tokio::test] - async fn models_cache_does_not_pin_on_discovery_error() { - let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); - let provider = Provider::DatabricksV2; - let model = "my-configured-model"; - - // First call — discovery fails. Cell must remain empty; fallback returned. - let first = crate::resolve_models_catalog(&cache, provider, model, async { - Err::, AgentError>(AgentError::LlmAuth("transient failure".into())) - }) - .await; - assert!( - cache.get().is_none(), - "cell must be empty after a discovery error — next session must retry" - ); - let expected_fallback = discovery_failure_fallback(provider, model); - assert_eq!( - first, expected_fallback, - "error path must return the provider-aware fallback" - ); + let (agent, goose_session_id) = { + let sessions = app.sessions.lock().await; + let Some(s) = sessions.get(&p.session_id) else { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: unknown session"), + ) + .await; + }; + // Optimistic concurrency: the harness distinguishes "no active run" + // from "run id mismatch" to decide whether to fire its cancel+merge + // fallback (`buzz-acp/src/pool.rs:329-366`). + let Some(active) = s.active_run_id.as_deref() else { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: no active run"), + ) + .await; + }; + if active != p.expected_run_id { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: run id mismatch"), + ) + .await; + } + (s.agent.clone(), s.goose_session_id.clone()) + }; - // Second call — discovery succeeds. Cell is now populated and returned. - let discovered = vec![ModelEntry { - id: "databricks-meta-llama-3-1-70b-instruct".into(), - name: "databricks-meta-llama-3-1-70b-instruct".into(), - }]; - let discovered_clone = discovered.clone(); - let second = crate::resolve_models_catalog(&cache, provider, model, async move { - Ok::, AgentError>(discovered_clone) - }) - .await; - assert_eq!( - second, discovered, - "second call must return the discovered catalog" - ); - assert!( - cache.get().is_some(), - "cell must be populated after successful discovery" - ); - assert_eq!( - cache.get().unwrap(), - &discovered, - "cache must hold the successful discovery result" - ); + let text = agent::prompt_to_text(&p.prompt); + if text.trim().is_empty() { + // Best-effort by contract: a whitespace-only steer is dropped, never + // fatal to the turn (buzz-agent `agent.rs:266-278`). + return wire::send(wire_tx, wire::ok(id, json!({ "runId": p.expected_run_id }))).await; } - /// Regression: legacy `Provider::Databricks` must not advertise v2 AI Gateway model IDs - /// on discovery failure (Wes W1). This test calls `discovery_failure_fallback` directly — - /// the same helper used by `session_new` — and verifies the split behavior. It FAILS if - /// the arm is un-split (i.e., if both providers return the v2 catalog on failure). - #[test] - fn databricks_discovery_failure_fallback_legacy_returns_configured_model_only() { - let configured = "my-serving-endpoint"; - let result = discovery_failure_fallback(Provider::Databricks, configured); - - // Legacy Databricks must advertise exactly the configured model — nothing more. - assert_eq!( - result.len(), - 1, - "legacy Databricks fallback must contain exactly one entry, got: {result:?}" - ); - assert_eq!( - result[0].id, configured, - "legacy Databricks fallback must be the configured model" - ); + let message_id = format!("steer_{}", uuid_like()); + agent + .steer( + &goose_session_id, + goose_provider_types::conversation::message::Message::user().with_text(text), + ) + .await; - // Crucially: must NOT contain any DATABRICKS_V2_KNOWN_MODELS entry. - let v2_ids: Vec<&str> = DATABRICKS_V2_KNOWN_MODELS.to_vec(); - for id in &result { - assert!( - !v2_ids.contains(&id.id.as_str()), - "legacy Databricks fallback must not include v2 ID '{}' — that endpoint \ - may not be served by /serving-endpoints/{{model}}/invocations", - id.id - ); + wire::send( + wire_tx, + wire::ok( + id, + json!({ "runId": p.expected_run_id, "messageId": message_id }), + ), + ) + .await; +} + +async fn cancel_session(app: &Arc, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + return; + }; + let sessions = app.sessions.lock().await; + if let Some(s) = sessions.get(&p.session_id) { + if let Some(c) = &s.cancel { + // Goose plumbs this token down into MCP tool calls and sends + // `notifications/cancelled` per in-flight request + // (goose `mcp_client.rs:687-690`) — a cooperative drain rather + // than a hard abort, matching buzz-agent's contract. + c.cancel(); } } +} - #[test] - fn databricks_discovery_failure_fallback_v2_returns_known_models_catalog() { - let configured = "my-configured-model"; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - - // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, - // plus the configured model so the picker can still represent the model - // the agent is actually running. - assert_eq!( - result.len(), - DATABRICKS_V2_KNOWN_MODELS.len() + 1, - "DatabricksV2 fallback must return all known models plus the configured model" - ); - let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - for known_id in DATABRICKS_V2_KNOWN_MODELS { - assert!( - result_ids.contains(known_id), - "DatabricksV2 fallback must include known model '{known_id}'" - ); - } - assert!( - result_ids.contains(&configured), - "DatabricksV2 fallback must include the configured model" - ); +/// Swap the model for an existing session. +/// +/// Rebuilds the provider (base-url/credential resolution lives in goose's +/// registry) and installs it with the new `ModelConfig`. `SharedProvider` is an +/// `Arc>>` precisely so this is hot-swappable +/// (goose `agents/types.rs:11-12`). +async fn apply_model( + agent: &Arc, + session_id: &str, + model_id: &str, +) -> Result<(), AgentError> { + let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let provider = goose::providers::create(&provider_name, Vec::new()) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + let model_config = goose::model_config::model_config_from_user_config(&provider_name, model_id) + .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; + agent + .update_provider(provider, model_config, session_id) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + Ok(()) +} + +/// Preserve buzz-agent's error taxonomy across provider construction, so the +/// harness's JSON-RPC code mapping (-32001 auth, -32002 model-not-found) keeps +/// its meaning. +fn map_provider_error(msg: &str) -> AgentError { + let lower = msg.to_ascii_lowercase(); + if lower.contains("auth") || lower.contains("401") || lower.contains("api key") { + AgentError::LlmAuth(msg.to_string()) + } else if lower.contains("model") && lower.contains("not found") { + AgentError::LlmModelNotFound(msg.to_string()) + } else { + AgentError::Llm(msg.to_string()) } +} - #[test] - fn databricks_discovery_failure_fallback_split_verified() { - // This test FAILS if the v1/v2 arms are merged back into one — it directly verifies - // that the two providers' error-path behavior diverges (Wes W1 protection). - let v1 = discovery_failure_fallback(Provider::Databricks, "my-endpoint"); - let v2 = discovery_failure_fallback(Provider::DatabricksV2, "my-endpoint"); +/// Short random id. Not a UUID; just needs to be unguessable enough that a +/// stale steer can't collide with a live run. +fn uuid_like() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} - let v1_ids: Vec<&str> = v1.iter().map(|m| m.id.as_str()).collect(); - let v2_ids: Vec<&str> = v2.iter().map(|m| m.id.as_str()).collect(); +pub fn run() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); - assert_ne!( - v1_ids, v2_ids, - "Provider::Databricks and Provider::DatabricksV2 must return different \ - fallback catalogs — if they are equal, the W1 arm split has been reverted" - ); - } + let cfg = Config::from_env(); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(serve(cfg))?; + Ok(()) } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs deleted file mode 100644 index 23cef24e72..0000000000 --- a/crates/buzz-agent/src/llm.rs +++ /dev/null @@ -1,3846 +0,0 @@ -use std::collections::BTreeSet; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use reqwest::Client; -use serde_json::{json, Value}; -use tokio::sync::Mutex; -use tokio::time::Instant; - -use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource}; -use crate::config::{ - is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_openai_route, - Config, OpenAiApi, Provider, ThinkingEffort, -}; -use crate::types::{ - AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef, ToolResultContent, -}; - -/// Databricks OAuth client_id — the public Databricks-published CLI client. -/// PKCE-only, no secret. Same identifier goose uses, so a user's browser -/// consent for `databricks-cli` covers buzz-agent too. -const DATABRICKS_CLIENT_ID: &str = "databricks-cli"; -const DATABRICKS_OAUTH_SCOPES: &[&str] = &["all-apis", "offline_access"]; - -const MAX_LLM_RESPONSE_BYTES: usize = 16 * 1024 * 1024; -const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024; -const STALL_NOTICE_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(300); -const MESH_VIRTUAL_MODEL_ID: &str = "mesh"; -const MESH_AUTO_MODEL_ID: &str = "auto"; -const MESH_AUTO_CATALOG_TTL: std::time::Duration = std::time::Duration::from_secs(5); -const MESH_AUTO_CATALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); -const MESH_AUTO_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); -const MESH_AUTO_ENABLE_OBSERVATIONS: u8 = 2; -const MESH_MOA_UNAVAILABLE_MESSAGE: &str = "MoA requires ≥2 models available in the mesh"; - -/// Parser for an OpenAI-family JSON response. Per-endpoint pair lives -/// alongside its `_body` serializer. -type OpenAiParse = fn(Value) -> Result; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MeshCatalogObservation { - Available, - Unavailable, - Unknown, -} - -fn mesh_catalog_supports_collective(catalog: &Value) -> Option { - let models = catalog.get("data").and_then(Value::as_array)?; - let mut has_virtual_mesh = false; - let mut physical_models = BTreeSet::new(); - for id in models - .iter() - .filter_map(|model| model.get("id").and_then(Value::as_str)) - .map(str::trim) - .filter(|id| !id.is_empty()) - { - if id == MESH_VIRTUAL_MODEL_ID { - has_virtual_mesh = true; - } else if id != MESH_AUTO_MODEL_ID { - physical_models.insert(id.replace("@main", "")); - } - } - Some(has_virtual_mesh && physical_models.len() >= 2) -} - -fn looks_like_unstructured_tool_call(text: &str) -> bool { - let text = text.trim_start().to_ascii_lowercase(); - text.starts_with("<|tool_call") - || text.starts_with(", - consecutive_available: u8, - collective_enabled: bool, - cooldown_until: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DatabricksV2Route { - OpenAiResponses, - AnthropicMessages, - MlflowChatCompletions, -} - -pub struct Llm { - http: Client, - /// One-shot sticky flag: set when a Chat Completions request comes - /// back with a "use /v1/responses" provider error while `cfg.openai_api - /// == Auto`. Subsequent OpenAI calls then go straight to Responses - /// for the lifetime of the process. - auto_upgraded: AtomicBool, - /// Hysteretic view of whether mesh-llm currently advertises its virtual - /// Mixture-of-Agents model. A TTL, confirmation count, and failure cooldown - /// let long-running agents adapt without bouncing as peers briefly flap. - mesh_auto_state: Mutex, - /// Bearer-token source for OpenAI-family requests. Static for OpenAI - /// (the `OPENAI_COMPAT_API_KEY` env var) and Databricks-with-token - /// (the `DATABRICKS_TOKEN` env var); a refreshable PKCE engine for - /// Databricks otherwise. Anthropic doesn't use this — it always - /// reads `cfg.api_key` directly because the API expects `x-api-key`. - auth: Arc, -} - -impl Llm { - pub fn new(cfg: &Config) -> Result { - let http = Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .read_timeout(cfg.llm_timeout) - .build() - .map_err(|e| AgentError::Llm(format!("http: {e}")))?; - let auth = build_token_source(cfg)?; - Ok(Self { - http, - auto_upgraded: AtomicBool::new(false), - mesh_auto_state: Mutex::new(MeshAutoState::default()), - auth, - }) - } - - pub async fn complete( - &self, - cfg: &Config, - system_prompt: &str, - history: &[HistoryItem], - tools: &[ToolDef], - effective_model: &str, - ) -> Result { - let effort = cfg.thinking_effort; - let result = match cfg.provider { - Provider::Anthropic => { - let v = self - .post_anthropic( - cfg, - &anthropic_body( - cfg, - system_prompt, - history, - tools, - effective_model, - effort, - ), - ) - .await?; - parse_anthropic(v) - } - Provider::OpenAi | Provider::Databricks => { - self.openai_request( - cfg, - effective_model, - !tools.is_empty(), - |use_responses, request_model| { - // Normalize effort for model-specific availability. Startup no longer rejects - // `max` for pure OpenAI/Databricks; this per-model table is the single authority - // — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped - // models, and still applies corrections like none→minimal on the gpt-5 base. - let e = - effort.map(|ef| normalize_effort_for_openai_route(ef, request_model)); - if use_responses { - ( - responses_body( - cfg, - system_prompt, - history, - tools, - request_model, - e, - ), - parse_responses as OpenAiParse, - ) - } else { - ( - openai_body(cfg, system_prompt, history, tools, request_model, e), - parse_openai as OpenAiParse, - ) - } - }, - ) - .await - } - Provider::DatabricksV2 => { - self.databricks_v2_request(cfg, effective_model, |route| match route { - DatabricksV2Route::OpenAiResponses => { - // OpenAI Responses path: normalize effort against the per-model table. - let e = - effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); - ( - responses_body(cfg, system_prompt, history, tools, effective_model, e), - parse_responses as OpenAiParse, - ) - } - DatabricksV2Route::AnthropicMessages => { - // Anthropic Messages path: normalize effort (none|minimal → omit). - let e = effort.and_then(normalize_effort_for_anthropic_route); - ( - anthropic_body(cfg, system_prompt, history, tools, effective_model, e), - parse_anthropic as OpenAiParse, - ) - } - DatabricksV2Route::MlflowChatCompletions => { - // MLflow Chat path (OpenAI-shaped): normalize effort against the per-model table. - let e = - effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); - ( - openai_body(cfg, system_prompt, history, tools, effective_model, e), - parse_openai as OpenAiParse, - ) - } - }) - .await - } - }; - // Stamp the effective model into Llm errors so log lines carry - // `llm: (model-name) 404 Not Found: …` instead of the bare status. - // The `llm: ` prefix comes from `Display for AgentError::Llm`; the - // map_err here prepends `(model-name) ` to the inner string only. - // This is the single place all provider paths converge, so the mapping - // is centralized and never needs to be repeated in each provider arm. - result.map_err(|e| match e { - AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), - AgentError::LlmModelNotFound(s) => { - AgentError::LlmModelNotFound(format!("({effective_model}) {s}")) - } - other => other, - }) - } - - pub async fn summarize( - &self, - cfg: &Config, - system_prompt: &str, - user_prompt: &str, - max_output_tokens: u32, - effective_model: &str, - ) -> Result { - match cfg.provider { - Provider::Anthropic => { - let body = json!({ - "model": effective_model, - "max_tokens": max_output_tokens, - "system": system_prompt, - "messages": [{ - "role": "user", - "content": [{ "type": "text", "text": user_prompt }], - }], - }); - Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text) - } - Provider::OpenAi | Provider::Databricks => { - let r = self - .openai_request( - cfg, - effective_model, - false, - |use_responses, request_model| { - if use_responses { - ( - json!({ - "model": request_model, - "max_output_tokens": max_output_tokens, - "instructions": system_prompt, - "input": user_prompt, - }), - parse_responses as OpenAiParse, - ) - } else { - ( - json!({ - "model": request_model, - "stream": false, - "max_completion_tokens": max_output_tokens, - "messages": [ - { "role": "system", "content": system_prompt }, - { "role": "user", "content": user_prompt }, - ], - }), - parse_openai as OpenAiParse, - ) - } - }, - ) - .await?; - Ok(r.text) - } - Provider::DatabricksV2 => { - let r = self - .databricks_v2_request(cfg, effective_model, |route| match route { - DatabricksV2Route::OpenAiResponses => ( - json!({ - "model": effective_model, - "max_output_tokens": max_output_tokens, - "instructions": system_prompt, - "input": user_prompt, - }), - parse_responses as OpenAiParse, - ), - DatabricksV2Route::AnthropicMessages => ( - json!({ - "model": effective_model, - "max_tokens": max_output_tokens, - "system": system_prompt, - "messages": [{ - "role": "user", - "content": [{ "type": "text", "text": user_prompt }], - }], - }), - parse_anthropic as OpenAiParse, - ), - DatabricksV2Route::MlflowChatCompletions => ( - json!({ - "model": effective_model, - "stream": false, - "max_completion_tokens": max_output_tokens, - "messages": [ - { "role": "system", "content": system_prompt }, - { "role": "user", "content": user_prompt }, - ], - }), - parse_openai as OpenAiParse, - ), - }) - .await?; - Ok(r.text) - } - } - } - - async fn post_anthropic(&self, cfg: &Config, body: &Value) -> Result { - let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); - post(&self.http, &url, body, false, |r| { - r.header("x-api-key", &cfg.api_key) - .header("anthropic-version", &cfg.anthropic_api_version) - }) - .await - .map_err(PostError::into_agent) - } - - /// OpenAI dispatch with Buzz's relay-mesh `auto` policy layered over the - /// normal endpoint selection. When enabled, a live virtual `mesh` model is - /// preferred; if the mesh contracts between discovery and inference, retry - /// the same request once through the router's ordinary `auto` model. - async fn openai_request( - &self, - cfg: &Config, - effective_model: &str, - tools_supplied: bool, - mut build: F, - ) -> Result - where - F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send, - { - let request_model = self.resolve_openai_model(cfg, effective_model).await; - let adaptive_mesh = - effective_model == MESH_AUTO_MODEL_ID && request_model == MESH_VIRTUAL_MODEL_ID; - - let first = self - .openai_request_for_model(cfg, &request_model, &mut build) - .await; - match first { - Err(PostError::MeshFallback(detail)) if adaptive_mesh => { - self.cool_down_collective().await; - tracing::warn!( - configured_model = effective_model, - attempted_model = MESH_VIRTUAL_MODEL_ID, - fallback_model = MESH_AUTO_MODEL_ID, - provider_message = detail, - "relay-mesh auto: collective request failed; retrying once with auto" - ); - self.openai_request_for_model(cfg, MESH_AUTO_MODEL_ID, &mut build) - .await - .map_err(PostError::into_agent) - } - Ok(response) - if adaptive_mesh - && tools_supplied - && response.tool_calls.is_empty() - && looks_like_unstructured_tool_call(&response.text) => - { - self.cool_down_collective().await; - tracing::warn!( - configured_model = effective_model, - attempted_model = MESH_VIRTUAL_MODEL_ID, - fallback_model = MESH_AUTO_MODEL_ID, - "relay-mesh auto: collective response emitted unstructured tool markup; retrying once with auto" - ); - self.openai_request_for_model(cfg, MESH_AUTO_MODEL_ID, &mut build) - .await - .map_err(PostError::into_agent) - } - Ok(response) => Ok(response), - Err(error) => Err(error.into_agent()), - } - } - - async fn cool_down_collective(&self) { - let now = Instant::now(); - let mut state = self.mesh_auto_state.lock().await; - state.last_checked = Some(now); - state.consecutive_available = 0; - state.collective_enabled = false; - state.cooldown_until = Some(now + MESH_AUTO_COOLDOWN); - } - - /// Resolve the model for one OpenAI-family request. Explicit model choices - /// are never changed. Relay-mesh `auto` dynamically follows the short-lived - /// `/models` catalog so long-running agents can adopt or leave MoA without - /// being restarted. - async fn resolve_openai_model(&self, cfg: &Config, effective_model: &str) -> String { - if cfg.provider != Provider::OpenAi - || !cfg.prefer_mesh_for_auto - || effective_model != MESH_AUTO_MODEL_ID - { - return effective_model.to_string(); - } - - let mut state = self.mesh_auto_state.lock().await; - let now = Instant::now(); - if let Some(cooldown_until) = state.cooldown_until { - if now < cooldown_until { - return MESH_AUTO_MODEL_ID.to_string(); - } - state.cooldown_until = None; - } - if state - .last_checked - .is_some_and(|checked_at| checked_at.elapsed() < MESH_AUTO_CATALOG_TTL) - { - return if state.collective_enabled { - MESH_VIRTUAL_MODEL_ID.to_string() - } else { - MESH_AUTO_MODEL_ID.to_string() - }; - } - - let observation = self.observe_mesh_virtual_model(cfg).await; - let checked_at = Instant::now(); - state.last_checked = Some(checked_at); - match observation { - MeshCatalogObservation::Available => { - state.consecutive_available = state.consecutive_available.saturating_add(1); - if state.consecutive_available >= MESH_AUTO_ENABLE_OBSERVATIONS { - state.collective_enabled = true; - } - } - MeshCatalogObservation::Unavailable => { - if state.collective_enabled { - state.cooldown_until = Some(checked_at + MESH_AUTO_COOLDOWN); - } - state.consecutive_available = 0; - state.collective_enabled = false; - } - // A failed catalog check is not evidence that a previously stable - // mesh disappeared. Preserve the last confirmed state; inference - // itself remains authoritative and has the narrow 503 fallback. - MeshCatalogObservation::Unknown => {} - } - let request_model = if state.collective_enabled { - MESH_VIRTUAL_MODEL_ID - } else { - MESH_AUTO_MODEL_ID - }; - tracing::debug!( - configured_model = effective_model, - request_model, - "relay-mesh auto: resolved request model from live catalog" - ); - request_model.to_string() - } - - async fn observe_mesh_virtual_model(&self, cfg: &Config) -> MeshCatalogObservation { - let url = format!("{}/models", cfg.base_url.trim_end_matches('/')); - let bearer = match self.auth.bearer().await { - Ok(bearer) => bearer, - Err(error) => { - tracing::debug!( - %error, - "relay-mesh auto: catalog auth unavailable; preserving last confirmed route" - ); - return MeshCatalogObservation::Unknown; - } - }; - let response = match self - .http - .get(&url) - .bearer_auth(bearer) - .timeout(MESH_AUTO_CATALOG_TIMEOUT) - .send() - .await - { - Ok(response) => response, - Err(error) => { - tracing::debug!( - %error, - "relay-mesh auto: catalog probe failed; preserving last confirmed route" - ); - return MeshCatalogObservation::Unknown; - } - }; - if !response.status().is_success() { - tracing::debug!( - status = %response.status(), - "relay-mesh auto: catalog probe was not successful; preserving last confirmed route" - ); - return MeshCatalogObservation::Unknown; - } - match response.json::().await { - Ok(catalog) => { - let Some(available) = mesh_catalog_supports_collective(&catalog) else { - tracing::debug!( - "relay-mesh auto: catalog response has no data array; preserving last confirmed route" - ); - return MeshCatalogObservation::Unknown; - }; - if available { - MeshCatalogObservation::Available - } else { - MeshCatalogObservation::Unavailable - } - } - Err(error) => { - tracing::debug!( - %error, - "relay-mesh auto: invalid catalog response; preserving last confirmed route" - ); - MeshCatalogObservation::Unknown - } - } - } - - /// Dispatch one OpenAI request for an already-resolved model. Endpoint - /// selection remains unchanged: pinned > sticky-upgraded > host default, - /// with the existing one-shot Chat-to-Responses upgrade. - async fn openai_request_for_model( - &self, - cfg: &Config, - request_model: &str, - build: &mut F, - ) -> Result - where - F: FnMut(bool, &str) -> (Value, OpenAiParse) + Send, - { - let use_responses = self.auto_upgraded.load(Ordering::Relaxed) - || matches!(cfg.openai_api, OpenAiApi::Responses) - || matches!(cfg.openai_api, OpenAiApi::Auto) && is_openai_host(&cfg.base_url); - - if use_responses { - let (body, parse) = build(true, request_model); - return parse( - self.post_openai(cfg, "/responses", &body, request_model) - .await?, - ) - .map_err(PostError::from); - } - let (body, parse) = build(false, request_model); - match self - .post_openai(cfg, "/chat/completions", &body, request_model) - .await - { - Ok(value) => parse(value).map_err(PostError::from), - Err(PostError::Agent(error)) - if cfg.openai_api == OpenAiApi::Auto && self.try_upgrade(&error) => - { - let (body, parse) = build(true, request_model); - parse( - self.post_openai(cfg, "/responses", &body, request_model) - .await?, - ) - .map_err(PostError::from) - } - Err(error) => Err(error), - } - } - - async fn databricks_v2_request( - &self, - cfg: &Config, - effective_model: &str, - build: F, - ) -> Result - where - F: FnOnce(DatabricksV2Route) -> (Value, OpenAiParse) + Send, - { - let route = databricks_v2_route_for_model(effective_model); - let (body, parse) = build(route); - parse( - self.post_openai(cfg, databricks_v2_path(route), &body, effective_model) - .await - .map_err(PostError::into_agent)?, - ) - } - - /// POST to an OpenAI-family endpoint. For OpenAI-compat this is just - /// `{base_url}{path}` with the body untouched. For Databricks the URL - /// becomes `{base_url}/serving-endpoints/{model}/invocations` and the - /// `model` field is stripped from the body (Databricks rejects it — - /// the endpoint path already names the model). - async fn post_openai( - &self, - cfg: &Config, - path: &str, - body: &Value, - effective_model: &str, - ) -> Result { - let (url, body_owned); - let body_ref: &Value = match cfg.provider { - Provider::Databricks => { - url = format!( - "{}/serving-endpoints/{}/invocations", - cfg.base_url.trim_end_matches('/'), - effective_model - ); - body_owned = strip_model(body); - &body_owned - } - _ => { - url = format!("{}{}", cfg.base_url.trim_end_matches('/'), path); - body - } - }; - - // A 401 or 403 can mean the local expiry clock disagreed with the - // server (skew, revocation, a node that never saw the token). On the - // first such rejection, force a refresh keyed off the rejected bearer - // and retry once. The guard is local to this call so an earlier turn's - // rejection can never suppress a later turn's legitimate retry. Both - // statuses map to `LlmAuth` in `post`: a 403 is indistinguishable from - // an expired-token 403 here, so we refresh once and let it propagate. - let mut bearer = self.auth.bearer().await.map_err(PostError::from)?; - let mut refreshed = false; - loop { - match post( - &self.http, - &url, - body_ref, - effective_model == MESH_VIRTUAL_MODEL_ID, - |r| r.bearer_auth(&bearer), - ) - .await - { - Err(PostError::Agent(AgentError::LlmAuth(_))) if !refreshed => { - refreshed = true; - bearer = self - .auth - .refresh_now(&bearer) - .await - .map_err(PostError::from)?; - } - result => return result, - } - } - } - - /// If `err` names `/v1/responses` / "use the Responses API", latch a - /// sticky upgrade so subsequent OpenAI calls hit Responses. Logged once. - fn try_upgrade(&self, err: &AgentError) -> bool { - let body = match err { - AgentError::Llm(s) => s.as_str(), - _ => return false, // auth/transport aren't "use the other endpoint" signals - }; - if !is_responses_required_error(body) { - return false; - } - if !self.auto_upgraded.swap(true, Ordering::Relaxed) { - tracing::warn!( - provider_message = body, - "openai: provider asked for the Responses API; \ - routing subsequent OpenAI calls to /v1/responses for this process" - ); - } - true - } -} - -fn anthropic_body( - cfg: &Config, - system_prompt: &str, - history: &[HistoryItem], - tools: &[ToolDef], - effective_model: &str, - effort: Option, -) -> Value { - let mut messages: Vec = Vec::new(); - let mut pending: Vec = Vec::new(); - let flush = |out: &mut Vec, p: &mut Vec| { - if !p.is_empty() { - out.push(json!({ "role": "user", "content": std::mem::take(p) })); - } - }; - for item in history { - match item { - HistoryItem::User(text) => { - flush(&mut messages, &mut pending); - messages.push(json!({ "role": "user", - "content": [{ "type": "text", "text": text }] })); - } - HistoryItem::Assistant { text, tool_calls } => { - flush(&mut messages, &mut pending); - let mut content: Vec = Vec::new(); - if !text.is_empty() { - content.push(json!({ "type": "text", "text": text })); - } - for c in tool_calls { - content.push(json!({ "type": "tool_use", "id": c.provider_id, - "name": c.name, "input": c.arguments })); - } - if content.is_empty() { - // Empty assistant turn (no text, no tool calls) — skip it. - // Anthropic rejects empty text blocks, and a placeholder - // just defers the problem. No tool_use = no pairing - // constraint, so omitting is safe. - continue; - } - messages.push(json!({ "role": "assistant", "content": content })); - } - HistoryItem::ToolResult(r) => pending.push(json!({ - "type": "tool_result", "tool_use_id": r.provider_id, - "content": anthropic_tool_result_content(&r.content), "is_error": r.is_error })), - } - } - flush(&mut messages, &mut pending); - let tools_json: Vec = tools - .iter() - .map(|t| { - json!({ - "name": t.name, "description": t.description, "input_schema": t.input_schema }) - }) - .collect(); - let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens, - "system": system_prompt, "messages": messages }); - if let Some(e) = effort { - let (thinking, output_config) = - crate::config::anthropic_thinking_config(effective_model, e, cfg.max_output_tokens); - if let Some(t) = thinking { - body["thinking"] = t; - } - if let Some(oc) = output_config { - body["output_config"] = oc; - } - } - if !tools_json.is_empty() { - body["tools"] = Value::Array(tools_json); - } - body -} - -fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec { - content - .iter() - .map(|c| match c { - ToolResultContent::Text(text) => json!({ "type": "text", "text": text }), - ToolResultContent::Image { data, mime_type } => json!({ - "type": "image", - "source": { "type": "base64", "media_type": mime_type, "data": data }, - }), - }) - .collect() -} - -fn openai_body( - cfg: &Config, - system_prompt: &str, - history: &[HistoryItem], - tools: &[ToolDef], - effective_model: &str, - effort: Option, -) -> Value { - let mut messages: Vec = vec![json!({ "role": "system", "content": system_prompt })]; - // Images returned from tool calls ride on a trailing `role:"user"` - // message because OpenAI Chat's `role:"tool"` content is text-only. We - // batch them across a run of adjacent ToolResult items so that all - // `role:"tool"` messages stay contiguous — splitting them with a user - // turn breaks OpenAI-Chat-compatible frontends that translate back to - // Anthropic `tool_result` (notably Databricks model serving), since - // Anthropic requires every `tool_use` in one assistant turn to be - // answered by a single immediately-following user message. - let mut pending_images: Vec = Vec::new(); - let flush_images = |messages: &mut Vec, pending: &mut Vec| { - if !pending.is_empty() { - messages.push(json!({ "role": "user", "content": std::mem::take(pending) })); - } - }; - for item in history { - match item { - HistoryItem::User(text) => { - flush_images(&mut messages, &mut pending_images); - messages.push(json!({ "role": "user", "content": text })); - } - HistoryItem::Assistant { text, tool_calls } => { - flush_images(&mut messages, &mut pending_images); - let mut msg = serde_json::Map::new(); - msg.insert("role".into(), json!("assistant")); - msg.insert("content".into(), json!(text.as_str())); - if !tool_calls.is_empty() { - let calls: Vec = tool_calls - .iter() - .map(|c| { - json!({ - "id": c.provider_id, "type": "function", - "function": { "name": c.name, - "arguments": serde_json::to_string(&c.arguments) - .unwrap_or_else(|_| "{}".into()) } }) - }) - .collect(); - msg.insert("tool_calls".into(), Value::Array(calls)); - } - messages.push(Value::Object(msg)); - } - HistoryItem::ToolResult(r) => { - messages.push(json!({ - "role": "tool", "tool_call_id": r.provider_id, - "content": openai_tool_text_content(&r.content) })); - pending_images.extend(openai_image_user_content(&r.content)); - } - } - } - flush_images(&mut messages, &mut pending_images); - let tools_json: Vec = tools - .iter() - .map(|t| { - json!({ - "type": "function", - "function": { "name": t.name, "description": t.description, - "parameters": t.input_schema } }) - }) - .collect(); - let mut body = json!({ "model": effective_model, "stream": false, - "max_completion_tokens": cfg.max_output_tokens, "messages": messages }); - if let Some(e) = effort { - body["reasoning_effort"] = json!(e.openai_effort_str()); - } - if !tools_json.is_empty() { - body["tools"] = Value::Array(tools_json); - body["tool_choice"] = json!("auto"); - } - body -} - -fn openai_tool_text_content(content: &[ToolResultContent]) -> String { - let mut parts = Vec::new(); - for c in content { - match c { - ToolResultContent::Text(text) => parts.push(text.clone()), - ToolResultContent::Image { data, mime_type } => parts.push(format!( - "This tool result included an image ({mime_type}, {} base64 bytes) that is provided in the next user message.", - data.len() - )), - } - } - parts.join("\n") -} - -fn openai_image_user_content(content: &[ToolResultContent]) -> Vec { - content - .iter() - .filter_map(|c| match c { - ToolResultContent::Image { data, mime_type } => Some(json!({ - "type": "image_url", - "image_url": { "url": format!("data:{mime_type};base64,{data}") }, - })), - ToolResultContent::Text(_) => None, - }) - .collect() -} - -// Spec: https://platform.openai.com/docs/api-reference/responses -// -// Replay invariant: each assistant `function_call` input item **must** -// precede its matching `function_call_output`, or the API rejects with -// "No tool call found for call_id ...". `HistoryItem` ordering already -// guarantees this. - -fn responses_body( - cfg: &Config, - system_prompt: &str, - history: &[HistoryItem], - tools: &[ToolDef], - effective_model: &str, - effort: Option, -) -> Value { - let mut input: Vec = Vec::with_capacity(history.len()); - for item in history { - match item { - HistoryItem::User(text) => input.push(json!({ - "role": "user", - "content": [{ "type": "input_text", "text": text }], - })), - HistoryItem::Assistant { text, tool_calls } => { - if !text.is_empty() { - input.push(json!({ - "role": "assistant", - "content": [{ "type": "output_text", "text": text }], - })); - } - for c in tool_calls { - input.push(json!({ - "type": "function_call", - "call_id": c.provider_id, - "name": c.name, - "arguments": serde_json::to_string(&c.arguments) - .unwrap_or_else(|_| "{}".into()), - })); - } - } - HistoryItem::ToolResult(r) => { - input.push(json!({ - "type": "function_call_output", - "call_id": r.provider_id, - "output": openai_tool_text_content(&r.content), - })); - // Responses takes images as `input_image` parts on a user message. - let images: Vec = r - .content - .iter() - .filter_map(|c| match c { - ToolResultContent::Image { data, mime_type } => Some(json!({ - "type": "input_image", - "image_url": format!("data:{mime_type};base64,{data}"), - })), - ToolResultContent::Text(_) => None, - }) - .collect(); - if !images.is_empty() { - input.push(json!({ "role": "user", "content": images })); - } - } - } - } - - let tools_json: Vec = tools - .iter() - .map(|t| { - json!({ - "type": "function", - "name": t.name, - "description": t.description, - "parameters": t.input_schema, - }) - }) - .collect(); - - let mut body = json!({ - "model": effective_model, - "instructions": system_prompt, - "max_output_tokens": cfg.max_output_tokens, - "input": input, - }); - if let Some(e) = effort { - body["reasoning"] = json!({ "effort": e.openai_effort_str() }); - } - if !tools_json.is_empty() { - body["tools"] = Value::Array(tools_json); - body["tool_choice"] = json!("auto"); - } - body -} - -/// Narrow matcher for "you should be on the Responses API" provider errors, -/// the signal we use to auto-upgrade. Triggers on the literal path -/// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose -/// "use the Responses API" / "Responses API instead". -fn is_responses_required_error(body: &str) -> bool { - let b = body.to_ascii_lowercase(); - b.contains("/v1/responses") - || b.contains("responses api instead") - || b.contains("use the responses api") -} - -fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route { - // Databricks v2 catalog names currently identify OpenAI-shaped GPT-5 - // models and Anthropic-shaped Claude models by these substrings. - let lower = model.to_ascii_lowercase(); - if lower.contains("gpt-5") || lower.contains("gpt5") { - DatabricksV2Route::OpenAiResponses - } else if lower.contains("claude") { - DatabricksV2Route::AnthropicMessages - } else { - DatabricksV2Route::MlflowChatCompletions - } -} - -fn databricks_v2_path(route: DatabricksV2Route) -> &'static str { - match route { - DatabricksV2Route::OpenAiResponses => "/ai-gateway/openai/v1/responses", - DatabricksV2Route::AnthropicMessages => "/ai-gateway/anthropic/v1/messages", - DatabricksV2Route::MlflowChatCompletions => "/ai-gateway/mlflow/v1/chat/completions", - } -} - -fn parse_responses(v: Value) -> Result { - let mut text = String::new(); - let mut reasoning = String::new(); - let mut tool_calls = Vec::new(); - let mut saw_function_call = false; - - for item in v - .get("output") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - match item.get("type").and_then(Value::as_str) { - Some("message") => { - for p in item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - // Responses emits "output_text"; accept "text" forward-compat. - if matches!( - p.get("type").and_then(Value::as_str), - Some("output_text" | "text") - ) { - if let Some(t) = p.get("text").and_then(Value::as_str) { - text.push_str(t); - } - } - } - } - Some("function_call") => { - saw_function_call = true; - let raw = item - .get("arguments") - .and_then(Value::as_str) - .unwrap_or("{}"); - let args: Value = serde_json::from_str(raw).map_err(|e| { - AgentError::Llm(format!("function_call.arguments not valid JSON: {e}")) - })?; - tool_calls.push(make_tool_call( - str_field(item, "call_id"), - str_field(item, "name"), - args, - )?); - } - Some("reasoning") => { - // Reasoning summary items from the Responses API. Each item has a - // `summary` array of `{"type": "summary_text", "text": "..."}` objects. - for s in item - .get("summary") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - if matches!( - s.get("type").and_then(Value::as_str), - Some("summary_text" | "text") - ) { - if let Some(t) = s.get("text").and_then(Value::as_str) { - if !reasoning.is_empty() { - reasoning.push('\n'); - } - reasoning.push_str(t); - } - } - } - } - // Unknown types ignored for forward-compat. - _ => {} - } - } - - let stop = match v.get("status").and_then(Value::as_str) { - Some("incomplete") => { - let reason = v - .get("incomplete_details") - .and_then(|d| d.get("reason")) - .and_then(Value::as_str); - if reason == Some("max_output_tokens") { - ProviderStop::MaxTokens - } else { - ProviderStop::Other - } - } - Some("completed") if saw_function_call => ProviderStop::ToolUse, - Some("completed") => ProviderStop::EndTurn, - _ => ProviderStop::Other, - }; - let input_tokens = sum_usage(&v, &["input_tokens"]); - let output_tokens = sum_usage(&v, &["output_tokens"]); - Ok(LlmResponse { - text, - tool_calls, - stop, - input_tokens, - output_tokens, - reasoning, - }) -} - -fn map_stop(s: Option<&str>) -> ProviderStop { - match s { - Some("end_turn" | "stop") => ProviderStop::EndTurn, - Some("tool_use" | "tool_calls") => ProviderStop::ToolUse, - Some("max_tokens" | "length") => ProviderStop::MaxTokens, - Some("refusal" | "content_filter") => ProviderStop::Refusal, - _ => ProviderStop::Other, - } -} - -/// Sum a set of `usage` token fields, returning `None` only when the `usage` -/// object is absent or carries none of the requested fields. A field that is -/// present is added; a field that is missing contributes 0. This keeps the -/// result an inclusive total (so cached tokens are never silently dropped) -/// while still distinguishing "no usage reported" from "usage was zero". -fn sum_usage(v: &Value, fields: &[&str]) -> Option { - let usage = v.get("usage")?; - let mut total: u64 = 0; - let mut saw_any = false; - for f in fields { - if let Some(n) = usage.get(*f).and_then(Value::as_u64) { - total = total.saturating_add(n); - saw_any = true; - } - } - saw_any.then_some(total) -} - -/// Input-token total for Anthropic / Databricks (Anthropic-style) responses. -/// `input_tokens` alone EXCLUDES cached tokens, so we sum it with the two -/// cache fields to get the inclusive total the context budget must gate on. -fn anthropic_input_tokens(v: &Value) -> Option { - sum_usage( - v, - &[ - "input_tokens", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ], - ) -} - -/// Input-token total for OpenAI Chat Completions and Databricks responses. -/// OpenAI's `prompt_tokens` is already inclusive. Databricks uses the same -/// `prompt_tokens` wire field but ALSO reports Anthropic-style cache fields -/// alongside it, so we sum them; the cache fields are simply absent (and -/// contribute 0) for vanilla OpenAI. -fn openai_chat_input_tokens(v: &Value) -> Option { - sum_usage( - v, - &[ - "prompt_tokens", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ], - ) -} - -fn str_field(v: &Value, key: &str) -> String { - v.get(key).and_then(Value::as_str).unwrap_or("").to_owned() -} - -fn parse_anthropic(v: Value) -> Result { - let stop = map_stop(v.get("stop_reason").and_then(Value::as_str)); - let mut tool_calls = Vec::new(); - let mut text = String::new(); - let mut reasoning = String::new(); - if let Some(blocks) = v.get("content").and_then(Value::as_array) { - for b in blocks { - match b.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(t) = b.get("text").and_then(Value::as_str) { - text.push_str(t); - } - } - Some("thinking") => { - // Anthropic extended thinking block: `{"type": "thinking", "thinking": "..."}` - if let Some(t) = b.get("thinking").and_then(Value::as_str) { - if !reasoning.is_empty() { - reasoning.push('\n'); - } - reasoning.push_str(t); - } - } - Some("tool_use") => tool_calls.push(make_tool_call( - str_field(b, "id"), - str_field(b, "name"), - b.get("input").cloned().unwrap_or(Value::Null), - )?), - _ => {} - } - } - } - let input_tokens = anthropic_input_tokens(&v); - let output_tokens = sum_usage(&v, &["output_tokens"]); - Ok(LlmResponse { - text, - tool_calls, - stop, - input_tokens, - output_tokens, - reasoning, - }) -} - -fn parse_openai(v: Value) -> Result { - let choice = v - .get("choices") - .and_then(Value::as_array) - .and_then(|a| a.first()) - .ok_or_else(|| AgentError::Llm("response missing choices".into()))?; - let stop = map_stop(choice.get("finish_reason").and_then(Value::as_str)); - let msg = choice - .get("message") - .ok_or_else(|| AgentError::Llm("missing message".into()))?; - let text = str_field(msg, "content"); - // DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the - // message object. Prefer `reasoning_content` (DeepSeek's field name); fall - // back to `reasoning` (some other providers). Both are absent for standard - // OpenAI responses, which leaves this empty without any special-casing. - let reasoning = { - let rc = str_field(msg, "reasoning_content"); - if rc.is_empty() { - str_field(msg, "reasoning") - } else { - rc - } - }; - let mut tool_calls = Vec::new(); - if let Some(arr) = msg.get("tool_calls").and_then(Value::as_array) { - for tc in arr { - let f = tc - .get("function") - .ok_or_else(|| AgentError::Llm("tool_call missing function".into()))?; - let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}"); - let args: Value = serde_json::from_str(raw) - .map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?; - tool_calls.push(make_tool_call( - str_field(tc, "id"), - str_field(f, "name"), - args, - )?); - } - } - let input_tokens = openai_chat_input_tokens(&v); - let output_tokens = sum_usage(&v, &["completion_tokens"]); - Ok(LlmResponse { - text, - tool_calls, - stop, - input_tokens, - output_tokens, - reasoning, - }) -} - -fn make_tool_call(id: String, name: String, args: Value) -> Result { - if id.is_empty() || name.is_empty() { - return Err(AgentError::Llm("tool_call missing id or name".into())); - } - let arguments = match args { - Value::Object(_) => args, - Value::Null => Value::Object(Default::default()), - _ => { - return Err(AgentError::Llm( - "tool_call arguments must be a JSON object".into(), - )) - } - }; - Ok(ToolCall { - provider_id: id, - name, - arguments, - }) -} - -async fn read_error_body(mut resp: reqwest::Response) -> String { - let mut buf: Vec = Vec::new(); - while buf.len() < MAX_LLM_ERROR_BODY_BYTES { - match resp.chunk().await { - Ok(Some(chunk)) => { - let take = chunk.len().min(MAX_LLM_ERROR_BODY_BYTES - buf.len()); - buf.extend_from_slice(&chunk[..take]); - if take < chunk.len() { - break; - } - } - _ => break, - } - } - String::from_utf8_lossy(&buf).into_owned() -} - -const MAX_RETRIES: u32 = 3; -const BASE_BACKOFF_MS: u64 = 500; -const MAX_BACKOFF_MS: u64 = 8_000; - -async fn backoff_with_jitter(attempt: u32) { - let base = BASE_BACKOFF_MS - .saturating_mul(1u64 << attempt) - .min(MAX_BACKOFF_MS); - let mut buf = [0u8; 8]; - let jitter_range = base / 2; - let delay = if jitter_range > 0 && getrandom::fill(&mut buf).is_ok() { - let r = u64::from_le_bytes(buf) % jitter_range; - base - jitter_range + r - } else { - base - }; - tokio::time::sleep(std::time::Duration::from_millis(delay)).await; -} - -/// Transport-layer errors safe to retry for non-streaming LLM POSTs. -/// -/// Covers timeouts, connect failures, and the broader request-class errors -/// reqwest reports for pre-response failures: TLS handshake aborts, sockets -/// dropped or reset mid-send, h2 GOAWAY/RST_STREAM, hyper protocol errors. -/// Body-serialization happens before the retry loop, so `is_request()` here -/// is always a network failure, never a malformed request we'd just resend. -fn is_retryable_transport_error(e: &reqwest::Error) -> bool { - e.is_timeout() || e.is_connect() || e.is_request() -} - -/// Build the terminal `AgentError::Llm` for a `post()` exit that has given up -/// retrying — persistent retryable status, transport failure, or a body-read -/// break. `detail` carries the specific cause (status/body, or the transport -/// error text); `elapsed` and `attempts` are the cumulative cost of every -/// attempt made on this call, including the retries that failed before this -/// one. When cumulative time crosses `STALL_NOTICE_THRESHOLD`, this also logs -/// a `tracing::warn!` so a slow-building outage is visible in logs even -/// before an operator reads the returned error text. -fn terminal_llm_error(elapsed: std::time::Duration, attempts: u32, detail: &str) -> AgentError { - if elapsed >= STALL_NOTICE_THRESHOLD { - tracing::warn!( - cumulative_stall = ?elapsed, - attempts, - "llm: cumulative stall {elapsed:?} across {attempts} attempts ({detail})" - ); - } - AgentError::Llm(format!( - "{detail} (cumulative {elapsed:?}, {attempts} attempt{})", - if attempts == 1 { "" } else { "s" }, - )) -} - -/// Internal HTTP failure that preserves a mesh-specific MoA failure as a -/// typed signal. This covers both pre-inference eligibility rejection and a -/// structured `moa_failure` from workers/reducers. It is consumed inside -/// `openai_request`: an adaptive `auto` call falls back once, while an explicit -/// `mesh` call is surfaced as the ordinary LLM error callers already -/// understand. -#[derive(Debug)] -enum PostError { - Agent(AgentError), - MeshFallback(String), -} - -impl PostError { - fn into_agent(self) -> AgentError { - match self { - Self::Agent(error) => error, - Self::MeshFallback(detail) => AgentError::Llm(detail), - } - } -} - -impl From for PostError { - fn from(error: AgentError) -> Self { - Self::Agent(error) - } -} - -fn is_mesh_moa_unavailable_body(body: &str) -> bool { - serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .pointer("/error/message") - .and_then(Value::as_str) - .map(str::to_owned) - }) - .as_deref() - == Some(MESH_MOA_UNAVAILABLE_MESSAGE) -} - -fn is_mesh_moa_failure_body(body: &str) -> bool { - serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .pointer("/error/type") - .and_then(Value::as_str) - .map(str::to_owned) - }) - .as_deref() - == Some("moa_failure") -} - -async fn post( - http: &Client, - url: &str, - body: &Value, - detect_mesh_fallback: bool, - apply: F, -) -> Result -where - F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder, -{ - let body_bytes = serde_json::to_vec(body) - .map_err(|e| PostError::Agent(AgentError::Llm(format!("serialize: {e}"))))?; - let call_start = std::time::Instant::now(); - for attempt in 0..MAX_RETRIES { - let resp = match apply( - http.post(url) - .header("content-type", "application/json") - .body(body_bytes.clone()), - ) - .send() - .await - { - Ok(r) => r, - Err(e) => { - if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) { - tracing::warn!( - attempt = attempt + 1, - max_attempts = MAX_RETRIES, - error = %e, - "llm: transport error, retrying" - ); - backoff_with_jitter(attempt).await; - continue; - } - return Err(PostError::Agent(terminal_llm_error( - call_start.elapsed(), - attempt + 1, - &format!("transport: {e}"), - ))); - } - }; - let status = resp.status(); - // Both 401 and 403 are treated as refreshable: a 403 can mean an - // expired or revoked token, not just a pure authorization verdict, and - // the two are indistinguishable at the HTTP-status layer. The caller's - // retry loop keys off `LlmAuth` and refreshes once; the per-call guard - // bounds a pure-authz 403 to one wasted refresh before it propagates. - // Not a stall path: auth failures are not surfaced through - // terminal_llm_error, they resolve on the next call after refresh. - if status == 401 || status == 403 { - return Err(PostError::Agent(AgentError::LlmAuth( - read_error_body(resp).await, - ))); - } - if status.is_server_error() || status == 429 || status.as_u16() == 499 { - let body = read_error_body(resp).await; - let mesh_fallback = detect_mesh_fallback - && (status == reqwest::StatusCode::SERVICE_UNAVAILABLE - && is_mesh_moa_unavailable_body(&body) - || status.is_server_error() && is_mesh_moa_failure_body(&body)); - if mesh_fallback { - return Err(PostError::MeshFallback(format!("{status}: {body}"))); - } - if attempt + 1 < MAX_RETRIES { - tracing::warn!( - attempt = attempt + 1, - max_attempts = MAX_RETRIES, - %status, - "llm: retryable status, retrying" - ); - backoff_with_jitter(attempt).await; - continue; - } - return Err(PostError::Agent(terminal_llm_error( - call_start.elapsed(), - attempt + 1, - &format!("exhausted retries: {status}: {body}"), - ))); - } - // Not a stall path: the model is misconfigured, not the transport or - // upstream capacity — no retry was attempted, so cumulative duration - // would be misleading. - if status == 404 { - return Err(PostError::Agent(AgentError::LlmModelNotFound(format!( - "{status}: {}", - read_error_body(resp).await - )))); - } - if !status.is_success() { - return Err(PostError::Agent(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await - )))); - } - if let Some(len) = resp.content_length() { - if len as usize > MAX_LLM_RESPONSE_BYTES { - return Err(PostError::Agent(AgentError::Llm(format!( - "response too large: {len} > {MAX_LLM_RESPONSE_BYTES}" - )))); - } - } - let mut buf: Vec = Vec::new(); - let mut stream = resp; - loop { - match stream.chunk().await { - Ok(Some(chunk)) => { - if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES { - return Err(PostError::Agent(AgentError::Llm(format!( - "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes" - )))); - } - buf.extend_from_slice(&chunk); - } - Ok(None) => break, - Err(e) => { - return Err(PostError::Agent(terminal_llm_error( - call_start.elapsed(), - attempt + 1, - &format!("body read: {e}"), - ))); - } - } - } - return serde_json::from_slice(&buf) - .map_err(|e| PostError::Agent(AgentError::Llm(format!("json: {e}")))); - } - unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)"); -} - -/// Build the `TokenSource` for the configured provider. -/// -/// - `Provider::Anthropic`: a static source seeded from `cfg.api_key`. It's -/// never read for Anthropic requests (those go through `post_anthropic` with -/// `x-api-key`), but Llm holds one to keep the field non-`Option`. -/// - `Provider::OpenAi`: a static source over `OPENAI_COMPAT_API_KEY`. -/// - `Provider::Databricks`: if `DATABRICKS_TOKEN` is set, a static source. -/// Otherwise a `PkceOAuthTokenSource` pointed at the workspace's OIDC -/// discovery URL. First request without a cached token triggers a browser -/// flow; subsequent requests use the cache + refresh transparently. -pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { - match cfg.provider { - Provider::Anthropic | Provider::OpenAi => { - Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) - } - Provider::Databricks | Provider::DatabricksV2 => { - if !cfg.api_key.is_empty() { - return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))); - } - let discovery_url = format!( - "{}/oidc/.well-known/oauth-authorization-server", - cfg.base_url.trim_end_matches('/') - ); - let pkce = PkceOAuthConfig { - discovery_url, - client_id: DATABRICKS_CLIENT_ID.into(), - scopes: DATABRICKS_OAUTH_SCOPES - .iter() - .map(|s| (*s).into()) - .collect(), - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - Ok(PkceOAuthTokenSource::new(pkce)?) - } - } -} - -/// Return a clone of `body` with any top-level `"model"` field removed. -/// Used for Databricks model-serving, which encodes the model in the URL -/// path and rejects the field in the body. -fn strip_model(body: &Value) -> Value { - match body { - Value::Object(map) => { - let mut m = map.clone(); - m.remove("model"); - Value::Object(m) - } - other => other.clone(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{Config, HookServers, OpenAiApi, Provider}; - use crate::types::{HistoryItem, ToolCall, ToolResult, ToolResultContent}; - use std::time::Duration; - use tracing_subscriber::layer::SubscriberExt; - - fn cfg(provider: Provider) -> Config { - Config { - provider, - system_prompt: "system".into(), - max_rounds: 10, - max_output_tokens: 1024, - llm_timeout: Duration::from_secs(10), - tool_timeout: Duration::from_secs(10), - mcp_init_timeout: Duration::from_secs(10), - mcp_max_restart_attempts: 1, - mcp_restart_base_ms: 1, - mcp_restart_max_ms: 1, - max_sessions: 1, - max_line_bytes: 1024 * 1024, - max_history_bytes: 16 * 1024 * 1024, - max_tool_result_text_bytes: 50 * 1024, - max_context_tokens: 200_000, - max_handoffs: 1, - max_parallel_tools: 1, - hook_timeout: Duration::from_secs(1), - stop_max_rejections: 0, - hook_servers: HookServers::None, - api_key: "key".into(), - model: "model".into(), - base_url: "http://example.invalid".into(), - anthropic_api_version: "2023-06-01".into(), - openai_api: OpenAiApi::Chat, - prefer_mesh_for_auto: false, - hints_enabled: true, - thinking_effort: None, - } - } - - #[derive(Debug, Clone)] - struct CapturedHttpRequest { - method: String, - path: String, - body: Option, - } - - #[derive(Debug)] - struct StubHttpResponse { - status: u16, - body: Value, - } - - impl StubHttpResponse { - fn ok(body: Value) -> Self { - Self { status: 200, body } - } - - fn error(status: u16, message: &str) -> Self { - Self { - status, - body: json!({ - "error": { - "message": message, - "type": "server_error", - "code": "service_unavailable", - } - }), - } - } - - fn moa_failure(status: u16, code: &str, message: &str) -> Self { - Self { - status, - body: json!({ - "choices": [{ - "finish_reason": "error", - "message": { "content": message, "role": "assistant" }, - }], - "error": { - "message": message, - "type": "moa_failure", - "code": code, - }, - "model": "mesh", - }), - } - } - } - - async fn spawn_sequence_stub( - responses: Vec, - ) -> (String, Arc>>) { - use std::collections::VecDeque; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}/v1", listener.local_addr().unwrap()); - let captured = Arc::new(Mutex::new(Vec::new())); - let captured_for_server = captured.clone(); - let responses = Arc::new(Mutex::new(VecDeque::from(responses))); - - tokio::spawn(async move { - loop { - let (mut socket, _) = match listener.accept().await { - Ok(connection) => connection, - Err(_) => return, - }; - let mut bytes = Vec::new(); - let mut chunk = [0u8; 4096]; - let header_end = loop { - if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { - break index + 4; - } - match socket.read(&mut chunk).await { - Ok(0) | Err(_) => return, - Ok(read) => bytes.extend_from_slice(&chunk[..read]), - } - }; - let header_text = String::from_utf8_lossy(&bytes[..header_end]).into_owned(); - let content_length = header_text - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - while bytes.len() < header_end + content_length { - match socket.read(&mut chunk).await { - Ok(0) | Err(_) => return, - Ok(read) => bytes.extend_from_slice(&chunk[..read]), - } - } - let mut request_line = header_text.lines().next().unwrap_or_default().split(' '); - let method = request_line.next().unwrap_or_default().to_string(); - let path = request_line.next().unwrap_or_default().to_string(); - let body = if content_length == 0 { - None - } else { - serde_json::from_slice(&bytes[header_end..header_end + content_length]).ok() - }; - captured_for_server - .lock() - .await - .push(CapturedHttpRequest { method, path, body }); - - let response = responses.lock().await.pop_front().unwrap_or_else(|| { - StubHttpResponse::error(500, "stub response sequence exhausted") - }); - let status_text = match response.status { - 200 => "OK", - 500 => "Internal Server Error", - 502 => "Bad Gateway", - 503 => "Service Unavailable", - other => panic!("unsupported stub status: {other}"), - }; - let body = response.body.to_string(); - let wire = format!( - "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - response.status, - status_text, - body.len(), - body, - ); - let _ = socket.write_all(wire.as_bytes()).await; - let _ = socket.shutdown().await; - } - }); - (base_url, captured) - } - - fn model_catalog(ids: &[&str]) -> Value { - json!({ - "object": "list", - "data": ids - .iter() - .map(|id| json!({ "id": id, "object": "model" })) - .collect::>(), - }) - } - - fn chat_response(text: &str) -> Value { - json!({ - "choices": [{ - "finish_reason": "stop", - "message": { "content": text }, - }] - }) - } - - async fn complete_model( - llm: &Llm, - cfg: &Config, - model: &str, - ) -> Result { - llm.complete( - cfg, - "system", - &[HistoryItem::User("hello".into())], - &[], - model, - ) - .await - } - - async fn complete_model_with_tool( - llm: &Llm, - cfg: &Config, - model: &str, - ) -> Result { - llm.complete( - cfg, - "system", - &[HistoryItem::User("add two numbers".into())], - &[ToolDef { - name: "add_numbers".into(), - description: "Add two numbers".into(), - input_schema: json!({ - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "number"} - }, - "required": ["a", "b"] - }), - }], - model, - ) - .await - } - - async fn expire_mesh_catalog_check(llm: &Llm) { - llm.mesh_auto_state.lock().await.last_checked = - Some(Instant::now() - MESH_AUTO_CATALOG_TTL); - } - - async fn expire_mesh_auto_cooldown(llm: &Llm) { - let mut state = llm.mesh_auto_state.lock().await; - state.last_checked = Some(Instant::now() - MESH_AUTO_CATALOG_TTL); - state.cooldown_until = Some(Instant::now() - std::time::Duration::from_secs(1)); - } - - fn posted_models(requests: &[CapturedHttpRequest]) -> Vec<&str> { - requests - .iter() - .filter(|request| request.method == "POST") - .filter_map(|request| request.body.as_ref()?.get("model")?.as_str()) - .collect() - } - - #[tokio::test] - async fn mesh_auto_requires_two_stable_catalog_observations() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("direct")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("collective")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - assert_eq!( - complete_model(&llm, &config, "auto").await.unwrap().text, - "direct" - ); - expire_mesh_catalog_check(&llm).await; - assert_eq!( - complete_model(&llm, &config, "auto").await.unwrap().text, - "collective" - ); - - let requests = captured.lock().await; - assert_eq!(posted_models(&requests), vec!["auto", "mesh"]); - assert_eq!( - requests - .iter() - .filter(|request| request.path == "/v1/models") - .count(), - 2 - ); - } - - #[tokio::test] - async fn mesh_auto_does_not_enable_while_second_model_flaps() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("one")), - StubHttpResponse::ok(model_catalog(&["model-a"])), - StubHttpResponse::ok(chat_response("two")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("three")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("four")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - for _ in 0..4 { - complete_model(&llm, &config, "auto").await.unwrap(); - expire_mesh_catalog_check(&llm).await; - } - - let requests = captured.lock().await; - assert_eq!( - posted_models(&requests), - vec!["auto", "auto", "auto", "mesh"] - ); - } - - #[test] - fn collective_catalog_requires_two_distinct_physical_models() { - assert_eq!(mesh_catalog_supports_collective(&json!({})), None); - assert_eq!( - mesh_catalog_supports_collective(&model_catalog(&[])), - Some(false) - ); - assert_eq!( - mesh_catalog_supports_collective(&model_catalog(&["model-a", "mesh"])), - Some(false) - ); - assert_eq!( - mesh_catalog_supports_collective(&model_catalog(&[ - "org/model@main:Q4", - "org/model:Q4", - "mesh" - ])), - Some(false), - "two spellings of one model are not collective capacity" - ); - assert_eq!( - mesh_catalog_supports_collective(&model_catalog(&["model-a", "model-b", "mesh"])), - Some(true) - ); - } - - #[tokio::test] - async fn mesh_auto_tracks_models_appearing_disappearing_and_reappearing() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::ok(model_catalog(&[])), - StubHttpResponse::ok(chat_response("zero")), - StubHttpResponse::ok(model_catalog(&["model-a", "mesh"])), - StubHttpResponse::ok(chat_response("one")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("two-first-observation")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("two-stable")), - StubHttpResponse::ok(model_catalog(&["model-a", "mesh"])), - StubHttpResponse::ok(chat_response("contracted")), - StubHttpResponse::ok(model_catalog(&[])), - StubHttpResponse::ok(chat_response("empty-again")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("rejoin-first-observation")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("rejoined-stable")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - for call in 0..8 { - if call == 5 { - expire_mesh_auto_cooldown(&llm).await; - } else if call > 0 { - expire_mesh_catalog_check(&llm).await; - } - complete_model(&llm, &config, "auto").await.unwrap(); - } - - let requests = captured.lock().await; - assert_eq!( - posted_models(&requests), - vec!["auto", "auto", "auto", "mesh", "auto", "auto", "auto", "mesh"] - ); - assert_eq!( - requests - .iter() - .filter(|request| request.path == "/v1/models") - .count(), - 8 - ); - } - - #[tokio::test] - async fn mesh_auto_falls_back_once_and_cools_down_when_mesh_contracts() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("warmup")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::error(503, MESH_MOA_UNAVAILABLE_MESSAGE), - StubHttpResponse::ok(chat_response("fallback")), - StubHttpResponse::ok(chat_response("cooldown")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - complete_model(&llm, &config, "auto").await.unwrap(); - expire_mesh_catalog_check(&llm).await; - assert_eq!( - complete_model(&llm, &config, "auto").await.unwrap().text, - "fallback" - ); - assert_eq!( - complete_model(&llm, &config, "auto").await.unwrap().text, - "cooldown" - ); - - let requests = captured.lock().await; - assert_eq!( - posted_models(&requests), - vec!["auto", "mesh", "auto", "auto"] - ); - assert_eq!( - requests - .iter() - .filter(|request| request.path == "/v1/models") - .count(), - 2, - "cooldown request must not re-probe the catalog" - ); - } - - #[tokio::test] - async fn mesh_auto_falls_back_once_when_moa_reducer_fails() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("warmup")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::moa_failure( - 502, - "all_reducers_failed", - "Reducer failed: unsupported chat template", - ), - StubHttpResponse::ok(chat_response("fallback")), - StubHttpResponse::ok(chat_response("cooldown")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - complete_model(&llm, &config, "auto").await.unwrap(); - expire_mesh_catalog_check(&llm).await; - assert_eq!( - complete_model(&llm, &config, "auto").await.unwrap().text, - "fallback" - ); - assert_eq!( - complete_model(&llm, &config, "auto").await.unwrap().text, - "cooldown" - ); - - let requests = captured.lock().await; - assert_eq!( - posted_models(&requests), - vec!["auto", "mesh", "auto", "auto"] - ); - assert_eq!( - requests - .iter() - .filter(|request| request.path == "/v1/models") - .count(), - 2, - "mesh-specific failure must enter cooldown without re-probing" - ); - } - - #[tokio::test] - async fn mesh_auto_retries_unstructured_tool_markup_through_auto() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response("warmup")), - StubHttpResponse::ok(model_catalog(&["model-a", "model-b", "mesh"])), - StubHttpResponse::ok(chat_response( - "<|tool_call>call:add_numbers{a:17,b:25}", - )), - StubHttpResponse::ok(chat_response("safe fallback")), - StubHttpResponse::ok(chat_response("cooldown")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - complete_model(&llm, &config, "auto").await.unwrap(); - expire_mesh_catalog_check(&llm).await; - assert_eq!( - complete_model_with_tool(&llm, &config, "auto") - .await - .unwrap() - .text, - "safe fallback" - ); - assert_eq!( - complete_model_with_tool(&llm, &config, "auto") - .await - .unwrap() - .text, - "cooldown" - ); - - let requests = captured.lock().await; - assert_eq!( - posted_models(&requests), - vec!["auto", "mesh", "auto", "auto"] - ); - assert_eq!( - requests - .iter() - .filter(|request| request.path == "/v1/models") - .count(), - 2, - "pseudo tool markup must enter cooldown without another catalog probe" - ); - } - - #[tokio::test] - async fn mesh_auto_catalog_failure_fails_open_to_auto() { - let (base_url, captured) = spawn_sequence_stub(vec![ - StubHttpResponse::error(500, "catalog unavailable"), - StubHttpResponse::ok(chat_response("direct")), - ]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - complete_model(&llm, &config, "auto").await.unwrap(); - let requests = captured.lock().await; - assert_eq!(posted_models(&requests), vec!["auto"]); - } - - #[tokio::test] - async fn generic_openai_auto_does_not_probe_or_select_mesh() { - let (base_url, captured) = - spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("provider auto"))]).await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = false; - let llm = Llm::new(&config).unwrap(); - - complete_model(&llm, &config, "auto").await.unwrap(); - let requests = captured.lock().await; - assert_eq!(posted_models(&requests), vec!["auto"]); - assert!(requests.iter().all(|request| request.path != "/v1/models")); - } - - #[tokio::test] - async fn explicit_models_are_never_rewritten_or_fallback_retried() { - let (base_url, captured) = spawn_sequence_stub(vec![StubHttpResponse::error( - 503, - MESH_MOA_UNAVAILABLE_MESSAGE, - )]) - .await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - let error = complete_model(&llm, &config, "mesh").await.unwrap_err(); - assert!(error.to_string().contains(MESH_MOA_UNAVAILABLE_MESSAGE)); - let requests = captured.lock().await; - assert_eq!(posted_models(&requests), vec!["mesh"]); - assert!(requests.iter().all(|request| request.path != "/v1/models")); - } - - #[tokio::test] - async fn explicit_real_model_bypasses_mesh_auto_policy() { - let (base_url, captured) = - spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("explicit"))]).await; - let mut config = cfg(Provider::OpenAi); - config.base_url = base_url; - config.prefer_mesh_for_auto = true; - let llm = Llm::new(&config).unwrap(); - - let response = complete_model(&llm, &config, "model-a").await.unwrap(); - assert_eq!(response.text, "explicit"); - let requests = captured.lock().await; - assert_eq!(posted_models(&requests), vec!["model-a"]); - assert!(requests.iter().all(|request| request.path != "/v1/models")); - } - - #[test] - fn mesh_unavailable_classifier_requires_exact_openai_error_message() { - assert!(is_mesh_moa_unavailable_body( - &StubHttpResponse::error(503, MESH_MOA_UNAVAILABLE_MESSAGE) - .body - .to_string() - )); - assert!(!is_mesh_moa_unavailable_body( - &StubHttpResponse::error(503, "some other outage") - .body - .to_string() - )); - } - - #[test] - fn mesh_failure_classifier_requires_structured_moa_failure_type() { - assert!(is_mesh_moa_failure_body( - &StubHttpResponse::moa_failure( - 502, - "all_reducers_failed", - "Reducer failed: unsupported chat template", - ) - .body - .to_string() - )); - assert!(!is_mesh_moa_failure_body( - &StubHttpResponse::error(502, "some other bad gateway") - .body - .to_string() - )); - } - - fn image_history() -> Vec { - vec![ - HistoryItem::User("describe the image".into()), - HistoryItem::Assistant { - text: String::new(), - tool_calls: vec![ToolCall { - provider_id: "toolu_1".into(), - name: "dev__view_image".into(), - arguments: serde_json::json!({"source":"x.png"}), - }], - }, - HistoryItem::ToolResult(ToolResult { - provider_id: "toolu_1".into(), - content: vec![ - ToolResultContent::Text("10×10, 70 B (image/png from x.png)".into()), - ToolResultContent::Image { - data: "aW1n".into(), - mime_type: "image/png".into(), - }, - ], - is_error: false, - }), - ] - } - - #[test] - fn anthropic_tool_result_preserves_image_block() { - let body = anthropic_body( - &cfg(Provider::Anthropic), - "system", - &image_history(), - &[], - "model", - None, - ); - let content = &body["messages"][2]["content"][0]["content"]; - assert_eq!(content[0]["type"], "text"); - assert_eq!(content[1]["type"], "image"); - assert_eq!(content[1]["source"]["type"], "base64"); - assert_eq!(content[1]["source"]["media_type"], "image/png"); - assert_eq!(content[1]["source"]["data"], "aW1n"); - } - - fn cfg_responses() -> Config { - let mut c = cfg(Provider::OpenAi); - c.openai_api = OpenAiApi::Responses; - c - } - - fn tool_call_history() -> Vec { - vec![ - HistoryItem::User("call the tool".into()), - HistoryItem::Assistant { - text: "ok, calling".into(), - tool_calls: vec![ToolCall { - provider_id: "call_abc".into(), - name: "dev__shell".into(), - arguments: serde_json::json!({"command": "ls"}), - }], - }, - HistoryItem::ToolResult(ToolResult { - provider_id: "call_abc".into(), - content: vec![ToolResultContent::Text("file.txt".into())], - is_error: false, - }), - ] - } - - #[test] - fn responses_body_top_level_shape() { - let tools = vec![ToolDef { - name: "dev__shell".into(), - description: "run a shell command".into(), - input_schema: serde_json::json!({ - "type": "object", - "properties": {"command": {"type": "string"}}, - }), - }]; - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &tools, - "model", - None, - ); - assert_eq!(body["model"], "model"); - assert_eq!(body["instructions"], "system"); - assert_eq!(body["max_output_tokens"], 1024); - assert!( - body.get("messages").is_none(), - "must use `input`, not `messages`" - ); - assert!(body.get("max_tokens").is_none()); - assert!(body.get("max_completion_tokens").is_none()); - - // Tools are flat — top-level type/name/description/parameters. - let tool = &body["tools"][0]; - assert_eq!(tool["type"], "function"); - assert_eq!(tool["name"], "dev__shell"); - assert!( - tool.get("function").is_none(), - "Responses tool schema is flat" - ); - assert_eq!(body["tool_choice"], "auto"); - } - - #[test] - fn responses_body_replay_emits_function_call_before_output() { - // Replay requirement from the live API: the assistant's prior - // function_call item *must* appear in `input[]` before its matching - // function_call_output, otherwise the API rejects with - // "No tool call found for call_id ...". - let body = responses_body( - &cfg_responses(), - "system", - &tool_call_history(), - &[], - "model", - None, - ); - let input = body["input"].as_array().unwrap(); - - // [0] user, [1] assistant text, [2] function_call, [3] function_call_output - assert_eq!(input[0]["role"], "user"); - assert_eq!(input[0]["content"][0]["type"], "input_text"); - assert_eq!(input[0]["content"][0]["text"], "call the tool"); - - assert_eq!(input[1]["role"], "assistant"); - assert_eq!(input[1]["content"][0]["type"], "output_text"); - assert_eq!(input[1]["content"][0]["text"], "ok, calling"); - - assert_eq!(input[2]["type"], "function_call"); - assert_eq!(input[2]["call_id"], "call_abc"); - assert_eq!(input[2]["name"], "dev__shell"); - // Arguments are a JSON-encoded string per spec. - assert_eq!(input[2]["arguments"], "{\"command\":\"ls\"}"); - - assert_eq!(input[3]["type"], "function_call_output"); - assert_eq!(input[3]["call_id"], "call_abc"); - assert_eq!(input[3]["output"], "file.txt"); - } - - #[test] - fn responses_body_skips_empty_assistant_text() { - // Mirrors the Chat Completions behavior (#559/#560): empty assistant - // turns are skipped so we don't emit an empty `output_text` block, - // but the tool_call(s) on that assistant turn still go through. - let history = vec![ - HistoryItem::User("u".into()), - HistoryItem::Assistant { - text: String::new(), - tool_calls: vec![ToolCall { - provider_id: "call_x".into(), - name: "t".into(), - arguments: serde_json::json!({}), - }], - }, - ]; - let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None); - let input = body["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["role"], "user"); - assert_eq!(input[1]["type"], "function_call"); - } - - #[test] - fn responses_body_image_tool_result_attaches_input_image() { - let body = responses_body( - &cfg_responses(), - "system", - &image_history(), - &[], - "model", - None, - ); - let input = body["input"].as_array().unwrap(); - // function_call_output carries the text part; image rides on a - // trailing user message as `input_image`. - let fco = input - .iter() - .find(|i| i["type"] == "function_call_output") - .unwrap(); - assert_eq!(fco["call_id"], "toolu_1"); - let img_msg = input.iter().rev().find(|i| i["role"] == "user").unwrap(); - assert_eq!(img_msg["content"][0]["type"], "input_image"); - assert_eq!( - img_msg["content"][0]["image_url"], - "data:image/png;base64,aW1n" - ); - } - - #[test] - fn parse_responses_completed_with_text_is_end_turn() { - let v = serde_json::json!({ - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "hello"}], - }], - }); - let r = parse_responses(v).unwrap(); - assert_eq!(r.text, "hello"); - assert!(r.tool_calls.is_empty()); - assert_eq!(r.stop, ProviderStop::EndTurn); - } - - #[test] - fn parse_responses_completed_with_function_call_is_tool_use() { - let v = serde_json::json!({ - "status": "completed", - "output": [ - {"type": "reasoning", "id": "rs_1", "summary": []}, - { - "type": "function_call", - "call_id": "call_z", - "name": "dev__shell", - "arguments": "{\"command\":\"ls\"}", - }, - ], - }); - let r = parse_responses(v).unwrap(); - assert_eq!(r.text, ""); - assert_eq!(r.tool_calls.len(), 1); - assert_eq!(r.tool_calls[0].provider_id, "call_z"); - assert_eq!(r.tool_calls[0].name, "dev__shell"); - assert_eq!( - r.tool_calls[0].arguments, - serde_json::json!({"command": "ls"}) - ); - assert_eq!(r.stop, ProviderStop::ToolUse); - } - - #[test] - fn parse_responses_incomplete_max_output_tokens() { - let v = serde_json::json!({ - "status": "incomplete", - "incomplete_details": {"reason": "max_output_tokens"}, - "output": [], - }); - let r = parse_responses(v).unwrap(); - assert_eq!(r.stop, ProviderStop::MaxTokens); - } - - #[test] - fn is_responses_required_error_matrix() { - for (body, want) in [ - // Databricks GPT-5.5 (the actual case we observed). - ("Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead.", true), - // Forward-compat: OpenAI saying the same thing in prose. - ("This model requires the Responses API. Please use the Responses API instead.", true), - // Negatives — must NOT trigger on unrelated 4xx. - ("{\"error\":\"invalid_api_key\"}", false), - ("max_tokens is not supported with this model", false), - ("", false), - ] { - assert_eq!(is_responses_required_error(body), want, "body={body:?}"); - } - } - - #[test] - fn databricks_v2_routes_by_model_family() { - for (model, route, path) in [ - ( - "databricks-gpt-5-5", - DatabricksV2Route::OpenAiResponses, - "/ai-gateway/openai/v1/responses", - ), - ( - "databricks-claude-opus-4-7", - DatabricksV2Route::AnthropicMessages, - "/ai-gateway/anthropic/v1/messages", - ), - ( - "custom-tool-model", - DatabricksV2Route::MlflowChatCompletions, - "/ai-gateway/mlflow/v1/chat/completions", - ), - ] { - let got = databricks_v2_route_for_model(model); - assert_eq!(got, route, "model={model}"); - assert_eq!(databricks_v2_path(got), path, "model={model}"); - } - } - - #[test] - fn parse_responses_rejects_malformed_function_arguments() { - let v = serde_json::json!({ - "status": "completed", - "output": [{ - "type": "function_call", - "call_id": "call_z", - "name": "t", - "arguments": "not json {", - }], - }); - assert!(matches!(parse_responses(v), Err(AgentError::Llm(_)))); - } - - #[test] - fn openai_tool_result_adds_followup_image_user_message() { - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &image_history(), - &[], - "model", - None, - ); - assert_eq!(body["messages"][3]["role"], "tool"); - assert!(body["messages"][3]["content"] - .as_str() - .unwrap() - .contains("provided in the next user message")); - assert_eq!(body["messages"][4]["role"], "user"); - assert_eq!(body["messages"][4]["content"][0]["type"], "image_url"); - assert_eq!( - body["messages"][4]["content"][0]["image_url"]["url"], - "data:image/png;base64,aW1n" - ); - } - - /// Regression for Databricks model serving (and any OpenAI-Chat frontend - /// that translates to Anthropic on the way to the model). Parallel tool - /// calls where one or more return images previously produced an - /// interleaved sequence: - /// role:"tool" (A) - /// role:"user" (image A) - /// role:"tool" (B) - /// role:"user" (image B) - /// The intervening user message split the run of tool results, so the - /// translator could no longer fold them into a single Anthropic - /// `tool_result`-bearing user message — Anthropic then rejected the - /// request with "tool_use ids were found without tool_result blocks - /// immediately after". Fix: every `role:"tool"` for a run of adjacent - /// ToolResults emits contiguously, then a single trailing user message - /// carries all of the images from the batch. - #[test] - fn openai_parallel_image_tool_results_stay_contiguous() { - let history = vec![ - HistoryItem::User("describe both images".into()), - HistoryItem::Assistant { - text: String::new(), - tool_calls: vec![ - ToolCall { - provider_id: "toolu_a".into(), - name: "dev__view_image".into(), - arguments: serde_json::json!({"source": "a.png"}), - }, - ToolCall { - provider_id: "toolu_b".into(), - name: "dev__view_image".into(), - arguments: serde_json::json!({"source": "b.png"}), - }, - ], - }, - HistoryItem::ToolResult(ToolResult { - provider_id: "toolu_a".into(), - content: vec![ - ToolResultContent::Text("10×10, 70 B (image/png from a.png)".into()), - ToolResultContent::Image { - data: "aaa".into(), - mime_type: "image/png".into(), - }, - ], - is_error: false, - }), - HistoryItem::ToolResult(ToolResult { - provider_id: "toolu_b".into(), - content: vec![ - ToolResultContent::Text("10×10, 70 B (image/png from b.png)".into()), - ToolResultContent::Image { - data: "bbb".into(), - mime_type: "image/png".into(), - }, - ], - is_error: false, - }), - ]; - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &history, - &[], - "model", - None, - ); - let messages = body["messages"].as_array().unwrap(); - // [0] system, [1] user, [2] assistant(tool_calls), [3] tool A, [4] tool B, [5] user(images) - assert_eq!(messages.len(), 6, "messages: {messages:#?}"); - assert_eq!(messages[3]["role"], "tool"); - assert_eq!(messages[3]["tool_call_id"], "toolu_a"); - assert_eq!( - messages[4]["role"], "tool", - "tool results must stay adjacent; intervening user message breaks Databricks/Anthropic pairing" - ); - assert_eq!(messages[4]["tool_call_id"], "toolu_b"); - assert_eq!(messages[5]["role"], "user"); - let imgs = messages[5]["content"].as_array().unwrap(); - assert_eq!(imgs.len(), 2); - assert_eq!(imgs[0]["image_url"]["url"], "data:image/png;base64,aaa"); - assert_eq!(imgs[1]["image_url"]["url"], "data:image/png;base64,bbb"); - } - - // ---- ThinkingEffort body-shape tests ---- - - #[test] - fn anthropic_body_omits_thinking_when_effort_none() { - let body = anthropic_body( - &cfg(Provider::Anthropic), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - None, - ); - assert!( - body.get("thinking").is_none(), - "thinking must be absent when effort is None" - ); - } - - #[test] - fn anthropic_body_emits_thinking_when_effort_high() { - // claude-3.x model → manual budget_tokens shape. - // Use max_output_tokens = 4096 so budget fits: headroom = 4096 - 1024 = 3072. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 4096; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-3-7-sonnet-20250219", - Some(ThinkingEffort::High), - ); - assert_eq!(body["thinking"]["type"], "enabled"); - // budget_tokens = min(32768, 4096-1024) = 3072 - assert_eq!(body["thinking"]["budget_tokens"], 3072); - assert!(body.get("output_config").is_none()); - } - - #[test] - fn anthropic_body_omits_thinking_when_max_output_too_small() { - // max_output_tokens = 2047: headroom = 2047 - 1024 = 1023 < 1024 → omit thinking. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 2047; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-3-7-sonnet-20250219", - Some(ThinkingEffort::High), - ); - assert!( - body.get("thinking").is_none(), - "thinking must be omitted when max_output_tokens leaves < 1024 for budget" - ); - } - - #[test] - fn anthropic_body_emits_thinking_at_boundary_2048() { - // max_output_tokens = 2048: headroom = 2048 - 1024 = 1024 ≥ 1024 → emit. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 2048; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-3-7-sonnet-20250219", - Some(ThinkingEffort::High), - ); - let t = body - .get("thinking") - .expect("thinking must be present at boundary 2048"); - assert_eq!(t["budget_tokens"], 1024); // min(32768, 2048-1024) - } - - #[test] - fn anthropic_body_emits_thinking_high_uncapped_when_budget_fits() { - // When max_output_tokens is large enough, budget_tokens is not capped. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 65_536; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-3-7-sonnet-20250219", - Some(ThinkingEffort::High), - ); - assert_eq!(body["thinking"]["budget_tokens"], 32_768); - } - - #[test] - fn anthropic_body_emits_thinking_low_budget() { - // Low budget (1024 tokens) exactly fits when max_output_tokens = 2048. - // headroom = 2048 - 1024 = 1024; min(1024, 1024) = 1024 ≥ 1024 → emit. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 2048; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-3-7-sonnet-20250219", - Some(ThinkingEffort::Low), - ); - // Low budget (1024) fits exactly at the boundary — emitted without capping. - assert_eq!(body["thinking"]["budget_tokens"], 1024); - } - - #[test] - fn anthropic_body_emits_adaptive_thinking_for_opus_4() { - // Adaptive Claude (claude-opus-4-6/4.7/4.8) → thinking:{type:"adaptive"} + output_config.effort. - // Note: Opus 4.5 is NOT adaptive — it uses manual budget. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 32_768; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-opus-4-7", - Some(ThinkingEffort::High), - ); - assert_eq!( - body["thinking"]["type"], "adaptive", - "thinking must be {{type:adaptive}} for claude-opus-4-7" - ); - assert_eq!(body["output_config"]["effort"], "high"); - } - - #[test] - fn anthropic_body_emits_manual_budget_for_opus_4_5() { - // Opus 4.5 uses manual budget (effort page: "uses manual thinking"). - // max_output_tokens = 32768; headroom = 32768 - 1024 = 31744; min(32768, 31744) = 31744. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 32_768; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-opus-4-5", - Some(ThinkingEffort::High), - ); - assert_eq!(body["thinking"]["type"], "enabled"); - assert_eq!(body["thinking"]["budget_tokens"], 31_744); // min(32768, 32768-1024) - assert!( - body.get("output_config").is_none(), - "output_config must be absent for claude-opus-4-5 (manual budget)" - ); - } - - #[test] - fn anthropic_body_omits_both_fields_for_unrecognized_model() { - // Non-Anthropic models (gpt-5, llama, etc.) → omit both fields rather than guess. - let body = anthropic_body( - &cfg(Provider::Anthropic), - "system", - &[HistoryItem::User("hi".into())], - &[], - "gpt-4o", - Some(ThinkingEffort::High), - ); - assert!(body.get("thinking").is_none(), "thinking must be absent"); - assert!( - body.get("output_config").is_none(), - "output_config must be absent" - ); - } - - #[test] - fn openai_body_omits_reasoning_effort_when_none() { - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - None, - ); - assert!( - body.get("reasoning_effort").is_none(), - "reasoning_effort must be absent when effort is None" - ); - } - - #[test] - fn openai_body_emits_reasoning_effort_medium() { - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - Some(ThinkingEffort::Medium), - ); - assert_eq!(body["reasoning_effort"], "medium"); - } - - #[test] - fn responses_body_omits_reasoning_when_effort_none() { - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - None, - ); - assert!( - body.get("reasoning").is_none(), - "reasoning must be absent when effort is None" - ); - } - - #[test] - fn responses_body_emits_reasoning_effort_low() { - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - Some(ThinkingEffort::Low), - ); - assert_eq!(body["reasoning"]["effort"], "low"); - } - - #[test] - fn effective_model_overrides_cfg_model_in_anthropic_body() { - let body = anthropic_body( - &cfg(Provider::Anthropic), - "system", - &[HistoryItem::User("hi".into())], - &[], - "override-model", - None, - ); - assert_eq!(body["model"], "override-model"); - } - - #[test] - fn effective_model_overrides_cfg_model_in_openai_body() { - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &[HistoryItem::User("hi".into())], - &[], - "override-model", - None, - ); - assert_eq!(body["model"], "override-model"); - } - - #[test] - fn anthropic_body_opus_4_8_xhigh_emits_xhigh_effort() { - // Body-shape regression: xhigh on Opus 4.8 must emit output_config.effort="xhigh". - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 32_768; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-opus-4-8", - Some(ThinkingEffort::XHigh), - ); - assert_eq!(body["thinking"]["type"], "adaptive"); - assert_eq!(body["output_config"]["effort"], "xhigh"); - } - - #[test] - fn anthropic_body_opus_4_8_max_emits_max_effort() { - // Body-shape regression: max on Opus 4.8 must emit output_config.effort="max". - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 32_768; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-opus-4-8", - Some(ThinkingEffort::Max), - ); - assert_eq!(body["thinking"]["type"], "adaptive"); - assert_eq!(body["output_config"]["effort"], "max"); - } - - #[test] - fn openai_body_emits_xhigh_effort() { - // xhigh is a valid OpenAI effort value — must pass through. - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - Some(ThinkingEffort::XHigh), - ); - assert_eq!(body["reasoning_effort"], "xhigh"); - } - - #[test] - fn openai_body_emits_none_effort() { - // none is a valid OpenAI effort value. - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - Some(ThinkingEffort::None), - ); - assert_eq!(body["reasoning_effort"], "none"); - } - - #[test] - fn responses_body_emits_xhigh_effort() { - // xhigh is a valid Responses API effort value. - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - Some(ThinkingEffort::XHigh), - ); - assert_eq!(body["reasoning"]["effort"], "xhigh"); - } - - #[test] - fn responses_body_emits_minimal_effort() { - // minimal is a valid Responses API effort value. - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "model", - Some(ThinkingEffort::Minimal), - ); - assert_eq!(body["reasoning"]["effort"], "minimal"); - } - - // ---- DatabricksV2 route-aware effort normalization (body-level assertions) ---- - // - // The DBv2 `complete()` dispatch applies `normalize_effort_for_openai_route` / - // `normalize_effort_for_anthropic_route` before calling body builders. These tests - // verify the body shape that results from the already-normalized effort values — i.e., - // they confirm the body builders correctly serialize the values the dispatch passes them. - - #[test] - fn dbv2_openai_route_max_effort_clamped_to_xhigh_in_responses_body() { - // DBv2 GPT-5.5 route: max → clamped to xhigh by normalize_effort_for_openai_route - // before reaching responses_body. gpt-5.5 supports xhigh so the final value is xhigh. - let clamped = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"); - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "gpt-5.5", - Some(clamped), - ); - assert_eq!( - body["reasoning"]["effort"], "xhigh", - "DBv2 GPT-5.5 route: max must be clamped to xhigh before responses_body" - ); - } - - #[test] - fn dbv2_openai_route_max_effort_passes_through_for_gpt5_6() { - let normalized = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.6-sol"); - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "gpt-5.6-sol", - Some(normalized), - ); - assert_eq!( - body["reasoning"]["effort"], "max", - "DBv2 GPT-5.6 route must serialize max to the Responses API" - ); - } - - #[test] - fn dbv2_mlflow_route_max_effort_clamped_to_xhigh_in_openai_body() { - // DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_openai_route. - // Unknown models pass through after the max→xhigh clamp. - let clamped = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "llama-4"); - let body = openai_body( - &cfg(Provider::OpenAi), - "system", - &[HistoryItem::User("hi".into())], - &[], - "llama-4", - Some(clamped), - ); - assert_eq!( - body["reasoning_effort"], "xhigh", - "DBv2 MLflow route: max must be clamped to xhigh before openai_body" - ); - } - - #[test] - fn dbv2_openai_route_none_minimal_pass_through_in_responses_body() { - // Verify that supported values pass through for the respective model families. - // gpt-5.5 supports none (but not minimal); gpt-5 base supports minimal (but not none). - let none_normalized = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5.5"); - assert_eq!( - none_normalized, - ThinkingEffort::None, - "OpenAI normalizer must not touch none for gpt-5.5" - ); - let minimal_normalized = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Minimal, "gpt-5"); - assert_eq!( - minimal_normalized, - ThinkingEffort::Minimal, - "OpenAI normalizer must not touch minimal for gpt-5 base" - ); - let body = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "gpt-5.5", - Some(none_normalized), - ); - assert_eq!( - body["reasoning"]["effort"], "none", - "DBv2 GPT-5.5 route: none must be emitted as-is" - ); - } - - #[test] - fn dbv2_claude_route_none_effort_omits_thinking_fields() { - // DBv2 Claude route: none → normalize_effort_for_anthropic_route returns None → omit. - let normalized = crate::config::normalize_effort_for_anthropic_route(ThinkingEffort::None); - assert_eq!( - normalized, None, - "Anthropic normalizer must return None for ThinkingEffort::None" - ); - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 32_768; - let body = anthropic_body( - &c, - "system", - &[HistoryItem::User("hi".into())], - &[], - "claude-opus-4-8", - normalized, // None → omit thinking fields - ); - assert!( - body.get("thinking").is_none(), - "DBv2 Claude route: none effort must omit thinking fields" - ); - assert!( - body.get("output_config").is_none(), - "DBv2 Claude route: none effort must omit output_config" - ); - } - - #[test] - fn dbv2_route_switch_max_body_level_simulation() { - // Body-level simulation of a session/set_model switch from a Claude model to a GPT-5 - // model when thinking_effort=max. Calls body builders and normalizers directly (not - // through the ACP session/set_model path or DatabricksV2 dispatch) to verify the - // correct output shape for each side of the route switch. - // Before the switch: Claude route → max passes through as Anthropic "max". - // After the switch: GPT-5 route → max clamped to xhigh. - let mut c = cfg(Provider::Anthropic); - c.max_output_tokens = 32_768; - - // Before switch: claude-opus-4-8 with effort=max → adaptive shape, effort="max" - let (thinking_before, oc_before) = crate::config::anthropic_thinking_config( - "claude-opus-4-8", - ThinkingEffort::Max, - 32_768, - ); - assert_eq!(thinking_before.unwrap()["type"], "adaptive"); - assert_eq!(oc_before.unwrap()["effort"], "max"); - - // After switch to GPT-5.5 route: normalize max → xhigh for responses_body - // (gpt-5.5 supports xhigh, so the clamp result is xhigh, not further reduced) - let clamped = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"); - assert_eq!(clamped, ThinkingEffort::XHigh); - let body_after = responses_body( - &cfg_responses(), - "system", - &[HistoryItem::User("hi".into())], - &[], - "gpt-5.5", - Some(clamped), - ); - assert_eq!( - body_after["reasoning"]["effort"], "xhigh", - "After set_model to GPT-5.5: max must be clamped to xhigh" - ); - } - - /// Regression: a connection that is accepted and then dropped before any - /// HTTP response bytes are written surfaces as a reqwest request-class - /// error (not `is_connect()`, not `is_timeout()`). The retry predicate - /// must recognize it; otherwise transient TLS/h2/proxy hiccups bubble - /// out of the agent as `transport: error sending request ...`. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_retries_on_dropped_connection_before_response() { - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::Arc; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}/v1/x", listener.local_addr().unwrap()); - let accepts = Arc::new(AtomicU32::new(0)); - let accepts_srv = accepts.clone(); - - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let n = accepts_srv.fetch_add(1, Ordering::SeqCst); - if n == 0 { - // First attempt: read the request, then drop the socket - // without writing a response. reqwest surfaces this as - // a request-class error (is_request() == true). - let mut tmp = [0u8; 4096]; - let _ = sock.read(&mut tmp).await; - drop(sock); - continue; - } - // Subsequent attempts: serve a tiny JSON body. - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(k) => buf.extend_from_slice(&tmp[..k]), - } - } - let body = "{\"ok\":true}"; - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - } - }); - - let client = Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .expect("post should succeed after retry"); - assert_eq!(out, serde_json::json!({ "ok": true })); - assert!( - accepts.load(Ordering::SeqCst) >= 2, - "server should have seen at least 2 connection attempts, saw {}", - accepts.load(Ordering::SeqCst) - ); - } - - /// A 499 response is retried and the call succeeds on the second attempt. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_retries_499_and_succeeds() { - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::Arc; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}/v1/x", listener.local_addr().unwrap()); - let accepts = Arc::new(AtomicU32::new(0)); - let accepts_srv = accepts.clone(); - - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let n = accepts_srv.fetch_add(1, Ordering::SeqCst); - // Read the full request headers before responding. - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(k) => buf.extend_from_slice(&tmp[..k]), - } - } - if n == 0 { - // First attempt: respond with 499. - let resp = "HTTP/1.1 499 Client Closed Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - continue; - } - // Subsequent attempts: 200 OK with a tiny JSON body. - let body = "{\"ok\":true}"; - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - } - }); - - let client = Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .expect("post should succeed after 499 retry"); - assert_eq!(out, serde_json::json!({ "ok": true })); - assert!( - accepts.load(Ordering::SeqCst) >= 2, - "server must see at least 2 attempts (got {})", - accepts.load(Ordering::SeqCst) - ); - } - - /// When all MAX_RETRIES attempts return 499 the error includes "exhausted retries". - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_exhausts_retries_on_persistent_499() { - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::Arc; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}/v1/x", listener.local_addr().unwrap()); - let accepts = Arc::new(AtomicU32::new(0)); - let accepts_srv = accepts.clone(); - - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - accepts_srv.fetch_add(1, Ordering::SeqCst); - // Read the full request headers before responding. - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(k) => buf.extend_from_slice(&tmp[..k]), - } - } - // Always 499. - let resp = "HTTP/1.1 499 Client Closed Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - } - }); - - let client = Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .unwrap(); - let err = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .unwrap_err(); - match &err { - PostError::Agent(AgentError::Llm(msg)) => { - assert!( - msg.contains("exhausted retries") && msg.contains("499"), - "expected 'exhausted retries' + '499' in error, got: {msg}" - ); - assert!( - msg.contains("cumulative") && msg.contains("3 attempts"), - "expected cumulative duration + exact attempt count, got: {msg}" - ); - } - other => panic!("expected PostError::Agent(AgentError::Llm), got: {other:?}"), - } - assert_eq!( - accepts.load(Ordering::SeqCst), - MAX_RETRIES, - "server must see exactly MAX_RETRIES attempts — 499 must be retried" - ); - } - - /// `terminal_llm_error` below `STALL_NOTICE_THRESHOLD` carries the detail - /// and attempt count but no stall-specific text — this is the common case - /// (a handful of quick retries), not an outage. - #[test] - fn terminal_llm_error_below_threshold_carries_detail_no_stall_claim() { - let err = terminal_llm_error(Duration::from_secs(2), 3, "exhausted retries: 499: body"); - match err { - AgentError::Llm(msg) => { - assert!(msg.contains("cumulative 2s"), "must carry duration: {msg}"); - assert!( - msg.contains("3 attempts"), - "must carry attempt count: {msg}" - ); - assert!( - msg.contains("exhausted retries") && msg.contains("499"), - "must preserve detail: {msg}" - ); - } - other => panic!("expected Llm, got: {other:?}"), - } - } - - /// Above `STALL_NOTICE_THRESHOLD`, the error still carries the same - /// detail/duration/count — the threshold only gates the extra - /// `tracing::warn!` (not separately assertable here), not the error text. - /// Singular "1 attempt" (not "1 attempts") confirms the pluralization. - #[test] - fn terminal_llm_error_above_threshold_uses_singular_attempt() { - let err = terminal_llm_error(Duration::from_secs(301), 1, "transport: connection reset"); - match err { - AgentError::Llm(msg) => { - assert!( - msg.contains("cumulative 301s"), - "must carry duration: {msg}" - ); - assert!(msg.contains("1 attempt"), "must carry count: {msg}"); - assert!( - !msg.contains("1 attempts"), - "singular for count of 1: {msg}" - ); - } - other => panic!("expected Llm, got: {other:?}"), - } - } - - /// Captures `tracing::warn!` events emitted during a scoped subscriber - /// so a test can assert on the stall-notice threshold without a real - /// sleep or a global logger. - struct StallWarnCapture { - count: Arc, - } - - struct StallWarnVisitor { - saw_cumulative_stall: bool, - saw_attempts: bool, - } - - impl tracing::field::Visit for StallWarnVisitor { - fn record_debug(&mut self, field: &tracing::field::Field, _value: &dyn std::fmt::Debug) { - match field.name() { - "cumulative_stall" => self.saw_cumulative_stall = true, - "attempts" => self.saw_attempts = true, - _ => {} - } - } - - fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) { - if field.name() == "attempts" { - self.saw_attempts = true; - } - } - } - - impl tracing_subscriber::Layer for StallWarnCapture { - fn on_event( - &self, - event: &tracing::Event<'_>, - _ctx: tracing_subscriber::layer::Context<'_, S>, - ) { - if *event.metadata().level() != tracing::Level::WARN { - return; - } - let mut visitor = StallWarnVisitor { - saw_cumulative_stall: false, - saw_attempts: false, - }; - event.record(&mut visitor); - if visitor.saw_cumulative_stall && visitor.saw_attempts { - self.count.fetch_add(1, Ordering::SeqCst); - } - } - } - - /// Runs `f` under a scoped subscriber that only counts `WARN` events - /// carrying both `cumulative_stall` and `attempts` fields — the shape - /// `terminal_llm_error`'s stall notice emits. Returns that count. - fn count_stall_warnings(f: impl FnOnce()) -> usize { - let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let subscriber = tracing_subscriber::registry().with(StallWarnCapture { - count: count.clone(), - }); - tracing::subscriber::with_default(subscriber, f); - count.load(Ordering::SeqCst) - } - - /// Below `STALL_NOTICE_THRESHOLD`, `terminal_llm_error` must not emit the - /// stall warning at all — otherwise every ordinary retry-exhaustion would - /// falsely read as an outage. This is mutation-sensitive: deleting the - /// threshold branch, weakening `>=` to `>` at the boundary, or an - /// always-warn mutation are all caught by pairing this with the 300s case - /// below. - #[test] - fn terminal_llm_error_below_threshold_emits_no_stall_warning() { - let warnings = count_stall_warnings(|| { - let _ = terminal_llm_error(Duration::from_secs(299), 3, "exhausted retries: 499"); - }); - assert_eq!(warnings, 0, "no stall warning below STALL_NOTICE_THRESHOLD"); - } - - /// At `STALL_NOTICE_THRESHOLD`, `terminal_llm_error` must emit exactly - /// one stall warning carrying the duration and attempt count — the - /// never-warn mutation is caught here; combined with the 299s case above, - /// a deleted branch or an always-warn mutation are caught by whichever - /// assertion it violates. - #[test] - fn terminal_llm_error_at_threshold_emits_one_stall_warning() { - let warnings = count_stall_warnings(|| { - let _ = terminal_llm_error(Duration::from_secs(300), 5, "transport: connection reset"); - }); - assert_eq!( - warnings, 1, - "exactly one stall warning with duration+attempts fields at threshold" - ); - } - - // ---- usage / input-token extraction ------------------------------------- - - #[test] - fn parse_anthropic_sums_input_and_cache_tokens() { - // input_tokens alone excludes cached tokens; the inclusive total must - // sum all three so a cache-heavy turn can't undercount the budget. - let v = serde_json::json!({ - "stop_reason": "end_turn", - "content": [{"type": "text", "text": "hi"}], - "usage": { - "input_tokens": 100, - "cache_read_input_tokens": 900, - "cache_creation_input_tokens": 50, - "output_tokens": 7 - } - }); - let r = parse_anthropic(v).unwrap(); - assert_eq!(r.input_tokens, Some(1050)); - } - - #[test] - fn parse_anthropic_input_tokens_only() { - let v = serde_json::json!({ - "stop_reason": "end_turn", - "content": [{"type": "text", "text": "hi"}], - "usage": {"input_tokens": 42, "output_tokens": 3} - }); - assert_eq!(parse_anthropic(v).unwrap().input_tokens, Some(42)); - } - - #[test] - fn parse_anthropic_missing_usage_is_none() { - let v = serde_json::json!({ - "stop_reason": "end_turn", - "content": [{"type": "text", "text": "hi"}] - }); - assert_eq!(parse_anthropic(v).unwrap().input_tokens, None); - } - - #[test] - fn parse_openai_uses_prompt_tokens() { - let v = serde_json::json!({ - "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], - "usage": {"prompt_tokens": 123, "completion_tokens": 4, "total_tokens": 127} - }); - assert_eq!(parse_openai(v).unwrap().input_tokens, Some(123)); - } - - #[test] - fn parse_openai_databricks_sums_cache_fields() { - // Databricks uses the OpenAI chat wire format (prompt_tokens) but also - // reports Anthropic-style cache fields; the inclusive total sums them. - let v = serde_json::json!({ - "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], - "usage": { - "prompt_tokens": 200, - "completion_tokens": 4, - "total_tokens": 204, - "cache_read_input_tokens": 800, - "cache_creation_input_tokens": 0 - } - }); - assert_eq!(parse_openai(v).unwrap().input_tokens, Some(1000)); - } - - #[test] - fn parse_openai_missing_usage_is_none() { - let v = serde_json::json!({ - "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}] - }); - assert_eq!(parse_openai(v).unwrap().input_tokens, None); - } - - #[test] - fn parse_responses_uses_input_tokens() { - let v = serde_json::json!({ - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "hi"}] - }], - "usage": {"input_tokens": 321, "output_tokens": 9, "total_tokens": 330} - }); - assert_eq!(parse_responses(v).unwrap().input_tokens, Some(321)); - } - - #[test] - fn parse_responses_missing_usage_is_none() { - let v = serde_json::json!({ - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "hi"}] - }] - }); - assert_eq!(parse_responses(v).unwrap().input_tokens, None); - } - - #[test] - fn sum_usage_empty_object_is_none() { - // A `usage` object present but carrying none of the requested fields - // is "no usable reading" -> None, not Some(0). - let v = serde_json::json!({"usage": {"output_tokens": 5}}); - assert_eq!(sum_usage(&v, &["input_tokens", "prompt_tokens"]), None); - } - - /// A token source whose `bearer()` always hands back the same stale - /// token and whose `refresh_now()` mints a distinct fresh one, counting - /// each refresh. Lets a test assert exactly how many forced refreshes a - /// `post_openai` call provoked. - struct CountingAuth { - refreshes: std::sync::atomic::AtomicU32, - } - - #[async_trait::async_trait] - impl TokenSource for CountingAuth { - async fn bearer(&self) -> Result { - Ok("stale".into()) - } - async fn refresh_now(&self, _rejected: &str) -> Result { - self.refreshes - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok("fresh".into()) - } - } - - /// Stub that answers `reject_status` to any request carrying `Bearer - /// stale` and 200 to `Bearer fresh`. When `always_reject` is set it rejects - /// unconditionally, simulating a token the refresh can never satisfy. - /// Counts requests so a test can assert "one retry, not a loop". - async fn spawn_auth_stub( - always_reject: std::sync::Arc, - reject_status: u16, - ) -> String { - use std::sync::atomic::Ordering; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - let reject_line = match reject_status { - 401 => "401 Unauthorized", - 403 => "403 Forbidden", - other => panic!("unsupported reject_status {other}"), - }; - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let always_reject = always_reject.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(k) => buf.extend_from_slice(&tmp[..k]), - } - } - let head = String::from_utf8_lossy(&buf).to_ascii_lowercase(); - let stale = head.contains("authorization: bearer stale"); - let resp = if always_reject.load(Ordering::SeqCst) || stale { - format!( - "HTTP/1.1 {reject_line}\r\nContent-Length: 11\r\n\ - Connection: close\r\n\r\ntoken stale" - ) - } else { - let body = "{\"ok\":true}"; - format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ) - }; - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - url - } - - fn llm_with(auth: Arc) -> Llm { - Llm { - http: Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .unwrap(), - auto_upgraded: std::sync::atomic::AtomicBool::new(false), - mesh_auto_state: Mutex::new(MeshAutoState::default()), - auth, - } - } - - /// A single 401 forces exactly one refresh, the retry with the fresh - /// token succeeds, and a *later* call gets its own refresh — proving the - /// one-shot guard is per-call, not stored on the source. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_openai_refreshes_once_per_call_on_401() { - use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; - - let always_401 = Arc::new(AtomicBool::new(false)); - let base = spawn_auth_stub(always_401, 401).await; - let auth = Arc::new(CountingAuth { - refreshes: AtomicU32::new(0), - }); - let llm = llm_with(auth.clone()); - let mut c = cfg(Provider::OpenAi); - c.base_url = base; - - let out = llm - .post_openai(&c, "/v1/x", &json!({}), "model") - .await - .expect("retry with fresh token should succeed"); - assert_eq!(out, json!({ "ok": true })); - assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1, "one refresh"); - - // Second call's 401 must trigger its own refresh — the guard cannot - // be a stored flag that an earlier turn already tripped. - let out2 = llm - .post_openai(&c, "/v1/x", &json!({}), "model") - .await - .unwrap(); - assert_eq!(out2, json!({ "ok": true })); - assert_eq!( - auth.refreshes.load(Ordering::SeqCst), - 2, - "later call gets its own retry" - ); - } - - /// A persistent 401 (even the refreshed token is rejected) propagates as - /// `LlmAuth` after exactly one refresh — no infinite loop. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_openai_persistent_401_propagates_after_one_retry() { - use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; - - let always_401 = Arc::new(AtomicBool::new(true)); - let base = spawn_auth_stub(always_401, 401).await; - let auth = Arc::new(CountingAuth { - refreshes: AtomicU32::new(0), - }); - let llm = llm_with(auth.clone()); - let mut c = cfg(Provider::OpenAi); - c.base_url = base; - - let err = llm - .post_openai(&c, "/v1/x", &json!({}), "model") - .await - .unwrap_err(); - assert!( - matches!(err, PostError::Agent(AgentError::LlmAuth(_))), - "got {err:?}" - ); - assert_eq!( - auth.refreshes.load(Ordering::SeqCst), - 1, - "exactly one refresh, then propagate" - ); - } - - /// A 403 is treated as refreshable: a persistent 403 forces exactly one - /// refresh-and-retry, then propagates as `LlmAuth`. Proves a revoked-token - /// 403 takes the same recovery path as a 401, bounded by the per-call guard. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_openai_persistent_403_propagates_after_one_retry() { - use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; - - let always_403 = Arc::new(AtomicBool::new(true)); - let base = spawn_auth_stub(always_403, 403).await; - let auth = Arc::new(CountingAuth { - refreshes: AtomicU32::new(0), - }); - let llm = llm_with(auth.clone()); - let mut c = cfg(Provider::OpenAi); - c.base_url = base; - - let err = llm - .post_openai(&c, "/v1/x", &json!({}), "model") - .await - .unwrap_err(); - assert!( - matches!(err, PostError::Agent(AgentError::LlmAuth(_))), - "got {err:?}" - ); - assert_eq!( - auth.refreshes.load(Ordering::SeqCst), - 1, - "403 refreshes exactly once, then propagates" - ); - } - - /// A recoverable 403 (stale token 403s, fresh token 200s) forces exactly - /// one refresh and the retry succeeds — proving a 403 enters the refresh - /// path and a refreshed token clears it, the stale-token-403 recovery case. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn post_openai_refreshes_once_on_403() { - use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; - - let always_403 = Arc::new(AtomicBool::new(false)); - let base = spawn_auth_stub(always_403, 403).await; - let auth = Arc::new(CountingAuth { - refreshes: AtomicU32::new(0), - }); - let llm = llm_with(auth.clone()); - let mut c = cfg(Provider::OpenAi); - c.base_url = base; - - let out = llm - .post_openai(&c, "/v1/x", &json!({}), "model") - .await - .expect("retry with fresh token should clear the 403"); - assert_eq!(out, json!({ "ok": true })); - assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1, "one refresh"); - } - - /// The default `refresh_now()` on a static source returns the static - /// token unchanged — a key that can't refresh still answers harmlessly. - #[tokio::test] - async fn static_token_source_refresh_now_returns_static_token() { - let src = StaticTokenSource::new("static-key"); - assert_eq!(src.refresh_now("rejected").await.unwrap(), "static-key"); - } - - // ── Output-token parsing tests ────────────────────────────────────────── - - /// `parse_anthropic` extracts `output_tokens` from the usage object. - #[test] - fn parse_anthropic_output_tokens() { - let v = serde_json::json!({ - "stop_reason": "end_turn", - "content": [{"type": "text", "text": "hi"}], - "usage": {"input_tokens": 42, "output_tokens": 7} - }); - assert_eq!(parse_anthropic(v).unwrap().output_tokens, Some(7)); - } - - /// `parse_anthropic` returns `None` for `output_tokens` when usage is absent. - #[test] - fn parse_anthropic_output_tokens_missing_usage_is_none() { - let v = serde_json::json!({ - "stop_reason": "end_turn", - "content": [{"type": "text", "text": "hi"}] - }); - assert_eq!(parse_anthropic(v).unwrap().output_tokens, None); - } - - /// `parse_openai` maps `completion_tokens` to `output_tokens`. - #[test] - fn parse_openai_output_tokens_from_completion_tokens() { - let v = serde_json::json!({ - "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], - "usage": {"prompt_tokens": 123, "completion_tokens": 4, "total_tokens": 127} - }); - assert_eq!(parse_openai(v).unwrap().output_tokens, Some(4)); - } - - /// `parse_openai` returns `None` for `output_tokens` when usage is absent. - #[test] - fn parse_openai_output_tokens_missing_usage_is_none() { - let v = serde_json::json!({ - "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}] - }); - assert_eq!(parse_openai(v).unwrap().output_tokens, None); - } - - /// `parse_responses` extracts `output_tokens` from the usage object. - #[test] - fn parse_responses_output_tokens() { - let v = serde_json::json!({ - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "hi"}] - }], - "usage": {"input_tokens": 321, "output_tokens": 9, "total_tokens": 330} - }); - assert_eq!(parse_responses(v).unwrap().output_tokens, Some(9)); - } - - /// `parse_responses` returns `None` for `output_tokens` when usage is absent. - #[test] - fn parse_responses_output_tokens_missing_usage_is_none() { - let v = serde_json::json!({ - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "hi"}] - }] - }); - assert_eq!(parse_responses(v).unwrap().output_tokens, None); - } -} diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs deleted file mode 100644 index fa8815df50..0000000000 --- a/crates/buzz-agent/src/mcp.rs +++ /dev/null @@ -1,1139 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use arc_swap::ArcSwap; -use rmcp::model::CallToolRequestParams; -use rmcp::service::{RoleClient, RunningService}; -use rmcp::transport::TokioChildProcess; -use rmcp::ServiceError; -use rmcp::ServiceExt; -use serde_json::{Map, Value}; -use tokio::process::Command; -use tokio::sync::watch; -use tokio::sync::Mutex as AsyncMutex; - -use crate::config::{Config, HookServers}; -use crate::types::{clamp, AgentError, McpServerStdio, ToolDef, ToolResult, ToolResultContent}; - -const SEP: &str = "__"; -const MAX_NAME_LEN: usize = 128; -const MAX_QNAME_LEN: usize = 64; -const MAX_TOOLS_PER_SESSION: usize = 128; -const MAX_DESCRIPTION_BYTES: usize = 1024; -const MAX_SCHEMA_BYTES: usize = 4096; -const MARKER_FIELD_MAX: usize = 256; -pub const MAX_MCP_SERVERS: usize = 16; -const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024; - -/// Byte budgets for a single tool result. `total` bounds everything the -/// result may occupy in history (text + images); `text` bounds the text -/// portion alone, since text is where runaway outputs (build logs, file -/// dumps) live while images are legitimately large and self-limiting. -#[derive(Clone, Copy)] -pub struct ResultBudget { - pub total: usize, - pub text: usize, -} - -const PASSTHROUGH_ENV: &[&str] = &[ - // Core - "PATH", - "HOME", - "TERM", - "LANG", - "LC_ALL", - "TMPDIR", - "XDG_CONFIG_HOME", - // SSH — required for git clone/push over SSH (git@github.com:...) - "SSH_AUTH_SOCK", - "SSH_AGENT_PID", - // Git — operator-configured helpers and transport overrides - "GIT_ASKPASS", - "GIT_SSH_COMMAND", - "GIT_CONFIG_GLOBAL", - // Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then - // removes it from its own env (children never see it). BUZZ_PRIVATE_KEY - // and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a - // non-secret signed ownership attestation needed by portable owner-scoped - // CLI operations; MCP subprocesses are trusted like the agent runtime. - "NOSTR_PRIVATE_KEY", - "BUZZ_PRIVATE_KEY", - "BUZZ_RELAY_URL", - "BUZZ_AUTH_TAG", -]; - -// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what -// std::env::temp_dir() consults — without them it falls back to C:\Windows, -// which child processes can't write to (PermissionDenied). USERPROFILE is the -// always-set floor. APPDATA carries child-tool config (git, etc.). -#[cfg(windows)] -const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "APPDATA"]; - -/// Environment retained by `spawn_one()` after `env_clear()` on Windows. -/// Shell resolver keys are shared with Doctor through the public contract. -#[cfg(windows)] -fn windows_child_passthrough_env() -> impl Iterator { - PASSTHROUGH_ENV_WINDOWS - .iter() - .copied() - .chain(crate::WINDOWS_SHELL_RESOLUTION_ENV.iter().copied()) -} - -type Client = RunningService; - -#[derive(Clone)] -struct ServerSpec { - name: String, - command: String, - args: Vec, - env: Vec<(String, String)>, - cwd: String, -} - -enum ClientState { - Healthy { - client: Arc, - pgid: Option, - tools: Arc>, - }, - Dead { - attempts: u32, - next_retry: Instant, - reason: String, - // Preserved from the last Healthy state so tools() filtering stays accurate while dead. - tools: Arc>, - }, -} - -struct Server { - name: String, - spec: ServerSpec, - client: ArcSwap, - restart_lock: AsyncMutex<()>, -} - -impl Drop for Server { - fn drop(&mut self) { - if let ClientState::Healthy { pgid: Some(p), .. } = &**self.client.load() { - killpg(*p, &self.name, "drop"); - } - } -} - -enum RestartCheck { - Healthy, - Ready { - attempt_n: u32, - prev_tools: Arc>, - }, -} - -fn check_restart_state(server: &Server, max_attempts: u32) -> Result { - match &**server.client.load() { - ClientState::Healthy { .. } => Ok(RestartCheck::Healthy), - ClientState::Dead { attempts, .. } if *attempts >= max_attempts => { - Err(AgentError::Mcp(format!( - "The MCP server '{}' is unavailable (exhausted). Its tools have been removed for this session.", - server.name - ))) - } - ClientState::Dead { next_retry, reason, .. } if Instant::now() < *next_retry => { - Err(AgentError::Mcp(format!( - "server '{}' is recovering (last error: {reason}). Try again later or use a different tool.", - server.name - ))) - } - ClientState::Dead { attempts, tools, .. } => Ok(RestartCheck::Ready { - attempt_n: attempts + 1, - prev_tools: tools.clone(), - }), - } -} - -struct Entry { - server_idx: usize, - bare: String, -} - -pub struct McpRegistry { - by_qname: HashMap, - defs: Vec, - servers: Vec>, - max_attempts: u32, - backoff_base: Duration, - backoff_max: Duration, - init_timeout: Duration, - /// Consecutive hook timeout count per server. Kill on second consecutive. - hook_timeouts: std::sync::Mutex>, -} - -impl McpRegistry { - pub async fn spawn_all( - cfg: &Config, - servers: &[McpServerStdio], - cwd: &str, - ) -> Result { - if servers.len() > MAX_MCP_SERVERS { - return Err(AgentError::Mcp(format!( - "too many MCP servers: {} > {MAX_MCP_SERVERS}", - servers.len() - ))); - } - let mut reg = Self { - by_qname: HashMap::new(), - defs: Vec::new(), - servers: Vec::new(), - - max_attempts: cfg.mcp_max_restart_attempts.max(1), - backoff_base: Duration::from_millis(cfg.mcp_restart_base_ms.max(1)), - backoff_max: Duration::from_millis(cfg.mcp_restart_max_ms.max(1)), - init_timeout: cfg.mcp_init_timeout, - hook_timeouts: std::sync::Mutex::new(HashMap::new()), - }; - - let mut seen_names = HashSet::new(); - for s in servers { - if !valid_name(&s.name) || s.name.contains("__") { - return Err(AgentError::Mcp(format!("invalid server name: {}", s.name))); - } - if !seen_names.insert(s.name.clone()) { - return Err(AgentError::Mcp(format!( - "duplicate server name: {}", - s.name - ))); - } - let spec = ServerSpec { - name: s.name.clone(), - command: s.command.clone(), - args: s.args.clone(), - env: s - .env - .iter() - .map(|e| (e.name.clone(), e.value.clone())) - .collect(), - cwd: cwd.to_owned(), - }; - let (client, pgid, tool_names, raw_tools) = spawn_one(&spec, reg.init_timeout).await?; - let server_idx = reg.servers.len(); - let server = Arc::new(Server { - name: spec.name.clone(), - spec, - client: ArcSwap::from_pointee(ClientState::Healthy { - client: Arc::new(client), - pgid, - tools: Arc::new(tool_names), - }), - restart_lock: AsyncMutex::new(()), - }); - reg.servers.push(server); - - for t in raw_tools { - if reg.defs.len() >= MAX_TOOLS_PER_SESSION { - return Err(AgentError::Mcp(format!( - "too many tools (>{MAX_TOOLS_PER_SESSION})" - ))); - } - let bare = t.name.to_string(); - if !valid_name(&bare) || bare.contains("__") { - return Err(AgentError::Mcp(format!("invalid tool name: {bare}"))); - } - let qname = format!("{}{SEP}{}", s.name, bare); - if qname.len() > MAX_QNAME_LEN { - return Err(AgentError::Mcp(format!( - "qualified tool name too long: {} ({} > {MAX_QNAME_LEN})", - qname, - qname.len() - ))); - } - if reg.by_qname.contains_key(&qname) { - return Err(AgentError::Mcp(format!("duplicate tool: {qname}"))); - } - reg.defs.push(ToolDef { - name: qname.clone(), - description: clamp( - t.description.as_deref().unwrap_or("").to_owned(), - MAX_DESCRIPTION_BYTES, - ), - input_schema: cap_schema(&qname, Value::Object((*t.input_schema).clone())), - }); - reg.by_qname.insert(qname, Entry { server_idx, bare }); - } - } - Ok(reg) - } - - pub fn server_of(&self, qname: &str) -> Option<&str> { - self.by_qname - .get(qname) - .map(|e| self.servers[e.server_idx].name.as_str()) - } - - pub fn has(&self, qname: &str) -> bool { - self.by_qname.contains_key(qname) - } - - /// True if `qname` resolves to a hidden hook tool (bare name starts - /// with `_`). Used to reject hook calls coming from the LLM path — - /// hooks are only callable via `call_hooks`. - pub fn is_hook(&self, qname: &str) -> bool { - self.by_qname - .get(qname) - .map(|e| e.bare.starts_with('_')) - .unwrap_or(false) - } - - pub fn tools(&self) -> Vec { - self.defs - .iter() - .filter(|d| { - let entry = match self.by_qname.get(&d.name) { - Some(e) => e, - None => return false, - }; - // Bare names starting with `_` are hooks — invisible to the LLM. - if entry.bare.starts_with('_') { - return false; - } - let server = &self.servers[entry.server_idx]; - match &**server.client.load() { - ClientState::Healthy { tools, .. } => tools.iter().any(|t| t == &entry.bare), - ClientState::Dead { - attempts, tools, .. - } => *attempts < self.max_attempts && tools.iter().any(|t| t == &entry.bare), - } - }) - .cloned() - .collect() - } - - /// Call every tool whose bare name equals `hook_name` across all - /// allowlisted servers in parallel, bounded by `timeout`. Returns - /// `(server_name, text)` pairs in **config order** (deterministic), - /// dropping empty/whitespace-only responses, errors and timeouts. - /// Hooks are fail-open and must never block the agent. - pub async fn call_hooks( - self: &Arc, - hook_name: &str, - input: &Value, - timeout: Duration, - allowed: &HookServers, - ) -> Vec<(String, String)> { - if allowed.is_disabled() { - return Vec::new(); - } - // Walk servers in registration order so the result is deterministic - // regardless of HashMap iteration order or task completion order. - let mut targets: Vec<(usize, String, String)> = Vec::new(); - for (idx, server) in self.servers.iter().enumerate() { - if !allowed.allows(&server.name) { - continue; - } - let qname = format!("{}{SEP}{}", server.name, hook_name); - if self.by_qname.contains_key(&qname) { - targets.push((idx, server.name.clone(), qname)); - } - } - if targets.is_empty() { - return Vec::new(); - } - let mut set = tokio::task::JoinSet::new(); - for (idx, server_name, qname) in targets { - let reg = Arc::clone(self); - let args = input.clone(); - set.spawn(async move { - // Hooks are intentionally non-cancellable: they are - // already bounded by their own timeout and are fail-open. - // Session cancel should not interrupt hook evaluation. - let (_dummy_tx, mut dummy_cancel) = watch::channel(false); - let res = tokio::time::timeout( - timeout, - reg.call( - &qname, - "hook", - &args, - ResultBudget { - total: MAX_HOOK_RESULT_BYTES, - text: MAX_HOOK_RESULT_BYTES, - }, - &mut dummy_cancel, - ), - ) - .await; - drop(_dummy_tx); - (idx, server_name, res) - }); - } - let mut indexed: Vec<(usize, String, String)> = Vec::new(); - while let Some(joined) = set.join_next().await { - // fail-open: drop join errors, timeouts, call errors, - // empty/whitespace-only text. On timeout, also kill the server - // process group so a wedged hook can't poison the next regular - // tool call. The registry's lazy restart handles the rest. - match joined { - Ok((idx, server_name, Ok(Ok(r)))) => { - // Success — reset consecutive timeout counter. - if let Ok(mut counts) = self.hook_timeouts.lock() { - counts.remove(&server_name); - } - if !r.is_error && !r.text().trim().is_empty() { - indexed.push((idx, server_name, r.text())); - } - } - Ok((_idx, server_name, Err(_elapsed))) => { - // Kill only on second consecutive timeout. - let count = { - let mut counts = - self.hook_timeouts.lock().unwrap_or_else(|e| e.into_inner()); - let c = counts.entry(server_name.clone()).or_insert(0); - *c += 1; - *c - }; - if count >= 2 { - tracing::warn!( - "hook: killing server '{}' after {} consecutive timeouts", - server_name, - count - ); - self.kill_server(&server_name, "hook timeout (consecutive)"); - if let Ok(mut counts) = self.hook_timeouts.lock() { - counts.remove(&server_name); - } - } else { - tracing::warn!("hook: server '{}' timed out ({}/2)", server_name, count); - } - } - _ => {} - } - } - indexed.sort_by_key(|(idx, _, _)| *idx); - indexed - .into_iter() - .map(|(_, name, text)| (name, text)) - .collect() - } - - /// Kill the server's process group and mark it dead. Idempotent: - /// if the server is already Dead (or unknown), this is a no-op. - /// Counts as one attempt toward the restart budget so that a - /// pathological server (starts fine, deadlocks on every call) - /// eventually exhausts. - pub fn kill_server(&self, name: &str, reason: &str) { - let server = match self.servers.iter().find(|s| s.name == name) { - Some(s) => s, - None => return, - }; - let current = server.client.load_full(); - let (pgid, tools) = match &*current { - ClientState::Dead { .. } => return, - ClientState::Healthy { pgid, tools, .. } => (*pgid, tools.clone()), - }; - let dead = Arc::new(ClientState::Dead { - attempts: 1, - next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max), - reason: reason.to_owned(), - tools, - }); - // CAS so we don't clobber a concurrent restart that already - // transitioned the state. If the swap fails, the kill below is - // still safe — the pgid we read belonged to a process we observed - // as Healthy, and killpg on an already-reaped pgid is a no-op. - let prev = server.client.compare_and_swap(¤t, dead); - if Arc::ptr_eq(&prev, ¤t) { - if let Some(p) = pgid { - killpg(p, &server.name, "kill_server"); - } - tracing::error!( - "MCP server '{}' killed and marked dead (reason={reason})", - server.name - ); - } - } - - fn kill_and_mark_dead_if_current( - &self, - server: &Server, - failed_client: &Arc, - reason: &str, - ) { - let current = server.client.load_full(); - match &*current { - ClientState::Healthy { - client, - pgid, - tools, - } if Arc::ptr_eq(client, failed_client) => { - if let Some(p) = *pgid { - killpg(p, &server.name, "call_failed"); - } - let dead = Arc::new(ClientState::Dead { - attempts: 1, - next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max), - reason: reason.to_owned(), - tools: tools.clone(), - }); - let _ = server.client.compare_and_swap(¤t, dead); - tracing::error!( - "MCP server '{}' killed and marked dead (reason={reason})", - server.name - ); - } - _ => {} - } - } - - pub async fn call( - &self, - qname: &str, - provider_id: &str, - arguments: &Value, - budget: ResultBudget, - cancel: &mut watch::Receiver, - ) -> Result { - let entry = self - .by_qname - .get(qname) - .ok_or_else(|| AgentError::Mcp(format!("unknown tool {qname}")))?; - let server = self.servers[entry.server_idx].clone(); - - let state = server.client.load(); - if let ClientState::Healthy { client, tools, .. } = &**state { - if !tools.iter().any(|t| t == &entry.bare) { - return Err(AgentError::Mcp(format!( - "tool '{qname}': no longer available; the MCP server restarted with a different tool set." - ))); - } - let client = client.clone(); - drop(state); - return self - .do_call( - &server, - &client, - &entry.bare, - qname, - provider_id, - arguments, - budget, - cancel, - ) - .await; - } - drop(state); - - self.maybe_restart(&server).await?; - let state = server.client.load(); - let client = match &**state { - ClientState::Healthy { client, tools, .. } => { - if !tools.iter().any(|t| t == &entry.bare) { - return Err(AgentError::Mcp(format!( - "tool '{qname}': no longer available; the MCP server restarted with a different tool set." - ))); - } - client.clone() - } - ClientState::Dead { reason, .. } => { - return Err(AgentError::Mcp(format!( - "tool '{qname}': server '{}' restart failed: {reason}", - server.name - ))); - } - }; - drop(state); - self.do_call( - &server, - &client, - &entry.bare, - qname, - provider_id, - arguments, - budget, - cancel, - ) - .await - } - - #[allow(clippy::too_many_arguments)] - async fn do_call( - &self, - server: &Server, - client: &Arc, - bare: &str, - qname: &str, - provider_id: &str, - arguments: &Value, - budget: ResultBudget, - cancel: &mut watch::Receiver, - ) -> Result { - let arg_obj = match arguments { - Value::Object(m) => Some(m.clone()), - Value::Null => None, - _ => { - return Err(AgentError::Mcp(format!( - "tool {qname} arguments must be a JSON object" - ))) - } - }; - let mut params = CallToolRequestParams::default(); - params.name = bare.to_owned().into(); - params.arguments = arg_obj; - - use rmcp::model::{CallToolRequest, ClientRequest, ServerResult}; - use rmcp::service::PeerRequestOptions; - - let req = ClientRequest::CallToolRequest(CallToolRequest::new(params)); - let mut handle = client - .peer() - .send_cancellable_request(req, PeerRequestOptions::no_options()) - .await - .map_err(|e| AgentError::Mcp(format!("call {qname}: {e}")))?; - - // Early cancel check — watch::changed() only fires on NEW writes. - if *cancel.borrow() { - fire_and_forget_cancel(handle, qname); - return Err(AgentError::Cancelled); - } - - // Poll the inner oneshot directly so we can still own `handle` in - // the cancel branch (await_response would move it). - let raw: Result = tokio::select! { - biased; - _ = cancel.changed() => { - fire_and_forget_cancel(handle, qname); - return Err(AgentError::Cancelled); - } - r = &mut handle.rx => match r { - Ok(inner) => inner, - Err(_) => Err(ServiceError::TransportClosed), - }, - }; - - let res = match raw { - Ok(ServerResult::CallToolResult(r)) => r, - Ok(_) => { - return Err(AgentError::Mcp(format!( - "call {qname}: unexpected response type" - ))) - } - Err(e) => { - if is_transport_error(&e) { - self.kill_and_mark_dead_if_current( - server, - client, - &format!("call failed: {e}"), - ); - return Err(AgentError::Mcp(format!("call {qname}: {e}"))); - } - // Application-level JSON-RPC error (e.g. -32602 invalid params). - // Server is healthy — it correctly rejected bad input. Return to LLM. - return Ok(ToolResult { - provider_id: provider_id.to_owned(), - content: vec![ToolResultContent::Text(clamp( - format!("Tool call rejected: {e}"), - budget.text, - ))], - is_error: true, - }); - } - }; - let content = tool_result_content(&res.content, budget.total, budget.text); - Ok(ToolResult { - provider_id: provider_id.to_owned(), - content, - is_error: res.is_error.unwrap_or(false), - }) - } - - async fn maybe_restart(&self, server: &Server) -> Result<(), AgentError> { - match check_restart_state(server, self.max_attempts)? { - RestartCheck::Healthy => return Ok(()), - RestartCheck::Ready { .. } => {} - } - - let _guard = server.restart_lock.lock().await; - - let (attempt_n, prev_tools) = match check_restart_state(server, self.max_attempts)? { - RestartCheck::Healthy => return Ok(()), - RestartCheck::Ready { - attempt_n, - prev_tools, - } => (attempt_n, prev_tools), - }; - - let started = Instant::now(); - tracing::info!( - "MCP server '{}' restarting (attempt {attempt_n}/{})", - server.name, - self.max_attempts - ); - match spawn_one(&server.spec, self.init_timeout).await { - Ok((client, pgid, tool_names, _raw_tools)) => { - server.client.store(Arc::new(ClientState::Healthy { - client: Arc::new(client), - pgid, - tools: Arc::new(tool_names), - })); - - tracing::info!( - "MCP server '{}' restarted in {}ms (attempt {attempt_n})", - server.name, - started.elapsed().as_millis() - ); - Ok(()) - } - Err(e) => { - let reason = format!("restart failed: {e}"); - let permanent = attempt_n >= self.max_attempts; - let next_retry = if permanent { - Instant::now() + Duration::from_secs(86_400) - } else { - Instant::now() + backoff(attempt_n, self.backoff_base, self.backoff_max) - }; - server.client.store(Arc::new(ClientState::Dead { - attempts: attempt_n, - next_retry, - reason: reason.clone(), - tools: prev_tools, - })); - - tracing::error!( - "MCP server '{}' restart failed (attempt {attempt_n}/{}, permanent={permanent}): {reason}", - server.name, self.max_attempts - ); - Err(AgentError::Mcp(reason)) - } - } - } -} - -async fn spawn_one( - spec: &ServerSpec, - timeout: Duration, -) -> Result<(Client, Option, Vec, Vec), AgentError> { - let mut cmd = Command::new(&spec.command); - cmd.args(&spec.args); - cmd.env_clear(); - for k in PASSTHROUGH_ENV { - if let Ok(v) = std::env::var(k) { - cmd.env(k, v); - } - } - #[cfg(windows)] - for k in windows_child_passthrough_env() { - if let Ok(v) = std::env::var(k) { - cmd.env(k, v); - } - } - for (k, v) in &spec.env { - cmd.env(k, v); - } - cmd.current_dir(&spec.cwd); - cmd.stderr(std::process::Stdio::inherit()); - - #[cfg(unix)] - cmd.process_group(0); - - configure_no_window(&mut cmd); - - let transport = TokioChildProcess::new(cmd) - .map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?; - let pgid = transport.id(); - - struct PgidGuard { - pgid: Option, - name: String, - } - impl Drop for PgidGuard { - fn drop(&mut self) { - if let Some(p) = self.pgid.take() { - killpg(p, &self.name, "spawn_dropped"); - } - } - } - let mut guard = PgidGuard { - pgid, - name: spec.name.clone(), - }; - - let client: Client = match tokio::time::timeout(timeout, ().serve(transport)).await { - Ok(Ok(c)) => c, - Ok(Err(e)) => { - return Err(AgentError::Mcp(format!("init {}: {e}", spec.name))); - } - Err(_) => { - return Err(AgentError::Mcp(timeout_msg("init", &spec.name, timeout))); - } - }; - - let tools = match tokio::time::timeout(timeout, client.peer().list_all_tools()).await { - Ok(Ok(t)) => t, - Ok(Err(e)) => { - return Err(AgentError::Mcp(format!("list_tools {}: {e}", spec.name))); - } - Err(_) => { - return Err(AgentError::Mcp(timeout_msg( - "list_tools", - &spec.name, - timeout, - ))); - } - }; - let names: Vec = tools.iter().map(|t| t.name.to_string()).collect(); - guard.pgid = None; - Ok((client, pgid, names, tools)) -} - -/// Send `notifications/cancelled` to the MCP server, fire-and-forget. -/// Per MCP spec, cancellation notifications are best-effort; we never -/// block the agent on slow server stdio. -fn fire_and_forget_cancel( - handle: rmcp::service::RequestHandle, - qname: &str, -) { - let qname_owned = qname.to_owned(); - tokio::spawn(async move { - if let Err(e) = handle.cancel(Some("session cancelled".into())).await { - tracing::debug!("cancel notification failed for {qname_owned}: {e}"); - } - }); -} - -/// Returns `true` for errors indicating the MCP server process is dead or -/// unreachable. Returns `false` for application-level JSON-RPC errors where -/// the server is healthy but rejected the request (e.g. invalid params). -fn is_transport_error(e: &ServiceError) -> bool { - matches!( - e, - ServiceError::TransportSend(_) - | ServiceError::TransportClosed - | ServiceError::Timeout { .. } - | ServiceError::UnexpectedResponse - ) -} - -fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration { - let shift = attempt.saturating_sub(1).min(20); - let scaled = base.saturating_mul(1u32 << shift); - let capped = scaled.min(max); - let ms = capped.as_millis() as u64; - let jitter_pct = jitter_percent(); - let jittered = (ms as i64) + ((ms as i64) * jitter_pct / 100); - Duration::from_millis(jittered.max(0) as u64) -} - -fn jitter_percent() -> i64 { - let mut buf = [0u8; 1]; - let _ = getrandom::fill(&mut buf); - ((buf[0] as i64) % 41) - 20 -} - -fn timeout_msg(stage: &str, name: &str, t: Duration) -> String { - format!("{stage} {name}: timeout after {}s", t.as_secs()) -} - -fn cap_schema(qname: &str, schema: Value) -> Value { - let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0); - if size <= MAX_SCHEMA_BYTES { - return schema; - } - tracing::warn!( - "tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object" - ); - Value::Object(Map::new()) -} - -#[cfg(unix)] -fn killpg(pgid: u32, name: &str, stage: &str) { - use nix::sys::signal::{killpg as nix_killpg, Signal}; - use nix::unistd::Pid; - let result = nix_killpg(Pid::from_raw(pgid as i32), Signal::SIGKILL); - tracing::info!( - "killpg MCP {name} ({stage}) pgid={pgid} ok={}", - result.is_ok() - ); -} -#[cfg(not(unix))] -fn killpg(_pgid: u32, name: &str, stage: &str) { - tracing::info!("relying on Drop to kill MCP {name} ({stage})"); -} - -fn valid_name(s: &str) -> bool { - !s.is_empty() - && s.len() <= MAX_NAME_LEN - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') -} - -pub(crate) fn truncate_at_boundary(s: &str, max: usize) -> &str { - if s.len() <= max { - return s; - } - let mut cut = max; - while cut > 0 && !s.is_char_boundary(cut) { - cut -= 1; - } - &s[..cut] -} - -/// Byte allowance reserved for the elision marker inside [`truncate_middle`]. -/// The marker is ~80 bytes; the slack keeps the arithmetic safely one-sided. -const ELISION_MARKER_ALLOWANCE: usize = 128; - -/// Truncate `s` to at most `max` bytes by eliding the *middle*, keeping the -/// head and tail. Tool output puts its conclusion at the end (test summaries, -/// error trailers) and its identity at the start; head-only truncation loses -/// the part the model needs most. The marker reports how much was elided so -/// the model knows to re-run a narrower command rather than trust the gap. -pub(crate) fn truncate_middle(s: &str, max: usize) -> String { - if s.len() <= max { - return s.to_owned(); - } - let keep = max.saturating_sub(ELISION_MARKER_ALLOWANCE); - if keep == 0 { - // Budget too small for head + marker + tail; degrade to a head cut. - return truncate_at_boundary(s, max).to_owned(); - } - let head = truncate_at_boundary(s, keep.div_ceil(2)); - let mut tail_start = s.len() - keep / 2; - while tail_start < s.len() && !s.is_char_boundary(tail_start) { - tail_start += 1; - } - let tail = &s[tail_start..]; - let elided = s.len() - head.len() - tail.len(); - format!( - "{head}\n[... {elided} of {} bytes elided from tool result ...]\n{tail}", - s.len() - ) -} - -/// Assemble tool-result content under two budgets: `max_bytes` bounds the -/// whole result (text + images), `max_text_bytes` bounds the text portion -/// alone. Images are large by nature and pass through whole or get elided -/// with a marker; text is middle-elided so the head (what ran) and tail -/// (how it ended) both survive. Every elision leaves an inline marker. -fn tool_result_content( - blocks: &[rmcp::model::Content], - max_bytes: usize, - max_text_bytes: usize, -) -> Vec { - use rmcp::model::RawContent; - let mut out = Vec::new(); - let mut text = String::new(); - let mut used = 0usize; // total bytes emitted (text + images) - let mut text_used = 0usize; // text bytes emitted - let short = |s: &str| truncate_at_boundary(s, MARKER_FIELD_MAX).to_owned(); - - // Flush accumulated text, middle-eliding to whatever budget remains. - let flush_text = |out: &mut Vec, - text: &mut String, - used: &mut usize, - text_used: &mut usize| { - if text.is_empty() { - return; - } - let budget = max_text_bytes - .saturating_sub(*text_used) - .min(max_bytes.saturating_sub(*used)); - let kept = truncate_middle(&std::mem::take(text), budget); - *used = used.saturating_add(kept.len()); - *text_used = text_used.saturating_add(kept.len()); - if !kept.is_empty() { - out.push(ToolResultContent::Text(kept)); - } - }; - - let append = |text: &mut String, s: &str| { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(s); - }; - - for c in blocks { - match &c.raw { - RawContent::Text(t) => append(&mut text, &t.text), - RawContent::Image(i) => { - flush_text(&mut out, &mut text, &mut used, &mut text_used); - let image_bytes = i.data.len().saturating_add(i.mime_type.len()); - if used.saturating_add(image_bytes) <= max_bytes { - used = used.saturating_add(image_bytes); - out.push(ToolResultContent::Image { - data: i.data.clone(), - mime_type: i.mime_type.clone(), - }); - } else { - append( - &mut text, - &format!( - "[image elided: {}, {} base64 bytes exceeds remaining tool-result budget]", - short(&i.mime_type), - i.data.len() - ), - ); - } - } - RawContent::Audio(a) => append( - &mut text, - &format!( - "[audio elided: {}, {} bytes]", - short(&a.mime_type), - a.data.len() - ), - ), - RawContent::ResourceLink(r) => { - append(&mut text, &format!("[resource: {}]", short(&r.uri))); - } - RawContent::Resource(_) => append(&mut text, "[resource elided]"), - } - } - flush_text(&mut out, &mut text, &mut used, &mut text_used); - out -} - -/// Suppress the console window that Windows otherwise allocates for every -/// console-subsystem child process spawned from a GUI (non-console) parent. -/// No-op on non-Windows platforms. -fn configure_no_window(cmd: &mut Command) { - #[cfg(windows)] - { - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); - } - #[cfg(not(windows))] - let _ = cmd; -} - -#[cfg(test)] -mod content_tests { - use super::*; - - #[test] - fn passthrough_includes_buzz_owner_attestation() { - assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG")); - } - use rmcp::model::Content; - - #[cfg(windows)] - #[test] - fn windows_passthrough_includes_shell_resolution_vars() { - // Temp directories and every resolver key must survive `env_clear()`. - for var in ["TMP", "TEMP", "USERPROFILE"] { - assert!( - PASSTHROUGH_ENV_WINDOWS.contains(&var), - "{var} must pass through for Windows child processes" - ); - } - let child_env: Vec<_> = windows_child_passthrough_env().collect(); - for var in crate::WINDOWS_SHELL_RESOLUTION_ENV { - assert!( - child_env.contains(var), - "{var} must pass through so the MCP shell resolver matches Doctor" - ); - } - } - - #[test] - fn tool_result_content_preserves_images() { - let blocks = vec![ - Content::text("header"), - Content::image("aW1n", "image/png"), - Content::text("tail"), - ]; - let out = tool_result_content(&blocks, 1024, 1024); - assert_eq!(out.len(), 3); - assert!(matches!(&out[0], ToolResultContent::Text(t) if t == "header")); - assert!(matches!( - &out[1], - ToolResultContent::Image { data, mime_type } - if data == "aW1n" && mime_type == "image/png" - )); - assert!(matches!(&out[2], ToolResultContent::Text(t) if t == "tail")); - } - - #[test] - fn tool_result_content_elides_images_over_budget() { - let blocks = vec![Content::image("a".repeat(300), "image/png")]; - let out = tool_result_content(&blocks, 256, 256); - assert_eq!(out.len(), 1); - assert!(matches!(&out[0], ToolResultContent::Text(t) if t.contains("image elided"))); - } - - #[test] - fn oversized_text_is_middle_elided() { - let mut body = String::new(); - for i in 0..5000 { - body.push_str(&format!("line {i}\n")); - } - let blocks = vec![Content::text(body.clone())]; - let out = tool_result_content(&blocks, 1024 * 1024, 4096); - assert_eq!(out.len(), 1); - let ToolResultContent::Text(t) = &out[0] else { - panic!("expected text"); - }; - assert!(t.len() <= 4096, "text exceeds budget: {}", t.len()); - assert!(t.starts_with("line 0\n"), "head lost"); - assert!(t.ends_with("line 4999\n"), "tail lost"); - assert!( - t.contains("bytes elided from tool result"), - "missing elision marker" - ); - } - - #[test] - fn text_within_budget_is_untouched() { - let blocks = vec![Content::text("short output")]; - let out = tool_result_content(&blocks, 1024 * 1024, 4096); - assert_eq!(out.len(), 1); - assert!(matches!(&out[0], ToolResultContent::Text(t) if t == "short output")); - } - - #[test] - fn image_passes_whole_even_when_text_budget_is_small() { - let big_text = "x".repeat(10_000); - let img = "a".repeat(100_000); - let blocks = vec![ - Content::text(big_text), - Content::image(img.clone(), "image/png"), - ]; - let out = tool_result_content(&blocks, 8 * 1024 * 1024, 4096); - assert_eq!(out.len(), 2); - assert!(matches!(&out[0], ToolResultContent::Text(t) if t.len() <= 4096)); - assert!(matches!( - &out[1], - ToolResultContent::Image { data, .. } if data == &img - )); - } - - #[test] - fn truncate_middle_respects_max_and_boundaries() { - let s = "é".repeat(60_000); // 2-byte chars stress boundary handling - for max in [200usize, 1024, 50 * 1024] { - let out = super::truncate_middle(&s, max); - assert!(out.len() <= max, "max={max} got {}", out.len()); - assert!(std::str::from_utf8(out.as_bytes()).is_ok()); - } - assert_eq!(super::truncate_middle("ok", 1024), "ok"); - } - - #[test] - fn configure_no_window_is_a_noop_on_non_windows() { - // Cross-host: calling configure_no_window must not panic on any OS. - // On non-Windows the body is a cfg-gated no-op and the argument is - // consumed as `let _ = cmd`, so the only assertion is "didn't crash". - let mut cmd = Command::new("true"); - configure_no_window(&mut cmd); - } - - #[cfg(windows)] - #[test] - fn configure_no_window_compiles_and_applies_flag_on_windows() { - // On Windows, creation_flags(0x0800_0000) must be accepted without panicking. - // The call is a setter with no getter on tokio::process::Command, so the - // regression test confirms the flag is SET by checking the std inner command. - let mut cmd = Command::new("cmd.exe"); - configure_no_window(&mut cmd); - // std::process::Command on Windows does have as_inner / get_creation_flags via - // CommandExt — but tokio wraps it; we verify by ensuring the call compiles and - // the resulting spawn wouldn't OOM (build+flag-set is the full contract here). - // The real protection is the cfg-gated production path in spawn_one(). - } -} diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index d29e975e03..de5e50b3a2 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -1,196 +1,18 @@ -use serde::Deserialize; -use serde_json::Value; - -/// Byte-equivalent charged to the handoff/context-pressure gate for a single -/// image tool result. The gate maps bytes to tokens at 1 byte/token (see -/// `handoff::CONSERVATIVE_BYTES_PER_TOKEN`), so this is also the per-image -/// token budget. Providers bill an image as visual *tiles*, not its base64 -/// length: Anthropic caps at ~1600 tokens/image and OpenAI high-detail lands -/// ~1.1K–1.5K. We charge 16 KiB — a generous ceiling that still over-counts -/// the real ~2K cost, while being ~190× smaller than the base64 length of a -/// typical multi-MiB screenshot. Charging `data.len()` to the gate instead -/// made a single `view_image` (~3.1M base64 bytes) trip the handoff gate on a -/// fresh context. -const IMAGE_CONTEXT_TOKEN_EQUIV: usize = 16 * 1024; - -#[derive(Debug, Clone)] -pub enum ToolResultContent { - Text(String), - Image { data: String, mime_type: String }, -} - -impl ToolResultContent { - /// Real serialized size in bytes. Used by `truncate_history` to keep the - /// outgoing request body under `max_history_bytes` — an image rides the - /// wire as its full base64 string, so that string's length is what counts - /// here. For context-window/handoff pressure use - /// [`Self::context_pressure_bytes`] instead, which charges an image its - /// (far smaller) visual-token equivalent. - pub fn estimated_bytes(&self) -> usize { - match self { - Self::Text(s) => s.len(), - Self::Image { data, mime_type } => data.len() + mime_type.len(), - } - } +//! Wire-facing types shared with `wire.rs`. +//! +//! This is a deliberate subset of `crates/buzz-agent/src/types.rs` (353 lines). +//! The original carried the hand-written loop's internal vocabulary — +//! `HistoryItem`, `ToolCall`, `ToolResult`, `LlmResponse`, `ProviderStop`, +//! `ToolDef`, plus byte-accounting helpers (`estimated_bytes`, +//! `context_pressure_bytes`) that fed the bespoke handoff heuristic. +//! +//! Goose owns all of that now: conversation state is `goose::conversation`, +//! tool plumbing is `rmcp`, and compaction is `goose::context_mgmt`. What +//! survives here is only what crosses the ACP wire. - /// Token-equivalent context-window pressure, in bytes (the handoff gate - /// maps bytes→tokens at 1:1). Identical to [`Self::estimated_bytes`] for - /// text, but an image is charged a flat [`IMAGE_CONTEXT_TOKEN_EQUIV`] - /// budget rather than its base64 length — providers bill it as visual - /// tiles (~2K tokens), so counting `data.len()` over-counts by ~1500× and - /// forces a handoff on a single image. - pub fn context_pressure_bytes(&self) -> usize { - match self { - Self::Text(s) => s.len(), - Self::Image { data: _, mime_type } => IMAGE_CONTEXT_TOKEN_EQUIV + mime_type.len(), - } - } - - pub fn as_text_lossy(&self) -> String { - match self { - Self::Text(s) => s.clone(), - Self::Image { data, mime_type } => { - format!("[image: {mime_type}, {} base64 bytes]", data.len()) - } - } - } -} - -#[derive(Debug, Clone)] -pub enum HistoryItem { - User(String), - Assistant { - text: String, - tool_calls: Vec, - }, - ToolResult(ToolResult), -} - -impl HistoryItem { - pub fn estimated_bytes(&self) -> usize { - self.size_with(ToolResultContent::estimated_bytes) - } - - /// Token-equivalent context-window pressure, in bytes. Mirrors - /// [`Self::estimated_bytes`] but charges image tool results their visual- - /// token equivalent rather than their base64 length — see - /// [`ToolResultContent::context_pressure_bytes`]. The handoff gate uses - /// this; `truncate_history` (request-body sizing) uses `estimated_bytes`. - pub fn context_pressure_bytes(&self) -> usize { - self.size_with(ToolResultContent::context_pressure_bytes) - } - - fn size_with(&self, content_size: fn(&ToolResultContent) -> usize) -> usize { - match self { - Self::User(s) => s.len(), - Self::Assistant { text, tool_calls } => { - text.len() - + tool_calls - .iter() - .map(|c| { - c.provider_id.len() - + c.name.len() - + serde_json::to_vec(&c.arguments) - .map(|b| b.len()) - .unwrap_or(0) - }) - .sum::() - } - Self::ToolResult(r) => { - r.provider_id.len() + r.content.iter().map(content_size).sum::() - } - } - } -} - -#[derive(Debug, Clone)] -pub struct ToolCall { - pub provider_id: String, - pub name: String, - pub arguments: Value, -} - -#[derive(Debug, Clone)] -pub struct ToolResult { - pub provider_id: String, - pub content: Vec, - pub is_error: bool, -} - -impl ToolResult { - pub fn text(&self) -> String { - self.content - .iter() - .map(ToolResultContent::as_text_lossy) - .collect::>() - .join("\n") - } -} - -#[derive(Debug, Clone)] -pub struct LlmResponse { - pub text: String, - pub tool_calls: Vec, - pub stop: ProviderStop, - /// Total input tokens the provider reported for this request, or `None` - /// if the response carried no usage. For Anthropic/Databricks this is the - /// inclusive sum `input_tokens + cache_read_input_tokens + - /// cache_creation_input_tokens` (plain `input_tokens` excludes cached - /// tokens, so reading it alone would undercount). Used to gate handoff on - /// the real token budget rather than a byte estimate. - pub input_tokens: Option, - /// Output tokens the provider reported for this request, or `None` if the - /// response carried no usage. Used to accumulate per-turn output counts - /// for NIP-AM metric publishing. - pub output_tokens: Option, - /// Reasoning/thinking content emitted by the model before its answer, if - /// any. Non-empty when the provider returns extended-thinking tokens: - /// - /// - Responses API: concatenated `summary[].text` from `type == "reasoning"` output items. - /// - Anthropic: concatenated `thinking` from `type == "thinking"` content blocks. - /// - OpenAI chat/completions: not exposed; always empty. - /// - /// Empty string when the provider returned no reasoning content. - pub reasoning: String, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ProviderStop { - EndTurn, - ToolUse, - MaxTokens, - Refusal, - Other, -} - -#[derive(Debug, Clone)] -pub struct ToolDef { - pub name: String, - pub description: String, - pub input_schema: Value, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum StopReason { - EndTurn, - Cancelled, - MaxTokens, - MaxTurnRequests, - Refusal, -} - -impl StopReason { - pub fn as_wire(self) -> &'static str { - match self { - Self::EndTurn => "end_turn", - Self::Cancelled => "cancelled", - Self::MaxTokens => "max_tokens", - Self::MaxTurnRequests => "max_turn_requests", - Self::Refusal => "refusal", - } - } -} +use serde::Deserialize; +/// A stdio MCP server declaration from `session/new`. #[derive(Debug, Deserialize, Clone)] pub struct McpServerStdio { pub name: String, @@ -207,6 +29,7 @@ pub struct EnvVar { pub value: String, } +/// A single block of an ACP `session/prompt` payload. #[derive(Debug, Deserialize, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { @@ -220,6 +43,31 @@ pub enum ContentBlock { Unsupported, } +/// ACP stop reasons. Wire strings are load-bearing: `buzz-acp` parses +/// `stopReason` off the `session/prompt` response and errors without it +/// (`acp.rs:1757-1761`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopReason { + EndTurn, + Cancelled, + MaxTokens, + MaxTurnRequests, + Refusal, +} + +impl StopReason { + pub fn as_wire(self) -> &'static str { + match self { + Self::EndTurn => "end_turn", + Self::Cancelled => "cancelled", + Self::MaxTokens => "max_tokens", + Self::MaxTurnRequests => "max_turn_requests", + Self::Refusal => "refusal", + } + } +} + +/// Errors surfaced to the client as JSON-RPC error responses. #[derive(Debug)] pub enum AgentError { InvalidParams(String), @@ -246,6 +94,8 @@ impl std::fmt::Display for AgentError { impl std::error::Error for AgentError {} impl AgentError { + /// Preserved verbatim from buzz-agent: the harness's error taxonomy keys + /// off these codes. pub fn json_rpc_code(&self) -> i32 { match self { Self::InvalidParams(_) => -32602, @@ -255,99 +105,3 @@ impl AgentError { } } } - -pub fn clamp(mut s: String, max: usize) -> String { - if s.len() <= max { - return s; - } - const MARKER: &str = "\n[truncated]"; - let budget = max.saturating_sub(MARKER.len()); - let mut cut = budget; - while cut > 0 && !s.is_char_boundary(cut) { - cut -= 1; - } - s.truncate(cut); - if max >= MARKER.len() { - s.push_str(MARKER); - } - s -} - -#[cfg(test)] -mod tests { - use super::*; - - fn image_item(base64_len: usize) -> HistoryItem { - HistoryItem::ToolResult(ToolResult { - provider_id: "call_1".into(), - content: vec![ToolResultContent::Image { - data: "A".repeat(base64_len), - mime_type: "image/png".into(), - }], - is_error: false, - }) - } - - #[test] - fn image_estimated_bytes_is_real_wire_size() { - // `truncate_history` relies on this to keep the request body under - // `max_history_bytes`, so an image must report its full base64 length. - let img = ToolResultContent::Image { - data: "A".repeat(3_000_000), - mime_type: "image/png".into(), - }; - assert_eq!(img.estimated_bytes(), 3_000_000 + "image/png".len()); - } - - #[test] - fn image_context_pressure_is_token_equivalent_not_base64_len() { - // The handoff gate must charge an image its visual-token equivalent, - // not its base64 length — otherwise one screenshot trips the gate. - let img = ToolResultContent::Image { - data: "A".repeat(3_000_000), - mime_type: "image/png".into(), - }; - assert_eq!( - img.context_pressure_bytes(), - IMAGE_CONTEXT_TOKEN_EQUIV + "image/png".len() - ); - // And it must be independent of the (huge) base64 payload length. - let bigger = ToolResultContent::Image { - data: "A".repeat(10_000_000), - mime_type: "image/png".into(), - }; - assert_eq!( - img.context_pressure_bytes(), - bigger.context_pressure_bytes() - ); - } - - #[test] - fn single_image_does_not_trip_default_handoff_threshold() { - // Regression: a single ~3.1M-base64-byte `view_image` result on an - // otherwise-empty history must NOT exceed the default pre-usage - // handoff cap. The gate's byte-fallback threshold with the shipped - // defaults (max_context_tokens=200_000, max_output_tokens=32_768) is - // min(200_000*9/10, 200_000-32_768) = 167_232 "bytes". Before the fix - // this item counted ~3.1M and tripped instantly. - let item = image_item(3_118_884); - const DEFAULT_PRE_USAGE_THRESHOLD: usize = 167_232; - assert!( - item.context_pressure_bytes() <= DEFAULT_PRE_USAGE_THRESHOLD, - "one image charged {} bytes of context pressure, over the {} threshold", - item.context_pressure_bytes(), - DEFAULT_PRE_USAGE_THRESHOLD - ); - // The real wire size, by contrast, is still the full base64 payload. - assert!(item.estimated_bytes() >= 3_118_884); - } - - #[test] - fn text_content_size_is_identical_for_both_measures() { - // Only images diverge; text must size the same under both paths. - let text = ToolResultContent::Text("hello world".into()); - assert_eq!(text.estimated_bytes(), text.context_pressure_bytes()); - let item = HistoryItem::User("a user message".into()); - assert_eq!(item.estimated_bytes(), item.context_pressure_bytes()); - } -} diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 0bbd1d3478..0275fff619 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -22,6 +22,7 @@ //! tree dies on timeout. //! FAKE_MCP_GRANDCHILD_PID_FILE=path //! — path to write the grandchild PID to. +//! FAKE_MCP_TOOL_ERROR=1 — `tools/call` returns isError: true //! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool //! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") //! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying @@ -271,7 +272,10 @@ fn main() { if tool_delay_secs > 0 { std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs)); } - let result_text = if result_size > 0 { + let tool_error = env_flag("FAKE_MCP_TOOL_ERROR"); + let result_text = if tool_error { + "boom: the tool failed".to_owned() + } else if result_size > 0 { "x".repeat(result_size) } else { "ok".to_owned() @@ -280,7 +284,7 @@ fn main() { id, json!({ "content": [{ "type": "text", "text": result_text }], - "isError": false, + "isError": tool_error, }), ); } diff --git a/buzz-agent-core/tests/cancel.rs b/crates/buzz-agent/tests/cancel.rs similarity index 100% rename from buzz-agent-core/tests/cancel.rs rename to crates/buzz-agent/tests/cancel.rs diff --git a/crates/buzz-agent/tests/databricks_oauth.rs b/crates/buzz-agent/tests/databricks_oauth.rs deleted file mode 100644 index 52acee1076..0000000000 --- a/crates/buzz-agent/tests/databricks_oauth.rs +++ /dev/null @@ -1,940 +0,0 @@ -//! Integration tests for the PKCE OAuth token source. -//! -//! No browser dance — we cover the silent-refresh and cache-hit paths -//! against a stubbed OIDC server (axum). The interactive browser flow is -//! exercised manually via the `buzz-agent auth databricks` subcommand -//! (see `lib.rs::auth_subcommand`). -//! -//! The second test module (further down) is an ACP-level envelope -//! regression: it spawns the real `buzz-agent` binary with -//! `DATABRICKS_TOKEN` set and a stub HTTP server, then asserts the wire -//! shape we send to Databricks. - -use std::net::SocketAddr; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -use axum::extract::Form; -use axum::{routing::get, routing::post, Json, Router}; -use buzz_agent::auth::{PkceOAuthConfig, PkceOAuthTokenSource, TokenSource}; -use serde::Deserialize; -use serde_json::json; -use tempfile::TempDir; - -#[derive(Deserialize)] -struct TokenForm { - grant_type: String, - #[allow(dead_code)] - refresh_token: Option, -} - -/// Boot a stub OIDC server that: -/// - serves discovery at `/.well-known/oauth-authorization-server` -/// - issues a fresh access token for every `refresh_token` request -/// - counts how many refresh hits it gets -async fn spawn_oidc() -> (String, Arc) { - let counter = Arc::new(AtomicU64::new(0)); - let counter_for_token = counter.clone(); - - // Bind first so we know our own base URL before building the router. - let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let base = format!("http://{addr}"); - let base_for_discovery = base.clone(); - - let app = Router::new() - .route( - "/.well-known/oauth-authorization-server", - get(move || { - let base = base_for_discovery.clone(); - async move { - Json(json!({ - "authorization_endpoint": format!("{base}/authorize"), - "token_endpoint": format!("{base}/token"), - })) - } - }), - ) - .route( - "/token", - post(move |Form(form): Form| { - let counter = counter_for_token.clone(); - async move { - let n = counter.fetch_add(1, Ordering::SeqCst) + 1; - assert_eq!(form.grant_type, "refresh_token"); - Json(json!({ - "access_token": format!("fresh-token-{n}"), - "refresh_token": "rotated-refresh", - "expires_in": 3600, - })) - } - }), - ); - - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - (base, counter) -} - -/// Cache key construction matches the auth module: sha256(discovery|client|scopes). -fn cache_path_for(cache_dir: &std::path::Path, cfg: &PkceOAuthConfig) -> std::path::PathBuf { - use sha2::Digest; - let mut h = sha2::Sha256::new(); - h.update(cfg.discovery_url.as_bytes()); - h.update(b"|"); - h.update(cfg.client_id.as_bytes()); - h.update(b"|"); - h.update(cfg.scopes.join(",").as_bytes()); - let hash = hex::encode(h.finalize()); - cache_dir - .join(&cfg.cache_namespace) - .join(format!("{hash}.json")) -} - -/// Write a token file the engine should pick up on construction. -fn seed_cache(path: &std::path::Path, body: serde_json::Value) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, serde_json::to_vec(&body).unwrap()).unwrap(); -} - -#[tokio::test] -async fn cache_hit_short_circuits_network() { - let tmp = TempDir::new().unwrap(); - - let (base, refresh_counter) = spawn_oidc().await; - let cfg = PkceOAuthConfig { - discovery_url: format!("{base}/.well-known/oauth-authorization-server"), - client_id: "test-client".into(), - scopes: vec!["a".into(), "b".into()], - cache_namespace: "databricks".into(), - cache_dir_override: Some(tmp.path().to_path_buf()), - }; - - // Seed an unexpired token in the cache. - let future = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() - + 3600; - let path = cache_path_for(tmp.path(), &cfg); - seed_cache( - &path, - json!({ - "access_token": "cached-token", - "refresh_token": "rt", - "expires_at": future, - }), - ); - - let src = PkceOAuthTokenSource::new(cfg).unwrap(); - let bearer = src.bearer().await.unwrap(); - assert_eq!(bearer, "cached-token"); - assert_eq!( - refresh_counter.load(Ordering::SeqCst), - 0, - "no refresh should fire" - ); -} - -#[tokio::test] -async fn expired_cache_silently_refreshes() { - let tmp = TempDir::new().unwrap(); - - let (base, refresh_counter) = spawn_oidc().await; - let cfg = PkceOAuthConfig { - discovery_url: format!("{base}/.well-known/oauth-authorization-server"), - client_id: "test-client".into(), - scopes: vec!["a".into()], - cache_namespace: "databricks".into(), - cache_dir_override: Some(tmp.path().to_path_buf()), - }; - - // Seed an already-expired token with a refresh_token. - let path = cache_path_for(tmp.path(), &cfg); - seed_cache( - &path, - json!({ - "access_token": "stale", - "refresh_token": "valid-refresh", - "expires_at": 1u64, // way in the past - }), - ); - - let src = PkceOAuthTokenSource::new(cfg).unwrap(); - let bearer = src.bearer().await.unwrap(); - assert_eq!(bearer, "fresh-token-1"); - assert_eq!(refresh_counter.load(Ordering::SeqCst), 1); - - // A second call should hit the in-memory cache and skip the network. - let bearer2 = src.bearer().await.unwrap(); - assert_eq!(bearer2, "fresh-token-1"); - assert_eq!(refresh_counter.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn refreshed_token_is_persisted_to_disk() { - let tmp = TempDir::new().unwrap(); - - let (base, _) = spawn_oidc().await; - let cfg = PkceOAuthConfig { - discovery_url: format!("{base}/.well-known/oauth-authorization-server"), - client_id: "test-client".into(), - scopes: vec!["a".into()], - cache_namespace: "databricks".into(), - cache_dir_override: Some(tmp.path().to_path_buf()), - }; - - let path = cache_path_for(tmp.path(), &cfg); - seed_cache( - &path, - json!({ - "access_token": "stale", - "refresh_token": "valid-refresh", - "expires_at": 1u64, - }), - ); - - let src = PkceOAuthTokenSource::new(cfg).unwrap(); - let _ = src.bearer().await.unwrap(); - - let on_disk: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(on_disk["access_token"], "fresh-token-1"); - assert_eq!(on_disk["refresh_token"], "rotated-refresh"); - assert!(on_disk["expires_at"].is_u64()); -} - -#[tokio::test] -async fn refresh_now_runs_grant_on_unexpired_rejected_token() { - let tmp = TempDir::new().unwrap(); - - let (base, refresh_counter) = spawn_oidc().await; - let cfg = PkceOAuthConfig { - discovery_url: format!("{base}/.well-known/oauth-authorization-server"), - client_id: "test-client".into(), - scopes: vec!["a".into()], - cache_namespace: "databricks".into(), - cache_dir_override: Some(tmp.path().to_path_buf()), - }; - - // The exact 401 case this whole change exists to fix: a token that is - // still locally *unexpired* but the server rejected it (skew, revocation, - // a node that never saw it). is_expired() says "keep it", so a clock-based - // gate would no-op and the agent would die. refresh_now() must instead key - // off identity — the cached token equals the rejected one — and run the - // grant anyway. The stub never serves a browser flow, so a fresh token - // here proves the refresh-token grant ran, not the interactive path. - let future = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() - + 3600; - let path = cache_path_for(tmp.path(), &cfg); - seed_cache( - &path, - json!({ - "access_token": "rejected", - "refresh_token": "valid-refresh", - "expires_at": future, - }), - ); - - let src = PkceOAuthTokenSource::new(cfg).unwrap(); - let bearer = src.refresh_now("rejected").await.unwrap(); - assert_eq!(bearer, "fresh-token-1", "grant ran despite local freshness"); - assert_eq!(refresh_counter.load(Ordering::SeqCst), 1, "grant ran once"); - - // The refresh token was preserved (rotated, not discarded): the saved - // token still carries one, so a future 401 can refresh again instead of - // falling to the browser flow. This is the property defect #1 broke. - let on_disk: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(on_disk["access_token"], "fresh-token-1"); - assert_eq!(on_disk["refresh_token"], "rotated-refresh"); -} - -#[tokio::test] -async fn refresh_now_coalesces_when_another_caller_already_refreshed() { - let tmp = TempDir::new().unwrap(); - - let (base, refresh_counter) = spawn_oidc().await; - let cfg = PkceOAuthConfig { - discovery_url: format!("{base}/.well-known/oauth-authorization-server"), - client_id: "test-client".into(), - scopes: vec!["a".into()], - cache_namespace: "databricks".into(), - cache_dir_override: Some(tmp.path().to_path_buf()), - }; - - // A concurrent caller already replaced the rejected token: the cached - // token differs from the one we hold. Coalesce by identity — return the - // new token without burning a second grant, so N concurrent 401s on the - // same stale token collapse onto one refresh. Note the cached token is - // *unexpired* here too, so this proves coalescing keys off identity, not - // the clock (which agrees with both the old and new token). - let future = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() - + 3600; - let path = cache_path_for(tmp.path(), &cfg); - seed_cache( - &path, - json!({ - "access_token": "already-refreshed", - "refresh_token": "rt", - "expires_at": future, - }), - ); - - let src = PkceOAuthTokenSource::new(cfg).unwrap(); - let bearer = src.refresh_now("the-rejected-one").await.unwrap(); - assert_eq!(bearer, "already-refreshed"); - assert_eq!( - refresh_counter.load(Ordering::SeqCst), - 0, - "no grant when a sibling already refreshed the rejected token" - ); -} - -#[tokio::test] -async fn refresh_now_without_refresh_token_is_terminal() { - let tmp = TempDir::new().unwrap(); - - let (base, refresh_counter) = spawn_oidc().await; - let cfg = PkceOAuthConfig { - discovery_url: format!("{base}/.well-known/oauth-authorization-server"), - client_id: "test-client".into(), - scopes: vec!["a".into()], - cache_namespace: "databricks".into(), - cache_dir_override: Some(tmp.path().to_path_buf()), - }; - - // The rejected token is still the cached one and there's no refresh token - // to fall back on. refresh_now() must fail terminally (LlmAuth) rather - // than open a browser — the headless hang this whole change exists to - // prevent. - let path = cache_path_for(tmp.path(), &cfg); - seed_cache( - &path, - json!({ - "access_token": "rejected", - "refresh_token": serde_json::Value::Null, - "expires_at": 1u64, - }), - ); - - let src = PkceOAuthTokenSource::new(cfg).unwrap(); - let err = src.refresh_now("rejected").await.unwrap_err(); - // `types::AgentError` isn't a public path; match on its Display, which - // prefixes `LlmAuth` variants with "llm auth:". A terminal LlmAuth (not - // a browser hang) is the whole point of this path. - let msg = err.to_string(); - assert!( - msg.starts_with("llm auth:"), - "expected terminal LlmAuth, got: {msg}" - ); - assert_eq!( - refresh_counter.load(Ordering::SeqCst), - 0, - "no grant attempted" - ); -} - -// ACP-level envelope regression test. -// -// Boots the real buzz-agent binary with `DATABRICKS_TOKEN` set (so the -// OAuth dance is skipped) pointed at a stub HTTP server that captures every -// inbound request. Asserts the wire-level shape Databricks model serving -// requires: path is `/serving-endpoints//invocations`, Authorization -// is `Bearer `, and the JSON body has *no* top-level `"model"`. This -// locks in the DRY envelope behavior so a refactor of `post_openai` can't -// silently break Databricks. - -use std::collections::VecDeque; -use std::process::Stdio; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; - -#[derive(Clone, Debug)] -struct CapturedRequest { - path: String, - authorization: Option, - body: serde_json::Value, -} - -async fn spawn_capturing_server( - responses: Vec, -) -> (String, Arc>>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captured = Arc::new(Mutex::new(Vec::::new())); - let cap_for_task = captured.clone(); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let captured = cap_for_task.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 4_000_000 { - return; - } - } - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let header_str = String::from_utf8_lossy(&buf[..header_end]).to_string(); - let (request_line, rest) = header_str.split_once('\n').unwrap_or(("", "")); - let path = request_line - .split_whitespace() - .nth(1) - .unwrap_or("") - .to_string(); - let mut authorization = None; - let mut body_len = 0usize; - for line in rest.lines() { - // Split case-insensitively on the colon but keep the value's case intact. - let Some((name, value)) = line.split_once(':') else { - continue; - }; - let value = value.trim().trim_end_matches('\r').to_string(); - match name.trim().to_ascii_lowercase().as_str() { - "authorization" => authorization = Some(value), - "content-length" => body_len = value.parse().unwrap_or(0), - _ => {} - } - } - while buf.len() < header_end + body_len { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - } - let body: serde_json::Value = - serde_json::from_slice(&buf[header_end..header_end + body_len]) - .unwrap_or(json!(null)); - captured.lock().await.push(CapturedRequest { - path, - authorization, - body, - }); - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), - body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - (url, captured) -} - -struct AgentHarness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - next_id: i64, -} - -impl Drop for AgentHarness { - fn drop(&mut self) { - let _ = self.child.start_kill(); - } -} - -impl AgentHarness { - async fn spawn_provider(provider: &str, base_url: &str, model: &str) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", provider) - .env("DATABRICKS_HOST", base_url) - .env("DATABRICKS_MODEL", model) - .env("DATABRICKS_TOKEN", "test-bearer") - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "2") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - Self { - child, - stdin, - stdout, - next_id: 1, - } - } - - async fn send(&mut self, method: &str, params: serde_json::Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - let mut s = serde_json::to_string( - &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }), - ) - .unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - id - } - - async fn recv_for(&mut self, want_id: i64) -> serde_json::Value { - loop { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - let v: serde_json::Value = serde_json::from_str(&line).expect("non-JSON line"); - if v.get("id") == Some(&json!(want_id)) { - return v; - } - } - } -} - -async fn run_single_prompt(provider: &str, base: &str, model: &str) { - let mut h = AgentHarness::spawn_provider(provider, base, model).await; - h.send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - h.recv_for(1).await; - h.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) - .await; - let r = h.recv_for(2).await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_string(); - h.send( - "session/prompt", - json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "say ok" }] }), - ) - .await; - let _ = h.recv_for(3).await; -} - -async fn run_captured_prompt( - provider: &str, - model: &str, - llm_canned: Vec, -) -> CapturedRequest { - // session/new triggers model catalog discovery against the same stub server. - // Prepend a minimal valid discovery response (empty endpoints list) so the - // discovery call is served cleanly and the LLM canned responses follow. - // Legacy Databricks discovery hits /api/2.0/serving-endpoints; - // Databricks v2 discovery hits /api/ai-gateway/v2/endpoints. - let discovery_resp = json!({ "endpoints": [], "next_page_token": null }); - let mut all_canned = vec![discovery_resp]; - all_canned.extend(llm_canned); - - let (base, captured) = spawn_capturing_server(all_canned).await; - run_single_prompt(provider, &base, model).await; - - // Filter out discovery requests — keep only the LLM invocation(s). - // Discovery paths: /api/2.0/serving-endpoints, /api/ai-gateway/v2/endpoints* - // LLM paths: /serving-endpoints/*, /ai-gateway/anthropic/*, /ai-gateway/openai/*, /ai-gateway/mlflow/* - let reqs = captured.lock().await; - let llm_reqs: Vec<_> = reqs - .iter() - .filter(|r| { - !r.path.starts_with("/api/2.0/serving-endpoints") - && !r.path.starts_with("/api/ai-gateway/v2/endpoints") - }) - .collect(); - assert_eq!(llm_reqs.len(), 1, "expected exactly one LLM request"); - llm_reqs[0].clone() -} - -#[tokio::test] -async fn databricks_envelope_routes_through_serving_endpoints_and_strips_model() { - // One canned chat-completions-shaped response → assistant says "ok" - // with end_turn so the agent loop exits cleanly. - let canned = vec![json!({ - "id": "x", - "object": "chat.completion", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "ok" }, - "finish_reason": "stop" - }] - })]; - let model = "goose-claude-4-6-sonnet"; - let req = run_captured_prompt("databricks", model, canned).await; - - assert_eq!( - req.path.as_str(), - format!("/serving-endpoints/{model}/invocations"), - "Databricks must route to serving-endpoints/{{model}}/invocations" - ); - assert_eq!( - req.authorization.as_deref(), - Some("Bearer test-bearer"), - "Authorization must be the static DATABRICKS_TOKEN as a Bearer" - ); - assert!( - req.body.get("model").is_none(), - "request body must NOT include `model` (Databricks rejects it): {:?}", - req.body - ); - // Sanity: the rest of the chat envelope should still be there. - assert!( - req.body - .get("messages") - .and_then(|v| v.as_array()) - .is_some(), - "request body should keep the chat `messages` field" - ); -} - -#[tokio::test] -async fn databricks_v2_gpt5_routes_through_ai_gateway_responses() { - let canned = vec![json!({ - "status": "completed", - "output": [{ - "type": "message", - "content": [{ "type": "output_text", "text": "ok" }] - }] - })]; - let model = "databricks-gpt-5-5"; - let req = run_captured_prompt("databricks_v2", model, canned).await; - - assert_eq!( - req.path.as_str(), - "/ai-gateway/openai/v1/responses", - "Databricks v2 GPT-5 models must route through AI Gateway Responses" - ); - assert_eq!( - req.authorization.as_deref(), - Some("Bearer test-bearer"), - "Authorization must be the static DATABRICKS_TOKEN as a Bearer" - ); - assert_eq!(req.body["model"], model); - assert!( - req.body.get("input").and_then(|v| v.as_array()).is_some(), - "Responses request body should keep `input`: {:?}", - req.body - ); -} - -#[tokio::test] -async fn databricks_v2_claude_routes_through_ai_gateway_anthropic_messages() { - let canned = vec![json!({ - "stop_reason": "end_turn", - "content": [{ "type": "text", "text": "ok" }] - })]; - let model = "databricks-claude-opus-4-7"; - let req = run_captured_prompt("databricks_v2", model, canned).await; - - assert_eq!( - req.path.as_str(), - "/ai-gateway/anthropic/v1/messages", - "Databricks v2 Claude models must route through AI Gateway Anthropic Messages" - ); - assert_eq!( - req.authorization.as_deref(), - Some("Bearer test-bearer"), - "Authorization must be the static DATABRICKS_TOKEN as a Bearer" - ); - assert_eq!(req.body["model"], model); - assert!( - req.body - .get("messages") - .and_then(|v| v.as_array()) - .is_some(), - "Anthropic request body should keep `messages`: {:?}", - req.body - ); -} - -#[tokio::test] -async fn databricks_v2_other_models_route_through_ai_gateway_mlflow_chat() { - let canned = vec![json!({ - "id": "x", - "object": "chat.completion", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "ok" }, - "finish_reason": "stop" - }] - })]; - let model = "custom-chat-model"; - let req = run_captured_prompt("databricks_v2", model, canned).await; - - assert_eq!( - req.path.as_str(), - "/ai-gateway/mlflow/v1/chat/completions", - "Databricks v2 fallback models must route through AI Gateway MLflow Chat" - ); - assert_eq!( - req.authorization.as_deref(), - Some("Bearer test-bearer"), - "Authorization must be the static DATABRICKS_TOKEN as a Bearer" - ); - assert_eq!(req.body["model"], model); - assert!( - req.body - .get("messages") - .and_then(|v| v.as_array()) - .is_some(), - "Chat request body should keep `messages`: {:?}", - req.body - ); -} - -// ---------- session/set_model integration tests ---------- - -/// Helper: run initialize + session/new + optional set_model + session/prompt on a -/// freshly-spawned harness against the given stub server base URL. Returns the -/// session/prompt response and the session ID so callers can also call set_model. -async fn run_with_set_model( - provider: &str, - base: &str, - initial_model: &str, - switch_to_model: Option<&str>, -) -> (String, serde_json::Value) { - let mut h = AgentHarness::spawn_provider(provider, base, initial_model).await; - h.send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - h.recv_for(1).await; - h.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) - .await; - let r = h.recv_for(2).await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_string(); - - if let Some(new_model) = switch_to_model { - h.send( - "session/set_model", - json!({ "sessionId": sid, "modelId": new_model }), - ) - .await; - let set_r = h.recv_for(3).await; - // Verify the response carries the expected modelId. - assert_eq!( - set_r["result"]["modelId"], - json!(new_model), - "set_model response must echo the new modelId" - ); - h.send( - "session/prompt", - json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "say ok" }] }), - ) - .await; - let prompt_r = h.recv_for(4).await; - (sid, prompt_r) - } else { - h.send( - "session/prompt", - json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "say ok" }] }), - ) - .await; - let prompt_r = h.recv_for(3).await; - (sid, prompt_r) - } -} - -/// After session/set_model switches from model A to model B, the next -/// session/prompt must route to B's Databricks serving-endpoint URL and -/// strip the `model` field from the body (legacy Databricks behaviour). -#[tokio::test] -async fn session_set_model_switches_databricks_legacy_route() { - let initial_model = "initial-model"; - let switched_model = "switched-model"; - - // Two canned responses: one for the discovery call (session/new), - // one for the LLM call after the switch. - let canned = vec![ - json!({ "endpoints": [], "next_page_token": null }), // discovery - json!({ // LLM response - "id": "x", - "object": "chat.completion", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "ok" }, - "finish_reason": "stop" - }] - }), - ]; - let (base, captured) = spawn_capturing_server(canned).await; - run_with_set_model("databricks", &base, initial_model, Some(switched_model)).await; - - let reqs = captured.lock().await; - let llm_reqs: Vec<_> = reqs - .iter() - .filter(|r| { - !r.path.starts_with("/api/2.0/serving-endpoints") - && !r.path.starts_with("/api/ai-gateway/v2/endpoints") - }) - .collect(); - assert_eq!( - llm_reqs.len(), - 1, - "expected exactly one LLM request after switch" - ); - let req = &llm_reqs[0]; - - // The request must go to the SWITCHED model endpoint, not the initial one. - assert_eq!( - req.path.as_str(), - format!("/serving-endpoints/{switched_model}/invocations"), - "Databricks legacy must route to the switched model endpoint" - ); - // The body must not include `model` (Databricks rejects it). - assert!( - req.body.get("model").is_none(), - "request body must NOT include `model` after switch: {:?}", - req.body - ); -} - -/// After session/set_model switches a Databricks v2 session from a GPT-5 model -/// (OpenAI Responses route) to a Claude model (Anthropic Messages route), -/// the next prompt must hit the Anthropic AI Gateway path. -#[tokio::test] -async fn session_set_model_switches_databricks_v2_route() { - let initial_model = "databricks-gpt-5-5"; // → OpenAI Responses - let switched_model = "databricks-claude-opus-4-7"; // → Anthropic Messages - - let canned = vec![ - json!({ "endpoints": [], "next_page_token": null }), // discovery (v2: /api/ai-gateway/v2/endpoints) - json!({ // LLM response (Anthropic Messages shape) - "stop_reason": "end_turn", - "content": [{ "type": "text", "text": "ok" }] - }), - ]; - let (base, captured) = spawn_capturing_server(canned).await; - run_with_set_model("databricks_v2", &base, initial_model, Some(switched_model)).await; - - let reqs = captured.lock().await; - let llm_reqs: Vec<_> = reqs - .iter() - .filter(|r| { - !r.path.starts_with("/api/2.0/serving-endpoints") - && !r.path.starts_with("/api/ai-gateway/v2/endpoints") - }) - .collect(); - assert_eq!( - llm_reqs.len(), - 1, - "expected exactly one LLM request after v2 route switch" - ); - let req = &llm_reqs[0]; - - assert_eq!( - req.path.as_str(), - "/ai-gateway/anthropic/v1/messages", - "After switching to a Claude model, Databricks v2 must route to Anthropic Messages" - ); - assert_eq!( - req.body["model"], - json!(switched_model), - "body must carry the switched model ID" - ); -} - -/// session/set_model with an unknown session ID must return an invalid_params -/// error without touching any LLM endpoint. -#[tokio::test] -async fn session_set_model_unknown_session_returns_error() { - // Spawn with a single discovery canned response; no LLM response needed. - let canned = vec![json!({ "endpoints": [], "next_page_token": null })]; - let (base, _captured) = spawn_capturing_server(canned).await; - - let mut h = AgentHarness::spawn_provider("databricks", &base, "some-model").await; - h.send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - h.recv_for(1).await; - h.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) - .await; - h.recv_for(2).await; - - // Call set_model with a bogus session ID. - h.send( - "session/set_model", - json!({ "sessionId": "nonexistent-session-id", "modelId": "new-model" }), - ) - .await; - let r = h.recv_for(3).await; - - assert!( - r.get("error").is_some(), - "set_model with unknown session must return an error: {:?}", - r - ); - let msg = r["error"]["message"].as_str().unwrap_or(""); - assert!( - msg.contains("unknown session"), - "error message must mention unknown session, got: {msg}" - ); -} - -/// session/set_model with an empty modelId must return an invalid_params error. -#[tokio::test] -async fn session_set_model_empty_model_id_returns_error() { - let canned = vec![json!({ "endpoints": [], "next_page_token": null })]; - let (base, _captured) = spawn_capturing_server(canned).await; - - let mut h = AgentHarness::spawn_provider("databricks", &base, "some-model").await; - h.send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - h.recv_for(1).await; - h.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) - .await; - let r = h.recv_for(2).await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_string(); - - // Empty string modelId. - h.send( - "session/set_model", - json!({ "sessionId": sid, "modelId": "" }), - ) - .await; - let r = h.recv_for(3).await; - - assert!( - r.get("error").is_some(), - "set_model with empty modelId must return an error: {:?}", - r - ); - let msg = r["error"]["message"].as_str().unwrap_or(""); - assert!( - msg.contains("modelId"), - "error message must mention modelId, got: {msg}" - ); -} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs deleted file mode 100644 index f2a86ac4b3..0000000000 --- a/crates/buzz-agent/tests/fake_llm.rs +++ /dev/null @@ -1,1125 +0,0 @@ -//! Integration test: fake LLM HTTP server + buzz-agent subprocess. -//! -//! Drives the agent through the ACP wire protocol and verifies: -//! - initialize / session/new responses -//! - tool_call (pending) → request_permission → tool_call_update -//! - session/prompt response with stopReason=end_turn -//! - concurrent prompt rejection - -use std::collections::VecDeque; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; - -async fn spawn_fake_llm(responses: Vec) -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 1_000_000 { - return; - } - } - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - url -} - -/// Like `spawn_fake_llm` but also captures the full JSON request body from each -/// incoming HTTP request. Returns (url, captured_requests). -async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc>>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); - let captures_clone = captures.clone(); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let captures = captures_clone.clone(); - tokio::spawn(async move { - // Read headers. - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 2_000_000 { - return; - } - } - // Parse Content-Length from headers to read the body. - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let header_str = String::from_utf8_lossy(&buf[..header_end]); - let content_length: usize = header_str - .lines() - .find_map(|line| { - let lower = line.to_lowercase(); - if lower.starts_with("content-length:") { - lower - .trim_start_matches("content-length:") - .trim() - .parse() - .ok() - } else { - None - } - }) - .unwrap_or(0); - - // Collect body bytes (some may already be in buf after headers). - let mut body_buf = buf[header_end..].to_vec(); - while body_buf.len() < content_length { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => break, - Ok(n) => body_buf.extend_from_slice(&tmp[..n]), - } - } - - // Parse and store the request body. - if let Ok(parsed) = - serde_json::from_slice::(&body_buf[..content_length.min(body_buf.len())]) - { - captures.lock().await.push(parsed); - } - - // Send canned response. - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - (url, captures) -} - -struct Harness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - next_id: i64, -} - -impl Harness { - async fn spawn(base_url: &str) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", base_url) - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "4") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - Self { - child, - stdin, - stdout, - next_id: 1, - } - } - - async fn send(&mut self, method: &str, params: Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) - .await; - id - } - - async fn write(&mut self, msg: Value) { - let mut s = serde_json::to_string(&msg).unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - } - - async fn recv(&mut self) -> Value { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - serde_json::from_str(&line).expect("non-JSON line") - } - - /// Read messages until one matches `pred`. - async fn recv_until bool>(&mut self, mut pred: F) -> Value { - loop { - let v = self.recv().await; - if pred(&v) { - return v; - } - } - } - - async fn shutdown(mut self) { - drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); - } -} - -fn openai_text(content: &str) -> Value { - json!({ - "id": "cc-1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - }) -} - -fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { - json!({ - "id": "cc-2", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": id, "type": "function", - "function": { "name": name, "arguments": args.to_string() }, - }], - }, - "finish_reason": "tool_calls", - }], - }) -} - -async fn init_session(h: &mut Harness) -> String { - h.send( - "initialize", - json!({"protocolVersion":2,"clientCapabilities":{}}), - ) - .await; - let r = h.recv().await; - assert_eq!(r["result"]["protocolVersion"], 2); - assert_eq!(r["result"]["agentInfo"]["name"], "buzz-agent"); - h.send("session/new", json!({"cwd":"/tmp","mcpServers":[]})) - .await; - let r = h.recv().await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); - assert!(sid.starts_with("ses_")); - sid -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn text_only_end_turn() { - let url = spawn_fake_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - let p_id = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "hi" }], - }), - ) - .await; - let v = h.recv_until(|v| v["id"] == json!(p_id)).await; - assert_eq!(v["result"]["stopReason"], "end_turn"); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_call_then_end_turn() { - // Round 1: tool call (will fail with "unknown tool" since no MCP registered). - // Round 2: text response → end_turn. - let url = spawn_fake_llm(vec![ - openai_tool_call("call_xyz", "fake__do_thing", json!({"foo": "bar"})), - openai_text("ok"), - ]) - .await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - let p_id = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{"type":"text","text":"do something"}], - }), - ) - .await; - - // Tool unknown: agent emits failed tool_call_update directly (no permission ask). - let v = h - .recv_until(|v| { - v.get("method") == Some(&json!("session/update")) - && v["params"]["update"]["sessionUpdate"] == "tool_call_update" - && v["params"]["update"]["status"] == "failed" - }) - .await; - assert_eq!(v["params"]["update"]["toolCallId"], "call_xyz"); - - // Final response. - let v = h.recv_until(|v| v["id"] == json!(p_id)).await; - assert_eq!(v["result"]["stopReason"], "end_turn"); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rejects_concurrent_prompts() { - // Slow first response so the second prompt arrives mid-flight. - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - tokio::spawn(async move { - let (mut sock, _) = listener.accept().await.unwrap(); - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - let n = sock.read(&mut tmp).await.unwrap_or(0); - if n == 0 { - return; - } - buf.extend_from_slice(&tmp[..n]); - } - tokio::time::sleep(Duration::from_millis(500)).await; - let body = openai_text("done").to_string(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - let p1 = h - .send( - "session/prompt", - json!({ - "sessionId": sid, "prompt": [{"type":"text","text":"go"}], - }), - ) - .await; - tokio::time::sleep(Duration::from_millis(50)).await; - let p2 = h - .send( - "session/prompt", - json!({ - "sessionId": sid, "prompt": [{"type":"text","text":"go again"}], - }), - ) - .await; - - let mut saw_p2_err = false; - let mut saw_p1_ok = false; - for _ in 0..10 { - let v = h.recv().await; - if v["id"] == json!(p2) { - assert_eq!(v["error"]["code"], -32602); - saw_p2_err = true; - } else if v["id"] == json!(p1) { - assert_eq!(v["result"]["stopReason"], "end_turn"); - saw_p1_ok = true; - } - if saw_p1_ok && saw_p2_err { - break; - } - } - assert!(saw_p2_err, "expected concurrent prompt rejection"); - assert!(saw_p1_ok, "first prompt didn't complete"); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rejects_oversized_line() { - // Set a tiny max line and send something larger; agent must abort with an - // io error and not OOM. - let url = spawn_fake_llm(vec![]).await; - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", &url) - .env("BUZZ_AGENT_MAX_LINE_BYTES", "256") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - let mut child = cmd.spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - // 1024-byte line — agent should reject and exit. - let big = "x".repeat(1024); - let _ = stdin.write_all(big.as_bytes()).await; - let _ = stdin.write_all(b"\n").await; - drop(stdin); - let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()) - .await - .expect("agent didn't exit after oversized line"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_new_rejects_oversized_system_prompt() { - // A systemPrompt exceeding 512KB must produce a JSON-RPC error, not a panic. - let url = spawn_fake_llm(vec![]).await; - let mut h = Harness::spawn(&url).await; - h.send( - "initialize", - json!({"protocolVersion":2,"clientCapabilities":{}}), - ) - .await; - let r = h.recv().await; - assert_eq!(r["result"]["protocolVersion"], 2); - - // 600KB payload — exceeds the 512KB limit. - let big_prompt = "x".repeat(600 * 1024); - let id = h - .send( - "session/new", - json!({"cwd":"/tmp","mcpServers":[],"systemPrompt": big_prompt}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(id)).await; - assert!( - r.get("error").is_some(), - "expected JSON-RPC error for oversized systemPrompt, got: {r}" - ); - let err_msg = r["error"]["message"].as_str().unwrap_or(""); - assert!( - err_msg.contains("512KB limit"), - "error message should mention 512KB limit, got: {err_msg}" - ); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn system_prompt_reaches_llm_system_role() { - // Proves the full contract: systemPrompt sent via session/new → agent appends - // it to the effective system prompt → LLM receives it in the system role. - let canary = "CANARY_E2E_TEST_MARKER_7f3a9b"; - let (url, captures) = spawn_capturing_fake_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn(&url).await; - - // initialize. - h.send( - "initialize", - json!({"protocolVersion":2,"clientCapabilities":{}}), - ) - .await; - let r = h.recv().await; - assert_eq!(r["result"]["protocolVersion"], 2); - - // session/new with systemPrompt containing the canary. - let sn_id = h - .send( - "session/new", - json!({"cwd":"/tmp","mcpServers":[],"systemPrompt": canary}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(sn_id)).await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); - assert!(sid.starts_with("ses_")); - - // session/prompt — triggers the LLM call. - let p_id = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{"type":"text","text":"hello"}], - }), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p_id)).await; - - // Inspect the captured LLM request. - let reqs = captures.lock().await; - assert!(!reqs.is_empty(), "expected at least one LLM request"); - let llm_req = &reqs[0]; - let messages = llm_req["messages"].as_array().expect("messages array"); - - // First message should be the system role. - let system_msg = &messages[0]; - assert_eq!( - system_msg["role"], "system", - "first message must be system role" - ); - let system_content = system_msg["content"].as_str().unwrap_or(""); - - // Canary must appear in the system message (proves systemPrompt was used as base). - assert!( - system_content.contains(canary), - "system message must contain the canary string.\nGot: {system_content}" - ); - - // The agent's default prompt must NOT appear — it is suppressed when - // the harness provides a systemPrompt. - let default_prompt = "You are buzz-agent"; - assert!( - !system_content.contains(default_prompt), - "system message must NOT contain the default prompt when systemPrompt is provided.\nGot: {system_content}" - ); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn system_prompt_absent_no_canary() { - // Negative case: when systemPrompt is NOT sent in session/new, the canary - // must NOT appear in the LLM system message. - let canary = "CANARY_E2E_TEST_MARKER_7f3a9b"; - let (url, captures) = spawn_capturing_fake_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn(&url).await; - - // initialize. - h.send( - "initialize", - json!({"protocolVersion":2,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - - // session/new WITHOUT systemPrompt field. - let sn_id = h - .send("session/new", json!({"cwd":"/tmp","mcpServers":[]})) - .await; - let r = h.recv_until(|v| v["id"] == json!(sn_id)).await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); - - // session/prompt — triggers the LLM call. - let p_id = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{"type":"text","text":"hello"}], - }), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p_id)).await; - - // Inspect the captured LLM request. - let reqs = captures.lock().await; - assert!(!reqs.is_empty(), "expected at least one LLM request"); - let llm_req = &reqs[0]; - let messages = llm_req["messages"].as_array().expect("messages array"); - let system_msg = &messages[0]; - assert_eq!(system_msg["role"], "system"); - let system_content = system_msg["content"].as_str().unwrap_or(""); - - // Canary must NOT appear (it was never sent). - assert!( - !system_content.contains(canary), - "system message must NOT contain canary when systemPrompt is absent.\nGot: {system_content}" - ); - - // But the agent's default prompt should still be there. - assert!( - system_content.contains("You are buzz-agent"), - "system message must still contain the agent's default prompt" - ); - - h.shutdown().await; -} - -// ─── Steering (_goose/unstable/session/steer) ─────────────────────────────── - -/// Wait for the `activeRunId` advert buzz-agent emits at prompt start and -/// return the run id, so a steer can target the live turn. -async fn recv_active_run_id(h: &mut Harness) -> String { - let v = h - .recv_until(|v| { - v.get("method") == Some(&json!("session/update")) - && v["params"]["update"]["_meta"]["goose"]["activeRunId"].is_string() - }) - .await; - v["params"]["update"]["_meta"]["goose"]["activeRunId"] - .as_str() - .unwrap() - .to_owned() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn steer_folds_into_active_turn_without_cancelling() { - // A two-round turn (tool call → text). A steer sent once the run is live - // must (a) be accepted with the matching runId, (b) NOT cancel the turn — - // it still ends with end_turn — and (c) reach the provider as a user turn. - let (url, captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_steer", "fake__noop", json!({})), - openai_text("acknowledged the steer"), - ]) - .await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - - let p_id = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{"type":"text","text":"work on the original task"}], - }), - ) - .await; - - // Learn the run id, then steer into it before the turn finishes. - let run_id = recv_active_run_id(&mut h).await; - let steer_text = "STEER-CANARY: also consider the edge case"; - let s_id = h - .send( - "_goose/unstable/session/steer", - json!({ - "sessionId": sid, - "expectedRunId": run_id, - "prompt": [{"type":"text","text": steer_text}], - }), - ) - .await; - - // Steer is accepted and echoes the run id it landed in. - let mut steer_ok = false; - let mut end_turn = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(s_id) { - assert_eq!( - v["result"]["runId"], - json!(run_id), - "steer ran into the live turn" - ); - assert!( - v["result"]["messageId"] - .as_str() - .is_some_and(|m| m.starts_with("steer_")), - "steer reply carries a messageId" - ); - steer_ok = true; - } else if v["id"] == json!(p_id) { - // The turn was NOT cancelled — it completed normally. - assert_eq!(v["result"]["stopReason"], "end_turn"); - end_turn = true; - } - if steer_ok && end_turn { - break; - } - } - assert!(steer_ok, "steer request was not accepted"); - assert!(end_turn, "turn did not complete with end_turn after steer"); - - // The steered text reached the provider as a user message in some round. - let reqs = captures.lock().await; - let saw_steer = reqs.iter().any(|req| { - req["messages"].as_array().is_some_and(|msgs| { - msgs.iter().any(|m| { - m["role"] == "user" - && m["content"] - .as_str() - .is_some_and(|c| c.contains(steer_text)) - }) - }) - }); - assert!( - saw_steer, - "steered text never reached the provider; captured requests: {reqs:#?}" - ); - drop(reqs); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn steer_rejected_when_no_active_run() { - // No prompt in flight → no active run → invalid_params. - let url = spawn_fake_llm(vec![]).await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - - let s_id = h - .send( - "_goose/unstable/session/steer", - json!({ - "sessionId": sid, - "expectedRunId": "run_does_not_exist", - "prompt": [{"type":"text","text":"hello?"}], - }), - ) - .await; - let v = h.recv_until(|v| v["id"] == json!(s_id)).await; - assert_eq!(v["error"]["code"], -32602, "expected invalid_params"); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn steer_rejected_on_run_id_mismatch() { - // A live run, but the caller targets a stale/wrong run id → invalid_params, - // so the client falls back to cancel+merge instead of injecting blind. - let (url, _captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_x", "fake__noop", json!({})), - openai_text("done"), - ]) - .await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - - let p_id = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _live_run = recv_active_run_id(&mut h).await; - - let s_id = h - .send( - "_goose/unstable/session/steer", - json!({ - "sessionId": sid, - "expectedRunId": "run_stale_mismatch", - "prompt": [{"type":"text","text":"too late"}], - }), - ) - .await; - - let mut saw_reject = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(s_id) { - assert_eq!( - v["error"]["code"], -32602, - "mismatched runId must be rejected" - ); - saw_reject = true; - } else if v["id"] == json!(p_id) { - // Turn finishes normally regardless of the rejected steer. - break; - } - } - assert!(saw_reject, "run-id mismatch was not rejected"); - h.shutdown().await; -} - -// ─── Usage notification (_goose/unstable/session/update usage_update) ─────── - -/// An OpenAI chat completion response with a `usage` block (prompt_tokens + -/// completion_tokens). buzz-agent maps these to `accumulatedInputTokens` / -/// `accumulatedOutputTokens` in the `_goose/unstable/session/update` notification. -fn openai_text_with_usage(content: &str, input_tokens: u64, output_tokens: u64) -> Value { - json!({ - "id": "cc-u", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - "usage": { - "prompt_tokens": input_tokens, - "completion_tokens": output_tokens, - "total_tokens": input_tokens + output_tokens, - }, - }) -} - -/// Returns true when `v` is a `_goose/unstable/session/update` usage_update -/// notification. -fn is_usage_update(v: &Value) -> bool { - v.get("method") == Some(&json!("_goose/unstable/session/update")) - && v["params"]["update"]["sessionUpdate"] == "usage_update" -} - -/// Collect every frame that arrives BEFORE the message matching `until_pred`, -/// then return (frames_before, matching_frame). -async fn recv_until_with_drain(h: &mut Harness, mut until_pred: F) -> (Vec, Value) -where - F: FnMut(&Value) -> bool, -{ - let mut before = Vec::new(); - loop { - let v = h.recv().await; - if until_pred(&v) { - return (before, v); - } - before.push(v); - } -} - -/// buzz-agent must emit `_goose/unstable/session/update` with `sessionUpdate: -/// "usage_update"` **before** the `session/prompt` response on each turn, and -/// must accumulate counters across turns (turn 2 reports turn1+turn2 sums). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn usage_notification_emitted_before_prompt_response() { - let url = spawn_fake_llm(vec![ - openai_text_with_usage("turn one reply", 10, 5), - openai_text_with_usage("turn two reply", 20, 8), - ]) - .await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - - // ── Turn 1 ────────────────────────────────────────────────────────────── - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), - ) - .await; - - let (frames_before_t1, response_t1) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await; - assert_eq!( - response_t1["result"]["stopReason"], "end_turn", - "turn 1 must complete with end_turn" - ); - - // A usage_update notification must appear in the frames before the response. - let usage_t1 = frames_before_t1 - .iter() - .find(|v| is_usage_update(v)) - .unwrap_or_else(|| { - panic!( - "expected _goose/unstable/session/update usage_update before turn-1 response; frames: {frames_before_t1:#?}" - ) - }); - assert_eq!( - usage_t1["params"]["update"]["sessionUpdate"], "usage_update", - "sessionUpdate field must be 'usage_update'" - ); - assert_eq!( - usage_t1["params"]["update"]["accumulatedInputTokens"], - json!(10u64), - "turn 1 accumulated input tokens" - ); - assert_eq!( - usage_t1["params"]["update"]["accumulatedOutputTokens"], - json!(5u64), - "turn 1 accumulated output tokens" - ); - - // ── Turn 2 ────────────────────────────────────────────────────────────── - let p2 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), - ) - .await; - - let (frames_before_t2, response_t2) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await; - assert_eq!( - response_t2["result"]["stopReason"], "end_turn", - "turn 2 must complete with end_turn" - ); - - // Notification arrives before the response, with cumulative sums (10+20, 5+8). - let usage_t2 = frames_before_t2 - .iter() - .find(|v| is_usage_update(v)) - .unwrap_or_else(|| { - panic!( - "expected _goose/unstable/session/update usage_update before turn-2 response; frames: {frames_before_t2:#?}" - ) - }); - assert_eq!( - usage_t2["params"]["update"]["accumulatedInputTokens"], - json!(30u64), - "turn 2 accumulated input tokens must be 10+20=30" - ); - assert_eq!( - usage_t2["params"]["update"]["accumulatedOutputTokens"], - json!(13u64), - "turn 2 accumulated output tokens must be 5+8=13" - ); - - h.shutdown().await; -} - -/// When the provider returns a response with no `usage` block, buzz-agent must -/// NOT emit a `_goose/unstable/session/update` notification for that turn. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn no_usage_turn_emits_no_usage_notification() { - let url = spawn_fake_llm(vec![openai_text("no usage here")]).await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - - let p_id = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - - let (frames_before, response) = recv_until_with_drain(&mut h, |v| v["id"] == p_id).await; - assert_eq!( - response["result"]["stopReason"], "end_turn", - "turn must complete with end_turn" - ); - - // No usage notification must appear in the frames before the response. - let found = frames_before.iter().any(is_usage_update); - assert!( - !found, - "expected NO usage_update notification when provider reports no usage; frames: {frames_before:#?}" - ); - - h.shutdown().await; -} - -/// When a turn is cancelled AFTER the provider has already returned a response -/// (so token counts are observed), buzz-agent must still emit the usage -/// notification before the cancelled `session/prompt` response. -/// -/// Setup: round 1 is a tool call WITH usage (tokens are captured). After the -/// tool_call_update notification (proving round 1 is fully processed), we gate -/// the round-2 LLM response behind a `oneshot` barrier that only releases after -/// cancel is sent. This guarantees the turn exits with `stopReason: "cancelled"` -/// deterministically, even on a slow CI worker. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cancelled_turn_with_usage_emits_notification_before_response() { - use tokio::sync::oneshot; - - // Gate: the second LLM request (round 2) is held until we explicitly release it. - let (gate_tx, gate_rx) = oneshot::channel::<()>(); - let gate_rx = Arc::new(tokio::sync::Mutex::new(Some(gate_rx))); - - // Round 1: tool call with usage — sets turn_input/output_tokens. - // Round 2: gated — blocked until cancel fires, then released so the - // in-flight TCP request can resolve. The queue is empty for round 2, so the - // agent receives the fallback "no canned response" body which it treats as - // an LLM error; the cancel check at the round boundary fires first because - // the gate is only released after cancel is enqueued. - let responses = vec![openai_tool_call_with_usage( - "call_cancel_test", - "fake__noop", - json!({}), - 15, - 6, - )]; - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let gate_rx_clone = gate_rx.clone(); - tokio::spawn(async move { - let mut request_num = 0usize; - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let gate = gate_rx_clone.clone(); - request_num += 1; - let req_num = request_num; - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 1_000_000 { - return; - } - } - // For request 2+ (round 2), wait for the gate to open before - // responding. This ensures cancel is sent before round 2 resolves, - // making stopReason: cancelled deterministic. - if req_num >= 2 { - let rx = gate.lock().await.take(); - if let Some(rx) = rx { - let _ = rx.await; - } - } - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - - let p_id = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"start work"}]}), - ) - .await; - - // Wait for the activeRunId advert (agent is live). - let _run_id = recv_active_run_id(&mut h).await; - // Wait for tool_call_update — proves round 1 LLM response is fully processed - // and tokens are captured before we send cancel. - h.recv_until(|v| { - v.get("method") == Some(&json!("session/update")) - && v["params"]["update"]["sessionUpdate"] == "tool_call_update" - }) - .await; - - // Now send cancel and release the round-2 gate. Cancel is enqueued before - // round 2 can respond, so the turn exits with stopReason: cancelled. - let c_id = h.send("session/cancel", json!({"sessionId": sid})).await; - let _ = gate_tx.send(()); // unblock round 2 - - let mut saw_usage_before_prompt_response = false; - let mut saw_usage = false; - let mut saw_cancel_ok = false; - let mut saw_prompt_response = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(c_id) { - saw_cancel_ok = true; - } else if is_usage_update(&v) { - saw_usage = true; - if !saw_prompt_response { - saw_usage_before_prompt_response = true; - } - } else if v["id"] == json!(p_id) { - saw_prompt_response = true; - // The gate guarantees stopReason: cancelled — not a race-driven error. - assert_eq!( - v["result"]["stopReason"], "cancelled", - "turn must end with stopReason: cancelled" - ); - } - if saw_usage && saw_prompt_response && saw_cancel_ok { - break; - } - } - assert!(saw_cancel_ok, "session/cancel was not acknowledged"); - assert!( - saw_usage, - "expected usage_update notification for cancelled turn with observed tokens" - ); - assert!( - saw_usage_before_prompt_response, - "usage_update must arrive before the session/prompt response" - ); - - h.shutdown().await; -} - -/// A tool-call OpenAI response with a `usage` block. Used to capture tokens in -/// round 1 before a cancel fires at the round boundary. -fn openai_tool_call_with_usage( - id: &str, - name: &str, - args: Value, - input_tokens: u64, - output_tokens: u64, -) -> Value { - json!({ - "id": "cc-u2", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": id, "type": "function", - "function": { "name": name, "arguments": args.to_string() }, - }], - }, - "finish_reason": "tool_calls", - }], - "usage": { - "prompt_tokens": input_tokens, - "completion_tokens": output_tokens, - "total_tokens": input_tokens + output_tokens, - }, - }) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn steer_rejected_on_empty_prompt() { - let (url, _captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_x", "fake__noop", json!({})), - openai_text("done"), - ]) - .await; - let mut h = Harness::spawn(&url).await; - let sid = init_session(&mut h).await; - let p_id = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let run_id = recv_active_run_id(&mut h).await; - let s_id = h - .send( - "_goose/unstable/session/steer", - json!({"sessionId": sid, "expectedRunId": run_id, "prompt": []}), - ) - .await; - let mut saw_reject = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(s_id) { - assert_eq!(v["error"]["code"], -32602, "empty prompt must be rejected"); - saw_reject = true; - } else if v["id"] == json!(p_id) { - break; - } - } - assert!(saw_reject, "empty steer prompt was not rejected"); - h.shutdown().await; -} diff --git a/crates/buzz-agent/tests/golden_transcripts.rs b/crates/buzz-agent/tests/golden_transcripts.rs deleted file mode 100644 index 4ac3503464..0000000000 --- a/crates/buzz-agent/tests/golden_transcripts.rs +++ /dev/null @@ -1,810 +0,0 @@ -use std::collections::VecDeque; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; - -struct Harness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - next_id: i64, -} - -impl Harness { - async fn spawn(extra: &[(&str, &str)]) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "4") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - for (k, v) in extra { - cmd.env(k, v); - } - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - Self { - child, - stdin, - stdout, - next_id: 1, - } - } - - async fn send(&mut self, method: &str, params: Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - self.write_json(json!({ - "jsonrpc": "2.0", "id": id, "method": method, "params": params - })) - .await; - id - } - - async fn notify(&mut self, method: &str, params: Value) { - self.write_json(json!({ - "jsonrpc": "2.0", "method": method, "params": params - })) - .await; - } - - async fn write_json(&mut self, msg: Value) { - let mut s = serde_json::to_string(&msg).unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - } - - async fn write_raw(&mut self, raw: &[u8]) { - let _ = self.stdin.write_all(raw).await; - let _ = self.stdin.flush().await; - } - - async fn recv(&mut self) -> Value { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - serde_json::from_str(&line).expect("non-JSON line") - } - - async fn recv_for_id(&mut self, id: i64) -> Value { - loop { - let v = self.recv().await; - if v["id"] == json!(id) { - return v; - } - } - } - - async fn recv_until bool>(&mut self, mut pred: F) -> Value { - loop { - let v = self.recv().await; - if pred(&v) { - return v; - } - } - } - - async fn shutdown(mut self) { - drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); - } -} - -async fn spawn_fake_llm(responses: Vec) -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 1_000_000 { - return; - } - } - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), - body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - url -} - -fn openai_text(content: &str) -> Value { - json!({ - "id": "cc-1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - }) -} - -fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { - json!({ - "id": "cc-2", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": id, "type": "function", - "function": { "name": name, "arguments": args.to_string() }, - }], - }, - "finish_reason": "tool_calls", - }], - }) -} - -async fn handshake(h: &mut Harness) -> String { - let init_id = h - .send( - "initialize", - json!({ "protocolVersion": 2, "clientCapabilities": {} }), - ) - .await; - let init = h.recv_for_id(init_id).await; - assert_eq!(init["result"]["protocolVersion"], 2); - assert_eq!(init["result"]["agentInfo"]["name"], "buzz-agent"); - assert_eq!( - init["result"]["agentCapabilities"]["promptCapabilities"]["image"], - false - ); - - let new_id = h - .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) - .await; - let new = h.recv_for_id(new_id).await; - let sid = new["result"]["sessionId"].as_str().unwrap().to_owned(); - assert!(sid.starts_with("ses_")); - sid -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_text_only_response() { - let url = spawn_fake_llm(vec![openai_text("hello back")]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "hi" }], - }), - ) - .await; - let result = h.recv_for_id(p).await; - assert_eq!(result["result"]["stopReason"], "end_turn"); - assert!(result.get("error").is_none()); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_full_tool_call_transcript() { - let url = spawn_fake_llm(vec![ - openai_tool_call("call_xyz", "fake__do_thing", json!({ "foo": "bar" })), - openai_text("done"), - ]) - .await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "use the tool" }], - }), - ) - .await; - - let failed = h - .recv_until(|v| { - v.get("method") == Some(&json!("session/update")) - && v["params"]["update"]["sessionUpdate"] == "tool_call_update" - && v["params"]["update"]["status"] == "failed" - }) - .await; - assert_eq!(failed["params"]["sessionId"], sid); - assert_eq!(failed["params"]["update"]["toolCallId"], "call_xyz"); - assert_eq!( - failed["params"]["update"]["rawOutput"]["error"], - "unknown tool: fake__do_thing" - ); - - let final_resp = h.recv_for_id(p).await; - assert_eq!(final_resp["result"]["stopReason"], "end_turn"); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_permission_denied_continues() { - let url = spawn_fake_llm(vec![openai_text("ok with no tool")]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "hi" }], - }), - ) - .await; - let final_resp = h.recv_for_id(p).await; - assert_eq!(final_resp["result"]["stopReason"], "end_turn"); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_initialize_version_check() { - let url = spawn_fake_llm(vec![]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let id = h - .send( - "initialize", - json!({ "protocolVersion": 99, "clientCapabilities": {} }), - ) - .await; - let resp = h.recv_for_id(id).await; - assert_eq!(resp["result"]["protocolVersion"], 2); - - let id2 = h - .send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - let ok = h.recv_for_id(id2).await; - assert_eq!(ok["result"]["protocolVersion"], 1); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_session_new_rejects_relative_cwd() { - let url = spawn_fake_llm(vec![]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let _ = h - .send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - let _ = h.recv().await; - - let id = h - .send( - "session/new", - json!({ "cwd": "relative/path", "mcpServers": [] }), - ) - .await; - let resp = h.recv_for_id(id).await; - assert_eq!(resp["error"]["code"], -32602); - assert!(resp["error"]["message"] - .as_str() - .unwrap() - .contains("cwd must be an absolute path")); - - let id_empty = h - .send("session/new", json!({ "cwd": "", "mcpServers": [] })) - .await; - let resp = h.recv_for_id(id_empty).await; - assert_eq!(resp["error"]["code"], -32602); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_malformed_json_rpc() { - let url = spawn_fake_llm(vec![]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - h.write_raw(b"this is not json\n").await; - let v = h.recv().await; - assert_eq!(v["error"]["code"], -32700); - assert_eq!(v["id"], Value::Null); - - h.write_json(json!({ "jsonrpc": "1.0", "method": "initialize", "id": 1 })) - .await; - let v = h.recv().await; - assert_eq!(v["error"]["code"], -32600); - - h.write_json(json!({ "jsonrpc": "2.0" })).await; - let v = h.recv().await; - assert_eq!(v["error"]["code"], -32600); - - let init_id = h - .send( - "initialize", - json!({ "protocolVersion": 1, "clientCapabilities": {} }), - ) - .await; - let ok = h.recv_for_id(init_id).await; - assert_eq!(ok["result"]["protocolVersion"], 1); - - let bad_id = h.send("nonsense/method", json!({})).await; - let v = h.recv_for_id(bad_id).await; - assert_eq!(v["error"]["code"], -32601); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_unsupported_content_block() { - let url = spawn_fake_llm(vec![openai_text("ok")]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "image", "data": "..." }], - }), - ) - .await; - let resp = h.recv_for_id(p).await; - assert_eq!(resp["error"]["code"], -32602); - assert!(resp["error"]["message"] - .as_str() - .unwrap() - .contains("unsupported content block")); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_concurrent_prompt_rejected() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - tokio::spawn(async move { - let (mut sock, _) = listener.accept().await.unwrap(); - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - let n = sock.read(&mut tmp).await.unwrap_or(0); - if n == 0 { - return; - } - buf.extend_from_slice(&tmp[..n]); - } - tokio::time::sleep(Duration::from_millis(500)).await; - let body = openai_text("done").to_string(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - let sid = handshake(&mut h).await; - - let p1 = h - .send( - "session/prompt", - json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }), - ) - .await; - tokio::time::sleep(Duration::from_millis(50)).await; - let p2 = h - .send( - "session/prompt", - json!({ "sessionId": sid, "prompt": [{"type":"text","text":"again"}] }), - ) - .await; - - let mut p1_ok = false; - let mut p2_err = false; - for _ in 0..10 { - let v = h.recv().await; - if v["id"] == json!(p1) { - assert_eq!(v["result"]["stopReason"], "end_turn"); - p1_ok = true; - } else if v["id"] == json!(p2) { - assert_eq!(v["error"]["code"], -32602); - p2_err = true; - } - if p1_ok && p2_err { - break; - } - } - assert!(p1_ok && p2_err, "expected p1=ok, p2=busy"); - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_oversized_line_kills_agent() { - let url = spawn_fake_llm(vec![]).await; - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", &url) - .env("BUZZ_AGENT_MAX_LINE_BYTES", "256") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - let mut child = cmd.spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - let big = "x".repeat(1024); - let _ = stdin.write_all(big.as_bytes()).await; - let _ = stdin.write_all(b"\n").await; - drop(stdin); - let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()) - .await - .expect("agent did not exit on oversized line"); -} - -/// Build an Anthropic Messages API response with an optional `thinking` block -/// followed by a `text` block. The `thinking` field is omitted when `None`. -fn anthropic_thinking_response(thinking: Option<&str>, text: &str) -> Value { - let mut content: Vec = Vec::new(); - if let Some(t) = thinking { - content.push(json!({ "type": "thinking", "thinking": t })); - } - content.push(json!({ "type": "text", "text": text })); - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "model": "claude-fake", - "stop_reason": "end_turn", - "content": content, - "usage": { "input_tokens": 10, "output_tokens": 5 }, - }) -} - -/// Build an OpenAI Responses API response with a `reasoning` output item -/// (containing a single `summary_text` entry) followed by a message item. -fn responses_reasoning_response(reasoning: &str, text: &str) -> Value { - json!({ - "id": "resp_1", - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "summary": [{ "type": "summary_text", "text": reasoning }], - }, - { - "type": "message", - "id": "msg_1", - "content": [{ "type": "output_text", "text": text }], - }, - ], - "usage": { "input_tokens": 10 }, - }) -} - -/// Drain all `session/update` notifications until the `session/prompt` reply -/// arrives for `prompt_id`, collecting notification payloads in order. -async fn collect_updates_until_done(h: &mut Harness, prompt_id: i64) -> Vec { - let mut updates = Vec::new(); - loop { - let v = h.recv().await; - if v.get("id") == Some(&json!(prompt_id)) { - return updates; - } - if v.get("method") == Some(&json!("session/update")) { - if let Some(u) = v["params"].get("update") { - updates.push(u.clone()); - } - } - } -} - -/// Asserts that `agent_thought_chunk` appears in `updates` BEFORE -/// `agent_message_chunk`, and that both are present. -fn assert_thought_before_message(updates: &[Value]) { - let thought_pos = updates - .iter() - .position(|u| u["sessionUpdate"] == "agent_thought_chunk"); - let message_pos = updates - .iter() - .position(|u| u["sessionUpdate"] == "agent_message_chunk"); - assert!( - thought_pos.is_some(), - "expected agent_thought_chunk in updates: {updates:?}" - ); - assert!( - message_pos.is_some(), - "expected agent_message_chunk in updates: {updates:?}" - ); - assert!( - thought_pos.unwrap() < message_pos.unwrap(), - "agent_thought_chunk must precede agent_message_chunk, got updates: {updates:?}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_thought_chunk_emitted_before_message_chunk_anthropic() { - // Anthropic extended-thinking: the response contains a `thinking` block - // followed by a `text` block. We expect agent_thought_chunk to be emitted - // before agent_message_chunk on the wire. - let url = spawn_fake_llm(vec![anthropic_thinking_response( - Some("Let me reason about this carefully."), - "Here is my answer.", - )]) - .await; - let mut h = Harness::spawn(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("ANTHROPIC_API_KEY", "test"), - ("ANTHROPIC_MODEL", "claude-fake"), - ("ANTHROPIC_BASE_URL", &url), - ("OPENAI_COMPAT_BASE_URL", ""), - ]) - .await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "think hard" }], - }), - ) - .await; - - let updates = collect_updates_until_done(&mut h, p).await; - assert_thought_before_message(&updates); - - let thought = updates - .iter() - .find(|u| u["sessionUpdate"] == "agent_thought_chunk") - .unwrap(); - assert_eq!( - thought["content"]["text"], - "Let me reason about this carefully." - ); - - let message = updates - .iter() - .find(|u| u["sessionUpdate"] == "agent_message_chunk") - .unwrap(); - assert_eq!(message["content"]["text"], "Here is my answer."); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_thought_chunk_emitted_before_message_chunk_responses_api() { - // OpenAI Responses API: reasoning item followed by message item. - // Setting OPENAI_COMPAT_API=responses forces the Responses API parse path. - let url = spawn_fake_llm(vec![responses_reasoning_response( - "Thinking step by step.", - "Final answer.", - )]) - .await; - let mut h = Harness::spawn(&[ - ("BUZZ_AGENT_PROVIDER", "openai"), - ("OPENAI_COMPAT_API_KEY", "test"), - ("OPENAI_COMPAT_MODEL", "fake-model"), - ("OPENAI_COMPAT_API", "responses"), - ("OPENAI_COMPAT_BASE_URL", &url), - ]) - .await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "reason it out" }], - }), - ) - .await; - - let updates = collect_updates_until_done(&mut h, p).await; - assert_thought_before_message(&updates); - - let thought = updates - .iter() - .find(|u| u["sessionUpdate"] == "agent_thought_chunk") - .unwrap(); - assert_eq!(thought["content"]["text"], "Thinking step by step."); - - let message = updates - .iter() - .find(|u| u["sessionUpdate"] == "agent_message_chunk") - .unwrap(); - assert_eq!(message["content"]["text"], "Final answer."); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_thought_chunk_emitted_before_message_chunk_chat_completions_reasoning_content() { - // OpenAI chat/completions path with DeepSeek-style `reasoning_content` field - // on the message object. OPENAI_COMPAT_API defaults to Auto, which routes - // non-openai.com hosts to chat/completions — this is the live path for - // self-hosted reasoning models (DeepSeek, vLLM, etc.). - let response = json!({ - "id": "cc-r1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Here is the answer.", - "reasoning_content": "Let me think through this step by step.", - }, - "finish_reason": "stop", - }], - }); - let url = spawn_fake_llm(vec![response]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "solve it" }], - }), - ) - .await; - - let updates = collect_updates_until_done(&mut h, p).await; - assert_thought_before_message(&updates); - - let thought = updates - .iter() - .find(|u| u["sessionUpdate"] == "agent_thought_chunk") - .unwrap(); - assert_eq!( - thought["content"]["text"], - "Let me think through this step by step." - ); - - let message = updates - .iter() - .find(|u| u["sessionUpdate"] == "agent_message_chunk") - .unwrap(); - assert_eq!(message["content"]["text"], "Here is the answer."); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_no_reasoning_no_thought_chunk() { - // Plain text response with no reasoning content — no agent_thought_chunk - // should appear on the wire. This guards against empty thought emissions. - let url = spawn_fake_llm(vec![openai_text("just text, no thinking")]).await; - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - - let sid = handshake(&mut h).await; - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{ "type": "text", "text": "hi" }], - }), - ) - .await; - - let updates = collect_updates_until_done(&mut h, p).await; - - let has_thought = updates - .iter() - .any(|u| u["sessionUpdate"] == "agent_thought_chunk"); - assert!( - !has_thought, - "expected no agent_thought_chunk for a plain text response, got: {updates:?}" - ); - - let has_message = updates - .iter() - .any(|u| u["sessionUpdate"] == "agent_message_chunk"); - assert!(has_message, "expected agent_message_chunk in updates"); - - h.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_cancel_notification_no_reply() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - tokio::spawn(async move { - let (mut sock, _) = listener.accept().await.unwrap(); - let mut buf = Vec::new(); - let mut tmp = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - let n = sock.read(&mut tmp).await.unwrap_or(0); - if n == 0 { - return; - } - buf.extend_from_slice(&tmp[..n]); - } - tokio::time::sleep(Duration::from_millis(800)).await; - let body = openai_text("done").to_string(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - - let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; - let sid = handshake(&mut h).await; - - let p = h - .send( - "session/prompt", - json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }), - ) - .await; - tokio::time::sleep(Duration::from_millis(50)).await; - h.notify("session/cancel", json!({ "sessionId": sid })) - .await; - - let final_resp = h.recv_for_id(p).await; - let stop = final_resp["result"]["stopReason"].as_str().unwrap_or(""); - assert!( - stop == "cancelled" || stop == "end_turn", - "unexpected stopReason {stop}" - ); - - h.shutdown().await; -} diff --git a/crates/buzz-agent/tests/hints_integration.rs b/crates/buzz-agent/tests/hints_integration.rs deleted file mode 100644 index 63a55514db..0000000000 --- a/crates/buzz-agent/tests/hints_integration.rs +++ /dev/null @@ -1,574 +0,0 @@ -//! Integration tests for AGENTS.md / SKILL.md hint loading. -//! -//! Uses the same subprocess + capturing-LLM pattern as `regressions.rs`. - -use std::collections::VecDeque; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; - -struct CapturingLlm { - url: String, - captured: Arc>>, -} - -async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let cap2 = captured.clone(); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let captured = cap2.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 4_000_000 { - return; - } - } - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let headers = &buf[..header_end]; - let mut body_len = 0usize; - for line in headers.split(|b| *b == b'\n') { - let line = std::str::from_utf8(line).unwrap_or(""); - if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { - body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); - } - } - while buf.len() < header_end + body_len { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - } - if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { - captured.lock().await.push(req); - } - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), - body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - CapturingLlm { url, captured } -} - -struct Harness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - next_id: i64, -} - -impl Harness { - async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", base_url) - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "8") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); - for (k, v) in extra { - cmd.env(k, v); - } - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - Self { - child, - stdin, - stdout, - next_id: 1, - } - } - - async fn send(&mut self, method: &str, params: Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) - .await; - id - } - - async fn write(&mut self, msg: Value) { - let mut s = serde_json::to_string(&msg).unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - } - - async fn recv(&mut self) -> Value { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - serde_json::from_str(&line).expect("non-JSON line") - } - - async fn recv_until bool>(&mut self, mut pred: F) -> Value { - loop { - let v = self.recv().await; - if pred(&v) { - return v; - } - } - } - - async fn shutdown(mut self) { - drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); - } -} - -fn openai_text(content: &str) -> Value { - json!({ - "id": "cc-1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - }) -} - -async fn init_session(h: &mut Harness, cwd: &str) -> String { - h.send( - "initialize", - json!({"protocolVersion": 1, "clientCapabilities": {}}), - ) - .await; - let _ = h.recv().await; - h.send("session/new", json!({"cwd": cwd, "mcpServers": []})) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - r["result"]["sessionId"] - .as_str() - .expect("sessionId") - .to_owned() -} - -/// AGENTS.md in cwd is loaded into the system prompt. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hints_loaded_from_cwd_agents_md() { - let tmp = tempfile::TempDir::new().unwrap(); - let cwd = tmp.path(); - let marker = "BUZZ_HINTS_MARKER_42"; - std::fs::write(cwd.join("AGENTS.md"), marker).unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; - let sid = init_session(&mut h, cwd.to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - assert!( - system.contains(marker), - "system prompt does not contain AGENTS.md marker: {system}" - ); - h.shutdown().await; -} - -/// BUZZ_AGENT_NO_HINTS=1 suppresses hint loading. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hints_suppressed_with_env_var() { - let tmp = tempfile::TempDir::new().unwrap(); - let cwd = tmp.path(); - let marker = "SUPPRESS_CHECK_MARKER_99"; - std::fs::write(cwd.join("AGENTS.md"), marker).unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_NO_HINTS", "1")]).await; - let sid = init_session(&mut h, cwd.to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - assert!( - !system.contains(marker), - "system prompt should NOT contain marker when hints disabled: {system}" - ); - h.shutdown().await; -} - -/// SKILL.md files in .agents/skills/ are loaded into the system prompt as metadata only. -/// The body is NOT inlined; the agent uses `load_skill` to fetch it on demand. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn skills_loaded_from_agents_skills_dir() { - let tmp = tempfile::TempDir::new().unwrap(); - let cwd = tmp.path(); - let skill_dir = cwd.join(".agents/skills/test-skill"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: test-skill\ndescription: A test skill\n---\nSKILL_BODY_MARKER_77\n", - ) - .unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; - let sid = init_session(&mut h, cwd.to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - // Skill name must appear in the metadata listing. - assert!( - system.contains("test-skill"), - "system prompt missing skill name: {system}" - ); - // Body must NOT be inlined — lazy loading only. - assert!( - !system.contains("SKILL_BODY_MARKER_77"), - "skill body must not be inlined in system prompt: {system}" - ); - // The load_skill instruction must be present. - assert!( - system.contains("load_skill"), - "system prompt missing load_skill instruction: {system}" - ); - h.shutdown().await; -} - -/// AGENTS.md files at git root and subdirectory are both loaded, root first. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn git_root_hints_included() { - let tmp = tempfile::TempDir::new().unwrap(); - let root = tmp.path(); - std::fs::create_dir(root.join(".git")).unwrap(); - std::fs::write(root.join("AGENTS.md"), "ROOT_HINT_MARKER_11").unwrap(); - let sub = root.join("sub"); - std::fs::create_dir(&sub).unwrap(); - std::fs::write(sub.join("AGENTS.md"), "SUB_HINT_MARKER_22").unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; - let sid = init_session(&mut h, sub.to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - assert!( - system.contains("ROOT_HINT_MARKER_11"), - "system prompt missing root hint: {system}" - ); - assert!( - system.contains("SUB_HINT_MARKER_22"), - "system prompt missing sub hint: {system}" - ); - let root_pos = system.find("ROOT_HINT_MARKER_11").unwrap(); - let sub_pos = system.find("SUB_HINT_MARKER_22").unwrap(); - assert!( - root_pos < sub_pos, - "root hint should appear before sub hint in system prompt" - ); - h.shutdown().await; -} - -/// ~/AGENTS.md (global) is loaded before CWD AGENTS.md when HOME is set. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn global_agents_md_loaded() { - let home_tmp = tempfile::TempDir::new().unwrap(); - let cwd_tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(home_tmp.path().join("AGENTS.md"), "GLOBAL_HINT_MARKER_55").unwrap(); - std::fs::write(cwd_tmp.path().join("AGENTS.md"), "LOCAL_HINT_MARKER_66").unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = - Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await; - let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - assert!( - system.contains("GLOBAL_HINT_MARKER_55"), - "system prompt missing global hint: {system}" - ); - assert!( - system.contains("LOCAL_HINT_MARKER_66"), - "system prompt missing local hint: {system}" - ); - let global_pos = system.find("GLOBAL_HINT_MARKER_55").unwrap(); - let local_pos = system.find("LOCAL_HINT_MARKER_66").unwrap(); - assert!( - global_pos < local_pos, - "global hint should appear before local hint in system prompt" - ); - h.shutdown().await; -} - -/// Global skills from ~/.agents/skills/ are loaded; project-level wins on name conflict. -/// Bodies are NOT inlined — only metadata (name + description) appears in the system prompt. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn global_skills_loaded_and_project_wins() { - let home_tmp = tempfile::TempDir::new().unwrap(); - let cwd_tmp = tempfile::TempDir::new().unwrap(); - - let global_only_dir = home_tmp.path().join(".agents/skills/global-only"); - std::fs::create_dir_all(&global_only_dir).unwrap(); - std::fs::write( - global_only_dir.join("SKILL.md"), - "---\nname: global-only\ndescription: A global skill\n---\nGLOBAL_SKILL_BODY_88\n", - ) - .unwrap(); - - let global_shared_dir = home_tmp.path().join(".agents/skills/shared-name"); - std::fs::create_dir_all(&global_shared_dir).unwrap(); - std::fs::write( - global_shared_dir.join("SKILL.md"), - "---\nname: shared-name\ndescription: Global version\n---\nGLOBAL_SHARED_BODY_LOSE\n", - ) - .unwrap(); - - let project_shared_dir = cwd_tmp.path().join(".agents/skills/shared-name"); - std::fs::create_dir_all(&project_shared_dir).unwrap(); - std::fs::write( - project_shared_dir.join("SKILL.md"), - "---\nname: shared-name\ndescription: Project version\n---\nPROJECT_SHARED_BODY_WIN\n", - ) - .unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = - Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await; - let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - // Both skill names must appear in the metadata listing. - assert!( - system.contains("global-only"), - "system prompt missing global-only skill name: {system}" - ); - assert!( - system.contains("shared-name"), - "system prompt missing shared-name skill: {system}" - ); - // Project description wins over global for the shared name. - assert!( - system.contains("Project version"), - "system prompt should show project description for shared-name: {system}" - ); - assert!( - !system.contains("Global version"), - "system prompt should NOT show global description for shared-name: {system}" - ); - // Bodies must NOT be inlined. - assert!( - !system.contains("GLOBAL_SKILL_BODY_88"), - "skill body must not be inlined: {system}" - ); - assert!( - !system.contains("PROJECT_SHARED_BODY_WIN"), - "skill body must not be inlined: {system}" - ); - assert!( - !system.contains("GLOBAL_SHARED_BODY_LOSE"), - "shadowed skill body must not be inlined: {system}" - ); - h.shutdown().await; -} - -/// Skill directories that are symlinks (e.g. managed by ai-rules) are discovered -/// correctly — `DirEntry::file_type()` returns `FileType::Symlink` for symlinks, -/// so the old `is_dir()` check silently dropped them. We now use -/// `std::fs::metadata()` which follows the symlink. -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn symlinked_skill_dir_is_discovered() { - let real_skill_root = tempfile::TempDir::new().unwrap(); - let real_skill_dir = real_skill_root.path().join("symlinked-skill"); - std::fs::create_dir_all(&real_skill_dir).unwrap(); - std::fs::write( - real_skill_dir.join("SKILL.md"), - "---\nname: symlinked-skill\ndescription: A symlinked skill\n---\nSYMLINK_SKILL_BODY_42\n", - ) - .unwrap(); - - let tmp = tempfile::TempDir::new().unwrap(); - let cwd = tmp.path(); - let skills_dir = cwd.join(".agents/skills"); - std::fs::create_dir_all(&skills_dir).unwrap(); - - // Create a symlink: .agents/skills/symlinked-skill -> real_skill_dir - std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap(); - - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; - let sid = init_session(&mut h, cwd.to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let system = captured[0]["messages"][0]["content"].as_str().unwrap_or(""); - // The symlinked skill name must appear in the metadata listing. - assert!( - system.contains("symlinked-skill"), - "system prompt missing symlinked skill name: {system}" - ); - // Body must NOT be inlined. - assert!( - !system.contains("SYMLINK_SKILL_BODY_42"), - "symlinked skill body must not be inlined in system prompt: {system}" - ); - h.shutdown().await; -} - -/// `load_skill` tool is advertised when skills exist, and returns the skill body. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn load_skill_tool_returns_body() { - let tmp = tempfile::TempDir::new().unwrap(); - let cwd = tmp.path(); - let skill_dir = cwd.join(".agents/skills/my-skill"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write( - skill_dir.join("SKILL.md"), - "---\nname: my-skill\ndescription: A skill\n---\nSKILL_BODY_CONTENT_99\n", - ) - .unwrap(); - - // Round 1: LLM calls load_skill("my-skill"). - // Round 2: LLM returns end_turn after seeing the body. - let load_skill_call = json!({ - "id": "cc-ls", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": "tc-1", "type": "function", - "function": { - "name": "load_skill", - "arguments": "{\"name\":\"my-skill\"}" - } - }] - }, - "finish_reason": "tool_calls" - }] - }); - let end_turn = openai_text("done"); - - let llm = spawn_capturing_llm(vec![load_skill_call, end_turn]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; - let sid = init_session(&mut h, cwd.to_str().unwrap()).await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"use my-skill"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - // The second LLM request (round 2) should contain the skill body in tool results. - let reqs = llm.captured.lock().await; - assert!( - reqs.len() >= 2, - "expected at least 2 LLM requests, got {}", - reqs.len() - ); - let round2_str = serde_json::to_string(&reqs[1]).unwrap(); - assert!( - round2_str.contains("SKILL_BODY_CONTENT_99"), - "load_skill result must contain skill body in round 2 request.\nGot: {round2_str}" - ); - h.shutdown().await; -} diff --git a/crates/buzz-agent/tests/openai_auto_upgrade.rs b/crates/buzz-agent/tests/openai_auto_upgrade.rs deleted file mode 100644 index f85a52bfaa..0000000000 --- a/crates/buzz-agent/tests/openai_auto_upgrade.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Integration test for OpenAI auto-upgrade chat→responses. -//! -//! Starts a tiny HTTP server that: -//! 1. accepts a POST to /chat/completions, replies 400 with a body that -//! mentions `/v1/responses` (mirrors the Databricks GPT-5.5 signal); -//! 2. accepts a POST to /responses, replies 200 with a Responses-shaped -//! JSON envelope. -//! -//! Spawns `buzz-agent` with `provider=openai` + `OPENAI_COMPAT_API=auto` -//! pointed at the fake server, drives one prompt through the ACP wire -//! protocol, and verifies the prompt completes with `stopReason=end_turn` -//! — which can only happen if the second (Responses) request succeeded. - -use std::io::{Read, Write}; -use std::net::TcpListener; -use std::process::Stdio; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use serde_json::json; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::Command; -use tokio::time::timeout; - -/// Spawns a single-shot fake provider. Returns the base URL (e.g. -/// `http://127.0.0.1:54321`). The server stays up for the lifetime of -/// the process — we don't need to clean it up explicitly. -fn spawn_fake_provider() -> (String, Arc, Arc) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(false).unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let chat_hits = Arc::new(AtomicUsize::new(0)); - let responses_hits = Arc::new(AtomicUsize::new(0)); - let chat = chat_hits.clone(); - let resp = responses_hits.clone(); - - std::thread::spawn(move || { - loop { - let (mut sock, _) = match listener.accept() { - Ok(p) => p, - Err(_) => return, - }; - let chat = chat.clone(); - let resp = resp.clone(); - std::thread::spawn(move || { - sock.set_read_timeout(Some(Duration::from_secs(5))).ok(); - // Read request head + body. Naive: read until we have the - // request line + headers, then read Content-Length bytes. - let mut buf = Vec::with_capacity(4096); - let mut tmp = [0u8; 4096]; - loop { - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - match sock.read(&mut tmp) { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 256 * 1024 { - return; - } - } - let head_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); - // Drain the body to satisfy keep-alive; we don't actually - // need it. - let cl = head - .lines() - .find_map(|l| { - l.strip_prefix("content-length:") - .or_else(|| l.strip_prefix("Content-Length:")) - }) - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - while buf.len() < head_end + cl { - match sock.read(&mut tmp) { - Ok(0) | Err(_) => break, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - } - - let (status, body) = if head.contains("POST /chat/completions") { - chat.fetch_add(1, Ordering::SeqCst); - let body = json!({ - "error": { - "code": "BAD_REQUEST", - "message": "Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead." - } - }) - .to_string(); - (400u16, body) - } else if head.contains("POST /responses") { - resp.fetch_add(1, Ordering::SeqCst); - let body = json!({ - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "ok from responses"}] - }] - }) - .to_string(); - (200u16, body) - } else { - (404u16, "{}".to_string()) - }; - let reason = match status { - 200 => "OK", - 400 => "Bad Request", - _ => "Not Found", - }; - let resp_text = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), body - ); - let _ = sock.write_all(resp_text.as_bytes()); - let _ = sock.shutdown(std::net::Shutdown::Write); - }); - } - }); - - (url, chat_hits, responses_hits) -} - -#[tokio::test] -async fn openai_auto_upgrades_chat_to_responses_on_databricks_signal() { - let (base_url, chat_hits, resp_hits) = spawn_fake_provider(); - - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "gpt-5.5") - .env("OPENAI_COMPAT_BASE_URL", &base_url) - // No OPENAI_COMPAT_API — must default to "auto" so the upgrade - // path is enabled. - .env_remove("OPENAI_COMPAT_API") - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "4") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); - - async fn send(stdin: &mut tokio::process::ChildStdin, v: serde_json::Value) { - let line = format!("{v}\n"); - stdin.write_all(line.as_bytes()).await.unwrap(); - stdin.flush().await.unwrap(); - } - async fn recv(stdout: &mut BufReader) -> serde_json::Value { - let mut line = String::new(); - timeout(Duration::from_secs(8), stdout.read_line(&mut line)) - .await - .expect("recv timed out") - .expect("recv io"); - serde_json::from_str(&line).expect("recv json") - } - - send( - &mut stdin, - json!({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": 1, "clientCapabilities": {}, - "clientInfo": {"name": "auto-upgrade-test"}} - }), - ) - .await; - let init = recv(&mut stdout).await; - assert!(init.get("result").is_some(), "initialize: {init}"); - - let cwd = std::env::current_dir().unwrap(); - send( - &mut stdin, - json!({ - "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": cwd.to_string_lossy(), "mcpServers": []} - }), - ) - .await; - let sess = recv(&mut stdout).await; - let sid = sess["result"]["sessionId"] - .as_str() - .unwrap_or_else(|| panic!("session/new failed: {sess}")) - .to_string(); - - send( - &mut stdin, - json!({ - "jsonrpc": "2.0", "id": 3, "method": "session/prompt", - "params": {"sessionId": sid, - "prompt": [{"type": "text", "text": "hi"}]} - }), - ) - .await; - - // Drain notifications until we see the response for id=3. - let mut stop_reason: Option = None; - for _ in 0..40 { - let msg = recv(&mut stdout).await; - if msg.get("id") == Some(&json!(3)) { - if let Some(r) = msg.get("result") { - stop_reason = r - .get("stopReason") - .and_then(|v| v.as_str()) - .map(String::from); - } - break; - } - } - assert_eq!(stop_reason.as_deref(), Some("end_turn")); - assert_eq!( - chat_hits.load(Ordering::SeqCst), - 1, - "must have tried chat first" - ); - assert!( - resp_hits.load(Ordering::SeqCst) >= 1, - "must have upgraded to responses" - ); - - drop(stdin); - let _ = child.wait().await; -} diff --git a/buzz-agent-core/tests/real_dev_mcp.rs b/crates/buzz-agent/tests/real_dev_mcp.rs similarity index 93% rename from buzz-agent-core/tests/real_dev_mcp.rs rename to crates/buzz-agent/tests/real_dev_mcp.rs index 4f9d90f0ef..85313d6452 100644 --- a/buzz-agent-core/tests/real_dev_mcp.rs +++ b/crates/buzz-agent/tests/real_dev_mcp.rs @@ -22,19 +22,28 @@ use serde_json::{json, Value}; /// Locate the real `buzz-dev-mcp` binary in the workspace target dir. /// -/// It lives in the outer workspace, which this crate is deliberately excluded -/// from, so `CARGO_BIN_EXE_` is unavailable and we resolve it by path. -fn buzz_dev_mcp() -> Option { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent()? - .to_path_buf(); - for profile in ["debug", "release"] { - let candidate = root.join("target").join(profile).join("buzz-dev-mcp"); - if candidate.is_file() { - return Some(candidate); +/// It is a separate binary crate, so `CARGO_BIN_EXE_` is unavailable here and +/// we resolve it by walking up to the workspace target dir. +fn buzz_dev_mcp() -> PathBuf { + let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + loop { + for profile in ["debug", "release"] { + let candidate = dir.join("target").join(profile).join("buzz-dev-mcp"); + if candidate.is_file() { + return candidate; + } + } + if !dir.pop() { + break; } } - None + // Fail loudly rather than skipping. buzz-dev-mcp is a workspace sibling + // that `just ci` always builds, so a miss means the search is wrong -- and + // a silent skip hides exactly the breakage this test exists to catch. + panic!( + "buzz-dev-mcp not found searching upward from {}. Run `cargo build -p buzz-dev-mcp`.", + env!("CARGO_MANIFEST_DIR") + ); } /// Provider that drives a scripted conversation: @@ -235,10 +244,7 @@ impl Drop for Harness { #[test] fn real_dev_mcp_stop_hook_blocks_end_of_turn() { - let Some(mcp) = buzz_dev_mcp() else { - eprintln!("skipping: buzz-dev-mcp not built (run `cargo build -p buzz-dev-mcp`)"); - return; - }; + let mcp = buzz_dev_mcp(); let (base_url, requests, calls) = spawn_scripted_provider(); let home = tempfile::tempdir().expect("home"); @@ -319,10 +325,7 @@ fn real_dev_mcp_stop_hook_blocks_end_of_turn() { #[test] fn real_dev_mcp_advertises_its_tools() { - let Some(mcp) = buzz_dev_mcp() else { - eprintln!("skipping: buzz-dev-mcp not built"); - return; - }; + let mcp = buzz_dev_mcp(); let (base_url, requests, _) = spawn_scripted_provider(); let home = tempfile::tempdir().expect("home"); diff --git a/buzz-agent-core/tests/reflect.rs b/crates/buzz-agent/tests/reflect.rs similarity index 100% rename from buzz-agent-core/tests/reflect.rs rename to crates/buzz-agent/tests/reflect.rs diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs deleted file mode 100644 index 2e0b579c84..0000000000 --- a/crates/buzz-agent/tests/regressions.rs +++ /dev/null @@ -1,1821 +0,0 @@ -//! Regression tests for round 4-6 hardening: -//! - assistant text preserved in history -//! - MCP init timeout (with explicit child kill) -//! - tool metadata caps (description bytes, count) -//! - cancellation leaves history valid for the next prompt -//! - empty-content assistant turn doesn't poison OpenAI history - -use std::collections::VecDeque; -use std::process::Stdio; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::{Duration, Instant}; - -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; - -struct CapturingLlm { - url: String, - captured: Arc>>, -} - -async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let cap2 = captured.clone(); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let captured = cap2.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - // Read until headers complete. - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 4_000_000 { - return; - } - } - // Parse Content-Length and read body. - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let headers = &buf[..header_end]; - let mut body_len = 0usize; - for line in headers.split(|b| *b == b'\n') { - let line = std::str::from_utf8(line).unwrap_or(""); - if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { - body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); - } - } - while buf.len() < header_end + body_len { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - } - if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { - captured.lock().await.push(req); - } - let body = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); - let body_s = serde_json::to_string(&body).unwrap(); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - CapturingLlm { url, captured } -} - -struct Harness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - stderr: Arc>, - next_id: i64, -} - -impl Harness { - async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", base_url) - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "8") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); - for (k, v) in extra { - cmd.env(k, v); - } - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let stderr = child.stderr.take().unwrap(); - let stderr_buf = Arc::new(StdMutex::new(String::new())); - let stderr_out = Arc::clone(&stderr_buf); - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - loop { - line.clear(); - let n = match reader.read_line(&mut line).await { - Ok(n) => n, - Err(_) => break, - }; - if n == 0 { - break; - } - if let Ok(mut out) = stderr_out.lock() { - out.push_str(&line); - } - } - }); - Self { - child, - stdin, - stdout, - stderr: stderr_buf, - next_id: 1, - } - } - - async fn spawn(base_url: &str) -> Self { - Self::spawn_with_env(base_url, &[]).await - } - - async fn send(&mut self, method: &str, params: Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) - .await; - id - } - - async fn notify(&mut self, method: &str, params: Value) { - self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params })) - .await; - } - - async fn write(&mut self, msg: Value) { - let mut s = serde_json::to_string(&msg).unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - } - - async fn recv(&mut self) -> Value { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - serde_json::from_str(&line).expect("non-JSON line") - } - - async fn recv_until bool>(&mut self, mut pred: F) -> Value { - loop { - let v = self.recv().await; - if pred(&v) { - return v; - } - } - } - - async fn shutdown(mut self) { - drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); - } - - fn stderr_text(&self) -> String { - self.stderr.lock().map(|s| s.clone()).unwrap_or_default() - } -} - -fn openai_text(content: &str) -> Value { - json!({ - "id": "cc-1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - }) -} - -/// Like [`openai_text`] but attaches a `usage` block so tests can drive the -/// token-based handoff gate. `prompt_tokens` is the input-token count the -/// agent will read and compare against the configured context budget. -fn openai_text_with_usage(content: &str, prompt_tokens: u64) -> Value { - let mut v = openai_text(content); - v["usage"] = json!({ - "prompt_tokens": prompt_tokens, - "completion_tokens": 1, - "total_tokens": prompt_tokens + 1, - }); - v -} - -fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { - json!({ - "id": "cc-2", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": id, "type": "function", - "function": { "name": name, "arguments": args.to_string() }, - }], - }, - "finish_reason": "tool_calls", - }], - }) -} - -async fn init_session(h: &mut Harness, mcp_servers: Value) -> String { - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - h.send( - "session/new", - json!({"cwd":"/tmp","mcpServers": mcp_servers}), - ) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - r["result"]["sessionId"] - .as_str() - .unwrap_or_else(|| { - panic!( - "session/new did not return sessionId: response={r}, stderr={}", - h.stderr_text() - ) - }) - .to_owned() -} - -/// After a text-only assistant response, the next prompt's request must -/// include that assistant text in `messages` history. Round 4 fix. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn assistant_text_preserved_across_prompts() { - let llm = spawn_capturing_llm(vec![openai_text("hello world"), openai_text("done")]).await; - let mut h = Harness::spawn(&llm.url).await; - let sid = init_session(&mut h, json!([])).await; - - // Prompt 1. - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - - // Prompt 2 — should carry assistant text from prompt 1. - let p2 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p2)).await; - - let captured = llm.captured.lock().await; - assert_eq!(captured.len(), 2, "expected 2 LLM requests"); - let msgs = captured[1]["messages"].as_array().unwrap(); - let assistants: Vec<&Value> = msgs.iter().filter(|m| m["role"] == "assistant").collect(); - assert!( - assistants.iter().any(|m| m["content"] == "hello world"), - "assistant text was dropped: messages={msgs:?}" - ); - h.shutdown().await; -} - -/// MCP init that hangs forever must time out within ~2s, surface an error, -/// and the child process must be killed (not lingering). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn mcp_init_timeout_kills_child() { - let llm = spawn_capturing_llm(vec![]).await; - let mut h = Harness::spawn(&llm.url).await; - - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - - let start = Instant::now(); - h.send( - "session/new", - json!({ - "cwd": "/tmp", - "mcpServers": [{ - "name": "stuck", - "command": fake_mcp, - "args": [], - "env": [{ "name": "FAKE_MCP_HANG_INIT", "value": "1" }], - }], - }), - ) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - let elapsed = start.elapsed(); - - assert!(r.get("error").is_some(), "expected error, got {r}"); - let msg = r["error"]["message"].as_str().unwrap_or(""); - assert!(msg.contains("timeout"), "error not a timeout: {msg}"); - // 2s timeout + small slack. Generous to cover slow CI. - assert!( - elapsed < Duration::from_secs(8), - "timeout took too long: {elapsed:?}" - ); - h.shutdown().await; -} - -/// A real MCP server that returns 200 tools with 100KB descriptions must -/// be capped: tool count ≤ MAX_TOOLS_PER_SESSION (128) — we expect spawn_all -/// to either reject (too many) OR truncate. We assert the spawn succeeds with -/// a bounded count, and that descriptions sent to the LLM are ≤ 1024 bytes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_metadata_caps_enforced() { - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn(&llm.url).await; - - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - h.send( - "session/new", - json!({ - "cwd": "/tmp", - "mcpServers": [{ - "name": "many", - "command": fake_mcp, - "args": [], - "env": [ - { "name": "FAKE_MCP_TOOL_COUNT", "value": "200" }, - { "name": "FAKE_MCP_HUGE_DESC", "value": "1" }, - ], - }], - }), - ) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - - // Either spawn rejects (200 > 128 cap) — that's acceptable hardening — - // OR it accepts and we verify the LLM request stays bounded. - if r.get("error").is_some() { - let msg = r["error"]["message"].as_str().unwrap_or(""); - assert!(msg.contains("too many"), "unexpected error: {msg}"); - h.shutdown().await; - return; - } - - let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - assert!(!captured.is_empty(), "no LLM request captured"); - let tools = captured[0]["tools"].as_array().unwrap(); - assert!(tools.len() <= 128, "tool count not capped: {}", tools.len()); - for t in tools { - let desc = t["function"]["description"].as_str().unwrap_or(""); - assert!( - desc.len() <= 1024, - "description not capped: {} bytes", - desc.len() - ); - } - h.shutdown().await; -} - -/// Cap on MCP server count: 17 servers must be rejected. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn mcp_server_count_cap() { - let llm = spawn_capturing_llm(vec![]).await; - let mut h = Harness::spawn(&llm.url).await; - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - let servers: Vec = (0..17) - .map(|i| { - json!({ - "name": format!("s{i}"), - "command": fake_mcp, - "args": [], - "env": [], - }) - }) - .collect(); - h.send("session/new", json!({"cwd":"/tmp","mcpServers": servers})) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - assert!(r.get("error").is_some(), "expected error for 17 servers"); - let msg = r["error"]["message"].as_str().unwrap_or(""); - assert!(msg.contains("too many"), "wrong error: {msg}"); - h.shutdown().await; -} - -/// After cancelling mid-tool-loop, the next prompt must succeed without -/// the LLM seeing a malformed history (assistant tool_use with no -/// matching tool_result). Round 5 fix. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cancel_leaves_history_valid_for_next_prompt() { - // Round 1: tool call (unknown — fails fast, no permission flow). - // Round 2: text "ok". - // After cancel, prompt 2 returns text immediately. - let llm = spawn_capturing_llm(vec![ - openai_tool_call("tc1", "fake__t", json!({})), - openai_text("after-cancel"), - openai_text("p2-done"), - ]) - .await; - let mut h = Harness::spawn(&llm.url).await; - let sid = init_session(&mut h, json!([])).await; - - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}), - ) - .await; - // Cancel right away; the agent races between cancellation and the LLM - // round trip — either way history must remain valid. - h.notify("session/cancel", json!({"sessionId": sid})).await; - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - - // Prompt 2 — must NOT error from a malformed history. - let p2 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p2)).await; - assert!(r.get("result").is_some(), "p2 errored: {r}"); - h.shutdown().await; -} - -/// Empty assistant content + no tool_calls must serialize as "" (not null) -/// for OpenAI, so subsequent prompts don't get rejected. Round 7 fix 6. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn empty_assistant_serializes_as_empty_string() { - // First call returns content="" finish_reason=stop — agent records an - // empty assistant turn. Second call's request body is what we inspect. - let llm = spawn_capturing_llm(vec![openai_text(""), openai_text("done")]).await; - let mut h = Harness::spawn(&llm.url).await; - let sid = init_session(&mut h, json!([])).await; - - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"a"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - let p2 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"b"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p2)).await; - - let captured = llm.captured.lock().await; - let msgs = captured[1]["messages"].as_array().unwrap(); - let empty_assistant = msgs - .iter() - .find(|m| m["role"] == "assistant" && m.get("tool_calls").is_none()) - .expect("no plain assistant turn"); - // Must be empty string, NOT null. - assert_eq!( - empty_assistant["content"], - json!(""), - "expected empty string content, got {empty_assistant}" - ); - h.shutdown().await; -} - -fn openai_n_tool_calls(n: usize) -> Value { - let calls: Vec = (0..n) - .map(|i| { - json!({ - "id": format!("c{i}"), - "type": "function", - "function": { "name": "many__tool_0", "arguments": "{}" }, - }) - }) - .collect(); - json!({ - "id": "cc-n", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": null, "tool_calls": calls }, - "finish_reason": "tool_calls", - }], - }) -} - -/// History budget evicts old turns: after many prompts, the LLM request -/// body stays below a sane bound. Round 7 fix; round 8 test. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn history_budget_evicts_old_turns() { - // Budget = 1 MB (MIN allowed by config). Each prompt is ~200 KB, so - // 12 prompts × 200 KB = ~2.4 MB blows the cap and forces eviction. - // We expect the captured request body to stay under 3× the cap. - const BUDGET: usize = 1024 * 1024; // 1 MB — must be >= MAX_PROMPT_BYTES - const PROMPT_BYTES: usize = 200 * 1024; // 200 KB per turn - let responses: Vec = (0..12).map(|_| openai_text(&"y".repeat(200))).collect(); - let llm = spawn_capturing_llm(responses).await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("BUZZ_AGENT_MAX_HISTORY_BYTES", &BUDGET.to_string()), - ("BUZZ_AGENT_MAX_HANDOFFS", "0"), // exercise truncation, not handoff - ], - ) - .await; - let sid = init_session(&mut h, json!([])).await; - - for i in 0..12 { - let user = "x".repeat(PROMPT_BYTES); - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text": format!("{i}:{user}")}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - } - - let captured = llm.captured.lock().await; - assert_eq!(captured.len(), 12); - // The last request must show eviction: body well under unbounded 12 × 200 KB = 2.4 MB. - let last = &captured[captured.len() - 1]; - let body_bytes = serde_json::to_vec(last).unwrap().len(); - assert!( - body_bytes < BUDGET * 3, - "history not evicted: request body is {body_bytes} bytes" - ); - let msgs = last["messages"].as_array().unwrap(); - // We must NEVER drop the latest user prompt. - assert!( - msgs.iter() - .any(|m| m["role"] == "user" && m["content"].as_str().unwrap_or("").starts_with("11:")), - "newest user turn missing" - ); - h.shutdown().await; -} - -/// Per-turn tool-call cap: an LLM that returns 100 tool_calls in one -/// response must only have 64 (MAX_TOOL_CALLS_PER_TURN) executed. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn per_turn_tool_call_cap_enforced() { - let llm = spawn_capturing_llm(vec![openai_n_tool_calls(100), openai_text("done")]).await; - let mut h = Harness::spawn(&llm.url).await; - - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - h.send( - "session/new", - json!({ - "cwd": "/tmp", - "mcpServers": [{ - "name": "many", - "command": fake_mcp, - "args": [], - "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], - }], - }), - ) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - - // Count distinct tool_call (pending) notifications until final response. - let mut tool_call_ids = std::collections::HashSet::new(); - loop { - let v = h.recv().await; - if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; - continue; - } - if v.get("method") == Some(&json!("session/update")) - && v["params"]["update"]["sessionUpdate"] == "tool_call" - { - if let Some(id) = v["params"]["update"]["toolCallId"].as_str() { - tool_call_ids.insert(id.to_owned()); - } - continue; - } - if v["id"] == json!(p) { - break; - } - } - // MAX_TOOL_CALLS_PER_TURN = 64. - assert_eq!( - tool_call_ids.len(), - 64, - "expected 64 tool_calls, got {}", - tool_call_ids.len() - ); - h.shutdown().await; -} - -/// Description clamping: a 5000-byte description from MCP must be -/// truncated to ≤ 1024 bytes in the LLM request. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn description_clamping_enforced() { - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn(&llm.url).await; - - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - h.send( - "session/new", - json!({ - "cwd": "/tmp", - "mcpServers": [{ - "name": "big", - "command": fake_mcp, - "args": [], - "env": [ - { "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }, - { "name": "FAKE_MCP_DESC_SIZE", "value": "5000" }, - ], - }], - }), - ) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - - let captured = llm.captured.lock().await; - let tools = captured[0]["tools"].as_array().unwrap(); - // The MCP tool is "big__tool_0"; load_skill may also be present when - // global skills are discovered from HOME. Find the MCP tool by name. - let mcp_tool = tools - .iter() - .find(|t| t["function"]["name"].as_str() == Some("big__tool_0")) - .expect("big__tool_0 not found in tool list"); - let desc = mcp_tool["function"]["description"].as_str().unwrap_or(""); - assert!( - desc.len() <= 1024, - "description not clamped: {} bytes (expected ≤ 1024)", - desc.len() - ); - // Sanity: the original was 5000 bytes, so we did clamp something. - assert!( - desc.len() < 5000, - "description not actually truncated: {} bytes", - desc.len() - ); - h.shutdown().await; -} - -/// Helper: spawn a session with a fake MCP server exposing one regular tool -/// plus an optional `_Stop` hook controlled by env vars. -async fn init_session_with_fake_mcp(h: &mut Harness, extra_mcp_env: &[(&str, &str)]) -> String { - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - let env: Vec = extra_mcp_env - .iter() - .map(|(k, v)| json!({ "name": k, "value": v })) - .collect(); - h.send( - "initialize", - json!({"protocolVersion":1,"clientCapabilities":{}}), - ) - .await; - let _ = h.recv().await; - h.send( - "session/new", - json!({ - "cwd": "/tmp", - "mcpServers": [{ - "name": "fake", - "command": fake_mcp, - "args": [], - "env": env, - }], - }), - ) - .await; - let r = h - .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) - .await; - r["result"]["sessionId"] - .as_str() - .expect("sessionId") - .to_owned() -} - -/// `_Stop` hook objects on the first end_turn → agent must NOT stop. -/// The hook returns an objection only on its first invocation; on the -/// second end_turn (after a tool round), the hook stays silent so the -/// agent ends cleanly. Verifies that the gate rerolls the LLM at least -/// once, and that the objection appears in history as a tool-role -/// message with the JSON-encoded source attribution. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_stop_blocks_premature_end() { - // LLM sequence: - // 1. text "premature" (triggers _Stop objection — call #1) - // 2. tool_call to fake__tool_0 (regular tool round) - // 3. text "really done" (hook returns empty on call #2 → end) - let llm = spawn_capturing_llm(vec![ - openai_text("premature"), - openai_tool_call("tc1", "fake__tool_0", json!({})), - openai_text("really done"), - ]) - .await; - // stop_max_rejections=10 so the budget never trips. The hook itself - // stays silent on its second call (FAKE_MCP_STOP_COUNT=1) so the - // second end_turn is accepted by the agent — this exercises the - // genuine "objected then later cleared" path, not a budget cap. - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("MCP_HOOK_SERVERS", "fake"), - ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_STOP_HOOK", "1"), - ("FAKE_MCP_STOP_TEXT", "you have open work"), - // Objection text returned for the first STOP_COUNT calls; - // empty string thereafter. - ("FAKE_MCP_STOP_COUNT", "1"), - ], - ) - .await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; - assert!(r.get("result").is_some(), "errored: {r}"); - assert_eq!(r["result"]["stopReason"], "end_turn"); - - // Agent must have called LLM ≥2 times (initial end_turn was rejected, - // forcing another LLM round). We expect exactly 3 here: text → tool → text. - let captured = llm.captured.lock().await; - assert!( - captured.len() >= 2, - "agent did not loop after objection: {} LLM calls", - captured.len() - ); - - // Round 2's request must carry the objection as a tool-role message - // (synthetic tool result), not a user/assistant message. Content is - // a JSON object with hook/server/text fields — never escapable. - let msgs = captured[1]["messages"].as_array().unwrap(); - let objection_present = msgs.iter().any(|m| { - if m["role"] != "tool" { - return false; - } - let content = m["content"].as_str().unwrap_or(""); - let parsed: Value = match serde_json::from_str(content) { - Ok(v) => v, - Err(_) => return false, - }; - parsed["hook"] == "_Stop" - && parsed["server"] == "fake" - && parsed["text"] - .as_str() - .unwrap_or("") - .contains("you have open work") - }); - assert!( - objection_present, - "objection (role=tool, JSON-encoded) missing from messages: {msgs:?}" - ); - h.shutdown().await; -} - -/// After `stop_max_rejections` objections, the agent honors end_turn -/// even if `_Stop` would still object. Set max=1 so it trips quickly. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_stop_budget_exhausted() { - // LLM sequence: - // 1. text → triggers _Stop objection (rejections: 0→1) - // 2. tool_call (regular tool round) - // 3. text → gate sees rejections>=max, returns end_turn (no _Stop call) - let llm = spawn_capturing_llm(vec![ - openai_text("first"), - openai_tool_call("tc1", "fake__tool_0", json!({})), - openai_text("second"), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("MCP_HOOK_SERVERS", "fake"), - ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_STOP_HOOK", "1"), - ("FAKE_MCP_STOP_TEXT", "still working"), - ], - ) - .await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; - assert!(r.get("result").is_some(), "errored: {r}"); - assert_eq!(r["result"]["stopReason"], "end_turn"); - - // Three LLM calls expected: budget cap stops the loop on the 3rd end_turn. - let captured = llm.captured.lock().await; - assert_eq!( - captured.len(), - 3, - "expected exactly 3 LLM calls (budget cap), got {}", - captured.len() - ); - h.shutdown().await; -} - -/// A persistent `_Stop` objection must keep the turn alive through repeated -/// consecutive end_turn responses. The configured rejection budget is the -/// bounded escape hatch; accepting the second response would silently idle a -/// session that still has open work. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_stop_consecutive_end_turn_uses_rejection_budget() { - // Three consecutive end_turn responses. With max=2, both objections must - // reroll the LLM and the third response is accepted by the budget cap. - let llm = spawn_capturing_llm(vec![ - openai_text("done-1"), - openai_text("done-2"), - openai_text("done-3"), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("MCP_HOOK_SERVERS", "fake"), - ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "2"), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_STOP_HOOK", "1"), - ("FAKE_MCP_STOP_TEXT", "keep going"), - ], - ) - .await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; - assert!(r.get("result").is_some(), "errored: {r}"); - assert_eq!(r["result"]["stopReason"], "end_turn"); - - // Both objections force another round; the budget permits the third end. - let captured = llm.captured.lock().await; - assert_eq!( - captured.len(), - 3, - "expected 3 LLM calls (two objections, then budget cap), got {}", - captured.len() - ); - h.shutdown().await; -} - -/// The `_Stop` rejection budget is per prompt: exhausting it on one prompt -/// must not disable the stop guard for the rest of the session. A second -/// prompt gets a fresh budget and its end_turn is objected to again. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_stop_budget_resets_per_prompt() { - // Each prompt: text → objection (budget 0→1) → text → cap. With max=1, - // both prompts take exactly 2 LLM calls; a session-cumulative budget - // would accept prompt 2's first end_turn without calling _Stop (3 total). - let llm = spawn_capturing_llm(vec![ - openai_text("p1-a"), - openai_text("p1-b"), - openai_text("p2-a"), - openai_text("p2-b"), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("MCP_HOOK_SERVERS", "fake"), - ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_STOP_HOOK", "1"), - ("FAKE_MCP_STOP_TEXT", "keep going"), - ], - ) - .await; - - for prompt in ["one", "two"] { - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text": prompt}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; - assert!(r.get("result").is_some(), "errored: {r}"); - assert_eq!(r["result"]["stopReason"], "end_turn"); - } - - let captured = llm.captured.lock().await; - assert_eq!( - captured.len(), - 4, - "expected 4 LLM calls (fresh budget objected on both prompts), got {}", - captured.len() - ); - h.shutdown().await; -} - -/// Regression: an LLM that tries to call a hidden hook tool (e.g. -/// `fake___Stop`) directly must get an "unknown tool" error result — -/// the MCP server must NOT be invoked. This guarantees a malicious or -/// confused model can't trigger lifecycle hooks itself. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_tools_hidden_from_llm() { - // LLM sequence: - // 1. tool_call to fake___Stop (hidden hook, must fail closed) - // 2. text "done" - let llm = spawn_capturing_llm(vec![ - openai_tool_call("tc1", "fake___Stop", json!({})), - openai_text("done"), - ]) - .await; - // We deliberately leave MCP_HOOK_SERVERS unset so the - // agent's hook gate is disabled — hook-tool hiding must hold even - // when hooks aren't allowlisted (defense in depth). - let mut h = Harness::spawn(&llm.url).await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_STOP_HOOK", "1"), - // Distinct text we can scan for. If the MCP server is ever - // invoked, this string would appear in the captured history. - ("FAKE_MCP_STOP_TEXT", "HOOK_LEAKED_TO_LLM"), - ], - ) - .await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; - assert!(r.get("result").is_some(), "errored: {r}"); - - // The tool result fed back to the LLM (round 2) must be the - // synthetic "unknown tool" error, not the hook's actual output. - let captured = llm.captured.lock().await; - assert_eq!( - captured.len(), - 2, - "expected 2 LLM calls, got {}", - captured.len() - ); - let msgs = captured[1]["messages"].as_array().unwrap(); - let tool_msg = msgs - .iter() - .find(|m| m["role"] == "tool") - .expect("expected a tool result message in round 2"); - let content = tool_msg["content"].as_str().unwrap_or(""); - assert!( - content.contains("unknown tool"), - "expected unknown-tool error, got: {content}" - ); - assert!( - !content.contains("HOOK_LEAKED_TO_LLM"), - "MCP hook was invoked from the LLM path: {content}" - ); - - // Defense-in-depth: also confirm the *advertised* tools never - // included the hook in the first place. - let round1_tools = captured[0]["tools"].as_array().unwrap(); - for t in round1_tools { - let name = t["function"]["name"].as_str().unwrap_or(""); - assert!( - !name.contains("_Stop"), - "hook tool advertised to LLM: {name}" - ); - } - h.shutdown().await; -} - -/// `_PostCompact` hook fires after a context-handoff and its output is -/// folded into the fresh `[Context Handoff]` user-context block as explicitly -/// untrusted text. The next LLM request must therefore see the post-compact -/// text without any orphan `role=tool` messages — proving the hook ran on the -/// *new* context, not the discarded one. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_post_compact_injects_after_handoff() { - // Sequence of canned LLM responses consumed in order: - // 1-3. Three `session/prompt` rounds returning short text. Each - // prompt body is ~300 KB, so by the 4th prompt we'll be over - // the 90% (= ~922 KB) threshold of a 1 MB budget. - // 4. Handoff `summarize()` call returns the summary text. - // 5. Next regular `complete()` call after the handoff returns - // a final "done" message; we inspect this request's body. - let llm = spawn_capturing_llm(vec![ - openai_text("ack-1"), - openai_text("ack-2"), - openai_text("ack-3"), - openai_text("handoff summary text"), - openai_text("done"), - ]) - .await; - // 1 MB budget = MIN allowed. Threshold = ~922 KB. Each ~300 KB prompt - // fills the budget on the 4th turn, triggering handoff. - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("MCP_HOOK_SERVERS", "fake"), - ("BUZZ_AGENT_MAX_HISTORY_BYTES", &(1024 * 1024).to_string()), - // Allow at least one handoff. - ("BUZZ_AGENT_MAX_HANDOFFS", "3"), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - // No _Stop hook here — _PostCompact only. - ("FAKE_MCP_POSTCOMPACT_HOOK", "1"), - ("FAKE_MCP_POSTCOMPACT_TEXT", "todo state here"), - ], - ) - .await; - - // Drive prompts until we observe a handoff. We detect it by counting - // captured LLM requests: a handoff inserts one extra `summarize` call - // that we didn't issue ourselves. We send up to 6 prompts. - let big = "x".repeat(300 * 1024); - let mut prompts_sent = 0usize; - let mut handoff_observed = false; - for i in 0..6 { - let p = h - .send( - "session/prompt", - json!({ - "sessionId": sid, - "prompt": [{"type":"text","text": format!("{i}:{big}")}], - }), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - prompts_sent += 1; - let captured_now = llm.captured.lock().await.len(); - // After N prompts we'd normally see N requests; an extra request - // means a handoff summarize() ran. - if captured_now > prompts_sent { - handoff_observed = true; - break; - } - } - assert!( - handoff_observed, - "no handoff observed after {prompts_sent} prompts (captured={})", - llm.captured.lock().await.len() - ); - - // The first LLM call AFTER the handoff is the one we inspect. Find it: - // it's the one where the messages array is short (history just reset) - // and contains the _PostCompact payload inside user-context text. It must - // not be emitted as an orphan tool result because the old assistant tool - // call was deliberately discarded by the handoff reset. - let captured = llm.captured.lock().await; - let post_compact_visible = captured.iter().any(|req| { - let msgs = match req["messages"].as_array() { - Some(m) => m, - None => return false, - }; - msgs.iter().any(|m| { - if m["role"] != "user" { - return false; - } - let content = m["content"].as_str().unwrap_or(""); - content.contains("[Post-compact hook output — untrusted]") - && content.contains("[fake]") - && content.contains("todo state here") - }) - }); - assert!( - post_compact_visible, - "_PostCompact context not visible to LLM after handoff" - ); - let orphan_tool_result = captured.iter().any(|req| { - req["messages"] - .as_array() - .is_some_and(|msgs| msgs.iter().any(|m| m["role"] == "tool")) - }); - assert!( - !orphan_tool_result, - "handoff reset must not leave orphan role=tool messages" - ); - h.shutdown().await; -} - -/// The handoff summary prompt should include all session history when that -/// history fits the summarizer context budget. This protects against regressing -/// to the old fixed tail of five tiny snippets. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn handoff_summary_prompt_includes_full_history_within_context_budget() { - let llm = spawn_capturing_llm(vec![ - openai_text_with_usage("ack-0", 9500), - openai_text("handoff summary text"), - openai_text_with_usage("done", 10), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"), - ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"), - ("BUZZ_AGENT_MAX_HANDOFFS", "3"), - ( - "BUZZ_AGENT_MAX_HISTORY_BYTES", - &(16 * 1024 * 1024).to_string(), - ), - ], - ) - .await; - let sid = init_session(&mut h, json!([])).await; - - let p0 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"early-history-marker"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p0)).await; - - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"late-history-marker"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - - let captured = llm.captured.lock().await; - assert_eq!(captured.len(), 3, "expected prompt, handoff, prompt"); - let handoff_messages = captured[1]["messages"].as_array().unwrap(); - let handoff_prompt = handoff_messages[1]["content"].as_str().unwrap(); - assert!( - handoff_prompt.contains("# Session History (oldest first)"), - "handoff prompt should describe full session history: {handoff_prompt}" - ); - assert!( - handoff_prompt.contains("early-history-marker"), - "oldest prompt was omitted despite fitting budget: {handoff_prompt}" - ); - assert!( - handoff_prompt.contains("ack-0"), - "assistant response was omitted despite fitting budget: {handoff_prompt}" - ); - assert!( - handoff_prompt.contains("late-history-marker"), - "latest prompt was omitted despite fitting budget: {handoff_prompt}" - ); - assert!( - !handoff_prompt.contains("older items omitted"), - "handoff should not report truncation when full history fits: {handoff_prompt}" - ); - h.shutdown().await; -} - -/// If one item is larger than the derived summarizer budget, keep a truncated -/// form of the most recent item instead of sending an empty history block. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn handoff_summary_prompt_keeps_latest_item_when_one_item_exceeds_budget() { - let llm = spawn_capturing_llm(vec![ - openai_text_with_usage("ack-0", 9500), - openai_text("handoff summary text"), - openai_text_with_usage("done", 10), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"), - ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"), - ("BUZZ_AGENT_MAX_HANDOFFS", "3"), - ( - "BUZZ_AGENT_MAX_HISTORY_BYTES", - &(16 * 1024 * 1024).to_string(), - ), - ], - ) - .await; - let sid = init_session(&mut h, json!([])).await; - - let huge = format!("oversize-latest-marker {}", "x".repeat(12000)); - let p0 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"early-history-marker"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p0)).await; - - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text": huge}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - - let captured = llm.captured.lock().await; - assert_eq!(captured.len(), 3, "expected prompt, handoff, prompt"); - let handoff_messages = captured[1]["messages"].as_array().unwrap(); - let handoff_prompt = handoff_messages[1]["content"].as_str().unwrap(); - assert!( - handoff_prompt.contains("oversize-latest-marker"), - "latest oversized item should be kept in truncated form: {handoff_prompt}" - ); - assert!( - handoff_prompt.contains("older items omitted"), - "handoff should report truncation when history exceeds budget: {handoff_prompt}" - ); - h.shutdown().await; -} - -/// Regression for the original bug: context fills, the provider 400s on the -/// next request, and the handoff never fires because the old gate measured -/// BYTES (16 MiB threshold) while the limit is in TOKENS. The fix gates on -/// provider-reported input tokens. Here the prompts are tiny (bytes nowhere -/// near any byte threshold), but the fake LLM reports `usage.prompt_tokens` -/// over the configured token budget — so the handoff MUST fire on the token -/// signal alone, before the next normal `complete()`. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn token_usage_over_budget_triggers_handoff() { - // Context window 1000 tokens, output 100 -> threshold = min(900, 900) = 900. - // First response reports 950 input tokens (> 900). The agent stores that; - // the next prompt's pre-flight gate sees 950 >= 900 and hands off, which - // inserts an extra summarize() call we didn't issue. - // req 1: prompt #0 -> text + usage(950) - // req 2: summarize() (the handoff) -> summary text - // req 3: prompt #1's actual complete() -> done - let llm = spawn_capturing_llm(vec![ - openai_text_with_usage("ack-0", 950), - openai_text("handoff summary text"), - openai_text_with_usage("done", 10), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), - ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), - ("BUZZ_AGENT_MAX_HANDOFFS", "3"), - // Huge byte budget so the byte path can NOT be what fires — only - // the token gate can explain a handoff on these tiny prompts. - ( - "BUZZ_AGENT_MAX_HISTORY_BYTES", - &(16 * 1024 * 1024).to_string(), - ), - ], - ) - .await; - let sid = init_session(&mut h, json!([])).await; - - // Prompt #0: small body; response carries usage(950) -> over threshold. - let p0 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello 0"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p0)).await; - assert_eq!( - llm.captured.lock().await.len(), - 1, - "first prompt should produce exactly one LLM request (no handoff yet)" - ); - - // Prompt #1: also small. The pre-flight gate sees the stored 800 tokens - // and hands off BEFORE issuing this prompt's complete() -> an extra - // summarize request appears (3 total, not 2). - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello 1"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - let captured = llm.captured.lock().await.len(); - assert_eq!( - captured, 3, - "expected handoff summarize() between the two prompts (3 reqs), saw {captured} — \ - token gate did not fire on usage over budget" - ); - let stderr = h.stderr_text(); - assert!( - stderr.contains("handoff #1 (history"), - "expected handoff log line in stderr, got: {stderr}" - ); - assert!( - stderr.contains(" -> ") && stderr.contains(" tokens"), - "expected handoff log to include before/after token counts, got: {stderr}" - ); - h.shutdown().await; -} - -/// Regression for the stale-usage gap (caught in review): the exact token -/// count describes the PREVIOUS request, but history grows afterward (tool -/// results, next prompt). If the gate trusted only the stale `Some(tokens)` -/// and skipped the byte signal, a previously-under-threshold session could -/// still 400 once a large tool result lands. The fix adds a conservative -/// token estimate of the bytes grown since the measurement. Here usage is -/// reported UNDER threshold, then a large tool result grows history enough -/// that the projection crosses — so the handoff must fire. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stale_usage_plus_history_growth_triggers_handoff() { - // window 10_000, output 1_000 -> threshold = min(9_000, 9_000) = 9_000. - // req1 reports usage 8_500 (UNDER 9_000). Its response is a tool_call; - // the fake MCP returns a ~6 KB result, appended to history. At the - // conservative 1 byte/token estimate that's ~6_000 projected tokens, so - // projected ~14_500 >= 9_000 -> the next loop iteration hands off before - // the follow-up complete(). - // req1: tool_call + usage(8500) - // (tool result ~6KB appended) - // req2: summarize() (handoff) - // req3: final text - let llm = spawn_capturing_llm(vec![ - { - let mut v = openai_tool_call("tc1", "fake__tool_0", json!({})); - v["usage"] = - json!({"prompt_tokens": 8500, "completion_tokens": 1, "total_tokens": 8501}); - v - }, - openai_text("handoff summary text"), - openai_text("done"), - ]) - .await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"), - ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"), - ("BUZZ_AGENT_MAX_HANDOFFS", "3"), - // Huge byte budget so the None-path byte fallback can't be what - // fires — only the token-mode growth estimate can explain it. - ( - "BUZZ_AGENT_MAX_HISTORY_BYTES", - &(16 * 1024 * 1024).to_string(), - ), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_RESULT_SIZE", "6000"), - ], - ) - .await; - - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; - // req1 (tool_call) + summarize (handoff) + req2 (done) = 3. Without the - // growth estimate we'd see only 2 (stale 8500 < 9000, no handoff). - let captured = llm.captured.lock().await.len(); - assert_eq!( - captured, 3, - "expected handoff after history grew past threshold (3 reqs), saw {captured} — \ - stale under-threshold usage skipped the growth estimate" - ); - h.shutdown().await; -} - -/// `_Stop` hook that takes longer than `BUZZ_AGENT_HOOK_TIMEOUT_MS` -/// must be treated as no-objection (fail-open). Agent stops normally. -/// -/// Note on server-kill-on-timeout: `call_hooks` calls `kill_server` on a -/// timed-out hook so a wedged server can't poison subsequent calls. We -/// don't add a separate per-test assertion for this — the -/// `mcp_init_timeout_kills_child` test already exercises the same -/// kill-on-timeout codepath through `kill_server`, and the harness here -/// (spawn-then-shutdown) makes a follow-up "tool still works" check -/// fragile because the server we just killed is the only one in the -/// session. The timeout assertion below (elapsed < 2.5s) implicitly -/// covers the kill: if the hook child kept running past the timeout, -/// we'd block on it during shutdown. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hook_stop_timeout_failopen() { - let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env( - &llm.url, - &[ - ("MCP_HOOK_SERVERS", "fake"), - // Hook delay (3s) >> hook timeout (200ms) → fail-open. - ("BUZZ_AGENT_HOOK_TIMEOUT_MS", "200"), - ], - ) - .await; - let sid = init_session_with_fake_mcp( - &mut h, - &[ - ("FAKE_MCP_TOOL_COUNT", "1"), - ("FAKE_MCP_STOP_HOOK", "1"), - ("FAKE_MCP_STOP_TEXT", "would object"), - ("FAKE_MCP_STOP_DELAY", "3"), - ], - ) - .await; - - let started = Instant::now(); - let p = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; - let elapsed = started.elapsed(); - - assert!(r.get("result").is_some(), "errored: {r}"); - assert_eq!(r["result"]["stopReason"], "end_turn"); - // Hook delay is 3s; if we waited for it the test would take ≥3s. - // 1.5s gives slack for CI without masking a regression. - assert!( - elapsed < Duration::from_millis(2500), - "did not fail-open: prompt took {elapsed:?}" - ); - - // Only the initial LLM call — agent did NOT loop after the timeout. - let captured = llm.captured.lock().await; - assert_eq!( - captured.len(), - 1, - "expected 1 LLM call, got {}", - captured.len() - ); - h.shutdown().await; -} - -/// When a session is cancelled while a tool call is in-flight, the agent -/// sends `notifications/cancelled` to the MCP server. With buzz-dev-mcp, -/// this cancels the CancellationToken and kills the running shell process -/// group. We verify: -/// 1. The prompt completes in under 5s (not 60s). -/// 2. The `sleep 60` process is actually dead after cancel. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cancel_kills_inflight_tool_via_mcp_notification() { - // buzz-dev-mcp is a separate crate; locate its binary relative to - // the buzz-agent test binary (they share the same target dir). - let self_bin = std::path::PathBuf::from(env!("CARGO_BIN_EXE_buzz-agent")); - let dev_mcp_bin = self_bin.parent().unwrap().join("buzz-dev-mcp"); - let dev_mcp_is_executable = std::fs::metadata(&dev_mcp_bin) - .map(|metadata| { - if !metadata.is_file() || metadata.len() == 0 { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - true - } - }) - .unwrap_or(false); - if !dev_mcp_is_executable { - eprintln!( - "SKIP: buzz-dev-mcp not built at {}; run `cargo build -p buzz-dev-mcp` first", - dev_mcp_bin.display() - ); - return; - } - let dev_mcp_bin = dev_mcp_bin.to_string_lossy().to_string(); - - // Use a unique marker (PID + timestamp) to avoid stale-file collisions. - let marker = format!( - "buzz_cancel_test_{}_{:x}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let pid_file = format!("/tmp/{marker}.pid"); - let _ = std::fs::remove_file(&pid_file); // clean any stale file - let cmd = format!("echo $$ > /tmp/{marker}.pid && exec sleep 60"); - - // LLM returns a shell tool call, then text after cancel. - let llm = spawn_capturing_llm(vec![ - openai_tool_call("tc1", "dev__shell", json!({"command": cmd})), - openai_text("done"), - ]) - .await; - - let mut h = Harness::spawn(&llm.url).await; - let sid = init_session( - &mut h, - json!([{ - "name": "dev", - "command": &dev_mcp_bin, - "args": [], - "env": [] - }]), - ) - .await; - - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"run"}]}), - ) - .await; - - // Wait for the tool call to be in-progress. - h.recv_until(|v| { - v.get("params") - .and_then(|p| p.get("update")) - .and_then(|u| u.get("status")) - .and_then(Value::as_str) - == Some("in_progress") - }) - .await; - - // Wait for the shell to spawn and write its PID (bounded). - let pid_deadline = Instant::now() + Duration::from_secs(3); - let shell_pid: u32 = loop { - if let Ok(content) = std::fs::read_to_string(&pid_file) { - if let Ok(pid) = content.trim().parse::() { - break pid; - } - } - assert!( - Instant::now() < pid_deadline, - "shell did not write PID file within 3s" - ); - tokio::time::sleep(Duration::from_millis(50)).await; - }; - - // Cancel the session — measure latency from here. - let cancel_start = Instant::now(); - h.notify("session/cancel", json!({"sessionId": sid})).await; - - // Wait for prompt to complete. - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - - let cancel_latency = cancel_start.elapsed(); - // Cancellation itself should complete in well under 3s. The 60s sleep - // must NOT run to completion. We allow generous CI slack. - assert!( - cancel_latency < Duration::from_secs(3), - "cancel latency too high: {cancel_latency:?} (expected < 3s)" - ); - - // Verify the shell process is actually dead (bounded poll). - let kill_deadline = Instant::now() + Duration::from_secs(2); - loop { - let alive = std::process::Command::new("kill") - .args(["-0", &shell_pid.to_string()]) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if !alive { - break; - } - assert!( - Instant::now() < kill_deadline, - "shell process {shell_pid} still alive 2s after cancel" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } - - // Cleanup. - let _ = std::fs::remove_file(&pid_file); - h.shutdown().await; -} - -/// Protocol-level test: verify that `notifications/cancelled` is sent to -/// any MCP server (not just buzz-dev-mcp) when a session is cancelled -/// during an in-flight tool call. Uses fake_mcp with FAKE_MCP_CANCEL_LOG -/// to capture the raw notification on stdin. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { - let cancel_log = std::env::temp_dir() - .join(format!( - "buzz_cancel_proto_{}_{:x}.log", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )) - .to_string_lossy() - .to_string(); - let _ = std::fs::remove_file(&cancel_log); - let call_received_marker = format!("{cancel_log}.call_received"); - let _ = std::fs::remove_file(&call_received_marker); - - let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); - - // LLM returns a tool call; fake_mcp will delay 999s (never responds). - let llm = spawn_capturing_llm(vec![ - openai_tool_call("tc1", "fake__tool_0", json!({})), - openai_text("done"), - ]) - .await; - - let mut h = Harness::spawn(&llm.url).await; - let sid = init_session( - &mut h, - json!([{ - "name": "fake", - "command": fake_mcp, - "args": [], - "env": [ - {"name": "FAKE_MCP_TOOL_DELAY", "value": "999"}, - {"name": "FAKE_MCP_CANCEL_LOG", "value": &cancel_log}, - {"name": "FAKE_MCP_CALL_RECEIVED", "value": &call_received_marker}, - ] - }]), - ) - .await; - - let p1 = h - .send( - "session/prompt", - json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), - ) - .await; - - // Wait for tool call to be in-progress. - h.recv_until(|v| { - v.get("params") - .and_then(|p| p.get("update")) - .and_then(|u| u.get("status")) - .and_then(Value::as_str) - == Some("in_progress") - }) - .await; - - // Wait until fake_mcp has received the tools/call request (bounded). - // The marker file contains the JSON-RPC request id. - let call_deadline = Instant::now() + Duration::from_secs(3); - let call_request_id: Value = loop { - if let Ok(content) = std::fs::read_to_string(&call_received_marker) { - if let Ok(id) = serde_json::from_str::(content.trim()) { - break id; - } - } - assert!( - Instant::now() < call_deadline, - "fake_mcp did not receive tools/call within 3s" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - }; - - // Cancel the session. - h.notify("session/cancel", json!({"sessionId": sid})).await; - - // Wait for prompt to complete. - let _ = h.recv_until(|v| v["id"] == json!(p1)).await; - - // Poll the cancel log with bounded timeout (replaces fixed sleep). - let poll_deadline = Instant::now() + Duration::from_secs(2); - let log_content = loop { - let content = std::fs::read_to_string(&cancel_log).unwrap_or_default(); - if content.contains("notifications/cancelled") { - break content; - } - assert!( - Instant::now() < poll_deadline, - "cancel notification not received within 2s; log: {content:?}" - ); - tokio::time::sleep(Duration::from_millis(50)).await; - }; - - // Parse the logged notification and verify requestId matches the - // actual tools/call request id that fake_mcp received. - let cancel_msg: Value = serde_json::from_str(log_content.trim()).unwrap_or(json!(null)); - let cancelled_id = &cancel_msg["params"]["requestId"]; - assert!( - cancelled_id.is_number(), - "expected numeric requestId in cancel notification, got: {cancel_msg}" - ); - assert_eq!( - cancelled_id, &call_request_id, - "cancelled requestId ({cancelled_id}) != tools/call id ({call_request_id})" - ); - - // Cleanup. - let _ = std::fs::remove_file(&cancel_log); - let _ = std::fs::remove_file(&call_received_marker); - h.shutdown().await; -} diff --git a/buzz-agent-core/tests/stdio_turn.rs b/crates/buzz-agent/tests/stdio_turn.rs similarity index 100% rename from buzz-agent-core/tests/stdio_turn.rs rename to crates/buzz-agent/tests/stdio_turn.rs diff --git a/buzz-agent-core/tests/steer.rs b/crates/buzz-agent/tests/steer.rs similarity index 100% rename from buzz-agent-core/tests/steer.rs rename to crates/buzz-agent/tests/steer.rs diff --git a/buzz-agent-core/tests/stop_hook.rs b/crates/buzz-agent/tests/stop_hook.rs similarity index 100% rename from buzz-agent-core/tests/stop_hook.rs rename to crates/buzz-agent/tests/stop_hook.rs diff --git a/crates/buzz-model-catalog/Cargo.toml b/crates/buzz-model-catalog/Cargo.toml new file mode 100644 index 0000000000..12a17c3f2e --- /dev/null +++ b/crates/buzz-model-catalog/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "buzz-model-catalog" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +# Databricks model discovery + the PKCE/static token sources it needs. +# +# Extracted from `buzz-agent` when the agent loop moved onto the goose library. +# The desktop model picker calls this directly as a library +# (`desktop/src-tauri/src/commands/agent_models.rs:791`), and linking the whole +# agent for it is now impossible: goose pulls `sqlx-sqlite` -> `libsqlite3-sys +# 0.30`, desktop already has `rusqlite 0.37` -> `libsqlite3-sys 0.35`, and both +# declare `links = "sqlite3"`. Cargo forbids two packages linking the same +# native library, and no version pin can resolve it. +# +# So the desktop depends on this crate instead — no goose, no sqlite, same API. + +[dependencies] +async-trait = "0.1" +base64 = "0.22" +reqwest = { workspace = true, features = ["json", "rustls", "form"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = ["sync", "time"] } +tracing = { workspace = true } + +axum = { workspace = true } +urlencoding = "2" +webbrowser = "1" +hex = { workspace = true } +getrandom = "0.4" + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-model-catalog/src/auth.rs similarity index 100% rename from crates/buzz-agent/src/auth.rs rename to crates/buzz-model-catalog/src/auth.rs diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-model-catalog/src/catalog.rs similarity index 93% rename from crates/buzz-agent/src/catalog.rs rename to crates/buzz-model-catalog/src/catalog.rs index aa2a121c99..3020032076 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-model-catalog/src/catalog.rs @@ -13,10 +13,50 @@ use reqwest::Client; use crate::{ + auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource}, config::{Config, Provider}, - llm::build_token_source, types::AgentError, }; +use std::sync::Arc; + +const DATABRICKS_CLIENT_ID: &str = "databricks-cli"; +const DATABRICKS_OAUTH_SCOPES: &[&str] = &["all-apis", "offline_access"]; + +/// Acquire a bearer for Databricks model discovery. +/// +/// Lifted verbatim from the deleted `llm.rs:1529-1555`. Goose owns provider +/// auth for the agent loop now, but the desktop model picker calls +/// [`discover_databricks_models`] directly as a library +/// (`desktop/src-tauri/src/commands/agent_models.rs:791`), so this path is +/// still ours and must keep its exact no-browser semantics: a PKCE cache miss +/// returns `Err(LlmAuth)` and the caller falls back to a subprocess probe. +fn build_token_source(cfg: &Config) -> Result, AgentError> { + match cfg.provider { + Provider::Anthropic | Provider::OpenAi => { + Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) + } + Provider::Databricks | Provider::DatabricksV2 => { + if !cfg.api_key.is_empty() { + return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))); + } + let discovery_url = format!( + "{}/oidc/.well-known/oauth-authorization-server", + cfg.base_url.trim_end_matches('/') + ); + let pkce = PkceOAuthConfig { + discovery_url, + client_id: DATABRICKS_CLIENT_ID.into(), + scopes: DATABRICKS_OAUTH_SCOPES + .iter() + .map(|s| (*s).into()) + .collect(), + cache_namespace: "databricks".into(), + cache_dir_override: None, + }; + Ok(PkceOAuthTokenSource::new(pkce)?) + } + } +} /// A discovered model entry: `id` is the picker value, `name` is the display /// label (same as `id` for Databricks — the API has no separate display name). diff --git a/crates/buzz-model-catalog/src/config.rs b/crates/buzz-model-catalog/src/config.rs new file mode 100644 index 0000000000..fe8069848b --- /dev/null +++ b/crates/buzz-model-catalog/src/config.rs @@ -0,0 +1,47 @@ +//! Minimal provider config for model discovery. +//! +//! This is the surviving slice of the pre-goose `buzz-agent/src/config.rs` +//! (2,709 lines). Everything else in that file existed to *implement* provider +//! configuration for the agent loop — provider enums, base URLs, model-name +//! normalization, OpenAI upgrade rules — and goose owns all of it now. +//! +//! What remains is only what Databricks model discovery needs, with the exact +//! names `desktop/src-tauri` matches on +//! (`commands/agent_models.rs:755-785`). + +/// Provider families the desktop model picker can discover models for. +/// +/// Names preserved verbatim: the desktop constructs these variants directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Provider { + Anthropic, + #[default] + OpenAi, + /// Databricks model serving (`api/2.0/serving-endpoints`). + Databricks, + /// Databricks AI Gateway v2. + DatabricksV2, +} + +/// Credentials and host for a discovery call. +#[derive(Debug, Clone)] +pub struct Config { + pub provider: Provider, + /// Static bearer. Empty means "try the PKCE cache, but never open a + /// browser" — see [`crate::catalog::discover_databricks_models`]. + pub api_key: String, + /// Provider host, e.g. `DATABRICKS_HOST`. + pub base_url: String, +} + +impl Config { + /// Signature preserved from the pre-goose crate — the desktop calls this + /// directly (`commands/agent_models.rs:785`). + pub fn for_discovery(provider: Provider, api_key: String, base_url: String) -> Self { + Self { + provider, + api_key, + base_url, + } + } +} diff --git a/crates/buzz-model-catalog/src/lib.rs b/crates/buzz-model-catalog/src/lib.rs new file mode 100644 index 0000000000..1b4b796769 --- /dev/null +++ b/crates/buzz-model-catalog/src/lib.rs @@ -0,0 +1,44 @@ +#![forbid(unsafe_code)] +//! Databricks model discovery, split out of `buzz-agent`. +//! +//! # Why this crate exists +//! +//! When `buzz-agent`'s loop moved onto the goose library, `desktop/src-tauri` +//! could no longer link it. Goose pulls `sqlx-sqlite` → `libsqlite3-sys 0.30`; +//! the desktop already has `rusqlite 0.37` → `libsqlite3-sys 0.35`. Both +//! declare `links = "sqlite3"`, and Cargo refuses to build two packages that +//! link the same native library. No version pin resolves that. +//! +//! The desktop never needed the agent loop anyway — only the model picker's +//! Databricks discovery (`commands/agent_models.rs:791`). So that code, and +//! the token sources it depends on, live here: no goose, no sqlite, no agent. + +pub mod auth; +pub mod catalog; +pub mod config; +pub mod types; + +pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; +pub use config::{Config, Provider}; +pub use types::AgentError; + +/// Environment keys the Windows Git Bash resolver may inspect. +/// +/// The MCP child is spawned with an otherwise-cleared environment, so every +/// key here must be forwarded or a ready agent cannot start its shell tool. +/// Doctor checks the same contract, which is why this is one shared constant +/// rather than two lists that can drift +/// (`desktop/src-tauri/src/managed_agents/git_bash.rs:136`). +/// +/// Parked in this crate because it is the only agent-side crate the desktop +/// can still link. +#[cfg(windows)] +pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[ + "PATH", + "BUZZ_SHELL", + "GIT_BASH", + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "LOCALAPPDATA", +]; diff --git a/crates/buzz-model-catalog/src/types.rs b/crates/buzz-model-catalog/src/types.rs new file mode 100644 index 0000000000..d3c886ddda --- /dev/null +++ b/crates/buzz-model-catalog/src/types.rs @@ -0,0 +1,30 @@ +//! Error type shared by the token sources and the discovery calls. +//! +//! Kept structurally identical to `buzz_agent::types::AgentError` because +//! `desktop/src-tauri` matches on the `LlmAuth` variant by name to decide +//! whether to fall back to a subprocess probe +//! (`commands/agent_models.rs:793`). + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentError { + InvalidParams(String), + Llm(String), + /// No usable credential. The desktop treats this as "fall through to the + /// subprocess probe", never as a hard failure — so discovery must return + /// it rather than hanging or opening a browser. + LlmAuth(String), + LlmModelNotFound(String), +} + +impl std::fmt::Display for AgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidParams(s) => write!(f, "invalid params: {s}"), + Self::Llm(s) => write!(f, "llm: {s}"), + Self::LlmAuth(s) => write!(f, "llm auth: {s}"), + Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), + } + } +} + +impl std::error::Error for AgentError {} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e0732f62dc..1929cf0588 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -963,30 +963,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "buzz-agent" -version = "0.1.0" -dependencies = [ - "arc-swap", - "async-trait", - "axum", - "base64 0.22.1", - "getrandom 0.4.3", - "hex", - "nix 0.31.3", - "reqwest 0.13.4", - "rmcp", - "serde", - "serde_json", - "serde_yaml", - "sha2 0.11.0", - "tokio", - "tracing", - "tracing-subscriber", - "urlencoding", - "webbrowser", -] - [[package]] name = "buzz-core" version = "0.1.0" @@ -1017,9 +993,9 @@ dependencies = [ "audioadapter-buffers", "axum", "base64 0.22.1", - "buzz-agent", "buzz-core", "buzz-media", + "buzz-model-catalog", "buzz-persona", "buzz-sdk", "bytes", @@ -1120,6 +1096,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-model-catalog" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "getrandom 0.4.3", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tracing", + "urlencoding", + "webbrowser", +] + [[package]] name = "buzz-persona" version = "0.1.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d689544688..84f68a4203 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -88,7 +88,17 @@ url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } -buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } +# Model discovery + Windows shell env only. NOT the agent loop. +# +# The desktop used to link `buzz-agent` wholesale. Once that crate's loop moved +# onto the goose library it became unlinkable here: goose pulls +# sqlx-sqlite -> libsqlite3-sys 0.30, this crate already has +# rusqlite 0.37 -> libsqlite3-sys 0.35, and both declare `links = "sqlite3"`. +# Cargo refuses two packages linking the same native library; no pin fixes it. +# +# The desktop only ever used discover_databricks_models, config::Provider and +# WINDOWS_SHELL_RESOLUTION_ENV, so those live in buzz-model-catalog now. +buzz_agent_pkg = { package = "buzz-model-catalog", path = "../../crates/buzz-model-catalog" } iroh = { version = "1.0.2", optional = true } mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } From ac6aed1eafd02d4624d4e712ab6652ebea7307d7 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 17:28:56 +1000 Subject: [PATCH 13/20] docs(buzz-agent): rewrite HANDTEST.md for the swap The old version described a parallel `buzz-agent-core` crate and a `just agent-core` recipe, neither of which exists any more -- the goose-backed loop IS buzz-agent now. There is nothing special to run: `just dev` and `just goose` already build and use the swapped crate. If you see a difference, that is the bug. Keeps the seven human-only checks (persona arrival, _Stop veto, streaming feel, cancel leaving no stuck spinner, steering, model picker, hook-tool hygiene) and the known gaps. Signed-off-by: Michael Neale --- crates/buzz-agent/HANDTEST.md | 150 +++++++++++++--------------------- 1 file changed, 56 insertions(+), 94 deletions(-) diff --git a/crates/buzz-agent/HANDTEST.md b/crates/buzz-agent/HANDTEST.md index b24f722313..8661798b20 100644 --- a/crates/buzz-agent/HANDTEST.md +++ b/crates/buzz-agent/HANDTEST.md @@ -1,129 +1,91 @@ -# Hand-testing `buzz-agent-core` +# Hand-testing `buzz-agent` -The premise: this binary **is** `buzz-agent`. Same name, same ACP contract, -goose underneath. So hand-testing is a *swap*, not a new setup — you run the -normal buzz-acp flow and repoint one environment variable. +This is a **swap**, not a new thing. `buzz-agent` keeps its name, its binary, +its ACP contract and its place in the workspace — only the internals changed +from a hand-written agent loop to the `goose` crate used as a Rust library. -`buzz-acp` derives harness identity from the command's **basename** -(`normalize_agent_command_identity`, `buzz-acp/src/config.rs:600-615`) and -already has a `"buzz-agent"` arm meaning "no extra args" -(`default_agent_args`, `:617-624`). The binary is emitted as `buzz-agent` -precisely so buzz-acp cannot tell the difference. - -## 0. Build +So there is nothing special to run. The normal flow *is* the test: ```bash -. ./bin/activate-hermit -cargo build --release -p buzz-acp -p buzz-dev-mcp -p buzz-cli -cargo build --release --manifest-path buzz-agent-core/Cargo.toml -``` - -Takes a few minutes the first time (goose pulls ~700 crates into its own -lockfile). - -## 1. The swap - -```bash -just relay # terminal 1 - -# terminal 2 -just agent-core # new: goose-backed buzz-agent -just goose # old: for A/B comparison +just dev # the whole app +# or +just relay # terminal 1 +just goose # terminal 2 — agent against that relay ``` -`just agent-core` is `just goose` with two lines changed: +Both already build and use the swapped `buzz-agent`. If you see a difference, +that is the bug. -```diff -- BUZZ_ACP_AGENT_COMMAND=goose -- BUZZ_ACP_AGENT_ARGS=acp -+ BUZZ_ACP_AGENT_COMMAND=/buzz-agent-core/target/release/buzz-agent -+ BUZZ_ACP_MCP_COMMAND=/target/release/buzz-dev-mcp -``` - -Everything else — relay URL, keys, agent count, heartbeat — is identical. +## What to look at -## 2. What to actually check +Ordered by risk. The automated suite (41 tests) covers each of these at the +stdio layer; this list is the part only a human can judge. -Ordered by how likely it is to be broken and how badly it would matter. -The automated suite covers each of these at the stdio layer; this is about -the parts only a human can see. - -### A. Persona actually arrives — the whole reason for embedding +### 1. Persona actually arrives ``` @fizz who are you? ``` -Should answer *as Fizz*, and should know the `buzz` CLI exists. If it answers -as generic goose, `systemPrompt` was dropped — the exact failure that makes -plain-ACP embedding impossible (goose's own ACP server never reads -`systemPrompt`; both PRs that would have wired it, buzz#1290 and goose#9971, -are closed unmerged). +Should answer *as Fizz* and know the `buzz` CLI exists. A generic goose answer +means the system prompt was dropped — the exact failure that makes plain-ACP +embedding impossible, and the reason this uses the library API instead. -### B. `_Stop` veto — buzz-agent's most load-bearing behaviour +### 2. `_Stop` veto ``` @fizz make a todo list with 3 items, then stop immediately without doing them ``` -It must **refuse to stop** while items are open, and keep working. Capped at 3 -vetoes, so it will eventually end regardless. Watch for -`_Stop hook vetoed end of turn` in the buzz-acp log. +Must refuse to stop while items are open. Capped at 3 vetoes, so it ends +eventually regardless. Look for `_Stop hook vetoed end of turn` in the log. -### C. Streaming feel +### 3. Streaming feel -buzz-agent emitted one chunk per round; goose streams token-by-token. The -automated tests prove the relay isn't write-amplified (chunks are coalesced by -identity key, flushed at 500ms, paced at 167ms/90-per-minute), but **only a -human can tell you whether it feels better or worse in the desktop app.** -This is the single most likely source of "something feels off". +The old loop emitted one chunk per round; goose streams token-by-token. Tests +prove the relay is not write-amplified (chunks are coalesced by identity key, +flushed at 500ms, paced at 167ms/90-per-minute), but **only a human can say +whether it feels better or worse in the desktop app.** This is the most likely +source of "something is off". -### D. Cancel mid-tool +### 4. Cancel mid-tool -Ask for something long (`count slowly to 100 with a shell sleep`), then hit -stop. Check: the turn ends promptly, and **no tool call is left spinning** in -the UI. Cancellation is a cooperative drain (5s budget) — dropping the stream -instead leaves the MCP child running and the spinner stuck forever. That bug -existed and is fixed; this is the visual confirmation. +Ask for something long (`count slowly to 100 with a shell sleep`), then stop. +The turn should end promptly with **no tool call left spinning**. Cancellation +is a cooperative drain with a 5s budget — dropping the stream instead would +leave the MCP child running and the spinner stuck forever. That bug existed and +is fixed; this is the visual confirmation. -### E. Steering +### 5. Steering Send a second message while the agent is working. It should be absorbed into -the running turn, *not* cancel-and-restart it. If the desktop visibly restarts -the turn, `activeRunId` is at the wrong nesting depth — it must be at -`params.update._meta.goose.activeRunId`, with `_meta` **inside** `update` -(`buzz-acp/src/acp.rs:1607-1613`). Wrong depth degrades silently to -cancel+re-prompt with no error anywhere. - -### F. Model picker +the running turn, not cancel-and-restart it. -Open the model picker in the desktop app. It should list models, not just the -current one. Absent catalog is degraded UX, never a session failure. +### 6. Model picker -### G. Tool hygiene — known deviation +Should list models, not just the current one. An absent catalog is degraded UX, +never a session failure. -The model can now *see* `_Stop` and `_PostCompact` (goose's allowlist gates -advertising and dispatch together, so hiding them would break the veto). A -system-prompt extension tells it not to call them. Watch for the agent calling -`_Stop` on its own — if that happens, the guidance text needs strengthening. +### 7. Tool hygiene — known deviation -## 3. A/B against the old agent +The model can now *see* `_Stop` and `_PostCompact`: goose's allowlist gates +advertising and dispatch through the same cache, so hiding them would also make +them undispatchable and break the veto. A system-prompt extension tells the +model to leave them alone. Watch for it calling `_Stop` itself — if that +happens the guidance needs strengthening. -The old binary still exists, so run both against the same relay and compare: - -```bash -just goose # old, terminal 2 -just agent-core # new, terminal 3 -``` +## Comparing against the old behaviour -Two agents, two identities, same channel. Ask both the same thing. +The old loop is gone, so A/B means checking out `main` in a second worktree and +running that relay side by side. -## 4. Known not-done +## Known gaps -* Never run against a **real** provider — all automated coverage uses a fake - SSE server. Databricks OAuth in particular is entirely goose's code path now - and completely unexercised. -* Never run inside the **desktop app** — only via buzz-acp on the CLI. -* `buzz-agent` (old) is not deleted, nothing is wired into packaging or the - harness catalog. -* Binary is +22.7 MiB raw / +6.0 MiB gzip vs the old one. +* **Never run against a real provider.** All automated coverage uses a fake SSE + server. Databricks *chat* now goes through goose entirely and is unexercised + — though Databricks *model discovery* still uses our own code, moved to + `buzz-model-catalog` (the desktop cannot link goose: native `sqlite3` + collision with its own rusqlite). +* Binary is roughly +22.7 MiB raw / +6.0 MiB gzip. +* Nothing changed in packaging or the harness catalog — same sidecar name, so + nothing needed to. From 81eec0f84615f0827c5426c16a5f0234b3abee37 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 18:11:56 +1000 Subject: [PATCH 14/20] fix(buzz-agent): restore `auth` subcommand and close config compat gaps Audited every BUZZ_AGENT_* variable the desktop and buzz-acp inject against what the swapped config.rs actually reads. Three gaps, two of them breaking. 1. `buzz-agent auth ` was dropped in the rewrite. Goose owns provider auth for the agent loop, but nothing in goose does an *interactive* Databricks PKCE login -- and buzz-model-catalog/src/auth.rs:417 still tells users to run this exact command when the token cache is empty. Restored, now backed by buzz-model-catalog. 2. The desktop persists the provider as "databricks-v2" (agent_models.rs:757) but goose registers "databricks_v2" (goose-providers/src/databricks_v2.rs). An existing Databricks v2 agent would fail to start with "unknown provider". Added the alias; extracted the mapping into goose_provider_name() with tests including a pass-through case, since goose owns the registry and we must not gatekeep names we don't list. 3. BUZZ_AGENT_PREFER_MESH_FOR_AUTO is still injected (relay_mesh.rs:42) but is no longer honoured: it used to re-resolve the relay-mesh `auto` model against the /models catalog mid-run so a long-lived agent could join or leave MoA without restarting (old llm.rs:410-440). Goose resolves the model once at session start and has no equivalent hook. The agent still works, it just pins whatever `auto` resolved to at startup. Now warns loudly rather than ignoring it silently. Verified: `buzz-agent auth` with no args and with a bogus provider both give the same errors as before. 44 tests green, fmt + clippy clean workspace-wide. Signed-off-by: Michael Neale --- Cargo.lock | 1 + crates/buzz-agent/Cargo.toml | 4 ++ crates/buzz-agent/src/config.rs | 69 ++++++++++++++++++++++++++++++--- crates/buzz-agent/src/lib.rs | 45 +++++++++++++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b1894467b..03fa86e2b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1139,6 +1139,7 @@ dependencies = [ "async-trait", "axum", "base64", + "buzz-model-catalog", "futures", "getrandom 0.4.3", "goose", diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index b249300360..f5a50ad065 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -25,6 +25,10 @@ name = "fake-mcp" path = "tests/bin/fake_mcp.rs" [dependencies] +# For the `buzz-agent auth ` subcommand only. Provider auth for the +# agent loop itself is goose's; this is the interactive PKCE login that seeds +# the token cache that goose (and the desktop model picker) then read. +buzz-model-catalog = { path = "../buzz-model-catalog" } goose = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab", default-features = false, features = ["rustls-tls"] } goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab" } # Resolution guard: goose pins icu_locale "=2.1.1" (needs icu_collections diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index add5684bba..f0cce6262d 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -103,6 +103,23 @@ fn parse_approval(raw: Option<&str>) -> GooseMode { } } +/// Translate a Buzz provider id into the name goose's registry knows. +/// +/// Unknown names pass through untouched — goose owns the registry, so +/// gatekeeping here would break every provider it gains that we don't list. +fn goose_provider_name(provider: &str) -> &str { + match provider { + // Buzz's OpenAI-wire-compatible providers; goose calls them `openai`. + "openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => "openai", + // The desktop persists this hyphenated (`agent_models.rs:757`) but + // goose registers `databricks_v2` + // (`goose-providers/src/databricks_v2.rs`). Without this alias an + // existing Databricks v2 agent fails to start. + "databricks-v2" => "databricks_v2", + other => other, + } +} + fn env_str(key: &str) -> Option { std::env::var(key).ok().filter(|s| !s.trim().is_empty()) } @@ -162,11 +179,7 @@ impl Config { // Provider: Buzz's `openai-compat` and `relay-mesh` are both // OpenAI-wire-compatible, and Goose knows them as plain `openai`. if let Some(provider) = env_str("BUZZ_AGENT_PROVIDER") { - let goose_provider = match provider.as_str() { - "openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => "openai", - other => other, - }; - set_if_absent("GOOSE_PROVIDER", goose_provider); + set_if_absent("GOOSE_PROVIDER", goose_provider_name(&provider)); } if let Some(model) = env_str("BUZZ_AGENT_MODEL") { @@ -190,6 +203,25 @@ impl Config { if let Some(ctx) = env_str("BUZZ_AGENT_MAX_CONTEXT_TOKENS") { set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx); } + + // `BUZZ_AGENT_PREFER_MESH_FOR_AUTO` is still injected by the desktop + // (`managed_agents/relay_mesh.rs:42`) but is NOT honoured any more. + // + // It used to make the old loop re-resolve the relay-mesh `auto` model + // against the `/models` catalog mid-run, so a long-lived agent could + // join or leave MoA without restarting (`llm.rs:410-440`). Goose + // resolves the model once at session start and has no equivalent hook. + // + // The agent still works — it just pins whatever `auto` resolved to at + // startup. Warn rather than fail: silently ignoring a config the user + // set is how "why is MoA not kicking in" becomes a day of debugging. + if env_str("BUZZ_AGENT_PREFER_MESH_FOR_AUTO").is_some_and(|v| v != "0") { + tracing::warn!( + "BUZZ_AGENT_PREFER_MESH_FOR_AUTO is set but no longer supported: \ + the relay-mesh `auto` model is resolved once at session start \ + and will not follow catalog changes mid-run" + ); + } } } @@ -222,6 +254,33 @@ mod tests { std::env::remove_var("BUZZ_TEST_MISSING"); } + #[test] + fn databricks_v2_hyphen_is_aliased_for_goose() { + // The desktop persists "databricks-v2" (agent_models.rs:757) but goose + // registers "databricks_v2". Without the alias an existing v2 agent + // fails to start. + assert_eq!(goose_provider_name("databricks-v2"), "databricks_v2"); + assert_eq!(goose_provider_name("databricks_v2"), "databricks_v2"); + assert_eq!(goose_provider_name("databricks"), "databricks"); + } + + #[test] + fn openai_wire_compatible_providers_map_to_openai() { + for alias in ["openai-compat", "openai_compat", "relay-mesh", "relay_mesh"] { + assert_eq!(goose_provider_name(alias), "openai", "alias {alias}"); + } + } + + #[test] + fn unknown_providers_pass_through_untouched() { + // Goose owns the registry; we must not gatekeep names we don't know. + assert_eq!(goose_provider_name("anthropic"), "anthropic"); + assert_eq!( + goose_provider_name("some_future_provider"), + "some_future_provider" + ); + } + #[test] fn approval_defaults_to_auto() { // Matches what buzz ships today; changing this silently would alter diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 0e7a7e26a4..1be7aa126d 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -837,6 +837,20 @@ fn uuid_like() -> String { } pub fn run() -> Result<(), Box> { + // `buzz-agent auth ` — interactive login, then exit. + // + // Preserved from the pre-goose crate. Goose owns provider auth for the + // agent loop, but nothing in goose performs an *interactive* Databricks + // PKCE login, and `buzz-model-catalog/src/auth.rs:417` still tells users + // to run this exact command when the token cache is empty. + let args: Vec = std::env::args().collect(); + if matches!(args.get(1).map(String::as_str), Some("auth")) { + return tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(auth_subcommand(&args[2..])); + } + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -852,3 +866,34 @@ pub fn run() -> Result<(), Box> { .block_on(serve(cfg))?; Ok(()) } + +/// `buzz-agent auth ` — run a provider's interactive auth flow and +/// persist the result. Needs a browser. Reads `DATABRICKS_HOST` from env. +/// +/// The cached token is what lets both the agent loop and the desktop model +/// picker work without a static `DATABRICKS_TOKEN`. +async fn auth_subcommand(args: &[String]) -> Result<(), Box> { + use buzz_model_catalog::auth::{PkceOAuthConfig, PkceOAuthTokenSource}; + + match args.first().map(String::as_str) { + Some("databricks" | "databricks_v2" | "databricks-v2") => { + let host = std::env::var("DATABRICKS_HOST") + .map_err(|_| "auth databricks: DATABRICKS_HOST required")?; + let pkce = PkceOAuthConfig { + discovery_url: format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ), + client_id: "databricks-cli".into(), + scopes: vec!["all-apis".into(), "offline_access".into()], + cache_namespace: "databricks".into(), + cache_dir_override: None, + }; + PkceOAuthTokenSource::new(pkce)?.interactive_login().await?; + eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); + Ok(()) + } + Some(other) => Err(format!("auth: unknown provider {other:?}").into()), + None => Err("auth: provider required (try: buzz-agent auth databricks)".into()), + } +} From 43d36985a366bf29b97e3ee43c0f8facc8d64aef Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 20:23:35 +1000 Subject: [PATCH 15/20] feat(buzz-agent): restore relay-mesh adaptive MoA routing Buzz owns the meaning of relay-mesh `auto`, and the swap had silently dropped it. The desktop sets BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1 on every relay-mesh agent (relay_mesh.rs:41-44); the old loop honoured it per request (old llm.rs:406-470) by polling the router's /models catalog and sending mesh-llm's virtual Mixture-of-Agents model instead of `auto` whenever the mesh could support it. I previously described this as "pins whatever auto resolved to at startup". That was wrong: `auto` is a router-side id, so nothing resolves it -- the agent just sent `auto` forever and MoA never engaged at all. For mesh-llm lab work that is the entire feature missing, not a degraded version of it. mesh::MeshAutoProvider wraps goose's provider and rewrites ModelConfig.model_name per call. Provider requires only get_name + stream, so wrapping is cheap -- and this is precisely the kind of interception that is only possible with goose as a library; an out-of-process ACP agent has no seam for it. Hysteresis is identical to the old implementation, deliberately: 5s catalog TTL, two consecutive positive observations to enable, immediate disable plus a 30s cooldown on a negative one, and an unreachable/malformed catalog preserves the last confirmed route rather than treating a failed probe as evidence the mesh vanished. A mid-request contraction (503 "MoA requires >=2 models" or error.type=moa_failure) cools down and retries once on `auto`, so the turn still completes. Other 5xx must NOT be treated as contractions -- that would mask real outages behind a silent retry -- and there is a test pinning that. 4 end-to-end tests against a fake mesh-llm router assert what actually goes on the wire: two-turn confirmation before MoA engages, single-model mesh never routes to MoA, contraction produces a mesh->auto retry pair without failing the turn, and the policy is inert (no extra /models polls) when the flag is absent. That last test initially failed on an absolute catalog-hit count -- my assertion was wrong, not the code: session/new polls /models for the desktop model picker and goose does its own lazy capability lookup. Rewritten as a differential across the TTL boundary, which isolates the policy's own poll. 48 tests green, fmt + clippy clean workspace-wide. Signed-off-by: Michael Neale --- crates/buzz-agent/HANDTEST.md | 5 + crates/buzz-agent/src/config.rs | 21 +- crates/buzz-agent/src/lib.rs | 56 +++- crates/buzz-agent/src/mesh.rs | 478 +++++++++++++++++++++++++++ crates/buzz-agent/tests/mesh_auto.rs | 366 ++++++++++++++++++++ 5 files changed, 902 insertions(+), 24 deletions(-) create mode 100644 crates/buzz-agent/src/mesh.rs create mode 100644 crates/buzz-agent/tests/mesh_auto.rs diff --git a/crates/buzz-agent/HANDTEST.md b/crates/buzz-agent/HANDTEST.md index 8661798b20..20b4c5d44f 100644 --- a/crates/buzz-agent/HANDTEST.md +++ b/crates/buzz-agent/HANDTEST.md @@ -86,6 +86,11 @@ running that relay side by side. — though Databricks *model discovery* still uses our own code, moved to `buzz-model-catalog` (the desktop cannot link goose: native `sqlite3` collision with its own rusqlite). +* **Relay-mesh MoA is restored but only tested against a fake router.** With + `mesh-llm` running, a relay-mesh agent on `auto` should switch to the virtual + `mesh` model within ~2 turns once ≥2 models are live, and fall back to `auto` + within 30s of the mesh shrinking. Grep the agent log for + `relay-mesh auto:`. * Binary is roughly +22.7 MiB raw / +6.0 MiB gzip. * Nothing changed in packaging or the harness catalog — same sidecar name, so nothing needed to. diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f0cce6262d..683698c550 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -204,24 +204,9 @@ impl Config { set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx); } - // `BUZZ_AGENT_PREFER_MESH_FOR_AUTO` is still injected by the desktop - // (`managed_agents/relay_mesh.rs:42`) but is NOT honoured any more. - // - // It used to make the old loop re-resolve the relay-mesh `auto` model - // against the `/models` catalog mid-run, so a long-lived agent could - // join or leave MoA without restarting (`llm.rs:410-440`). Goose - // resolves the model once at session start and has no equivalent hook. - // - // The agent still works — it just pins whatever `auto` resolved to at - // startup. Warn rather than fail: silently ignoring a config the user - // set is how "why is MoA not kicking in" becomes a day of debugging. - if env_str("BUZZ_AGENT_PREFER_MESH_FOR_AUTO").is_some_and(|v| v != "0") { - tracing::warn!( - "BUZZ_AGENT_PREFER_MESH_FOR_AUTO is set but no longer supported: \ - the relay-mesh `auto` model is resolved once at session start \ - and will not follow catalog changes mid-run" - ); - } + // `BUZZ_AGENT_PREFER_MESH_FOR_AUTO` is consumed in `build_provider` + // (lib.rs), not here: it selects a provider *wrapper* rather than an + // env var goose reads. See `mesh.rs`. } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 1be7aa126d..d46d5c4025 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -22,6 +22,7 @@ pub mod agent; pub mod config; pub mod hooks; +pub mod mesh; pub mod types; pub mod wire; @@ -129,9 +130,7 @@ async fn build_agent( .or_else(|| std::env::var("GOOSE_MODEL").ok()) .ok_or_else(|| AgentError::Llm("no model configured".into()))?; - let provider = goose::providers::create(&provider_name, Vec::new()) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; + let provider = build_provider(&provider_name).await?; let model_config = goose::model_config::model_config_from_user_config(&provider_name, &model_name) .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; @@ -793,15 +792,60 @@ async fn cancel_session(app: &Arc, params: Value) { /// registry) and installs it with the new `ModelConfig`. `SharedProvider` is an /// `Arc>>` precisely so this is hot-swappable /// (goose `agents/types.rs:11-12`). +/// Construct the goose provider, wrapping it in Buzz's relay-mesh `auto` +/// policy when the desktop asked for it. +/// +/// `BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1` is set on every relay-mesh agent +/// (`desktop/src-tauri/src/managed_agents/relay_mesh.rs:41-44`). It means "when +/// the configured model is `auto`, dynamically use mesh-llm's virtual +/// Mixture-of-Agents model whenever the live catalog can support it". Goose +/// resolves a model once per session and has no hook for that, so +/// [`mesh::MeshAutoProvider`] re-resolves per request instead — see that module +/// for why this is only possible with goose-as-a-library. +async fn build_provider( + provider_name: &str, +) -> Result, AgentError> { + let provider = goose::providers::create(provider_name, Vec::new()) + .await + .map_err(|e| map_provider_error(&e.to_string()))?; + + let prefer_mesh = std::env::var("BUZZ_AGENT_PREFER_MESH_FOR_AUTO") + .is_ok_and(|v| !v.trim().is_empty() && v != "0"); + if !prefer_mesh { + return Ok(provider); + } + + // The policy needs to poll the router's own `/models`, so it needs the + // base URL and key. Without them there is nothing to probe, and silently + // pinning `auto` would look like MoA is broken. + let Some(base_url) = env_first(&["OPENAI_BASE_URL", "OPENAI_COMPAT_BASE_URL"]) else { + tracing::warn!( + "BUZZ_AGENT_PREFER_MESH_FOR_AUTO is set but no OpenAI base URL is \ + configured; relay-mesh auto policy disabled" + ); + return Ok(provider); + }; + let api_key = env_first(&["OPENAI_API_KEY", "OPENAI_COMPAT_API_KEY"]).unwrap_or_default(); + + tracing::info!(%base_url, "relay-mesh auto policy enabled"); + Ok(Arc::new(mesh::MeshAutoProvider::new( + provider, base_url, api_key, + ))) +} + +fn env_first(keys: &[&str]) -> Option { + keys.iter() + .filter_map(|k| std::env::var(k).ok()) + .find(|v| !v.trim().is_empty()) +} + async fn apply_model( agent: &Arc, session_id: &str, model_id: &str, ) -> Result<(), AgentError> { let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string()); - let provider = goose::providers::create(&provider_name, Vec::new()) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; + let provider = build_provider(&provider_name).await?; let model_config = goose::model_config::model_config_from_user_config(&provider_name, model_id) .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; agent diff --git a/crates/buzz-agent/src/mesh.rs b/crates/buzz-agent/src/mesh.rs new file mode 100644 index 0000000000..56d276bdeb --- /dev/null +++ b/crates/buzz-agent/src/mesh.rs @@ -0,0 +1,478 @@ +//! Relay-mesh `auto` policy: adaptive Mixture-of-Agents routing. +//! +//! # What this restores +//! +//! Buzz owns the meaning of relay-mesh's `auto` model. The desktop sets +//! `BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1` on every relay-mesh agent +//! (`desktop/src-tauri/src/managed_agents/relay_mesh.rs:41-44`), and the +//! pre-goose agent loop honoured it per request (old `llm.rs:406-470`): +//! +//! * Poll the router's `/models` catalog, cached for 5s. +//! * If the virtual `mesh` model is advertised **and** ≥2 distinct physical +//! models are present, send `mesh` (Mixture-of-Agents) instead of `auto`. +//! * If the mesh contracts mid-request, cool down for 30s and retry once with +//! plain `auto`. +//! +//! The point is that a long-running agent adopts or leaves MoA as lab nodes +//! come and go, without a restart. Goose resolves a model once at session +//! start, so without this the agent would send `auto` forever and MoA would +//! never engage at all. +//! +//! # How +//! +//! [`MeshAutoProvider`] wraps goose's real provider and rewrites +//! `ModelConfig.model_name` per call. `Provider` only requires `get_name` and +//! `stream`, so wrapping is cheap — and this is the kind of thing that is only +//! possible because we drive goose as a library: an out-of-process ACP agent +//! has no seam to intercept model resolution. +//! +//! Hysteresis is deliberately identical to the old implementation: two +//! consecutive positive observations to enable, immediate disable on a negative +//! one, and an unknown (unreachable/malformed) catalog preserves the last +//! confirmed state rather than treating a failed probe as evidence the mesh +//! vanished. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use goose::providers::base::Provider; +use goose_provider_types::base::{MessageStream, ModelInfo}; +use goose_provider_types::conversation::message::Message; +use goose_provider_types::conversation::token_usage::ProviderUsage; +use goose_provider_types::errors::ProviderError; +use goose_provider_types::model::ModelConfig; +use goose_provider_types::retry::RetryConfig; +use rmcp::model::Tool; +use serde_json::Value; +use tokio::sync::Mutex; + +/// mesh-llm's virtual Mixture-of-Agents model. +const VIRTUAL_MODEL: &str = "mesh"; +/// The router's ordinary single-model routing. +const AUTO_MODEL: &str = "auto"; +/// How long one catalog observation is trusted. +const CATALOG_TTL: Duration = Duration::from_secs(5); +/// Budget for the catalog probe itself — it sits in the inference path. +const CATALOG_TIMEOUT: Duration = Duration::from_secs(2); +/// Backoff after MoA is withdrawn or fails, so a flapping mesh cannot thrash. +const COOLDOWN: Duration = Duration::from_secs(30); +/// Consecutive positive observations required before enabling MoA. +const ENABLE_OBSERVATIONS: u8 = 2; + +/// The 503 body mesh-llm returns when MoA is configured but under-provisioned. +const MOA_UNAVAILABLE_MESSAGE: &str = "MoA requires ≥2 models available in the mesh"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Observation { + Available, + Unavailable, + /// Probe failed or was unparseable. Explicitly *not* evidence of absence. + Unknown, +} + +#[derive(Debug, Default)] +struct State { + last_checked: Option, + consecutive_available: u8, + collective_enabled: bool, + cooldown_until: Option, +} + +/// Does this catalog justify Mixture-of-Agents? +/// +/// Requires the virtual `mesh` model to be advertised *and* at least two +/// distinct physical models behind it. `@main` suffixes are stripped so the +/// same model pinned to a branch does not count twice — MoA over one model is +/// pointless. +/// +/// `None` means the response had no `data` array, i.e. not a catalog we +/// understand; the caller maps that to [`Observation::Unknown`]. +fn catalog_supports_collective(catalog: &Value) -> Option { + let models = catalog.get("data").and_then(Value::as_array)?; + let mut has_virtual_mesh = false; + let mut physical = BTreeSet::new(); + for id in models + .iter() + .filter_map(|model| model.get("id").and_then(Value::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + if id == VIRTUAL_MODEL { + has_virtual_mesh = true; + } else if id != AUTO_MODEL { + physical.insert(id.replace("@main", "")); + } + } + Some(has_virtual_mesh && physical.len() >= 2) +} + +/// Did this error mean "the mesh shrank", as opposed to a generic failure? +/// +/// Two shapes, both from the old implementation (`llm.rs:1362-1388`): a 503 +/// whose `error.message` is exactly [`MOA_UNAVAILABLE_MESSAGE`], or any server +/// error whose `error.type` is `moa_failure`. Anything else is a real error and +/// must surface — silently retrying every 5xx as `auto` would mask outages. +fn is_mesh_contraction(err: &ProviderError) -> bool { + let text = err.to_string(); + if text.contains(MOA_UNAVAILABLE_MESSAGE) { + return true; + } + // Provider errors carry the body inline; find the JSON and inspect it. + let Some(start) = text.find('{') else { + return false; + }; + serde_json::from_str::(&text[start..]) + .ok() + .is_some_and(|v| { + let message = v.pointer("/error/message").and_then(Value::as_str); + let kind = v.pointer("/error/type").and_then(Value::as_str); + message == Some(MOA_UNAVAILABLE_MESSAGE) || kind == Some("moa_failure") + }) +} + +/// Wraps a provider and applies Buzz's relay-mesh `auto` policy. +pub struct MeshAutoProvider { + inner: Arc, + /// Router base URL, for the `/models` probe. + base_url: String, + api_key: String, + http: reqwest::Client, + state: Mutex, +} + +impl std::fmt::Debug for MeshAutoProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MeshAutoProvider") + .field("base_url", &self.base_url) + .finish_non_exhaustive() + } +} + +impl MeshAutoProvider { + /// Wrap `inner`, probing `base_url/models` to decide when to use MoA. + pub fn new(inner: Arc, base_url: String, api_key: String) -> Self { + Self { + inner, + base_url: base_url.trim_end_matches('/').to_string(), + api_key, + http: reqwest::Client::new(), + state: Mutex::new(State::default()), + } + } + + /// Should this call be routed to MoA? + /// + /// Only rewrites the configured `auto`; an explicitly chosen model is never + /// second-guessed. + async fn resolve_model(&self, configured: &str) -> String { + if configured != AUTO_MODEL { + return configured.to_string(); + } + + let mut state = self.state.lock().await; + let now = Instant::now(); + + if let Some(until) = state.cooldown_until { + if now < until { + return AUTO_MODEL.to_string(); + } + state.cooldown_until = None; + } + + if state + .last_checked + .is_some_and(|at| at.elapsed() < CATALOG_TTL) + { + return self.model_for(state.collective_enabled); + } + + let observation = self.observe().await; + let checked_at = Instant::now(); + state.last_checked = Some(checked_at); + match observation { + Observation::Available => { + state.consecutive_available = state.consecutive_available.saturating_add(1); + if state.consecutive_available >= ENABLE_OBSERVATIONS { + state.collective_enabled = true; + } + } + Observation::Unavailable => { + if state.collective_enabled { + state.cooldown_until = Some(checked_at + COOLDOWN); + } + state.consecutive_available = 0; + state.collective_enabled = false; + } + // A failed probe is not evidence a stable mesh disappeared. + // Inference stays authoritative via the contraction fallback. + Observation::Unknown => {} + } + + let model = self.model_for(state.collective_enabled); + tracing::debug!( + configured_model = configured, + request_model = %model, + "relay-mesh auto: resolved request model from live catalog" + ); + model + } + + fn model_for(&self, collective: bool) -> String { + if collective { + VIRTUAL_MODEL + } else { + AUTO_MODEL + } + .to_string() + } + + /// Note that inference just told us the mesh is too small, and back off. + async fn cool_down(&self) { + let now = Instant::now(); + let mut state = self.state.lock().await; + state.last_checked = Some(now); + state.consecutive_available = 0; + state.collective_enabled = false; + state.cooldown_until = Some(now + COOLDOWN); + } + + async fn observe(&self) -> Observation { + let url = format!("{}/models", self.base_url); + let response = match self + .http + .get(&url) + .bearer_auth(&self.api_key) + .timeout(CATALOG_TIMEOUT) + .send() + .await + { + Ok(response) => response, + Err(error) => { + tracing::debug!( + %error, + "relay-mesh auto: catalog probe failed; preserving last confirmed route" + ); + return Observation::Unknown; + } + }; + if !response.status().is_success() { + tracing::debug!( + status = %response.status(), + "relay-mesh auto: catalog probe unsuccessful; preserving last confirmed route" + ); + return Observation::Unknown; + } + match response.json::().await { + Ok(catalog) => match catalog_supports_collective(&catalog) { + Some(true) => Observation::Available, + Some(false) => Observation::Unavailable, + None => { + tracing::debug!( + "relay-mesh auto: catalog has no data array; preserving last confirmed route" + ); + Observation::Unknown + } + }, + Err(error) => { + tracing::debug!( + %error, + "relay-mesh auto: invalid catalog response; preserving last confirmed route" + ); + Observation::Unknown + } + } + } + + fn config_for(model_config: &ModelConfig, model: &str) -> ModelConfig { + let mut resolved = model_config.clone(); + resolved.model_name = model.to_string(); + resolved + } +} + +#[async_trait] +impl Provider for MeshAutoProvider { + fn get_name(&self) -> &str { + self.inner.get_name() + } + + async fn stream( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let model = self.resolve_model(&model_config.model_name).await; + let attempting_moa = model == VIRTUAL_MODEL; + + let resolved = Self::config_for(model_config, &model); + match self.inner.stream(&resolved, system, messages, tools).await { + Err(error) if attempting_moa && is_mesh_contraction(&error) => { + // The mesh shrank between discovery and inference. Back off and + // retry once with ordinary routing so the turn still completes. + self.cool_down().await; + tracing::warn!( + attempted_model = VIRTUAL_MODEL, + fallback_model = AUTO_MODEL, + provider_message = %error, + "relay-mesh auto: collective request failed; retrying once with auto" + ); + let fallback = Self::config_for(model_config, AUTO_MODEL); + self.inner.stream(&fallback, system, messages, tools).await + } + other => other, + } + } + + async fn complete( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let model = self.resolve_model(&model_config.model_name).await; + let attempting_moa = model == VIRTUAL_MODEL; + + let resolved = Self::config_for(model_config, &model); + match self + .inner + .complete(&resolved, system, messages, tools) + .await + { + Err(error) if attempting_moa && is_mesh_contraction(&error) => { + self.cool_down().await; + tracing::warn!( + attempted_model = VIRTUAL_MODEL, + fallback_model = AUTO_MODEL, + provider_message = %error, + "relay-mesh auto: collective request failed; retrying once with auto" + ); + let fallback = Self::config_for(model_config, AUTO_MODEL); + self.inner + .complete(&fallback, system, messages, tools) + .await + } + other => other, + } + } + + // Everything below must delegate: the wrapper exists only to pick a model. + + async fn get_context_limit(&self, model_config: &ModelConfig) -> Result { + self.inner.get_context_limit(model_config).await + } + + fn retry_config(&self) -> RetryConfig { + self.inner.retry_config() + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + self.inner.fetch_supported_models().await + } + + async fn fetch_supported_model_info(&self) -> Result, ProviderError> { + self.inner.fetch_supported_model_info().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn catalog(ids: &[&str]) -> Value { + json!({ "data": ids.iter().map(|id| json!({ "id": id })).collect::>() }) + } + + #[test] + fn collective_needs_virtual_mesh_and_two_physical_models() { + assert_eq!( + catalog_supports_collective(&catalog(&["mesh", "qwen3", "llama3"])), + Some(true) + ); + } + + #[test] + fn one_physical_model_is_not_a_collective() { + // MoA over a single model is pointless, and mesh-llm 503s on it. + assert_eq!( + catalog_supports_collective(&catalog(&["mesh", "qwen3"])), + Some(false) + ); + } + + #[test] + fn virtual_mesh_must_be_advertised() { + assert_eq!( + catalog_supports_collective(&catalog(&["qwen3", "llama3"])), + Some(false) + ); + } + + #[test] + fn branch_pins_do_not_count_as_distinct_models() { + // `qwen3` and `qwen3@main` are the same model. + assert_eq!( + catalog_supports_collective(&catalog(&["mesh", "qwen3", "qwen3@main"])), + Some(false) + ); + } + + #[test] + fn auto_is_not_counted_as_a_physical_model() { + assert_eq!( + catalog_supports_collective(&catalog(&["mesh", "auto", "qwen3"])), + Some(false) + ); + } + + #[test] + fn missing_data_array_is_unknown_not_false() { + // Must be distinguishable: unknown preserves the last confirmed route, + // false actively disables MoA. + assert_eq!(catalog_supports_collective(&json!({})), None); + assert_eq!( + catalog_supports_collective(&json!({"data": []})), + Some(false) + ); + } + + #[test] + fn blank_model_ids_are_ignored() { + assert_eq!( + catalog_supports_collective(&catalog(&["mesh", "qwen3", " ", ""])), + Some(false) + ); + } + + #[test] + fn recognises_the_moa_unavailable_503() { + let err = ProviderError::ServerError(format!( + "503: {{\"error\":{{\"message\":\"{MOA_UNAVAILABLE_MESSAGE}\"}}}}" + )); + assert!(is_mesh_contraction(&err)); + } + + #[test] + fn recognises_moa_failure_type() { + let err = + ProviderError::ServerError("500: {\"error\":{\"type\":\"moa_failure\"}}".to_string()); + assert!(is_mesh_contraction(&err)); + } + + #[test] + fn ordinary_server_errors_are_not_contractions() { + // Critical: treating every 5xx as a contraction would silently mask + // real outages behind an `auto` retry. + let err = + ProviderError::ServerError("500: {\"error\":{\"message\":\"internal\"}}".to_string()); + assert!(!is_mesh_contraction(&err)); + assert!(!is_mesh_contraction(&ProviderError::RequestFailed( + "timeout".to_string() + ))); + } +} diff --git a/crates/buzz-agent/tests/mesh_auto.rs b/crates/buzz-agent/tests/mesh_auto.rs new file mode 100644 index 0000000000..b13b287e78 --- /dev/null +++ b/crates/buzz-agent/tests/mesh_auto.rs @@ -0,0 +1,366 @@ +//! Proves the relay-mesh `auto` policy end to end, over the real stdio wire. +//! +//! The unit tests in `src/mesh.rs` cover the pure decisions (catalog parsing, +//! error classification). This covers the part that actually matters: does the +//! request the provider sends carry `"model":"mesh"` instead of `"auto"`? +//! +//! Hysteresis makes this a two-turn test, deliberately. `ENABLE_OBSERVATIONS` +//! is 2 and one observation is cached for `CATALOG_TTL` (5s), so: +//! +//! turn 1 → first observation → still `auto` +//! (wait out the TTL) +//! turn 2 → second observation → `mesh` +//! +//! That two-strike rule is the whole point — it stops a briefly-flapping peer +//! from bouncing a long-running agent in and out of MoA. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Fake mesh-llm router. +/// +/// `/models` reports the virtual `mesh` model plus `physical_models` real ones. +/// `/chat/completions` records the requested model and answers "ok" — unless +/// `moa_503` is set, in which case a `mesh` request gets mesh-llm's real +/// under-provisioned 503 so the contraction fallback can be exercised. +fn spawn_router( + physical_models: usize, + moa_503: bool, +) -> (String, mpsc::Receiver, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + let catalog_hits = Arc::new(AtomicUsize::new(0)); + let hits = catalog_hits.clone(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + let respond = |stream: &mut std::net::TcpStream, + status: &str, + ct: &str, + payload: String| { + let _ = stream.write_all( + format!( + "HTTP/1.1 {status}\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), + payload + ) + .as_bytes(), + ); + let _ = stream.flush(); + }; + + if request_line.contains("/models") { + hits.fetch_add(1, Ordering::SeqCst); + let mut ids: Vec = vec![json!({"id": "mesh"}), json!({"id": "auto"})]; + for i in 0..physical_models { + ids.push(json!({ "id": format!("model-{i}") })); + } + respond( + &mut stream, + "200 OK", + "application/json", + json!({ "object": "list", "data": ids }).to_string(), + ); + continue; + } + + // Record which model the agent actually asked for. + let requested = serde_json::from_slice::(&body) + .ok() + .and_then(|v| v["model"].as_str().map(str::to_owned)) + .unwrap_or_else(|| "".into()); + let _ = tx.send(requested.clone()); + + if moa_503 && requested == "mesh" { + // mesh-llm's real under-provisioned response. + respond( + &mut stream, + "503 Service Unavailable", + "application/json", + json!({"error": {"message": "MoA requires ≥2 models available in the mesh"}}) + .to_string(), + ); + continue; + } + + let usage = json!({"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}); + let text = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, "model": requested, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": null}], + }); + let done = json!({ + "id": "c", "object": "chat.completion.chunk", "created": 1, "model": "m", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": usage, + }); + respond( + &mut stream, + "200 OK", + "text/event-stream", + format!("data: {text}\n\ndata: {done}\n\ndata: [DONE]\n\n"), + ); + } + }); + + (format!("http://{addr}"), rx, catalog_hits) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path, prefer_mesh: bool) -> Self { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_buzz-agent")); + cmd.env("BUZZ_AGENT_PROVIDER", "relay-mesh") + .env("BUZZ_AGENT_MODEL", "auto") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + if prefer_mesh { + cmd.env("BUZZ_AGENT_PREFER_MESH_FOR_AUTO", "1"); + } + let mut child = cmd.spawn().expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 0, + } + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + self.next_id += 1; + let id = self.next_id; + writeln!( + self.stdin, + "{}", + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + id + } + + fn await_response(&mut self, id: i64) -> Value { + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting id={id}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return msg; + } + } + } + + fn new_session(&mut self, cwd: &std::path::Path) -> String { + let id = self.request("initialize", json!({"protocolVersion": 2})); + let _ = self.await_response(id); + let id = self.request( + "session/new", + json!({"cwd": cwd.to_str().unwrap(), "mcpServers": []}), + ); + let resp = self.await_response(id); + resp["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {resp}")) + .to_string() + } + + fn prompt(&mut self, session_id: &str, text: &str) -> Value { + let id = self.request( + "session/prompt", + json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}), + ); + self.await_response(id) + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Collect every model the router was asked for. +fn drain(rx: &mpsc::Receiver) -> Vec { + let mut seen = Vec::new(); + while let Ok(model) = rx.recv_timeout(Duration::from_millis(500)) { + seen.push(model); + } + seen +} + +#[test] +fn moa_engages_after_two_confirmations() { + // 2 physical models + virtual mesh = MoA is viable. + let (base_url, requests, catalog_hits) = spawn_router(2, false); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path(), true); + let session = h.new_session(cwd.path()); + + let resp = h.prompt(&session, "one"); + assert!(resp["result"]["stopReason"].is_string(), "turn 1: {resp}"); + let first = drain(&requests); + assert_eq!( + first, + vec!["auto"], + "first observation must NOT enable MoA — two strikes are required so a \ + flapping peer cannot bounce a long-running agent" + ); + + // Wait out CATALOG_TTL so the next turn re-observes. + std::thread::sleep(Duration::from_millis(5_200)); + + let resp = h.prompt(&session, "two"); + assert!(resp["result"]["stopReason"].is_string(), "turn 2: {resp}"); + let second = drain(&requests); + assert_eq!( + second, + vec!["mesh"], + "second confirmation must switch to the virtual MoA model" + ); + + assert!( + catalog_hits.load(Ordering::SeqCst) >= 2, + "expected the catalog to be re-polled after the TTL — that re-poll is \ + what lets a long-running agent adopt MoA without a restart" + ); +} + +#[test] +fn moa_stays_off_with_only_one_physical_model() { + // MoA over a single model is pointless, and mesh-llm 503s on it. + let (base_url, requests, _) = spawn_router(1, false); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path(), true); + let session = h.new_session(cwd.path()); + + h.prompt(&session, "one"); + std::thread::sleep(Duration::from_millis(5_200)); + h.prompt(&session, "two"); + + let seen = drain(&requests); + assert!( + seen.iter().all(|m| m == "auto"), + "a single-model mesh must never route to MoA, got {seen:?}" + ); +} + +#[test] +fn contraction_mid_request_falls_back_to_auto() { + // Catalog says MoA is fine, but inference 503s: the mesh shrank in between. + let (base_url, requests, _) = spawn_router(2, true); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path(), true); + let session = h.new_session(cwd.path()); + + h.prompt(&session, "one"); // observation 1 → auto + std::thread::sleep(Duration::from_millis(5_200)); + + // Observation 2 enables MoA, the mesh request 503s, and the turn must still + // complete via a single retry on `auto`. + let resp = h.prompt(&session, "two"); + assert!( + resp["result"]["stopReason"].is_string(), + "a contraction must not fail the turn: {resp}" + ); + + let seen = drain(&requests); + assert!( + seen.windows(2).any(|w| w[0] == "mesh" && w[1] == "auto"), + "expected a mesh attempt immediately followed by an auto retry, got {seen:?}" + ); +} + +#[test] +fn policy_is_inert_when_the_desktop_did_not_ask_for_it() { + // No BUZZ_AGENT_PREFER_MESH_FOR_AUTO: `auto` must be sent untouched, and + // the catalog must not be probed at all. + let (base_url, requests, catalog_hits) = spawn_router(2, false); + let home = tempfile::tempdir().expect("home"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path(), false); + let session = h.new_session(cwd.path()); + + // /models is polled for reasons that have nothing to do with this policy: + // `session/new` populates the desktop model picker via `discover_models`, + // and goose's own provider does a lazy capability lookup on its first + // request. So an absolute count proves nothing. + // + // The differential does. The policy re-observes once per turn whose cached + // observation has expired, so measuring across the TTL boundary isolates + // it: enabled adds a poll, disabled adds none. + h.prompt(&session, "one"); + let after_first = catalog_hits.load(Ordering::SeqCst); + + std::thread::sleep(Duration::from_millis(5_200)); + + h.prompt(&session, "two"); + let after_second = catalog_hits.load(Ordering::SeqCst); + + let seen = drain(&requests); + assert!( + seen.iter().all(|m| m == "auto"), + "without the flag the model must pass through untouched, got {seen:?}" + ); + assert_eq!( + after_second, after_first, + "a disabled policy must not poll /models across the TTL boundary" + ); +} From 55696fc098bd8ef1e5bca3421c9c873e6e21b4b4 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 28 Jul 2026 10:29:59 +1000 Subject: [PATCH 16/20] docs(buzz-agent): pin the one mesh behaviour that is NOT parity I claimed the restored relay-mesh policy was identical to the old loop. Checked it properly: constants, catalog parsing, hysteresis and the gate all match, but contraction detection does not, and the difference is forced. The old loop read the raw HTTP body and accepted two shapes: a 503 whose error.message is the MoA-unavailable string, or any 5xx whose error.type is "moa_failure". A provider-level wrapper only sees what goose leaves behind, and extract_message (goose-providers/src/http_status.rs:186-197) reduces the payload to error.message when that field exists. So "moa_failure" *alongside* a message is invisible to us and fails the turn instead of retrying on auto. Verified by probe, not assumed. The message shape -- which is what mesh-llm's under-provisioned path actually sends -- still works, as does moa_failure with no message. Documented on is_mesh_contraction and pinned with a test that fails loudly if goose ever stops stripping the body, so the caveat cannot silently rot. Signed-off-by: Michael Neale --- crates/buzz-agent/src/mesh.rs | 46 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/buzz-agent/src/mesh.rs b/crates/buzz-agent/src/mesh.rs index 56d276bdeb..dc045d5cc5 100644 --- a/crates/buzz-agent/src/mesh.rs +++ b/crates/buzz-agent/src/mesh.rs @@ -110,10 +110,29 @@ fn catalog_supports_collective(catalog: &Value) -> Option { /// Did this error mean "the mesh shrank", as opposed to a generic failure? /// -/// Two shapes, both from the old implementation (`llm.rs:1362-1388`): a 503 -/// whose `error.message` is exactly [`MOA_UNAVAILABLE_MESSAGE`], or any server -/// error whose `error.type` is `moa_failure`. Anything else is a real error and -/// must surface — silently retrying every 5xx as `auto` would mask outages. +/// The old loop inspected the raw HTTP body (`llm.rs:1362-1388`) and accepted +/// two shapes: a 503 whose `error.message` is exactly +/// [`MOA_UNAVAILABLE_MESSAGE`], or any 5xx whose `error.type` is +/// `moa_failure`. +/// +/// **We only see what goose leaves us.** `extract_message` +/// (`goose-providers/src/http_status.rs:186-197`) reduces the payload to +/// `error.message` when that field exists, and only falls back to the whole +/// JSON when it does not. So: +/// +/// * message shape — always detected (the message survives verbatim); +/// * `moa_failure` with no `message` — detected (whole JSON survives); +/// * `moa_failure` *alongside* a `message` — **not** detected, because the +/// type is discarded before it reaches us. Such a request fails the turn +/// instead of retrying on `auto`. +/// +/// That last case is the one behaviour the provider-level wrapper cannot +/// reproduce; recovering it needs an HTTP-level seam goose does not expose. +/// mesh-llm's under-provisioned path sends the message shape, which is why the +/// common case is covered. +/// +/// Anything else is a real error and must surface — silently retrying every +/// 5xx as `auto` would mask outages. fn is_mesh_contraction(err: &ProviderError) -> bool { let text = err.to_string(); if text.contains(MOA_UNAVAILABLE_MESSAGE) { @@ -458,12 +477,29 @@ mod tests { } #[test] - fn recognises_moa_failure_type() { + fn recognises_moa_failure_type_when_no_message_field() { + // Reachable only because goose falls back to the whole payload when + // `error.message` is absent (http_status.rs:196). let err = ProviderError::ServerError("500: {\"error\":{\"type\":\"moa_failure\"}}".to_string()); assert!(is_mesh_contraction(&err)); } + #[test] + fn moa_failure_alongside_a_message_is_a_known_blind_spot() { + // goose keeps only `error.message`, so `type` never reaches us. This + // pins the gap rather than pretending parity: if goose ever preserves + // the body, this test flips and we can delete the caveat in the docs. + let err = ProviderError::ServerError( + "Server error (500) at http://x: upstream exploded".to_string(), + ); + assert!( + !is_mesh_contraction(&err), + "if this now passes, goose stopped stripping the body -- update the \ + doc comment on is_mesh_contraction" + ); + } + #[test] fn ordinary_server_errors_are_not_contractions() { // Critical: treating every 5xx as a contraction would silently mask From 41974bc57b372f280b651caf1dbda9bb18092a64 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 28 Jul 2026 10:34:43 +1000 Subject: [PATCH 17/20] fix(buzz-agent): catch mesh-llm's 502 MoA failure path, not just the 503 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yesterday I documented a "known blind spot" and left it. Checking mesh-llm's actual source shows that was the wrong call: the gap covers the COMMON failure, not a rare one. mesh-llm has two failure paths: 503, gateway level — mesh too small to start MoA at all. Plain message, no JSON. (moa_gateway/mod.rs:56) 502, MoA level — workers or reducers died mid-turn. Body carries error.message AND error.type=moa_failure. (mesh-mixture-of-agents/src/lib.rs:1168) Only the 503 was handled. The 502 is the one that actually fires in a running lab — a worker dropping out mid-turn — and because goose reduces a payload to error.message when that field exists (http_status.rs:186-197), the moa_failure type never reached us. Those turns failed outright instead of retrying on auto. Fixed by matching the messages themselves: MOA_FAILURE_MESSAGES lists every error_response call site in mesh-llm. The JSON-shaped check stays as a fallback for moa_failure with no message field. Message matching is brittle if mesh-llm rewords these, but the alternative is an HTTP-level seam goose does not expose, and silently losing the fallback is worse than a string that needs updating. 49 tests green, fmt + clippy clean. Signed-off-by: Michael Neale --- crates/buzz-agent/src/mesh.rs | 90 +++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/crates/buzz-agent/src/mesh.rs b/crates/buzz-agent/src/mesh.rs index dc045d5cc5..2ff88268b1 100644 --- a/crates/buzz-agent/src/mesh.rs +++ b/crates/buzz-agent/src/mesh.rs @@ -61,9 +61,25 @@ const COOLDOWN: Duration = Duration::from_secs(30); /// Consecutive positive observations required before enabling MoA. const ENABLE_OBSERVATIONS: u8 = 2; -/// The 503 body mesh-llm returns when MoA is configured but under-provisioned. +/// The 503 body mesh-llm returns when MoA is configured but under-provisioned +/// (`mesh-llm-host-runtime/src/network/openai/moa_gateway/mod.rs:56`). const MOA_UNAVAILABLE_MESSAGE: &str = "MoA requires ≥2 models available in the mesh"; +/// Messages from mesh-llm's *other* failure path: `error_response` +/// (`mesh-mixture-of-agents/src/lib.rs:1168`) returns a 502 whose body carries +/// both `error.message` and `error.type = "moa_failure"`. +/// +/// Goose keeps only the message (`http_status.rs:186-197`), so the type never +/// reaches us and we have to match the messages themselves. These are every +/// call site of `error_response` in mesh-llm. +const MOA_FAILURE_MESSAGES: &[&str] = &[ + "All MoA workers failed", + "All MoA reducers failed", + "MoA could not produce a usable answer", + "MoA reducer returned no usable answer", + "Reducer failed", +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Observation { Available, @@ -110,45 +126,37 @@ fn catalog_supports_collective(catalog: &Value) -> Option { /// Did this error mean "the mesh shrank", as opposed to a generic failure? /// -/// The old loop inspected the raw HTTP body (`llm.rs:1362-1388`) and accepted -/// two shapes: a 503 whose `error.message` is exactly -/// [`MOA_UNAVAILABLE_MESSAGE`], or any 5xx whose `error.type` is -/// `moa_failure`. +/// mesh-llm has two failure paths and both must be caught: /// -/// **We only see what goose leaves us.** `extract_message` -/// (`goose-providers/src/http_status.rs:186-197`) reduces the payload to -/// `error.message` when that field exists, and only falls back to the whole -/// JSON when it does not. So: +/// * **503, gateway level** — under-provisioned mesh. Plain message, no JSON +/// (`moa_gateway/mod.rs:56`). +/// * **502, MoA level** — workers or reducers failed. Body carries +/// `error.message` *and* `error.type = "moa_failure"` +/// (`mesh-mixture-of-agents/src/lib.rs:1168`). /// -/// * message shape — always detected (the message survives verbatim); -/// * `moa_failure` with no `message` — detected (whole JSON survives); -/// * `moa_failure` *alongside* a `message` — **not** detected, because the -/// type is discarded before it reaches us. Such a request fails the turn -/// instead of retrying on `auto`. +/// Goose reduces a payload to `error.message` when that field exists +/// (`goose-providers/src/http_status.rs:186-197`), so `moa_failure` never +/// survives — we match [`MOA_FAILURE_MESSAGES`] instead. That second path is +/// the *common* one at runtime (a worker dying mid-turn); the 503 only fires +/// when the mesh is too small to start with. /// -/// That last case is the one behaviour the provider-level wrapper cannot -/// reproduce; recovering it needs an HTTP-level seam goose does not expose. -/// mesh-llm's under-provisioned path sends the message shape, which is why the -/// common case is covered. -/// -/// Anything else is a real error and must surface — silently retrying every -/// 5xx as `auto` would mask outages. +/// Anything else is a real error and must surface — retrying every 5xx as +/// `auto` would mask outages. fn is_mesh_contraction(err: &ProviderError) -> bool { let text = err.to_string(); - if text.contains(MOA_UNAVAILABLE_MESSAGE) { + if text.contains(MOA_UNAVAILABLE_MESSAGE) + || MOA_FAILURE_MESSAGES.iter().any(|m| text.contains(m)) + { return true; } - // Provider errors carry the body inline; find the JSON and inspect it. + // Fallback for the shape goose passes through whole: `moa_failure` with no + // `error.message` field at all. let Some(start) = text.find('{') else { return false; }; serde_json::from_str::(&text[start..]) .ok() - .is_some_and(|v| { - let message = v.pointer("/error/message").and_then(Value::as_str); - let kind = v.pointer("/error/type").and_then(Value::as_str); - message == Some(MOA_UNAVAILABLE_MESSAGE) || kind == Some("moa_failure") - }) + .is_some_and(|v| v.pointer("/error/type").and_then(Value::as_str) == Some("moa_failure")) } /// Wraps a provider and applies Buzz's relay-mesh `auto` policy. @@ -486,18 +494,20 @@ mod tests { } #[test] - fn moa_failure_alongside_a_message_is_a_known_blind_spot() { - // goose keeps only `error.message`, so `type` never reaches us. This - // pins the gap rather than pretending parity: if goose ever preserves - // the body, this test flips and we can delete the caveat in the docs. - let err = ProviderError::ServerError( - "Server error (500) at http://x: upstream exploded".to_string(), - ); - assert!( - !is_mesh_contraction(&err), - "if this now passes, goose stopped stripping the body -- update the \ - doc comment on is_mesh_contraction" - ); + fn recognises_moa_worker_and_reducer_failures() { + // mesh-llm's 502 path always sends message AND type; goose strips the + // type, so these must be caught by message. This is the common runtime + // failure — a worker dying mid-turn. + for message in [ + "All MoA workers failed", + "MoA could not produce a usable answer", + "MoA reducer returned no usable answer", + "Reducer failed (tried 3): timeout", + ] { + let err = + ProviderError::ServerError(format!("Server error (502) at http://x: {message}")); + assert!(is_mesh_contraction(&err), "missed: {message}"); + } } #[test] From fa8166e6055c2a6af2774c98d7ba442a982a08c2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 28 Jul 2026 13:44:57 +1000 Subject: [PATCH 18/20] =?UTF-8?q?fix(buzz-agent):=20address=20expert=20rev?= =?UTF-8?q?iew=20=E2=80=94=20shutdown,=20cap=20race,=20usage=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of the swap found several real defects, most of them comments asserting parity that did not survive checking. Fixed: B3 serve() dropped in-flight work on stdin EOF. Dropping a CancellationToken does not cancel it, so goose never sent notifications/cancelled to its MCP children and they outlived us as orphans -- exactly the failure run_turn goes to lengths to avoid on session/cancel. The detached writer task was also never awaited, so frames still queued (including a response from a turn that finished on the same tick) were discarded. main did both; restored. B4 max_sessions was TOCTOU-racy. session/new is dispatched on its own task and build_agent (MCP spawn + provider round-trip) sits between the check and the insert, so N concurrent calls all passed. main re-checked under the insert guard; that guard had been dropped. Restored. B5 usage_update reported an empty model whenever the model came from GOOSE_MODEL rather than BUZZ_AGENT_MODEL -- a supported path everywhere else (build_agent and session/new both fall back to it). Blanked kind-44200 attribution silently. Now uses the same resolution chain. B6 BUZZ_AGENT_LLM_TIMEOUT_SECS was parsed, documented, and never read. Deleted rather than left as a knob that does nothing. R2 A whitespace-only steer returned success instead of INVALID_PARAMS. buzz-acp maps success to SteerAck::Ok and treats the message as delivered, so it was swallowed and the cancel+merge fallback suppressed. Now rejected up front, before touching the session map, as main did. R6 Restored #![forbid(unsafe_code)], lost in the rewrite. B1/B2 are documentation corrections, and they matter more than the code fixes: the module table claimed builtin.rs was replaced by goose's skills extension and hints.rs by goose's hint loader. Neither holds. Agent::with_config loads zero extensions and build_agent only adds the harness's declared mcpServers, so the skills extension is never loaded and load_skill/SKILL.md discovery are simply gone. Goose's hint loader keys off .goosehints while the old code walked for AGENTS.md -- every repo here ships the latter and none the former, so no hints load at all. Both now documented as losses instead of substitutions. 53 tests green, fmt + clippy -D warnings clean workspace-wide. Signed-off-by: Michael Neale --- Cargo.lock | 2 + crates/buzz-agent/src/agent.rs | 17 +++++- crates/buzz-agent/src/config.rs | 4 -- crates/buzz-agent/src/lib.rs | 95 +++++++++++++++++++++++++-------- 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f61bac5b2..e947ccaf22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3621,9 +3621,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.4", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index f3ece95198..be0fdc0733 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -8,11 +8,24 @@ //! | `mcp.rs` | 1139 | `goose::agents::extension_manager` | //! | `auth.rs` | 845 | `goose::providers` (incl. Databricks OAuth) | //! | `agent.rs` | 746 | `goose::agents::Agent::reply` | -//! | `hints.rs` | 726 | `goose`'s hint loader (`prompt_manager::with_hints`) | +//! | `hints.rs` | 726 | *partly* -- see the caveat below | //! | `catalog.rs` | 631 | `goose::providers::init` model discovery | -//! | `builtin.rs` | 575 | `goose`'s `skills` platform extension | +//! | `builtin.rs` | 575 | **dropped** -- see the caveat below | //! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) | //! +//! Two entries above are NOT clean substitutions, and saying otherwise would +//! be worse than the loss itself: +//! +//! * **`builtin.rs` / `load_skill` is gone.** `Agent::with_config` loads zero +//! extensions and `build_agent` only adds the `mcpServers` the harness +//! declares, so goose's `skills` platform extension is never loaded. The +//! `load_skill` tool and `SKILL.md` discovery (old `hints.rs:110-204`) have +//! no replacement. Restoring them means explicitly adding the extension. +//! * **`AGENTS.md` hints are not loaded.** Goose's loader keys off +//! `GOOSE_HINTS_FILENAME` (`.goosehints`); the old code walked the directory +//! chain for `AGENTS.md` plus `~/AGENTS.md`. Every repo here ships the +//! former and none ship the latter, so in practice no hints load at all. +//! //! What this file keeps is the part Goose does *not* know about: the mapping //! from `AgentEvent` onto the exact `session/update` notifications `buzz-acp` //! consumes (`acp.rs:1528-1627`), and the `keepalive` ticker that resets the diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 683698c550..8cd0310962 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -37,8 +37,6 @@ pub struct Config { pub max_sessions: usize, /// Default system prompt, used only when `session/new` omits one. pub system_prompt: Option, - /// Per-turn wall-clock budget for a single provider request. - pub llm_timeout_secs: u64, /// Tool-call approval policy. /// /// `GooseMode::default()` is **`Auto`** (`goose_mode.rs:23-25`) — every @@ -145,7 +143,6 @@ impl Config { max_rounds: env_parse::("BUZZ_AGENT_MAX_ROUNDS").filter(|n| *n > 0), max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(8), system_prompt, - llm_timeout_secs: env_parse("BUZZ_AGENT_LLM_TIMEOUT_SECS").unwrap_or(600), goose_mode: parse_approval(env_str("BUZZ_AGENT_APPROVAL").as_deref()), // Discovery-only; the agent loop resolves providers through goose. provider: Provider::default(), @@ -164,7 +161,6 @@ impl Config { max_rounds: None, max_sessions: 1, system_prompt: None, - llm_timeout_secs: 30, goose_mode: GooseMode::default(), provider, api_key, diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index d46d5c4025..b679b9b5be 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -1,3 +1,4 @@ +#![forbid(unsafe_code)] //! buzz-agent's ACP server, with Goose as the agent loop. //! //! This is the layer `buzz-acp` actually talks to, and it is deliberately @@ -225,7 +226,10 @@ fn hook_tool_guidance() -> String { pub async fn serve(cfg: Config) -> anyhow::Result<()> { let (wire_tx, wire_rx) = tokio::sync::mpsc::channel(256); - tokio::spawn(wire::writer_task(wire_rx)); + // Keep the join handle: on EOF we must await it so queued frames -- including + // the final response of a turn that finished on the same tick -- actually + // reach stdout before the runtime is dropped. + let writer = tokio::spawn(wire::writer_task(wire_rx)); let app = Arc::new(App { cfg, @@ -261,6 +265,25 @@ pub async fn serve(cfg: Config) -> anyhow::Result<()> { } } } + + // Orderly shutdown. Both halves matter and both were missing: + // + // 1. Cancel every in-flight turn. Dropping a CancellationToken does NOT + // cancel it, so goose would never send `notifications/cancelled` to its + // MCP children and they would outlive us as orphans -- exactly the + // failure `agent::run_turn` goes to lengths to avoid on session/cancel. + // 2. Await the writer. `wire_tx` is a bounded mpsc drained by a detached + // task; returning here would discard anything still queued. + { + let sessions = app.sessions.lock().await; + for session in sessions.values() { + if let Some(token) = &session.cancel { + token.cancel(); + } + } + } + drop(wire_tx); + let _ = writer.await; Ok(()) } @@ -388,6 +411,9 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen } } + // Cheap early reject. The authoritative check is re-done under the insert + // guard below -- `session/new` is dispatched on its own task, so N + // concurrent calls would otherwise all pass this and all insert. if app.sessions.lock().await.len() >= app.cfg.max_sessions { return wire::send( wire_tx, @@ -415,21 +441,34 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen .or_else(|| std::env::var("GOOSE_MODEL").ok()) .unwrap_or_default(); - app.sessions.lock().await.insert( - sid.clone(), - Session { - agent, - goose_session_id, - busy: false, - active_run_id: None, - cancel: None, - model_override: None, - pending_model: None, - hook_extension, - accumulated_input_tokens: 0, - accumulated_output_tokens: 0, - }, - ); + { + let mut sessions = app.sessions.lock().await; + // Re-check under the guard we insert with: build_agent above spawns MCP + // children and does a provider round-trip, so other session/new tasks + // can land in that window. + if sessions.len() >= app.cfg.max_sessions { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "session/new: max sessions reached"), + ) + .await; + } + sessions.insert( + sid.clone(), + Session { + agent, + goose_session_id, + busy: false, + active_run_id: None, + cancel: None, + model_override: None, + pending_model: None, + hook_extension, + accumulated_input_tokens: 0, + accumulated_output_tokens: 0, + }, + ); + } // Advertise the model catalog. `buzz-acp` reads `models.availableModels` // off this response (`acp.rs:1866`, `:1900`) to drive the desktop @@ -629,6 +668,10 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire .get(&p.session_id) .and_then(|s| s.model_override.clone()) .or_else(|| app.cfg.model.clone()) + // A session starts fine with only GOOSE_MODEL set + // (build_agent falls back to it), so omitting it here + // emitted `"model": ""` and blanked kind-44200 attribution. + .or_else(|| std::env::var("GOOSE_MODEL").ok()) .unwrap_or_default() }; wire::send( @@ -716,6 +759,19 @@ async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { } }; + // Reject an empty steer before touching the session map, matching main. + // buzz-acp maps a successful steer to SteerAck::Ok and considers the user's + // message delivered, so acknowledging a no-op would swallow it silently and + // suppress the cancel+merge fallback (`buzz-acp/src/pool.rs:329-366`). + let text = agent::prompt_to_text(&p.prompt); + if text.trim().is_empty() { + return wire::send( + wire_tx, + wire::err(id, INVALID_PARAMS, "steer: prompt must not be empty"), + ) + .await; + } + let (agent, goose_session_id) = { let sessions = app.sessions.lock().await; let Some(s) = sessions.get(&p.session_id) else { @@ -745,13 +801,6 @@ async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { (s.agent.clone(), s.goose_session_id.clone()) }; - let text = agent::prompt_to_text(&p.prompt); - if text.trim().is_empty() { - // Best-effort by contract: a whitespace-only steer is dropped, never - // fatal to the turn (buzz-agent `agent.rs:266-278`). - return wire::send(wire_tx, wire::ok(id, json!({ "runId": p.expected_run_id }))).await; - } - let message_id = format!("steer_{}", uuid_like()); agent .steer( From cca05c0ae1c28565bc5ee429c5c0cd27d2aae15a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 28 Jul 2026 15:17:28 +1000 Subject: [PATCH 19/20] feat(buzz-agent): restore AGENTS.md hints and load_skill on the goose loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swap documented both as losses (B1/B2 in fa8166e60). Restore them by keeping the old buzz-agent modules and wiring them into goose, rather than using goose's own equivalents, which don't fit: * goose's hint loader keys off GOOSE_HINTS_FILENAME (.goosehints); our repos ship AGENTS.md. hints.rs (directory-chain walk + ~/AGENTS.md + skill discovery under .agents/skills, .goose/skills, .claude/skills) is kept and its output injected via extend_system_prompt("buzz_hints") at session build — system_prompt_extras survive override_system_prompt, so this works with the persona path too. * goose's skills platform extension is never loaded (Agent::with_config loads zero extensions; build_agent only adds the harness's mcpServers). builtin.rs / load_skill is kept and registered as a goose *frontend* extension: goose advertises the tool, and we answer the calls in-process. The frontend-tool wiring had a deadlock in the first cut: it listened for MessageContent::ToolRequest, but goose strips frontend calls out of the normal ToolRequest flow (reply_parts.rs categorize_tools) and yields a dedicated FrontendToolRequest variant instead, then BLOCKS the reply stream on tool_result_rx.recv() until handle_tool_result is called. The handler never matched, so the first load_skill call hung the turn forever — this is what the hung `cargo test --test skills` processes on this machine were. Now: * serve_frontend_tool matches FrontendToolRequest, answers every yielded request exactly once (unknown tool name gets an error result rather than silence — goose is already blocked on the id), and skips only the Err parse case, where goose does not block (tool_execution.rs:181 yields inside the Ok arm only). * emit_content announces FrontendToolRequest to the desktop as a tool_call update; its result comes back as a plain ToolResponse, and an update for a never-announced id would break the announce→terminal pairing that keeps the UI spinner honest. The skills integration test drives the full path over stdio against a fake SSE provider: AGENTS.md content and the skill name/description index must reach the system prompt, the skill BODY must not (that is the point of load_skill), load_skill must be advertised, and the turn must complete — a broken frontend-tool path fails by hanging, so the turn completing IS the assertion. 53 buzz-agent tests green; fmt + clippy -D warnings clean. Signed-off-by: Michael Neale --- crates/buzz-agent/src/agent.rs | 131 +++++- crates/buzz-agent/src/builtin.rs | 575 +++++++++++++++++++++++ crates/buzz-agent/src/hints.rs | 726 ++++++++++++++++++++++++++++++ crates/buzz-agent/src/lib.rs | 61 ++- crates/buzz-agent/src/types.rs | 58 +++ crates/buzz-agent/tests/skills.rs | 277 ++++++++++++ 6 files changed, 1814 insertions(+), 14 deletions(-) create mode 100644 crates/buzz-agent/src/builtin.rs create mode 100644 crates/buzz-agent/src/hints.rs create mode 100644 crates/buzz-agent/tests/skills.rs diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index be0fdc0733..ca618a7421 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -8,23 +8,26 @@ //! | `mcp.rs` | 1139 | `goose::agents::extension_manager` | //! | `auth.rs` | 845 | `goose::providers` (incl. Databricks OAuth) | //! | `agent.rs` | 746 | `goose::agents::Agent::reply` | -//! | `hints.rs` | 726 | *partly* -- see the caveat below | +//! | `hints.rs` | 726 | kept -- goose's loader can't replace it (below) | //! | `catalog.rs` | 631 | `goose::providers::init` model discovery | -//! | `builtin.rs` | 575 | **dropped** -- see the caveat below | +//! | `builtin.rs` | 575 | kept -- served as a goose *frontend* tool (below) | //! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) | //! -//! Two entries above are NOT clean substitutions, and saying otherwise would -//! be worse than the loss itself: +//! Two entries above are NOT goose substitutions; the old buzz-agent modules +//! are kept and wired into goose instead, because goose's own equivalents +//! don't cover them: //! -//! * **`builtin.rs` / `load_skill` is gone.** `Agent::with_config` loads zero +//! * **`builtin.rs` / `load_skill`.** `Agent::with_config` loads zero //! extensions and `build_agent` only adds the `mcpServers` the harness -//! declares, so goose's `skills` platform extension is never loaded. The -//! `load_skill` tool and `SKILL.md` discovery (old `hints.rs:110-204`) have -//! no replacement. Restoring them means explicitly adding the extension. -//! * **`AGENTS.md` hints are not loaded.** Goose's loader keys off +//! declares, so goose's `skills` platform extension is never loaded. Instead +//! `build_agent` registers `load_skill` as a goose *frontend* extension and +//! [`serve_frontend_tool`] answers the calls in-process (goose yields a +//! `FrontendToolRequest` and blocks the stream until we respond). +//! * **`AGENTS.md` hints / skill index.** Goose's loader keys off //! `GOOSE_HINTS_FILENAME` (`.goosehints`); the old code walked the directory //! chain for `AGENTS.md` plus `~/AGENTS.md`. Every repo here ships the -//! former and none ship the latter, so in practice no hints load at all. +//! latter shape, so `hints.rs` is kept and its output injected via +//! `extend_system_prompt` at session build. //! //! What this file keeps is the part Goose does *not* know about: the mapping //! from `AgentEvent` onto the exact `session/update` notifications `buzz-acp` @@ -137,6 +140,7 @@ pub async fn run_turn( wire_tx: &WireSender, cancel: CancellationToken, hook_extension: Option<&str>, + skills: &[crate::hints::SkillEntry], ) -> (Result, TurnTokens) { let mut tokens = TurnTokens::default(); let mut next_message = Message::user().with_text(prompt_to_text(&prompt)); @@ -157,6 +161,7 @@ pub async fn run_turn( &cancel, &mut tokens, hook_extension, + skills, ) .await { @@ -221,6 +226,7 @@ async fn drive_stream( cancel: &CancellationToken, tokens: &mut TurnTokens, hook_extension: Option<&str>, + skills: &[crate::hints::SkillEntry], ) -> Result { let session_config = SessionConfig { id: session_id.to_string(), @@ -302,6 +308,7 @@ async fn drive_stream( &mut compacted, &mut reflections, &mut open_tool_calls, + skills, ) .await { @@ -390,6 +397,7 @@ async fn handle_event( compacted: &mut bool, reflections: &mut usize, open_tool_calls: &mut Vec, + skills: &[crate::hints::SkillEntry], ) -> Option { match event { AgentEvent::Message(msg) => { @@ -400,6 +408,17 @@ async fn handle_event( open_tool_calls.push(req.id.clone()); } + // Frontend tool calls (`load_skill`) never arrive as + // `ToolRequest`: goose strips them from the normal flow + // (`reply_parts.rs` `categorize_tools`) and yields this + // dedicated variant instead, then blocks the reply stream on + // `tool_result_rx` until we answer. Answer it or the turn + // hangs forever. + if let MessageContent::FrontendToolRequest(req) = content { + open_tool_calls.push(req.id.clone()); + serve_frontend_tool(req, agent, skills).await; + } + if let MessageContent::ToolResponse(resp) = content { open_tool_calls.retain(|id| id != &resp.id); @@ -513,6 +532,36 @@ async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &Wire .await; } + // Frontend tool calls (`load_skill`) are stripped out of the normal + // `ToolRequest` flow by goose and arrive as this variant instead. The + // desktop still needs the announcement: its response comes back as a + // plain `ToolResponse`, and an update for a never-announced id would + // break the announce→terminal pairing. + MessageContent::FrontendToolRequest(req) => { + let (name, raw) = match &req.tool_call { + Ok(call) => ( + call.name.to_string(), + serde_json::to_value(&call.arguments).unwrap_or(Value::Null), + ), + Err(e) => (String::from("unknown"), json!({ "error": e.to_string() })), + }; + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call", + "toolCallId": req.id, + "title": name, + "kind": "other", + "status": "in_progress", + "rawInput": raw, + }), + ), + ) + .await; + } + MessageContent::ToolResponse(resp) => { let failed = match &resp.tool_result { Ok(r) => r.is_error.unwrap_or(false), @@ -536,6 +585,68 @@ async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &Wire } } +/// Serve a goose *frontend* tool call. +/// +/// Goose never dispatches frontend tools itself. `handle_frontend_tool_request` +/// (`goose/src/agents/tool_execution.rs:175`) yields the +/// `FrontendToolRequest` message we are handling right now and then **blocks +/// the reply stream** on `tool_result_rx.recv()` until the embedder calls +/// `agent.handle_tool_result(id, ..)`. That has two consequences: +/// +/// * every `FrontendToolRequest` event MUST be answered — returning without +/// sending a result hangs the turn forever, so even an unrecognised tool +/// name gets an error result rather than silence; +/// * exactly one answer per event — a stray extra send would be picked up by +/// the recv of the *next* frontend call and mis-pair results. +async fn serve_frontend_tool( + req: &goose_provider_types::conversation::message::FrontendToolRequest, + agent: &Arc, + skills: &[crate::hints::SkillEntry], +) { + let result = match &req.tool_call { + Ok(call) if call.name == crate::builtin::LOAD_SKILL_TOOL => { + let args = call + .arguments + .clone() + .map(serde_json::Value::Object) + .unwrap_or(serde_json::Value::Null); + crate::builtin::call_load_skill(&args, skills).await + } + // Only `load_skill` is registered, so this arm is unreachable today — + // but goose is already blocked waiting for this id, so answer anyway. + Ok(call) => crate::builtin::error_result(&format!( + "unknown frontend tool {:?}; only {} is served in-process", + call.name, + crate::builtin::LOAD_SKILL_TOOL + )), + // An Err here means goose did NOT block on the result channel — its + // yield only happens inside the `Ok` branch (`tool_execution.rs:181`). + // Sending would leave a stray result in the queue that mis-pairs the + // NEXT frontend call (the recv trusts the id coming off the channel). + Err(e) => { + tracing::warn!("unparseable frontend tool call {}: {e}", req.id); + return; + } + }; + + let content: Vec = result + .content + .iter() + .map(|c| rmcp::model::Content::text(c.as_text_lossy())) + .collect(); + + agent + .handle_tool_result( + req.id.clone(), + Ok(if result.is_error { + rmcp::model::CallToolResult::error(content) + } else { + rmcp::model::CallToolResult::success(content) + }), + ) + .await; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-agent/src/builtin.rs b/crates/buzz-agent/src/builtin.rs new file mode 100644 index 0000000000..95e8487611 --- /dev/null +++ b/crates/buzz-agent/src/builtin.rs @@ -0,0 +1,575 @@ +//! Built-in tools that run in-process, bypassing MCP. +//! +//! Currently: `load_skill` — reads a skill's full SKILL.md body from disk +//! and returns it so the agent can load skill content on demand rather than +//! having every skill inlined into the system prompt at session start. + +use serde_json::{json, Value}; + +use crate::hints::{strip_frontmatter, SkillEntry, MAX_SKILL_BODY_BYTES}; +use crate::types::truncate_at_boundary; +use crate::types::{ToolDef, ToolResult, ToolResultContent}; + +pub const LOAD_SKILL_TOOL: &str = "load_skill"; + +/// Return the `ToolDef` for `load_skill` to include in the LLM tool list. +pub fn load_skill_def() -> ToolDef { + ToolDef { + name: LOAD_SKILL_TOOL.to_owned(), + description: "Load the full content of a skill by name. \ + Call this before using a skill — the system prompt lists skill names \ + and descriptions only; the full instructions are loaded on demand. \ + To load a supporting file within a skill, use the form \ + \"skill-name/relative/path\" (e.g. \"my-skill/references/foo.md\")." + .to_owned(), + input_schema: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The skill name as listed in the Available Skills section, \ + or \"skill-name/relative/path\" to load a supporting file." + } + }, + "required": ["name"] + }), + } +} + +/// Execute a `load_skill` call. Returns a `ToolResult` on success or a +/// user-visible error result if the skill is not found or cannot be read. +pub async fn call_load_skill(arguments: &Value, skills: &[SkillEntry]) -> ToolResult { + let name = match arguments.get("name").and_then(Value::as_str) { + Some(n) => n, + None => { + return error_result("load_skill: missing required argument \"name\""); + } + }; + + // Two forms: + // "skill-name" → load SKILL.md body + ## Supporting Files section + // "skill-name/rel/path" → load a specific supporting file + if let Some((skill_name, rel_path)) = name.split_once('/') { + return load_supporting_file(skill_name, rel_path, skills).await; + } + + // Plain skill-name form: load SKILL.md body. + let entry = match skills.iter().find(|s| s.name == name) { + Some(e) => e, + None => { + let available: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); + return error_result(&format!( + "load_skill: skill {name:?} not found. Available: {available:?}" + )); + } + }; + + // Read the file off the async executor to avoid blocking a Tokio worker. + let skill_path = entry.path.clone(); + let raw = match tokio::task::spawn_blocking(move || std::fs::read_to_string(&skill_path)) + .await + .unwrap_or_else(|e| Err(std::io::Error::other(e))) + { + Ok(s) => s, + Err(e) => { + return error_result(&format!("load_skill: could not read {:?}: {e}", entry.path)); + } + }; + + // Strip the YAML frontmatter — the agent already knows name/description + // from the system prompt; return only the body. + let body = strip_frontmatter(&raw); + + let mut output = body.to_owned(); + + // Append ## Supporting Files section if this skill has any. + if !entry.supporting_files.is_empty() { + let skill_dir = entry.path.parent().unwrap_or(&entry.path); + output.push_str("\n\n## Supporting Files\n\n"); + for file in &entry.supporting_files { + if let Ok(rel) = file.strip_prefix(skill_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + output.push_str(&format!( + "- {} (load_skill(name: \"{}/{}\"))\n", + rel_str, entry.name, rel_str + )); + } + } + } + + // Apply the size cap to the full output (body + Supporting Files section) + // so the total tool result stays within MAX_SKILL_BODY_BYTES. + let output = if output.len() > MAX_SKILL_BODY_BYTES { + truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned() + } else { + output + }; + + ToolResult { + provider_id: String::new(), + content: vec![ToolResultContent::Text(output)], + is_error: false, + } +} + +/// Load a supporting file identified by `skill_name/rel_path`. +/// Matches against the pre-enumerated `supporting_files` list and applies a +/// canonicalize-based traversal guard before reading. +async fn load_supporting_file( + skill_name: &str, + rel_path: &str, + skills: &[SkillEntry], +) -> ToolResult { + let rel_path = rel_path.replace('\\', "/"); + + let entry = match skills.iter().find(|s| s.name == skill_name) { + Some(e) => e, + None => { + let available: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); + return error_result(&format!( + "load_skill: skill {skill_name:?} not found. Available: {available:?}" + )); + } + }; + + let skill_dir = match entry.path.parent() { + Some(d) => d, + None => { + return error_result(&format!( + "load_skill: could not determine skill directory for {skill_name:?}" + )); + } + }; + + // Match rel_path against the pre-enumerated supporting_files list. + let matched = entry.supporting_files.iter().find(|f| { + f.strip_prefix(skill_dir) + .map(|r| r.to_string_lossy().replace('\\', "/") == rel_path) + .unwrap_or(false) + }); + + let file_path = match matched { + Some(p) => p, + None => { + let available: Vec = entry + .supporting_files + .iter() + .filter_map(|f| { + f.strip_prefix(skill_dir) + .ok() + .map(|r| r.to_string_lossy().replace('\\', "/")) + }) + .collect(); + if available.is_empty() { + return error_result(&format!( + "load_skill: skill {skill_name:?} has no supporting files." + )); + } + return error_result(&format!( + "load_skill: file {rel_path:?} not found in skill {skill_name:?}. \ + Available: {available:?}" + )); + } + }; + + // Traversal guard: canonicalize both paths and verify the file stays inside + // the skill directory. Fail hard if the skill directory itself can't be + // canonicalized — a degraded guard is worse than no guard. + let canonical_skill_dir = match skill_dir.canonicalize() { + Ok(p) => p, + Err(e) => { + return error_result(&format!( + "load_skill: could not canonicalize skill directory for {skill_name:?}: {e}" + )); + } + }; + + // Clone the path so we can move it into spawn_blocking. + let file_path = file_path.clone(); + let skill_name = skill_name.to_owned(); + let rel_path_owned = rel_path.clone(); + + match tokio::task::spawn_blocking(move || file_path.canonicalize().map(|c| (c, file_path))) + .await + .unwrap_or_else(|e| Err(std::io::Error::other(e))) + { + Ok((canonical_file, resolved_path)) if canonical_file.starts_with(&canonical_skill_dir) => { + match tokio::task::spawn_blocking(move || std::fs::read_to_string(&resolved_path)) + .await + .unwrap_or_else(|e| Err(std::io::Error::other(e))) + { + Ok(content) => { + let output = format!( + "# Loaded: {}/{}\n\n{}\n\n---\nFile loaded into context.", + skill_name, rel_path_owned, content + ); + let output = if output.len() > MAX_SKILL_BODY_BYTES { + truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned() + } else { + output + }; + ToolResult { + provider_id: String::new(), + content: vec![ToolResultContent::Text(output)], + is_error: false, + } + } + Err(e) => error_result(&format!( + "load_skill: could not read {skill_name:?}/{rel_path_owned}: {e}" + )), + } + } + Ok(_) => error_result(&format!( + "load_skill: refusing to load {skill_name:?}/{rel_path_owned}: \ + resolves outside the skill directory" + )), + Err(e) => error_result(&format!( + "load_skill: could not resolve {skill_name:?}/{rel_path_owned}: {e}" + )), + } +} + +pub fn error_result(msg: &str) -> ToolResult { + ToolResult { + provider_id: String::new(), + content: vec![ToolResultContent::Text(msg.to_owned())], + is_error: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use tempfile::TempDir; + + fn text_content(result: &ToolResult) -> String { + match &result.content[0] { + ToolResultContent::Text(t) => t.clone(), + ToolResultContent::Image { .. } => panic!("unexpected Image content in test"), + } + } + + fn make_skill(name: &str, description: &str, path: PathBuf) -> SkillEntry { + SkillEntry { + name: name.to_owned(), + description: description.to_owned(), + path, + supporting_files: Vec::new(), + } + } + + fn make_skill_with_files( + name: &str, + description: &str, + path: PathBuf, + supporting_files: Vec, + ) -> SkillEntry { + SkillEntry { + name: name.to_owned(), + description: description.to_owned(), + path, + supporting_files, + } + } + + #[tokio::test] + async fn call_load_skill_missing_name_arg() { + let result = call_load_skill(&serde_json::json!({}), &[]).await; + assert!(result.is_error); + let text = text_content(&result); + assert!(text.contains("missing required argument"), "got: {text}"); + } + + #[tokio::test] + async fn call_load_skill_skill_not_found() { + let result = call_load_skill(&serde_json::json!({"name": "no-such"}), &[]).await; + assert!(result.is_error); + let text = text_content(&result); + assert!(text.contains("not found"), "got: {text}"); + } + + #[tokio::test] + async fn call_load_skill_returns_body_strips_frontmatter() { + let tmp = TempDir::new().unwrap(); + let skill_md = tmp.path().join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: test\ndescription: A test\n---\nSkill body here.\n", + ) + .unwrap(); + let skills = vec![make_skill("test", "A test", skill_md)]; + let result = call_load_skill(&serde_json::json!({"name": "test"}), &skills).await; + assert!(!result.is_error); + let text = text_content(&result); + assert!(text.contains("Skill body here."), "got: {text}"); + assert!( + !text.contains("---"), + "frontmatter should be stripped: {text}" + ); + } + + #[tokio::test] + async fn call_load_skill_appends_supporting_files_section() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + let skill_md = skill_dir.join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: my-skill\ndescription: desc\n---\nBody.\n", + ) + .unwrap(); + let refs_dir = skill_dir.join("references"); + std::fs::create_dir_all(&refs_dir).unwrap(); + let ref_file = refs_dir.join("foo.md"); + std::fs::write(&ref_file, "Reference content.").unwrap(); + + let skills = vec![make_skill_with_files( + "my-skill", + "desc", + skill_md, + vec![ref_file], + )]; + let result = call_load_skill(&serde_json::json!({"name": "my-skill"}), &skills).await; + assert!(!result.is_error); + let text = text_content(&result); + assert!(text.contains("Body."), "body missing: {text}"); + assert!( + text.contains("## Supporting Files"), + "missing Supporting Files section: {text}" + ); + assert!( + text.contains("references/foo.md"), + "missing file listing: {text}" + ); + assert!( + text.contains("load_skill(name: \"my-skill/references/foo.md\")"), + "missing load_skill hint: {text}" + ); + } + + #[tokio::test] + async fn call_load_skill_no_supporting_files_section_when_empty() { + let tmp = TempDir::new().unwrap(); + let skill_md = tmp.path().join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: bare\ndescription: desc\n---\nBody.\n", + ) + .unwrap(); + let skills = vec![make_skill("bare", "desc", skill_md)]; + let result = call_load_skill(&serde_json::json!({"name": "bare"}), &skills).await; + assert!(!result.is_error); + let text = text_content(&result); + assert!( + !text.contains("## Supporting Files"), + "should not have Supporting Files section when none: {text}" + ); + } + + #[tokio::test] + async fn call_load_skill_supporting_file_returns_content() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + let skill_md = skill_dir.join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: my-skill\ndescription: desc\n---\nBody.\n", + ) + .unwrap(); + let refs_dir = skill_dir.join("references"); + std::fs::create_dir_all(&refs_dir).unwrap(); + let ref_file = refs_dir.join("foo.md"); + std::fs::write(&ref_file, "Reference content here.").unwrap(); + + let skills = vec![make_skill_with_files( + "my-skill", + "desc", + skill_md, + vec![ref_file], + )]; + let result = call_load_skill( + &serde_json::json!({"name": "my-skill/references/foo.md"}), + &skills, + ) + .await; + assert!(!result.is_error, "expected success, got error"); + let text = text_content(&result); + assert!( + text.contains("Reference content here."), + "file content missing: {text}" + ); + assert!( + text.contains("# Loaded: my-skill/references/foo.md"), + "missing header: {text}" + ); + } + + #[tokio::test] + async fn call_load_skill_supporting_file_not_found_lists_available() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + let skill_md = skill_dir.join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: my-skill\ndescription: desc\n---\nBody.\n", + ) + .unwrap(); + let refs_dir = skill_dir.join("references"); + std::fs::create_dir_all(&refs_dir).unwrap(); + let ref_file = refs_dir.join("foo.md"); + std::fs::write(&ref_file, "content").unwrap(); + + let skills = vec![make_skill_with_files( + "my-skill", + "desc", + skill_md, + vec![ref_file], + )]; + let result = call_load_skill( + &serde_json::json!({"name": "my-skill/references/missing.md"}), + &skills, + ) + .await; + assert!(result.is_error); + let text = text_content(&result); + assert!(text.contains("not found"), "got: {text}"); + assert!( + text.contains("references/foo.md"), + "should list available: {text}" + ); + } + + #[tokio::test] + async fn call_load_skill_no_supporting_files_error_message() { + let tmp = TempDir::new().unwrap(); + let skill_md = tmp.path().join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: bare\ndescription: desc\n---\nBody.\n", + ) + .unwrap(); + let skills = vec![make_skill("bare", "desc", skill_md)]; + let result = + call_load_skill(&serde_json::json!({"name": "bare/anything.md"}), &skills).await; + assert!(result.is_error); + let text = text_content(&result); + assert!(text.contains("no supporting files"), "got: {text}"); + } + + #[tokio::test] + async fn call_load_skill_traversal_guard_rejects_escape() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join("my-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + let skill_md = skill_dir.join("SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: my-skill\ndescription: desc\n---\nBody.\n", + ) + .unwrap(); + + // Create a file outside the skill dir that we'll try to reference. + let outside_file = tmp.path().join("secret.txt"); + std::fs::write(&outside_file, "secret content").unwrap(); + + // Manually construct a SkillEntry with a supporting_files entry that + // points outside the skill dir — simulating a crafted/malicious entry. + // The traversal guard should catch this. + let skills = vec![make_skill_with_files( + "my-skill", + "desc", + skill_md.clone(), + vec![outside_file.clone()], + )]; + + // The slash form splits "my-skill/../secret.txt" into skill_name="my-skill" + // and rel_path="../secret.txt". strip_prefix(skill_dir) on outside_file + // fails, so it won't match any supporting_files entry — the pre-enumeration + // guard rejects it before the canonicalize guard even fires. + let result = call_load_skill( + &serde_json::json!({"name": "my-skill/../secret.txt"}), + &skills, + ) + .await; + assert!(result.is_error, "traversal attempt should be rejected"); + let text = text_content(&result); + assert!( + !text.contains("secret content"), + "secret content must not be returned: {text}" + ); + } + + #[tokio::test] + async fn call_load_skill_truncates_large_body() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + let skill_md = skill_dir.join("SKILL.md"); + // Build a body that exceeds MAX_SKILL_BODY_BYTES (32 KiB). + let large_body = "x".repeat(40 * 1024); + std::fs::write( + &skill_md, + format!("---\nname: big\ndescription: desc\n---\n{large_body}\n"), + ) + .unwrap(); + // Add a supporting file so the Supporting Files section is also appended + // before the cap is applied. + let refs_dir = skill_dir.join("references"); + std::fs::create_dir_all(&refs_dir).unwrap(); + let ref_file = refs_dir.join("extra.md"); + std::fs::write(&ref_file, "extra content").unwrap(); + + let skills = vec![make_skill_with_files( + "big", + "desc", + skill_md, + vec![ref_file], + )]; + let result = call_load_skill(&serde_json::json!({"name": "big"}), &skills).await; + assert!(!result.is_error); + let text = text_content(&result); + assert!( + text.len() <= MAX_SKILL_BODY_BYTES, + "output length {} exceeds MAX_SKILL_BODY_BYTES {}", + text.len(), + MAX_SKILL_BODY_BYTES + ); + } + + #[tokio::test] + async fn call_load_skill_truncates_large_supporting_file() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + let skill_md = skill_dir.join("SKILL.md"); + std::fs::write(&skill_md, "---\nname: big\ndescription: desc\n---\nBody.\n").unwrap(); + + let refs_dir = skill_dir.join("references"); + std::fs::create_dir_all(&refs_dir).unwrap(); + let ref_file = refs_dir.join("huge.md"); + std::fs::write(&ref_file, "x".repeat(MAX_SKILL_BODY_BYTES * 2)).unwrap(); + + let skills = vec![make_skill_with_files( + "big", + "desc", + skill_md, + vec![ref_file], + )]; + let result = call_load_skill( + &serde_json::json!({"name": "big/references/huge.md"}), + &skills, + ) + .await; + assert!(!result.is_error); + let text = text_content(&result); + assert!( + text.len() <= MAX_SKILL_BODY_BYTES, + "output length {} exceeds MAX_SKILL_BODY_BYTES {}", + text.len(), + MAX_SKILL_BODY_BYTES + ); + assert!( + text.starts_with("# Loaded: big/references/huge.md"), + "missing supporting-file header: {text}" + ); + } +} diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs new file mode 100644 index 0000000000..90833aa3db --- /dev/null +++ b/crates/buzz-agent/src/hints.rs @@ -0,0 +1,726 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use crate::types::truncate_at_boundary; + +const MAX_HINTS_BYTES: usize = 128 * 1024; +pub const MAX_SKILL_BODY_BYTES: usize = 32 * 1024; +const SKILL_DIRS: &[&str] = &[".agents/skills", ".goose/skills", ".claude/skills"]; + +fn home_dir() -> Option { + std::env::var("HOME").ok().map(PathBuf::from) +} + +#[derive(Clone)] +pub struct SkillEntry { + pub name: String, + pub description: String, + /// Absolute path to the SKILL.md file; used by `load_skill` to read on demand. + pub path: PathBuf, + /// Absolute paths to every non-SKILL.md file in the skill directory tree. + /// Pre-enumerated at discovery time so `load_skill` can match by relative path + /// without doing arbitrary filesystem lookups at call time. + pub supporting_files: Vec, +} + +/// Handles both normal repos (`.git/` dir) and worktrees (`.git` file). +fn find_git_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".git").exists() { + return Some(current); + } + match current.parent() { + Some(parent) => current = parent.to_path_buf(), + None => return None, + } + } +} + +fn load_hint_files_impl(cwd: &Path, home: Option<&Path>) -> String { + let mut chain = match find_git_root(cwd) { + Some(root) => { + let mut c: Vec = cwd + .ancestors() + .take_while(|a| a.starts_with(&root)) + .map(|a| a.to_path_buf()) + .collect(); + // ancestors() yields cwd first, root last — reverse for root→cwd. + c.reverse(); + c + } + None => vec![cwd.to_path_buf()], + }; + + // Prepend ~/AGENTS.md as global layer, unless ~ is already in the chain. + if let Some(home) = home { + if !chain.iter().any(|d| d == home) { + chain.insert(0, home.to_path_buf()); + } + } + + let mut result = String::new(); + for dir in &chain { + let path = dir.join("AGENTS.md"); + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + if !result.is_empty() { + result.push_str("\n\n"); + } + let remaining = MAX_HINTS_BYTES.saturating_sub(result.len()); + if remaining == 0 { + break; + } + if content.len() <= remaining { + result.push_str(&content); + } else { + let truncated = truncate_at_boundary(&content, remaining); + result.push_str(truncated); + break; + } + } + result +} + +fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> { + // Must start with `---` + let rest = content.strip_prefix("---\n")?; + // Find the closing `---` + let close_pos = rest.find("\n---")?; + let yaml_block = &rest[..close_pos]; + + let map: HashMap = serde_yaml::from_str(yaml_block).ok()?; + let name = map + .get("name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string)?; + let description = map + .get("description") + .and_then(|v| v.as_str()) + .map(str::trim) + .unwrap_or("") + .to_string(); + + Some((name, description)) +} + +fn scan_skill_dir(dir: &Path, seen: &mut HashSet, skills: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut subdirs: Vec = entries + .filter_map(|e| e.ok()) + // Use std::fs::metadata (follows symlinks) rather than DirEntry::file_type + // (which returns FileType::Symlink for symlinks, causing is_dir() to return + // false even when the symlink target is a directory). + .filter(|e| { + std::fs::metadata(e.path()) + .map(|m| m.is_dir()) + .unwrap_or(false) + }) + .map(|e| e.path()) + .collect(); + subdirs.sort(); + + for subdir in subdirs { + let skill_md = subdir.join("SKILL.md"); + let Ok(content) = std::fs::read_to_string(&skill_md) else { + continue; + }; + let Some((name, description)) = parse_skill_frontmatter(&content) else { + continue; + }; + if seen.contains(&name) { + continue; + } + seen.insert(name.clone()); + + // Collect supporting files: every non-SKILL.md file in the skill dir tree. + // Don't descend into subdirs that themselves have a SKILL.md — those are + // separate skills with their own entries. + let supporting_files = collect_supporting_files(&subdir); + + skills.push(SkillEntry { + name, + description, + path: skill_md, + supporting_files, + }); + } +} + +/// Walk `skill_dir` recursively and return the absolute path of every file +/// that is not `SKILL.md`. Subdirectories that contain their own `SKILL.md` +/// are treated as separate skills and are not descended into. +fn collect_supporting_files(skill_dir: &Path) -> Vec { + let mut result = Vec::new(); + let mut visited_dirs = HashSet::new(); + collect_supporting_files_impl(skill_dir, &mut result, &mut visited_dirs); + result.sort(); + result +} + +fn collect_supporting_files_impl( + current: &Path, + out: &mut Vec, + visited_dirs: &mut HashSet, +) { + let Ok(canonical_current) = current.canonicalize() else { + return; + }; + if !visited_dirs.insert(canonical_current) { + return; + } + + let Ok(entries) = std::fs::read_dir(current) else { + return; + }; + let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + items.sort_by_key(|e| e.path()); + + for entry in items { + let path = entry.path(); + // Use std::fs::metadata (follows symlinks) so symlinked subdirs and files + // inside a skill directory are handled correctly. + let ft = match std::fs::metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if ft.is_dir() { + // Don't descend into subdirs that are themselves skills. + if path.join("SKILL.md").is_file() { + continue; + } + collect_supporting_files_impl(&path, out, visited_dirs); + } else if ft.is_file() && path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") { + out.push(path); + } + } +} + +fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec { + let mut seen = HashSet::new(); + let mut skills = Vec::new(); + + for dir_suffix in SKILL_DIRS { + scan_skill_dir(&cwd.join(dir_suffix), &mut seen, &mut skills); + } + + if let Some(home) = home { + scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills); + } + + skills +} + +pub fn build_hints_section(cwd: &Path) -> (String, Vec) { + build_hints_section_impl(cwd, home_dir().as_deref()) +} + +fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec) { + let hints_text = load_hint_files_impl(cwd, home); + let skills = discover_skills_impl(cwd, home); + + if hints_text.is_empty() && skills.is_empty() { + return (String::new(), skills); + } + + let mut out = String::from("# Additional Instructions\n"); + + if !hints_text.is_empty() { + out.push_str("\n## Project Hints\n"); + out.push_str(&hints_text); + out.push('\n'); + } + + if !skills.is_empty() { + out.push_str("\n## Available Skills\n"); + for skill in &skills { + out.push_str(&format!("- {}: {}\n", skill.name, skill.description)); + } + out.push_str( + "\nUse the `load_skill` tool to read the full content of a skill before using it.\n", + ); + } + + (out, skills) +} + +/// Strip the YAML frontmatter block from a skill file's content and return +/// the body. If no valid frontmatter is found, returns the content unchanged. +pub(crate) fn strip_frontmatter(content: &str) -> &str { + let Some(rest) = content.strip_prefix("---\n") else { + return content; + }; + let Some(close_pos) = rest.find("\n---") else { + return content; + }; + let after = &rest[close_pos + 4..]; // skip "\n---" + after.strip_prefix('\n').unwrap_or(after) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn find_git_root_normal_repo() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + assert_eq!(find_git_root(root), Some(root.to_path_buf())); + } + + #[test] + fn find_git_root_worktree() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + // .git as a file (worktree) + std::fs::write(root.join(".git"), "gitdir: ../main/.git/worktrees/wt").unwrap(); + assert_eq!(find_git_root(root), Some(root.to_path_buf())); + } + + #[test] + fn find_git_root_none() { + let tmp = TempDir::new().unwrap(); + // No .git anywhere under tmp + let result = find_git_root(tmp.path()); + // In a CI environment the test itself may live inside a real git repo, + // so only assert None when tmp is truly isolated (not a subpath of a git repo). + // We verify by checking that any found root is NOT inside tmp. + if let Some(found) = result { + assert!(!found.starts_with(tmp.path())); + } + } + + #[test] + fn find_git_root_from_subdirectory() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + let deep = root.join("sub").join("deep"); + std::fs::create_dir_all(&deep).unwrap(); + assert_eq!(find_git_root(&deep), Some(root.to_path_buf())); + } + + #[test] + fn load_hint_files_single_at_cwd() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + // No .git → no git root discovery; only cwd is checked. + std::fs::write(cwd.join("AGENTS.md"), "cwd hints").unwrap(); + let result = load_hint_files_impl(cwd, None); + assert_eq!(result, "cwd hints"); + } + + #[test] + fn load_hint_files_git_root_and_cwd() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + std::fs::write(root.join("AGENTS.md"), "root hints").unwrap(); + let sub = root.join("sub"); + std::fs::create_dir(&sub).unwrap(); + std::fs::write(sub.join("AGENTS.md"), "sub hints").unwrap(); + let result = load_hint_files_impl(&sub, None); + // Root hints must come first. + assert!( + result.starts_with("root hints"), + "expected root hints first, got: {result:?}" + ); + assert!(result.contains("sub hints"), "missing sub hints"); + let root_pos = result.find("root hints").unwrap(); + let sub_pos = result.find("sub hints").unwrap(); + assert!(root_pos < sub_pos, "root hints should precede sub hints"); + } + + #[test] + fn load_hint_files_missing_files() { + let tmp = TempDir::new().unwrap(); + let result = load_hint_files_impl(tmp.path(), None); + assert_eq!(result, ""); + } + + #[test] + fn discover_skills_finds_across_dirs() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + + // Skill in .agents/skills/ + let agents_skill = cwd.join(".agents/skills/my-skill"); + std::fs::create_dir_all(&agents_skill).unwrap(); + std::fs::write( + agents_skill.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nSkill body here.\n", + ) + .unwrap(); + + // Skill in .goose/skills/ + let goose_skill = cwd.join(".goose/skills/other-skill"); + std::fs::create_dir_all(&goose_skill).unwrap(); + std::fs::write( + goose_skill.join("SKILL.md"), + "---\nname: other-skill\ndescription: Another skill\n---\nOther body.\n", + ) + .unwrap(); + + let skills = discover_skills_impl(cwd, None); + assert_eq!(skills.len(), 2); + let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"my-skill"), "missing my-skill"); + assert!(names.contains(&"other-skill"), "missing other-skill"); + // Paths should point to the SKILL.md files. + for skill in &skills { + assert!(skill.path.exists(), "path does not exist: {:?}", skill.path); + assert!(skill.path.ends_with("SKILL.md")); + } + } + + #[test] + fn discover_skills_dedup_by_name() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + + // Same name in .agents/skills/ (first) and .goose/skills/ (second) + let agents_skill = cwd.join(".agents/skills/shared"); + std::fs::create_dir_all(&agents_skill).unwrap(); + std::fs::write( + agents_skill.join("SKILL.md"), + "---\nname: shared\ndescription: from agents\n---\nAgents body.\n", + ) + .unwrap(); + + let goose_skill = cwd.join(".goose/skills/shared"); + std::fs::create_dir_all(&goose_skill).unwrap(); + std::fs::write( + goose_skill.join("SKILL.md"), + "---\nname: shared\ndescription: from goose\n---\nGoose body.\n", + ) + .unwrap(); + + let skills = discover_skills_impl(cwd, None); + assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); + assert_eq!( + skills[0].description, "from agents", + "first wins (.agents/)" + ); + // Path should point to the .agents/ version (first wins). + assert!(skills[0] + .path + .to_str() + .unwrap() + .contains(".agents/skills/shared")); + } + + #[test] + fn discover_skills_skips_missing_name() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + + let skill_dir = cwd.join(".agents/skills/no-name"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\ndescription: No name here\n---\nBody.\n", + ) + .unwrap(); + + let skills = discover_skills_impl(cwd, None); + assert!(skills.is_empty(), "entry without name should be skipped"); + } + + #[test] + fn build_hints_section_empty() { + let tmp = TempDir::new().unwrap(); + let (result, skills) = build_hints_section_impl(tmp.path(), None); + assert_eq!(result, ""); + assert!(skills.is_empty()); + } + + #[test] + fn build_hints_section_combined() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + + std::fs::write(cwd.join("AGENTS.md"), "Project-level hints.").unwrap(); + + let skill_dir = cwd.join(".agents/skills/buzz-cli"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: buzz-cli\ndescription: CLI reference for Buzz managed agents\n---\nUse `buzz` to manage agents.\n", + ) + .unwrap(); + + let (result, skills) = build_hints_section_impl(cwd, None); + + assert!( + result.contains("# Additional Instructions"), + "missing header" + ); + assert!(result.contains("## Project Hints"), "missing Project Hints"); + assert!( + result.contains("Project-level hints."), + "missing hints content" + ); + assert!( + result.contains("## Available Skills"), + "missing Available Skills" + ); + assert!( + result.contains("buzz-cli: CLI reference for Buzz managed agents"), + "missing skill bullet" + ); + // Body must NOT be inlined — lazy loading only. + assert!( + !result.contains("Use `buzz` to manage agents."), + "skill body must not be inlined in system prompt" + ); + // The load_skill instruction must be present. + assert!( + result.contains("load_skill"), + "missing load_skill instruction" + ); + // The old ### heading format must not appear. + assert!( + !result.contains("### buzz-cli"), + "skill body heading must not be inlined" + ); + // The returned skills list should contain the discovered skill. + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "buzz-cli"); + } + + #[test] + fn load_hint_files_global_loaded_first() { + let home = TempDir::new().unwrap(); + let cwd = TempDir::new().unwrap(); + std::fs::write(home.path().join("AGENTS.md"), "global hints").unwrap(); + std::fs::write(cwd.path().join("AGENTS.md"), "local hints").unwrap(); + let result = load_hint_files_impl(cwd.path(), Some(home.path())); + let global_pos = result.find("global hints").unwrap(); + let local_pos = result.find("local hints").unwrap(); + assert!( + global_pos < local_pos, + "global hints should precede local hints" + ); + } + + #[test] + fn load_hint_files_home_missing_agents_md() { + let home = TempDir::new().unwrap(); + let cwd = TempDir::new().unwrap(); + std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap(); + let result = load_hint_files_impl(cwd.path(), Some(home.path())); + assert_eq!(result, "local only"); + } + + #[test] + fn load_hint_files_no_home_dir() { + let cwd = TempDir::new().unwrap(); + std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap(); + let result = load_hint_files_impl(cwd.path(), None); + assert_eq!(result, "local only"); + } + + #[test] + fn load_hint_files_dedup_when_home_in_chain() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + std::fs::write(home.join("AGENTS.md"), "single load").unwrap(); + let result = load_hint_files_impl(home, Some(home)); + assert_eq!( + result.matches("single load").count(), + 1, + "AGENTS.md should be loaded exactly once when CWD is home" + ); + } + + #[test] + fn load_hint_files_dedup_when_home_is_git_root() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + std::fs::create_dir(home.join(".git")).unwrap(); + std::fs::write(home.join("AGENTS.md"), "root+home hints").unwrap(); + let sub = home.join("sub"); + std::fs::create_dir(&sub).unwrap(); + let result = load_hint_files_impl(&sub, Some(home)); + assert_eq!( + result.matches("root+home hints").count(), + 1, + "AGENTS.md should be loaded once when home is git root" + ); + } + + #[test] + fn discover_skills_global_skills_loaded() { + let home = TempDir::new().unwrap(); + let cwd = TempDir::new().unwrap(); + let skill_dir = home.path().join(".agents/skills/global-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n", + ) + .unwrap(); + let skills = discover_skills_impl(cwd.path(), Some(home.path())); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "global-skill"); + } + + #[test] + fn discover_skills_project_wins_over_global() { + let home = TempDir::new().unwrap(); + let cwd = TempDir::new().unwrap(); + + let project_skill = cwd.path().join(".agents/skills/shared"); + std::fs::create_dir_all(&project_skill).unwrap(); + std::fs::write( + project_skill.join("SKILL.md"), + "---\nname: shared\ndescription: from project\n---\nProject body.\n", + ) + .unwrap(); + + let global_skill = home.path().join(".agents/skills/shared"); + std::fs::create_dir_all(&global_skill).unwrap(); + std::fs::write( + global_skill.join("SKILL.md"), + "---\nname: shared\ndescription: from global\n---\nGlobal body.\n", + ) + .unwrap(); + + let skills = discover_skills_impl(cwd.path(), Some(home.path())); + assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); + assert_eq!( + skills[0].description, "from project", + "project-level should win over global" + ); + } + + #[test] + fn discover_skills_no_home_dir() { + let cwd = TempDir::new().unwrap(); + let skill_dir = cwd.path().join(".agents/skills/local"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: local\ndescription: Local skill\n---\nBody.\n", + ) + .unwrap(); + let skills = discover_skills_impl(cwd.path(), None); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "local"); + } + + #[test] + fn collect_supporting_files_finds_non_skill_md_files() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + // SKILL.md should be excluded. + std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); + // A references subdir with files. + let refs = skill_dir.join("references"); + std::fs::create_dir_all(&refs).unwrap(); + std::fs::write(refs.join("foo.md"), "foo").unwrap(); + std::fs::write(refs.join("bar.md"), "bar").unwrap(); + // A script at the top level. + std::fs::write(skill_dir.join("setup.sh"), "#!/bin/sh").unwrap(); + + let files = collect_supporting_files(skill_dir); + let names: Vec = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert!( + names.contains(&"foo.md".to_owned()), + "missing foo.md: {names:?}" + ); + assert!( + names.contains(&"bar.md".to_owned()), + "missing bar.md: {names:?}" + ); + assert!( + names.contains(&"setup.sh".to_owned()), + "missing setup.sh: {names:?}" + ); + assert!( + !names.contains(&"SKILL.md".to_owned()), + "SKILL.md should be excluded: {names:?}" + ); + } + + #[test] + fn collect_supporting_files_does_not_descend_into_nested_skills() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); + std::fs::write(skill_dir.join("helper.sh"), "#!/bin/sh").unwrap(); + + // A nested subdir that is itself a skill — should not be descended into. + let nested = skill_dir.join("nested-skill"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("SKILL.md"), "---\nname: nested\n---\n").unwrap(); + std::fs::write(nested.join("secret.md"), "should not appear").unwrap(); + + let files = collect_supporting_files(skill_dir); + let names: Vec = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert!( + names.contains(&"helper.sh".to_owned()), + "missing helper.sh: {names:?}" + ); + assert!( + !names.contains(&"secret.md".to_owned()), + "nested skill's files should not appear: {names:?}" + ); + } + + #[cfg(unix)] + #[test] + fn collect_supporting_files_skips_symlink_cycles() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path(); + std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); + + let refs = skill_dir.join("references"); + std::fs::create_dir_all(&refs).unwrap(); + let guide = refs.join("guide.md"); + std::fs::write(&guide, "guide").unwrap(); + std::os::unix::fs::symlink(&refs, refs.join("loop")).unwrap(); + + let files = collect_supporting_files(skill_dir); + assert_eq!(files, vec![guide]); + } + + #[test] + fn discover_skills_populates_supporting_files() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let skill_dir = cwd.join(".agents/skills/with-refs"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: with-refs\ndescription: Has refs\n---\nBody.\n", + ) + .unwrap(); + let refs = skill_dir.join("references"); + std::fs::create_dir_all(&refs).unwrap(); + std::fs::write(refs.join("guide.md"), "guide content").unwrap(); + + let skills = discover_skills_impl(cwd, None); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "with-refs"); + assert_eq!(skills[0].supporting_files.len(), 1); + assert!( + skills[0].supporting_files[0].ends_with("references/guide.md"), + "unexpected path: {:?}", + skills[0].supporting_files[0] + ); + } +} diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b679b9b5be..b86b67f901 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -21,7 +21,9 @@ //! * `keepalive` — see `agent.rs`. pub mod agent; +pub mod builtin; pub mod config; +pub mod hints; pub mod hooks; pub mod mesh; pub mod types; @@ -69,6 +71,8 @@ struct Session { /// the override takes effect "from the next prompt" and never mutates a /// turn already in flight (`buzz-agent/src/lib.rs:494-502`). pending_model: Option, + /// Skills discovered at session start; the bodies `load_skill` serves. + skills: Vec, /// Name of the MCP extension carrying `_Stop`/`_PostCompact`, if any. /// See [`crate::hooks`] for why we dispatch these ourselves. hook_extension: Option, @@ -93,7 +97,15 @@ async fn build_agent( cwd: &str, system_prompt: Option<&str>, mcp_servers: &[McpServerStdio], -) -> Result<(Arc, String, Option), AgentError> { +) -> Result< + ( + Arc, + String, + Option, + Vec, + ), + AgentError, +> { let session_manager = Arc::new(SessionManager::instance()); let permission_manager = PermissionManager::instance(); @@ -179,6 +191,44 @@ async fn build_agent( } } + // AGENTS.md hints + the skill index. `system_prompt_extras` are appended + // whether the base prompt came from the override or goose's template + // (`prompt_manager.rs:170-198`), so this survives `override_system_prompt`. + let (hints, skills) = crate::hints::build_hints_section(std::path::Path::new(cwd)); + if !hints.trim().is_empty() { + agent + .extend_system_prompt("buzz_hints".to_string(), hints) + .await; + } + + // `load_skill` runs in-process. The prompt carries only skill names and + // descriptions; the body is pulled on demand, which is the whole point of + // the index above. Registered as a goose *frontend* tool: goose advertises + // it, then hands the call back to us rather than dispatching it itself + // (`agent.rs:1167`). + if !skills.is_empty() { + let def = crate::builtin::load_skill_def(); + let tool = rmcp::model::Tool::new( + def.name.clone(), + def.description.clone(), + match def.input_schema { + serde_json::Value::Object(map) => std::sync::Arc::new(map), + _ => std::sync::Arc::new(serde_json::Map::new()), + }, + ); + let ext = ExtensionConfig::Frontend { + name: "buzz_builtin".to_string(), + description: String::new(), + tools: vec![tool], + instructions: None, + bundled: None, + available_tools: Vec::new(), + }; + if let Err(e) = agent.add_extension(ext, &session.id).await { + tracing::warn!("load_skill unavailable: {e}"); + } + } + let agent = Arc::new(agent); // Find the extension carrying `_Stop`, by asking rather than assuming the @@ -209,7 +259,7 @@ async fn build_agent( tracing::info!(extension = %ext, "lifecycle hooks available"); } - Ok((agent, session.id, hook_extension)) + Ok((agent, session.id, hook_extension, skills)) } /// Keep the model's hands off the lifecycle hooks. @@ -422,7 +472,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen .await; } - let (agent, goose_session_id, hook_extension) = + let (agent, goose_session_id, hook_extension, skills) = match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { Ok(v) => v, Err(e) => { @@ -464,6 +514,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen model_override: None, pending_model: None, hook_extension, + skills, accumulated_input_tokens: 0, accumulated_output_tokens: 0, }, @@ -558,7 +609,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire let cancel = CancellationToken::new(); // Single-flight per session, and capture the agent handle. - let (agent, goose_session_id, pending_model, hook_extension) = { + let (agent, goose_session_id, pending_model, hook_extension, skills) = { let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { return wire::send( @@ -588,6 +639,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire s.goose_session_id.clone(), s.pending_model.take(), s.hook_extension.clone(), + s.skills.clone(), ) }; @@ -626,6 +678,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire wire_tx, cancel, hook_extension.as_deref(), + &skills, ) .await; diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index de5e50b3a2..3b52cdc2f0 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -105,3 +105,61 @@ impl AgentError { } } } + +/// A tool definition advertised to the model. Used by [`crate::builtin`] for +/// the in-process `load_skill` tool. +#[derive(Debug, Clone)] +pub struct ToolDef { + pub name: String, + pub description: String, + pub input_schema: serde_json::Value, +} + +/// One piece of a tool result. +#[derive(Debug, Clone)] +pub enum ToolResultContent { + Text(String), + Image { data: String, mime_type: String }, +} + +impl ToolResultContent { + pub fn as_text_lossy(&self) -> String { + match self { + Self::Text(t) => t.clone(), + Self::Image { mime_type, .. } => format!("[image: {mime_type}]"), + } + } +} + +/// Result of an in-process tool call. +#[derive(Debug, Clone)] +pub struct ToolResult { + pub provider_id: String, + pub content: Vec, + pub is_error: bool, +} + +impl ToolResult { + pub fn text(&self) -> String { + self.content + .iter() + .map(ToolResultContent::as_text_lossy) + .collect::>() + .join("\n") + } +} + +/// Truncate `s` to at most `max` bytes without splitting a UTF-8 character. +/// +/// Lifted from the deleted `mcp.rs`; `hints` and `builtin` both cap what they +/// read off disk with it. +pub fn truncate_at_boundary(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut cut = max; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + &s[..cut] +} diff --git a/crates/buzz-agent/tests/skills.rs b/crates/buzz-agent/tests/skills.rs new file mode 100644 index 0000000000..af792cbbb6 --- /dev/null +++ b/crates/buzz-agent/tests/skills.rs @@ -0,0 +1,277 @@ +//! Proves `load_skill` and `AGENTS.md` hints survive the goose swap. +//! +//! Both were briefly lost in the port. `load_skill` is registered as a goose +//! *frontend* tool, which means goose advertises it to the model but refuses to +//! dispatch it — it returns "Frontend tool execution required" and waits for +//! the embedder to call `handle_tool_result` +//! (`goose/src/agents/agent.rs:1167`). If that wiring is wrong the turn does +//! not fail, it **hangs forever**, so the timeout in `await_response` is the +//! real assertion here. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Fake OpenAI provider that asks for `load_skill` on the first turn, then +/// answers normally. Sends every observed `system` prompt and tool list back. +fn spawn_provider() -> (String, mpsc::Receiver<(String, Vec)>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + + std::thread::spawn(move || { + let mut call_count = 0usize; + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + let mut len = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t + .strip_prefix("content-length: ") + .or_else(|| t.strip_prefix("Content-Length: ")) + { + len = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + use std::io::Read; + let _ = reader.read_exact(&mut body); + + let send = |stream: &mut std::net::TcpStream, ct: &str, payload: String| { + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + payload.len(), payload + ).as_bytes(), + ); + let _ = stream.flush(); + }; + + if request_line.contains("/models") { + send( + &mut stream, + "application/json", + json!({"object":"list","data":[{"id":"fake-model"}]}).to_string(), + ); + continue; + } + + if let Ok(req) = serde_json::from_slice::(&body) { + let system = req["messages"] + .as_array() + .and_then(|m| { + m.iter() + .find(|x| x["role"] == "system") + .and_then(|x| x["content"].as_str()) + .map(str::to_owned) + }) + .unwrap_or_default(); + let tools = req["tools"] + .as_array() + .map(|ts| { + ts.iter() + .filter_map(|t| t["function"]["name"].as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + let _ = tx.send((system, tools)); + } + + call_count += 1; + let sse = if call_count == 1 { + // Ask for the skill. If our frontend-tool plumbing is broken, + // goose never resolves this and the turn hangs. + let c = json!({ + "id":"c","object":"chat.completion.chunk","created":1,"model":"fake-model", + "choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{ + "index":0,"id":"call_1","type":"function", + "function":{"name":"load_skill","arguments":"{\"name\":\"widget-maker\"}"} + }]},"finish_reason":null}] + }); + let d = json!({ + "id":"c","object":"chat.completion.chunk","created":1,"model":"fake-model", + "choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}], + "usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7} + }); + format!("data: {c}\n\ndata: {d}\n\ndata: [DONE]\n\n") + } else { + let c = json!({ + "id":"c","object":"chat.completion.chunk","created":1,"model":"fake-model", + "choices":[{"index":0,"delta":{"role":"assistant","content":"Got the skill."},"finish_reason":null}] + }); + let d = json!({ + "id":"c","object":"chat.completion.chunk","created":1,"model":"fake-model", + "choices":[{"index":0,"delta":{},"finish_reason":"stop"}], + "usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7} + }); + format!("data: {c}\n\ndata: {d}\n\ndata: [DONE]\n\n") + }; + send(&mut stream, "text/event-stream", sse); + } + }); + + (format!("http://{addr}"), rx) +} + +struct Harness { + child: Child, + stdin: std::process::ChildStdin, + stdout: BufReader, + id: i64, +} + +impl Harness { + fn start(base_url: &str, home: &std::path::Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent")) + .env("BUZZ_AGENT_PROVIDER", "openai-compat") + .env("BUZZ_AGENT_MODEL", "fake-model") + .env("OPENAI_COMPAT_API_KEY", "k") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("cfg")) + .env("XDG_DATA_HOME", home.join("data")) + .env("GOOSE_DISABLE_KEYRING", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + id: 0, + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + self.id += 1; + let id = self.id; + writeln!( + self.stdin, + "{}", + json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}) + ) + .expect("write"); + self.stdin.flush().expect("flush"); + loop { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).expect("read"); + assert_ne!(n, 0, "agent closed stdout awaiting {method}"); + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + if msg.get("id").and_then(Value::as_i64) == Some(id) { + return msg; + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Build a workspace with an AGENTS.md and one skill. +fn workspace() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tmp"); + let root = dir.path(); + std::fs::write(root.join("AGENTS.md"), "Always ship the sprocket first.").unwrap(); + // hints.rs walks up to the git root; give it one so the walk terminates here. + std::fs::create_dir_all(root.join(".git")).unwrap(); + let skill = root.join(".agents/skills/widget-maker"); + std::fs::create_dir_all(&skill).unwrap(); + std::fs::write( + skill.join("SKILL.md"), + "---\nname: widget-maker\ndescription: Makes widgets to order.\n---\n\ + Step 1: measure. Step 2: cut the flange.", + ) + .unwrap(); + dir +} + +#[test] +fn agents_md_and_skill_index_reach_the_model_and_load_skill_resolves() { + let (base_url, seen) = spawn_provider(); + let home = tempfile::tempdir().expect("home"); + let ws = workspace(); + let mut h = Harness::start(&base_url, home.path()); + + let r = h.call("initialize", json!({"protocolVersion": 2})); + assert_eq!(r["result"]["agentInfo"]["name"], "buzz-agent"); + + let r = h.call( + "session/new", + json!({"cwd": ws.path().to_str().unwrap(), "mcpServers": [], + "systemPrompt": "You are Fizz."}), + ); + let sid = r["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {r}")) + .to_string(); + + // The turn only returns if load_skill was served. A broken frontend-tool + // path hangs here rather than erroring. + let r = h.call( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"use the widget skill"}]}), + ); + assert_eq!( + r["result"]["stopReason"], "end_turn", + "turn did not complete — load_skill was probably never resolved: {r}" + ); + + let mut systems = Vec::new(); + let mut tool_lists = Vec::new(); + while let Ok((sys, tools)) = seen.recv_timeout(Duration::from_millis(500)) { + systems.push(sys); + tool_lists.push(tools); + } + assert!(!systems.is_empty(), "provider was never called"); + + let first = &systems[0]; + assert!( + first.contains("Always ship the sprocket first."), + "AGENTS.md hints missing from system prompt:\n{first}" + ); + assert!( + first.contains("widget-maker") && first.contains("Makes widgets to order."), + "skill index (name + description) missing from system prompt:\n{first}" + ); + assert!( + !first.contains("Step 2: cut the flange."), + "skill BODY was inlined — the point of load_skill is that it is not:\n{first}" + ); + assert!( + tool_lists[0].iter().any(|t| t == "load_skill"), + "load_skill not advertised: {:?}", + tool_lists[0] + ); + assert!( + systems.len() >= 2, + "expected a second round after the tool result" + ); +} From 66d1bffc854c5aba03d1a799eb91ce3a1c097347 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 28 Jul 2026 15:23:28 +1000 Subject: [PATCH 20/20] chore(deny): allow MIT-0 for goose's borrow-or-share transitive dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Security job's cargo-deny licenses gate rejected MIT-0 (MIT No Attribution — OSI approved, strictly more permissive than MIT), newly pulled in via goose → jsonschema → referencing → fluent-uri → borrow-or-share. Signed-off-by: Michael Neale --- deny.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deny.toml b/deny.toml index d3c5fcd4bc..8c7e679ed9 100644 --- a/deny.toml +++ b/deny.toml @@ -41,6 +41,10 @@ allow = [ # bzip2/libbzip2's permissive BSD-like license. New via desktop zip/bzip2 # transitive deps; compatible with Apache-2.0 distribution. "bzip2-1.0.6", + # MIT No Attribution — MIT minus the attribution requirement; OSI approved. + # New via the goose dep: goose → jsonschema → referencing → fluent-uri → + # borrow-or-share. + "MIT-0", ] confidence-threshold = 0.8