From 2527efac3fcdbfcbb4ba3ec189f4c747b42595a2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 27 Jul 2026 15:03:43 +1000 Subject: [PATCH 01/51] 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 3499285f917..a72b97f5d50 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 00000000000..2f7896d1d13 --- /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 00000000000..e88e70b005d --- /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 00000000000..c7fe9cc4080 --- /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 00000000000..a1732aad230 --- /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 00000000000..5eeac6acde5 --- /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 00000000000..4e12339b86b --- /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 00000000000..3b2c49e90fa --- /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 00000000000..de5e50b3a29 --- /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 00000000000..7b50e7982aa --- /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 00000000000..1d19f8b17cc --- /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/51] 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 e88e70b005d..eeb3a8f8ba2 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 c7fe9cc4080..fdfc62bc92c 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 4e12339b86b..e7eccce5b52 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 1d19f8b17cc..cd985d4731c 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/51] 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 e7eccce5b52..bb8b94c2d9f 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 cd985d4731c..c2a46860ba0 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/51] 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 eeb3a8f8ba2..a4d07d7b4f4 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 fdfc62bc92c..8e32304b429 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 a1732aad230..8a29a5eced8 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 00000000000..6fdb8db5be9 --- /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 bb8b94c2d9f..fbad500847a 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 00000000000..0bbd1d3478b --- /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 00000000000..42b56b7d9fb --- /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/51] 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 8a29a5eced8..82817cd1a08 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 0bbd1d3478b..0275fff619e 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 00000000000..0ddb40e7794 --- /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/51] 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 00000000000..734145b8475 --- /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/51] 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 82817cd1a08..f3ece951985 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 00000000000..a811f6af9d5 --- /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/51] 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 00000000000..335bf10de84 --- /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/51] 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 5eeac6acde5..58045fbd314 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 fbad500847a..690ef41678c 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/51] 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 8e32304b429..5c348783c8f 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 a811f6af9d5..601db76e9c1 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 734145b8475..4f9d90f0ef3 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 0ddb40e7794..b64308cedf2 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 c2a46860ba0..4f3983c4493 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 335bf10de84..6328f0afd46 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 42b56b7d9fb..cdfdf061fd1 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/51] 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 00000000000..b24f722313e --- /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/51] 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 9d0190868de..5b1894467b5 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 a72b97f5d50..7cbf64d9f6d 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 2f7896d1d13..00000000000 --- 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 a4d07d7b4f4..00000000000 --- 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 5c348783c8f..00000000000 --- 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 f3ece951985..00000000000 --- 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 58045fbd314..00000000000 --- 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 690ef41678c..00000000000 --- 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 3b2c49e90fa..00000000000 --- 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 de5e50b3a29..00000000000 --- 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 7b50e7982aa..00000000000 --- 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 0275fff619e..00000000000 --- 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 7889ad34a75..b2493003607 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 730e87b2e87..f3ece951985 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 9b604766d42..00000000000 --- 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 f3464fb9039..add5684bbaa 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 9c27c6606d4..00000000000 --- 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 9fb99f0ebcf..00000000000 --- 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 e141b9860f4..0e7a7e26a48 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 23cef24e72a..00000000000 --- 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 fa8815df50a..00000000000 --- 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 d29e975e03b..de5e50b3a29 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 0bbd1d3478b..0275fff619e 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 52acee1076e..00000000000 --- 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 f2a86ac4b3b..00000000000 --- 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 4ac35034646..00000000000 --- 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 63a55514dbe..00000000000 --- 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 f85a52bfaa1..00000000000 --- 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 4f9d90f0ef3..85313d64526 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 2e0b579c849..00000000000 --- 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 00000000000..12a17c3f2e0 --- /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 aa2a121c996..30200320768 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 00000000000..fe8069848b8 --- /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 00000000000..1b4b7967697 --- /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 00000000000..d3c886ddda5 --- /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 e0732f62dc4..1929cf0588f 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 d689544688d..84f68a42037 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/51] 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 b24f722313e..8661798b202 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/51] 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 5b1894467b5..03fa86e2b1f 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 b2493003607..f5a50ad0654 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 add5684bbaa..f0cce6262dc 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 0e7a7e26a48..1be7aa126dc 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/51] 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 8661798b202..20b4c5d44f6 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 f0cce6262dc..683698c5506 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 1be7aa126dc..d46d5c40257 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 00000000000..56d276bdebb --- /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 00000000000..b13b287e784 --- /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/51] 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 56d276bdebb..dc045d5cc55 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/51] 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 dc045d5cc55..2ff88268b16 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/51] =?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 3f61bac5b28..e947ccaf222 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 f3ece951985..be0fdc0733f 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 683698c5506..8cd0310962c 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 d46d5c40257..b679b9b5be5 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/51] 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 be0fdc0733f..ca618a74210 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 00000000000..95e8487611c --- /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 00000000000..90833aa3dbf --- /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 b679b9b5be5..b86b67f9015 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 de5e50b3a29..3b52cdc2f08 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 00000000000..af792cbbb67 --- /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/51] 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 d3c5fcd4bc7..8c7e679ed95 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 From 3a7ffc07af94c2e75718853fd9164413a9c69270 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 31 Jul 2026 16:20:23 +1000 Subject: [PATCH 21/51] refactor(buzz-agent): dispatch load_skill as a platform extension, not a frontend tool Adopts the shape Maple uses for its in-process tools (MapleDeveloperClient, SkillsClient): an `McpClientTrait` impl registered via `extension_manager.add_client` with an `ExtensionConfig::Platform`, rather than `ExtensionConfig::Frontend`. Goose advertises frontend tools but refuses to dispatch them. It yields a `FrontendToolRequest` and blocks the reply stream until the embedder calls `handle_tool_result` -- strictly sequential, no timeout, and a single result channel with no request-id correlation, so one missed or duplicated result wedges the session for good and the cancel token will not free it. That is a failure mode with no upside here. A platform client goes through goose's ordinary tool path and gets concurrency, per-request timeouts and `notifications/cancelled` for free. Net effect is less code: `serve_frontend_tool` is gone, and with it the `skills` parameter threaded through run_turn -> drive_stream -> handle_event and the `Session.skills` field. The BuiltinClient owns the skill list. Also fixes a real mismatch the swap exposed. Goose namespaces platform tools as `{extension}__{tool}` (`extension_manager.rs:1415`), so the model sees `buzz__load_skill`, but the skills section of the system prompt told it to call `load_skill`. The prompt now derives the name from the same constants the registration uses, so the two cannot drift. 89 tests green, fmt + clippy -D warnings clean. Signed-off-by: Michael Neale --- crates/buzz-agent/src/agent.rs | 84 +---------------- crates/buzz-agent/src/builtin_client.rs | 119 ++++++++++++++++++++++++ crates/buzz-agent/src/hints.rs | 12 ++- crates/buzz-agent/src/lib.rs | 64 +++++-------- crates/buzz-agent/tests/skills.rs | 18 ++-- 5 files changed, 161 insertions(+), 136 deletions(-) create mode 100644 crates/buzz-agent/src/builtin_client.rs diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index ca618a74210..4af2bdb90b9 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -20,9 +20,9 @@ //! * **`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. 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). +//! `build_agent` registers `load_skill` as a goose *platform* extension +//! backed by an `McpClientTrait` (see [`crate::builtin_client`]), so goose +//! dispatches it on its ordinary tool path. //! * **`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 @@ -140,7 +140,6 @@ 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)); @@ -161,7 +160,6 @@ pub async fn run_turn( &cancel, &mut tokens, hook_extension, - skills, ) .await { @@ -226,7 +224,6 @@ 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(), @@ -308,7 +305,6 @@ async fn drive_stream( &mut compacted, &mut reflections, &mut open_tool_calls, - skills, ) .await { @@ -397,7 +393,6 @@ 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) => { @@ -408,17 +403,6 @@ 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); @@ -585,68 +569,6 @@ 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_client.rs b/crates/buzz-agent/src/builtin_client.rs new file mode 100644 index 00000000000..0324d3e4802 --- /dev/null +++ b/crates/buzz-agent/src/builtin_client.rs @@ -0,0 +1,119 @@ +//! Buzz's in-process tools, exposed to Goose as a platform extension. +//! +//! Goose has two ways to take a tool the embedder implements in Rust: +//! +//! * [`ExtensionConfig::Frontend`] — Goose *advertises* the tool but refuses to +//! run it. It returns "Frontend tool execution required" and blocks until the +//! embedder calls `handle_tool_result`. Dispatch is strictly sequential, has +//! no timeout, and the result channel carries no request id, so a single +//! missed result wedges the agent for the rest of the session and the cancel +//! token will not free it. +//! * [`ExtensionConfig::Platform`] + `extension_manager.add_client` with an +//! [`McpClientTrait`] — Goose dispatches it through the ordinary tool path, +//! so concurrency, per-request timeouts and `notifications/cancelled` all +//! work, and there is nothing for us to correlate. +//! +//! This uses the second. It is the shape Maple settled on for its own +//! in-process developer and skills tools. +//! +//! The only tool here is `load_skill`. The system prompt carries the skill +//! *index* (names and descriptions, from [`crate::hints`]); bodies are pulled +//! on demand so a large skill library costs nothing per turn. + +use std::sync::Arc; + +use async_trait::async_trait; +use goose::agents::mcp_client::McpClientTrait; +use goose::agents::{ExtensionConfig, ToolCallContext}; +use rmcp::model::{CallToolResult, Content, InitializeResult, JsonObject, ListToolsResult, Tool}; +use rmcp::service::ServiceError as Error; +use tokio_util::sync::CancellationToken; + +use crate::hints::SkillEntry; + +/// Extension key and display name. Goose prefixes tool names with the +/// extension name (`{extension}__{tool}`) unless the extension is registered +/// as unprefixed, so this shows up in the model's tool list. +pub const BUILTIN_EXTENSION_NAME: &str = "buzz"; + +/// Goose-side declaration for the extension backed by [`BuiltinClient`]. +pub fn builtin_extension_config() -> ExtensionConfig { + ExtensionConfig::Platform { + name: BUILTIN_EXTENSION_NAME.to_string(), + description: "Buzz built-in tools".to_string(), + display_name: Some("Buzz".to_string()), + bundled: Some(true), + available_tools: vec![crate::builtin::LOAD_SKILL_TOOL.to_string()], + } +} + +/// In-process MCP client serving [`crate::builtin`]'s tools. +pub struct BuiltinClient { + skills: Vec, +} + +impl BuiltinClient { + pub fn new(skills: Vec) -> Self { + Self { skills } + } + + fn load_skill_tool() -> Tool { + let def = crate::builtin::load_skill_def(); + let schema = match def.input_schema { + serde_json::Value::Object(map) => Arc::new(map), + _ => Arc::new(serde_json::Map::new()), + }; + Tool::new(def.name, def.description, schema) + } +} + +#[async_trait] +impl McpClientTrait for BuiltinClient { + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: vec![Self::load_skill_tool()], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _ctx: &ToolCallContext, + name: &str, + arguments: Option, + _cancel_token: CancellationToken, + ) -> Result { + if name != crate::builtin::LOAD_SKILL_TOOL { + return Ok(CallToolResult::error(vec![Content::text(format!( + "unknown tool: {name}" + ))])); + } + + let args = arguments + .map(serde_json::Value::Object) + .unwrap_or(serde_json::Value::Null); + let result = crate::builtin::call_load_skill(&args, &self.skills).await; + + let content: Vec = result + .content + .iter() + .map(|c| Content::text(c.as_text_lossy())) + .collect(); + + Ok(if result.is_error { + CallToolResult::error(content) + } else { + CallToolResult::success(content) + }) + } + + fn get_info(&self) -> Option<&InitializeResult> { + None + } +} diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs index 90833aa3dbf..cedb5872af8 100644 --- a/crates/buzz-agent/src/hints.rs +++ b/crates/buzz-agent/src/hints.rs @@ -241,9 +241,15 @@ fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec, - /// 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, @@ -97,15 +96,7 @@ async fn build_agent( cwd: &str, system_prompt: Option<&str>, mcp_servers: &[McpServerStdio], -) -> Result< - ( - Arc, - String, - Option, - Vec, - ), - AgentError, -> { +) -> Result<(Arc, String, Option), AgentError> { let session_manager = Arc::new(SessionManager::instance()); let permission_manager = PermissionManager::instance(); @@ -201,32 +192,24 @@ async fn build_agent( .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`). + // `load_skill` runs in-process, registered the way Maple does it: a + // platform extension backed by an `McpClientTrait`, NOT + // `ExtensionConfig::Frontend`. Goose advertises frontend tools but refuses + // to dispatch them -- it blocks on an uncorrelated result channel until the + // embedder calls `handle_tool_result`, with no timeout and no cancellation, + // so one missed result wedges the session. A platform client goes through + // goose's ordinary tool path instead. See `crate::builtin_client`. 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}"); - } + agent + .extension_manager + .add_client( + crate::builtin_client::BUILTIN_EXTENSION_NAME.to_string(), + crate::builtin_client::builtin_extension_config(), + std::sync::Arc::new(crate::builtin_client::BuiltinClient::new(skills)), + None, + None, + ) + .await; } let agent = Arc::new(agent); @@ -259,7 +242,7 @@ async fn build_agent( tracing::info!(extension = %ext, "lifecycle hooks available"); } - Ok((agent, session.id, hook_extension, skills)) + Ok((agent, session.id, hook_extension)) } /// Keep the model's hands off the lifecycle hooks. @@ -472,7 +455,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen .await; } - let (agent, goose_session_id, hook_extension, skills) = + 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) => { @@ -514,7 +497,6 @@ 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, }, @@ -609,7 +591,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, skills) = { + 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( @@ -639,7 +621,6 @@ 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(), ) }; @@ -678,7 +659,6 @@ 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/tests/skills.rs b/crates/buzz-agent/tests/skills.rs index af792cbbb67..2de2a91dce1 100644 --- a/crates/buzz-agent/tests/skills.rs +++ b/crates/buzz-agent/tests/skills.rs @@ -1,12 +1,10 @@ //! 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. +//! *platform* extension backed by an `McpClientTrait` (the shape Maple uses), +//! so goose dispatches it on its ordinary tool path. Goose namespaces such +//! tools as `{extension}__{tool}`, which the system prompt has to match — a +//! prompt naming a tool that is not in the tool list is a silent failure. use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; @@ -96,13 +94,13 @@ fn spawn_provider() -> (String, mpsc::Receiver<(String, Vec)>) { call_count += 1; let sse = if call_count == 1 { - // Ask for the skill. If our frontend-tool plumbing is broken, + // Ask for the skill by its namespaced name. // 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\"}"} + "function":{"name":"buzz__load_skill","arguments":"{\"name\":\"widget-maker\"}"} }]},"finish_reason":null}] }); let d = json!({ @@ -233,7 +231,7 @@ fn agents_md_and_skill_index_reach_the_model_and_load_skill_resolves() { .unwrap_or_else(|| panic!("session/new failed: {r}")) .to_string(); - // The turn only returns if load_skill was served. A broken frontend-tool + // The turn only returns if load_skill was dispatched. A broken tool // path hangs here rather than erroring. let r = h.call( "session/prompt", @@ -266,7 +264,7 @@ fn agents_md_and_skill_index_reach_the_model_and_load_skill_resolves() { "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"), + tool_lists[0].iter().any(|t| t == "buzz__load_skill"), "load_skill not advertised: {:?}", tool_lists[0] ); From 80a9f9a3602cd17015e83b389912af92bf149a80 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 14:14:14 +1000 Subject: [PATCH 22/51] chore(buzz-agent): bump goose pin to bf332b9 (unrolled loop) and fix rmcp 3.x breaks Moves the goose git pin from 305849b to bf332b9, which is past ca52cce (#9574, the unrolled agent loop). Consequences of the bump: - goose now uses rmcp 3.x; buzz-agent's own rmcp dep moves 1 -> 3 so the two do not resolve to different crate versions of the same types. - rmcp 3 renames Content -> ContentBlock and adds fields to ListToolsResult; builtin_client.rs updated to match. - buzz-model-catalog needs an explicit dirs dep after the merge. - TurnTotalState / PricingIdentity, added on main while this branch was away, ported back into types.rs for wire.rs's usage_update_payload. cargo check -p buzz-agent passes. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- Cargo.lock | 1190 ++++++++++++++++++++--- crates/buzz-agent/Cargo.toml | 6 +- crates/buzz-agent/src/builtin_client.rs | 11 +- crates/buzz-agent/src/types.rs | 102 ++ crates/buzz-model-catalog/Cargo.toml | 1 + 5 files changed, 1156 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8fb990b9d1d..dd6bc03d261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,7 +225,7 @@ checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7" dependencies = [ "arrayvec", "aws-lc-rs", - "base64", + "base64 0.22.1", "byteorder", "minicbor", "rustls-pki-types", @@ -263,7 +263,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -568,13 +568,23 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic-write-file" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae67d5c03ee972c101a1d8db3ddbf8a9c819ad1ea9d6fd9cd385605547b60edb" +dependencies = [ + "nix 0.31.3", + "rand 0.10.2", +] + [[package]] name = "attohttpc" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.0", "log", "rustls", @@ -708,7 +718,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -806,12 +816,24 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -834,6 +856,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -1010,7 +1038,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1077,7 +1105,7 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", @@ -1138,8 +1166,9 @@ dependencies = [ "arc-swap", "async-trait", "axum", - "base64", + "base64 0.22.1", "buzz-model-catalog", + "dirs", "futures", "getrandom 0.4.3", "goose", @@ -1148,7 +1177,7 @@ dependencies = [ "idna_adapter", "nix 0.31.3", "reqwest 0.13.4", - "rmcp", + "rmcp 3.1.2", "serde", "serde_json", "serde_yaml", @@ -1158,6 +1187,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "url", "urlencoding", "webbrowser", ] @@ -1187,7 +1217,7 @@ dependencies = [ "buzz-core", "hex", "nostr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -1198,12 +1228,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-backend-kubernetes" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "http 1.4.0", + "http-body-util", + "k8s-openapi", + "kube", + "nostr", + "rand 0.10.2", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tower", +] + [[package]] name = "buzz-cli" version = "0.1.0" dependencies = [ "axum", - "base64", + "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", @@ -1216,7 +1266,7 @@ dependencies = [ "hex", "infer", "nostr", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rustls", "serde", @@ -1244,13 +1294,13 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", "nostr", "percent-encoding", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -1271,7 +1321,7 @@ dependencies = [ "metrics", "metrics-util", "nostr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -1286,7 +1336,7 @@ dependencies = [ name = "buzz-dev-mcp" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "buzz-cli", "buzz-core", "git-credential-nostr", @@ -1296,7 +1346,7 @@ dependencies = [ "nix 0.31.3", "nostr", "reqwest 0.13.4", - "rmcp", + "rmcp 1.8.0", "rustls", "schemars 1.2.1", "serde", @@ -1347,7 +1397,8 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", + "dirs", "getrandom 0.4.3", "hex", "reqwest 0.13.4", @@ -1433,7 +1484,7 @@ dependencies = [ "appattest", "async-trait", "axum", - "base64", + "base64 0.22.1", "byteorder", "chrono", "getrandom 0.4.3", @@ -1444,7 +1495,7 @@ dependencies = [ "nostr", "p256", "proptest", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "serde", "serde_json", @@ -1462,13 +1513,13 @@ dependencies = [ [[package]] name = "buzz-relay" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "async-compression", "async-trait", "axum", - "base64", + "base64 0.22.1", "buzz-audit", "buzz-auth", "buzz-conformance", @@ -1479,11 +1530,13 @@ dependencies = [ "buzz-relay-mesh", "buzz-sdk", "buzz-search", + "buzz-test-client", "buzz-workflow", "bytes", "chrono", "dashmap", "deadpool-redis", + "ed25519-dalek", "flate2", "futures", "futures-util", @@ -1502,7 +1555,7 @@ dependencies = [ "opentelemetry_sdk 0.32.1", "postcard", "pulldown-cmark", - "rand 0.10.1", + "rand 0.10.2", "redis", "reqwest 0.13.4", "rust-s3", @@ -1578,7 +1631,7 @@ name = "buzz-test-client" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "buzz-core", "buzz-media", "buzz-sdk", @@ -1587,7 +1640,7 @@ dependencies = [ "futures-util", "hex", "nostr", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rust-s3", "rustls", @@ -1604,6 +1657,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-voice" +version = "0.1.0" +dependencies = [ + "atomic-write-file", + "hex", + "ort", + "ort-sys", + "rand 0.10.2", + "sentencepiece-model", + "serde", + "serde_json", + "sha2 0.11.0", + "sherpa-onnx", + "symphonia", + "tempfile", + "tokenizers", +] + [[package]] name = "buzz-workflow" version = "0.1.0" @@ -1687,6 +1759,26 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "calendrical_calculations" version = "0.2.4" @@ -1923,7 +2015,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1964,6 +2056,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -2533,6 +2626,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core 0.24.0", + "darling_macro 0.24.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2560,6 +2663,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2582,6 +2698,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core 0.24.0", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -2619,7 +2755,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]] @@ -2856,7 +2992,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3103,7 +3239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3112,6 +3248,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etcetera" version = "0.8.0" @@ -3180,6 +3322,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -3190,6 +3338,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fancy-regex" version = "0.17.0" @@ -3579,8 +3738,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3679,7 +3838,7 @@ dependencies = [ name = "git-credential-nostr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "nostr", "serde_json", "zeroize", @@ -3689,7 +3848,7 @@ dependencies = [ name = "git-sign-nostr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "libc", @@ -3745,8 +3904,8 @@ dependencies = [ [[package]] name = "goose" -version = "1.44.0" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +version = "1.45.0" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -3758,7 +3917,7 @@ dependencies = [ "aws-lc-rs", "axum", "axum-server", - "base64", + "base64 0.23.1", "blake3", "chrono", "clap", @@ -3769,6 +3928,7 @@ dependencies = [ "futures", "gethostname", "goose-acp-macros", + "goose-context-management", "goose-download-manager", "goose-providers", "goose-sdk-types", @@ -3788,15 +3948,14 @@ dependencies = [ "oauth2", "once_cell", "pastey", - "pem", + "pem 4.0.0", "process-wrap", - "pulldown-cmark", - "rand 0.10.1", + "rand 0.10.2", "rayon", "rcgen", "regex", "reqwest 0.13.4", - "rmcp", + "rmcp 3.1.2", "rustls", "schemars 1.2.1", "serde", @@ -3846,17 +4005,33 @@ dependencies = [ [[package]] name = "goose-acp-macros" -version = "1.44.0" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +version = "1.45.0" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" dependencies = [ "quote", "syn 2.0.117", ] +[[package]] +name = "goose-context-management" +version = "0.1.0-alpha.5" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" +dependencies = [ + "anyhow", + "async-trait", + "goose-providers", + "include_dir", + "minijinja", + "rmcp 3.1.2", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "goose-download-manager" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" dependencies = [ "anyhow", "once_cell", @@ -3869,19 +4044,19 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" dependencies = [ "anyhow", "async-stream", "async-trait", - "base64", + "base64 0.23.1", "chrono", "futures", "once_cell", - "rand 0.10.1", + "rand 0.10.2", "regex", "reqwest 0.13.4", - "rmcp", + "rmcp 3.1.2", "serde", "serde_json", "strum 0.28.0", @@ -3895,7 +4070,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" dependencies = [ "anyhow", "async-stream", @@ -3905,7 +4080,7 @@ dependencies = [ "goose-provider-types", "include_dir", "reqwest 0.13.4", - "rmcp", + "rmcp 3.1.2", "serde", "serde_json", "tokio", @@ -3919,7 +4094,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose?rev=305849b71709b95b86ed9f11bd3bc939899c0aab#305849b71709b95b86ed9f11bd3bc939899c0aab" +source = "git+https://github.com/aaif-goose/goose?rev=bf332b983756eaf80353e82e4bb052d589629bf4#bf332b983756eaf80353e82e4bb052d589629bf4" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -4066,7 +4241,7 @@ version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" dependencies = [ - "base64", + "base64 0.22.1", "bon", "bytes", "futures", @@ -4127,7 +4302,7 @@ dependencies = [ "idna", "ipnet", "jni 0.22.4", - "rand 0.10.1", + "rand 0.10.2", "rustls", "thiserror 2.0.18", "tinyvec", @@ -4149,7 +4324,7 @@ dependencies = [ "jni 0.22.4", "once_cell", "prefix-trie", - "rand 0.10.1", + "rand 0.10.2", "ring", "thiserror 2.0.18", "tinyvec", @@ -4174,7 +4349,7 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "resolv-conf", "rustls", "smallvec", @@ -4351,6 +4526,7 @@ dependencies = [ "http 1.4.0", "hyper", "hyper-util", + "log", "rustls", "rustls-native-certs", "tokio", @@ -4394,7 +4570,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -4425,7 +4601,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4684,7 +4860,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.10.1", + "rand 0.10.2", "tokio", "url", "xmltree", @@ -4906,7 +5082,7 @@ dependencies = [ "pin-project", "portable-atomic", "portmapper", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rustc-hash", "rustls", @@ -4936,7 +5112,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.4.3", "n0-error", - "rand 0.10.1", + "rand 0.10.2", "serde", "url", "zeroize", @@ -4957,7 +5133,7 @@ dependencies = [ "n0-future", "ndk-context", "portable-atomic", - "rand 0.10.1", + "rand 0.10.2", "rustls", "simple-dns", "strum 0.28.0", @@ -5021,7 +5197,7 @@ dependencies = [ "num_enum", "pin-project", "postcard", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rustls", "rustls-pki-types", @@ -5172,6 +5348,19 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "jsonschema" version = "0.46.10" @@ -5210,15 +5399,15 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.4.0" +version = "11.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "getrandom 0.2.17", "js-sys", - "pem", + "pem 3.0.6", "serde", "serde_json", "signature 2.2.0", @@ -5226,6 +5415,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "k8s-openapi" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde_json", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -5271,6 +5472,70 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "kube" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem 3.0.6", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tracing", +] + +[[package]] +name = "kube-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" +dependencies = [ + "chrono", + "derive_more", + "form_urlencoded", + "http 1.4.0", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "lab" version = "0.11.0" @@ -5406,6 +5671,39 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -5465,6 +5763,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "matchers" version = "0.2.0" @@ -5480,6 +5794,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -5592,7 +5916,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "crypto_box", "ed25519-dalek", @@ -5605,8 +5929,8 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", - "rand 0.10.1", + "prost 0.14.3", + "rand 0.10.2", "rustls", "serde", "serde_json", @@ -5694,7 +6018,7 @@ dependencies = [ "argon2", "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "chacha20poly1305", "chrono", @@ -5743,11 +6067,11 @@ dependencies = [ "opentelemetry 0.31.0", "opentelemetry-otlp 0.31.1", "opentelemetry_sdk 0.31.0", - "prost", - "rand 0.10.1", + "prost 0.14.3", + "rand 0.10.2", "regex-lite", "reqwest 0.12.28", - "rmcp", + "rmcp 1.8.0", "rpassword", "rustls", "schemars 1.2.1", @@ -5784,7 +6108,7 @@ version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", - "base64", + "base64 0.22.1", "chacha20poly1305", "chrono", "crypto_box", @@ -5792,7 +6116,7 @@ dependencies = [ "ed25519-dalek", "hex", "keyring", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.10.9", @@ -5832,10 +6156,10 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", - "rmcp", + "rmcp 1.8.0", "schemars 1.2.1", "serde", "serde_json", @@ -5869,7 +6193,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.3", "serde_json", "sha2 0.10.9", ] @@ -6000,7 +6324,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "http-body-util", "hyper", @@ -6045,7 +6369,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" [[package]] -name = "mime" +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" @@ -6199,6 +6545,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -6314,6 +6682,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -6517,7 +6900,7 @@ dependencies = [ "getrandom 0.4.3", "identity-hash", "lru-slab", - "rand 0.10.1", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -6550,7 +6933,7 @@ version = "0.44.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" dependencies = [ - "base64", + "base64 0.22.1", "bech32", "bip39", "bitcoin_hashes", @@ -6636,7 +7019,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6800,7 +7183,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -7109,7 +7492,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -7124,7 +7507,7 @@ dependencies = [ "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "thiserror 2.0.18", "tokio", "tonic", @@ -7137,11 +7520,11 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -7156,7 +7539,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -7200,6 +7583,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -7238,6 +7630,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -7374,7 +7784,17 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", "serde_core", ] @@ -7444,6 +7864,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -7627,7 +8057,7 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ - "base64", + "base64 0.22.1", "indexmap 2.14.0", "quick-xml 0.39.4", "serde", @@ -7706,13 +8136,22 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "derive_more", "hyper-util", @@ -7723,7 +8162,7 @@ dependencies = [ "n0-future", "netwatch", "num_enum", - "rand 0.10.1", + "rand 0.10.2", "serde", "smallvec", "socket2", @@ -7884,6 +8323,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -7891,7 +8340,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -7904,15 +8373,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "regex", "syn 2.0.117", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -7926,13 +8408,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -7999,6 +8503,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -8115,7 +8646,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.1", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -8139,7 +8670,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8196,9 +8727,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20 0.10.0", "getrandom 0.4.3", @@ -8320,7 +8851,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -8383,7 +8914,7 @@ dependencies = [ "strum 0.28.0", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -8395,6 +8926,12 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -8405,6 +8942,17 @@ dependencies = [ "rayon-core", ] +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + [[package]] name = "rayon-core" version = "1.13.0" @@ -8422,7 +8970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", - "pem", + "pem 3.0.6", "rustls-pki-types", "time", "x509-parser", @@ -8576,7 +9124,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -8624,7 +9172,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cookie", "cookie_store", @@ -8717,22 +9265,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", "http 1.4.0", "http-body 1.0.1", "http-body-util", + "pastey", + "pin-project-lite", + "process-wrap", + "rand 0.10.2", + "reqwest 0.13.4", + "rmcp-macros 1.8.0", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8dddc5b1924b9a59fba420166160ca2c4663a4e01803e52eda33070f56d63c8" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http 1.4.0", + "http-body-util", "hyper", "hyper-util", "oauth2", "pastey", "pin-project-lite", "process-wrap", - "rand 0.10.1", "reqwest 0.13.4", - "rmcp-macros", + "rmcp-macros 3.1.2", "schemars 1.2.1", "serde", "serde_json", @@ -8741,7 +9320,6 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "tower-service", "tracing", "url", "uuid", @@ -8760,6 +9338,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rmcp-macros" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521" +dependencies = [ + "darling 0.24.0", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rpassword" version = "5.0.1" @@ -8809,7 +9400,7 @@ dependencies = [ "async-trait", "aws-creds", "aws-region", - "base64", + "base64 0.22.1", "bytes", "cfg-if 1.0.4", "futures-util", @@ -8882,7 +9473,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8941,7 +9532,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9154,6 +9745,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -9216,7 +9816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9231,6 +9831,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -9241,6 +9853,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -9345,7 +9967,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -9480,6 +10102,28 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227" +dependencies = [ + "bzip2", + "tar", + "ureq", +] + [[package]] name = "shlex" version = "1.3.0" @@ -9637,8 +10281,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "serde", ] @@ -9666,7 +10310,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "blake3", "clap", "futures-util", @@ -9796,6 +10440,18 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "sprig" version = "0.1.0" @@ -9837,7 +10493,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -9874,7 +10530,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if 1.0.4", "chrono", @@ -9990,7 +10646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "bytes", @@ -10061,7 +10717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "chrono", @@ -10099,7 +10755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "chrono", @@ -10116,7 +10772,7 @@ dependencies = [ "log", "md-5 0.11.0", "memchr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -10182,9 +10838,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", @@ -10292,6 +10948,164 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -10314,6 +11128,17 @@ dependencies = [ "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" @@ -10399,7 +11224,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -10429,7 +11254,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10473,7 +11298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "fancy-regex 0.11.0", "filedescriptor", @@ -10564,7 +11389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bstr", "fancy-regex 0.17.0", "lazy_static", @@ -10651,6 +11476,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -10712,7 +11570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40f644c762e9d396831ae2f8935c954b0d758c4532e924bead0f666d0c1c8640" dependencies = [ "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "tokio", ] @@ -10803,14 +11661,14 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", "getrandom 0.4.3", "http 1.4.0", "httparse", - "rand 0.10.1", + "rand 0.10.2", "ring", "rustls-pki-types", "sha1_smol", @@ -10904,7 +11762,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http 1.4.0", @@ -10933,7 +11791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -10943,8 +11801,8 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "tonic", ] @@ -10974,6 +11832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", + "base64 0.22.1", "bitflags 2.13.0", "bytes", "futures-core", @@ -11377,6 +12236,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -11397,9 +12265,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -11412,6 +12286,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" @@ -11440,6 +12320,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -11940,7 +12836,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12491,8 +13387,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -12606,7 +13502,7 @@ checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "clap", "crc32fast", @@ -12615,7 +13511,7 @@ dependencies = [ "hyper", "lazy_static", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "redb", "reqwest 0.13.4", "reqwest-middleware", @@ -12643,7 +13539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "blake3", "bytemuck", "bytes", @@ -12658,7 +13554,7 @@ dependencies = [ "lazy_static", "lz4_flex", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "regex", "safe-transmute", "serde", @@ -12689,7 +13585,7 @@ dependencies = [ "itertools", "lazy_static", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.10.9", @@ -12729,7 +13625,7 @@ dependencies = [ "more-asserts", "oneshot", "pin-project", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "serde", "serde_json", diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 20ae2426276..d764c812571 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -29,8 +29,8 @@ path = "tests/bin/fake_mcp.rs" # 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" } +goose = { git = "https://github.com/aaif-goose/goose", rev = "bf332b983756eaf80353e82e4bb052d589629bf4", default-features = false, features = ["rustls-tls"] } +goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "bf332b983756eaf80353e82e4bb052d589629bf4" } # 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 @@ -46,7 +46,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls", "form"] } -rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] } +rmcp = { version = "3", default-features = false, features = ["client", "transport-child-process"] } arc-swap = "1" getrandom = "0.4" tracing = { workspace = true } diff --git a/crates/buzz-agent/src/builtin_client.rs b/crates/buzz-agent/src/builtin_client.rs index 0324d3e4802..03244219d21 100644 --- a/crates/buzz-agent/src/builtin_client.rs +++ b/crates/buzz-agent/src/builtin_client.rs @@ -25,7 +25,9 @@ use std::sync::Arc; use async_trait::async_trait; use goose::agents::mcp_client::McpClientTrait; use goose::agents::{ExtensionConfig, ToolCallContext}; -use rmcp::model::{CallToolResult, Content, InitializeResult, JsonObject, ListToolsResult, Tool}; +use rmcp::model::{ + CallToolResult, ContentBlock, InitializeResult, JsonObject, ListToolsResult, Tool, +}; use rmcp::service::ServiceError as Error; use tokio_util::sync::CancellationToken; @@ -79,6 +81,7 @@ impl McpClientTrait for BuiltinClient { tools: vec![Self::load_skill_tool()], next_cursor: None, meta: None, + ..Default::default() }) } @@ -90,7 +93,7 @@ impl McpClientTrait for BuiltinClient { _cancel_token: CancellationToken, ) -> Result { if name != crate::builtin::LOAD_SKILL_TOOL { - return Ok(CallToolResult::error(vec![Content::text(format!( + return Ok(CallToolResult::error(vec![ContentBlock::text(format!( "unknown tool: {name}" ))])); } @@ -100,10 +103,10 @@ impl McpClientTrait for BuiltinClient { .unwrap_or(serde_json::Value::Null); let result = crate::builtin::call_load_skill(&args, &self.skills).await; - let content: Vec = result + let content: Vec = result .content .iter() - .map(|c| Content::text(c.as_text_lossy())) + .map(|c| ContentBlock::text(c.as_text_lossy())) .collect(); Ok(if result.is_error { diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 3b52cdc2f08..83b23777f6c 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -163,3 +163,105 @@ pub fn truncate_at_boundary(s: &str, max: usize) -> &str { } &s[..cut] } + +/// Publisher-side billing identity for a turn. +/// +/// Mirrors `buzz_core::agent_turn_metric::PricingIdentity` but is local to +/// `buzz-agent` (which does not depend on `buzz-core`). The two structs have +/// the same camelCase wire representation and are deserialized identically by +/// `buzz-acp`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PricingIdentity { + /// Registered billing-namespace identifier (bare lowercase hostname). + pub authority: String, + /// Actually-requested billable model identifier. + pub model: String, + /// Cache-write class when applicable; omitted otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_class: Option, +} + +/// Tri-state accumulator for provider-reported total tokens within one ACP turn. +/// +/// - `Unseen`: no usage-bearing response observed yet (initial state for each turn). +/// - `Exact(n)`: every response so far reported a total; `n` is their sum. +/// - `Unknown`: at least one response lacked a total — permanently poisoned for +/// this turn. The session-cumulative also transitions to Unknown when any turn +/// lands Unknown, and stays there until a new session resets it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TurnTotalState { + #[default] + Unseen, + Exact(u64), + Unknown, +} + +impl TurnTotalState { + /// Add two exact token counts with overflow protection. + /// + /// Returns `Exact(acc + n)` on success or `Unknown` on overflow. + /// This is the single implementation of the checked-add / overflow-poisons + /// contract; both `fold()` and `merge_session()` call this helper so a + /// change to overflow semantics needs to be made in exactly one place. + fn checked_exact_sum(acc: u64, n: u64) -> TurnTotalState { + match acc.checked_add(n) { + Some(sum) => TurnTotalState::Exact(sum), + None => TurnTotalState::Unknown, + } + } + + /// Fold one provider-reported total into the current state. + /// + /// `total`: `Some(n)` when the provider included a genuine total on this + /// response; `None` when it was absent (e.g. Anthropic, or an OpenAI + /// response that omits usage). Absence of a total on any usage-bearing + /// response poisons the whole turn. + /// + /// Overflow is handled by `checked_exact_sum`: a saturated value would + /// not be a genuine provider-reported total, so overflow → `Unknown`. + pub fn fold(self, total: Option) -> TurnTotalState { + match (self, total) { + // Already poisoned — stays Unknown regardless. + (TurnTotalState::Unknown, _) => TurnTotalState::Unknown, + // No total from this response — poison the accumulator. + (_, None) => TurnTotalState::Unknown, + // First response with a total. + (TurnTotalState::Unseen, Some(n)) => TurnTotalState::Exact(n), + // Subsequent response — delegate to the shared checked-sum helper. + (TurnTotalState::Exact(acc), Some(n)) => Self::checked_exact_sum(acc, n), + } + } + + /// Merge a completed turn's total state into the session-cumulative state. + /// + /// This is the turn→session boundary accumulation: + /// - An `Unseen` turn (no usage-bearing responses) leaves the cumulative unchanged. + /// - Any `Unknown` side poisons the session permanently. + /// - Two `Exact` values are summed via `checked_exact_sum`; overflow → `Unknown`. + /// + /// The checked-add logic lives in `checked_exact_sum`; both this function and + /// `fold()` call that helper so overflow semantics are defined once. + pub fn merge_session(self, turn: TurnTotalState) -> TurnTotalState { + match (self, turn) { + // Either side poisoned → session is poisoned. + (TurnTotalState::Unknown, _) | (_, TurnTotalState::Unknown) => TurnTotalState::Unknown, + // Turn had no usage-bearing responses → no change to cumulative. + (acc, TurnTotalState::Unseen) => acc, + // First exact turn — adopt its value. + (TurnTotalState::Unseen, TurnTotalState::Exact(n)) => TurnTotalState::Exact(n), + // Add to running exact sum — delegate to the shared checked-sum helper. + (TurnTotalState::Exact(acc), TurnTotalState::Exact(n)) => { + Self::checked_exact_sum(acc, n) + } + } + } + + /// Consume the exact value if present; `None` for `Unseen` or `Unknown`. + pub fn exact_value(self) -> Option { + match self { + TurnTotalState::Exact(n) => Some(n), + _ => None, + } + } +} diff --git a/crates/buzz-model-catalog/Cargo.toml b/crates/buzz-model-catalog/Cargo.toml index 12a17c3f2e0..13e5d3dbebb 100644 --- a/crates/buzz-model-catalog/Cargo.toml +++ b/crates/buzz-model-catalog/Cargo.toml @@ -33,6 +33,7 @@ urlencoding = "2" webbrowser = "1" hex = { workspace = true } getrandom = "0.4" +dirs = "6" [dev-dependencies] tempfile = "3" From 9869f24319cff0be8f6c8ce31eea5559a566d08d Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 14:50:05 +1000 Subject: [PATCH 23/51] refactor(buzz-agent): buzz owns the agent loop; goose supplies the parts Inverts the previous design. buzz-agent no longer calls goose::agents::Agent::reply -- it drives its own round loop and calls goose for the four heavy components: model call Provider::stream (via Agent::provider) tool surface Agent::list_tools tool execution Agent::dispatch_tool_call system prompt PromptManager (our own instance) compaction context_mgmt::{check_if_compaction_needed, compact_messages} Under Agent::reply every buzz-specific behaviour had to be smuggled in around goose's turn policy: the _Stop veto needed an outer loop purely to re-enter reply(), and [Reflect] had to be delivered as a *steer* because the tool result itself was out of reach. Owning the loop removes the smuggling -- the veto is a branch, and [Reflect] goes back on the tool result where buzz-agent originally put it and where the model reads it in context. New modules: loop_drive.rs the round loop: inference, tools, compaction, _Stop veto, steer drain, cancellation, max-rounds bound tools.rs parallel dispatch, announce->terminal wire invariant, [Reflect] on failure prompt.rs our PromptManager (goose's is pub(super) to Agent::reply) steer.rs our steer queue (goose's drain is pub(crate) to reply) agent.rs shrinks to what goose knows nothing about: ACP session/update emission and the keepalive ticker. Tool lifecycle now emits from tools.rs, which is the only place that knows when a call starts and ends -- emitting from streamed content as well would double-announce every tool. Behaviour preserved and covered by the existing suite: Fizz persona, _Stop veto (cap 3), [Reflect] (cap 8), _PostCompact re-injection, steering without cancellation, cancel leaving no unresolved tool call, usage accounting, and the model catalog. 81 unit + 21 integration tests pass; clippy clean (the one hints.rs warning predates this branch). Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/agent.rs | 548 ++++------------------------ crates/buzz-agent/src/lib.rs | 76 ++-- crates/buzz-agent/src/loop_drive.rs | 537 +++++++++++++++++++++++++++ crates/buzz-agent/src/prompt.rs | 108 ++++++ crates/buzz-agent/src/steer.rs | 86 +++++ crates/buzz-agent/src/tools.rs | 209 +++++++++++ 6 files changed, 1060 insertions(+), 504 deletions(-) create mode 100644 crates/buzz-agent/src/loop_drive.rs create mode 100644 crates/buzz-agent/src/prompt.rs create mode 100644 crates/buzz-agent/src/steer.rs create mode 100644 crates/buzz-agent/src/tools.rs diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 43984f7cc95..305f9c4f075 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -1,49 +1,18 @@ -//! The turn driver: Goose's agent loop, translated onto buzz-agent's ACP wire. +//! ACP wire emission for buzz-agent's turn loop. //! -//! 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 | kept -- goose's loader can't replace it (below) | -//! | `catalog.rs` | 631 | `goose::providers::init` model discovery | -//! | `builtin.rs` | 575 | kept -- served as a goose *frontend* tool (below) | -//! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) | -//! -//! 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`.** `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. Instead -//! `build_agent` registers `load_skill` as a goose *platform* extension -//! backed by an `McpClientTrait` (see [`crate::builtin_client`]), so goose -//! dispatches it on its ordinary tool path. -//! * **`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 -//! 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` +//! The loop itself lives in [`crate::loop_drive`] — buzz-agent owns it and +//! calls goose for the model, the tools, the prompt and compaction. What stays +//! here is the part goose knows nothing about: the mapping from goose +//! `MessageContent` 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 goose_provider_types::conversation::message::MessageContent; +use rmcp::model::CallToolRequestParams; -use crate::types::{AgentError, ContentBlock, StopReason}; +use crate::types::ContentBlock; use crate::wire::{self, WireSender}; /// How often to emit `keepalive` while waiting on the provider. @@ -58,32 +27,7 @@ use crate::wire::{self, WireSender}; /// 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); +pub(crate) 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)] @@ -129,351 +73,70 @@ pub fn prompt_to_text(blocks: &[ContentBlock]) -> String { 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`). +/// Reset the harness idle clock during provider silence. +pub(crate) async fn emit_keepalive(session_id: &str, wire_tx: &WireSender) { + wire::send( + wire_tx, + wire::session_update(session_id, json!({ "sessionUpdate": "keepalive" })), + ) + .await; +} + +/// Announce a tool call as in-progress. /// -/// `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, +/// Every announcement must be matched by exactly one +/// [`emit_tool_call_update`], or the desktop renders a spinner that never +/// resolves. buzz-agent's loop guarantees the pairing including on cancel. +pub(crate) async fn emit_tool_call( 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: &str, + tool_call_id: &str, + call: &CallToolRequestParams, +) { + let raw = serde_json::to_value(&call.arguments).unwrap_or(Value::Null); + wire::send( + wire_tx, + wire::session_update( 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); - } + json!({ + "sessionUpdate": "tool_call", + "toolCallId": tool_call_id, + "title": call.name.to_string(), + "kind": "other", + "status": "in_progress", + "rawInput": raw, + }), + ), + ) + .await; } -/// 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, +/// Move an announced tool call to its terminal state. +pub(crate) async fn emit_tool_call_update( 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); - } - let cached = u.cache_read_input_tokens.unwrap_or(0).max(0) as u64 - + u.cache_write_input_tokens.unwrap_or(0).max(0) as u64; - if u.cache_read_input_tokens.is_some() || u.cache_write_input_tokens.is_some() { - tokens.cached_input = Some(tokens.cached_input.unwrap_or(0) + cached); - } - if let Some(t) = u.total_tokens { - tokens.total = Some(tokens.total.unwrap_or(0) + t.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, - } + tool_call_id: &str, + failed: bool, +) { + wire::send( + wire_tx, + wire::session_update( + session_id, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": tool_call_id, + "status": if failed { "failed" } else { "completed" }, + }), + ), + ) + .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) { +/// Emit streamed assistant content onto the ACP wire. +/// +/// Text and reasoning only — tool lifecycle is emitted by [`crate::tools`], +/// which is the only place that knows when a call actually starts and ends. +pub(crate) async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &WireSender) { match content { MessageContent::Text(t) if !t.text.is_empty() => { wire::send( @@ -503,84 +166,11 @@ async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &Wire .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; - } - - // 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), - 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; - } - + // Tool lifecycle is NOT emitted from here. buzz-agent's loop owns + // it: `crate::tools` announces each call immediately before dispatch + // and emits the terminal update immediately after, including for calls + // a cancelled turn never ran. Emitting from the streamed content too + // would double-announce every tool. _ => {} } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 07f3941975e..aacdf392e6e 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -26,7 +26,11 @@ pub mod builtin_client; pub mod config; pub mod hints; pub mod hooks; +pub mod loop_drive; pub mod mesh; +pub mod prompt; +pub mod steer; +pub mod tools; pub mod types; pub mod wire; @@ -75,6 +79,12 @@ struct Session { /// Name of the MCP extension carrying `_Stop`/`_PostCompact`, if any. /// See [`crate::hooks`] for why we dispatch these ourselves. hook_extension: Option, + /// This session's system prompt. Owned here rather than inside goose's + /// `Agent`, because buzz-agent builds the prompt itself each round. + prompt: crate::prompt::SessionPrompt, + /// Mid-turn steer queue. goose's own is `pub(crate)` to `Agent::reply`, + /// which buzz-agent no longer calls. See [`crate::steer`]. + steers: crate::steer::SteerQueue, accumulated_input_tokens: u64, accumulated_output_tokens: u64, /// Subset of `accumulated_input_tokens`, published as @@ -102,7 +112,15 @@ async fn build_agent( cwd: &str, system_prompt: Option<&str>, mcp_servers: &[McpServerStdio], -) -> Result<(Arc, String, Option), AgentError> { +) -> Result< + ( + Arc, + String, + Option, + crate::prompt::SessionPrompt, + ), + AgentError, +> { let session_manager = Arc::new(SessionManager::instance()); let permission_manager = PermissionManager::instance(); @@ -155,9 +173,13 @@ async fn build_agent( // 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. + // buzz-agent owns the loop, so it owns the prompt: goose's own + // `PromptManager` is only reachable from `Agent::reply`, which we no longer + // call. See `crate::prompt`. + let prompt = crate::prompt::SessionPrompt::new(cfg.goose_mode); 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; + prompt.set_override(sp.to_string()).await; } } @@ -193,9 +215,7 @@ async fn build_agent( // (`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; + prompt.add_extra("buzz_hints", hints).await; } // `load_skill` runs in-process, registered the way Maple does it: a @@ -242,13 +262,13 @@ async fn build_agent( } if let Some(ext) = &hook_extension { - agent - .extend_system_prompt("buzz_hook_tools".to_string(), hook_tool_guidance()) + prompt + .add_extra("buzz_hook_tools", hook_tool_guidance()) .await; tracing::info!(extension = %ext, "lifecycle hooks available"); } - Ok((agent, session.id, hook_extension)) + Ok((agent, session.id, hook_extension, prompt)) } /// Keep the model's hands off the lifecycle hooks. @@ -461,7 +481,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, prompt) = match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { Ok(v) => v, Err(e) => { @@ -503,6 +523,8 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen model_override: None, pending_model: None, hook_extension, + prompt, + steers: crate::steer::SteerQueue::new(), accumulated_input_tokens: 0, accumulated_output_tokens: 0, accumulated_cached_input_tokens: 0, @@ -599,7 +621,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, prompt, steers) = { let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { return wire::send( @@ -629,6 +651,8 @@ 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.prompt.clone(), + s.steers.clone(), ) }; @@ -659,14 +683,19 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire ) .await; - let (result, tokens) = agent::run_turn( - agent, - &goose_session_id, - p.prompt, - app.cfg.max_rounds, - wire_tx, - cancel, - hook_extension.as_deref(), + let (result, tokens) = loop_drive::run_turn( + loop_drive::TurnContext { + agent: &agent, + session_id: &goose_session_id, + wire_tx, + cancel: &cancel, + hook_extension: hook_extension.as_deref(), + max_rounds: app.cfg.max_rounds, + prompt: &prompt, + steers: &steers, + }, + goose_provider_types::conversation::message::Message::user() + .with_text(agent::prompt_to_text(&p.prompt)), ) .await; @@ -835,7 +864,7 @@ async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { .await; } - let (agent, goose_session_id) = { + let steers = { let sessions = app.sessions.lock().await; let Some(s) = sessions.get(&p.session_id) else { return wire::send( @@ -861,15 +890,12 @@ async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { ) .await; } - (s.agent.clone(), s.goose_session_id.clone()) + s.steers.clone() }; let message_id = format!("steer_{}", uuid_like()); - agent - .steer( - &goose_session_id, - goose_provider_types::conversation::message::Message::user().with_text(text), - ) + steers + .push(goose_provider_types::conversation::message::Message::user().with_text(text)) .await; wire::send( diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs new file mode 100644 index 00000000000..9e066fd6b28 --- /dev/null +++ b/crates/buzz-agent/src/loop_drive.rs @@ -0,0 +1,537 @@ +//! buzz-agent's own agent loop, built from goose components. +//! +//! # Who owns what +//! +//! This is the deliberate inversion of the earlier design, where the turn was +//! `goose::agents::Agent::reply` and buzz-agent only translated the resulting +//! event stream onto the ACP wire. **buzz-agent owns the loop.** goose is a +//! parts bin: +//! +//! | concern | goose API used | +//! |---|---| +//! | model call | `Provider::stream` (via `Agent::provider`) | +//! | tool surface | `Agent::list_tools` | +//! | tool execution | `Agent::dispatch_tool_call` | +//! | system prompt | `Agent::build_turn_system_prompt` → `PromptManager` | +//! | compaction | `goose::context_mgmt::{check_if_compaction_needed, compact_messages}` | +//! | conversation store | `SessionManager` (sqlite), read/written by us | +//! +//! Everything that decides *turn shape* stays here: round structure, the +//! `_Stop` veto, `[Reflect]` on tool failure, `[PostCompact]` re-injection, +//! steer draining, cancellation semantics, and the ACP `session/update` +//! emission. +//! +//! # Why own the loop +//! +//! `Agent::reply` bakes in goose's own turn policy — its compaction trigger, +//! its stop-hook handling, its steer drain points, its max-turns rule. Every +//! buzz-specific behaviour then has to be smuggled in around the edges +//! (see the old `run_turn`: an outer loop purely to re-enter `reply()` for the +//! `_Stop` veto, and `[Reflect]` delivered as a *steer* because the tool +//! result itself was out of reach). Driving the components directly removes +//! the smuggling: the veto is a branch, `[Reflect]` is appended to the tool +//! result where buzz-agent originally put it. +//! +//! # Round structure +//! +//! One round is: build prompt+tools → stream from the provider → persist the +//! assistant message → if it asked for tools, run them, append results, and +//! loop; otherwise the turn wants to end, at which point `_Stop` may veto it. +//! +//! Compaction is checked at the top of each round rather than mid-stream, so a +//! rewrite of history can never race a partially-consumed response. + +use std::sync::Arc; + +use futures::StreamExt; +use tokio_util::sync::CancellationToken; + +use goose::agents::Agent; +use goose::session::session_manager::{Session, SessionManager}; +use goose_provider_types::conversation::message::{Message, MessageContent, ToolRequest}; +use goose_provider_types::conversation::Conversation; + +use crate::types::{AgentError, StopReason}; +use crate::wire::WireSender; + +/// Appended to every failed tool result so the model diagnoses the failure +/// instead of blindly retrying it. +/// +/// This is buzz-agent's original behaviour, restored. Under `Agent::reply` the +/// tool result was owned by goose and unreachable, so the text had to be +/// delivered as a steer instead; owning the loop means we can put it back on +/// the result where the model reads it in context. +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; + +/// Hard cap on consecutive `_Stop` vetoes, mirroring goose's own +/// `stop_hook_block_cap`. A tool that always objects must not trap the turn. +pub const MAX_STOP_BLOCKS: u32 = 3; + +/// Rounds allowed when the caller sets no limit. +/// +/// buzz-agent's loop was previously bounded by goose's `max_turns`. Owning the +/// loop means owning the bound, and an unbounded loop against a model that +/// tool-calls forever is a runaway spend. +const DEFAULT_MAX_ROUNDS: u32 = 1000; + +/// Outcome of a single round, as decided by this loop. +enum Round { + /// The model asked for tools; they ran and their results are in history. + Continued, + /// The model produced a final answer. + WantsToEnd, + /// A terminal condition the loop must surrender to. + Stopped(StopReason), +} + +/// Everything a round needs that does not change between rounds. +pub struct TurnContext<'a> { + pub agent: &'a Arc, + pub session_id: &'a str, + pub wire_tx: &'a WireSender, + pub cancel: &'a CancellationToken, + pub hook_extension: Option<&'a str>, + pub max_rounds: Option, + /// This session's system prompt. buzz-agent owns it because goose's own + /// `PromptManager` is only reachable from `Agent::reply`. + pub prompt: &'a crate::prompt::SessionPrompt, + /// Messages injected mid-turn by `session/steer`. Drained at the round + /// boundary; a pending steer also blocks the end of the turn. + pub steers: &'a crate::steer::SteerQueue, +} + +/// Drive one `session/prompt` turn to completion. +/// +/// Returns the ACP stop reason plus the turn's token counts. The caller emits +/// `usage_update` *before* the `session/prompt` response — that ordering is +/// load-bearing for kind-44200 metrics. +pub async fn run_turn( + ctx: TurnContext<'_>, + prompt: Message, +) -> (Result, super::agent::TurnTokens) { + let mut tokens = super::agent::TurnTokens::default(); + let sessions = SessionManager::instance(); + + // The user's prompt is persisted before the first inference so a resumed + // or inspected session sees the same history the model saw. + if let Err(e) = sessions.add_message(ctx.session_id, &prompt).await { + return (Err(AgentError::Llm(format!("persist prompt: {e}"))), tokens); + } + + let max_rounds = ctx.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS); + let mut rounds = 0u32; + let mut stop_blocks = 0u32; + let mut reflections = 0usize; + + loop { + if ctx.cancel.is_cancelled() { + return (Ok(StopReason::Cancelled), tokens); + } + if rounds >= max_rounds { + tracing::warn!(rounds, "max rounds reached; ending turn"); + return (Ok(StopReason::MaxTurnRequests), tokens); + } + rounds += 1; + + // Steers land here, at the round boundary — never mid-inference, + // where they would race a partially streamed response. + for message in ctx.steers.drain().await { + if let Err(e) = sessions.add_message(ctx.session_id, &message).await { + return (Err(AgentError::Llm(format!("persist steer: {e}"))), tokens); + } + } + + // Compaction at the round boundary, never mid-stream. + if let Err(e) = maybe_compact(&ctx, &sessions).await { + tracing::warn!(error = %e, "compaction failed; continuing uncompacted"); + } + + match round(&ctx, &sessions, &mut tokens, &mut reflections).await { + Err(e) => return (Err(e), tokens), + Ok(Round::Continued) => continue, + Ok(Round::Stopped(reason)) => return (Ok(reason), tokens), + Ok(Round::WantsToEnd) => { + // A steer that arrived while the model was finishing must be + // answered, not dropped — so it also blocks the end of turn. + if ctx.steers.is_pending().await && !ctx.cancel.is_cancelled() { + continue; + } + + // The turn wants to end. `_Stop` gets a veto. + match stop_veto(&ctx, &sessions, &mut stop_blocks).await { + Some(objection) => { + let message = Message::user() + .with_text(format!("[Stop] {objection}")) + .with_visibility(false, true); + if let Err(e) = sessions.add_message(ctx.session_id, &message).await { + return (Err(AgentError::Llm(format!("persist stop: {e}"))), tokens); + } + continue; + } + None => { + // A steer arriving after this point belongs to no run. + ctx.steers.clear().await; + return (Ok(StopReason::EndTurn), tokens); + } + } + } + } + } +} + +/// One inference plus, if the model asked for them, one batch of tool calls. +async fn round( + ctx: &TurnContext<'_>, + sessions: &SessionManager, + tokens: &mut super::agent::TurnTokens, + reflections: &mut usize, +) -> Result { + let session = load_session(sessions, ctx.session_id).await?; + let conversation = session.conversation.clone().unwrap_or_default(); + + let assistant = match infer(ctx, &session, &conversation, tokens).await? { + Some(message) => message, + // A provider that returns nothing is not a turn we can continue. + None => return Ok(Round::Stopped(StopReason::EndTurn)), + }; + + sessions + .add_message(ctx.session_id, &assistant) + .await + .map_err(|e| AgentError::Llm(format!("persist assistant: {e}")))?; + + let requests: Vec = assistant + .content + .iter() + .filter_map(|content| match content { + MessageContent::ToolRequest(req) => Some(req.clone()), + _ => None, + }) + .collect(); + + if requests.is_empty() { + return Ok(Round::WantsToEnd); + } + + if ctx.cancel.is_cancelled() { + // Announced tool calls must still reach a terminal state or the + // desktop renders a spinner forever. + let results = crate::tools::cancelled_results(&requests); + for request in &requests { + crate::agent::emit_tool_call_update(ctx.wire_tx, ctx.session_id, &request.id, true) + .await; + } + persist_tool_results(ctx, sessions, results).await?; + return Ok(Round::Stopped(StopReason::Cancelled)); + } + + // Tool arguments feed goose's subdirectory-hint tracker, so the next + // round's prompt picks up an AGENTS.md in a directory we just touched. + for request in &requests { + if let Ok(call) = &request.tool_call { + ctx.prompt + .record_tool_arguments(&call.arguments, &session.working_dir) + .await; + } + } + + let results = crate::tools::execute( + ctx.agent, + &session, + ctx.wire_tx, + ctx.cancel, + &requests, + reflections, + ) + .await; + persist_tool_results(ctx, sessions, results).await?; + + Ok(Round::Continued) +} + +/// Stream one assistant response, emitting chunks onto the ACP wire as they +/// arrive and accumulating usage. +async fn infer( + ctx: &TurnContext<'_>, + session: &Session, + conversation: &Conversation, + tokens: &mut super::agent::TurnTokens, +) -> Result, AgentError> { + let provider = ctx + .agent + .provider() + .await + .map_err(|e| AgentError::Llm(e.to_string()))?; + let model_config = ctx + .agent + .model_config_for_session(ctx.session_id) + .await + .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; + + let system_prompt = ctx.prompt.build(&session.working_dir).await; + let tools = ctx.agent.list_tools(ctx.session_id, None).await; + + let mut stream = provider + .stream( + &model_config, + &system_prompt, + conversation.messages(), + &tools, + ) + .await + .map_err(|e| classify_provider_error(&e.to_string()))?; + + let mut accumulated: Option = None; + let mut keepalive = tokio::time::interval(crate::agent::KEEPALIVE_INTERVAL); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + keepalive.tick().await; // first tick is immediate; discard it + + loop { + tokio::select! { + // Keep the harness idle clock alive during provider silence. + _ = keepalive.tick() => { + crate::agent::emit_keepalive(ctx.session_id, ctx.wire_tx).await; + } + + // Cancelling the model call is safe to do abruptly: unlike tool + // dispatch there is no child process to notify, and the partial + // response is discarded rather than persisted. + _ = ctx.cancel.cancelled() => { + return Ok(accumulated); + } + + next = stream.next() => { + let Some(item) = next else { break }; + let (message, usage) = item.map_err(|e| classify_provider_error(&e.to_string()))?; + + if let Some(usage) = usage { + accumulate_usage(tokens, &usage); + } + + let Some(chunk) = message else { continue }; + for content in &chunk.content { + crate::agent::emit_content(content, ctx.session_id, ctx.wire_tx).await; + } + accumulated = Some(match accumulated { + None => chunk, + Some(mut prev) => { + merge_chunk(&mut prev, chunk); + prev + } + }); + } + } + } + + Ok(accumulated) +} + +/// Fold a streamed chunk into the message being accumulated. +/// +/// Text arrives fragmented and must be coalesced or the persisted history +/// contains one message per token; tool calls arrive whole and are appended. +fn merge_chunk(target: &mut Message, chunk: Message) { + for content in chunk.content { + match (target.content.last_mut(), &content) { + (Some(MessageContent::Text(last)), MessageContent::Text(new)) => { + last.text.push_str(&new.text); + } + (Some(MessageContent::Thinking(last)), MessageContent::Thinking(new)) => { + last.thinking.push_str(&new.thinking); + } + _ => target.content.push(content), + } + } +} + +fn accumulate_usage( + tokens: &mut super::agent::TurnTokens, + usage: &goose::providers::base::ProviderUsage, +) { + 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); + } + if u.cache_read_input_tokens.is_some() || u.cache_write_input_tokens.is_some() { + let cached = u.cache_read_input_tokens.unwrap_or(0).max(0) as u64 + + u.cache_write_input_tokens.unwrap_or(0).max(0) as u64; + tokens.cached_input = Some(tokens.cached_input.unwrap_or(0) + cached); + } + if let Some(t) = u.total_tokens { + tokens.total = Some(tokens.total.unwrap_or(0) + t.max(0) as u64); + } +} + +/// Persist tool results as a single user message, matching the shape every +/// provider wire format expects (one result per outstanding request). +async fn persist_tool_results( + ctx: &TurnContext<'_>, + sessions: &SessionManager, + results: Vec<( + String, + goose_provider_types::conversation::message::ToolResult, + )>, +) -> Result<(), AgentError> { + if results.is_empty() { + return Ok(()); + } + let mut message = Message::user(); + for (id, result) in results { + message = message.with_tool_response(id, result); + } + sessions + .add_message(ctx.session_id, &message) + .await + .map_err(|e| AgentError::Llm(format!("persist tool results: {e}"))) +} + +/// Compact history when it is close to the context limit, then re-inject the +/// state `_PostCompact` reports so a todo list survives the truncation. +async fn maybe_compact(ctx: &TurnContext<'_>, sessions: &SessionManager) -> anyhow::Result<()> { + let session = sessions.get_session(ctx.session_id, true).await?; + let Some(conversation) = session.conversation.clone() else { + return Ok(()); + }; + let provider = ctx.agent.provider().await?; + + if !goose::context_mgmt::check_if_compaction_needed( + provider.as_ref(), + &conversation, + None, + &session, + ) + .await? + { + return Ok(()); + } + + let model_config = ctx.agent.model_config_for_session(ctx.session_id).await?; + let result = goose::context_mgmt::compact_messages( + provider.as_ref(), + &model_config, + ctx.session_id, + &conversation, + false, + ) + .await?; + + sessions + .replace_conversation(ctx.session_id, &result.conversation) + .await?; + tracing::info!(target: "buzz_agent::compaction", "history compacted"); + + // `_PostCompact` state is what buzz-agent's handoff existed to preserve. + if let Some(extension) = ctx.hook_extension { + let session = sessions.get_session(ctx.session_id, true).await?; + if let Some(state) = crate::hooks::post_compact_state(ctx.agent, &session, extension).await + { + let message = Message::user() + .with_text(format!("[PostCompact] {state}")) + .with_visibility(false, true); + sessions.add_message(ctx.session_id, &message).await?; + } + } + + Ok(()) +} + +/// Ask `_Stop` whether the turn may end. `Some(objection)` means keep working. +async fn stop_veto( + ctx: &TurnContext<'_>, + sessions: &SessionManager, + stop_blocks: &mut u32, +) -> Option { + if ctx.cancel.is_cancelled() { + return None; + } + let extension = ctx.hook_extension?; + + if *stop_blocks >= MAX_STOP_BLOCKS { + tracing::warn!(blocks = *stop_blocks, "_Stop veto cap reached; ending turn"); + return None; + } + + let session = sessions.get_session(ctx.session_id, false).await.ok()?; + let objection = crate::hooks::stop_objection(ctx.agent, &session, extension).await?; + + *stop_blocks += 1; + tracing::info!(blocks = *stop_blocks, "_Stop hook vetoed end of turn"); + Some(objection) +} + +async fn load_session(sessions: &SessionManager, id: &str) -> Result { + sessions + .get_session(id, true) + .await + .map_err(|e| AgentError::Llm(format!("load session: {e}"))) +} + +/// Preserve buzz-agent's error taxonomy so the harness's JSON-RPC code mapping +/// stays meaningful. +fn classify_provider_error(msg: &str) -> AgentError { + let lower = msg.to_lowercase(); + if lower.contains("auth") || lower.contains("401") { + 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()) + } +} + +/// Text appended to a failed tool result, exposed for the tool module. +pub(crate) fn reflection_text() -> &'static str { + ERROR_REFLECTION +} + +pub(crate) fn max_reflections() -> usize { + MAX_REFLECTIONS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_coalesces_consecutive_text() { + let mut target = Message::assistant().with_text("hel"); + merge_chunk(&mut target, Message::assistant().with_text("lo")); + assert_eq!(target.as_concat_text(), "hello"); + assert_eq!(target.content.len(), 1, "text must coalesce, not append"); + } + + #[test] + fn merge_appends_distinct_content_kinds() { + let mut target = Message::assistant().with_text("thinking about it"); + merge_chunk( + &mut target, + Message::assistant() + .with_tool_response("id", Ok(rmcp::model::CallToolResult::success(vec![]))), + ); + assert_eq!(target.content.len(), 2); + } + + #[test] + fn provider_errors_keep_buzz_agents_taxonomy() { + assert!(matches!( + classify_provider_error("401 Unauthorized"), + AgentError::LlmAuth(_) + )); + assert!(matches!( + classify_provider_error("model gpt-9 not found"), + AgentError::LlmModelNotFound(_) + )); + assert!(matches!( + classify_provider_error("connection reset"), + AgentError::Llm(_) + )); + } +} diff --git a/crates/buzz-agent/src/prompt.rs b/crates/buzz-agent/src/prompt.rs new file mode 100644 index 00000000000..514be7d41fc --- /dev/null +++ b/crates/buzz-agent/src/prompt.rs @@ -0,0 +1,108 @@ +//! buzz-agent's system prompt, assembled with goose's `PromptManager`. +//! +//! `Agent::prompt_manager` is `pub(super)` in goose, and `Agent::reply` is the +//! only thing that reads it. Since buzz-agent owns the loop it also owns the +//! prompt: we keep our own `PromptManager` and call it at the top of each +//! round, which is exactly what goose's inference step does internally. +//! +//! Owning it is not just a workaround for the visibility — it is the seam that +//! makes the persona work at all. goose's ACP server never reads +//! `systemPrompt`, so an agent driven over plain ACP silently loses Fizz and +//! the `[Base]` operating manual. Building the prompt here puts them in front +//! of the model. + +use std::path::Path; +use std::sync::Arc; + +use goose::agents::PromptManager; +use goose::config::GooseMode; +use tokio::sync::Mutex; + +/// The system prompt for one session. +/// +/// Cloneable so a turn can hold it without borrowing the session map. +#[derive(Clone)] +pub struct SessionPrompt { + manager: Arc>, + mode: GooseMode, +} + +impl SessionPrompt { + pub fn new(mode: GooseMode) -> Self { + Self { + manager: Arc::new(Mutex::new(PromptManager::new())), + mode, + } + } + + /// Replace goose's base template with the harness-supplied persona. + pub async fn set_override(&self, template: String) { + self.manager + .lock() + .await + .set_system_prompt_override(template); + } + + /// Append a keyed section (AGENTS.md hints, the skill index, hook + /// guidance). Extras survive an override — goose appends them to whichever + /// base template is in force. + pub async fn add_extra(&self, key: &str, instruction: String) { + self.manager + .lock() + .await + .add_system_prompt_extra(key.to_string(), instruction); + } + + /// Render the prompt for one inference. + /// + /// Called per round rather than once per session because + /// `build_system_prompt` also folds in subdirectory hints discovered from + /// the tool arguments seen so far — a prompt built once at session start + /// would never pick them up. + pub async fn build(&self, working_dir: &Path) -> String { + self.manager + .lock() + .await + .build_system_prompt(working_dir, Vec::new(), self.mode) + } + + /// Feed tool arguments to the subdirectory-hint tracker, so entering a new + /// part of the tree pulls in that directory's AGENTS.md on the next round. + pub async fn record_tool_arguments( + &self, + arguments: &Option>, + working_dir: &Path, + ) { + self.manager + .lock() + .await + .record_tool_arguments(arguments, working_dir); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn override_replaces_the_base_template() { + let prompt = SessionPrompt::new(GooseMode::Auto); + prompt.set_override("You are Fizz.".to_string()).await; + let built = prompt.build(Path::new(".")).await; + assert!(built.contains("You are Fizz.")); + } + + #[tokio::test] + async fn extras_survive_an_override() { + // The persona arrives as an override and the hints as an extra; losing + // either one is a silent failure the harness cannot detect. + let prompt = SessionPrompt::new(GooseMode::Auto); + prompt.set_override("You are Fizz.".to_string()).await; + prompt + .add_extra("buzz_hints", "Always ship the sprocket first.".to_string()) + .await; + let built = prompt.build(Path::new(".")).await; + assert!(built.contains("You are Fizz.")); + assert!(built.contains("Always ship the sprocket first.")); + } +} diff --git a/crates/buzz-agent/src/steer.rs b/crates/buzz-agent/src/steer.rs new file mode 100644 index 00000000000..a9a09922ea1 --- /dev/null +++ b/crates/buzz-agent/src/steer.rs @@ -0,0 +1,86 @@ +//! Mid-turn steering, owned by buzz-agent. +//! +//! Steering is a message injected into a turn already in flight, without +//! cancelling it. goose has an equivalent queue on its `Agent`, but +//! `drain_pending_steers` / `has_pending_steers` are `pub(crate)` — only +//! `Agent::reply` can consume them. Since buzz-agent owns the loop, it owns +//! the queue too. +//! +//! The semantics match what buzz-acp expects and what goose does internally: +//! +//! * the message is queued, never applied mid-inference — it lands at the +//! **round boundary**, so it cannot corrupt a partially streamed response; +//! * a pending steer **prevents the turn from ending**, so a user who steers +//! just as the model finishes is not silently ignored; +//! * steering never restarts or cancels the turn. + +use std::collections::VecDeque; +use std::sync::Arc; + +use goose_provider_types::conversation::message::Message; +use tokio::sync::Mutex; + +/// A session's pending steer messages. +/// +/// Cloneable and shared: `session/steer` pushes from the JSON-RPC dispatch +/// task while the turn drains from the loop task. +#[derive(Clone, Default)] +pub struct SteerQueue { + inner: Arc>>, +} + +impl SteerQueue { + pub fn new() -> Self { + Self::default() + } + + pub async fn push(&self, message: Message) { + self.inner.lock().await.push_back(message); + } + + pub async fn is_pending(&self) -> bool { + !self.inner.lock().await.is_empty() + } + + /// Take everything queued so far. + /// + /// Draining and appending is a single logical step for the loop: a steer + /// that arrives *during* the drain stays queued for the next boundary + /// rather than being lost. + pub async fn drain(&self) -> Vec { + self.inner.lock().await.drain(..).collect() + } + + /// Drop anything queued. Used when a turn ends so a late steer cannot leak + /// into the next one. + pub async fn clear(&self) { + self.inner.lock().await.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn drain_takes_everything_in_order() { + let queue = SteerQueue::new(); + queue.push(Message::user().with_text("first")).await; + queue.push(Message::user().with_text("second")).await; + assert!(queue.is_pending().await); + + let drained = queue.drain().await; + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].as_concat_text(), "first"); + assert_eq!(drained[1].as_concat_text(), "second"); + assert!(!queue.is_pending().await, "drain must empty the queue"); + } + + #[tokio::test] + async fn clear_discards_a_late_steer() { + let queue = SteerQueue::new(); + queue.push(Message::user().with_text("too late")).await; + queue.clear().await; + assert!(!queue.is_pending().await); + } +} diff --git a/crates/buzz-agent/src/tools.rs b/crates/buzz-agent/src/tools.rs new file mode 100644 index 00000000000..59aaa7a2bb3 --- /dev/null +++ b/crates/buzz-agent/src/tools.rs @@ -0,0 +1,209 @@ +//! Tool execution for buzz-agent's own loop. +//! +//! goose's `Agent::dispatch_tool_call` resolves the prefixed tool name, runs +//! the MCP call, and applies goose's own pre/post hooks — so we use it rather +//! than reaching into `ExtensionManager` directly. What we keep is the policy +//! around it: parallel dispatch, the announce→terminal wire invariant, and +//! `[Reflect]` appended to failed results. + +use std::sync::Arc; + +use futures::stream::{FuturesUnordered, StreamExt}; +use rmcp::model::{CallToolResult, ContentBlock, ErrorData}; +use tokio_util::sync::CancellationToken; + +use goose::agents::Agent; +use goose::session::session_manager::Session; +use goose_provider_types::conversation::message::{ToolRequest, ToolResult}; + +use crate::wire::WireSender; + +/// One tool's outcome, keyed by the request id the model used. +pub type Outcome = (String, ToolResult); + +/// Run every tool the model asked for. +/// +/// Calls are dispatched concurrently — a turn that reads three files should +/// not pay for them serially — but results are returned in **request order**, +/// because providers require tool results to line up with the requests that +/// produced them. +pub async fn execute( + agent: &Arc, + session: &Session, + wire_tx: &WireSender, + cancel: &CancellationToken, + requests: &[ToolRequest], + reflections: &mut usize, +) -> Vec { + let mut pending = FuturesUnordered::new(); + for (index, request) in requests.iter().enumerate() { + pending.push(run_one(agent, session, wire_tx, cancel, index, request)); + } + + let mut collected: Vec> = (0..requests.len()).map(|_| None).collect(); + while let Some((index, outcome)) = pending.next().await { + collected[index] = Some(outcome); + } + + let mut results = Vec::with_capacity(collected.len()); + for outcome in collected.into_iter().flatten() { + let (id, result) = outcome; + let result = reflect_on_failure(result, reflections); + emit_terminal(wire_tx, session, &id, &result).await; + results.push((id, result)); + } + results +} + +/// Synthesise results for tools that were never run because the turn was +/// cancelled first. +/// +/// Every announced tool call must reach a terminal state; the desktop renders +/// an unresolved one as a spinner that never stops. +pub fn cancelled_results(requests: &[ToolRequest]) -> Vec { + requests + .iter() + .map(|request| { + ( + request.id.clone(), + Ok(CallToolResult::error(vec![ContentBlock::text( + "Tool call was cancelled before execution", + )])), + ) + }) + .collect() +} + +async fn run_one( + agent: &Arc, + session: &Session, + wire_tx: &WireSender, + cancel: &CancellationToken, + index: usize, + request: &ToolRequest, +) -> (usize, Outcome) { + let id = request.id.clone(); + + // A tool call the provider itself failed to parse never reaches MCP. + let call = match &request.tool_call { + Ok(call) => call.clone(), + Err(e) => return (index, (id, Err(e.clone()))), + }; + + crate::agent::emit_tool_call(wire_tx, &session.id, &id, &call).await; + + let (_request_id, dispatched) = agent + .dispatch_tool_call(call, id.clone(), Some(cancel.clone()), session) + .await; + + let result = match dispatched { + Ok(call_result) => call_result.result.await, + Err(e) => Err(e), + }; + + (index, (id, result)) +} + +/// Append `[Reflect]` to a failed tool result. +/// +/// buzz-agent's original behaviour: the model reads the reflection in the same +/// content block as the failure, rather than as a separate steer message +/// arriving at an unrelated point in the conversation. Capped so a tool +/// failing in a loop cannot flood the context. +fn reflect_on_failure( + result: ToolResult, + reflections: &mut usize, +) -> ToolResult { + let failed = match &result { + Ok(out) => out.is_error.unwrap_or(false), + Err(_) => true, + }; + if !failed || *reflections >= crate::loop_drive::max_reflections() { + return result; + } + *reflections += 1; + + match result { + Ok(mut out) => { + out.content + .push(ContentBlock::text(crate::loop_drive::reflection_text())); + Ok(out) + } + // An `ErrorData` has no content to append to, so the reflection is + // folded into its message instead of being dropped. + Err(e) => Err(ErrorData::new( + e.code, + format!("{} {}", e.message, crate::loop_drive::reflection_text()), + e.data, + )), + } +} + +async fn emit_terminal( + wire_tx: &WireSender, + session: &Session, + id: &str, + result: &ToolResult, +) { + let failed = match result { + Ok(out) => out.is_error.unwrap_or(false), + Err(_) => true, + }; + crate::agent::emit_tool_call_update(wire_tx, &session.id, id, failed).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(id: &str) -> ToolRequest { + ToolRequest { + id: id.to_string(), + tool_call: Ok(rmcp::model::CallToolRequestParams::new("x")), + metadata: None, + tool_meta: None, + } + } + + #[test] + fn cancellation_still_terminates_every_announced_call() { + // The desktop spins forever on an unresolved tool call, so a cancelled + // turn must still produce one result per request. + let requests = vec![request("a"), request("b")]; + let results = cancelled_results(&requests); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, "a"); + assert_eq!(results[1].0, "b"); + } + + #[test] + fn reflection_is_appended_to_a_failed_result() { + let mut reflections = 0; + let failed = Ok(CallToolResult::error(vec![ContentBlock::text("boom")])); + let out = reflect_on_failure(failed, &mut reflections).unwrap(); + let text: String = out + .content + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.clone())) + .collect(); + assert!(text.contains("[Reflect]")); + assert_eq!(reflections, 1); + } + + #[test] + fn success_is_left_alone() { + let mut reflections = 0; + let ok = Ok(CallToolResult::success(vec![ContentBlock::text("fine")])); + let out = reflect_on_failure(ok, &mut reflections).unwrap(); + assert_eq!(out.content.len(), 1); + assert_eq!(reflections, 0); + } + + #[test] + fn reflection_stops_at_the_cap() { + let mut reflections = crate::loop_drive::max_reflections(); + let failed = Ok(CallToolResult::error(vec![ContentBlock::text("boom")])); + let out = reflect_on_failure(failed, &mut reflections).unwrap(); + assert_eq!(out.content.len(), 1, "capped: no reflection appended"); + } +} From 1d098d6d5273deca2b70e5e7817c8dd9bd354eed Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 15:46:24 +1000 Subject: [PATCH 24/51] test(buzz-agent): hand-test script driving a real provider over ACP stdio Closes the biggest coverage gap on this branch: every automated test talks to a fake SSE server, so nothing proved the loop works against a real model. scripts/handtest.py starts the actual binary, speaks real ACP to it, and asserts on the five behaviours that are easy to break and hard to notice: basic persona + AGENTS.md hints reach the model; catalog populated tools a real MCP tool is dispatched and its output comes back stop-veto _Stop blocks end-of-turn, and the cap still releases it cancel cancel mid-tool leaves no tool call spinning steer a mid-turn steer is absorbed without restarting the turn All 18 checks pass against Anthropic claude-sonnet-4-6. Each mode gets a fresh process so one mode's conversation cannot pollute the next. HANDTEST.md updated: describes the script, and its 'never run against a real provider' known gap is replaced with what is now actually covered. Also corrects the opening paragraph, which still said goose owns the loop. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/HANDTEST.md | 40 ++- crates/buzz-agent/scripts/handtest.py | 376 ++++++++++++++++++++++++++ 2 files changed, 409 insertions(+), 7 deletions(-) create mode 100755 crates/buzz-agent/scripts/handtest.py diff --git a/crates/buzz-agent/HANDTEST.md b/crates/buzz-agent/HANDTEST.md index 20b4c5d44f6..e89ac31c722 100644 --- a/crates/buzz-agent/HANDTEST.md +++ b/crates/buzz-agent/HANDTEST.md @@ -1,8 +1,11 @@ # Hand-testing `buzz-agent` 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. +its ACP contract and its place in the workspace — only the internals changed. +buzz-agent still owns the agent loop (`src/loop_drive.rs`); what changed is +that the loop now calls the `goose` crate for the model stream, the tool +surface, tool dispatch, the system prompt and compaction, instead of using +buzz-agent's own ~8k lines of provider and MCP code. So there is nothing special to run. The normal flow *is* the test: @@ -16,6 +19,26 @@ just goose # terminal 2 — agent against that relay Both already build and use the swapped `buzz-agent`. If you see a difference, that is the bug. +## Fastest useful check: the stdio smoke script + +`just dev` is the real test, but it needs a relay, a desktop app and a human. +To exercise the loop against a **real provider** in ~30 seconds, drive the +binary over its ACP stdio interface directly: + +```bash +cargo build -p buzz-agent -p buzz-dev-mcp +export ANTHROPIC_API_KEY=... # or your provider of choice +python3 crates/buzz-agent/scripts/handtest.py # persona + hints +python3 crates/buzz-agent/scripts/handtest.py --tools # real MCP tools +python3 crates/buzz-agent/scripts/handtest.py --stop-veto # _Stop veto +python3 crates/buzz-agent/scripts/handtest.py --cancel # no stuck spinner +python3 crates/buzz-agent/scripts/handtest.py --steer # mid-turn steer +``` + +Each mode asserts and prints its own verdict. This is the coverage the +automated suite cannot give you: **the suite uses a fake SSE server, so it +never proves a real provider works.** + ## What to look at Ordered by risk. The automated suite (41 tests) covers each of these at the @@ -81,11 +104,14 @@ running that relay side by side. ## Known gaps -* **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). +* **Anthropic is the only provider hand-tested end-to-end** (via + `scripts/handtest.py`, see above: persona, AGENTS.md hints, 9-model catalog, + real MCP tool call, `_Stop` veto to its cap, cancel-with-no-stuck-spinner, + and a mid-turn steer absorbed without restarting the turn). The automated + suite otherwise 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). * **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` diff --git a/crates/buzz-agent/scripts/handtest.py b/crates/buzz-agent/scripts/handtest.py new file mode 100755 index 00000000000..9492391e07b --- /dev/null +++ b/crates/buzz-agent/scripts/handtest.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Drive buzz-agent over its ACP stdio interface against a real provider. + +The automated suite (`cargo test -p buzz-agent`) talks to a fake SSE server, so +it proves the wiring but never proves a real provider works. This script closes +that gap: it starts the actual binary, speaks real ACP to it, and asserts on the +behaviours that are easy to break and hard to notice. + + cargo build -p buzz-agent -p buzz-dev-mcp + export ANTHROPIC_API_KEY=... + python3 crates/buzz-agent/scripts/handtest.py --all + +Each mode prints PASS/FAIL and the script exits non-zero if any assertion fails. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +AGENT = REPO / "target" / "debug" / "buzz-agent" +DEV_MCP = REPO / "target" / "debug" / "buzz-dev-mcp" + +# Planted in the workspace's AGENTS.md and asserted in the model's reply. If +# this does not come back, hints never reached the system prompt. +MAGIC_WORD = "FLANGEBERRY" +# Planted in the systemPrompt. Its absence means the persona was dropped -- the +# exact failure that makes plain-ACP embedding unusable for Buzz. +PERSONA_TOKEN = "BUZZBUZZ" + +failures: list[str] = [] + + +def check(label: str, ok: bool, detail: str = "") -> None: + print(f" {'PASS' if ok else 'FAIL'} {label}{f' -- {detail}' if detail else ''}") + if not ok: + failures.append(label) + + +class Agent: + """A running buzz-agent process, spoken to in ACP over stdio.""" + + def __init__(self, workspace: Path, home: Path, provider: str, model: str): + env = dict(os.environ) + env.update( + { + "BUZZ_AGENT_PROVIDER": provider, + "BUZZ_AGENT_MODEL": model, + # Isolate goose's config/session store from the developer's own. + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / "cfg"), + "XDG_DATA_HOME": str(home / "data"), + "RUST_LOG": env.get("RUST_LOG", "warn,buzz_agent=info"), + } + ) + self.proc = subprocess.Popen( + [str(AGENT)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + env=env, + ) + self.workspace = workspace + self.updates: list[dict] = [] + self.responses: dict[int, dict] = {} + self.run_id: str | None = None + self._next_id = 0 + threading.Thread(target=self._read, daemon=True).start() + + def _read(self) -> None: + for line in self.proc.stdout: # type: ignore[union-attr] + try: + msg = json.loads(line) + except ValueError: + continue + if msg.get("method") == "session/update": + self.updates.append(msg["params"]) + # `_meta` nests inside `update`, under a `goose` key. + meta = (msg["params"].get("update") or {}).get("_meta") or {} + run_id = (meta.get("goose") or {}).get("activeRunId") + if run_id: + self.run_id = run_id + elif "id" in msg: + self.responses[msg["id"]] = msg + + def send(self, method: str, params: dict) -> int: + self._next_id += 1 + self._write({"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}) + return self._next_id + + def notify(self, method: str, params: dict) -> None: + self._write({"jsonrpc": "2.0", "method": method, "params": params}) + + def _write(self, payload: dict) -> None: + self.proc.stdin.write(json.dumps(payload) + "\n") # type: ignore[union-attr] + self.proc.stdin.flush() # type: ignore[union-attr] + + def wait(self, request_id: int, timeout: float = 240) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + if request_id in self.responses: + return self.responses[request_id] + if self.proc.poll() is not None: + raise SystemExit("agent exited early") + time.sleep(0.05) + raise SystemExit(f"timed out waiting for response {request_id}") + + def call(self, method: str, params: dict, timeout: float = 240) -> dict: + return self.wait(self.send(method, params), timeout) + + def new_session(self, system_prompt: str, with_tools: bool) -> tuple[str, int]: + self.call("initialize", {"protocolVersion": 2}) + servers = ( + [{"name": "devtools", "command": str(DEV_MCP), "args": [], "env": []}] + if with_tools + else [] + ) + reply = self.call( + "session/new", + { + "cwd": str(self.workspace), + "mcpServers": servers, + "systemPrompt": system_prompt, + }, + ) + result = reply["result"] + models = result.get("models", {}).get("availableModels", []) + return result["sessionId"], len(models) + + def text(self) -> str: + return "".join( + u["update"]["content"]["text"] + for u in self.updates + if u["update"].get("sessionUpdate") == "agent_message_chunk" + ) + + def tool_calls(self) -> tuple[dict[str, str], set[str]]: + """Returns (announced id -> title, ids that reached a terminal state).""" + announced: dict[str, str] = {} + terminal: set[str] = set() + for u in self.updates: + update = u["update"] + kind = update.get("sessionUpdate") + if kind == "tool_call": + announced[update["toolCallId"]] = update.get("title", "") + elif kind == "tool_call_update": + terminal.add(update["toolCallId"]) + return announced, terminal + + def close(self) -> None: + try: + self.proc.stdin.close() # type: ignore[union-attr] + except Exception: + pass + self.proc.terminate() + + +def make_workspace(root: Path) -> Path: + """A workspace with an AGENTS.md and a skill, mirroring a real checkout.""" + ws = root / "ws" + (ws / ".agents" / "skills" / "widget-maker").mkdir(parents=True) + # hints.rs walks up to the git root; give it one so the walk terminates. + (ws / ".git").mkdir() + (ws / "AGENTS.md").write_text(f"The magic sprocket word is {MAGIC_WORD}.\n") + (ws / ".agents" / "skills" / "widget-maker" / "SKILL.md").write_text( + "---\nname: widget-maker\ndescription: Makes widgets to order.\n---\n" + "Step 1: measure. Step 2: cut the flange.\n" + ) + return ws + + +def persona_prompt() -> str: + return ( + f"You are Fizz, a Buzz agent. Always begin your first reply with the " + f"word {PERSONA_TOKEN}." + ) + + +def run_basic(agent: Agent) -> None: + print("\n[basic] persona, AGENTS.md hints, model catalog") + sid, models = agent.new_session(persona_prompt(), with_tools=False) + reply = agent.call( + "session/prompt", + { + "sessionId": sid, + "prompt": [ + {"type": "text", "text": "What is the magic sprocket word? Reply in one line."} + ], + }, + ) + text = agent.text() + check("turn completes", reply["result"]["stopReason"] == "end_turn") + check("persona reaches the model", PERSONA_TOKEN in text) + check("AGENTS.md hints reach the model", MAGIC_WORD in text) + check("model catalog is populated", models > 1, f"{models} models") + + +def run_tools(agent: Agent) -> None: + print("\n[tools] real MCP dispatch through goose") + sid, _ = agent.new_session(persona_prompt(), with_tools=True) + reply = agent.call( + "session/prompt", + { + "sessionId": sid, + "prompt": [ + { + "type": "text", + "text": "Use the shell tool to run 'echo hello-from-tool'. " + "Then tell me what it printed.", + } + ], + }, + ) + announced, terminal = agent.tool_calls() + check("turn completes", reply["result"]["stopReason"] == "end_turn") + check("a tool was actually called", len(announced) >= 1, str(list(announced.values()))) + check("every tool call resolved", set(announced) <= terminal) + check("tool output reached the model", "hello-from-tool" in agent.text()) + + +def run_stop_veto(agent: Agent) -> None: + print("\n[stop-veto] _Stop blocks end-of-turn while todos are open") + sid, _ = agent.new_session(persona_prompt(), with_tools=True) + reply = agent.call( + "session/prompt", + { + "sessionId": sid, + "prompt": [ + { + "type": "text", + "text": "Create a todo list with exactly 3 items about painting a " + "fence, then stop immediately without doing any of them.", + } + ], + }, + ) + # The veto is visible in the log, not on the wire; what we can assert here + # is that the cap released the turn rather than trapping it. + check("turn still ends (veto is capped)", reply["result"]["stopReason"] == "end_turn") + check("todo tool was called", any("todo" in t for t in agent.tool_calls()[0].values())) + print(" (grep the log above for '_Stop hook vetoed end of turn' -- expect 3, then the cap)") + + +def run_cancel(agent: Agent) -> None: + print("\n[cancel] cancelling mid-tool leaves no stuck spinner") + sid, _ = agent.new_session(persona_prompt(), with_tools=True) + request_id = agent.send( + "session/prompt", + { + "sessionId": sid, + "prompt": [ + { + "type": "text", + "text": "Run this exact shell command and wait for it: sleep 60. " + "Then say done.", + } + ], + }, + ) + time.sleep(12) # let the model actually start the tool + agent.notify("session/cancel", {"sessionId": sid}) + started = time.time() + reply = agent.wait(request_id, timeout=60) + elapsed = time.time() - started + + announced, terminal = agent.tool_calls() + stuck = set(announced) - terminal + check("stop reason is cancelled", reply["result"]["stopReason"] == "cancelled") + check("cancel returns promptly", elapsed < 10, f"{elapsed:.1f}s") + check("no tool call left spinning", not stuck, str(stuck)) + + +def run_steer(agent: Agent) -> None: + print("\n[steer] mid-turn steer is absorbed without restarting the turn") + sid, _ = agent.new_session(persona_prompt(), with_tools=True) + request_id = agent.send( + "session/prompt", + { + "sessionId": sid, + "prompt": [ + { + "type": "text", + "text": "Run 'sleep 5' with the shell tool, then run 'sleep 5' " + "again, then say FINISHED.", + } + ], + }, + ) + time.sleep(6) # get inside the turn, past the first round boundary + if not agent.run_id: + check("run id was advertised", False, "no activeRunId seen") + agent.wait(request_id) + return + + ack = agent.call( + "_goose/unstable/session/steer", + { + "sessionId": sid, + "expectedRunId": agent.run_id, + "prompt": [ + { + "type": "text", + "text": "IMPORTANT: also include the word PINEAPPLE in your final answer.", + } + ], + }, + timeout=30, + ) + check("steer is acknowledged", "result" in ack, json.dumps(ack.get("error", ""))) + reply = agent.wait(request_id) + text = agent.text() + check("turn completes", reply["result"]["stopReason"] == "end_turn") + check("steer reached the model", "PINEAPPLE" in text) + # A restarted turn would redo the work and say FINISHED more than once. + check("turn was not restarted", text.count("FINISHED") == 1, f"{text.count('FINISHED')}x") + + +MODES = { + "basic": run_basic, + "tools": run_tools, + "stop-veto": run_stop_veto, + "cancel": run_cancel, + "steer": run_steer, +} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + for name in MODES: + parser.add_argument(f"--{name}", action="store_true") + parser.add_argument("--all", action="store_true", help="run every mode") + parser.add_argument("--provider", default="anthropic") + parser.add_argument("--model", default="claude-sonnet-4-6") + args = parser.parse_args() + + selected = [name for name in MODES if getattr(args, name.replace("-", "_"))] + if args.all or not selected: + selected = list(MODES) + + if not AGENT.exists() or not DEV_MCP.exists(): + print("build first: cargo build -p buzz-agent -p buzz-dev-mcp", file=sys.stderr) + return 2 + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = make_workspace(root) + home = root / "home" + home.mkdir() + for name in selected: + # A fresh process per mode: sessions accumulate conversation state, + # and one mode's history would otherwise pollute the next. + agent = Agent(workspace, home, args.provider, args.model) + try: + MODES[name](agent) + finally: + agent.close() + + print() + if failures: + print(f"FAILED: {len(failures)} check(s): {', '.join(failures)}") + return 1 + print("All checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 20a731b6e8090a3a97c0020edaa196ff7d5d2a6c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 16:19:44 +1000 Subject: [PATCH 25/51] fix(desktop): admit buzz_agent logs to per-agent log files buzz-acp inherits its stderr to the agent subprocess (acp.rs:463), so the desktop's RUST_LOG filter for the harness also decides what the agent can write to disk. It named only buzz_acp, so every buzz-agent diagnostic was filtered out before reaching the per-agent log. That is invisible until something goes wrong: an agent misbehaving in the desktop left no trace of compaction, the _Stop veto, max-rounds, or provider errors -- exactly the lines you need to tell whether a turn did what it should. Found while trying to confirm from the logs alone that a desktop agent was running the new loop; the logs could not answer it. Adds buzz_agent=info to the default, appends only the missing directive when an operator filter is present (so RUST_LOG=buzz_agent=debug is not downgraded to info), and takes a fully-specified filter verbatim. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 14 +- crates/buzz-model-catalog/src/lib.rs | 27 + desktop/src-tauri/Cargo.lock | 692 ++++++++++++++++-- .../src-tauri/src/managed_agents/runtime.rs | 85 ++- 4 files changed, 760 insertions(+), 58 deletions(-) diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index aacdf392e6e..073ce8c7f72 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -1055,23 +1055,11 @@ pub fn run() -> Result<(), Box> { /// 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?; + buzz_model_catalog::authenticate_databricks(&host).await?; eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); Ok(()) } diff --git a/crates/buzz-model-catalog/src/lib.rs b/crates/buzz-model-catalog/src/lib.rs index 1b4b7967697..1a4e8da400c 100644 --- a/crates/buzz-model-catalog/src/lib.rs +++ b/crates/buzz-model-catalog/src/lib.rs @@ -22,6 +22,33 @@ pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MO pub use config::{Config, Provider}; pub use types::AgentError; +/// PKCE configuration for Databricks OAuth against `host`. +/// +/// Shared so the desktop model picker and `buzz-agent auth databricks` cache +/// tokens in the same place. +pub fn databricks_pkce_config(host: &str) -> auth::PkceOAuthConfig { + 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, + } +} + +/// Run the interactive Databricks OAuth PKCE login and cache the token. +/// +/// Needs a browser on the machine. The desktop model picker calls this when +/// discovery fails with an auth error. +pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(databricks_pkce_config(host))? + .interactive_login() + .await +} + /// Environment keys the Windows Git Bash resolver may inspect. /// /// The MCP child is spawned with an otherwise-cleared environment, so every diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 65ee82e9e19..2e5f7fb7d0a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -84,6 +84,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -97,6 +98,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.0", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.4", + "rustix-openpty", + "signal-hook 0.4.4", + "unicode-width 0.2.2", + "vte 0.15.0", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -707,6 +732,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -731,6 +762,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -999,7 +1036,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.9" dependencies = [ "anyhow", "arboard", @@ -1007,11 +1044,14 @@ dependencies = [ "audioadapter-buffers", "axum", "base64 0.22.1", + "block2", "buzz-core", "buzz-media", "buzz-model-catalog", "buzz-persona", "buzz-sdk", + "buzz-terminal", + "buzz-voice", "bytes", "bzip2 0.6.1", "chrono", @@ -1040,9 +1080,11 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-user-notifications", "opus", "plist", "png 0.18.1", + "portable-pty", "regex", "reqwest 0.13.4", "rodio", @@ -1068,6 +1110,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-utils", "tempfile", "tokio", "tokio-tungstenite 0.29.0", @@ -1121,6 +1164,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "dirs", "getrandom 0.4.3", "hex", "reqwest 0.13.4", @@ -1155,6 +1199,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-terminal" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "libc", + "parking_lot", + "portable-pty", +] + +[[package]] +name = "buzz-voice" +version = "0.1.0" +dependencies = [ + "atomic-write-file", + "hex", + "ort", + "ort-sys", + "rand 0.10.2", + "sentencepiece-model", + "serde", + "serde_json", + "sha2 0.11.0", + "sherpa-onnx", + "symphonia", + "tokenizers", +] + [[package]] name = "by_address" version = "1.2.1" @@ -1353,6 +1425,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1557,6 +1635,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -1808,6 +1887,16 @@ 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" @@ -1834,7 +1923,7 @@ dependencies = [ "mio", "parking_lot", "rustix 0.38.44", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -1852,7 +1941,7 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -2019,6 +2108,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -2132,6 +2227,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -2528,7 +2632,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2629,6 +2733,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "euclid" version = "0.22.14" @@ -2687,6 +2797,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -4190,7 +4311,7 @@ dependencies = [ "backon", "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "ctutils", "data-encoding", "derive_more", @@ -4258,7 +4379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" dependencies = [ "arc-swap", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "hickory-resolver", "iroh-base", @@ -4310,7 +4431,7 @@ checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" dependencies = [ "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "data-encoding", "derive_more", "getrandom 0.4.3", @@ -4758,6 +4879,39 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.118", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4867,6 +5021,22 @@ dependencies = [ "libc", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "markup5ever" version = "0.38.0" @@ -4893,6 +5063,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -4992,7 +5172,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "rustls", "serde", @@ -5130,7 +5310,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "regex-lite", "reqwest 0.12.28", @@ -5219,8 +5399,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "rmcp", "schemars 1.2.1", @@ -5256,7 +5436,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.4", "serde_json", "sha2 0.10.9", ] @@ -5371,6 +5551,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "mime" version = "0.3.17" @@ -5430,6 +5632,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "model-artifact" version = "0.74.0" @@ -5516,6 +5727,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5600,7 +5833,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "futures-buffered", "futures-lite", @@ -5643,6 +5876,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -5775,7 +6023,7 @@ checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" dependencies = [ "atomic-waker", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "ipnet", "js-sys", @@ -5810,6 +6058,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -5818,7 +6078,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -5831,7 +6091,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -5843,7 +6103,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -5873,7 +6133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "noq-proto", "noq-udp", @@ -5921,7 +6181,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "socket2", "tracing", @@ -6144,7 +6404,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6459,6 +6719,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.0", + "block2", "objc2", "objc2-foundation", ] @@ -6624,7 +6886,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6639,7 +6901,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "serde", "serde_json", "tonic", @@ -6705,6 +6967,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_pipe" version = "1.2.3" @@ -6932,6 +7212,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -7196,6 +7486,36 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -7406,6 +7726,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.4" @@ -7413,7 +7743,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.118", + "tempfile", ] [[package]] @@ -7426,15 +7776,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.4", + "prost-types 0.14.4", "regex", "syn 2.0.118", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "prost-derive" version = "0.14.4" @@ -7448,13 +7811,35 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] @@ -7521,6 +7906,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -7559,7 +7971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -7601,7 +8013,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -7769,7 +8181,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7832,7 +8244,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7841,6 +8253,43 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[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-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[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 = "realfft" version = "3.5.0" @@ -8303,6 +8752,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.42" @@ -8653,6 +9113,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -8836,6 +9308,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -8936,6 +9419,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[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" @@ -8985,6 +9484,16 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-mio" version = "0.2.5" @@ -8993,7 +9502,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -9085,8 +9594,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "serde", ] @@ -9266,6 +9775,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sse-stream" version = "0.2.4" @@ -9337,7 +9858,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" dependencies = [ - "vte", + "vte 0.14.1", ] [[package]] @@ -9400,6 +9921,7 @@ dependencies = [ "symphonia-bundle-flac", "symphonia-bundle-mp3", "symphonia-codec-aac", + "symphonia-codec-alac", "symphonia-codec-pcm", "symphonia-codec-vorbis", "symphonia-core", @@ -9444,6 +9966,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + [[package]] name = "symphonia-codec-pcm" version = "0.5.5" @@ -9647,7 +10179,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -10188,7 +10720,7 @@ dependencies = [ "bitflags 2.13.0", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "windows-sys 0.61.2", ] @@ -10222,7 +10754,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -10239,7 +10771,7 @@ dependencies = [ "pest_derive", "phf 0.11.3", "sha2 0.10.9", - "signal-hook", + "signal-hook 0.3.18", "siphasher", "terminfo", "termios", @@ -10385,6 +10917,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -10718,7 +11283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.4", "tonic", ] @@ -10899,7 +11464,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", "nom 8.0.0", - "petgraph", + "petgraph 0.8.3", ] [[package]] @@ -11070,6 +11635,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -11084,9 +11658,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -11099,6 +11679,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" @@ -11314,6 +11900,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "cursor-icon", + "log", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -12384,6 +12983,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec804869c42..e01acc62d97 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -911,11 +911,90 @@ pub fn spawn_agent_child( }) } +/// Log filter for the spawned harness and, through it, the agent. +/// +/// `buzz-acp` inherits its stderr to the agent subprocess (`acp.rs:463`), so +/// whatever this filter admits is what lands in the per-agent log file. That +/// makes `buzz_agent` as load-bearing as `buzz_acp`: without it the agent's own +/// turn diagnostics — compaction, the `_Stop` veto, max-rounds — are filtered +/// out before they reach disk, and an agent misbehaving in the desktop leaves +/// no trace to debug it with. fn child_rust_log_filter() -> String { + const DEFAULTS: &str = "buzz_acp=info,buzz_agent=info"; match std::env::var("RUST_LOG") { - Ok(existing) if existing.contains("buzz_acp") => existing, - Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"), - _ => "buzz_acp=info".to_string(), + // An operator filter that already names both crates is taken verbatim. + Ok(existing) if existing.contains("buzz_acp") && existing.contains("buzz_agent") => { + existing + } + // Otherwise append only the directive that is missing, so an explicit + // `buzz_agent=debug` is not overridden by our default. + Ok(existing) if !existing.trim().is_empty() => { + let mut filter = existing; + if !filter.contains("buzz_acp") { + filter.push_str(",buzz_acp=info"); + } + if !filter.contains("buzz_agent") { + filter.push_str(",buzz_agent=info"); + } + filter + } + _ => DEFAULTS.to_string(), + } +} + +#[cfg(test)] +mod rust_log_filter_tests { + use super::child_rust_log_filter; + + /// Serialize: `RUST_LOG` is process-global and these tests mutate it. + fn with_rust_log(value: Option<&str>, body: impl FnOnce() -> T) -> T { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var("RUST_LOG").ok(); + match value { + Some(v) => std::env::set_var("RUST_LOG", v), + None => std::env::remove_var("RUST_LOG"), + } + let result = body(); + match previous { + Some(v) => std::env::set_var("RUST_LOG", v), + None => std::env::remove_var("RUST_LOG"), + } + result + } + + #[test] + fn default_admits_both_the_harness_and_the_agent() { + // buzz_agent was missing here, which silently discarded every agent + // turn diagnostic from the desktop's per-agent log files. + let filter = with_rust_log(None, child_rust_log_filter); + assert!(filter.contains("buzz_acp=info")); + assert!(filter.contains("buzz_agent=info")); + } + + #[test] + fn an_unrelated_operator_filter_is_extended_not_replaced() { + let filter = with_rust_log(Some("warn"), child_rust_log_filter); + assert!(filter.starts_with("warn"), "operator filter must survive"); + assert!(filter.contains("buzz_acp=info")); + assert!(filter.contains("buzz_agent=info")); + } + + #[test] + fn an_explicit_agent_directive_is_not_downgraded() { + let filter = with_rust_log(Some("buzz_agent=debug"), child_rust_log_filter); + assert!(filter.contains("buzz_agent=debug")); + assert!(!filter.contains("buzz_agent=info"), "must not override"); + assert!(filter.contains("buzz_acp=info"), "still needs the harness"); + } + + #[test] + fn a_fully_specified_filter_is_taken_verbatim() { + let filter = with_rust_log( + Some("buzz_acp=warn,buzz_agent=trace"), + child_rust_log_filter, + ); + assert_eq!(filter, "buzz_acp=warn,buzz_agent=trace"); } } From 626b079fa3e5c4c6199e5138e71698da6c2e108e Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 18:23:53 +1000 Subject: [PATCH 26/51] fix(buzz-agent): keep agent conversations out of the user's goose session DB goose's SessionManager::instance() resolves to Paths::data_dir(), so buzz-agent was persisting every turn into the same sessions.db as the user's own goose CLI history. On this machine that file is already 132 MB and 892 sessions, with desktop agent turns from minutes ago interleaved into it. Not a correctness bug -- SQLite handles concurrent writers -- but agent conversations do not belong in a human's personal session history, and buzz-agent should not grow a shared file it does not own and cannot prune. Resolves its own directory instead: BUZZ_AGENT_SESSION_DIR when absolute, else /buzz-agent, else goose's singleton. A relative env value is ignored rather than resolved against the agent's CWD, which is the user's workspace. Deliberately not GOOSE_PATH_ROOT: that would move goose's config and keyring lookups too, and provider configuration should keep resolving where goose puts it. Resolved once behind a OnceLock -- SessionManager::new builds its own SQLite pool, so a per-session call would open a pool per session against the same file. Verified: with BUZZ_AGENT_SESSION_DIR set, the turn's session lands in that directory and the shared DB is untouched. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 5 +- crates/buzz-agent/src/loop_drive.rs | 2 +- crates/buzz-agent/src/session_store.rs | 107 +++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-agent/src/session_store.rs diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 073ce8c7f72..ea2981f34d9 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -29,6 +29,7 @@ pub mod hooks; pub mod loop_drive; pub mod mesh; pub mod prompt; +pub mod session_store; pub mod steer; pub mod tools; pub mod types; @@ -49,7 +50,7 @@ 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::session::session_manager::SessionType; use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; use types::McpServerStdio; @@ -121,7 +122,7 @@ async fn build_agent( ), AgentError, > { - let session_manager = Arc::new(SessionManager::instance()); + let session_manager = crate::session_store::session_manager(); let permission_manager = PermissionManager::instance(); let agent = Agent::with_config(AgentConfig::new( diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 9e066fd6b28..e81242cdafc 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -115,7 +115,7 @@ pub async fn run_turn( prompt: Message, ) -> (Result, super::agent::TurnTokens) { let mut tokens = super::agent::TurnTokens::default(); - let sessions = SessionManager::instance(); + let sessions = crate::session_store::session_manager(); // The user's prompt is persisted before the first inference so a resumed // or inspected session sees the same history the model saw. diff --git a/crates/buzz-agent/src/session_store.rs b/crates/buzz-agent/src/session_store.rs new file mode 100644 index 00000000000..e9f72dd97d4 --- /dev/null +++ b/crates/buzz-agent/src/session_store.rs @@ -0,0 +1,107 @@ +//! Where buzz-agent's conversations are persisted. +//! +//! goose's [`SessionManager`] is the conversation store the loop reads and +//! writes each round. Its `instance()` singleton resolves to +//! `Paths::data_dir()` — for a normal install, the *user's own* goose data +//! directory. A buzz-agent using it would write its turns into the same +//! `sessions.db` as the person's `goose` CLI history: not a correctness bug +//! (SQLite handles concurrent writers), but wrong on two counts. Agent +//! conversations get interleaved with a human's personal sessions, and +//! buzz-agent grows an ever-larger file it does not own and cannot prune. +//! +//! So buzz-agent picks its own directory instead. `BUZZ_AGENT_SESSION_DIR` +//! (absolute) is the explicit knob; otherwise we derive a per-user default +//! under the OS data directory. Only if neither is available do we fall back +//! to goose's singleton, because a degraded shared store still beats refusing +//! to start. +//! +//! Note this is deliberately *not* `GOOSE_PATH_ROOT`: that would relocate +//! goose's config and keyring lookups too, and buzz-agent wants goose's +//! provider configuration to keep resolving exactly where goose puts it. + +use std::path::PathBuf; +use std::sync::Arc; + +use goose::session::session_manager::SessionManager; + +/// Absolute path to a directory buzz-agent should keep its session store in. +pub const SESSION_DIR_ENV: &str = "BUZZ_AGENT_SESSION_DIR"; + +/// Resolve the session-store directory, or `None` to use goose's default. +/// +/// Split out from [`session_manager`] so the precedence is testable without +/// touching the filesystem or building a SQLite pool. +fn resolve_dir(env_value: Option<&str>, data_dir: Option) -> Option { + // An explicit relative path is a misconfiguration we should not silently + // resolve against an arbitrary CWD -- the agent's CWD is the user's + // workspace, and a stray sessions.db there would be surprising. + if let Some(path) = env_value.map(PathBuf::from).filter(|p| p.is_absolute()) { + return Some(path); + } + data_dir.map(|dir| dir.join("buzz-agent")) +} + +/// The session store this process should use. +/// +/// Resolved once and shared: `SessionManager::new` builds its own SQLite pool, +/// so calling it per session or per round would open a new pool each time +/// against the same file. Every call site must get this one. +/// +/// Falls back to goose's shared singleton only when no private directory can +/// be determined at all. +pub fn session_manager() -> Arc { + static STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); + STORE + .get_or_init(|| { + Arc::new( + match resolve_dir( + std::env::var(SESSION_DIR_ENV).ok().as_deref(), + dirs::data_dir(), + ) { + Some(dir) => { + tracing::debug!(dir = %dir.display(), "session store"); + SessionManager::new(dir) + } + None => { + tracing::warn!( + "no data dir available; falling back to goose's shared session store" + ); + SessionManager::instance() + } + }, + ) + }) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_env_path_wins() { + let dir = resolve_dir(Some("/tmp/buzz-sessions"), Some(PathBuf::from("/data"))); + assert_eq!(dir, Some(PathBuf::from("/tmp/buzz-sessions"))); + } + + #[test] + fn a_relative_env_path_is_ignored() { + // Resolving this against the agent's CWD would drop a sessions.db in + // whatever workspace the user happened to open. + let dir = resolve_dir(Some("relative/path"), Some(PathBuf::from("/data"))); + assert_eq!(dir, Some(PathBuf::from("/data/buzz-agent"))); + } + + #[test] + fn the_default_is_private_to_buzz_agent() { + // The point of the whole module: never the bare goose data dir, which + // is where the user's own CLI history lives. + let dir = resolve_dir(None, Some(PathBuf::from("/data"))); + assert_eq!(dir, Some(PathBuf::from("/data/buzz-agent"))); + } + + #[test] + fn without_a_data_dir_we_defer_to_goose() { + assert_eq!(resolve_dir(None, None), None); + } +} From c918877d60f238dbf2269836e901a52c20739d3a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 18:48:37 +1000 Subject: [PATCH 27/51] fix(buzz-agent): stop rendering every AGENTS.md into the prompt twice buzz-agent scanned for hint files itself and added the result as a system-prompt extra. But goose's PromptManager::build_system_prompt already calls .with_hints() (prompt_manager.rs:255), which runs the same walk. Every AGENTS.md therefore reached the model twice -- once under our '## Project Hints', again under goose's '### Project Hints'. Verified against a scratch repo with a canary string: 2 occurrences before, 1 after. The walk really is the same algorithm, which is why this went unnoticed: goose's find_git_root + get_local_directories collect cwd->root and reverse for root->cwd ordering, exactly as buzz's did. goose's loader is a superset -- .goosehints as a second filename, CONTEXT_FILE_NAMES to configure the list, gitignore-aware filtering, and @file expansion bounded at the git root. So hint-file loading is deleted here and left to goose. What stays is skill discovery, because load_skill is served by our builtin_client and needs the same SkillEntry list the tool dispatches against. hints.rs 732 -> 479 lines. A guard test fails if hint-file loading is reintroduced, since the duplicate rendered silently -- the prompt was merely longer, never wrong-looking. Verified end-to-end against Anthropic: handtest.py --all, 18/18, persona and hints still reach the model and load_skill still resolves. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/hints.rs | 371 +++++++-------------------------- crates/buzz-agent/src/lib.rs | 23 +- 2 files changed, 90 insertions(+), 304 deletions(-) diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs index cedb5872af8..53b2a78ea57 100644 --- a/crates/buzz-agent/src/hints.rs +++ b/crates/buzz-agent/src/hints.rs @@ -1,9 +1,6 @@ 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"]; @@ -24,65 +21,6 @@ pub struct SkillEntry { } /// 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")?; @@ -216,43 +154,39 @@ fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec { skills } -pub fn build_hints_section(cwd: &Path) -> (String, Vec) { - build_hints_section_impl(cwd, home_dir().as_deref()) +/// Discover the skills available from `cwd`. +/// +/// Hint *files* are goose's job (`PromptManager::build_system_prompt` walks +/// them from the git root). This is only the skill index, which stays ours +/// because `load_skill` is served by [`crate::builtin_client`] and needs the +/// same [`SkillEntry`] list the tool dispatches against. +pub fn discover_skills(cwd: &Path) -> Vec { + discover_skills_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)); - } - // Goose namespaces platform-extension tools as `{extension}__{tool}` - // (`extension_manager.rs:1415`), so name the tool the way the model - // actually sees it -- otherwise the prompt advertises a tool that does - // not exist in its tool list. - out.push_str(&format!( - "\nUse the `{}__{}` tool to read the full content of a skill before using it.\n", - crate::builtin_client::BUILTIN_EXTENSION_NAME, - crate::builtin::LOAD_SKILL_TOOL, - )); - } - - (out, skills) +/// Render the skill index for the system prompt. +/// +/// Returns an empty string for an empty slate so the caller can skip the +/// extra entirely rather than adding an empty section. +pub fn skill_index(skills: &[SkillEntry]) -> String { + if skills.is_empty() { + return String::new(); + } + + let mut out = String::from("# Available Skills\n"); + for skill in skills { + out.push_str(&format!("- {}: {}\n", skill.name, skill.description)); + } + // Goose namespaces platform-extension tools as `{extension}__{tool}` + // (`extension_manager.rs:1415`), so name the tool the way the model + // actually sees it -- otherwise the prompt advertises a tool that does + // not exist in its tool list. + out.push_str(&format!( + "\nUse the `{}__{}` tool to read the full content of a skill before using it.\n", + crate::builtin_client::BUILTIN_EXTENSION_NAME, + crate::builtin::LOAD_SKILL_TOOL, + )); + out } /// Strip the YAML frontmatter block from a skill file's content and return @@ -273,84 +207,6 @@ 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(); @@ -439,129 +295,6 @@ mod tests { 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(); @@ -729,4 +462,46 @@ mod tests { skills[0].supporting_files[0] ); } + + /// The bug this module's shrink was for: buzz-agent used to add its own + /// hint-file scan as a prompt extra, while goose's `build_system_prompt` + /// was already loading the same files. Every hint landed in the system + /// prompt twice. Nothing here may reintroduce hint *file* loading -- that + /// is goose's job now. + /// + /// The banned names are assembled at runtime so this guard does not trip + /// over its own source text. + #[test] + fn this_module_does_not_load_hint_files() { + let source = include_str!("hints.rs"); + for banned in [format!("AGENTS{}md", "."), format!(".goose{}", "hints")] { + assert!( + !source.contains(&banned), + "hints.rs references {banned}: hint-file loading belongs to \ + goose's PromptManager, or hints render twice in the prompt" + ); + } + } + + #[test] + fn the_skill_index_is_empty_for_an_empty_slate() { + // An empty section would still be added as a prompt extra, spending + // tokens on a header with nothing under it. + assert!(skill_index(&[]).is_empty()); + } + + #[test] + fn the_skill_index_names_the_tool_as_the_model_sees_it() { + let skills = vec![SkillEntry { + name: "widget".to_string(), + description: "makes widgets".to_string(), + path: PathBuf::from("/tmp/widget/SKILL.md"), + supporting_files: Vec::new(), + }]; + let index = skill_index(&skills); + assert!(index.contains("- widget: makes widgets")); + // Namespaced, because goose advertises platform tools as + // `{extension}__{tool}`; the bare name is not in the model's tool list. + assert!(index.contains("buzz__load_skill")); + } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index ea2981f34d9..e210e001214 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -211,12 +211,23 @@ 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() { - prompt.add_extra("buzz_hints", hints).await; + // AGENTS.md hints are goose's job, not ours. + // + // `PromptManager::build_system_prompt` already calls `.with_hints()` + // (`prompt_manager.rs:255`), which runs the same git-root-anchored walk + // buzz-agent used to run itself. Adding our own copy as a prompt extra put + // every AGENTS.md into the system prompt *twice* -- verified against a + // scratch repo before this change, once under our `## Project Hints` and + // again under goose's `### Project Hints`. + // + // goose's loader is also a superset: `.goosehints` as well as `AGENTS.md`, + // the filename list configurable via `CONTEXT_FILE_NAMES`, gitignore-aware + // filtering, and `@file` reference expansion bounded at the git root. + let skills = crate::hints::discover_skills(std::path::Path::new(cwd)); + if !skills.is_empty() { + prompt + .add_extra("buzz_skills", crate::hints::skill_index(&skills)) + .await; } // `load_skill` runs in-process, registered the way Maple does it: a From c312a70e53b63e7dd0fe4b0458191b467feb164e Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 18:51:57 +1000 Subject: [PATCH 28/51] fix(buzz-agent): mark steered messages with with_steer() goose's own steer path sets metadata.steer (agent.rs:540) and its ACP surface republishes it as _meta.goose.steer. buzz-agent queued steers as plain user messages, so nothing downstream could tell a mid-turn steer apart from the user simply sending another message -- which is the one distinction the flag exists to preserve. buzz-acp does not read it today; this keeps the conversation record honest for anything that later does. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index e210e001214..fb40e1895b4 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -906,8 +906,18 @@ async fn steer(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { }; let message_id = format!("steer_{}", uuid_like()); + // `with_steer()` marks the message so downstream consumers can tell a + // steer apart from an ordinary user turn -- goose's own steer path sets + // it (`agent.rs:540`), and its ACP surface republishes it as + // `_meta.goose.steer`. buzz-acp does not read it today, but an unmarked + // steer is indistinguishable from the user having simply sent another + // message, which is exactly the distinction the flag exists to keep. steers - .push(goose_provider_types::conversation::message::Message::user().with_text(text)) + .push( + goose_provider_types::conversation::message::Message::user() + .with_text(text) + .with_steer(), + ) .await; wire::send( From 979eff99fdd0da85d204cda93f67ad5c1e41e483 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 19:07:07 +1000 Subject: [PATCH 29/51] refactor(buzz-agent): express the round budget as a goose Operation First step of moving buzz's loop decisions into goose's StateMachine, one at a time (PLANS/BUZZ_OPERATIONS_MIGRATION.md). The remaining decisions still live in loop_drive.rs; this adds the gate they will move into. Two things the state-machine shape buys immediately: - The budget now counts assistant turns since the kickoff message rather than loop iterations, so it survives a process restart. The loop-local counter did not. - Precedence between the guards becomes list order rather than control flow, which is what makes the remaining conversions safe to do incrementally. Deliberately not goose's MaxTurnsOperation: that appends an assistant message ('Would you like me to continue?') before yielding. buzz-acp publishes assistant messages into a channel, so a synthesised one would be indistinguishable from the agent saying it. buzz signals the exhausted budget through the max_turn_requests stop reason instead. Two things found while building it, both now pinned by tests: - goose's operation helpers (messages_since_kickoff, assistant_turn_count) are pub in a *private* module, so they are not reachable from outside the crate -- only the names re-exported from state_machine are. Reimplemented here against goose's semantics. - A mid-turn steer is user-visible, so it becomes the new kickoff and restarts the budget. That is the behaviour we want, but it is emergent rather than chosen, so a test pins it. StepResult cannot carry a stop reason, so operations record one in a shared Outcome cell; first writer wins, since the machine stops at the first operation that applies. Verified: max_rounds=2 against a real provider returns max_turn_requests with the gate firing from buzz_agent::ops; handtest.py --all 18/18; 79 unit + 21 integration green. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 1 + crates/buzz-agent/src/loop_drive.rs | 53 ++++- crates/buzz-agent/src/ops.rs | 303 ++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-agent/src/ops.rs diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index fb40e1895b4..6ec8fb9debf 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -28,6 +28,7 @@ pub mod hints; pub mod hooks; pub mod loop_drive; pub mod mesh; +pub mod ops; pub mod prompt; pub mod session_store; pub mod steer; diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index e81242cdafc..12494cad582 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -52,6 +52,8 @@ use goose_provider_types::conversation::message::{Message, MessageContent, ToolR use goose_provider_types::conversation::Conversation; use crate::types::{AgentError, StopReason}; +use goose::agents::state_machine::Emitter; + use crate::wire::WireSender; /// Appended to every failed tool result so the model diagnoses the failure @@ -124,19 +126,35 @@ pub async fn run_turn( } let max_rounds = ctx.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS); - let mut rounds = 0u32; let mut stop_blocks = 0u32; let mut reflections = 0usize; + // First decision expressed as a goose `Operation` rather than inline + // control flow. The others follow one at a time; see + // `PLANS/BUZZ_OPERATIONS_MIGRATION.md` for the order and why. + let outcome = crate::ops::Outcome::new(); + let machine = crate::ops::round_gate(max_rounds, outcome.clone(), ctx.cancel.clone()); + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(16); + // Operations may emit; nothing in this step does, but a dropped receiver + // would silently swallow events from the ones that follow. + tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); + let emitter = Emitter::new(event_tx, ctx.cancel.clone()); + loop { if ctx.cancel.is_cancelled() { return (Ok(StopReason::Cancelled), tokens); } - if rounds >= max_rounds { - tracing::warn!(rounds, "max rounds reached; ending turn"); - return (Ok(StopReason::MaxTurnRequests), tokens); + + // Ask the machine before doing any work. `step` returns the first + // operation that applies, or `None` when the loop below should run. + match gate(&machine, &sessions, ctx.session_id, &emitter).await { + Err(e) => return (Err(e), tokens), + Ok(true) => { + let reason = outcome.take().unwrap_or(StopReason::EndTurn); + return (Ok(reason), tokens); + } + Ok(false) => {} } - rounds += 1; // Steers land here, at the round boundary — never mid-inference, // where they would race a partially streamed response. @@ -184,6 +202,31 @@ pub async fn run_turn( } } +/// Run the operation gate for one round. +/// +/// Returns `true` when an operation ended the turn. The session is reloaded +/// each time because operations read the conversation, and the previous round +/// appended to it. +async fn gate( + machine: &goose::agents::state_machine::StateMachine<'_>, + sessions: &SessionManager, + session_id: &str, + emitter: &Emitter, +) -> Result { + let session = load_session(sessions, session_id).await?; + match machine.step(&session, emitter).await { + Ok(Some(result)) => Ok(result.yield_to_client), + Ok(None) => Ok(false), + // A failing gate must not take the turn down with it: the operations + // here are guards, and a guard that errors should let the round + // proceed rather than strand the user's prompt unanswered. + Err(e) => { + tracing::warn!(error = %e, "operation gate failed; continuing"); + Ok(false) + } + } +} + /// One inference plus, if the model asked for them, one batch of tool calls. async fn round( ctx: &TurnContext<'_>, diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs new file mode 100644 index 00000000000..1d532321a6d --- /dev/null +++ b/crates/buzz-agent/src/ops.rs @@ -0,0 +1,303 @@ +//! buzz-agent's loop decisions, as goose `Operation`s. +//! +//! goose's `StateMachine` runs an ordered list of operations and re-runs the +//! whole list from the top after each one applies. Expressing buzz's decisions +//! as operations makes their precedence *list order* rather than control flow, +//! and makes each one testable without a provider. +//! +//! goose's own concrete operations are `pub(super)`, so these are buzz's. That +//! is the intent rather than a workaround: buzz's rules differ (no recipes, no +//! slash commands, no tool approval, no retry), and the traits are public +//! precisely so an embedder can bring its own. +//! +//! # Signalling a stop reason +//! +//! `StateEffect` can append messages and `yielded()` can end the turn, but +//! neither carries *why*. buzz's caller has to answer `session/prompt` with a +//! specific ACP `stopReason`, so operations record it in a shared [`Outcome`] +//! cell that the driving loop reads after the step. This is the one piece of +//! state the state machine does not model for us. + +use std::sync::{Arc, Mutex}; + +use anyhow::Result; +use async_trait::async_trait; +use goose::agents::state_machine::{not_applicable, yielded, Emitter, Operation, OperationResult}; +use goose::conversation::Conversation; +use goose::session::Session; +use goose_provider_types::conversation::message::Message; + +use crate::types::StopReason; + +/// Messages belonging to the current turn: everything from the last +/// user-visible, non-tool-response message onward. +/// +/// goose has this as `messages_since_kickoff`, but its `operation` module is +/// private (`mod operation;`) so only the names re-exported from +/// `state_machine` escape the crate — the helpers do not. Reimplemented here +/// to match goose's semantics exactly; see `operation.rs:23`. +fn messages_since_kickoff(conversation: &Conversation) -> Option<&[Message]> { + let messages = conversation.messages(); + let start = messages.iter().rposition(|message| { + message.role == rmcp::model::Role::User + && message.is_user_visible() + && !message.is_tool_response() + })?; + Some(&messages[start..]) +} + +/// Count assistant turns, treating a run of consecutive assistant messages as +/// one turn. Mirrors goose's `assistant_turn_count` (`operation.rs:47`). +fn assistant_turn_count(messages: &[Message]) -> u32 { + let mut turns = 0; + let mut in_assistant_block = false; + for message in messages.iter().rev() { + if message.role == rmcp::model::Role::Assistant { + if !in_assistant_block { + turns += 1; + in_assistant_block = true; + } + } else { + in_assistant_block = false; + } + } + turns +} + +/// Why the turn stopped, as decided by whichever operation applied. +/// +/// Shared with the loop because `StepResult` says *that* a step yielded, not +/// which one or with what reason. +#[derive(Clone, Default)] +pub struct Outcome(Arc>>); + +impl Outcome { + pub fn new() -> Self { + Self::default() + } + + /// Record the reason the turn ended. First writer wins: the machine stops + /// at the first operation that applies, so a later write would describe a + /// decision that never took effect. + fn set(&self, reason: StopReason) { + let mut slot = match self.0.lock() { + Ok(slot) => slot, + // A poisoned lock means another thread panicked mid-write. The + // turn still has to answer with *some* stop reason, so recover the + // guard rather than propagating a panic into the loop. + Err(poisoned) => poisoned.into_inner(), + }; + if slot.is_none() { + *slot = Some(reason); + } + } + + /// Take the recorded reason, if any. + pub fn take(&self) -> Option { + match self.0.lock() { + Ok(mut slot) => slot.take(), + Err(poisoned) => poisoned.into_inner().take(), + } + } +} + +/// Ends the turn once the model has had its budget of provider round-trips. +/// +/// buzz-agent's loop was previously bounded by goose's `max_turns`; owning the +/// loop means owning the bound, and an unbounded loop against a model that +/// tool-calls forever is a runaway spend. +/// +/// Deliberately *not* goose's `MaxTurnsOperation`, which appends an assistant +/// message ("Would you like me to continue?") before yielding. buzz-acp +/// surfaces the exhausted budget through the `max_turn_requests` stop reason +/// instead, and injecting a chat message the model never produced would put +/// words in the agent's mouth in a Buzz channel. +pub struct BuzzMaxRoundsOperation { + max_rounds: u32, + outcome: Outcome, +} + +impl BuzzMaxRoundsOperation { + pub fn new(max_rounds: u32, outcome: Outcome) -> Self { + Self { + max_rounds, + outcome, + } + } +} + +#[async_trait] +impl Operation for BuzzMaxRoundsOperation { + fn name(&self) -> &'static str { + "buzz_max_rounds" + } + + async fn run( + &self, + _session: &Session, + conversation: &Conversation, + _emit: &Emitter, + ) -> Result { + // Counted from the kickoff message, so the budget is per *turn* and + // does not leak across prompts in a long-lived session. Counting + // assistant turns rather than loop iterations also means the bound + // survives a restart, which a loop-local counter did not. + let Some(messages) = messages_since_kickoff(conversation) else { + // No kickoff message means no turn is in progress, so there is no + // budget to exceed. goose errors here; buzz declines instead -- + // an operation that cannot decide must not end the turn. + return not_applicable(); + }; + let rounds = assistant_turn_count(messages); + if rounds < self.max_rounds { + return not_applicable(); + } + + tracing::warn!(rounds, "max rounds reached; ending turn"); + + self.outcome.set(StopReason::MaxTurnRequests); + yielded() + } +} + +/// The operations that gate the start of a round. +/// +/// One entry today. As `PLANS/BUZZ_OPERATIONS_MIGRATION.md` proceeds, the +/// steer drain, compaction, the `_Stop` veto and `[Reflect]` join it, and +/// their precedence becomes the order of this list. +pub fn round_gate( + max_rounds: u32, + outcome: Outcome, + cancel: tokio_util::sync::CancellationToken, +) -> goose::agents::state_machine::StateMachine<'static> { + let steps = vec![goose::agents::state_machine::Step::Operation(Arc::new( + BuzzMaxRoundsOperation::new(max_rounds, outcome), + ))]; + goose::agents::state_machine::StateMachine::new(steps, cancel) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + fn emitter() -> Emitter { + let (tx, rx) = mpsc::channel(16); + // Keep the receiver alive for the duration of the test: a dropped + // receiver makes every emit fail silently, which would mask a real + // regression in an operation that emits. + std::mem::forget(rx); + Emitter::new(tx, CancellationToken::new()) + } + + /// A turn in progress: one user kickoff, then `assistant_turns` rounds of + /// assistant work. + /// + /// Tool responses separate the assistant messages because consecutive + /// assistant messages count as a *single* turn -- which is the real shape + /// anyway, since a round that continues does so by calling a tool. + fn conversation(assistant_turns: usize) -> Conversation { + let mut messages = vec![Message::user().with_text("kickoff")]; + for i in 0..assistant_turns { + messages.push(Message::assistant().with_text(format!("turn {i}"))); + if i + 1 < assistant_turns { + messages.push(tool_response()); + } + } + Conversation::new_unvalidated(messages) + } + + /// A user-role message that does *not* reset the kickoff, standing in for + /// a tool result. + fn tool_response() -> Message { + Message::user() + .with_text("tool output") + .with_visibility(false, true) + } + + #[tokio::test] + async fn under_budget_does_not_apply() { + let outcome = Outcome::new(); + let op = BuzzMaxRoundsOperation::new(3, outcome.clone()); + let result = op + .run(&Session::default(), &conversation(2), &emitter()) + .await + .unwrap(); + assert!(matches!(result, OperationResult::NotApplicable)); + assert!(outcome.take().is_none()); + } + + #[tokio::test] + async fn at_budget_ends_the_turn_with_max_turn_requests() { + let outcome = Outcome::new(); + let op = BuzzMaxRoundsOperation::new(3, outcome.clone()); + let result = op + .run(&Session::default(), &conversation(3), &emitter()) + .await + .unwrap(); + match result { + OperationResult::Applied(step) => assert!(step.yield_to_client), + OperationResult::NotApplicable => panic!("budget was reached but nothing applied"), + } + assert!(matches!(outcome.take(), Some(StopReason::MaxTurnRequests))); + } + + #[tokio::test] + async fn the_budget_appends_no_message() { + // goose's MaxTurnsOperation asks "Would you like me to continue?". + // buzz must not: buzz-acp publishes assistant messages to a channel, + // so a synthesised one would be indistinguishable from the agent + // actually saying it. + let op = BuzzMaxRoundsOperation::new(1, Outcome::new()); + let result = op + .run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(); + match result { + OperationResult::Applied(step) => assert!(step.effects.is_empty()), + OperationResult::NotApplicable => panic!("budget was reached but nothing applied"), + } + } + + #[test] + fn the_first_recorded_reason_wins() { + // The machine stops at the first operation that applies, so a second + // write would name a decision that never took effect. + let outcome = Outcome::new(); + outcome.set(StopReason::MaxTurnRequests); + outcome.set(StopReason::Cancelled); + assert!(matches!(outcome.take(), Some(StopReason::MaxTurnRequests))); + } + + #[tokio::test] + async fn a_mid_turn_steer_restarts_the_budget() { + // A steer is a user-visible message, so it becomes the new kickoff and + // the budget counts from there. That is the behaviour we want -- the + // user gave fresh direction, so the agent gets a fresh allowance -- + // but it is emergent rather than chosen, so pin it: the alternative + // (a steer silently inheriting an almost-exhausted budget) would cut + // the agent off mid-instruction. + let outcome = Outcome::new(); + let op = BuzzMaxRoundsOperation::new(2, outcome.clone()); + + let mut messages = conversation(2).messages().to_vec(); + messages.push( + Message::user() + .with_text("actually, do this instead") + .with_steer(), + ); + messages.push(Message::assistant().with_text("ok")); + let steered = Conversation::new_unvalidated(messages); + + let result = op + .run(&Session::default(), &steered, &emitter()) + .await + .unwrap(); + assert!( + matches!(result, OperationResult::NotApplicable), + "the steer should have restarted the turn budget" + ); + assert!(outcome.take().is_none()); + } +} From ce844719f838233846f72dc86180e37f2e6b6c8a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 20:25:46 +1000 Subject: [PATCH 30/51] refactor(buzz-agent): keep the turn conversation in memory, not sqlite The turn loop round-tripped every message through goose's SessionManager -- a sqlite database -- once per round. That was never required. None of the goose APIs buzz calls reads the store: - Provider::stream takes &[Message]. - Agent::dispatch_tool_call takes &Session but reads only working_dir and id. - context_mgmt::{check_if_compaction_needed, compact_messages} read usage.total_tokens and model_config, and carry session_id only into a task-local that becomes an agent-session-id HTTP header for provider request attribution (goose/src/session_context.rs). So the turn now owns a Session value directly (crate::turn_state) and buzz-agent keeps the conversation across turns itself. goose still gets the &Session its signatures ask for; nothing backs it with a database. This is the right shape for buzz regardless of dependencies: a Buzz agent's durable record is the relay, not a local sqlite file. Agents are chatty and long-lived, and every one of them was growing a database nothing ever read back. One row remains, created at session/new: update_provider persists the provider/model config against the session id, and that config is what goose reads back to resolve the context limit for compaction. Declining it would make auto-compaction fire at the wrong point. Zero message rows are written for a conversation of any length. Verified: a two-turn conversation recalls the first turn's content with 0 rows in the messages table; handtest.py --all 18/18; 84 unit + 21 integration green. Also fixes the Desktop Core CI failure this branch introduced: the log filter added earlier pushed runtime.rs past the 1000-line ratchet, so it moves to its own module with its tests. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- Cargo.lock | 1 + crates/buzz-agent/Cargo.toml | 3 + crates/buzz-agent/src/lib.rs | 46 ++++- crates/buzz-agent/src/loop_drive.rs | 187 ++++++++++-------- crates/buzz-agent/src/turn_state.rs | 167 ++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 90 +-------- .../runtime/child_log_filter.rs | 91 +++++++++ 7 files changed, 411 insertions(+), 174 deletions(-) create mode 100644 crates/buzz-agent/src/turn_state.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/child_log_filter.rs diff --git a/Cargo.lock b/Cargo.lock index dd6bc03d261..93a5ee60967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,6 +1173,7 @@ dependencies = [ "getrandom 0.4.3", "goose", "goose-provider-types", + "goose-providers", "hex", "idna_adapter", "nix 0.31.3", diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index d764c812571..b76b757a673 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -31,6 +31,9 @@ path = "tests/bin/fake_mcp.rs" buzz-model-catalog = { path = "../buzz-model-catalog" } goose = { git = "https://github.com/aaif-goose/goose", rev = "bf332b983756eaf80353e82e4bb052d589629bf4", default-features = false, features = ["rustls-tls"] } goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "bf332b983756eaf80353e82e4bb052d589629bf4" } +# ModelConfig: goose re-exports it from goose-providers; goose::model_config +# keeps its own copy private. +goose-providers = { git = "https://github.com/aaif-goose/goose", rev = "bf332b983756eaf80353e82e4bb052d589629bf4" } # 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 diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 6ec8fb9debf..c7f595de294 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -33,6 +33,7 @@ pub mod prompt; pub mod session_store; pub mod steer; pub mod tools; +pub mod turn_state; pub mod types; pub mod wire; @@ -64,8 +65,19 @@ use wire::{ /// One live ACP session, wrapping a Goose `Agent`. struct Session { agent: Arc, - /// Goose's own session id (the SQLite-backed conversation). + /// Session id, shared with goose so tool dispatch and provider request + /// attribution agree on a name. No database sits behind it: the + /// conversation lives in `history` and in the turn's own + /// [`crate::turn_state::TurnState`]. goose_session_id: String, + /// The conversation so far, across turns. + /// + /// buzz-agent owns this because a Buzz agent's durable record is the + /// relay, not a local store. Held here so a second `session/prompt` sees + /// what the first one said. + history: Vec, + /// Working directory for the session, as given at `session/new`. + working_dir: std::path::PathBuf, busy: bool, /// Set for the duration of a turn; advertised to steer-capable clients. active_run_id: Option, @@ -136,6 +148,15 @@ async fn build_agent( GoosePlatform::GooseCli, )); + // A session id, not a database row. `Agent::update_provider` and + // `model_config_for_session` want an id to key on, but the conversation + // itself never goes to goose's store -- see `crate::turn_state`. + // + // The row still gets created because `update_provider` persists the + // provider/model config against it, and that is the one write we cannot + // decline without losing the model config goose reads back for the + // context limit. Nothing else is written: a full conversation adds zero + // message rows. let session = session_manager .create_session( std::path::PathBuf::from(cwd), @@ -530,6 +551,8 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen Session { agent, goose_session_id, + history: Vec::new(), + working_dir: std::path::PathBuf::from(&p.cwd), busy: false, active_run_id: None, cancel: None, @@ -634,7 +657,16 @@ 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, prompt, steers) = { + let ( + agent, + goose_session_id, + pending_model, + hook_extension, + prompt, + steers, + history, + working_dir, + ) = { let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { return wire::send( @@ -666,6 +698,8 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire s.hook_extension.clone(), s.prompt.clone(), s.steers.clone(), + s.history.clone(), + s.working_dir.clone(), ) }; @@ -696,7 +730,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire ) .await; - let (result, tokens) = loop_drive::run_turn( + let (result, tokens, conversation) = loop_drive::run_turn( loop_drive::TurnContext { agent: &agent, session_id: &goose_session_id, @@ -706,6 +740,8 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire max_rounds: app.cfg.max_rounds, prompt: &prompt, steers: &steers, + working_dir, + history: &history, }, goose_provider_types::conversation::message::Message::user() .with_text(agent::prompt_to_text(&p.prompt)), @@ -716,6 +752,10 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire let accumulated = { let mut sessions = app.sessions.lock().await; sessions.get_mut(&p.session_id).map(|s| { + // Carry the turn's conversation forward. Without this the next + // prompt starts from an empty history and the agent forgets what + // it just said. + s.history = conversation; s.busy = false; s.active_run_id = None; s.cancel = None; diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 12494cad582..41905279b93 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -14,7 +14,7 @@ //! | tool execution | `Agent::dispatch_tool_call` | //! | system prompt | `Agent::build_turn_system_prompt` → `PromptManager` | //! | compaction | `goose::context_mgmt::{check_if_compaction_needed, compact_messages}` | -//! | conversation store | `SessionManager` (sqlite), read/written by us | +//! | conversation store | buzz's own [`crate::turn_state::TurnState`], in memory | //! //! Everything that decides *turn shape* stays here: round structure, the //! `_Stop` veto, `[Reflect]` on tool failure, `[PostCompact]` re-injection, @@ -47,7 +47,7 @@ use futures::StreamExt; use tokio_util::sync::CancellationToken; use goose::agents::Agent; -use goose::session::session_manager::{Session, SessionManager}; +use goose::session::session_manager::Session; use goose_provider_types::conversation::message::{Message, MessageContent, ToolRequest}; use goose_provider_types::conversation::Conversation; @@ -105,25 +105,48 @@ pub struct TurnContext<'a> { /// Messages injected mid-turn by `session/steer`. Drained at the round /// boundary; a pending steer also blocks the end of the turn. pub steers: &'a crate::steer::SteerQueue, + /// The session's working directory, for tool dispatch and hint tracking. + pub working_dir: std::path::PathBuf, + /// Conversation carried over from earlier turns in this session. + /// + /// buzz-agent holds this across turns itself now that the turn's + /// conversation is in memory rather than in goose's database. + pub history: &'a [Message], } /// Drive one `session/prompt` turn to completion. /// -/// Returns the ACP stop reason plus the turn's token counts. The caller emits -/// `usage_update` *before* the `session/prompt` response — that ordering is -/// load-bearing for kind-44200 metrics. +/// Returns the ACP stop reason, the turn's token counts, and the conversation +/// as it stands at the end of the turn — the caller keeps that for the next +/// prompt, since no database does it for us. The caller emits `usage_update` +/// *before* the `session/prompt` response: that ordering is load-bearing for +/// kind-44200 metrics. pub async fn run_turn( ctx: TurnContext<'_>, prompt: Message, -) -> (Result, super::agent::TurnTokens) { +) -> ( + Result, + super::agent::TurnTokens, + Vec, +) { let mut tokens = super::agent::TurnTokens::default(); - let sessions = crate::session_store::session_manager(); - // The user's prompt is persisted before the first inference so a resumed - // or inspected session sees the same history the model saw. - if let Err(e) = sessions.add_message(ctx.session_id, &prompt).await { - return (Err(AgentError::Llm(format!("persist prompt: {e}"))), tokens); + // The turn's conversation lives here, not in a database. See + // `crate::turn_state` for why goose never needed one. + let model_config = ctx + .agent + .model_config_for_session(ctx.session_id) + .await + .ok(); + let mut state = crate::turn_state::TurnState::new( + ctx.session_id.to_string(), + ctx.working_dir.clone(), + model_config, + ); + for message in ctx.history.iter().cloned() { + state.push(message); } + state.push(prompt); let max_rounds = ctx.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS); let mut stop_blocks = 0u32; @@ -142,16 +165,20 @@ pub async fn run_turn( loop { if ctx.cancel.is_cancelled() { - return (Ok(StopReason::Cancelled), tokens); + return ( + Ok(StopReason::Cancelled), + tokens, + state.conversation().messages().to_vec(), + ); } // Ask the machine before doing any work. `step` returns the first // operation that applies, or `None` when the loop below should run. - match gate(&machine, &sessions, ctx.session_id, &emitter).await { - Err(e) => return (Err(e), tokens), + match gate(&machine, state.session(), &emitter).await { + Err(e) => return (Err(e), tokens, state.conversation().messages().to_vec()), Ok(true) => { let reason = outcome.take().unwrap_or(StopReason::EndTurn); - return (Ok(reason), tokens); + return (Ok(reason), tokens, state.conversation().messages().to_vec()); } Ok(false) => {} } @@ -159,20 +186,20 @@ pub async fn run_turn( // Steers land here, at the round boundary — never mid-inference, // where they would race a partially streamed response. for message in ctx.steers.drain().await { - if let Err(e) = sessions.add_message(ctx.session_id, &message).await { - return (Err(AgentError::Llm(format!("persist steer: {e}"))), tokens); - } + state.push(message); } // Compaction at the round boundary, never mid-stream. - if let Err(e) = maybe_compact(&ctx, &sessions).await { + if let Err(e) = maybe_compact(&ctx, &mut state).await { tracing::warn!(error = %e, "compaction failed; continuing uncompacted"); } - match round(&ctx, &sessions, &mut tokens, &mut reflections).await { - Err(e) => return (Err(e), tokens), + match round(&ctx, &mut state, &mut tokens, &mut reflections).await { + Err(e) => return (Err(e), tokens, state.conversation().messages().to_vec()), Ok(Round::Continued) => continue, - Ok(Round::Stopped(reason)) => return (Ok(reason), tokens), + Ok(Round::Stopped(reason)) => { + return (Ok(reason), tokens, state.conversation().messages().to_vec()) + } Ok(Round::WantsToEnd) => { // A steer that arrived while the model was finishing must be // answered, not dropped — so it also blocks the end of turn. @@ -181,20 +208,23 @@ pub async fn run_turn( } // The turn wants to end. `_Stop` gets a veto. - match stop_veto(&ctx, &sessions, &mut stop_blocks).await { + match stop_veto(&ctx, state.session(), &mut stop_blocks).await { Some(objection) => { - let message = Message::user() - .with_text(format!("[Stop] {objection}")) - .with_visibility(false, true); - if let Err(e) = sessions.add_message(ctx.session_id, &message).await { - return (Err(AgentError::Llm(format!("persist stop: {e}"))), tokens); - } + state.push( + Message::user() + .with_text(format!("[Stop] {objection}")) + .with_visibility(false, true), + ); continue; } None => { // A steer arriving after this point belongs to no run. ctx.steers.clear().await; - return (Ok(StopReason::EndTurn), tokens); + return ( + Ok(StopReason::EndTurn), + tokens, + state.conversation().messages().to_vec(), + ); } } } @@ -204,17 +234,13 @@ pub async fn run_turn( /// Run the operation gate for one round. /// -/// Returns `true` when an operation ended the turn. The session is reloaded -/// each time because operations read the conversation, and the previous round -/// appended to it. +/// Returns `true` when an operation ended the turn. async fn gate( machine: &goose::agents::state_machine::StateMachine<'_>, - sessions: &SessionManager, - session_id: &str, + session: &Session, emitter: &Emitter, ) -> Result { - let session = load_session(sessions, session_id).await?; - match machine.step(&session, emitter).await { + match machine.step(session, emitter).await { Ok(Some(result)) => Ok(result.yield_to_client), Ok(None) => Ok(false), // A failing gate must not take the turn down with it: the operations @@ -230,23 +256,25 @@ async fn gate( /// One inference plus, if the model asked for them, one batch of tool calls. async fn round( ctx: &TurnContext<'_>, - sessions: &SessionManager, + state: &mut crate::turn_state::TurnState, tokens: &mut super::agent::TurnTokens, reflections: &mut usize, ) -> Result { - let session = load_session(sessions, ctx.session_id).await?; - let conversation = session.conversation.clone().unwrap_or_default(); + let conversation = state.conversation(); - let assistant = match infer(ctx, &session, &conversation, tokens).await? { + let assistant = match infer(ctx, state.session(), &conversation, tokens).await? { Some(message) => message, // A provider that returns nothing is not a turn we can continue. None => return Ok(Round::Stopped(StopReason::EndTurn)), }; - sessions - .add_message(ctx.session_id, &assistant) - .await - .map_err(|e| AgentError::Llm(format!("persist assistant: {e}")))?; + state.push(assistant.clone()); + // Keep the running total current: goose's compaction check prefers it + // over re-estimating the conversation. + // i32 because that is the width goose's `Usage` uses; a total beyond + // i32::MAX means something has gone very wrong upstream, and saturating is + // better than wrapping into a negative that reads as "no usage". + state.set_total_tokens(tokens.total.map(|t| i32::try_from(t).unwrap_or(i32::MAX))); let requests: Vec = assistant .content @@ -269,7 +297,7 @@ async fn round( crate::agent::emit_tool_call_update(ctx.wire_tx, ctx.session_id, &request.id, true) .await; } - persist_tool_results(ctx, sessions, results).await?; + push_tool_results(state, results); return Ok(Round::Stopped(StopReason::Cancelled)); } @@ -278,21 +306,21 @@ async fn round( for request in &requests { if let Ok(call) = &request.tool_call { ctx.prompt - .record_tool_arguments(&call.arguments, &session.working_dir) + .record_tool_arguments(&call.arguments, &state.session().working_dir) .await; } } let results = crate::tools::execute( ctx.agent, - &session, + state.session(), ctx.wire_tx, ctx.cancel, &requests, reflections, ) .await; - persist_tool_results(ctx, sessions, results).await?; + push_tool_results(state, results); Ok(Round::Continued) } @@ -413,43 +441,39 @@ fn accumulate_usage( } } -/// Persist tool results as a single user message, matching the shape every +/// Append tool results as a single user message, matching the shape every /// provider wire format expects (one result per outstanding request). -async fn persist_tool_results( - ctx: &TurnContext<'_>, - sessions: &SessionManager, +fn push_tool_results( + state: &mut crate::turn_state::TurnState, results: Vec<( String, goose_provider_types::conversation::message::ToolResult, )>, -) -> Result<(), AgentError> { +) { if results.is_empty() { - return Ok(()); + return; } let mut message = Message::user(); for (id, result) in results { message = message.with_tool_response(id, result); } - sessions - .add_message(ctx.session_id, &message) - .await - .map_err(|e| AgentError::Llm(format!("persist tool results: {e}"))) + state.push(message); } /// Compact history when it is close to the context limit, then re-inject the /// state `_PostCompact` reports so a todo list survives the truncation. -async fn maybe_compact(ctx: &TurnContext<'_>, sessions: &SessionManager) -> anyhow::Result<()> { - let session = sessions.get_session(ctx.session_id, true).await?; - let Some(conversation) = session.conversation.clone() else { - return Ok(()); - }; +async fn maybe_compact( + ctx: &TurnContext<'_>, + state: &mut crate::turn_state::TurnState, +) -> anyhow::Result<()> { + let conversation = state.conversation(); let provider = ctx.agent.provider().await?; if !goose::context_mgmt::check_if_compaction_needed( provider.as_ref(), &conversation, None, - &session, + state.session(), ) .await? { @@ -466,20 +490,23 @@ async fn maybe_compact(ctx: &TurnContext<'_>, sessions: &SessionManager) -> anyh ) .await?; - sessions - .replace_conversation(ctx.session_id, &result.conversation) - .await?; + state.replace(result.conversation); + // Compaction resets the context: the old running total describes a + // conversation that no longer exists, so drop it and let goose re-estimate + // from the compacted messages. + state.set_total_tokens(None); tracing::info!(target: "buzz_agent::compaction", "history compacted"); // `_PostCompact` state is what buzz-agent's handoff existed to preserve. if let Some(extension) = ctx.hook_extension { - let session = sessions.get_session(ctx.session_id, true).await?; - if let Some(state) = crate::hooks::post_compact_state(ctx.agent, &session, extension).await + if let Some(reported) = + crate::hooks::post_compact_state(ctx.agent, state.session(), extension).await { - let message = Message::user() - .with_text(format!("[PostCompact] {state}")) - .with_visibility(false, true); - sessions.add_message(ctx.session_id, &message).await?; + state.push( + Message::user() + .with_text(format!("[PostCompact] {reported}")) + .with_visibility(false, true), + ); } } @@ -489,7 +516,7 @@ async fn maybe_compact(ctx: &TurnContext<'_>, sessions: &SessionManager) -> anyh /// Ask `_Stop` whether the turn may end. `Some(objection)` means keep working. async fn stop_veto( ctx: &TurnContext<'_>, - sessions: &SessionManager, + session: &Session, stop_blocks: &mut u32, ) -> Option { if ctx.cancel.is_cancelled() { @@ -502,21 +529,13 @@ async fn stop_veto( return None; } - let session = sessions.get_session(ctx.session_id, false).await.ok()?; - let objection = crate::hooks::stop_objection(ctx.agent, &session, extension).await?; + let objection = crate::hooks::stop_objection(ctx.agent, session, extension).await?; *stop_blocks += 1; tracing::info!(blocks = *stop_blocks, "_Stop hook vetoed end of turn"); Some(objection) } -async fn load_session(sessions: &SessionManager, id: &str) -> Result { - sessions - .get_session(id, true) - .await - .map_err(|e| AgentError::Llm(format!("load session: {e}"))) -} - /// Preserve buzz-agent's error taxonomy so the harness's JSON-RPC code mapping /// stays meaningful. fn classify_provider_error(msg: &str) -> AgentError { diff --git a/crates/buzz-agent/src/turn_state.rs b/crates/buzz-agent/src/turn_state.rs new file mode 100644 index 00000000000..e6a2b495dc7 --- /dev/null +++ b/crates/buzz-agent/src/turn_state.rs @@ -0,0 +1,167 @@ +//! The conversation for one turn, held in memory. +//! +//! buzz-agent used to round-trip every message through goose's +//! `SessionManager` — a sqlite database — once per round. That was never +//! required: none of the goose APIs buzz calls reads the store. +//! +//! * `Provider::stream` takes `&[Message]`, not a session. +//! * `Agent::dispatch_tool_call` takes `&Session` but reads only +//! `working_dir` (for the subdirectory-hint tracker) and `id` (for the +//! `PreToolUse` hook context). +//! * `context_mgmt::{check_if_compaction_needed, compact_messages}` take a +//! `&Session` / `session_id`, but the id is only carried into a tokio +//! task-local that becomes an `agent-session-id` HTTP header for provider +//! request attribution (`goose/src/session_context.rs`), and the session is +//! read for `usage.total_tokens` and `model_config` — both plain fields. +//! +//! So the turn owns a [`Session`] value directly. goose still gets the +//! `&Session` its signatures ask for; it just isn't backed by a database. +//! +//! # Why this matters beyond dependency hygiene +//! +//! A `SessionManager` per agent process meant every buzz agent grew a sqlite +//! file it never read back. Worse, before the store was isolated they all +//! shared the user's own goose CLI history. An agent turn is ephemeral — the +//! durable record of a Buzz conversation is the relay, not a local database. + +use goose::conversation::Conversation; +use goose::session::session_manager::Session; +use goose_provider_types::conversation::message::Message; + +/// One turn's conversation and the session metadata goose needs alongside it. +pub struct TurnState { + session: Session, +} + +impl TurnState { + /// Start a turn from the session's identity and working directory. + /// + /// `model_config` is carried because `check_if_compaction_needed` reads it + /// from the session to resolve the context limit; without it goose falls + /// back to a default limit and compaction fires at the wrong point. + pub fn new( + id: String, + working_dir: std::path::PathBuf, + model_config: Option, + ) -> Self { + let mut session = Session { + id, + working_dir, + model_config, + ..Session::default() + }; + session.conversation = Some(Conversation::new_unvalidated(Vec::new())); + Self { session } + } + + /// The session value goose's APIs take. + pub fn session(&self) -> &Session { + &self.session + } + + /// This turn's conversation. + pub fn conversation(&self) -> Conversation { + self.session.conversation.clone().unwrap_or_default() + } + + /// Append a message to the turn. + pub fn push(&mut self, message: Message) { + let mut messages = self + .session + .conversation + .take() + .map(|conversation| conversation.messages().to_vec()) + .unwrap_or_default(); + messages.push(message); + self.session.message_count = messages.len(); + self.session.conversation = Some(Conversation::new_unvalidated(messages)); + } + + /// Replace the conversation wholesale, as compaction does. + pub fn replace(&mut self, conversation: Conversation) { + self.session.message_count = conversation.messages().len(); + self.session.conversation = Some(conversation); + } + + /// Record the running token total. + /// + /// `check_if_compaction_needed` prefers `session.usage.total_tokens` over + /// re-counting the conversation, so keeping this current is what makes + /// compaction fire on the provider's numbers rather than an estimate. + pub fn set_total_tokens(&mut self, total: Option) { + self.session.usage.total_tokens = total; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> TurnState { + TurnState::new("s1".to_string(), std::path::PathBuf::from("/tmp"), None) + } + + #[test] + fn a_new_turn_starts_empty_but_has_a_conversation() { + // `Some(empty)` rather than `None`: goose's compaction check bails on + // a session with no conversation at all. + let state = state(); + assert!(state.session().conversation.is_some()); + assert!(state.conversation().messages().is_empty()); + } + + #[test] + fn pushed_messages_are_visible_to_goose() { + let mut state = state(); + state.push(Message::user().with_text("hello")); + state.push(Message::assistant().with_text("hi")); + + let conversation = state.conversation(); + assert_eq!(conversation.messages().len(), 2); + // goose reads the conversation through the session, so the two views + // must not drift. + assert_eq!( + state + .session() + .conversation + .as_ref() + .map(|c| c.messages().len()), + Some(2) + ); + assert_eq!(state.session().message_count, 2); + } + + #[test] + fn replace_swaps_the_whole_conversation() { + let mut state = state(); + state.push(Message::user().with_text("one")); + state.push(Message::user().with_text("two")); + + state.replace(Conversation::new_unvalidated(vec![ + Message::user().with_text("summary") + ])); + + assert_eq!(state.conversation().messages().len(), 1); + assert_eq!(state.session().message_count, 1); + } + + #[test] + fn total_tokens_reach_the_session_goose_reads() { + let mut state = state(); + state.set_total_tokens(Some(1234)); + assert_eq!(state.session().usage.total_tokens, Some(1234)); + } + + #[test] + fn the_working_dir_and_id_goose_reads_are_preserved() { + // These two fields are the entirety of what `dispatch_tool_call` uses + // the session for; getting them wrong breaks subdirectory hints and + // the PreToolUse hook context. + let state = TurnState::new("abc".to_string(), std::path::PathBuf::from("/work"), None); + assert_eq!(state.session().id, "abc"); + assert_eq!( + state.session().working_dir, + std::path::PathBuf::from("/work") + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index e01acc62d97..37e2318d401 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -911,93 +911,6 @@ pub fn spawn_agent_child( }) } -/// Log filter for the spawned harness and, through it, the agent. -/// -/// `buzz-acp` inherits its stderr to the agent subprocess (`acp.rs:463`), so -/// whatever this filter admits is what lands in the per-agent log file. That -/// makes `buzz_agent` as load-bearing as `buzz_acp`: without it the agent's own -/// turn diagnostics — compaction, the `_Stop` veto, max-rounds — are filtered -/// out before they reach disk, and an agent misbehaving in the desktop leaves -/// no trace to debug it with. -fn child_rust_log_filter() -> String { - const DEFAULTS: &str = "buzz_acp=info,buzz_agent=info"; - match std::env::var("RUST_LOG") { - // An operator filter that already names both crates is taken verbatim. - Ok(existing) if existing.contains("buzz_acp") && existing.contains("buzz_agent") => { - existing - } - // Otherwise append only the directive that is missing, so an explicit - // `buzz_agent=debug` is not overridden by our default. - Ok(existing) if !existing.trim().is_empty() => { - let mut filter = existing; - if !filter.contains("buzz_acp") { - filter.push_str(",buzz_acp=info"); - } - if !filter.contains("buzz_agent") { - filter.push_str(",buzz_agent=info"); - } - filter - } - _ => DEFAULTS.to_string(), - } -} - -#[cfg(test)] -mod rust_log_filter_tests { - use super::child_rust_log_filter; - - /// Serialize: `RUST_LOG` is process-global and these tests mutate it. - fn with_rust_log(value: Option<&str>, body: impl FnOnce() -> T) -> T { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let previous = std::env::var("RUST_LOG").ok(); - match value { - Some(v) => std::env::set_var("RUST_LOG", v), - None => std::env::remove_var("RUST_LOG"), - } - let result = body(); - match previous { - Some(v) => std::env::set_var("RUST_LOG", v), - None => std::env::remove_var("RUST_LOG"), - } - result - } - - #[test] - fn default_admits_both_the_harness_and_the_agent() { - // buzz_agent was missing here, which silently discarded every agent - // turn diagnostic from the desktop's per-agent log files. - let filter = with_rust_log(None, child_rust_log_filter); - assert!(filter.contains("buzz_acp=info")); - assert!(filter.contains("buzz_agent=info")); - } - - #[test] - fn an_unrelated_operator_filter_is_extended_not_replaced() { - let filter = with_rust_log(Some("warn"), child_rust_log_filter); - assert!(filter.starts_with("warn"), "operator filter must survive"); - assert!(filter.contains("buzz_acp=info")); - assert!(filter.contains("buzz_agent=info")); - } - - #[test] - fn an_explicit_agent_directive_is_not_downgraded() { - let filter = with_rust_log(Some("buzz_agent=debug"), child_rust_log_filter); - assert!(filter.contains("buzz_agent=debug")); - assert!(!filter.contains("buzz_agent=info"), "must not override"); - assert!(filter.contains("buzz_acp=info"), "still needs the harness"); - } - - #[test] - fn a_fully_specified_filter_is_taken_verbatim() { - let filter = with_rust_log( - Some("buzz_acp=warn,buzz_agent=trace"), - child_rust_log_filter, - ); - assert_eq!(filter, "buzz_acp=warn,buzz_agent=trace"); - } -} - pub fn start_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, @@ -1055,6 +968,9 @@ pub fn start_managed_agent_process( Ok(()) } +mod child_log_filter; +use child_log_filter::child_rust_log_filter; + #[cfg(test)] mod test_fixtures; diff --git a/desktop/src-tauri/src/managed_agents/runtime/child_log_filter.rs b/desktop/src-tauri/src/managed_agents/runtime/child_log_filter.rs new file mode 100644 index 00000000000..06a7196d983 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/child_log_filter.rs @@ -0,0 +1,91 @@ +//! Log filter for spawned agent processes. +//! +//! Extracted from `runtime.rs` to keep that file under the desktop file-size +//! ratchet; it is a self-contained decision with its own tests. + +/// Log filter for the spawned harness and, through it, the agent. +/// +/// `buzz-acp` inherits its stderr to the agent subprocess (`acp.rs:463`), so +/// whatever this filter admits is what lands in the per-agent log file. That +/// makes `buzz_agent` as load-bearing as `buzz_acp`: without it the agent's own +/// turn diagnostics — compaction, the `_Stop` veto, max-rounds — are filtered +/// out before they reach disk, and an agent misbehaving in the desktop leaves +/// no trace to debug it with. +pub(super) fn child_rust_log_filter() -> String { + const DEFAULTS: &str = "buzz_acp=info,buzz_agent=info"; + match std::env::var("RUST_LOG") { + // An operator filter that already names both crates is taken verbatim. + Ok(existing) if existing.contains("buzz_acp") && existing.contains("buzz_agent") => { + existing + } + // Otherwise append only the directive that is missing, so an explicit + // `buzz_agent=debug` is not overridden by our default. + Ok(existing) if !existing.trim().is_empty() => { + let mut filter = existing; + if !filter.contains("buzz_acp") { + filter.push_str(",buzz_acp=info"); + } + if !filter.contains("buzz_agent") { + filter.push_str(",buzz_agent=info"); + } + filter + } + _ => DEFAULTS.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::child_rust_log_filter; + + /// Serialize: `RUST_LOG` is process-global and these tests mutate it. + fn with_rust_log(value: Option<&str>, body: impl FnOnce() -> T) -> T { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var("RUST_LOG").ok(); + match value { + Some(v) => std::env::set_var("RUST_LOG", v), + None => std::env::remove_var("RUST_LOG"), + } + let result = body(); + match previous { + Some(v) => std::env::set_var("RUST_LOG", v), + None => std::env::remove_var("RUST_LOG"), + } + result + } + + #[test] + fn default_admits_both_the_harness_and_the_agent() { + // buzz_agent was missing here, which silently discarded every agent + // turn diagnostic from the desktop's per-agent log files. + let filter = with_rust_log(None, child_rust_log_filter); + assert!(filter.contains("buzz_acp=info")); + assert!(filter.contains("buzz_agent=info")); + } + + #[test] + fn an_unrelated_operator_filter_is_extended_not_replaced() { + let filter = with_rust_log(Some("warn"), child_rust_log_filter); + assert!(filter.starts_with("warn"), "operator filter must survive"); + assert!(filter.contains("buzz_acp=info")); + assert!(filter.contains("buzz_agent=info")); + } + + #[test] + fn an_explicit_agent_directive_is_not_downgraded() { + let filter = with_rust_log(Some("buzz_agent=debug"), child_rust_log_filter); + assert!(filter.contains("buzz_agent=debug")); + assert!(!filter.contains("buzz_agent=info"), "must not override"); + assert!(filter.contains("buzz_acp=info"), "still needs the harness"); + } + + #[test] + fn a_fully_specified_filter_is_taken_verbatim() { + let filter = with_rust_log( + Some("buzz_acp=warn,buzz_agent=trace"), + child_rust_log_filter, + ); + assert_eq!(filter, "buzz_acp=warn,buzz_agent=trace"); + } +} From 45584a9c7ae5923b7d5fe1a1ea5424b4a5e9c090 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 21:32:00 +1000 Subject: [PATCH 31/51] refactor(buzz-agent): hold the provider on buzz's side, not in goose's session row I claimed the remaining session row was needed because update_provider persists the model config there and goose reads it back for the context limit. That was wrong, and the fix is straightforward: buzz builds the provider and model config itself, so it can hold them. New crate::model::SessionModel owns the pair. The turn reads the provider and config from it instead of Agent::provider and model_config_for_session, and session/set_model swaps it there rather than calling update_provider. That removes the last write buzz makes to goose's store. The row itself still has to exist, but for a different reason than I gave. Agent::add_extension_inner (goose agent.rs:1465) resolves each MCP extension's working directory by loading the session, and fails setup if the row is missing -- 'Failed to get session: Session not found'. I found that by removing create_session and watching every tool-using test fail. So: one row per session, created for extension setup, and zero writes after it. Removing it needs goose to take the working directory as an argument instead of reading it back. Verified: a session with an MCP server, a tool call and two turns leaves 1 sessions row and 0 messages rows, still recalls turn 1 in turn 2, and still advertises 9 catalog models. handtest.py --all 18/18; 87 unit + 21 integration green. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 74 ++++++++-------- crates/buzz-agent/src/loop_drive.rs | 20 ++--- crates/buzz-agent/src/model.rs | 128 ++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 46 deletions(-) create mode 100644 crates/buzz-agent/src/model.rs diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index c7f595de294..ac5873a7615 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -28,6 +28,7 @@ pub mod hints; pub mod hooks; pub mod loop_drive; pub mod mesh; +pub mod model; pub mod ops; pub mod prompt; pub mod session_store; @@ -78,6 +79,9 @@ struct Session { history: Vec, /// Working directory for the session, as given at `session/new`. working_dir: std::path::PathBuf, + /// Provider + model config in force. Held by buzz rather than pushed into + /// goose's `Agent`, because `update_provider` writes to the session store. + model: crate::model::SessionModel, busy: bool, /// Set for the duration of a turn; advertised to steer-capable clients. active_run_id: Option, @@ -130,6 +134,7 @@ async fn build_agent( ( Arc, String, + crate::model::SessionModel, Option, crate::prompt::SessionPrompt, ), @@ -148,15 +153,20 @@ async fn build_agent( GoosePlatform::GooseCli, )); - // A session id, not a database row. `Agent::update_provider` and - // `model_config_for_session` want an id to key on, but the conversation - // itself never goes to goose's store -- see `crate::turn_state`. + // One session row, and it is genuinely required -- I tried removing it. // - // The row still gets created because `update_provider` persists the - // provider/model config against it, and that is the one write we cannot - // decline without losing the model config goose reads back for the - // context limit. Nothing else is written: a full conversation adds zero - // message rows. + // `Agent::add_extension_inner` (goose agent.rs:1465) resolves the + // extension's working directory by loading the session from the store, and + // fails setup outright if the row is absent: "Failed to get session ... + // Session not found". Every MCP server buzz mounts goes through that path, + // so without the row an agent has no tools at all. + // + // Nothing else needs it. The conversation stays in memory + // (`crate::turn_state`) and the provider config stays on buzz's own handle + // (`crate::model`), so a conversation of any length adds zero further + // rows. Removing this last one means an upstream change: goose would have + // to take the working directory as an argument rather than reading it back + // out of the session. let session = session_manager .create_session( std::path::PathBuf::from(cwd), @@ -166,11 +176,8 @@ async fn build_agent( ) .await .map_err(|e| AgentError::Llm(format!("session create: {e}")))?; + let session_id = session.id; - // 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. @@ -186,10 +193,10 @@ async fn build_agent( 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()))?; + // Held here rather than pushed into the `Agent` with `update_provider`: + // that call writes the provider config to goose's sqlite session row, and + // buzz reads the provider and config back through `SessionModel` anyway. + let model = crate::model::SessionModel::new(provider, model_config); // 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 @@ -226,7 +233,7 @@ async fn build_agent( available_tools: Vec::new(), }; - if let Err(e) = agent.add_extension(ext, &session.id).await { + 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))); @@ -285,7 +292,7 @@ async fn build_agent( // 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 { + for tool in agent.list_tools(&session_id, None).await { if tool.name.ends_with("___Stop") { hook_extension = tool .name @@ -302,7 +309,7 @@ async fn build_agent( tracing::info!(extension = %ext, "lifecycle hooks available"); } - Ok((agent, session.id, hook_extension, prompt)) + Ok((agent, session_id, model, hook_extension, prompt)) } /// Keep the model's hands off the lifecycle hooks. @@ -515,7 +522,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen .await; } - let (agent, goose_session_id, hook_extension, prompt) = + let (agent, goose_session_id, model, hook_extension, prompt) = match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await { Ok(v) => v, Err(e) => { @@ -526,7 +533,7 @@ 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 session_model = model.clone(); let current_model = app .cfg .model @@ -553,6 +560,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen goose_session_id, history: Vec::new(), working_dir: std::path::PathBuf::from(&p.cwd), + model, busy: false, active_run_id: None, cancel: None, @@ -581,7 +589,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen // 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 { + if let Some(models) = discover_models(&session_model, ¤t_model).await { result["models"] = models; } @@ -598,8 +606,8 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen /// `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()?; +async fn discover_models(model: &crate::model::SessionModel, current_model: &str) -> Option { + let provider = model.provider().await; let ids = provider.fetch_supported_models().await.ok()?; let mut available: Vec = ids @@ -666,6 +674,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire steers, history, working_dir, + model, ) = { let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { @@ -700,12 +709,13 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire s.steers.clone(), s.history.clone(), s.working_dir.clone(), + s.model.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 { + if let Err(e) = apply_model(&model, &model_id).await { { let mut sessions = app.sessions.lock().await; if let Some(s) = sessions.get_mut(&p.session_id) { @@ -742,6 +752,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire steers: &steers, working_dir, history: &history, + model: &model, }, goose_provider_types::conversation::message::Message::user() .with_text(agent::prompt_to_text(&p.prompt)), @@ -1040,19 +1051,14 @@ fn env_first(keys: &[&str]) -> Option { .find(|v| !v.trim().is_empty()) } -async fn apply_model( - agent: &Arc, - session_id: &str, - model_id: &str, -) -> Result<(), AgentError> { +async fn apply_model(model: &crate::model::SessionModel, model_id: &str) -> Result<(), AgentError> { let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".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 - .update_provider(provider, model_config, session_id) - .await - .map_err(|e| map_provider_error(&e.to_string()))?; + // Swapped on buzz's own handle. `Agent::update_provider` would do the same + // thing plus a write to goose's session row; see [`crate::model`]. + model.set(provider, model_config).await; Ok(()) } diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 41905279b93..235d9cae50e 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -112,6 +112,10 @@ pub struct TurnContext<'a> { /// buzz-agent holds this across turns itself now that the turn's /// conversation is in memory rather than in goose's database. pub history: &'a [Message], + /// Provider and model config for this turn. Read from here rather than + /// from goose's `Agent`, which resolves the config out of its session + /// store; see [`crate::model`]. + pub model: &'a crate::model::SessionModel, } /// Drive one `session/prompt` turn to completion. @@ -333,16 +337,8 @@ async fn infer( conversation: &Conversation, tokens: &mut super::agent::TurnTokens, ) -> Result, AgentError> { - let provider = ctx - .agent - .provider() - .await - .map_err(|e| AgentError::Llm(e.to_string()))?; - let model_config = ctx - .agent - .model_config_for_session(ctx.session_id) - .await - .map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?; + let provider = ctx.model.provider().await; + let model_config = ctx.model.config().await; let system_prompt = ctx.prompt.build(&session.working_dir).await; let tools = ctx.agent.list_tools(ctx.session_id, None).await; @@ -467,7 +463,7 @@ async fn maybe_compact( state: &mut crate::turn_state::TurnState, ) -> anyhow::Result<()> { let conversation = state.conversation(); - let provider = ctx.agent.provider().await?; + let provider = ctx.model.provider().await; if !goose::context_mgmt::check_if_compaction_needed( provider.as_ref(), @@ -480,7 +476,7 @@ async fn maybe_compact( return Ok(()); } - let model_config = ctx.agent.model_config_for_session(ctx.session_id).await?; + let model_config = ctx.model.config().await; let result = goose::context_mgmt::compact_messages( provider.as_ref(), &model_config, diff --git a/crates/buzz-agent/src/model.rs b/crates/buzz-agent/src/model.rs new file mode 100644 index 00000000000..0d2dce73368 --- /dev/null +++ b/crates/buzz-agent/src/model.rs @@ -0,0 +1,128 @@ +//! The provider and model config in force for a session. +//! +//! buzz-agent builds both of these itself — `build_provider` resolves the +//! provider, and `model_config_from_user_config` the config — so it does not +//! need goose to hold them. +//! +//! It used to hand them to `Agent::update_provider`, which stores them on the +//! agent *and writes them to goose's sqlite session row*. That write was the +//! last thing putting a row in the database. Since the turn loop reads the +//! provider and model config back through this struct rather than through +//! goose, the round trip bought nothing. +//! +//! Two things still need goose's `Agent`: `list_tools` and +//! `dispatch_tool_call`. Neither touches the provider (`dispatch_tool_call` +//! contains no reference to it) or the session store. + +use std::sync::Arc; + +use goose::providers::base::Provider; +use goose_providers::model::ModelConfig; +use tokio::sync::RwLock; + +/// Provider and model config for one session, swappable by `session/set_model`. +#[derive(Clone)] +pub struct SessionModel { + inner: Arc>, +} + +struct Inner { + provider: Arc, + config: ModelConfig, +} + +impl SessionModel { + pub fn new(provider: Arc, config: ModelConfig) -> Self { + Self { + inner: Arc::new(RwLock::new(Inner { provider, config })), + } + } + + /// The provider to stream from. + pub async fn provider(&self) -> Arc { + Arc::clone(&self.inner.read().await.provider) + } + + /// The model config, which also carries the context limit compaction needs. + pub async fn config(&self) -> ModelConfig { + self.inner.read().await.config.clone() + } + + /// Swap both, as `session/set_model` does. + /// + /// Taken together under one write so a turn can never observe a new + /// provider with the previous model's config. + pub async fn set(&self, provider: Arc, config: ModelConfig) { + let mut inner = self.inner.write().await; + inner.provider = provider; + inner.config = config; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use goose_provider_types::conversation::message::Message; + use goose_provider_types::errors::ProviderError; + + /// Minimal provider that reports its own name, so a swap is observable. + struct NamedProvider(&'static str); + + #[async_trait::async_trait] + impl Provider for NamedProvider { + fn get_name(&self) -> &str { + self.0 + } + + async fn stream( + &self, + _model_config: &ModelConfig, + _system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result { + unreachable!("tests never call the model") + } + } + + fn model(name: &'static str) -> (Arc, ModelConfig) { + (Arc::new(NamedProvider(name)), ModelConfig::new(name)) + } + + #[tokio::test] + async fn the_configured_provider_and_config_are_what_come_back() { + let (provider, config) = model("first"); + let session = SessionModel::new(provider, config); + + assert_eq!(session.provider().await.get_name(), "first"); + assert_eq!(session.config().await.model_name, "first"); + } + + #[tokio::test] + async fn set_model_swaps_provider_and_config_together() { + // The pair must move as one: a turn seeing a new provider with the old + // config would stream against the wrong context limit. + let (provider, config) = model("first"); + let session = SessionModel::new(provider, config); + + let (next_provider, next_config) = model("second"); + session.set(next_provider, next_config).await; + + assert_eq!(session.provider().await.get_name(), "second"); + assert_eq!(session.config().await.model_name, "second"); + } + + #[tokio::test] + async fn clones_share_one_model() { + // `SessionModel` is cloned into the turn; a `session/set_model` applied + // through one handle has to be visible through the other. + let (provider, config) = model("first"); + let session = SessionModel::new(provider, config); + let clone = session.clone(); + + let (next_provider, next_config) = model("second"); + session.set(next_provider, next_config).await; + + assert_eq!(clone.provider().await.get_name(), "second"); + } +} From 6c7648f72034fceff83647aa9c3059e6b7a3ec43 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 21:36:19 +1000 Subject: [PATCH 32/51] fix(deps): patch eight nostr advisories instead of ignoring them I reported the Security CI failure as not fixable here and something for main to add to deny.toml's ignore list. That was wrong: the advisories carry a semver-compatible fix, and cargo-deny prints it. cargo update -p nostr 0.44.6 -> 0.44.8 cargo update -p nostr-relay-pool 0.44.1 -> 0.44.3 That clears all eight: RUSTSEC-2026-0224/0225/0226/0231/0232 and the three other nostr parsing advisories. cargo-deny check now reports advisories ok, bans ok, licenses ok, sources ok. Lockfile-only -- no manifest change, no new ignores, and nothing added to deny.toml. RUSTSEC-2026-0243 (nostr-relay-pool unmaintained) stays ignored; that one genuinely needs the nostr-sdk 0.45 migration. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- Cargo.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93a5ee60967..1e286137458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,7 +197,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -208,7 +208,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2016,7 +2016,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2756,7 +2756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -2993,7 +2993,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3240,7 +3240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3739,7 +3739,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -6930,9 +6930,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.6" +version = "0.44.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" +checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" dependencies = [ "base64 0.22.1", "bech32", @@ -6974,9 +6974,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.1" +version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" dependencies = [ "async-utility", "async-wsocket", @@ -7020,7 +7020,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9474,7 +9474,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9533,7 +9533,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9817,7 +9817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10386,7 +10386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11255,7 +11255,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11268,7 +11268,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12185,7 +12185,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12837,7 +12837,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 8e1ba34207fc9dcbf8a4594b3b42ab049348d039 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 10:39:22 +1000 Subject: [PATCH 33/51] refactor(buzz-agent): use goose's skills instead of buzz's duplicate buzz-agent grew its own skill discovery, prompt index and load_skill tool before goose had one. goose now ships goose::skills plus a SkillsClient registered in PLATFORM_EXTENSIONS, so buzz's version was a strictly smaller duplicate: the same three project directories, but goose also reads the global directories, plugin-installed skills and its own builtins, and supports args templating buzz never had. Delete builtin.rs, builtin_client.rs and hints.rs (1,204 lines) and register goose's skills extension by name so its factory runs. goose declares it unprefixed_tools, so the model now sees load_skill rather than buzz__load_skill. skills.rs keeps only the prompt index, because the goose code that renders it lives on SkillOperation, which is pub(super). The hint-file guard from hints.rs moves there rather than being dropped, and now matches filename literals instead of the bare words so it can document itself. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/builtin.rs | 575 ------------------------ crates/buzz-agent/src/builtin_client.rs | 122 ----- crates/buzz-agent/src/hints.rs | 507 --------------------- crates/buzz-agent/src/lib.rs | 60 +-- crates/buzz-agent/src/skills.rs | 157 +++++++ crates/buzz-agent/tests/skills.rs | 18 +- 6 files changed, 200 insertions(+), 1239 deletions(-) delete mode 100644 crates/buzz-agent/src/builtin.rs delete mode 100644 crates/buzz-agent/src/builtin_client.rs delete mode 100644 crates/buzz-agent/src/hints.rs create mode 100644 crates/buzz-agent/src/skills.rs diff --git a/crates/buzz-agent/src/builtin.rs b/crates/buzz-agent/src/builtin.rs deleted file mode 100644 index 95e8487611c..00000000000 --- 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::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/builtin_client.rs b/crates/buzz-agent/src/builtin_client.rs deleted file mode 100644 index 03244219d21..00000000000 --- a/crates/buzz-agent/src/builtin_client.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Buzz's in-process tools, exposed to Goose as a platform extension. -//! -//! Goose has two ways to take a tool the embedder implements in Rust: -//! -//! * [`ExtensionConfig::Frontend`] — Goose *advertises* the tool but refuses to -//! run it. It returns "Frontend tool execution required" and blocks until the -//! embedder calls `handle_tool_result`. Dispatch is strictly sequential, has -//! no timeout, and the result channel carries no request id, so a single -//! missed result wedges the agent for the rest of the session and the cancel -//! token will not free it. -//! * [`ExtensionConfig::Platform`] + `extension_manager.add_client` with an -//! [`McpClientTrait`] — Goose dispatches it through the ordinary tool path, -//! so concurrency, per-request timeouts and `notifications/cancelled` all -//! work, and there is nothing for us to correlate. -//! -//! This uses the second. It is the shape Maple settled on for its own -//! in-process developer and skills tools. -//! -//! The only tool here is `load_skill`. The system prompt carries the skill -//! *index* (names and descriptions, from [`crate::hints`]); bodies are pulled -//! on demand so a large skill library costs nothing per turn. - -use std::sync::Arc; - -use async_trait::async_trait; -use goose::agents::mcp_client::McpClientTrait; -use goose::agents::{ExtensionConfig, ToolCallContext}; -use rmcp::model::{ - CallToolResult, ContentBlock, InitializeResult, JsonObject, ListToolsResult, Tool, -}; -use rmcp::service::ServiceError as Error; -use tokio_util::sync::CancellationToken; - -use crate::hints::SkillEntry; - -/// Extension key and display name. Goose prefixes tool names with the -/// extension name (`{extension}__{tool}`) unless the extension is registered -/// as unprefixed, so this shows up in the model's tool list. -pub const BUILTIN_EXTENSION_NAME: &str = "buzz"; - -/// Goose-side declaration for the extension backed by [`BuiltinClient`]. -pub fn builtin_extension_config() -> ExtensionConfig { - ExtensionConfig::Platform { - name: BUILTIN_EXTENSION_NAME.to_string(), - description: "Buzz built-in tools".to_string(), - display_name: Some("Buzz".to_string()), - bundled: Some(true), - available_tools: vec![crate::builtin::LOAD_SKILL_TOOL.to_string()], - } -} - -/// In-process MCP client serving [`crate::builtin`]'s tools. -pub struct BuiltinClient { - skills: Vec, -} - -impl BuiltinClient { - pub fn new(skills: Vec) -> Self { - Self { skills } - } - - fn load_skill_tool() -> Tool { - let def = crate::builtin::load_skill_def(); - let schema = match def.input_schema { - serde_json::Value::Object(map) => Arc::new(map), - _ => Arc::new(serde_json::Map::new()), - }; - Tool::new(def.name, def.description, schema) - } -} - -#[async_trait] -impl McpClientTrait for BuiltinClient { - async fn list_tools( - &self, - _session_id: &str, - _next_cursor: Option, - _cancel_token: CancellationToken, - ) -> Result { - Ok(ListToolsResult { - tools: vec![Self::load_skill_tool()], - next_cursor: None, - meta: None, - ..Default::default() - }) - } - - async fn call_tool( - &self, - _ctx: &ToolCallContext, - name: &str, - arguments: Option, - _cancel_token: CancellationToken, - ) -> Result { - if name != crate::builtin::LOAD_SKILL_TOOL { - return Ok(CallToolResult::error(vec![ContentBlock::text(format!( - "unknown tool: {name}" - ))])); - } - - let args = arguments - .map(serde_json::Value::Object) - .unwrap_or(serde_json::Value::Null); - let result = crate::builtin::call_load_skill(&args, &self.skills).await; - - let content: Vec = result - .content - .iter() - .map(|c| ContentBlock::text(c.as_text_lossy())) - .collect(); - - Ok(if result.is_error { - CallToolResult::error(content) - } else { - CallToolResult::success(content) - }) - } - - fn get_info(&self) -> Option<&InitializeResult> { - None - } -} diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs deleted file mode 100644 index 53b2a78ea57..00000000000 --- a/crates/buzz-agent/src/hints.rs +++ /dev/null @@ -1,507 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -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 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 -} - -/// Discover the skills available from `cwd`. -/// -/// Hint *files* are goose's job (`PromptManager::build_system_prompt` walks -/// them from the git root). This is only the skill index, which stays ours -/// because `load_skill` is served by [`crate::builtin_client`] and needs the -/// same [`SkillEntry`] list the tool dispatches against. -pub fn discover_skills(cwd: &Path) -> Vec { - discover_skills_impl(cwd, home_dir().as_deref()) -} - -/// Render the skill index for the system prompt. -/// -/// Returns an empty string for an empty slate so the caller can skip the -/// extra entirely rather than adding an empty section. -pub fn skill_index(skills: &[SkillEntry]) -> String { - if skills.is_empty() { - return String::new(); - } - - let mut out = String::from("# Available Skills\n"); - for skill in skills { - out.push_str(&format!("- {}: {}\n", skill.name, skill.description)); - } - // Goose namespaces platform-extension tools as `{extension}__{tool}` - // (`extension_manager.rs:1415`), so name the tool the way the model - // actually sees it -- otherwise the prompt advertises a tool that does - // not exist in its tool list. - out.push_str(&format!( - "\nUse the `{}__{}` tool to read the full content of a skill before using it.\n", - crate::builtin_client::BUILTIN_EXTENSION_NAME, - crate::builtin::LOAD_SKILL_TOOL, - )); - out -} - -/// 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 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 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] - ); - } - - /// The bug this module's shrink was for: buzz-agent used to add its own - /// hint-file scan as a prompt extra, while goose's `build_system_prompt` - /// was already loading the same files. Every hint landed in the system - /// prompt twice. Nothing here may reintroduce hint *file* loading -- that - /// is goose's job now. - /// - /// The banned names are assembled at runtime so this guard does not trip - /// over its own source text. - #[test] - fn this_module_does_not_load_hint_files() { - let source = include_str!("hints.rs"); - for banned in [format!("AGENTS{}md", "."), format!(".goose{}", "hints")] { - assert!( - !source.contains(&banned), - "hints.rs references {banned}: hint-file loading belongs to \ - goose's PromptManager, or hints render twice in the prompt" - ); - } - } - - #[test] - fn the_skill_index_is_empty_for_an_empty_slate() { - // An empty section would still be added as a prompt extra, spending - // tokens on a header with nothing under it. - assert!(skill_index(&[]).is_empty()); - } - - #[test] - fn the_skill_index_names_the_tool_as_the_model_sees_it() { - let skills = vec![SkillEntry { - name: "widget".to_string(), - description: "makes widgets".to_string(), - path: PathBuf::from("/tmp/widget/SKILL.md"), - supporting_files: Vec::new(), - }]; - let index = skill_index(&skills); - assert!(index.contains("- widget: makes widgets")); - // Namespaced, because goose advertises platform tools as - // `{extension}__{tool}`; the bare name is not in the model's tool list. - assert!(index.contains("buzz__load_skill")); - } -} diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 39bde5cff6c..c7c3827dc93 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -21,16 +21,14 @@ //! * `keepalive` — see `agent.rs`. pub mod agent; -pub mod builtin; -pub mod builtin_client; pub mod config; -pub mod hints; pub mod hooks; pub mod loop_drive; pub mod model; pub mod ops; pub mod prompt; pub mod session_store; +pub mod skills; pub mod steer; pub mod tools; pub mod turn_state; @@ -251,31 +249,39 @@ async fn build_agent( // goose's loader is also a superset: `.goosehints` as well as `AGENTS.md`, // the filename list configurable via `CONTEXT_FILE_NAMES`, gitignore-aware // filtering, and `@file` reference expansion bounded at the git root. - let skills = crate::hints::discover_skills(std::path::Path::new(cwd)); - if !skills.is_empty() { - prompt - .add_extra("buzz_skills", crate::hints::skill_index(&skills)) - .await; - } - - // `load_skill` runs in-process, registered the way Maple does it: a - // platform extension backed by an `McpClientTrait`, NOT - // `ExtensionConfig::Frontend`. Goose advertises frontend tools but refuses - // to dispatch them -- it blocks on an uncorrelated result channel until the - // embedder calls `handle_tool_result`, with no timeout and no cancellation, - // so one missed result wedges the session. A platform client goes through - // goose's ordinary tool path instead. See `crate::builtin_client`. + // Skills are goose's too, for the same reason as hints. + // + // buzz-agent grew its own discovery, index and `load_skill` tool + // (`hints.rs` + `builtin.rs` + `builtin_client.rs`, ~1.2k lines) before + // goose had one. goose has since shipped `goose::skills` and registers a + // `SkillsClient` as a platform extension, so ours was a strictly smaller + // duplicate: same three project directories, but goose also reads the + // global ones, plugin-installed skills and its own builtins, and supports + // `args` templating we never had. + // + // Registered by *name* rather than by handing over a client: `skills` is + // in goose's `PLATFORM_EXTENSIONS` table, so `add_extension` runs its + // factory. It is declared `unprefixed_tools: true` there, which is why the + // tool the model sees is plain `load_skill` and not `skills__load_skill`. + let skills = goose::skills::discover_skills(Some(std::path::Path::new(cwd))); if !skills.is_empty() { - agent - .extension_manager - .add_client( - crate::builtin_client::BUILTIN_EXTENSION_NAME.to_string(), - crate::builtin_client::builtin_extension_config(), - std::sync::Arc::new(crate::builtin_client::BuiltinClient::new(skills)), - None, - None, - ) - .await; + let ext = ExtensionConfig::Platform { + name: goose::skills::EXTENSION_NAME.to_string(), + description: "Skills".to_string(), + display_name: Some("Skills".to_string()), + bundled: Some(true), + available_tools: Vec::new(), + }; + if let Err(e) = agent.add_extension(ext, &session_id).await { + // Unlike an MCP server, a missing skills extension is not fatal: + // the agent still has every other tool. Losing it silently would + // be worse than losing it loudly, hence the warning. + tracing::warn!(error = %e, "skills extension unavailable"); + } else { + prompt + .add_extra("skills", crate::skills::skill_index(&skills)) + .await; + } } let agent = Arc::new(agent); diff --git a/crates/buzz-agent/src/skills.rs b/crates/buzz-agent/src/skills.rs new file mode 100644 index 00000000000..3330cd3ef4b --- /dev/null +++ b/crates/buzz-agent/src/skills.rs @@ -0,0 +1,157 @@ +//! The skill index for the system prompt. +//! +//! Discovery, `load_skill`, argument templating, supporting-file loading and +//! the path-escape guard are all [`goose::skills`]. The only thing left here +//! is rendering the index, and only because the piece of goose that renders it +//! (`ops_skills::skill_instructions`) is reachable through `SkillOperation`, +//! which is `pub(super)` — its concrete operations stay internal to goose +//! while the `Operation` trait is public. +//! +//! So buzz gets goose's skills the way an embedder can: the `skills` platform +//! extension supplies the tool, and this supplies the index that tells the +//! model the tool is worth calling. The wording matches goose's own so the two +//! do not drift into describing the same tool differently. + +use goose::custom_requests::{SourceEntry, SourceType}; + +/// Render the index of available skills for the system prompt. +/// +/// Empty for an empty slate, so the caller can skip the prompt extra entirely +/// rather than spending tokens on a header with nothing under it. +pub fn skill_index(skills: &[SourceEntry]) -> String { + let mut entries: Vec<&SourceEntry> = skills + .iter() + .filter(|skill| { + matches!( + skill.source_type, + SourceType::Skill | SourceType::BuiltinSkill + ) + }) + .collect(); + if entries.is_empty() { + return String::new(); + } + // Sorted on the same key as goose's own renderer, so the section is stable + // between rounds. An index that reorders itself would invalidate the + // provider's prompt cache every turn for no benefit. + entries.sort_by(|a, b| (&a.name, &a.path).cmp(&(&b.name, &b.path))); + + let mut out = String::from( + "# Skills\n\nYou have these skills at your disposal. Load one when it can help \ + with the task or when the user asks for it:", + ); + for skill in entries { + out.push_str(&format!("\n- {}: {}", skill.name, skill.description)); + } + // Unprefixed on purpose. goose registers `skills` with + // `unprefixed_tools: true` (`platform_extensions/mod.rs:217`), so the + // model's tool list carries `load_skill`, not `skills__load_skill`. + // Naming it the other way would advertise a tool that is not there. + out.push_str("\n\nLoad a skill with the `load_skill` tool before using it.\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn skill(name: &str, source_type: SourceType) -> SourceEntry { + SourceEntry { + source_type, + name: name.to_string(), + description: format!("does {name}"), + content: String::new(), + path: format!("/tmp/{name}"), + global: false, + writable: false, + supporting_files: Vec::new(), + properties: Default::default(), + } + } + + #[test] + fn an_empty_slate_renders_nothing() { + assert!(skill_index(&[]).is_empty()); + } + + #[test] + fn builtin_and_filesystem_skills_both_appear() { + let index = skill_index(&[ + skill("widget", SourceType::Skill), + skill("doc-guide", SourceType::BuiltinSkill), + ]); + assert!(index.contains("- widget: does widget")); + assert!(index.contains("- doc-guide: does doc-guide")); + } + + #[test] + fn non_skill_sources_are_left_out() { + // `discover_skills` returns `SourceEntry`, which also models projects. + // Listing a project as a skill would advertise a `load_skill` call + // that cannot succeed. + let index = skill_index(&[skill("some-project", SourceType::Project)]); + assert!(index.is_empty(), "unexpected index: {index:?}"); + } + + #[test] + fn the_tool_is_named_the_way_the_model_sees_it() { + // goose exposes the skills extension unprefixed; `skills__load_skill` + // is not in the tool list and would be a dead reference. + let index = skill_index(&[skill("widget", SourceType::Skill)]); + assert!(index.contains("`load_skill`")); + assert!(!index.contains("skills__load_skill")); + } + + #[test] + fn the_order_is_stable() { + let unsorted = [ + skill("zebra", SourceType::Skill), + skill("alpha", SourceType::Skill), + ]; + let index = skill_index(&unsorted); + let alpha = index.find("alpha").expect("alpha listed"); + let zebra = index.find("zebra").expect("zebra listed"); + assert!(alpha < zebra, "index must not reorder between rounds"); + } +} + +/// Nothing in this crate may *load* hint files. +/// +/// buzz-agent used to walk `AGENTS.md` itself while goose's +/// `PromptManager::build_system_prompt` was already loading the same files, so +/// every hint landed in the system prompt twice. This guard outlived the +/// module it was written in (`hints.rs`, deleted along with the duplicate +/// skills code it shared a file with), so it moves here rather than being +/// dropped. +/// +/// It looks for the *filename literals* a loader would need, not for the +/// names in prose -- the previous version banned the bare word and could only +/// live in a file that never explained itself. The literals are assembled at +/// runtime so the guard does not trip over its own source text. +#[cfg(test)] +mod hint_files_are_gooses_job { + const SOURCES: &[(&str, &str)] = &[ + ("lib.rs", include_str!("lib.rs")), + ("prompt.rs", include_str!("prompt.rs")), + ("loop_drive.rs", include_str!("loop_drive.rs")), + ("skills.rs", include_str!("skills.rs")), + ]; + + #[test] + fn no_module_loads_hint_files() { + let quote = '"'; + // Assembled rather than written out, so this test's own source does + // not count as a violation of the rule it enforces. + for name in [format!("AGENTS{}md", '.'), format!(".goose{}", "hints")] { + let literal = format!("{quote}{name}{quote}"); + for (file, source) in SOURCES { + assert!( + !source.contains(&literal), + "{file} has a {literal} string literal: hint-file loading \ + belongs to goose's PromptManager, or hints render twice \ + in the prompt" + ); + } + } + } +} diff --git a/crates/buzz-agent/tests/skills.rs b/crates/buzz-agent/tests/skills.rs index 2de2a91dce1..c47fd60b1c0 100644 --- a/crates/buzz-agent/tests/skills.rs +++ b/crates/buzz-agent/tests/skills.rs @@ -1,10 +1,11 @@ //! 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 -//! *platform* extension backed by an `McpClientTrait` (the shape Maple uses), -//! so goose dispatches it on its ordinary tool path. Goose namespaces such -//! tools as `{extension}__{tool}`, which the system prompt has to match — a -//! prompt naming a tool that is not in the tool list is a silent failure. +//! Both were briefly lost in the port. Skills are now goose's `skills` +//! platform extension rather than a buzz reimplementation, and goose declares +//! it `unprefixed_tools: true`, so the model sees plain `load_skill` — not +//! `skills__load_skill`. buzz only renders the index, and an index naming a +//! tool that is not in the tool list is a silent failure, so this pins both +//! halves against each other. use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; @@ -100,7 +101,7 @@ fn spawn_provider() -> (String, mpsc::Receiver<(String, Vec)>) { "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":"buzz__load_skill","arguments":"{\"name\":\"widget-maker\"}"} + "function":{"name":"load_skill","arguments":"{\"name\":\"widget-maker\"}"} }]},"finish_reason":null}] }); let d = json!({ @@ -198,7 +199,8 @@ 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. + // goose's hint loader walks up to the git root; give it one so the walk + // terminates inside the temp workspace rather than in the real repo. std::fs::create_dir_all(root.join(".git")).unwrap(); let skill = root.join(".agents/skills/widget-maker"); std::fs::create_dir_all(&skill).unwrap(); @@ -264,7 +266,7 @@ fn agents_md_and_skill_index_reach_the_model_and_load_skill_resolves() { "skill BODY was inlined — the point of load_skill is that it is not:\n{first}" ); assert!( - tool_lists[0].iter().any(|t| t == "buzz__load_skill"), + tool_lists[0].iter().any(|t| t == "load_skill"), "load_skill not advertised: {:?}", tool_lists[0] ); From 69c60912169be2edb60bff74a010f2b15d23e8fe Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 10:44:46 +1000 Subject: [PATCH 34/51] feat(buzz-agent): back goose's Databricks provider with buzz's PKCE engine Both sides ran OAuth PKCE against the same Databricks host with separate token caches: goose's providers::oauth for inference, buzz's PkceOAuthTokenSource for the desktop model picker. A user could be authenticated for one and not the other and face two browser flows for one host. Buzz's engine is the hardened one (#5534: O_NOFOLLOW, 0600 from creation, unique temp names) and the only one the desktop can link, so it wins. goose supports this without a fork: DatabricksV2Provider::new takes an oauth token provider closure, and databricks_v2_def::from_env is just the variant passing goose's own. Retries, the agent-session-id header, endpoint resolution and the wire format all stay goose's. Uses bearer_no_browser: inference runs in a headless subprocess where a browser step would hang unseen. Interactive login stays in buzz-agent auth databricks and the desktop picker. A static DATABRICKS_TOKEN still takes precedence, as in goose's own from_env, and no DATABRICKS_HOST falls through to goose's registry unchanged. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 13 +- crates/buzz-agent/src/provider.rs | 216 ++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 crates/buzz-agent/src/provider.rs diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index c7c3827dc93..b94c1d93b29 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -27,6 +27,7 @@ pub mod loop_drive; pub mod model; pub mod ops; pub mod prompt; +pub mod provider; pub mod session_store; pub mod skills; pub mod steer; @@ -1009,17 +1010,11 @@ 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. -/// -/// Relay-mesh `auto` is resolved by the desktop before spawn -/// (`managed_agents/relay_mesh.rs:relay_mesh_wire_model`), so buzz-agent sends -/// whatever model it was given and does not know meshes exist (#5289). +/// Construct the goose provider. See [`crate::provider`]. async fn build_provider( provider_name: &str, ) -> Result, AgentError> { - goose::providers::create(provider_name, Vec::new()) - .await - .map_err(|e| map_provider_error(&e.to_string())) + crate::provider::build(provider_name).await } async fn apply_model(model: &crate::model::SessionModel, model_id: &str) -> Result<(), AgentError> { @@ -1036,7 +1031,7 @@ async fn apply_model(model: &crate::model::SessionModel, model_id: &str) -> Resu /// 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 { +pub(crate) 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()) diff --git a/crates/buzz-agent/src/provider.rs b/crates/buzz-agent/src/provider.rs new file mode 100644 index 00000000000..483c1420c7d --- /dev/null +++ b/crates/buzz-agent/src/provider.rs @@ -0,0 +1,216 @@ +//! Provider construction. +//! +//! Almost all of this is goose's: `goose::providers::create` resolves base +//! URLs, credentials and model metadata from its own registry, and buzz does +//! not duplicate any of it. +//! +//! # Databricks is the exception, on purpose +//! +//! Both sides implement OAuth 2.0 Authorization Code + PKCE against the same +//! Databricks host, and before this module they did it with **two separate +//! token caches**: +//! +//! | | flow | cache | +//! |---|---|---| +//! | inference | goose `providers::oauth` | goose's config dir | +//! | model picker | [`buzz_model_catalog::auth`] | `~/.config/buzz-agent/oauth/databricks/` | +//! +//! A user could be authenticated for inference and not for the model picker, +//! or the reverse, and be sent through the browser twice for the same host. +//! +//! Buzz's engine is the hardened one — PR #5534 gave it `O_NOFOLLOW`, mode +//! `0600` from creation and unique temp names — and it is also the one the +//! desktop calls directly, since the desktop cannot link goose at all (see +//! `buzz-model-catalog`'s lib.rs). So buzz's engine wins and goose's provider +//! is constructed around it. +//! +//! goose supports this without a fork: `DatabricksV2Provider::new` takes a +//! `DatabricksOauthTokenProvider` closure, and `databricks_v2_def::from_env` +//! is simply the variant that passes goose's own. Buzz passes its own instead. +//! Everything else about the provider — retries, the session-id header, +//! endpoint resolution, the wire format — stays goose's. + +use std::sync::Arc; + +use goose::providers::base::Provider; +use goose_providers::databricks_auth::{DatabricksAuth, DatabricksOauthTokenProvider}; +use goose_providers::databricks_v2::DatabricksV2Provider; + +use crate::types::AgentError; + +/// goose's registry name for the v2 Databricks provider. +const DATABRICKS_V2: &str = "databricks_v2"; + +/// Construct the goose provider for `provider_name`. +/// +/// Relay-mesh `auto` is resolved by the desktop before spawn +/// (`managed_agents/relay_mesh.rs:relay_mesh_wire_model`), so buzz-agent sends +/// whatever model it was given and does not know meshes exist (#5289). +pub async fn build(provider_name: &str) -> Result, AgentError> { + if provider_name == DATABRICKS_V2 { + if let Some(host) = databricks_host() { + // Only when buzz has a host to authenticate against. Without one + // there is nothing to improve on, and falling through to goose's + // registry keeps `DATABRICKS_TOKEN`-only setups working unchanged. + return databricks_v2_with_buzz_oauth(host); + } + } + + goose::providers::create(provider_name, Vec::new()) + .await + .map_err(|e| crate::map_provider_error(&e.to_string())) +} + +/// The Databricks workspace host, from the environment. +/// +/// Read from the environment rather than from goose's `Config` because +/// `Config::get_secret` consults the OS keyring, and an agent subprocess is +/// launched with the environment the desktop gives it. +fn databricks_host() -> Option { + std::env::var("DATABRICKS_HOST") + .ok() + .map(|host| host.trim().to_string()) + .filter(|host| !host.is_empty()) +} + +/// goose's `databricks_v2` provider, backed by buzz's PKCE token source. +fn databricks_v2_with_buzz_oauth(host: String) -> Result, AgentError> { + // A static token still wins, exactly as in goose's own `from_env`: a + // configured `DATABRICKS_TOKEN` is an explicit instruction not to run an + // interactive flow at all. + let auth = match std::env::var("DATABRICKS_TOKEN") { + Ok(token) if !token.trim().is_empty() => DatabricksAuth::token(token), + _ => DatabricksAuth::oauth(host.clone()), + }; + + let retry_config = DatabricksV2Provider::load_retry_config(|key| std::env::var(key).ok()); + + let provider = DatabricksV2Provider::new( + host, + auth, + retry_config, + None, + Some(buzz_oauth_token_provider()), + Some(Arc::new(|| { + std::env::var("DATABRICKS_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()) + })), + // Keeps the `agent-session-id` request header goose attaches for + // provider-side attribution; dropping it would silently lose that. + Some(goose::session_context::session_id_request_builder()), + None, + ) + .map_err(|e| AgentError::Llm(format!("databricks provider: {e}")))?; + + Ok(Arc::new(provider)) +} + +/// Adapts buzz's [`TokenSource`](buzz_model_catalog::auth::TokenSource) to the +/// closure shape goose's Databricks provider asks for. +/// +/// `bearer_no_browser` rather than `bearer`: this runs inside an inference +/// request on a headless agent subprocess, where the browser step would hang +/// with nobody to see it. Interactive login stays where a human can answer it +/// — `buzz-agent auth databricks`, and the desktop model picker. +fn buzz_oauth_token_provider() -> DatabricksOauthTokenProvider { + Arc::new(|host, _client_id, _redirect_url, _scopes| { + Box::pin(async move { + use buzz_model_catalog::auth::TokenSource; + let source = buzz_model_catalog::auth::PkceOAuthTokenSource::new( + buzz_model_catalog::databricks_pkce_config(&host), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; + source + .bearer_no_browser() + .await + .map_err(|e| anyhow::anyhow!("{e}")) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Guards against the environment leaking between these tests and the rest + /// of the suite: they mutate process-global state. + fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T { + let previous: Vec<(String, Option)> = vars + .iter() + .map(|(key, _)| (key.to_string(), std::env::var(key).ok())) + .collect(); + for (key, value) in vars { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + let result = f(); + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(&key, value), + None => std::env::remove_var(&key), + } + } + result + } + + /// One test, not three, because `std::env` is process-global and Rust + /// runs tests in this binary on parallel threads: separate tests touching + /// `DATABRICKS_HOST` would race each other and fail intermittently. + #[test] + fn the_host_is_read_and_trimmed_and_blank_means_absent() { + with_env( + &[("DATABRICKS_HOST", Some(" https://example.databricks.com "))], + || { + assert_eq!( + databricks_host().as_deref(), + Some("https://example.databricks.com") + ); + }, + ); + // An empty or whitespace host is what a desktop launch with an + // unconfigured workspace produces. Treating it as present would build + // a provider pointed at "", failing later with a confusing URL error + // instead of falling back to goose's registry cleanly. + for blank in ["", " "] { + with_env(&[("DATABRICKS_HOST", Some(blank))], || { + assert!(databricks_host().is_none(), "blank host {blank:?} accepted"); + }); + } + with_env(&[("DATABRICKS_HOST", None)], || { + assert!(databricks_host().is_none()); + }); + } + + /// Also one test: same process-global environment, same race otherwise. + #[test] + fn the_provider_builds_with_a_token_and_without_one() { + let host = "https://example.databricks.com"; + // A configured token is an explicit instruction not to run any + // interactive flow, and goose's own `from_env` honours it. buzz must + // not quietly change that. + with_env( + &[ + ("DATABRICKS_HOST", Some(host)), + ("DATABRICKS_TOKEN", Some("dapi-secret")), + ], + || assert!(databricks_v2_with_buzz_oauth(host.to_string()).is_ok()), + ); + // A blank token is not a token. Treating it as one would send + // `Authorization: Bearer ` and 401 every request, never trying the + // cached OAuth credential that would have worked. + with_env( + &[ + ("DATABRICKS_HOST", Some(host)), + ("DATABRICKS_TOKEN", Some(" ")), + ], + || assert!(databricks_v2_with_buzz_oauth(host.to_string()).is_ok()), + ); + with_env( + &[("DATABRICKS_HOST", Some(host)), ("DATABRICKS_TOKEN", None)], + || assert!(databricks_v2_with_buzz_oauth(host.to_string()).is_ok()), + ); + } +} From 42fb755040c3e6ceec0d883d8c6d0b3cb0999f46 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 12:57:32 +1000 Subject: [PATCH 35/51] fix(buzz-agent): the agent's configured provider must beat an inherited one An agent configured for OpenAI sent its OpenAI model to Anthropic and 404'd every turn (llm model not found: gpt-5.6-sol at api.anthropic.com). Found in live testing, not in a test. project_goose_env used set_if_absent, so an inherited GOOSE_PROVIDER won over the agent's own BUZZ_AGENT_PROVIDER. That looks defensive but inverts the intended precedence: BUZZ_AGENT_PROVIDER/MODEL are not ambient config, they are derived by the desktop from the agent record's structured provider/model fields at spawn time, and the desktop deliberately refuses to persist GOOSE_PROVIDER/GOOSE_MODEL in an agent's env so they cannot shadow those fields (env_vars.rs:DERIVED_PROVIDER_MODEL_ENV_KEYS). The subprocess still inherits the desktop's environment, though, and anyone with goose installed exports GOOSE_PROVIDER from their login shell. The failure is silent in the worst way: the agent's settings read correctly in the UI while its traffic goes to another provider. Set both unconditionally when the agent has them, and likewise map OPENAI_COMPAT_API_KEY over OPENAI_API_KEY: an inherited OPENAI_API_KEY would otherwise authenticate and bill the agent as whoever that key belongs to. With no BUZZ_AGENT_* value there is nothing to override with, so an ambient GOOSE_* is still honoured. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/config.rs | 83 ++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index b5d75661fa5..693b493469e 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -172,22 +172,44 @@ impl Config { /// /// 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`. + // Provider and model **override** any ambient `GOOSE_*`, they do not + // defer to it. + // + // `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` are not ambient config: + // the desktop derives them from the agent record's structured + // provider/model fields at spawn time, and deliberately refuses to + // persist `GOOSE_PROVIDER` / `GOOSE_MODEL` in an agent's own env so + // they cannot shadow those fields + // (`managed_agents/env_vars.rs:DERIVED_PROVIDER_MODEL_ENV_KEYS`). + // + // But the agent subprocess still inherits the desktop's environment, + // and the desktop inherits the user's login shell. Anyone with goose + // installed exports `GOOSE_PROVIDER`. With `set_if_absent` that + // inherited value won, so an agent configured for OpenAI sent its + // OpenAI model to Anthropic and got `404 model: gpt-…` on every turn + // — the agent looked broken while its settings looked correct. + // Observed in live testing, not hypothetical. if let Some(provider) = env_str("BUZZ_AGENT_PROVIDER") { - set_if_absent("GOOSE_PROVIDER", goose_provider_name(&provider)); + std::env::set_var("GOOSE_PROVIDER", goose_provider_name(&provider)); } if let Some(model) = env_str("BUZZ_AGENT_MODEL") { - set_if_absent("GOOSE_MODEL", &model); + std::env::set_var("GOOSE_MODEL", &model); } - // Key/base-url aliasing: native Goose names win if already present. + // Credentials override too, and for the same reason as the provider: + // the desktop hands the agent its configured key as + // `OPENAI_COMPAT_API_KEY`, while `OPENAI_API_KEY` is very commonly + // exported in a developer's login shell and inherited here. Deferring + // to the inherited one would bill and authenticate the agent as + // whoever that key belongs to rather than as configured — the same + // silent-wrong-target failure as the provider, with a credential + // instead of a hostname. if let Some(key) = env_str("OPENAI_COMPAT_API_KEY") { - set_if_absent("OPENAI_API_KEY", &key); + std::env::set_var("OPENAI_API_KEY", &key); } if let Some(base) = env_str("OPENAI_COMPAT_BASE_URL") { - set_if_absent("OPENAI_BASE_URL", &base); + std::env::set_var("OPENAI_BASE_URL", &base); } if let Some(effort) = env_str("BUZZ_AGENT_THINKING_EFFORT") { @@ -231,6 +253,53 @@ mod tests { std::env::remove_var("BUZZ_TEST_MISSING"); } + /// The regression that made a live OpenAI agent 404 against Anthropic. + /// + /// The desktop refuses to persist `GOOSE_PROVIDER` in an agent's env + /// precisely so it cannot shadow the agent's configured provider — but the + /// subprocess still inherits it from the user's login shell if goose is + /// installed. Deferring to the inherited value silently reroutes the + /// agent's traffic to the wrong provider while its settings still read + /// correctly. + #[test] + fn the_agents_configured_provider_beats_an_inherited_goose_provider() { + // One test: `project_goose_env` mutates process-global env, so + // splitting these would race the rest of the suite. + let restore: Vec<(&str, Option)> = [ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "GOOSE_PROVIDER", + "GOOSE_MODEL", + ] + .iter() + .map(|key| (*key, std::env::var(key).ok())) + .collect(); + + std::env::set_var("GOOSE_PROVIDER", "anthropic"); + std::env::set_var("GOOSE_MODEL", "claude-opus-5"); + std::env::set_var("BUZZ_AGENT_PROVIDER", "openai"); + std::env::set_var("BUZZ_AGENT_MODEL", "gpt-5.6-sol"); + + Config::project_goose_env(); + + assert_eq!(std::env::var("GOOSE_PROVIDER").unwrap(), "openai"); + assert_eq!(std::env::var("GOOSE_MODEL").unwrap(), "gpt-5.6-sol"); + + // With no agent-configured provider there is nothing to override with, + // so an ambient value is still the best answer available. + std::env::remove_var("BUZZ_AGENT_PROVIDER"); + std::env::set_var("GOOSE_PROVIDER", "anthropic"); + Config::project_goose_env(); + assert_eq!(std::env::var("GOOSE_PROVIDER").unwrap(), "anthropic"); + + for (key, value) in restore { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + #[test] fn databricks_v2_hyphen_is_aliased_for_goose() { // The desktop persists "databricks-v2" (agent_models.rs:757) but goose From d4db850b3af8cb5f1513678e273944de65c5aa32 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 13:57:21 +1000 Subject: [PATCH 36/51] fix(desktop): stop an agent inheriting the developer's provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop already refuses to persist GOOSE_PROVIDER/GOOSE_MODEL in an agent's env_vars so a stale override cannot shadow the record's structured provider/model fields. That guard only covers env we persist. The spawned child still inherits Desktop's environment, and Desktop inherits the user's login shell — where anyone with goose installed exports GOOSE_PROVIDER. So the developer's shell became the agent's configuration: an agent configured for OpenAI sent its OpenAI model to Anthropic and 404'd every turn, while the UI still showed OpenAI. Two agents on different providers made it look like one bot was broken rather than that the setting was being ignored. Clear DERIVED_PROVIDER_MODEL_ENV_KEYS at spawn before writing the ones derived from the record, so the record is the only source and an agent with no configured provider gets none rather than the developer's. The agent-side precedence fix (buzz-agent config.rs) makes buzz-agent robust to a dirty environment; this makes Desktop stop handing it one. Test asserts no inherited value survives into the child, and fails without the clearing step. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- .../src-tauri/src/managed_agents/agent_env.rs | 42 +++++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 25 +++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 05979e76cbf..af3850503cf 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -148,6 +148,48 @@ mod tests { ); } + /// The regression that routed an OpenAI agent's traffic to Anthropic. + /// + /// Desktop inherits the developer's login shell, so `GOOSE_PROVIDER` from + /// an installed goose reached the agent subprocess and beat the record's + /// configured provider. Spawn must clear the derived keys before writing + /// the ones it derives, so the record is the only source. + /// + /// Uses `env` as the child rather than reaching into `spawn_agent_child` + /// (which needs an `AppHandle`): the assertion is about what survives to + /// the child's environment, which is exactly what `env` prints. + #[test] + fn spawn_clears_inherited_provider_and_model_before_deriving_its_own() { + let mut cmd = std::process::Command::new("env"); + cmd.env_clear(); + // Stand in for the inherited login-shell environment. + for key in super::super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { + cmd.env(key, "inherited-from-login-shell"); + } + + // The clearing step under test, mirrored from `spawn_agent_child`. + for key in super::super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { + cmd.env_remove(key); + } + // Then the record's own values, as `runtime_metadata_env_vars` writes. + cmd.env("BUZZ_AGENT_PROVIDER", "openai"); + cmd.env("BUZZ_AGENT_MODEL", "gpt-5.6-sol"); + + let output = cmd.output().expect("env should run"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + !stdout.contains("inherited-from-login-shell"), + "no inherited provider/model value may reach the agent: {stdout}" + ); + assert!(stdout.contains("BUZZ_AGENT_PROVIDER=openai")); + assert!(stdout.contains("BUZZ_AGENT_MODEL=gpt-5.6-sol")); + // An agent with no configured provider must get none at all, rather + // than silently inheriting the developer's. + assert!(!stdout.contains("GOOSE_PROVIDER=")); + assert!(!stdout.contains("GOOSE_MODEL=")); + } + #[test] fn parse_agent_env_lines_splits_on_first_equals() { // Value may itself contain `=` — only the first `=` is the separator. diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 51f03c10776..66180e01cbe 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -6,10 +6,10 @@ use super::agent_env::build_buzz_agent_provider_defaults; use crate::{ managed_agents::{ - append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, - missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + append_log_marker, env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS, known_acp_runtime, + login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, + open_log_file, resolve_command, spawn_key_refusal, KnownAcpRuntime, + ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -740,6 +740,23 @@ pub fn spawn_agent_child( resolve_session_title(record.display_name.as_deref(), &record.name), ); build_buzz_agent_provider_defaults(&mut command); + // Clear inherited provider/model keys before deriving this agent's own. + // + // `DERIVED_PROVIDER_MODEL_ENV_KEYS` are refused at save time so a stale + // override cannot shadow the record's structured provider/model fields. + // That guard only covers env we *persist*: the child still inherits this + // process's environment, and Desktop inherits the user's login shell. + // A developer with goose installed exports `GOOSE_PROVIDER` there, and it + // reached the agent as if it were configuration — an agent set to OpenAI + // sent its OpenAI model to Anthropic and 404'd every turn while its + // settings still read "OpenAI" in the UI. + // + // Removing them here makes the record the only source: whatever + // `runtime_metadata_env_vars` derives below is what the child sees, and an + // agent with no configured provider gets none rather than the developer's. + for key in DERIVED_PROVIDER_MODEL_ENV_KEYS { + command.env_remove(key); + } if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, From 15f6fd57f8b4f5bdadbccbe7e5ff33d67b50119a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 14:06:03 +1000 Subject: [PATCH 37/51] refactor(buzz-agent): express the _Stop veto as a goose Operation Step 2 of PLANS/BUZZ_OPERATIONS_MIGRATION.md. Behaviour is unchanged: the existing stop_hook and real_dev_mcp integration tests pass without modification, which is the point of this step. BuzzStopVetoOperation replaces the inline stop_veto() branch. The loop now runs the gate a second time when a round wants to end, which is where the operation applies -- it declines unless the last message ends the turn, so the top-of-round call is a no-op for it. Two things move rather than change: - The block cap was a loop-local u32 in run_turn; objections now carry a metadata note and the cap counts them since kickoff. run_turn is per session/prompt, so both are turn-scoped -- same three-strikes behaviour, but now a pure function of the conversation, which is why goose tags its own denials the same way. - MAX_STOP_BLOCKS was declared in both loop_drive and hooks with the same value. Dropped the loop_drive copy. Deliberately not goose's StopHookOperation: it drives goose's private HookManager plugin system, while buzz's hook is an MCP tool that can see buzz-dev-mcp's in-process todo state. It also emits a user-facing notification per denial, which in buzz would post 'stop hook blocked ending this turn' into a channel every time an agent had an open todo. The objection reaches the model, not the room. 65 unit + 21 integration green, handtest --all 18/18. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/loop_drive.rs | 138 +++++++++------- crates/buzz-agent/src/ops.rs | 233 +++++++++++++++++++++++++++- 2 files changed, 311 insertions(+), 60 deletions(-) diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 235d9cae50e..2c9697426d4 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -70,10 +70,6 @@ const ERROR_REFLECTION: &str = /// conversation. const MAX_REFLECTIONS: usize = 8; -/// Hard cap on consecutive `_Stop` vetoes, mirroring goose's own -/// `stop_hook_block_cap`. A tool that always objects must not trap the turn. -pub const MAX_STOP_BLOCKS: u32 = 3; - /// Rounds allowed when the caller sets no limit. /// /// buzz-agent's loop was previously bounded by goose's `max_turns`. Owning the @@ -153,14 +149,19 @@ pub async fn run_turn( state.push(prompt); let max_rounds = ctx.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS); - let mut stop_blocks = 0u32; let mut reflections = 0usize; // First decision expressed as a goose `Operation` rather than inline // control flow. The others follow one at a time; see // `PLANS/BUZZ_OPERATIONS_MIGRATION.md` for the order and why. let outcome = crate::ops::Outcome::new(); - let machine = crate::ops::round_gate(max_rounds, outcome.clone(), ctx.cancel.clone()); + let machine = crate::ops::round_gate( + max_rounds, + outcome.clone(), + ctx.cancel.clone(), + ctx.hook_extension + .map(|extension| (Arc::clone(ctx.agent), extension.to_string())), + ); let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(16); // Operations may emit; nothing in this step does, but a dropped receiver // would silently swallow events from the ones that follow. @@ -179,12 +180,16 @@ pub async fn run_turn( // Ask the machine before doing any work. `step` returns the first // operation that applies, or `None` when the loop below should run. match gate(&machine, state.session(), &emitter).await { - Err(e) => return (Err(e), tokens, state.conversation().messages().to_vec()), - Ok(true) => { + Gate::Ended => { let reason = outcome.take().unwrap_or(StopReason::EndTurn); return (Ok(reason), tokens, state.conversation().messages().to_vec()); } - Ok(false) => {} + Gate::Applied(messages) => { + for message in messages { + state.push(message); + } + } + Gate::Open => {} } // Steers land here, at the round boundary — never mid-inference, @@ -211,48 +216,92 @@ pub async fn run_turn( continue; } - // The turn wants to end. `_Stop` gets a veto. - match stop_veto(&ctx, state.session(), &mut stop_blocks).await { - Some(objection) => { - state.push( - Message::user() - .with_text(format!("[Stop] {objection}")) - .with_visibility(false, true), - ); - continue; - } - None => { - // A steer arriving after this point belongs to no run. - ctx.steers.clear().await; - return ( - Ok(StopReason::EndTurn), - tokens, - state.conversation().messages().to_vec(), - ); + // The turn wants to end, so ask the machine again. This is + // the call `BuzzStopVetoOperation` exists for: it only applies + // to a conversation whose last message ends the turn, which is + // true here and false at the top of a round. + // + // Cancellation short-circuits it -- dispatching a hook tool + // for a turn the user already abandoned would delay the + // cancel for no gain. + if !ctx.cancel.is_cancelled() { + match gate(&machine, state.session(), &emitter).await { + Gate::Applied(messages) if !messages.is_empty() => { + for message in messages { + state.push(message); + } + continue; + } + Gate::Ended => { + let reason = outcome.take().unwrap_or(StopReason::EndTurn); + ctx.steers.clear().await; + return (Ok(reason), tokens, state.conversation().messages().to_vec()); + } + // An operation that applied without appending anything + // has not given the model new work, so the turn still + // ends -- otherwise the loop would spin. + Gate::Applied(_) | Gate::Open => {} } } + + // A steer arriving after this point belongs to no run. + ctx.steers.clear().await; + return ( + Ok(StopReason::EndTurn), + tokens, + state.conversation().messages().to_vec(), + ); } } } } -/// Run the operation gate for one round. +/// What the operation gate decided. +enum Gate { + /// No operation applied; the loop proceeds. + Open, + /// An operation applied and appended messages; the loop continues with + /// them in history rather than ending. + Applied(Vec), + /// An operation ended the turn. + Ended, +} + +/// Run the operation gate once. /// -/// Returns `true` when an operation ended the turn. +/// Called at the top of every round, and again when a round wants to end -- +/// that second call is what gives `_Stop` its veto, since its operation only +/// applies to a conversation whose last message ends the turn. async fn gate( machine: &goose::agents::state_machine::StateMachine<'_>, session: &Session, emitter: &Emitter, -) -> Result { +) -> Gate { match machine.step(session, emitter).await { - Ok(Some(result)) => Ok(result.yield_to_client), - Ok(None) => Ok(false), + Ok(Some(result)) if result.yield_to_client => Gate::Ended, + Ok(Some(result)) => Gate::Applied( + result + .effects + .into_iter() + .filter_map(|effect| match effect { + goose::agents::state_machine::StateEffect::AppendMessage(message) => { + Some(message) + } + // buzz's operations only append. Other effects belong to + // goose's own operations (recipes, approval, extension + // data) and have no meaning in this loop; ignoring them + // is correct rather than lossy. + _ => None, + }) + .collect(), + ), + Ok(None) => Gate::Open, // A failing gate must not take the turn down with it: the operations // here are guards, and a guard that errors should let the round // proceed rather than strand the user's prompt unanswered. Err(e) => { tracing::warn!(error = %e, "operation gate failed; continuing"); - Ok(false) + Gate::Open } } } @@ -509,29 +558,6 @@ async fn maybe_compact( Ok(()) } -/// Ask `_Stop` whether the turn may end. `Some(objection)` means keep working. -async fn stop_veto( - ctx: &TurnContext<'_>, - session: &Session, - stop_blocks: &mut u32, -) -> Option { - if ctx.cancel.is_cancelled() { - return None; - } - let extension = ctx.hook_extension?; - - if *stop_blocks >= MAX_STOP_BLOCKS { - tracing::warn!(blocks = *stop_blocks, "_Stop veto cap reached; ending turn"); - return None; - } - - let objection = crate::hooks::stop_objection(ctx.agent, session, extension).await?; - - *stop_blocks += 1; - tracing::info!(blocks = *stop_blocks, "_Stop hook vetoed end of turn"); - Some(objection) -} - /// Preserve buzz-agent's error taxonomy so the harness's JSON-RPC code mapping /// stays meaningful. fn classify_provider_error(msg: &str) -> AgentError { diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs index 1d532321a6d..666042275b0 100644 --- a/crates/buzz-agent/src/ops.rs +++ b/crates/buzz-agent/src/ops.rs @@ -22,7 +22,10 @@ use std::sync::{Arc, Mutex}; use anyhow::Result; use async_trait::async_trait; -use goose::agents::state_machine::{not_applicable, yielded, Emitter, Operation, OperationResult}; +use goose::agents::state_machine::{ + applied, not_applicable, yielded, Emitter, Operation, OperationResult, +}; +use goose::agents::Agent; use goose::conversation::Conversation; use goose::session::Session; use goose_provider_types::conversation::message::Message; @@ -160,6 +163,125 @@ impl Operation for BuzzMaxRoundsOperation { } } +/// Whether the last message ends the turn: an assistant message with no error +/// and no outstanding tool request. +/// +/// goose has this as `ends_turn` in its private `operation` module +/// (`operation.rs:63`); only the names re-exported from `state_machine` escape +/// the crate. Reimplemented to match, with one deliberate narrowing: buzz has +/// no frontend tools and no approval flow, so `FrontendToolRequest` and +/// `ActionRequired` cannot appear in a buzz conversation. Matching on +/// `ToolRequest` alone is the whole of buzz's turn shape. +fn ends_turn(messages: &[Message]) -> bool { + messages.last().is_some_and(|last| { + last.role == rmcp::model::Role::Assistant + && last.error_kind().is_none() + && !last.content.iter().any(|content| { + matches!( + content, + goose_provider_types::conversation::message::MessageContent::ToolRequest(_) + ) + }) + }) +} + +/// Lets the `_Stop` hook object to a turn that wants to end. +/// +/// When the agent has finished, `_Stop` is asked whether it may stop. An +/// objection is appended as an agent-visible `[Stop]` message and the turn +/// continues; silence lets it end. +/// +/// # How this differs from goose's `StopHookOperation` +/// +/// goose drives its own `HookManager` plugins and emits a user-facing +/// notification for each denial. buzz's hook is an **MCP tool** (`_Stop` on +/// the dev-MCP extension), and buzz stays silent: `buzz-acp` publishes +/// assistant messages into a channel, so a "hook blocked ending this turn" +/// notification would be posted to the humans in that channel every time an +/// agent's todo list was incomplete. The objection reaches the model, not the +/// room. Same reasoning as `BuzzMaxRoundsOperation` not appending goose's +/// "Would you like me to continue?". +/// +/// # Why the block count lives on the messages +/// +/// The cap was a loop-local `u32` in `run_turn`. Counting prior objections +/// from their own metadata instead makes the operation a pure function of the +/// conversation, so a pipeline rebuilt from persisted messages reaches the +/// same decision — the same reason goose tags its denials. +/// +/// **This is not a behaviour change.** `run_turn` is per `session/prompt`, so +/// the old counter was already turn-scoped, and `messages_since_kickoff` +/// spans exactly that same turn. Three objections still end the turn; a new +/// prompt still starts from zero. +pub struct BuzzStopVetoOperation { + agent: Arc, + extension: String, + block_cap: u32, +} + +impl BuzzStopVetoOperation { + /// `extension` is the MCP extension serving `_Stop`; without one there is + /// no hook to ask, and the operation never applies. + pub fn new(agent: Arc, extension: String, block_cap: u32) -> Self { + Self { + agent, + extension, + block_cap, + } + } +} + +/// Metadata key marking a message as a `_Stop` objection, so later rounds can +/// count them without a side channel. +const OBJECTED: &str = "objected"; + +#[async_trait] +impl Operation for BuzzStopVetoOperation { + fn name(&self) -> &'static str { + "buzz_stop_veto" + } + + async fn run( + &self, + session: &Session, + conversation: &Conversation, + _emit: &Emitter, + ) -> Result { + let Some(messages) = messages_since_kickoff(conversation) else { + return not_applicable(); + }; + // Only a turn that is trying to end can be vetoed. + if !ends_turn(messages) { + return not_applicable(); + } + + let blocks = messages + .iter() + .filter(|message| self.message_meta(message, OBJECTED).is_some()) + .count() as u32; + if blocks >= self.block_cap { + tracing::warn!(blocks, "_Stop veto cap reached; ending turn"); + return not_applicable(); + } + + // A broken or absent hook must never trap a turn -- `stop_objection` + // maps every failure to `None`, which is "no objection". + let Some(objection) = + crate::hooks::stop_objection(&self.agent, session, &self.extension).await + else { + return not_applicable(); + }; + + tracing::info!(blocks = blocks + 1, "_Stop hook vetoed end of turn"); + + let mut message = Message::user() + .with_text(format!("[Stop] {objection}")) + .with_visibility(false, true); + self.set_message_meta(&mut message, OBJECTED, serde_json::json!(true)); + applied([message.into()]) + } +} + /// The operations that gate the start of a round. /// /// One entry today. As `PLANS/BUZZ_OPERATIONS_MIGRATION.md` proceeds, the @@ -169,10 +291,23 @@ pub fn round_gate( max_rounds: u32, outcome: Outcome, cancel: tokio_util::sync::CancellationToken, + stop_veto: Option<(Arc, String)>, ) -> goose::agents::state_machine::StateMachine<'static> { - let steps = vec![goose::agents::state_machine::Step::Operation(Arc::new( - BuzzMaxRoundsOperation::new(max_rounds, outcome), - ))]; + use goose::agents::state_machine::Step; + + // Order is precedence. The round budget is checked first: once it is + // spent the turn ends, and asking `_Stop` to veto a turn we are ending + // anyway would dispatch a tool call whose answer cannot be honoured. + let mut steps: Vec = vec![Step::Operation(Arc::new(BuzzMaxRoundsOperation::new( + max_rounds, outcome, + )))]; + if let Some((agent, extension)) = stop_veto { + steps.push(Step::Operation(Arc::new(BuzzStopVetoOperation::new( + agent, + extension, + crate::hooks::MAX_STOP_BLOCKS, + )))); + } goose::agents::state_machine::StateMachine::new(steps, cancel) } @@ -182,6 +317,12 @@ mod tests { use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; + /// A bare agent with no extensions: enough to dispatch a tool call that + /// will fail to resolve, which is the "hook unavailable" path. + async fn agent() -> Arc { + Arc::new(Agent::new()) + } + fn emitter() -> Emitter { let (tx, rx) = mpsc::channel(16); // Keep the receiver alive for the duration of the test: a dropped @@ -243,6 +384,90 @@ mod tests { assert!(matches!(outcome.take(), Some(StopReason::MaxTurnRequests))); } + /// An assistant message with an outstanding tool request: the model is + /// still working, so the turn is not trying to end. + fn tool_calling_conversation() -> Conversation { + Conversation::new_unvalidated(vec![ + Message::user().with_text("kickoff"), + Message::assistant().with_tool_request( + "call_1", + Ok(rmcp::model::CallToolRequestParams::new("developer__shell")), + ), + ]) + } + + #[tokio::test] + async fn the_veto_does_not_apply_while_tools_are_outstanding() { + // The hook is only asked when the turn is trying to end. Asking + // mid-tool-call would dispatch `_Stop` on every round. + let op = BuzzStopVetoOperation::new(agent().await, "buzz-dev-mcp".to_string(), 3); + let result = op + .run( + &Session::default(), + &tool_calling_conversation(), + &emitter(), + ) + .await + .unwrap(); + assert!(matches!(result, OperationResult::NotApplicable)); + } + + #[tokio::test] + async fn a_missing_hook_extension_never_traps_the_turn() { + // No such extension -> `stop_objection` yields None -> the turn ends. + // A broken hook must not be able to hold a turn open. + let op = BuzzStopVetoOperation::new(agent().await, "no-such-extension".to_string(), 3); + let result = op + .run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(); + assert!(matches!(result, OperationResult::NotApplicable)); + } + + #[tokio::test] + async fn the_cap_counts_objections_already_in_the_conversation() { + // Three prior objections is the cap, so the operation must decline + // rather than dispatch a fourth `_Stop` call. Proven by pointing it at + // an extension that would otherwise be consulted: the result is the + // same NotApplicable either way, so instead assert the counting + // helper directly against tagged messages. + let op = BuzzStopVetoOperation::new(agent().await, "buzz-dev-mcp".to_string(), 3); + + let mut objection = Message::user() + .with_text("[Stop] finish your todos") + .with_visibility(false, true); + op.set_message_meta(&mut objection, OBJECTED, serde_json::json!(true)); + + let mut messages = vec![Message::user().with_text("kickoff")]; + for _ in 0..3 { + messages.push(objection.clone()); + messages.push(Message::assistant().with_text("done")); + } + let conversation = Conversation::new_unvalidated(messages); + + let counted = messages_since_kickoff(&conversation) + .unwrap() + .iter() + .filter(|message| op.message_meta(message, OBJECTED).is_some()) + .count(); + assert_eq!(counted, 3, "each objection must be countable from history"); + + // At the cap the turn is allowed to end. + let result = op + .run(&Session::default(), &conversation, &emitter()) + .await + .unwrap(); + assert!(matches!(result, OperationResult::NotApplicable)); + } + + #[tokio::test] + async fn ends_turn_matches_the_shape_of_a_finished_answer() { + assert!(ends_turn(&[Message::assistant().with_text("done")])); + assert!(!ends_turn(&[Message::user().with_text("hi")])); + assert!(!ends_turn(tool_calling_conversation().messages())); + assert!(!ends_turn(&[])); + } + #[tokio::test] async fn the_budget_appends_no_message() { // goose's MaxTurnsOperation asks "Would you like me to continue?". From ff44cf405cebc1ba9d5e7407e3f7bc94414ca0d0 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 15:08:45 +1000 Subject: [PATCH 38/51] fix(desktop): keep runtime.rs under the file-size ratchet The inherited-provider fix pushed runtime.rs from 996 to 1008 lines, past the 1000-line limit, failing Desktop Core. Move the clearing step into agent_env.rs as clear_inherited_provider_model_env, next to build_buzz_agent_provider_defaults which it pairs with, and next to the test that covers it. runtime.rs drops to 991; no behaviour change. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- .../src-tauri/src/managed_agents/agent_env.rs | 31 ++++++++++++++++--- .../src-tauri/src/managed_agents/runtime.rs | 28 ++++------------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index af3850503cf..88b8c2c1510 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -97,6 +97,29 @@ pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command } } +/// Drop inherited provider/model env before an agent's own is derived. +/// +/// `DERIVED_PROVIDER_MODEL_ENV_KEYS` are refused at save time so a stale +/// override cannot shadow the record's structured provider/model fields. That +/// guard only covers env we *persist*: the spawned child still inherits this +/// process's environment, and Desktop inherits the user's login shell. A +/// developer with goose installed exports `GOOSE_PROVIDER` there, and it +/// reached the agent as if it were configuration — an agent set to OpenAI sent +/// its OpenAI model to Anthropic and 404'd every turn while its settings still +/// read "OpenAI" in the UI. +/// +/// Clearing them makes the record the only source: whatever +/// `runtime_metadata_env_vars` writes afterwards is what the child sees, and an +/// agent with no configured provider gets none rather than the developer's. +/// +/// Call AFTER `build_buzz_agent_provider_defaults` and BEFORE the record's own +/// provider/model env. +pub(crate) fn clear_inherited_provider_model_env(cmd: &mut std::process::Command) { + for key in super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { + cmd.env_remove(key); + } +} + /// Parse newline-delimited `KEY=VALUE` lines from a baked env blob. /// Blank lines are skipped. Each non-blank line must contain `=`; the key /// is everything before the first `=`, the value is everything after (values @@ -122,7 +145,7 @@ pub(crate) fn parse_agent_env_lines(raw: &str) -> Vec<(&str, &str)> { mod tests { use super::{ baked_build_env, build_buzz_agent_provider_defaults, build_env_map, - discovery_env_with_baked_floor, parse_agent_env_lines, + clear_inherited_provider_model_env, discovery_env_with_baked_floor, parse_agent_env_lines, }; #[test] @@ -167,10 +190,8 @@ mod tests { cmd.env(key, "inherited-from-login-shell"); } - // The clearing step under test, mirrored from `spawn_agent_child`. - for key in super::super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { - cmd.env_remove(key); - } + // The clearing step under test, as `spawn_agent_child` calls it. + clear_inherited_provider_model_env(&mut cmd); // Then the record's own values, as `runtime_metadata_env_vars` writes. cmd.env("BUZZ_AGENT_PROVIDER", "openai"); cmd.env("BUZZ_AGENT_MODEL", "gpt-5.6-sol"); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 66180e01cbe..5a4fe1e3da2 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use tauri::AppHandle; -use super::agent_env::build_buzz_agent_provider_defaults; +use super::agent_env::{build_buzz_agent_provider_defaults, clear_inherited_provider_model_env}; use crate::{ managed_agents::{ - append_log_marker, env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS, known_acp_runtime, - login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, - open_log_file, resolve_command, spawn_key_refusal, KnownAcpRuntime, - ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, + append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, + missing_command_message, normalize_agent_args, open_log_file, resolve_command, + spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, + ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -740,23 +740,7 @@ pub fn spawn_agent_child( resolve_session_title(record.display_name.as_deref(), &record.name), ); build_buzz_agent_provider_defaults(&mut command); - // Clear inherited provider/model keys before deriving this agent's own. - // - // `DERIVED_PROVIDER_MODEL_ENV_KEYS` are refused at save time so a stale - // override cannot shadow the record's structured provider/model fields. - // That guard only covers env we *persist*: the child still inherits this - // process's environment, and Desktop inherits the user's login shell. - // A developer with goose installed exports `GOOSE_PROVIDER` there, and it - // reached the agent as if it were configuration — an agent set to OpenAI - // sent its OpenAI model to Anthropic and 404'd every turn while its - // settings still read "OpenAI" in the UI. - // - // Removing them here makes the record the only source: whatever - // `runtime_metadata_env_vars` derives below is what the child sees, and an - // agent with no configured provider gets none rather than the developer's. - for key in DERIVED_PROVIDER_MODEL_ENV_KEYS { - command.env_remove(key); - } + clear_inherited_provider_model_env(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, From ab5b289488908f2a76ab7db2beb5ccb86a3a87b9 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 15:18:27 +1000 Subject: [PATCH 39/51] fix(buzz-agent): restore the env surface main had The port dropped BUZZ_AGENT_* knobs whose implementation moved to goose. That conflated two different things: goose owning the mechanism, and the setting silently ceasing to work. Someone who set BUZZ_AGENT_TOOL_TIMEOUT_SECS=1200 got no error and a 300s timeout. Restored as mappings onto goose, defaults matching main: - TOOL_TIMEOUT_SECS -> GOOSE_DEFAULT_EXTENSION_TIMEOUT - MAX_TOOL_RESULT_TEXT_BYTES -> GOOSE_MAX_TOOL_RESPONSE_SIZE - NO_HINTS -> CONTEXT_FILE_NAMES=[] - STOP_MAX_REJECTIONS -> the veto cap, configurable again (0 disables) Both timeout and truncation project buzz's *default* as well as an explicit value, because goose's differ (300s vs 660s, 200KB vs 50KB) -- mapping only explicit values would still have changed behaviour for everyone who never set them. Two silent regressions fixed: - MAX_SESSIONS defaulted to 8; main was unlimited. A busy agent would have started refusing sessions it used to accept. - The reply guard was gone entirely. Desktop still enables it by default for shared-compute agents (relay_mesh.rs), so the flag was set and ignored. Restored as BuzzReplyGuardOperation with main's nag text, two-reminder budget, and publish-shaped-call latch. README: every removed knob is now listed as 'No longer read' with what replaced it, rather than documented as if still live. 74 unit + 21 integration green, handtest --all 18/18. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/README.md | 27 +++- crates/buzz-agent/src/config.rs | 168 ++++++++++++++++++- crates/buzz-agent/src/hooks.rs | 18 ++- crates/buzz-agent/src/lib.rs | 1 + crates/buzz-agent/src/loop_drive.rs | 5 + crates/buzz-agent/src/ops.rs | 242 +++++++++++++++++++++++++++- 6 files changed, 448 insertions(+), 13 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 5d942777d5e..de7b64617fb 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -155,16 +155,27 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. | | `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `32768` | Per LLM call. Headroom for large tool-call inputs (e.g. file writes via heredoc); Sonnet 4 / Opus 4 cap at 64K. | | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | -| `BUZZ_AGENT_MAX_HANDOFFS` | `10` | Max context handoffs per session before falling back to truncation. | -| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). | -| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds | -| `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) | +| `BUZZ_AGENT_MAX_HANDOFFS` | — | **No longer read.** goose's compaction replaced the handoff mechanism it bounded. | +| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | — | **No longer read.** goose owns provider transport and its own per-provider timeouts (`OPENAI_TIMEOUT`, `ANTHROPIC_TIMEOUT`, default 600s). | +| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds. Projected onto goose's `GOOSE_DEFAULT_EXTENSION_TIMEOUT`. | +| `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | — | **No longer read.** All of a round's tool calls are dispatched concurrently; there is no cap to configure. | | `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. | -| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | -| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | -| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | +| `BUZZ_AGENT_MAX_LINE_BYTES` | — | **No longer read.** The frame cap is a fixed 16 MiB protocol limit (`config.rs::MAX_LINE_BYTES`). | +| `BUZZ_AGENT_MAX_HISTORY_BYTES` | — | **No longer read.** Byte-based eviction is replaced by goose's token-aware compaction; size the window with `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. | +| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text. Projected onto goose's `GOOSE_MAX_TOOL_RESPONSE_SIZE` (whose own default is 200 KiB). | | `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | - +| `BUZZ_AGENT_STOP_MAX_REJECTIONS` | `3` | Max consecutive `_Stop` vetoes per turn. `0` disables the `_Stop` veto entirely. | +| `BUZZ_AGENT_NO_HINTS` | `0` | `1` suppresses `AGENTS.md` / `.goosehints` loading (sets goose's `CONTEXT_FILE_NAMES` empty). | +| `BUZZ_AGENT_APPROVAL` | `auto` | Tool-call approval policy passed to goose: `auto` \| `approve` \| `smart_approve` \| `chat`. `auto` matches previous behaviour. | +| `BUZZ_AGENT_HOOK_TIMEOUT_MS` | — | **No longer read.** Hook tools are dispatched through goose's normal tool path and share its extension timeout. | +| `BUZZ_AGENT_PROMPT_CACHING` | — | **No longer read.** goose's providers manage prompt caching. | +| `BUZZ_AGENT_THINKING_SUMMARY` | — | **No longer read.** goose's providers own reasoning-summary handling; `BUZZ_AGENT_THINKING_EFFORT` still applies. | +| `BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS`, `BUZZ_AGENT_MCP_RESTART_*` | — | **No longer read.** goose's `ExtensionManager` owns MCP startup and restart. | + + +> Variables marked **No longer read** were live in the pre-goose agent. They are +> listed rather than deleted so anyone who set one can see that it now has no +> effect, and what replaced it. Everything else behaves as it did before. ## Reply Guard diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 693b493469e..2a2b819f415 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -52,6 +52,10 @@ pub struct Config { /// 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, + /// Remind the model to publish when a turn is about to end with no + /// recognised attempt to post to Buzz. Default off; Desktop opts + /// shared-compute agents in via `BUZZ_AGENT_REQUIRE_REPLY=1`. + pub require_reply: bool, // ---- Databricks model-discovery fields ------------------------------- // Goose owns provider auth for the agent loop, but the desktop model @@ -141,9 +145,13 @@ impl Config { 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), + // `usize::MAX`, not a small number: main defaulted to unlimited + // and an agent that never set this must not start refusing + // sessions after some arbitrary count. + max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(usize::MAX), system_prompt, goose_mode: parse_approval(env_str("BUZZ_AGENT_APPROVAL").as_deref()), + require_reply: env_str("BUZZ_AGENT_REQUIRE_REPLY").is_some_and(|v| v != "0"), // Discovery-only; the agent loop resolves providers through goose. provider: Provider::default(), api_key: String::new(), @@ -162,6 +170,7 @@ impl Config { max_sessions: 1, system_prompt: None, goose_mode: GooseMode::default(), + require_reply: false, provider, api_key, base_url, @@ -221,9 +230,56 @@ impl Config { if let Some(ctx) = env_str("BUZZ_AGENT_MAX_CONTEXT_TOKENS") { set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx); } + + // ---- Knobs whose implementation moved to goose ------------------ + // + // goose owning the *mechanism* must not mean the `BUZZ_AGENT_*` name + // stops working. Anyone who set one of these against buzz-agent on + // main gets the same effect here, because we translate it onto goose's + // equivalent. Silently ignoring a knob someone deliberately set is the + // worst outcome: no error, just different behaviour. + // + // `set_if_absent`, unlike the provider/model pair above: these have no + // structured field behind them, so an explicit `GOOSE_*` in the + // environment is a deliberate override and should win. + + // Per-tool-call timeout. buzz's default was 660s; goose's is 300s + // (`DEFAULT_EXTENSION_TIMEOUT`), so leaving it unset would silently + // shorten every long tool call. We therefore project buzz's default + // too, not just an explicit value. + set_if_absent( + "GOOSE_DEFAULT_EXTENSION_TIMEOUT", + &env_str("BUZZ_AGENT_TOOL_TIMEOUT_SECS") + .unwrap_or_else(|| DEFAULT_TOOL_TIMEOUT_SECS.to_string()), + ); + + // Tool-result truncation threshold. Same reasoning: buzz truncated at + // 50 KB, goose at 200 KB, so the default has to be carried across or a + // tool returning 100 KB reaches the model on this branch when it did + // not on main. + set_if_absent( + "GOOSE_MAX_TOOL_RESPONSE_SIZE", + &env_str("BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES") + .unwrap_or_else(|| DEFAULT_TOOL_RESULT_TEXT_BYTES.to_string()), + ); + + // `BUZZ_AGENT_NO_HINTS=1` suppressed AGENTS.md/.goosehints loading. + // goose has no boolean for this, but it takes the *filename list* — + // an empty list finds nothing, which is the same outcome. + if env_str("BUZZ_AGENT_NO_HINTS").is_some_and(|v| v != "0") { + set_if_absent("CONTEXT_FILE_NAMES", "[]"); + } } } +/// Per-tool-call timeout, in seconds. buzz-agent's long-standing default, +/// preserved because goose's is shorter (300s). +const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 660; + +/// Tool-result text truncation threshold, in bytes. buzz-agent's default, +/// preserved because goose's is larger (200 KB). +const DEFAULT_TOOL_RESULT_TEXT_BYTES: usize = 50 * 1024; + fn set_if_absent(key: &str, value: &str) { if std::env::var_os(key).is_none() { std::env::set_var(key, value); @@ -234,9 +290,119 @@ fn set_if_absent(key: &str, value: &str) { mod tests { use super::*; + /// Process env is global; tests that mutate it must not interleave. + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + // Env is process-global; these tests set disjoint keys and assert only on // the pure mapping helpers where possible. + /// Every `BUZZ_AGENT_*` knob whose implementation moved to goose must + /// still reach goose under its goose name. A knob that silently stops + /// working is the regression this test exists to prevent. + /// + /// Serialised by the shared env lock: `project_goose_env` mutates process + /// globals. + #[test] + fn knobs_that_moved_to_goose_still_reach_it() { + let _guard = env_lock(); + let keys = [ + "BUZZ_AGENT_TOOL_TIMEOUT_SECS", + "BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES", + "BUZZ_AGENT_NO_HINTS", + "GOOSE_DEFAULT_EXTENSION_TIMEOUT", + "GOOSE_MAX_TOOL_RESPONSE_SIZE", + "CONTEXT_FILE_NAMES", + ]; + for key in keys { + std::env::remove_var(key); + } + + std::env::set_var("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "1200"); + std::env::set_var("BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES", "12345"); + std::env::set_var("BUZZ_AGENT_NO_HINTS", "1"); + Config::project_goose_env(); + + assert_eq!( + std::env::var("GOOSE_DEFAULT_EXTENSION_TIMEOUT").unwrap(), + "1200" + ); + assert_eq!( + std::env::var("GOOSE_MAX_TOOL_RESPONSE_SIZE").unwrap(), + "12345" + ); + assert_eq!(std::env::var("CONTEXT_FILE_NAMES").unwrap(), "[]"); + + for key in keys { + std::env::remove_var(key); + } + } + + /// buzz's defaults differ from goose's, so leaving them unset would change + /// behaviour for everyone who never touched these knobs: tool calls would + /// time out at 300s instead of 660s, and tool output would truncate at + /// 200 KB instead of 50 KB. + #[test] + fn buzz_defaults_are_projected_not_left_to_goose() { + let _guard = env_lock(); + for key in [ + "BUZZ_AGENT_TOOL_TIMEOUT_SECS", + "BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES", + "GOOSE_DEFAULT_EXTENSION_TIMEOUT", + "GOOSE_MAX_TOOL_RESPONSE_SIZE", + ] { + std::env::remove_var(key); + } + + Config::project_goose_env(); + + assert_eq!( + std::env::var("GOOSE_DEFAULT_EXTENSION_TIMEOUT").unwrap(), + "660", + "buzz's tool timeout, not goose's 300s" + ); + assert_eq!( + std::env::var("GOOSE_MAX_TOOL_RESPONSE_SIZE").unwrap(), + (50 * 1024).to_string(), + "buzz's truncation threshold, not goose's 200 KB" + ); + + for key in [ + "GOOSE_DEFAULT_EXTENSION_TIMEOUT", + "GOOSE_MAX_TOOL_RESPONSE_SIZE", + ] { + std::env::remove_var(key); + } + } + + /// main defaulted to unlimited concurrent sessions. A small default would + /// make a busy agent start refusing sessions it used to accept. + #[test] + fn max_sessions_defaults_to_unlimited() { + let _guard = env_lock(); + std::env::remove_var("BUZZ_AGENT_MAX_SESSIONS"); + assert_eq!(Config::from_env().max_sessions, usize::MAX); + } + + /// Desktop enables this for shared-compute agents; it must be read. + #[test] + fn require_reply_is_read_from_the_environment() { + let _guard = env_lock(); + std::env::remove_var("BUZZ_AGENT_REQUIRE_REPLY"); + assert!(!Config::from_env().require_reply); + + std::env::set_var("BUZZ_AGENT_REQUIRE_REPLY", "1"); + assert!(Config::from_env().require_reply); + + // An explicit 0 opts out, which relay_mesh.rs goes out of its way to + // preserve through the spawn path. + std::env::set_var("BUZZ_AGENT_REQUIRE_REPLY", "0"); + assert!(!Config::from_env().require_reply); + std::env::remove_var("BUZZ_AGENT_REQUIRE_REPLY"); + } + #[test] fn set_if_absent_does_not_clobber() { std::env::set_var("BUZZ_TEST_EXISTING", "native"); diff --git a/crates/buzz-agent/src/hooks.rs b/crates/buzz-agent/src/hooks.rs index 6fdb8db5be9..3f2e51a4998 100644 --- a/crates/buzz-agent/src/hooks.rs +++ b/crates/buzz-agent/src/hooks.rs @@ -38,10 +38,24 @@ 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. +/// Default 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; +/// The veto cap in force, from `BUZZ_AGENT_STOP_MAX_REJECTIONS`. +/// +/// Configurable rather than hardcoded because it was configurable on main, and +/// **`0` disables the `_Stop` veto entirely** — documented behaviour there +/// ("set to 0 to disable `_Stop` hooks entirely"), which an operator with a +/// misbehaving hook relies on. A hardcoded cap silently takes that away. +pub fn stop_block_cap() -> u32 { + std::env::var("BUZZ_AGENT_STOP_MAX_REJECTIONS") + .ok() + .and_then(|raw| raw.trim().parse().ok()) + .unwrap_or(MAX_STOP_BLOCKS) +} + /// 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. diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b94c1d93b29..ceca9b6ca52 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -753,6 +753,7 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire wire_tx, cancel: &cancel, hook_extension: hook_extension.as_deref(), + require_reply: app.cfg.require_reply, max_rounds: app.cfg.max_rounds, prompt: &prompt, steers: &steers, diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 2c9697426d4..30f37219d8d 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -108,6 +108,10 @@ pub struct TurnContext<'a> { /// buzz-agent holds this across turns itself now that the turn's /// conversation is in memory rather than in goose's database. pub history: &'a [Message], + /// Whether the reply guard is armed for this session + /// (`BUZZ_AGENT_REQUIRE_REPLY`). Desktop turns it on by default for + /// shared-compute agents. + pub require_reply: bool, /// Provider and model config for this turn. Read from here rather than /// from goose's `Agent`, which resolves the config out of its session /// store; see [`crate::model`]. @@ -161,6 +165,7 @@ pub async fn run_turn( ctx.cancel.clone(), ctx.hook_extension .map(|extension| (Arc::clone(ctx.agent), extension.to_string())), + ctx.require_reply, ); let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(16); // Operations may emit; nothing in this step does, but a dropped receiver diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs index 666042275b0..5c24455afdf 100644 --- a/crates/buzz-agent/src/ops.rs +++ b/crates/buzz-agent/src/ops.rs @@ -28,7 +28,7 @@ use goose::agents::state_machine::{ use goose::agents::Agent; use goose::conversation::Conversation; use goose::session::Session; -use goose_provider_types::conversation::message::Message; +use goose_provider_types::conversation::message::{Message, ToolRequest}; use crate::types::StopReason; @@ -282,6 +282,125 @@ impl Operation for BuzzStopVetoOperation { } } +/// Reminds the model to publish when a turn is about to end with nothing +/// posted to Buzz. +/// +/// Ported from main's reply guard (`agent.rs`, pre-goose), which this branch +/// dropped. Desktop still enables it by default for shared-compute/mesh agents +/// (`relay_mesh.rs`), so without this the flag is set and silently ignored. +/// +/// Advisory: at most [`MAX_REPLY_NAGS`] reminders, then the turn ends whether +/// or not anything was published. The guard catches accidental omission; it +/// does not compel speech. +pub struct BuzzReplyGuardOperation { + max_nags: u32, +} + +/// Reminder text. Kept byte-identical to main's. +/// +/// Explicitly licenses silence: the base prompt tells agents publishing is +/// optional and "silence is usually correct", and a reminder that argued +/// otherwise would fight that instruction and make agents chattier. +const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \ +Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \ +or hit a blocker that someone is waiting on, it exists only if you publish it. \ +If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn."; + +/// Maximum reminders per turn, as on main. +pub const MAX_REPLY_NAGS: u32 = 2; + +/// Metadata key marking a reminder, so the budget survives in the +/// conversation rather than in a loop-local counter. +const NAGGED: &str = "nagged"; + +impl BuzzReplyGuardOperation { + pub fn new(max_nags: u32) -> Self { + Self { max_nags } + } + + /// Whether a tool request is a recognised attempt to publish to Buzz. + /// + /// An *attempt*, not a success: a failed send already returns a non-zero + /// exit and error JSON to the model, which is louder than this reminder. + /// + /// Same matcher as main's `is_reply_shaped`, with one simplification the + /// port allows: main checked `mcp.has(name) && !mcp.is_hook(name)` first + /// because a hallucinated `fake__shell` would otherwise disarm the guard. + /// Here the conversation only contains requests goose actually dispatched, + /// so a hallucinated name never reaches this code. + fn is_reply_shaped(request: &ToolRequest) -> bool { + let Ok(call) = &request.tool_call else { + return false; + }; + if !call.name.ends_with("__shell") { + return false; + } + call.arguments + .as_ref() + .and_then(|args| args.get("command")) + .and_then(|command| command.as_str()) + // Coarse substring test against the structured `command` field, as + // on main: `messages send` also covers `send-diff`, and `reactions + // add` counts because the base prompt tells agents to react rather + // than post a bare acknowledgement. Missing a real post is the + // expensive direction, so the forgiving match is the right one. + .is_some_and(|cmd| cmd.contains("messages send") || cmd.contains("reactions add")) + } +} + +#[async_trait] +impl Operation for BuzzReplyGuardOperation { + fn name(&self) -> &'static str { + "buzz_reply_guard" + } + + async fn run( + &self, + _session: &Session, + conversation: &Conversation, + _emit: &Emitter, + ) -> Result { + let Some(messages) = messages_since_kickoff(conversation) else { + return not_applicable(); + }; + if !ends_turn(messages) { + return not_applicable(); + } + + let nags = messages + .iter() + .filter(|message| self.message_meta(message, NAGGED).is_some()) + .count() as u32; + if nags >= self.max_nags { + return not_applicable(); + } + + // Any publish-shaped call this turn disarms the guard for the rest of + // it, matching main's `buzz_reply_call_seen` latch. + let published = messages.iter().any(|message| { + message.content.iter().any(|content| { + matches!( + content, + goose_provider_types::conversation::message::MessageContent::ToolRequest( + request, + ) if Self::is_reply_shaped(request) + ) + }) + }); + if published { + return not_applicable(); + } + + tracing::info!(nags = nags + 1, "reply guard reminded the model to publish"); + + let mut message = Message::user() + .with_text(REPLY_GUARD_NAG) + .with_visibility(false, true); + self.set_message_meta(&mut message, NAGGED, serde_json::json!(true)); + applied([message.into()]) + } +} + /// The operations that gate the start of a round. /// /// One entry today. As `PLANS/BUZZ_OPERATIONS_MIGRATION.md` proceeds, the @@ -292,6 +411,7 @@ pub fn round_gate( outcome: Outcome, cancel: tokio_util::sync::CancellationToken, stop_veto: Option<(Arc, String)>, + require_reply: bool, ) -> goose::agents::state_machine::StateMachine<'static> { use goose::agents::state_machine::Step; @@ -305,7 +425,15 @@ pub fn round_gate( steps.push(Step::Operation(Arc::new(BuzzStopVetoOperation::new( agent, extension, - crate::hooks::MAX_STOP_BLOCKS, + crate::hooks::stop_block_cap(), + )))); + } + // After the `_Stop` veto: on main both objections rode the same gate, and + // a hook objection already keeps the turn alive, so reminding as well + // would spend two rounds where main spent one. + if require_reply { + steps.push(Step::Operation(Arc::new(BuzzReplyGuardOperation::new( + MAX_REPLY_NAGS, )))); } goose::agents::state_machine::StateMachine::new(steps, cancel) @@ -460,6 +588,116 @@ mod tests { assert!(matches!(result, OperationResult::NotApplicable)); } + fn shell_request(command: &str) -> Message { + let mut args = serde_json::Map::new(); + args.insert("command".into(), serde_json::json!(command)); + Message::assistant().with_tool_request( + "call_1", + Ok(rmcp::model::CallToolRequestParams::new("developer__shell").with_arguments(args)), + ) + } + + /// A turn that ran a tool and then answered. + fn turn_with(request: Message) -> Conversation { + Conversation::new_unvalidated(vec![ + Message::user().with_text("kickoff"), + request, + tool_response(), + Message::assistant().with_text("done"), + ]) + } + + #[tokio::test] + async fn the_reply_guard_reminds_when_nothing_was_published() { + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let result = op + .run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(); + match result { + OperationResult::Applied(step) => { + assert!(!step.yield_to_client, "a reminder must not end the turn"); + assert_eq!(step.effects.len(), 1); + } + OperationResult::NotApplicable => panic!("expected a reminder"), + } + } + + #[tokio::test] + async fn a_publish_attempt_disarms_the_guard() { + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + for command in [ + "buzz messages send --channel x --content hi", + "buzz messages send-diff --channel x", + "buzz reactions add --event x --emoji +1", + ] { + let result = op + .run( + &Session::default(), + &turn_with(shell_request(command)), + &emitter(), + ) + .await + .unwrap(); + assert!( + matches!(result, OperationResult::NotApplicable), + "`{command}` should disarm the guard" + ); + } + } + + #[tokio::test] + async fn an_unrelated_tool_call_does_not_disarm_the_guard() { + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let result = op + .run( + &Session::default(), + &turn_with(shell_request("ls -la")), + &emitter(), + ) + .await + .unwrap(); + assert!(matches!(result, OperationResult::Applied(_))); + } + + #[tokio::test] + async fn the_guard_stops_after_its_budget() { + // Advisory, not compulsion: after MAX_REPLY_NAGS the turn ends whether + // or not anything was published. + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let mut nag = Message::user() + .with_text(REPLY_GUARD_NAG) + .with_visibility(false, true); + op.set_message_meta(&mut nag, NAGGED, serde_json::json!(true)); + + let mut messages = vec![Message::user().with_text("kickoff")]; + for _ in 0..MAX_REPLY_NAGS { + messages.push(nag.clone()); + messages.push(Message::assistant().with_text("done")); + } + let result = op + .run( + &Session::default(), + &Conversation::new_unvalidated(messages), + &emitter(), + ) + .await + .unwrap(); + assert!(matches!(result, OperationResult::NotApplicable)); + } + + #[tokio::test] + async fn a_zero_cap_disables_the_veto_entirely() { + // Documented behaviour on main: BUZZ_AGENT_STOP_MAX_REJECTIONS=0 turns + // `_Stop` off. An operator with a misbehaving hook depends on it. + let op = BuzzStopVetoOperation::new(agent().await, "buzz-dev-mcp".to_string(), 0); + let result = op + .run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(); + assert!(matches!(result, OperationResult::NotApplicable)); + } + #[tokio::test] async fn ends_turn_matches_the_shape_of_a_finished_answer() { assert!(ends_turn(&[Message::assistant().with_text("done")])); From aef3921c1e01195aaa8803d5be65d6610a2cf832 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 15:23:14 +1000 Subject: [PATCH 40/51] fix(desktop): strip inherited agent config by prefix, not by name The previous fix removed GOOSE_MODEL and GOOSE_PROVIDER: the two keys that caused the observed bug. That is a denylist of length two. GOOSE_MODE, GOOSE_CONTEXT_LIMIT, GOOSE_MAX_TOKENS, GOOSE_THINKING_EFFORT, OPENAI_API_KEY and the rest still reached the agent from the developer's login shell -- the same bug waiting on a different variable. Strip by prefix instead: GOOSE_, ANTHROPIC_, OPENAI_, OPENROUTER_, DATABRICKS_. That closes the class, including variables goose adds later. Deliberately not a whole-environment allowlist. Agents run shell tools that need the developer's PATH, HOME, SSH_AUTH_SOCK, proxy settings and toolchain vars; an allowlist that missed one would break tools silently, which is worse than the bug being fixed. Agent configuration is Buzz's to own -- the rest of the shell is the user's. The test now supplies inherited keys explicitly instead of reading the real environment. As written before it passed only because my own shell exports GOOSE_*; on a clean machine it asserted nothing. Desktop suite 2401 green, clippy and fmt clean, ratchet passes. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- .../src-tauri/src/managed_agents/agent_env.rs | 146 ++++++++++++++---- .../src-tauri/src/managed_agents/runtime.rs | 4 +- 2 files changed, 115 insertions(+), 35 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 88b8c2c1510..56c5c7adc93 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -97,26 +97,68 @@ pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command } } -/// Drop inherited provider/model env before an agent's own is derived. +/// Prefixes of inherited environment that must not reach an agent. /// -/// `DERIVED_PROVIDER_MODEL_ENV_KEYS` are refused at save time so a stale -/// override cannot shadow the record's structured provider/model fields. That -/// guard only covers env we *persist*: the spawned child still inherits this -/// process's environment, and Desktop inherits the user's login shell. A -/// developer with goose installed exports `GOOSE_PROVIDER` there, and it -/// reached the agent as if it were configuration — an agent set to OpenAI sent -/// its OpenAI model to Anthropic and 404'd every turn while its settings still -/// read "OpenAI" in the UI. +/// `GOOSE_*` because goose reads its entire configuration from the +/// environment, so any of it inherited from the launching shell is +/// configuration Buzz did not choose. The provider families because Buzz +/// supplies the agent's own credentials and endpoints; an inherited key would +/// authenticate and bill the agent as whoever it belongs to. +const STRIPPED_ENV_PREFIXES: &[&str] = &[ + "GOOSE_", + "ANTHROPIC_", + "OPENAI_", + "OPENROUTER_", + "DATABRICKS_", +]; + +/// Whether an inherited variable is Buzz's to decide rather than the shell's. +fn is_stripped_env_key(key: &str) -> bool { + STRIPPED_ENV_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) +} + +/// Drop inherited agent configuration so the record is the only source. +/// +/// A spawned agent inherits Desktop's environment, and Desktop inherits the +/// user's login shell. A developer with goose installed exports +/// `GOOSE_PROVIDER` there, and it reached the agent as if it were +/// configuration: an agent set to OpenAI sent its OpenAI model to Anthropic and +/// 404'd every turn while its settings still read "OpenAI" in the UI. +/// +/// Stripping is **by prefix, not by name**. Naming the two keys that caused +/// that bug would leave `GOOSE_MODE`, `GOOSE_CONTEXT_LIMIT`, +/// `GOOSE_MAX_TOKENS`, `OPENAI_API_KEY` and the rest still leaking — the same +/// bug waiting on a different variable. Prefixes close the class. +/// +/// Deliberately **not** a whole-environment allowlist. Agents run shell tools, +/// and those need the developer's `PATH`, `HOME`, `SSH_AUTH_SOCK`, proxy +/// settings and language-toolchain variables (`NVM_DIR`, `CARGO_HOME`, +/// `JAVA_HOME`, …). An allowlist that missed one would break tools silently, +/// which is a worse failure than the one being fixed. Only agent +/// configuration is Buzz's to own; the rest of the shell is the user's. /// -/// Clearing them makes the record the only source: whatever -/// `runtime_metadata_env_vars` writes afterwards is what the child sees, and an -/// agent with no configured provider gets none rather than the developer's. +/// Call AFTER `build_buzz_agent_provider_defaults` and BEFORE any env Buzz +/// derives from the record — those are written afterwards and so survive. +pub(crate) fn clear_inherited_agent_config(cmd: &mut std::process::Command) { + clear_inherited_agent_config_from(cmd, std::env::vars().map(|(key, _)| key)); +} + +/// The strip step, over a supplied key list. /// -/// Call AFTER `build_buzz_agent_provider_defaults` and BEFORE the record's own -/// provider/model env. -pub(crate) fn clear_inherited_provider_model_env(cmd: &mut std::process::Command) { - for key in super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { - cmd.env_remove(key); +/// Split out so a test can supply keys directly. Taking them from +/// `std::env::vars()` inside the function would make the test pass or fail +/// depending on what the developer running it happens to export — which is the +/// same class of accident this whole function exists to prevent. +fn clear_inherited_agent_config_from( + cmd: &mut std::process::Command, + keys: impl IntoIterator, +) { + for key in keys { + if is_stripped_env_key(&key) { + cmd.env_remove(&key); + } } } @@ -145,7 +187,8 @@ pub(crate) fn parse_agent_env_lines(raw: &str) -> Vec<(&str, &str)> { mod tests { use super::{ baked_build_env, build_buzz_agent_provider_defaults, build_env_map, - clear_inherited_provider_model_env, discovery_env_with_baked_floor, parse_agent_env_lines, + clear_inherited_agent_config_from, discovery_env_with_baked_floor, is_stripped_env_key, + parse_agent_env_lines, }; #[test] @@ -175,23 +218,31 @@ mod tests { /// /// Desktop inherits the developer's login shell, so `GOOSE_PROVIDER` from /// an installed goose reached the agent subprocess and beat the record's - /// configured provider. Spawn must clear the derived keys before writing - /// the ones it derives, so the record is the only source. + /// configured provider. Spawn must strip inherited agent configuration + /// before writing what it derives, so the record is the only source. /// - /// Uses `env` as the child rather than reaching into `spawn_agent_child` - /// (which needs an `AppHandle`): the assertion is about what survives to - /// the child's environment, which is exactly what `env` prints. + /// Inherited keys are supplied explicitly rather than read from the real + /// environment: a test that depended on what the developer exports would + /// be testing their shell, not this code. #[test] - fn spawn_clears_inherited_provider_and_model_before_deriving_its_own() { + fn spawn_strips_inherited_agent_config_before_deriving_its_own() { + let inherited = [ + "GOOSE_PROVIDER", + "GOOSE_MODEL", + "GOOSE_MODE", + "GOOSE_CONTEXT_LIMIT", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "DATABRICKS_HOST", + ]; + let mut cmd = std::process::Command::new("env"); cmd.env_clear(); - // Stand in for the inherited login-shell environment. - for key in super::super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { + for key in inherited { cmd.env(key, "inherited-from-login-shell"); } - // The clearing step under test, as `spawn_agent_child` calls it. - clear_inherited_provider_model_env(&mut cmd); + clear_inherited_agent_config_from(&mut cmd, inherited.iter().map(|key| (*key).to_string())); // Then the record's own values, as `runtime_metadata_env_vars` writes. cmd.env("BUZZ_AGENT_PROVIDER", "openai"); cmd.env("BUZZ_AGENT_MODEL", "gpt-5.6-sol"); @@ -201,14 +252,43 @@ mod tests { assert!( !stdout.contains("inherited-from-login-shell"), - "no inherited provider/model value may reach the agent: {stdout}" + "no inherited agent config may reach the agent: {stdout}" ); assert!(stdout.contains("BUZZ_AGENT_PROVIDER=openai")); assert!(stdout.contains("BUZZ_AGENT_MODEL=gpt-5.6-sol")); - // An agent with no configured provider must get none at all, rather - // than silently inheriting the developer's. - assert!(!stdout.contains("GOOSE_PROVIDER=")); - assert!(!stdout.contains("GOOSE_MODEL=")); + } + + /// Stripping is by prefix so a variable we never thought about cannot leak + /// the same way `GOOSE_PROVIDER` did. The negative cases matter as much: + /// agents run shell tools that need the developer's toolchain and auth + /// sockets, so only agent *configuration* is Buzz's to own. + #[test] + fn only_agent_configuration_is_stripped() { + for key in [ + "GOOSE_PROVIDER", + "GOOSE_MODE", + "GOOSE_SOMETHING_INVENTED_LATER", + "OPENAI_API_KEY", + "ANTHROPIC_BASE_URL", + "OPENROUTER_MODEL", + "DATABRICKS_TOKEN", + ] { + assert!(is_stripped_env_key(key), "{key} should be stripped"); + } + + for key in [ + "PATH", + "HOME", + "SSH_AUTH_SOCK", + "HTTPS_PROXY", + "NVM_DIR", + "CARGO_HOME", + "TMPDIR", + "BUZZ_AGENT_PROVIDER", + "BUZZ_ACP_RELAY_URL", + ] { + assert!(!is_stripped_env_key(key), "{key} must survive"); + } } #[test] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 5a4fe1e3da2..2d4b67c8c34 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use tauri::AppHandle; -use super::agent_env::{build_buzz_agent_provider_defaults, clear_inherited_provider_model_env}; +use super::agent_env::{build_buzz_agent_provider_defaults, clear_inherited_agent_config}; use crate::{ managed_agents::{ @@ -740,7 +740,7 @@ pub fn spawn_agent_child( resolve_session_title(record.display_name.as_deref(), &record.name), ); build_buzz_agent_provider_defaults(&mut command); - clear_inherited_provider_model_env(&mut command); + clear_inherited_agent_config(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, From 099bacbe6ae39d6255dc602a267de075d4b89ef2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 15:32:05 +1000 Subject: [PATCH 41/51] refactor(buzz-agent): express steer and compaction as goose Operations Steps 4 and 5 of PLANS/BUZZ_OPERATIONS_MIGRATION.md. Behaviour is unchanged: steer.rs, cancel.rs and the real-MCP integration tests all pass without modification. BuzzSteerOperation drains the steer queue; BuzzCompactionOperation wraps goose's check_if_compaction_needed/compact_messages plus the _PostCompact re-injection that buzz's old context handoff existed for. These need a second StateMachine (round_start), not more steps on the existing one: round_gate also runs when a turn wants to END, and draining steers or compacting there would change when they happen. Since step() stops at the first operation that applies, the start machine runs to exhaustion -- a turn that both steers and compacts needs two passes. The gate now carries StateEffect rather than Message, because compaction replaces the conversation instead of appending to it. apply_effects reports whether state actually changed, which is what stops a no-op-applied operation from spinning the loop. Not goose's CompactionOperation: it yields to the client and emits a user-facing notification. In buzz a yield ends the turn, and the notification would post 'compacting' into the channel -- so a long conversation would stop mid-work and narrate its own housekeeping. Nearly dropped the [PostCompact] prefix on the re-injected state while moving it. Caught by re-reading the old function before deleting it; the model uses that prefix to tell re-injected state from a user turn. 74 unit + 21 integration green, handtest --all 18/18. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/loop_drive.rs | 172 ++++++++++++---------------- crates/buzz-agent/src/ops.rs | 170 ++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 97 deletions(-) diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 30f37219d8d..3d446d3b19b 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -155,9 +155,10 @@ pub async fn run_turn( let max_rounds = ctx.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS); let mut reflections = 0usize; - // First decision expressed as a goose `Operation` rather than inline - // control flow. The others follow one at a time; see - // `PLANS/BUZZ_OPERATIONS_MIGRATION.md` for the order and why. + // Every loop decision is a goose `Operation` -- see + // `PLANS/BUZZ_OPERATIONS_MIGRATION.md`. Two machines, because they run at + // different points: `start_machine` before inference, `machine` at the + // round gate and again when a turn wants to end. let outcome = crate::ops::Outcome::new(); let machine = crate::ops::round_gate( max_rounds, @@ -167,6 +168,16 @@ pub async fn run_turn( .map(|extension| (Arc::clone(ctx.agent), extension.to_string())), ctx.require_reply, ); + let start_machine = crate::ops::round_start( + ctx.steers.clone(), + crate::ops::BuzzCompactionOperation::new( + ctx.model.clone(), + Arc::clone(ctx.agent), + ctx.hook_extension.map(str::to_string), + ctx.session_id.to_string(), + ), + ctx.cancel.clone(), + ); let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(16); // Operations may emit; nothing in this step does, but a dropped receiver // would silently swallow events from the ones that follow. @@ -189,23 +200,24 @@ pub async fn run_turn( let reason = outcome.take().unwrap_or(StopReason::EndTurn); return (Ok(reason), tokens, state.conversation().messages().to_vec()); } - Gate::Applied(messages) => { - for message in messages { - state.push(message); - } + Gate::Applied(effects) => { + apply_effects(&mut state, effects); } Gate::Open => {} } - // Steers land here, at the round boundary — never mid-inference, - // where they would race a partially streamed response. - for message in ctx.steers.drain().await { - state.push(message); - } - - // Compaction at the round boundary, never mid-stream. - if let Err(e) = maybe_compact(&ctx, &mut state).await { - tracing::warn!(error = %e, "compaction failed; continuing uncompacted"); + // Steering and compaction, both at the round boundary and never + // mid-inference where they would race a partially streamed response. + // Run to exhaustion: `step` stops at the first operation that applies, + // so a turn that both steers and compacts needs more than one pass. + loop { + let Gate::Applied(effects) = gate(&start_machine, state.session(), &emitter).await + else { + break; + }; + if !apply_effects(&mut state, effects) { + break; + } } match round(&ctx, &mut state, &mut tokens, &mut reflections).await { @@ -231,21 +243,20 @@ pub async fn run_turn( // cancel for no gain. if !ctx.cancel.is_cancelled() { match gate(&machine, state.session(), &emitter).await { - Gate::Applied(messages) if !messages.is_empty() => { - for message in messages { - state.push(message); + Gate::Applied(effects) => { + if apply_effects(&mut state, effects) { + continue; } - continue; + // Applied without changing state: the model has no + // new work, so the turn still ends rather than the + // loop spinning. } Gate::Ended => { let reason = outcome.take().unwrap_or(StopReason::EndTurn); ctx.steers.clear().await; return (Ok(reason), tokens, state.conversation().messages().to_vec()); } - // An operation that applied without appending anything - // has not given the model new work, so the turn still - // ends -- otherwise the loop would spin. - Gate::Applied(_) | Gate::Open => {} + Gate::Open => {} } } @@ -265,9 +276,9 @@ pub async fn run_turn( enum Gate { /// No operation applied; the loop proceeds. Open, - /// An operation applied and appended messages; the loop continues with - /// them in history rather than ending. - Applied(Vec), + /// An operation applied; the loop continues with its effects in state + /// rather than ending. + Applied(Vec), /// An operation ended the turn. Ended, } @@ -284,22 +295,7 @@ async fn gate( ) -> Gate { match machine.step(session, emitter).await { Ok(Some(result)) if result.yield_to_client => Gate::Ended, - Ok(Some(result)) => Gate::Applied( - result - .effects - .into_iter() - .filter_map(|effect| match effect { - goose::agents::state_machine::StateEffect::AppendMessage(message) => { - Some(message) - } - // buzz's operations only append. Other effects belong to - // goose's own operations (recipes, approval, extension - // data) and have no meaning in this loop; ignoring them - // is correct rather than lossy. - _ => None, - }) - .collect(), - ), + Ok(Some(result)) => Gate::Applied(result.effects), Ok(None) => Gate::Open, // A failing gate must not take the turn down with it: the operations // here are guards, and a guard that errors should let the round @@ -311,6 +307,43 @@ async fn gate( } } +/// Apply an operation's effects to the turn's state. +/// +/// buzz's operations produce two kinds: appended messages (objections, +/// reminders, steers) and a whole replaced conversation (compaction). Other +/// `StateEffect` variants belong to goose's own operations -- recipes, +/// approval, extension data -- and have no meaning in this loop, so ignoring +/// them is correct rather than lossy. +/// +/// Returns whether anything was actually applied: an operation that applied +/// without changing state has given the model no new work, and treating that +/// as progress would spin the loop. +fn apply_effects( + state: &mut crate::turn_state::TurnState, + effects: Vec, +) -> bool { + use goose::agents::state_machine::StateEffect; + + let mut changed = false; + for effect in effects { + match effect { + StateEffect::AppendMessage(message) => { + state.push(message); + changed = true; + } + StateEffect::ReplaceConversation { conversation, .. } => { + state.replace(conversation); + // The running total described a conversation that no longer + // exists; let goose re-estimate from the compacted messages. + state.set_total_tokens(None); + changed = true; + } + _ => {} + } + } + changed +} + /// One inference plus, if the model asked for them, one batch of tool calls. async fn round( ctx: &TurnContext<'_>, @@ -510,59 +543,6 @@ fn push_tool_results( state.push(message); } -/// Compact history when it is close to the context limit, then re-inject the -/// state `_PostCompact` reports so a todo list survives the truncation. -async fn maybe_compact( - ctx: &TurnContext<'_>, - state: &mut crate::turn_state::TurnState, -) -> anyhow::Result<()> { - let conversation = state.conversation(); - let provider = ctx.model.provider().await; - - if !goose::context_mgmt::check_if_compaction_needed( - provider.as_ref(), - &conversation, - None, - state.session(), - ) - .await? - { - return Ok(()); - } - - let model_config = ctx.model.config().await; - let result = goose::context_mgmt::compact_messages( - provider.as_ref(), - &model_config, - ctx.session_id, - &conversation, - false, - ) - .await?; - - state.replace(result.conversation); - // Compaction resets the context: the old running total describes a - // conversation that no longer exists, so drop it and let goose re-estimate - // from the compacted messages. - state.set_total_tokens(None); - tracing::info!(target: "buzz_agent::compaction", "history compacted"); - - // `_PostCompact` state is what buzz-agent's handoff existed to preserve. - if let Some(extension) = ctx.hook_extension { - if let Some(reported) = - crate::hooks::post_compact_state(ctx.agent, state.session(), extension).await - { - state.push( - Message::user() - .with_text(format!("[PostCompact] {reported}")) - .with_visibility(false, true), - ); - } - } - - Ok(()) -} - /// Preserve buzz-agent's error taxonomy so the harness's JSON-RPC code mapping /// stays meaningful. fn classify_provider_error(msg: &str) -> AgentError { diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs index 5c24455afdf..02a2a19948c 100644 --- a/crates/buzz-agent/src/ops.rs +++ b/crates/buzz-agent/src/ops.rs @@ -23,7 +23,7 @@ use std::sync::{Arc, Mutex}; use anyhow::Result; use async_trait::async_trait; use goose::agents::state_machine::{ - applied, not_applicable, yielded, Emitter, Operation, OperationResult, + applied, not_applicable, yielded, Emitter, Operation, OperationResult, StateEffect, }; use goose::agents::Agent; use goose::conversation::Conversation; @@ -401,6 +401,148 @@ impl Operation for BuzzReplyGuardOperation { } } +/// Drains `session/steer` messages into the conversation at the round +/// boundary. +/// +/// Steering is buzz's own: goose has an equivalent queue on `Agent`, but +/// `drain_pending_steers` is `pub(crate)` — only `Agent::reply` can consume +/// it, and buzz owns the loop. Expressing the drain as an operation puts it +/// under the same gate as the other loop decisions instead of beside them. +/// +/// Semantics are unchanged from the inline drain: messages land at the round +/// boundary and never mid-inference, where they would race a partially +/// streamed response. +pub struct BuzzSteerOperation { + steers: crate::steer::SteerQueue, +} + +impl BuzzSteerOperation { + pub fn new(steers: crate::steer::SteerQueue) -> Self { + Self { steers } + } +} + +#[async_trait] +impl Operation for BuzzSteerOperation { + fn name(&self) -> &'static str { + "buzz_steer" + } + + async fn run( + &self, + _session: &Session, + _conversation: &Conversation, + _emit: &Emitter, + ) -> Result { + let messages = self.steers.drain().await; + if messages.is_empty() { + return not_applicable(); + } + tracing::info!( + count = messages.len(), + "steer messages drained into the turn" + ); + applied(messages.into_iter().map(StateEffect::AppendMessage)) + } +} + +/// Compacts the conversation when it approaches the context limit. +/// +/// The mechanism is entirely goose's (`check_if_compaction_needed` / +/// `compact_messages`); this operation is the buzz-specific part around it: +/// the `_PostCompact` hook that re-injects buzz-dev-mcp's todo state, which is +/// what buzz-agent's old context-handoff existed to preserve. +/// +/// Not goose's `CompactionOperation`: that one yields to the client and emits +/// its own user-facing notification. In buzz a yield ends the turn and the +/// notification would post into the channel, so a long conversation would stop +/// mid-work and announce its own housekeeping to the humans watching. +pub struct BuzzCompactionOperation { + model: crate::model::SessionModel, + agent: Arc, + hook_extension: Option, + session_id: String, +} + +impl BuzzCompactionOperation { + pub fn new( + model: crate::model::SessionModel, + agent: Arc, + hook_extension: Option, + session_id: String, + ) -> Self { + Self { + model, + agent, + hook_extension, + session_id, + } + } +} + +#[async_trait] +impl Operation for BuzzCompactionOperation { + fn name(&self) -> &'static str { + "buzz_compaction" + } + + async fn run( + &self, + session: &Session, + conversation: &Conversation, + _emit: &Emitter, + ) -> Result { + let provider = self.model.provider().await; + if !goose::context_mgmt::check_if_compaction_needed( + provider.as_ref(), + conversation, + None, + session, + ) + .await? + { + return not_applicable(); + } + + let model_config = self.model.config().await; + let result = goose::context_mgmt::compact_messages( + provider.as_ref(), + &model_config, + &self.session_id, + conversation, + false, + ) + .await?; + + tracing::info!(target: "buzz_agent::compaction", "history compacted"); + + let mut effects = vec![StateEffect::ReplaceConversation { + conversation: result.conversation, + // `None`, deliberately: the running total described a conversation + // that no longer exists, so goose re-estimates from the compacted + // messages rather than carrying a stale count forward. + usage: None, + }]; + + if let Some(extension) = &self.hook_extension { + if let Some(reported) = + crate::hooks::post_compact_state(&self.agent, session, extension).await + { + // `[PostCompact]` prefix preserved from the inline version: + // it is how the model tells re-injected state apart from a + // user turn, and dropping it would change what it reads. + effects.push(StateEffect::AppendMessage( + Message::user() + .with_text(format!("[PostCompact] {reported}")) + .with_visibility(false, true), + )); + } + } + + applied(effects) + } +} + /// The operations that gate the start of a round. /// /// One entry today. As `PLANS/BUZZ_OPERATIONS_MIGRATION.md` proceeds, the @@ -439,6 +581,32 @@ pub fn round_gate( goose::agents::state_machine::StateMachine::new(steps, cancel) } +/// The operations that run at the start of a round, before inference. +/// +/// A separate machine from [`round_gate`] because these two sets run at +/// different points and must not be confused: `round_gate` also runs when a +/// turn wants to *end*, and draining steers or compacting there would change +/// when they happen. `StateMachine::step` stops at the first operation that +/// applies, so one combined machine could not express "do both". +pub fn round_start( + steers: crate::steer::SteerQueue, + compaction: BuzzCompactionOperation, + cancel: tokio_util::sync::CancellationToken, +) -> goose::agents::state_machine::StateMachine<'static> { + use goose::agents::state_machine::Step; + + // Steer before compaction: a steer that arrives just as the window fills + // should be part of what gets summarised, not appended to a conversation + // that was compacted a moment earlier without it. + goose::agents::state_machine::StateMachine::new( + vec![ + Step::Operation(Arc::new(BuzzSteerOperation::new(steers))), + Step::Operation(Arc::new(compaction)), + ], + cancel, + ) +} + #[cfg(test)] mod tests { use super::*; From 60e63a065dc87f542ebf62ba12ddb0e49fbf1ed0 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 15:33:59 +1000 Subject: [PATCH 42/51] docs(buzz-agent): record why [Reflect] stays out of the state machine Reflect was step 3 of the migration plan. It should not be an Operation, and the reason is worth writing down rather than leaving as an omission. StateEffect can append a message or patch a tool request's metadata, but nothing edits tool result *content*. PatchToolRequestMeta is applied via SessionManager -- the store this branch deliberately does not write to -- and patches metadata, not what the model reads. So as an operation the reflection would arrive as a separate message after the result rather than inside it. That is exactly the arrangement this port moved away from when it stopped delivering reflections as steers. Structure follows behaviour. Also corrected reflect.rs's module comment, which still described the steer-based delivery an intermediate commit used. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/tools.rs | 9 +++++++++ crates/buzz-agent/tests/reflect.rs | 16 ++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/buzz-agent/src/tools.rs b/crates/buzz-agent/src/tools.rs index 59aaa7a2bb3..d1d3c5e6b1c 100644 --- a/crates/buzz-agent/src/tools.rs +++ b/crates/buzz-agent/src/tools.rs @@ -110,6 +110,15 @@ async fn run_one( /// content block as the failure, rather than as a separate steer message /// arriving at an unrelated point in the conversation. Capped so a tool /// failing in a loop cannot flood the context. +/// +/// **Deliberately not a goose `Operation`**, unlike the other loop decisions. +/// An operation cannot reach inside a tool result: `StateEffect` can append a +/// message or patch a request's *metadata* +/// (`PatchToolRequestMeta`, which goose applies through `SessionManager` -- +/// the store buzz does not write to), but nothing edits result *content*. As +/// an operation the reflection would arrive as a separate message after the +/// result instead of inside it, which is the arrangement this deliberately +/// moved away from. Structure follows behaviour, not the other way round. fn reflect_on_failure( result: ToolResult, reflections: &mut usize, diff --git a/crates/buzz-agent/tests/reflect.rs b/crates/buzz-agent/tests/reflect.rs index b64308cedf2..5c07c54ba6e 100644 --- a/crates/buzz-agent/tests/reflect.rs +++ b/crates/buzz-agent/tests/reflect.rs @@ -1,15 +1,15 @@ //! 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. +//! buzz-agent appends the text to the failed tool result itself, which is +//! where the model reads it in context. An earlier draft of this port +//! delivered it as a steer instead, because `Agent::reply` owned the tool +//! result and gave no interception point; owning the loop put it back on the +//! result. This comment described that intermediate state and no longer did. //! -//! 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. +//! Observable consequence either way: the reflection must appear in the *next* +//! request the provider receives. That is what this asserts, rather than +//! trusting that any particular delivery mechanism was invoked. use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; From e0d5f204759fb99e190ac93acec477899e9ea3a3 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 16:15:04 +1000 Subject: [PATCH 43/51] test(buzz-agent): cover the steer and compaction operations The two operations added in 099bacbe had no direct unit tests -- they were covered only indirectly by the integration suite. Four tests, each pinning a property where the failure would be silent: - a steer drains rather than peeks (a peek repeats the instruction to the model every round) - an empty queue is NotApplicable, not an empty Applied (the loop treats applied-but-unchanged as a reason to look again, so an always-applying operation spins it) - only effects that change state count as progress, including effects this loop deliberately ignores - compaction clears the running token total (a stale total describes the pre-compaction conversation and would re-trigger compaction at once) Verified by mutation rather than assumed: removing the token reset fails the compaction test, and making drain() peek fails the steer test. 78 unit + 21 integration green. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/loop_drive.rs | 76 +++++++++++++++++++++++++++++ crates/buzz-agent/src/ops.rs | 44 +++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 3d446d3b19b..9742295a551 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -569,6 +569,82 @@ pub(crate) fn max_reflections() -> usize { mod tests { use super::*; + fn turn_state() -> crate::turn_state::TurnState { + crate::turn_state::TurnState::new("s".to_string(), std::path::PathBuf::from("/tmp"), None) + } + + /// The loop treats "applied but changed nothing" as a reason to stop + /// looking. If an ignored effect reported progress the start machine would + /// spin forever, and a real change that reported none would end the turn + /// with the model's work unseen. + #[test] + fn only_effects_that_change_state_count_as_progress() { + use goose::agents::state_machine::StateEffect; + + let mut state = turn_state(); + assert!( + apply_effects( + &mut state, + vec![StateEffect::AppendMessage(Message::user().with_text("hi"))] + ), + "an appended message is progress" + ); + assert_eq!(state.conversation().len(), 1); + + assert!( + !apply_effects(&mut state, vec![]), + "no effects is not progress" + ); + + // goose's own operations emit effects buzz has no use for. Ignoring + // them is correct, but they must not read as progress. + assert!( + !apply_effects( + &mut state, + vec![StateEffect::PatchToolRequestMeta { + tool_call_id: "x".into(), + patch: serde_json::json!({}), + }] + ), + "an effect this loop ignores is not progress" + ); + assert_eq!(state.conversation().len(), 1, "and it changed nothing"); + } + + /// Compaction replaces the conversation rather than appending to it, and + /// must clear the running token total -- that number described a + /// conversation that no longer exists, and carrying it forward would make + /// goose think the window is still nearly full and compact again. + #[test] + fn compaction_replaces_the_conversation_and_resets_the_token_total() { + use goose::agents::state_machine::StateEffect; + + let mut state = turn_state(); + state.push(Message::user().with_text("one")); + state.push(Message::assistant().with_text("two")); + state.set_total_tokens(Some(190_000)); + + let compacted = Conversation::new_unvalidated(vec![Message::user().with_text("summary")]); + assert!(apply_effects( + &mut state, + vec![StateEffect::ReplaceConversation { + conversation: compacted, + usage: None, + }] + )); + + assert_eq!(state.conversation().len(), 1); + assert_eq!( + state.conversation().messages()[0].as_concat_text(), + "summary" + ); + assert_eq!( + state.session().usage.total_tokens, + None, + "a stale total would re-trigger compaction immediately" + ); + } + #[test] fn merge_coalesces_consecutive_text() { let mut target = Message::assistant().with_text("hel"); diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs index 02a2a19948c..42ba119ef42 100644 --- a/crates/buzz-agent/src/ops.rs +++ b/crates/buzz-agent/src/ops.rs @@ -854,6 +854,50 @@ mod tests { assert!(matches!(result, OperationResult::NotApplicable)); } + #[tokio::test] + async fn steering_appends_queued_messages_and_drains_them() { + let steers = crate::steer::SteerQueue::new(); + steers + .push(Message::user().with_text("actually, do X")) + .await; + let op = BuzzSteerOperation::new(steers.clone()); + + match op + .run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap() + { + OperationResult::Applied(step) => { + assert_eq!(step.effects.len(), 1); + assert!(!step.yield_to_client, "a steer must not end the turn"); + } + OperationResult::NotApplicable => panic!("expected the steer to apply"), + } + + // Drained, not peeked: a steer delivered twice would repeat the + // instruction to the model on the next round. + assert!(matches!( + op.run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(), + OperationResult::NotApplicable + )); + } + + #[tokio::test] + async fn an_empty_steer_queue_does_not_apply() { + // Must be NotApplicable rather than an empty Applied: the loop treats + // "applied but changed nothing" as a reason to look again, and an + // always-applying operation would spin it. + let op = BuzzSteerOperation::new(crate::steer::SteerQueue::new()); + assert!(matches!( + op.run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(), + OperationResult::NotApplicable + )); + } + #[tokio::test] async fn a_zero_cap_disables_the_veto_entirely() { // Documented behaviour on main: BUZZ_AGENT_STOP_MAX_REJECTIONS=0 turns From 0ea48f44ea3f0503746f38b1bb44c7ec4013eb06 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 16:21:47 +1000 Subject: [PATCH 44/51] test(buzz-agent): prove turn-to-turn memory across separate prompts Mic asked whether the tests really cover multi-turn recall. They did not, and I had claimed the gap could only be closed by hand testing. That was wrong on both counts. Every existing integration test sends a single session/prompt. All of them would still pass if history were dropped between prompts and each turn started from nothing -- which is exactly the risk this branch introduces, since conversation history now lives in TurnState rather than goose's sqlite store. memory.rs sends two prompts on one session and asserts on what the PROVIDER receives in the second request: turn 1's user message and turn 1's assistant reply must both be present. Asserting on the second answer instead would pass against a model that ignored history entirely. Verified against the real regression: deleting the line that carries the conversation forward (s.history = conversation) in session_prompt fails the test with 'history is not carried across prompts'. Nothing else in the suite notices that deletion. Also fixed a flaw in my own first draft: the fake provider varied its reply depending on whether the request mentioned the secret word. Turn 1's own user message mentions it, so both turns got the same reply and the assistant-reply assertion proved nothing. It now returns a fixed distinctive string. 79 unit + 22 integration green. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/tests/memory.rs | 269 ++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 crates/buzz-agent/tests/memory.rs diff --git a/crates/buzz-agent/tests/memory.rs b/crates/buzz-agent/tests/memory.rs new file mode 100644 index 00000000000..2b07ae33e96 --- /dev/null +++ b/crates/buzz-agent/tests/memory.rs @@ -0,0 +1,269 @@ +//! Proves turn-to-turn memory: prompt 2 must see what happened in prompt 1. +//! +//! This is the riskiest property on this branch and the one every other test +//! misses. buzz-agent no longer writes conversation history to goose's sqlite +//! store -- `TurnState` holds it in memory and buzz carries it across prompts +//! itself. Every other integration test sends a *single* `session/prompt`, so +//! all of them would still pass if the history were dropped between turns and +//! each prompt started from nothing. +//! +//! What is asserted is what the *provider* receives, not what the agent +//! reports: the second request body must contain the first turn's user text +//! and the first turn's assistant reply. A test that only checked the second +//! answer would pass against a model that ignored history entirely. + +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-compatible provider that forwards every request's `messages` +/// array, so the test can inspect exactly what history the model was shown. +fn spawn_history_capturing_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 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 trimmed = line.trim_end(); + if trimmed.is_empty() { + break; + } + if let Some(v) = trimmed.to_lowercase().strip_prefix("content-length: ") { + content_length = v.trim().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": [{ "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; + } + + // A fixed, distinctive reply. An earlier version of this test + // varied the answer by whether the request mentioned the secret, + // which was useless: turn 1's own user message mentions it, so + // both turns got the same answer and the assertion proved nothing. + let answer = "noted-and-filed"; + if let Ok(req) = serde_json::from_slice::(&body) { + let _ = tx.send(req["messages"].clone()); + } + + let chunk = |delta: Value, finish: Value| { + let c = json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "fake-model", + "choices": [{ "index": 0, "delta": delta, "finish_reason": finish }], + }); + format!("data: {c}\n\n") + }; + + let body = format!( + "{}{}data: [DONE]\n\n", + chunk( + json!({ "role": "assistant", "content": answer }), + Value::Null + ), + chunk(json!({}), json!("stop")), + ); + + 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"); + let mut child = Command::new(exe) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", home) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CONFIG_HOME", home.join("config")) + .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") + .env("GOOSE_DISABLE_KEYRING", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn buzz-agent"); + + 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 line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + writeln!(self.stdin, "{line}").expect("write"); + self.stdin.flush().expect("flush"); + id + } + + /// Read until the response with `id` arrives, discarding notifications. + 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!(n > 0, "agent closed stdout before responding to {id}"); + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value["id"] == json!(id) { + return value; + } + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn a_second_prompt_sees_the_first_turn() { + let (base_url, messages_rx) = spawn_history_capturing_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, "clientCapabilities": {} }), + ); + h.await_response(id); + + let id = h.request( + "session/new", + json!({ "cwd": cwd.path().to_str().unwrap(), "mcpServers": [] }), + ); + let session_id = h.await_response(id)["result"]["sessionId"] + .as_str() + .expect("sessionId") + .to_string(); + + // Turn 1: tell it something only this conversation knows. + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "Remember the secret word: ZUCCHINI" }], + }), + ); + assert!( + h.await_response(id)["result"]["stopReason"].is_string(), + "turn 1 did not complete" + ); + let first = messages_rx + .recv_timeout(Duration::from_secs(30)) + .expect("provider never saw turn 1"); + assert_eq!( + first.as_array().map(Vec::len), + Some(2), + "turn 1 should be system + the user prompt, got: {first:#?}" + ); + + // Turn 2: a separate session/prompt on the SAME session. + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "What was the secret word?" }], + }), + ); + assert!( + h.await_response(id)["result"]["stopReason"].is_string(), + "turn 2 did not complete" + ); + let second = messages_rx + .recv_timeout(Duration::from_secs(30)) + .expect("provider never saw turn 2"); + + let rendered = serde_json::to_string(&second).expect("serialize"); + + // The user's turn-1 message must still be there. Without it the model has + // no way to answer, and every single-prompt test in this suite would still + // pass -- which is exactly why this test exists. + assert!( + rendered.contains("ZUCCHINI"), + "turn 2 lost turn 1's user message; history is not carried across \ + prompts. messages were:\n{second:#?}" + ); + + // And turn 1's *assistant* reply, so the model sees a conversation rather + // than two disconnected questions. Dropping only this half is the subtler + // failure: the model still answers, just without knowing what it said. + assert!( + rendered.contains("noted-and-filed"), + "turn 2 lost turn 1's assistant reply. messages were:\n{second:#?}" + ); + + assert!( + second.as_array().map(Vec::len).unwrap_or(0) >= 4, + "expected system + 2 user + 1 assistant at minimum, got: {second:#?}" + ); +} From e5f73cd27c53e868295814ff899576cf06ef6869 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 18 Aug 2026 12:09:57 +1000 Subject: [PATCH 45/51] fix(buzz-agent): carry BUZZ_AGENT_LLM_TIMEOUT_SECS onto goose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #6115 seats a 660 s client budget for mesh agents in the desktop (`managed_agents/relay_mesh.rs`) because MeshLLM's own backend budget is 600 s and a cold prefill of a large agent prompt can legitimately take ~500 s. On this branch that seed reached nothing: buzz-agent no longer has a request loop, and `project_goose_env` had no projection for the variable, so goose's own default applied instead. goose's default is 600 s (`DEFAULT_PROVIDER_TIMEOUT_SECS`) — just *under* the mesh server's, which is the same wrong-side-of-the-server ordering #6115 diagnosed. So the bug #6115 fixes would have come back on merge, silently and only on shared compute. goose reads the timeout per provider rather than globally, so this projects onto the variable belonging to the configured provider. `relay-mesh` maps to goose's `openai` provider and so reads `OPENAI_TIMEOUT` (`goose/src/providers/openai_def.rs:118`). Providers goose gives no timeout knob — databricks — return `None` rather than getting a variable goose never reads. Two tests. The mesh one asserts the invariant (`> 600`) rather than the literal, and I verified it fails with the projection removed and the test kept, reconstructing that baseline from the file rather than `git stash`. README corrected: it claimed this knob was "no longer read", and its limits table still listed the pre-goose 240 s default. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/README.md | 4 +- crates/buzz-agent/src/config.rs | 104 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index d2642b8e580..9385702656b 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -157,7 +157,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` | `3` | Retries after a successful response is truncated at the output-token limit. `0` disables recovery; the finite value and `BUZZ_AGENT_MAX_ROUNDS` prevent infinite retries. | | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | — | **No longer read.** goose's compaction replaced the handoff mechanism it bounded. | -| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | — | **No longer read.** goose owns provider transport and its own per-provider timeouts (`OPENAI_TIMEOUT`, `ANTHROPIC_TIMEOUT`, default 600s). | +| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | goose's own (600 s) | Per-request timeout. goose owns provider transport and reads this **per provider**, so buzz projects the value onto the variable belonging to the configured provider — `OPENAI_TIMEOUT` (which covers `relay-mesh` and the other OpenAI-wire providers), `ANTHROPIC_TIMEOUT`, `OLLAMA_TIMEOUT`, `LITELLM_TIMEOUT`. Providers goose gives no timeout knob (databricks) cannot honour it. | | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds. Projected onto goose's `GOOSE_DEFAULT_EXTENSION_TIMEOUT`. | | `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | — | **No longer read.** All of a round's tool calls are dispatched concurrently; there is no cap to configure. | | `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. | @@ -333,7 +333,7 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | Tool schema bytes | 4 KiB | `MAX_SCHEMA_BYTES` (oversize → replaced with `{}`) | | Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` | | Loop rounds | 0 (unlimited) | `BUZZ_AGENT_MAX_ROUNDS` | -| LLM read inactivity timeout | 240 s | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | +| LLM request timeout | goose's own (600 s) | `BUZZ_AGENT_LLM_TIMEOUT_SECS` (projected per provider) | | Tool call timeout | 660 s | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | ## What This Is NOT diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 2a2b819f415..6d8c2f99466 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -122,6 +122,26 @@ fn goose_provider_name(provider: &str) -> &str { } } +/// goose's per-request timeout variable for a Buzz provider id, if it has one. +/// +/// goose reads the timeout per provider rather than globally, so the name +/// depends on which provider the agent is configured for. `relay-mesh` and the +/// other OpenAI-wire providers all run through goose's `openai` provider and so +/// read `OPENAI_TIMEOUT` (`goose/src/providers/openai_def.rs:118`). +/// +/// Returns `None` for providers whose goose implementation has no timeout knob +/// (databricks among them) — there the buzz value cannot be honoured, and +/// inventing a variable goose never reads would be worse than not setting one. +fn provider_timeout_env_key(provider: &str) -> Option<&'static str> { + match goose_provider_name(provider) { + "openai" => Some("OPENAI_TIMEOUT"), + "anthropic" => Some("ANTHROPIC_TIMEOUT"), + "ollama" => Some("OLLAMA_TIMEOUT"), + "litellm" => Some("LITELLM_TIMEOUT"), + _ => None, + } +} + fn env_str(key: &str) -> Option { std::env::var(key).ok().filter(|s| !s.trim().is_empty()) } @@ -263,6 +283,27 @@ impl Config { .unwrap_or_else(|| DEFAULT_TOOL_RESULT_TEXT_BYTES.to_string()), ); + // Per-LLM-call timeout. goose has no single provider-agnostic name for + // this — each provider reads its own (`OPENAI_TIMEOUT`, + // `ANTHROPIC_TIMEOUT`, ...) — so this projects onto the one belonging + // to the provider actually in use. + // + // This is load-bearing for shared compute, not hygiene. The desktop + // seeds `BUZZ_AGENT_LLM_TIMEOUT_SECS=660` for mesh agents + // (`managed_agents/relay_mesh.rs`, PR #6115) because MeshLLM's own + // backend budget is 600 s and a cold prefill of a large prompt can + // legitimately take ~500 s. Without this projection that seed reaches + // nothing on this branch: goose's default is 600 s, just under the + // server's, so the client would abort a request the mesh is still + // serving — the exact failure #6115 diagnosed and fixed. + if let Some(timeout) = env_str("BUZZ_AGENT_LLM_TIMEOUT_SECS") { + if let Some(key) = + provider_timeout_env_key(&env_str("BUZZ_AGENT_PROVIDER").unwrap_or_default()) + { + set_if_absent(key, &timeout); + } + } + // `BUZZ_AGENT_NO_HINTS=1` suppressed AGENTS.md/.goosehints loading. // goose has no boolean for this, but it takes the *filename list* — // an empty list finds nothing, which is the same outcome. @@ -299,6 +340,69 @@ mod tests { // Env is process-global; these tests set disjoint keys and assert only on // the pure mapping helpers where possible. + /// The mesh client budget must outlast the mesh server's own. + /// + /// The desktop seeds `BUZZ_AGENT_LLM_TIMEOUT_SECS=660` for mesh agents + /// (PR #6115) against MeshLLM's 600 s backend budget. goose reads the + /// timeout per provider, so without the projection under test the seed + /// reaches nothing and goose's own 600 s default applies — under the + /// server's, which is what made Buzz abandon prefills the mesh was still + /// serving. Asserts the invariant (`> 600`) rather than the literal. + #[test] + fn mesh_llm_timeout_outlasts_the_mesh_backend_budget() { + let _guard = env_lock(); + for key in [ + "BUZZ_AGENT_LLM_TIMEOUT_SECS", + "BUZZ_AGENT_PROVIDER", + "OPENAI_TIMEOUT", + "GOOSE_PROVIDER", + ] { + std::env::remove_var(key); + } + + std::env::set_var("BUZZ_AGENT_PROVIDER", "relay-mesh"); + std::env::set_var("BUZZ_AGENT_LLM_TIMEOUT_SECS", "660"); + Config::project_goose_env(); + + let seated: u64 = std::env::var("OPENAI_TIMEOUT") + .expect("mesh agents must carry a timeout onto goose's openai provider") + .parse() + .expect("timeout must be numeric"); + assert!( + seated > 600, + "client budget {seated}s must outlast MeshLLM's 600s backend budget" + ); + + for key in [ + "BUZZ_AGENT_LLM_TIMEOUT_SECS", + "BUZZ_AGENT_PROVIDER", + "OPENAI_TIMEOUT", + "GOOSE_PROVIDER", + ] { + std::env::remove_var(key); + } + } + + /// A provider goose gives no timeout knob must not get a bogus one. + #[test] + fn provider_without_a_timeout_knob_projects_nothing() { + assert_eq!( + provider_timeout_env_key("relay-mesh"), + Some("OPENAI_TIMEOUT") + ); + assert_eq!( + provider_timeout_env_key("openai-compat"), + Some("OPENAI_TIMEOUT") + ); + assert_eq!( + provider_timeout_env_key("anthropic"), + Some("ANTHROPIC_TIMEOUT") + ); + // goose's databricks providers read no timeout variable; inventing one + // would be a silent no-op dressed up as support. + assert_eq!(provider_timeout_env_key("databricks-v2"), None); + } + /// Every `BUZZ_AGENT_*` knob whose implementation moved to goose must /// still reach goose under its goose name. A knob that silently stops /// working is the regression this test exists to prevent. From 16a52827eed8c71b56e43cbd2460230c3002f705 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 18 Aug 2026 12:12:29 +1000 Subject: [PATCH 46/51] docs(buzz-agent): correct stale env/limit rows after the goose move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swept all 27 `BUZZ_AGENT_*` knobs from the pre-goose config against this branch, prompted by finding that #6115's mesh timeout reached nothing. The good news: every knob whose implementation moved or went away is already documented as such. These rows were not. - `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` still documented as live with a default of `3`. It arrived with #5475, which tuned buzz's own truncation-recovery loop — deleted on this branch along with the request transport, so goose owns retries and the knob is inert. - Frame cap listed as a configurable 4 MiB in two places. It is a fixed 16 MiB protocol limit (`config.rs::MAX_LINE_BYTES`); the env row above already said so, the security and limits tables disagreed. - `BUZZ_AGENT_MAX_HISTORY_BYTES` still had a limits-table row after being marked no-longer-read; byte-based eviction is gone. - Noted the `GOOSE_MAX_TOOL_RESPONSE_SIZE` projection where the limit is stated, not only in the env table. No behaviour change. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 9385702656b..c40c0755289 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -154,7 +154,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. | | `BUZZ_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. | | `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `65536` | Desired per-call ceiling. Set this at or below the served model's output limit for each agent deployment. Proactive handoff is independently based on 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. | -| `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` | `3` | Retries after a successful response is truncated at the output-token limit. `0` disables recovery; the finite value and `BUZZ_AGENT_MAX_ROUNDS` prevent infinite retries. | +| `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` | — | **No longer read.** buzz's own truncation-recovery loop went with its request transport; goose owns retries. | | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | — | **No longer read.** goose's compaction replaced the handoff mechanism it bounded. | | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | goose's own (600 s) | Per-request timeout. goose owns provider transport and reads this **per provider**, so buzz projects the value onto the variable belonging to the configured provider — `OPENAI_TIMEOUT` (which covers `relay-mesh` and the other OpenAI-wire providers), `ANTHROPIC_TIMEOUT`, `OLLAMA_TIMEOUT`, `LITELLM_TIMEOUT`. Providers goose gives no timeout knob (databricks) cannot honour it. | @@ -310,7 +310,7 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | MCP child env | Whitelist (`PATH`, `HOME`, `TERM`, `LANG`, `LC_ALL`, `TMPDIR`) plus what the client explicitly passes. Your `ANTHROPIC_API_KEY` does not leak into MCP children. | | MCP child lifetime | Process group via `setpgid(0,0)` in `pre_exec`. On transport break or shutdown: `killpg(SIGKILL)`. Grandchildren die too. | | Server poisoning | After a timeout or transport break, the offending server is marked dead. Future calls trigger a lazy restart with exponential backoff. Other servers keep working. | -| Frame size | `BUZZ_AGENT_MAX_LINE_BYTES` (default 4 MiB). Oversize → connection killed. | +| Frame size | Fixed 16 MiB (`config.rs::MAX_LINE_BYTES`). Oversize → connection killed. | | LLM response size | 16 MiB hard cap. Both `Content-Length` precheck and streaming-buffer cap. | | Cancellation | `tokio::select! { biased; _ = cancel.changed() => ... }` at every loop boundary. Cancel always wins the race. | | Session isolation | Unlimited concurrent sessions by default (configurable via `BUZZ_AGENT_MAX_SESSIONS`). One prompt per session at a time. Each session gets its own MCP servers. | @@ -320,13 +320,12 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | Limit | Default | Where | |---|---|---| -| Inbound JSON-RPC frame | 4 MiB | `BUZZ_AGENT_MAX_LINE_BYTES` | +| Inbound JSON-RPC frame | 16 MiB | `MAX_LINE_BYTES` (fixed) | | Single prompt | 1 MiB | `MAX_PROMPT_BYTES` | -| History window | 1 MiB | `BUZZ_AGENT_MAX_HISTORY_BYTES` | | LLM response body | 16 MiB | `MAX_LLM_RESPONSE_BYTES` | | LLM error body | 4 KiB | `MAX_LLM_ERROR_BODY_BYTES` | | Tool result body (total, incl. images) | 8 MiB | `MAX_TOOL_RESULT_BYTES` | -| Tool result text | 50 KiB | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | +| Tool result text | 50 KiB | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` (projected onto goose's `GOOSE_MAX_TOOL_RESPONSE_SIZE`) | | MCP servers / session | 16 | `MAX_MCP_SERVERS` | | Tools / session | 128 | `MAX_TOOLS_PER_SESSION` | | Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` | From 678dc68384646f70e024afdb6e62b731e9fa4ef2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 18 Aug 2026 12:14:37 +1000 Subject: [PATCH 47/51] chore(buzz-agent): drop the resurrected model_capabilities.rs copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought #5597's `buzz-agent/src/model_capabilities.rs` back as an untracked-by-any-`mod` file. The live copy is in `buzz-model-catalog`, where I moved it so the desktop can read the manifest without linking goose; the two were byte-identical. Nothing declared `mod model_capabilities` in buzz-agent, so it was never compiled — which is exactly why it survived a green build, a full test run and clippy. Found by counting files, not by a failure. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/model_capabilities.rs | 899 -------------------- 1 file changed, 899 deletions(-) delete mode 100644 crates/buzz-agent/src/model_capabilities.rs diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs deleted file mode 100644 index 81f4b4e3b64..00000000000 --- a/crates/buzz-agent/src/model_capabilities.rs +++ /dev/null @@ -1,899 +0,0 @@ -//! Runtime model-capability interpreter. -//! -//! `scripts/model-capabilities.json` is the single source of truth for every -//! model's six-axis capability profile (thinking mode, supported efforts, -//! default effort, Databricks v2 wire route, normalization policy, and picker -//! label). It is embedded at compile time (`include_str!`), parsed once through -//! strict `serde` (`deny_unknown_fields` + real enums), and cached in a -//! [`OnceLock`]. No codegen: both this interpreter and the TypeScript one in -//! `desktop/` read the same hand-curated manifest, and the shared normative -//! corpus (`scripts/normative-corpus.json`) is the cross-language contract that -//! guarantees they agree. -//! -//! ## Resolution algorithm (`resolve`) -//! 1. Provider canonicalization happens *inside* the resolver: trim, lowercase, -//! and apply the alias map (`openai-compat` → `openai`, -//! `databricks-v2` → `databricks_v2`). -//! 2. Provider-qualified exact-record lookup (case-insensitive on the model id). -//! 3. Boundary-aware family-rule match: strip any endpoint prefix at the first -//! family token on a non-alphanumeric boundary, then take the longest match -//! across every rule's `match_value` and `match_aliases`, breaking ties on -//! the lexicographically smallest rule id. -//! 4. Provider fallback, distinguishing a blank model id from a concrete-unknown -//! one. -//! -//! Every path yields a complete six-axis result; `registry_label` is populated -//! only on an exact-record hit. - -use std::sync::OnceLock; - -use serde::{Deserialize, Serialize}; - -use crate::config::ThinkingEffort; - -/// How a model activates and controls reasoning depth on the wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum ThinkingMode { - Adaptive, - ManualBudget, - None, - OmitFields, -} - -/// The Databricks v2 AI Gateway wire route a model is served on. `NotApplicable` -/// marks non-Databricks providers; `RouteUnknown` marks a blank Databricks id. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum DatabricksV2Route { - AnthropicMessages, - MlflowChat, - NotApplicable, - OpenaiResponses, - RouteUnknown, -} - -/// Post-resolution effort normalization applied before a request is sent. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum NormalizationPolicy { - None, - OpenaiClampMaxToXhigh, - OpenaiStandard, -} - -/// Whether a family rule matches its token exactly or as a boundary-aware prefix. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum MatchKind { - Exact, - Prefix, -} - -/// A family/prefix rule: matches a canonical (prefix-stripped) model id against -/// `match_value` or any `match_aliases` token for the listed providers. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct FamilyRule { - id: String, - match_kind: MatchKind, - match_value: String, - #[serde(default)] - match_aliases: Vec, - providers: Vec, - thinking_mode: ThinkingMode, - supported_efforts: Vec, - default_effort: Option, - databricks_v2_wire_route: DatabricksV2Route, - normalization_policy: NormalizationPolicy, - /// Documentation only; modeled so `deny_unknown_fields` accepts the manifest. - #[serde(rename = "_comment", default)] - #[allow(dead_code)] - comment: Option, -} - -/// An authoritative six-axis snapshot for one concrete `(provider, model)` pair. -/// Exact records do *not* inherit from family rules at runtime; the doc fields -/// record the one-time provenance of each axis (see the manifest `_comment`). -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ExactRecord { - provider: String, - raw_model_id: String, - registry_label: String, - thinking_mode: ThinkingMode, - supported_efforts: Vec, - default_effort: Option, - databricks_v2_wire_route: DatabricksV2Route, - normalization_policy: NormalizationPolicy, - // Documentation/provenance keys; modeled for strict parsing, not read at runtime. - #[serde(rename = "_provenance", default)] - #[allow(dead_code)] - provenance: Option, - #[serde(default)] - #[allow(dead_code)] - source: Option, - #[serde(rename = "_source", default)] - #[allow(dead_code)] - source_alt: Option, - #[serde(rename = "_reconciliation", default)] - #[allow(dead_code)] - reconciliation: Option, - #[serde(rename = "_reconciliation_note", default)] - #[allow(dead_code)] - reconciliation_note: Option, - #[serde(rename = "_reconciliation_doc", default)] - #[allow(dead_code)] - reconciliation_doc: Option, -} - -/// One provider's fallback profiles for a blank vs. a concrete-unknown model id. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct FallbackPair { - blank: FallbackState, - concrete_unknown: FallbackState, -} - -/// A five-axis fallback profile (no label — fallbacks never carry one). -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct FallbackState { - databricks_v2_wire_route: DatabricksV2Route, - thinking_mode: ThinkingMode, - supported_efforts: Vec, - default_effort: Option, - normalization_policy: NormalizationPolicy, -} - -/// Provider fallbacks keyed by canonical provider, with a `_default` catch-all. -/// Both states of every provider are required, so "both fallback states present" -/// is enforced structurally by the parse. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ProviderFallbacks { - anthropic: FallbackPair, - openai: FallbackPair, - databricks: FallbackPair, - databricks_v2: FallbackPair, - openrouter: FallbackPair, - #[serde(rename = "_default")] - default: FallbackPair, -} - -impl ProviderFallbacks { - /// Fallback pair for a canonical provider, or `_default` for anything else. - fn get(&self, provider: &str) -> &FallbackPair { - match provider { - "anthropic" => &self.anthropic, - "openai" => &self.openai, - "databricks" => &self.databricks, - "databricks_v2" => &self.databricks_v2, - "openrouter" => &self.openrouter, - _ => &self.default, - } - } - - /// Named pairs, for validation. - fn named(&self) -> [(&str, &FallbackPair); 6] { - [ - ("anthropic", &self.anthropic), - ("openai", &self.openai), - ("databricks", &self.databricks), - ("databricks_v2", &self.databricks_v2), - ("openrouter", &self.openrouter), - ("_default", &self.default), - ] - } -} - -/// The parsed manifest. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct Manifest { - family_tokens: Vec, - family_rules: Vec, - databricks_v2_known_models: Vec, - exact_records: Vec, - provider_fallbacks: ProviderFallbacks, - // Root documentation keys; modeled for strict parsing, not read at runtime. - #[serde(rename = "_comment", default)] - #[allow(dead_code)] - comment: Option, - #[serde(rename = "_comment_databricks_v2_known_models", default)] - #[allow(dead_code)] - comment_known_models: Option, - #[serde(rename = "_sources", default)] - #[allow(dead_code)] - sources: std::collections::BTreeMap, -} - -/// The resolved six-axis capability profile for one `(provider, model)` query. -/// All fields borrow from the process-lifetime manifest. The field names and -/// declaration order are the corpus `expect` schema — the test-only generator -/// serializes this struct directly, so there is no second encoding of the axes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct CapabilityResult { - pub thinking_mode: ThinkingMode, - pub supported_efforts: &'static [ThinkingEffort], - pub default_effort: Option, - pub databricks_v2_wire_route: DatabricksV2Route, - pub normalization_policy: NormalizationPolicy, - pub registry_label: Option<&'static str>, -} - -const MANIFEST_JSON: &str = include_str!("../../../scripts/model-capabilities.json"); - -static MANIFEST: OnceLock = OnceLock::new(); - -/// Parse (once) and return the embedded manifest. Panics on a malformed or -/// invalid bundled manifest — a build-time data error that must never ship. -fn manifest() -> &'static Manifest { - MANIFEST.get_or_init(|| { - let parsed: Manifest = serde_json::from_str(MANIFEST_JSON) - .expect("bundled model-capabilities.json must parse"); - if let Err(e) = validate_manifest(&parsed) { - panic!("bundled model-capabilities.json failed validation: {e}"); - } - parsed - }) -} - -/// Canonicalize a provider name: trim, lowercase, apply the alias map. -fn canonical_provider(provider: &str) -> String { - let canon = provider.trim().to_ascii_lowercase(); - match canon.as_str() { - "openai-compat" => "openai".to_string(), - "databricks-v2" => "databricks_v2".to_string(), - _ => canon, - } -} - -/// Strip an endpoint-naming prefix by locating the earliest family token that -/// begins on a non-alphanumeric boundary (or at the start), returning the slice -/// from that token onward. Returns the input unchanged when no token qualifies. -fn strip_catalog_prefix<'a>(model_lower: &'a str, family_tokens: &[String]) -> &'a str { - let bytes = model_lower.as_bytes(); - let mut best: Option = None; - for tok in family_tokens { - let mut from = 0; - while let Some(rel) = model_lower[from..].find(tok.as_str()) { - let idx = from + rel; - if idx == 0 || !bytes[idx - 1].is_ascii_alphanumeric() { - best = Some(best.map_or(idx, |b| b.min(idx))); - break; - } - from = idx + 1; - } - } - match best { - Some(idx) => &model_lower[idx..], - None => model_lower, - } -} - -/// Boundary-aware prefix test: `s` equals `token`, or `s` starts with `token` -/// and the following character is a non-alphanumeric boundary. -fn prefix_matches(token: &str, s: &str) -> bool { - match s.strip_prefix(token) { - Some(rest) => rest - .chars() - .next() - .is_none_or(|c| !c.is_ascii_alphanumeric()), - None => false, - } -} - -/// Resolve the capability profile for a `(provider, raw_model_id)` pair. -pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult { - let m = manifest(); - let canon = canonical_provider(provider); - let blank = raw_model_id.trim().is_empty(); - - // 1. Provider-qualified exact-record lookup (case-insensitive on the id). - if !blank { - for rec in &m.exact_records { - if rec.provider == canon && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) { - return CapabilityResult { - thinking_mode: rec.thinking_mode, - supported_efforts: &rec.supported_efforts, - default_effort: rec.default_effort, - databricks_v2_wire_route: rec.databricks_v2_wire_route, - normalization_policy: rec.normalization_policy, - registry_label: Some(&rec.registry_label), - }; - } - } - } - - // 2. Boundary-aware family match: longest token wins, lexicographic tie-break. - if !blank { - let model_lower = raw_model_id.to_ascii_lowercase(); - let stripped = strip_catalog_prefix(&model_lower, &m.family_tokens); - let mut best: Option<(usize, &FamilyRule)> = None; - for rule in &m.family_rules { - if !rule.providers.iter().any(|p| p == &canon) { - continue; - } - let mut matched: Option = None; - for tok in std::iter::once(&rule.match_value).chain(rule.match_aliases.iter()) { - let ok = match rule.match_kind { - MatchKind::Exact => stripped == tok.as_str(), - MatchKind::Prefix => prefix_matches(tok, stripped), - }; - if ok { - matched = Some(matched.map_or(tok.len(), |l| l.max(tok.len()))); - } - } - if let Some(len) = matched { - let better = match best { - None => true, - Some((blen, brule)) => len > blen || (len == blen && rule.id < brule.id), - }; - if better { - best = Some((len, rule)); - } - } - } - if let Some((_, rule)) = best { - let route = if canon == "databricks_v2" { - rule.databricks_v2_wire_route - } else { - DatabricksV2Route::NotApplicable - }; - return CapabilityResult { - thinking_mode: rule.thinking_mode, - supported_efforts: &rule.supported_efforts, - default_effort: rule.default_effort, - databricks_v2_wire_route: route, - normalization_policy: rule.normalization_policy, - registry_label: None, - }; - } - } - - // 3. Provider fallback (blank vs. concrete-unknown); never carries a label. - let pair = m.provider_fallbacks.get(&canon); - let state = if blank { - &pair.blank - } else { - &pair.concrete_unknown - }; - CapabilityResult { - thinking_mode: state.thinking_mode, - supported_efforts: &state.supported_efforts, - default_effort: state.default_effort, - databricks_v2_wire_route: state.databricks_v2_wire_route, - normalization_policy: state.normalization_policy, - registry_label: None, - } -} - -/// Authoritative list of known Databricks v2 model ids, sourced from the manifest. -pub fn databricks_v2_known_models() -> &'static [String] { - &manifest().databricks_v2_known_models -} - -/// Curated display label for a Databricks endpoint id, or `None` when no exact -/// record covers it. Read-only accessor over the same `databricks_v2` exact -/// records `resolve()` consults, with the same case-insensitive id match; used -/// by discovery to curate `ModelEntry.name` (the Databricks API returns no -/// display name of its own). Scoped to `databricks_v2` records only, so it can -/// never surface a curated label for a non-Databricks provider. -pub fn databricks_registry_label(raw_model_id: &str) -> Option<&'static str> { - if raw_model_id.trim().is_empty() { - return None; - } - manifest() - .exact_records - .iter() - .find(|rec| { - rec.provider == "databricks_v2" && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) - }) - .map(|rec| rec.registry_label.as_str()) -} - -/// Semantic invariants that strict typed parsing cannot express. Structural -/// checks (required fields, enum domains, both fallback states) are already -/// guaranteed by `serde` + `deny_unknown_fields`; this owns the rest. -fn validate_manifest(m: &Manifest) -> Result<(), String> { - if m.family_tokens.is_empty() { - return Err("family_tokens must be non-empty".to_string()); - } - - let check_efforts = |ctx: &str, - efforts: &[ThinkingEffort], - default: Option| - -> Result<(), String> { - if efforts.is_empty() { - return Err(format!("{ctx}: supported_efforts must be non-empty")); - } - // Canonical enum order is None < Minimal < ... < Max; strict ascending - // enforces sorted + duplicate-free in one check. - if !efforts.windows(2).all(|w| w[0] < w[1]) { - return Err(format!( - "{ctx}: supported_efforts must be sorted in canonical order with no duplicates" - )); - } - if let Some(d) = default { - if !efforts.contains(&d) { - return Err(format!( - "{ctx}: default_effort {d:?} not in supported_efforts" - )); - } - } - Ok(()) - }; - - // Family rules: unique ids, non-empty providers, effort validity, and no - // match token (value or alias) shared across or within rules. - let mut rule_ids = std::collections::HashSet::new(); - let mut token_owner: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); - for rule in &m.family_rules { - if !rule_ids.insert(rule.id.as_str()) { - return Err(format!("duplicate family rule id: {}", rule.id)); - } - if rule.providers.is_empty() { - return Err(format!("family rule {} has empty providers", rule.id)); - } - check_efforts( - &format!("family_rule {}", rule.id), - &rule.supported_efforts, - rule.default_effort, - )?; - for tok in std::iter::once(&rule.match_value).chain(rule.match_aliases.iter()) { - if let Some(prev) = token_owner.insert(tok.as_str(), rule.id.as_str()) { - return Err(format!( - "duplicate match token {tok:?} (rules {prev} and {})", - rule.id - )); - } - } - } - - // Exact records: case-insensitive uniqueness of (provider, id), non-empty - // labels, effort validity. - let mut exact_keys = std::collections::HashSet::new(); - for rec in &m.exact_records { - let key = (rec.provider.clone(), rec.raw_model_id.to_ascii_lowercase()); - if !exact_keys.insert(key) { - return Err(format!( - "duplicate exact record: {} / {}", - rec.provider, rec.raw_model_id - )); - } - if rec.registry_label.trim().is_empty() { - return Err(format!( - "exact record {} has an empty registry_label", - rec.raw_model_id - )); - } - check_efforts( - &format!("exact_record {}", rec.raw_model_id), - &rec.supported_efforts, - rec.default_effort, - )?; - } - - // Known-model ids: case-insensitive uniqueness. - let mut known = std::collections::HashSet::new(); - for id in &m.databricks_v2_known_models { - if id.trim().is_empty() { - return Err("databricks_v2_known_models contains an empty id".to_string()); - } - if !known.insert(id.to_ascii_lowercase()) { - return Err(format!("duplicate databricks_v2_known_models id: {id}")); - } - } - - // Provider fallbacks: effort validity for both states of every provider. - for (name, pair) in m.provider_fallbacks.named() { - check_efforts( - &format!("fallback {name}/blank"), - &pair.blank.supported_efforts, - pair.blank.default_effort, - )?; - check_efforts( - &format!("fallback {name}/concrete_unknown"), - &pair.concrete_unknown.supported_efforts, - pair.concrete_unknown.default_effort, - )?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// One entry of the generator's *inputs-only* table. It encodes **which - /// questions to ask** — section headers and `(provider, raw_model_id)` - /// query pairs plus a human note — and never any expected answer. Every - /// answer is computed by the production [`resolve`] at generation time, so - /// the manifest stays the single place capability behavior is encoded. - enum Q { - Section { - group: &'static str, - note: Option<&'static str>, - }, - Vector { - id: &'static str, - provider: &'static str, - raw_model_id: &'static str, - note: Option<&'static str>, - }, - } - - /// The inputs-only question set (section headers interleaved with query - /// vectors, in file order). Answers live only in the manifest; this table - /// says which questions to ask. Adding, removing, or reordering a `Vector` - /// here changes the generated corpus — run `just regen-model-corpus`. - const INPUTS: &[Q] = &[ - Q::Section { group: "Anthropic curated family-rule model names", note: None }, - Q::Vector { id: "anthropic-claude-3-family", provider: "anthropic", raw_model_id: "claude-3-7-sonnet-20250219", note: None }, - Q::Vector { id: "anthropic-claude-opus-4-5", provider: "anthropic", raw_model_id: "claude-opus-4-5", note: None }, - Q::Vector { id: "anthropic-claude-opus-4-7", provider: "anthropic", raw_model_id: "claude-opus-4-7", note: None }, - Q::Vector { id: "anthropic-claude-opus-4-8", provider: "anthropic", raw_model_id: "claude-opus-4-8", note: None }, - Q::Vector { id: "anthropic-claude-sonnet-5", provider: "anthropic", raw_model_id: "claude-sonnet-5-20260101", note: None }, - Q::Vector { id: "anthropic-claude-fable-5", provider: "anthropic", raw_model_id: "claude-fable-5", note: None }, - Q::Vector { id: "anthropic-claude-mythos-5", provider: "anthropic", raw_model_id: "claude-mythos-5", note: None }, - Q::Vector { id: "anthropic-claude-opus-4-6", provider: "anthropic", raw_model_id: "claude-opus-4-6", note: None }, - Q::Vector { id: "anthropic-claude-sonnet-4-6", provider: "anthropic", raw_model_id: "claude-sonnet-4-6", note: None }, - Q::Vector { id: "anthropic-claude-mythos-preview", provider: "anthropic", raw_model_id: "claude-mythos-preview", note: None }, - Q::Section { group: "Anthropic blank and concrete-unknown inputs", note: None }, - Q::Vector { id: "anthropic-unknown-blank", provider: "anthropic", raw_model_id: "", note: None }, - Q::Vector { id: "anthropic-unknown-concrete", provider: "anthropic", raw_model_id: "claude-ultra-9000", note: None }, - Q::Section { group: "OpenAI curated family-rule model names", note: None }, - Q::Vector { id: "openai-gpt5-pro", provider: "openai", raw_model_id: "gpt-5-pro", note: None }, - Q::Vector { id: "openai-gpt5.6", provider: "openai", raw_model_id: "gpt-5.6", note: None }, - Q::Vector { id: "openai-gpt5-6-dashed", provider: "openai", raw_model_id: "gpt-5-6", note: None }, - Q::Vector { id: "openai-gpt5.5", provider: "openai", raw_model_id: "gpt-5.5", note: None }, - Q::Vector { id: "openai-gpt5.4", provider: "openai", raw_model_id: "gpt-5.4", note: None }, - Q::Vector { id: "openai-gpt5.1", provider: "openai", raw_model_id: "gpt-5.1", note: None }, - Q::Vector { id: "openai-gpt5-base", provider: "openai", raw_model_id: "gpt-5", note: None }, - Q::Section { group: "OpenAI gpt-5 boundary-matching probes (ported from config.rs tests)", note: None }, - Q::Vector { id: "openai-gpt5-1106-date-suffix-probe", provider: "openai", raw_model_id: "gpt-5-1106", note: Some("Probes a 4-digit date-shaped suffix after the gpt-5 stem.") }, - Q::Vector { id: "openai-gpt5-4o-alpha-suffix-probe", provider: "openai", raw_model_id: "gpt-5-4o", note: Some("Probes a leading-digit-then-letter suffix ('4o') after the gpt-5 stem.") }, - Q::Vector { id: "openai-gpt5-pro-precedence-probe", provider: "openai", raw_model_id: "gpt-5-pro", note: Some("Probes precedence between the gpt-5-pro rule and the gpt-5 base stem.") }, - Q::Vector { id: "openai-gpt5-10-multi-digit-probe", provider: "openai", raw_model_id: "gpt-5-10", note: Some("Probes a two-digit minor-version suffix after the gpt-5 stem.") }, - Q::Vector { id: "openai-gpt5-date-suffix-probe", provider: "openai", raw_model_id: "gpt-5-20260101", note: Some("Probes an 8-digit date suffix after the gpt-5 stem.") }, - Q::Section { group: "DatabricksV2 segment/prefix routing probes (ported from llm.rs tests)", note: None }, - Q::Vector { id: "dbv2-gpt5-5-probe", provider: "databricks_v2", raw_model_id: "gpt-5.5", note: None }, - Q::Vector { id: "dbv2-claude-opus-4-7-probe", provider: "databricks_v2", raw_model_id: "claude-opus-4-7", note: None }, - Q::Vector { id: "dbv2-databricks-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-7", note: Some("Probes stripping of the databricks- catalog prefix.") }, - Q::Vector { id: "dbv2-goose-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes stripping of the goose- catalog prefix.") }, - Q::Vector { id: "dbv2-team-prefix-probe", provider: "databricks_v2", raw_model_id: "team-x-claude-opus-4-7", note: Some("Probes stripping of a team-x- catalog prefix.") }, - Q::Vector { id: "dbv2-consolidated-llama-substring-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where a code word ('sol') appears only as a substring, not a boundary-aligned segment.") }, - Q::Vector { id: "dbv2-terraform-coder-substring-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where a code word ('terra') is only a segment prefix, not a full segment.") }, - Q::Vector { id: "dbv2-corpus-reranker-substring-probe", provider: "databricks_v2", raw_model_id: "corpus-reranker", note: Some("Probes a name where 'opus' appears only as a substring of a segment.") }, - Q::Vector { id: "dbv2-octopus-model-substring-probe", provider: "databricks_v2", raw_model_id: "octopus-model", note: Some("Probes a name where 'opus' appears only as a substring of a segment.") }, - Q::Vector { id: "dbv2-goose-opus-5-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-opus-5", note: Some("Probes a goose- prefix over a bare code-name segment with no leading claude.") }, - Q::Section { group: "Resolver-contract probes (plan v4 §Resolver contract)", note: None }, - Q::Vector { id: "resolver-exact-raw-id-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes a raw id that has an exact record.") }, - Q::Vector { id: "resolver-prefixed-alias-probe", provider: "databricks_v2", raw_model_id: "team-x-databricks-gpt-5-4-mini", note: Some("Probes a prefixed alias of an exact-record id (raw exact key differs).") }, - Q::Vector { id: "resolver-cross-provider-probe", provider: "openai", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes the same raw id under a different provider (exact records are provider-scoped).") }, - Q::Vector { id: "resolver-exact-record-with-family-route-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Exact-vs-family route-axis probe (raw exact key with a covering family rule).") }, - Q::Vector { id: "dbv2-gpt5-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-5", note: Some("Exact-vs-family effort-axis probe (exact record overlapping a family rule).") }, - Q::Section { group: "Blank and concrete-unknown inputs per provider", note: None }, - Q::Vector { id: "dbv2-blank-probe", provider: "databricks_v2", raw_model_id: "", note: Some("Probes a blank databricks_v2 model id.") }, - Q::Vector { id: "dbv2-concrete-unknown-probe", provider: "databricks_v2", raw_model_id: "some-unknown-model-xyz", note: Some("Probes a concrete, uncatalogued databricks_v2 model id.") }, - Q::Vector { id: "openai-blank-probe", provider: "openai", raw_model_id: "", note: Some("Probes a blank openai model id.") }, - Q::Vector { id: "openai-concrete-unknown-probe", provider: "openai", raw_model_id: "gpt-4o", note: Some("Probes a concrete openai model id in no verified family.") }, - Q::Vector { id: "anthropic-blank-probe", provider: "anthropic", raw_model_id: "", note: Some("Probes a blank anthropic model id.") }, - Q::Vector { id: "anthropic-concrete-unknown-probe", provider: "anthropic", raw_model_id: "claude-ultra-9000", note: Some("Probes a concrete, uncatalogued anthropic model id.") }, - Q::Section { group: "Legacy Databricks provider inputs", note: None }, - Q::Vector { id: "databricks-gpt5-pro-probe", provider: "databricks", raw_model_id: "databricks-gpt-5-pro", note: Some("Probes the legacy databricks provider with a GPT-5 Pro id.") }, - Q::Vector { id: "databricks-gpt5-6-probe", provider: "databricks", raw_model_id: "databricks-gpt-5.6", note: Some("Probes the legacy databricks provider with a GPT-5.6 id.") }, - Q::Vector { id: "databricks-gpt5-1-probe", provider: "databricks", raw_model_id: "databricks-gpt-5.1", note: Some("Probes the legacy databricks provider with a GPT-5.1 id.") }, - Q::Section { group: "openai-compat alias canonicalization probes", note: Some("Probes whether openai-compat is canonicalized to openai before resolving; both interpreters must agree.") }, - Q::Vector { id: "openai-compat-gpt-5-pro-probe", provider: "openai-compat", raw_model_id: "gpt-5-pro", note: None }, - Q::Vector { id: "openai-compat-gpt-5-5-probe", provider: "openai-compat", raw_model_id: "gpt-5.5", note: None }, - Q::Vector { id: "openai-compat-blank-probe", provider: "openai-compat", raw_model_id: "", note: Some("Probes openai-compat canonicalization with a blank model id.") }, - Q::Section { group: "gpt-5 short-version-suffix boundary probes (Rust/TS divergence window)", note: Some("Probes the 1-2 digit version-suffix window where the Rust guard and the TS regex historically diverged.") }, - Q::Vector { id: "openai-gpt5-10-preview-probe", provider: "openai", raw_model_id: "gpt-5-10-preview", note: None }, - Q::Vector { id: "openai-gpt5-2-mini-probe", provider: "openai", raw_model_id: "gpt-5-2-mini", note: None }, - Q::Vector { id: "openai-gpt5-9-dot-1-probe", provider: "openai", raw_model_id: "gpt-5-9.1", note: None }, - Q::Vector { id: "dbv2-gpt5-10-multi-axis-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-10", note: Some("Probes a databricks_v2 gpt-5- id, exercising both the effort axes and the wire route.") }, - Q::Vector { id: "dbv2-gpt-5-2-exact-vs-base-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-2", note: Some("Exact-vs-base-stem probe: an exact record coexisting with the gpt-5 base stem rule.") }, - Q::Vector { id: "openai-customgpt-5-5-nonboundary-probe", provider: "openai", raw_model_id: "customgpt-5-5-endpoint", note: Some("Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).") }, - Q::Section { group: "DBv2 gpt-segment boundary probes", note: Some("Probes whether 'gpt' is treated as a full segment rather than a segment prefix.") }, - Q::Vector { id: "dbv2-gptoss-segment-probe", provider: "databricks_v2", raw_model_id: "gptoss-model", note: Some("Probes a segment ('gptoss') that starts with but is not exactly 'gpt'/'gpt5'.") }, - Q::Vector { id: "dbv2-gptj-6b-segment-probe", provider: "databricks_v2", raw_model_id: "gptj-6b", note: Some("Probes a segment ('gptj') that is not exactly 'gpt'/'gpt5'.") }, - Q::Vector { id: "dbv2-customgpt-nonboundary-probe", provider: "databricks_v2", raw_model_id: "customgpt-5-5-endpoint", note: Some("Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).") }, - Q::Vector { id: "dbv2-gpt-neox-version-segment-probe", provider: "databricks_v2", raw_model_id: "gpt-neox-20b", note: Some("Probes a gpt- name whose next segment ('neox') is non-numeric.") }, - Q::Vector { id: "dbv2-gpt5-custom-segment-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt5-custom", note: Some("Probes a 'gpt5' segment inside a databricks- prefixed name.") }, - Q::Vector { id: "dbv2-gpt-opus-5-dual-marker-probe", provider: "databricks_v2", raw_model_id: "gpt-opus-5", note: Some("Probes a name carrying both a gpt marker and a claude code word.") }, - Q::Section { group: "Additional coverage probes", note: None }, - Q::Vector { id: "anthropic-opus-5-prefix-probe", provider: "anthropic", raw_model_id: "claude-opus-5-20270101", note: Some("Probes the claude-opus-5 prefix rule.") }, - Q::Vector { id: "dbv2-gpt-5-6-sol-normalization-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Probes the sol exact record's normalization and effort axes.") }, - Q::Vector { id: "dbv2-gpt-5-6-luna-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-luna", note: Some("Probes the luna exact record against its family rule.") }, - Q::Vector { id: "dbv2-gpt-5-6-terra-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-terra", note: Some("Probes the terra exact record against its family rule.") }, - Q::Vector { id: "dbv2-gpt-5-4-nano-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-nano", note: Some("Probes the gpt-5-4-nano exact record and its label.") }, - Q::Vector { id: "openrouter-concrete-unknown-probe", provider: "openrouter", raw_model_id: "some-model-xyz", note: Some("Probes an uncatalogued openrouter model id.") }, - Q::Vector { id: "openai-gpt5-pro-uppercase-provider-probe", provider: "OpenAI", raw_model_id: "gpt-5-pro", note: Some("Probes an uppercased provider string ('OpenAI').") }, - Q::Vector { id: "dbv2-uppercase-model-probe", provider: "databricks_v2", raw_model_id: "DATABRICKS-GPT-5-4-NANO", note: Some("Probes an uppercased raw model id against a lowercase exact record.") }, - Q::Section { group: "Prototype-key provider probes", note: Some("Probes provider strings that collide with Object prototype keys.") }, - Q::Vector { id: "prototype-key-constructor-blank-probe", provider: "constructor", raw_model_id: "", note: None }, - Q::Vector { id: "prototype-key-constructor-some-model-probe", provider: "constructor", raw_model_id: "some-model", note: None }, - Q::Vector { id: "prototype-key-proto__-blank-probe", provider: "__proto__", raw_model_id: "", note: None }, - Q::Vector { id: "prototype-key-proto__-some-model-probe", provider: "__proto__", raw_model_id: "some-model", note: None }, - Q::Section { group: "Non-boundary gpt- prefix probes", note: Some("Probes names whose gpt- token is not boundary-aligned (preceded by an alphanumeric).") }, - Q::Vector { id: "openai-sgpt-5-5-nonboundary-probe", provider: "openai", raw_model_id: "sgpt-5-5", note: None }, - Q::Vector { id: "dbv2-sgpt-5-5-nonboundary-probe", provider: "databricks_v2", raw_model_id: "sgpt-5-5", note: None }, - Q::Vector { id: "openai-mygpt-5-nonboundary-probe", provider: "openai", raw_model_id: "mygpt-5", note: None }, - Q::Vector { id: "dbv2-mygpt-5-nonboundary-probe", provider: "databricks_v2", raw_model_id: "mygpt-5", note: None }, - Q::Vector { id: "dbv2-gpt-5-mini-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-mini", note: Some("Probes the gpt-5-mini exact record and its label.") }, - Q::Vector { id: "dbv2-gpt-5-nano-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-nano", note: Some("Probes the gpt-5-nano exact record and its label.") }, - Q::Vector { id: "dbv2-claude-opus-5-custom-family-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-5-custom", note: Some("Probes a family-matched name with no exact record and its label axis.") }, - Q::Vector { id: "dbv2-gpt-doubled-separator-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt--5", note: Some("Probes a doubled separator between gpt and its version.") }, - Q::Section { group: "gpt-5 prefix collision probes (longest-prefix + boundary)", note: None }, - Q::Vector { id: "collision-gpt-5-base-probe", provider: "openai", raw_model_id: "gpt-5", note: Some("Probes the base gpt-5 stem alone.") }, - Q::Vector { id: "collision-gpt-5-pro-probe", provider: "openai", raw_model_id: "gpt-5-pro", note: Some("Probes gpt-5-pro against the shorter gpt-5 stem.") }, - Q::Vector { id: "collision-gpt-5-10-probe", provider: "openai", raw_model_id: "gpt-5-10", note: Some("Probes a two-digit minor version against the gpt-5 stem.") }, - Q::Vector { id: "collision-gpt-5-6-probe", provider: "openai", raw_model_id: "gpt-5.6", note: Some("Probes a dotted minor version against the gpt-5 stem.") }, - Q::Vector { id: "collision-gpt-5-1-probe", provider: "openai", raw_model_id: "gpt-5.1", note: Some("Probes the gpt-5.1 prefix.") }, - Q::Section { group: "Uncurated DBv2 token probes", note: None }, - Q::Vector { id: "uncurated-dbv2-gpt-6-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-6", note: Some("Probes a non-5 gpt version with no exact record or prefix rule.") }, - Q::Vector { id: "uncurated-dbv2-gpt-4o-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-4o", note: Some("Probes an uncatalogued gpt-4o databricks_v2 id.") }, - Q::Vector { id: "uncurated-dbv2-opus-5-bare-probe", provider: "databricks_v2", raw_model_id: "opus-5", note: Some("Probes a bare Claude code-name segment with no leading claude.") }, - Q::Vector { id: "uncurated-dbv2-sol-bare-probe", provider: "databricks_v2", raw_model_id: "sol", note: Some("Probes a bare OpenAI code name.") }, - Q::Vector { id: "uncurated-dbv2-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-experimental", note: Some("Probes an uncurated databricks-claude-* name.") }, - Q::Section { group: "Negative-match probes (no family rule expected to bind)", note: None }, - Q::Vector { id: "neg-gptoss-openai-probe", provider: "openai", raw_model_id: "gptoss", note: Some("Probes a name with no gpt- boundary token.") }, - Q::Vector { id: "neg-gptj-6b-openai-probe", provider: "openai", raw_model_id: "gptj-6b", note: Some("Probes 'gptj', which is not a gpt- token.") }, - Q::Vector { id: "neg-consolidated-llama-dbv2-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where 'sol' is a substring, not a segment.") }, - Q::Vector { id: "neg-terraform-coder-dbv2-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where 'terra' is a substring, not a segment.") }, - Q::Vector { id: "neg-octopus-model-dbv2-probe", provider: "databricks_v2", raw_model_id: "octopus-model", note: Some("Probes a name where 'opus' is a substring, not a leading claude prefix.") }, - Q::Section { group: "Exact+prefix matcher boundary probes", note: None }, - Q::Vector { id: "boundary-embedded-token-openai-probe", provider: "openai", raw_model_id: "gpt-4-gpt-5-pro", note: Some("Probes a gpt-5-pro token embedded mid-name rather than at the start.") }, - Q::Vector { id: "boundary-dot-suffix-openai-probe", provider: "openai", raw_model_id: "gpt-5.6.x", note: Some("Probes a trailing dot-delimited segment after gpt-5.6.") }, - Q::Vector { id: "boundary-claude-3-digit-run-anthropic-probe", provider: "anthropic", raw_model_id: "claude-35", note: Some("Probes whether the claude-3 prefix binds a longer digit run ('35').") }, - Q::Vector { id: "boundary-claude-opus-4-70-anthropic-probe", provider: "anthropic", raw_model_id: "claude-opus-4-70", note: Some("Probes whether the claude-opus-4-7 prefix binds a longer digit run ('70').") }, - Q::Vector { id: "boundary-gpt-5-1234-openai-probe", provider: "openai", raw_model_id: "gpt-5-1234", note: Some("Probes a 4-digit run after the gpt-5 stem.") }, - ]; - - /// A section marker in the generated corpus (`_group` + optional `_note`). - #[derive(Serialize)] - struct SectionOut { - #[serde(rename = "_group")] - group: &'static str, - #[serde(rename = "_note", skip_serializing_if = "Option::is_none")] - note: Option<&'static str>, - } - - /// One executable vector: the query, an optional note, and the resolver's - /// snapshotted answer. `expect` is a [`CapabilityResult`] serialized - /// directly — the axis names/order and the enum spellings come from the - /// production types, so nothing about the answer is encoded a second time. - #[derive(Serialize)] - struct VectorOut { - id: &'static str, - provider: &'static str, - raw_model_id: &'static str, - #[serde(rename = "_note", skip_serializing_if = "Option::is_none")] - note: Option<&'static str>, - expect: CapabilityResult, - } - - /// A heterogeneous corpus entry. `untagged` writes the inner object with no - /// discriminator, yielding the one flat array the harnesses replay. - #[derive(Serialize)] - #[serde(untagged)] - enum CorpusOut { - Section(SectionOut), - Vector(VectorOut), - } - - const CORPUS_JSON: &str = include_str!("../../../scripts/normative-corpus.json"); - - /// Render the corpus from [`INPUTS`] by running the production [`resolve`] - /// over every query. Deterministic: fixed input order, struct-declaration - /// key order, `serde_json` pretty (2-space) formatting, trailing newline. - /// This is the single writer used by both the drift gate and the regen - /// recipe, so "what the gate checks" and "what regen writes" cannot drift. - fn generate_corpus_json() -> String { - let entries: Vec = INPUTS - .iter() - .map(|q| match *q { - Q::Section { group, note } => CorpusOut::Section(SectionOut { group, note }), - Q::Vector { - id, - provider, - raw_model_id, - note, - } => CorpusOut::Vector(VectorOut { - id, - provider, - raw_model_id, - note, - expect: resolve(provider, raw_model_id), - }), - }) - .collect(); - let mut json = serde_json::to_string_pretty(&entries) - .expect("corpus entries serialize as pretty JSON"); - json.push('\n'); - json - } - - /// Absolute path of the committed corpus, from the crate root at compile - /// time — the same file [`CORPUS_JSON`] embeds, so the regen recipe writes - /// exactly what the drift gate reads. - fn corpus_path() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/normative-corpus.json") - } - - #[test] - fn bundled_manifest_parses_and_validates() { - // Exercises the include_str! + strict serde + validate_manifest chain. - let _ = manifest(); - } - - #[test] - fn corpus_matches_generated_snapshot() { - // Drift gate: the committed corpus must be byte-identical to what the - // production resolver generates right now. A byte match proves every - // `expect` in the file is the resolver's current answer — the same - // cross-language contract the old hand-maintained corpus enforced, - // now impossible to hand-edit out of sync. `just regen-model-corpus` - // rewrites the file from this exact generator. - assert_eq!( - CORPUS_JSON, - generate_corpus_json(), - "scripts/normative-corpus.json is out of date — run `just regen-model-corpus` and commit the result" - ); - } - - #[test] - fn corpus_has_exactly_103_executable_vectors() { - // Locks the vector count so a silent INPUTS edit can't quietly drop - // coverage; must equal the gate in the TS harness - // (modelCapabilitiesCorpus.test.mjs). - let vectors = INPUTS - .iter() - .filter(|q| matches!(q, Q::Vector { .. })) - .count(); - assert_eq!( - vectors, 103, - "corpus executable-vector count changed; update this gate deliberately" - ); - } - - /// Rewrite `scripts/normative-corpus.json` from the production resolver. - /// `#[ignore]` so the ordinary test run only *checks* the committed bytes - /// (via `corpus_matches_generated_snapshot`); this is the writer half, - /// invoked by `just regen-model-corpus`. - #[test] - #[ignore = "writer, not a check — run via `just regen-model-corpus`"] - fn regen_corpus_file() { - std::fs::write(corpus_path(), generate_corpus_json()) - .expect("write scripts/normative-corpus.json"); - } - - // --- Migrated relational/invariant tests (see 42-test inventory) --- - // These assert cross-input properties a single corpus vector cannot express. - - #[test] - fn test_gpt5_numeric_date_suffix_matches_base_not_version() { - // A 4-digit date-like suffix on a non-boundary must fall to the gpt-5 base, - // never to the gpt-5.1 version rule. - let base = resolve("openai", "gpt-5"); - for id in ["gpt-5-1106", "gpt-5-20260101"] { - assert_eq!( - resolve("openai", id).supported_efforts, - base.supported_efforts, - "{id} must match gpt-5 base efforts" - ); - } - } - - #[test] - fn test_gpt5_lettered_suffix_matches_base_not_gpt5_4() { - // `gpt-5-4o` has an alnum char after `gpt-5-4`, so the gpt-5.4 rule must - // not match; it falls to the base rule. - let base = resolve("openai", "gpt-5"); - let gpt5_4 = resolve("openai", "gpt-5.4"); - let got = resolve("openai", "gpt-5-4o"); - assert_eq!(got.supported_efforts, base.supported_efforts); - assert_ne!(got.supported_efforts, gpt5_4.supported_efforts); - } - - #[test] - fn test_gpt5_pro_wins_over_base_by_longest_prefix() { - // `gpt-5-pro` matches both the base (`gpt-5`) and the pro rule; longest - // prefix must select pro (high-only). - let pro = resolve("openai", "gpt-5-pro"); - let base = resolve("openai", "gpt-5"); - assert_eq!(pro.supported_efforts, &[ThinkingEffort::High]); - assert_ne!(pro.supported_efforts, base.supported_efforts); - } - - #[test] - fn test_every_resolve_yields_a_complete_result() { - // Complete-result invariant: supported_efforts is never empty on any path. - let inputs = [ - ("anthropic", "claude-opus-4-7"), - ("anthropic", ""), - ("anthropic", "claude-ultra-9000"), - ("openai", "gpt-5"), - ("openai", ""), - ("openai", "gpt-4o"), - ("databricks_v2", "databricks-gpt-5-4-mini"), - ("databricks_v2", ""), - ("databricks_v2", "some-unknown-xyz"), - ("databricks", "databricks-gpt-5-pro"), - ("openrouter", "whatever"), - ("openai-compat", "gpt-5.5"), - ("__proto__", ""), - ("constructor", "some-model"), - ("", ""), - ("totally-unknown", "totally-unknown"), - ]; - for (provider, model) in inputs { - let got = resolve(provider, model); - assert!( - !got.supported_efforts.is_empty(), - "resolve({provider:?}, {model:?}) returned empty supported_efforts" - ); - } - } - - // --- New direct-resolver tests (contract 5) --- - - #[test] - fn test_whitespace_only_model_id_uses_blank_fallback() { - // A whitespace-only id trims to blank and takes the blank fallback, which - // differs from the concrete-unknown fallback for databricks_v2 (route). - let ws = resolve("databricks_v2", " "); - let blank = resolve("databricks_v2", ""); - assert_eq!(ws, blank); - assert_eq!(ws.databricks_v2_wire_route, DatabricksV2Route::RouteUnknown); - let concrete = resolve("databricks_v2", "some-unknown-xyz"); - assert_eq!( - concrete.databricks_v2_wire_route, - DatabricksV2Route::MlflowChat - ); - } - - #[test] - fn test_prefix_tie_break_is_lexicographic_on_rule_id() { - // gpt-5.1 matches the gpt-5.1 rule's exact value (len 7) over the base - // prefix (len 5); the longest-match + tie-break path is deterministic. - let a = resolve("openai", "gpt-5.1"); - let b = resolve("openai", "gpt-5.1"); - assert_eq!(a, b); - assert_eq!(a.default_effort, Some(ThinkingEffort::None)); - } - - #[test] - fn test_exact_record_beats_family_prefix() { - // databricks-gpt-5-4-mini has an exact record (label present); the family - // prefix would otherwise apply and carry no label. - let got = resolve("databricks_v2", "databricks-gpt-5-4-mini"); - assert_eq!(got.registry_label, Some("GPT-5.4 mini")); - } - - #[test] - fn test_known_models_accessor_reads_manifest() { - let known = databricks_v2_known_models(); - assert!(known.iter().any(|m| m == "databricks-gpt-5-5")); - assert!(known.iter().any(|m| m == "databricks-claude-opus-4-7")); - } - - #[test] - fn test_databricks_registry_label_lookup() { - // Known id → curated label; case-insensitive on the id, matching resolve(). - assert_eq!( - databricks_registry_label("databricks-gpt-5-5"), - Some("GPT-5.5") - ); - assert_eq!( - databricks_registry_label("DATABRICKS-GPT-5-5"), - Some("GPT-5.5") - ); - // Unknown id and blank input → no label. - assert_eq!(databricks_registry_label("custom-unlisted-endpoint"), None); - assert_eq!(databricks_registry_label(" "), None); - } -} From b3d77f2b264c75c9b10023695d30490a7eddc36d Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 18 Aug 2026 12:44:23 +1000 Subject: [PATCH 48/51] test(buzz-agent): cover provider failure paths end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stdio_turn.rs` drives the real binary against a real socket, but its fake provider only ever answers 200. buzz's deleted `llm.rs` tests covered the unhappy paths; nothing on this branch did. Five tests against a scriptable provider (429/401/500/truncated stream), driving the actual binary over stdio. The property under test is the one buzz owns and a user feels: a misbehaving provider ENDS THE TURN. Silence is the worst failure available to us — `buzz-acp` waits on that response, so a turn that never answers is an agent that has visibly stopped replying with nothing in the log to explain it. Each turn runs under a deadline so a hang fails as one test rather than stalling the suite. Deliberately not a port of the 149 `llm.rs` tests: nothing here asserts backoff timing, retry counts or `Retry-After` parsing. That is goose's logic to test, and re-asserting it would rebuild the duplication this PR deletes. What is asserted is observable behaviour through goose — including that 401 keeps its distinct `-32001` code, which `buzz-acp` routes on (`acp.rs:118`) to tell a credentials problem apart from a generic provider failure. Every test verified falsifiable by mutating the provider, not by inspection: making the 429 terminal, serving 401 as 500, letting the "persistent" 500 succeed, and keeping the second turn broken each fail the intended assertion with a clear message. That exercise also showed `BUZZ_AGENT_LLM_TIMEOUT_SECS` is load-bearing rather than test impatience: with the fake provider sleeping on a half-written stream the turn still ends, and with the timeout removed the prompt never answers at all. So the projection added in the previous commit is what rescues a provider that holds a socket open — noted at the line that depends on it. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/tests/provider_faults.rs | 498 +++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 crates/buzz-agent/tests/provider_faults.rs diff --git a/crates/buzz-agent/tests/provider_faults.rs b/crates/buzz-agent/tests/provider_faults.rs new file mode 100644 index 00000000000..cb78f75aeb7 --- /dev/null +++ b/crates/buzz-agent/tests/provider_faults.rs @@ -0,0 +1,498 @@ +//! What a turn does when the provider misbehaves. +//! +//! `stdio_turn.rs` proves the happy path end-to-end against a real socket. It +//! only ever answers `200`, so nothing there covers the unhappy half: a +//! rate-limited turn, an expired key, a hard server error, a stream that dies +//! mid-sentence. +//! +//! buzz used to cover that in `llm.rs`, against its own HTTP client. goose owns +//! that client now, and these tests are deliberately **not** a port of those: +//! they assert nothing about backoff timing, retry counts or `Retry-After` +//! parsing, because that is goose's logic to test and re-asserting it here +//! would rebuild the duplication this PR exists to delete. +//! +//! What they pin is the property buzz is responsible for and a user actually +//! feels: **a misbehaving provider ends the turn.** Every one of these +//! scenarios must produce a JSON-RPC response — a `stopReason` or an `error` — +//! and never a hang, a dropped request, or a crashed agent. A turn that never +//! answers is the worst failure mode available to us: `buzz-acp` waits on this +//! response, so silence means an agent that has visibly stopped replying in a +//! channel with nothing in the log to explain it. + +use std::io::{BufRead, BufReader, Read, 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}; + +/// How a scripted provider should answer one request. +/// +/// Named rather than a bare status code so a test reads as the scenario it +/// describes, and so `TruncatedStream` — which is not expressible as a status — +/// sits in the same vocabulary as the others. +#[derive(Clone, Copy, Debug)] +enum Reply { + /// A complete, well-formed SSE turn. + Ok, + /// Rate limited, with `Retry-After: 0` so a retrying client comes straight + /// back and the test does not pay a real backoff. + RateLimited, + /// Expired or invalid credentials. + Unauthorized, + /// A hard server error, the non-retryable-looking kind. + ServerError, + /// Headers and a first chunk, then the connection dies with no + /// `finish_reason` and no `[DONE]`. Models a provider or proxy dropping + /// mid-stream, which is not the same as any HTTP status. + TruncatedStream, +} + +/// A provider that answers from a script, one entry per request. +/// +/// Requests past the end of the script get `Reply::Ok`, so a test states only +/// the interesting prefix. The returned counter is the number of requests that +/// actually arrived, which is how a test distinguishes "the client retried" +/// from "the client gave up" without reaching into goose. +fn spawn_scripted_provider(script: Vec) -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_thread = Arc::clone(&hits); + + 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 trimmed = line.trim_end(); + if trimmed.is_empty() { + break; + } + if let Some(v) = trimmed + .strip_prefix("content-length: ") + .or_else(|| trimmed.strip_prefix("Content-Length: ")) + { + content_length = v.parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + let _ = reader.read_exact(&mut body); + + // The catalog endpoint always succeeds: these tests are about + // completion failures, and a broken /models would fail the turn + // earlier for an unrelated reason. + if request_line.contains("/models") { + let payload = + json!({ "object": "list", "data": [{ "id": "fake-model", "object": "model" }] }) + .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; + } + + // Count only completion requests, so the number a test asserts on + // is not inflated by catalog lookups. + let n = hits_for_thread.fetch_add(1, Ordering::SeqCst); + let reply = script.get(n).copied().unwrap_or(Reply::Ok); + + let response = match reply { + Reply::Ok => sse_ok(), + Reply::RateLimited => error_response( + 429, + "Too Many Requests", + Some("retry-after: 0\r\n"), + r#"{"error":{"message":"slow down","type":"rate_limit_exceeded"}}"#, + ), + Reply::Unauthorized => error_response( + 401, + "Unauthorized", + None, + r#"{"error":{"message":"invalid api key","type":"invalid_request_error"}}"#, + ), + Reply::ServerError => error_response( + 500, + "Internal Server Error", + None, + r#"{"error":{"message":"boom","type":"server_error"}}"#, + ), + Reply::TruncatedStream => { + // Deliberately no content-length: a length would let the + // client detect a short body as a framing error rather + // than an ended stream. Dropping the socket after one + // chunk is what a real mid-stream death looks like. + let chunk = json!({ + "id": "chatcmpl-truncated", + "object": "chat.completion.chunk", + "created": 1, + "model": "fake-model", + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": "I was saying" }, + "finish_reason": Value::Null, + }], + }); + format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\ndata: {chunk}\n\n" + ) + } + }; + + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + // Dropping `stream` here ends the body, which is what makes + // `TruncatedStream` truncated. + } + }); + + (format!("http://{addr}"), hits) +} + +/// A complete SSE turn: two content chunks, then `finish_reason` with usage. +fn sse_ok() -> String { + 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": "Recovered." }), + Value::Null, + Value::Null + ), + chunk( + json!({}), + json!("stop"), + json!({ "prompt_tokens": 11, "completion_tokens": 2, "total_tokens": 13 }) + ), + ); + 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 + ) +} + +fn error_response(status: u16, reason: &str, extra_headers: Option<&str>, payload: &str) -> String { + format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n{}connection: close\r\n\r\n{payload}", + payload.len(), + extra_headers.unwrap_or(""), + ) +} + +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"); + let mut child = Command::new(exe) + .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") + // Load-bearing, not just impatience. A provider that holds the + // socket open without finishing the stream is only rescued by the + // request timeout, and this is the knob that sets it — verified by + // making the fake provider sleep on a half-written stream: with + // this line the turn still ends, without it the prompt never + // answers. It also depends on the projection in `config.rs` that + // carries this onto goose's per-provider variable; before that + // projection existed this env var reached nothing. + .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "10") + .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"); + + 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 + } + + 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 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; + } + } + } + + /// `initialize` + `session/new`, returning the session id. + fn open_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!("no sessionId: {resp}")) + .to_string() + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Run one prompt against a scripted provider and return the response plus the +/// number of completion requests the provider saw. +/// +/// The turn runs on a worker thread so the test can impose its own deadline: a +/// hang is the failure mode most worth catching here, and without a deadline it +/// would present as the whole suite stalling rather than as one failed test. +fn prompt_once(script: Vec) -> (Value, usize) { + let (base_url, hits) = spawn_scripted_provider(script); + let hits_for_assert = Arc::clone(&hits); + + let (tx, rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + let session_id = h.open_session(cwd.path()); + + let id = h.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "say hello" }], + }), + ); + let resp = h.await_response(id); + // `home`/`cwd` must outlive the child: dropping them first would pull + // the session store out from under a still-running agent. + let _ = tx.send(resp); + drop(h); + }); + + let resp = rx + .recv_timeout(Duration::from_secs(90)) + .expect("session/prompt never answered — a turn that never returns hangs buzz-acp"); + worker.join().expect("harness thread panicked"); + (resp, hits_for_assert.load(Ordering::SeqCst)) +} + +/// Every scenario must end the turn — with a `stopReason` or a JSON-RPC error, +/// never silence. Returns the `stopReason` if there was one. +fn assert_turn_ended(resp: &Value, scenario: &str) -> Option { + let has_result = resp["result"]["stopReason"].is_string(); + let has_error = resp["error"].is_object(); + assert!( + has_result || has_error, + "{scenario}: turn produced neither stopReason nor error, which hangs buzz-acp: {resp}" + ); + resp["result"]["stopReason"].as_str().map(str::to_owned) +} + +/// A rate limit that clears must not be terminal. +/// +/// `Retry-After: 0` keeps the test fast. The assertion that matters is the +/// second request arriving at all: if a 429 ended the turn, the user would see +/// an agent that stopped replying because the provider was briefly busy. +#[test] +fn rate_limited_then_ok_completes_the_turn() { + let (resp, hits) = prompt_once(vec![Reply::RateLimited, Reply::Ok]); + + let stop = assert_turn_ended(&resp, "429 then 200"); + assert!( + stop.is_some(), + "a cleared rate limit must still complete the turn: {resp}" + ); + assert!( + hits >= 2, + "provider saw {hits} completion request(s): the 429 was treated as terminal rather than retried" + ); +} + +/// A bad key must fail the turn as an *auth* error specifically. +/// +/// `-32001` is not cosmetic: `AgentError::LlmAuth` maps to it +/// (`types.rs:102`) and `buzz-acp` routes on it (`acp.rs:118`, +/// `agent_error_from_json`) to tell "your key is wrong" apart from a generic +/// provider failure. Collapsing it into `-32000` would leave a user with an +/// expired key seeing an unexplained failure instead of a credentials problem. +/// +/// The bound matters too: 401 is not transient, so retrying it forever would +/// turn one misconfigured agent into sustained load against the provider. +#[test] +fn unauthorized_ends_the_turn_as_an_auth_error() { + let (resp, hits) = prompt_once(vec![Reply::Unauthorized; 8]); + + assert_turn_ended(&resp, "401"); + assert_eq!( + resp["error"]["code"], -32001, + "401 must surface as LlmAuth (-32001), which buzz-acp routes on, not a generic provider error: {resp}" + ); + assert!( + hits <= 4, + "provider saw {hits} completion requests for an invalid key — 401 is not transient and must not be retried indefinitely" + ); +} + +/// A persistent 500 must surface as a bounded, reported failure. +/// +/// Two properties, both user-visible. It must *report* — a 500 is not an auth +/// problem, so it takes the generic `-32000` arm and must not be silently +/// converted into a successful empty turn, which would look like an agent that +/// answered with nothing. And it must *stop*: retries are bounded, so one +/// broken provider cannot be hammered indefinitely by a single prompt. +#[test] +fn server_error_ends_the_turn_and_stops_retrying() { + let (resp, hits) = prompt_once(vec![Reply::ServerError; 8]); + + assert_turn_ended(&resp, "500"); + assert!( + resp["error"].is_object(), + "a provider that never succeeded must not report a successful turn: {resp}" + ); + assert_eq!( + resp["error"]["code"], -32000, + "a server error is not an auth error: {resp}" + ); + assert!( + hits <= 6, + "provider saw {hits} completion requests: retries must stay bounded" + ); +} + +/// A stream that dies mid-sentence must still end the turn. +/// +/// This is the scenario least likely to be handled by status-code logic: the +/// response was a valid `200` and the failure is the *absence* of a terminator. +/// A client waiting for `[DONE]` on a closed socket is exactly how a turn hangs +/// forever. +#[test] +fn truncated_stream_ends_the_turn() { + let (resp, hits) = prompt_once(vec![Reply::TruncatedStream; 4]); + + let stop = assert_turn_ended(&resp, "truncated stream"); + assert!( + stop.is_some() || resp["error"].is_object(), + "a stream that died mid-sentence must resolve one way or the other: {resp}" + ); + assert!( + hits >= 1, + "the request never reached the provider, so nothing was tested" + ); +} + +/// A transient failure must not poison the session. +/// +/// Recovery within one turn (`rate_limited_then_ok_completes_the_turn`) and +/// recovery across turns are different properties: goose holds per-session +/// state, so a turn that failed hard could leave a session that never works +/// again. That would present to a user as an agent permanently silent after one +/// bad moment, which is worse than the original failure. +#[test] +fn a_session_survives_a_failed_turn() { + let (base_url, hits) = spawn_scripted_provider(vec![Reply::ServerError; 4]); + + let (tx, rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("cwd"); + let mut h = Harness::start(&base_url, home.path()); + let session_id = h.open_session(cwd.path()); + + let id = h.request( + "session/prompt", + json!({ "sessionId": session_id, "prompt": [{ "type": "text", "text": "first" }] }), + ); + let first = h.await_response(id); + + // The script is exhausted, so this turn is served `Reply::Ok`. + let id = h.request( + "session/prompt", + json!({ "sessionId": session_id, "prompt": [{ "type": "text", "text": "second" }] }), + ); + let second = h.await_response(id); + + let _ = tx.send((first, second)); + drop(h); + }); + + let (first, second) = rx + .recv_timeout(Duration::from_secs(120)) + .expect("a prompt never answered after a failed turn"); + worker.join().expect("harness thread panicked"); + + assert_turn_ended(&first, "failing first turn"); + let recovered = assert_turn_ended(&second, "recovering second turn"); + assert!( + recovered.is_some(), + "the session did not recover once the provider did — a transient failure must not leave an agent permanently silent: {second}" + ); + assert!( + hits.load(Ordering::SeqCst) >= 2, + "the second prompt never reached the provider" + ); +} From 913cd56eb33ae2b0e0bf7be3a6e0cdbc9089dc19 Mon Sep 17 00:00:00 2001 From: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> Date: Thu, 20 Aug 2026 22:18:27 +1000 Subject: [PATCH 49/51] chore(buzz-agent): update goose to 1.47 Keep the Goose facade and split provider crates on one current upstream revision after reconciling Buzz main. Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> --- Cargo.lock | 2236 +++++++++++++++++++++++++++++++--- crates/buzz-agent/Cargo.toml | 6 +- 2 files changed, 2075 insertions(+), 167 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16d86d0206f..5c37796b5ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,73 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "rustix 1.1.4", + "schemars 1.2.1", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" +dependencies = [ + "quote", + "syn 3.0.3", +] + +[[package]] +name = "agent-client-protocol-http" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4d8db045bc66b84526dfe4ef2ffb87d651b81c4ff945f91c63a2bc677a582c" +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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" +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" @@ -66,6 +133,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" @@ -117,7 +199,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +210,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -170,6 +252,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.2" @@ -209,6 +308,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" @@ -321,6 +459,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" @@ -422,7 +582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64 0.22.1", - "http", + "http 1.4.0", "log", "rustls", "serde", @@ -460,6 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -484,6 +645,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.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647" +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" @@ -496,8 +719,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", @@ -529,8 +752,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", @@ -551,6 +774,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" @@ -580,6 +825,22 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[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" @@ -645,6 +906,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-consensus-encoding" version = "1.1.0" @@ -807,6 +1077,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" @@ -888,22 +1194,30 @@ dependencies = [ name = "buzz-agent" version = "0.1.0" dependencies = [ + "anyhow", "arc-swap", "async-trait", "axum", "base64 0.22.1", + "buzz-model-catalog", "dirs", + "futures", "getrandom 0.4.3", + "goose", + "goose-provider-types", + "goose-providers", "hex", + "idna_adapter", "nix 0.31.3", "reqwest 0.13.4", - "rmcp", + "rmcp 3.1.4", "serde", "serde_json", "serde_yaml", "sha2 0.11.0", "tempfile", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "url", @@ -954,7 +1268,7 @@ version = "0.1.0" dependencies = [ "chrono", "hex", - "http", + "http 1.4.0", "http-body-util", "k8s-openapi", "kube", @@ -1100,13 +1414,13 @@ dependencies = [ "git-credential-nostr", "git-sign-nostr", "ignore", - "image", + "image 0.25.10", "nix 0.31.3", "nostr 0.44.7", "reqwest 0.13.4", - "rmcp", + "rmcp 1.8.0", "rustls", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "similar", @@ -1131,7 +1445,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "image", + "image 0.25.10", "imagesize", "infer", "mp4", @@ -1149,6 +1463,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-model-catalog" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "dirs", + "getrandom 0.4.3", + "hex", + "nix 0.31.3", + "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" @@ -1241,7 +1577,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tower", - "tower-http", + "tower-http 0.6.11", "tracing", "tracing-subscriber", "url", @@ -1311,7 +1647,7 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -1461,6 +1797,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" @@ -1485,6 +1827,16 @@ 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 = "bzip2" version = "0.4.4" @@ -1506,16 +1858,26 @@ dependencies = [ ] [[package]] -name = "castaway" +name = "calendrical_calculations" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" dependencies = [ - "rustversion", + "core_maths", + "displaydoc", ] [[package]] -name = "cbc" +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" @@ -1619,6 +1981,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" @@ -1670,6 +2042,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" @@ -1712,7 +2093,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1763,9 +2144,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]] @@ -1851,6 +2235,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +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" @@ -1887,6 +2300,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" @@ -1972,10 +2394,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" @@ -2142,7 +2575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", ] [[package]] @@ -2238,6 +2671,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core 0.24.0", + "darling_macro 0.24.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2265,6 +2708,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2287,6 +2743,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core 0.24.0", + "quote", + "syn 3.0.3", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -2333,7 +2800,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]] @@ -2396,6 +2863,37 @@ dependencies = [ "tokio", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deltae" version = "0.3.2" @@ -2413,6 +2911,20 @@ dependencies = [ "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" @@ -2544,7 +3056,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2627,7 +3139,7 @@ dependencies = [ "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki", "zeroize", ] @@ -2640,7 +3152,7 @@ checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2654,7 +3166,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2689,6 +3201,15 @@ dependencies = [ "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" @@ -2767,9 +3288,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "esaxx-rs" version = "0.1.10" @@ -2860,6 +3387,28 @@ dependencies = [ "regex-syntax", ] +[[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" @@ -2968,6 +3517,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" @@ -3032,6 +3592,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" @@ -3076,6 +3666,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" @@ -3183,7 +3786,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3198,6 +3801,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" @@ -3218,9 +3831,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]] @@ -3247,6 +3862,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" @@ -3325,6 +3950,224 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "goose" +version = "1.47.0" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +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 0.23.1", + "blake3", + "chrono", + "clap", + "dirs", + "etcetera", + "fs-err", + "fs2", + "futures", + "gethostname", + "goose-acp-macros", + "goose-agent", + "goose-context-management", + "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", + "minijinja", + "nanoid", + "ntapi", + "oauth2", + "once_cell", + "pastey", + "pem 4.0.0", + "process-wrap", + "rand 0.10.1", + "rayon", + "rcgen", + "regex", + "reqwest 0.13.4", + "rmcp 3.1.4", + "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.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 = "goose-acp-macros" +version = "1.47.0" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "quote", + "syn 3.0.3", +] + +[[package]] +name = "goose-agent" +version = "0.1.0-alpha.6" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "anyhow", + "async-trait", + "goose-provider-types", + "rmcp 3.1.4", + "serde_json", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "goose-context-management" +version = "0.1.0-alpha.6" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "anyhow", + "async-trait", + "goose-providers", + "include_dir", + "minijinja", + "rmcp 3.1.4", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "goose-download-manager" +version = "0.1.0-alpha.6" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "anyhow", + "once_cell", + "reqwest 0.13.4", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "goose-provider-types" +version = "0.1.0-alpha.6" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.23.1", + "chrono", + "futures", + "once_cell", + "rand 0.10.1", + "regex", + "reqwest 0.13.4", + "rmcp 3.1.4", + "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.6" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "futures", + "goose-provider-types", + "include_dir", + "reqwest 0.13.4", + "rmcp 3.1.4", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "goose-sdk-types" +version = "0.1.0-alpha.6" +source = "git+https://github.com/aaif-goose/goose?rev=d1d8f4581257f75feeba73c2db3445a9d74a19b8#d1d8f4581257f75feeba73c2db3445a9d74a19b8" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "schemars 1.2.1", + "serde", + "serde_json", +] + [[package]] name = "group" version = "0.14.0" @@ -3347,8 +4190,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", - "indexmap", + "http 1.4.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3370,6 +4213,12 @@ 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" @@ -3477,7 +4326,7 @@ dependencies = [ "anyhow", "async-trait", "bytes", - "http", + "http 1.4.0", "more-asserts", "serde", "serde_json", @@ -3508,7 +4357,7 @@ dependencies = [ "futures-util", "h2", "hickory-proto", - "http", + "http 1.4.0", "idna", "ipnet", "jni 0.22.4", @@ -3615,14 +4464,36 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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", - "itoa", + "http 0.2.12", + "pin-project-lite", ] [[package]] @@ -3632,7 +4503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3643,8 +4514,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -3694,8 +4565,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3711,7 +4582,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", "log", @@ -3762,8 +4633,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -3801,18 +4672,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]] @@ -3822,51 +4732,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" @@ -3876,11 +4842,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]] @@ -3908,9 +4887,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", @@ -3935,7 +4914,7 @@ dependencies = [ "attohttpc", "bytes", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -3962,6 +4941,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" @@ -3971,11 +4965,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", ] @@ -3996,6 +4990,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" @@ -4004,6 +5028,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -4098,7 +5124,7 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hickory-resolver", - "http", + "http 1.4.0", "ipnet", "iroh-base", "iroh-dns", @@ -4122,7 +5148,7 @@ dependencies = [ "rustls-pki-types", "serde", "smallvec", - "strum", + "strum 0.28.0", "time", "tokio", "tokio-stream", @@ -4169,7 +5195,7 @@ dependencies = [ "rand 0.10.1", "rustls", "simple-dns", - "strum", + "strum 0.28.0", "tokio", "tracing", "url", @@ -4215,7 +5241,7 @@ dependencies = [ "derive_more", "getrandom 0.4.3", "hickory-resolver", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -4236,7 +5262,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_bytes", - "strum", + "strum 0.28.0", "tokio", "tokio-rustls", "tokio-util", @@ -4269,6 +5295,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -4353,6 +5432,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" @@ -4388,6 +5473,76 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jsonschema" +version = "0.49.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257549c093d8f3d043337ba0d507e8eeace2447dfae3f0ee37eb0f57d3df7655" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem 3.0.6", + "serde", + "serde_json", + "signature 2.2.0", + "simple_asn1", + "zeroize", +] + [[package]] name = "k8s-openapi" version = "0.26.1" @@ -4468,8 +5623,8 @@ dependencies = [ "either", "futures", "home", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -4478,7 +5633,7 @@ dependencies = [ "jsonpath-rust", "k8s-openapi", "kube-core", - "pem", + "pem 3.0.6", "rustls", "secrecy", "serde", @@ -4488,7 +5643,7 @@ dependencies = [ "tokio", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tracing", ] @@ -4501,7 +5656,7 @@ dependencies = [ "chrono", "derive_more", "form_urlencoded", - "http", + "http 1.4.0", "k8s-openapi", "serde", "serde-value", @@ -4579,6 +5734,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -4616,6 +5772,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" @@ -4824,6 +5986,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" @@ -5006,7 +6174,7 @@ dependencies = [ "flate2", "futures-util", "hex", - "http", + "http 1.4.0", "http-body-util", "httparse", "iroh", @@ -5048,10 +6216,10 @@ dependencies = [ "rand 0.10.1", "regex-lite", "reqwest 0.12.28", - "rmcp", + "rmcp 1.8.0", "rpassword", "rustls", - "schemars", + "schemars 1.2.1", "semver", "serde", "serde_json", @@ -5136,8 +6304,8 @@ dependencies = [ "prost 0.14.3", "prost-build 0.14.3", "protoc-bin-vendored", - "rmcp", - "schemars", + "rmcp 1.8.0", + "schemars 1.2.1", "serde", "serde_json", "tokio", @@ -5336,7 +6504,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap", + "indexmap 2.14.0", "ipnet", "metrics", "metrics-util", @@ -5357,7 +6525,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "metrics", "ordered-float 5.3.0", "quanta", @@ -5368,6 +6536,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 = "miette" version = "7.6.0" @@ -5421,6 +6595,16 @@ dependencies = [ "rxml", ] +[[package]] +name = "minijinja" +version = "2.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5646,6 +6830,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" @@ -6028,7 +7221,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6055,6 +7248,12 @@ dependencies = [ "num-traits", ] +[[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" @@ -6163,6 +7362,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "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" @@ -6180,20 +7399,34 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "objc2", - "objc2-foundation", + "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.0", + "block2", + "dispatch2", + "libc", + "objc2", ] [[package]] -name = "objc2-core-foundation" +name = "objc2-core-graphics" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.0", - "block2", "dispatch2", - "libc", "objc2", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -6239,6 +7472,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" @@ -6274,6 +7518,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" @@ -6414,7 +7667,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http", + "http 1.4.0", "opentelemetry 0.31.0", "reqwest 0.12.28", ] @@ -6425,7 +7678,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", @@ -6441,7 +7694,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", @@ -6595,6 +7848,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" @@ -6720,6 +7979,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6784,7 +8053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset 0.5.7", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -6795,7 +8064,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -6815,7 +8084,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]] @@ -6825,7 +8103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -6834,7 +8112,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", ] @@ -6845,7 +8123,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", @@ -6860,6 +8138,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" @@ -6886,6 +8173,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" @@ -6920,12 +8213,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", - "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" @@ -7053,7 +8359,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]] @@ -7144,7 +8452,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", @@ -7377,6 +8685,22 @@ dependencies = [ "thiserror 1.0.69", ] +[[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" @@ -7501,7 +8825,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7678,7 +9002,7 @@ dependencies = [ "lru", "palette", "serde", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -7742,7 +9066,7 @@ dependencies = [ "line-clipping", "ratatui-core", "serde", - "strum", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -7794,6 +9118,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "aws-lc-rs", + "pem 3.0.6", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redb" version = "3.1.3" @@ -7874,6 +9212,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -7922,8 +9277,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", @@ -7947,7 +9302,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -7965,12 +9320,15 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "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", @@ -7993,7 +9351,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -8010,7 +9368,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http", + "http 1.4.0", "reqwest 0.13.4", "thiserror 2.0.18", "tower-service", @@ -8042,7 +9400,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -8057,16 +9415,16 @@ dependencies = [ "bytes", "chrono", "futures", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "pastey", "pin-project-lite", "process-wrap", "rand 0.10.1", "reqwest 0.13.4", - "rmcp-macros", - "schemars", + "rmcp-macros 1.8.0", + "schemars 1.2.1", "serde", "serde_json", "sse-stream", @@ -8079,6 +9437,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "rmcp" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a15bc53261a9dc37e105df006e4656c598379a8f9581f8950debb130f27a7cf" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "indexmap 2.14.0", + "oauth2", + "pastey", + "pin-project-lite", + "process-wrap", + "reqwest 0.13.4", + "rmcp-macros 3.1.4", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", +] + [[package]] name = "rmcp-macros" version = "1.8.0" @@ -8092,6 +9485,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rmcp-macros" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" +dependencies = [ + "darling 0.24.0", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rpassword" version = "5.0.1" @@ -8127,7 +9533,7 @@ dependencies = [ "futures-util", "hex", "hmac 0.12.1", - "http", + "http 1.4.0", "log", "maybe-async", "md5", @@ -8162,6 +9568,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" @@ -8172,7 +9587,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8185,7 +9600,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8244,7 +9659,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8262,7 +9677,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -8341,6 +9756,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" @@ -8527,7 +9954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8621,6 +10048,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", @@ -8671,13 +10099,46 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +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", @@ -8753,6 +10214,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" @@ -8823,6 +10290,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -8873,6 +10349,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" @@ -9006,6 +10494,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" @@ -9024,7 +10525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9123,7 +10624,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "percent-encoding", @@ -9273,13 +10774,13 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -9306,6 +10807,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" @@ -9323,13 +10830,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]] @@ -9567,6 +11095,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" @@ -9652,7 +11190,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9665,7 +11203,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9676,7 +11214,7 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -9713,7 +11251,7 @@ dependencies = [ "ordered-float 4.6.0", "pest", "pest_derive", - "phf", + "phf 0.11.3", "sha2 0.10.9", "signal-hook", "siphasher", @@ -9780,6 +11318,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 0.22.1", + "bstr", + "fancy-regex 0.17.0", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "time" version = "0.3.47" @@ -9823,6 +11376,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" @@ -9830,7 +11393,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", - "zerovec", + "serde_core", + "zerovec 0.11.6", ] [[package]] @@ -9898,6 +11462,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-happy-eyeballs" version = "0.1.0" @@ -10013,6 +11593,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "pin-project-lite", @@ -10031,7 +11612,7 @@ dependencies = [ "futures-core", "futures-sink", "getrandom 0.4.3", - "http", + "http 1.4.0", "httparse", "rand 0.10.1", "ring", @@ -10073,7 +11654,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", @@ -10088,7 +11669,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", @@ -10121,7 +11702,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", @@ -10154,8 +11735,8 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -10203,7 +11784,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -10226,8 +11807,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", @@ -10244,6 +11825,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" @@ -10302,6 +11898,18 @@ dependencies = [ "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" @@ -10354,12 +11962,123 @@ dependencies = [ "sharded-slab", "smallvec", "thread_local", + "time", "tracing", "tracing-core", "tracing-log", "tracing-serde", ] +[[package]] +name = "tree-sitter" +version = "0.26.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c567a8e18ae93f20982c90370b16fd24023aeaf52f6052b96957ab253a0fec" +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" @@ -10374,7 +12093,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -10393,7 +12112,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -10435,7 +12154,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10466,6 +12185,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" @@ -10559,6 +12284,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" @@ -10606,6 +12337,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" @@ -10631,6 +12368,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" @@ -10686,6 +12448,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" @@ -10978,6 +12746,15 @@ 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 = "2.1.2" @@ -11019,7 +12796,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11519,6 +13296,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" @@ -11544,6 +13333,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" @@ -11576,7 +13400,7 @@ dependencies = [ "bytes", "crc32fast", "futures", - "http", + "http 1.4.0", "hyper", "more-asserts", "rand 0.10.1", @@ -11644,7 +13468,7 @@ dependencies = [ "bytes", "chrono", "gearhash", - "http", + "http 1.4.0", "itertools", "more-asserts", "rand 0.10.1", @@ -11726,6 +13550,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" @@ -11733,10 +13579,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" @@ -11873,8 +13731,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.4", ] [[package]] @@ -11883,9 +13751,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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -11910,7 +13790,7 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", "thiserror 2.0.18", "zopfli", @@ -11934,6 +13814,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/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 8fc3ef3414f..2fa3cb6f2fe 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -29,11 +29,11 @@ path = "tests/bin/fake_mcp.rs" # 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 = "7c4ba2219166700becb68d6db35989ebcaa52f69", default-features = false, features = ["rustls-tls"] } -goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "7c4ba2219166700becb68d6db35989ebcaa52f69" } +goose = { git = "https://github.com/aaif-goose/goose", rev = "d1d8f4581257f75feeba73c2db3445a9d74a19b8", default-features = false, features = ["rustls-tls"] } +goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "d1d8f4581257f75feeba73c2db3445a9d74a19b8" } # ModelConfig: goose re-exports it from goose-providers; goose::model_config # keeps its own copy private. -goose-providers = { git = "https://github.com/aaif-goose/goose", rev = "7c4ba2219166700becb68d6db35989ebcaa52f69" } +goose-providers = { git = "https://github.com/aaif-goose/goose", rev = "d1d8f4581257f75feeba73c2db3445a9d74a19b8" } # 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 From 55edc4aef5670960326417bd7e212c28b85932cf Mon Sep 17 00:00:00 2001 From: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> Date: Fri, 21 Aug 2026 16:39:18 +1000 Subject: [PATCH 50/51] fix(agent): preserve migration behavior Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> --- crates/buzz-agent/src/agent.rs | 20 +-- crates/buzz-agent/src/config.rs | 44 +++++++ crates/buzz-agent/src/lib.rs | 79 ++++++------ crates/buzz-agent/src/loop_drive.rs | 177 +++++++++++++++++++++----- crates/buzz-agent/src/ops.rs | 49 ++++--- crates/buzz-agent/src/provider.rs | 42 ++++-- crates/buzz-agent/src/types.rs | 37 ++++++ crates/buzz-model-catalog/src/auth.rs | 56 ++++++++ scripts/run-tests.sh | 2 + 9 files changed, 401 insertions(+), 105 deletions(-) diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 305f9c4f075..afc4dd3aa04 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -30,18 +30,20 @@ use crate::wire::{self, WireSender}; pub(crate) 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)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct TurnTokens { pub input: Option, pub output: Option, - /// Cache reads+writes. A *subset* of `input`, not an addition to it -- - /// goose documents `cache_read_input_tokens`/`cache_write_input_tokens` as - /// already counted in `input_tokens` (`token_usage.rs:72-78`). buzz-acp - /// reads this as `accumulatedCachedInputTokens` for pricing (#3463). - pub cached_input: Option, - /// Provider-reported total, when it reports one. buzz-acp treats a missing - /// total as unknown rather than zero (#3593). - pub total: Option, + /// Cache-served input tokens, a subset of `input`. + pub cached_input: crate::types::CacheTotalState, + /// Cache-written input tokens, a subset of `input` but billed separately. + pub cache_write: crate::types::CacheTotalState, + /// Provider-reported total. A missing value on any usage-bearing response + /// poisons the turn rather than silently under-reporting it. + pub total: crate::types::TurnTotalState, + /// Proven publisher billing identity while every usage-bearing response + /// agrees; `Some(None)` is permanently poisoned for this turn. + pub pricing_identity: Option>, } impl TurnTokens { diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 6d8c2f99466..3641a4c51a8 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -25,6 +25,9 @@ pub const PROTOCOL_VERSION: u32 = 2; 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; +/// Maximum model-requested tool calls dispatched from one provider round. +/// Preserved from the pre-Goose loop to bound fan-out and resource use. +pub const MAX_TOOL_CALLS_PER_TURN: usize = 64; #[derive(Debug, Clone)] pub struct Config { @@ -150,6 +153,47 @@ fn env_parse(key: &str) -> Option { env_str(key).and_then(|s| s.parse().ok()) } +/// Derive a billing identity only when the configured provider endpoint is an +/// official allowlisted origin. Custom/gateway endpoints deliberately return +/// `None`, even when they speak a compatible wire protocol. +pub fn pricing_identity(provider: &str, model: &str) -> Option { + let base_url = match goose_provider_name(provider) { + "anthropic" => { + env_str("ANTHROPIC_HOST").unwrap_or_else(|| "https://api.anthropic.com".to_string()) + } + "openai" => env_str("OPENAI_BASE_URL") + .or_else(|| env_str("OPENAI_HOST")) + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), + "openrouter" => "https://openrouter.ai/api/v1".to_string(), + _ => return None, + }; + pricing_authority(&base_url).map(|authority| crate::types::PricingIdentity { + authority: authority.to_string(), + model: model.to_string(), + cache_class: None, + }) +} + +fn pricing_authority(base_url: &str) -> Option<&'static str> { + let parsed = url::Url::parse(base_url).ok()?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.port().is_some_and(|port| port != 443) + { + return None; + } + let path = parsed.path().trim_end_matches('/'); + match (parsed.host_str()?.to_ascii_lowercase().as_str(), path) { + ("api.anthropic.com", "") => Some("api.anthropic.com"), + ("api.openai.com", "/v1") => Some("api.openai.com"), + ("openrouter.ai", "/api/v1") => Some("openrouter.ai"), + _ => None, + } +} + impl Config { /// Read `BUZZ_AGENT_*` from the environment and, as a side effect, project /// the provider-shaped ones onto the `GOOSE_*` names Goose reads. diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 39496118d38..0dc28399df1 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -103,12 +103,15 @@ struct Session { steers: crate::steer::SteerQueue, accumulated_input_tokens: u64, accumulated_output_tokens: u64, - /// Subset of `accumulated_input_tokens`, published as - /// `accumulatedCachedInputTokens` for buzz-acp pricing (#3463). - accumulated_cached_input_tokens: u64, - /// Session-cumulative provider total. `None` once any turn fails to report - /// one -- buzz-acp must not read a missing total as zero (#3593). - accumulated_total_tokens: Option, + /// Subset of `accumulated_input_tokens`, published as cache-read tokens. + accumulated_cached_input_tokens: crate::types::CacheTotalState, + /// Cache-written subset, billed independently from cache reads. + accumulated_cache_write_tokens: crate::types::CacheTotalState, + /// Session-cumulative provider total with sticky unknown semantics. + accumulated_total_tokens: crate::types::TurnTotalState, + /// Proven publisher identity across every usage-bearing turn. `Some(None)` + /// is sticky once any turn is untrusted or disagrees. + accumulated_pricing_identity: Option>, } pub struct App { @@ -577,8 +580,10 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen steers: crate::steer::SteerQueue::new(), accumulated_input_tokens: 0, accumulated_output_tokens: 0, - accumulated_cached_input_tokens: 0, - accumulated_total_tokens: Some(0), + accumulated_cached_input_tokens: crate::types::CacheTotalState::Unseen, + accumulated_cache_write_tokens: crate::types::CacheTotalState::Unseen, + accumulated_total_tokens: crate::types::TurnTotalState::Unseen, + accumulated_pricing_identity: None, }, ); } @@ -801,18 +806,27 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire .saturating_add(tokens.output.unwrap_or(0)); s.accumulated_cached_input_tokens = s .accumulated_cached_input_tokens - .saturating_add(tokens.cached_input.unwrap_or(0)); - // Sticky-unknown: one turn without a provider total poisons the - // session cumulative, so we omit the field rather than under-report. - s.accumulated_total_tokens = match (s.accumulated_total_tokens, tokens.total) { - (Some(acc), Some(t)) => Some(acc.saturating_add(t)), - _ => None, + .merge_session(tokens.cached_input); + s.accumulated_cache_write_tokens = s + .accumulated_cache_write_tokens + .merge_session(tokens.cache_write); + s.accumulated_total_tokens = s.accumulated_total_tokens.merge_session(tokens.total); + s.accumulated_pricing_identity = match ( + s.accumulated_pricing_identity.take(), + tokens.pricing_identity.clone(), + ) { + (None, identity) => identity, + (Some(Some(current)), Some(Some(turn))) if current == turn => Some(Some(current)), + (Some(_), Some(_)) => Some(None), + (current, None) => current, }; ( s.accumulated_input_tokens, s.accumulated_output_tokens, s.accumulated_cached_input_tokens, + s.accumulated_cache_write_tokens, s.accumulated_total_tokens, + s.accumulated_pricing_identity.clone(), ) }) }; @@ -832,7 +846,9 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire // 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, acc_cached, acc_total)) = accumulated { + if let Some((acc_in, acc_out, acc_cached, acc_written, acc_total, acc_identity)) = + accumulated + { let model = { let sessions = app.sessions.lock().await; sessions @@ -845,29 +861,16 @@ async fn session_prompt(app: &Arc, id: Value, params: Value, wire_tx: &Wire .or_else(|| std::env::var("GOOSE_MODEL").ok()) .unwrap_or_default() }; - wire::send( - wire_tx, - goose_session_update(&p.session_id, { - let mut u = json!({ - "sessionUpdate": "usage_update", - "used": acc_in.saturating_add(acc_out), - "contextLimit": 0u64, - "accumulatedInputTokens": acc_in, - "accumulatedOutputTokens": acc_out, - // A subset of accumulatedInputTokens, not an - // addition to it (#3463). - "accumulatedCachedInputTokens": acc_cached, - "model": model, - }); - // Only when exactly known; absent means "unknown", - // which buzz-acp distinguishes from zero (#3593). - if let Some(total) = acc_total { - u["accumulatedTotalTokens"] = json!(total); - } - u - }), - ) - .await; + let update = wire::usage_update_payload( + Some(acc_in), + Some(acc_out), + acc_cached.exact_value(), + acc_written.exact_value(), + acc_total, + &model, + acc_identity.as_ref().and_then(|identity| identity.as_ref()), + ); + wire::send(wire_tx, goose_session_update(&p.session_id, update)).await; } } diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 399b3ef59a4..a2dcecacbd0 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -170,13 +170,25 @@ pub async fn run_turn( // different points: `start_machine` before inference, `machine` at the // round gate and again when a turn wants to end. let outcome = crate::ops::Outcome::new(); + let reply_guard_tools = if ctx.require_reply { + Some( + ctx.agent + .list_tools(ctx.session_id, None) + .await + .into_iter() + .map(|tool| tool.name.to_string()) + .collect(), + ) + } else { + None + }; let machine = crate::ops::round_gate( max_rounds, outcome.clone(), ctx.cancel.clone(), ctx.hook_extension .map(|extension| (Arc::clone(ctx.agent), extension.to_string())), - ctx.require_reply, + reply_guard_tools, ); let start_machine = crate::ops::round_start( ctx.steers.clone(), @@ -359,20 +371,25 @@ async fn round( let conversation = state.conversation(); let assistant = match infer(ctx, state.session(), &conversation, tokens).await? { - Some(message) => message, + Some(Inference { + message, + total_tokens, + }) => { + // Goose's compaction gate needs current conversation occupancy, + // not the turn-cumulative sum of each round's full context. + state.set_total_tokens(total_tokens); + message + } + // Cancellation discards a partial inference but still has to preserve + // the ACP cancellation contract. + None if ctx.cancel.is_cancelled() => return Ok(Round::Stopped(StopReason::Cancelled)), // A provider that returns nothing is not a turn we can continue. None => return Ok(Round::Stopped(StopReason::EndTurn)), }; state.push(assistant.clone()); - // Keep the running total current: goose's compaction check prefers it - // over re-estimating the conversation. - // i32 because that is the width goose's `Usage` uses; a total beyond - // i32::MAX means something has gone very wrong upstream, and saturating is - // better than wrapping into a negative that reads as "no usage". - state.set_total_tokens(tokens.total.map(|t| i32::try_from(t).unwrap_or(i32::MAX))); - - let requests: Vec = assistant + + let mut requests: Vec = assistant .content .iter() .filter_map(|content| match content { @@ -381,6 +398,15 @@ async fn round( }) .collect(); + if requests.len() > crate::config::MAX_TOOL_CALLS_PER_TURN { + tracing::warn!( + requested = requests.len(), + limit = crate::config::MAX_TOOL_CALLS_PER_TURN, + "capping model-requested tool calls" + ); + requests.truncate(crate::config::MAX_TOOL_CALLS_PER_TURN); + } + if requests.is_empty() { return Ok(Round::WantsToEnd); } @@ -421,6 +447,12 @@ async fn round( Ok(Round::Continued) } +/// One fully accumulated inference plus that response's own occupancy total. +struct Inference { + message: Message, + total_tokens: Option, +} + /// Stream one assistant response, emitting chunks onto the ACP wire as they /// arrive and accumulating usage. async fn infer( @@ -428,7 +460,7 @@ async fn infer( session: &Session, conversation: &Conversation, tokens: &mut super::agent::TurnTokens, -) -> Result, AgentError> { +) -> Result, AgentError> { let provider = ctx.model.provider().await; let model_config = ctx.model.config().await; @@ -443,9 +475,10 @@ async fn infer( &tools, ) .await - .map_err(|e| classify_provider_error(&e.to_string()))?; + .map_err(classify_provider_error)?; let mut accumulated: Option = None; + let mut response_total: Option = None; let mut keepalive = tokio::time::interval(crate::agent::KEEPALIVE_INTERVAL); keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); keepalive.tick().await; // first tick is immediate; discard it @@ -461,14 +494,15 @@ async fn infer( // dispatch there is no child process to notify, and the partial // response is discarded rather than persisted. _ = ctx.cancel.cancelled() => { - return Ok(accumulated); + return Ok(None); } next = stream.next() => { let Some(item) = next else { break }; - let (message, usage) = item.map_err(|e| classify_provider_error(&e.to_string()))?; + let (message, usage) = item.map_err(classify_provider_error)?; if let Some(usage) = usage { + response_total = usage.usage.total_tokens; accumulate_usage(tokens, &usage); } @@ -487,7 +521,10 @@ async fn infer( } } - Ok(accumulated) + Ok(accumulated.map(|message| Inference { + message, + total_tokens: response_total, + })) } /// Fold a streamed chunk into the message being accumulated. @@ -519,13 +556,25 @@ fn accumulate_usage( if let Some(o) = u.output_tokens { tokens.output = Some(tokens.output.unwrap_or(0) + o.max(0) as u64); } - if u.cache_read_input_tokens.is_some() || u.cache_write_input_tokens.is_some() { - let cached = u.cache_read_input_tokens.unwrap_or(0).max(0) as u64 - + u.cache_write_input_tokens.unwrap_or(0).max(0) as u64; - tokens.cached_input = Some(tokens.cached_input.unwrap_or(0) + cached); - } - if let Some(t) = u.total_tokens { - tokens.total = Some(tokens.total.unwrap_or(0) + t.max(0) as u64); + let usage_bearing = u.input_tokens.is_some() || u.output_tokens.is_some(); + if usage_bearing { + tokens.cached_input = tokens + .cached_input + .fold(u.cache_read_input_tokens.map(|n| n.max(0) as u64)); + tokens.cache_write = tokens + .cache_write + .fold(u.cache_write_input_tokens.map(|n| n.max(0) as u64)); + tokens.total = tokens.total.fold(u.total_tokens.map(|n| n.max(0) as u64)); + + let round_identity = crate::config::pricing_identity( + &std::env::var("GOOSE_PROVIDER").unwrap_or_default(), + &usage.model, + ); + tokens.pricing_identity = Some(match tokens.pricing_identity.take() { + None => round_identity, + Some(Some(current)) if round_identity.as_ref() == Some(¤t) => Some(current), + Some(Some(_)) | Some(None) => None, + }); } } @@ -550,14 +599,31 @@ fn push_tool_results( /// Preserve buzz-agent's error taxonomy so the harness's JSON-RPC code mapping /// stays meaningful. -fn classify_provider_error(msg: &str) -> AgentError { - let lower = msg.to_lowercase(); - if lower.contains("auth") || lower.contains("401") { - 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()) +fn classify_provider_error(error: goose_provider_types::errors::ProviderError) -> AgentError { + use goose_provider_types::errors::ProviderError; + let message = error.to_string(); + match error { + ProviderError::Authentication(_) | ProviderError::NotConfigured => { + AgentError::LlmAuth(message) + } + // Goose has no model-not-found variant; providers commonly surface it + // as a 404 endpoint error, preserving Buzz's dedicated wire code. + ProviderError::EndpointNotFound(details) + if details.to_ascii_lowercase().contains("model") => + { + AgentError::LlmModelNotFound(message) + } + ProviderError::ContextLengthExceeded(_) + | ProviderError::RateLimitExceeded { .. } + | ProviderError::ServerError(_) + | ProviderError::NetworkError(_) + | ProviderError::RequestFailed(_) + | ProviderError::ExecutionError(_) + | ProviderError::UsageError(_) + | ProviderError::NotImplemented(_) + | ProviderError::EndpointNotFound(_) + | ProviderError::CreditsExhausted { .. } + | ProviderError::Refusal { .. } => AgentError::Llm(message), } } @@ -578,6 +644,47 @@ mod tests { crate::turn_state::TurnState::new("s".to_string(), std::path::PathBuf::from("/tmp"), None) } + fn usage( + model: &str, + input: i32, + output: i32, + total: Option, + read: Option, + write: Option, + ) -> goose::providers::base::ProviderUsage { + let mut provider_usage = goose_provider_types::conversation::token_usage::Usage::new( + Some(input), + Some(output), + total, + ) + .with_cache_tokens(read, write); + // `Usage::new` synthesizes a total when absent; tests need to exercise + // a provider response that genuinely omitted it. + provider_usage.total_tokens = total; + goose::providers::base::ProviderUsage::new(model.to_string(), provider_usage) + } + + #[test] + fn usage_keeps_cache_categories_separate_and_poisoned_totals_sticky() { + let mut tokens = crate::agent::TurnTokens::default(); + accumulate_usage( + &mut tokens, + &usage("gpt-test", 100, 10, Some(110), Some(20), Some(30)), + ); + accumulate_usage( + &mut tokens, + &usage("gpt-test", 120, 12, None, Some(25), Some(35)), + ); + accumulate_usage( + &mut tokens, + &usage("gpt-test", 140, 14, Some(154), Some(30), Some(40)), + ); + + assert_eq!(tokens.cached_input.exact_value(), Some(75)); + assert_eq!(tokens.cache_write.exact_value(), Some(105)); + assert_eq!(tokens.total, crate::types::TurnTotalState::Unknown); + } + /// The loop treats "applied but changed nothing" as a reason to stop /// looking. If an ignored effect reported progress the start machine would /// spin forever, and a real change that reported none would end the turn @@ -666,16 +773,20 @@ mod tests { #[test] fn provider_errors_keep_buzz_agents_taxonomy() { + use goose_provider_types::errors::ProviderError; + assert!(matches!( - classify_provider_error("401 Unauthorized"), + classify_provider_error(ProviderError::Authentication("expired".into())), AgentError::LlmAuth(_) )); assert!(matches!( - classify_provider_error("model gpt-9 not found"), + classify_provider_error(ProviderError::EndpointNotFound( + "model gpt-9 not found".into() + )), AgentError::LlmModelNotFound(_) )); assert!(matches!( - classify_provider_error("connection reset"), + classify_provider_error(ProviderError::NetworkError("connection reset".into())), AgentError::Llm(_) )); } diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs index b4ce9ff728a..a91c54d9993 100644 --- a/crates/buzz-agent/src/ops.rs +++ b/crates/buzz-agent/src/ops.rs @@ -294,6 +294,7 @@ impl Operation for BuzzStopVetoOperation { /// does not compel speech. pub struct BuzzReplyGuardOperation { max_nags: u32, + available_tools: std::collections::HashSet, } /// Reminder text. Kept byte-identical to main's. @@ -314,8 +315,11 @@ pub const MAX_REPLY_NAGS: u32 = 2; const NAGGED: &str = "nagged"; impl BuzzReplyGuardOperation { - pub fn new(max_nags: u32) -> Self { - Self { max_nags } + pub fn new(max_nags: u32, available_tools: impl IntoIterator) -> Self { + Self { + max_nags, + available_tools: available_tools.into_iter().collect(), + } } /// Whether a tool request is a recognised attempt to publish to Buzz. @@ -323,16 +327,14 @@ impl BuzzReplyGuardOperation { /// An *attempt*, not a success: a failed send already returns a non-zero /// exit and error JSON to the model, which is louder than this reminder. /// - /// Same matcher as main's `is_reply_shaped`, with one simplification the - /// port allows: main checked `mcp.has(name) && !mcp.is_hook(name)` first - /// because a hallucinated `fake__shell` would otherwise disarm the guard. - /// Here the conversation only contains requests goose actually dispatched, - /// so a hallucinated name never reaches this code. - fn is_reply_shaped(request: &ToolRequest) -> bool { + /// The available-tool check is load-bearing: provider output can contain a + /// hallucinated request that Goose later rejects at dispatch. Such a name + /// must not disarm the guard merely because it looks like a shell tool. + fn is_reply_shaped(&self, request: &ToolRequest) -> bool { let Ok(call) = &request.tool_call else { return false; }; - if !call.name.ends_with("__shell") { + if !self.available_tools.contains(call.name.as_ref()) || !call.name.ends_with("__shell") { return false; } call.arguments @@ -383,7 +385,7 @@ impl Operation for BuzzReplyGuardOperation { content, goose_provider_types::conversation::message::MessageContent::ToolRequest( request, - ) if Self::is_reply_shaped(request) + ) if self.is_reply_shaped(request) ) }) }); @@ -551,7 +553,7 @@ pub fn round_gate( outcome: Outcome, cancel: tokio_util::sync::CancellationToken, stop_veto: Option<(Arc, String)>, - require_reply: bool, + reply_guard_tools: Option>, ) -> goose::agents::state_machine::StateMachine<'static, Session, ConversationEffect> { use goose::agents::state_machine::Step; @@ -571,9 +573,10 @@ pub fn round_gate( // After the `_Stop` veto: on main both objections rode the same gate, and // a hook objection already keeps the turn alive, so reminding as well // would spend two rounds where main spent one. - if require_reply { + if let Some(available_tools) = reply_guard_tools { steps.push(Step::Operation(Arc::new(BuzzReplyGuardOperation::new( MAX_REPLY_NAGS, + available_tools, )))); } goose::agents::state_machine::StateMachine::new(steps, cancel) @@ -775,7 +778,7 @@ mod tests { #[tokio::test] async fn the_reply_guard_reminds_when_nothing_was_published() { - let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS, ["developer__shell".to_string()]); let result = op .run(&Session::default(), &conversation(1), &emitter()) .await @@ -791,7 +794,7 @@ mod tests { #[tokio::test] async fn a_publish_attempt_disarms_the_guard() { - let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS, ["developer__shell".to_string()]); for command in [ "buzz messages send --channel x --content hi", "buzz messages send-diff --channel x", @@ -814,7 +817,7 @@ mod tests { #[tokio::test] async fn an_unrelated_tool_call_does_not_disarm_the_guard() { - let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS, ["developer__shell".to_string()]); let result = op .run( &Session::default(), @@ -826,11 +829,25 @@ mod tests { assert!(matches!(result, OperationResult::Applied(_))); } + #[tokio::test] + async fn a_hallucinated_shell_does_not_disarm_the_guard() { + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS, ["real__shell".to_string()]); + let result = op + .run( + &Session::default(), + &turn_with(shell_request("buzz messages send --channel x --content hi")), + &emitter(), + ) + .await + .unwrap(); + assert!(matches!(result, OperationResult::Applied(_))); + } + #[tokio::test] async fn the_guard_stops_after_its_budget() { // Advisory, not compulsion: after MAX_REPLY_NAGS the turn ends whether // or not anything was published. - let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS); + let op = BuzzReplyGuardOperation::new(MAX_REPLY_NAGS, ["developer__shell".to_string()]); let mut nag = Message::user() .with_text(REPLY_GUARD_NAG) .with_visibility(false, true); diff --git a/crates/buzz-agent/src/provider.rs b/crates/buzz-agent/src/provider.rs index 483c1420c7d..0371e3607f3 100644 --- a/crates/buzz-agent/src/provider.rs +++ b/crates/buzz-agent/src/provider.rs @@ -33,7 +33,9 @@ use std::sync::Arc; use goose::providers::base::Provider; -use goose_providers::databricks_auth::{DatabricksAuth, DatabricksOauthTokenProvider}; +use goose_providers::databricks_auth::{ + DatabricksAuth, DatabricksOauthTokenProvider, DatabricksRefreshHook, +}; use goose_providers::databricks_v2::DatabricksV2Provider; use crate::types::AgentError; @@ -83,6 +85,20 @@ fn databricks_v2_with_buzz_oauth(host: String) -> Result, Agen _ => DatabricksAuth::oauth(host.clone()), }; + let oauth_token_source = match &auth { + DatabricksAuth::OAuth { .. } => Some( + buzz_model_catalog::auth::PkceOAuthTokenSource::new( + buzz_model_catalog::databricks_pkce_config(&host), + ) + .map_err(|e| AgentError::Llm(format!("databricks oauth: {e}")))?, + ), + DatabricksAuth::Token(_) => None, + }; + let oauth_token_provider = oauth_token_source + .as_ref() + .map(|source| buzz_oauth_token_provider(Arc::clone(source))); + let refresh_hook = oauth_token_source.map(buzz_oauth_refresh_hook); + let retry_config = DatabricksV2Provider::load_retry_config(|key| std::env::var(key).ok()); let provider = DatabricksV2Provider::new( @@ -90,7 +106,7 @@ fn databricks_v2_with_buzz_oauth(host: String) -> Result, Agen auth, retry_config, None, - Some(buzz_oauth_token_provider()), + oauth_token_provider, Some(Arc::new(|| { std::env::var("DATABRICKS_TOKEN") .ok() @@ -99,7 +115,7 @@ fn databricks_v2_with_buzz_oauth(host: String) -> Result, Agen // Keeps the `agent-session-id` request header goose attaches for // provider-side attribution; dropping it would silently lose that. Some(goose::session_context::session_id_request_builder()), - None, + refresh_hook, ) .map_err(|e| AgentError::Llm(format!("databricks provider: {e}")))?; @@ -113,14 +129,13 @@ fn databricks_v2_with_buzz_oauth(host: String) -> Result, Agen /// request on a headless agent subprocess, where the browser step would hang /// with nobody to see it. Interactive login stays where a human can answer it /// — `buzz-agent auth databricks`, and the desktop model picker. -fn buzz_oauth_token_provider() -> DatabricksOauthTokenProvider { - Arc::new(|host, _client_id, _redirect_url, _scopes| { +fn buzz_oauth_token_provider( + source: Arc, +) -> DatabricksOauthTokenProvider { + Arc::new(move |_host, _client_id, _redirect_url, _scopes| { + let source = Arc::clone(&source); Box::pin(async move { use buzz_model_catalog::auth::TokenSource; - let source = buzz_model_catalog::auth::PkceOAuthTokenSource::new( - buzz_model_catalog::databricks_pkce_config(&host), - ) - .map_err(|e| anyhow::anyhow!("{e}"))?; source .bearer_no_browser() .await @@ -129,6 +144,15 @@ fn buzz_oauth_token_provider() -> DatabricksOauthTokenProvider { }) } +/// Goose's refresh hook is synchronous, so mark the rejected access token in +/// shared state and let the next async token-provider call force-refresh it. +/// This keeps the refresh grant headless and avoids blocking a runtime worker. +fn buzz_oauth_refresh_hook( + source: Arc, +) -> DatabricksRefreshHook { + Arc::new(move || source.reject_current_bearer()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 83b23777f6c..33119e5abae 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -182,6 +182,43 @@ pub struct PricingIdentity { pub cache_class: Option, } +/// Per-turn and per-session accumulator for one optional cache category. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CacheTotalState { + #[default] + Unseen, + Exact(u64), + Unknown, +} + +impl CacheTotalState { + pub fn fold(self, value: Option) -> Self { + match (self, value) { + (Self::Unknown, _) | (_, None) => Self::Unknown, + (Self::Unseen, Some(n)) => Self::Exact(n), + (Self::Exact(acc), Some(n)) => acc.checked_add(n).map_or(Self::Unknown, Self::Exact), + } + } + + pub fn merge_session(self, turn: Self) -> Self { + match (self, turn) { + (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown, + (acc, Self::Unseen) => acc, + (Self::Unseen, Self::Exact(n)) => Self::Exact(n), + (Self::Exact(acc), Self::Exact(n)) => { + acc.checked_add(n).map_or(Self::Unknown, Self::Exact) + } + } + } + + pub fn exact_value(self) -> Option { + match self { + Self::Exact(n) => Some(n), + Self::Unseen | Self::Unknown => None, + } + } +} + /// Tri-state accumulator for provider-reported total tokens within one ACP turn. /// /// - `Unseen`: no usage-bearing response observed yet (initial state for each turn). diff --git a/crates/buzz-model-catalog/src/auth.rs b/crates/buzz-model-catalog/src/auth.rs index a78a499bdd1..99cb1dd5d03 100644 --- a/crates/buzz-model-catalog/src/auth.rs +++ b/crates/buzz-model-catalog/src/auth.rs @@ -18,6 +18,7 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -139,6 +140,9 @@ pub struct PkceOAuthTokenSource { /// Single-flight guard: only one refresh/browser flow at a time, even /// if many tool calls land concurrently. state: Mutex>, + /// Set synchronously by provider retry hooks after a 401. The next async + /// bearer request consumes it and refreshes the currently cached token. + current_bearer_rejected: AtomicBool, } impl PkceOAuthTokenSource { @@ -154,9 +158,19 @@ impl PkceOAuthTokenSource { http: Client::new(), cache_path, state: Mutex::new(initial), + current_bearer_rejected: AtomicBool::new(false), })) } + /// Mark the current access token as rejected by the provider. + /// + /// This synchronous edge is designed for transport retry hooks. No I/O is + /// performed here; the next [`TokenSource::bearer_no_browser`] call does + /// the refresh under the source's async single-flight lock. + pub fn reject_current_bearer(&self) { + self.current_bearer_rejected.store(true, Ordering::Release); + } + /// Discover authorization + token endpoints from the well-known URL. async fn endpoints(&self) -> Result { let v: Value = self @@ -369,6 +383,20 @@ impl PkceOAuthTokenSource { pub(crate) async fn try_bearer_no_browser(&self) -> Result { let mut state = self.state.lock().await; + // A provider retry hook reported that the locally-fresh access token + // was rejected. Refresh it unconditionally instead of trusting expiry. + if self.current_bearer_rejected.swap(false, Ordering::AcqRel) { + let rejected = state + .as_ref() + .map(|token| token.access_token.clone()) + .or_else(|| read_cache(&self.cache_path).map(|token| token.access_token)) + .ok_or_else(|| { + AgentError::LlmAuth("token rejected and no cached token available".into()) + })?; + drop(state); + return self.refresh_now(&rejected).await; + } + // 1. In-memory cache hit, still fresh. if let Some(tok) = state.as_ref() { if !is_expired(tok) { @@ -986,6 +1014,34 @@ mod tests { ); } + #[tokio::test] + async fn rejected_current_bearer_does_not_reuse_a_locally_fresh_token() { + let dir = tempfile::tempdir().unwrap(); + let cfg = PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }; + let source = PkceOAuthTokenSource::new(cfg).unwrap(); + let future_expiry = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600; + *source.state.lock().await = Some(CachedToken { + access_token: "rejected-but-locally-fresh".into(), + refresh_token: None, + expires_at: Some(future_expiry), + }); + + source.reject_current_bearer(); + let result = source.try_bearer_no_browser().await; + + assert!(matches!(result, Err(AgentError::LlmAuth(_)))); + } + /// `try_bearer_no_browser` with an empty cache and no refresh token must /// return `LlmAuth` immediately — it must NOT attempt OIDC discovery even /// when the `discovery_url` is unreachable/invalid. This guards the diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index ce11c1de4bd..4ec529ba064 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -120,6 +120,8 @@ run_unit_tests() { # `just test-unit` — the two lists must stay in step. run_test_step "buzz-agent unit tests" \ cargo test -p buzz-agent --lib -- --nocapture + + run_test_step "buzz-model-catalog unit tests" \ cargo test -p buzz-model-catalog --lib -- --nocapture } From 2ea414f0b3ac26b49e89281dae4fdcaafd097fbf Mon Sep 17 00:00:00 2001 From: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> Date: Fri, 21 Aug 2026 16:46:18 +1000 Subject: [PATCH 51/51] test(agent): serialize provider environment Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz> --- crates/buzz-agent/src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 3641a4c51a8..7dcc1f1a70b 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -577,6 +577,7 @@ mod tests { /// correctly. #[test] fn the_agents_configured_provider_beats_an_inherited_goose_provider() { + let _guard = env_lock(); // One test: `project_goose_env` mutates process-global env, so // splitting these would race the rest of the suite. let restore: Vec<(&str, Option)> = [