diff --git a/rust/.gitea/workflows/agent-runtime-ci.yml b/rust/.gitea/workflows/agent-runtime-ci.yml new file mode 100644 index 000000000..cab6b8f43 --- /dev/null +++ b/rust/.gitea/workflows/agent-runtime-ci.yml @@ -0,0 +1,88 @@ +name: Agent Runtime CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_INCREMENTAL: '0' + CARGO_NET_RETRY: '10' + CARGO_TERM_COLOR: always + RUSTUP_AUTO_INSTALL: '0' + +jobs: + rust: + name: Rust workspace + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Prepare isolated temporary directory + shell: bash + run: | + mkdir -p "$HOME/data/tmp" + echo "TMPDIR=$HOME/data/tmp" >> "$GITHUB_ENV" + # rust-toolchain.toml 固定编译器和组件;runner 镜像需预装它们, + # 这里先失败得更明确,不让后续 Clippy 步骤才暴露环境缺口。 + - name: Verify pinned Rust toolchain + shell: bash + run: | + rustc --version + cargo fmt --version + cargo clippy --version + - name: Check formatting + run: cargo fmt --all -- --check + - name: Check warnings and targets + run: RUSTFLAGS='-D warnings' cargo check --locked --workspace --all-targets --all-features + - name: Check no-default-features build + run: RUSTFLAGS='-D warnings' cargo check --locked --workspace --all-targets --no-default-features + # The workspace compatibility matrix includes the SQLite service through + # Host. Test the portable Runtime package in isolation so this gate + # proves the physical dependency boundary rather than relying on feature + # unification in the full workspace. + - name: Check portable Runtime without SQLite + shell: bash + run: | + if cargo tree --locked --no-default-features -p agent-runtime -e normal \ + | grep -Eiq 'agent-storage-sqlite|rusqlite|libsqlite3-sys'; then + echo 'portable agent-runtime unexpectedly contains SQLite' >&2 + exit 1 + fi + RUSTFLAGS='-D warnings' cargo check --locked -p agent-runtime --all-targets --no-default-features + cargo test --locked -p agent-runtime --all-targets --no-default-features --no-fail-fast + RUSTDOCFLAGS='-D warnings' cargo doc --locked -p agent-runtime --no-default-features --no-deps + - name: Verify kernel dependency boundary + shell: bash + run: | + ./scripts/check-dependencies.sh Cargo.toml + - name: Verify package manifests + shell: bash + run: | + ./scripts/check-package-manifests.sh Cargo.toml + # cargo-audit 和 RustSec advisory DB 由 runner 预装/挂载;本步骤刻意不 + # 执行 cargo install、git fetch 或其它联网更新。可用 CARGO_AUDIT_BIN + # 指向 runner 固定版本的二进制,并必须提供 RUSTSEC_ADVISORY_DB。 + - name: Run offline cargo-audit + shell: bash + run: ./scripts/run-cargo-audit.sh + - name: Verify independent workspace copy + shell: bash + run: ./scripts/verify-independent-workspace.sh + - name: Run tests + run: cargo test --locked --workspace --all-features --no-fail-fast + - name: Run no-default-features tests + run: cargo test --locked --workspace --no-default-features --no-fail-fast + - name: Run deterministic agent test set + shell: bash + run: ./scripts/run-agent-test-set.sh --quick + - name: Build documentation without warnings + run: RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps + - name: Run Clippy policy gate + run: cargo clippy --locked --workspace --all-features --all-targets -- -D warnings + - name: Run no-default-features Clippy policy gate + run: cargo clippy --locked --workspace --no-default-features --all-targets -- -D warnings + - name: Run portable Runtime Clippy policy gate + run: cargo clippy --locked -p agent-runtime --no-default-features --all-targets -- -D warnings diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 000000000..e41cb536e --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,8 @@ +/target/ +*.db +*.db-shm +*.db-wal +.env +agent.toml +.tmp-test/ +.tmp-cli.*/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 000000000..a85280a5d --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1857 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agent-app" +version = "0.1.0" +dependencies = [ + "agent-codex", + "agent-mcp", + "agent-provider-openai", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "agent-cli" +version = "0.1.0" +dependencies = [ + "agent-app", + "agent-codex", + "agent-host", + "agent-mcp", + "agent-provider-openai", + "agent-runtime-core", + "agent-runtime-engine", + "agent-skills", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "agent-codex" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-host" +version = "0.1.0" +dependencies = [ + "agent-codex", + "agent-mcp", + "agent-provider-fake", + "agent-provider-openai", + "agent-runtime-core", + "agent-runtime-engine", + "agent-runtime-sqlite", + "agent-skills", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-mcp" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "agent-provider-fake" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "serde", + "serde_json", +] + +[[package]] +name = "agent-provider-openai" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "reqwest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-runtime" +version = "0.1.0" +dependencies = [ + "agent-runtime-contracts", + "agent-runtime-core", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-runtime-contracts" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "serde_json", +] + +[[package]] +name = "agent-runtime-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "agent-runtime-engine" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-runtime-orchestration" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-runtime-sqlite" +version = "0.1.0" +dependencies = [ + "agent-runtime", + "agent-runtime-contracts", + "agent-runtime-core", + "agent-storage-sqlite", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "agent-skills" +version = "0.1.0" +dependencies = [ + "agent-runtime-core", + "serde", + "serde_json", +] + +[[package]] +name = "agent-storage-sqlite" +version = "0.1.0" +dependencies = [ + "agent-runtime-contracts", + "agent-runtime-core", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 = "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 = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "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 = "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.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "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 = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[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", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[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-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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 = "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 = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +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" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 000000000..fb03115ed --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +resolver = "2" +members = [ + "crates/agent-runtime-core", + "crates/agent-runtime-contracts", + "crates/agent-runtime-engine", + "crates/agent-runtime", + "crates/agent-runtime-sqlite", + "crates/agent-provider-openai", + "crates/agent-provider-fake", + "crates/agent-codex", + "crates/agent-runtime-orchestration", + "crates/agent-storage-sqlite", + "crates/agent-mcp", + "crates/agent-skills", + "crates/agent-app", + "crates/agent-host", + "crates/agent-cli", +] + +[workspace.package] +edition = "2024" +version = "0.1.0" +rust-version = "1.96" +license = "UNLICENSED" + +[workspace.dependencies] +agent-runtime-core = { path = "crates/agent-runtime-core", version = "0.1.0" } +agent-runtime-contracts = { path = "crates/agent-runtime-contracts", version = "0.1.0" } +agent-runtime-engine = { path = "crates/agent-runtime-engine", version = "0.1.0" } +agent-runtime = { path = "crates/agent-runtime", version = "0.1.0" } +agent-runtime-sqlite = { path = "crates/agent-runtime-sqlite", version = "0.1.0" } +agent-provider-openai = { path = "crates/agent-provider-openai", version = "0.1.0" } +agent-provider-fake = { path = "crates/agent-provider-fake", version = "0.1.0" } +agent-codex = { path = "crates/agent-codex", version = "0.1.0" } +agent-runtime-orchestration = { path = "crates/agent-runtime-orchestration", version = "0.1.0" } +agent-storage-sqlite = { path = "crates/agent-storage-sqlite", version = "0.1.0" } +agent-mcp = { path = "crates/agent-mcp", version = "0.1.0" } +agent-skills = { path = "crates/agent-skills", version = "0.1.0" } +agent-app = { path = "crates/agent-app", version = "0.1.0" } +agent-host = { path = "crates/agent-host", version = "0.1.0" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +toml = "0.8" +thiserror = "2" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 000000000..2f6251d23 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,634 @@ +# agent-runtime + +一个与具体业务项目解耦的 Rust Agent 内核和单 Agent 主程序。 + +原始 P0–P6 建设计划与逐项状态见 +[`docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md`](docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md)。本 README 只描述当前已实现的子集,不能作为完整计划的替代。 + +当前消息持久化按 Host 执行尝试内的已提交事件位置衔接 checkpoint 与 trace,避免正常工具 +完成、连续工具和审批恢复重复写入同一消息;压缩前先提交旧上下文的工具结果。集成回归逐条比较 +Engine 输出与 Runtime 消息,并从空快照重放事件;Fake CLI smoke 也会重新打开 SQLite 只读核验消息。 +公开许可证、registry、自动 webhook、跨主机调度及全量 Codex schema 不属于本期完成门槛, +以权威计划「原始范围复核」为准,不采用下方历史增量中的扩大范围表述。 + +当前仓库先保证一个最小闭环:中立运行时契约、已提交边界内可重放的事件状态、模型与工具循环、SQLite durable Runtime,以及可替换的 MCP/Skill/Provider 适配器。Codex 外部 backend 和 DAG 编排基础已经作为独立库提供;编排器目前包含有界的配额、消息去重、节点隔离/修复和可选的任务图/协调器原子快照,但真实 Codex wire、HTTP 服务和持久化的完整多 Agent 调度仍不进入内核依赖。 + +独立 workspace 的工具链由 [`rust-toolchain.toml`](./rust-toolchain.toml) 固定为 +Rust 1.96,并声明 `rustfmt` 与 `clippy` 组件;独立 CI runner 需要在执行 workflow +前预装同一工具链和组件。这样 `cargo check`、测试、格式化与 Clippy 使用同一编译器, +不会依赖父仓库的工具链配置。 + +workspace 内部 path 依赖带有 `0.1.0` 版本要求,便于后续按依赖顺序发布到私有 +registry;当前仍未发布独立远程仓库,`cargo package --list` 只作为本地 manifest +预检,不代表 registry 中已经存在这些 crate。 + +`scripts/check-package-manifests.sh` 使用离线 `cargo package --list --no-verify` +检查 15 个 crate 的 Rust 版本、描述、license、内部 path 依赖和待发布文件边界; +未显式设置 `CARGO_TARGET_DIR` 时,临时 target 放在 `~/data/tmp`(可用 +`AGENT_PACKAGE_TMPDIR` 覆盖)并在退出时清理。它不会联网、上传 crate 或改变 registry。 +依赖其它内部 crate 的完整 +`cargo package` 校验必须在目标 registry 按依赖顺序发布后进行。 + +依赖边界可用 `./scripts/check-dependencies.sh Cargo.toml` 离线复核;发布前的 +manifest/打包边界可用 `./scripts/check-package-manifests.sh Cargo.toml` 复核;它们检查 +workspace path 依赖、`agent-runtime-core` 的反向依赖和 `agent-runtime-contracts` 的 +适配器黑名单,不替代带漏洞数据库的 +`cargo-audit`/`cargo-deny` 报告。审计范围和独立 workspace 复制验收见 +[`docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md`](docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md)。 +该脚本还单独检查 portable `agent-runtime --no-default-features` 不带 SQLite,并确认 +`agent-runtime-sqlite` 只沿 `agent-runtime + agent-storage-sqlite` 单向装配。 + +## 2026-09-04 当前增量 + +- SQLite RuntimeStore 从空快照重放完整 event history,校验 revision 连续性与事件语义; + event log 缺失、断档或语义篡改均 fail-closed。新增 + `runtime_store_rejects_event_history_gap_on_load_and_commit`;Storage all-features + 5+45=50,直接 no-default 4+36=40。 +- Skill discovery/list 从 discovery 阶段即受 `max_body_bytes` 硬上限,正文超限返回 + `BodyTooLarge`;新增 `discovery_and_list_reject正文超过配置上限`,Skill 当前 28。 +- Codex process backend runtime event bridge 显式映射 request→notification→result,并 + 保留显式 handler 变体;新增 + `app_server_process_backend_runtime_event_bridge_maps_notification_order`,Codex 当前 81。 +- 原始 P0–P6 仍部分完成;根 Vitest 的 `3189/3189` 为历史记录,本轮工作区未安装 + `vitest`,尝试以退出码 127 结束,未计入当前通过项。 + +## 2026-09-05 当前增量 + +- Core 扩展值对象和外部端口(`ToolBinding`、`SkillDefinition`、`SkillActivation`、 + `AgentDescriptor`、`BackendRequest`、`BackendResult`、`ToolContext`)在公开 serde/ + 兼容入口复验;`backend_result_as_tool` 拒绝身份错配和未知副作用,Core 当前为 + 30 个单元、20 个 `core_contracts` 集成、1 个 `tool_context_contracts` 集成和 1 个 doctest。 +- Engine 在调用 `ContextSource` 前验证 `ContextRequest`;Host/Engine 在工具、Skill、MCP + 和外部 backend dispatch 前验证上下文与调用合同。Runtime/Storage/Host 提供固定排序、 + 状态/run 过滤和硬上限的只读 `list_external_sessions`,不会自动对账或重放。 +- `agent-runtime` 现在只包含 portable `DurableRuntime`、`RuntimeSnapshotService`、 + `WorkerLease`/`RunHandle` 与中立 `DurableStore` 合同;SQLite-specific `RuntimeService`、 + records、错误和跨表事务已迁移到 `agent-runtime-sqlite`。后者依赖 + `agent-runtime` + `agent-storage-sqlite`,不反向污染 portable runtime。 +- `agent-runtime-sqlite` 的 `RuntimeService` 通过 `SqliteDurableStore` 提供 + `prepare_run*`、run/session 查询、claim/heartbeat/release、checkpoint fencing、approval + pending-only CAS、external-session 候选、request-cancel/stale 扫描和 runtime-aware + finish/recovery command;Host 统一从该 crate 装配,未保留 `agent_runtime::RuntimeService` + 平行 re-export。 +- `DurableRuntime` 提供不绑定 SQLite 的拥有式控制面 facade;可直接注入、 + 借用或取回任意 durable adapter。SQLite convenience API 位于 + `agent-runtime-sqlite::RuntimeService`,不复制连接或状态。 +- Runtime 测试还提供 `cfg(test)` 的 `InMemoryDurableStore` contract harness,实际覆盖 + bundle、lease、snapshot CAS、safe requeue、finish 和 expired recovery;它只证明泛型 facade + 可接入非 SQLite adapter,不改变生产构建。 +- Host 的工具请求/结果现在写入 `tool_calls` durable 表,支持按 run 查询、JSONL 导出和 + 相同 identity 幂等重试;请求/结果与对应 Core runtime snapshot/events 通过 + `DurableToolCallRuntimeCommit` 在 SQLite 单事务中提交,checkpoint 仍保持独立。 +- 当调用方已同时拥有工具结果和 Engine 游标时,可使用 + `DurableToolCallCheckpointRuntimeCommit`;`DurableStore`/`RuntimeService` 会把工具行、 + checkpoint 与 Core runtime snapshot/events 在同一个 SQLite `IMMEDIATE` 事务中提交, + 并以 lease/CAS 失败整体回滚。Host 的 `ToolCompleted` trace 在已有 checkpoint 时已复用 + 该边界;Host 对首次 `awaiting_approval` checkpoint 已把 `ToolRequested`、工具行、 + checkpoint 和 Core runtime event 放进同一事务,后续普通 checkpoint 与终态仍保持各自 + 边界,不能把这条窄接线误读成全链路全局事务。 +- 最新隔离门禁:all/no-default workspace 测试、check、Clippy `-D warnings`、rustdoc、 + fmt、Storage 直接 no-default、依赖/manifest、独立复制、Fake 2/2、能力 10/10、shell、 + 编码和 `git diff --check` 均通过;Storage 为 all-features 7+53、直接 no-default 6+36, + Runtime portable 8 + Runtime SQLite 31、Host 80(另有 7 个消息持久化集成测试)、Engine 55、Codex 101;编排、Host、 + Skill、SQLite Storage 测试默认回退 `~/data/tmp`,显式 `TMPDIR` 仍可覆盖。 +- 原始 P0–P6 的本地可交付出口已逐项通过;整体交付仍保留独立远端 CI/registry/许可证、 + 真实 Provider/Codex session、自动外部对账/订阅以及真实 Codex generated wire 的外部证据边界。 + +## 2026-09-06 当前增量 + +- `CodexSessionMetadataSink` 增加向后兼容的 `persist_lifecycle` 扩展;ProcessBackend 在 + request 成功、post-dispatch 失败和匹配 cancel 收束后发送中立生命周期观察,Host sink + 通过 read/merge/write 更新 `external_sessions`,保留 custom audit 字段并防止 terminal + 状态复活。 +- 新增 `agent-runtime-contracts` crate,承接 DurableStore command/view/trait;`agent-runtime` + 已完成 portable 化;SQLite-specific Service/adapter 位于独立的 + `agent-runtime-sqlite` crate,最终依赖树不再把 SQLite 带入 `agent-runtime`。 +- 长连接 `thread/start`/`turn/start` accepted response 在 child 仍存活时记录为 `active`, + `ProcessControl` 可读取已回收 child 的退出码;进程级 sink 支持一次性自然退出、显式终止、 + cancel/timeout/drop 观察,并可由 session sink 转发。 +- Codex lifecycle fixture 与 Host merge/terminal 回归通过(含 + `app_server_process_lifecycle_sink_reports_timeout`、 + `app_server_process_lifecycle_sink_distinguishes_reader_eof`);当前定向 Codex 101、Host 80、MCP + 52,Clippy `-D warnings` 通过。协议级 turn interrupt 和真实 generated wire/session + 仍未验收。 +- `JsonRpcAppServerRouter` 提供独立的有界 pending map、后台 reader、乱序 response + 分发和并发 `turn/interrupt` transport 接缝;`CodexAppServerProcessRouter` 将其接入 + ProcessControl 管理的真实 stdio child,但不猜测具体 Codex wire。 +- Host `from_host` 构造的 Codex 中立/0.152.1 typed handler 复用同一 Runtime 的 + `tool_calls`:已完成 call_id 返回缓存,in-flight 重复拒绝,工具错误写入 `error` 终态; + 直接 `new` 构造仍不要求 SQLite。 +- 后台 queue metadata 会在 worker claim 前核对带 `providerKind` marker 的 persisted model; + 模型错配保持 queued 且不触发 Provider,legacy provider-only metadata 继续兼容。 + +## 2026-09-04 当前门禁增量 + +queued 取消的最终条件由 SQLite `RunFinishGuard::QueuedUnclaimed` 在同一 +`BEGIN IMMEDIATE` 事务内复核;领取竞争会返回明确冲突并由 Host 发出 cooperative +cancel,不会把仍持有 lease 的 run 写成 cancelled。Codex JSONL channel 对空白 keep-alive +行采用循环跳过,连续 8192 行空帧有回归测试。当前 Runtime/Host/Codex/Storage 计数和 +完整 P0–P6 状态以 [`docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md`](docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md) +及最新验收记录为准;远端仓库/CI、真实 Provider/Codex wire/session 和自动外部恢复仍 +未完成,Runtime/SQLite 物理拆分已由 `agent-runtime-sqlite` 完成。 + +Runtime-only snapshot/event 还提供公开 `DynRuntimeStore` newtype,可承载 + `Box` 并注入 `RuntimeSnapshotService`;`agent-runtime-sqlite::RuntimeService::snapshot_store()` + 还可把同一 SQLite 状态以该窄 facade 暴露给调用方;它保留 typed CAS/error, +不把 SQLite durable run/session/lease/checkpoint 伪装成已经可替换。 + +上下文压缩器被视为不可信扩展:Engine 会拒绝其返回的 `Tool` 角色或结构化 +`ToolCall`/`ToolResult`,避免下一次 Provider 请求伪造工具历史。Core 的默认 +`ModelProvider::stream` 在只有 `complete` 实现时也会保留 tool-call delta 和 usage +事件,流式观察不会静默丢失结构化结果;压缩 Provider 响应的 content 中出现结构化 +工具块也会直接拒绝,不会静默过滤。 + +清理构建目录后可用临时数据库直接验证程序入口: + +```bash +mkdir -p "$HOME/data/tmp" +AGENT_DB="$HOME/data/tmp/agent.db" cargo run --locked -p agent-cli -- run "hello" +``` + +该命令默认选择 Fake Provider,完成一次工具调用闭环;真实 OpenAI 请求仍需显式配置 +凭据和 endpoint。 + +真实 LLM 能力评测(固定输入、最终输出和流式轨迹断言)使用: + +```bash +./scripts/run-agent-llm-eval.sh --real --from-codex-config +``` + +该命令显式读取当前 Codex 配置并产生真实请求;默认回归不会联网。 + +## 2026-09-03 继续执行的外部会话边界(历史快照) + +Codex App Server 可通过 `CodexAppServerBackend::invoke_node_with_runtime_events` 显式 +把 request、事件和结果映射成 Core `RuntimeEvent`;调用方负责把 sink 事件交给 reducer +或 RuntimeStore,未知事件和未知副作用不会被静默完成。Host 外部工具桥在重开进程后可 +通过 `AgentHost::cancel_external_request(tool_name, request_id)` 查 durable request-id +别名并调用 backend cancel;没有记录时才执行兼容的无归属 cancel,成功/失败状态仍需按 +`cancelled`/`unknown` 和显式 reconciliation 处理。 + +本轮后 Codex 为 78 个测试、Host 为 62 个测试,Engine 为 52 个测试,CLI 为 23 个测试, + Runtime 默认特性为 27 个测试(package-only no-default-features 为 6 个),OpenAI 为 29 个测试; +Storage 为 all-features 5+44、直接 no-default-features 4+36;运行时快照篡改回归与相关 all/no-default 回归和静态门禁通过。原始 P0–P6 仍为 +“部分完成”:真实 Codex wire/session、自动外部对账、远端仓库/CI、registry/许可证策略、 +完整持久化多 Agent 调度和最终 Host/Runtime 拆分不由本地 fixture 代替。 + +## 2026-09-03 最新复核(历史快照) + +当时本地实现的测试计数为:Engine 51、Host 61、MCP 47、CLI 23、Codex 78、Runtime 27 +(package-only no-default-features 为 6)、Orchestration 38、Skill 27、OpenAI 29、Fake 4;Core 为 25 个单元测试 + 16 个集成测试, +Storage 为 all-features 的 5 个单元测试 + 43 个集成测试(48),以及直接 +no-default-features 的 4 个单元测试 + 36 个集成测试(40)。 + +MCP resources/prompts 只会在调用方通过显式 selection 选择后注入上下文;这些外部上下文 +保持不可信,Engine 在模型边界把不可信的 system/developer/assistant 角色降级为 User,避免 +外部文本获得高权限角色;其中结构化 tool-call/tool-result 也会渲染为普通 User 文本,不能 +伪造工具历史。Engine 对 `ProviderResponse.content` 只接受 `Text`/`Image`,结构化 +tool call/result 必须分别走 `tool_calls` 或工具结果回填路径。Host 的 +`AgentHost::with_runtime` 可注入已经装配好的 `RuntimeService`,Host 与调用方共享同一 +durable 控制面,不会重新打开或平行持有 SQLite。Host 的 `CodexHostServerRequestHandler` 处理 +`item/tool/call`,在审批或执行前校验已注册工具的 JSON Schema,再使用 Host 的审批策略和 +ToolRouter;它是同步的低层桥接,不创建 durable approval/checkpoint/audit,`Ask` 以 +JSON-RPC error 返回。另有明确命名的 `codex_01521_server_request_handler`,将已核对的 +0.152.1 `tool`/`callId` 请求转换为 `contentItems`/`success` typed response;它仍只覆盖 +dynamic-tool 子集。需要持久化审批和恢复时仍应走 Engine/Runtime 执行路径。 + +Host 已提供显式 `NamespaceToolResolver` 端口和 `StaticNamespaceToolResolver` 映射表。 +默认未注入映射时,中立和 0.152.1 typed bridge 对显式非 `null` namespace 仍在 +approval/execution 前 fail-closed;调用方显式注册 `(namespace, tool) -> registered_tool` +后才会继续走同一套工具定义、Schema 和审批校验。缺省或 JSON `null` namespace 仍按全局 +工具名处理,Host 不猜测分隔符或静默改写工具名。 + +MCP stdio 同步 client 等待响应时暂存的 pending 消息队列也有硬上限 +(`MAX_STDIO_PENDING_MESSAGES`,当前为 4096);溢出返回协议错误,不会把跨请求的通知 +积累成无界内存。 + +MCP initialize 在发送 `notifications/initialized` 前会校验服务端返回的 +`protocolVersion`;默认候选是 `2025-06-18`,调用方可配置一个有界候选列表。明确版本拒绝时, +只有带可重建配置的连接会创建新 transport 尝试下一个版本;缺失、非字符串或未知版本会保持 +连接未初始化,工具调用不会自动重放。 +Skill loader 的 frontmatter 仍是轻量、有界的行式子集,但现在会拒绝未闭合引号/括号、空列表项 +和空工具名,避免畸形元数据被静默解释。需要在同一进程内让多个 Runtime facade 共享一个 +存储实例时,可使用 Core 的 `SharedRuntimeStore`;它只提供 `Arc>` 同步和 +`Unavailable` 锁错误映射,不改变 `RuntimeStore` trait 或跨进程语义。 + +通用 `CodexAppServerBackend` 的同步 channel 若需要在阻塞 invoke 期间中断,可显式使用 +`with_interrupt_hook` 注入独立 control transport;hook 不获取 channel mutex,也不自动 +修改 Host/Runtime 状态。未配置独立 transport 时仍使用 channel 自带的串行 interrupt, +因此不能把该 opt-in 接线当成真实 Codex control wire 或强制取消实现。 + +P4 还提供随 crate 分发的本地 fixture:`agent-mcp/fixtures/stdio-jsonrpc-server.sh` 可用于 +stdio 握手、工具发现和调用回归;`agent-skills/fixtures/skills/review/SKILL.md` 与非法 +frontmatter fixture 用于 metadata-first/显式激活测试。fixture 只返回固定数据,不读取密钥 +或执行外部脚本。 + +编排 JSON 文件快照的 revision CAS 通过同目录 sidecar advisory lock 保护跨进程写入; +该锁只覆盖本机文件临界区,不是跨主机锁,也不会启动自动 scheduler。 + +原始 P0–P6 仍全部为“部分完成”。尚未有证据的出口包括独立远程仓库/CI、registry 与正式 +许可证策略、真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、 +跨主机协调,以及最终 Host/Runtime 拆分。 +上下文、提示词和 Skill 元数据的确定性边界见 +[`docs/【审计】上下文提示词与Skill边界-2026-09-03.md`](docs/【审计】上下文提示词与Skill边界-2026-09-03.md)。 + +## 离线依赖漏洞审计 + +`scripts/run-cargo-audit.sh` 是 CI 和本地共用的离线 wrapper。它从脚本自身位置 +解析 workspace 根目录,因而不会因为调用者的当前目录而误审其它 `Cargo.lock`; +workspace manifest/锁文件、advisory DB 或工具缺失时会直接失败。runner 必须预先 +提供本地 RustSec advisory-db checkout(`RUSTSEC_ADVISORY_DB`),并预装固定版本的 +`cargo-audit`;需要指定可执行文件时设置 `CARGO_AUDIT_BIN`: + +```bash +RUSTSEC_ADVISORY_DB=/path/to/advisory-db \ + CARGO_AUDIT_BIN=/path/to/cargo-audit \ + ./scripts/run-cargo-audit.sh +``` + +脚本实际调用 `cargo-audit audit --no-fetch --db --file Cargo.lock`,不会 +安装工具、联网拉取或改写 advisory DB。仓库已用 fake binary 自测参数、工作目录 +及缺失输入;2026-09-04 另用隔离临时目录安装的 `cargo-audit 0.22.2` 与 RustSec advisory-db +提交 `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 完成真实离线扫描(1239 条 advisory、 +188 个锁定依赖,退出码 0,无漏洞或 warning)。临时工具和数据库已清理;独立远端 +CI 仍需由 runner 提供固定输入并留下运行记录。 + +## 快速开始 + +```bash +# 从 Genarrative-master 根目录进入独立 workspace;这样 agent.db 也会留在这里。 +cd rust +cargo test --workspace +cargo run -p agent-cli -- "把这句话复述一遍" +AGENT_DB=agent.db cargo run -p agent-cli -- run --background "后台执行一个任务" +# 设置 OPENAI_API_KEY 后,CLI 会切换到 OpenAI Responses +OPENAI_API_KEY=... cargo run -p agent-cli -- run "回答一个问题" + +# OpenAI-compatible 网关:base URL 会自动补成 /responses +OPENAI_BASE_URL=https://your-gateway.example/v1 \ +OPENAI_API_KEY=... \ +cargo run -p agent-cli -- run "通过网关回答" + +# 面向脚本的逐行 JSON 输出(前台最后一行 type=result) +AGENT_PROVIDER=fake cargo run -p agent-cli -- run --no-stream --jsonl "脚本任务" +``` + +`run --jsonl` 只改变 CLI 的呈现格式,不改变 SQLite 持久化或默认的 pretty JSON +输出。前台 run 在 Host 返回后将已收集的事件按顺序编码为 NDJSON:每个 +Engine 事件一条 `type=engine_event`,每个 Provider 流事件一条 +`type=stream_event`,最后一条 `type=result` 携带完整 `HostRunOutput`;每行都带 +`session_id`、`run_id` 和 `runtime_id`。因此它是稳定的完成后批次,不是实时流日志。 +后台 `run --background --jsonl` 只输出一条 `type=queued`(含 worker PID 和运行 +身份),worker 的最终结果需另用 `inspect` 或 `export` 查询。 + +## 测试集与真实 Provider smoke + +仓库提供一组可重复的 Agent 回归用例,先用 Fake Provider 验证工具闭环、流式路径、 +SQLite 持久化和 JSONL 导出,再按需追加一次真实 Provider 请求: + +```bash +./scripts/run-agent-test-set.sh # Cargo 测试 + 离线测试集 +./scripts/run-agent-test-set.sh --quick # 只跑离线测试集 +./scripts/run-agent-test-set.sh --quick --real # 追加真实 Provider smoke +``` + +测试数据和真实 Provider 配置说明见 +[`docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md`](docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md)。真实 smoke 只在显式 +`--real`(或 `AGENT_TEST_REAL_PROVIDER=1`)时读取 key;默认不访问网络。 +测试脚本默认在 `~/data/tmp` 下创建本轮的 `agent-test-set.*` 目录,数据库、Cargo +target 和中间文件都放在其中;显式设置 `TMPDIR` 或 `AGENT_TEST_TMPDIR` 时尊重调用方的 +临时父目录。脚本只删除自己创建的目录,不会把本轮构建产物留在 workspace 的 `target/` +中。 + +本地 workspace 回归基线(2026-09-06,`cargo test --locked --workspace +--all-features --no-fail-fast`)为:CLI 24、Host 80(另有 7 个消息持久化集成测试)、MCP 52、Engine 55、OpenAI +29、Fake 4、Runtime 36、Codex 101、Orchestration 40、Skill 28;Core 为 30 个 +单元测试加 21 个集成测试,Storage 为 7 个单元测试加 53 个集成测试(共 60,含 +工具调用联合事务、event-history gap、外部 session 候选回归)。 +直接以 `--no-default-features` 测试 Storage 时为 6 个单元测试加 36 个集成测试(共 +42)。Runtime package-only `cargo test --locked --offline -p agent-runtime +--no-default-features` 另有 6 个 portable 测试;workspace 级 `--no-default-features` +会因 Host 的默认依赖特性合并 SQLite,不能替代该 package-only 检查。这些是本地命令计数, +不代表远端 CI 或真实 Provider/许可证数据库已经验收。 +CI 样例同时执行 all-features 与 no-default-features 的 check、test 和 Clippy,避免 +只在默认特性下验证隐藏的可选依赖边界。 + +公开 `validate_tool_arguments` 也会在 JSON Schema 校验前复验 `ToolCall`、 +`ToolDefinition` 及二者的工具名匹配;因此 approval UI 或入队方即使直接使用该预检 +入口,也不会把 serde/兼容构造出的非法调用当成可执行参数。Engine 当前定向测试为 +55 个,内部执行路径仍会在真正审批和工具副作用前再次校验。 + +CLI 默认使用确定性的 Fake Provider,便于离线运行和回归测试;检测到配置的 key +环境变量(默认 `OPENAI_API_KEY`)时切换到 `agent-provider-openai`。真实 Provider 通过 +`AgentHost` 注入,不把密钥写入会话或日志。 + +OpenAI endpoint 有四类装配入口: + +- `OpenAiProvider::new(key)` 使用官方默认的 + `https://api.openai.com/v1/responses`。 +- `OpenAiProvider::with_endpoint(key, full_endpoint)` 使用手动指定的完整地址。 +- `OpenAiProvider::with_base_url(key, base_url)` 或 + `OpenAiProvider::with_config(key, &OpenAiProviderConfig)` 使用配置对象;base + URL 会自动追加 `/responses`。 +- 已创建的 Provider 也可调用 `set_endpoint(full_endpoint)` 或 + `set_base_url(base_url)` 热切换地址;两者先完成同一套 URL/凭据校验,失败时 + 保留原 endpoint。 + +`OpenAiProvider::from_env()` 读取 `OPENAI_API_KEY`(可用 +`OPENAI_API_KEY_ENV` 改名),并读取 `OPENAI_ENDPOINT` 或 `OPENAI_BASE_URL`。 +完整 endpoint 优先于 base URL。CLI 还接受 `OPENAI_MODEL`、TOML 的 +`openai_endpoint`/`openai_base_url`;环境变量优先于 `agent.toml`。该适配器发送 +Responses API 的 `model`、`input`、`tools` 等字段,网关需要兼容同一请求/响应 +协议,详见 [OpenAI Responses API reference](https://developers.openai.com/api/reference/cli/resources/responses/methods/create)。 + +Provider 注册时,`ProviderInstanceId` 表示具体实例(例如 `openai-prod`), +`ProviderProtocolId` 表示 wire 协议(例如 `openai-responses`),两者是不同的强类型。 +`ModelProvider::protocol_id()` 是可选的适配器自描述端口;OpenAI Responses adapter +会报告 `openai-responses`,Registry 在注册和解析时核对 descriptor,省略 descriptor +协议时自动补齐。旧/自定义 Provider 返回 `None` 时仍可由调用方声明协议;这项本地 +校验用于防止错配,不替代真实网关兼容矩阵。 +OpenAI 的 Host 装配 helper 会同时绑定一个拥有式上下文压缩器;长消息超过预算时 +会复用同一 Provider 做摘要。自定义 Provider 可在最终选定实例后调用 +`with_provider_context_compressor()`,专用摘要 Provider 则继续使用 +`with_context_compressor()`。 + +最近的适配器增量还收紧了四个边界:MCP 的认证环境变量会在高层连接、HTTP +直接构造和 stdio 直接构造三条路径一致解析;Skill 激活正文用有界读取抵抗文件在 +检查后的增长;通用 Codex JSON-RPC channel 提供显式 server-request handler(默认 +仍拒绝未知请求),不会把这些适配器能力下沉到 Core。Engine 在 Provider 响应和 +ToolExecutor 结果进入事件、历史或下一次请求前重新执行 Core 构造校验;Codex CLI +参数过滤也会规范化检查大小写、连字符和 header/bearer 形式的凭据参数。审批记录的专用 binding token +在 SQLite JSONL 导出和 CLI `approval list/get/allow/deny` 展示边界都会递归移除, +但 Host 内部仍保留完整记录供显式 `resume` 校验。 + +## Prompt、Skill 与 MCP + +CLI 不把这些扩展写死在项目代码里,而是通过环境变量显式接入: + +- `AGENT_SYSTEM_PROMPT`、`AGENT_DEVELOPER_PROMPT`、`AGENT_CONTEXT_PROMPT` 会分别 + 形成独立的初始消息;context section 在当前兼容消息合同中走 user 通道。 +- Skill 需要同时设置 `AGENT_SKILL_ROOT`(或冒号分隔的 + `AGENT_SKILL_ROOTS`)和 `AGENT_SKILLS`。名称必须显式列出,不会扫描后自动 + 激活: + + AGENT_SKILL_ROOT=./skills AGENT_SKILLS=review,writer \ + cargo run -p agent-cli -- run "检查这段代码" + +- MCP 二选一配置 `AGENT_MCP_STDIO_COMMAND`(可选 + `AGENT_MCP_STDIO_ARGS`,空白分隔或 JSON 字符串数组)或 + `AGENT_MCP_HTTP_URL`。HTTP 认证头可通过 + `AGENT_MCP_HTTP_HEADERS`='{"Authorization":"Bearer ..."}' 传入;可选 + `AGENT_MCP_SERVER`、`AGENT_MCP_TIMEOUT_SECS`: + + AGENT_MCP_SERVER=workspace \ + AGENT_MCP_STDIO_COMMAND=npx \ + AGENT_MCP_STDIO_ARGS='["-y","@modelcontextprotocol/server-filesystem","."]' \ + AGENT_MCP_ALLOW=read_file \ + cargo run -p agent-cli -- run "列出工作区文件" + + MCP 工具会被命名为 `mcp::`。发现目录不会自动授予权限; + `AGENT_MCP_ALLOW` 中的原始工具名会按当前 server 补全命名空间,也可直接 + 写完整名称。stdio 参数按 argv 传递,不经过 shell。 + + transport 对远端不可信输入使用固定上限:单条 stdio JSON-RPC 消息最多 1 MiB, + Streamable HTTP 响应最多 4 MiB,SSE 单行最多 1 MiB、单事件累计 `data` 最多 + 4 MiB;`tools/list`、`resources/list`、`prompts/list` 各最多跟随 1024 页。 + 超限会返回协议错误,不会静默截断;这些是 transport 读取上限,不替代 Engine + 的上下文/工具结果预算。 + + `agent.toml` 也可以用 `[[mcp.auth]]` 保存认证环境变量引用,而不把 token + 写进 TOML: + + [[mcp.auth]] + variable = "TEAM_MCP_TOKEN" + target = "http_bearer" + + stdio 子进程认证使用 `target = "stdio_environment"` 并额外设置 `name`; + 自定义 HTTP 头使用 `target = "http_header"`、`name` 和可选 `prefix`。旧的 + `AGENT_MCP_HTTP_HEADERS` 仅作为运行时兼容入口。 + + Resource/prompt 默认只做能力发现,不会自动进入上下文。需要显式读取时,在 + `[mcp]` 下列出 `context_resources`(URI 数组)或 `context_prompts`(名称数组), + 或设置 `AGENT_MCP_CONTEXT_RESOURCES` / `AGENT_MCP_CONTEXT_PROMPTS`(逗号或空白 + 分隔);CLI prompt 使用空参数。Host 会先按 URI/name 精确匹配,再把读取结果作为 + `ContextSource` 的不可信候选项交给 Engine;带 prompt 参数的场景使用库 API 的 + `McpContextSelection::with_prompt_selection`,不会隐式读取整个远端目录。CLI `mcp list` + 只调用一次 `McpClient::capability_snapshot()`,从同一份快照生成 tools 和 fingerprint, + 因而不会重复发 `tools/list`;resources/prompts 的发现错误也会沿原路径返回。 + +Skill 文件采用一个有界的 `SKILL.md` frontmatter 子集:`name` 必填,支持 +`description`、`version`、`allowed-tools` 和字符串扩展字段;解析器不是完整 YAML +实现。目录发现只读元数据,正文在显式激活时读取并重新校验路径、UTF-8 和大小; +空正文会被激活拒绝。每个逻辑字段只能出现一次;历史别名 `allowed_tools` 与 +`allowed-tools` 视为同一字段,重复时直接拒绝,避免后值覆盖造成激活语义漂移。 +`allowed-tools` 只作为元数据,不会自动放行工具。 + +`run --background` 只把任务消息和运行身份写入 SQLite;隐藏 worker 会从继承的 +环境重新加载 Skill 并重新建立 MCP 连接。因此扩展配置和 Skill 文件应在 worker +启动前保持可用;库调用方若自行创建 worker,也要在每个 worker 进程重建这些适配器。 + +排队和终态是原子边界:Host 会一次事务写入 session、queued run、runtime 初始 +快照/事件;正常完成、失败或取消会一次事务更新 run/runtime/session 并清理 +checkpoint。worker 在 claim 前发现 Provider、Skill 或 MCP 配置错误时会把 run +记录为 `failed`,不会静默留下 queued;claim 后的本地准备错误会先进入 +`reconciling`,保留恢复证据后再释放 lease。 + +Host 默认审批策略只放行无副作用的内置 `echo`;接入自己的工具时,通过 +`with_approval` 显式提供策略。Engine 会把可选的 session/run 身份传给上下文源 +和工具执行器,但不会自动把这些身份拼进模型提示词。 + +可用命令:`run`、`inspect`、`checkpoint`、`export`、`doctor`、`cancel`、`resume`、`resume-safe`、`reconcile`、`reconcile --stale [limit]`、`reconcile-provider`、`reconcile-tool`、`approval list/get/allow/deny/resume`、`codex validate`。需要后台运行时使用 +`run --background`;它先持久化 `queued` run,再启动一个隐藏 worker。`cancel` +通过 SQLite 发出跨进程可见的 cooperative 请求,当前 Provider/工具调用返回后在 +下一个 step 收口。每个 worker 还持有带 token 的 lease 并定期心跳,避免第二个 +worker 写回迟到结果。`resume` 只重新领取尚未启动的 `queued` run,不会重放已经运行 +过的外部调用;Engine 会在 Provider/审批/工具边界写入当前 run 的增量 checkpoint, +`reconcile` 只把 lease 已过期或历史上没有 lease 的 `running` run 转成 `reconciling`; +`reconcile --stale [limit]` 对同一类 run 做一次固定排序、有界批量扫描(省略 limit +时最多 256 项),并保留最后游标供宿主读取和完成外部副作用对账。Host 在尝试启动一个仍为 +`running`/`cancel_requested` 的 run 时也会先做一次有条件的过期 lease 探测:有效 +lease 会拒绝第二个 worker,过期 lease 会原子进入 `reconciling`,不会启动 Engine +或重放调用。库调用方还可使用 `agent-runtime-sqlite::RuntimeService::reconcile_stale_runs(limit)` 做 +一次固定排序、最多 256 项的候选扫描;每项会重新检查 lease 后再进入同一原子 gate。 +对账方先在 Provider/工具外部系统 +确认结果,再提交完整 Core 消息历史: + +```bash +# messages.json 是完整历史,不是仅新增的一条消息;也可把 - 换成 stdin,或直接传入以 [ 开头的 JSON +AGENT_DB=agent.db agent reconcile-provider messages.json +AGENT_DB=agent.db agent reconcile-tool messages.json +``` + +消息格式沿用 Core 的 serde 字段;例如工具结果后缀形如: + +```json +[ + {"role":"user","content":[{"type":"text","text":"执行任务"}]}, + {"role":"assistant","content":[{"type":"tool-call","id":"call-1","name":"echo","arguments":{"text":"hello"}}]}, + {"role":"tool","content":[{"type":"tool-result","toolCallId":"call-1","output":{"ok":true},"isError":false}]} +] +``` + +实际提交时应以 `agent checkpoint ` 返回的历史为前缀,只追加已经从外部 +系统核对到的结果。 + +提交消息必须保留 checkpoint 的逐项前缀,并追加已观察的 assistant 响应(Provider) +或匹配的 `tool` 消息中的 `tool-result` 内容块(工具);Host/SQLite 会校验 phase、调用 ID、step、 +attempt、角色、顺序和重复项。命令只写入 `safe` checkpoint,不会查询外部系统、 +执行 Provider 或再次执行工具;成功后使用 `resume-safe`,它先把 run 重新排队(尚未启动 worker 时重复执行也幂等);worker 领取后在启动 Engine 前用一次 +RuntimeStore CAS 补齐已观察的消息/工具结果,再从 `next_step` 继续。两步之间 +退出仍可重试;`provider_in_flight` 和 `tool_in_flight` 仍不会自动重放。 +`AGENT_DB` 指定 SQLite 文件路径;不设置时使用当前目录的 `agent.db`。 + +Provider 在进入工具调度前会校验 response 的 request/model identity 和批次内 +tool-call ID 唯一性。审批策略返回 `Ask` 时,worker 保留 +`awaiting_approval` checkpoint 并进入 reconciliation gate,不会把它当作 deny +或自动执行。控制端先用 `approval list/get` 读取请求,再用 `approval allow` 或 +`approval deny <原因>` 做 pending-only 决议,最后用 `approval resume ` +显式重新排队并启动 worker;同一决议可安全重试,已取消或已决议记录不能被覆盖。 +审批 `Deny` 会生成失败的 tool result;若同一批次还有后续调用,checkpoint 会 +指向下一调用,只有整批处理完成才可标记 `safe`。 + +MCP stdio 子进程只继承 `PATH` 和显式环境变量,MCP 配置/请求/错误的调试与 CLI +展示会脱敏;tools/list 会限制分页并拒绝循环游标。同步 client 提供显式 + `poll_notification`、有界 `McpReconnectScheduler`(可输出调度审计)和调用前 + 权限审计 gate;重连只重做握手,不缓存或重放 `tools/call`,也不能强杀已经阻塞 + 的同步 I/O。已完成握手的 stdio client 还可通过 + `into_notification_subscription` 转成独占的后台通知订阅:队列有界、支持 + cooperative cancel,`Drop`/`join` 会回收线程和子进程;订阅期间不能再对原 client + 发 request。Streamable HTTP 也支持独占 GET/SSE 长连接(内部 worker 使用私有 + current-thread runtime),自定义 transport 仍需显式覆盖订阅 hook;订阅线程不会 + 自动应答、重连或重放调用。transport 对远端不可信输入使用固定上限:单条 stdio JSON-RPC 消息 +最多 1 MiB,Streamable HTTP 响应最多 4 MiB,SSE 单行最多 1 MiB、单事件累计 + `data` 最多 4 MiB;`tools/list`、`resources/list`、`prompts/list` 各最多跟随 + 1024 页。超限会返回协议错误,不会静默截断;这些上限不替代 Engine 的上下文/ + 工具结果预算。CLI `mcp list` 使用一次 capability snapshot 同时产出工具列表和指纹, + 不会为指纹再次请求 `tools/list`。OpenAI adapter 在 complete/stream 请求中以本地 + request id 发送 `Idempotency-Key`,并将 HTTP 408/429/502/503/504 归类为可限次重试的 + `Unavailable`,其它非 2xx 归类为 `Upstream`;厂商 response id 仍只是外部对账线索, + adapter 不会自行查询结果或重放。若网关不承诺幂等,应把调用方的重试预算设为 0。 + +## Crate 边界 + +- `agent-runtime-core`:纯数据契约、端口和状态 reducer;不依赖异步运行时或外部协议。 +- `agent-runtime-contracts`:数据库无关的 durable command/view 与 `DurableStore` trait; + 只依赖 Core 和 JSON,不持有 SQLite、连接或线程生命周期。 +- `agent-runtime-engine`:单 Agent Loop、上下文预算、工具调度与 checkpoint 边界回调; + `ContextObservation` 记录候选上下文的 selected/skipped 及 trusted/untrusted 计数, + 不复制正文。进入 Engine 的 serde 输入、ContextSource 项、压缩器输出和压缩响应 + 身份都会在边界重新校验;Provider response 的 `content` 只允许 Text/Image,结构化 + tool call/result 不得混入该字段;校验失败发生在 Provider、checkpoint 或工具副作用之前。 +- `agent-runtime`:portable durable command/view facade、`DurableRuntime`、 + `RuntimeSnapshotService`、`RuntimeRunHandle` 和 `WorkerLease`;无 SQLite feature、 + `rusqlite` 或 `agent-storage-sqlite` 依赖,package-only no-default 测试覆盖其 portable + 合同。不启动 Engine 或 worker。 +- `agent-runtime-sqlite`:SQLite-backed `RuntimeService`、records/error 转换、journal mode、 + JSONL/export 和跨表 lease/checkpoint/终态/recovery 事务;依赖 `agent-runtime` 与 + `agent-storage-sqlite`,Host 统一通过它装配,未保留 `agent_runtime::RuntimeService` 的 + 平行 re-export。 +- `AgentHost::load_runtime_snapshot`:RuntimeSnapshot 的只读观察入口,同样通过 + Runtime facade,不领取 lease、不改变状态;跨表原子事务由 Runtime 的 SQLite + 实现负责,Host 只保留 `store()` 等旧兼容 accessor,不形成第二份连接所有权。 +- `AgentHost::observe_external`:通过 Runtime facade 调用 Core + `ExternalObservationSource` 查询已有 provider/tool/external 引用;这是只读观察, + 不写 checkpoint、消息、requeue 或 reconciliation,外部错误分类会原样返回。 +- `DurableApprovalCheckpointRuntimeCommit`:Host 在 Engine 生成 approval binding 后, + 通过 Runtime/SQLite 的单事务重新校验 live lease、awaiting checkpoint 和 runtime + revision,再幂等写入 pending approval;Engine callback 早于 binding 生成的窄窗口仍需 + reconciliation/failure gate 处理。 +- Host 的 Runtime-only 事件/快照 CAS 通过 `agent-runtime-sqlite::RuntimeService::commit_runtime_snapshot`; + 该入口不替代跨表 run/session/lease 事务。 +- `agent-storage-sqlite`:SQLite 事件/快照、增量 checkpoint,以及 worker lease/fencing; + 具体 RuntimeService 装配位于 `agent-runtime-sqlite`。 +- `agent-mcp`、`agent-skills`:外部协议和文件格式适配器;前者提供同步 + stdio/Streamable HTTP JSON/SSE、显式通知轮询、stdio 与 HTTP/SSE 的有界独占通知 + 订阅、有界重连调度和权限审计;stdio 同步等待响应时的 pending 消息队列同样有 + `MAX_STDIO_PENDING_MESSAGES` 硬上限,后者实现 Core `SkillSource` 并执行显式激活。 +- `agent-provider-openai`:OpenAI Responses 请求/响应映射。 +- `agent-provider-fake`:可脚本化的离线 Provider fixture。 +- `agent-codex`:受限 Codex CLI 一次性调用(含超时、取消、Unix process-group + 终止和输出上限;子进程启动后的非法输出、超限或异常退出均保留为未知副作用)、 + 可注入 App Server channel、带版本字段的本地 JSONL fixture,以及不绑定发行版的 + 最小 JSON-RPC/JSONL channel;另提供窄 V2 `CodexAppServerClient` 和真实 stdio + `CodexAppServerProcess`,覆盖 initialize/initialized、thread/start、turn/start 接受、 + 通知轮询、可选通用 server-request handler 和精确 `threadId`/`turnId` interrupt。真实进程配置只接受显式 + `program + args`,有界 supervisor 负责 stdin/stdout 排空、deadline、取消、进程组 + 终止、wait/reap 和 reader/writer join;不会自动重连或重放请求。不嵌入 `codex-core`, + 也不替宿主持有 Host 的会话和持久化真相;V2 不是具体 Codex 发行版兼容承诺,完整 + generated schema、服务端审批/工具请求和版本特定 wire 仍由上层适配器负责; + `codex_0_152_1` 只接受调用方已核对的 `codex-cli 0.152.1`,并提供当前 typed + 子集与 provenance/hash 清单,不是任意 v2 发行版兼容层。 + 另提供独立 `JsonRpcAppServerRouter`,以有界 pending map 和后台 reader 支持 + 乱序 response/并发 `turn/interrupt`;另有 `CodexAppServerProcessRouter` 将其接入 + ProcessControl 管理的真实 stdio child,并在 timeout/protocol/cancel/Drop 时收束。 + ProcessBackend 可注入 `CodexSessionMetadataSink`,在 request/cancel 收束后报告中立 + lifecycle;Host 的实现把状态、external ID、退出码和取消结果合并进 `external_sessions`, + 但不覆盖进程级 EOF/terminate/Drop 或自动外部对账。 +- `agent-runtime-orchestration`:通用 DAG、Delegation/Join/Proposal、状态和下游修复, + 以及 Coordinator 的配额、消息去重、节点隔离和显式修复;持久协调器还提供 + revision-CAS 保护的 `cancel_run`,原子标记 `Cancelled` 并释放活动配额;支持把任务图与协调器 + 控制面作为同一版本化快照保存的 `PersistentCoordinator`,并提供带 revision CAS + 的内存/原子 JSON 文件 `OrchestrationSnapshotStore`。其中 + `JsonFileOrchestrationSnapshotStore` 通过同目录 sidecar advisory lock 把 revision + CAS 与 rename 串成遵守该适配器的本机跨进程写临界区;旧 + `JsonFileCoordinatorStore` 仍只有进程内锁。它们都不直接接 SQLite、不创建 Runtime + run、不调度线程,也不提供自动多 Agent scheduler。 +- `agent-host`:组合依赖并提供稳定库 API;`AgentBuilder`/`AgentService` 当前是 + `AgentHost` 的类型别名,`AgentHost::new`/`builder` 是轻量装配入口,不创建第二 + 套 Runtime 或生命周期。Host 内部不再重复持有 Store;`store()` 已标记为 deprecated, + 仅作为旧导出/诊断调用的兼容 accessor 委托给 Runtime,Runtime 仍是当前 SQLite-backed + 具体实现。新代码可使用 `list_events`、`list_runtime_events`、`get_session`、 + `export_jsonl` 和 `export_runtime_jsonl` 等窄 facade。 +- `agent-app`:无状态的通用程序配置与装配输入层,承接 `AgentTomlConfig`、环境/TOML + 优先级、OpenAI endpoint、MCP 认证引用、effective model 和 queued metadata;不依赖 + Host、Runtime、线程或数据库,不拥有运行状态。 +- `agent-cli`:面向人和脚本的最小命令行入口。 + +可选的真实 Codex wire 探测使用 `scripts/probe-codex-app-server.sh`。默认只在隔离 +`CODEX_HOME` 中执行 `initialize`/`initialized`/`thread/start`;加 `--schema` 可生成 +本机 v2 schema 的大小/hash 摘要。它不需要 API key、不发送 `turn/start`,也不进入默认 CI。 + +库调用方也可直接使用 `AgentHost::read_checkpoint` 观察游标,再调用 +`reconcile_provider_result` / `reconcile_tool_result` 和 `requeue_safe_run`;这些 +入口只接受已核对的完整消息历史。若宿主已有具体外部查询实现,可注入 +`ExternalSessionResolver` 调用 `reconcile_external_sessions(limit, resolver)` 做有界批处理: +Completed 只推进 safe checkpoint,Pending/NotFound 保守保留 unknown,仍不会自动 requeue、 +重放或启动 Engine。自动幂等键查询、长连接结果订阅和未知调用的自动重放需要绑定具体外部 +系统,暂不由通用内核假设。`agent-codex` 的 Host 接入 +方式是实现/注入 Core `ExternalBackend`,调用 `AgentHost::with_external_backend`, +再通过审批策略显式放行;未知结果会写 `external_sessions` 并停在 +`tool_in_flight`,不会被自动当作成功。多工具批次在首个 `Ask` 后恢复时只会把当前 +pending call 及其前缀物化到 Core,后续调用仍留在 checkpoint;MCP 工具桥对发送后 +timeout/断线/协议/编码/HTTP/远端错误统一标记为未知副作用,避免显式 failed 重试 +策略重放未知调用。`doctor` 不联网、不启动外部进程,但会打开并按需初始化/迁移本地 +SQLite/WAL,再分项校验 SQLite、Provider key/endpoint、Skill、MCP 和 Codex 配置;Codex +program 只输出已配置标记,不回显路径。 + +需要观察 durable 审计时,可注入 `with_durable_event_listener` 或 +`with_durable_event_callback`。回调在每条 `events` 行的 SQLite append 事务提交后 +同步执行,并收到 `(run_id, revision, event)`;它只证明该行已落盘,不代表 runtime +trace、approval 或终态事务已完成,也不能回滚或直接修改 Host 状态。Engine 的普通 +`EventListener`/`StreamEventListener` 仍是提交前的观察接口。 + +Engine 的当前边界回归还覆盖五类容易被 serde 绕过的输入:顶层 AgentInput 的消息、工具 +定义和请求字段;ContextSource 返回的消息项;压缩器返回的消息集合;压缩响应的 +`request_id`/`model` 身份;以及压缩阶段的取消传播。它们统一在进入 Provider、checkpoint +或工具副作用前失败,避免不合法数据被截断、重写或继续执行。 + +Codex 一次性 CLI 的配置可放在 `agent.toml` 的 `[codex.cli]` 下,并运行 +`agent codex validate` 做无副作用的参数白名单检查;实际调用由 +`CodexCliBackend` 在有界 supervisor 中管理 child、超时和取消。JSONL App Server +channel 只规定本仓库的中立 `request/event/result/interrupt` frame,并以 +`protocolVersion` 做本地 fixture 握手;需要当前窄 V2 生命周期时可使用 +`CodexAppServerClient` 的 `initialize`、`thread_start`、`turn_start`、 +`poll_notification` 和 `turn_interrupt`。若需要直接启动本地 app-server,可使用 +`CodexAppServerProcessConfig` 配置显式 `program + args`,再通过 +`CodexAppServerProcess::spawn` 获取同一组方法;它会为 stdin/stdout 使用有界的 +reader/writer supervisor,并在 timeout、cancel、EOF 或 Drop 时完成 process-group +终止、wait/reap 和线程 join。每个 argv 必须匹配调用方提供的 +`allowed_arg_prefixes`,不会把一整段字符串再交给 shell 解析。客户端提供 +`CodexServerRequestHandler` 这一中立回调,用于回应审批/动态工具等 server request;Host +还提供只覆盖 Codex 0.152.1 dynamic-tool wire 的 `codex_01521_server_request_handler`, +返回该版本的 `contentItems`/`success` 形状。具体参数 schema、审批策略、工具执行、异步 +通知订阅或自动重连仍需由上层适配器负责,避免把厂商协议写进 Kernel。进一步的依赖/CI 证据见 +[`docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md`](docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md)。 diff --git a/rust/agent.toml.example b/rust/agent.toml.example new file mode 100644 index 000000000..71e83e454 --- /dev/null +++ b/rust/agent.toml.example @@ -0,0 +1,51 @@ +# 复制为 agent.toml 后即可运行 `cargo run -p agent-cli -- run "任务"`。 +# 认证字段只保存环境变量名;不要把 api_key、token 或 Cookie 写进此文件。 +db = "./agent.db" +provider = "fake" +model = "fake" +stream = false + +# provider = "openai" 时可使用以下任一 endpoint 配置: +# openai_base_url = "https://gateway.example/v1" # 自动补 /responses +# openai_endpoint = "https://gateway.example/v1/responses" # 完整地址,优先 +# openai_api_key_env = "OPENAI_API_KEY" # 只保存环境变量名 +# OPENAI_MODEL 或 AGENT_MODEL 可覆盖 model + +# 可选的固定提示词 section;system/developer/context 不会被压成同一条 user 消息。 +# system_prompt = "你是一个简洁的助手" +# developer_prompt = "输出可审计的步骤" +# context_prompt = "这是不可信的外部背景" + +[skills] +# roots = ["./.codex/skills", "./.agents/skills"] +# names = ["review"] + +[mcp] +# server = "workspace" +# stdio_command = "npx" +# stdio_args = ["-y", "@modelcontextprotocol/server-filesystem", "."] +# timeout_secs = 30 +# allow = ["read_file"] +# 只有显式列出的内容会被读取并作为不可信上下文注入;默认不读取资源或 prompt。 +# context_resources = ["file:///workspace/README.md"] +# context_prompts = ["welcome"] # CLI 使用空参数;带参数请用库 API + +# HTTP 或 stdio 的秘密均通过环境变量引用。示例: +# [[mcp.auth]] +# variable = "MCP_TOKEN" +# target = "http_bearer" +# +# stdio 服务器也可以把环境变量转发给子进程: +# [[mcp.auth]] +# variable = "MCP_WORKSPACE_TOKEN" +# target = "stdio_environment" +# name = "WORKSPACE_TOKEN" + +# Codex 是显式外部 backend,不替换当前 CLI 的 ModelProvider。 +# `agent codex validate` 只校验配置和参数白名单,不启动进程。 +# [codex.cli] +# program = "codex" +# args = ["--model=gpt-5-codex"] +# allowed_arg_prefixes = ["--model"] +# timeout_ms = 120000 +# max_output_bytes = 1048576 diff --git a/rust/crates/agent-app/Cargo.toml b/rust/crates/agent-app/Cargo.toml new file mode 100644 index 000000000..f666972b1 --- /dev/null +++ b/rust/crates/agent-app/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agent-app" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "通用 Agent 程序的配置与装配边界" + +[dependencies] +agent-codex.workspace = true +agent-mcp.workspace = true +agent-provider-openai.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true diff --git a/rust/crates/agent-app/src/lib.rs b/rust/crates/agent-app/src/lib.rs new file mode 100644 index 000000000..9165b5b80 --- /dev/null +++ b/rust/crates/agent-app/src/lib.rs @@ -0,0 +1,332 @@ +//! 通用 Agent 程序的无状态配置与装配输入。 +//! +//! 这个 crate 只承接 `agent.toml`、环境变量和非秘密路由 metadata 的解析。 +//! 它不依赖 Host、Runtime、线程或数据库,因此 CLI 之外的入口也可以复用 +//! 同一套配置优先级,而不会复制一份运行状态机。 + +use std::env; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use agent_codex::CodexCliConfig; +use agent_mcp::{McpAuthEnv, McpAuthTarget}; +use agent_provider_openai::OpenAiProviderConfig; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// `agent.toml` 只描述可复现的装配参数;密钥字段故意没有对应结构, +/// 未知字段会被拒绝,避免用户误把明文 token 写入配置文件。 +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct AgentTomlConfig { + pub db: Option, + pub provider: Option, + pub model: Option, + pub stream: Option, + pub openai_api_key_env: Option, + /// OpenAI-compatible 网关的 base URL;运行时会自动补 /responses。 + pub openai_base_url: Option, + /// OpenAI Responses 的完整 endpoint,优先于 base URL。 + pub openai_endpoint: Option, + pub system_prompt: Option, + pub developer_prompt: Option, + pub context_prompt: Option, + #[serde(default)] + pub skills: SkillTomlConfig, + #[serde(default)] + pub mcp: McpTomlConfig, + #[serde(default)] + pub codex: CodexTomlConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct SkillTomlConfig { + pub roots: Vec, + pub names: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct McpTomlConfig { + pub server: Option, + pub stdio_command: Option, + pub stdio_args: Vec, + pub http_url: Option, + /// 认证只保存环境变量引用;解析后的 token 由 agent-mcp 在连接时读取。 + #[serde(default)] + pub auth: Vec, + pub timeout_secs: Option, + pub allow: Vec, + /// 只读取这些明确列出的 resource URI 作为不可信上下文。 + pub context_resources: Vec, + /// 只展开这些明确列出的 prompt(CLI 使用空参数;需要参数时使用库 API)。 + pub context_prompts: Vec, +} + +/// Codex 仍是显式外部 backend,不替换 CLI 的 ModelProvider。这里先把一次性 +/// CLI 的受限启动配置纳入同一份 agent.toml,并由 `codex validate` 做无副作用 +/// 校验;App Server channel 继续由嵌入方注入,避免配置层偷藏第二套运行状态。 +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct CodexTomlConfig { + pub cli: Option, +} + +/// agent.toml 中的认证引用保持扁平、可读的写法: +/// `target = "http_bearer"`,真正的 Core target 在连接前才构造。 +/// 结构里没有 secret 字段,故配置序列化和 Debug 都不会持有凭据原文。 +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct McpAuthToml { + pub variable: String, + pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, +} + +impl fmt::Debug for McpAuthToml { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("McpAuthToml") + .field("variable", &"") + .field("target", &self.target) + .field("name", &self.name) + .field("prefix", &self.prefix.as_deref().map(|_| "")) + .finish() + } +} + +impl McpAuthToml { + /// 将非秘密 TOML 引用转换为 MCP 的 Core 认证引用。 + pub fn into_core(self) -> Result> { + let target = match self.target.as_str() { + "http_bearer" => { + if self.name.is_some() || self.prefix.is_some() { + return Err("http_bearer 认证引用不应带 name/prefix 字段" + .to_owned() + .into()); + } + McpAuthTarget::HttpBearer + } + "http_header" => { + let name = self + .name + .filter(|name| !name.trim().is_empty()) + .ok_or("http_header 认证引用需要非空 name")?; + McpAuthTarget::HttpHeader { + name, + prefix: self.prefix.unwrap_or_default(), + } + } + "stdio_environment" => { + let name = self + .name + .filter(|name| !name.trim().is_empty()) + .ok_or("stdio_environment 认证引用需要非空 name")?; + McpAuthTarget::StdioEnvironment { name } + } + _ => { + return Err( + "MCP 认证 target 无效(支持 http_bearer/http_header/stdio_environment)" + .to_owned() + .into(), + ); + } + }; + if self.variable.trim().is_empty() { + return Err("MCP 认证 variable 不能为空".to_owned().into()); + } + Ok(McpAuthEnv { + variable: self.variable, + target, + }) + } +} + +impl AgentTomlConfig { + /// 从 `AGENT_CONFIG`(缺省为当前目录 `agent.toml`)读取配置。 + pub fn load() -> Result> { + let path = env::var_os("AGENT_CONFIG") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("agent.toml")); + Self::load_from_path(path) + } + + /// 从明确路径加载配置;调用方可用它避免在测试中修改全局环境。 + pub fn load_from_path(path: impl AsRef) -> Result> { + let path = path.as_ref(); + if !path.exists() { + return Ok(Self::default()); + } + let text = fs::read_to_string(path)?; + toml::from_str(&text) + .map_err(|error| format!("配置文件 {} 无效: {error}", path.display()).into()) + } + + /// 解析数据库路径:进程环境覆盖 TOML,最后使用 `agent.db`。 + pub fn db_path(&self) -> PathBuf { + env::var_os("AGENT_DB") + .map(PathBuf::from) + .or_else(|| self.db.as_deref().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("agent.db")) + } + + /// 解析 Provider 名称。没有显式选择时,有可用 OpenAI key 才默认 openai。 + pub fn provider(&self) -> String { + non_empty_env("AGENT_PROVIDER") + .or_else(|| self.provider.clone()) + .unwrap_or_else(|| { + let key_env = self.openai_api_key_env(); + if env::var(key_env) + .map(|key| !key.trim().is_empty()) + .unwrap_or(false) + { + "openai".to_owned() + } else { + "fake".to_owned() + } + }) + } + + /// 解析模型名称,并让环境变量覆盖配置文件。 + pub fn model(&self) -> String { + non_empty_env("AGENT_MODEL") + .or_else(|| { + (self.provider() == "openai") + .then(|| non_empty_env("OPENAI_MODEL")) + .flatten() + }) + // Treat a blank TOML value like an unset override, matching the + // environment helpers and allowing the normal provider default. + .or_else(|| self.model.clone().filter(|value| !value.trim().is_empty())) + .unwrap_or_else(|| "fake".to_owned()) + } + + /// API key 只解析环境变量名,从不读取或保存 key 原文。 + pub fn openai_api_key_env(&self) -> String { + non_empty_env("AGENT_OPENAI_API_KEY_ENV") + .or_else(|| non_empty_env("OPENAI_API_KEY_ENV")) + .or_else(|| self.openai_api_key_env.clone()) + .unwrap_or_else(|| "OPENAI_API_KEY".to_owned()) + } + + /// 按环境变量优先级构造 OpenAI 非秘密配置。 + pub fn openai_provider_config(&self) -> OpenAiProviderConfig { + self.openai_provider_config_with_env( + non_empty_env("OPENAI_ENDPOINT").as_deref(), + non_empty_env("OPENAI_BASE_URL").as_deref(), + ) + } + + /// 明确传入环境候选,便于嵌入方做纯函数测试而不修改进程环境。 + /// 环境完整 endpoint 优先于环境 base URL;任一环境来源都优先于 TOML。 + pub fn openai_provider_config_with_env( + &self, + env_endpoint: Option<&str>, + env_base_url: Option<&str>, + ) -> OpenAiProviderConfig { + let mut config = + OpenAiProviderConfig::default().with_api_key_env(self.openai_api_key_env()); + if let Some(endpoint) = env_endpoint + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + { + config = config.with_endpoint(endpoint); + } else if let Some(base_url) = env_base_url + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + { + config = config.with_base_url(base_url); + } else if let Some(endpoint) = self + .openai_endpoint + .clone() + .filter(|value| !value.trim().is_empty()) + { + config = config.with_endpoint(endpoint); + } else if let Some(base_url) = self + .openai_base_url + .clone() + .filter(|value| !value.trim().is_empty()) + { + config = config.with_base_url(base_url); + } + config + } + + /// 解析流式开关;显式环境值优先,OpenAI 缺省开启流式。 + pub fn streaming(&self) -> bool { + if let Some(value) = non_empty_env("AGENT_STREAM") { + return matches!(value.as_str(), "1" | "true" | "yes" | "on"); + } + self.stream.unwrap_or_else(|| self.provider() == "openai") + } +} + +/// 将 provider 与实际模型写成后台 queued run 的非秘密路由观察。 +/// worker 仍会在真正执行前重新装配 Provider,不会从 metadata 读取凭据。 +pub fn queued_run_metadata(config: &AgentTomlConfig, provider: &str) -> Value { + json!({ + "provider": effective_model(config, provider), + "providerKind": provider, + }) +} + +/// 将配置模型解析为实际执行模型;OpenAI 的 `fake` 只是未指定模型的哨兵。 +pub fn effective_model(config: &AgentTomlConfig, provider: &str) -> String { + let model = config.model(); + if provider == "openai" && model == "fake" { + "gpt-4.1-mini".to_owned() + } else { + model + } +} + +fn non_empty_env(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::{AgentTomlConfig, effective_model, queued_run_metadata}; + + #[test] + fn blank_model_uses_provider_default_without_secret_values() { + let config: AgentTomlConfig = + toml::from_str("provider = 'openai'\nmodel = ' '").expect("配置应可解析"); + assert_eq!(config.model(), "fake"); + assert_eq!(effective_model(&config, "openai"), "gpt-4.1-mini"); + assert_eq!( + queued_run_metadata(&config, "openai"), + serde_json::json!({"provider": "gpt-4.1-mini", "providerKind": "openai"}) + ); + } + + #[test] + fn load_missing_path_returns_default() { + let config = AgentTomlConfig::load_from_path( + std::env::temp_dir().join("agent-app-missing-config-does-not-exist.toml"), + ) + .expect("缺失配置应使用默认值"); + assert!(config.db.is_none()); + assert_eq!(config.provider, None); + } + + #[test] + fn endpoint_prefers_environment_before_toml() { + let config: AgentTomlConfig = toml::from_str( + "provider = 'openai'\nopenai_endpoint = 'https://toml.example/v1/responses'\nopenai_base_url = 'https://toml-base.example/v1'", + ) + .expect("配置应可解析"); + assert_eq!( + config + .openai_provider_config_with_env(None, Some("https://env.example/v1")) + .resolve_endpoint() + .expect("endpoint 应可解析"), + "https://env.example/v1/responses" + ); + } +} diff --git a/rust/crates/agent-cli/Cargo.toml b/rust/crates/agent-cli/Cargo.toml new file mode 100644 index 000000000..e8d243def --- /dev/null +++ b/rust/crates/agent-cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agent-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "通用 Agent 单智能体命令行程序" + +[[bin]] +name = "agent" +path = "src/main.rs" + +[dependencies] +agent-app.workspace = true +agent-codex.workspace = true +agent-host.workspace = true +agent-mcp.workspace = true +agent-provider-openai.workspace = true +agent-runtime-core.workspace = true +agent-runtime-engine.workspace = true +agent-skills.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true diff --git a/rust/crates/agent-cli/src/main.rs b/rust/crates/agent-cli/src/main.rs new file mode 100644 index 000000000..b5e69b8e7 --- /dev/null +++ b/rust/crates/agent-cli/src/main.rs @@ -0,0 +1,1900 @@ +//! 面向人和脚本的最小 CLI。 +//! +//! 不引入参数解析框架,保持首个可执行程序容易审查和嵌入。复杂部署可以 +//! 直接使用 `agent-host` 库 API。 + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use agent_app::{AgentTomlConfig, McpAuthToml, effective_model, queued_run_metadata}; +use agent_codex::CodexCliBackend; +use agent_host::{AgentHost, HostRunHandle, HostRunOutput, McpContextSelection}; +use agent_mcp::{McpAuthEnv, McpClientOptions, McpServerConfig, McpTransportConfig}; +use agent_runtime_core::{ApprovalDecision, Message, PromptBuilder}; +use agent_runtime_engine::AllowList; +use agent_skills::SkillLoader; +use serde::Serialize; +use serde_json::{Value, json}; + +/// `reconcile` 保留旧的单 run 入口,并用显式 `--stale` 选择一次有界扫描。 +/// 默认值与 Runtime 的硬上限一致;扫描本身仍由 Host/Runtime 原子执行。 +const DEFAULT_STALE_RECONCILE_LIMIT: usize = 256; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ReconcileArgs { + Run(String), + Stale { limit: usize }, +} + +fn parse_reconcile_args(args: Vec) -> Result { + let Some(first) = args.first() else { + return Err("reconcile 需要 run_id 或 --stale [limit]".to_owned()); + }; + if first == "--stale" { + if args.len() > 2 { + return Err("reconcile --stale 最多接受一个 limit".to_owned()); + } + let limit = args + .get(1) + .map(|value| { + value + .parse::() + .map_err(|_| format!("reconcile stale limit 无效: {value}")) + }) + .transpose()? + .unwrap_or(DEFAULT_STALE_RECONCILE_LIMIT); + return Ok(ReconcileArgs::Stale { limit }); + } + if args.len() != 1 { + return Err( + "reconcile 只接受一个 run_id;批量扫描请使用 --stale [limit]".to_owned(), + ); + } + Ok(ReconcileArgs::Run(first.clone())) +} + +fn main() { + if let Err(error) = run() { + eprintln!("agent: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let mut args = env::args().skip(1); + let command = args.next().unwrap_or_else(|| "run".to_owned()); + let config = AgentTomlConfig::load()?; + let db = config.db_path(); + + match command.as_str() { + "run" => { + let (run_options, task_args) = parse_run_options(args.collect()) + .map_err(|error| -> Box { error.into() })?; + let streaming = run_options.streaming.unwrap_or_else(|| config.streaming()); + let task = task_args.join(" "); + let task = if task.is_empty() { + "请简短介绍你自己".to_owned() + } else { + task + }; + let messages = prompt_messages_with_config(&task, &config)?; + if run_options.background { + // 排队阶段只需要本地 SQLite/Core;不要因为 MCP/Provider + // 暂时不可用而阻止 durable run 身份落盘。真正的外部配置在 + // 隐藏 worker 内重建并在执行前握手。 + let host = AgentHost::open(&db)?; + let provider = config.provider(); + let metadata = queued_run_metadata(&config, &provider); + let handle = + host.prepare_run_with_messages_and_metadata(task, messages, metadata)?; + let pid = spawn_worker_or_fail_unclaimed(&host, &db, &handle.run_id, streaming)?; + print_queued_result(&handle, pid, run_options.jsonl)?; + } else { + let host = open_configured_host(&db, &config)?; + let result = if streaming { + host.run_with_messages_streaming(task, messages)? + } else { + host.run_with_messages(task, messages)? + }; + print_run_result(&result, run_options.jsonl)?; + } + } + "worker" => { + let run_id = args.next().ok_or("worker 需要 run_id")?; + // worker 读取已持久化的初始消息;扩展配置仍需在进程内重建, + // 这样 Skill/MCP 工具会在真正执行前注册到同一 Host。 + let host = match open_configured_host(&db, &config) { + Ok(host) => host, + Err(error) => { + // 配置失败发生在 claim 之前;用裸 Host 把 durable run + // 收束为 failed,避免后台 worker 静默退出后留下 queued。 + let control_result = AgentHost::open(&db).and_then(|control| { + control + .fail_unclaimed_run(&run_id, "worker 配置初始化失败") + .map(|_| ()) + }); + return match control_result { + Ok(()) => Err(format!( + "worker 配置初始化失败(run 已标记 failed): {error}" + ) + .into()), + Err(control_error) => Err(format!( + "worker 配置初始化失败且无法收口 run: {error}; 收口错误: {control_error}" + ) + .into()), + }; + } + }; + let streaming = env::var("AGENT_STREAM_WORKER").is_ok_and(|value| value == "1"); + match if streaming { + host.run_existing_streaming(&run_id) + } else { + host.run_existing(&run_id) + } { + Ok(result) => println!("{}", serde_json::to_string_pretty(&result)?), + Err(error) if error.is_cancelled() => return Ok(()), + Err(error) => return Err(error.into()), + } + } + "cancel" => { + let run_id = args.next().ok_or("cancel 需要 run_id")?; + let host = AgentHost::open(&db)?; + let record = host.cancel(&run_id)?; + println!("{}", serde_json::to_string_pretty(&record)?); + } + "approval" => { + let subcommand = args + .next() + .ok_or("approval 需要子命令:list/get/allow/deny/resume")?; + let host = AgentHost::open(&db)?; + match subcommand.as_str() { + "list" => { + let run_id = args.next().ok_or("approval list 需要 run_id")?; + let approvals = host.list_approvals(&run_id)?; + let approvals = approvals + .iter() + .map(redacted_approval_view) + .collect::, _>>()?; + println!("{}", serde_json::to_string_pretty(&approvals)?); + } + "get" => { + let approval_id = args.next().ok_or("approval get 需要 approval_id")?; + let approval = host + .get_approval(&approval_id)? + .ok_or("找不到指定 approval")?; + println!( + "{}", + serde_json::to_string_pretty(&redacted_approval_view(&approval)?)? + ); + } + "allow" => { + let approval_id = args.next().ok_or("approval allow 需要 approval_id")?; + let approval = host.resolve_approval(&approval_id, ApprovalDecision::Allow)?; + println!( + "{}", + serde_json::to_string_pretty(&redacted_approval_view(&approval)?)? + ); + } + "deny" => { + let approval_id = args.next().ok_or("approval deny 需要 approval_id")?; + let reason = args.collect::>().join(" "); + if reason.trim().is_empty() { + return Err("approval deny 需要拒绝原因".into()); + } + let approval = + host.resolve_approval(&approval_id, ApprovalDecision::Deny { reason })?; + println!( + "{}", + serde_json::to_string_pretty(&redacted_approval_view(&approval)?)? + ); + } + "resume" => { + let approval_id = args.next().ok_or("approval resume 需要 approval_id")?; + let record = host.resume_approval(&approval_id)?; + let pid = + spawn_worker_or_fail_unclaimed(&host, &db, &record.id, config.streaming())?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": record.status, + "worker_pid": pid, + "run_id": record.id, + "approval_id": approval_id + }))? + ); + } + other => { + return Err(format!( + "未知 approval 子命令: {other}(支持 list/get/allow/deny/resume)" + ) + .into()); + } + } + } + "resume" => { + let run_id = args.next().ok_or("resume 需要 run_id")?; + let host = AgentHost::open(&db)?; + let record = host.get_run(&run_id)?.ok_or("找不到指定 run")?; + if record.status != "queued" { + return Err(format!( + "只允许启动尚未领取的 queued run,当前状态为 {};running 请先 reconcile(不会自动重放)", + record.status + ) + .into()); + } + if host.read_checkpoint(&run_id)?.is_some() { + return Err( + "queued run 已有 checkpoint,请先用 resume-safe 完成显式 safe 恢复" + .to_owned() + .into(), + ); + } + let pid = spawn_worker_or_fail_unclaimed(&host, &db, &run_id, config.streaming())?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": "queued", + "worker_pid": pid, + "run_id": run_id + }))? + ); + } + "resume-safe" => { + let run_id = args.next().ok_or("resume-safe 需要 run_id")?; + let host = AgentHost::open(&db)?; + let record = host.requeue_safe_run(&run_id)?; + let pid = spawn_worker_or_fail_unclaimed(&host, &db, &run_id, config.streaming())?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": record.status, + "worker_pid": pid, + "run_id": run_id, + "checkpoint": "safe" + }))? + ); + } + "reconcile" => { + let host = AgentHost::open(&db)?; + match parse_reconcile_args(args.collect()) + .map_err(|error| -> Box { error.into() })? + { + ReconcileArgs::Run(run_id) => { + let record = host.reconcile_expired_run(&run_id)?; + println!("{}", serde_json::to_string_pretty(&record)?); + } + ReconcileArgs::Stale { limit } => { + let records = host.reconcile_stale_runs(limit)?; + println!("{}", serde_json::to_string_pretty(&records)?); + } + } + } + "reconcile-provider" => { + let run_id = args.next().ok_or("reconcile-provider 需要 run_id")?; + let request_id = args + .next() + .ok_or("reconcile-provider 需要 provider_request_id")?; + let messages_arg = args + .next() + .ok_or("reconcile-provider 需要消息 JSON 文件路径(或 - 读取 stdin)")?; + let messages = read_messages_arg(&messages_arg)?; + let host = AgentHost::open(&db)?; + let checkpoint = host.reconcile_provider_result(&run_id, &request_id, messages)?; + println!("{}", serde_json::to_string_pretty(&checkpoint)?); + } + "reconcile-tool" => { + let run_id = args.next().ok_or("reconcile-tool 需要 run_id")?; + let call_id = args.next().ok_or("reconcile-tool 需要 tool_call_id")?; + let messages_arg = args + .next() + .ok_or("reconcile-tool 需要消息 JSON 文件路径(或 - 读取 stdin)")?; + let messages = read_messages_arg(&messages_arg)?; + let host = AgentHost::open(&db)?; + let checkpoint = host.reconcile_tool_result(&run_id, &call_id, messages)?; + println!("{}", serde_json::to_string_pretty(&checkpoint)?); + } + "checkpoint" => { + let run_id = args.next().ok_or("checkpoint 需要 run_id")?; + let host = AgentHost::open(&db)?; + let checkpoint = host.read_checkpoint(&run_id)?; + println!("{}", serde_json::to_string_pretty(&checkpoint)?); + } + "inspect" => { + let run_id = args.next().ok_or("inspect 需要 run_id")?; + let host = AgentHost::open(&db)?; + let record = host.get_run(&run_id)?.ok_or("找不到指定 run")?; + println!("{}", serde_json::to_string_pretty(&record)?); + } + "export" => { + let run_id = args.next().ok_or("export 需要 run_id")?; + let host = AgentHost::open(&db)?; + let mut output = Vec::new(); + let count = host.export_jsonl(&run_id, &mut output)?; + print!("{}", String::from_utf8(output)?); + eprintln!("导出 {count} 条记录"); + } + "skills" => { + let subcommand = args.next().unwrap_or_else(|| "list".to_owned()); + match subcommand.as_str() { + "list" => { + let definitions = list_skills(&config)?; + println!("{}", serde_json::to_string_pretty(&definitions)?); + } + other => return Err(format!("未知 skills 子命令: {other}").into()), + } + } + "mcp" => { + let subcommand = args.next().unwrap_or_else(|| "list".to_owned()); + match subcommand.as_str() { + "list" => { + let snapshot = list_mcp(&config)?; + println!("{}", serde_json::to_string_pretty(&snapshot)?); + } + other => return Err(format!("未知 mcp 子命令: {other}").into()), + } + } + "codex" => { + let subcommand = args.next().unwrap_or_else(|| "validate".to_owned()); + match subcommand.as_str() { + "validate" => { + let Some(cli) = config.codex.cli.clone() else { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "configured": false, + "mode": "cli" + }))? + ); + return Ok(()); + }; + let backend = CodexCliBackend::new(cli)?; + let cli = backend.config(); + // 不回显完整 argv;即使白名单校验通过,也不把潜在的 + // 业务参数或可疑的可执行路径复制到 doctor/日志输出。 + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "configured": true, + "mode": "cli", + "program": redacted_program_name(&cli.program), + "arg_count": cli.args.len(), + "timeout_ms": cli.timeout_ms, + "max_output_bytes": cli.max_output_bytes + }))? + ); + } + other => return Err(format!("未知 codex 子命令: {other}(支持 validate)").into()), + } + } + "doctor" => { + let (report, failed) = doctor_report(&db, &config)?; + println!("{}", serde_json::to_string_pretty(&report)?); + if failed { + return Err("doctor 检查失败".into()); + } + } + "help" | "--help" | "-h" => print_help(), + other => { + // 兼容 README 中的简写:首个参数不是已知命令时,把整行当作任务。 + let mut words = vec![other.to_owned()]; + words.extend(args); + let task = words.join(" "); + let host = open_configured_host(&db, &config)?; + let result = + host.run_with_messages(task.clone(), prompt_messages_with_config(&task, &config)?)?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + } + Ok(()) +} + +/// 打开一个带有可选 Provider、Skill 和 MCP 配置的通用 Host。 +/// +/// 环境变量覆盖 `agent.toml`,但两者都只保存引用和普通配置;运行时密钥 +/// 始终从环境读取,不写入 SQLite。 +fn open_configured_host( + db: &Path, + config: &AgentTomlConfig, +) -> Result> { + let mut host = AgentHost::open(db)?; + match config.provider().as_str() { + "fake" => host = host.with_fake_provider(), + "openai" => { + let model = config.model(); + let model = if model == "fake" { + "gpt-4.1-mini".to_owned() + } else { + model + }; + host = host.with_openai_config(config.openai_provider_config(), model)?; + } + other => return Err(format!("未知 provider: {other}(仅支持 fake/openai)").into()), + } + + if let Some((loader, names)) = skill_config_from_config(config)? { + host = host.with_skills_from_loader(&loader, names)?; + } + + let mcp_server = mcp_config_from_config(config)?; + let mcp_name = mcp_server.as_ref().map(|config| config.name.clone()); + let mcp_context = mcp_context_selection_from_config(config)?; + if let Some(server_config) = mcp_server { + let timeout = mcp_timeout_from_config(config.mcp.timeout_secs)?; + let options = McpClientOptions::default().with_timeout(timeout); + host = if mcp_context.is_empty() { + host.with_mcp_server(&server_config, options)? + } else { + host.with_mcp_server_and_context(&server_config, options, mcp_context)? + }; + } else if !mcp_context.is_empty() { + return Err( + "设置 MCP context_resources/context_prompts 前需要设置 MCP transport 配置".into(), + ); + } + + let allowed = non_empty_env("AGENT_MCP_ALLOW") + .map(|value| split_names(&value)) + .unwrap_or_else(|| config.mcp.allow.clone()); + if !allowed.is_empty() { + let server = mcp_name.ok_or( + "设置 AGENT_MCP_ALLOW 前需要设置 AGENT_MCP_STDIO_COMMAND 或 AGENT_MCP_HTTP_URL", + )?; + let mut names = vec!["echo".to_owned()]; + names.extend(allowed.into_iter().map(|name| { + if name.starts_with("mcp:") { + name + } else { + format!("mcp:{server}:{name}") + } + })); + host = host.with_approval(Arc::new(AllowList::new(names))); + } + Ok(host) +} + +/// 只做本地配置检查。doctor 不会连接 MCP/Provider,也不会启动 Codex, +/// 但会通过 `AgentHost::open` 打开并按需初始化/迁移本地 SQLite/WAL;它只验证 +/// 真正执行路径会使用的配置边界,并把每个组件的结果单独输出,避免 SQLite +/// 正常却掩盖其它配置错误。 +fn doctor_report( + db: &Path, + config: &AgentTomlConfig, +) -> Result<(Value, bool), Box> { + let host = AgentHost::open(db)?; + let journal_mode = host.journal_mode()?; + let mut failures = Vec::new(); + let mut checks = serde_json::Map::new(); + + checks.insert( + "database".to_owned(), + json!({ + "status": "ok", + "journal_mode": journal_mode, + }), + ); + checks.insert( + "provider".to_owned(), + doctor_component("provider", doctor_provider_check(config), &mut failures), + ); + checks.insert( + "skills".to_owned(), + doctor_component("skills", doctor_skills_check(config), &mut failures), + ); + checks.insert( + "mcp".to_owned(), + doctor_component("mcp", doctor_mcp_check(config), &mut failures), + ); + checks.insert( + "codex".to_owned(), + doctor_component("codex", doctor_codex_check(config), &mut failures), + ); + + let failed = !failures.is_empty(); + let status = if failed { "error" } else { "ok" }; + Ok(( + json!({ + "status": status, + "checks": checks, + "errors": failures, + "config": env::var_os("AGENT_CONFIG") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("agent.toml")), + }), + failed, + )) +} + +fn doctor_component( + name: &str, + result: Result, + failures: &mut Vec, +) -> Value { + match result { + Ok(mut value) => { + if let Value::Object(object) = &mut value { + object.insert("status".to_owned(), Value::String("ok".to_owned())); + } + value + } + Err(error) => { + let message = format!("{name}: {error}"); + failures.push(message.clone()); + json!({"status": "error", "error": message}) + } + } +} + +fn doctor_provider_check(config: &AgentTomlConfig) -> Result { + let provider = config.provider(); + match provider.as_str() { + "fake" => { + let model = config.model(); + if model.trim().is_empty() { + return Err("Fake Provider model 不能为空".to_owned()); + } + Ok(json!({"provider": provider, "model": model})) + } + "openai" => { + let key_env = config.openai_api_key_env(); + if key_env.trim().is_empty() { + return Err("OpenAI API key 环境变量名不能为空".to_owned()); + } + let endpoint = config + .openai_provider_config() + .resolve_endpoint() + .map_err(|error| error.to_string())?; + if !env::var(&key_env) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Err(format!("OpenAI API key 环境变量不可用: {key_env}")); + } + let model = effective_model(config, &provider); + if model.trim().is_empty() { + return Err("OpenAI model 不能为空".to_owned()); + } + // endpoint 只用于确认 URL 形状;可能包含网关路由信息,不在诊断 + // 输出中回显原文。 + let _ = endpoint; + Ok(json!({ + "provider": provider, + "model": model, + "api_key_env": key_env, + "api_key_available": true, + "endpoint_configured": true, + })) + } + other => Err(format!("未知 provider: {other}(仅支持 fake/openai)")), + } +} + +fn doctor_skills_check(config: &AgentTomlConfig) -> Result { + let Some((loader, names)) = + skill_config_from_config(config).map_err(|error| error.to_string())? + else { + return Ok(json!({"configured": false, "requested": 0, "discovered": 0})); + }; + let definitions = loader + .list_definitions() + .map_err(|error| error.to_string())?; + let missing = names + .iter() + .filter(|name| { + !definitions + .iter() + .any(|definition| definition.name() == name.as_str()) + }) + .cloned() + .collect::>(); + if !missing.is_empty() { + return Err(format!("未找到显式激活的 Skill: {}", missing.join(", "))); + } + Ok(json!({ + "configured": true, + "root_count": loader.roots().len(), + "requested": names.len(), + "discovered": definitions.len(), + })) +} + +fn doctor_mcp_check(config: &AgentTomlConfig) -> Result { + let context = mcp_context_selection_from_config(config).map_err(|error| error.to_string())?; + let Some(server) = mcp_config_from_config(config).map_err(|error| error.to_string())? else { + if !context.is_empty() { + return Err( + "设置 MCP context_resources/context_prompts 前需要设置 MCP transport 配置" + .to_owned(), + ); + } + return Ok(json!({"configured": false, "auth_references": 0})); + }; + if server.name.trim().is_empty() { + return Err("MCP server name 不能为空".to_owned()); + } + + let transport_name = match &server.transport { + McpTransportConfig::Stdio { command, .. } => { + if command.trim().is_empty() { + return Err("MCP stdio command 不能为空".to_owned()); + } + if command.chars().any(char::is_control) { + return Err("MCP stdio command 不能包含控制字符".to_owned()); + } + "stdio" + } + McpTransportConfig::StreamableHttp { url, headers } => { + if url.trim().is_empty() { + return Err("MCP HTTP URL 不能为空".to_owned()); + } + let mut names = BTreeMap::::new(); + for name in headers.keys() { + let normalized = name.to_ascii_lowercase(); + if names.insert(normalized, ()).is_some() { + return Err("MCP HTTP headers 不能包含大小写重复的字段".to_owned()); + } + } + "streamable_http" + } + }; + + for auth in &server.auth { + if auth.variable.trim().is_empty() { + return Err("MCP 认证环境变量名不能为空".to_owned()); + } + if !env::var(&auth.variable) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Err(format!("MCP 认证环境变量不可用: {}", auth.variable)); + } + let target_matches = matches!( + (&server.transport, &auth.target), + ( + McpTransportConfig::StreamableHttp { .. }, + agent_mcp::McpAuthTarget::HttpBearer | agent_mcp::McpAuthTarget::HttpHeader { .. } + ) | ( + McpTransportConfig::Stdio { .. }, + agent_mcp::McpAuthTarget::StdioEnvironment { .. } + ) + ); + if !target_matches { + return Err("MCP 认证 target 与 transport 不匹配".to_owned()); + } + } + Ok(json!({ + "configured": true, + "server": server.name, + "transport": transport_name, + "auth_references": server.auth.len(), + "context_resources": context.resource_uris().len(), + "context_prompts": context.prompts().len(), + })) +} + +fn doctor_codex_check(config: &AgentTomlConfig) -> Result { + let Some(cli) = config.codex.cli.clone() else { + return Ok(json!({"configured": false, "mode": "cli"})); + }; + let backend = CodexCliBackend::new(cli).map_err(|error| error.to_string())?; + let cli = backend.config(); + Ok(json!({ + "configured": true, + "mode": "cli", + "program": redacted_program_name(&cli.program), + "arg_count": cli.args.len(), + "timeout_ms": cli.timeout_ms, + "max_output_bytes": cli.max_output_bytes, + })) +} + +fn redacted_program_name(_program: &str) -> &'static str { + // Even a basename can contain an inline token (for example a generated + // wrapper name), so diagnostics expose only that a program was configured. + "" +} + +/// 从配置构造确定性 Prompt;环境覆盖 TOML,每个 section 保持独立边界。 +fn prompt_messages_with_config( + task: &str, + config: &AgentTomlConfig, +) -> Result, Box> { + let mut prompt = PromptBuilder::new(); + if let Some(value) = + non_empty_env("AGENT_SYSTEM_PROMPT").or_else(|| config.system_prompt.clone()) + { + prompt = prompt.system(value)?; + } + if let Some(value) = + non_empty_env("AGENT_DEVELOPER_PROMPT").or_else(|| config.developer_prompt.clone()) + { + prompt = prompt.developer(value)?; + } + if let Some(value) = + non_empty_env("AGENT_CONTEXT_PROMPT").or_else(|| config.context_prompt.clone()) + { + // Context section 在旧 MessageRole 合同中通过 user 通道发送, + // 但 section 类型仍保留,Provider 不会把它误当成 system 约束。 + prompt = prompt.context(value)?; + } + prompt = prompt.user(task.to_owned())?; + Ok(prompt.build()?) +} + +type ConfiguredSkills = (SkillLoader, Vec); + +fn skill_config_from_config( + config: &AgentTomlConfig, +) -> Result, Box> { + let names = non_empty_env("AGENT_SKILLS") + .map(|value| split_names(&value)) + .unwrap_or_else(|| config.skills.names.clone()); + let roots = non_empty_env("AGENT_SKILL_ROOTS") + .or_else(|| non_empty_env("AGENT_SKILL_ROOT")) + .map(|value| { + value + .split(':') + .filter(|root| !root.trim().is_empty()) + .map(PathBuf::from) + .collect::>() + }) + .unwrap_or_else(|| config.skills.roots.iter().map(PathBuf::from).collect()); + match (roots, names) { + (roots, names) if roots.is_empty() && names.is_empty() => Ok(None), + (roots, names) if roots.is_empty() && !names.is_empty() => { + Err("AGENT_SKILLS 已设置,但缺少 AGENT_SKILL_ROOT/AGENT_SKILL_ROOTS".into()) + } + (roots, names) if !roots.is_empty() && names.is_empty() => { + Err("AGENT_SKILL_ROOT 已设置,但缺少显式 AGENT_SKILLS 名称".into()) + } + (roots, names) => Ok(Some((SkillLoader::with_roots(roots), names))), + } +} + +fn mcp_config_from_config( + config: &AgentTomlConfig, +) -> Result, Box> { + let stdio_args = non_empty_env("AGENT_MCP_STDIO_ARGS").or_else(|| { + (!config.mcp.stdio_args.is_empty()) + .then(|| serde_json::to_string(&config.mcp.stdio_args).expect("字符串数组可序列化")) + }); + // 旧的 `AGENT_MCP_HTTP_HEADERS` 仍可作为进程环境中的兼容入口;TOML + // 不再接受明文 header map,只能通过 `[[mcp.auth]]` 引用环境变量。 + let http_headers = non_empty_env("AGENT_MCP_HTTP_HEADERS"); + let auth = config + .mcp + .auth + .clone() + .into_iter() + .map(McpAuthToml::into_core) + .collect::, _>>()?; + mcp_config_from_values_with_auth( + non_empty_env("AGENT_MCP_STDIO_COMMAND").or_else(|| config.mcp.stdio_command.clone()), + stdio_args, + non_empty_env("AGENT_MCP_HTTP_URL").or_else(|| config.mcp.http_url.clone()), + http_headers, + non_empty_env("AGENT_MCP_SERVER").or_else(|| config.mcp.server.clone()), + auth, + ) +} + +/// 读取显式 MCP context 选择;默认不读取任何 resource/prompt。 +/// 环境变量使用与工具 allow list 相同的逗号/空白分隔形式,TOML 则保留 +/// 每个 URI/名称作为一个字符串。prompt 参数需要更丰富的形状时请使用 +/// `agent-host::McpContextSelection` 库 API,而不是在 CLI 中猜测 wire。 +fn mcp_context_selection_from_config( + config: &AgentTomlConfig, +) -> Result> { + let resources = non_empty_env("AGENT_MCP_CONTEXT_RESOURCES") + .map(|value| split_names(&value)) + .unwrap_or_else(|| config.mcp.context_resources.clone()); + let prompts = non_empty_env("AGENT_MCP_CONTEXT_PROMPTS") + .map(|value| split_names(&value)) + .unwrap_or_else(|| config.mcp.context_prompts.clone()); + + let mut selection = McpContextSelection::new(); + for uri in resources { + selection = selection.with_resource_uri(uri); + } + for name in prompts { + selection = selection.with_prompt(name); + } + Ok(selection) +} + +/// 纯配置组装函数;把环境读取留在上一层,测试和嵌入宿主可以不改全局环境 +/// 就验证 transport 互斥、参数归属和认证头解析。 +#[cfg(test)] +fn mcp_config_from_values( + command: Option, + stdio_args: Option, + url: Option, + http_headers: Option, + configured_server: Option, +) -> Result, Box> { + mcp_config_from_values_with_auth( + command, + stdio_args, + url, + http_headers, + configured_server, + Vec::new(), + ) +} + +/// 组装 MCP 配置并保留认证环境变量引用;这里绝不解析或复制 secret。 +fn mcp_config_from_values_with_auth( + command: Option, + stdio_args: Option, + url: Option, + http_headers: Option, + configured_server: Option, + auth: Vec, +) -> Result, Box> { + if command.is_some() && url.is_some() { + return Err("AGENT_MCP_STDIO_COMMAND 与 AGENT_MCP_HTTP_URL 只能配置一个".into()); + } + if command.is_none() && stdio_args.is_some() { + return Err("设置 AGENT_MCP_STDIO_ARGS 前需要设置 AGENT_MCP_STDIO_COMMAND".into()); + } + if url.is_none() && http_headers.is_some() { + return Err("设置 AGENT_MCP_HTTP_HEADERS 前需要设置 AGENT_MCP_HTTP_URL".into()); + } + if command.is_none() && url.is_none() && !auth.is_empty() { + return Err("设置 MCP 认证引用前需要设置 MCP transport 配置".into()); + } + let Some(server_name) = configured_server + .or_else(|| (command.is_some() || url.is_some()).then(|| "default".to_owned())) + else { + return Ok(None); + }; + + let transport = if let Some(command) = command { + let args = stdio_args + .map(|value| parse_stdio_args(&value)) + .transpose()? + .unwrap_or_default(); + McpTransportConfig::stdio(command, args) + } else if let Some(url) = url { + let mut transport = McpTransportConfig::streamable_http(url); + if let Some(raw_headers) = http_headers { + let headers = serde_json::from_str::>(&raw_headers).map_err( + |error| format!("AGENT_MCP_HTTP_HEADERS 必须是 JSON 字符串对象: {error}"), + )?; + if let McpTransportConfig::StreamableHttp { + headers: configured, + .. + } = &mut transport + { + *configured = headers; + } + } + transport + } else { + return Err("AGENT_MCP_SERVER 已设置,但缺少 MCP transport 配置".into()); + }; + let mut server = McpServerConfig::new(server_name, transport); + server.auth = auth; + Ok(Some(server)) +} + +/// JSON 数组可精确保留空格;简短命令仍可使用无 shell 展开的空白分隔形式。 +fn parse_stdio_args(value: &str) -> Result, Box> { + if value.trim_start().starts_with('[') { + return serde_json::from_str::>(value) + .map_err(|error| format!("AGENT_MCP_STDIO_ARGS JSON 数组无效: {error}").into()); + } + Ok(value.split_whitespace().map(str::to_owned).collect()) +} + +fn mcp_timeout_from_config( + configured_seconds: Option, +) -> Result> { + let seconds = non_empty_env("AGENT_MCP_TIMEOUT_SECS") + .map(|value| { + value + .parse::() + .map_err(|error| format!("AGENT_MCP_TIMEOUT_SECS 无效: {error}")) + }) + .transpose()? + .or(configured_seconds) + .unwrap_or(30); + if seconds == 0 { + return Err("AGENT_MCP_TIMEOUT_SECS 必须大于 0".into()); + } + Ok(Duration::from_secs(seconds)) +} + +fn non_empty_env(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) +} + +/// `run` 的选项解析保持无依赖、无 shell 语义;未知参数仍作为任务文本保留。 +/// 这样脚本可以把 `--jsonl` 放在任务前后,而不会改变任务中的其它词。 +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct RunOptions { + background: bool, + jsonl: bool, + streaming: Option, +} + +fn parse_run_options(args: Vec) -> Result<(RunOptions, Vec), String> { + let mut options = RunOptions::default(); + let mut task = Vec::new(); + for arg in args { + match arg.as_str() { + "--background" => options.background = true, + "--jsonl" => options.jsonl = true, + "--stream" => { + if options.streaming == Some(false) { + return Err("--stream 与 --no-stream 不能同时使用".to_owned()); + } + options.streaming = Some(true); + } + "--no-stream" => { + if options.streaming == Some(true) { + return Err("--stream 与 --no-stream 不能同时使用".to_owned()); + } + options.streaming = Some(false); + } + _ => task.push(arg), + } + } + Ok((options, task)) +} + +/// 将一次同步 Host 结果编码为稳定的 NDJSON 记录。 +/// +/// `engine_event` 和 `stream_event` 记录保留各自的事件类型;最后的 +/// `result` 记录携带原来的完整 `HostRunOutput`,因此已有 JSON 消费者可以 +/// 只读取最后一行,而需要增量审计的脚本可以逐行处理前面的事件。 +fn jsonl_run_records(result: &HostRunOutput) -> Result, serde_json::Error> { + let identity = |record_type: &str| { + json!({ + "type": record_type, + "session_id": result.session_id, + "run_id": result.run_id, + "runtime_id": result.runtime_id, + }) + }; + let mut records = + Vec::with_capacity(result.output.events.len() + result.output.stream_events.len() + 1); + for event in &result.output.events { + let mut record = identity("engine_event"); + record["event"] = serde_json::to_value(event)?; + records.push(record); + } + for event in &result.output.stream_events { + let mut record = identity("stream_event"); + record["event"] = serde_json::to_value(event)?; + records.push(record); + } + let mut final_record = identity("result"); + final_record["result"] = serde_json::to_value(result)?; + records.push(final_record); + Ok(records) +} + +fn print_run_result(result: &HostRunOutput, jsonl: bool) -> Result<(), Box> { + if !jsonl { + println!("{}", serde_json::to_string_pretty(result)?); + return Ok(()); + } + for record in jsonl_run_records(result)? { + println!("{}", serde_json::to_string(&record)?); + } + Ok(()) +} + +/// 把 Host 返回的审批记录转换成 CLI 展示视图。 +/// +/// `ApprovalRecord.approval_token` 以及持久化在 `request` 里的 +/// `approvalToken` 都是 Host 恢复时校验 binding 所需的内部值,不能随 +/// `approval list/get/allow/deny` 回显。这里仅在输出边界复制 JSON 并移除 +/// token 字段,Host 内部仍持有完整记录,故不会影响 `approval resume`。 +fn redacted_approval_view(record: &T) -> Result { + let mut value = serde_json::to_value(record)?; + redact_approval_tokens(&mut value); + Ok(value) +} + +/// 当前记录本身使用 snake_case、嵌套 Core request 使用 camelCase;递归移除 +/// 两种字段名可覆盖这两个持久化层次,同时保留审批请求中的工具参数等展示信息。 +fn redact_approval_tokens(value: &mut Value) { + match value { + Value::Object(object) => { + object.remove("approval_token"); + object.remove("approvalToken"); + for child in object.values_mut() { + redact_approval_tokens(child); + } + } + Value::Array(values) => { + for child in values { + redact_approval_tokens(child); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +fn print_queued_result( + handle: &HostRunHandle, + worker_pid: u32, + jsonl: bool, +) -> Result<(), Box> { + let record = json!({ + "type": "queued", + "status": "queued", + "worker_pid": worker_pid, + "session_id": handle.session_id, + "run_id": handle.run_id, + "runtime_id": handle.runtime_id, + }); + if jsonl { + println!("{}", serde_json::to_string(&record)?); + } else { + println!("{}", serde_json::to_string_pretty(&record)?); + } + Ok(()) +} + +fn split_names(value: &str) -> Vec { + value + .split([',', ' ', '\n', '\t']) + .filter(|name| !name.trim().is_empty()) + .map(|name| name.trim().to_owned()) + .collect() +} + +/// 读取外部对账方提交的完整 Core 消息历史。支持文件路径、`-` stdin, +/// 以及直接传入以 `[` 开头的 JSON,方便脚本在不落盘时调用。 +fn read_messages_arg(value: &str) -> Result, Box> { + let raw = if value == "-" { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input)?; + input + } else if value.trim_start().starts_with('[') { + value.to_owned() + } else { + fs::read_to_string(value)? + }; + serde_json::from_str::>(&raw) + .map_err(|error| format!("对账消息必须是 Core Message JSON 数组: {error}").into()) +} + +/// 启动后台 worker;如果子进程根本没有成功创建,则只尝试把仍未领取的 +/// durable run 收束为 failed。`fail_unclaimed_run` 自身会原子检查 status、 +/// cancel_requested 和 lease,因此不会覆盖已经被其它 worker 领取的 run。 +fn spawn_worker_or_fail_unclaimed( + host: &AgentHost, + db: &Path, + run_id: &str, + streaming: bool, +) -> Result> { + spawn_worker_or_fail_unclaimed_with( + run_id, + || spawn_worker(db, run_id, streaming), + |reason| { + host.fail_unclaimed_run(run_id, reason) + .map(|_| ()) + .map_err(Into::into) + }, + ) +} + +/// `spawn_worker_or_fail_unclaimed` 的可注入内核,供 CLI 单测覆盖子进程 +/// 创建失败和 durable 收口失败,而无需真的 fork 当前可执行文件。 +fn spawn_worker_or_fail_unclaimed_with( + run_id: &str, + spawn: S, + fail_unclaimed: C, +) -> Result> +where + S: FnOnce() -> Result>, + C: FnOnce(&str) -> Result<(), Box>, +{ + match spawn() { + Ok(pid) => Ok(pid), + Err(spawn_error) => { + let reason = format!("worker 启动失败: {spawn_error}"); + match fail_unclaimed(&reason) { + Ok(()) => { + Err(format!("{reason};run {run_id} 已标记 failed,未启动 worker").into()) + } + Err(control_error) => { + Err(format!("{reason};run {run_id} 未能自动收口: {control_error}").into()) + } + } + } + } +} + +fn spawn_worker( + db: &Path, + run_id: &str, + streaming: bool, +) -> Result> { + let executable = env::current_exe()?; + let child = Command::new(executable) + .arg("worker") + .arg(run_id) + .env("AGENT_DB", db) + .env("AGENT_STREAM_WORKER", if streaming { "1" } else { "0" }) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(child.id()) +} + +fn skill_roots_from_config(config: &AgentTomlConfig) -> Vec { + non_empty_env("AGENT_SKILL_ROOTS") + .or_else(|| non_empty_env("AGENT_SKILL_ROOT")) + .map(|value| { + value + .split(':') + .filter(|root| !root.trim().is_empty()) + .map(PathBuf::from) + .collect() + }) + .unwrap_or_else(|| config.skills.roots.iter().map(PathBuf::from).collect()) +} + +fn list_skills( + config: &AgentTomlConfig, +) -> Result, Box> { + let roots = skill_roots_from_config(config); + if roots.is_empty() { + return Ok(Vec::new()); + } + Ok(SkillLoader::with_roots(roots).list_definitions()?) +} + +fn list_mcp(config: &AgentTomlConfig) -> Result> { + let server = mcp_config_from_config(config)? + .ok_or("未配置 MCP;请设置 agent.toml 的 [mcp] 或 AGENT_MCP_* 环境变量")?; + let mut client = agent_mcp::McpClient::connect( + &server, + McpClientOptions::default().with_timeout(mcp_timeout_from_config(config.mcp.timeout_secs)?), + )?; + mcp_list_from_client(&server.name, &mut client) +} + +/// 从同一个能力快照生成 `mcp list` 输出。 +/// +/// 保持这个小 helper 独立于连接装配,既让 CLI 的真实路径只握手一次, +/// 也让离线 transport 回归能验证展示内容和指纹来自同一批目录请求。 +fn mcp_list_from_client( + server_name: &str, + client: &mut agent_mcp::McpClient, +) -> Result> { + // Use one capability snapshot for both displayed tools and its fingerprint. + // Calling list_tools first and capability_fingerprint second would issue a + // second tools/list request, allowing a changing server to produce a + // fingerprint that does not describe the tools shown to the user. Errors + // stay visible instead of being converted into a misleading null hash. + let snapshot = client.capability_snapshot()?; + let capability_fingerprint = snapshot.fingerprint()?; + Ok(json!({ + "server": server_name, + "tools": snapshot.tools, + "capability_fingerprint": capability_fingerprint, + })) +} + +fn print_help() { + println!( + r#"用法: + agent run [--stream|--no-stream] [--jsonl] [任务] + agent run --background [--jsonl] [任务] + agent worker # 内部 worker + agent cancel + agent approval list + agent approval get + agent approval allow + agent approval deny <原因> + agent approval resume # resolve 后显式启动 worker + agent resume # 仅启动 queued run + agent resume-safe # 外部对账后只从 safe checkpoint 继续 + agent reconcile # 过期 running -> reconciling,不重放 + agent reconcile --stale [limit] # 有界扫描失去 lease 的 run,默认最多 256 项 + agent reconcile-provider # 写入已核对 Provider 响应 + agent reconcile-tool # 写入已核对工具结果 + agent checkpoint # 查看最近边界检查点,不改变状态 + agent inspect + agent export + agent skills list + agent mcp list + agent codex validate # 校验 agent.toml 中受限 Codex CLI 配置,不启动进程 + agent doctor # 不联网/不启动外部进程;会打开并按需初始化本地 SQLite/WAL + +基础配置:AGENT_CONFIG(默认 agent.toml)、AGENT_DB、AGENT_PROVIDER、AGENT_MODEL、OPENAI_MODEL、AGENT_STREAM、OPENAI_API_KEY、OPENAI_API_KEY_ENV、AGENT_OPENAI_API_KEY_ENV、OPENAI_BASE_URL、OPENAI_ENDPOINT、AGENT_SYSTEM_PROMPT、AGENT_DEVELOPER_PROMPT、AGENT_CONTEXT_PROMPT。 +OpenAI endpoint:OPENAI_ENDPOINT 是完整请求地址;否则使用 OPENAI_BASE_URL 并自动补 /responses。TOML 可写 openai_endpoint 或 openai_base_url;环境变量优先于 TOML。TOML 示例:provider = "fake";model = "fake";stream = true;db = "agent.db";openai_api_key_env = "OPENAI_API_KEY";openai_base_url = "https://gateway.example/v1";[skills] roots = [".codex/skills"] names = ["review"]。 +Skill:AGENT_SKILL_ROOT(或 AGENT_SKILL_ROOTS,冒号分隔)+ AGENT_SKILLS(逗号/空白分隔,必须显式列名)。 +MCP:AGENT_MCP_STDIO_COMMAND + AGENT_MCP_STDIO_ARGS,或 AGENT_MCP_HTTP_URL;认证使用 [[mcp.auth]] variable/target/name/prefix 引用环境变量(不写 token 原文),兼容入口 AGENT_MCP_HTTP_HEADERS(JSON 字符串对象);可选 AGENT_MCP_SERVER、AGENT_MCP_TIMEOUT_SECS、AGENT_MCP_CONTEXT_RESOURCES、AGENT_MCP_CONTEXT_PROMPTS。context 只读取显式列出的 URI/名称,并作为不可信内容注入。 +MCP 工具默认拒绝,设置 AGENT_MCP_ALLOW(原名或 mcp:server:name,逗号/空白分隔)后才放行;命令和参数不会经过 shell 展开。 +Codex:`[codex.cli]` 可配置 program/args/timeout_ms/max_output_bytes/allowed_arg_prefixes;`codex validate` 只做本地白名单校验,不启动进程、不写入运行状态。App Server 仍通过 `agent-codex` 的 channel API 注入。 +取消是 cooperative:当前 Provider/工具调用返回后在下一个 step 生效。worker lease 过期后必须先对账,CLI 不会自动重放外部调用;reconcile-provider/reconcile-tool 只接受完整消息历史并写 safe checkpoint,随后仍需 resume-safe。"# + ); +} + +#[cfg(test)] +mod tests { + use super::{ + AgentTomlConfig, DEFAULT_STALE_RECONCILE_LIMIT, HostRunOutput, McpAuthToml, ReconcileArgs, + doctor_mcp_check, jsonl_run_records, mcp_config_from_values, + mcp_config_from_values_with_auth, mcp_context_selection_from_config, mcp_list_from_client, + parse_reconcile_args, parse_run_options, parse_stdio_args, read_messages_arg, + redacted_approval_view, spawn_worker_or_fail_unclaimed_with, + }; + use agent_codex::CodexCliBackend; + use agent_mcp::{ + DEFAULT_PROTOCOL_VERSION, JsonRpcRequest, JsonRpcResponse, McpAuthEnv, McpAuthTarget, + McpCapabilitySnapshot, McpError, McpSyncTransport, McpToolDefinition, McpTransportConfig, + }; + use agent_runtime_engine::{AgentOutput, EngineEvent}; + use serde_json::json; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + #[test] + fn blank_toml_model_is_treated_as_unset() { + let config: AgentTomlConfig = + toml::from_str("provider = 'openai'\nmodel = ' '").expect("配置应可解析"); + assert_eq!(config.model(), "fake"); + assert_eq!(super::effective_model(&config, "openai"), "gpt-4.1-mini"); + } + + #[test] + fn background_queue_metadata_uses_configured_provider_and_effective_model() { + let config: AgentTomlConfig = + toml::from_str("provider = 'openai'\nmodel = ' '\n").expect("配置应可解析"); + let metadata = super::queued_run_metadata(&config, "openai"); + assert_eq!(metadata["provider"], "gpt-4.1-mini"); + assert_eq!(metadata["providerKind"], "openai"); + } + + #[test] + fn run_options_allow_jsonl_anywhere_and_reject_conflicting_stream_flags() { + let (options, task) = parse_run_options(vec![ + "--jsonl".to_owned(), + "--stream".to_owned(), + "回答".to_owned(), + "--background".to_owned(), + ]) + .unwrap(); + assert_eq!( + options, + super::RunOptions { + background: true, + jsonl: true, + streaming: Some(true), + } + ); + assert_eq!(task, ["回答"]); + assert!(parse_run_options(vec!["--stream".to_owned(), "--no-stream".to_owned(),]).is_err()); + } + + #[test] + fn reconcile_args_keep_single_run_and_add_bounded_stale_batch() { + assert_eq!( + parse_reconcile_args(vec!["run-123".to_owned()]).unwrap(), + ReconcileArgs::Run("run-123".to_owned()) + ); + assert_eq!( + parse_reconcile_args(vec!["--stale".to_owned()]).unwrap(), + ReconcileArgs::Stale { + limit: DEFAULT_STALE_RECONCILE_LIMIT + } + ); + assert_eq!( + parse_reconcile_args(vec!["--stale".to_owned(), "7".to_owned()]).unwrap(), + ReconcileArgs::Stale { limit: 7 } + ); + assert!(parse_reconcile_args(Vec::new()).is_err()); + assert!(parse_reconcile_args(vec!["--stale".to_owned(), "nope".to_owned()]).is_err()); + assert!( + parse_reconcile_args(vec![ + "--stale".to_owned(), + "1".to_owned(), + "extra".to_owned(), + ]) + .is_err() + ); + } + + #[test] + fn jsonl_records_are_one_line_and_end_with_complete_result() { + let result = HostRunOutput { + session_id: "session".to_owned(), + run_id: "run".to_owned(), + runtime_id: "runtime".to_owned(), + output: AgentOutput { + text: "done".to_owned(), + steps: 1, + events: vec![EngineEvent::Finished { + step: 0, + text: "done".to_owned(), + }], + stream_events: Vec::new(), + context_observations: Vec::new(), + messages: Vec::new(), + }, + }; + let records = jsonl_run_records(&result).unwrap(); + assert_eq!(records.len(), 2); + assert_eq!(records[0]["type"], "engine_event"); + assert_eq!(records[1]["type"], "result"); + assert_eq!(records[1]["result"]["run_id"], "run"); + for record in records { + let line = serde_json::to_string(&record).unwrap(); + assert!(!line.contains('\n')); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + record + ); + } + } + + #[test] + fn approval_cli_view_redacts_binding_tokens_but_keeps_request_details() { + // Storage records keep the binding token twice: once as the dedicated + // column and once inside the serialized Core request. The CLI view + // must hide both copies while leaving the fields users need to decide. + let record = serde_json::json!({ + "id": "approval-1", + "status": "pending", + "approval_token": "approval-secret", + "request": { + "requestId": "approval-1", + "approvalToken": "approval-secret", + "call": {"id": "call-1", "name": "echo"}, + "metadata": [{"approval_token": "nested-secret"}] + }, + "decision": null + }); + + let view = redacted_approval_view(&record).unwrap(); + let encoded = serde_json::to_string(&view).unwrap(); + assert!(!encoded.contains("approval-secret")); + assert!(!encoded.contains("nested-secret")); + assert!(view.get("approval_token").is_none()); + assert!(view["request"].get("approvalToken").is_none()); + assert!(view["request"]["call"].get("name").is_some()); + assert_eq!(view["status"], "pending"); + } + + #[test] + fn mcp_stdio_args_support_exact_json_array_and_simple_whitespace() { + assert_eq!( + parse_stdio_args(r#"["-c","print('hello world')"]"#).unwrap(), + ["-c", "print('hello world')"] + ); + assert_eq!( + parse_stdio_args("-y @example/server").unwrap(), + ["-y", "@example/server"] + ); + } + + #[test] + fn mcp_config_rejects_stray_options_and_conflicting_transports() { + assert!(mcp_config_from_values(None, Some("-x".to_owned()), None, None, None,).is_err()); + assert!(mcp_config_from_values(None, None, None, Some("{}".to_owned()), None,).is_err()); + assert!( + mcp_config_from_values( + Some("stdio-server".to_owned()), + None, + Some("http://127.0.0.1:1".to_owned()), + None, + None, + ) + .is_err() + ); + assert!( + mcp_config_from_values(None, None, None, None, Some("configured-only".to_owned()),) + .is_err() + ); + } + + #[test] + fn mcp_config_builds_stdio_and_http_values_without_environment_mutation() { + let stdio = mcp_config_from_values( + Some("node".to_owned()), + Some(r#"["server.js","--stdio"]"#.to_owned()), + None, + None, + Some("tools".to_owned()), + ) + .unwrap() + .unwrap(); + assert_eq!(stdio.name, "tools"); + match stdio.transport { + McpTransportConfig::Stdio { command, args, .. } => { + assert_eq!(command, "node"); + assert_eq!(args, ["server.js", "--stdio"]); + } + _ => panic!("expected stdio transport"), + } + + let http = mcp_config_from_values( + None, + None, + Some("http://127.0.0.1:4318/mcp".to_owned()), + Some(r#"{"Authorization":"Bearer test","X-Trace":"one"}"#.to_owned()), + None, + ) + .unwrap() + .unwrap(); + assert_eq!(http.name, "default"); + match http.transport { + McpTransportConfig::StreamableHttp { url, headers } => { + assert_eq!(url, "http://127.0.0.1:4318/mcp"); + assert_eq!( + headers.get("Authorization"), + Some(&"Bearer test".to_owned()) + ); + assert_eq!(headers.get("X-Trace"), Some(&"one".to_owned())); + } + _ => panic!("expected streamable HTTP transport"), + } + } + + #[test] + fn mcp_list_uses_one_capability_snapshot_for_tools_and_fingerprint() { + struct CountingCatalog { + tools_requests: Arc>, + } + + impl McpSyncTransport for CountingCatalog { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + let result = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": DEFAULT_PROTOCOL_VERSION, + "capabilities": {"tools": {}} + }), + "tools/list" => { + *self.tools_requests.lock().unwrap() += 1; + json!({ + "tools": [{"name": "echo", "inputSchema": {"type": "object"}}] + }) + } + other => return Err(McpError::Protocol(format!("unexpected method: {other}"))), + }; + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(result), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &agent_mcp::JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + } + + let tools_requests = Arc::new(Mutex::new(0)); + let mut client = agent_mcp::McpClient::with_transport( + "catalog", + CountingCatalog { + tools_requests: tools_requests.clone(), + }, + agent_mcp::McpClientOptions::default(), + ); + let output = mcp_list_from_client("catalog", &mut client).unwrap(); + assert_eq!(*tools_requests.lock().unwrap(), 1); + assert_eq!(output["tools"][0]["name"], "echo"); + + let expected = McpCapabilitySnapshot { + initialize: json!({ + "protocolVersion": DEFAULT_PROTOCOL_VERSION, + "capabilities": {"tools": {}} + }), + tools: vec![McpToolDefinition::new("echo", json!({"type": "object"}))], + resources: Vec::new(), + prompts: Vec::new(), + }; + assert_eq!( + output["capability_fingerprint"], + expected.fingerprint().unwrap() + ); + } + + #[test] + fn reconciliation_message_argument_accepts_inline_core_json() { + let messages = + read_messages_arg(r#"[{"role":"user","content":[{"type":"text","text":"hello"}]}]"#) + .unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content()[0].as_text(), Some("hello")); + } + + #[test] + fn toml_config_keeps_provider_and_secret_reference_separate() { + let config: AgentTomlConfig = toml::from_str( + r#" + db = "./tmp/agent.db" + provider = "openai" + model = "gpt-test" + openai_api_key_env = "TEAM_OPENAI_KEY" + openai_base_url = "https://gateway.example/v1" + system_prompt = "be concise" + [skills] + roots = [".codex/skills"] + names = ["review"] + [mcp] + server = "workspace" + stdio_command = "node" + stdio_args = ["server.js"] + timeout_secs = 9 + allow = ["read"] + context_resources = ["file:///workspace/README.md"] + context_prompts = ["welcome"] + + [[mcp.auth]] + variable = "TEAM_MCP_TOKEN" + target = "stdio_environment" + name = "MCP_TOKEN" + "#, + ) + .unwrap(); + assert_eq!(config.provider.as_deref(), Some("openai")); + assert_eq!( + config.openai_api_key_env.as_deref(), + Some("TEAM_OPENAI_KEY") + ); + assert_eq!( + config.openai_base_url.as_deref(), + Some("https://gateway.example/v1") + ); + assert_eq!( + config.openai_provider_config().resolve_endpoint().unwrap(), + "https://gateway.example/v1/responses" + ); + assert_eq!(config.skills.names, ["review"]); + assert_eq!(config.mcp.timeout_secs, Some(9)); + let context = mcp_context_selection_from_config(&config).unwrap(); + assert_eq!(context.resource_uris(), ["file:///workspace/README.md"]); + assert_eq!(context.prompts().len(), 1); + assert_eq!(context.prompts()[0].name(), "welcome"); + assert_eq!(config.mcp.auth.len(), 1); + assert_eq!(config.mcp.auth[0].variable, "TEAM_MCP_TOKEN"); + assert_eq!(config.mcp.auth[0].target, "stdio_environment"); + assert_eq!(config.mcp.auth[0].name.as_deref(), Some("MCP_TOKEN")); + // 序列化/调试只应携带环境变量名和目标,不应出现任何 token 原文。 + let encoded = toml::to_string(&config).unwrap(); + let debug = format!("{config:?}"); + assert!(encoded.contains("TEAM_MCP_TOKEN")); + assert!(debug.contains("TEAM_MCP_TOKEN") || debug.contains("env-ref")); + for secret in ["plaintext-token", "Bearer plaintext-token"] { + assert!(!encoded.contains(secret)); + assert!(!debug.contains(secret)); + } + assert!(toml::from_str::("api_key = 'plaintext'").is_err()); + // 明文 TOML header map 不再是允许字段,避免 secret 落盘。 + assert!( + toml::from_str::( + "[mcp]\nhttp_headers = { Authorization = 'Bearer plaintext-token' }" + ) + .is_err() + ); + let core = config.mcp.auth[0].clone().into_core().unwrap(); + assert!(matches!( + core.target, + McpAuthTarget::StdioEnvironment { name } if name == "MCP_TOKEN" + )); + } + + #[test] + fn openai_config_accepts_full_endpoint_without_secret_value() { + let config: AgentTomlConfig = toml::from_str( + r#" + provider = "openai" + openai_api_key_env = "TEAM_OPENAI_KEY" + openai_endpoint = "https://gateway.example/v1/responses" + "#, + ) + .unwrap(); + let provider_config = config.openai_provider_config(); + assert_eq!( + provider_config.resolve_endpoint().unwrap(), + "https://gateway.example/v1/responses" + ); + let encoded = toml::to_string(&config).unwrap(); + assert!(encoded.contains("TEAM_OPENAI_KEY")); + assert!(!encoded.contains("secret-key")); + } + + #[test] + fn openai_endpoint_precedence_is_environment_first_across_endpoint_forms() { + let config: AgentTomlConfig = toml::from_str( + r#" + provider = "openai" + openai_endpoint = "https://toml.example/v1/responses" + openai_base_url = "https://toml-base.example/v1" + "#, + ) + .unwrap(); + + // A complete environment endpoint wins over every lower-priority + // source, including another environment base URL and both TOML forms. + assert_eq!( + config + .openai_provider_config_with_env( + Some("https://env.example/v1/responses"), + Some("https://env-base.example/v1"), + ) + .resolve_endpoint() + .unwrap(), + "https://env.example/v1/responses" + ); + // An environment base URL still overrides a TOML full endpoint; the + // source priority is evaluated before the full-vs-base preference. + assert_eq!( + config + .openai_provider_config_with_env(None, Some("https://env-base.example/v1"),) + .resolve_endpoint() + .unwrap(), + "https://env-base.example/v1/responses" + ); + // With no environment override, TOML keeps its own full-endpoint + // precedence, then falls back to TOML base URL when needed. + assert_eq!( + config + .openai_provider_config_with_env(None, None) + .resolve_endpoint() + .unwrap(), + "https://toml.example/v1/responses" + ); + + let base_only: AgentTomlConfig = toml::from_str( + r#" + provider = "openai" + openai_base_url = "https://toml-base.example/v1" + "#, + ) + .unwrap(); + assert_eq!( + base_only + .openai_provider_config_with_env(None, None) + .resolve_endpoint() + .unwrap(), + "https://toml-base.example/v1/responses" + ); + // Blank environment variables are treated as absent, matching the + // normal `non_empty_env` behavior used by the live CLI path. + assert_eq!( + base_only + .openai_provider_config_with_env(Some(" "), Some("")) + .resolve_endpoint() + .unwrap(), + "https://toml-base.example/v1/responses" + ); + } + + #[test] + fn mcp_context_requires_an_explicit_transport() { + let config: AgentTomlConfig = toml::from_str( + r#" + [mcp] + context_resources = ["file:///workspace/README.md"] + "#, + ) + .unwrap(); + let error = doctor_mcp_check(&config).unwrap_err(); + assert!(error.contains("context_resources")); + assert!(error.contains("transport")); + } + + #[test] + fn codex_cli_toml_is_validated_without_echoing_argv() { + let config: AgentTomlConfig = toml::from_str( + r#" + [codex.cli] + program = "codex" + args = ["--model=gpt-test"] + allowed_arg_prefixes = ["--model"] + timeout_ms = 5000 + max_output_bytes = 4096 + "#, + ) + .unwrap(); + let backend = CodexCliBackend::new(config.codex.cli.clone().unwrap()).unwrap(); + assert_eq!(backend.config().timeout_ms, 5000); + assert_eq!(backend.config().max_output_bytes, 4096); + let encoded = serde_json::to_string(&config).unwrap(); + assert!(encoded.contains("gpt-test")); + // CLI 的 validate 输出只会返回 arg_count,不应把完整 argv 当作 + // 诊断日志;这里锁定配置本身可解析,具体输出由命令分支控制。 + assert_eq!(config.codex.cli.unwrap().args.len(), 1); + } + + #[test] + fn doctor_codex_view_does_not_echo_program_path() { + let config: AgentTomlConfig = toml::from_str( + r#" + [codex.cli] + program = "/tmp/generated-wrapper-with-token" + "#, + ) + .unwrap(); + let report = super::doctor_codex_check(&config).unwrap(); + assert_eq!(report["program"], ""); + assert!( + !serde_json::to_string(&report) + .unwrap() + .contains("generated-wrapper-with-token") + ); + } + + #[test] + fn doctor_mcp_check_rejects_empty_auth_reference_without_connecting() { + let config: AgentTomlConfig = toml::from_str( + r#" + [mcp] + server = "workspace" + http_url = "https://example.test/mcp" + + [[mcp.auth]] + variable = "" + target = "http_bearer" + "#, + ) + .unwrap(); + let error = super::doctor_mcp_check(&config).unwrap_err(); + assert!(error.contains("variable")); + } + + #[test] + fn worker_spawn_failure_uses_injected_control_to_fail_unclaimed_run() { + let host = agent_host::AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("worker spawn failure").unwrap(); + let run_id = handle.run_id.clone(); + let mut control_calls = 0; + + let error = spawn_worker_or_fail_unclaimed_with( + &run_id, + || Err(std::io::Error::other("injected spawn failure").into()), + |reason| { + control_calls += 1; + host.fail_unclaimed_run(&run_id, reason) + .map(|_| ()) + .map_err(|error| -> Box { error.into() }) + }, + ) + .unwrap_err(); + + assert_eq!(control_calls, 1); + assert!(error.to_string().contains("已标记 failed")); + assert_eq!(host.get_run(&run_id).unwrap().unwrap().status, "failed"); + } + + #[test] + fn worker_spawn_success_does_not_invoke_injected_failure_control() { + let mut control_called = false; + let pid = spawn_worker_or_fail_unclaimed_with( + "run-success", + || Ok(4242), + |_| { + control_called = true; + Err(std::io::Error::other("control must not run").into()) + }, + ) + .unwrap(); + + assert_eq!(pid, 4242); + assert!(!control_called); + } + + #[test] + fn worker_spawn_failure_preserves_control_error() { + let error = spawn_worker_or_fail_unclaimed_with( + "run-control-error", + || Err(std::io::Error::other("injected spawn failure").into()), + |_| Err(std::io::Error::other("lease already held").into()), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("worker 启动失败")); + assert!(error.contains("未能自动收口")); + assert!(error.contains("lease already held")); + assert!(!error.contains("已标记 failed")); + } + + #[test] + fn mcp_auth_reference_is_wired_without_resolving_secret() { + let reference = McpAuthEnv::http_bearer("TEAM_MCP_TOKEN"); + let config = mcp_config_from_values_with_auth( + None, + None, + Some("https://example.test/mcp".to_owned()), + None, + Some("workspace".to_owned()), + vec![reference.clone()], + ) + .unwrap() + .unwrap(); + + assert_eq!(config.name, "workspace"); + assert_eq!(config.auth, vec![reference]); + let encoded = serde_json::to_string(&config).unwrap(); + assert!(encoded.contains("TEAM_MCP_TOKEN")); + assert!(!encoded.contains("resolved-secret")); + let debug = format!("{config:?}"); + assert!(!debug.contains("resolved-secret")); + assert!(debug.contains("env refs")); + } + + #[test] + fn mcp_auth_reference_without_transport_is_rejected() { + let error = mcp_config_from_values_with_auth( + None, + None, + None, + None, + Some("workspace".to_owned()), + vec![McpAuthEnv::http_bearer("TEAM_MCP_TOKEN")], + ) + .unwrap_err() + .to_string(); + assert!(error.contains("transport")); + } + + #[test] + fn mcp_toml_auth_targets_convert_without_secret_values() { + let cases = [ + ( + McpAuthToml { + variable: "MCP_BEARER".to_owned(), + target: "http_bearer".to_owned(), + name: None, + prefix: None, + }, + McpAuthTarget::HttpBearer, + ), + ( + McpAuthToml { + variable: "MCP_HEADER".to_owned(), + target: "http_header".to_owned(), + name: Some("X-API-Key".to_owned()), + prefix: Some("Token ".to_owned()), + }, + McpAuthTarget::HttpHeader { + name: "X-API-Key".to_owned(), + prefix: "Token ".to_owned(), + }, + ), + ( + McpAuthToml { + variable: "MCP_CHILD".to_owned(), + target: "stdio_environment".to_owned(), + name: Some("CHILD_TOKEN".to_owned()), + prefix: None, + }, + McpAuthTarget::StdioEnvironment { + name: "CHILD_TOKEN".to_owned(), + }, + ), + ]; + for (toml_auth, expected_target) in cases { + let variable = toml_auth.variable.clone(); + let core = toml_auth.into_core().unwrap(); + assert_eq!(core.variable, variable); + assert_eq!(core.target, expected_target); + } + } +} diff --git a/rust/crates/agent-codex/Cargo.toml b/rust/crates/agent-codex/Cargo.toml new file mode 100644 index 000000000..f2ca13dd4 --- /dev/null +++ b/rust/crates/agent-codex/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-codex" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Codex CLI 与 App Server 的外部 Agent 适配器" + +[dependencies] +agent-runtime-core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/rust/crates/agent-codex/fixtures/codex-0.152.1/protocol-manifest.json b/rust/crates/agent-codex/fixtures/codex-0.152.1/protocol-manifest.json new file mode 100644 index 000000000..53d4ce4b4 --- /dev/null +++ b/rust/crates/agent-codex/fixtures/codex-0.152.1/protocol-manifest.json @@ -0,0 +1,38 @@ +{ + "binaryVersion": "0.152.1", + "protocol": "v2", + "appServerArgs": ["app-server", "--stdio"], + "generatedSchemaArgs": [ + "app-server", + "generate-json-schema", + "--out", + "", + "--experimental" + ], + "schemaBundle": "codex_app_server_protocol.v2.schemas.json", + "schemaBundleSha256": "f9e3ca7e56300b4e5a5686419940ef77bbdc42846760d6a9cd53a21f20dc9ebd", + "clientMethods": [ + "initialize", + "thread/start", + "turn/start", + "turn/interrupt" + ], + "serverRequestMethods": [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/tool/requestUserInput", + "mcpServer/elicitation/request", + "item/permissions/requestApproval", + "item/tool/call", + "applyPatchApproval", + "execCommandApproval" + ], + "notificationMethods": [ + "thread/started", + "turn/started", + "turn/completed", + "item/agentMessage/delta", + "item/started", + "item/completed" + ] +} diff --git a/rust/crates/agent-codex/src/codex_0_152_1.rs b/rust/crates/agent-codex/src/codex_0_152_1.rs new file mode 100644 index 000000000..48f4a4c38 --- /dev/null +++ b/rust/crates/agent-codex/src/codex_0_152_1.rs @@ -0,0 +1,1926 @@ +//! 本地审计过的 Codex CLI `0.152.1` 版本适配器。 +//! +//! 这个模块不是“V2 永久兼容层”。它把 `codex app-server +//! generate-json-schema --experimental` 在 `0.152.1` 生成的少量稳定边界 +//! 固定成可复用 Rust 类型,并把未知字段继续保留在 JSON 值中。升级 Codex +//! 后应重新生成 schema、更新 fixture 和本模块的版本常量,再运行对应审计; +//! 不应把新版本直接标记为兼容。 + +use std::collections::BTreeMap; +use std::io::{Read, Write}; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; + +use super::{ + CodexAppServerNotification, CodexAppServerProcess, CodexAppServerProcessConfig, CodexError, + CodexInitializeResult, CodexServerRequest, CodexServerRequestHandler, + CodexServerRequestResponse, +}; + +/// 被本模块明确支持、且已在本机 binary 上核对过的版本。 +pub const CODEX_CLI_VERSION: &str = "0.152.1"; +/// 该发行版的 app-server 请求形状标签。它不是发行版协商协议。 +pub const CODEX_APP_SERVER_PROTOCOL: &str = "v2"; +/// `0.152.1` 的无 shell 启动 argv;调用方仍须先核对 binary 版本。 +pub const APP_SERVER_ARGS: &[&str] = &["app-server", "--stdio"]; +/// 生成本模块 provenance fixture 所用的命令片段。 +pub const GENERATED_SCHEMA_ARGS: &[&str] = &[ + "app-server", + "generate-json-schema", + "--out", + "", + "--experimental", +]; + +/// 由本机 `0.152.1` 生成的压缩 provenance 清单。 +pub const PROTOCOL_MANIFEST_JSON: &str = + include_str!("../fixtures/codex-0.152.1/protocol-manifest.json"); + +/// 仅保存生成 schema 的来源和本适配器实际消费的 method 集合。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProtocolManifest { + pub binary_version: String, + pub protocol: String, + pub app_server_args: Vec, + pub generated_schema_args: Vec, + pub schema_bundle: String, + pub schema_bundle_sha256: String, + pub client_methods: Vec, + pub server_request_methods: Vec, + pub notification_methods: Vec, +} + +/// 读取内置的版本清单;失败表示 crate 内 fixture 本身损坏。 +pub fn protocol_manifest() -> Result { + serde_json::from_str(PROTOCOL_MANIFEST_JSON) + .map_err(|error| CodexError::Protocol(format!("Codex 0.152.1 manifest 无效: {error}"))) +} + +/// 从“已由调用方执行并核对过的 `codex --version` 输出”创建严格版本配置。 +/// +/// 适配器不自行猜测版本,也不把任意 binary 伪装成 `0.152.1`。调用方应把 +/// `--version` 的原始输出传入 `observed_version`;这里接受常见的 +/// `codex-cli 0.152.1` 或单独的 `0.152.1` 形式。 +#[derive(Clone, Debug, PartialEq)] +pub struct ProcessConfig01521 { + inner: CodexAppServerProcessConfig, +} + +impl ProcessConfig01521 { + pub fn try_new(program: impl Into, observed_version: &str) -> Result { + if !version_output_matches(observed_version) { + return Err(CodexError::InvalidConfig(format!( + "Codex 版本不匹配: expected={CODEX_CLI_VERSION} observed={}", + redact_version_output(observed_version) + ))); + } + let inner = CodexAppServerProcessConfig::try_new(program)? + .with_args(APP_SERVER_ARGS.iter().copied()) + .with_allowed_arg_prefixes(APP_SERVER_ARGS.iter().copied()); + Ok(Self { inner }) + } + + pub fn as_process_config(&self) -> &CodexAppServerProcessConfig { + &self.inner + } + + pub fn into_process_config(self) -> CodexAppServerProcessConfig { + self.inner + } +} + +fn version_output_matches(observed: &str) -> bool { + observed + .split_whitespace() + .any(|token| token.trim_start_matches('v') == CODEX_CLI_VERSION) +} + +fn redact_version_output(value: &str) -> String { + let compact = value.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= 80 { + compact + } else { + format!("{}…", compact.chars().take(77).collect::()) + } +} + +/// 已核对版本的 app-server process facade。 +pub struct AppServer01521 { + process: CodexAppServerProcess, +} + +/// 版本化的泛型 transport facade,供内存 fixture、Unix socket 或自有进程封装复用。 +/// +/// 和 [`AppServer01521`] 的区别是它不创建 child;调用方负责提供已经连接好的 +/// JSON-RPC reader/writer。这样协议测试不需要启动真实 Codex,也不会把网络或 +/// 凭据带入测试。 +pub struct Client01521 { + client: super::CodexAppServerClient, +} + +impl Client01521 { + pub fn new(reader: R, writer: W) -> Result { + Ok(Self { + client: super::CodexAppServerClient::with_protocol_and_max_frame_bytes( + reader, + writer, + super::CodexAppServerProtocol::V2, + 1024 * 1024, + )?, + }) + } + + pub fn with_max_frame_bytes( + reader: R, + writer: W, + max_frame_bytes: usize, + ) -> Result { + Ok(Self { + client: super::CodexAppServerClient::with_protocol_and_max_frame_bytes( + reader, + writer, + super::CodexAppServerProtocol::V2, + max_frame_bytes, + )?, + }) + } + + pub fn into_inner(self) -> super::CodexAppServerClient { + self.client + } + + pub fn initialize( + &mut self, + params: InitializeParams01521, + handler: &mut H, + ) -> Result { + params.validate()?; + let mut bridge = HandlerBridge { handler }; + let result = self + .client + .initialize_with_params_and_handler(serialize(¶ms, "initialize")?, &mut bridge)?; + InitializeResponse01521::try_from(result) + } + + pub fn thread_start( + &mut self, + params: ThreadStartParams01521, + handler: &mut H, + ) -> Result { + let mut bridge = HandlerBridge { handler }; + let result = self.client.request_with_server_handler( + "thread/start", + serialize(¶ms, "thread/start")?, + &mut bridge, + )?; + ThreadStartResponse01521::try_from(result) + } + + pub fn turn_start( + &mut self, + params: TurnStartParams01521, + handler: &mut H, + ) -> Result { + params.validate()?; + let mut bridge = HandlerBridge { handler }; + let result = self.client.request_with_server_handler( + "turn/start", + serialize(¶ms, "turn/start")?, + &mut bridge, + )?; + TurnStartResponse01521::try_from(result) + } + + pub fn turn_interrupt( + &mut self, + params: TurnInterruptParams01521, + handler: &mut H, + ) -> Result<(), CodexError> { + params.validate()?; + let mut bridge = HandlerBridge { handler }; + self.client.request_with_server_handler( + "turn/interrupt", + serialize(¶ms, "turn/interrupt")?, + &mut bridge, + )?; + Ok(()) + } + + pub fn poll_notification( + &mut self, + handler: &mut H, + ) -> Result, CodexError> { + let mut bridge = HandlerBridge { handler }; + let notification = self.client.poll_notification_with_handler(&mut bridge)?; + notification.map(Notification01521::try_from).transpose() + } +} + +impl std::fmt::Debug for AppServer01521 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AppServer01521") + .field("version", &CODEX_CLI_VERSION) + .field("protocol", &CODEX_APP_SERVER_PROTOCOL) + .field("terminated", &self.process.is_terminated()) + .finish() + } +} + +impl AppServer01521 { + pub fn spawn(config: ProcessConfig01521) -> Result { + Ok(Self { + process: CodexAppServerProcess::spawn(config.into_process_config())?, + }) + } + + pub fn from_process(process: CodexAppServerProcess) -> Self { + Self { process } + } + + pub fn process(&self) -> &CodexAppServerProcess { + &self.process + } + + pub fn terminate(&self) { + self.process.terminate(); + } + + pub fn is_terminated(&self) -> bool { + self.process.is_terminated() + } + + pub fn cancel(&self) -> Result<(), CodexError> { + self.process.cancel() + } + + /// 使用生成 schema 的 `initialize` 子集完成握手,并严格检查 4 个响应元数据。 + pub fn initialize( + &self, + params: InitializeParams01521, + handler: &mut H, + ) -> Result { + params.validate()?; + let mut bridge = HandlerBridge { handler }; + let result = self + .process + .initialize_with_params_and_handler(serialize(¶ms, "initialize")?, &mut bridge)?; + InitializeResponse01521::try_from(result) + } + + pub fn thread_start( + &self, + params: ThreadStartParams01521, + handler: &mut H, + ) -> Result { + let mut bridge = HandlerBridge { handler }; + let result = self.process.request_with_server_handler( + "thread/start", + serialize(¶ms, "thread/start")?, + &mut bridge, + )?; + ThreadStartResponse01521::try_from(result) + } + + pub fn turn_start( + &self, + params: TurnStartParams01521, + handler: &mut H, + ) -> Result { + params.validate()?; + let mut bridge = HandlerBridge { handler }; + let result = self.process.request_with_server_handler( + "turn/start", + serialize(¶ms, "turn/start")?, + &mut bridge, + )?; + TurnStartResponse01521::try_from(result) + } + + pub fn turn_interrupt( + &self, + params: TurnInterruptParams01521, + handler: &mut H, + ) -> Result<(), CodexError> { + params.validate()?; + let mut bridge = HandlerBridge { handler }; + self.process.request_with_server_handler( + "turn/interrupt", + serialize(¶ms, "turn/interrupt")?, + &mut bridge, + )?; + Ok(()) + } + + pub fn poll_notification( + &self, + handler: &mut H, + ) -> Result, CodexError> { + let mut bridge = HandlerBridge { handler }; + let notification = self.process.poll_notification_with_handler(&mut bridge)?; + notification.map(Notification01521::try_from).transpose() + } +} + +fn serialize(value: &T, operation: &str) -> Result { + serde_json::to_value(value) + .map_err(|error| CodexError::Protocol(format!("Codex {operation} 参数编码失败: {error}"))) +} + +/// `initialize` 参数的 `0.152.1` 生成 schema 子集。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InitializeParams01521 { + pub client_info: ClientInfo01521, + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} + +impl InitializeParams01521 { + pub fn new(name: impl Into, version: impl Into) -> Result { + let name = name.into(); + let version = version.into(); + if name.trim().is_empty() || version.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex initialize clientInfo 的 name/version 不能为空".to_owned(), + )); + } + Ok(Self { + client_info: ClientInfo01521 { + name, + title: None, + version, + }, + capabilities: None, + }) + } + + pub fn with_capabilities(mut self, capabilities: InitializeCapabilities01521) -> Self { + self.capabilities = Some(capabilities); + self + } + + /// Re-run the generated request contract before touching a transport. + /// Public serde fields can bypass `new`, so dispatchers must validate the + /// client identity at the wire boundary as well as in the constructor. + pub fn validate(&self) -> Result<(), CodexError> { + self.client_info.validate() + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientInfo01521 { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub version: String, +} + +impl ClientInfo01521 { + pub fn validate(&self) -> Result<(), CodexError> { + validate_id(&self.name, "Codex initialize clientInfo.name")?; + validate_id(&self.version, "Codex initialize clientInfo.version")?; + if let Some(title) = &self.title { + validate_id(title, "Codex initialize clientInfo.title")?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InitializeCapabilities01521 { + #[serde(default, skip_serializing_if = "is_false")] + pub experimental_api: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub mcp_server_openai_form_elicitation: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub opt_out_notification_methods: Option>, + #[serde(default, skip_serializing_if = "is_false")] + pub request_attestation: bool, +} + +fn is_false(value: &bool) -> bool { + !*value +} + +/// `initialize` 响应的 0.152.1 必填字段。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitializeResponse01521 { + pub user_agent: String, + pub codex_home: String, + pub platform_family: String, + pub platform_os: String, +} + +impl TryFrom for InitializeResponse01521 { + type Error = CodexError; + + fn try_from(value: CodexInitializeResult) -> Result { + Ok(Self { + user_agent: required_metadata(value.user_agent, "userAgent")?, + codex_home: required_metadata(value.codex_home, "codexHome")?, + platform_family: required_metadata(value.platform_family, "platformFamily")?, + platform_os: required_metadata(value.platform_os, "platformOs")?, + }) + } +} + +fn required_metadata(value: Option, field: &str) -> Result { + let value = value.ok_or_else(|| { + CodexError::Protocol(format!("Codex 0.152.1 initialize result 缺少 {field}")) + })?; + if value.trim().is_empty() { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 initialize result 的 {field} 不能为空" + ))); + } + Ok(value) +} + +/// `thread/start` 的实际 schema 子集。未列出的发行版字段不在这里猜测。 +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ThreadStartParams01521 { + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ephemeral: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub developer_instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_workspace_roots: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental_raw_events: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub history_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, +} + +impl ThreadStartParams01521 { + pub fn new() -> Self { + Self::default() + } + + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + pub fn with_model_provider(mut self, model_provider: impl Into) -> Self { + self.model_provider = Some(model_provider.into()); + self + } + + pub fn with_cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn with_ephemeral(mut self, ephemeral: bool) -> Self { + self.ephemeral = Some(ephemeral); + self + } + + pub fn with_approval_policy(mut self, approval_policy: Value) -> Self { + self.approval_policy = Some(approval_policy); + self + } + + pub fn with_sandbox(mut self, sandbox: Value) -> Self { + self.sandbox = Some(sandbox); + self + } + + pub fn with_developer_instructions(mut self, instructions: impl Into) -> Self { + self.developer_instructions = Some(instructions.into()); + self + } +} + +/// `thread/start` 响应中供 Runtime 关联的稳定字段。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ThreadStartResponse01521 { + pub thread_id: String, + pub session_id: Option, + pub cli_version: Option, + pub model: String, + pub model_provider: String, + pub cwd: String, + pub raw: Value, +} + +impl TryFrom for ThreadStartResponse01521 { + type Error = CodexError; + + fn try_from(value: Value) -> Result { + let object = value.as_object().ok_or_else(|| { + CodexError::Protocol("Codex 0.152.1 thread/start result 必须是对象".to_owned()) + })?; + let thread = object + .get("thread") + .and_then(Value::as_object) + .ok_or_else(|| { + CodexError::Protocol( + "Codex 0.152.1 thread/start result 缺少 thread 对象".to_owned(), + ) + })?; + let thread_id = required_string(thread.get("id"), "thread.id")?; + let session_id = optional_string(thread.get("sessionId"), "thread.sessionId")?; + let cli_version = optional_string(thread.get("cliVersion"), "thread.cliVersion")?; + let model = required_string(object.get("model"), "model")?; + let model_provider = required_string(object.get("modelProvider"), "modelProvider")?; + let cwd = required_string(object.get("cwd"), "cwd")?; + Ok(Self { + thread_id, + session_id, + cli_version, + model, + model_provider, + cwd, + raw: value, + }) + } +} + +/// `turn/start` 的 0.152.1 UserInput 子集;七种生成 schema 变体均可保留。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type")] +pub enum UserInput01521 { + #[serde(rename = "text")] + Text { + text: String, + #[serde( + rename = "text_elements", + default, + skip_serializing_if = "Vec::is_empty" + )] + text_elements: Vec, + }, + #[serde(rename = "image")] + Image { + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + #[serde(rename = "localImage")] + LocalImage { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + #[serde(rename = "audio")] + Audio { url: String }, + #[serde(rename = "localAudio")] + LocalAudio { path: String }, + #[serde(rename = "skill")] + Skill { name: String, path: String }, + #[serde(rename = "mention")] + Mention { name: String, path: String }, +} + +impl UserInput01521 { + pub fn text(text: impl Into) -> Self { + Self::Text { + text: text.into(), + text_elements: Vec::new(), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageDetail01521 { + Auto, + Low, + High, + Original, +} + +/// `turn/start` 的必填字段和常用可选字段。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TurnStartParams01521 { + pub thread_id: String, + pub input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_user_message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_workspace_roots: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier_for_turn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_trigger: Option, +} + +impl TurnStartParams01521 { + pub fn text(thread_id: impl Into, text: impl Into) -> Result { + let thread_id = thread_id.into(); + validate_id(&thread_id, "Codex thread_id")?; + Ok(Self { + thread_id, + input: vec![UserInput01521::text(text)], + additional_context: None, + approval_policy: None, + approvals_reviewer: None, + client_user_message_id: None, + cwd: None, + effort: None, + model: None, + output_schema: None, + permissions: None, + personality: None, + runtime_workspace_roots: None, + sandbox_policy: None, + service_tier: None, + service_tier_for_turn: None, + summary: None, + tool_output: None, + turn_trigger: None, + }) + } + + fn validate(&self) -> Result<(), CodexError> { + validate_id(&self.thread_id, "Codex thread_id") + } +} + +/// `turn/start` 接受响应中稳定的 turn ID。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TurnStartResponse01521 { + pub turn_id: String, + pub status: Option, + pub raw: Value, +} + +impl TryFrom for TurnStartResponse01521 { + type Error = CodexError; + + fn try_from(value: Value) -> Result { + let object = value.as_object().ok_or_else(|| { + CodexError::Protocol("Codex 0.152.1 turn/start result 必须是对象".to_owned()) + })?; + let turn = object + .get("turn") + .and_then(Value::as_object) + .ok_or_else(|| { + CodexError::Protocol("Codex 0.152.1 turn/start result 缺少 turn 对象".to_owned()) + })?; + Ok(Self { + turn_id: required_string(turn.get("id"), "turn.id")?, + status: optional_string(turn.get("status"), "turn.status")?, + raw: value, + }) + } +} + +/// `turn/interrupt` 的精确必填字段。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TurnInterruptParams01521 { + pub thread_id: String, + pub turn_id: String, +} + +impl TurnInterruptParams01521 { + pub fn try_new( + thread_id: impl Into, + turn_id: impl Into, + ) -> Result { + let value = Self { + thread_id: thread_id.into(), + turn_id: turn_id.into(), + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<(), CodexError> { + validate_id(&self.thread_id, "Codex thread_id")?; + validate_id(&self.turn_id, "Codex turn_id") + } +} + +fn validate_id(value: &str, field: &str) -> Result<(), CodexError> { + if value.trim().is_empty() { + Err(CodexError::InvalidConfig(format!("{field} 不能为空"))) + } else { + Ok(()) + } +} + +fn required_string(value: Option<&Value>, field: &str) -> Result { + let value = value + .and_then(Value::as_str) + .ok_or_else(|| CodexError::Protocol(format!("Codex 0.152.1 缺少字符串字段 {field}")))?; + validate_id(value, field)?; + Ok(value.to_owned()) +} + +fn optional_string(value: Option<&Value>, field: &str) -> Result, CodexError> { + match value { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(value) => Err(CodexError::Protocol(format!( + "Codex 0.152.1 字段 {field} 必须是字符串或 null: {value}" + ))), + } +} + +/// `item/commandExecution/requestApproval` 的字段子集。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CommandExecutionApprovalParams01521 { + pub item_id: String, + pub started_at_ms: i64, + pub thread_id: String, + pub turn_id: String, + #[serde(default)] + pub approval_id: Option, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub available_decisions: Option>, + #[serde(default)] + pub command_actions: Option>, + #[serde(default)] + pub additional_permissions: Option, + #[serde(default)] + pub network_approval_context: Option, + #[serde(default)] + pub proposed_execpolicy_amendment: Option>, + #[serde(default)] + pub proposed_network_policy_amendments: Option>, + #[serde(default)] + pub environment_id: Option, +} + +/// 文件修改审批请求。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FileChangeApprovalParams01521 { + pub item_id: String, + pub started_at_ms: i64, + pub thread_id: String, + pub turn_id: String, + #[serde(default)] + pub grant_root: Option, + #[serde(default)] + pub reason: Option, +} + +/// `item/tool/requestUserInput` 请求及问题子集。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ToolRequestUserInputParams01521 { + pub is_blocking: bool, + pub item_id: String, + pub questions: Vec, + pub thread_id: String, + pub turn_id: String, + #[serde(default)] + pub auto_resolution_ms: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ToolRequestUserInputQuestion01521 { + pub header: String, + pub id: String, + pub question: String, + #[serde(default)] + pub is_other: bool, + #[serde(default)] + pub is_secret: bool, + #[serde(default)] + pub options: Option>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ToolRequestUserInputOption01521 { + pub description: String, + pub label: String, +} + +/// 动态工具调用请求;`arguments` 保持不透明,避免在通用 crate 重复工具 schema。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DynamicToolCallParams01521 { + pub arguments: Value, + pub call_id: String, + pub thread_id: String, + pub tool: String, + pub turn_id: String, + #[serde(default)] + pub namespace: Option, +} + +/// MCP elicitation 请求的公共字段;不同 mode 的 `requestedSchema`/URL 保持 JSON。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct McpElicitationRequestParams01521 { + pub server_name: String, + pub thread_id: String, + #[serde(default)] + pub turn_id: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub requested_schema: Option, + #[serde(default)] + pub elicitation_id: Option, + #[serde(default)] + pub url: Option, +} + +/// 权限审批请求;复杂权限 profile 交给上层按当前策略解释。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PermissionsApprovalParams01521 { + pub cwd: String, + pub item_id: String, + pub permissions: Value, + pub started_at_ms: i64, + pub thread_id: String, + pub turn_id: String, + #[serde(default)] + pub environment_id: Option, + #[serde(default)] + pub reason: Option, +} + +/// 旧/兼容 wire 中的 apply-patch 审批请求。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ApplyPatchApprovalParams01521 { + pub call_id: String, + pub conversation_id: String, + pub file_changes: Value, + #[serde(default)] + pub grant_root: Option, + #[serde(default)] + pub reason: Option, +} + +/// 旧/兼容 wire 中的 exec-command 审批请求。 +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExecCommandApprovalParams01521 { + pub call_id: String, + pub command: Vec, + pub conversation_id: String, + pub cwd: String, + pub parsed_cmd: Vec, + #[serde(default)] + pub approval_id: Option, + #[serde(default)] + pub reason: Option, +} + +/// 从中立 envelope 解码出的 0.152.1 server request。 +#[derive(Clone, Debug, PartialEq)] +pub enum ServerRequest01521 { + CommandExecution { + id: Value, + params: CommandExecutionApprovalParams01521, + }, + FileChange { + id: Value, + params: FileChangeApprovalParams01521, + }, + UserInput { + id: Value, + params: ToolRequestUserInputParams01521, + }, + McpElicitation { + id: Value, + params: McpElicitationRequestParams01521, + }, + Permissions { + id: Value, + params: PermissionsApprovalParams01521, + }, + DynamicTool { + id: Value, + params: DynamicToolCallParams01521, + }, + ApplyPatch { + id: Value, + params: ApplyPatchApprovalParams01521, + }, + ExecCommand { + id: Value, + params: ExecCommandApprovalParams01521, + }, + /// 未在本模块固定的 method;默认 handler 应拒绝它。 + Unknown { + id: Value, + method: String, + params: Value, + }, +} + +impl ServerRequest01521 { + pub fn id(&self) -> &Value { + match self { + Self::CommandExecution { id, .. } + | Self::FileChange { id, .. } + | Self::UserInput { id, .. } + | Self::McpElicitation { id, .. } + | Self::Permissions { id, .. } + | Self::DynamicTool { id, .. } + | Self::ApplyPatch { id, .. } + | Self::ExecCommand { id, .. } + | Self::Unknown { id, .. } => id, + } + } + + pub fn method(&self) -> &str { + match self { + Self::CommandExecution { .. } => "item/commandExecution/requestApproval", + Self::FileChange { .. } => "item/fileChange/requestApproval", + Self::UserInput { .. } => "item/tool/requestUserInput", + Self::McpElicitation { .. } => "mcpServer/elicitation/request", + Self::Permissions { .. } => "item/permissions/requestApproval", + Self::DynamicTool { .. } => "item/tool/call", + Self::ApplyPatch { .. } => "applyPatchApproval", + Self::ExecCommand { .. } => "execCommandApproval", + Self::Unknown { method, .. } => method, + } + } + + pub fn decode(request: &CodexServerRequest) -> Result { + let id = request.id().clone(); + let params = request.params().clone(); + let decode_error = |error: serde_json::Error| { + CodexError::Protocol(format!( + "Codex 0.152.1 {} params 无效: {error}", + request.method() + )) + }; + match request.method() { + "item/commandExecution/requestApproval" => Ok(Self::CommandExecution { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "item/fileChange/requestApproval" => Ok(Self::FileChange { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "item/tool/requestUserInput" => Ok(Self::UserInput { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "mcpServer/elicitation/request" => Ok(Self::McpElicitation { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "item/permissions/requestApproval" => Ok(Self::Permissions { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "item/tool/call" => Ok(Self::DynamicTool { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "applyPatchApproval" => Ok(Self::ApplyPatch { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + "execCommandApproval" => Ok(Self::ExecCommand { + id, + params: serde_json::from_value(params).map_err(decode_error)?, + }), + method => Ok(Self::Unknown { + id, + method: method.to_owned(), + params, + }), + } + } +} + +/// `item/commandExecution/requestApproval` 的决议值。 +#[derive(Clone, Debug, PartialEq)] +pub enum CommandApprovalDecision01521 { + Accept, + AcceptForSession, + AcceptWithExecpolicyAmendment { + execpolicy_amendment: Vec, + }, + ApplyNetworkPolicyAmendment { + action: NetworkPolicyAction01521, + host: String, + }, + Decline, + Cancel, +} + +impl CommandApprovalDecision01521 { + fn into_value(self) -> Value { + match self { + Self::Accept => json!("accept"), + Self::AcceptForSession => json!("acceptForSession"), + Self::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => json!({ + "acceptWithExecpolicyAmendment": { + "execpolicy_amendment": execpolicy_amendment, + } + }), + Self::ApplyNetworkPolicyAmendment { action, host } => json!({ + "applyNetworkPolicyAmendment": { + "network_policy_amendment": { + "action": action.as_str(), + "host": host, + } + } + }), + Self::Decline => json!("decline"), + Self::Cancel => json!("cancel"), + } + } +} + +/// 文件修改审批决议。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileChangeApprovalDecision01521 { + Accept, + AcceptForSession, + Decline, + Cancel, +} + +impl FileChangeApprovalDecision01521 { + fn as_str(self) -> &'static str { + match self { + Self::Accept => "accept", + Self::AcceptForSession => "acceptForSession", + Self::Decline => "decline", + Self::Cancel => "cancel", + } + } +} + +/// 旧/兼容 exec-command 审批决议;只支持 schema 中的无参数字符串项和常用拒绝项。 +#[derive(Clone, Debug, PartialEq)] +pub enum ExecCommandApprovalDecision01521 { + Approved, + ApprovedForSession, + ApprovedMcpPolicyAmendment, + TimedOut, + Abort, + Denied { + rejection: String, + }, + ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: Vec, + }, + NetworkPolicyAmendment { + action: NetworkPolicyAction01521, + host: String, + }, +} + +impl ExecCommandApprovalDecision01521 { + fn into_value(self) -> Value { + match self { + Self::Approved => json!("approved"), + Self::ApprovedForSession => json!("approved_for_session"), + Self::ApprovedMcpPolicyAmendment => json!("approved_mcp_policy_amendment"), + Self::TimedOut => json!("timed_out"), + Self::Abort => json!("abort"), + Self::Denied { rejection } => json!({"denied": {"rejection": rejection}}), + Self::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => json!({ + "approved_execpolicy_amendment": { + "proposed_execpolicy_amendment": proposed_execpolicy_amendment, + } + }), + Self::NetworkPolicyAmendment { action, host } => json!({ + "network_policy_amendment": { + "network_policy_amendment": { + "action": action.as_str(), + "host": host, + } + } + }), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NetworkPolicyAction01521 { + Allow, + Deny, +} + +impl NetworkPolicyAction01521 { + fn as_str(self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Deny => "deny", + } + } +} + +/// `item/tool/requestUserInput` 的答案格式。 +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ToolRequestUserInputResponse01521 { + pub answers: BTreeMap>, +} + +impl ToolRequestUserInputResponse01521 { + pub fn new(answers: BTreeMap>) -> Self { + Self { answers } + } + + fn into_value(self) -> Value { + let answers = self + .answers + .into_iter() + .map(|(id, answers)| (id, json!({"answers": answers}))) + .collect::>(); + json!({"answers": answers}) + } +} + +/// 动态工具返回值的三个生成 schema 变体。 +#[derive(Clone, Debug, PartialEq)] +pub enum DynamicToolOutput01521 { + Text(String), + ImageUrl(String), + AudioUrl(String), +} + +impl DynamicToolOutput01521 { + fn into_value(self) -> Value { + match self { + Self::Text(text) => json!({"type":"inputText","text":text}), + Self::ImageUrl(image_url) => json!({"type":"inputImage","imageUrl":image_url}), + Self::AudioUrl(audio_url) => json!({"type":"inputAudio","audioUrl":audio_url}), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DynamicToolCallResponse01521 { + pub content_items: Vec, + pub success: bool, +} + +impl DynamicToolCallResponse01521 { + fn into_value(self) -> Value { + json!({ + "contentItems": self.content_items.into_iter().map(DynamicToolOutput01521::into_value).collect::>(), + "success": self.success, + }) + } +} + +/// MCP elicitation 的 0.152.1 response。 +#[derive(Clone, Debug, PartialEq)] +pub struct McpElicitationResponse01521 { + pub action: McpElicitationAction01521, + pub content: Option, + pub metadata: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum McpElicitationAction01521 { + Accept, + Decline, + Cancel, +} + +impl McpElicitationResponse01521 { + fn into_value(self) -> Value { + let mut value = json!({"action": self.action.as_str()}); + if let Some(content) = self.content { + value["content"] = content; + } + if let Some(metadata) = self.metadata { + value["_meta"] = metadata; + } + value + } +} + +impl McpElicitationAction01521 { + fn as_str(self) -> &'static str { + match self { + Self::Accept => "accept", + Self::Decline => "decline", + Self::Cancel => "cancel", + } + } +} + +/// 权限 profile 响应。`permissions` 维持 generated schema 的不透明结构。 +#[derive(Clone, Debug, PartialEq)] +pub struct PermissionsApprovalResponse01521 { + pub permissions: Value, + pub scope: Option, + pub strict_auto_review: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PermissionGrantScope01521 { + Turn, + Session, +} + +impl PermissionsApprovalResponse01521 { + fn into_value(self) -> Value { + let mut value = json!({"permissions": self.permissions}); + if let Some(scope) = self.scope { + value["scope"] = json!(match scope { + PermissionGrantScope01521::Turn => "turn", + PermissionGrantScope01521::Session => "session", + }); + } + if let Some(strict) = self.strict_auto_review { + value["strictAutoReview"] = json!(strict); + } + value + } +} + +/// 版本适配器可返回的 response;方法与 response 类型不匹配时会被拒绝。 +#[derive(Clone, Debug, PartialEq)] +pub enum ServerResponse01521 { + CommandApproval(CommandApprovalDecision01521), + FileChangeApproval(FileChangeApprovalDecision01521), + UserInput(ToolRequestUserInputResponse01521), + McpElicitation(McpElicitationResponse01521), + Permissions(PermissionsApprovalResponse01521), + DynamicTool(DynamicToolCallResponse01521), + ApplyPatch(Value), + ExecCommand(ExecCommandApprovalDecision01521), + Raw(Value), +} + +impl ServerResponse01521 { + /// 将 typed response 编码为与对应 request 匹配的 JSON 对象。 + /// + /// Host 等外层装配器可以复用这条版本化边界,而不需要复制 + /// `contentItems`/审批 response 的 wire 细节。 + pub fn into_value_for(self, request: &ServerRequest01521) -> Result { + let (expected, value) = match (request, self) { + (ServerRequest01521::CommandExecution { .. }, Self::CommandApproval(value)) => { + ("command approval", json!({"decision": value.into_value()})) + } + (ServerRequest01521::FileChange { .. }, Self::FileChangeApproval(value)) => { + ("file change approval", json!({"decision": value.as_str()})) + } + (ServerRequest01521::UserInput { .. }, Self::UserInput(value)) => { + ("user input", value.into_value()) + } + (ServerRequest01521::McpElicitation { .. }, Self::McpElicitation(value)) => { + ("MCP elicitation", value.into_value()) + } + (ServerRequest01521::Permissions { .. }, Self::Permissions(value)) => { + ("permissions approval", value.into_value()) + } + (ServerRequest01521::DynamicTool { .. }, Self::DynamicTool(value)) => { + ("dynamic tool", value.into_value()) + } + (ServerRequest01521::ApplyPatch { .. }, Self::ApplyPatch(value)) => { + ("apply patch approval", value) + } + (ServerRequest01521::ExecCommand { .. }, Self::ExecCommand(value)) => ( + "exec command approval", + json!({"decision": value.into_value()}), + ), + (ServerRequest01521::Unknown { .. }, Self::Raw(value)) => ("raw", value), + (request, _) => { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 {} response 类型不匹配", + request.method() + ))); + } + }; + if !value.is_object() { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 {expected} response 必须是对象" + ))); + } + Ok(value) + } +} + +/// 版本化的 server-request handler;策略仍由调用方提供,不在 adapter 中自动批准。 +pub trait ServerRequestHandler01521 { + fn handle(&mut self, request: &ServerRequest01521) -> Result; +} + +impl ServerRequestHandler01521 for F +where + F: FnMut(&ServerRequest01521) -> Result, +{ + fn handle(&mut self, request: &ServerRequest01521) -> Result { + self(request) + } +} + +struct HandlerBridge<'a, H> { + handler: &'a mut H, +} + +impl CodexServerRequestHandler for HandlerBridge<'_, H> { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + let typed = ServerRequest01521::decode(request)?; + let response = self.handler.handle(&typed)?; + let value = response.into_value_for(&typed)?; + Ok(CodexServerRequestResponse::result(value)) + } +} + +/// 0.152.1 中与单 Agent 生命周期最相关的通知子集。 +#[derive(Clone, Debug, PartialEq)] +pub enum Notification01521 { + AgentMessageDelta(AgentMessageDeltaNotification01521), + ThreadStarted(ThreadStartedNotification01521), + TurnStarted(TurnStartedNotification01521), + TurnCompleted(TurnCompletedNotification01521), + ItemStarted(ItemStartedNotification01521), + ItemCompleted(ItemCompletedNotification01521), + /// 其它通知保持中立 JSON,不在本模块伪造字段语义。 + Other(CodexAppServerNotification), +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentMessageDeltaNotification01521 { + pub delta: String, + pub item_id: String, + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ThreadStartedNotification01521 { + pub thread: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TurnStartedNotification01521 { + pub thread_id: String, + pub turn: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TurnCompletedNotification01521 { + pub thread_id: String, + pub turn: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ItemStartedNotification01521 { + pub item: Value, + pub thread_id: String, + pub turn_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ItemCompletedNotification01521 { + pub item: Value, + pub thread_id: String, + pub turn_id: String, + pub completed_at_ms: i64, +} + +impl TryFrom for Notification01521 { + type Error = CodexError; + + fn try_from(value: CodexAppServerNotification) -> Result { + let method = value.method.as_str(); + let params = value.params.clone(); + match method { + "item/agentMessage/delta" => { + Ok(Self::AgentMessageDelta(parse_notification(params, method)?)) + } + "thread/started" => Ok(Self::ThreadStarted(parse_notification(params, method)?)), + "turn/started" => Ok(Self::TurnStarted(parse_notification(params, method)?)), + "turn/completed" => Ok(Self::TurnCompleted(parse_notification(params, method)?)), + "item/started" => Ok(Self::ItemStarted(parse_notification(params, method)?)), + "item/completed" => Ok(Self::ItemCompleted(parse_notification(params, method)?)), + _ => Ok(Self::Other(value)), + } + } +} + +fn parse_notification(params: Value, method: &str) -> Result { + serde_json::from_value(params).map_err(|error| { + CodexError::Protocol(format!("Codex 0.152.1 {method} notification 无效: {error}")) + }) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::sync::{Arc, Mutex}; + + use super::*; + + #[derive(Clone, Default)] + struct SharedWriter(Arc>>); + + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().expect("writer lock").extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn written_messages(written: &Arc>>) -> Vec { + String::from_utf8(written.lock().expect("writer lock").clone()) + .expect("JSON-RPC output is UTF-8") + .lines() + .map(|line| serde_json::from_str(line).expect("JSON-RPC output is JSON")) + .collect() + } + + #[test] + fn manifest_records_the_audited_binary_and_schema_provenance() { + let manifest = protocol_manifest().expect("embedded manifest"); + assert_eq!(manifest.binary_version, CODEX_CLI_VERSION); + assert_eq!(manifest.protocol, CODEX_APP_SERVER_PROTOCOL); + assert_eq!(manifest.app_server_args, ["app-server", "--stdio"]); + assert_eq!( + manifest.generated_schema_args, + [ + "app-server", + "generate-json-schema", + "--out", + "", + "--experimental" + ] + ); + assert_eq!( + manifest.schema_bundle, + "codex_app_server_protocol.v2.schemas.json" + ); + assert_eq!(manifest.client_methods.len(), 4); + assert_eq!(manifest.server_request_methods.len(), 8); + assert_eq!(manifest.notification_methods.len(), 6); + } + + #[test] + fn process_config_requires_the_exact_observed_cli_version() { + assert!(ProcessConfig01521::try_new("codex", "codex-cli 0.152.0").is_err()); + assert!(ProcessConfig01521::try_new("codex", "codex-cli 0.152.1-beta").is_err()); + + let config = ProcessConfig01521::try_new("codex", "codex-cli 0.152.1").unwrap(); + assert_eq!(config.as_process_config().args, ["app-server", "--stdio"]); + assert_eq!( + config.as_process_config().allowed_arg_prefixes, + ["app-server", "--stdio"] + ); + + let v_prefixed = ProcessConfig01521::try_new("codex", "v0.152.1").unwrap(); + assert_eq!( + v_prefixed.as_process_config().args, + ["app-server", "--stdio"] + ); + } + + #[test] + fn typed_client_revalidates_initialize_before_transport() { + let malformed_initialize: InitializeParams01521 = serde_json::from_value(json!({ + "clientInfo": {"name": "", "version": ""} + })) + .expect("malformed initialize params should deserialize before validation"); + let initialize_output = Arc::new(Mutex::new(Vec::new())); + let mut initialize_client = Client01521::new( + Cursor::new(Vec::::new()), + SharedWriter(initialize_output.clone()), + ) + .unwrap(); + let mut handler = + |_request: &ServerRequest01521| -> Result { + panic!("invalid initialize params must not reach the handler") + }; + let error = initialize_client + .initialize(malformed_initialize, &mut handler) + .expect_err("empty client identity must fail before writing"); + assert!(matches!(error, CodexError::InvalidConfig(message) if message.contains("name"))); + assert!(initialize_output.lock().unwrap().is_empty()); + } + + #[test] + fn typed_client_runs_lifecycle_and_routes_command_approval() { + let input = concat!( + r#"{"id":1,"result":{"userAgent":"codex-cli/0.152.1","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"linux"}}"#, + "\n", + r#"{"id":"approval-1","method":"item/commandExecution/requestApproval","params":{"itemId":"item-1","startedAtMs":1,"threadId":"thread-1","turnId":"turn-1","command":"echo hi"}}"#, + "\n", + r#"{"id":2,"result":{"thread":{"id":"thread-1","sessionId":"session-1","cliVersion":"0.152.1"},"model":"gpt-5","modelProvider":"openai","cwd":"/tmp/project"}}"#, + "\n", + r#"{"method":"thread/started","params":{"thread":{"id":"thread-1"}}}"#, + "\n", + r#"{"id":3,"result":{"turn":{"id":"turn-1","status":"inProgress"}}}"#, + "\n", + r#"{"method":"item/agentMessage/delta","params":{"delta":"hello","itemId":"message-1","threadId":"thread-1","turnId":"turn-1"}}"#, + "\n", + r#"{"id":4,"result":{}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut client = Client01521::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + let mut seen_approvals = Vec::new(); + let mut handler = |request: &ServerRequest01521| { + seen_approvals.push(request.method().to_owned()); + match request { + ServerRequest01521::CommandExecution { params, .. } => { + assert_eq!(params.command.as_deref(), Some("echo hi")); + Ok(ServerResponse01521::CommandApproval( + CommandApprovalDecision01521::Accept, + )) + } + _ => panic!("unexpected typed request: {request:?}"), + } + }; + + let initialize = client + .initialize( + InitializeParams01521::new("agent-runtime", "0.1.0").unwrap(), + &mut handler, + ) + .unwrap(); + assert_eq!(initialize.user_agent, "codex-cli/0.152.1"); + + let thread = client + .thread_start( + ThreadStartParams01521::new() + .with_model("gpt-5") + .with_model_provider("openai") + .with_cwd("/tmp/project") + .with_ephemeral(true), + &mut handler, + ) + .unwrap(); + assert_eq!(thread.thread_id, "thread-1"); + assert_eq!(thread.session_id.as_deref(), Some("session-1")); + assert_eq!(thread.cli_version.as_deref(), Some("0.152.1")); + + let thread_notification = client + .poll_notification(&mut handler) + .unwrap() + .expect("thread notification"); + assert!(matches!( + thread_notification, + Notification01521::ThreadStarted(_) + )); + + let turn = client + .turn_start( + TurnStartParams01521::text("thread-1", "hello").unwrap(), + &mut handler, + ) + .unwrap(); + assert_eq!(turn.turn_id, "turn-1"); + + let delta = client + .poll_notification(&mut handler) + .unwrap() + .expect("agent message delta"); + assert!(matches!( + delta, + Notification01521::AgentMessageDelta(AgentMessageDeltaNotification01521 { + delta, + .. + }) if delta == "hello" + )); + + client + .turn_interrupt( + TurnInterruptParams01521::try_new("thread-1", "turn-1").unwrap(), + &mut handler, + ) + .unwrap(); + assert_eq!(seen_approvals, ["item/commandExecution/requestApproval"]); + + let messages = written_messages(&written); + assert_eq!(messages[0]["method"], "initialize"); + assert_eq!( + messages[0]["params"], + json!({"clientInfo":{"name":"agent-runtime","version":"0.1.0"}}) + ); + assert_eq!(messages[1], json!({"method":"initialized"})); + assert_eq!(messages[2]["method"], "thread/start"); + assert_eq!(messages[2]["params"]["modelProvider"], "openai"); + assert_eq!(messages[3]["id"], "approval-1"); + assert_eq!(messages[3]["result"]["decision"], "accept"); + assert_eq!(messages[4]["method"], "turn/start"); + assert_eq!(messages[4]["params"]["threadId"], "thread-1"); + assert_eq!( + messages[4]["params"]["input"][0], + json!({ + "type":"text", + "text":"hello" + }) + ); + assert_eq!(messages[5]["method"], "turn/interrupt"); + assert_eq!( + messages[5]["params"], + json!({ + "threadId":"thread-1", + "turnId":"turn-1" + }) + ); + } + + #[test] + fn every_typed_server_request_variant_decodes_from_the_audited_wire() { + let cases = [ + ( + "item/commandExecution/requestApproval", + json!({"itemId":"item","startedAtMs":1,"threadId":"thread","turnId":"turn"}), + ), + ( + "item/fileChange/requestApproval", + json!({"itemId":"item","startedAtMs":1,"threadId":"thread","turnId":"turn"}), + ), + ( + "item/tool/requestUserInput", + json!({"isBlocking":true,"itemId":"item","questions":[],"threadId":"thread","turnId":"turn"}), + ), + ( + "mcpServer/elicitation/request", + json!({"serverName":"server","threadId":"thread","message":"message","mode":"form","requestedSchema":{}}), + ), + ( + "item/permissions/requestApproval", + json!({"cwd":"/tmp/project","itemId":"item","permissions":{},"startedAtMs":1,"threadId":"thread","turnId":"turn"}), + ), + ( + "item/tool/call", + json!({"arguments":{},"callId":"call","threadId":"thread","tool":"tool","turnId":"turn"}), + ), + ( + "applyPatchApproval", + json!({"callId":"call","conversationId":"thread","fileChanges":{}}), + ), + ( + "execCommandApproval", + json!({"callId":"call","command":[],"conversationId":"thread","cwd":"/tmp/project","parsedCmd":[]}), + ), + ]; + + for (method, params) in cases { + let request = CodexServerRequest { + id: json!(method), + method: method.to_owned(), + params, + }; + let decoded = ServerRequest01521::decode(&request).unwrap(); + assert_eq!(decoded.method(), method); + assert_eq!(decoded.id(), request.id()); + } + + let unknown = ServerRequest01521::decode(&CodexServerRequest { + id: json!(99), + method: "future/method".to_owned(), + params: json!({"future":true}), + }) + .unwrap(); + assert!(matches!(unknown, ServerRequest01521::Unknown { .. })); + } + + #[test] + fn typed_responses_match_each_request_and_keep_wire_shapes() { + let command = ServerRequest01521::CommandExecution { + id: json!(1), + params: serde_json::from_value(json!({ + "itemId":"item", + "startedAtMs":1, + "threadId":"thread", + "turnId":"turn" + })) + .unwrap(), + }; + let command_response = ServerResponse01521::CommandApproval( + CommandApprovalDecision01521::AcceptWithExecpolicyAmendment { + execpolicy_amendment: vec!["allow echo".to_owned()], + }, + ) + .into_value_for(&command) + .unwrap(); + assert_eq!( + command_response, + json!({"decision":{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["allow echo"]}}}) + ); + + let tool = ServerRequest01521::DynamicTool { + id: json!(2), + params: serde_json::from_value(json!({ + "arguments":{"x":1}, + "callId":"call", + "threadId":"thread", + "tool":"tool", + "turnId":"turn" + })) + .unwrap(), + }; + let tool_response = ServerResponse01521::DynamicTool(DynamicToolCallResponse01521 { + content_items: vec![ + DynamicToolOutput01521::Text("ok".to_owned()), + DynamicToolOutput01521::ImageUrl("https://example.invalid/image".to_owned()), + ], + success: true, + }) + .into_value_for(&tool) + .unwrap(); + assert_eq!(tool_response["success"], true); + assert_eq!( + tool_response["contentItems"][0], + json!({ + "type":"inputText", + "text":"ok" + }) + ); + assert_eq!(tool_response["contentItems"][1]["type"], "inputImage"); + + let mismatch = ServerResponse01521::Raw(json!({"ok":true})) + .into_value_for(&command) + .unwrap_err(); + assert!(mismatch.to_string().contains("response 类型不匹配")); + } + + #[test] + fn typed_notifications_follow_required_fields_and_preserve_unknown_methods() { + let started = Notification01521::try_from(CodexAppServerNotification { + method: "item/started".to_owned(), + params: json!({ + "item":{"id":"item"}, + "startedAtMs":7, + "threadId":"thread", + "turnId":"turn" + }), + }) + .unwrap(); + assert!(matches!( + started, + Notification01521::ItemStarted(ItemStartedNotification01521 { + started_at_ms: 7, + .. + }) + )); + + let missing_timestamp = Notification01521::try_from(CodexAppServerNotification { + method: "item/started".to_owned(), + params: json!({ + "item":{"id":"item"}, + "threadId":"thread", + "turnId":"turn" + }), + }) + .unwrap_err(); + assert!( + missing_timestamp + .to_string() + .contains("item/started notification 无效") + ); + + let other = Notification01521::try_from(CodexAppServerNotification { + method: "future/notification".to_owned(), + params: json!({"value":true}), + }) + .unwrap(); + assert!(matches!( + other, + Notification01521::Other(CodexAppServerNotification { method, .. }) + if method == "future/notification" + )); + } + + #[test] + fn user_input_variants_serialize_with_schema_discriminators() { + let values = [ + serde_json::to_value(UserInput01521::text("text")).unwrap(), + serde_json::to_value(UserInput01521::Image { + url: "https://example.invalid/image".to_owned(), + detail: Some(ImageDetail01521::Low), + }) + .unwrap(), + serde_json::to_value(UserInput01521::LocalImage { + path: "/tmp/image.png".to_owned(), + detail: None, + }) + .unwrap(), + serde_json::to_value(UserInput01521::Audio { + url: "https://example.invalid/audio".to_owned(), + }) + .unwrap(), + serde_json::to_value(UserInput01521::LocalAudio { + path: "/tmp/audio.wav".to_owned(), + }) + .unwrap(), + serde_json::to_value(UserInput01521::Skill { + name: "skill".to_owned(), + path: "/tmp/SKILL.md".to_owned(), + }) + .unwrap(), + serde_json::to_value(UserInput01521::Mention { + name: "agent".to_owned(), + path: "/tmp/agent".to_owned(), + }) + .unwrap(), + ]; + let types = values + .iter() + .map(|value| value["type"].as_str().unwrap()) + .collect::>(); + assert_eq!( + types, + [ + "text", + "image", + "localImage", + "audio", + "localAudio", + "skill", + "mention" + ] + ); + assert_eq!(values[1]["detail"], "low"); + assert!(values[2].get("detail").is_none()); + } +} diff --git a/rust/crates/agent-codex/src/lib.rs b/rust/crates/agent-codex/src/lib.rs new file mode 100644 index 000000000..aa459a7c8 --- /dev/null +++ b/rust/crates/agent-codex/src/lib.rs @@ -0,0 +1,9143 @@ +//! Codex CLI/App Server 的外部适配器。 +//! +//! 这个 crate 只负责进程/协议边界和中立事件映射,不嵌入 `codex-core`,也不 +//! 持有 Host 的会话、权限或持久化真相。调用方应把返回的外部 ID、状态和 +//! `side_effect_unknown` 交给自己的 Runtime/Store 记录。 + +use std::collections::{HashMap, VecDeque}; +use std::fs::File; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use agent_runtime_core::{ + BackendRequest, BackendResult, ExternalBackend, ExternalError, ExternalErrorKind, Message, + RuntimeEvent, RuntimeEventKind, ToolCall, ToolResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; + +/// 针对本地审计过的 Codex CLI `0.152.1` 的窄版本适配器。 +/// +/// 该模块只在调用方明确选择这个发行版时使用;`CodexAppServerProtocol::V2` +/// 仍然保留为不绑定发行版的中立客户端。 +pub mod codex_0_152_1; + +const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); +const DEFAULT_JSON_RPC_FRAME_BYTES: usize = 1024 * 1024; +const DEFAULT_MAX_PENDING_NOTIFICATIONS: usize = 64; +const PROCESS_READER_CHANNEL_CHUNKS: usize = 32; +const PROCESS_READER_CHUNK_BYTES: usize = 8192; +const PROCESS_WRITER_CHANNEL_REQUESTS: usize = 8; +const PROCESS_EXIT_GRACE: Duration = Duration::from_millis(100); +const JSON_RPC_ROUTER_JOIN_GRACE: Duration = Duration::from_millis(100); +const JSON_RPC_VERSION: &str = "2.0"; + +/// 本适配器自己的中立 JSONL 版本。它不是对任何具体 Codex 发行版 wire +/// schema 的兼容声明;真实 Codex 版本应在更上层做转换。 +pub const APP_SERVER_PROTOCOL_VERSION: u16 = 1; + +fn default_app_server_protocol_version() -> u16 { + APP_SERVER_PROTOCOL_VERSION +} + +#[derive(Debug, Error)] +pub enum CodexError { + #[error("Codex 配置无效: {0}")] + InvalidConfig(String), + #[error("Codex 进程启动失败")] + Spawn, + #[error("Codex 进程超时")] + Timeout, + #[error("Codex 进程被中断")] + Interrupted, + #[error("Codex 输出超过限制")] + OutputTooLarge, + #[error("Codex 输出不是有效 JSON")] + InvalidOutput, + #[error("Codex 进程退出码异常: {0}")] + Exit(i32), + #[error("Codex 进程被信号终止: {0}")] + Signal(i32), + #[error("Codex 进程适配器已终止")] + ProcessTerminated, + #[error("Codex 协议错误: {0}")] + Protocol(String), +} + +impl CodexError { + /// 将尚未明确进入外部进程的错误映射成 Core 的外部错误类别。 + /// + /// 这个映射适合调用方在 dispatch 前做配置/启动检查。请求已经写入 + /// app-server 后,应使用 [`Self::external_error_kind_after_dispatch`] + /// 或 [`Self::external_error_kind_for_cli_failure`],避免把协议/超时 + /// 错误误当成可以安全重放的输入错误。 + pub fn external_error_kind(&self) -> ExternalErrorKind { + match self { + Self::InvalidConfig(_) | Self::Protocol(_) => ExternalErrorKind::InvalidInput, + // InvalidOutput 只会在 child 已启动后产生(stdout 读取/解析阶段)。 + // 即使进程以 0 退出,也无法证明外部动作没有发生;交给 + // Runtime 的 reconciliation gate,禁止按普通输入错误重放。 + Self::InvalidOutput => ExternalErrorKind::UnknownSideEffect, + Self::Timeout | Self::Interrupted => ExternalErrorKind::UnknownSideEffect, + // 进程已经启动后才发现输出过大,无法证明远端动作没有发生; + // 交给 Runtime 的 reconciliation gate,而不是当成可安全重试。 + Self::OutputTooLarge | Self::Exit(_) | Self::Signal(_) | Self::ProcessTerminated => { + ExternalErrorKind::UnknownSideEffect + } + Self::Spawn => ExternalErrorKind::Unavailable, + } + } + + /// Map an error observed after an App Server request has been dispatched. + /// + /// `Protocol` is intentionally classified differently here than in the + /// generic/configuration mapping above. A malformed frame, an ID mismatch, + /// or a closed connection can happen after the remote process accepted the + /// request; treating that as `InvalidInput` would allow a caller to replay + /// an operation whose side effect is not known. Configuration validation is + /// still safe to report as invalid input, and a process that could not be + /// spawned remains unavailable. + pub fn external_error_kind_after_dispatch(&self) -> ExternalErrorKind { + match self { + Self::InvalidConfig(_) => ExternalErrorKind::InvalidInput, + Self::Spawn => ExternalErrorKind::Unavailable, + Self::Protocol(_) + | Self::Timeout + | Self::Interrupted + | Self::InvalidOutput + | Self::OutputTooLarge + | Self::Exit(_) + | Self::Signal(_) + | Self::ProcessTerminated => ExternalErrorKind::UnknownSideEffect, + } + } + + /// Map an error returned by a real process operation after the backend has + /// reserved the client and entered its dispatch path. + /// + /// Unlike the in-memory channel, a process-side server-request handler can + /// return `InvalidConfig` after the outbound frame was already written (for + /// example, a local approval policy may reject a server request). That + /// variant must not be treated as safe-to-retry input: the remote process + /// may already have performed the requested side effect. The process + /// backend therefore keeps every post-reservation error in the + /// reconciliation lane. + pub fn external_error_kind_after_process_dispatch(&self) -> ExternalErrorKind { + ExternalErrorKind::UnknownSideEffect + } + + /// Map errors from the one-shot CLI supervisor. `invoke_cli_supervised` + /// performs its serialization/config checks before spawning; after spawn it + /// returns process/output/timeout variants that conservatively stay in the + /// unknown-side-effect lane. Protocol here denotes supervisor state (for + /// example a poisoned cancellation table), not user request syntax. + pub fn external_error_kind_for_cli_failure(&self) -> ExternalErrorKind { + match self { + Self::InvalidConfig(_) => ExternalErrorKind::InvalidInput, + Self::Spawn => ExternalErrorKind::Unavailable, + Self::Protocol(_) + | Self::Timeout + | Self::Interrupted + | Self::InvalidOutput + | Self::OutputTooLarge + | Self::Exit(_) + | Self::Signal(_) + | Self::ProcessTerminated => ExternalErrorKind::UnknownSideEffect, + } + } +} + +/// Process-level lifecycle reason emitted by the stdio supervisor. +/// +/// This is intentionally separate from [`CodexSessionLifecycleStatus`]: a +/// process may terminate while the remote turn result is still unknown. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CodexProcessLifecycleReason { + NaturalExit, + ReaderEof, + ReaderError, + ExplicitTerminate, + Cancel, + Timeout, + Drop, +} + +impl CodexProcessLifecycleReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::NaturalExit => "natural_exit", + Self::ReaderEof => "reader_eof", + Self::ReaderError => "reader_error", + Self::ExplicitTerminate => "explicit_terminate", + Self::Cancel => "cancel", + Self::Timeout => "timeout", + Self::Drop => "drop", + } + } +} + +/// A one-shot observation emitted after the process has been reaped and the +/// reader/writer workers have been joined. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CodexProcessLifecycleEvent { + pub reason: CodexProcessLifecycleReason, + pub exit_code: Option, +} + +/// Optional process-level observation sink. Implementations should persist or +/// forward the event quickly and must not synchronously re-enter the process. +pub trait CodexProcessLifecycleSink: Send + Sync { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError>; +} + +/// Codex CLI 一次性节点的启动配置。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CodexCliConfig { + pub program: String, + #[serde(default)] + pub args: Vec, + #[serde( + default = "default_timeout_ms", + alias = "timeout_ms", + deserialize_with = "deserialize_nonzero_timeout_ms" + )] + pub timeout_ms: u64, + #[serde( + default = "default_max_output_bytes", + alias = "max_output_bytes", + deserialize_with = "deserialize_nonzero_max_output_bytes" + )] + pub max_output_bytes: usize, + /// 允许的参数前缀;空集合表示只允许无参数启动,避免把任意 shell + /// 片段从上层配置直接传给 Codex。 + #[serde(default, alias = "allowed_arg_prefixes")] + pub allowed_arg_prefixes: Vec, +} + +fn default_timeout_ms() -> u64 { + DEFAULT_TIMEOUT.as_millis() as u64 +} + +fn default_max_output_bytes() -> usize { + DEFAULT_MAX_OUTPUT_BYTES +} + +/// 配置文件里的 `0` 不能被当成“尽快超时”或“关闭上限”。 +/// +/// 这里在 serde 边界先拒绝零值;运行时构造出的公开 struct 仍会在 +/// [`CodexCliBackend::new`] / `invoke_cli` 的校验中再次拒绝,避免调用方 +/// 通过字段直写绕过配置解析。 +fn deserialize_nonzero_timeout_ms<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = u64::deserialize(deserializer)?; + if value == 0 { + return Err(serde::de::Error::custom("timeout_ms 必须大于 0")); + } + Ok(value) +} + +fn deserialize_nonzero_max_output_bytes<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = usize::deserialize(deserializer)?; + if value == 0 { + return Err(serde::de::Error::custom("max_output_bytes 必须大于 0")); + } + Ok(value) +} + +impl CodexCliConfig { + pub fn try_new(program: impl Into) -> Result { + let program = program.into(); + if program.trim().is_empty() || contains_control(&program) { + return Err(CodexError::InvalidConfig( + "program 不能为空或含控制字符".to_owned(), + )); + } + Ok(Self { + program, + args: Vec::new(), + timeout_ms: default_timeout_ms(), + max_output_bytes: default_max_output_bytes(), + allowed_arg_prefixes: Vec::new(), + }) + } + + pub fn with_args(mut self, args: impl IntoIterator>) -> Self { + self.args = args.into_iter().map(Into::into).collect(); + self + } + + pub fn with_allowed_arg_prefixes( + mut self, + prefixes: impl IntoIterator>, + ) -> Self { + self.allowed_arg_prefixes = prefixes.into_iter().map(Into::into).collect(); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Result { + let millis = timeout.as_millis(); + if millis == 0 { + return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned())); + } + self.timeout_ms = u64::try_from(millis) + .map_err(|_| CodexError::InvalidConfig("timeout 超出 u64 毫秒范围".to_owned()))?; + Ok(self) + } + + pub fn with_max_output_bytes(mut self, max: usize) -> Result { + if max == 0 { + return Err(CodexError::InvalidConfig( + "max_output_bytes 必须大于 0".to_owned(), + )); + } + self.max_output_bytes = max; + Ok(self) + } + + fn validate_args(&self) -> Result<(), CodexError> { + validate_program_and_args(&self.program, &self.args, &self.allowed_arg_prefixes)?; + if self.timeout_ms == 0 { + return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned())); + } + if self.max_output_bytes == 0 { + return Err(CodexError::InvalidConfig( + "max_output_bytes 必须大于 0".to_owned(), + )); + } + Ok(()) + } + + fn timeout(&self) -> Duration { + // `validate_args` rejects zero before a process can be spawned. Do not + // normalize an invalid value here, so a missed validation cannot turn + // into an unbounded/surprising execution window. + Duration::from_millis(self.timeout_ms) + } +} + +fn validate_program_and_args( + program: &str, + args: &[String], + allowed_arg_prefixes: &[String], +) -> Result<(), CodexError> { + if program.trim().is_empty() || contains_control(program) { + return Err(CodexError::InvalidConfig( + "Codex program 不能为空或含控制字符".to_owned(), + )); + } + if allowed_arg_prefixes + .iter() + .any(|prefix| prefix.is_empty() || contains_control(prefix)) + { + return Err(CodexError::InvalidConfig( + "Codex 参数白名单前缀不能为空或含控制字符".to_owned(), + )); + } + for arg in args { + if contains_control(arg) { + return Err(CodexError::InvalidConfig("Codex 参数含控制字符".to_owned())); + } + if contains_sensitive_arg(arg) { + return Err(CodexError::InvalidConfig( + "禁止在 Codex argv 中传递密钥".to_owned(), + )); + } + if !allowed_arg_prefixes + .iter() + .any(|prefix| arg.starts_with(prefix)) + { + return Err(CodexError::InvalidConfig(format!( + "Codex 参数不在白名单中: {}", + redact_arg(arg) + ))); + } + } + Ok(()) +} + +// argv 会被进程表和诊断工具直接暴露;只要参数形状明确表示凭据,就在 +// 白名单检查前拒绝。这里按 flag 名称的 ASCII 形式匹配,避免把普通的 +// `--tokenizer` 一类参数误判为密钥,也不尝试解析 shell 语法。 +const SENSITIVE_ARG_KEYS: &[&str] = &[ + "accesskey", + "accesstoken", + "apikey", + "apitoken", + "authorization", + "authtoken", + "bearertoken", + "clientsecret", + "cookie", + "credential", + "credentials", + "idtoken", + "oauth2token", + "oauthtoken", + "password", + "passwd", + "privatekey", + "refreshtoken", + "secret", + "secretkey", + "sessiontoken", + "token", +]; + +fn contains_sensitive_arg(arg: &str) -> bool { + let lower = arg.to_ascii_lowercase(); + // 保留历史上拒绝的写法,并覆盖大小写变化;Bearer 值可能没有显式 + // `Authorization:` 键,因此单独识别其值前缀。 + if lower.contains("api_key") + || lower.contains("api-key") + || lower == "bearer" + || lower.starts_with("bearer ") + || lower.starts_with("bearer=") + { + return true; + } + + if is_sensitive_arg_key(arg) { + return true; + } + + // 常见 header 形式会把真实键放在 `--header=` 的值中;只检查值 + // 的键和 Bearer 前缀,不对任意普通文本做 token 子串匹配。 + if let Some((_, value)) = split_arg_assignment(arg) { + if is_sensitive_arg_key(value) { + return true; + } + let value = value.trim_start().to_ascii_lowercase(); + if value == "bearer" || value.starts_with("bearer ") || value.starts_with("bearer=") { + return true; + } + } + + // 也覆盖没有 `=`/`:` 的空格分隔形式(例如 `--token secret`)。 + arg.split_whitespace() + .next() + .is_some_and(is_sensitive_arg_key) +} + +fn split_arg_assignment(value: &str) -> Option<(&str, &str)> { + value.split_once('=').or_else(|| value.split_once(':')) +} + +fn is_sensitive_arg_key(value: &str) -> bool { + let key = split_arg_assignment(value) + .map_or(value, |(key, _)| key) + .split_whitespace() + .next() + .unwrap_or_default(); + let normalized: String = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect(); + SENSITIVE_ARG_KEYS.contains(&normalized.to_ascii_lowercase().as_str()) +} + +fn contains_control(value: &str) -> bool { + value.chars().any(char::is_control) +} + +fn redact_arg(value: &str) -> String { + format!("", value.chars().count()) +} + +/// 真实 App Server 子进程的显式启动配置。 +/// +/// `program` 与每一项 `args` 会逐项传给 [`Command`];这里不解析 shell 字符串, +/// 也不从环境变量拼接隐藏参数。`allowed_arg_prefixes` 保留与一次性 CLI 相同的 +/// 最小白名单,调用方若确实要启动本地 fixture,可显式允许 `-c` 等参数。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CodexAppServerProcessConfig { + pub program: String, + #[serde(default)] + pub args: Vec, + #[serde( + default = "default_timeout_ms", + alias = "timeout_ms", + deserialize_with = "deserialize_nonzero_timeout_ms" + )] + pub timeout_ms: u64, + #[serde( + default = "default_json_rpc_frame_bytes", + alias = "max_frame_bytes", + deserialize_with = "deserialize_nonzero_max_frame_bytes" + )] + pub max_frame_bytes: usize, + #[serde(default, alias = "allowed_arg_prefixes")] + pub allowed_arg_prefixes: Vec, +} + +fn default_json_rpc_frame_bytes() -> usize { + DEFAULT_JSON_RPC_FRAME_BYTES +} + +fn deserialize_nonzero_max_frame_bytes<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = usize::deserialize(deserializer)?; + if value == 0 { + return Err(serde::de::Error::custom("max_frame_bytes 必须大于 0")); + } + Ok(value) +} + +impl CodexAppServerProcessConfig { + pub fn try_new(program: impl Into) -> Result { + let program = program.into(); + validate_program_and_args(&program, &[], &[])?; + Ok(Self { + program, + args: Vec::new(), + timeout_ms: default_timeout_ms(), + max_frame_bytes: default_json_rpc_frame_bytes(), + allowed_arg_prefixes: Vec::new(), + }) + } + + pub fn with_args(mut self, args: impl IntoIterator>) -> Self { + self.args = args.into_iter().map(Into::into).collect(); + self + } + + pub fn with_allowed_arg_prefixes( + mut self, + prefixes: impl IntoIterator>, + ) -> Self { + self.allowed_arg_prefixes = prefixes.into_iter().map(Into::into).collect(); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Result { + let millis = timeout.as_millis(); + if millis == 0 { + return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned())); + } + self.timeout_ms = u64::try_from(millis) + .map_err(|_| CodexError::InvalidConfig("timeout 超出 u64 毫秒范围".to_owned()))?; + Ok(self) + } + + pub fn with_max_frame_bytes(mut self, max: usize) -> Result { + if max == 0 { + return Err(CodexError::InvalidConfig( + "max_frame_bytes 必须大于 0".to_owned(), + )); + } + self.max_frame_bytes = max; + Ok(self) + } + + fn validate(&self) -> Result<(), CodexError> { + validate_program_and_args(&self.program, &self.args, &self.allowed_arg_prefixes)?; + if self.max_frame_bytes == 0 { + return Err(CodexError::InvalidConfig( + "max_frame_bytes 必须大于 0".to_owned(), + )); + } + if self.timeout_ms == 0 { + return Err(CodexError::InvalidConfig("timeout 必须大于 0".to_owned())); + } + Ok(()) + } + + fn timeout(&self) -> Duration { + // `validate` rejects zero before a child is spawned. Keep the raw + // value here instead of silently converting an invalid config to 1 ms. + Duration::from_millis(self.timeout_ms) + } +} + +/// CLI 适配器返回的最小外部进程结果。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CodexCliResult { + pub external_id: String, + pub status: CodexProcessStatus, + pub output: Value, + pub exit_code: i32, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CodexProcessStatus { + Completed, + Failed, + TimedOut, + Interrupted, +} + +static NEXT_PROCESS_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_APP_SERVER_BACKEND_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_BACKEND_NONCE: AtomicU64 = AtomicU64::new(1); +static NEXT_PROCESS_OPERATION_ID: AtomicU64 = AtomicU64::new(1); + +/// 生成一个不会只依赖进程内计数器的 backend 实例 nonce。 +/// +/// 结果会进入持久化的 `external_id`:优先混入操作系统熵源,fallback +/// 同时包含时间戳、PID 和本进程序号。这样应用重启后不会因为计数器重新 +/// 从 1 开始就复用上一轮的外部调用 ID;它不是认证凭据,也不要求可逆。 +fn new_backend_nonce() -> String { + let sequence = NEXT_BACKEND_NONCE.fetch_add(1, Ordering::Relaxed); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let pid = u128::from(std::process::id()); + + let mut entropy = [0_u8; 16]; + let random = if let Ok(mut source) = File::open("/dev/urandom") + && source.read_exact(&mut entropy).is_ok() + { + u128::from_le_bytes(entropy) + } else { + // 这条 fallback 只承担唯一性,不声称提供密码学随机性。 + timestamp ^ pid.rotate_left(37) ^ u128::from(sequence).rotate_left(71) + }; + + format!("{random:032x}-{timestamp:x}-{pid:x}-{sequence:x}") +} + +/// 以 stdin JSON、stdout JSON 的一次性 Codex CLI 后端。 +#[derive(Clone, Debug)] +pub struct CodexCliBackend { + config: CodexCliConfig, + /// Shared by clones of one logical backend; the nonce prevents durable + /// external IDs from restarting at the same value after process restart. + instance_nonce: String, + /// request_id -> cancel flag。flag 放在共享表中而不是只保存在 invoke + /// 栈上,使宿主可以从另一个线程调用 `ExternalBackend::cancel`。 + cancel_flags: Arc>>>, +} + +impl CodexCliBackend { + pub fn new(config: CodexCliConfig) -> Result { + config.validate_args()?; + Ok(Self { + config, + instance_nonce: new_backend_nonce(), + cancel_flags: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub fn config(&self) -> &CodexCliConfig { + &self.config + } + + pub fn invoke_cli(&self, request: &BackendRequest) -> Result { + self.config.validate_args()?; + let request_id = request.request_id().to_owned(); + let cancel_flag = Arc::new(AtomicBool::new(false)); + { + let mut active = self + .cancel_flags + .lock() + .map_err(|_| CodexError::Protocol("Codex supervisor 状态锁已损坏".to_owned()))?; + if active.contains_key(&request_id) { + // 同一个 request_id 同时执行时无法安全判断 cancel 应该作用于 + // 哪个 child;直接拒绝比误杀另一个请求更可控。 + return Err(CodexError::InvalidConfig(format!( + "Codex request_id 已在执行: {}", + redact_arg(&request_id) + ))); + } + active.insert(request_id.clone(), cancel_flag.clone()); + } + + let result = self.invoke_cli_supervised(request, &cancel_flag); + if let Ok(mut active) = self.cancel_flags.lock() { + active.remove(&request_id); + } + result + } + + /// 请求一个正在运行的 CLI 进程尽快退出。未知 ID 视为幂等成功,便于 + /// Host 在重试/恢复路径中重复发送取消;真正的 child 由 invoke 线程 + /// 在下一个监督 tick 中 kill 并 wait。 + pub fn cancel_cli(&self, request_id: &str) -> Result<(), CodexError> { + if request_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex cancel request_id 不能为空".to_owned(), + )); + } + let flag = self + .cancel_flags + .lock() + .map_err(|_| CodexError::Protocol("Codex supervisor 状态锁已损坏".to_owned()))? + .get(request_id) + .cloned(); + if let Some(flag) = flag { + flag.store(true, Ordering::Release); + } + Ok(()) + } + + fn invoke_cli_supervised( + &self, + request: &BackendRequest, + cancel_flag: &AtomicBool, + ) -> Result { + let external_id = format!( + "codex-cli-{}-{}", + self.instance_nonce, + NEXT_PROCESS_ID.fetch_add(1, Ordering::Relaxed) + ); + // Encode before spawning so a local serialization failure remains a + // dispatch-free InvalidConfig rather than being confused with a child + // that may already have observed the request. + let payload = serde_json::to_vec(&request.payload()) + .map_err(|_| CodexError::InvalidConfig("Codex 请求 JSON 编码失败".to_owned()))?; + let mut command = Command::new(&self.config.program); + command + .args(&self.config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + configure_process_group(&mut command); + let mut child = command.spawn().map_err(|_| CodexError::Spawn)?; + + if let Some(stdin) = child.stdin.as_mut() + && stdin.write_all(&payload).is_err() + { + terminate_child(&mut child); + // The child was already started and may have consumed part of the + // request before the pipe failed; callers must reconcile instead + // of treating this as a safe, dispatch-free spawn failure. + return Err(CodexError::ProcessTerminated); + } + // 关闭 stdin,让一次性脚本知道请求已经结束。 + drop(child.stdin.take()); + + // stdout 必须在进程运行期间持续排空;等 child 退出后再读取会在 + // 大输出超过 pipe 缓冲区时形成互相等待。线程只保留有界字节数, + // 不改变外部进程的生命周期真相。 + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child(&mut child); + return Err(CodexError::ProcessTerminated); + } + }; + let max_output_bytes = self.config.max_output_bytes; + let reader = std::thread::spawn(move || { + let mut bytes = Vec::new(); + let mut stdout = stdout; + let mut chunk = [0_u8; 8192]; + loop { + let read = stdout + .read(&mut chunk) + .map_err(|_| CodexError::InvalidOutput)?; + if read == 0 { + break; + } + // 即使超过限制也继续排空 pipe,避免让外部进程卡在写入上。 + if bytes.len() <= max_output_bytes { + let keep = read.min(max_output_bytes.saturating_add(1) - bytes.len()); + bytes.extend_from_slice(&chunk[..keep]); + } + } + Ok(bytes) + }); + + let started = Instant::now(); + loop { + if cancel_flag.load(Ordering::Acquire) { + terminate_child(&mut child); + let _ = reader.join(); + return Err(CodexError::Interrupted); + } + let status = match child.try_wait() { + Ok(status) => status, + Err(_) => { + terminate_child(&mut child); + let _ = reader.join(); + // try_wait failed after dispatch; the remote process may + // have run the request even though its exit status is no + // longer observable. + return Err(CodexError::ProcessTerminated); + } + }; + if let Some(status) = status { + let bytes = reader.join().map_err(|_| CodexError::InvalidOutput)??; + if bytes.len() > self.config.max_output_bytes { + return Err(CodexError::OutputTooLarge); + } + let exit_code = process_exit_code(&status); + let process_status = if status.success() { + CodexProcessStatus::Completed + } else { + CodexProcessStatus::Failed + }; + let output = serde_json::from_slice::(&bytes).map_err(|_| { + if status.success() { + CodexError::InvalidOutput + } else { + // 非零退出优先保留“进程已执行但结果未知”的语义, + // 不让损坏的 stdout 把它误分类成普通输入错误。 + process_exit_error(&status) + } + })?; + return Ok(CodexCliResult { + external_id, + status: process_status, + output, + exit_code, + }); + } + if started.elapsed() >= self.config.timeout() { + terminate_child(&mut child); + let _ = reader.join(); + return Err(CodexError::Timeout); + } + std::thread::sleep(Duration::from_millis(5)); + } + } +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + use std::os::unix::process::CommandExt; + // 把 child 设为新 process group leader;超时/取消时连同它启动的 + // shell/子进程一起终止,避免 stdout 管道被孤儿进程长期占住。 + command.process_group(0); +} + +#[cfg(not(unix))] +fn configure_process_group(_command: &mut Command) {} + +fn terminate_child(child: &mut Child) -> Option { + #[cfg(unix)] + { + // std::process::Child::kill 只覆盖直接 child;固定路径调用系统 + // kill 发送给负 PID 代表的 process group。失败时仍回退到直接 kill。 + let group_killed = terminate_process_group(child.id()); + if !group_killed { + let _ = child.kill(); + } + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + child.wait().ok() +} + +#[cfg(unix)] +unsafe extern "C" { + fn kill(pid: std::os::raw::c_int, signal: std::os::raw::c_int) -> std::os::raw::c_int; +} + +#[cfg(unix)] +fn terminate_process_group(pid: u32) -> bool { + let Ok(pid) = std::os::raw::c_int::try_from(pid) else { + return false; + }; + // 直接调用 kill(2) 避免每次 cancel/Drop 再创建一个 `/bin/kill` helper; + // 负 PID 表示目标 child 的 process group,SIGKILL 后由调用方 wait/reap。 + // SAFETY: pid 来自本进程刚 spawn 的 child,转换后取负值只用于 process + // group;signal 使用平台稳定的 SIGKILL 数值,调用不持有 Rust 引用。 + unsafe { kill(-pid, 9) == 0 } +} + +fn process_exit_error(status: &ExitStatus) -> CodexError { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return CodexError::Signal(signal); + } + } + CodexError::Exit(status.code().unwrap_or(-1)) +} + +fn process_exit_code(status: &ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + // 负值遵循常见 supervisor 约定,保留“被 signal 终止”的信息, + // 同时不把它误当作一个正常的 shell exit code。 + return -signal; + } + } + -1 +} + +impl ExternalBackend for CodexCliBackend { + fn invoke(&self, request: &BackendRequest) -> Result { + let result = self.invoke_cli(request).map_err(|error| { + ExternalError::new( + error.external_error_kind_for_cli_failure(), + error.to_string(), + ) + })?; + if result.status != CodexProcessStatus::Completed { + // 非零退出说明 Codex 进程确实被启动过,但 stdout/退出状态不 + // 能证明内部副作用是否完成;禁止把它包装成成功 BackendResult。 + return Err(ExternalError::new( + ExternalErrorKind::UnknownSideEffect, + format!( + "Codex CLI 未正常完成: status={:?} exit_code={}", + result.status, result.exit_code + ), + )); + } + BackendResult::try_new(request.request_id(), result.output) + // The process has completed by this point. A local result contract + // failure cannot prove that the external operation was harmless. + .map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + }) + .and_then(|value| { + value.with_external_id(result.external_id).map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + }) + }) + } + + fn cancel(&self, request_id: &str) -> Result<(), ExternalError> { + self.cancel_cli(request_id) + .map_err(|error| ExternalError::new(error.external_error_kind(), error.to_string())) + } +} + +/// App Server 的中立请求/事件/结果 DTO;不暴露 Codex 内部上下文结构。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NodeRequest { + pub request_id: String, + pub operation: String, + pub payload: Value, +} + +static NEXT_NODE_REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +impl NodeRequest { + /// 构造一个带显式 ID 的中立请求,并在进入 wire 层前做最小边界校验。 + pub fn try_new( + request_id: impl Into, + operation: impl Into, + payload: Value, + ) -> Result { + let request_id = request_id.into(); + let operation = operation.into(); + if request_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server request_id 不能为空".to_owned(), + )); + } + if operation.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server operation 不能为空".to_owned(), + )); + } + Ok(Self { + request_id, + operation, + payload, + }) + } + + /// 当上层没有自己的 ID 时生成本适配器范围内唯一的 ID。ID 仍只是 + /// 一次调用的关联键,不代表 Codex 服务端的持久化会话。 + pub fn with_generated_id( + operation: impl Into, + payload: Value, + ) -> Result { + Self::try_new( + format!( + "codex-node-{}", + NEXT_NODE_REQUEST_ID.fetch_add(1, Ordering::Relaxed) + ), + operation, + payload, + ) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NodeEvent { + pub request_id: String, + pub event_type: String, + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NodeResult { + pub request_id: String, + pub output: Value, + #[serde(default)] + pub side_effect_unknown: bool, +} + +/// App Server 连接由宿主注入,方便用本地 fixture 测试 request ID、事件流和中断。 +pub trait AppServerChannel: Send { + /// 启动/握手边界。默认实现让内存 fixture 保持轻量;真实 wire 实现应 + /// 校验自己的协议版本和 session,再允许 request/event/result 流动。 + fn initialize(&mut self, _session_id: &str) -> Result<(), CodexError> { + Ok(()) + } + + fn send(&mut self, request: NodeRequest) -> Result; + + /// 长连接实现可逐条转发事件;默认通道仍可只返回最终结果,保持最小 + /// fixture/同步调用兼容。事件只是观察值,不直接修改 Host 状态。 + fn send_with_events( + &mut self, + request: NodeRequest, + events: &mut dyn FnMut(NodeEvent), + ) -> Result { + let result = self.send(request)?; + let _ = events; + Ok(result) + } + + fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> { + Ok(()) + } +} + +type AppServerInterruptHook = dyn Fn(&str) -> Result<(), CodexError> + Send + Sync; + +pub struct CodexAppServerBackend { + channel: std::sync::Mutex, + session_id: String, + /// 可选的带外中断路径。普通 channel interrupt 与 `send` 共用同一把锁, + /// 同步请求等待响应时无法取得进展;调用方若有独立控制传输,可显式启用 + /// 这个 hook。hook 执行时不会获取 `channel` 锁。 + interrupt_hook: Option>, +} + +impl std::fmt::Debug for CodexAppServerBackend { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CodexAppServerBackend") + .field("session_id", &self.session_id) + .field("interrupt_hook", &self.interrupt_hook.is_some()) + .finish_non_exhaustive() + } +} + +impl CodexAppServerBackend { + pub fn new(channel: C, session_id: impl Into) -> Result { + let session_id = session_id.into(); + if session_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server session_id 不能为空".to_owned(), + )); + } + Ok(Self { + channel: std::sync::Mutex::new(channel), + session_id, + interrupt_hook: None, + }) + } + + /// 显式安装带外中断路径。 + /// + /// 回调必须使用与 [`Self::new`] 传入的 `AppServerChannel` 独立的传输。 + /// 它会在回退到 channel 锁路径前调用,因此 `invoke_node` 等待响应时也能 + /// 发送中断;未显式启用时,原有 channel interrupt 行为保持不变。 + pub fn with_interrupt_hook(mut self, hook: F) -> Self + where + F: Fn(&str) -> Result<(), CodexError> + Send + Sync + 'static, + { + self.interrupt_hook = Some(Arc::new(hook)); + self + } + + /// 创建并立即执行一次中立协议握手。保留 [`Self::new`] 的惰性行为, + /// 使只发送 interrupt 或使用旧 fixture 的调用方仍可自行控制读写时机。 + pub fn new_initialized(channel: C, session_id: impl Into) -> Result { + let backend = Self::new(channel, session_id)?; + // 这里不能从 backend 再取出 channel;先用一次锁调用初始化,仍保持 + // 与正常 invoke 相同的共享状态边界。 + backend.initialize()?; + Ok(backend) + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 暴露可注入的启动边界,供断线重连或宿主显式恢复时重新握手。 + pub fn initialize(&self) -> Result<(), CodexError> { + self.channel + .lock() + .map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))? + .initialize(&self.session_id) + } + + pub fn invoke_node(&self, request: NodeRequest) -> Result { + validate_node_request(&request)?; + let expected_request_id = request.request_id.clone(); + let result = self + .channel + .lock() + .map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))? + .send(request)?; + validate_node_result_request_id(&expected_request_id, result) + } + + pub fn invoke_node_with_events( + &self, + request: NodeRequest, + events: &mut dyn FnMut(NodeEvent), + ) -> Result { + validate_node_request(&request)?; + let expected_request_id = request.request_id.clone(); + let result = self + .channel + .lock() + .map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))? + .send_with_events(request, events)?; + validate_node_result_request_id(&expected_request_id, result) + } + + /// 直接把 App Server 的中立 request/event/result 流接到 Core 事件映射器。 + /// + /// 这是一个显式 bridge:它先发出 `NodeRequest` 对应的审计事件,再把 + /// channel 逐条产生的 `NodeEvent` 和最终 `NodeResult` 交给同一个 + /// [`NodeRuntimeEventMapper`]。回调收到的 `RuntimeEvent` 仍需由调用方 + /// 写入 RuntimeStore/reducer;本方法不隐式修改 Host 或创建第二套会话。 + /// 如果某条事件无法映射,当前 channel 调用会先结束,随后返回协议错误, + /// 不会把未知厂商事件静默丢弃。 + pub fn invoke_node_with_runtime_events( + &self, + request: NodeRequest, + mapper: &mut NodeRuntimeEventMapper, + sink: &mut dyn FnMut(RuntimeEvent), + ) -> Result { + let request_event = mapper.map_request(&request)?; + sink(request_event); + + let mut mapping_error = None; + let result = self.invoke_node_with_events(request, &mut |event| { + if mapping_error.is_some() { + return; + } + match mapper.map_event(&event) { + Ok(runtime_event) => sink(runtime_event), + Err(error) => mapping_error = Some(error), + } + })?; + if let Some(error) = mapping_error { + return Err(error); + } + + let result_event = mapper.map_result(&result)?; + sink(result_event); + Ok(result) + } + + pub fn interrupt(&self, request_id: &str) -> Result<(), CodexError> { + if let Some(hook) = self.interrupt_hook.as_ref() { + // 这里不获取 `channel` 锁:正常 invoke 路径可能在等待服务端响应时 + // 持有该锁。 + return hook(request_id); + } + self.channel + .lock() + .map_err(|_| CodexError::Protocol("App Server channel 锁已损坏".to_owned()))? + .interrupt(request_id) + } +} + +fn validate_node_request(request: &NodeRequest) -> Result<(), CodexError> { + if request.request_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server request_id 不能为空".to_owned(), + )); + } + if request.operation.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server operation 不能为空".to_owned(), + )); + } + Ok(()) +} + +fn validate_node_result_request_id( + expected_request_id: &str, + result: NodeResult, +) -> Result { + if result.request_id != expected_request_id { + return Err(CodexError::Protocol(format!( + "App Server request_id 不匹配: expected={expected_request_id} actual={}", + result.request_id + ))); + } + Ok(result) +} + +impl ExternalBackend for CodexAppServerBackend { + fn invoke(&self, request: &BackendRequest) -> Result { + let node = NodeRequest { + request_id: request.request_id().to_owned(), + operation: request.operation().to_owned(), + payload: request.payload().clone(), + }; + let result = self + .invoke_node(node) + // `invoke_node` validates before writing, but every channel error + // after that boundary is conservatively treated as an unknown + // side effect. Host can persist the request ID and reconcile it + // instead of silently replaying a possibly accepted operation. + .map_err(|error| { + ExternalError::new( + error.external_error_kind_after_dispatch(), + error.to_string(), + ) + })?; + BackendResult::try_new(request.request_id(), result.output) + // A response that violates the Core contract still proves that + // the external request was processed; do not turn it into a safe + // retry just because local DTO validation failed. + .map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + }) + .and_then(|value| { + value + .with_external_id(format!("{}:{}", self.session_id, result.request_id)) + .map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + }) + }) + .map(|value| value.with_unknown_side_effect(result.side_effect_unknown)) + } + + fn cancel(&self, request_id: &str) -> Result<(), ExternalError> { + self.interrupt(request_id) + // The interrupt is sent for an already active external request; + // protocol/transport failures therefore cannot be treated as a + // harmless configuration error or safely retried operation. + .map_err(|error| { + ExternalError::new( + error.external_error_kind_after_dispatch(), + error.to_string(), + ) + }) + } +} + +/// 将一个 App Server 事件转换成稳定的外部审计字段。 +pub fn node_event_json(event: &NodeEvent) -> Value { + json!({ + "requestId": &event.request_id, + "eventType": &event.event_type, + "payload": &event.payload, + }) +} + +/// 将中立 App Server DTO 映射为 Core `RuntimeEvent`。 +/// +/// `RuntimeEvent` 没有通用的 external-request/opaque kind,因此普通 +/// `NodeRequest` 会被记录为一条带有完整 JSON envelope 的 developer message。 +/// 这只是受信的适配器审计文本,不伪造用户消息,也不会替调用方推进 +/// `RuntimeSnapshot`。当 operation 明确表示 tool call 时,才使用 +/// `ToolCallRequested`,避免把任意 Node payload 当成工具调用。 +/// +/// Mapper 自己只维护事件游标和 request correlation;调用方仍必须把返回事件 +/// 交给 Core reducer。这样状态转换(例如完成前必须处于 Running)仍由 Core +/// 统一校验,而不是在 Codex 适配器中复制一套状态机。 +#[derive(Clone, Debug)] +pub struct NodeRuntimeEventMapper { + runtime_id: String, + run_id: String, + last_revision: u64, + occurred_at_ms: u64, + active_request_id: Option, + reconciliation_pending: bool, + terminal_result: bool, +} + +impl NodeRuntimeEventMapper { + /// `current_revision` 应是调用方快照中已经提交的 runtime revision;返回 + /// 的第一条事件会使用其后的连续 revision。ID 在这里按 Core 的稳定标识 + /// 规则校验,避免先生成一个 reducer 必然拒绝的事件。 + pub fn try_new( + runtime_id: impl Into, + run_id: impl Into, + current_revision: u64, + occurred_at_ms: u64, + ) -> Result { + let runtime_id = runtime_id.into(); + let run_id = run_id.into(); + let next_revision = current_revision.checked_add(1).ok_or_else(|| { + CodexError::InvalidConfig("runtime event revision 已达到上限".to_owned()) + })?; + + // RuntimeEvent::new 与 Core reducer 使用同一套 runtime/run ID 校验; + // 这里构造并丢弃一个最小事件,避免在适配器中复制校验规则。 + RuntimeEvent::new( + runtime_id.clone(), + next_revision, + occurred_at_ms, + RuntimeEventKind::RunStarted, + Some(run_id.clone()), + json!({}), + ) + .map_err(|error| CodexError::InvalidConfig(format!("runtime/run 标识无效: {error}")))?; + + Ok(Self { + runtime_id, + run_id, + last_revision: current_revision, + occurred_at_ms, + active_request_id: None, + reconciliation_pending: false, + terminal_result: false, + }) + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + + /// 返回最后一条已生成事件的 revision。尚未生成事件时这是构造器传入的 + /// snapshot revision,而不是一个虚构的 revision 0。 + pub fn revision(&self) -> u64 { + self.last_revision + } + + pub fn request_id(&self) -> Option<&str> { + self.active_request_id.as_deref() + } + + /// 映射一个 Node 请求。普通 operation 以 neutral audit message 记录; + /// `tool_call`/`tool` 等显式 operation 才解析为 Core tool call。 + pub fn map_request(&mut self, request: &NodeRequest) -> Result { + validate_node_request(request)?; + self.ensure_can_accept_request(&request.request_id)?; + + let (kind, detail) = if is_tool_request_operation(&request.operation) { + let call = node_tool_call(&request.payload)?; + ( + RuntimeEventKind::ToolCallRequested, + serde_json::to_value(call).map_err(|error| { + CodexError::Protocol(format!("tool call 编码失败: {error}")) + })?, + ) + } else { + let audit = json!({ + "source": "codex-node", + "kind": "request", + "requestId": &request.request_id, + "operation": &request.operation, + "payload": &request.payload, + }); + let audit_text = serde_json::to_string(&audit).map_err(|error| { + CodexError::Protocol(format!("NodeRequest 审计文本编码失败: {error}")) + })?; + let message = Message::developer(format!("[codex node request audit] {audit_text}")) + .map_err(|error| { + CodexError::Protocol(format!("NodeRequest 审计消息无效: {error}")) + })?; + ( + RuntimeEventKind::MessageAppended, + serde_json::to_value(message).map_err(|error| { + CodexError::Protocol(format!("NodeRequest 消息编码失败: {error}")) + })?, + ) + }; + + let event = self.push_event(kind, detail)?; + self.active_request_id = Some(request.request_id.clone()); + Ok(event) + } + + /// 映射一个与当前 request 关联的中立事件。这里只接受有明确 Core + /// 语义的白名单;未知 event type 返回 Protocol,而不是静默丢弃。 + pub fn map_event(&mut self, event: &NodeEvent) -> Result { + self.ensure_request_matches(&event.request_id)?; + let event_type = normalize_node_event_type(&event.event_type); + self.ensure_event_allowed_while_pending(&event_type)?; + + let (kind, detail) = match event_type.as_str() { + "delta" | "message" | "message_delta" | "output_text" | "text" => { + let text = node_event_text(&event.payload)?; + let message = Message::assistant(text).map_err(|error| { + CodexError::Protocol(format!("NodeEvent 文本无效: {error}")) + })?; + ( + RuntimeEventKind::MessageAppended, + serde_json::to_value(message).map_err(|error| { + CodexError::Protocol(format!("NodeEvent 消息编码失败: {error}")) + })?, + ) + } + "tool_call" | "tool_call_requested" | "tool_use" => { + let call = node_tool_call(&event.payload)?; + ( + RuntimeEventKind::ToolCallRequested, + serde_json::to_value(call).map_err(|error| { + CodexError::Protocol(format!("NodeEvent tool call 编码失败: {error}")) + })?, + ) + } + "tool_started" | "tool_call_started" => { + (RuntimeEventKind::ToolCallStarted, node_event_json(event)) + } + "tool_result" + | "tool_call_completed" + | "tool_completed" + | "tool_call_failed" + | "tool_failed" => { + let failed_by_type = event_type.ends_with("failed"); + let (result, failed) = node_tool_result(&event.payload, failed_by_type)?; + ( + if failed { + RuntimeEventKind::ToolCallFailed + } else { + RuntimeEventKind::ToolCallCompleted + }, + serde_json::to_value(result).map_err(|error| { + CodexError::Protocol(format!("NodeEvent tool result 编码失败: {error}")) + })?, + ) + } + "reconciliation_required" | "unknown_side_effect" => ( + RuntimeEventKind::ReconciliationRequired, + node_event_json(event), + ), + "reconciled" | "run_reconciled" => { + let mut detail = node_event_json(event); + if let Some(object) = detail.as_object_mut() { + // Core requires both flags before reopening a reconciliation gate. + object.insert("reconciled".to_owned(), Value::Bool(true)); + object.insert("external_pending".to_owned(), Value::Bool(false)); + } + (RuntimeEventKind::RunReconciled, detail) + } + "started" | "run_started" => (RuntimeEventKind::RunStarted, node_event_json(event)), + "paused" | "run_paused" => (RuntimeEventKind::RunPaused, node_event_json(event)), + "resumed" | "run_resumed" => (RuntimeEventKind::RunResumed, node_event_json(event)), + "approval_requested" => (RuntimeEventKind::ApprovalRequested, node_event_json(event)), + "approval_resolved" => (RuntimeEventKind::ApprovalResolved, node_event_json(event)), + "cancelled" | "canceled" | "run_cancelled" => { + (RuntimeEventKind::RunCancelled, node_event_json(event)) + } + "completed" | "run_completed" => { + let output = event_output(&event.payload); + ( + RuntimeEventKind::RunCompleted, + json!({ + "summary": output_summary(&output), + "requestId": &event.request_id, + "output": output, + "eventType": &event.event_type, + }), + ) + } + "failed" | "run_failed" => { + let error = node_error_text(&event.payload); + ( + RuntimeEventKind::RunFailed, + json!({ + "error": error, + "requestId": &event.request_id, + "eventType": &event.event_type, + "payload": &event.payload, + }), + ) + } + _ => { + return Err(CodexError::Protocol(format!( + "不支持的 NodeEvent event_type: {}", + event.event_type + ))); + } + }; + + let terminal = matches!( + kind, + RuntimeEventKind::RunCompleted + | RuntimeEventKind::RunFailed + | RuntimeEventKind::RunCancelled + ); + let opens_reconciliation = kind == RuntimeEventKind::ReconciliationRequired; + let closes_reconciliation = kind == RuntimeEventKind::RunReconciled; + let runtime_event = self.push_event(kind, detail)?; + self.terminal_result |= terminal; + if opens_reconciliation { + self.reconciliation_pending = true; + } else if closes_reconciliation { + self.reconciliation_pending = false; + } + Ok(runtime_event) + } + + /// 映射最终 Node 结果。完整 `output` 会保留在 event detail 中,即使 + /// Core reducer 只从 `summary` 字段更新 `RunSnapshot.final_text`。 + /// `side_effect_unknown` 结果先进入 reconciliation gate,不能被当成完成。 + pub fn map_result(&mut self, result: &NodeResult) -> Result { + self.ensure_request_matches(&result.request_id)?; + if self.reconciliation_pending { + return Err(CodexError::Protocol( + "reconciliation 尚未明确收口,不能提交 NodeResult".to_owned(), + )); + } + if self.terminal_result { + return Err(CodexError::Protocol( + "NodeResult 已经在当前 mapper 中收口".to_owned(), + )); + } + + let output = result.output.clone(); + let detail = if result.side_effect_unknown { + json!({ + "requestId": &result.request_id, + "output": output, + "sideEffectUnknown": true, + "external_pending": true, + }) + } else { + json!({ + "requestId": &result.request_id, + "output": output, + "sideEffectUnknown": false, + "summary": output_summary(&result.output), + }) + }; + let kind = if result.side_effect_unknown { + RuntimeEventKind::ReconciliationRequired + } else { + RuntimeEventKind::RunCompleted + }; + let event = self.push_event(kind, detail)?; + if result.side_effect_unknown { + self.reconciliation_pending = true; + } else { + self.terminal_result = true; + } + Ok(event) + } + + fn ensure_can_accept_request(&self, request_id: &str) -> Result<(), CodexError> { + if request_id.trim().is_empty() { + return Err(CodexError::Protocol( + "NodeRequest request_id 不能为空".to_owned(), + )); + } + if self.active_request_id.is_some() { + return Err(CodexError::Protocol( + "当前 mapper 已绑定一个 NodeRequest".to_owned(), + )); + } + if self.terminal_result { + return Err(CodexError::Protocol( + "当前 mapper 已经收口,不能重新绑定 NodeRequest".to_owned(), + )); + } + Ok(()) + } + + fn ensure_request_matches(&self, request_id: &str) -> Result<(), CodexError> { + let Some(expected) = self.active_request_id.as_deref() else { + return Err(CodexError::Protocol( + "NodeEvent/NodeResult 在 NodeRequest 之前到达".to_owned(), + )); + }; + if request_id.trim().is_empty() || request_id != expected { + return Err(CodexError::Protocol(format!( + "Node request_id 不匹配: expected={expected} actual={request_id}" + ))); + } + if self.terminal_result { + return Err(CodexError::Protocol( + "当前 mapper 已经收口,不能继续接收 Node 事件".to_owned(), + )); + } + Ok(()) + } + + fn ensure_event_allowed_while_pending(&self, event_type: &str) -> Result<(), CodexError> { + if !self.reconciliation_pending { + return Ok(()); + } + if matches!( + event_type, + "reconciled" + | "run_reconciled" + | "cancelled" + | "canceled" + | "run_cancelled" + | "failed" + | "run_failed" + ) { + Ok(()) + } else { + Err(CodexError::Protocol( + "reconciliation gate 未收口,不能继续映射 NodeEvent".to_owned(), + )) + } + } + + fn push_event( + &mut self, + kind: RuntimeEventKind, + detail: Value, + ) -> Result { + let revision = self + .last_revision + .checked_add(1) + .ok_or_else(|| CodexError::Protocol("runtime event revision 溢出".to_owned()))?; + let event = RuntimeEvent::new( + self.runtime_id.clone(), + revision, + self.occurred_at_ms, + kind, + Some(self.run_id.clone()), + detail, + ) + .map_err(|error| CodexError::Protocol(format!("RuntimeEvent 构造失败: {error}")))?; + // Keep these checks explicit: if a future refactor changes the constructor + // arguments, a mismatched event must fail here instead of reaching storage. + if event.runtime_id() != self.runtime_id + || event.run_id() != Some(self.run_id.as_str()) + || event.revision() != revision + { + return Err(CodexError::Protocol( + "生成的 RuntimeEvent identity/revision 不匹配".to_owned(), + )); + } + self.last_revision = revision; + Ok(event) + } +} + +fn normalize_node_event_type(event_type: &str) -> String { + let normalized = event_type + .trim() + .to_ascii_lowercase() + .replace(['-', '.', ' '], "_"); + // Codex app-server notifications use slash-qualified method names. Keep + // the mapping explicit so audited lifecycle events enter the neutral + // mapper while unknown vendor methods still fail closed below. + match normalized.as_str() { + "item/agentmessage/delta" => "delta".to_owned(), + "turn/completed" => "completed".to_owned(), + "turn/failed" => "failed".to_owned(), + "turn/cancelled" | "turn/canceled" => "cancelled".to_owned(), + "item/tool/call" => "tool_call".to_owned(), + "item/tool/result" => "tool_result".to_owned(), + other => other.to_owned(), + } +} + +fn is_tool_request_operation(operation: &str) -> bool { + matches!( + normalize_node_event_type(operation).as_str(), + "tool" | "tool_call" | "tool_call_requested" | "tool_use" + ) +} + +fn node_payload_field<'a>(payload: &'a Value, names: &[&str]) -> Option<&'a Value> { + let object = payload.as_object()?; + names.iter().find_map(|name| object.get(*name)) +} + +fn node_string_field(payload: &Value, names: &[&str], field: &str) -> Result { + node_payload_field(payload, names) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| CodexError::Protocol(format!("Node payload 缺少有效 {field}"))) +} + +fn node_tool_call(payload: &Value) -> Result { + let payload = node_payload_field(payload, &["call", "toolCall"]).unwrap_or(payload); + let id = node_string_field( + payload, + &["callId", "call_id", "id", "toolCallId", "tool_call_id"], + "callId", + )?; + let name = node_string_field(payload, &["name", "tool", "toolName", "tool_name"], "name")?; + let arguments = match node_payload_field(payload, &["arguments", "args", "input"]) { + None => json!({}), + Some(Value::String(text)) => serde_json::from_str(text).map_err(|error| { + CodexError::Protocol(format!("Node tool arguments 不是 JSON object: {error}")) + })?, + Some(value) => value.clone(), + }; + ToolCall::try_new(id, name, arguments) + .map_err(|error| CodexError::Protocol(format!("Node tool call 无效: {error}"))) +} + +fn node_tool_result( + payload: &Value, + failed_by_type: bool, +) -> Result<(ToolResult, bool), CodexError> { + let call_id = node_string_field( + payload, + &["callId", "call_id", "toolCallId", "tool_call_id", "id"], + "callId", + )?; + let output = node_payload_field(payload, &["output", "result", "value"]) + .cloned() + .unwrap_or_else(|| payload.clone()); + let failed = node_payload_field(payload, &["isError", "is_error", "failed"]) + .and_then(Value::as_bool) + .unwrap_or(failed_by_type); + let result = if failed { + ToolResult::failure(call_id, output) + } else { + ToolResult::success(call_id, output) + } + .map_err(|error| CodexError::Protocol(format!("Node tool result 无效: {error}")))?; + Ok((result, failed)) +} + +fn node_event_text(payload: &Value) -> Result { + match payload { + Value::String(text) if !text.trim().is_empty() => Ok(text.clone()), + Value::Object(object) => ["text", "delta", "output"] + .iter() + .find_map(|name| object.get(*name).and_then(Value::as_str)) + .filter(|text| !text.trim().is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + CodexError::Protocol("NodeEvent 文本 payload 缺少 text/delta/output".to_owned()) + }), + _ => Err(CodexError::Protocol( + "NodeEvent 文本 payload 必须是非空字符串或对象".to_owned(), + )), + } +} + +fn event_output(payload: &Value) -> Value { + node_payload_field(payload, &["output", "result", "value"]) + .cloned() + .unwrap_or_else(|| payload.clone()) +} + +fn output_summary(output: &Value) -> String { + match output { + Value::String(text) if !text.is_empty() => text.clone(), + _ => output.to_string(), + } +} + +fn node_error_text(payload: &Value) -> String { + node_payload_field(payload, &["error", "message", "reason"]) + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + payload + .as_str() + .filter(|text| !text.trim().is_empty()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "Codex node failed".to_owned()) +} + +/// 一个不绑定具体 Codex 版本的 JSONL App Server 通道。 +/// +/// Codex 的进程启动参数和内部 wire schema 会随发行版变化,因此这里故意只 +/// 约定本仓库的中立 fixture 格式:每行一个带 `kind` 的 JSON frame,`event` +/// 和 `result` 必须带回原始 `requestId`。真正的 Codex 进程适配器可以把它的 +/// wire frame 转换到这个接口,而不会把厂商字段泄漏到 Core 或 Host。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +// `protocolVersion` 由 `AppServerWireFrame` 校验;这里允许读取该 envelope +// 中的额外字段,以兼容仍直接反序列化中立 DTO 的旧调用方。 +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum AppServerFrame { + Initialize { + #[serde(rename = "sessionId")] + session_id: String, + }, + Ready { + #[serde(rename = "sessionId")] + session_id: String, + }, + Request { + #[serde(rename = "requestId")] + request_id: String, + operation: String, + payload: Value, + }, + Event { + #[serde(rename = "requestId")] + request_id: String, + #[serde(rename = "eventType")] + event_type: String, + payload: Value, + }, + Result { + #[serde(rename = "requestId")] + request_id: String, + output: Value, + #[serde(default)] + #[serde(rename = "sideEffectUnknown")] + side_effect_unknown: bool, + }, + Error { + #[serde(rename = "requestId")] + request_id: String, + message: String, + }, + Interrupt { + #[serde(rename = "requestId")] + request_id: String, + }, +} + +/// JSONL 上实际传输的版本化 frame。`AppServerFrame` 保持为中立 DTO,避免 +/// 把协议版本字段散落到 Host/Core;只有这个 wire enum 会携带版本。 +/// +/// 这里的版本是本仓库 fixture 协议版本,不声称与任意具体 Codex 版本兼容。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum AppServerWireFrame { + Initialize { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "sessionId")] + session_id: String, + }, + Ready { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "sessionId")] + session_id: String, + }, + Request { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "requestId")] + request_id: String, + operation: String, + payload: Value, + }, + Event { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "requestId")] + request_id: String, + #[serde(rename = "eventType")] + event_type: String, + payload: Value, + }, + Result { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "requestId")] + request_id: String, + output: Value, + #[serde(default)] + #[serde(rename = "sideEffectUnknown")] + side_effect_unknown: bool, + }, + Error { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "requestId")] + request_id: String, + message: String, + }, + Interrupt { + #[serde( + rename = "protocolVersion", + default = "default_app_server_protocol_version" + )] + protocol_version: u16, + #[serde(rename = "requestId")] + request_id: String, + }, +} + +impl AppServerWireFrame { + fn from_frame(frame: AppServerFrame) -> Self { + let version = APP_SERVER_PROTOCOL_VERSION; + match frame { + AppServerFrame::Initialize { session_id } => Self::Initialize { + protocol_version: version, + session_id, + }, + AppServerFrame::Ready { session_id } => Self::Ready { + protocol_version: version, + session_id, + }, + AppServerFrame::Request { + request_id, + operation, + payload, + } => Self::Request { + protocol_version: version, + request_id, + operation, + payload, + }, + AppServerFrame::Event { + request_id, + event_type, + payload, + } => Self::Event { + protocol_version: version, + request_id, + event_type, + payload, + }, + AppServerFrame::Result { + request_id, + output, + side_effect_unknown, + } => Self::Result { + protocol_version: version, + request_id, + output, + side_effect_unknown, + }, + AppServerFrame::Error { + request_id, + message, + } => Self::Error { + protocol_version: version, + request_id, + message, + }, + AppServerFrame::Interrupt { request_id } => Self::Interrupt { + protocol_version: version, + request_id, + }, + } + } + + fn protocol_version(&self) -> u16 { + match self { + Self::Initialize { + protocol_version, .. + } + | Self::Ready { + protocol_version, .. + } + | Self::Request { + protocol_version, .. + } + | Self::Event { + protocol_version, .. + } + | Self::Result { + protocol_version, .. + } + | Self::Error { + protocol_version, .. + } + | Self::Interrupt { + protocol_version, .. + } => *protocol_version, + } + } + + fn into_frame(self) -> Result { + let version = self.protocol_version(); + if version != APP_SERVER_PROTOCOL_VERSION { + return Err(CodexError::Protocol(format!( + "App Server protocolVersion 不支持: expected={} actual={version}", + APP_SERVER_PROTOCOL_VERSION + ))); + } + Ok(match self { + Self::Initialize { session_id, .. } => AppServerFrame::Initialize { session_id }, + Self::Ready { session_id, .. } => AppServerFrame::Ready { session_id }, + Self::Request { + request_id, + operation, + payload, + .. + } => AppServerFrame::Request { + request_id, + operation, + payload, + }, + Self::Event { + request_id, + event_type, + payload, + .. + } => AppServerFrame::Event { + request_id, + event_type, + payload, + }, + Self::Result { + request_id, + output, + side_effect_unknown, + .. + } => AppServerFrame::Result { + request_id, + output, + side_effect_unknown, + }, + Self::Error { + request_id, + message, + .. + } => AppServerFrame::Error { + request_id, + message, + }, + Self::Interrupt { request_id, .. } => AppServerFrame::Interrupt { request_id }, + }) + } +} + +impl AppServerFrame { + fn request(request: &NodeRequest) -> Self { + Self::Request { + request_id: request.request_id.clone(), + operation: request.operation.clone(), + payload: request.payload.clone(), + } + } + + fn into_node_event(self) -> Result { + match self { + Self::Event { + request_id, + event_type, + payload, + } => Ok(NodeEvent { + request_id, + event_type, + payload, + }), + other => Err(CodexError::Protocol(format!( + "App Server frame 不是 event: {}", + frame_kind(&other) + ))), + } + } + + fn into_node_result(self) -> Result { + match self { + Self::Result { + request_id, + output, + side_effect_unknown, + } => Ok(NodeResult { + request_id, + output, + side_effect_unknown, + }), + Self::Error { + request_id, + message, + } => Err(CodexError::Protocol(format!( + "App Server 返回错误 (request_id={request_id}): {message}" + ))), + other => Err(CodexError::Protocol(format!( + "App Server frame 不是 result: {}", + frame_kind(&other) + ))), + } + } +} + +fn frame_kind(frame: &AppServerFrame) -> &'static str { + match frame { + AppServerFrame::Initialize { .. } => "initialize", + AppServerFrame::Ready { .. } => "ready", + AppServerFrame::Request { .. } => "request", + AppServerFrame::Event { .. } => "event", + AppServerFrame::Result { .. } => "result", + AppServerFrame::Error { .. } => "error", + AppServerFrame::Interrupt { .. } => "interrupt", + } +} + +/// 以有界 JSONL frame 驱动 [`AppServerChannel`]。读写端由调用方注入,因而 +/// 可以接真实 child process 的 stdin/stdout,也可以在测试中使用 Cursor;本 +/// 类型本身不负责创建进程、重连或保存外部 session。 +pub struct JsonLineAppServerChannel { + reader: BufReader, + writer: W, + max_frame_bytes: usize, + initialized: bool, + strict_protocol: bool, +} + +impl JsonLineAppServerChannel { + pub fn new(reader: R, writer: W) -> Result { + Self::with_max_frame_bytes(reader, writer, DEFAULT_MAX_OUTPUT_BYTES) + } + + pub fn with_max_frame_bytes( + reader: R, + writer: W, + max_frame_bytes: usize, + ) -> Result { + if max_frame_bytes == 0 { + return Err(CodexError::InvalidConfig( + "App Server max_frame_bytes 必须大于 0".to_owned(), + )); + } + Ok(Self { + reader: BufReader::new(reader), + writer, + max_frame_bytes, + initialized: false, + strict_protocol: false, + }) + } + + pub fn max_frame_bytes(&self) -> usize { + self.max_frame_bytes + } + + pub fn into_parts(self) -> (R, W) { + (self.reader.into_inner(), self.writer) + } + + fn write_frame(&mut self, frame: &AppServerFrame) -> Result<(), CodexError> { + let wire = AppServerWireFrame::from_frame(frame.clone()); + let mut encoded = serde_json::to_vec(&wire) + .map_err(|error| CodexError::Protocol(format!("App Server frame 编码失败: {error}")))?; + if encoded.len().saturating_add(1) > self.max_frame_bytes { + return Err(CodexError::Protocol(format!( + "App Server frame 超过 {} 字节限制", + self.max_frame_bytes + ))); + } + encoded.push(b'\n'); + self.writer + .write_all(&encoded) + .map_err(|error| CodexError::Protocol(format!("App Server frame 写入失败: {error}")))?; + self.writer + .flush() + .map_err(|error| CodexError::Protocol(format!("App Server flush 失败: {error}"))) + } + + /// 从 BufReader 读取一行,同时限制没有换行符的 frame,避免对端发送 + /// 无限长单行 JSON 时把内存耗尽。 + fn read_frame(&mut self) -> Result, CodexError> { + loop { + let mut bytes = Vec::new(); + loop { + let (take, has_newline) = { + let available = self.reader.fill_buf().map_err(|error| { + CodexError::Protocol(format!("App Server frame 读取失败: {error}")) + })?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + return Err(CodexError::Protocol( + "App Server 在 frame 结束前关闭连接".to_owned(), + )); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + ( + newline.map_or(available.len(), |index| index + 1), + newline.is_some(), + ) + }; + if bytes.len().saturating_add(take) > self.max_frame_bytes { + return Err(CodexError::Protocol(format!( + "App Server frame 超过 {} 字节限制", + self.max_frame_bytes + ))); + } + let available = self.reader.fill_buf().map_err(|error| { + CodexError::Protocol(format!("App Server frame 读取失败: {error}")) + })?; + bytes.extend_from_slice(&available[..take]); + self.reader.consume(take); + if has_newline { + break; + } + } + while matches!(bytes.last(), Some(b'\n' | b'\r')) { + bytes.pop(); + } + // Ignore empty keep-alive lines without recursive calls. A peer can + // send an arbitrary number of blank lines, so recursion here would + // grow the stack for otherwise harmless protocol input. + if bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let raw = serde_json::from_slice::(&bytes).map_err(|error| { + CodexError::Protocol(format!("App Server JSON frame 无效: {error}")) + })?; + if self.strict_protocol && raw.get("protocolVersion").is_none() { + return Err(CodexError::Protocol( + "App Server frame 缺少 protocolVersion".to_owned(), + )); + } + let wire = serde_json::from_value::(raw).map_err(|error| { + CodexError::Protocol(format!( + "App Server JSON frame 无效或缺少 protocolVersion: {error}" + )) + })?; + return wire.into_frame().map(Some); + } + } +} + +impl AppServerChannel for JsonLineAppServerChannel { + fn initialize(&mut self, session_id: &str) -> Result<(), CodexError> { + if session_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server session_id 不能为空".to_owned(), + )); + } + if self.initialized { + return Ok(()); + } + self.write_frame(&AppServerFrame::Initialize { + session_id: session_id.to_owned(), + })?; + // 握手成功后要求后续 request/event/result 都带显式版本;惰性 legacy + // 模式仍允许读取旧 fixture,以保持 `new` 的兼容性。 + self.strict_protocol = true; + let Some(frame) = self.read_frame()? else { + return Err(CodexError::Protocol( + "App Server 握手后未收到 ready".to_owned(), + )); + }; + match frame { + AppServerFrame::Ready { + session_id: ready_session, + } if ready_session == session_id => { + self.initialized = true; + Ok(()) + } + AppServerFrame::Ready { + session_id: actual_session, + } => Err(CodexError::Protocol(format!( + "App Server ready session_id 不匹配: expected={session_id}, actual={actual_session}", + ))), + other => Err(CodexError::Protocol(format!( + "App Server 握手后收到 {} frame,而不是 ready", + frame_kind(&other) + ))), + } + } + + fn send(&mut self, request: NodeRequest) -> Result { + self.send_with_events(request, &mut |_| {}) + } + + fn send_with_events( + &mut self, + request: NodeRequest, + events: &mut dyn FnMut(NodeEvent), + ) -> Result { + validate_node_request(&request)?; + self.write_frame(&AppServerFrame::request(&request))?; + loop { + let Some(frame) = self.read_frame()? else { + return Err(CodexError::Protocol( + "App Server 在返回 result 前关闭连接".to_owned(), + )); + }; + match frame { + AppServerFrame::Event { ref request_id, .. } => { + if request_id != &request.request_id { + return Err(CodexError::Protocol(format!( + "App Server event request_id 不匹配: expected={} actual={request_id}", + request.request_id + ))); + } + let event = frame.into_node_event()?; + events(event); + } + AppServerFrame::Result { ref request_id, .. } + | AppServerFrame::Error { ref request_id, .. } => { + if request_id != &request.request_id { + return Err(CodexError::Protocol(format!( + "App Server result request_id 不匹配: expected={} actual={request_id}", + request.request_id + ))); + } + return frame.into_node_result(); + } + other => { + return Err(CodexError::Protocol(format!( + "App Server 在等待 result 时收到 {} frame", + frame_kind(&other) + ))); + } + } + } + } + + fn interrupt(&mut self, request_id: &str) -> Result<(), CodexError> { + if request_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "App Server interrupt request_id 不能为空".to_owned(), + )); + } + self.write_frame(&AppServerFrame::Interrupt { + request_id: request_id.to_owned(), + }) + } +} + +/// 一个最小的 Codex App Server JSON-RPC/stdio 通道。 +/// +/// App Server 当前在 stdio 上使用逐行 JSON-RPC;有些发行版省略 wire 上的 +/// `jsonrpc` 字段,因此读取时接受缺省或 `"2.0"`,写入时沿用省略字段的形式。 +/// 这里仅提供中立 `AppServerChannel` 所需的 request/event/result 边界,不绑定 +/// 任何具体 Codex 发行版、生成 schema 或 thread/turn 生命周期。需要真实 +/// `threadId`/`turnId`、审批和工具请求处理的调用方应在更上层实现版本适配。 +pub struct JsonRpcAppServerChannel { + reader: BufReader, + writer: W, + max_frame_bytes: usize, + initialized: bool, +} + +impl JsonRpcAppServerChannel { + pub fn new(reader: R, writer: W) -> Result { + Self::with_max_frame_bytes(reader, writer, DEFAULT_JSON_RPC_FRAME_BYTES) + } + + pub fn with_max_frame_bytes( + reader: R, + writer: W, + max_frame_bytes: usize, + ) -> Result { + if max_frame_bytes == 0 { + return Err(CodexError::InvalidConfig( + "JSON-RPC max_frame_bytes 必须大于 0".to_owned(), + )); + } + Ok(Self { + reader: BufReader::new(reader), + writer, + max_frame_bytes, + initialized: false, + }) + } + + pub fn max_frame_bytes(&self) -> usize { + self.max_frame_bytes + } + + pub fn into_parts(self) -> (R, W) { + (self.reader.into_inner(), self.writer) + } + + fn write_message(&mut self, message: &Value) -> Result<(), CodexError> { + let mut encoded = serde_json::to_vec(message) + .map_err(|error| CodexError::Protocol(format!("JSON-RPC frame 编码失败: {error}")))?; + if encoded.len().saturating_add(1) > self.max_frame_bytes { + return Err(CodexError::Protocol(format!( + "JSON-RPC frame 超过 {} 字节限制", + self.max_frame_bytes + ))); + } + encoded.push(b'\n'); + self.writer + .write_all(&encoded) + .map_err(|error| CodexError::Protocol(format!("JSON-RPC frame 写入失败: {error}")))?; + self.writer + .flush() + .map_err(|error| CodexError::Protocol(format!("JSON-RPC flush 失败: {error}"))) + } + + /// 读取一个有界 JSONL frame。EOF 时若已有完整 JSON 仍接受无尾换行的 + /// frame,方便调用方把固定的 Cursor/管道输出接入测试。 + fn read_message(&mut self) -> Result, CodexError> { + loop { + let mut bytes = Vec::new(); + loop { + let available = self.reader.fill_buf().map_err(map_json_rpc_read_error)?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + break; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + if bytes.len().saturating_add(take) > self.max_frame_bytes { + return Err(CodexError::Protocol(format!( + "JSON-RPC frame 超过 {} 字节限制", + self.max_frame_bytes + ))); + } + bytes.extend_from_slice(&available[..take]); + self.reader.consume(take); + if newline.is_some() { + break; + } + } + while matches!(bytes.last(), Some(b'\n' | b'\r')) { + bytes.pop(); + } + if bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let message = serde_json::from_slice::(&bytes) + .map_err(|error| CodexError::Protocol(format!("JSON-RPC frame 无效: {error}")))?; + if !message.is_object() { + return Err(CodexError::Protocol("JSON-RPC frame 必须是对象".to_owned())); + } + validate_optional_json_rpc_version(&message)?; + return Ok(Some(message)); + } + } + + /// 将中立 server request 交给调用方,并保证无论 handler 成功还是失败, + /// 都先写回一个 JSON-RPC response,避免对端永久等待。handler 只负责 + /// 当前请求的 JSON 值,不会获得通道的其它状态。 + fn handle_server_request( + &mut self, + request: CodexServerRequest, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result<(), CodexError> { + match handler.handle(&request) { + Ok(response) => self.write_server_response(&request, response), + Err(error) => { + self.write_server_response( + &request, + CodexServerRequestResponse::error(-32601, error.to_string()), + )?; + Err(error) + } + } + } + + fn write_server_response( + &mut self, + request: &CodexServerRequest, + response: CodexServerRequestResponse, + ) -> Result<(), CodexError> { + let message = match response { + CodexServerRequestResponse::Result(result) => { + json!({"id": request.id, "result": result}) + } + CodexServerRequestResponse::Error { + code, + message, + data, + } => { + let mut error = json!({"code": code, "message": message}); + if let Some(data) = data { + error["data"] = data; + } + json!({"id": request.id, "error": error}) + } + }; + self.write_message(&message) + } + + /// 等待一个 JSON-RPC response,同时处理期间到达的 notification 和 + /// server request。`events` 为 `None` 时通知只被消费,不进入回调。 + fn wait_for_response_with_handler( + &mut self, + expected_id: &str, + operation: &str, + mut events: Option<&mut dyn FnMut(NodeEvent)>, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + loop { + let Some(message) = self.read_message()? else { + return Err(CodexError::Protocol(format!( + "JSON-RPC {operation} response 前连接已关闭" + ))); + }; + // Request/notification 与 response 是互斥 envelope;先做这个 + // 检查,再调用 handler,避免无效 frame 诱发上层副作用。 + validate_json_rpc_envelope(&message)?; + let method = json_rpc_method(&message)?; + let id = json_rpc_id(&message)?; + if let Some(method) = method { + if let Some(id) = id { + self.handle_server_request( + CodexServerRequest { + id, + method: method.to_owned(), + params: message.get("params").cloned().unwrap_or(Value::Null), + }, + handler, + )?; + } else { + if let Some(events) = events.as_deref_mut() { + events(NodeEvent { + request_id: expected_id.to_owned(), + event_type: method.to_owned(), + payload: message.get("params").cloned().unwrap_or(Value::Null), + }); + } + } + continue; + } + let Some(id) = id else { + return Err(CodexError::Protocol(format!( + "JSON-RPC {operation} response 缺少 id" + ))); + }; + if !json_rpc_id_matches(&id, expected_id) { + return Err(CodexError::Protocol(format!( + "JSON-RPC {operation} response id 不匹配: expected={expected_id} actual={id}" + ))); + } + return parse_json_rpc_result(&message, operation); + } + } +} + +fn map_json_rpc_read_error(error: io::Error) -> CodexError { + match error.kind() { + io::ErrorKind::TimedOut => CodexError::Timeout, + io::ErrorKind::Interrupted => CodexError::Interrupted, + _ => CodexError::Protocol(format!("JSON-RPC frame 读取失败: {error}")), + } +} + +impl AppServerChannel for JsonRpcAppServerChannel { + fn initialize(&mut self, session_id: &str) -> Result<(), CodexError> { + let mut handler = RejectingServerRequestHandler; + self.initialize_with_handler(session_id, &mut handler) + } + + fn send(&mut self, request: NodeRequest) -> Result { + self.send_with_events(request, &mut |_| {}) + } + + fn send_with_events( + &mut self, + request: NodeRequest, + events: &mut dyn FnMut(NodeEvent), + ) -> Result { + let mut handler = RejectingServerRequestHandler; + self.send_with_events_and_handler(request, events, &mut handler) + } + + fn interrupt(&mut self, request_id: &str) -> Result<(), CodexError> { + let mut handler = RejectingServerRequestHandler; + self.interrupt_with_handler(request_id, &mut handler) + } +} + +impl JsonRpcAppServerChannel { + /// 完成握手并允许调用方处理握手响应前到达的 server request。 + /// + /// 默认 [`AppServerChannel::initialize`] 仍使用拒绝 handler;需要审批或 + /// 其它请求的宿主应显式调用此方法,避免在通道内自动放行任何副作用。 + pub fn initialize_with_handler( + &mut self, + session_id: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result<(), CodexError> { + if session_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "JSON-RPC session_id 不能为空".to_owned(), + )); + } + if self.initialized { + return Err(CodexError::Protocol( + "JSON-RPC connection 已经 initialized".to_owned(), + )); + } + + // `session_id` 只作为本通道的字符串关联 ID;它不是 Codex 的 thread + // 或 turn ID,也不构成对某个 Codex 版本 schema 的兼容声明。 + self.write_message(&json!({ + "method": "initialize", + "id": session_id, + "params": { + "clientInfo": { + "name": "agent-runtime", + "version": env!("CARGO_PKG_VERSION") + } + } + }))?; + + self.wait_for_response_with_handler(session_id, "initialize", None, handler)?; + + // 官方协议要求 initialize response 后再发 initialized notification。 + self.write_message(&json!({ + "method": "initialized", + "params": {} + }))?; + self.initialized = true; + Ok(()) + } + + /// 发送请求并在等待 response 时处理中立 server request。 + pub fn send_with_events_and_handler( + &mut self, + request: NodeRequest, + events: &mut dyn FnMut(NodeEvent), + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + validate_node_request(&request)?; + if !self.initialized { + return Err(CodexError::Protocol( + "JSON-RPC request 必须在 initialize 后发送".to_owned(), + )); + } + self.write_message(&json!({ + "method": request.operation, + "id": request.request_id, + "params": request.payload + }))?; + let output = self.wait_for_response_with_handler( + &request.request_id, + &request.operation, + Some(events), + handler, + )?; + Ok(NodeResult { + request_id: request.request_id, + output, + side_effect_unknown: false, + }) + } + + /// 发送中立 `turn/interrupt` 并允许处理响应前到达的 server request。 + pub fn interrupt_with_handler( + &mut self, + request_id: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result<(), CodexError> { + if request_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "JSON-RPC interrupt request_id 不能为空".to_owned(), + )); + } + if !self.initialized { + return Err(CodexError::Protocol( + "JSON-RPC interrupt 必须在 initialize 后发送".to_owned(), + )); + } + // AppServerChannel 目前只暴露一个中立 request_id,没有真实 Codex + // 所需的 threadId/turnId;把它放入 params 作为关联值,版本特定调用方 + // 可在更上层提供完整 turn/interrupt 映射。这里使用 request 形状,便于 + // 服务端记录/拒绝,而不伪造某个发行版的成功语义。 + self.write_message(&json!({ + "method": "turn/interrupt", + "id": request_id, + "params": {"requestId": request_id} + }))?; + self.wait_for_response_with_handler(request_id, "turn/interrupt", None, handler)?; + Ok(()) + } +} + +/// 一个可以安全并发复用的 JSON-RPC transport router。 +/// +/// 这是一个纯内存/通用 transport 组件:它只负责给每个 outbound request +/// 分配字符串 ID、串行化 writer、在后台 reader 中按 ID 分发 response,及 +/// 保存有限数量的 notification。它不接入内部 `ProcessControl`,也不创建 +/// 进程或假设 Codex 的 thread/turn 生命周期;真实进程接线仍待上层适配器 +/// 后续完成。带 ID 的 server request 当前没有 handler,因此会触发 fail-closed +/// 并唤醒所有 pending request,而不会把它误当成 response。 +pub struct JsonRpcAppServerRouter { + state: Arc, + writer: Arc>, + reader_join: Mutex>>, + next_request_id: AtomicU64, + max_frame_bytes: usize, + // The reader is moved into the background thread during construction; it + // is not shared by callers. A function-pointer marker keeps that fact in + // the auto-trait model, so a non-Sync reader can still back a Sync router. + _reader_type: std::marker::PhantomData R>, +} + +struct JsonRpcRouterState { + pending: Mutex, + notifications: Mutex>, + stop: AtomicBool, +} + +struct JsonRpcRouterPendingState { + terminal: Option, + max_pending_responses: usize, + pending: HashMap>>, +} + +impl std::fmt::Debug for JsonRpcAppServerRouter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("JsonRpcAppServerRouter") + .field("max_frame_bytes", &self.max_frame_bytes) + .finish_non_exhaustive() + } +} + +impl JsonRpcAppServerRouter { + /// 使用默认 frame、pending response 和 notification 上限创建 router。 + pub fn new(reader: R, writer: W) -> Result { + Self::with_limits( + reader, + writer, + DEFAULT_JSON_RPC_FRAME_BYTES, + DEFAULT_MAX_PENDING_NOTIFICATIONS, + DEFAULT_MAX_PENDING_NOTIFICATIONS, + ) + } + + /// 创建带显式边界的 router。 + /// + /// `max_pending_responses` 和 `max_notifications` 都必须大于零;写入和 + /// 读取的每一行 JSON 也受 `max_frame_bytes` 限制。所有边界一旦被突破, + /// router 就进入终态并唤醒已注册请求,不继续消费未知协议。 + pub fn with_limits( + reader: R, + writer: W, + max_frame_bytes: usize, + max_pending_responses: usize, + max_notifications: usize, + ) -> Result { + if max_frame_bytes == 0 { + return Err(CodexError::InvalidConfig( + "JSON-RPC router max_frame_bytes 必须大于 0".to_owned(), + )); + } + if max_pending_responses == 0 { + return Err(CodexError::InvalidConfig( + "JSON-RPC router max_pending_responses 必须大于 0".to_owned(), + )); + } + if max_notifications == 0 { + return Err(CodexError::InvalidConfig( + "JSON-RPC router max_notifications 必须大于 0".to_owned(), + )); + } + + let (notification_tx, notification_rx) = mpsc::sync_channel(max_notifications); + let state = Arc::new(JsonRpcRouterState { + pending: Mutex::new(JsonRpcRouterPendingState { + terminal: None, + max_pending_responses, + pending: HashMap::with_capacity(max_pending_responses), + }), + notifications: Mutex::new(notification_rx), + stop: AtomicBool::new(false), + }); + let reader_state = Arc::clone(&state); + let reader_join = std::thread::Builder::new() + .name("agent-codex-json-rpc-router".to_owned()) + .spawn(move || { + json_rpc_router_reader_loop(reader, reader_state, notification_tx, max_frame_bytes) + }) + .map_err(|error| { + CodexError::Protocol(format!("JSON-RPC router reader 启动失败: {error}")) + })?; + + Ok(Self { + state, + writer: Arc::new(Mutex::new(writer)), + reader_join: Mutex::new(Some(reader_join)), + next_request_id: AtomicU64::new(1), + max_frame_bytes, + _reader_type: std::marker::PhantomData, + }) + } + + pub fn max_frame_bytes(&self) -> usize { + self.max_frame_bytes + } + + /// 发送一个并发安全的 JSON-RPC request。 + /// + /// 请求先进入有界 pending map,再在 writer mutex 下写出;因此多个调用 + /// 可以同时等待各自 response,且 reader 可按任意顺序完成它们。超时会 + /// 删除自己的 pending entry;之后到达同一 ID 的 response 会被视为协议 + /// 错误并使 router fail-closed。 + pub fn request( + &self, + method: impl Into, + params: Value, + timeout: Duration, + ) -> Result { + let method = method.into(); + if method.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "JSON-RPC router method 不能为空".to_owned(), + )); + } + let id_number = self.next_request_id.fetch_add(1, Ordering::Relaxed); + if id_number == u64::MAX { + return Err(CodexError::Protocol( + "JSON-RPC router request id 已耗尽".to_owned(), + )); + } + let id = format!("agent-runtime-router-{id_number}"); + let (response_tx, response_rx) = mpsc::sync_channel(1); + { + let mut pending = + self.state.pending.lock().map_err(|_| { + CodexError::Protocol("JSON-RPC router pending 锁已损坏".to_owned()) + })?; + if let Some(error) = pending.terminal.as_ref() { + return Err(CodexError::Protocol(error.clone())); + } + if pending.pending.len() >= pending.max_pending_responses { + return Err(CodexError::Protocol( + "JSON-RPC router pending response 队列已满".to_owned(), + )); + } + pending.pending.insert(id.clone(), response_tx); + } + + let write_result = self.write_message(&json!({ + "jsonrpc": JSON_RPC_VERSION, + "id": id, + "method": method, + "params": params, + })); + if let Err(error) = write_result { + self.fail_router(format!("JSON-RPC router request 写入失败: {error}")); + // A concurrent cancel/terminate may already have published a more + // useful terminal reason. Prefer it over the lower-level broken + // pipe error so pending callers observe deterministic cancellation. + return Err(self.terminal_error().unwrap_or(error)); + } + + match response_rx.recv_timeout(timeout) { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => Err(CodexError::Protocol(error)), + Err(RecvTimeoutError::Timeout) => { + let removed = self.remove_pending(&id); + if removed { + Err(CodexError::Timeout) + } else { + // A reader failure may win the race with timeout. Prefer + // the terminal reason when one is already available. + Err(self.terminal_error().unwrap_or(CodexError::Timeout)) + } + } + Err(RecvTimeoutError::Disconnected) => { + Err(self.terminal_error().unwrap_or_else(|| { + CodexError::Protocol("JSON-RPC router response channel 已关闭".to_owned()) + })) + } + } + } + + /// 发送真实形状的 `turn/interrupt`。该方法使用 router 自己分配的独立 + /// request ID,params 只包含调用方给出的 thread/turn 身份。 + pub fn turn_interrupt( + &self, + thread_id: &str, + turn_id: &str, + timeout: Duration, + ) -> Result<(), CodexError> { + validate_non_empty_id(thread_id, "JSON-RPC router thread_id")?; + validate_non_empty_id(turn_id, "JSON-RPC router turn_id")?; + self.request( + "turn/interrupt", + json!({"threadId": thread_id, "turnId": turn_id}), + timeout, + )?; + Ok(()) + } + + /// 非阻塞地消费一条无 id notification。超时只是“当前没有通知”,而 + /// reader EOF、非法 frame、未知 response 或队列溢出会返回终态错误。 + pub fn try_recv_notification(&self) -> Result, CodexError> { + let receiver = self.state.notifications.lock().map_err(|_| { + CodexError::Protocol("JSON-RPC router notification 锁已损坏".to_owned()) + })?; + match receiver.try_recv() { + Ok(notification) => Ok(Some(notification)), + Err(mpsc::TryRecvError::Empty) => self.terminal_error().map_or(Ok(None), Err), + Err(mpsc::TryRecvError::Disconnected) => self.terminal_error().map_or(Ok(None), Err), + } + } + + /// 在给定时限内等待一条 notification;返回 `Ok(None)` 表示时限内没有 + /// 通知。该方法不处理带 id 的 server request,后者由 reader fail-closed。 + pub fn recv_notification( + &self, + timeout: Duration, + ) -> Result, CodexError> { + let receiver = self.state.notifications.lock().map_err(|_| { + CodexError::Protocol("JSON-RPC router notification 锁已损坏".to_owned()) + })?; + match receiver.recv_timeout(timeout) { + Ok(notification) => Ok(Some(notification)), + Err(RecvTimeoutError::Timeout) => self.terminal_error().map_or(Ok(None), Err), + Err(RecvTimeoutError::Disconnected) => self.terminal_error().map_or(Ok(None), Err), + } + } + + /// Close the router and wake every pending request. This only closes the + /// generic transport; a process-backed wrapper must also terminate its + /// child, which is why `CodexAppServerProcessRouter` calls this method + /// together with `ProcessControl` shutdown. + pub fn shutdown(&self, reason: impl Into) { + self.fail_router(reason.into()); + } + + fn write_message(&self, message: &Value) -> Result<(), CodexError> { + let mut encoded = serde_json::to_vec(message) + .map_err(|error| CodexError::Protocol(format!("JSON-RPC router 编码失败: {error}")))?; + if encoded.len().saturating_add(1) > self.max_frame_bytes { + return Err(CodexError::Protocol(format!( + "JSON-RPC router frame 超过 {} 字节限制", + self.max_frame_bytes + ))); + } + encoded.push(b'\n'); + let mut writer = self + .writer + .lock() + .map_err(|_| CodexError::Protocol("JSON-RPC router writer 锁已损坏".to_owned()))?; + writer + .write_all(&encoded) + .map_err(|error| CodexError::Protocol(format!("JSON-RPC router 写入失败: {error}")))?; + writer + .flush() + .map_err(|error| CodexError::Protocol(format!("JSON-RPC router flush 失败: {error}"))) + } + + fn remove_pending(&self, id: &str) -> bool { + self.state + .pending + .lock() + .map(|mut pending| pending.pending.remove(id).is_some()) + .unwrap_or(false) + } + + fn terminal_error(&self) -> Option { + self.state + .pending + .lock() + .ok() + .and_then(|pending| pending.terminal.clone()) + .map(CodexError::Protocol) + } + + fn fail_router(&self, reason: String) { + json_rpc_router_fail(&self.state, reason); + } +} + +impl Drop for JsonRpcAppServerRouter { + fn drop(&mut self) { + self.state.stop.store(true, Ordering::Release); + json_rpc_router_fail(&self.state, "JSON-RPC router 已关闭".to_owned()); + + // std::thread::JoinHandle 没有可取消的 join;poll is_finished for a + // short bounded grace period, then drop the handle to detach a reader + // whose generic Read implementation is still blocked. Pipe owners + // should be closed by the transport so the normal path joins promptly. + let Some(handle) = self + .reader_join + .lock() + .ok() + .and_then(|mut slot| slot.take()) + else { + return; + }; + let deadline = Instant::now() + JSON_RPC_ROUTER_JOIN_GRACE; + while !handle.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + if handle.is_finished() { + let _ = handle.join(); + } + } +} + +fn json_rpc_router_reader_loop( + reader: R, + state: Arc, + notification_tx: SyncSender, + max_frame_bytes: usize, +) { + let mut reader = BufReader::new(reader); + loop { + if state.stop.load(Ordering::Acquire) { + break; + } + let message = match read_json_rpc_router_message(&mut reader, max_frame_bytes) { + Ok(Some(message)) => message, + Ok(None) => { + json_rpc_router_fail(&state, "JSON-RPC router reader EOF".to_owned()); + break; + } + Err(error) => { + json_rpc_router_fail(&state, error.to_string()); + break; + } + }; + if let Err(error) = route_json_rpc_router_message(&state, ¬ification_tx, message) { + json_rpc_router_fail(&state, error); + break; + } + } +} + +fn route_json_rpc_router_message( + state: &JsonRpcRouterState, + notification_tx: &SyncSender, + message: Value, +) -> Result<(), String> { + validate_json_rpc_envelope(&message).map_err(|error| error.to_string())?; + let method = json_rpc_method(&message).map_err(|error| error.to_string())?; + let id = json_rpc_id(&message).map_err(|error| error.to_string())?; + if let Some(method) = method { + if id.is_some() { + return Err(format!( + "JSON-RPC router 不支持带 id 的 server request: {method}" + )); + } + let notification = notification_from_message(method, &message); + notification_tx + .try_send(notification) + .map_err(|error| match error { + TrySendError::Full(_) => "JSON-RPC router notification 队列已满".to_owned(), + TrySendError::Disconnected(_) => { + "JSON-RPC router notification 队列已关闭".to_owned() + } + })?; + return Ok(()); + } + + let Some(id) = id else { + return Err("JSON-RPC router frame 既不是 notification 也不是 response".to_owned()); + }; + let Value::String(id) = id else { + return Err("JSON-RPC router response id 必须是字符串".to_owned()); + }; + if message.get("result").is_none() && message.get("error").is_none() { + return Err("JSON-RPC router response 缺少 result 或 error".to_owned()); + } + // Parse the envelope before removing its pending entry. A malformed + // result/error envelope must fail the entire router, while a valid + // JSON-RPC error remains a normal per-request result. + let result = parse_json_rpc_router_response(&message)?; + let sender = { + let mut pending = state + .pending + .lock() + .map_err(|_| "JSON-RPC router pending 锁已损坏".to_owned())?; + if pending.terminal.is_some() { + return Err("JSON-RPC router 已进入终态".to_owned()); + } + pending + .pending + .remove(&id) + .ok_or_else(|| format!("JSON-RPC router response id 不匹配或重复: {id}"))? + }; + sender + .send(result) + .map_err(|_| "JSON-RPC router response receiver 已关闭".to_owned()) +} + +fn parse_json_rpc_router_response(message: &Value) -> Result, String> { + if message.get("result").is_some() && message.get("error").is_some() { + return Err("JSON-RPC router response 同时包含 result 和 error".to_owned()); + } + if let Some(error) = message.get("error") { + let Some(error_object) = error.as_object() else { + return Err("JSON-RPC router error 必须是对象".to_owned()); + }; + let Some(code) = error_object.get("code").and_then(Value::as_i64) else { + return Err("JSON-RPC router error code 必须是整数".to_owned()); + }; + let Some(message_text) = error_object.get("message").and_then(Value::as_str) else { + return Err("JSON-RPC router error message 必须是字符串".to_owned()); + }; + return Ok(Err(format!( + "JSON-RPC router response error ({code}): {message_text}" + ))); + } + message + .get("result") + .cloned() + .map(Ok) + .ok_or_else(|| "JSON-RPC router response 缺少 result 或 error".to_owned()) +} + +fn json_rpc_router_fail(state: &JsonRpcRouterState, reason: String) { + let senders = { + let Ok(mut pending) = state.pending.lock() else { + return; + }; + if pending.terminal.is_some() { + return; + } + pending.terminal = Some(reason.clone()); + pending + .pending + .drain() + .map(|(_, sender)| sender) + .collect::>() + }; + state.stop.store(true, Ordering::Release); + for sender in senders { + let _ = sender.send(Err(reason.clone())); + } +} + +fn read_json_rpc_router_message( + reader: &mut BufReader, + max_frame_bytes: usize, +) -> Result, CodexError> { + loop { + let mut bytes = Vec::new(); + loop { + let available = reader.fill_buf().map_err(map_json_rpc_read_error)?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + break; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + if bytes.len().saturating_add(take) > max_frame_bytes { + return Err(CodexError::Protocol(format!( + "JSON-RPC router frame 超过 {max_frame_bytes} 字节限制" + ))); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take); + if newline.is_some() { + break; + } + } + while matches!(bytes.last(), Some(b'\n' | b'\r')) { + bytes.pop(); + } + if bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let message = serde_json::from_slice::(&bytes).map_err(|error| { + CodexError::Protocol(format!("JSON-RPC router frame 无效: {error}")) + })?; + if !message.is_object() { + return Err(CodexError::Protocol( + "JSON-RPC router frame 必须是对象".to_owned(), + )); + } + validate_optional_json_rpc_version(&message)?; + return Ok(Some(message)); + } +} + +/// 真实 app-server 进程的共享控制面。 +/// +/// stdout 由单独 reader 线程持续排空,但线程只通过有界 channel 向协议层交付 +/// 字节。`terminate` 会先设置停止标记,再终止 process group、wait/reap child, +/// 最后 join reader;因此超时、取消和 `Drop` 都不会留下后台 reader 或孤儿进程。 +struct ProcessControl { + child: Mutex>, + last_exit: Mutex>, + process_lifecycle_sink: Mutex>>, + process_lifecycle_emitted: AtomicBool, + reader_stop: AtomicBool, + writer_stop: AtomicBool, + reader_closed: AtomicBool, + reader_error: AtomicBool, + cancelled: AtomicBool, + terminated: AtomicBool, + forced_termination: AtomicBool, + /// Serializes the short pending/active transition with cancellation. + /// + /// `operation_active` alone leaves a race between a backend registering a + /// request and `begin_operation` setting that flag. The backend reserves + /// the operation under this gate, so a matching cancel cannot be mistaken + /// for an idle no-op in that window. + operation_gate: Mutex<()>, + /// Non-zero while a backend has reserved the next client operation. The + /// token keeps a direct public process call from consuming another + /// backend's reservation when both callers race for the client mutex. + operation_pending: AtomicU64, + operation_active: AtomicBool, + deadline: Mutex>, + reader_join: Mutex>>, + writer_join: Mutex>>, +} + +impl ProcessControl { + fn new(child: Child) -> Self { + Self { + child: Mutex::new(Some(child)), + last_exit: Mutex::new(None), + process_lifecycle_sink: Mutex::new(None), + process_lifecycle_emitted: AtomicBool::new(false), + reader_stop: AtomicBool::new(false), + writer_stop: AtomicBool::new(false), + reader_closed: AtomicBool::new(false), + reader_error: AtomicBool::new(false), + cancelled: AtomicBool::new(false), + terminated: AtomicBool::new(false), + forced_termination: AtomicBool::new(false), + operation_gate: Mutex::new(()), + operation_pending: AtomicU64::new(0), + operation_active: AtomicBool::new(false), + deadline: Mutex::new(None), + reader_join: Mutex::new(None), + writer_join: Mutex::new(None), + } + } + + fn install_reader(&self, join: std::thread::JoinHandle<()>) { + if let Ok(mut slot) = self.reader_join.lock() { + *slot = Some(join); + } + } + + fn install_writer(&self, join: std::thread::JoinHandle<()>) { + if let Ok(mut slot) = self.writer_join.lock() { + *slot = Some(join); + } + } + + fn install_process_lifecycle_sink(&self, sink: Arc) { + if let Ok(mut slot) = self.process_lifecycle_sink.lock() { + *slot = Some(sink); + } + } + + /// Emit at most once, after all process-owned locks and worker joins are + /// complete. Sink errors are observational and never change the process + /// result or cancellation classification. + fn emit_process_lifecycle(&self, reason: CodexProcessLifecycleReason) { + let sink = self + .process_lifecycle_sink + .lock() + .ok() + .and_then(|slot| slot.clone()); + let Some(sink) = sink else { + return; + }; + if self.process_lifecycle_emitted.swap(true, Ordering::AcqRel) { + return; + } + let exit_code = self + .last_exit + .lock() + .ok() + .and_then(|value| value.as_ref().map(process_exit_code)); + let _ = sink.record(&CodexProcessLifecycleEvent { reason, exit_code }); + } + + fn begin_operation( + &self, + timeout: Duration, + reservation: Option, + ) -> Result<(), CodexError> { + // 子进程可能在上一轮响应后立即退出;先尝试回收并把连接标成终态, + // 避免下一轮把已结束的 stdin 当成可复用连接。 + // 如果是自然非零退出,退出码仍是最有用的诊断;不能因为调用方 + // 恰好晚于 child 调度就把一个已知的 Exit(code) 降级成泛化的 + // ProcessTerminated。主动 terminate 的路径不会在这里重新进入操作。 + if let Some(status) = self.reap_if_exited() + && !status.success() + { + return Err(process_exit_error(&status)); + } + if self.terminated.load(Ordering::Acquire) { + return Err(CodexError::ProcessTerminated); + } + let deadline = Instant::now() + .checked_add(timeout) + .unwrap_or_else(Instant::now); + let cancelled_during_begin = { + let gate = self.operation_gate.lock().map_err(|_| { + CodexError::Protocol("Codex 进程 supervisor operation 锁已损坏".to_owned()) + })?; + if self.operation_active.load(Ordering::Acquire) { + return Err(CodexError::InvalidConfig( + "Codex 进程已有 operation 在执行".to_owned(), + )); + } + let pending = self.operation_pending.load(Ordering::Acquire); + match reservation { + Some(token) if pending == token => { + // Only the backend that received this token may consume + // the reservation. A matching cancel leaves `cancelled` + // set so the operation is aborted before dispatch. + self.operation_pending.store(0, Ordering::Release); + } + Some(_) => { + return Err(CodexError::InvalidConfig( + "Codex 进程 operation reservation 已失效".to_owned(), + )); + } + None if pending != 0 => { + // A direct public process method must not consume a + // reservation owned by the backend. It can retry after + // the backend has completed its operation. + return Err(CodexError::InvalidConfig( + "Codex 进程已有待处理 backend operation".to_owned(), + )); + } + None => { + // An idle direct `process.cancel()` remains a no-op and + // its stale flag is cleared here. + self.cancelled.store(false, Ordering::Release); + } + } + self.operation_active.store(true, Ordering::Release); + match self.deadline.lock() { + Ok(mut value) => { + *value = Some(deadline); + let cancelled = self.cancelled.load(Ordering::Acquire); + if cancelled { + *value = None; + } + cancelled + } + Err(_) => { + self.operation_active.store(false, Ordering::Release); + // deadline 锁损坏时也要收束已经启动的 child,不能把一个 + // 无法再设置截止时间的后台进程留给调用方自行猜测。 + drop(gate); + self.terminate(); + return Err(CodexError::Protocol( + "Codex 进程 supervisor deadline 锁已损坏".to_owned(), + )); + } + } + }; + if cancelled_during_begin { + self.operation_active.store(false, Ordering::Release); + // 这里的 cancel 可能在 active 标记被观察前到达;直接终止可 + // 避免 begin 返回后遗留一个永远没有 reader 操作的 child。 + self.terminate_with_reason(CodexProcessLifecycleReason::Cancel); + return Err(CodexError::Interrupted); + } + Ok(()) + } + + /// Reserve a backend request before it performs the client-locking I/O. + /// This closes the cancellation window between `ExternalBackend::invoke` + /// registration and `begin_operation`. The token prevents an unrelated + /// direct process call from consuming this reservation. + fn reserve_operation(&self) -> Result { + let _gate = self.operation_gate.lock().map_err(|_| { + CodexError::Protocol("Codex 进程 supervisor operation 锁已损坏".to_owned()) + })?; + if self.terminated.load(Ordering::Acquire) { + return Err(CodexError::ProcessTerminated); + } + if self.operation_pending.load(Ordering::Acquire) != 0 + || self.operation_active.load(Ordering::Acquire) + { + return Err(CodexError::Protocol( + "Codex 进程已有 operation 在执行".to_owned(), + )); + } + self.cancelled.store(false, Ordering::Release); + let token = NEXT_PROCESS_OPERATION_ID.fetch_add(1, Ordering::Relaxed); + // Zero is reserved for “no pending reservation”; wraparound is not + // realistically reachable, but skip it if a long-lived process ever + // exhausts the counter. + let token = if token == 0 { 1 } else { token }; + self.operation_pending.store(token, Ordering::Release); + Ok(token) + } + + /// Release a reservation when validation fails before `begin_operation`. + fn release_operation_reservation(&self, token: u64) { + if let Ok(_gate) = self.operation_gate.lock() { + if self.operation_pending.load(Ordering::Acquire) == token { + self.operation_pending.store(0, Ordering::Release); + } + if !self.operation_active.load(Ordering::Acquire) { + self.cancelled.store(false, Ordering::Release); + } + } + } + + fn finish_operation(&self) { + if let Ok(_gate) = self.operation_gate.lock() { + self.operation_pending.store(0, Ordering::Release); + self.operation_active.store(false, Ordering::Release); + self.cancelled.store(false, Ordering::Release); + if let Ok(mut value) = self.deadline.lock() { + *value = None; + } + } + } + + /// Mark a pending/active operation cancelled and report whether the + /// cancellation won the operation race. `None` means the supervisor lock + /// was poisoned, so callers must not claim a successful cancellation. + fn cancel_and_report(&self) -> Option { + // Keep the pending/active check and flag write under the same gate as + // reserve/begin. A request reserved by the backend is cancellable + // even though its blocking I/O has not started yet. + let (accepted, should_terminate) = match self.operation_gate.lock() { + Ok(_gate) => { + let pending = self.operation_pending.load(Ordering::Acquire); + let active = self.operation_active.load(Ordering::Acquire); + if pending != 0 || active { + self.cancelled.store(true, Ordering::Release); + (true, active) + } else { + // An idle direct cancel is an actual no-op; do not leave a + // flag that can affect a later operation. + (false, false) + } + } + Err(_) => return None, + }; + if should_terminate { + self.terminate_with_reason(CodexProcessLifecycleReason::Cancel); + } + Some(accepted) + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + + fn deadline_expired(&self) -> bool { + self.deadline + .lock() + .ok() + .and_then(|value| *value) + .is_some_and(|deadline| Instant::now() >= deadline) + } + + fn remaining(&self) -> Option { + self.deadline + .lock() + .ok() + .and_then(|value| *value) + .map(|deadline| deadline.saturating_duration_since(Instant::now())) + } + + fn child_failure(&self) -> Option { + let status = self + .reap_if_exited() + .or_else(|| { + self.reader_closed + .load(Ordering::Acquire) + .then(|| self.wait_for_exit_grace()) + .flatten() + }) + .or_else(|| self.last_exit.lock().ok().and_then(|value| *value))?; + if status.success() { + None + } else { + Some(process_exit_error(&status)) + } + } + + fn exit_code(&self) -> Option { + if let Some(status) = self.last_exit.lock().ok().and_then(|value| *value) { + return Some(process_exit_code(&status)); + } + // stdout EOF and child wait are independent kernel notifications. A + // short grace probe makes the observable exit code deterministic for + // short-lived fixtures without holding a live app-server open. + if self.reader_closed.load(Ordering::Acquire) + && let Some(status) = self.wait_for_exit_grace() + { + return Some(process_exit_code(&status)); + } + None + } + + fn wait_for_exit_grace(&self) -> Option { + // stdout EOF 与 wait 状态不是同一个内核事件;在短命 fixture 中, + // reader 可能先看到 EOF 而 try_wait 还暂时返回 None。给 child 一个 + // 很短的收敛窗口,避免把真实的非零退出误报成泛化协议错误,同时 + // 不让一个仍存活的 app-server 挡住后续 terminate。 + let deadline = Instant::now() + PROCESS_EXIT_GRACE; + loop { + if let Some(status) = self.reap_if_exited() { + return Some(status); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(1)); + } + } + + /// 非阻塞检查并回收自然退出的 child。 + fn reap_if_exited(&self) -> Option { + let status = { + let mut child_slot = self.child.lock().ok()?; + let child = child_slot.as_mut()?; + let status = child.try_wait().ok()??; + // child 已经退出,但它启动的后代可能仍持有 stdout pipe;先 + // 收束同一 process group,再 join reader,避免自然 EOF 路径被 + // 孤儿后代拖成永久阻塞。没有后代时该 kill 会安全地返回失败。 + #[cfg(unix)] + let _ = terminate_process_group(child.id()); + // `try_wait` 已确认退出;再调用 wait 取得最终回收语义,并从控制面 + // 移除句柄,防止长期持有 zombie/失效 child。 + let _ = child.wait(); + child_slot.take(); + status + }; + self.terminated.store(true, Ordering::Release); + self.reader_stop.store(true, Ordering::Release); + self.writer_stop.store(true, Ordering::Release); + if let Ok(mut last_exit) = self.last_exit.lock() { + *last_exit = Some(status); + } + self.join_reader_and_writer(); + self.emit_process_lifecycle(CodexProcessLifecycleReason::NaturalExit); + Some(status) + } + + fn join_reader_and_writer(&self) { + if let Ok(mut join_slot) = self.reader_join.lock() + && let Some(join) = join_slot.take() + { + let _ = join.join(); + } + if let Ok(mut join_slot) = self.writer_join.lock() + && let Some(join) = join_slot.take() + { + let _ = join.join(); + } + } + + fn terminate(&self) { + self.terminate_with_reason(CodexProcessLifecycleReason::ExplicitTerminate); + } + + fn terminate_with_reason(&self, reason: CodexProcessLifecycleReason) { + self.forced_termination.store(true, Ordering::Release); + self.terminated.store(true, Ordering::Release); + self.reader_stop.store(true, Ordering::Release); + self.writer_stop.store(true, Ordering::Release); + + if let Ok(mut child_slot) = self.child.lock() + && let Some(mut child) = child_slot.take() + { + // 先观察退出状态;即使 child 已退出,也要在 wait/reap 前收束 + // 同一 process group,因为后台后代可能仍持有 stdout/stderr pipe。 + // child 尚未 wait,因此其 PID 不能在这里被系统复用。 + let already_exited = child.try_wait().ok().flatten().is_some(); + if !already_exited { + if let Some(status) = terminate_child(&mut child) + && let Ok(mut last_exit) = self.last_exit.lock() + { + *last_exit = Some(status); + } + } else { + #[cfg(unix)] + let _ = terminate_process_group(child.id()); + if let Ok(status) = child.wait() + && let Ok(mut last_exit) = self.last_exit.lock() + { + *last_exit = Some(status); + } + } + } + + self.join_reader_and_writer(); + self.emit_process_lifecycle(reason); + } +} + +/// 把真实 child stdout 转成有界、可取消的 `Read`。 +/// +/// `BufReader::fill_buf` 本身是阻塞 API,所以 process adapter 不直接把 +/// `ChildStdout` 交给它,而是由 reader 线程读取固定大小块;读线程在 channel +/// 满时使用 `try_send` 轮询停止标记,取消时不会永远卡在发送上。 +struct ProcessReader { + receiver: Receiver>, + buffered: std::collections::VecDeque, + control: Arc, + /// Router-owned readers stay alive between requests, so they use a short + /// polling interval instead of the per-operation deadline used by the + /// sequential process client. + persistent: bool, +} + +fn spawn_process_reader( + stdout: impl Read + Send + 'static, + control: Arc, +) -> (ProcessReader, std::thread::JoinHandle<()>) { + spawn_process_reader_with_mode(stdout, control, false) +} + +fn spawn_process_reader_with_mode( + mut stdout: impl Read + Send + 'static, + control: Arc, + persistent: bool, +) -> (ProcessReader, std::thread::JoinHandle<()>) { + let (sender, receiver) = mpsc::sync_channel(PROCESS_READER_CHANNEL_CHUNKS); + let reader_control = Arc::clone(&control); + let join = std::thread::spawn(move || { + let mut chunk = [0_u8; PROCESS_READER_CHUNK_BYTES]; + loop { + if reader_control.reader_stop.load(Ordering::Acquire) { + break; + } + let count = match stdout.read(&mut chunk) { + Ok(0) => { + reader_control.reader_closed.store(true, Ordering::Release); + break; + } + Err(_) => { + reader_control.reader_error.store(true, Ordering::Release); + reader_control.reader_closed.store(true, Ordering::Release); + break; + } + Ok(count) => count, + }; + let mut pending = chunk[..count].to_vec(); + loop { + if reader_control.reader_stop.load(Ordering::Acquire) { + return; + } + match sender.try_send(pending) { + Ok(()) => break, + Err(TrySendError::Full(bytes)) => { + pending = bytes; + std::thread::sleep(Duration::from_millis(1)); + } + Err(TrySendError::Disconnected(_)) => return, + } + } + } + }); + ( + ProcessReader { + receiver, + buffered: std::collections::VecDeque::new(), + control, + persistent, + }, + join, + ) +} + +enum ProcessWriteRequest { + Bytes(Vec, SyncSender>), + Flush(SyncSender>), +} + +/// 把 ChildStdin 的阻塞写入移到一个可回收的专用线程。 +/// +/// app-server 通常会立即读取 stdin,但协议 frame 仍可能达到上限;直接在 +/// 调用线程 `write_all` 会让 deadline 失效。这里用小型有界队列和逐请求 ack, +/// 让 timeout/cancel 能通过关闭 child 解除阻塞,再由 supervisor join 线程。 +struct ProcessWriter { + sender: SyncSender, + control: Arc, + /// Persistent JSON-RPC routers do not reserve one deadline per request; + /// their wrapper still terminates the process when a request times out. + persistent: bool, +} + +fn spawn_process_writer( + stdin: ChildStdin, + control: Arc, +) -> (ProcessWriter, std::thread::JoinHandle<()>) { + spawn_process_writer_with_mode(stdin, control, false) +} + +fn spawn_process_writer_with_mode( + mut stdin: ChildStdin, + control: Arc, + persistent: bool, +) -> (ProcessWriter, std::thread::JoinHandle<()>) { + let (sender, receiver) = mpsc::sync_channel(PROCESS_WRITER_CHANNEL_REQUESTS); + let writer_control = Arc::clone(&control); + let join = std::thread::spawn(move || { + loop { + if writer_control.writer_stop.load(Ordering::Acquire) { + break; + } + let request = match receiver.recv_timeout(Duration::from_millis(10)) { + Ok(request) => request, + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break, + }; + match request { + ProcessWriteRequest::Bytes(bytes, ack) => { + let result = stdin.write_all(&bytes).map(|()| bytes.len()); + let failed = result.is_err(); + let _ = ack.send(result); + if failed { + break; + } + } + ProcessWriteRequest::Flush(ack) => { + let result = stdin.flush(); + let failed = result.is_err(); + let _ = ack.send(result.map(|()| 0)); + if failed { + break; + } + } + } + } + }); + ( + ProcessWriter { + sender, + control, + persistent, + }, + join, + ) +} + +impl ProcessWriter { + fn remaining(&self) -> io::Result { + if self.control.is_cancelled() { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "Codex process cancelled", + )); + } + self.control.remaining().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "Codex process operation has no deadline", + ) + }) + } + + fn send_request( + &self, + mut request: ProcessWriteRequest, + ack: Receiver>, + ) -> io::Result { + loop { + if self.persistent + && (self.control.is_cancelled() + || self.control.writer_stop.load(Ordering::Acquire) + || self.control.terminated.load(Ordering::Acquire)) + { + return Err(io::Error::new( + if self.control.is_cancelled() { + io::ErrorKind::Interrupted + } else { + io::ErrorKind::BrokenPipe + }, + "Codex process writer 已关闭", + )); + } + let remaining = if self.persistent { + Duration::from_millis(10) + } else { + let remaining = self.remaining()?; + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Codex process operation timed out", + )); + } + remaining + }; + match self.sender.try_send(request) { + Ok(()) => break, + Err(TrySendError::Full(value)) => { + request = value; + std::thread::sleep(Duration::from_millis(1).min(remaining)); + } + Err(TrySendError::Disconnected(_)) => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Codex process stdin writer 已关闭", + )); + } + } + } + loop { + if self.persistent + && (self.control.is_cancelled() + || self.control.writer_stop.load(Ordering::Acquire) + || self.control.terminated.load(Ordering::Acquire)) + { + return Err(io::Error::new( + if self.control.is_cancelled() { + io::ErrorKind::Interrupted + } else { + io::ErrorKind::BrokenPipe + }, + "Codex process writer 已关闭", + )); + } + let remaining = if self.persistent { + Duration::from_millis(10) + } else { + let remaining = self.remaining()?; + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Codex process operation timed out", + )); + } + remaining + }; + match ack.recv_timeout(remaining) { + Ok(result) => return result, + Err(RecvTimeoutError::Timeout) if self.persistent => continue, + Err(RecvTimeoutError::Timeout) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Codex process operation timed out", + )); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Codex process stdin writer 已关闭", + )); + } + } + } + } +} + +impl Write for ProcessWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + let (ack_sender, ack_receiver) = mpsc::sync_channel(1); + self.send_request( + ProcessWriteRequest::Bytes(bytes.to_vec(), ack_sender), + ack_receiver, + ) + } + + fn flush(&mut self) -> io::Result<()> { + let (ack_sender, ack_receiver) = mpsc::sync_channel(1); + self.send_request(ProcessWriteRequest::Flush(ack_sender), ack_receiver) + .map(|_| ()) + } +} + +impl Read for ProcessReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() { + return Ok(0); + } + loop { + if !self.buffered.is_empty() { + let count = output.len().min(self.buffered.len()); + for slot in &mut output[..count] { + // `count` is bounded by the deque length, so pop always succeeds. + *slot = self.buffered.pop_front().expect("buffered length checked"); + } + return Ok(count); + } + if self.control.is_cancelled() { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "Codex process cancelled", + )); + } + if self.persistent + && (self.control.reader_stop.load(Ordering::Acquire) + || self.control.terminated.load(Ordering::Acquire)) + { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Codex process reader 已关闭", + )); + } + let remaining = if self.persistent { + Duration::from_millis(10) + } else { + let Some(remaining) = self.control.remaining() else { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Codex process operation has no deadline", + )); + }; + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Codex process operation timed out", + )); + } + remaining + }; + match self.receiver.recv_timeout(remaining) { + Ok(bytes) => self.buffered.extend(bytes), + Err(mpsc::RecvTimeoutError::Timeout) if self.persistent => continue, + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Codex process operation timed out", + )); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + // reader 已看到 EOF;立即尝试 wait/reap,避免自然退出的 + // child 在长寿命 adapter 中滞留成 zombie。 + let _ = self.control.reap_if_exited(); + return Ok(0); + } + } + } + } +} + +/// 真实 Codex app-server 的最小进程适配器。 +/// +/// 它复用 [`CodexAppServerClient`] 的窄 V2 JSON-RPC 形状,并把 client 的 +/// `Read`/`Write` 接到真实 `std::process::Command` 的 stdout/stdin。配置只接受 +/// 显式 program/argv;适配器不解析 shell、不猜测发行版 wire,也不声称兼容 +/// 任意 Codex 版本。一次操作超时或取消后,进程会被永久收束,不能自动重连或 +/// 重放已经发出的请求。 +pub struct CodexAppServerProcess { + client: Mutex>, + control: Arc, + config: CodexAppServerProcessConfig, +} + +impl std::fmt::Debug for CodexAppServerProcess { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CodexAppServerProcess") + .field("program", &redact_arg(&self.config.program)) + .field("args", &format!("<{} args>", self.config.args.len())) + .field("protocol", &CodexAppServerProtocol::V2.name()) + .field( + "terminated", + &self.control.terminated.load(Ordering::Acquire), + ) + .finish() + } +} + +impl CodexAppServerProcess { + /// 按显式 argv 启动一个长期 app-server child;spawn 成功后才创建 reader。 + pub fn spawn(config: CodexAppServerProcessConfig) -> Result { + config.validate()?; + let mut command = Command::new(&config.program); + command + .args(&config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // stderr 不进入协议流;丢弃它避免未处理日志填满第二条 pipe。 + .stderr(Stdio::null()); + configure_process_group(&mut command); + let mut child = command.spawn().map_err(|_| CodexError::Spawn)?; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child(&mut child); + return Err(CodexError::Spawn); + } + }; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + terminate_child(&mut child); + return Err(CodexError::Spawn); + } + }; + let control = Arc::new(ProcessControl::new(child)); + let (reader, join) = spawn_process_reader(stdout, Arc::clone(&control)); + control.install_reader(join); + let (writer, writer_join) = spawn_process_writer(stdin, Arc::clone(&control)); + control.install_writer(writer_join); + let client = match CodexAppServerClient::with_protocol_and_max_frame_bytes( + reader, + writer, + CodexAppServerProtocol::V2, + config.max_frame_bytes, + ) { + Ok(client) => client, + Err(error) => { + control.terminate(); + return Err(error); + } + }; + Ok(Self { + client: Mutex::new(client), + control, + config, + }) + } + + pub fn config(&self) -> &CodexAppServerProcessConfig { + &self.config + } + + /// Attach a one-shot process lifecycle observer before the first operation. + /// The process remains owned by this value; the observer only receives a + /// best-effort event after natural exit or explicit termination. + pub fn with_process_lifecycle_sink(self, sink: S) -> Self + where + S: CodexProcessLifecycleSink + 'static, + { + self.control.install_process_lifecycle_sink(Arc::new(sink)); + self + } + + pub const fn protocol(&self) -> CodexAppServerProtocol { + CodexAppServerProtocol::V2 + } + + /// 返回本地 JSON-RPC client 是否已经完成 `initialize`。 + /// + /// 这是一个只读生命周期检查,不会向 child 发送 frame,也不会隐式执行 + /// 握手。Process backend 用它把“尚未初始化”的调用挡在 dispatch 边界 + /// 之前;需要启动握手的调用方仍应显式调用 [`Self::initialize`] 或其 + /// 带参数变体。 + pub fn is_initialized(&self) -> Result { + self.client + .lock() + .map(|client| client.initialized) + .map_err(|_| CodexError::Protocol("Codex app-server client 锁已损坏".to_owned())) + } + + pub fn is_terminated(&self) -> bool { + let _ = self.control.reap_if_exited(); + self.control.terminated.load(Ordering::Acquire) + } + + /// 返回已观察到的 child 退出码;child 仍运行或退出状态尚未可见时为 + /// `None`。被 signal 终止时返回负 signal number,与内部诊断保持一致。 + pub fn exit_code(&self) -> Option { + let _ = self.control.reap_if_exited(); + self.control.exit_code() + } + + /// 取消当前阻塞中的 client 操作。未知/空闲操作按幂等 no-op 处理;如果确实 + /// 有 in-flight 请求,child 会被终止并 reap,后续不能继续复用该连接。 + pub fn cancel(&self) -> Result<(), CodexError> { + let _ = self.control.cancel_and_report(); + Ok(()) + } + + fn cancel_and_report(&self) -> Option { + self.control.cancel_and_report() + } + + /// 显式关闭 child;与 `Drop` 相同会终止 process group 并 join reader。 + pub fn terminate(&self) { + self.control.terminate(); + } + + pub fn initialize(&self) -> Result { + self.with_client(|client| client.initialize()) + } + + pub fn initialize_with_client_info( + &self, + name: &str, + version: &str, + ) -> Result { + self.with_client(|client| client.initialize_with_client_info(name, version)) + } + + /// 使用版本适配器编码的原始 `initialize` 参数启动握手。 + pub fn initialize_with_params( + &self, + params: Value, + ) -> Result { + self.with_client(|client| client.initialize_with_params(params)) + } + + /// 初始化并把握手期间的 server request 交给调用方。handler 在持有 + /// client 锁时执行,不应从回调中重入此 process 的其它方法。 + pub fn initialize_with_client_info_and_handler( + &self, + name: &str, + version: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client(|client| { + client.initialize_with_client_info_and_handler(name, version, handler) + }) + } + + /// 参数化握手版本;handler 在持有 client 锁时执行,不应重入此 process。 + pub fn initialize_with_params_and_handler( + &self, + params: Value, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client(|client| client.initialize_with_params_and_handler(params, handler)) + } + + pub fn thread_start( + &self, + params: CodexThreadStartParams, + ) -> Result { + self.with_client(|client| client.thread_start(params)) + } + + pub fn thread_start_with_handler( + &self, + params: CodexThreadStartParams, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client(|client| client.thread_start_with_handler(params, handler)) + } + + pub fn turn_start( + &self, + params: CodexTurnStartParams, + ) -> Result { + self.with_client(|client| client.turn_start(params)) + } + + pub fn turn_start_with_handler( + &self, + params: CodexTurnStartParams, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client(|client| client.turn_start_with_handler(params, handler)) + } + + pub fn turn_interrupt(&self, thread_id: &str, turn_id: &str) -> Result<(), CodexError> { + self.with_client(|client| client.turn_interrupt(thread_id, turn_id)) + } + + pub fn turn_interrupt_with_handler( + &self, + thread_id: &str, + turn_id: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result<(), CodexError> { + self.with_client(|client| client.turn_interrupt_with_handler(thread_id, turn_id, handler)) + } + + pub fn poll_notification(&self) -> Result, CodexError> { + self.with_client(|client| client.poll_notification()) + } + + pub fn poll_notification_with_handler( + &self, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result, CodexError> { + self.with_client(|client| client.poll_notification_with_handler(handler)) + } + + /// 发送一个由具体版本适配器编码的请求,并持续处理 server request。 + pub fn request_with_server_handler( + &self, + method: &str, + params: Value, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client(|client| client.request_with_server_handler(method, params, handler)) + } + + /// Internal backend path that consumes the reservation created before the + /// caller released its request-registration lock. Keeping this separate + /// from the public method prevents an unrelated direct process operation + /// from accidentally consuming that reservation. + fn request_with_server_handler_reserved( + &self, + reservation: u64, + method: &str, + params: Value, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client_reserved(reservation, |client| { + client.request_with_server_handler(method, params, handler) + }) + } + + /// Internal process-backend path that also forwards response-time + /// notifications as neutral `NodeEvent` values. The reservation keeps the + /// process operation bound to the request registered by the backend. + fn request_with_server_handler_reserved_with_events( + &self, + reservation: u64, + method: &str, + params: Value, + event_request_id: &str, + events: &mut dyn FnMut(NodeEvent), + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.with_client_reserved(reservation, |client| { + client.request_with_events_and_server_handler( + method, + params, + event_request_id, + events, + handler, + ) + }) + } + + fn with_client( + &self, + operation: impl FnOnce( + &mut CodexAppServerClient, + ) -> Result, + ) -> Result { + self.with_client_reservation(None, operation) + } + + fn with_client_reserved( + &self, + reservation: u64, + operation: impl FnOnce( + &mut CodexAppServerClient, + ) -> Result, + ) -> Result { + self.with_client_reservation(Some(reservation), operation) + } + + fn with_client_reservation( + &self, + reservation: Option, + operation: impl FnOnce( + &mut CodexAppServerClient, + ) -> Result, + ) -> Result { + let mut client = self + .client + .lock() + .map_err(|_| CodexError::Protocol("Codex app-server client 锁已损坏".to_owned()))?; + self.control + .begin_operation(self.config.timeout(), reservation)?; + let result = operation(&mut client); + // 即使本轮拿到了合法响应,fixture/child 也可能随后正常退出;尽早 + // 回收可避免把已结束的进程句柄留到下一次调用或 Drop。 + let _ = self.control.reap_if_exited(); + let cancelled = self.control.is_cancelled(); + let timed_out = + matches!(result, Err(CodexError::Timeout)) || self.control.deadline_expired(); + self.control.finish_operation(); + // EOF and the child's wait status are delivered by different kernel + // paths. Inspect the status (including the short EOF grace window) + // before killing an apparently idle process; otherwise a natural + // non-zero exit can race with reader EOF and be misreported as a + // supervisor-forced termination. + let child_error = if !cancelled && !timed_out { + self.control.child_failure() + } else { + None + }; + // EOF 表示这条 stdio 连接已经不可再用。若 child 尚未自行退出(例如 + // 它关闭 stdout 后仍保留后台工作),也要收束 process group,避免 + // `poll_notification -> None` 留下一个没有协议通道的孤儿进程。 + let eof_shutdown = self.control.reader_closed.load(Ordering::Acquire) + && !self.control.terminated.load(Ordering::Acquire); + if eof_shutdown { + let reason = if self.control.reader_error.load(Ordering::Acquire) { + CodexProcessLifecycleReason::ReaderError + } else { + CodexProcessLifecycleReason::ReaderEof + }; + self.control.terminate_with_reason(reason); + } + + if cancelled { + self.control + .terminate_with_reason(CodexProcessLifecycleReason::Cancel); + return Err(CodexError::Interrupted); + } + if timed_out { + self.control + .terminate_with_reason(CodexProcessLifecycleReason::Timeout); + return Err(CodexError::Timeout); + } + if self.control.forced_termination.load(Ordering::Acquire) && !eof_shutdown { + return Err(CodexError::ProcessTerminated); + } + // 即使操作已经拿到一个看似合法的响应,也要检查本轮是否紧接着 + // 以非零状态退出;否则 `thread/start` 的成功值会掩盖 child failure。 + if let Some(exit_error) = child_error.or_else(|| self.control.child_failure()) { + self.control.terminate(); + return Err(exit_error); + } + + match result { + Ok(value) => Ok(value), + Err(error) if matches!(&error, CodexError::Protocol(_)) => { + // 协议流一旦失步,不能把同一条连接交给下一次请求;即使 + // child 仍活着,也先收束 process group,避免残留 frame + // 被错误地解释成下一次 response。 + self.control.terminate(); + Err(error) + } + Err(error) => Err(error), + } + } +} + +/// 使用通用并发 JSON-RPC router 的 app-server 进程接缝。 +/// +/// 与 [`CodexAppServerProcess`] 的窄、版本中立 client 不同,这个入口把同一 +/// `ProcessControl` 接到可并发的 [`JsonRpcAppServerRouter`]:每个请求可以独立 +/// 等待 response,notification 由有界队列承接,超时/协议错误会终止并回收 +/// child。调用方仍需自行发送适配器所需的 `initialize`/`initialized` frame; +/// 这里不猜测某个 Codex 发行版的 wire schema。 +pub struct CodexAppServerProcessRouter { + router: JsonRpcAppServerRouter, + control: Arc, + config: CodexAppServerProcessConfig, +} + +impl std::fmt::Debug for CodexAppServerProcessRouter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CodexAppServerProcessRouter") + .field("program", &redact_arg(&self.config.program)) + .field("args", &format!("<{} args>", self.config.args.len())) + .field("router", &self.router) + .field( + "terminated", + &self.control.terminated.load(Ordering::Acquire), + ) + .finish() + } +} + +impl CodexAppServerProcessRouter { + /// 按显式 argv 启动一个支持并发请求的长连接 app-server。 + pub fn spawn(config: CodexAppServerProcessConfig) -> Result { + config.validate()?; + let mut command = Command::new(&config.program); + command + .args(&config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + configure_process_group(&mut command); + let mut child = command.spawn().map_err(|_| CodexError::Spawn)?; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child(&mut child); + return Err(CodexError::Spawn); + } + }; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + terminate_child(&mut child); + return Err(CodexError::Spawn); + } + }; + let control = Arc::new(ProcessControl::new(child)); + // A router request owns its own timeout, so the process I/O adapters + // use short cancellation polls instead of the sequential deadline. + let (reader, reader_join) = + spawn_process_reader_with_mode(stdout, Arc::clone(&control), true); + control.install_reader(reader_join); + let (writer, writer_join) = + spawn_process_writer_with_mode(stdin, Arc::clone(&control), true); + control.install_writer(writer_join); + let router = match JsonRpcAppServerRouter::with_limits( + reader, + writer, + config.max_frame_bytes, + DEFAULT_MAX_PENDING_NOTIFICATIONS, + DEFAULT_MAX_PENDING_NOTIFICATIONS, + ) { + Ok(router) => router, + Err(error) => { + control.terminate(); + return Err(error); + } + }; + Ok(Self { + router, + control, + config, + }) + } + + pub fn config(&self) -> &CodexAppServerProcessConfig { + &self.config + } + + /// 安装一个与窄 process adapter 相同的 emit-once 生命周期观察器。 + pub fn with_process_lifecycle_sink(self, sink: S) -> Self + where + S: CodexProcessLifecycleSink + 'static, + { + self.control.install_process_lifecycle_sink(Arc::new(sink)); + self + } + + pub fn request( + &self, + method: impl Into, + params: Value, + timeout: Duration, + ) -> Result { + self.finish_router_result(self.router.request(method, params, timeout)) + } + + pub fn turn_interrupt( + &self, + thread_id: &str, + turn_id: &str, + timeout: Duration, + ) -> Result<(), CodexError> { + self.finish_router_result(self.router.turn_interrupt(thread_id, turn_id, timeout)) + } + + pub fn try_recv_notification(&self) -> Result, CodexError> { + self.finish_router_result(self.router.try_recv_notification()) + } + + pub fn recv_notification( + &self, + timeout: Duration, + ) -> Result, CodexError> { + self.finish_router_result(self.router.recv_notification(timeout)) + } + + /// Router operations are not tied to the sequential operation reservation; + /// cancellation therefore closes the child directly and remains bounded. + pub fn cancel(&self) -> Result<(), CodexError> { + self.router + .shutdown("Codex app-server router 已取消".to_owned()); + self.control + .terminate_with_reason(CodexProcessLifecycleReason::Cancel); + Ok(()) + } + + pub fn terminate(&self) { + self.router + .shutdown("Codex app-server router 已终止".to_owned()); + self.control + .terminate_with_reason(CodexProcessLifecycleReason::ExplicitTerminate); + } + + pub fn is_terminated(&self) -> bool { + let _ = self.control.reap_if_exited(); + self.control.terminated.load(Ordering::Acquire) + } + + pub fn exit_code(&self) -> Option { + let _ = self.control.reap_if_exited(); + self.control.exit_code() + } + + fn finish_router_result(&self, result: Result) -> Result { + if let Err(error) = &result + && matches!( + error, + CodexError::Protocol(_) + | CodexError::Timeout + | CodexError::Interrupted + | CodexError::ProcessTerminated + | CodexError::Exit(_) + | CodexError::Signal(_) + ) + && !is_scoped_router_error(error) + { + self.shutdown_after_router_error(); + } + result + } + + fn shutdown_after_router_error(&self) { + if self.control.reader_closed.load(Ordering::Acquire) + && self.control.reap_if_exited().is_some() + { + return; + } + if !self.control.terminated.load(Ordering::Acquire) { + let reason = if self.control.reader_error.load(Ordering::Acquire) { + CodexProcessLifecycleReason::ReaderError + } else { + CodexProcessLifecycleReason::ExplicitTerminate + }; + self.control.terminate_with_reason(reason); + } + } +} + +fn is_scoped_router_error(error: &CodexError) -> bool { + // A valid JSON-RPC error response belongs to one request and does not + // poison the stream; malformed envelopes and transport failures still do. + matches!( + error, + CodexError::Protocol(message) + if message.starts_with("JSON-RPC router response error (") + ) +} + +impl Drop for CodexAppServerProcessRouter { + fn drop(&mut self) { + self.router + .shutdown("Codex app-server router 已关闭".to_owned()); + if self.control.reader_closed.load(Ordering::Acquire) + && self.control.reap_if_exited().is_some() + { + return; + } + if !self.control.terminated.load(Ordering::Acquire) { + self.control + .terminate_with_reason(CodexProcessLifecycleReason::Drop); + } + } +} + +/// 由 Codex app-server 分配、可由宿主持久化的真实 session 身份。 +/// +/// 这是故意保持中立的 durable metadata:它只表达 thread/turn 身份,不携带 +/// SQLite、Runtime 或某个 Codex 发行版的完整 wire 状态。 +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CodexSessionMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, +} + +impl CodexSessionMetadata { + pub fn from_thread_start(result: &CodexThreadStartResult) -> Self { + Self { + thread_id: Some(result.thread_id.clone()), + turn_id: None, + } + } + + pub fn from_turn_start(result: &CodexTurnStartResult) -> Self { + Self { + thread_id: None, + turn_id: Some(result.turn_id.clone()), + } + } + + fn validate(&self) -> Result<(), CodexError> { + if self + .thread_id + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(CodexError::InvalidConfig( + "Codex session metadata thread_id 不能为空".to_owned(), + )); + } + if self + .turn_id + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(CodexError::InvalidConfig( + "Codex session metadata turn_id 不能为空".to_owned(), + )); + } + Ok(()) + } + + fn merge(&mut self, update: Self) { + if update.thread_id.is_some() { + self.thread_id = update.thread_id; + } + if update.turn_id.is_some() { + self.turn_id = update.turn_id; + } + } +} + +/// Codex 外部会话的生命周期状态;它不等同于 Core run 状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CodexSessionLifecycleStatus { + Active, + Completed, + Failed, + Cancelled, + Unknown, +} + +impl CodexSessionLifecycleStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + Self::Unknown => "unknown", + } + } +} + +/// 一次 Codex process/backend 收束时传给宿主的中立生命周期观察值。 +#[derive(Clone, Debug, PartialEq)] +pub struct CodexSessionLifecycle { + pub metadata: CodexSessionMetadata, + pub status: CodexSessionLifecycleStatus, + pub external_id: Option, + pub exit_code: Option, + pub cancel_result: Option, +} + +/// 宿主提供的 metadata 持久化接口;agent-codex 不依赖具体 Store 实现。 +pub trait CodexSessionMetadataSink: Send + Sync { + fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError>; + + /// 生命周期扩展保持默认实现,避免破坏只关心 thread/turn 的旧宿主。 + /// 没有任何远端身份时不要求旧 sink 凭空创建一条 durable 记录。 + /// 实现应把它视为一次观察回调,不要同步重入同一个 backend。 + fn persist_lifecycle(&self, lifecycle: &CodexSessionLifecycle) -> Result<(), CodexError> { + if lifecycle.metadata.thread_id.is_none() && lifecycle.metadata.turn_id.is_none() { + return Ok(()); + } + self.persist(&lifecycle.metadata) + } + + /// Optional process-level observation. Legacy metadata-only sinks keep a + /// no-op default; hosts that need process exit/termination audit can opt in. + fn persist_process_lifecycle( + &self, + _event: &CodexProcessLifecycleEvent, + ) -> Result<(), CodexError> { + Ok(()) + } +} + +struct SessionProcessLifecycleForwarder { + sink: Arc, +} + +impl CodexProcessLifecycleSink for SessionProcessLifecycleForwarder { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + self.sink.persist_process_lifecycle(event) + } +} + +/// 把一个已经完成握手的 app-server 进程接到 Core 的同步 +/// [`ExternalBackend`] 端口。 +/// +/// 这是一个有意很窄的桥:`BackendRequest::operation` 会原样作为 JSON-RPC +/// method,`payload` 会原样作为 params;本类型不猜测 Codex 版本 schema,也 +/// 不把 Core 的 request ID 改写成 wire ID(底层 client 会自行分配数字 ID)。 +/// 调用方必须先完成版本适配器所需的 `initialize`,或者使用 +/// [`Self::new_initialized`] 的默认握手。默认 `invoke` 使用拒绝式 +/// [`CodexServerRequestHandler`];需要审批/工具等 server request 的宿主必须 +/// 显式调用 [`Self::invoke_with_handler`],不能因 method 被识别就自动放行。 +/// +/// 一个实例同时只允许一个 Core request。`cancel` 只匹配当前 request ID,且 +/// 取消语义是终止整个 app-server child;已经写入 wire 的请求结果因此按 +/// `UnknownSideEffect` 交给 Runtime 对账,不会被当成可安全重放。 +pub struct CodexAppServerProcessBackend { + process: CodexAppServerProcess, + active_request_id: Mutex>, + /// Typed `thread/start`/`turn/start` 调用得到的真实身份快照。 + session_metadata: Mutex, + /// 宿主可选的持久化出口;适配器不绑定具体数据库或 Runtime Store。 + session_metadata_sink: Option>, + /// Backend-instance nonce keeps durable external IDs distinct after an + /// application restart, where the in-process request counter resets. + instance_nonce: String, +} + +impl std::fmt::Debug for CodexAppServerProcessBackend { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let active = self + .active_request_id + .lock() + .ok() + .and_then(|value| value.as_ref().map(|_| true)) + .unwrap_or(false); + formatter + .debug_struct("CodexAppServerProcessBackend") + .field("process", &self.process) + .field("active_request", &active) + .field( + "session_metadata_sink", + &self.session_metadata_sink.is_some(), + ) + .finish() + } +} + +impl CodexAppServerProcessBackend { + /// 使用已完成 `initialize` 的 process 构造桥接器。 + /// + /// 自定义版本适配器应先调用 `initialize_with_params*`,再使用本构造器; + /// 构造器不会偷偷发送第二次握手。 + pub fn from_initialized(process: CodexAppServerProcess) -> Result { + if !process.is_initialized()? { + return Err(CodexError::InvalidConfig( + "Codex process backend 要求先完成 initialize".to_owned(), + )); + } + Ok(Self { + process, + active_request_id: Mutex::new(None), + session_metadata: Mutex::new(CodexSessionMetadata::default()), + session_metadata_sink: None, + instance_nonce: new_backend_nonce(), + }) + } + + /// 完成默认 clientInfo 握手后构造桥接器。 + /// + /// 需要具体发行版参数或 server-request handler 的调用方应使用 + /// [`Self::from_initialized`],自行完成版本化握手后再接入。 + pub fn new_initialized(process: CodexAppServerProcess) -> Result { + process.initialize()?; + Self::from_initialized(process) + } + + pub fn process(&self) -> &CodexAppServerProcess { + &self.process + } + + pub fn into_process(self) -> CodexAppServerProcess { + self.process + } + + /// 安装一个中立的 session metadata 持久化出口。 + /// + /// 这里用泛型接收宿主自己的 Store,避免让 agent-codex 依赖 SQLite、 + /// Host 或 Runtime。每次 typed `thread_start`/`turn_start` 成功后,sink + /// 会收到合并后的完整快照;sink 失败会原样返回给调用方。 + pub fn with_session_metadata_sink(mut self, sink: S) -> Self + where + S: CodexSessionMetadataSink + 'static, + { + let sink: Arc = Arc::new(sink); + self.process + .control + .install_process_lifecycle_sink(Arc::new(SessionProcessLifecycleForwarder { + sink: Arc::clone(&sink), + })); + self.session_metadata_sink = Some(sink); + self + } + + /// 读取当前已提取的真实 Codex session 身份。 + pub fn session_metadata(&self) -> Result { + self.session_metadata + .lock() + .map(|metadata| metadata.clone()) + .map_err(|_| CodexError::Protocol("Codex session metadata 锁已损坏".to_owned())) + } + + /// 合并并记录一份身份更新。这个入口也允许宿主从已有 Store 恢复快照; + /// 只有非空字段会覆盖旧值,避免一次部分响应清掉另一个真实 ID。 + pub fn record_session_metadata(&self, update: CodexSessionMetadata) -> Result<(), CodexError> { + update.validate()?; + let mut metadata = self + .session_metadata + .lock() + .map_err(|_| CodexError::Protocol("Codex session metadata 锁已损坏".to_owned()))?; + let mut merged = metadata.clone(); + merged.merge(update); + if merged == *metadata { + return Ok(()); + } + if let Some(sink) = self.session_metadata_sink.as_ref() { + // 先落持久化,再替换内存快照;这样返回成功时两者保持一致。 + sink.persist(&merged)?; + } + *metadata = merged; + Ok(()) + } + + fn persist_lifecycle( + &self, + status: CodexSessionLifecycleStatus, + external_id: Option, + cancel_result: Option, + ) -> Result<(), CodexError> { + let Some(sink) = self.session_metadata_sink.as_ref() else { + return Ok(()); + }; + let metadata = self.session_metadata()?; + sink.persist_lifecycle(&CodexSessionLifecycle { + metadata, + status, + external_id, + exit_code: self.process.exit_code(), + cancel_result, + }) + } + + /// 通过 typed app-server 方法创建 thread,并提取服务端分配的真实 ID。 + pub fn thread_start( + &self, + params: CodexThreadStartParams, + ) -> Result { + let result = self.process.thread_start(params)?; + self.record_session_metadata(CodexSessionMetadata::from_thread_start(&result))?; + Ok(result) + } + + /// 带 server-request handler 的 thread 创建入口。 + pub fn thread_start_with_handler( + &self, + params: CodexThreadStartParams, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + let result = self.process.thread_start_with_handler(params, handler)?; + self.record_session_metadata(CodexSessionMetadata::from_thread_start(&result))?; + Ok(result) + } + + /// 通过 typed app-server 方法启动 turn,并提取服务端分配的真实 ID。 + pub fn turn_start( + &self, + params: CodexTurnStartParams, + ) -> Result { + let result = self.process.turn_start(params)?; + self.record_session_metadata(CodexSessionMetadata::from_turn_start(&result))?; + Ok(result) + } + + /// 带 server-request handler 的 turn 启动入口。 + pub fn turn_start_with_handler( + &self, + params: CodexTurnStartParams, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + let result = self.process.turn_start_with_handler(params, handler)?; + self.record_session_metadata(CodexSessionMetadata::from_turn_start(&result))?; + Ok(result) + } + + /// 在调用方提供 server-request handler 时执行一次原始 JSON-RPC method。 + /// + /// handler 在 process 的 client 锁内执行,不能从回调重入 `process()` 的 + /// 请求方法;默认实现见 [`ExternalBackend::invoke`] 的拒绝路径。 + pub fn invoke_with_handler( + &self, + request: &BackendRequest, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.invoke_with_node_events_optional(request, None, handler) + } + + /// Execute one process request and forward response-time notifications as + /// neutral `NodeEvent` values. The event callback is observational only; + /// callers that need Core state changes must explicitly map and persist the + /// events (for example through [`NodeRuntimeEventMapper`]). + pub fn invoke_with_node_events( + &self, + request: &BackendRequest, + events: &mut dyn FnMut(NodeEvent), + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.invoke_with_node_events_optional(request, Some(events), handler) + } + + fn invoke_with_node_events_optional( + &self, + request: &BackendRequest, + events: Option<&mut dyn FnMut(NodeEvent)>, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + // BackendRequest 目前主要由 Core 构造,但它也实现 Deserialize;在 + // 适配器边界再次构造 NodeRequest,确保反序列化数据不会绕过 method/ID + // 校验而进入子进程。 + let node = NodeRequest::try_new( + request.request_id().to_owned(), + request.operation().to_owned(), + request.payload().clone(), + ) + .map_err(|error| ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string()))?; + + let reservation = self.begin_request(node.request_id.as_str())?; + let initialized = match self.process.is_initialized() { + Ok(initialized) => initialized, + Err(error) => { + self.end_request(node.request_id.as_str(), reservation); + // 读取本地生命周期状态尚未触碰 wire;保留该错误的 + // pre-dispatch 类别(例如 poisoned lock -> InvalidInput), + // 不要误报成未知远端副作用。 + return Err(ExternalError::new( + error.external_error_kind(), + error.to_string(), + )); + } + }; + if !initialized { + self.end_request(node.request_id.as_str(), reservation); + return Err(ExternalError::new( + ExternalErrorKind::InvalidInput, + "Codex process backend 要求先完成 initialize", + )); + } + let result = if let Some(events) = events { + self.process + .request_with_server_handler_reserved_with_events( + reservation, + &node.operation, + node.payload, + &node.request_id, + events, + handler, + ) + } else { + self.process.request_with_server_handler_reserved( + reservation, + &node.operation, + node.payload, + handler, + ) + }; + self.end_request(node.request_id.as_str(), reservation); + + let output = match result { + Ok(output) => output, + Err(error) => { + // 这里已经完成 initialize 并进入真实 child 的请求路径;即使 + // handler 返回 InvalidConfig,也不能证明远端没有执行副作用。 + // handler 错误会让本轮 response 不再继续 drain,因而这条 stdio + // 连接不能安全复用;连同分类一起收束 child,避免遗留孤儿进程 + // 或把本轮残留 response 当成下一次请求的结果。 + self.process.terminate(); + let mapped = ExternalError::new( + error.external_error_kind_after_process_dispatch(), + error.to_string(), + ); + // A sink failure must not hide the original process/protocol + // classification; the request is already in reconciliation. + // A concrete child exit is a failed process observation even + // though the external operation itself remains unknown. + let lifecycle_status = match error { + CodexError::Exit(_) | CodexError::Signal(_) => { + CodexSessionLifecycleStatus::Failed + } + _ => CodexSessionLifecycleStatus::Unknown, + }; + let _ = self.persist_lifecycle(lifecycle_status, None, None); + return Err(mapped); + } + }; + let value = match BackendResult::try_new(request.request_id(), output) { + Ok(value) => value, + Err(error) => { + // 返回值契约失败同样不能证明请求没有被执行;交给 reconciliation。 + let _ = self.persist_lifecycle(CodexSessionLifecycleStatus::Unknown, None, None); + return Err(ExternalError::new( + ExternalErrorKind::UnknownSideEffect, + error.to_string(), + )); + } + }; + let external_id = format!( + "codex-app-server-{}-{}", + self.instance_nonce, + NEXT_APP_SERVER_BACKEND_ID.fetch_add(1, Ordering::Relaxed), + ); + let value = value.with_external_id(external_id).map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + })?; + // `thread/start` and `turn/start` responses are acceptance events for + // a long-lived app-server. Keep the external session active while + // that child is still alive; a short-lived fixture that exits after + // its response is treated as completed instead. + let process_terminated = self.process.is_terminated(); + let status = if value.side_effect_unknown() { + CodexSessionLifecycleStatus::Unknown + } else if !process_terminated + && matches!(request.operation(), "thread/start" | "turn/start") + { + CodexSessionLifecycleStatus::Active + } else { + CodexSessionLifecycleStatus::Completed + }; + self.persist_lifecycle(status, value.external_id().map(str::to_owned), None) + .map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + })?; + Ok(value) + } + + /// Explicitly bridge a process-backed request into Core runtime events. + /// Request, response-time notification, and final result are emitted in + /// order; this method does not persist events or create another runtime. + pub fn invoke_with_runtime_events( + &self, + request: &BackendRequest, + mapper: &mut NodeRuntimeEventMapper, + sink: &mut dyn FnMut(RuntimeEvent), + ) -> Result { + let mut handler = RejectingServerRequestHandler; + self.invoke_with_runtime_events_and_handler(request, mapper, sink, &mut handler) + } + + /// Runtime-event bridge variant with an explicit server-request handler. + pub fn invoke_with_runtime_events_and_handler( + &self, + request: &BackendRequest, + mapper: &mut NodeRuntimeEventMapper, + sink: &mut dyn FnMut(RuntimeEvent), + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + let node = NodeRequest::try_new( + request.request_id().to_owned(), + request.operation().to_owned(), + request.payload().clone(), + ) + .map_err(|error| ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string()))?; + let request_event = mapper.map_request(&node).map_err(|error| { + ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string()) + })?; + sink(request_event); + + let mut mapping_error = None; + let result = self.invoke_with_node_events( + request, + &mut |event| { + if mapping_error.is_some() { + return; + } + match mapper.map_event(&event) { + Ok(runtime_event) => sink(runtime_event), + Err(error) => mapping_error = Some(error), + } + }, + handler, + )?; + if let Some(error) = mapping_error { + return Err(ExternalError::new( + ExternalErrorKind::UnknownSideEffect, + error.to_string(), + )); + } + let result_event = mapper + .map_result(&NodeResult { + request_id: result.request_id().to_owned(), + output: result.output().clone(), + side_effect_unknown: result.side_effect_unknown(), + }) + .map_err(|error| { + ExternalError::new(ExternalErrorKind::UnknownSideEffect, error.to_string()) + })?; + sink(result_event); + Ok(result) + } + + fn begin_request(&self, request_id: &str) -> Result { + let mut active = self.active_request_id.lock().map_err(|_| { + ExternalError::new( + ExternalErrorKind::Unavailable, + "Codex process backend active request 锁已损坏", + ) + })?; + if active.is_some() { + return Err(ExternalError::new( + ExternalErrorKind::InvalidInput, + "Codex process backend 同时只允许一个 request", + )); + } + // Reserve the process operation while the active-request lock is held. + // `cancel` takes this lock before inspecting the reservation, so it + // cannot slip between registration and ProcessControl::begin_operation. + let reservation = + self.process.control.reserve_operation().map_err(|error| { + ExternalError::new(error.external_error_kind(), error.to_string()) + })?; + *active = Some(request_id.to_owned()); + Ok(reservation) + } + + fn end_request(&self, request_id: &str, reservation: u64) { + if let Ok(mut active) = self.active_request_id.lock() + && active.as_deref() == Some(request_id) + { + *active = None; + // Clear a reservation left by a pre-dispatch validation failure; + // a started operation is cleared by finish_operation itself. + self.process + .control + .release_operation_reservation(reservation); + } + } +} + +impl ExternalBackend for CodexAppServerProcessBackend { + fn invoke(&self, request: &BackendRequest) -> Result { + let mut handler = RejectingServerRequestHandler; + self.invoke_with_handler(request, &mut handler) + } + + fn cancel(&self, request_id: &str) -> Result<(), ExternalError> { + if request_id.trim().is_empty() { + return Err(ExternalError::new( + ExternalErrorKind::InvalidInput, + "Codex cancel request_id 不能为空", + )); + } + let matches_active = { + let active = self.active_request_id.lock().map_err(|_| { + ExternalError::new( + ExternalErrorKind::Unavailable, + "Codex process backend active request 锁已损坏", + ) + })?; + active.as_deref() == Some(request_id) + }; + if matches_active { + // 只在 request ID 匹配时终止 child;未知 ID 按幂等 no-op 处理,避免 + // 一个迟到的取消误杀另一个已开始的 app-server 操作。 + match self.process.cancel_and_report() { + Some(true) => { + let _ = self.persist_lifecycle( + CodexSessionLifecycleStatus::Cancelled, + None, + Some("ok".to_owned()), + ); + } + Some(false) => { + // The request completed before this late cancel acquired + // the process gate; do not overwrite a completed lifecycle. + } + None => { + let _ = self.persist_lifecycle( + CodexSessionLifecycleStatus::Unknown, + None, + Some("unknown".to_owned()), + ); + return Err(ExternalError::new( + ExternalErrorKind::Unavailable, + "Codex process cancel 状态锁已损坏", + )); + } + } + } + Ok(()) + } +} + +impl Drop for CodexAppServerProcess { + fn drop(&mut self) { + self.control + .terminate_with_reason(CodexProcessLifecycleReason::Drop); + } +} + +/// 当前 Codex app-server v2 请求形状的显式版本标签。 +/// +/// Codex 不会在 stdio frame 中发送可用于协商的版本号;官方仓库要求调用方 +/// 从所运行的二进制生成并固定对应 schema。因此这个标签只约束本客户端使用的 +/// v2 `thread/*`/`turn/*` 字段形状,不能替代对外部 Codex 二进制版本的 pin。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CodexAppServerProtocol { + V2, +} + +impl CodexAppServerProtocol { + pub const fn v2() -> Self { + Self::V2 + } + + pub const fn name(self) -> &'static str { + match self { + Self::V2 => "v2", + } + } +} + +/// `initialize` 成功响应中当前 v2 schema 的稳定元数据字段。 +/// +/// 这些字段在不同 Codex 构建中可能缺失或增加,所以客户端只保留可识别的 +/// 字符串字段;thread/turn 的身份字段则会严格校验。 +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexInitializeResult { + pub user_agent: Option, + pub codex_home: Option, + pub platform_family: Option, + pub platform_os: Option, +} + +/// `thread/start` 的窄请求 DTO。 +/// +/// 只暴露当前 v2 中已经稳定且无需额外枚举定义的字段;其它版本特定参数应由 +/// 上层通过专用适配器传递,避免在通用客户端里伪造完整 generated schema。 +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexThreadStartParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ephemeral: Option, +} + +impl CodexThreadStartParams { + pub fn new() -> Self { + Self::default() + } + + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + pub fn with_cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn with_ephemeral(mut self, ephemeral: bool) -> Self { + self.ephemeral = Some(ephemeral); + self + } +} + +/// `thread/start` 返回的稳定身份边界。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CodexThreadStartResult { + pub thread_id: String, +} + +/// 当前客户端支持的最小 `turn/start` 输入项。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CodexUserInput { + Text { text: String }, +} + +impl CodexUserInput { + pub fn text(text: impl Into) -> Self { + Self::Text { text: text.into() } + } +} + +/// `turn/start` 的窄请求 DTO。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexTurnStartParams { + pub thread_id: String, + pub input: Vec, +} + +impl CodexTurnStartParams { + pub fn try_new( + thread_id: impl Into, + input: Vec, + ) -> Result { + let thread_id = thread_id.into(); + if thread_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex thread_id 不能为空".to_owned(), + )); + } + Ok(Self { thread_id, input }) + } + + fn validate(&self) -> Result<(), CodexError> { + validate_non_empty_id(&self.thread_id, "Codex thread_id") + } + + pub fn text(thread_id: impl Into, text: impl Into) -> Result { + Self::try_new(thread_id, vec![CodexUserInput::text(text)]) + } +} + +/// `turn/start` 只表示服务端接受并创建了一个 turn;最终状态必须由后续通知 +/// (通常是 `turn/completed`)确认。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CodexTurnStartResult { + pub turn_id: String, +} + +/// app-server 发出的无 id 通知。调用方必须显式 `poll_notification` 消费它。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexAppServerNotification { + pub method: String, + pub params: Value, +} + +/// Codex app-server 发给客户端的 JSON-RPC 请求的中立 envelope。 +/// +/// 真实 v2 schema 为审批、动态工具和 MCP elicitation 定义了多种 method; +/// 这里只保留稳定的 `id`/`method`/`params` 外壳,不把任何一种请求的参数 +/// 结构复制进通用客户端。上层可以按自己支持的 method 做版本化解码。 +#[derive(Clone, Debug, PartialEq)] +pub struct CodexServerRequest { + id: Value, + method: String, + params: Value, +} + +/// App Server server-request 的中立类别。 +/// +/// 这些类别只用于路由、审计和选择版本化解码器,不代表允许执行或自动批准。 +/// 未知 method 始终落到 [`Self::Unknown`],调用方不得因为类别识别成功就绕过 +/// 自己的权限策略。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CodexServerRequestKind { + /// 命令、文件变更或权限等需要上层决议的审批请求。 + Approval, + /// Codex 请求宿主执行一个动态工具。 + ToolCall, + /// 请求用户输入。 + UserInput, + /// MCP elicitation 请求。 + Elicitation, + /// 当前版本适配器未识别的请求。 + Unknown, +} + +impl CodexServerRequest { + pub fn id(&self) -> &Value { + &self.id + } + + pub fn method(&self) -> &str { + &self.method + } + + pub fn params(&self) -> &Value { + &self.params + } + + /// 按稳定的 method 名称做最小中立分类。 + /// + /// 这里不解码 params,也不把分类结果转换为审批决定;具体 wire 字段仍由 + /// 版本化 adapter(例如 `codex_0_152_1`)负责校验。 + pub fn kind(&self) -> CodexServerRequestKind { + match self.method.as_str() { + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "item/permissions/requestApproval" + | "applyPatchApproval" + | "execCommandApproval" => CodexServerRequestKind::Approval, + "item/tool/call" => CodexServerRequestKind::ToolCall, + "item/tool/requestUserInput" => CodexServerRequestKind::UserInput, + "mcpServer/elicitation/request" => CodexServerRequestKind::Elicitation, + _ => CodexServerRequestKind::Unknown, + } + } + + /// 是否属于当前中立映射已知的 method。未知请求仍必须由上层显式处理, + /// 不会因为返回 `false` 而被自动丢弃或重放。 + pub fn is_known(&self) -> bool { + self.kind() != CodexServerRequestKind::Unknown + } +} + +/// 对 server request 的中立 JSON-RPC 响应。 +#[derive(Clone, Debug, PartialEq)] +pub enum CodexServerRequestResponse { + Result(Value), + Error { + code: i64, + message: String, + data: Option, + }, +} + +impl CodexServerRequestResponse { + pub fn result(value: Value) -> Self { + Self::Result(value) + } + + pub fn error(code: i64, message: impl Into) -> Self { + Self::Error { + code, + message: message.into(), + data: None, + } + } + + pub fn error_with_data(code: i64, message: impl Into, data: Value) -> Self { + Self::Error { + code, + message: message.into(), + data: Some(data), + } + } +} + +/// 处理真实 app-server server request 的回调。 +/// +/// 回调只负责当前请求的 JSON 值,具体审批、工具和权限策略仍属于上层; +/// 未注入 handler 时客户端继续发送标准 `-32601`,保持安全的拒绝默认值。 +pub trait CodexServerRequestHandler { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result; +} + +impl CodexServerRequestHandler for F +where + F: FnMut(&CodexServerRequest) -> Result, +{ + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + self(request) + } +} + +struct RejectingServerRequestHandler; + +impl CodexServerRequestHandler for RejectingServerRequestHandler { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + Err(CodexError::Protocol(format!( + "Codex server-initiated request 不受支持: {}", + request.method + ))) + } +} + +/// 面向当前 Codex app-server v2 wire 的窄 JSONL 客户端。 +/// +/// 这个客户端和 [`JsonRpcAppServerChannel`] 有意并存:后者仍是本仓库的中立 +/// `AppServerChannel` fixture,而这里提供真实 v2 生命周期的最小身份边界。 +/// 它不实现完整 generated schema,也不创建后台异步线程;请求期间的审批/工具 +/// 等 server request 可通过显式 handler 处理,未提供 handler 时默认拒绝。请求 +/// 返回后,通知必须由调用方持续调用 [`Self::poll_notification`] 读取。外部 +/// Codex 二进制和生成 schema 仍须由部署方固定,不能把 `V2` 当成发行版兼容承诺。 +pub struct CodexAppServerClient { + transport: JsonRpcAppServerChannel, + protocol: CodexAppServerProtocol, + initialized: bool, + next_request_id: u64, + pending_notifications: VecDeque, +} + +impl CodexAppServerClient { + /// 使用当前窄 v2 请求形状创建客户端。 + pub fn new(reader: R, writer: W) -> Result { + Self::with_protocol(reader, writer, CodexAppServerProtocol::V2) + } + + pub fn with_protocol( + reader: R, + writer: W, + protocol: CodexAppServerProtocol, + ) -> Result { + Self::with_protocol_and_max_frame_bytes( + reader, + writer, + protocol, + DEFAULT_JSON_RPC_FRAME_BYTES, + ) + } + + pub fn with_max_frame_bytes( + reader: R, + writer: W, + max_frame_bytes: usize, + ) -> Result { + Self::with_protocol_and_max_frame_bytes( + reader, + writer, + CodexAppServerProtocol::V2, + max_frame_bytes, + ) + } + + pub fn with_protocol_and_max_frame_bytes( + reader: R, + writer: W, + protocol: CodexAppServerProtocol, + max_frame_bytes: usize, + ) -> Result { + Ok(Self { + transport: JsonRpcAppServerChannel::with_max_frame_bytes( + reader, + writer, + max_frame_bytes, + )?, + protocol, + initialized: false, + next_request_id: 1, + pending_notifications: VecDeque::new(), + }) + } + + pub const fn protocol(&self) -> CodexAppServerProtocol { + self.protocol + } + + pub fn max_frame_bytes(&self) -> usize { + self.transport.max_frame_bytes() + } + + pub fn into_parts(self) -> (R, W) { + self.transport.into_parts() + } + + /// 使用本 crate 的中立 client metadata 完成一次 v2 initialize 握手。 + pub fn initialize(&mut self) -> Result { + self.initialize_with_client_info("agent-runtime", env!("CARGO_PKG_VERSION")) + } + + /// 允许宿主显式提供 client name/version;这两个字段只用于 app-server 握手 + /// 和审计,不用于猜测 Codex 的发行版版本。 + pub fn initialize_with_client_info( + &mut self, + name: &str, + version: &str, + ) -> Result { + let mut handler = RejectingServerRequestHandler; + self.initialize_with_client_info_and_handler(name, version, &mut handler) + } + + /// 使用调用方已经按具体版本 schema 编码好的 `initialize` 参数完成握手。 + /// + /// 通用客户端只检查参数是 JSON 对象;字段级校验必须由版本适配器完成, + /// 这样不会把某个 Codex 发行版的 capabilities 误写进中立层。 + pub fn initialize_with_params( + &mut self, + params: Value, + ) -> Result { + let mut handler = RejectingServerRequestHandler; + self.initialize_with_params_and_handler(params, &mut handler) + } + + /// 使用调用方提供的 server-request handler 完成参数化 `initialize` 握手。 + pub fn initialize_with_params_and_handler( + &mut self, + params: Value, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + if !params.is_object() { + return Err(CodexError::InvalidConfig( + "Codex initialize params 必须是对象".to_owned(), + )); + } + if self.initialized { + return Err(CodexError::Protocol( + "Codex app-server connection 已经 initialized".to_owned(), + )); + } + let id = self.allocate_request_id()?; + self.transport.write_message(&json!({ + "id": id, + "method": "initialize", + "params": params, + }))?; + let result = self.wait_for_response_with_handler(&id, "initialize", handler)?; + let metadata = parse_initialize_result(&result)?; + + // 官方 wire 的 initialized notification 没有 id,也没有 params。 + self.transport + .write_message(&json!({"method": "initialized"}))?; + self.initialized = true; + Ok(metadata) + } + + /// 初始化时允许上层处理 server request;默认的 + /// [`Self::initialize_with_client_info`] 对未知请求保持拒绝。 + pub fn initialize_with_client_info_and_handler( + &mut self, + name: &str, + version: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + if name.trim().is_empty() || version.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex initialize clientInfo 的 name/version 不能为空".to_owned(), + )); + } + self.initialize_with_params_and_handler( + json!({ + "clientInfo": { + "name": name, + "version": version, + } + }), + handler, + ) + } + + /// 创建一个新 thread,并只返回服务端分配的 thread ID。 + pub fn thread_start( + &mut self, + params: CodexThreadStartParams, + ) -> Result { + let mut handler = RejectingServerRequestHandler; + self.thread_start_with_handler(params, &mut handler) + } + + /// 创建 thread,并在等待响应期间把 server request 交给调用方处理。 + pub fn thread_start_with_handler( + &mut self, + params: CodexThreadStartParams, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.require_initialized()?; + let params = serde_json::to_value(params).map_err(|error| { + CodexError::Protocol(format!("Codex thread/start 参数编码失败: {error}")) + })?; + let result = self.request_with_server_handler("thread/start", params, handler)?; + let thread_id = required_nested_string(&result, "thread", "id", "thread/start")?; + Ok(CodexThreadStartResult { thread_id }) + } + + /// 启动一个 turn。返回值只代表 turn 已被接受;最终完成状态不在这里猜测。 + pub fn turn_start( + &mut self, + params: CodexTurnStartParams, + ) -> Result { + let mut handler = RejectingServerRequestHandler; + self.turn_start_with_handler(params, &mut handler) + } + + /// 启动 turn,并在等待接受响应期间处理 server request。 + pub fn turn_start_with_handler( + &mut self, + params: CodexTurnStartParams, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.require_initialized()?; + params.validate()?; + let params = serde_json::to_value(params).map_err(|error| { + CodexError::Protocol(format!("Codex turn/start 参数编码失败: {error}")) + })?; + let result = self.request_with_server_handler("turn/start", params, handler)?; + let turn_id = required_nested_string(&result, "turn", "id", "turn/start")?; + Ok(CodexTurnStartResult { turn_id }) + } + + /// 发送真实 v2 `turn/interrupt`,必须显式提供 thread 和 turn 身份,并消费 + /// 匹配的 JSON-RPC response;不会把中立 request ID 塞进错误的 params 字段。 + pub fn turn_interrupt(&mut self, thread_id: &str, turn_id: &str) -> Result<(), CodexError> { + let mut handler = RejectingServerRequestHandler; + self.turn_interrupt_with_handler(thread_id, turn_id, &mut handler) + } + + /// 发送 `turn/interrupt`,并允许上层处理响应前到达的 server request。 + pub fn turn_interrupt_with_handler( + &mut self, + thread_id: &str, + turn_id: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result<(), CodexError> { + self.require_initialized()?; + validate_non_empty_id(thread_id, "Codex thread_id")?; + validate_non_empty_id(turn_id, "Codex turn_id")?; + let _ = self.request_with_server_handler( + "turn/interrupt", + json!({"threadId": thread_id, "turnId": turn_id}), + handler, + )?; + Ok(()) + } + + /// 显式读取一条通知;EOF 返回 `None`。通知在请求响应到达前也会先进入 + /// 有界调用方队列,避免丢失 `thread/started` 等生命周期事件。 + pub fn poll_notification(&mut self) -> Result, CodexError> { + let mut handler = RejectingServerRequestHandler; + self.poll_notification_with_handler(&mut handler) + } + + /// 读取一条通知;如果先收到带 id 的 server request,则调用 handler 回应 + /// 后继续读取,直到得到通知或 EOF。这样审批/动态工具等上层扩展无需复制 + /// JSON-RPC framing,但仍由调用方决定是否允许具体 method。 + pub fn poll_notification_with_handler( + &mut self, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result, CodexError> { + self.require_initialized()?; + if let Some(notification) = self.pending_notifications.pop_front() { + return Ok(Some(notification)); + } + loop { + let Some(message) = self.transport.read_message()? else { + return Ok(None); + }; + if let Some(request) = server_request_from_message(&message)? { + self.handle_server_request(request, handler)?; + continue; + } + return self.message_as_notification(message); + } + } + + fn require_initialized(&self) -> Result<(), CodexError> { + if self.initialized { + Ok(()) + } else { + Err(CodexError::Protocol( + "Codex app-server request 必须在 initialize 后发送".to_owned(), + )) + } + } + + fn allocate_request_id(&mut self) -> Result { + let id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .ok_or_else(|| CodexError::Protocol("Codex app-server request id 已耗尽".to_owned()))?; + Ok(json!(id)) + } + + /// 发送一个已初始化的 JSON-RPC request,并对等待期间的 server request + /// 使用调用方提供的 handler。返回值仍是未绑定厂商 schema 的 JSON。 + pub fn request_with_server_handler( + &mut self, + method: &str, + params: Value, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.require_initialized()?; + if method.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex app-server method 不能为空".to_owned(), + )); + } + let id = self.allocate_request_id()?; + self.transport.write_message(&json!({ + "id": id, + "method": method, + "params": params, + }))?; + self.wait_for_response_with_handler(&id, method, handler) + } + + /// 发送一个已初始化的 JSON-RPC request,并将等待 response 期间到达的 + /// 无 id notification 转换为带当前 request id 的中立 `NodeEvent`。该 + /// 回调只观察协议流,不改变 client/Host 状态;现有无事件入口继续丢弃 + /// 这些通知(同时保留 pending notification 轮询语义仅供旧路径使用)。 + fn request_with_events_and_server_handler( + &mut self, + method: &str, + params: Value, + event_request_id: &str, + events: &mut dyn FnMut(NodeEvent), + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.require_initialized()?; + if method.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex app-server method 不能为空".to_owned(), + )); + } + let id = self.allocate_request_id()?; + self.transport.write_message(&json!({ + "id": id, + "method": method, + "params": params, + }))?; + self.wait_for_response_with_events_and_handler( + &id, + method, + event_request_id, + Some(events), + handler, + ) + } + + fn wait_for_response_with_handler( + &mut self, + expected_id: &Value, + method: &str, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + self.wait_for_response_with_events_and_handler(expected_id, method, "", None, handler) + } + + fn wait_for_response_with_events_and_handler( + &mut self, + expected_id: &Value, + method: &str, + event_request_id: &str, + mut events: Option<&mut dyn FnMut(NodeEvent)>, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result { + loop { + let Some(message) = self.transport.read_message()? else { + return Err(CodexError::Protocol(format!( + "Codex {method} response 前连接已关闭" + ))); + }; + if message.get("method").is_some() + && (message.get("result").is_some() || message.get("error").is_some()) + { + return Err(CodexError::Protocol(format!( + "Codex {method} frame 同时包含 method 与 result/error" + ))); + } + let method_name = json_rpc_method(&message)?; + let id = json_rpc_id(&message)?; + if let Some(method_name) = method_name { + if let Some(id) = id { + let request = CodexServerRequest { + id, + method: method_name.to_owned(), + params: message.get("params").cloned().unwrap_or(Value::Null), + }; + self.handle_server_request(request, handler)?; + continue; + } + if let Some(events) = events.as_deref_mut() { + events(NodeEvent { + request_id: event_request_id.to_owned(), + event_type: method_name.to_owned(), + payload: message.get("params").cloned().unwrap_or(Value::Null), + }); + } else { + self.enqueue_notification(notification_from_message(method_name, &message))?; + } + continue; + } + let Some(id) = id else { + return Err(CodexError::Protocol(format!( + "Codex {method} response 缺少 id" + ))); + }; + if &id != expected_id { + return Err(CodexError::Protocol(format!( + "Codex {method} response id 不匹配: expected={expected_id} actual={id}" + ))); + } + return parse_json_rpc_result(&message, method); + } + } + + fn handle_server_request( + &mut self, + request: CodexServerRequest, + handler: &mut dyn CodexServerRequestHandler, + ) -> Result<(), CodexError> { + match handler.handle(&request) { + Ok(response) => self.write_server_response(&request, response), + Err(error) => { + // Always answer a server request before returning the handler + // error; otherwise the app-server can remain blocked waiting + // for a response that the caller already abandoned. + self.write_server_response( + &request, + CodexServerRequestResponse::error(-32601, error.to_string()), + )?; + Err(error) + } + } + } + + fn write_server_response( + &mut self, + request: &CodexServerRequest, + response: CodexServerRequestResponse, + ) -> Result<(), CodexError> { + let message = match response { + CodexServerRequestResponse::Result(result) => { + json!({"id": request.id, "result": result}) + } + CodexServerRequestResponse::Error { + code, + message, + data, + } => { + let mut error = json!({"code": code, "message": message}); + if let Some(data) = data { + error["data"] = data; + } + json!({"id": request.id, "error": error}) + } + }; + self.transport.write_message(&message) + } + + fn message_as_notification( + &mut self, + message: Value, + ) -> Result, CodexError> { + if message.get("method").is_some() + && (message.get("result").is_some() || message.get("error").is_some()) + { + return Err(CodexError::Protocol( + "Codex notification 同时包含 method 与 result/error".to_owned(), + )); + } + let method = json_rpc_method(&message)?; + let id = json_rpc_id(&message)?; + let Some(method) = method else { + return Err(CodexError::Protocol( + "Codex poll_notification 收到 response,而不是 notification".to_owned(), + )); + }; + if id.is_some() { + // `poll_notification_with_handler` handles requests before this + // helper is reached. Keep this guard for malformed/internal calls + // instead of exposing a request as a notification. + return Err(CodexError::Protocol(format!( + "Codex message 带 id,不是 notification: {method}" + ))); + } + Ok(Some(notification_from_message(method, &message))) + } + + fn enqueue_notification( + &mut self, + notification: CodexAppServerNotification, + ) -> Result<(), CodexError> { + if self.pending_notifications.len() >= DEFAULT_MAX_PENDING_NOTIFICATIONS { + return Err(CodexError::Protocol(format!( + "Codex pending notification 队列超过 {} 条限制", + DEFAULT_MAX_PENDING_NOTIFICATIONS + ))); + } + self.pending_notifications.push_back(notification); + Ok(()) + } +} + +fn server_request_from_message(message: &Value) -> Result, CodexError> { + // JSON-RPC request/notification 与 response 是互斥 envelope。轮询路径也 + // 必须拒绝这个形状,不能把它降级成普通 server request 后继续读,否则 + // handler 可能对一条本来就无效的 frame 产生副作用。 + validate_json_rpc_envelope(message)?; + let method = json_rpc_method(message)?; + let Some(method) = method else { + return Ok(None); + }; + let Some(id) = json_rpc_id(message)? else { + return Ok(None); + }; + Ok(Some(CodexServerRequest { + id, + method: method.to_owned(), + params: message.get("params").cloned().unwrap_or(Value::Null), + })) +} + +fn notification_from_message(method: &str, message: &Value) -> CodexAppServerNotification { + CodexAppServerNotification { + method: method.to_owned(), + params: message.get("params").cloned().unwrap_or(Value::Null), + } +} + +fn validate_non_empty_id(value: &str, field: &str) -> Result<(), CodexError> { + if value.trim().is_empty() { + Err(CodexError::InvalidConfig(format!("{field} 不能为空"))) + } else { + Ok(()) + } +} + +fn parse_initialize_result(value: &Value) -> Result { + if !value.is_object() { + return Err(CodexError::Protocol( + "Codex initialize result 必须是对象".to_owned(), + )); + } + Ok(CodexInitializeResult { + user_agent: optional_string_field(value, "userAgent", "initialize")?, + codex_home: optional_string_field(value, "codexHome", "initialize")?, + platform_family: optional_string_field(value, "platformFamily", "initialize")?, + platform_os: optional_string_field(value, "platformOs", "initialize")?, + }) +} + +fn optional_string_field( + value: &Value, + field: &str, + operation: &str, +) -> Result, CodexError> { + match value.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) => Ok(Some(text.clone())), + Some(actual) => Err(CodexError::Protocol(format!( + "Codex {operation} 字段 {field} 必须是字符串: {actual}" + ))), + } +} + +fn required_nested_string( + value: &Value, + object_field: &str, + string_field: &str, + operation: &str, +) -> Result { + let object = value + .get(object_field) + .and_then(Value::as_object) + .ok_or_else(|| { + CodexError::Protocol(format!( + "Codex {operation} result 缺少对象字段 {object_field}" + )) + })?; + let text = object + .get(string_field) + .and_then(Value::as_str) + .ok_or_else(|| { + CodexError::Protocol(format!( + "Codex {operation} result 缺少字符串字段 {object_field}.{string_field}" + )) + })?; + if text.trim().is_empty() { + return Err(CodexError::Protocol(format!( + "Codex {operation} result 的 {object_field}.{string_field} 不能为空" + ))); + } + Ok(text.to_owned()) +} + +fn validate_optional_json_rpc_version(message: &Value) -> Result<(), CodexError> { + if let Some(version) = message.get("jsonrpc") + && version.as_str() != Some(JSON_RPC_VERSION) + { + return Err(CodexError::Protocol(format!( + "JSON-RPC jsonrpc 版本不支持: expected={JSON_RPC_VERSION} actual={version}" + ))); + } + Ok(()) +} + +fn json_rpc_method(message: &Value) -> Result, CodexError> { + match message.get("method") { + None => Ok(None), + Some(Value::String(method)) => Ok(Some(method)), + Some(_) => Err(CodexError::Protocol( + "JSON-RPC method 必须是字符串".to_owned(), + )), + } +} + +fn validate_json_rpc_envelope(message: &Value) -> Result<(), CodexError> { + if message.get("method").is_some() + && (message.get("result").is_some() || message.get("error").is_some()) + { + return Err(CodexError::Protocol( + "JSON-RPC frame 同时包含 method 与 result/error".to_owned(), + )); + } + Ok(()) +} + +fn json_rpc_id(message: &Value) -> Result, CodexError> { + match message.get("id") { + None => Ok(None), + Some(id @ Value::String(_)) | Some(id @ Value::Number(_)) => Ok(Some(id.clone())), + Some(Value::Null) => Err(CodexError::Protocol("JSON-RPC id 不能为 null".to_owned())), + Some(_) => Err(CodexError::Protocol( + "JSON-RPC id 必须是字符串或数字".to_owned(), + )), + } +} + +fn json_rpc_id_matches(id: &Value, expected: &str) -> bool { + // JSON-RPC ID 按 JSON 值而不是文本渲染结果比较:数字 `1` 不能匹配字符串 + // `"1"`。本通道发出字符串 ID,因此只有相同字符串响应才能结束请求。 + matches!(id, Value::String(value) if value == expected) +} + +fn parse_json_rpc_result(message: &Value, operation: &str) -> Result { + if message.get("result").is_some() && message.get("error").is_some() { + return Err(CodexError::Protocol(format!( + "JSON-RPC {operation} response 同时包含 result 和 error" + ))); + } + if let Some(error) = message.get("error") { + let Some(error_object) = error.as_object() else { + return Err(CodexError::Protocol(format!( + "JSON-RPC {operation} error 必须是对象" + ))); + }; + let code = error_object + .get("code") + .and_then(Value::as_i64) + .map_or_else(|| "unknown".to_owned(), |code| code.to_string()); + let text = error_object + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error"); + return Err(CodexError::Protocol(format!( + "JSON-RPC {operation} error ({code}): {text}" + ))); + } + message.get("result").cloned().ok_or_else(|| { + CodexError::Protocol(format!( + "JSON-RPC {operation} response 缺少 result 或 error" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_runtime_core::{BackendRequest, RunSnapshot, RuntimeSnapshot, apply_event}; + use serde_json::json; + use std::io::Cursor; + use std::sync::{Arc, Mutex}; + + #[test] + fn cli_config_rejects_unapproved_arguments_and_secrets() { + let config = CodexCliConfig::try_new("codex") + .unwrap() + .with_args(["--model", "gpt-test"]) + .with_allowed_arg_prefixes(["--model"]); + assert!(CodexCliBackend::new(config).is_err()); + let config = CodexCliConfig::try_new("codex") + .unwrap() + .with_args(["--model=gpt-test"]) + .with_allowed_arg_prefixes(["--model"]); + assert!(CodexCliBackend::new(config).is_ok()); + let secret = CodexCliConfig::try_new("codex") + .unwrap() + .with_args(["--api_key=secret"]) + .with_allowed_arg_prefixes(["--api"]); + assert!(CodexCliBackend::new(secret).is_err()); + } + + #[test] + fn cli_config_rejects_common_secret_argument_spellings() { + let secret_args = vec![ + vec!["--API-KEY=secret"], + vec!["--token", "secret"], + vec!["--access-token=secret"], + vec!["Authorization: Bearer secret"], + vec!["--header=authorization: bearer secret"], + vec!["bearer secret"], + ]; + for args in secret_args { + let display = format!("{args:?}"); + let config = CodexCliConfig::try_new("codex") + .unwrap() + .with_args(args) + .with_allowed_arg_prefixes(["-", "Authorization:", "bearer", "secret"]); + assert!( + CodexCliBackend::new(config).is_err(), + "secret argv should be rejected: {display}" + ); + } + + // Matching is by a normalized complete key, so an unrelated option such + // as `--tokenizer` remains usable when explicitly whitelisted. + let safe = CodexCliConfig::try_new("codex") + .unwrap() + .with_args(["--tokenizer=sentencepiece"]) + .with_allowed_arg_prefixes(["--tokenizer"]); + assert!(CodexCliBackend::new(safe).is_ok()); + } + + #[test] + fn cli_and_process_configs_reject_empty_or_controlled_arg_prefixes() { + let cli = CodexCliConfig::try_new("codex") + .unwrap() + .with_args(["--danger"]) + .with_allowed_arg_prefixes([""]); + assert!(matches!( + CodexCliBackend::new(cli), + Err(CodexError::InvalidConfig(message)) if message.contains("前缀") + )); + + let process = CodexAppServerProcessConfig::try_new("codex") + .unwrap() + .with_args(["--danger"]) + .with_allowed_arg_prefixes(["\n"]); + assert!(matches!( + process.validate(), + Err(CodexError::InvalidConfig(message)) if message.contains("前缀") + )); + } + + #[test] + fn cli_config_rejects_zero_limits_at_serde_and_runtime_boundaries() { + for document in [ + r#"{"program":"codex","timeoutMs":0}"#, + r#"{"program":"codex","timeout_ms":0}"#, + ] { + let error = serde_json::from_str::(document).unwrap_err(); + assert!(error.to_string().contains("timeout_ms")); + } + for document in [ + r#"{"program":"codex","maxOutputBytes":0}"#, + r#"{"program":"codex","max_output_bytes":0}"#, + ] { + let error = serde_json::from_str::(document).unwrap_err(); + assert!(error.to_string().contains("max_output_bytes")); + } + + let mut timeout_zero = CodexCliConfig::try_new("codex").unwrap(); + timeout_zero.timeout_ms = 0; + assert!(matches!( + CodexCliBackend::new(timeout_zero), + Err(CodexError::InvalidConfig(message)) if message.contains("timeout") + )); + let mut output_zero = CodexCliConfig::try_new("codex").unwrap(); + output_zero.max_output_bytes = 0; + assert!(matches!( + CodexCliBackend::new(output_zero), + Err(CodexError::InvalidConfig(message)) if message.contains("max_output_bytes") + )); + } + + #[test] + fn cli_config_lets_serde_report_integer_overflow_and_rejects_submillisecond_timeout() { + let timeout_overflow = format!( + r#"{{"program":"codex","timeoutMs":{}}}"#, + u128::from(u64::MAX) + 1 + ); + assert!(serde_json::from_str::(&timeout_overflow).is_err()); + let output_overflow = format!( + r#"{{"program":"codex","maxOutputBytes":{}}}"#, + (usize::MAX as u128) + 1 + ); + assert!(serde_json::from_str::(&output_overflow).is_err()); + + let config = CodexCliConfig::try_new("codex").unwrap(); + assert!(matches!( + config.with_timeout(Duration::from_nanos(1)), + Err(CodexError::InvalidConfig(message)) if message.contains("timeout") + )); + let overflow_duration = Duration::from_millis(u64::MAX) + .checked_add(Duration::from_millis(1)) + .expect("Duration can represent this conversion-overflow case"); + assert!(matches!( + CodexCliConfig::try_new("codex") + .unwrap() + .with_timeout(overflow_duration), + Err(CodexError::InvalidConfig(message)) if message.contains("u64") + )); + + // No arbitrary upper bound is imposed: the integer type itself remains + // the serde boundary for representable maximum values. + let max_values = format!( + r#"{{"program":"codex","timeoutMs":{},"maxOutputBytes":{}}}"#, + u64::MAX, + usize::MAX + ); + let config = serde_json::from_str::(&max_values).unwrap(); + assert_eq!(config.timeout_ms, u64::MAX); + assert_eq!(config.max_output_bytes, usize::MAX); + assert!(CodexCliBackend::new(config).is_ok()); + } + + #[test] + fn app_server_process_config_rejects_zero_limits_at_serde_and_runtime_boundaries() { + for document in [ + r#"{"program":"codex","timeoutMs":0}"#, + r#"{"program":"codex","timeout_ms":0}"#, + ] { + let error = serde_json::from_str::(document).unwrap_err(); + assert!(error.to_string().contains("timeout_ms")); + } + for document in [ + r#"{"program":"codex","maxFrameBytes":0}"#, + r#"{"program":"codex","max_frame_bytes":0}"#, + ] { + let error = serde_json::from_str::(document).unwrap_err(); + assert!(error.to_string().contains("max_frame_bytes")); + } + + let mut timeout_zero = CodexAppServerProcessConfig::try_new("codex").unwrap(); + timeout_zero.timeout_ms = 0; + assert!(matches!( + timeout_zero.validate(), + Err(CodexError::InvalidConfig(message)) if message.contains("timeout") + )); + let mut frame_zero = CodexAppServerProcessConfig::try_new("codex").unwrap(); + frame_zero.max_frame_bytes = 0; + assert!(matches!( + frame_zero.validate(), + Err(CodexError::InvalidConfig(message)) if message.contains("max_frame_bytes") + )); + } + + #[test] + fn app_server_process_config_uses_checked_timeout_millis_conversion() { + let config = CodexAppServerProcessConfig::try_new("codex").unwrap(); + assert!(matches!( + config.with_timeout(Duration::from_nanos(1)), + Err(CodexError::InvalidConfig(message)) if message.contains("timeout") + )); + let overflow_duration = Duration::from_millis(u64::MAX) + .checked_add(Duration::from_millis(1)) + .expect("Duration can represent this conversion-overflow case"); + assert!(matches!( + CodexAppServerProcessConfig::try_new("codex") + .unwrap() + .with_timeout(overflow_duration), + Err(CodexError::InvalidConfig(message)) if message.contains("u64") + )); + + let timeout_overflow = format!( + r#"{{"program":"codex","timeoutMs":{}}}"#, + u128::from(u64::MAX) + 1 + ); + assert!(serde_json::from_str::(&timeout_overflow).is_err()); + let frame_overflow = format!( + r#"{{"program":"codex","maxFrameBytes":{}}}"#, + (usize::MAX as u128) + 1 + ); + assert!(serde_json::from_str::(&frame_overflow).is_err()); + } + + #[cfg(unix)] + #[test] + fn cli_backend_maps_local_fake_process_result() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "cat >/dev/null; printf '{\"ok\":true}'"]) + .with_allowed_arg_prefixes(["-", "cat"]); + let backend = CodexCliBackend::new(config).unwrap(); + let request = + BackendRequest::try_new("request-1", "run-1", "turn", json!({"text":"hi"})).unwrap(); + let result = backend.invoke(&request).unwrap(); + assert_eq!(result.output(), &json!({"ok":true})); + assert!(result.external_id().is_some_and(|value| { + value.starts_with(&format!("codex-cli-{}-", backend.instance_nonce)) + })); + } + + #[cfg(unix)] + #[test] + fn cli_backend_drains_large_stdout_and_reports_output_limit() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "printf '%4096s' x"]) + .with_allowed_arg_prefixes(["-", "printf"]) + .with_max_output_bytes(128) + .unwrap(); + let backend = CodexCliBackend::new(config).unwrap(); + let request = BackendRequest::try_new("request-large", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke_cli(&request).unwrap_err(); + assert!(matches!(error, CodexError::OutputTooLarge)); + } + + #[cfg(unix)] + #[test] + fn cli_nonzero_exit_is_an_unknown_external_side_effect() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "cat >/dev/null; printf '{\"ok\":true}'; exit 7"]) + .with_allowed_arg_prefixes(["-", "cat"]); + let backend = CodexCliBackend::new(config).unwrap(); + let request = + BackendRequest::try_new("request-failed", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("未正常完成")); + } + + #[cfg(unix)] + #[test] + fn cli_success_with_malformed_stdout_is_an_unknown_external_side_effect() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "cat >/dev/null; printf 'not-json'; exit 0"]) + .with_allowed_arg_prefixes(["-", "cat"]); + let backend = CodexCliBackend::new(config).unwrap(); + let request = + BackendRequest::try_new("request-malformed", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("输出不是有效 JSON")); + } + + #[cfg(unix)] + #[test] + fn cli_output_limit_is_an_unknown_external_side_effect() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "printf '%4096s' x"]) + .with_allowed_arg_prefixes(["-", "printf"]) + .with_max_output_bytes(128) + .unwrap(); + let backend = CodexCliBackend::new(config).unwrap(); + let request = + BackendRequest::try_new("request-large-backend", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("输出超过限制")); + } + + #[test] + fn cli_supervisor_classifies_post_spawn_failures_as_unknown() { + let post_spawn = [ + CodexError::Protocol("pipe state lost".to_owned()), + CodexError::Timeout, + CodexError::Interrupted, + CodexError::InvalidOutput, + CodexError::OutputTooLarge, + CodexError::Exit(7), + CodexError::Signal(9), + CodexError::ProcessTerminated, + ]; + for error in post_spawn { + assert_eq!( + error.external_error_kind_for_cli_failure(), + ExternalErrorKind::UnknownSideEffect, + "post-spawn error must require reconciliation: {error:?}" + ); + } + assert_eq!( + CodexError::InvalidConfig("bad argv".to_owned()).external_error_kind_for_cli_failure(), + ExternalErrorKind::InvalidInput + ); + assert_eq!( + CodexError::Spawn.external_error_kind_for_cli_failure(), + ExternalErrorKind::Unavailable + ); + } + + #[cfg(unix)] + #[test] + fn cli_supervisor_enforces_timeout() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "cat >/dev/null; sleep 2; printf '{\"ok\":true}'"]) + .with_allowed_arg_prefixes(["-", "cat", "sleep", "printf"]) + .with_timeout(Duration::from_millis(30)) + .unwrap(); + let backend = CodexCliBackend::new(config).unwrap(); + let request = + BackendRequest::try_new("request-timeout", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke_cli(&request).unwrap_err(); + assert!(matches!(error, CodexError::Timeout)); + } + + #[cfg(unix)] + #[test] + fn cli_supervisor_supports_explicit_cancel() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "cat >/dev/null; sleep 5; printf '{\"ok\":true}'"]) + .with_allowed_arg_prefixes(["-", "cat", "sleep", "printf"]) + .with_timeout(Duration::from_secs(10)) + .unwrap(); + let backend = Arc::new(CodexCliBackend::new(config).unwrap()); + let request = + BackendRequest::try_new("request-cancel", "run-1", "turn", json!({})).unwrap(); + let worker_backend = Arc::clone(&backend); + let worker = std::thread::spawn(move || worker_backend.invoke_cli(&request)); + // 给 supervisor 一个机会完成 spawn 并登记 request_id;cancel 本身仍是 + // 幂等的,未知 ID 也不会导致宿主失败。 + std::thread::sleep(Duration::from_millis(40)); + backend.cancel_cli("request-cancel").unwrap(); + let cancelled_at = Instant::now(); + let result = worker.join().expect("CLI worker thread"); + assert!(matches!(result, Err(CodexError::Interrupted))); + assert!(cancelled_at.elapsed() < Duration::from_secs(2)); + assert!(backend.cancel_cli("request-cancel").is_ok()); + assert!(matches!( + backend.cancel_cli(""), + Err(CodexError::InvalidConfig(_)) + )); + } + + #[cfg(unix)] + #[test] + fn cli_supervisor_maps_signal_exit() { + let config = CodexCliConfig::try_new("sh") + .unwrap() + .with_args(["-c", "cat >/dev/null; kill -TERM $$"]) + .with_allowed_arg_prefixes(["-", "cat", "kill"]); + let backend = CodexCliBackend::new(config).unwrap(); + let request = + BackendRequest::try_new("request-signal", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke_cli(&request).unwrap_err(); + assert!(matches!(error, CodexError::Signal(15))); + } + + struct FakeChannel { + interrupted: bool, + } + impl AppServerChannel for FakeChannel { + fn send(&mut self, request: NodeRequest) -> Result { + Ok(NodeResult { + request_id: request.request_id, + output: json!({"operation": request.operation}), + side_effect_unknown: false, + }) + } + + fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> { + self.interrupted = true; + Ok(()) + } + } + + struct BlockingChannel { + started: mpsc::SyncSender<()>, + release: mpsc::Receiver<()>, + channel_interrupts: Arc, + } + + impl AppServerChannel for BlockingChannel { + fn send(&mut self, request: NodeRequest) -> Result { + self.started + .send(()) + .map_err(|_| CodexError::Protocol("测试请求启动通知失败".to_owned()))?; + self.release + .recv() + .map_err(|_| CodexError::Protocol("测试请求未收到释放信号".to_owned()))?; + Ok(NodeResult { + request_id: request.request_id, + output: json!({"ok": true}), + side_effect_unknown: false, + }) + } + + fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> { + self.channel_interrupts.store(true, Ordering::Release); + Ok(()) + } + } + + #[test] + fn app_server_out_of_band_interrupt_hook_runs_while_invoke_is_blocked() { + let (started_tx, started_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let channel_interrupts = Arc::new(AtomicBool::new(false)); + let hook_calls = Arc::new(Mutex::new(Vec::::new())); + let hook_calls_clone = hook_calls.clone(); + let backend = Arc::new( + CodexAppServerBackend::new( + BlockingChannel { + started: started_tx, + release: release_rx, + channel_interrupts: channel_interrupts.clone(), + }, + "session-out-of-band", + ) + .unwrap() + .with_interrupt_hook(move |request_id| { + hook_calls_clone + .lock() + .map_err(|_| CodexError::Protocol("测试 hook 锁已损坏".to_owned()))? + .push(request_id.to_owned()); + Ok(()) + }), + ); + let request = NodeRequest::try_new("blocked-request", "turn", json!({})).unwrap(); + let invoke_backend = backend.clone(); + let invoke = std::thread::spawn(move || invoke_backend.invoke_node(request)); + + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("invoke should reach the blocking channel"); + + let interrupt_backend = backend.clone(); + let (interrupt_tx, interrupt_rx) = mpsc::sync_channel(1); + let interrupt = std::thread::spawn(move || { + let result = interrupt_backend.interrupt("blocked-request"); + interrupt_tx + .send(result) + .expect("interrupt result receiver"); + }); + // The hook must complete while `send` still owns the channel mutex. + // Keep the wait bounded so a regression to the old lock-only path + // fails the test instead of hanging the whole test process. + let interrupt_result = interrupt_rx.recv_timeout(Duration::from_secs(1)); + + release_tx.send(()).unwrap(); + let result = invoke + .join() + .expect("invoke thread should finish") + .expect("released invoke should return its result"); + interrupt_result + .expect("interrupt hook should finish before invoke is released") + .expect("out-of-band interrupt hook should succeed"); + interrupt.join().expect("interrupt thread should finish"); + assert_eq!( + hook_calls.lock().unwrap().as_slice(), + &["blocked-request".to_owned()], + "hook must run before the blocked invoke is released" + ); + assert!( + !channel_interrupts.load(Ordering::Acquire), + "opt-in hook should not wait for or call the channel mutex path" + ); + assert_eq!(result.output, json!({"ok": true})); + } + + #[test] + fn app_server_maps_node_result_and_interrupt() { + let backend = + CodexAppServerBackend::new(FakeChannel { interrupted: false }, "session-1").unwrap(); + let request = BackendRequest::try_new("request-2", "run-1", "turn", json!({})).unwrap(); + let result = backend.invoke(&request).unwrap(); + assert_eq!(result.output(), &json!({"operation":"turn"})); + backend.cancel("request-2").unwrap(); + } + + struct InterruptProtocolErrorChannel; + + impl AppServerChannel for InterruptProtocolErrorChannel { + fn send(&mut self, request: NodeRequest) -> Result { + Ok(NodeResult { + request_id: request.request_id, + output: json!({"ok": true}), + side_effect_unknown: false, + }) + } + + fn interrupt(&mut self, _request_id: &str) -> Result<(), CodexError> { + Err(CodexError::Protocol("interrupt response lost".to_owned())) + } + } + + #[test] + fn app_server_cancel_protocol_failure_is_unknown_after_dispatch() { + let backend = + CodexAppServerBackend::new(InterruptProtocolErrorChannel, "session-interrupt-error") + .unwrap(); + let error = backend + .cancel("request-active") + .expect_err("interrupt protocol failure should be returned"); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + } + + struct MismatchedChannel; + + impl AppServerChannel for MismatchedChannel { + fn send(&mut self, request: NodeRequest) -> Result { + Ok(NodeResult { + request_id: format!("{}-other", request.request_id), + output: json!({"ok": true}), + side_effect_unknown: false, + }) + } + } + + #[test] + fn app_server_rejects_mismatched_response_request_id() { + let backend = CodexAppServerBackend::new(MismatchedChannel, "session-mismatch").unwrap(); + let request = + BackendRequest::try_new("request-mismatch", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + // The request frame was already handed to the channel; a mismatched + // response cannot prove whether the remote operation ran. Keep it in + // the reconciliation/unknown-side-effect lane rather than allowing a + // generic retry policy to replay it. + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("request_id 不匹配")); + } + + struct InvalidConfigChannel; + + impl AppServerChannel for InvalidConfigChannel { + fn send(&mut self, _request: NodeRequest) -> Result { + Err(CodexError::InvalidConfig( + "fixture rejected configuration".to_owned(), + )) + } + } + + #[test] + fn app_server_preserves_pre_dispatch_configuration_errors() { + let backend = CodexAppServerBackend::new(InvalidConfigChannel, "session-config").unwrap(); + let request = + BackendRequest::try_new("request-config", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::InvalidInput); + assert!(error.message().contains("fixture rejected configuration")); + } + + struct ProtocolErrorChannel; + + impl AppServerChannel for ProtocolErrorChannel { + fn send(&mut self, _request: NodeRequest) -> Result { + Err(CodexError::Protocol("response frame lost".to_owned())) + } + } + + #[test] + fn app_server_maps_post_dispatch_protocol_errors_to_unknown_side_effect() { + let backend = CodexAppServerBackend::new(ProtocolErrorChannel, "session-protocol").unwrap(); + let request = + BackendRequest::try_new("request-protocol", "run-1", "turn", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("response frame lost")); + } + + #[test] + fn event_mapping_keeps_only_neutral_fields() { + let event = NodeEvent { + request_id: "r1".to_owned(), + event_type: "delta".to_owned(), + payload: json!({"text":"ok"}), + }; + assert_eq!(node_event_json(&event)["eventType"], "delta"); + } + + fn mapper_running_snapshot() -> RuntimeSnapshot { + let mut snapshot = RuntimeSnapshot::try_new("runtime-mapper").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-mapper", 1, 1).expect("runtime event"), + ) + .expect("create runtime"); + let run = RunSnapshot::try_new("run-mapper", "agent-mapper", "task", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-mapper", 2, 2, &run).expect("run event"), + ) + .expect("create run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-mapper", + 3, + 3, + "run-mapper", + RuntimeEventKind::RunStarted, + ) + .expect("start event"), + ) + .expect("start run"); + snapshot + } + + #[test] + fn node_runtime_mapper_preserves_request_and_result_through_core_reducer() { + let mut snapshot = mapper_running_snapshot(); + let mut mapper = NodeRuntimeEventMapper::try_new( + snapshot.runtime_id(), + "run-mapper", + snapshot.revision(), + 10, + ) + .expect("mapper"); + let request = NodeRequest::try_new( + "node-request-1", + "turn", + json!({"prompt":"hello","nested":{"ok":true}}), + ) + .expect("request"); + + let request_event = mapper.map_request(&request).expect("request event"); + assert_eq!(request_event.kind(), RuntimeEventKind::MessageAppended); + assert_eq!(request_event.run_id(), Some("run-mapper")); + assert_eq!(request_event.revision(), 4); + assert!( + request_event + .detail() + .to_string() + .contains("node-request-1") + ); + apply_event(&mut snapshot, &request_event).expect("request accepted by core"); + + let result = NodeResult { + request_id: "node-request-1".to_owned(), + output: json!({"answer":"ok","tokens":3}), + side_effect_unknown: false, + }; + let completion = mapper.map_result(&result).expect("result event"); + assert_eq!(completion.kind(), RuntimeEventKind::RunCompleted); + assert_eq!(completion.revision(), 5); + assert_eq!(completion.detail()["output"], result.output); + assert_eq!(completion.detail()["requestId"], "node-request-1"); + apply_event(&mut snapshot, &completion).expect("completion accepted by core"); + let run = snapshot.run("run-mapper").expect("run"); + assert_eq!(run.status(), agent_runtime_core::RunStatus::Completed); + assert_eq!(run.final_text(), Some(r#"{"answer":"ok","tokens":3}"#)); + } + + #[test] + fn node_runtime_mapper_maps_tool_events_and_keeps_revisions_contiguous() { + let mut snapshot = mapper_running_snapshot(); + let mut mapper = NodeRuntimeEventMapper::try_new( + "runtime-mapper", + "run-mapper", + snapshot.revision(), + 20, + ) + .expect("mapper"); + let request = NodeRequest::try_new( + "node-tool-1", + "tool-call", + json!({"callId":"call-1","name":"lookup","arguments":{"q":"rust"}}), + ) + .expect("tool request"); + let call_event = mapper.map_request(&request).expect("tool call event"); + assert_eq!(call_event.kind(), RuntimeEventKind::ToolCallRequested); + assert_eq!(call_event.revision(), 4); + apply_event(&mut snapshot, &call_event).expect("tool call accepted by core"); + assert_eq!( + snapshot.run("run-mapper").expect("run").status(), + agent_runtime_core::RunStatus::WaitingForTool + ); + + let tool_event = NodeEvent { + request_id: "node-tool-1".to_owned(), + event_type: "tool-result".to_owned(), + payload: json!({"callId":"call-1","output":{"rows":1}}), + }; + let result_event = mapper.map_event(&tool_event).expect("tool result event"); + assert_eq!(result_event.kind(), RuntimeEventKind::ToolCallCompleted); + assert_eq!(result_event.revision(), 5); + apply_event(&mut snapshot, &result_event).expect("tool result accepted by core"); + assert_eq!( + snapshot.run("run-mapper").expect("run").status(), + agent_runtime_core::RunStatus::Running + ); + + let completion = mapper + .map_result(&NodeResult { + request_id: "node-tool-1".to_owned(), + output: json!("done"), + side_effect_unknown: false, + }) + .expect("completion event"); + assert_eq!(completion.revision(), 6); + apply_event(&mut snapshot, &completion).expect("completion accepted by core"); + assert_eq!( + snapshot.run("run-mapper").expect("run").status(), + agent_runtime_core::RunStatus::Completed + ); + } + + #[test] + fn node_runtime_mapper_accepts_audited_codex_notification_aliases() { + let mut mapper = + NodeRuntimeEventMapper::try_new("runtime-mapper", "run-mapper", 3, 25).expect("mapper"); + mapper + .map_request(&NodeRequest::try_new("codex-turn", "turn", json!({})).unwrap()) + .expect("request event"); + + let delta = mapper + .map_event(&NodeEvent { + request_id: "codex-turn".to_owned(), + event_type: "item/agentMessage/delta".to_owned(), + payload: json!({"delta": "hello"}), + }) + .expect("Codex agent message delta alias"); + assert_eq!(delta.kind(), RuntimeEventKind::MessageAppended); + assert_eq!(delta.revision(), 5); + + let completed = mapper + .map_event(&NodeEvent { + request_id: "codex-turn".to_owned(), + event_type: "turn/completed".to_owned(), + payload: json!({"turn": {"status": "completed"}}), + }) + .expect("Codex turn completed alias"); + assert_eq!(completed.kind(), RuntimeEventKind::RunCompleted); + assert_eq!(completed.revision(), 6); + + let mut tool_mapper = + NodeRuntimeEventMapper::try_new("runtime-mapper", "run-mapper", 3, 25) + .expect("tool mapper"); + let call = tool_mapper + .map_request( + &NodeRequest::try_new( + "codex-tool", + "tool-call", + json!({"callId":"call-1","name":"lookup","arguments":{}}), + ) + .unwrap(), + ) + .expect("tool request"); + assert_eq!(call.kind(), RuntimeEventKind::ToolCallRequested); + let result = tool_mapper + .map_event(&NodeEvent { + request_id: "codex-tool".to_owned(), + event_type: "item/tool/result".to_owned(), + payload: json!({"callId":"call-1","output":{"ok":true}}), + }) + .expect("Codex tool result alias"); + assert_eq!(result.kind(), RuntimeEventKind::ToolCallCompleted); + } + + #[test] + fn node_runtime_mapper_rejects_unknown_events_and_mismatched_request_ids() { + let mut mapper = + NodeRuntimeEventMapper::try_new("runtime-mapper", "run-mapper", 3, 30).expect("mapper"); + mapper + .map_request(&NodeRequest::try_new("node-request-2", "turn", json!({})).unwrap()) + .expect("request event"); + assert_eq!(mapper.revision(), 4); + + let unknown = mapper.map_event(&NodeEvent { + request_id: "node-request-2".to_owned(), + event_type: "future-vendor-event".to_owned(), + payload: json!({"x":1}), + }); + assert!(matches!(unknown, Err(CodexError::Protocol(_)))); + assert_eq!( + mapper.revision(), + 4, + "failed mapping must not consume revision" + ); + + let mismatched_event = mapper.map_event(&NodeEvent { + request_id: "another-request".to_owned(), + event_type: "delta".to_owned(), + payload: json!({"text":"ignored"}), + }); + assert!(matches!(mismatched_event, Err(CodexError::Protocol(_)))); + let mismatched_result = mapper.map_result(&NodeResult { + request_id: "another-request".to_owned(), + output: json!("ignored"), + side_effect_unknown: false, + }); + assert!(matches!(mismatched_result, Err(CodexError::Protocol(_)))); + assert_eq!(mapper.revision(), 4); + } + + #[test] + fn node_runtime_mapper_keeps_unknown_side_effect_output_until_reconciliation() { + let mut snapshot = mapper_running_snapshot(); + let mut mapper = NodeRuntimeEventMapper::try_new( + "runtime-mapper", + "run-mapper", + snapshot.revision(), + 40, + ) + .expect("mapper"); + mapper + .map_request(&NodeRequest::try_new("node-unknown-1", "turn", json!({})).unwrap()) + .map(|event| apply_event(&mut snapshot, &event).expect("request event")) + .expect("request mapping"); + + let unknown_result = mapper + .map_result(&NodeResult { + request_id: "node-unknown-1".to_owned(), + output: json!({"remoteId":"external-7","state":"accepted"}), + side_effect_unknown: true, + }) + .expect("reconciliation event"); + assert_eq!( + unknown_result.kind(), + RuntimeEventKind::ReconciliationRequired + ); + assert_eq!( + unknown_result.detail()["output"], + json!({"remoteId":"external-7","state":"accepted"}) + ); + apply_event(&mut snapshot, &unknown_result).expect("reconciliation accepted by core"); + + let reconciled = mapper + .map_event(&NodeEvent { + request_id: "node-unknown-1".to_owned(), + event_type: "reconciled".to_owned(), + payload: json!({"remoteId":"external-7"}), + }) + .expect("reconciled event"); + assert_eq!(reconciled.kind(), RuntimeEventKind::RunReconciled); + apply_event(&mut snapshot, &reconciled).expect("reconciled accepted by core"); + assert_eq!( + snapshot.run("run-mapper").expect("run").status(), + agent_runtime_core::RunStatus::Running + ); + + let completion = mapper + .map_result(&NodeResult { + request_id: "node-unknown-1".to_owned(), + output: json!("confirmed"), + side_effect_unknown: false, + }) + .expect("final result event"); + apply_event(&mut snapshot, &completion).expect("final result accepted by core"); + assert_eq!( + snapshot.run("run-mapper").expect("run").status(), + agent_runtime_core::RunStatus::Completed + ); + } + + struct EventChannel; + impl AppServerChannel for EventChannel { + fn send(&mut self, request: NodeRequest) -> Result { + Ok(NodeResult { + request_id: request.request_id, + output: json!({"ok": true}), + side_effect_unknown: false, + }) + } + + fn send_with_events( + &mut self, + request: NodeRequest, + events: &mut dyn FnMut(NodeEvent), + ) -> Result { + events(NodeEvent { + request_id: request.request_id.clone(), + event_type: "delta".to_owned(), + payload: json!({"text": "hi"}), + }); + self.send(request) + } + } + + #[test] + fn app_server_forwards_neutral_event_stream() { + let backend = CodexAppServerBackend::new(EventChannel, "session-events").unwrap(); + let request = NodeRequest { + request_id: "r-events".to_owned(), + operation: "turn".to_owned(), + payload: json!({}), + }; + let mut events = Vec::new(); + let result = backend + .invoke_node_with_events(request, &mut |event| events.push(event)) + .unwrap(); + assert_eq!(result.output, json!({"ok": true})); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "delta"); + } + + #[test] + fn app_server_runtime_event_bridge_feeds_core_reducer() { + let backend = CodexAppServerBackend::new(EventChannel, "session-runtime-events").unwrap(); + let mut snapshot = mapper_running_snapshot(); + let mut mapper = NodeRuntimeEventMapper::try_new( + snapshot.runtime_id(), + "run-mapper", + snapshot.revision(), + 50, + ) + .unwrap(); + let mut kinds = Vec::new(); + let result = backend + .invoke_node_with_runtime_events( + NodeRequest::try_new("bridge-request", "turn", json!({"prompt":"hi"})).unwrap(), + &mut mapper, + &mut |event| { + kinds.push(event.kind()); + apply_event(&mut snapshot, &event).expect("mapped event should reduce"); + }, + ) + .expect("runtime event bridge should complete"); + + assert_eq!(result.output, json!({"ok": true})); + assert_eq!(kinds.len(), 3, "request, delta and result are all observed"); + assert_eq!(kinds[0], RuntimeEventKind::MessageAppended); + assert_eq!(kinds[1], RuntimeEventKind::MessageAppended); + assert_eq!(kinds[2], RuntimeEventKind::RunCompleted); + assert_eq!(mapper.revision(), 6); + assert_eq!( + snapshot.run("run-mapper").unwrap().status(), + agent_runtime_core::RunStatus::Completed + ); + } + + #[test] + fn node_request_validates_fields_and_can_generate_id() { + assert!(NodeRequest::try_new("", "turn", json!({})).is_err()); + assert!(NodeRequest::try_new("r1", "", json!({})).is_err()); + let request = NodeRequest::with_generated_id("turn", json!({})).unwrap(); + assert!(request.request_id.starts_with("codex-node-")); + } + + #[derive(Clone, Default)] + struct SharedWriter(Arc>>); + + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().expect("writer lock").extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn jsonl_channel_writes_request_and_forwards_event_before_result() { + let input = concat!( + r#"{"kind":"event","requestId":"wire-1","eventType":"delta","payload":{"text":"hi"}}"#, + "\n", + r#"{"kind":"result","requestId":"wire-1","output":{"ok":true},"sideEffectUnknown":false}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let channel = JsonLineAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap(); + let request = NodeRequest { + request_id: "wire-1".to_owned(), + operation: "turn".to_owned(), + payload: json!({"message":"hello"}), + }; + let mut events = Vec::new(); + let result = backend + .invoke_node_with_events(request, &mut |event| events.push(event)) + .unwrap(); + assert_eq!(result.output, json!({"ok":true})); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "delta"); + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let frame: AppServerFrame = serde_json::from_str(lines.trim()).unwrap(); + assert_eq!( + frame, + AppServerFrame::Request { + request_id: "wire-1".to_owned(), + operation: "turn".to_owned(), + payload: json!({"message":"hello"}), + } + ); + let wire: AppServerWireFrame = serde_json::from_str(lines.trim()).unwrap(); + assert!(matches!( + wire, + AppServerWireFrame::Request { + protocol_version: APP_SERVER_PROTOCOL_VERSION, + .. + } + )); + } + + #[test] + fn jsonl_channel_supports_versioned_startup_handshake() { + let input = concat!( + r#"{"kind":"ready","protocolVersion":1,"sessionId":"handshake-session"}"#, + "\n", + r#"{"kind":"event","protocolVersion":1,"requestId":"wire-handshake","eventType":"delta","payload":{"text":"hi"}}"#, + "\n", + r#"{"kind":"result","protocolVersion":1,"requestId":"wire-handshake","output":{"ok":true}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let channel = JsonLineAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + let backend = CodexAppServerBackend::new_initialized(channel, "handshake-session").unwrap(); + let result = backend + .invoke_node(NodeRequest::try_new("wire-handshake", "turn", json!({})).unwrap()) + .unwrap(); + assert_eq!(result.output, json!({"ok":true})); + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let wire: AppServerWireFrame = serde_json::from_str(lines.lines().next().unwrap()).unwrap(); + assert!(matches!( + wire, + AppServerWireFrame::Initialize { + protocol_version: APP_SERVER_PROTOCOL_VERSION, + session_id + } if session_id == "handshake-session" + )); + } + + #[test] + fn jsonl_channel_skips_many_blank_frames_without_recursion() { + // 空白行是合法的 keep-alive 输入;数量不应影响读取栈深度。 + let mut input = "\n".repeat(8_192); + input.push_str(concat!( + r#"{"kind":"ready","protocolVersion":1,"sessionId":"blank-session"}"#, + "\n", + r#"{"kind":"result","protocolVersion":1,"requestId":"blank-1","output":{"ok":true}}"#, + "\n", + )); + let channel = + JsonLineAppServerChannel::new(Cursor::new(input.into_bytes()), Vec::::new()) + .unwrap(); + let backend = CodexAppServerBackend::new_initialized(channel, "blank-session").unwrap(); + let result = backend + .invoke_node(NodeRequest::try_new("blank-1", "turn", json!({})).unwrap()) + .unwrap(); + assert_eq!(result.output, json!({"ok":true})); + } + + #[test] + fn jsonl_channel_rejects_unknown_protocol_version() { + let input = concat!( + r#"{"kind":"ready","protocolVersion":99,"sessionId":"bad-version"}"#, + "\n", + ); + let channel = + JsonLineAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::::new()) + .unwrap(); + let error = CodexAppServerBackend::new_initialized(channel, "bad-version").unwrap_err(); + assert!(error.to_string().contains("protocolVersion 不支持")); + } + + #[test] + fn jsonl_channel_requires_version_after_handshake() { + let input = concat!( + r#"{"kind":"ready","protocolVersion":1,"sessionId":"strict-session"}"#, + "\n", + r#"{"kind":"result","requestId":"strict-1","output":{"ok":true}}"#, + "\n", + ); + let channel = + JsonLineAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::::new()) + .unwrap(); + let backend = CodexAppServerBackend::new_initialized(channel, "strict-session").unwrap(); + let error = backend + .invoke_node(NodeRequest::try_new("strict-1", "turn", json!({})).unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("缺少 protocolVersion")); + } + + #[test] + fn jsonl_channel_rejects_mismatched_event_and_bounds_frames() { + let mismatch = concat!( + r#"{"kind":"event","requestId":"other","eventType":"delta","payload":{}}"#, + "\n", + ); + let channel = JsonLineAppServerChannel::new( + Cursor::new(mismatch.as_bytes().to_vec()), + Vec::::new(), + ) + .unwrap(); + let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap(); + let error = backend + .invoke_node(NodeRequest { + request_id: "wire-2".to_owned(), + operation: "turn".to_owned(), + payload: json!({}), + }) + .unwrap_err(); + assert!(error.to_string().contains("event request_id 不匹配")); + + let oversized = format!( + "{{\"kind\":\"result\",\"requestId\":\"wire-3\",\"output\":{{\"x\":\"{}\"}}}}\n", + "x".repeat(64) + ); + let channel = JsonLineAppServerChannel::with_max_frame_bytes( + Cursor::new(oversized.into_bytes()), + Vec::::new(), + 32, + ) + .unwrap(); + let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap(); + let error = backend + .invoke_node(NodeRequest { + request_id: "wire-3".to_owned(), + operation: "turn".to_owned(), + payload: json!({}), + }) + .unwrap_err(); + assert!(error.to_string().contains("超过 32 字节限制")); + } + + #[test] + fn jsonl_channel_interrupt_writes_control_frame() { + let written = Arc::new(Mutex::new(Vec::new())); + let channel = JsonLineAppServerChannel::new( + Cursor::new(Vec::::new()), + SharedWriter(written.clone()), + ) + .unwrap(); + let backend = CodexAppServerBackend::new(channel, "wire-session").unwrap(); + backend.interrupt("wire-4").unwrap(); + let frame: AppServerFrame = + serde_json::from_slice(written.lock().unwrap().as_slice()).unwrap(); + assert_eq!( + frame, + AppServerFrame::Interrupt { + request_id: "wire-4".to_owned() + } + ); + } + + #[test] + fn json_rpc_channel_performs_handshake_and_streams_notification() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":"rpc-session","result":{"userAgent":"fixture"}}"#, + "\n", + r#"{"method":"item/agentMessage/delta","params":{"delta":"hi"}}"#, + "\n", + r#"{"id":"rpc-node","result":{"ok":true}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let mut events = Vec::new(); + let result = channel + .send_with_events( + NodeRequest::try_new("rpc-node", "turn/start", json!({"input": []})).unwrap(), + &mut |event| events.push(event), + ) + .unwrap(); + assert_eq!(result.output, json!({"ok":true})); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "item/agentMessage/delta"); + assert_eq!(events[0].payload, json!({"delta":"hi"})); + + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let messages = lines + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(messages.len(), 3); + assert_eq!(messages[0]["method"], "initialize"); + assert_eq!(messages[0]["id"], "rpc-session"); + assert!(messages[0].get("jsonrpc").is_none()); + assert_eq!(messages[1]["method"], "initialized"); + assert!(messages[1].get("id").is_none()); + assert_eq!(messages[2]["method"], "turn/start"); + assert_eq!(messages[2]["id"], "rpc-node"); + } + + #[test] + fn json_rpc_channel_rejects_mismatched_response_id() { + let input = concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":1,"result":{}}"#, + "\n", + ); + let mut channel = + JsonRpcAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::::new()) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let error = channel + .send(NodeRequest::try_new("1", "turn/start", json!({})).unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("response id 不匹配")); + } + + #[test] + fn json_rpc_channel_rejects_server_request_and_writes_error_response() { + let input = concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":"server-1","method":"item/commandExecution/requestApproval","params":{}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let error = channel + .send(NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("server-initiated request")); + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let response = lines + .lines() + .last() + .map(|line| serde_json::from_str::(line).unwrap()) + .unwrap(); + assert_eq!(response["id"], "server-1"); + assert_eq!(response["error"]["code"], -32601); + } + + #[test] + fn json_rpc_channel_handler_answers_server_request_during_initialize() { + let input = concat!( + r#"{"id":"rpc-session","method":"item/commandExecution/requestApproval","params":{"command":["echo"]}}"#, + "\n", + r#"{"id":"rpc-session","result":{}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + let mut seen = Vec::new(); + channel + .initialize_with_handler("rpc-session", &mut |request: &CodexServerRequest| { + seen.push(request.method().to_owned()); + Ok(CodexServerRequestResponse::result( + json!({"decision":"accept"}), + )) + }) + .unwrap(); + assert_eq!(seen, ["item/commandExecution/requestApproval"]); + + let messages = String::from_utf8(written.lock().unwrap().clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let response = messages + .iter() + .find(|message| { + message["id"] == "rpc-session" && message["result"]["decision"] == "accept" + }) + .expect("server request response"); + assert_eq!(response["result"]["decision"], "accept"); + assert!( + messages + .iter() + .any(|message| message["method"] == "initialized") + ); + } + + #[test] + fn json_rpc_channel_handler_answers_server_request_during_send() { + let input = concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":"server-1","method":"item/tool/call","params":{"name":"lookup"}}"#, + "\n", + r#"{"id":"rpc-node","result":{"ok":true}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let mut events = Vec::new(); + let mut seen = Vec::new(); + let result = channel + .send_with_events_and_handler( + NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap(), + &mut |event| events.push(event), + &mut |request: &CodexServerRequest| { + seen.push((request.method().to_owned(), request.params().clone())); + Ok(CodexServerRequestResponse::result(json!({"success":true}))) + }, + ) + .unwrap(); + assert_eq!(result.output, json!({"ok":true})); + assert!(events.is_empty()); + assert_eq!(seen[0].0, "item/tool/call"); + assert_eq!(seen[0].1["name"], "lookup"); + + let messages = String::from_utf8(written.lock().unwrap().clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .expect("server request response"); + assert_eq!(response["result"]["success"], true); + } + + #[test] + fn json_rpc_channel_handler_error_is_replied_and_propagated() { + let input = concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":"server-1","method":"item/tool/call","params":{}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let error = channel + .send_with_events_and_handler( + NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap(), + &mut |_| {}, + &mut |_request: &CodexServerRequest| { + Err(CodexError::Protocol("fixture denied".to_owned())) + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("fixture denied")); + let response = String::from_utf8(written.lock().unwrap().clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|message| message["id"] == "server-1") + .expect("handler error response"); + assert_eq!(response["error"]["code"], -32601); + assert_eq!( + response["error"]["message"], + "Codex 协议错误: fixture denied" + ); + } + + #[test] + fn json_rpc_channel_rejects_mixed_envelope_before_handler() { + let input = concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":"server-1","method":"item/tool/call","params":{},"result":{}}"#, + "\n", + ); + let mut channel = + JsonRpcAppServerChannel::new(Cursor::new(input.as_bytes().to_vec()), Vec::::new()) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let mut called = false; + let error = channel + .send_with_events_and_handler( + NodeRequest::try_new("rpc-node", "turn/start", json!({})).unwrap(), + &mut |_| {}, + &mut |_request: &CodexServerRequest| { + called = true; + Ok(CodexServerRequestResponse::result(json!({}))) + }, + ) + .unwrap_err(); + assert!(!called); + assert!( + error + .to_string() + .contains("同时包含 method 与 result/error") + ); + } + + #[test] + fn json_rpc_poll_rejects_mixed_server_request_response_envelope() { + let input = concat!( + // A JSON-RPC message cannot be both a request and a response. The + // polling path must reject it before invoking the handler. + r#"{"id":1,"result":{}}"#, + "\n", + r#"{"id":"server-1","method":"item/tool/call","params":{},"result":{}}"#, + "\n", + ); + let mut client = + CodexAppServerClient::new(Cursor::new(input.as_bytes().to_vec()), Vec::::new()) + .unwrap(); + client.initialize().unwrap(); + let mut handler_called = false; + let error = client + .poll_notification_with_handler(&mut |_: &CodexServerRequest| { + handler_called = true; + Ok(CodexServerRequestResponse::result(json!({}))) + }) + .unwrap_err(); + assert!(!handler_called); + assert!( + error + .to_string() + .contains("同时包含 method 与 result/error") + ); + } + + #[test] + fn server_request_kind_is_neutral_and_unknown_is_not_authorized() { + let cases = [ + ( + "item/commandExecution/requestApproval", + CodexServerRequestKind::Approval, + ), + ( + "item/fileChange/requestApproval", + CodexServerRequestKind::Approval, + ), + ( + "item/permissions/requestApproval", + CodexServerRequestKind::Approval, + ), + ("item/tool/call", CodexServerRequestKind::ToolCall), + ( + "item/tool/requestUserInput", + CodexServerRequestKind::UserInput, + ), + ( + "mcpServer/elicitation/request", + CodexServerRequestKind::Elicitation, + ), + ("future/server/request", CodexServerRequestKind::Unknown), + ]; + for (method, expected_kind) in cases { + let request = CodexServerRequest { + id: json!(1), + method: method.to_owned(), + params: json!({"opaque": true}), + }; + assert_eq!(request.kind(), expected_kind); + assert_eq!( + request.is_known(), + expected_kind != CodexServerRequestKind::Unknown + ); + } + } + + #[test] + fn error_classification_keeps_dispatch_boundary_explicit() { + assert_eq!( + CodexError::InvalidConfig("bad request".to_owned()).external_error_kind(), + ExternalErrorKind::InvalidInput + ); + assert_eq!( + CodexError::Protocol("frame lost".to_owned()).external_error_kind(), + ExternalErrorKind::InvalidInput + ); + assert_eq!( + CodexError::Protocol("frame lost".to_owned()).external_error_kind_after_dispatch(), + ExternalErrorKind::UnknownSideEffect + ); + assert_eq!( + CodexError::InvalidConfig("handler rejected".to_owned()) + .external_error_kind_after_process_dispatch(), + ExternalErrorKind::UnknownSideEffect + ); + assert_eq!( + CodexError::Timeout.external_error_kind_after_dispatch(), + ExternalErrorKind::UnknownSideEffect + ); + assert_eq!( + CodexError::Spawn.external_error_kind_for_cli_failure(), + ExternalErrorKind::Unavailable + ); + } + + #[test] + fn json_rpc_channel_enforces_bounded_input_and_output_frames() { + let input = format!( + "{{\"id\":\"rpc-session\",\"result\":{{\"x\":\"{}\"}}}}\n", + "x".repeat(64) + ); + let mut channel = JsonRpcAppServerChannel::with_max_frame_bytes( + Cursor::new(input.into_bytes()), + Vec::::new(), + 32, + ) + .unwrap(); + let error = channel.initialize("rpc-session").unwrap_err(); + assert!(error.to_string().contains("超过 32 字节限制")); + + let mut channel = JsonRpcAppServerChannel::with_max_frame_bytes( + Cursor::new(concat!(r#"{"id":"rpc-session","result":{}}"#, "\n").as_bytes()), + Vec::::new(), + 128, + ) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let error = channel + .send( + NodeRequest::try_new("rpc-node", "turn/start", json!({"x": "x".repeat(128)})) + .unwrap(), + ) + .unwrap_err(); + assert!(error.to_string().contains("超过 128 字节限制")); + } + + #[test] + fn json_rpc_channel_interrupt_writes_turn_interrupt_request() { + let written = Arc::new(Mutex::new(Vec::new())); + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new( + concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":"rpc-node","result":{}}"#, + "\n" + ) + .as_bytes(), + ), + SharedWriter(written.clone()), + ) + .unwrap(); + assert!(matches!( + channel.interrupt("rpc-node"), + Err(CodexError::Protocol(message)) if message.contains("initialize") + )); + channel.initialize("rpc-session").unwrap(); + channel.interrupt("rpc-node").unwrap(); + let message: Value = written + .lock() + .unwrap() + .split(|byte| *byte == b'\n') + .rfind(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()) + .unwrap(); + assert_eq!(message["method"], "turn/interrupt"); + assert_eq!(message["id"], "rpc-node"); + assert_eq!(message["params"]["requestId"], "rpc-node"); + } + + #[test] + fn json_rpc_channel_interrupt_propagates_error_response() { + let mut channel = JsonRpcAppServerChannel::new( + Cursor::new( + concat!( + r#"{"id":"rpc-session","result":{}}"#, + "\n", + r#"{"id":"rpc-node","error":{"code":-32000,"message":"busy"}}"#, + "\n" + ) + .as_bytes(), + ), + Vec::::new(), + ) + .unwrap(); + channel.initialize("rpc-session").unwrap(); + let error = channel.interrupt("rpc-node").unwrap_err(); + assert!( + error + .to_string() + .contains("turn/interrupt error (-32000): busy") + ); + } + + #[test] + fn codex_v2_client_initializes_and_starts_thread() { + let input = concat!( + r#"{"id":1,"result":{"userAgent":"codex","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"linux"}}"#, + "\n", + r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#, + "\n", + r#"{"method":"thread/started","params":{"thread":{"id":"thr_123"}}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut client = CodexAppServerClient::with_protocol( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + CodexAppServerProtocol::V2, + ) + .unwrap(); + let initialized = client + .initialize_with_client_info("test-client", "0.1.0") + .unwrap(); + assert_eq!(initialized.user_agent.as_deref(), Some("codex")); + assert_eq!(initialized.platform_os.as_deref(), Some("linux")); + assert_eq!(client.protocol(), CodexAppServerProtocol::V2); + + let thread = client + .thread_start( + CodexThreadStartParams::new() + .with_model("gpt-5.1-codex") + .with_cwd("/tmp/project") + .with_ephemeral(true), + ) + .unwrap(); + assert_eq!(thread.thread_id, "thr_123"); + let notification = client.poll_notification().unwrap().unwrap(); + assert_eq!(notification.method, "thread/started"); + + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let messages = lines + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(messages.len(), 3); + assert_eq!(messages[0]["method"], "initialize"); + assert_eq!(messages[0]["id"], 1); + assert_eq!(messages[0]["params"]["clientInfo"]["name"], "test-client"); + assert_eq!(messages[1]["method"], "initialized"); + assert!(messages[1].get("id").is_none()); + assert!(messages[1].get("params").is_none()); + assert_eq!(messages[2]["method"], "thread/start"); + assert_eq!(messages[2]["id"], 2); + assert_eq!(messages[2]["params"]["model"], "gpt-5.1-codex"); + assert_eq!(messages[2]["params"]["cwd"], "/tmp/project"); + assert_eq!(messages[2]["params"]["ephemeral"], true); + } + + #[test] + fn codex_v2_client_turn_start_returns_id_and_polls_lifecycle_notifications() { + let input = concat!( + r#"{"id":1,"result":{}}"#, + "\n", + r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#, + "\n", + r#"{"method":"thread/started","params":{"thread":{"id":"thr_123"}}}"#, + "\n", + r#"{"id":3,"result":{"turn":{"id":"turn_456","status":"inProgress"}}}"#, + "\n", + r#"{"method":"turn/started","params":{"turn":{"id":"turn_456"}}}"#, + "\n", + r#"{"method":"turn/completed","params":{"turn":{"id":"turn_456","status":"completed"}}}"#, + "\n", + ); + let mut client = + CodexAppServerClient::new(Cursor::new(input.as_bytes().to_vec()), Vec::::new()) + .unwrap(); + client.initialize().unwrap(); + let thread = client + .thread_start(CodexThreadStartParams::default()) + .unwrap(); + assert_eq!(thread.thread_id, "thr_123"); + assert_eq!( + client.poll_notification().unwrap().unwrap().method, + "thread/started" + ); + + let turn = client + .turn_start(CodexTurnStartParams::text("thr_123", "Run tests").unwrap()) + .unwrap(); + assert_eq!(turn.turn_id, "turn_456"); + assert_eq!( + client.poll_notification().unwrap().unwrap().method, + "turn/started" + ); + assert_eq!( + client.poll_notification().unwrap().unwrap().method, + "turn/completed" + ); + assert!(client.poll_notification().unwrap().is_none()); + } + + #[test] + fn codex_v2_client_interrupt_uses_thread_and_turn_ids() { + let input = concat!( + r#"{"id":1,"result":{}}"#, + "\n", + r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#, + "\n", + r#"{"id":3,"result":{"turn":{"id":"turn_456"}}}"#, + "\n", + r#"{"id":4,"result":{}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut client = CodexAppServerClient::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + client.initialize().unwrap(); + client + .thread_start(CodexThreadStartParams::default()) + .unwrap(); + client + .turn_start(CodexTurnStartParams::text("thr_123", "hello").unwrap()) + .unwrap(); + client.turn_interrupt("thr_123", "turn_456").unwrap(); + + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let messages = lines + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let interrupt = messages.last().unwrap(); + assert_eq!(interrupt["method"], "turn/interrupt"); + assert_eq!(interrupt["id"], 4); + assert_eq!( + interrupt["params"], + json!({ + "threadId": "thr_123", + "turnId": "turn_456" + }) + ); + } + + #[test] + fn codex_v2_client_rejects_empty_turn_thread_id_before_writing() { + let mut client = CodexAppServerClient::new( + Cursor::new(concat!(r#"{"id":1,"result":{}}"#, "\n").as_bytes()), + Vec::::new(), + ) + .unwrap(); + client.initialize().unwrap(); + let error = client + .turn_start(CodexTurnStartParams { + thread_id: " ".to_owned(), + input: Vec::new(), + }) + .unwrap_err(); + assert!(error.to_string().contains("thread_id 不能为空")); + } + + #[test] + fn codex_v2_client_rejects_server_request_and_replies_with_json_rpc_error() { + let input = concat!( + r#"{"id":1,"result":{}}"#, + "\n", + r#"{"id":2,"method":"item/commandExecution/requestApproval","params":{"threadId":"thr_123"}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut client = CodexAppServerClient::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + client.initialize().unwrap(); + let error = client + .thread_start(CodexThreadStartParams::default()) + .unwrap_err(); + assert!(error.to_string().contains("server-initiated request")); + + let lines = String::from_utf8(written.lock().unwrap().clone()).unwrap(); + let response = lines + .lines() + .last() + .map(|line| serde_json::from_str::(line).unwrap()) + .unwrap(); + assert_eq!(response["id"], 2); + assert_eq!(response["error"]["code"], -32601); + } + + #[test] + fn codex_v2_client_dispatches_server_request_to_handler_and_writes_result() { + let input = concat!( + r#"{"id":1,"result":{}}"#, + "\n", + r#"{"id":"approval-1","method":"item/commandExecution/requestApproval","params":{"threadId":"thr_123"}}"#, + "\n", + r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut client = CodexAppServerClient::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + client.initialize().unwrap(); + let mut seen = Vec::new(); + let thread = client + .thread_start_with_handler( + CodexThreadStartParams::default(), + &mut |request: &CodexServerRequest| { + seen.push((request.method().to_owned(), request.params().clone())); + Ok(CodexServerRequestResponse::result(json!({ + "decision": "accept" + }))) + }, + ) + .unwrap(); + assert_eq!(thread.thread_id, "thr_123"); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, "item/commandExecution/requestApproval"); + assert_eq!(seen[0].1["threadId"], "thr_123"); + + let messages = String::from_utf8(written.lock().unwrap().clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let response = messages + .iter() + .find(|message| message["id"] == "approval-1") + .expect("server request response"); + assert_eq!(response["result"]["decision"], "accept"); + } + + #[test] + fn codex_v2_client_poll_handler_consumes_server_request_before_notification() { + let input = concat!( + r#"{"id":1,"result":{}}"#, + "\n", + r#"{"id":"approval-1","method":"item/tool/requestUserInput","params":{"questions":[]}}"#, + "\n", + r#"{"method":"turn/completed","params":{"turn":{"id":"turn_1"}}}"#, + "\n", + ); + let written = Arc::new(Mutex::new(Vec::new())); + let mut client = CodexAppServerClient::new( + Cursor::new(input.as_bytes().to_vec()), + SharedWriter(written.clone()), + ) + .unwrap(); + client.initialize().unwrap(); + let mut seen_method = None; + let notification = client + .poll_notification_with_handler(&mut |request: &CodexServerRequest| { + seen_method = Some(request.method().to_owned()); + Ok(CodexServerRequestResponse::error_with_data( + -32602, + "unsupported input shape", + json!({"retryable": false}), + )) + }) + .unwrap() + .unwrap(); + assert_eq!(seen_method.as_deref(), Some("item/tool/requestUserInput")); + assert_eq!(notification.method, "turn/completed"); + let messages = String::from_utf8(written.lock().unwrap().clone()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let response = messages + .iter() + .find(|message| message["id"] == "approval-1") + .expect("server request error response"); + assert_eq!(response["error"]["code"], -32602); + assert_eq!(response["error"]["data"]["retryable"], false); + } + + #[test] + fn codex_v2_client_bounds_notifications_seen_before_response() { + let mut input = String::from(r#"{"id":1,"result":{}}"#); + input.push('\n'); + for index in 0..=DEFAULT_MAX_PENDING_NOTIFICATIONS { + input.push_str(&format!(r#"{{"method":"progress/{index}","params":{{}}}}"#)); + input.push('\n'); + } + input.push_str(r#"{"id":2,"result":{"thread":{"id":"thr_123"}}}"#); + input.push('\n'); + + let mut client = + CodexAppServerClient::new(Cursor::new(input.into_bytes()), Vec::::new()).unwrap(); + client.initialize().unwrap(); + let error = client + .thread_start(CodexThreadStartParams::default()) + .unwrap_err(); + assert!(error.to_string().contains("pending notification 队列超过")); + } + + #[cfg(unix)] + fn process_fixture_config(script: &str) -> CodexAppServerProcessConfig { + CodexAppServerProcessConfig::try_new("sh") + .unwrap() + .with_args(["-c", script]) + // 只允许 fixture 所需的显式 argv;script 本身仍作为一个完整 + // argv 元素传递,适配器不会替调用方解析 shell 字符串。 + .with_allowed_arg_prefixes(["-", "read", "printf"]) + } + + #[cfg(unix)] + #[test] + fn app_server_process_performs_real_stdio_handshake_and_thread_start() { + let process = CodexAppServerProcess::spawn(process_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{\"userAgent\":\"fixture\"}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thr_fixture\"}}}'", + )) + .unwrap(); + let metadata = process.initialize().unwrap(); + assert_eq!(metadata.user_agent.as_deref(), Some("fixture")); + let thread = process + .thread_start(CodexThreadStartParams::default()) + .unwrap(); + assert_eq!(thread.thread_id, "thr_fixture"); + // 这个一次性 fixture 在返回 thread/start 后主动 EOF;adapter 应在 + // 观察到 EOF 时回收 child,而不是把已结束连接继续当成长连接复用。 + let deadline = Instant::now() + Duration::from_secs(1); + while !process.is_terminated() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(2)); + } + assert!(process.is_terminated()); + process.terminate(); + } + + #[cfg(unix)] + #[test] + fn app_server_process_reports_malformed_output_without_reusing_child() { + let process = CodexAppServerProcess::spawn(process_fixture_config( + // 保持 child 存活一小段时间,确认协议错误会主动 poison 连接, + // 而不是等自然 EOF 后才回收。 + "read -r line; printf '%s\\n' 'not-json'; sleep 5", + )) + .unwrap(); + let error = process.initialize().unwrap_err(); + assert!(error.to_string().contains("JSON-RPC frame 无效")); + assert!(process.is_terminated()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_reaps_successful_eof_before_next_operation() { + let process = CodexAppServerProcess::spawn(process_fixture_config( + // 背景后代故意短暂持有 stdout;自然 EOF 回收必须收束整个 + // process group 后再 join reader,不能把孤儿 pipe 留成阻塞。 + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; sleep 5 & exit 0", + )) + .unwrap(); + let started = Instant::now(); + process.initialize().unwrap(); + // `with_client` 会在响应返回后主动 try_wait;若调度尚未让 shell + // 完成退出,下一次操作也会再次探测并拒绝复用已结束连接。 + let deadline = Instant::now() + Duration::from_secs(1); + while !process.is_terminated() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(2)); + } + assert!(process.is_terminated()); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(matches!( + process.initialize(), + Err(CodexError::ProcessTerminated) + )); + process.terminate(); + } + + #[cfg(unix)] + #[test] + fn app_server_process_reaps_nonzero_exit_and_rejects_later_use() { + let process = + CodexAppServerProcess::spawn(process_fixture_config("read -r line; exit 7")).unwrap(); + let error = process.initialize().unwrap_err(); + assert!(matches!(error, CodexError::Exit(7))); + assert!(process.is_terminated()); + assert_eq!(process.exit_code(), Some(7)); + assert!(matches!( + process.initialize(), + Err(CodexError::ProcessTerminated) + )); + } + + #[cfg(unix)] + #[test] + fn app_server_process_timeout_kills_process_group_and_joins_reader() { + let config = process_fixture_config("read -r line; sleep 5 & wait") + .with_timeout(Duration::from_millis(40)) + .unwrap(); + let process = CodexAppServerProcess::spawn(config).unwrap(); + let started = Instant::now(); + let error = process.initialize().unwrap_err(); + assert!(matches!(error, CodexError::Timeout)); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(process.is_terminated()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_cancel_from_another_thread_is_bounded() { + let process = Arc::new( + CodexAppServerProcess::spawn(process_fixture_config("read -r line; sleep 5")).unwrap(), + ); + let worker_process = Arc::clone(&process); + let worker = std::thread::spawn(move || worker_process.initialize()); + let deadline = Instant::now() + Duration::from_secs(1); + while !process.is_terminated() && Instant::now() < deadline { + // cancel 在操作尚未登记时是幂等 no-op;循环只为覆盖线程调度 + // 窗口,确保一旦 initialize 进入阻塞就能立即收束 child。 + process.cancel().unwrap(); + std::thread::sleep(Duration::from_millis(5)); + } + let result = worker.join().expect("app-server worker thread"); + assert!(matches!(result, Err(CodexError::Interrupted))); + assert!(process.is_terminated()); + } + + #[cfg(unix)] + fn process_backend_fixture_config(script: &str) -> CodexAppServerProcessConfig { + CodexAppServerProcessConfig::try_new("sh") + .unwrap() + .with_args(["-c", script]) + .with_allowed_arg_prefixes(["-", "read", "printf", "sleep"]) + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_requires_explicit_initialize() { + let process = + CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 5")).unwrap(); + let backend = CodexAppServerProcessBackend::from_initialized(process); + assert!(matches!( + backend, + Err(CodexError::InvalidConfig(message)) if message.contains("先完成 initialize") + )); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_maps_a_completed_request() { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"ok\":true}}'; sleep 5", + )) + .unwrap(); + let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap(); + let request = + BackendRequest::try_new("process-request", "run-1", "turn/start", json!({"x":1})) + .unwrap(); + let result = backend.invoke(&request).unwrap(); + assert_eq!(result.request_id(), "process-request"); + assert_eq!(result.output(), &json!({"ok":true})); + assert!(result.external_id().is_some_and(|value| { + value.starts_with(&format!("codex-app-server-{}-", backend.instance_nonce)) + })); + assert!(!result.side_effect_unknown()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_runtime_event_bridge_maps_notification_order() { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + // initialize consumes the first request/initialized pair. The + // next request emits one id-less notification before its response; + // the process bridge must forward that notification immediately as + // a NodeEvent instead of hiding it in the polling queue. + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"method\":\"delta\",\"params\":{\"text\":\"hello\"}}'; printf '%s\\n' '{\"id\":2,\"result\":{\"output\":\"done\"}}'", + )) + .unwrap(); + let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap(); + let mut snapshot = mapper_running_snapshot(); + let mut mapper = NodeRuntimeEventMapper::try_new( + snapshot.runtime_id(), + "run-mapper", + snapshot.revision(), + 60, + ) + .unwrap(); + let request = BackendRequest::try_new( + "process-bridge-request", + "run-mapper", + "turn/start", + json!({"prompt":"hi"}), + ) + .unwrap(); + let mut kinds = Vec::new(); + let result = backend + .invoke_with_runtime_events(&request, &mut mapper, &mut |event| { + kinds.push(event.kind()); + apply_event(&mut snapshot, &event).expect("mapped process event should reduce"); + }) + .expect("process runtime event bridge should complete"); + + assert_eq!(result.output(), &json!({"output":"done"})); + assert_eq!( + kinds.len(), + 3, + "request, notification and result are ordered" + ); + assert_eq!(kinds[0], RuntimeEventKind::MessageAppended); + assert_eq!(kinds[1], RuntimeEventKind::MessageAppended); + assert_eq!(kinds[2], RuntimeEventKind::RunCompleted); + assert_eq!(mapper.revision(), 6); + assert_eq!( + snapshot.run("run-mapper").unwrap().status(), + agent_runtime_core::RunStatus::Completed + ); + backend.process().terminate(); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_records_real_thread_and_turn_ids() { + #[derive(Clone)] + struct RecordingSink(Arc>>); + + impl CodexSessionMetadataSink for RecordingSink { + fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError> { + self.0 + .lock() + .expect("metadata sink lock") + .push(metadata.clone()); + Ok(()) + } + } + + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-real\"}}}'; read -r line; printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-real\"}}}'", + )) + .unwrap(); + let persisted = Arc::new(Mutex::new(Vec::new())); + let backend = CodexAppServerProcessBackend::new_initialized(process) + .unwrap() + .with_session_metadata_sink(RecordingSink(Arc::clone(&persisted))); + + let thread = backend + .thread_start(CodexThreadStartParams::default()) + .unwrap(); + assert_eq!(thread.thread_id, "thread-real"); + let turn = backend + .turn_start(CodexTurnStartParams::text("thread-real", "hello").unwrap()) + .unwrap(); + assert_eq!(turn.turn_id, "turn-real"); + + assert_eq!( + backend.session_metadata().unwrap(), + CodexSessionMetadata { + thread_id: Some("thread-real".to_owned()), + turn_id: Some("turn-real".to_owned()), + } + ); + assert_eq!( + *persisted.lock().unwrap(), + vec![ + CodexSessionMetadata { + thread_id: Some("thread-real".to_owned()), + turn_id: None, + }, + CodexSessionMetadata { + thread_id: Some("thread-real".to_owned()), + turn_id: Some("turn-real".to_owned()), + }, + ] + ); + backend.process().terminate(); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_reports_lifecycle_to_sink() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexSessionMetadataSink for LifecycleSink { + fn persist(&self, _metadata: &CodexSessionMetadata) -> Result<(), CodexError> { + Ok(()) + } + + fn persist_lifecycle( + &self, + lifecycle: &CodexSessionLifecycle, + ) -> Result<(), CodexError> { + self.0 + .lock() + .expect("lifecycle sink lock") + .push(lifecycle.clone()); + Ok(()) + } + } + + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"ok\":true}}'; sleep 5", + )) + .unwrap(); + let persisted = Arc::new(Mutex::new(Vec::new())); + let backend = CodexAppServerProcessBackend::new_initialized(process) + .unwrap() + .with_session_metadata_sink(LifecycleSink(Arc::clone(&persisted))); + backend + .record_session_metadata(CodexSessionMetadata { + thread_id: Some("thread-life".to_owned()), + turn_id: Some("turn-life".to_owned()), + }) + .unwrap(); + + let request = + BackendRequest::try_new("lifecycle-request", "run-1", "turn/start", json!({})).unwrap(); + let result = backend.invoke(&request).unwrap(); + let lifecycle = persisted + .lock() + .unwrap() + .last() + .cloned() + .expect("lifecycle should be observed"); + assert_eq!(lifecycle.status, CodexSessionLifecycleStatus::Active); + assert_eq!(lifecycle.metadata.thread_id.as_deref(), Some("thread-life")); + assert_eq!(lifecycle.metadata.turn_id.as_deref(), Some("turn-life")); + assert_eq!(lifecycle.external_id.as_deref(), result.external_id()); + assert_eq!(lifecycle.exit_code, None); + let lifecycle_count = persisted.lock().unwrap().len(); + // A cancel that arrives after the operation has already completed is + // an idempotent no-op and must not overwrite the completed observation. + backend.cancel("lifecycle-request").unwrap(); + assert_eq!(persisted.lock().unwrap().len(), lifecycle_count); + backend.process().terminate(); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_reports_natural_exit_code_on_failure() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexSessionMetadataSink for LifecycleSink { + fn persist(&self, _metadata: &CodexSessionMetadata) -> Result<(), CodexError> { + Ok(()) + } + + fn persist_lifecycle( + &self, + lifecycle: &CodexSessionLifecycle, + ) -> Result<(), CodexError> { + self.0 + .lock() + .expect("lifecycle sink lock") + .push(lifecycle.clone()); + Ok(()) + } + } + + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; exit 7", + )) + .unwrap(); + let persisted = Arc::new(Mutex::new(Vec::new())); + let backend = CodexAppServerProcessBackend::new_initialized(process) + .unwrap() + .with_session_metadata_sink(LifecycleSink(Arc::clone(&persisted))); + backend + .record_session_metadata(CodexSessionMetadata { + thread_id: Some("thread-failure".to_owned()), + turn_id: Some("turn-failure".to_owned()), + }) + .unwrap(); + + let request = + BackendRequest::try_new("failure-request", "run-1", "turn/start", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + let lifecycle = persisted + .lock() + .unwrap() + .last() + .cloned() + .expect("failed lifecycle should be observed"); + assert_eq!(lifecycle.status, CodexSessionLifecycleStatus::Failed); + assert_eq!(lifecycle.exit_code, Some(7)); + } + + #[cfg(unix)] + #[test] + fn app_server_process_lifecycle_sink_emits_natural_exit_once() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexProcessLifecycleSink for LifecycleSink { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + self.0 + .lock() + .expect("process lifecycle sink lock") + .push(event.clone()); + Ok(()) + } + } + + let events = Arc::new(Mutex::new(Vec::new())); + let process = CodexAppServerProcess::spawn(process_fixture_config("read -r line; exit 7")) + .unwrap() + .with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events))); + assert!(matches!(process.initialize(), Err(CodexError::Exit(7)))); + assert!(process.is_terminated()); + assert_eq!( + *events.lock().unwrap(), + vec![CodexProcessLifecycleEvent { + reason: CodexProcessLifecycleReason::NaturalExit, + exit_code: Some(7), + }] + ); + process.terminate(); + assert_eq!(events.lock().unwrap().len(), 1); + } + + #[cfg(unix)] + #[test] + fn app_server_process_lifecycle_sink_emits_explicit_terminate_once() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexProcessLifecycleSink for LifecycleSink { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + self.0 + .lock() + .expect("process lifecycle sink lock") + .push(event.clone()); + Ok(()) + } + } + + let events = Arc::new(Mutex::new(Vec::new())); + let process = CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 5")) + .unwrap() + .with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events))); + process.terminate(); + process.terminate(); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].reason, + CodexProcessLifecycleReason::ExplicitTerminate + ); + assert!(events[0].exit_code.is_some()); + } + + #[cfg(unix)] + #[test] + fn app_server_backend_forwards_process_lifecycle_to_session_sink() { + #[derive(Clone)] + struct SessionSink(Arc>>); + + impl CodexSessionMetadataSink for SessionSink { + fn persist(&self, _metadata: &CodexSessionMetadata) -> Result<(), CodexError> { + Ok(()) + } + + fn persist_process_lifecycle( + &self, + event: &CodexProcessLifecycleEvent, + ) -> Result<(), CodexError> { + self.0 + .lock() + .expect("session process sink lock") + .push(event.clone()); + Ok(()) + } + } + + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; sleep 5", + )) + .unwrap(); + let observed = Arc::new(Mutex::new(Vec::new())); + let backend = CodexAppServerProcessBackend::new_initialized(process) + .unwrap() + .with_session_metadata_sink(SessionSink(Arc::clone(&observed))); + backend.process().terminate(); + let observed = observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!( + observed[0].reason, + CodexProcessLifecycleReason::ExplicitTerminate + ); + } + + #[cfg(unix)] + #[test] + fn app_server_process_lifecycle_sink_emits_drop_once() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexProcessLifecycleSink for LifecycleSink { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + self.0 + .lock() + .expect("process lifecycle sink lock") + .push(event.clone()); + Ok(()) + } + } + + let events = Arc::new(Mutex::new(Vec::new())); + { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 5")) + .unwrap() + .with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events))); + drop(process); + } + let events = events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].reason, CodexProcessLifecycleReason::Drop); + } + + #[cfg(unix)] + #[test] + fn app_server_process_lifecycle_sink_reports_timeout() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexProcessLifecycleSink for LifecycleSink { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + self.0 + .lock() + .expect("process lifecycle sink lock") + .push(event.clone()); + Ok(()) + } + } + + let config = process_fixture_config("read -r line; sleep 5") + .with_timeout(Duration::from_millis(40)) + .unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let process = CodexAppServerProcess::spawn(config) + .unwrap() + .with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events))); + assert!(matches!(process.initialize(), Err(CodexError::Timeout))); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].reason, CodexProcessLifecycleReason::Timeout); + } + + #[cfg(unix)] + #[test] + fn app_server_process_lifecycle_sink_distinguishes_reader_eof() { + #[derive(Clone)] + struct LifecycleSink(Arc>>); + + impl CodexProcessLifecycleSink for LifecycleSink { + fn record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + self.0 + .lock() + .expect("process lifecycle sink lock") + .push(event.clone()); + Ok(()) + } + } + + // Close stdout after the handshake while keeping the child alive. The + // operation cleanup observes EOF and the supervisor must report + // ReaderEof, not a generic explicit termination. + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; exec 1>&-; sleep 5", + )) + .unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let process = process.with_process_lifecycle_sink(LifecycleSink(Arc::clone(&events))); + // The supervisor terminates the still-running child after EOF; the + // initialize call may surface the resulting signal error. + let _ = process.initialize(); + let _ = process.poll_notification(); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].reason, CodexProcessLifecycleReason::ReaderEof); + } + + #[test] + fn backend_instance_nonce_is_unique_for_each_instance() { + let config = CodexCliConfig::try_new("codex").unwrap(); + let first = CodexCliBackend::new(config.clone()).unwrap(); + let second = CodexCliBackend::new(config).unwrap(); + assert_ne!(first.instance_nonce, second.instance_nonce); + assert!(first.instance_nonce.contains('-')); + assert!(second.instance_nonce.contains('-')); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_single_cancel_before_io_is_not_lost() { + // Keep the fixture alive after initialize. The short request timeout + // makes the pre-fix behavior (cancel flag cleared by begin_operation, + // then a blocked request) fail quickly as Timeout instead of hanging a + // test worker indefinitely. + let config = process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; sleep 5", + ) + .with_timeout(Duration::from_millis(100)) + .unwrap(); + let process = CodexAppServerProcess::spawn(config).unwrap(); + process.initialize().unwrap(); + let backend = CodexAppServerProcessBackend::from_initialized(process).unwrap(); + + // This is the exact registration -> begin_operation window: register + // once, cancel once, then enter the process call. No retry loop is + // allowed to hide a lost cancellation. + let reservation = backend.begin_request("pre-io-cancel").unwrap(); + backend.cancel("pre-io-cancel").unwrap(); + let mut handler = RejectingServerRequestHandler; + let result = backend.process().request_with_server_handler_reserved( + reservation, + "turn/start", + json!({}), + &mut handler, + ); + backend.end_request("pre-io-cancel", reservation); + + assert!(matches!(result, Err(CodexError::Interrupted))); + assert!(backend.process().is_terminated()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_rejects_server_request_as_unknown_side_effect() { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{}}'; read -r line; sleep 5", + )) + .unwrap(); + let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap(); + let request = + BackendRequest::try_new("reject-request", "run-1", "turn/start", json!({})).unwrap(); + let error = backend.invoke(&request).unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("server-initiated request")); + assert!(backend.process().is_terminated()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_maps_handler_invalid_config_to_unknown_side_effect() { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{}}'; read -r line; sleep 5", + )) + .unwrap(); + let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap(); + let request = + BackendRequest::try_new("handler-config-request", "run-1", "turn/start", json!({})) + .unwrap(); + let mut handler = |_request: &CodexServerRequest| { + Err(CodexError::InvalidConfig( + "approval policy rejected request".to_owned(), + )) + }; + let error = backend + .invoke_with_handler(&request, &mut handler) + .unwrap_err(); + assert_eq!(error.kind(), ExternalErrorKind::UnknownSideEffect); + assert!(error.message().contains("approval policy rejected request")); + // Handler failure aborts the in-flight response wait; explicitly close + // this fixture so the test never leaves a live child behind. + backend.process().terminate(); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_allows_only_explicit_server_request_handler() { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{}}'; read -r line; printf '%s\\n' '{\"id\":2,\"result\":{\"ok\":true}}'", + )) + .unwrap(); + let backend = CodexAppServerProcessBackend::new_initialized(process).unwrap(); + let request = + BackendRequest::try_new("handler-request", "run-1", "turn/start", json!({})).unwrap(); + let mut handled_method = None; + let result = backend + .invoke_with_handler(&request, &mut |server_request: &CodexServerRequest| { + handled_method = Some(server_request.method().to_owned()); + Ok(CodexServerRequestResponse::result(json!({"approved":true}))) + }) + .unwrap(); + assert_eq!( + handled_method.as_deref(), + Some("item/commandExecution/requestApproval") + ); + assert_eq!(result.output(), &json!({"ok":true})); + assert!(!result.side_effect_unknown()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_backend_cancel_matches_request_and_is_bounded() { + let process = CodexAppServerProcess::spawn(process_backend_fixture_config( + "read -r line; printf '%s\\n' '{\"id\":1,\"result\":{}}'; read -r line; read -r line; sleep 5", + )) + .unwrap(); + let backend = Arc::new(CodexAppServerProcessBackend::new_initialized(process).unwrap()); + let request = + BackendRequest::try_new("cancel-request", "run-1", "turn/start", json!({})).unwrap(); + let worker_backend = Arc::clone(&backend); + let worker = std::thread::spawn(move || worker_backend.invoke(&request)); + let deadline = Instant::now() + Duration::from_secs(1); + while !backend.process().is_terminated() && Instant::now() < deadline { + // Unknown IDs are a bounded no-op; retry the matching cancellation + // until the invoke thread has registered its active request. + backend.cancel("other-request").unwrap(); + backend.cancel("cancel-request").unwrap(); + std::thread::sleep(Duration::from_millis(5)); + } + let result = worker.join().expect("process backend invoke thread"); + assert!(matches!( + result, + Err(error) if error.kind() == ExternalErrorKind::UnknownSideEffect + )); + assert!(backend.process().is_terminated()); + assert!(backend.cancel("cancel-request").is_ok()); + assert!(matches!( + backend.cancel(""), + Err(error) if error.kind() == ExternalErrorKind::InvalidInput + )); + } + + #[cfg(unix)] + #[test] + fn app_server_process_terminate_kills_group_after_child_exit() { + // The shell exits immediately but its background descendant keeps the + // stdout pipe open. Polling `try_wait` observes the already-exited + // child without invoking `reap_if_exited`, so the test exercises the + // terminate() branch that previously skipped process-group cleanup. + let process = Arc::new( + CodexAppServerProcess::spawn(process_backend_fixture_config("sleep 30 & exit 0")) + .unwrap(), + ); + let child_pid = process + .control + .child + .lock() + .unwrap() + .as_ref() + .expect("spawned child") + .id(); + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let exited = process + .control + .child + .lock() + .unwrap() + .as_mut() + .expect("child should remain unreaped") + .try_wait() + .unwrap() + .is_some(); + if exited { + break; + } + assert!(Instant::now() < deadline, "fixture child did not exit"); + std::thread::sleep(Duration::from_millis(2)); + } + + let worker_process = Arc::clone(&process); + let (done_sender, done_receiver) = mpsc::channel(); + let worker = std::thread::spawn(move || { + worker_process.terminate(); + done_sender.send(()).expect("terminate completion receiver"); + }); + + if done_receiver.recv_timeout(Duration::from_secs(2)).is_err() { + // Keep the regression bounded even when run against the old + // implementation: release the descendant, join the worker, then + // fail with an actionable assertion instead of leaking a process. + let _ = terminate_process_group(child_pid); + assert!( + done_receiver.recv_timeout(Duration::from_secs(2)).is_ok(), + "terminate remained blocked after process-group cleanup" + ); + worker.join().expect("terminate worker"); + panic!("terminate did not kill an exited child's process group"); + } + worker.join().expect("terminate worker"); + assert!(process.is_terminated()); + } + + #[cfg(unix)] + #[test] + fn json_rpc_router_dispatches_concurrent_responses_out_of_order() { + use std::os::unix::net::UnixStream; + + let (client, server) = UnixStream::pair().expect("unix stream pair"); + let client_reader = client.try_clone().expect("client reader clone"); + let router = Arc::new(JsonRpcAppServerRouter::new(client_reader, client).unwrap()); + let server_thread = std::thread::spawn(move || { + let mut server_reader = + BufReader::new(server.try_clone().expect("server reader clone")); + let mut server_writer = server; + let mut requests = Vec::new(); + for _ in 0..2 { + let mut line = String::new(); + server_reader + .read_line(&mut line) + .expect("read router request"); + requests.push(serde_json::from_str::(&line).expect("request JSON")); + } + // Reverse the responses to prove the reader routes by ID rather + // than by the order in which requests were written. Derive the + // payload from each method so thread scheduling cannot affect the + // expected result. + for request in requests.into_iter().rev() { + let output = match request["method"].as_str() { + Some("first") => "first", + Some("second") => "second", + other => panic!("unexpected router method: {other:?}"), + }; + writeln!( + server_writer, + "{}", + json!({"jsonrpc":"2.0", "id": request["id"], "result": {"output": output}}) + ) + .expect("write router response"); + } + }); + + let first_router = Arc::clone(&router); + let first = std::thread::spawn(move || { + first_router.request("first", json!({}), Duration::from_secs(1)) + }); + let second_router = Arc::clone(&router); + let second = std::thread::spawn(move || { + second_router.request("second", json!({}), Duration::from_secs(1)) + }); + let first = first.join().expect("first request thread").unwrap(); + let second = second.join().expect("second request thread").unwrap(); + assert_eq!(first["output"], "first"); + assert_eq!(second["output"], "second"); + server_thread.join().expect("router server thread"); + drop(router); + } + + #[cfg(unix)] + #[test] + fn json_rpc_router_mixes_notification_and_response() { + use std::os::unix::net::UnixStream; + + let (client, server) = UnixStream::pair().expect("unix stream pair"); + let client_reader = client.try_clone().expect("client reader clone"); + let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap(); + let server_thread = std::thread::spawn(move || { + let mut server_reader = + BufReader::new(server.try_clone().expect("server reader clone")); + let mut server_writer = server; + let mut line = String::new(); + server_reader + .read_line(&mut line) + .expect("read router request"); + let request = serde_json::from_str::(&line).expect("request JSON"); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "method": "turn/started", + "params": {"turnId": "turn-1"} + }) + ) + .expect("write notification"); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"accepted": true} + }) + ) + .expect("write response"); + }); + + let result = router + .request( + "turn/start", + json!({"threadId": "thread-1"}), + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(result["accepted"], true); + let notification = router + .recv_notification(Duration::from_secs(1)) + .unwrap() + .expect("notification"); + assert_eq!(notification.method, "turn/started"); + assert_eq!(notification.params["turnId"], "turn-1"); + server_thread.join().expect("router server thread"); + } + + #[cfg(unix)] + #[test] + fn json_rpc_router_timeout_releases_bounded_pending_slot() { + use std::os::unix::net::UnixStream; + + let (client, server) = UnixStream::pair().expect("unix stream pair"); + let client_reader = client.try_clone().expect("client reader clone"); + let router = JsonRpcAppServerRouter::with_limits( + client_reader, + client, + DEFAULT_JSON_RPC_FRAME_BYTES, + 1, + DEFAULT_MAX_PENDING_NOTIFICATIONS, + ) + .unwrap(); + let server_thread = std::thread::spawn(move || { + let mut server_reader = + BufReader::new(server.try_clone().expect("server reader clone")); + let mut server_writer = server; + let mut first_line = String::new(); + server_reader + .read_line(&mut first_line) + .expect("read first request"); + // Keep the first response absent. The client timeout must remove + // its entry before this second request is accepted. + std::thread::sleep(Duration::from_millis(80)); + let mut second_line = String::new(); + server_reader + .read_line(&mut second_line) + .expect("read second request"); + let second = serde_json::from_str::(&second_line).expect("second request JSON"); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "id": second["id"], + "result": {"afterTimeout": true} + }) + ) + .expect("write second response"); + }); + + assert!(matches!( + router.request("slow", json!({}), Duration::from_millis(10)), + Err(CodexError::Timeout) + )); + let result = router + .request("after-timeout", json!({}), Duration::from_secs(1)) + .unwrap(); + assert_eq!(result["afterTimeout"], true); + server_thread.join().expect("router server thread"); + } + + #[cfg(unix)] + #[test] + fn json_rpc_router_turn_interrupt_uses_independent_request_and_params() { + use std::os::unix::net::UnixStream; + + let (client, server) = UnixStream::pair().expect("unix stream pair"); + let client_reader = client.try_clone().expect("client reader clone"); + let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap(); + let server_thread = std::thread::spawn(move || { + let mut server_reader = + BufReader::new(server.try_clone().expect("server reader clone")); + let mut server_writer = server; + let mut line = String::new(); + server_reader + .read_line(&mut line) + .expect("read interrupt request"); + let request = serde_json::from_str::(&line).expect("interrupt JSON"); + assert_eq!(request["method"], "turn/interrupt"); + assert_eq!( + request["params"], + json!({"threadId":"thread-42", "turnId":"turn-7"}) + ); + assert!(request["id"].as_str().is_some()); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"interrupted": true} + }) + ) + .expect("write interrupt response"); + }); + + router + .turn_interrupt("thread-42", "turn-7", Duration::from_secs(1)) + .unwrap(); + server_thread.join().expect("router server thread"); + } + + #[cfg(unix)] + #[test] + fn json_rpc_router_rejects_unknown_response_and_fails_closed() { + use std::os::unix::net::UnixStream; + + let (client, server) = UnixStream::pair().expect("unix stream pair"); + let client_reader = client.try_clone().expect("client reader clone"); + let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap(); + let server_thread = std::thread::spawn(move || { + let mut server_reader = + BufReader::new(server.try_clone().expect("server reader clone")); + let mut server_writer = server; + let mut line = String::new(); + server_reader + .read_line(&mut line) + .expect("read router request"); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "id": "not-the-request-id", + "result": {"unexpected": true} + }) + ) + .expect("write unknown response"); + }); + + let error = router + .request("known", json!({}), Duration::from_secs(1)) + .expect_err("unknown response id must fail closed"); + assert!(error.to_string().contains("id 不匹配或重复")); + let terminal = router + .request("after-failure", json!({}), Duration::from_secs(1)) + .expect_err("router must remain terminal after protocol failure"); + assert!(terminal.to_string().contains("id 不匹配或重复")); + server_thread.join().expect("router server thread"); + } + + #[cfg(unix)] + #[test] + fn json_rpc_router_keeps_valid_request_error_scoped_to_one_call() { + use std::os::unix::net::UnixStream; + + let (client, server) = UnixStream::pair().expect("unix stream pair"); + let client_reader = client.try_clone().expect("client reader clone"); + let router = JsonRpcAppServerRouter::new(client_reader, client).unwrap(); + let server_thread = std::thread::spawn(move || { + let mut server_reader = + BufReader::new(server.try_clone().expect("server reader clone")); + let mut server_writer = server; + let mut first_line = String::new(); + server_reader + .read_line(&mut first_line) + .expect("read first request"); + let first = serde_json::from_str::(&first_line).expect("first request JSON"); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "id": first["id"], + "error": {"code": -32000, "message": "busy"} + }) + ) + .expect("write request error"); + + let mut second_line = String::new(); + server_reader + .read_line(&mut second_line) + .expect("read second request"); + let second = serde_json::from_str::(&second_line).expect("second request JSON"); + writeln!( + server_writer, + "{}", + json!({ + "jsonrpc": "2.0", + "id": second["id"], + "result": {"ok": true} + }) + ) + .expect("write second response"); + }); + + let error = router + .request("first", json!({}), Duration::from_secs(1)) + .expect_err("valid JSON-RPC error should reach its request"); + assert!(error.to_string().contains("busy")); + assert_eq!( + router + .request("second", json!({}), Duration::from_secs(1)) + .unwrap()["ok"], + true + ); + server_thread.join().expect("router server thread"); + } + + #[cfg(unix)] + #[test] + fn app_server_process_router_uses_process_control_for_long_lived_wire() { + // This fixture keeps the child alive across two requests and emits a + // notification between them. It exercises the process-backed router, + // rather than only the in-memory UnixStream transport tests above. + let process = CodexAppServerProcessRouter::spawn(process_backend_fixture_config( + r#"read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","method":"turn/started","params":{"turnId":"turn-fixture"}}\n'; printf '{"jsonrpc":"2.0","id":"%s","result":{"accepted":true}}\n' "$id"; read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","id":"%s","result":{"interrupted":true}}\n' "$id""#, + )) + .unwrap(); + let result = process + .request("initialize", json!({}), Duration::from_secs(1)) + .unwrap(); + assert_eq!(result["accepted"], true); + let notification = process + .recv_notification(Duration::from_secs(1)) + .unwrap() + .expect("fixture notification"); + assert_eq!(notification.method, "turn/started"); + process + .turn_interrupt("thread-fixture", "turn-fixture", Duration::from_secs(1)) + .unwrap(); + process.terminate(); + assert!(process.is_terminated()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_router_cancel_wakes_pending_request() { + let process = Arc::new( + CodexAppServerProcessRouter::spawn(process_backend_fixture_config( + "read -r line; sleep 5", + )) + .unwrap(), + ); + let worker_process = Arc::clone(&process); + let worker = std::thread::spawn(move || { + worker_process.request("initialize", json!({}), Duration::from_secs(5)) + }); + std::thread::sleep(Duration::from_millis(20)); + process.cancel().unwrap(); + let result = worker + .join() + .expect("router request worker should join promptly"); + assert!(matches!(result, Err(CodexError::Protocol(message)) if message.contains("取消"))); + assert!(process.is_terminated()); + } + + #[cfg(unix)] + #[test] + fn app_server_process_router_keeps_scoped_remote_error_reusable() { + let process = CodexAppServerProcessRouter::spawn(process_backend_fixture_config( + r#"read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","id":"%s","error":{"code":-32000,"message":"busy"}}\n' "$id"; read -r line; id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p'); printf '{"jsonrpc":"2.0","id":"%s","result":{"ok":true}}\n' "$id""#, + )) + .unwrap(); + let error = process + .request("first", json!({}), Duration::from_secs(1)) + .expect_err("remote JSON-RPC error should reach the caller"); + assert!(error.to_string().contains("busy")); + assert!(!process.is_terminated()); + assert_eq!( + process + .request("second", json!({}), Duration::from_secs(1)) + .unwrap()["ok"], + true + ); + process.terminate(); + } +} diff --git a/rust/crates/agent-codex/【审计】Codex-0.152.1协议适配-2026-09-03.md b/rust/crates/agent-codex/【审计】Codex-0.152.1协议适配-2026-09-03.md new file mode 100644 index 000000000..a16d0c7eb --- /dev/null +++ b/rust/crates/agent-codex/【审计】Codex-0.152.1协议适配-2026-09-03.md @@ -0,0 +1,135 @@ +# Codex 0.152.1 协议适配审计 + +> 本文件按日期追加;测试数字和配置边界以文末“当前配置边界复核”段落为准,前文旧 +> 数字保留为历史审计快照。 + +## 审计范围 + +本记录针对本机 `codex-cli 0.152.1` 的 App Server stdio 接线,范围只覆盖 +`agent-codex`。审计没有使用 API key、登录凭据或网络请求;自动化测试全部使用 +内存 JSON-RPC fixture。`0.152.1` 是本适配器明确固定的版本,不代表其它 Codex +版本兼容。 + +## 本机事实 + +通过本机 CLI 的帮助和 schema 生成入口核对到: + +```text +codex --version +codex-cli 0.152.1 + +codex app-server --help + --stdio + generate-json-schema --out [--experimental] +``` + +使用 `codex app-server generate-json-schema --out --experimental` 得到的 +schema 统计如下: + +| 文件 | 数量 | SHA-256 | +| --- | ---: | --- | +| `ClientRequest.json` | 154 methods | `7443008decd3f978288accbc22da15e18ca20df4aca179c4750f71fdc0d91587` | +| `ServerRequest.json` | 11 methods | `38bb1c9dbb1dda2a688a7c8712b04319fe6ee2ed28b54acd2f5fd341d25567ff` | +| `ServerNotification.json` | 81 methods | `9adaa7f1d3838cf8328026294cee3194297c4f9ebc53bd23a9290fafc16d33c1` | +| `ClientNotification.json` | 1 method | `706cf248d75027c84a3c63348d0ed507182e8eba40069dd17541793de029145a` | +| `codex_app_server_protocol.v2.schemas.json` | 734 definitions | `f9e3ca7e56300b4e5a5686419940ef77bbdc42846760d6a9cd53a21f20dc9ebd` | + +仓库只提交 `fixtures/codex-0.152.1/protocol-manifest.json`,它是上述生成结果的 +来源/hash 和本适配器消费集合的 provenance 清单,不冒充完整 generated Rust schema。 +清单中的 8 个 server-request method 和 6 个 notification method 是本模块当前有 +字段级类型的子集;schema 中另外 3 个 server request(认证 token refresh、attestation、 +current time)及其它通知会落到 `Unknown`/中立 JSON 路径。 + +## 已接线 API + +- `ProcessConfig01521::try_new` 要求调用方先核对 `--version` 输出,只接受精确 + `0.152.1`(也接受常见的 `v0.152.1` 前缀),并固定无 shell 的 + `app-server --stdio` argv。 +- `Client01521` 可复用任意已连接的 reader/writer,提供严格版本子集的 + `initialize`、`thread/start`、`turn/start`、`turn/interrupt` 和显式通知轮询。 +- `AppServer01521` 把同一套 DTO 接到已有有界 `CodexAppServerProcess`;进程的 + deadline、取消、EOF/异常退出回收和 reader/writer join 仍由通用 process facade + 负责。 +- `ServerRequest01521` 解码 command/file approval、user input、MCP elicitation、 + permissions、dynamic tool、apply-patch 和 exec-command 请求; + `ServerRequestHandler01521` 通过 `HandlerBridge` 回写对应 JSON-RPC result。 + handler 由宿主提供,适配器不会自动批准或执行工具;未知 method 必须由宿主显式 + 处理(或返回 `Raw`)。 +- 生成 schema 中的 dynamic tool 有独立的 `namespace` 规格:调用里的 `tool` 是 + namespace 内的子工具名,不是可忽略的备注。Host 两条 bridge 现在支持注入 + `NamespaceToolResolver`;内置 `StaticNamespaceToolResolver` 只接受调用方显式注册的 + `(namespace, tool) -> registered_tool` 映射,命中后仍会重新做工具定义、JSON Schema + 和 approval 校验。缺省或 JSON `null` 的 namespace 继续走全局工具名;未注入 resolver、 + 未知 namespace、未知 namespace 内工具和空 namespace 均在审批/执行前 fail-closed,绝不 + 猜测分隔符或把 namespace 静默拼到全局工具名上。 +- `Notification01521` 提供 agent message delta、thread/turn 生命周期和 item + started/completed 的字段级解析;未知通知保留为中立 `CodexAppServerNotification`。 + +复杂厂商对象(sandbox、permission profile、dynamic tool arguments、thread item) +在这个窄适配器中保持 `serde_json::Value`,避免把完整 734-definition schema 复制进 +通用内核;版本升级时应重新生成 schema、更新清单和对应类型/测试。 + +## 离线回归 + +在 `/data/dsk/Genarrative-master` 执行: + +```text +cargo fmt --manifest-path rust/Cargo.toml --all -- --check +TMPDIR=/var/tmp cargo test --locked --manifest-path rust/Cargo.toml \ + -p agent-codex --all-features --no-fail-fast +RUSTFLAGS='-D warnings' TMPDIR=/var/tmp cargo clippy --locked \ + --manifest-path rust/Cargo.toml -p agent-codex --all-targets --all-features -- -D warnings +``` + +结果:`agent-codex` 64 tests passed(其中 7 个为本版本 adapter 的 manifest、版本 +校验、生命周期、审批/工具 handler、response shape、notification 和 UserInput +回归);Clippy 和 rustfmt 通过。测试没有启动真实 Codex,也没有访问网络。 + +## 明确不做项 + +- 不声称支持任意 Codex CLI/App Server 版本;不会仅因为都标记为 `v2` 就跳过版本 + 清单核对。 +- 不把完整 generated schema、Codex 登录/认证、网络调用或长期会话持久化塞进 + `agent-codex` 的通用内核;外部会话与恢复真相仍由 Host/Runtime 持有。 +- 不自动批准 server request,不自动重连或重放已经发出的请求;进程启动后出现 + 协议/退出/超时错误仍按未知外部副作用交给上层 reconciliation。 + +## 2026-09-03 继续执行补充 + +- `NodeRuntimeEventMapper` 现在提供中立 `NodeRequest`、白名单 `NodeEvent` 和 + `NodeResult` 到 Core `RuntimeEvent` 的连续 revision 映射。显式调用 + `CodexAppServerBackend::invoke_node_with_runtime_events` 时,request、事件和结果会按 + 顺序交给调用方 sink;sink 自己负责 reducer/RuntimeStore 提交,适配器不会隐式修改 + Host 会话或创建第二套状态机。 +- Host 的外部会话 cancel 在当前进程没有 active index 时,会先用持久化 request-id 别名 + 找到原记录;`cancel_persisted`/`AgentHost::cancel_external_request` 可在重开 Host 后 + 显式发出 cancel,并将记录收束为 `cancel_requested`、`cancelled` 或保守的 `unknown`。 + 该路径不重新 invoke,也不把未知副作用自动标记为 safe。 +- 本地新增 bridge/reopen/带外中断回归后,`agent-codex` 为 64 个测试,Host 为 54 个测试;两种 + feature 集合的定向测试和 `-D warnings` Clippy 均通过。证据仍限于本地 fixture;真实 + Codex session、完整 generated wire、自动对账/订阅和远端 CI 未验收。 +- `CodexAppServerBackend::with_interrupt_hook` 是显式 opt-in 的带外控制入口:调用方必须 + 提供不重入同一 channel 的独立 control transport;未配置 hook 时仍走 channel 自带的 + interrupt,不能把同步 channel 的调用误写成可抢占阻塞 I/O。 + +## 2026-09-03 当前配置边界复核 + +- `CodexCliConfig` 与 `CodexAppServerProcessConfig` 的 timeout、output/frame limit 在 + serde 和运行时两层拒绝零值;`with_timeout` 拒绝小于一毫秒的 `Duration`,并使用检查式 + `u64` 毫秒转换拒绝溢出。`timeout()` 不再用 `max(1)` 静默修正无效配置。 +- 新增回归覆盖 camelCase 与 snake_case 配置、公开 struct 直写零值、`u64`/`usize` 解析 + 溢出、子毫秒以及 `Duration` 转换溢出。当前 `agent-codex` 两套 workspace feature + 组合均为 77 个测试通过,Clippy `-D warnings` 与 rustfmt 通过。 +- 这仍是本地配置和受控 fixture 的证据;真实 Codex 发行版 session、完整 generated + wire、远端 CI 和自动外部对账不在本地验收范围。 + +## 2026-09-05 typed 请求边界复核(当前) + +- `InitializeParams01521`/`ClientInfo01521` 现在在 `Client01521` 和 `AppServer01521` + 的 `initialize` 序列化前复验 `clientInfo.name/version`。公开字段或 serde 构造出的 + 非法参数会在触碰 transport/handler 前返回 `InvalidConfig`;`thread/start` 字段全为 + 可选,保持原有空值语义。 +- 新增 `typed_client_revalidates_initialize_before_transport` 回归; + `agent-codex` 当前 81 个测试在 all/no-default workspace 均通过,Clippy `-D warnings` + 与 rustfmt 通过。该证据仍是本地 fixture,不代表完整 generated wire 或真实发行版 + session 已完成。 diff --git a/rust/crates/agent-host/Cargo.toml b/rust/crates/agent-host/Cargo.toml new file mode 100644 index 000000000..8497a3c5c --- /dev/null +++ b/rust/crates/agent-host/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "agent-host" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "通用 Agent 运行时的组件装配与持久化 Host" + +[dependencies] +agent-runtime-core.workspace = true +agent-runtime-engine.workspace = true +agent-runtime-sqlite.workspace = true +agent-codex.workspace = true +agent-provider-openai.workspace = true +agent-provider-fake.workspace = true +agent-mcp.workspace = true +agent-skills.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/rust/crates/agent-host/src/lib.rs b/rust/crates/agent-host/src/lib.rs new file mode 100644 index 000000000..d8b73ef2f --- /dev/null +++ b/rust/crates/agent-host/src/lib.rs @@ -0,0 +1,11536 @@ +//! 通用 Agent Host。 +//! +//! Host 负责把可替换的 Engine 端口组合起来,并把一次运行的观察事件写入 +//! SQLite。它不把业务规则塞进 Core,也不要求调用方了解数据库表结构。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; +#[cfg(test)] +use std::time::{SystemTime, UNIX_EPOCH}; + +use agent_codex::{ + CodexError, CodexProcessLifecycleEvent, CodexProcessLifecycleReason, CodexServerRequest, + CodexServerRequestHandler, CodexServerRequestResponse, CodexSessionLifecycle, + CodexSessionMetadata, CodexSessionMetadataSink, + codex_0_152_1::{ + DynamicToolCallResponse01521, DynamicToolOutput01521, ServerRequest01521, + ServerRequestHandler01521, ServerResponse01521, + }, +}; +use agent_mcp::{ + McpClient, McpError, McpErrorKind, McpServerConfig, McpToolDefinition, McpToolResult, +}; +use agent_provider_fake::FakeProvider; +use agent_provider_openai::{OpenAiProvider, OpenAiProviderConfig}; +use agent_runtime_core::{ + ApprovalDecision, ApprovalPolicy, ApprovalRequest, BackendRequest, ContentPart, ContextError, + ContextErrorKind, ContextItem, ContextRequest, ContextSource, ExtensionError, + ExtensionErrorKind, ExternalBackend, ExternalError, ExternalErrorKind, ExternalObservation, + ExternalObservationRequest, ExternalObservationSource, Message, MessageRole, ModelProvider, + PromptBuilder, ProviderDescriptor, ProviderRegistry, ProviderRegistryError, ProviderTarget, + RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, SkillActivation, SkillSource, SystemClock, + ToolBinding, ToolCall, ToolContext, ToolDefinition, ToolError, ToolErrorKind, ToolExecutor, + ToolOrigin, ToolResult, ToolSource, reduce, +}; +use agent_runtime_engine::{ + AgentEngine, AgentInput, AgentOutput, AllowList, ApprovalResume, Cancellation, + CheckpointListener, ContextCompressor, EchoProvider, EngineCheckpoint, EngineError, + EngineEvent, EventListener, OwnedProviderContextCompressor, validate_tool_arguments, +}; +use agent_runtime_sqlite::{ + ApprovalRecord, CheckpointRecord, EventRecord, ExternalSessionRecord, + MAX_EXTERNAL_SESSION_SCAN_LIMIT, NewApproval, NewCheckpoint, NewEvent, NewExternalSession, + NewToolCall, RunRecord, RuntimeService, RuntimeServiceError, SessionRecord, SqliteStore, + StorageError, ToolCallRecord, WorkerLease, +}; +use agent_skills::{ActivatedSkill, SkillLoader}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum HostError { + #[error("运行引擎错误: {0}")] + Engine(#[from] agent_runtime_engine::EngineError), + #[error("运行时错误: {0}")] + Runtime(#[from] RuntimeServiceError), + #[error("存储错误: {0}")] + Storage(#[from] StorageError), + #[error("外部观察错误: {0}")] + External(#[from] ExternalError), + #[error("配置错误: {0}")] + Config(String), +} + +/// 将 Core 注册表错误保留在 Host 的配置错误边界内。 +/// +/// `HostError` 早期版本只有一个字符串配置分支;这里不新增破坏性的公开 +/// 枚举变体,而是把实例不存在/协议不匹配转换成带稳定关键词的配置错误。 +fn provider_registry_error(error: ProviderRegistryError) -> HostError { + HostError::Config(format!("Provider target 校验失败: {error}")) +} + +impl HostError { + /// CLI worker 用这个稳定谓词把 cooperative cancel 当作正常收口, + /// 其它 Engine 错误仍然以非零退出码暴露给脚本。 + pub fn is_cancelled(&self) -> bool { + matches!(self, Self::Engine(EngineError::Cancelled)) + } +} + +/// Keep the Host-facing error taxonomy stable while Runtime owns the call. +/// RuntimeService wraps adapter failures so its standalone API has one error +/// type; Host still exposes SQLite failures as `HostError::Storage` for callers +/// that used the previous direct storage path. +fn host_error_from_runtime(error: RuntimeServiceError) -> HostError { + match error { + RuntimeServiceError::Storage(error) => HostError::Storage(error), + RuntimeServiceError::External(error) => HostError::External(error), + other => HostError::Runtime(other), + } +} + +/// Host 对外返回的结果,保留数据库 ID 方便后续 resume/inspect。 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HostRunOutput { + pub session_id: String, + pub run_id: String, + pub runtime_id: String, + pub output: AgentOutput, +} + +/// 已创建但尚未执行的 run 身份。CLI 可先持久化它,再交给后台 worker。 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HostRunHandle { + pub session_id: String, + pub run_id: String, + pub runtime_id: String, +} + +/// 一个外部会话 resolver 对单条记录的有界决策。 +/// +/// Completed 必须携带已经由适配器核验过的完整消息历史;Host 只验证 +/// checkpoint 前缀/工具顺序并推进 safe 游标,不会把外部 JSON 当成消息, +/// 也不会再次 invoke。Pending/NotFound 都保留在保守的 `unknown` 状态, +/// 方便下一轮扫描而不把未知副作用误判为成功。 +#[derive(Clone, Debug, PartialEq)] +pub enum ExternalSessionResolution { + Pending { + metadata: Value, + }, + NotFound { + metadata: Value, + }, + Completed { + messages: Vec, + metadata: Value, + }, +} + +/// Host 对外部会话的可替换查询/转换端口。 +/// +/// `request` 仅描述已存在的调用身份;实现可以在内部调用 Provider、MCP +/// 或 Codex 的只读查询接口,但必须把“再次 invoke”视为错误。返回的 +/// `Completed.messages` 必须是对应 checkpoint 的完整历史。 +pub trait ExternalSessionResolver: Send + Sync { + fn resolve( + &self, + record: &ExternalSessionRecord, + request: &ExternalObservationRequest, + ) -> Result; +} + +impl ExternalSessionResolver for F +where + F: Fn( + &ExternalSessionRecord, + &ExternalObservationRequest, + ) -> Result + + Send + + Sync, +{ + fn resolve( + &self, + record: &ExternalSessionRecord, + request: &ExternalObservationRequest, + ) -> Result { + self(record, request) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExternalSessionReconcileOutcome { + Completed, + Pending, + NotFound, + Failed, +} + +/// 单条外部会话对账结果。批量 API 遇到某个 resolver/消息校验失败时会 +/// 继续处理其它候选,并把错误放在该条结果中;候选查询或 SQLite 结构性 +/// 错误仍作为整个 API 的 `HostError` 返回。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ExternalSessionReconcileResult { + pub record_id: String, + pub run_id: Option, + pub outcome: ExternalSessionReconcileOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +const WORKER_LEASE_DURATION: Duration = Duration::from_secs(30); +const WORKER_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Default)] +struct CollectedEvents { + events: Mutex>, + /// The checkpoint listener needs the immediately preceding ToolRequested + /// identity to close the approval boundary before calling user code. The + /// observer is deliberately private and observational; it does not write + /// durable state from the Engine event callback. + pending_tool_calls: Option>>>, +} + +impl CollectedEvents { + fn with_pending_tool_calls(pending_tool_calls: Arc>>) -> Self { + Self { + events: Mutex::new(Vec::new()), + pending_tool_calls: Some(pending_tool_calls), + } + } + + fn snapshot(&self) -> Result, HostError> { + self.events + .lock() + .map(|events| events.clone()) + .map_err(|_| HostError::Config("Engine 事件收集器锁已损坏".to_owned())) + } +} + +impl EventListener for CollectedEvents { + fn on_event(&self, event: &EngineEvent) { + if let Ok(mut events) = self.events.lock() { + events.push(event.clone()); + } + if let (Some(pending), EngineEvent::ToolRequested { call, .. }) = + (&self.pending_tool_calls, event) + && let Ok(mut pending) = pending.lock() + { + pending.insert(call.id().to_owned(), call.clone()); + } + } +} + +/// Host 层 durable 审计事件监听器。 +/// +/// `on_event_committed` 只会在对应的 SQLite `events` 行已经由 +/// `append_event_with_lease` 事务提交后调用。它表示“这条审计事件已落盘”, +/// 不表示整个 run 已经完成:后续 runtime trace、approval 或终态提交仍可能 +/// 失败。监听器只能观察事件;需要改变运行状态时必须调用显式 Host/Runtime +/// command,不能在回调里修改 Host 内部状态。 +pub trait DurableEventListener: Send + Sync { + fn on_event_committed(&self, run_id: &str, revision: i64, event: &EngineEvent); +} + +impl DurableEventListener for F +where + F: Fn(&str, i64, &EngineEvent) + Send + Sync, +{ + fn on_event_committed(&self, run_id: &str, revision: i64, event: &EngineEvent) { + self(run_id, revision, event); + } +} + +/// 把 Engine 的边界检查点写进当前 run 的 fenced lease。 +/// +/// 回调发生在 Provider/工具调用前后,若写入失败就让 Engine 停止;这样 +/// 宿主不会在没有 durable 游标的情况下继续触发外部副作用。 +struct DurableCheckpoints<'a> { + runtime: RuntimeService, + runtime_id: String, + run_id: String, + session_id: String, + lease: WorkerLease, + attempt: i64, + pending_tool_calls: Arc>>, + observed: &'a CollectedEvents, + trace_progress: Mutex, + /// Checkpoints are delivered synchronously, but the listener itself is + /// shared behind `&self`; keep the compaction edge idempotent so a retry + /// cannot append a second Core event for the same compression window. + compaction_open: Mutex, +} + +/// 两个位置都属于本次 Engine 尝试的观察序列,不按内容对历史消息去重。 +/// checkpoint 可以先投影消息,工具结果索引则仍需后续 trace 消费;压缩前 +/// 消费完成的事件位置也要保留,不能在上下文替换后重放旧结果。 +#[derive(Default)] +struct RuntimeTraceProgress { + events_through: usize, + messages_through: usize, +} + +impl CheckpointListener for DurableCheckpoints<'_> { + fn on_checkpoint(&self, checkpoint: &EngineCheckpoint) -> Result<(), String> { + let input = self.new_checkpoint(checkpoint)?; + if checkpoint.phase == agent_runtime_engine::CheckpointPhase::AwaitingApproval + && let Some(call) = self.pending_tool_call(checkpoint.tool_call_id.as_deref())? + { + return self.save_approval_checkpoint(input, call); + } + if checkpoint.phase == agent_runtime_engine::CheckpointPhase::Compacting { + return self.save_compaction_started(input); + } + if self.compaction_is_open()? { + return self.save_compaction_completed(input); + } + self.runtime + .save_checkpoint_with_lease(input, &self.lease) + .map(|_| ()) + .map_err(|error| error.to_string()) + } +} + +impl DurableCheckpoints<'_> { + fn mark_messages_projected(&self) -> Result<(), String> { + let event_count = self + .observed + .events + .lock() + .map_err(|_| "Engine 事件收集器锁已损坏".to_owned())? + .len(); + self.trace_progress + .lock() + .map_err(|_| "Runtime 投影游标锁已损坏".to_owned())? + .messages_through = event_count; + Ok(()) + } + + fn persist_trace( + &self, + snapshot: &mut RuntimeSnapshot, + events: &[EngineEvent], + ) -> Result<(), HostError> { + let mut progress = self + .trace_progress + .lock() + .map_err(|_| HostError::Config("Runtime 投影游标锁已损坏".to_owned()))?; + persist_runtime_trace( + &self.runtime, + snapshot, + &self.runtime_id, + &self.run_id, + &self.lease, + events, + &mut progress, + ) + } + + fn pending_tool_call(&self, call_id: Option<&str>) -> Result, String> { + let Some(call_id) = call_id else { + return Ok(None); + }; + self.pending_tool_calls + .lock() + .map(|pending| pending.get(call_id).cloned()) + .map_err(|_| "工具调用观察器锁已损坏".to_owned()) + } + + fn clear_pending_tool_call(&self, call_id: &str) -> Result<(), String> { + self.pending_tool_calls + .lock() + .map(|mut pending| { + pending.remove(call_id); + }) + .map_err(|_| "工具调用观察器锁已损坏".to_owned()) + } + + /// The first AwaitingApproval checkpoint is the last durable boundary + /// before ApprovalPolicy (and therefore before a possible side effect). + /// Persist the requested tool row, runtime ToolCallRequested event and + /// checkpoint together so a crash cannot leave an approval cursor with no + /// durable call identity. Later checkpoints for an already materialized + /// call keep the ordinary fenced checkpoint path. + fn save_approval_checkpoint(&self, input: NewCheckpoint, call: ToolCall) -> Result<(), String> { + if input.tool_call_id.as_deref() != Some(call.id()) { + return Err("工具调用观察到的 call id 与 checkpoint 不一致".to_owned()); + } + let snapshot = self + .runtime + .load_runtime_snapshot(&self.runtime_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("runtime 中找不到 run: {}", self.run_id))?; + if snapshot.run(&self.run_id).is_some_and(|run| { + run.tool_calls() + .iter() + .any(|existing| existing.id() == call.id()) + }) { + // Resume paths may already have materialized this call in an + // earlier attempt. Do not manufacture a duplicate reducer event. + self.runtime + .save_checkpoint_with_lease(input, &self.lease) + .map(|_| ()) + .map_err(|error| error.to_string())?; + self.clear_pending_tool_call(call.id())?; + return Ok(()); + } + let checkpoint_messages = serde_json::from_value::>(input.messages.clone()) + .map_err(|error| format!("审批 checkpoint 消息无法解码: {error}"))?; + let run = snapshot + .run(&self.run_id) + .ok_or_else(|| format!("runtime 中找不到 run: {}", self.run_id))?; + if run.messages().len() > checkpoint_messages.len() + || run + .messages() + .iter() + .zip(&checkpoint_messages) + .any(|(current, checkpoint)| current != checkpoint) + { + return Err("runtime 消息不是审批 checkpoint 的完整前缀".to_owned()); + } + + // The checkpoint already contains the assistant message carrying the + // model's tool-call batch. Materialize any missing message prefix in + // this same transaction before the first ToolCallRequested event; + // later calls remain only in the checkpoint until their own approval. + let mut next = snapshot.clone(); + let mut runtime_events = Vec::new(); + for message in &checkpoint_messages[run.messages().len()..] { + let event = RuntimeEvent::message_appended( + self.runtime_id.clone(), + next.revision() + 1, + SystemClock.now_millis(), + self.run_id.clone(), + message, + ) + .map_err(|error| error.to_string())?; + next = reduce_runtime_event(&next, &event).map_err(|error| error.to_string())?; + runtime_events.push(event); + } + let event = RuntimeEvent::tool_call_requested( + self.runtime_id.clone(), + next.revision() + 1, + SystemClock.now_millis(), + self.run_id.clone(), + &call, + ) + .map_err(|error| error.to_string())?; + next = reduce_runtime_event(&next, &event).map_err(|error| error.to_string())?; + runtime_events.push(event); + self.runtime + .create_tool_call_with_checkpoint_runtime_and_lease( + NewToolCall { + id: call.id().to_owned(), + session_id: self.session_id.clone(), + run_id: self.run_id.clone(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + input, + &self.lease, + &self.runtime_id, + Some(snapshot.revision()), + &next, + &runtime_events, + ) + .map_err(|error| error.to_string())?; + // 只有联合提交成功,观察序列中的消息才算已经投影。最终 phase + // 会继续变成 tool/provider-in-flight 或 safe,不能拿它作去重凭据。 + self.mark_messages_projected()?; + self.clear_pending_tool_call(call.id())?; + Ok(()) + } + + fn new_checkpoint(&self, checkpoint: &EngineCheckpoint) -> Result { + Ok(NewCheckpoint { + run_id: self.run_id.clone(), + phase: checkpoint.phase.as_str().to_owned(), + step: i64::try_from(checkpoint.step) + .map_err(|_| "checkpoint step 超出 SQLite INTEGER 范围".to_owned())?, + next_step: i64::try_from(checkpoint.next_step) + .map_err(|_| "checkpoint next_step 超出 SQLite INTEGER 范围".to_owned())?, + messages: serde_json::to_value(&checkpoint.messages) + .map_err(|error| format!("检查点消息无法编码: {error}"))?, + provider_request_id: checkpoint.provider_request_id.clone(), + tool_call_id: checkpoint.tool_call_id.clone(), + attempt: self.attempt, + }) + } + + fn compaction_is_open(&self) -> Result { + self.compaction_open + .lock() + .map(|open| *open) + .map_err(|_| "compaction listener 锁已损坏".to_owned()) + } + + fn set_compaction_open(&self, value: bool) -> Result<(), String> { + let mut open = self + .compaction_open + .lock() + .map_err(|_| "compaction listener 锁已损坏".to_owned())?; + *open = value; + Ok(()) + } + + /// Persist the opening edge and its checkpoint together. This prevents a + /// stale running snapshot from being mistaken for a safe cursor after a + /// crash immediately before compression starts. + fn save_compaction_started(&self, input: NewCheckpoint) -> Result<(), String> { + if self.compaction_is_open()? { + return self + .runtime + .save_checkpoint_with_lease(input, &self.lease) + .map(|_| ()) + .map_err(|error| error.to_string()); + } + let runtime_id = self + .runtime + .runtime_id_for_run(&self.run_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("run 缺少 runtime 身份: {}", self.run_id))?; + let mut snapshot = self + .runtime + .load_runtime_snapshot(&runtime_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("runtime 中找不到 run: {}", self.run_id))?; + // 压缩将替换消息及派生工具索引。先完成旧上下文的事件/工具行, + // 再进入 Compacting;否则收尾 trace 会把已压缩的旧消息重新追加。 + let observed = self + .observed + .snapshot() + .map_err(|error| error.to_string())?; + self.persist_trace(&mut snapshot, &observed) + .map_err(|error| error.to_string())?; + if snapshot + .run(&self.run_id) + .is_some_and(|run| run.status() == agent_runtime_core::RunStatus::Compacting) + { + self.set_compaction_open(true)?; + self.runtime + .save_checkpoint_with_lease(input, &self.lease) + .map(|_| ()) + .map_err(|error| error.to_string())?; + return Ok(()); + } + let event = RuntimeEvent::compaction_started( + snapshot.runtime_id().to_owned(), + snapshot.revision() + 1, + SystemClock.now_millis(), + self.run_id.clone(), + ) + .map_err(|error| error.to_string())?; + let next = reduce_runtime_event(&snapshot, &event).map_err(|error| error.to_string())?; + self.runtime + .save_checkpoint_with_runtime_and_lease( + input, + &self.lease, + snapshot.runtime_id(), + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + ) + .map_err(|error| error.to_string())?; + self.set_compaction_open(true)?; + Ok(()) + } + + /// Atomically rewrite the Core message context, close the compaction + /// state, and save the first post-compression checkpoint. The checkpoint + /// is the source of truth for the exact compressed history; the Engine + /// event remains observational and is deliberately not re-applied later. + fn save_compaction_completed(&self, input: NewCheckpoint) -> Result<(), String> { + let runtime_id = self + .runtime + .runtime_id_for_run(&self.run_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("run 缺少 runtime 身份: {}", self.run_id))?; + let snapshot = self + .runtime + .load_runtime_snapshot(&runtime_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("runtime 中找不到 run: {}", self.run_id))?; + let Some(run) = snapshot.run(&self.run_id) else { + return Err(format!("runtime 中找不到 run: {}", self.run_id)); + }; + if run.status() != agent_runtime_core::RunStatus::Compacting { + // A retry after a successfully committed atomic edge only needs + // the ordinary checkpoint write; never append duplicate events. + self.set_compaction_open(false)?; + return self + .runtime + .save_checkpoint_with_lease(input, &self.lease) + .map(|_| ()) + .map_err(|error| error.to_string()); + } + let messages = serde_json::from_value::>(input.messages.clone()) + .map_err(|error| format!("压缩 checkpoint 消息无效: {error}"))?; + let context_event = RuntimeEvent::context_compacted( + snapshot.runtime_id().to_owned(), + snapshot.revision() + 1, + SystemClock.now_millis(), + self.run_id.clone(), + usize::try_from(input.step).map_err(|_| "压缩 step 无效".to_owned())?, + &messages, + ) + .map_err(|error| error.to_string())?; + let compacted = + reduce_runtime_event(&snapshot, &context_event).map_err(|error| error.to_string())?; + let completed_event = RuntimeEvent::compaction_completed( + compacted.runtime_id().to_owned(), + compacted.revision() + 1, + SystemClock.now_millis(), + self.run_id.clone(), + ) + .map_err(|error| error.to_string())?; + let next = reduce_runtime_event(&compacted, &completed_event) + .map_err(|error| error.to_string())?; + let events = [context_event, completed_event]; + self.runtime + .save_checkpoint_with_runtime_and_lease( + input, + &self.lease, + snapshot.runtime_id(), + Some(snapshot.revision()), + &next, + &events, + ) + .map_err(|error| error.to_string())?; + self.set_compaction_open(false)?; + Ok(()) + } +} + +/// 一个最小、可扩展的工具路由器。注册表只负责按名称分发,不授予权限。 +#[derive(Clone, Default)] +pub struct ToolRouter { + definitions: Vec, + executors: BTreeMap>, + origins: BTreeMap, +} + +impl ToolRouter { + pub fn new() -> Self { + Self::default() + } + + pub fn register( + &mut self, + definition: ToolDefinition, + executor: Arc, + ) -> Result<(), HostError> { + // ToolRouter is a public registration boundary; a definition decoded + // from serde must not become selectable merely because its name is + // unique. Engine repeats the check at run time as a second boundary. + definition + .validate() + .map_err(|error| HostError::Config(format!("工具定义无效: {error}")))?; + if self.executors.contains_key(definition.name()) { + return Err(HostError::Config(format!( + "工具重复: {}", + definition.name() + ))); + } + self.executors + .insert(definition.name().to_owned(), executor); + self.origins + .insert(definition.name().to_owned(), ToolOrigin::Local); + self.definitions.push(definition); + Ok(()) + } + + /// 直接注册一个已经带有来源信息的工具;来源用于审计,执行仍由 policy 控制。 + pub fn register_binding( + &mut self, + binding: ToolBinding, + executor: Arc, + ) -> Result<(), HostError> { + binding + .validate() + .map_err(|error| HostError::Config(format!("工具绑定无效: {error}")))?; + let name = binding.tool().name().to_owned(); + let origin = binding.origin().clone(); + self.register(binding.tool().clone(), executor)?; + self.origins.insert(name, origin); + Ok(()) + } + + pub fn definitions(&self) -> &[ToolDefinition] { + &self.definitions + } + + pub fn origin(&self, tool_name: &str) -> Option<&ToolOrigin> { + self.origins.get(tool_name) + } +} + +impl ToolExecutor for ToolRouter { + fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { + // Router is also a public Host port used by Codex server-request + // handlers; do not rely on the normal Engine input validation path. + call.validate()?; + context.validate()?; + let Some(executor) = self.executors.get(call.name()) else { + return Err(ToolError::new( + ToolErrorKind::NotFound, + format!("未注册工具: {}", call.name()), + )); + }; + executor.execute(call, context) + } +} + +/// Namespace 到 Host 工具名的显式解析错误。 +/// +/// namespace 只是一段 wire 元数据,不能靠拼接分隔符猜出实际注册名。 +/// resolver 通过这个错误把“没有声明映射”和“映射目标不存在”分开, +/// 让调用方在进入审批/执行前就能 fail-closed。 +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum NamespaceToolResolverError { + #[error("namespace 不能为空")] + EmptyNamespace, + #[error("namespace 工具名不能为空")] + EmptyTool, + #[error("namespace 未注册: {0}")] + UnknownNamespace(String), + #[error("namespace 工具映射不存在: {namespace}/{tool}")] + UnknownTool { namespace: String, tool: String }, + #[error("namespace 工具映射目标不能为空")] + EmptyTarget, + #[error("namespace 工具映射冲突: {namespace}/{tool} 已指向 {existing}, 不能改为 {requested}")] + Conflict { + namespace: String, + tool: String, + existing: String, + requested: String, + }, +} + +/// 将 wire namespace/tool 映射到 `ToolRouter` 中已经注册的全局工具名。 +/// +/// 解析器不持有工具执行器,也不授予权限;返回的目标名仍会由 Host +/// 重新查找 definition、校验 JSON Schema,并交给 ApprovalPolicy。这样同一 +/// 个 wire tool 可以在多个 namespace 下指向不同的工具,且未知 namespace +/// 不会因为某个全局同名工具而被意外放行。 +pub trait NamespaceToolResolver: Send + Sync { + fn resolve_tool( + &self, + namespace: &str, + tool: &str, + ) -> Result; +} + +/// 一个无动态状态的显式 namespace 映射表,适合 Host 装配和测试。 +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct StaticNamespaceToolResolver { + mappings: BTreeMap<(String, String), String>, +} + +impl StaticNamespaceToolResolver { + pub fn new() -> Self { + Self::default() + } + + /// 注册 `(namespace, wire_tool) -> registered_tool` 映射。 + /// + /// 同一映射重复注册为幂等;尝试把它改到另一个目标则拒绝,避免 + /// 装配顺序悄悄改变审批绑定。空 namespace、空工具名和空目标都无效。 + pub fn register( + &mut self, + namespace: impl Into, + tool: impl Into, + target: impl Into, + ) -> Result<(), NamespaceToolResolverError> { + let namespace = namespace.into(); + let tool = tool.into(); + let target = target.into(); + validate_namespace_mapping_parts(&namespace, &tool, &target)?; + let key = (namespace.clone(), tool.clone()); + if let Some(existing) = self.mappings.get(&key) { + if existing == &target { + return Ok(()); + } + return Err(NamespaceToolResolverError::Conflict { + namespace, + tool, + existing: existing.clone(), + requested: target, + }); + } + self.mappings.insert(key, target); + Ok(()) + } + + /// 链式注册单条映射。 + pub fn with_mapping( + mut self, + namespace: impl Into, + tool: impl Into, + target: impl Into, + ) -> Result { + self.register(namespace, tool, target)?; + Ok(self) + } + + pub fn len(&self) -> usize { + self.mappings.len() + } + + pub fn is_empty(&self) -> bool { + self.mappings.is_empty() + } +} + +fn validate_namespace_mapping_parts( + namespace: &str, + tool: &str, + target: &str, +) -> Result<(), NamespaceToolResolverError> { + if namespace.trim().is_empty() { + return Err(NamespaceToolResolverError::EmptyNamespace); + } + if tool.trim().is_empty() { + return Err(NamespaceToolResolverError::EmptyTool); + } + if target.trim().is_empty() { + return Err(NamespaceToolResolverError::EmptyTarget); + } + Ok(()) +} + +impl NamespaceToolResolver for StaticNamespaceToolResolver { + fn resolve_tool( + &self, + namespace: &str, + tool: &str, + ) -> Result { + if namespace.trim().is_empty() { + return Err(NamespaceToolResolverError::EmptyNamespace); + } + if tool.trim().is_empty() { + return Err(NamespaceToolResolverError::EmptyTool); + } + let namespace_key = namespace.to_owned(); + let tool_key = tool.to_owned(); + self.mappings + .get(&(namespace_key.clone(), tool_key.clone())) + .cloned() + .ok_or_else(|| { + if self + .mappings + .keys() + .any(|(registered_namespace, _)| registered_namespace == namespace) + { + NamespaceToolResolverError::UnknownTool { + namespace: namespace_key, + tool: tool_key, + } + } else { + NamespaceToolResolverError::UnknownNamespace(namespace_key) + } + }) + } +} + +/// 允许把已经放在 `Arc` 中的 resolver 继续注入 Host/handler。 +impl NamespaceToolResolver for Arc +where + T: NamespaceToolResolver + ?Sized, +{ + fn resolve_tool( + &self, + namespace: &str, + tool: &str, + ) -> Result { + (**self).resolve_tool(namespace, tool) + } +} + +fn default_namespace_tool_resolver() -> Arc { + Arc::new(StaticNamespaceToolResolver::new()) +} + +/// 从 optional JSON namespace 和 wire tool 名解析 Host 实际工具名。 +/// +/// 缺省或 JSON `null` 表示普通全局工具调用;任何非字符串 namespace 都 +/// 是格式错误;字符串 namespace 必须由显式 resolver 命中。这里不拼接、 +/// 不裁剪、也不把空字符串当作缺省值。 +fn resolve_dynamic_tool_name( + resolver: &dyn NamespaceToolResolver, + namespace: Option<&Value>, + tool: &str, +) -> Result { + match namespace { + None | Some(Value::Null) => Ok(tool.to_owned()), + Some(Value::String(namespace)) => { + if namespace.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex dynamic tool namespace 不能为空".to_owned(), + )); + } + resolver.resolve_tool(namespace, tool).map_err(|error| { + CodexError::InvalidConfig(format!("Codex dynamic tool namespace 解析失败: {error}")) + }) + } + Some(_) => Err(CodexError::InvalidConfig( + "Codex dynamic tool namespace 必须是字符串或 null".to_owned(), + )), + } +} + +fn resolve_dynamic_tool_name_typed( + resolver: &dyn NamespaceToolResolver, + namespace: Option<&str>, + tool: &str, +) -> Result { + namespace + .map(|namespace| { + if namespace.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex dynamic tool namespace 不能为空".to_owned(), + )); + } + resolver.resolve_tool(namespace, tool).map_err(|error| { + CodexError::InvalidConfig(format!("Codex dynamic tool namespace 解析失败: {error}")) + }) + }) + .unwrap_or_else(|| Ok(tool.to_owned())) +} + +/// Host 对 Codex App Server server-request 的中立接线。 +/// +/// 这个 handler 只处理中立的 `item/tool/call` 请求:先把参数解码为 Core +/// `ToolCall`,再经过已有 `ApprovalPolicy`,最后交给同一个 `ToolRouter`。 +/// 输入同时兼容旧的 `name` 和已审计 Codex 0.152.1 的 `tool` 字段(两者同时 +/// 出现时必须一致)。返回值仍保持本 handler 的中立 +/// `callId`/`output`/`isError` 形状;需要 Codex 0.152.1 的 +/// `contentItems`/`success` response 时,请使用下面明确命名的 typed handler, +/// 因而这里不会被误解为完整版本适配器。`from_host` 构造的实例还会把 +/// 工具调用写入现有 Runtime 的 `tool_calls` 表,`new` 构造则保持无持久化。 +/// 未知 method、拒绝/询问和参数错误都返回 JSON-RPC error,不会因为 Codex +/// 请求来自 server 端就自动放行,也不会创建第二套 session 或 durable run。 +/// 显式 namespace 只有在 Host 注入的 `NamespaceToolResolver` 命中后才会路由。 +pub struct CodexHostServerRequestHandler { + tools: Arc, + namespace_resolver: Arc, + approval: Arc, + context: ToolContext, + /// 只有 `from_host` 注入 Runtime;`new` 保持原来的无持久化行为。 + runtime: Option, +} + +impl std::fmt::Debug for CodexHostServerRequestHandler { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CodexHostServerRequestHandler") + .field("tool_count", &self.tools.definitions().len()) + .field("session_id", &self.context.session_id()) + .field("run_id", &self.context.run_id()) + .finish_non_exhaustive() + } +} + +impl CodexHostServerRequestHandler { + /// 使用 Host 当前已经装配好的 Router/ApprovalPolicy 创建 handler。 + /// `ToolContext` 中的 run/session 身份用于审批绑定、工具执行和同一 run + /// 的 durable tool-call 记录;不会在这里创建新的 session/run。 + pub fn from_host(host: &AgentHost, context: ToolContext) -> Self { + Self { + tools: host.tools.clone(), + namespace_resolver: host.namespace_resolver.clone(), + approval: host.approval.clone(), + context, + runtime: Some(host.runtime.clone()), + } + } + + /// 允许其它 Host-like 装配层显式提供同一组 Core 端口。 + pub fn new( + tools: Arc, + approval: Arc, + context: ToolContext, + ) -> Self { + Self::new_with_namespace_resolver( + tools, + approval, + context, + default_namespace_tool_resolver(), + ) + } + + /// 显式注入 namespace resolver;resolver 只负责名称映射,不负责权限。 + pub fn new_with_namespace_resolver( + tools: Arc, + approval: Arc, + context: ToolContext, + namespace_resolver: R, + ) -> Self + where + R: NamespaceToolResolver + 'static, + { + Self { + tools, + namespace_resolver: Arc::new(namespace_resolver), + approval, + context, + runtime: None, + } + } + + pub fn context(&self) -> &ToolContext { + &self.context + } + + fn request_id(request: &CodexServerRequest) -> Result { + match request.id() { + serde_json::Value::String(value) if !value.trim().is_empty() => Ok(value.clone()), + serde_json::Value::Number(value) => Ok(value.to_string()), + value => Err(CodexError::InvalidConfig(format!( + "Codex server request id 无法绑定工具调用: {value}" + ))), + } + } + + fn parse_tool_call(&self, request: &CodexServerRequest) -> Result { + let params = request.params().as_object().ok_or_else(|| { + CodexError::InvalidConfig("item/tool/call params 必须是 JSON 对象".to_owned()) + })?; + let wire_name = tool_name_alias(params)?; + let name = resolve_dynamic_tool_name( + self.namespace_resolver.as_ref(), + params.get("namespace"), + &wire_name, + )?; + let arguments = params.get("arguments").ok_or_else(|| { + CodexError::InvalidConfig("item/tool/call params 缺少 arguments".to_owned()) + })?; + let call_id = ["callId", "toolCallId", "id"] + .iter() + .find_map(|field| params.get(*field).and_then(serde_json::Value::as_str)) + .map(ToOwned::to_owned) + .unwrap_or_else(|| Self::request_id(request).unwrap_or_default()); + + let call = if let Some(arguments) = arguments.as_str() { + ToolCall::from_json_text(call_id, name, arguments) + } else { + ToolCall::try_new(call_id, name, arguments.clone()) + }; + call.map_err(|error| CodexError::InvalidConfig(format!("item/tool/call 参数无效: {error}"))) + } + + fn error(code: i64, message: impl Into) -> CodexServerRequestResponse { + CodexServerRequestResponse::error(code, message) + } +} + +/// 解析中立 handler 和 0.152.1 typed handler 共用的工具名别名。 +/// +/// 0.152.1 dynamic-tool wire 使用 `tool`,旧的中立 fixture 使用 `name`。 +/// 两个字段若同时存在却不相同,必须在审批和路由前拒绝,避免调用方看到的 +/// 名称与实际执行的名称分裂。`namespace` 不通过分隔符拼接;需要 namespaced +/// 调用时,Host 只使用 `NamespaceToolResolver` 的显式映射。猜测分隔符会把 +/// 一个合法工具重写到另一个权限条目。带 namespace 的请求只有在 resolver +/// 显式命中后才会继续。 +fn tool_name_alias(params: &serde_json::Map) -> Result { + fn string_field<'a>( + params: &'a serde_json::Map, + field: &str, + ) -> Result, CodexError> { + match params.get(field) { + None => Ok(None), + Some(value) => value.as_str().map(Some).ok_or_else(|| { + CodexError::InvalidConfig(format!("item/tool/call params {field} 必须是字符串")) + }), + } + } + + let name = string_field(params, "name")?; + let tool = string_field(params, "tool")?; + match (name, tool) { + (Some(name), Some(tool)) if name != tool => Err(CodexError::InvalidConfig( + "item/tool/call params 的 name 与 tool 不一致".to_owned(), + )), + (Some(name), _) => Ok(name.to_owned()), + (_, Some(tool)) => Ok(tool.to_owned()), + (None, None) => Err(CodexError::InvalidConfig( + "item/tool/call params 缺少字符串 name 或 tool".to_owned(), + )), + } +} + +/// 直接从 Host 进入的 Codex server-request 也要共享 Engine 的 durable +/// tool-call 记录。没有对应的 durable run 时旁路,保留旧 `new`/fixture +/// 用法;`from_host` 的正常运行路径会在插入前校验 session/run 归属。 +enum CodexDurableToolCallAction { + Execute, + Cached(ToolResult), + InFlight, +} + +fn begin_codex_durable_tool_call( + runtime: Option<&RuntimeService>, + context: &ToolContext, + call: &ToolCall, +) -> Result { + let Some(runtime) = runtime else { + return Ok(CodexDurableToolCallAction::Execute); + }; + let (Some(session_id), Some(run_id)) = (context.session_id(), context.run_id()) else { + // Older direct handlers accepted a context containing only run_id (or + // neither ID in tests). There is no valid SQLite foreign-key identity + // to persist in that shape, so keep the compatibility path unchanged. + return Ok(CodexDurableToolCallAction::Execute); + }; + let Some(run) = runtime + .get_run(run_id) + .map_err(|error| CodexError::Protocol(format!("读取 Codex durable run 失败: {error}")))? + else { + return Ok(CodexDurableToolCallAction::Execute); + }; + if run.session_id != session_id { + return Err(CodexError::InvalidConfig(format!( + "Codex tool call 的 session_id 与 run 不匹配: {}", + call.id() + ))); + } + + let (record, existed) = match runtime + .get_tool_call(call.id()) + .map_err(|error| CodexError::Protocol(format!("读取 Codex tool call 失败: {error}")))? + { + Some(record) => (record, true), + None => ( + runtime + .create_tool_call(NewToolCall { + id: call.id().to_owned(), + session_id: session_id.to_owned(), + run_id: run_id.to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }) + .map_err(|error| { + CodexError::Protocol(format!("创建 Codex tool call 记录失败: {error}")) + })?, + false, + ), + }; + + if record.session_id != session_id + || record.run_id != run_id + || record.tool_name != call.name() + || record.arguments != *call.arguments() + { + return Err(CodexError::InvalidConfig(format!( + "Codex tool call identity 已存在但内容不一致: {}", + call.id() + ))); + } + match record.status.as_str() { + "completed" | "error" | "failed" | "cancelled" | "canceled" => { + let output = record.result.ok_or_else(|| { + CodexError::Protocol(format!("Codex tool call 终态记录缺少结果: {}", call.id())) + })?; + let result = ToolResult::try_new(call.id(), output, record.status != "completed") + .map_err(|error| { + CodexError::Protocol(format!("Codex tool call 缓存结果无效: {error}")) + })?; + Ok(CodexDurableToolCallAction::Cached(result)) + } + _ if existed => Ok(CodexDurableToolCallAction::InFlight), + _ => Ok(CodexDurableToolCallAction::Execute), + } +} + +fn complete_codex_durable_tool_call( + runtime: Option<&RuntimeService>, + call: &ToolCall, + result: &ToolResult, +) -> Result<(), CodexError> { + let Some(runtime) = runtime else { + return Ok(()); + }; + // A compatibility handler without a durable row is intentionally a no-op; + // `begin_codex_durable_tool_call` already gated this path on a valid run. + if runtime + .get_tool_call(call.id()) + .map_err(|error| CodexError::Protocol(format!("读取 Codex tool call 记录失败: {error}")))? + .is_none() + { + return Ok(()); + } + runtime + .complete_tool_call( + call.id(), + if result.is_error() { + "error" + } else { + "completed" + }, + result.output().clone(), + ) + .map_err(|error| CodexError::Protocol(format!("收束 Codex tool call 失败: {error}")))?; + Ok(()) +} + +fn fail_codex_durable_tool_call( + runtime: Option<&RuntimeService>, + call: &ToolCall, + error: &ToolError, +) -> Result<(), CodexError> { + let Some(runtime) = runtime else { + return Ok(()); + }; + if runtime + .get_tool_call(call.id()) + .map_err(|runtime_error| { + CodexError::Protocol(format!("读取 Codex tool call 记录失败: {runtime_error}")) + })? + .is_none() + { + return Ok(()); + } + runtime + .complete_tool_call(call.id(), "error", json!({"error": error.to_string()})) + .map_err(|runtime_error| { + CodexError::Protocol(format!( + "收束 Codex tool call 错误状态失败: {runtime_error}" + )) + })?; + Ok(()) +} + +impl CodexServerRequestHandler for CodexHostServerRequestHandler { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + if request.kind() != agent_codex::CodexServerRequestKind::ToolCall { + return Ok(Self::error( + -32601, + format!("Host 不支持 Codex server request: {}", request.method()), + )); + } + + if let Err(error) = self.context.validate() { + return Ok(Self::error(-32602, error.to_string())); + } + if self.context.is_cancelled() { + return Ok(Self::error( + -32800, + "Codex tool call 已取消;Host 不会触发审批或工具执行", + )); + } + + let call = match self.parse_tool_call(request) { + Ok(call) => call, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + // Server requests enter Host below the normal Engine loop, so repeat + // the same Core JSON-Schema gate before approval or any side effect. + // An approval decision must never be used to bless malformed args. + let Some(definition) = self + .tools + .definitions() + .iter() + .find(|definition| definition.name() == call.name()) + else { + return Ok(Self::error(-32602, format!("未注册工具: {}", call.name()))); + }; + if let Err(error) = validate_tool_arguments(&call, definition) { + return Ok(Self::error(-32602, error.to_string())); + } + + let approval_id = match Self::request_id(request) { + Ok(id) => id, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + let run_id = self.context.run_id().ok_or_else(|| { + CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) + })?; + let approval = ApprovalRequest::try_new(approval_id, run_id.to_owned(), call.clone()) + .map_err(|error| { + CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) + })?; + let decision = self.approval.decide(&approval).map_err(|error| { + CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")) + })?; + match decision { + ApprovalDecision::Allow => {} + ApprovalDecision::Deny { reason } => { + return Ok(Self::error( + -32001, + format!("Codex tool call 被 Host 拒绝: {reason}"), + )); + } + ApprovalDecision::Ask => { + return Ok(Self::error( + -32002, + "Codex tool call 需要外部审批;Host 不会在此 handler 中自动等待或放行", + )); + } + } + + match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { + CodexDurableToolCallAction::Cached(result) => { + return Ok(CodexServerRequestResponse::result(json!({ + "callId": result.call_id(), + "output": result.output(), + "isError": result.is_error(), + }))); + } + CodexDurableToolCallAction::InFlight => { + return Ok(Self::error( + -32000, + format!("Codex tool call 正在执行,拒绝重复 call_id: {}", call.id()), + )); + } + CodexDurableToolCallAction::Execute => {} + } + + let result = match self.tools.execute(&call, &self.context) { + Ok(result) => result, + Err(error) => { + fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; + return Err(CodexError::Protocol(format!( + "Codex tool call 执行失败: {error}" + ))); + } + }; + complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; + Ok(CodexServerRequestResponse::result(json!({ + "callId": result.call_id(), + "output": result.output(), + "isError": result.is_error(), + }))) + } +} + +/// Codex CLI 0.152.1 dynamic-tool 的 Host 侧窄 typed bridge。 +/// +/// 这个类型只消费 `agent_codex::codex_0_152_1::ServerRequest01521` 中的 +/// `item/tool/call` 变体:请求字段是审计过的 `tool`/`callId`,成功或工具自身 +/// 失败都按该版本的 `contentItems`/`success` 结果形状返回。其它 typed +/// server-request 仍返回 JSON-RPC `-32601`。它复用 Host 的 schema gate、审批 +/// 和工具路由,不创建第二套 session、run、checkpoint 或 durable approval。 +/// +/// Core `ToolResult::output` 可以是任意 JSON,而 0.152.1 dynamic-tool 的窄 +/// response 只定义 text/image/audio content item。为避免猜测业务 JSON 的媒体 +/// 语义,这个 bridge 将 output 序列化为一个 `inputText`;需要更丰富的媒体 +/// 映射时应由版本化上层 adapter 明确转换,而不是把本类型当成完整 Codex +/// generated-schema 实现。 +/// 显式 namespace 复用同一个 `NamespaceToolResolver`,未命中时保持 fail-closed。 +pub struct Codex01521HostServerRequestHandler { + tools: Arc, + namespace_resolver: Arc, + approval: Arc, + context: ToolContext, + /// 只有 `from_host` 注入 Runtime;`new` 保持原来的无持久化行为。 + runtime: Option, +} + +impl std::fmt::Debug for Codex01521HostServerRequestHandler { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Codex01521HostServerRequestHandler") + .field("tool_count", &self.tools.definitions().len()) + .field("session_id", &self.context.session_id()) + .field("run_id", &self.context.run_id()) + .finish_non_exhaustive() + } +} + +impl Codex01521HostServerRequestHandler { + /// 使用 Host 当前已经装配好的 Router/ApprovalPolicy 创建 typed bridge; + /// `from_host` 同时启用同一 run 的 durable tool-call 记录。 + pub fn from_host(host: &AgentHost, context: ToolContext) -> Self { + Self { + tools: host.tools.clone(), + namespace_resolver: host.namespace_resolver.clone(), + approval: host.approval.clone(), + context, + runtime: Some(host.runtime.clone()), + } + } + + /// 允许其它 Host-like 装配层显式提供同一组 Core 端口。 + pub fn new( + tools: Arc, + approval: Arc, + context: ToolContext, + ) -> Self { + Self::new_with_namespace_resolver( + tools, + approval, + context, + default_namespace_tool_resolver(), + ) + } + + /// 显式注入 namespace resolver;typed wire 和中立 wire 共用同一映射。 + pub fn new_with_namespace_resolver( + tools: Arc, + approval: Arc, + context: ToolContext, + namespace_resolver: R, + ) -> Self + where + R: NamespaceToolResolver + 'static, + { + Self { + tools, + namespace_resolver: Arc::new(namespace_resolver), + approval, + context, + runtime: None, + } + } + + pub fn context(&self) -> &ToolContext { + &self.context + } + + fn error(code: i64, message: impl Into) -> CodexServerRequestResponse { + CodexServerRequestResponse::error(code, message) + } + + fn dynamic_result(result: &ToolResult) -> Value { + let text = match result.output() { + Value::String(text) => text.clone(), + output => output.to_string(), + }; + json!({ + "contentItems": [{"type": "inputText", "text": text}], + "success": !result.is_error(), + }) + } + + fn dynamic_typed_result(result: &ToolResult) -> DynamicToolCallResponse01521 { + let text = match result.output() { + Value::String(text) => text.clone(), + output => output.to_string(), + }; + DynamicToolCallResponse01521 { + content_items: vec![DynamicToolOutput01521::Text(text)], + success: !result.is_error(), + } + } +} + +impl CodexServerRequestHandler for Codex01521HostServerRequestHandler { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + if request.kind() != agent_codex::CodexServerRequestKind::ToolCall { + return Ok(Self::error( + -32601, + format!( + "Codex 0.152.1 typed Host 不支持 server request: {}", + request.method() + ), + )); + } + + if let Err(error) = self.context.validate() { + return Ok(Self::error(-32602, error.to_string())); + } + if self.context.is_cancelled() { + return Ok(Self::error( + -32800, + "Codex 0.152.1 dynamic tool 已取消;typed Host 不会触发审批或工具执行", + )); + } + + // Validate optional legacy `name` before the typed decoder so an extra + // alias cannot silently disagree with the audited `tool` field. The + // versioned decoder then enforces all required 0.152.1 fields. + let raw_params = request.params().as_object().ok_or_else(|| { + CodexError::InvalidConfig("item/tool/call params 必须是 JSON 对象".to_owned()) + }); + let raw_params = match raw_params { + Ok(params) => params, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + if let Err(error) = tool_name_alias(raw_params) { + return Ok(Self::error(-32602, error.to_string())); + } + + let typed = match ServerRequest01521::decode(request) { + Ok(typed) => typed, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + let params = match typed { + ServerRequest01521::DynamicTool { params, .. } => params, + _ => { + // `kind()` is method based; retain a defensive typed match if + // the version adapter grows another mapping for this method. + return Ok(Self::error( + -32601, + format!( + "Codex 0.152.1 typed Host 不支持 server request: {}", + request.method() + ), + )); + } + }; + + let name = match resolve_dynamic_tool_name_typed( + self.namespace_resolver.as_ref(), + params.namespace.as_deref(), + ¶ms.tool, + ) { + Ok(name) => name, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + let call = ToolCall::try_new(params.call_id, name, params.arguments) + .map_err(|error| CodexError::InvalidConfig(format!("动态工具参数无效: {error}"))); + let call = match call { + Ok(call) => call, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + + let Some(definition) = self + .tools + .definitions() + .iter() + .find(|definition| definition.name() == call.name()) + else { + return Ok(Self::error(-32602, format!("未注册工具: {}", call.name()))); + }; + if let Err(error) = validate_tool_arguments(&call, definition) { + return Ok(Self::error(-32602, error.to_string())); + } + + let request_id = match request.id() { + Value::String(value) if !value.trim().is_empty() => value.clone(), + Value::Number(value) => value.to_string(), + value => { + return Ok(Self::error( + -32602, + format!("Codex server request id 无法绑定工具调用: {value}"), + )); + } + }; + let run_id = self.context.run_id().ok_or_else(|| { + CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) + })?; + let approval = ApprovalRequest::try_new(request_id, run_id.to_owned(), call.clone()) + .map_err(|error| { + CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) + })?; + let decision = self.approval.decide(&approval).map_err(|error| { + CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")) + })?; + match decision { + ApprovalDecision::Allow => {} + ApprovalDecision::Deny { reason } => { + return Ok(Self::error( + -32001, + format!("Codex 0.152.1 dynamic tool 被 Host 拒绝: {reason}"), + )); + } + ApprovalDecision::Ask => { + return Ok(Self::error( + -32002, + "Codex 0.152.1 dynamic tool 需要外部审批;typed Host 不会在此 handler 中自动等待或放行", + )); + } + } + + match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { + CodexDurableToolCallAction::Cached(result) => { + return Ok(CodexServerRequestResponse::result(Self::dynamic_result( + &result, + ))); + } + CodexDurableToolCallAction::InFlight => { + return Ok(Self::error( + -32000, + format!("Codex tool call 正在执行,拒绝重复 call_id: {}", call.id()), + )); + } + CodexDurableToolCallAction::Execute => {} + } + + let result = match self.tools.execute(&call, &self.context) { + Ok(result) => result, + Err(error) => { + fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; + return Err(CodexError::Protocol(format!( + "Codex tool call 执行失败: {error}" + ))); + } + }; + complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; + Ok(CodexServerRequestResponse::result(Self::dynamic_result( + &result, + ))) + } +} + +/// 让同一个 Host handler 也能直接传给 `AppServer01521` 的版本化客户端。 +/// +/// typed trait 没有 JSON-RPC error response 变体,因此拒绝、询问和执行错误以 +/// `CodexError` 返回,由版本化 client 的既有错误边界处理;中立 trait 仍保留 +/// JSON-RPC `-32001/-32002` 结果,供不绑定发行版的 client 使用。 +impl ServerRequestHandler01521 for Codex01521HostServerRequestHandler { + fn handle(&mut self, request: &ServerRequest01521) -> Result { + let ServerRequest01521::DynamicTool { id, params } = request else { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 typed Host 不支持 server request: {}", + request.method() + ))); + }; + + self.context + .validate() + .map_err(|error| CodexError::InvalidConfig(error.to_string()))?; + if self.context.is_cancelled() { + return Err(CodexError::Interrupted); + } + + let name = resolve_dynamic_tool_name_typed( + self.namespace_resolver.as_ref(), + params.namespace.as_deref(), + ¶ms.tool, + )?; + let call = ToolCall::try_new(params.call_id.clone(), name, params.arguments.clone()) + .map_err(|error| CodexError::InvalidConfig(format!("动态工具参数无效: {error}")))?; + let Some(definition) = self + .tools + .definitions() + .iter() + .find(|definition| definition.name() == call.name()) + else { + return Err(CodexError::InvalidConfig(format!( + "未注册工具: {}", + call.name() + ))); + }; + validate_tool_arguments(&call, definition) + .map_err(|error| CodexError::InvalidConfig(error.to_string()))?; + let approval_id = match id { + Value::String(value) if !value.trim().is_empty() => value.clone(), + Value::Number(value) => value.to_string(), + value => { + return Err(CodexError::InvalidConfig(format!( + "Codex server request id 无法绑定工具调用: {value}" + ))); + } + }; + let run_id = self.context.run_id().ok_or_else(|| { + CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) + })?; + let approval = ApprovalRequest::try_new(approval_id, run_id.to_owned(), call.clone()) + .map_err(|error| { + CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) + })?; + match self + .approval + .decide(&approval) + .map_err(|error| CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")))? + { + ApprovalDecision::Allow => {} + ApprovalDecision::Deny { reason } => { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 dynamic tool 被 Host 拒绝: {reason}" + ))); + } + ApprovalDecision::Ask => { + return Err(CodexError::Protocol( + "Codex 0.152.1 dynamic tool 需要外部审批;typed Host 不会在此 handler 中自动等待或放行" + .to_owned(), + )); + } + } + + match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { + CodexDurableToolCallAction::Cached(result) => { + return Ok(ServerResponse01521::DynamicTool( + Self::dynamic_typed_result(&result), + )); + } + CodexDurableToolCallAction::InFlight => { + return Err(CodexError::Protocol(format!( + "Codex tool call 正在执行,拒绝重复 call_id: {}", + call.id() + ))); + } + CodexDurableToolCallAction::Execute => {} + } + + let result = match self.tools.execute(&call, &self.context) { + Ok(result) => result, + Err(error) => { + fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 dynamic tool 执行失败: {error}" + ))); + } + }; + complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; + Ok(ServerResponse01521::DynamicTool( + Self::dynamic_typed_result(&result), + )) + } +} + +/// 默认回显工具,用于 CLI 离线自检;真实宿主可以注册自己的实现。 +#[derive(Clone, Debug, Default)] +pub struct EchoTool; + +impl ToolExecutor for EchoTool { + fn execute(&self, call: &ToolCall, _context: &ToolContext) -> Result { + ToolResult::success(call.id(), json!({"received": call.arguments()})).map_err(Into::into) + } +} + +/// 为一个 Core `ExternalBackend` 提供 Host 侧工具桥。 +/// +/// 这个桥只做三件事:把当前 `ToolCall` 转成中立的 `BackendRequest`、把 +/// 返回值转成普通 `ToolResult`,以及把外部会话身份写入已有 SQLite 表。它 +/// 不依赖 Codex;Codex CLI、App Server 或其它远端执行器都可以实现同一个 +/// Core 端口后接入。工具仍需经过 `ApprovalPolicy`,本类型不会自动放行。 +pub struct ExternalBackendToolExecutor { + backend: Arc, + backend_name: String, + operation: String, + runtime: RuntimeService, + /// request_id -> durable lifecycle context for calls that have crossed the + /// external dispatch boundary. The map is only an in-process cancel + /// index; SQLite remains the recovery/audit source of truth. + active_calls: Arc>>, +} + +#[derive(Clone, Debug)] +struct ActiveExternalCall { + record_id: String, + request_id: String, + session_id: String, + run_id: String, + cancel_requested: Arc, + cancel_result: Arc>>, +} + +impl std::fmt::Debug for ExternalBackendToolExecutor { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExternalBackendToolExecutor") + .field("backend_name", &self.backend_name) + .field("operation", &self.operation) + .finish_non_exhaustive() + } +} + +/// 把 Codex app-server 分配的 thread/turn 身份写入 Runtime 外部会话表。 +/// +/// `agent-codex` 只定义中立 sink 合同;这个 Host 实现负责把一次 run 的 +/// session/run 归属和 metadata 接到 `RuntimeService`,不会让 Codex 适配器 +/// 依赖 SQLite。thread/turn ID 是不透明远端身份,不会被拼进工具权限或 +/// Core reducer 状态;同一 thread 的 turn 更新会复用一个本地 durable row。 +#[derive(Clone, Debug)] +pub struct CodexRuntimeSessionMetadataSink { + runtime: RuntimeService, + session_id: String, + run_id: String, + backend: String, +} + +impl CodexRuntimeSessionMetadataSink { + pub fn new( + runtime: RuntimeService, + session_id: impl Into, + run_id: impl Into, + backend: impl Into, + ) -> Result { + let session_id = session_id.into(); + let run_id = run_id.into(); + let backend = backend.into(); + if session_id.trim().is_empty() { + return Err(HostError::Config( + "Codex session sink 需要 session_id".to_owned(), + )); + } + if run_id.trim().is_empty() { + return Err(HostError::Config( + "Codex session sink 需要 run_id".to_owned(), + )); + } + ToolOrigin::external(&backend) + .map_err(|error| HostError::Config(format!("Codex session backend 无效: {error}")))?; + Ok(Self { + runtime, + session_id, + run_id, + backend, + }) + } + + fn metadata_json(metadata: &CodexSessionMetadata) -> Result { + let thread_id = metadata.thread_id.as_deref(); + let turn_id = metadata.turn_id.as_deref(); + if thread_id.is_none() && turn_id.is_none() { + return Err(CodexError::InvalidConfig( + "Codex session metadata 至少需要 thread_id 或 turn_id".to_owned(), + )); + } + if thread_id.is_some_and(|value| value.trim().is_empty()) { + return Err(CodexError::InvalidConfig( + "Codex session metadata thread_id 不能为空".to_owned(), + )); + } + if turn_id.is_some_and(|value| value.trim().is_empty()) { + return Err(CodexError::InvalidConfig( + "Codex session metadata turn_id 不能为空".to_owned(), + )); + } + let mut fields = serde_json::Map::new(); + fields.insert( + "source".to_owned(), + Value::String("codex-app-server".to_owned()), + ); + if let Some(thread_id) = thread_id { + fields.insert("threadId".to_owned(), Value::String(thread_id.to_owned())); + } + if let Some(turn_id) = turn_id { + fields.insert("turnId".to_owned(), Value::String(turn_id.to_owned())); + } + Ok(Value::Object(fields)) + } + + /// Persist one merged session observation. Runtime updates replace the + /// metadata JSON as a whole, so read/merge/write here keeps audit fields + /// from an earlier observation instead of silently dropping them. + fn persist_record( + &self, + metadata: &CodexSessionMetadata, + requested_status: &str, + external_id_override: Option<&str>, + lifecycle: Option<&CodexSessionLifecycle>, + ) -> Result<(), CodexError> { + let mut metadata_json = Self::metadata_json(metadata)?; + if let Some(lifecycle) = lifecycle { + let Value::Object(fields) = &mut metadata_json else { + return Err(CodexError::Protocol( + "Codex lifecycle metadata 不是对象".to_owned(), + )); + }; + fields.insert( + "lifecycle".to_owned(), + Value::String(lifecycle.status.as_str().to_owned()), + ); + if let Some(external_id) = lifecycle.external_id.as_deref() { + fields.insert( + "externalId".to_owned(), + Value::String(external_id.to_owned()), + ); + } + if let Some(exit_code) = lifecycle.exit_code { + fields.insert("exitCode".to_owned(), Value::from(exit_code)); + } + if let Some(cancel_result) = lifecycle.cancel_result.as_deref() { + fields.insert( + "cancelResult".to_owned(), + Value::String(cancel_result.to_owned()), + ); + } + } + + let requested_external_id = external_id_override + .or(metadata.turn_id.as_deref()) + .or(metadata.thread_id.as_deref()); + let Some(requested_external_id) = requested_external_id else { + return Err(CodexError::InvalidConfig( + "Codex session metadata 至少需要 thread_id 或 turn_id".to_owned(), + )); + }; + if requested_external_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex session external_id 不能为空".to_owned(), + )); + } + + let stable_id = metadata + .thread_id + .as_deref() + .unwrap_or(requested_external_id); + let record_id = external_session_record_id(&self.backend, &format!("thread:{stable_id}")); + let existing = self + .runtime + .get_external_session(&record_id) + .map_err(|error| { + CodexError::Protocol(format!("读取 Codex session 记录失败: {error}")) + })?; + if let Some(existing) = existing { + if existing.session_id != self.session_id + || existing.run_id.as_deref() != Some(self.run_id.as_str()) + { + return Err(CodexError::Protocol(format!( + "Codex session 记录归属不匹配: {}", + existing.id + ))); + } + let mut merged_metadata = existing.metadata.clone(); + match (&mut merged_metadata, metadata_json) { + (Value::Object(current), Value::Object(update)) => { + current.extend(update); + } + (_, update) => merged_metadata = update, + } + let status = if matches!( + existing.status.as_str(), + "completed" | "failed" | "cancelled" | "canceled" + ) { + // Late thread/turn metadata must not resurrect a terminal row. + existing.status.clone() + } else { + requested_status.to_owned() + }; + let external_id = external_id_override + .or(metadata.turn_id.as_deref()) + .unwrap_or(existing.external_id.as_str()) + .to_owned(); + self.runtime + .update_external_session(&existing.id, &external_id, &status, merged_metadata) + .map_err(|error| { + CodexError::Protocol(format!("更新 Codex session 记录失败: {error}")) + })?; + } else { + self.runtime + .upsert_external_session(NewExternalSession { + id: record_id, + session_id: self.session_id.clone(), + run_id: Some(self.run_id.clone()), + backend: self.backend.clone(), + external_id: requested_external_id.to_owned(), + status: requested_status.to_owned(), + metadata: metadata_json, + }) + .map_err(|error| { + CodexError::Protocol(format!("创建 Codex session 记录失败: {error}")) + })?; + } + Ok(()) + } + + /// Store process supervisor observations in a stable auxiliary row for the + /// run. This row is separate from the thread/turn identity row because a + /// process can exit before the app-server has returned either identity. + fn persist_process_record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + let record_id = + external_session_record_id(&self.backend, &format!("process:run:{}", self.run_id)); + let external_id = format!("process:{}", self.run_id); + let status = match event.reason { + CodexProcessLifecycleReason::NaturalExit if event.exit_code == Some(0) => "completed", + CodexProcessLifecycleReason::NaturalExit => "failed", + CodexProcessLifecycleReason::ExplicitTerminate + | CodexProcessLifecycleReason::Cancel => "cancelled", + CodexProcessLifecycleReason::ReaderEof + | CodexProcessLifecycleReason::ReaderError + | CodexProcessLifecycleReason::Timeout + | CodexProcessLifecycleReason::Drop => "unknown", + }; + let metadata = json!({ + "source": "codex-app-server", + "processLifecycle": event.reason.as_str(), + "exitCode": event.exit_code, + }); + let existing = self + .runtime + .get_external_session(&record_id) + .map_err(|error| { + CodexError::Protocol(format!("读取 Codex process 记录失败: {error}")) + })?; + if let Some(existing) = existing { + if existing.session_id != self.session_id + || existing.run_id.as_deref() != Some(self.run_id.as_str()) + { + return Err(CodexError::Protocol(format!( + "Codex process 记录归属不匹配: {}", + existing.id + ))); + } + let mut merged = existing.metadata.clone(); + match (&mut merged, metadata) { + (Value::Object(current), Value::Object(update)) => current.extend(update), + (_, update) => merged = update, + } + let status = if matches!( + existing.status.as_str(), + "completed" | "failed" | "cancelled" | "canceled" + ) { + existing.status.clone() + } else { + status.to_owned() + }; + self.runtime + .update_external_session(&existing.id, &external_id, &status, merged) + .map_err(|error| { + CodexError::Protocol(format!("更新 Codex process 记录失败: {error}")) + })?; + } else { + self.runtime + .upsert_external_session(NewExternalSession { + id: record_id, + session_id: self.session_id.clone(), + run_id: Some(self.run_id.clone()), + backend: self.backend.clone(), + external_id, + status: status.to_owned(), + metadata, + }) + .map_err(|error| { + CodexError::Protocol(format!("创建 Codex process 记录失败: {error}")) + })?; + } + Ok(()) + } +} + +impl CodexSessionMetadataSink for CodexRuntimeSessionMetadataSink { + fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError> { + self.persist_record(metadata, "active", None, None) + } + + fn persist_lifecycle(&self, lifecycle: &CodexSessionLifecycle) -> Result<(), CodexError> { + // A process request can be dispatched before typed thread/turn + // identity is known; there is no stable external-session row to + // update in that case, so keep the lifecycle observation best-effort. + if lifecycle.metadata.thread_id.is_none() && lifecycle.metadata.turn_id.is_none() { + return Ok(()); + } + self.persist_record( + &lifecycle.metadata, + lifecycle.status.as_str(), + lifecycle.external_id.as_deref(), + Some(lifecycle), + ) + } + + fn persist_process_lifecycle( + &self, + event: &CodexProcessLifecycleEvent, + ) -> Result<(), CodexError> { + self.persist_process_record(event) + } +} + +impl ExternalBackendToolExecutor { + /// 构造一个带 durable 外部会话记录的桥。 + pub fn new( + backend_name: impl Into, + operation: impl Into, + backend: Arc, + store: SqliteStore, + ) -> Result { + Self::new_with_runtime( + backend_name, + operation, + backend, + RuntimeService::from_store(store), + ) + } + + /// 使用已经装配好的 Runtime facade,避免外部会话桥再次直接依赖 + /// SQLite adapter。保留上面的 Store 构造器供旧嵌入方逐步迁移。 + pub fn new_with_runtime( + backend_name: impl Into, + operation: impl Into, + backend: Arc, + runtime: RuntimeService, + ) -> Result { + let backend_name = backend_name.into(); + let operation = operation.into(); + if backend_name.trim().is_empty() { + return Err(HostError::Config("外部 backend 名称不能为空".to_owned())); + } + ToolOrigin::external(&backend_name) + .map_err(|error| HostError::Config(format!("外部 backend 名称无效: {error}")))?; + // 在真正执行前复用 Core 的 identifier 规则校验 operation,避免 + // 第一次工具调用才暴露配置错误。 + BackendRequest::try_new("external-probe", "run-probe", &operation, json!({})) + .map_err(|error| HostError::Config(format!("外部 backend operation 无效: {error}")))?; + Ok(Self { + backend, + backend_name, + operation, + runtime, + active_calls: Arc::new(Mutex::new(BTreeMap::new())), + }) + } + + pub fn backend_name(&self) -> &str { + &self.backend_name + } + + pub fn operation(&self) -> &str { + &self.operation + } + + /// 显式取消一个仍由外部 backend 管理的请求。 + /// + /// Engine 的 `ToolExecutor` 端口没有隐式 cancel 生命周期,因此 Host + /// 只暴露这个显式动作,不在超时或 Drop 时猜测外部副作用已经停止。 + pub fn cancel(&self, request_id: &str) -> Result<(), HostError> { + if request_id.trim().is_empty() { + return Err(HostError::Config( + "外部 backend cancel request_id 不能为空".to_owned(), + )); + } + let active = self + .active_calls + .lock() + .map_err(|_| HostError::Config("外部 backend 活动表锁已损坏".to_owned()))? + .get(request_id) + .cloned(); + + let Some(active) = active else { + // active index 只存在于当前进程。先查 request-id 别名对应的 + // durable row,让重开 Host 后的显式 cancel 仍能更新已有 session, + // 而不是凭空再插一条没有 run/session 归属的记录。 + let record_id = external_session_record_id(&self.backend_name, request_id); + if let Some(record) = self + .runtime + .get_external_session(&record_id) + .map_err(host_error_from_runtime)? + { + return self.cancel_persisted_record(&record, request_id); + } + // 没有 durable row 时仍保留 backend 的幂等 cancel 行为;这类 + // 调用可能来自尚未登记生命周期的旧嵌入方,不能伪造归属信息。 + return self + .backend + .cancel(request_id) + .map_err(|error| HostError::Config(format!("外部 backend 取消失败: {error}"))); + }; + + active.cancel_requested.store(true, Ordering::Release); + self.update_active_session( + &active, + "cancel_requested", + json!({ + "requestId": active.request_id, + "operation": self.operation, + "lifecycle": "cancel_requested", + "cancelRequested": true, + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + )?; + + match self.backend.cancel(request_id) { + Ok(()) => { + if let Ok(mut result) = active.cancel_result.lock() { + *result = Some("ok"); + } + self.update_active_session( + &active, + "cancelled", + json!({ + "requestId": active.request_id, + "operation": self.operation, + "lifecycle": "cancelled", + "cancelRequested": true, + "cancelResult": "ok", + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + )?; + Ok(()) + } + Err(error) => { + if let Ok(mut result) = active.cancel_result.lock() { + *result = Some("error"); + } + // A failed cancellation cannot prove that the child stopped; + // keep the row in the conservative unknown bucket and let the + // caller perform explicit reconciliation. + let persist_error = self.update_active_session( + &active, + "unknown", + json!({ + "requestId": active.request_id, + "operation": self.operation, + "lifecycle": "cancel_failed", + "cancelRequested": true, + "cancelResult": "error", + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + ); + persist_error?; + Err(HostError::Config(format!("外部 backend 取消失败: {error}"))) + } + } + } + + /// 按 durable external-session 主键显式取消一个在其它进程登记的调用。 + /// + /// 该入口只会调用 backend 的 `cancel`,不会重新执行 `invoke`,也不会 + /// 自动把 unknown 结果标成完成。调用方应在 backend 成功提供终态证明后 + /// 继续走既有 reconciliation;取消失败会保守地保留 `unknown` 状态。 + pub fn cancel_persisted(&self, record_id: &str) -> Result<(), HostError> { + if record_id.trim().is_empty() { + return Err(HostError::Config( + "外部 backend cancel external-session id 不能为空".to_owned(), + )); + } + let record = self + .runtime + .get_external_session(record_id) + .map_err(host_error_from_runtime)? + .ok_or_else(|| HostError::Config(format!("外部会话不存在: {record_id}")))?; + self.cancel_persisted_record(&record, persisted_request_id(&record)) + } + + fn cancel_persisted_record( + &self, + record: &ExternalSessionRecord, + request_id: &str, + ) -> Result<(), HostError> { + if record.backend != self.backend_name { + return Err(HostError::Config(format!( + "外部会话 backend 不匹配: expected={} actual={}", + self.backend_name, record.backend + ))); + } + if matches!( + record.status.as_str(), + "completed" | "failed" | "cancelled" | "canceled" + ) { + // 终态取消是幂等 no-op;不再向一个已经完成的外部调用发送 + // 可能带来额外副作用的 interrupt。 + return Ok(()); + } + let cancel_reference = if request_id.trim().is_empty() { + record.external_id.as_str() + } else { + request_id + }; + let mut requested_metadata = record.metadata.clone(); + merge_external_lifecycle_metadata( + &mut requested_metadata, + cancel_reference, + &self.operation, + "cancel_requested", + Some("pending"), + ); + self.runtime + .update_external_session( + &record.id, + &record.external_id, + "cancel_requested", + requested_metadata, + ) + .map_err(host_error_from_runtime)?; + + match self.backend.cancel(cancel_reference) { + Ok(()) => { + let mut metadata = record.metadata.clone(); + merge_external_lifecycle_metadata( + &mut metadata, + cancel_reference, + &self.operation, + "cancelled", + Some("ok"), + ); + self.runtime + .update_external_session(&record.id, &record.external_id, "cancelled", metadata) + .map_err(host_error_from_runtime)?; + Ok(()) + } + Err(error) => { + let mut metadata = record.metadata.clone(); + merge_external_lifecycle_metadata( + &mut metadata, + cancel_reference, + &self.operation, + "cancel_failed", + Some("error"), + ); + self.runtime + .update_external_session(&record.id, &record.external_id, "unknown", metadata) + .map_err(host_error_from_runtime)?; + Err(HostError::Config(format!("外部 backend 取消失败: {error}"))) + } + } + } + + fn update_active_session( + &self, + active: &ActiveExternalCall, + status: &str, + metadata: Value, + ) -> Result { + self.runtime + .update_external_session(&active.record_id, &active.request_id, status, metadata) + .map_err(host_error_from_runtime) + } + + fn persist_session( + &self, + context: &ToolContext, + external_id: &str, + status: &str, + metadata: serde_json::Value, + ) -> Result { + let session_id = context.session_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.session_id", + ) + })?; + let run_id = context.run_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.run_id", + ) + })?; + self.runtime + .upsert_external_session(NewExternalSession { + id: external_session_record_id(&self.backend_name, external_id), + session_id: session_id.to_owned(), + run_id: Some(run_id.to_owned()), + backend: self.backend_name.clone(), + external_id: external_id.to_owned(), + status: status.to_owned(), + metadata, + }) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string())) + } + + /// 在外部 dispatch 前登记一个可恢复的 lifecycle row。 + /// + /// request id 是唯一已知的稳定关联键;如果后端稍后返回真正的 + /// external id,完成路径会额外写入该 id,并把这个 request-id 别名一并 + /// 收束,避免重启扫描时留下假 `running` 会话。 + fn begin_active_call( + &self, + context: &ToolContext, + request: &BackendRequest, + ) -> Result { + let session_id = context.session_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.session_id", + ) + })?; + let run_id = context.run_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.run_id", + ) + })?; + let record_id = external_session_record_id(&self.backend_name, request.request_id()); + let active = ActiveExternalCall { + record_id, + request_id: request.request_id().to_owned(), + session_id: session_id.to_owned(), + run_id: run_id.to_owned(), + cancel_requested: Arc::new(AtomicBool::new(false)), + cancel_result: Arc::new(Mutex::new(None)), + }; + + // 先检查本地 active index,再写 durable row;重复 request_id 不能 + // 让一次 cancel 不确定地作用于两个 child。 + { + let mut active_calls = self.active_calls.lock().map_err(|_| { + ToolError::new(ToolErrorKind::Failed, "外部 backend 活动表锁已损坏") + })?; + if active_calls.contains_key(request.request_id()) { + return Err(ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend request_id 已在执行", + )); + } + active_calls.insert(request.request_id().to_owned(), active.clone()); + } + + let persisted = self.persist_session( + context, + request.request_id(), + "running", + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "running", + "dispatchStarted": true, + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + ); + if let Err(error) = persisted { + if let Ok(mut active_calls) = self.active_calls.lock() { + active_calls.remove(request.request_id()); + } + return Err(error); + } + Ok(active) + } + + fn finish_active_call( + &self, + active: &ActiveExternalCall, + external_id: &str, + status: &str, + mut metadata: Value, + ) -> Result<(), ToolError> { + if let Some(object) = metadata.as_object_mut() { + object.insert( + "cancelRequested".to_owned(), + Value::Bool(active.cancel_requested.load(Ordering::Acquire)), + ); + if let Ok(result) = active.cancel_result.lock() + && let Some(result) = *result + { + object.insert("cancelResult".to_owned(), Value::String(result.to_owned())); + } + } + + // 先收束 dispatch 前登记的 request-id 别名;这一步即使后端返回了 + // 另一个 external id 也不会留下旧的 running 状态。 + self.runtime + .update_external_session( + &active.record_id, + active.request_id.as_str(), + status, + metadata.clone(), + ) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; + + if external_id != active.request_id { + // 保留现有按真实 external_id 查询的兼容路径;这条记录与上面的 + // request-id 别名共享同一 lifecycle metadata,不是第二次调用。 + self.runtime + .upsert_external_session(NewExternalSession { + id: external_session_record_id(&self.backend_name, external_id), + session_id: active.session_id.clone(), + run_id: Some(active.run_id.clone()), + backend: self.backend_name.clone(), + external_id: external_id.to_owned(), + status: status.to_owned(), + metadata, + }) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; + } + Ok(()) + } + + fn remove_active_call( + &self, + request_id: &str, + ) -> Result, ToolError> { + self.active_calls + .lock() + .map_err(|_| ToolError::new(ToolErrorKind::Failed, "外部 backend 活动表锁已损坏")) + .map(|mut calls| calls.remove(request_id)) + } +} + +impl ToolExecutor for ExternalBackendToolExecutor { + fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { + // This executor is public and can be called outside AgentEngine; keep + // the same Core input boundary before registering or invoking an + // external side effect. + call.validate()?; + context.validate()?; + let run_id = context.run_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.run_id", + ) + })?; + // 在触发外部副作用前就检查两个 durable 身份;不能等调用返回后 + // 才发现没有 session,留下无法归属的 opaque 操作。 + if context.session_id().is_none() { + return Err(ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.session_id", + )); + } + // Tool call ID 是 Engine 的稳定幂等边界;同一请求重试时不生成第二 + // 个随机 ID,便于外部 backend 自己做去重或由 Host 对账。 + let request = + BackendRequest::try_new(call.id(), run_id, &self.operation, call.arguments().clone()) + .and_then(|request| { + request.with_metadata(json!({ + "toolName": call.name(), + "sessionId": context.session_id(), + "runId": run_id, + })) + }) + .map_err(|error| ToolError::new(ToolErrorKind::InvalidInput, error.to_string()))?; + + let active = self.begin_active_call(context, &request)?; + let result = match self.backend.invoke(&request) { + Ok(result) => result, + Err(error) => { + let _ = self.remove_active_call(request.request_id())?; + let unknown = matches!( + error.kind(), + ExternalErrorKind::UnknownSideEffect | ExternalErrorKind::Timeout + ); + let status = if unknown { "unknown" } else { "failed" }; + self.finish_active_call( + &active, + request.request_id(), + status, + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "invoke_error", + "externalIdKnown": false, + "sideEffectUnknown": unknown, + "errorKind": external_error_kind_name(error.kind()), + }), + )?; + return Err(external_error_as_tool_error(error)); + } + }; + let _ = self.remove_active_call(request.request_id())?; + if let Err(error) = result.validate() { + // A custom backend can still return a compatibility value; after + // dispatch, any malformed envelope is an unknown side effect and + // must remain behind reconciliation. + self.finish_active_call( + &active, + request.request_id(), + "unknown", + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "response_invalid", + "externalIdKnown": false, + "sideEffectUnknown": true, + "errorKind": "unknown-side-effect", + }), + )?; + return Err(ToolError::new( + ToolErrorKind::Unknown, + format!("外部 backend 返回非法结果: {error}"), + )); + } + if result.request_id() != request.request_id() { + // 返回身份错配意味着不能证明外部调用是否完成;先落一条 + // unknown 会话,再把错误交给 Engine 的 tool-in-flight gate。 + self.finish_active_call( + &active, + request.request_id(), + "unknown", + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "response_identity_mismatch", + "externalIdKnown": false, + "sideEffectUnknown": true, + "errorKind": "unknown-side-effect", + }), + )?; + return Err(ToolError::new( + ToolErrorKind::Unknown, + format!( + "外部 backend 返回 request id 不匹配: expected={} actual={}", + request.request_id(), + result.request_id() + ), + )); + } + + let side_effect_unknown = result.side_effect_unknown(); + let external_id = result + .external_id() + .unwrap_or_else(|| request.request_id()) + .to_owned(); + let status = if side_effect_unknown { + "unknown" + } else { + "completed" + }; + self.finish_active_call( + &active, + &external_id, + status, + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": status, + "externalIdKnown": result.external_id().is_some(), + "sideEffectUnknown": side_effect_unknown, + }), + )?; + + let tool = ToolResult::try_new(call.id(), result.output().clone(), side_effect_unknown) + .and_then(|tool| { + tool.with_metadata(json!({ + "external": true, + "backend": self.backend_name, + "operation": self.operation, + "externalId": external_id, + "sideEffectUnknown": side_effect_unknown, + })) + }) + .map_err(ToolError::from)?; + if side_effect_unknown { + // unknown 结果不能写成 safe checkpoint;返回错误让 Engine/Host + // 保留 tool-in-flight,对账后才能继续,避免模型自动重试副作用。 + return Err(ToolError::new( + ToolErrorKind::Unknown, + format!("外部 backend 结果副作用未知: {external_id}"), + )); + } + Ok(tool) + } +} + +/// 外部会话表的稳定本地主键;backend/external_id 仍由 SQLite 唯一约束兜底。 +pub fn external_session_record_id(backend: &str, external_id: &str) -> String { + format!("external-session:{backend}:{external_id}") +} + +fn persisted_request_id(record: &ExternalSessionRecord) -> &str { + record + .metadata + .get("requestId") + .and_then(Value::as_str) + .filter(|request_id| !request_id.trim().is_empty()) + .unwrap_or(record.external_id.as_str()) +} + +/// 在不丢弃适配器已有非敏感字段的前提下更新外部生命周期元数据。 +/// 手工登记的 scalar metadata 也会被包在 `previousMetadata` 中,避免 +/// 为了写 cancel 状态而静默覆盖调用方的审计信息。 +fn merge_external_lifecycle_metadata( + metadata: &mut Value, + request_id: &str, + operation: &str, + lifecycle: &str, + cancel_result: Option<&str>, +) { + let previous = std::mem::replace(metadata, Value::Null); + let mut object = match previous { + Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("previousMetadata".to_owned(), other); + object + } + }; + object.insert("requestId".to_owned(), Value::String(request_id.to_owned())); + object.insert("operation".to_owned(), Value::String(operation.to_owned())); + object.insert("lifecycle".to_owned(), Value::String(lifecycle.to_owned())); + object.insert("cancelRequested".to_owned(), Value::Bool(true)); + object.insert("sideEffectUnknown".to_owned(), Value::Bool(true)); + if let Some(cancel_result) = cancel_result { + object.insert( + "cancelResult".to_owned(), + Value::String(cancel_result.to_owned()), + ); + } + *metadata = Value::Object(object); +} + +fn external_error_kind_name(kind: ExternalErrorKind) -> &'static str { + match kind { + ExternalErrorKind::InvalidInput => "invalid-input", + ExternalErrorKind::Unavailable => "unavailable", + ExternalErrorKind::Timeout => "timeout", + ExternalErrorKind::UnknownSideEffect => "unknown-side-effect", + } +} + +fn external_error_as_tool_error(error: ExternalError) -> ToolError { + let kind = match error.kind() { + ExternalErrorKind::InvalidInput => ToolErrorKind::InvalidInput, + ExternalErrorKind::Unavailable => ToolErrorKind::Failed, + // A synchronous external timeout cannot tell us whether the request + // reached the remote side. Treat it as unknown rather than allowing + // an idempotent retry policy to issue the same side effect again. + ExternalErrorKind::Timeout => ToolErrorKind::Unknown, + ExternalErrorKind::UnknownSideEffect => ToolErrorKind::Unknown, + }; + ToolError::new(kind, format!("外部 backend 调用失败: {error}")) +} + +/// 将一个已经握手的 MCP client 暴露为 Core 工具执行器。 +/// +/// `McpClient` 的同步 transport 由互斥锁保护;Engine 仍只看到统一的 +/// `ToolExecutor`,不会感知子进程、HTTP 或 JSON-RPC 细节。调用错误保持在 +/// 当前 tool call 内,不会伪造成功结果。 +pub struct McpToolExecutor { + client: Arc>, +} + +impl McpToolExecutor { + pub fn new(client: Arc>) -> Self { + Self { client } + } + + pub fn client(&self) -> &Arc> { + &self.client + } +} + +impl ToolExecutor for McpToolExecutor { + fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { + // MCP execution is also an exposed adapter port, so do not depend on + // Engine/ToolRouter having validated serde-compatible values first. + call.validate()?; + context.validate()?; + // 发送前门禁保证已取消的 Engine 不会触碰 MCP transport。正在阻塞的 + // 同步 I/O 仍由具体 MCP adapter 的硬取消能力负责;这里不强杀线程。 + if context.is_cancelled() { + return Err(ToolError::new( + ToolErrorKind::Cancelled, + "MCP 工具调用已取消(发送前)", + )); + } + let mut client = self + .client + .lock() + .map_err(|_| ToolError::new(ToolErrorKind::Failed, "MCP client 锁已损坏"))?; + // 取消可能在等待 client 锁期间到达;再次检查,避免拿到锁后仍发送 + // 一个已经被宿主取消的 tools/call。 + if context.is_cancelled() { + return Err(ToolError::new( + ToolErrorKind::Cancelled, + "MCP 工具调用已取消(发送前)", + )); + } + let result: McpToolResult = client + .call_namespaced_tool(call.name(), call.arguments().clone()) + .map_err(mcp_error_as_tool_error)?; + let McpToolResult { + content, + is_error, + structured_content, + extra, + } = result; + // 优先保留 MCP 的 structuredContent;只有纯 content 时才包装为稳定 + // JSON,避免把服务端返回的结构化数据丢给下一轮 Provider。 + let output = structured_content.unwrap_or_else(|| { + json!({ + "content": content, + "isError": is_error, + "extra": extra, + }) + }); + ToolResult::try_new(call.id(), output, is_error).map_err(Into::into) + } +} + +/// MCP tools/call 已经写入 transport 后,超时、断线、协议/编码错误和 +/// 远端 HTTP/JSON-RPC 错误都不能证明副作用没有发生。统一映射为 Unknown +/// 可阻止 Engine 的显式 retry_on_failed 策略重放未知调用;只有本地配置、 +/// 权限和取消错误保留可区分的非副作用类别。 +fn mcp_error_as_tool_error(error: McpError) -> ToolError { + let kind = match error.kind() { + McpErrorKind::PermissionDenied | McpErrorKind::PermissionRequired => { + ToolErrorKind::PermissionDenied + } + McpErrorKind::Cancelled => ToolErrorKind::Cancelled, + McpErrorKind::Configuration | McpErrorKind::Authentication => ToolErrorKind::InvalidInput, + // `Unsupported` can be emitted after a tools/call has already been + // written (for example when the server sends an unhandled request). + // The call boundary is therefore unknown, not a safe local input + // failure; do not allow an idempotent retry to replay it. + McpErrorKind::Unsupported => ToolErrorKind::Unknown, + McpErrorKind::Encoding + | McpErrorKind::Connection + | McpErrorKind::Timeout + | McpErrorKind::Protocol + | McpErrorKind::Remote + | McpErrorKind::HttpStatus + | McpErrorKind::RecoveryExhausted => ToolErrorKind::Unknown, + }; + ToolError::new(kind, format!("MCP 工具调用失败: {error}")) +} + +/// 将 MCP 的工具目录项转换成 Core 的带来源绑定。 +/// 传输层仍由 `agent-mcp`/Host 负责,转换本身不授予执行权限。 +pub fn bind_mcp_tool( + server: &str, + definition: &McpToolDefinition, +) -> Result { + let name = definition.namespaced_name(server); + let description = definition + .description + .as_deref() + .or(definition.title.as_deref()) + .unwrap_or("MCP tool"); + let tool = ToolDefinition::try_new(&name, description, definition.input_schema.clone()) + .map_err(|error| { + ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()) + })?; + let origin = ToolOrigin::mcp(server).map_err(|error| { + ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()) + })?; + Ok(ToolBinding::new(tool, origin)) +} + +/// 一个只读的 MCP 工具目录。真正调用时可把命名后的请求交给 MCP transport。 +#[derive(Clone, Debug)] +pub struct McpToolCatalog { + server: String, + definitions: Vec, +} + +impl McpToolCatalog { + pub fn new(server: impl Into, definitions: Vec) -> Self { + Self { + server: server.into(), + definitions, + } + } +} + +impl ToolSource for McpToolCatalog { + fn list_tools(&self) -> Result, ExtensionError> { + self.definitions + .iter() + .map(|definition| bind_mcp_tool(&self.server, definition)) + .collect() + } +} + +/// MCP resources/prompts 的只读上下文桥接。 +/// +/// 读取动作由调用方显式触发,结果进入 Engine 时一律标记为不可信;该源 +/// 不会把资源内容变成工具,也不会在每个 step 隐式重复请求远端服务。 +#[derive(Clone, Debug, Default)] +pub struct McpContextSource { + items: Vec, +} + +impl McpContextSource { + pub fn new() -> Self { + Self::default() + } + + pub fn from_resource( + server: &str, + resource: &agent_mcp::McpResourceDefinition, + result: &agent_mcp::McpReadResourceResult, + ) -> Result { + let mut source = Self::new(); + for (index, content) in result.contents.iter().enumerate() { + let text = content + .text + .clone() + .or_else(|| { + content + .blob + .as_ref() + .map(|blob| format!("[base64 blob] {blob}")) + }) + .unwrap_or_else(|| serde_json::to_string(content).unwrap_or_default()); + let message = + Message::user(text).map_err(|error| HostError::Config(error.to_string()))?; + let metadata = json!({ + "server": server, + "uri": &resource.uri, + "mimeType": &content.mime_type, + "kind": "mcp-resource" + }); + let item = ContextItem::try_new( + format!("mcp:{server}:resource:{}:{index}", resource.name), + message, + 5, + false, + ) + .map_err(|error| HostError::Config(error.to_string()))? + .with_metadata(metadata) + .map_err(|error| HostError::Config(error.to_string()))?; + source.items.push(item); + } + if source.items.is_empty() { + return Err(HostError::Config(format!( + "MCP resource 没有可注入内容: {}", + resource.uri + ))); + } + Ok(source) + } + + pub fn from_prompt( + server: &str, + prompt_name: &str, + result: &agent_mcp::McpGetPromptResult, + ) -> Result { + let mut source = Self::new(); + for (index, prompt) in result.messages.iter().enumerate() { + let message = prompt_message(prompt)?; + let metadata = json!({ + "server": server, + "prompt": prompt_name, + "kind": "mcp-prompt" + }); + let item = ContextItem::try_new( + format!("mcp:{server}:prompt:{prompt_name}:{index}"), + message, + 5, + false, + ) + .map_err(|error| HostError::Config(error.to_string()))? + .with_metadata(metadata) + .map_err(|error| HostError::Config(error.to_string()))?; + source.items.push(item); + } + Ok(source) + } + + pub fn push(&mut self, item: ContextItem) { + self.items.push(item); + } + + pub fn items(&self) -> &[ContextItem] { + &self.items + } + + /// 返回当前来源是否没有可注入的上下文项。 + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +impl ContextSource for McpContextSource { + fn contribute(&self, _request: &ContextRequest) -> Result, ContextError> { + Ok(self.items.clone()) + } +} + +/// 一次 MCP 装配中明确选择的外部上下文。 +/// +/// MCP 资源和 prompt 不会因为“发现了能力”就自动进入每次运行;调用方必须 +/// 逐项加入这个选择。这样既保持资源内容的不可信边界,也避免启动 Host 时 +/// 把整个远端目录无界地读进上下文。 +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct McpContextSelection { + resource_uris: Vec, + prompts: Vec, +} + +/// 一个显式展开的 MCP prompt 及其字符串参数。 +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct McpPromptSelection { + name: String, + arguments: BTreeMap, +} + +impl McpPromptSelection { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + arguments: BTreeMap::new(), + } + } + + pub fn with_arguments(mut self, arguments: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.arguments = arguments + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(); + self + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn arguments(&self) -> &BTreeMap { + &self.arguments + } +} + +impl McpContextSelection { + pub fn new() -> Self { + Self::default() + } + + /// 选择一个资源 URI;不会在此处发起网络/进程调用。 + pub fn with_resource_uri(mut self, uri: impl Into) -> Self { + self.resource_uris.push(uri.into()); + self + } + + /// 选择一个不带参数的 prompt。 + pub fn with_prompt(mut self, name: impl Into) -> Self { + self.prompts.push(McpPromptSelection::new(name)); + self + } + + /// 选择一个带字符串参数的 prompt。 + pub fn with_prompt_selection(mut self, prompt: McpPromptSelection) -> Self { + self.prompts.push(prompt); + self + } + + pub fn resource_uris(&self) -> &[String] { + &self.resource_uris + } + + pub fn prompts(&self) -> &[McpPromptSelection] { + &self.prompts + } + + pub fn is_empty(&self) -> bool { + self.resource_uris.is_empty() && self.prompts.is_empty() + } +} + +fn validate_mcp_context_selection(selection: &McpContextSelection) -> Result<(), HostError> { + let mut resources = BTreeSet::new(); + for uri in &selection.resource_uris { + if uri.trim().is_empty() || uri.chars().any(char::is_control) { + return Err(HostError::Config( + "MCP context resource URI 不能为空或包含控制字符".to_owned(), + )); + } + if !resources.insert(uri) { + return Err(HostError::Config(format!( + "MCP context resource URI 重复: {uri}" + ))); + } + } + + let mut prompts = BTreeSet::new(); + for prompt in &selection.prompts { + if prompt.name.trim().is_empty() || prompt.name.chars().any(char::is_control) { + return Err(HostError::Config( + "MCP context prompt 名称不能为空或包含控制字符".to_owned(), + )); + } + if !prompts.insert(&prompt.name) { + return Err(HostError::Config(format!( + "MCP context prompt 重复: {}", + prompt.name + ))); + } + if prompt + .arguments + .keys() + .chain(prompt.arguments.values()) + .any(|value| value.chars().any(char::is_control)) + { + return Err(HostError::Config( + "MCP context prompt 参数不能包含控制字符".to_owned(), + )); + } + } + Ok(()) +} + +fn prompt_message(prompt: &agent_mcp::McpPromptMessage) -> Result { + let text = if prompt.content.kind == "text" { + prompt + .content + .data + .get("text") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned() + } else { + serde_json::to_string(&prompt.content) + .map_err(|error| HostError::Config(error.to_string()))? + }; + match prompt.role.as_str() { + "system" => Message::system(text), + "developer" => Message::developer(text), + "assistant" => Message::assistant(text), + _ => Message::user(text), + } + .map_err(|error| HostError::Config(error.to_string())) +} + +/// 已显式激活 Skill 的上下文源。Skill 正文按不可信内容注入,不能改变审批策略。 +#[derive(Clone, Debug, Default)] +pub struct SkillContextSource { + active: Vec, +} + +impl SkillContextSource { + pub fn new() -> Self { + Self::default() + } + + pub fn activate(mut self, skill: ActivatedSkill) -> Self { + self.active.push(skill); + self + } + + pub fn len(&self) -> usize { + self.active.len() + } + + pub fn is_empty(&self) -> bool { + self.active.is_empty() + } +} + +impl ContextSource for SkillContextSource { + fn contribute(&self, _request: &ContextRequest) -> Result, ContextError> { + self.active + .iter() + .map(|skill| { + let name = skill.descriptor.name(); + let metadata = + serde_json::to_value(skill.descriptor.metadata()).map_err(|error| { + ContextError::new(ContextErrorKind::InvalidInput, error.to_string()) + })?; + ContextItem::try_new( + format!("skill:{name}"), + // Skill 正文是上下文而非用户授权;使用普通 user 消息承载 + // 可兼容的出站形状,同时由 source_id/metadata 保留来源。 + Message::user(skill.body()).map_err(ContextError::from)?, + 10, + false, + ) + .map_err(ContextError::from) + .and_then(|item| item.with_metadata(metadata).map_err(ContextError::from)) + }) + .collect() + } +} + +/// Core `SkillActivation` 的上下文桥接。 +/// +/// Core 只保存已经构造好的 `ContextItem`,Host 负责把它们挂到 Engine 的 +/// 可插拔 source 列表。这里不重新解释 Skill 正文,也不把 metadata 当成 +/// 工具权限;需要执行器的绑定由下方 API 在进入 Host 前显式拒绝。 +#[derive(Clone, Debug)] +struct SkillActivationContextSource { + items: Vec, +} + +impl SkillActivationContextSource { + fn new(items: &[ContextItem]) -> Self { + Self { + items: items.to_vec(), + } + } +} + +impl ContextSource for SkillActivationContextSource { + fn contribute(&self, _request: &ContextRequest) -> Result, ContextError> { + Ok(self.items.clone()) + } +} + +/// 可注入 Provider/工具的单 Agent 宿主。 +pub struct AgentHost { + /// Durable run/lease/recovery 的唯一装配入口。Host 不再重复持有 + /// SQLite adapter;需要兼容旧调用方时,`store()` 会从 Runtime facade + /// 借出同一个底层句柄。 + runtime: RuntimeService, + provider: Arc, + /// 可选的实例注册表。旧的 `provider` 字段仍是默认/直接注入路径;只有 + /// 通过 `select_provider` 选择了 target,执行前才会从该注册表解析。 + provider_registry: ProviderRegistry, + provider_target: Option, + tools: Arc, + /// 已注册外部工具的控制句柄。ToolRouter 只负责执行;这里保留同一 + /// executor 的 opaque handle,允许控制面在重开 Host 后按 request_id + /// 显式取消并更新 durable external session。 + external_executors: BTreeMap>, + /// Codex dynamic-tool 的显式 namespace 映射;空映射保持全局工具路径。 + namespace_resolver: Arc, + approval: Arc, + context_sources: Vec>, + context_compressor: Option>, + /// Whether the compressor is bound to the current Provider. Explicit + /// summary providers are independent and must survive provider swaps. + provider_compressor_auto: bool, + durable_event_listener: Option>, + model: String, + /// Built-in CLI provider kind used to fence queued metadata. Generic + /// `with_provider` injections leave this unset for compatibility. + provider_kind: Option, +} + +/// 计划中的稳定装配名称目前与 `AgentHost` 共享同一实现。 +/// +/// 这是有意保留的轻量兼容别名:真正的 Runtime/Engine 分层仍由 Host 内部 +/// 负责,调用方可以先用 `AgentBuilder`/`AgentService` 命名迁移,而不会引入 +/// 第二套状态或生命周期对象。 +pub type AgentBuilder = AgentHost; +pub type AgentService = AgentHost; + +impl AgentHost { + /// 默认构造一个内存 Host,适合单 Agent 示例、测试和嵌入式调用。 + /// 需要 durable 文件时使用 [`Self::open`],需要复用已有 Runtime 时使用 + /// [`Self::with_runtime`];旧的 SQLite 调用方仍可使用 [`Self::with_store`]。 + pub fn new() -> Result { + Self::in_memory() + } + + /// 兼容计划中 `AgentBuilder` 的装配入口;返回值仍是同一 Host 实现, + /// 因而可以直接继续链式调用 `with_provider`、`with_tools` 等方法。 + pub fn builder() -> Result { + Self::new() + } + + /// 打开文件数据库;父目录由调用方提前创建。 + pub fn open(path: impl AsRef) -> Result { + let runtime = RuntimeService::open(path).map_err(host_error_from_runtime)?; + Self::with_runtime(runtime) + } + + /// 内存数据库适合测试和一次性 CLI 运行。 + pub fn in_memory() -> Result { + let runtime = RuntimeService::in_memory().map_err(host_error_from_runtime)?; + Self::with_runtime(runtime) + } + + /// 从已经装配好的 Runtime 创建 Host。 + /// + /// Runtime 是 Host 的唯一 durable 控制面依赖;这个入口让上层可以先 + /// 创建/持有 Runtime,再注入 Provider、工具和策略,而不必把 SQLite + /// 连接重新暴露给 Host 的装配代码。`with_store` 保留给旧调用方,实际 + /// 仍只会构造一个 Runtime facade。 + pub fn with_runtime(runtime: RuntimeService) -> Result { + let mut router = ToolRouter::new(); + let definition = ToolDefinition::try_new( + "echo", + "回显传入的 JSON 参数", + json!({"type": "object", "properties": {"text": {"type": "string"}}}), + ) + .map_err(|error| HostError::Config(error.to_string()))?; + router.register(definition, Arc::new(EchoTool))?; + Ok(Self { + runtime, + // 保留旧的 EchoProvider 默认行为以兼容嵌入方;CLI 会显式调用 + // `with_fake_provider`,独立 Fake adapter 不再藏在 Engine 里。 + provider: Arc::new(EchoProvider), + provider_registry: ProviderRegistry::new(), + provider_target: None, + tools: Arc::new(router), + external_executors: BTreeMap::new(), + namespace_resolver: default_namespace_tool_resolver(), + // 默认只放行无副作用的内置 echo;宿主注册真实工具后要显式注入 policy。 + approval: Arc::new(AllowList::new(["echo".to_owned()])), + context_sources: Vec::new(), + context_compressor: None, + provider_compressor_auto: false, + durable_event_listener: None, + model: "fake".to_owned(), + provider_kind: None, + }) + } + + pub fn with_store(store: SqliteStore) -> Result { + Self::with_runtime(RuntimeService::from_store(store)) + } + + pub fn with_provider( + mut self, + provider: Arc, + model: impl Into, + ) -> Self { + self.provider = provider; + self.model = model.into(); + self.provider_kind = None; + // 直接注入是既有 API 的明确选择;清除此前可能选中的 registry + // target,避免链式装配时旧 target 意外覆盖新 provider。 + self.provider_target = None; + if self.provider_compressor_auto { + // Only discard a compressor created from the previous Provider; + // an explicitly injected summary model is independent. + self.context_compressor = None; + self.provider_compressor_auto = false; + } + self + } + + /// 用当前 Provider 构造一个拥有式上下文压缩器。 + /// + /// Host 的 Engine 在超预算时需要一个 `'static` 的共享适配器;借用式 + /// `ProviderContextCompressor` 适合短生命周期库调用,但无法直接存进 + /// Host。该入口只捕获已经装配好的 Provider,不读取环境变量,也不会 + /// 预先发起请求。若随后切换 Provider,应在切换后再次调用本方法。 + pub fn with_provider_context_compressor(self) -> Self { + let provider = self.provider.clone(); + let mut host = self; + host.context_compressor = Some(Arc::new(OwnedProviderContextCompressor::new(provider))); + host.provider_compressor_auto = true; + host + } + + fn with_openai_provider(self, provider: OpenAiProvider, model: impl Into) -> Self { + let provider: Arc = Arc::new(provider); + let mut host = self + .with_provider(provider, model) + .with_provider_context_compressor(); + host.provider_kind = Some("openai".to_owned()); + host + } + + /// 向 Host 的注册表加入一个 Provider 实例及其非秘密描述。 + /// + /// `instance_id` 和 `protocol_id` 分别来自 Core 的强类型字段;注册只 + /// 保存调用方提供的 `Arc` 和 descriptor,不读取环境变量,也不触发模型 + /// 请求。重复实例会在 Core 注册表中拒绝。 + pub fn register_provider( + &mut self, + descriptor: ProviderDescriptor, + provider: Arc, + ) -> Result<(), HostError> { + self.provider_registry + .register(descriptor, provider) + .map_err(|error| HostError::Config(format!("Provider 注册失败: {error}"))) + } + + /// `register_provider` 的链式装配版本,适合在构造 Host 时注册多个实例。 + pub fn with_provider_descriptor( + mut self, + descriptor: ProviderDescriptor, + provider: Arc, + ) -> Result { + self.register_provider(descriptor, provider)?; + Ok(self) + } + + /// 只读查看当前 Host 注册的 Provider 实例。 + pub fn provider_registry(&self) -> &ProviderRegistry { + &self.provider_registry + } + + /// 返回当前选中的 target;直接 `with_provider` 装配时为 `None`。 + pub fn provider_target(&self) -> Option<&ProviderTarget> { + self.provider_target.as_ref() + } + + /// 解析一个 target 并执行 Core 的实例/协议校验,但不改变当前 Host。 + /// + /// 该只读入口让控制面可以在创建 run 前检查路由;真正执行路径还会在 + /// Engine 启动前再次解析当前选中的 target。 + pub fn resolve_provider_target( + &self, + target: &ProviderTarget, + ) -> Result, ProviderRegistryError> { + self.provider_registry.resolve(target) + } + + /// 选择一个已注册的 Provider 实例,并要求 target 中的 protocol(如果有) + /// 与 descriptor 完全匹配。校验成功后才会改变当前 provider/model。 + pub fn select_provider( + &mut self, + target: ProviderTarget, + model: impl Into, + ) -> Result<(), HostError> { + let provider = self + .resolve_provider_target(&target) + .map_err(provider_registry_error)?; + self.provider = provider; + self.model = model.into(); + self.provider_target = Some(target); + if self.provider_compressor_auto { + // Do not send a summary to the old selected instance after a + // registry switch; explicit compressors are left untouched. + self.context_compressor = None; + self.provider_compressor_auto = false; + } + Ok(()) + } + + /// `select_provider` 的链式装配版本。 + pub fn with_provider_target( + mut self, + target: ProviderTarget, + model: impl Into, + ) -> Result { + self.select_provider(target, model)?; + Ok(self) + } + + /// 取得本次 Engine 执行应使用的 Provider。选中 registry target 时即使 + /// 之前已经解析过,也在副作用发生前重新做一次协议校验。 + fn provider_for_execution(&self) -> Result, HostError> { + match self.provider_target.as_ref() { + Some(target) => self + .resolve_provider_target(target) + .map_err(provider_registry_error), + None => Ok(self.provider.clone()), + } + } + + /// 将本次 Provider 路由的非敏感身份写进 session metadata。 + /// + /// `providerTarget` 只包含实例/协议 ID,不包含 key、endpoint 或其它 + /// secret。这样重开 Runtime 后,控制面可以要求调用方重新装配同一个 + /// Provider 实例,而不会因为当前进程恰好有另一个默认 Provider 就把 + /// durable run 静默路由到错误的协议。 + fn provider_run_metadata(&self) -> Result { + let mut metadata = json!({"provider": self.model}); + if let Some(target) = self.provider_target.as_ref() { + let target = serde_json::to_value(target).map_err(|error| { + HostError::Config(format!("Provider target 无法持久化: {error}")) + })?; + metadata + .as_object_mut() + .expect("provider metadata is an object") + .insert("providerTarget".to_owned(), target); + } + Ok(metadata) + } + + /// Queue metadata is an observation/configuration record, not a second + /// provider contract. Keep the public escape hatch bounded to an object and + /// reject obvious credential-bearing keys before it reaches durable storage. + fn validate_queue_metadata(metadata: &Value) -> Result<(), HostError> { + let Value::Object(_) = metadata else { + return Err(HostError::Config( + "queued run metadata 必须是 JSON object".to_owned(), + )); + }; + let encoded = serde_json::to_vec(metadata) + .map_err(|error| HostError::Config(format!("queued run metadata 无法编码: {error}")))?; + if encoded.len() > 16 * 1024 { + return Err(HostError::Config( + "queued run metadata 不能超过 16 KiB".to_owned(), + )); + } + fn visit(value: &Value) -> Result<(), HostError> { + match value { + Value::Object(object) => { + for (key, child) in object { + let normalized = key + .chars() + .filter(|character| *character != '-' && *character != '_') + .flat_map(char::to_lowercase) + .collect::(); + if [ + "apikey", + "accesstoken", + "refreshtoken", + "password", + "secret", + "cookie", + "authorization", + ] + .iter() + .any(|needle| normalized.contains(needle)) + { + return Err(HostError::Config(format!( + "queued run metadata 禁止 secret 字段: {key}" + ))); + } + visit(child)?; + } + } + Value::Array(values) => { + for child in values { + visit(child)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + Ok(()) + } + visit(metadata) + } + + /// 在 queued run claim 前校验持久化的 Provider 路由。 + /// + /// 旧 run 没有 `providerTarget` 时保持兼容;一旦 run 带有该字段,就 + /// 必须由当前 Host 选择完全相同的 target,并通过当前注册表/adapter + /// 的第二次协议校验。失败发生在 claim 之前,run 仍保持 queued,因而 + /// 不会触发 Provider 或工具副作用,也不会留下无主 lease。 + fn validate_persisted_provider_target(&self, record: &RunRecord) -> Result<(), HostError> { + let session = self + .runtime + .get_session(&record.session_id)? + .ok_or_else(|| { + HostError::Config(format!("找不到 run 对应 session: {}", record.session_id)) + })?; + let Some(raw_target) = session.metadata.get("providerTarget") else { + // 这是升级前创建的 legacy run;没有可比较的路由身份时沿用旧 + // direct-provider 语义,避免把历史队列全部变成不可恢复状态。 + return Ok(()); + }; + let persisted: ProviderTarget = + serde_json::from_value(raw_target.clone()).map_err(|error| { + HostError::Config(format!("run 的 providerTarget 元数据无效: {error}")) + })?; + let Some(current) = self.provider_target.as_ref() else { + return Err(HostError::Config(format!( + "run 要求 Provider target {},当前 Host 未选择对应实例: {}", + persisted.instance_id(), + record.id + ))); + }; + if current != &persisted { + return Err(HostError::Config(format!( + "run 的 Provider target 与当前 Host 不匹配: persisted={}, current={}, run={}", + persisted.instance_id(), + current.instance_id(), + record.id + ))); + } + self.resolve_provider_target(&persisted) + .map(|_| ()) + .map_err(provider_registry_error) + } + + /// 校验后台队列写入的 provider/model 观察信息。 + /// + /// `providerKind` 是当前 CLI metadata 的显式 marker;没有它的旧 run + /// 可能只有一个历史 `provider` 字段,不能把该字段重新解释成强约束, + /// 否则升级后旧队列会因为当前 Host 的默认模型变化而无法恢复。新 + /// metadata 一旦带 marker,就必须携带非空 `provider`(实际模型名), + /// 并在 claim 前与当前 Host 模型完全一致。 + fn validate_persisted_provider_metadata(&self, record: &RunRecord) -> Result<(), HostError> { + let session = self + .runtime + .get_session(&record.session_id)? + .ok_or_else(|| { + HostError::Config(format!("找不到 run 对应 session: {}", record.session_id)) + })?; + if session.metadata.get("providerKind").is_none() { + // 没有 marker 的 metadata 属于历史 direct-provider 形状,保持兼容。 + return Ok(()); + } + + let provider_kind = session + .metadata + .get("providerKind") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + HostError::Config(format!( + "run 的 providerKind 元数据无效,必须是非空字符串: {}", + record.id + )) + })?; + let persisted_model = session + .metadata + .get("provider") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + HostError::Config(format!( + "run 的 provider metadata 缺少非空模型名(providerKind={provider_kind}): {}", + record.id + )) + })?; + if persisted_model != self.model { + return Err(HostError::Config(format!( + "run 的 Provider model 与当前 Host 不匹配: persisted={persisted_model}, current={}, run={}", + self.model, record.id + ))); + } + if let Some(current_kind) = self.provider_kind.as_deref() + && current_kind != provider_kind + { + return Err(HostError::Config(format!( + "run 的 Provider kind 与当前 Host 不匹配: persisted={provider_kind}, current={current_kind}, run={}", + record.id + ))); + } + Ok(()) + } + + /// 显式切回离线 Fake Provider,适合 CLI 自检和嵌入式测试。 + pub fn with_fake_provider(self) -> Self { + let mut host = self.with_provider( + Arc::new(FakeProvider::tool_then_text( + "echo-call-1", + "echo", + json!({"text": "hello from fake provider"}), + "fake provider complete", + )), + "fake", + ); + host.provider_kind = Some("fake".to_owned()); + host + } + + /// 使用环境变量中的 OpenAI key;未设置时调用方可以继续使用 Fake Provider。 + pub fn with_openai_from_env(self) -> Result { + self.with_openai_from_env_with_model("gpt-4.1-mini") + } + + /// 从指定环境变量读取 OpenAI key,并由调用方决定模型名。 + /// + /// 只把环境变量的名字留在装配逻辑中;key 本身永远不会进入 Host + /// 元数据、运行事件或错误展示。 + pub fn with_openai_from_env_var( + self, + key_env: &str, + model: impl Into, + ) -> Result { + let provider = OpenAiProvider::from_api_key_env(key_env).map_err(|error| match error { + agent_provider_openai::OpenAiError::MissingApiKey => { + HostError::Config(format!("{key_env} 未设置")) + } + other => HostError::Config(other.to_string()), + })?; + Ok(self.with_openai_provider(provider, model)) + } + + /// 使用非秘密 OpenAI 配置装配 Provider。配置只保存 key 的环境变量名; + /// endpoint 可以来自完整地址或 base URL,具体解析由 adapter 负责。 + pub fn with_openai_config( + self, + config: OpenAiProviderConfig, + model: impl Into, + ) -> Result { + let provider = OpenAiProvider::from_config(&config) + .map_err(|error| HostError::Config(error.to_string()))?; + Ok(self.with_openai_provider(provider, model)) + } + + /// 直接注入已取得的 key 和 endpoint 配置,适合 secret manager。 + pub fn with_openai_config_and_key( + self, + api_key: impl Into, + config: &OpenAiProviderConfig, + model: impl Into, + ) -> Result { + let provider = OpenAiProvider::with_config(api_key, config) + .map_err(|error| HostError::Config(error.to_string()))?; + Ok(self.with_openai_provider(provider, model)) + } + + pub fn with_openai_from_env_with_model( + self, + model: impl Into, + ) -> Result { + // 让 Provider 自己解析 `OPENAI_API_KEY_ENV`、`OPENAI_ENDPOINT` 和 + // `OPENAI_BASE_URL`。这里不能把 key 环境变量硬编码回 + // `OPENAI_API_KEY`,否则嵌入方通过自定义 secret 名称时 endpoint + // 虽然生效,key 却会被错误地判定为缺失。 + let provider = OpenAiProvider::from_env().map_err(|error| match error { + agent_provider_openai::OpenAiError::MissingApiKey => { + HostError::Config("OpenAI API key 环境变量未设置".to_owned()) + } + other => HostError::Config(other.to_string()), + })?; + Ok(self.with_openai_provider(provider, model)) + } + + pub fn with_tools(mut self, tools: ToolRouter) -> Self { + self.tools = Arc::new(tools); + // 替换整个 Router 后,旧 executor 不再属于当前 Host 的工具集合; + // 清掉控制索引,避免控制面误取消一个已经卸载的工具。 + self.external_executors.clear(); + self + } + + /// 注入 Codex dynamic-tool 的 namespace resolver。 + /// + /// resolver 只把 `(namespace, tool)` 转成已经注册的 Host 工具名;工具 + /// 是否存在仍由 Router 校验,是否允许执行仍由 ApprovalPolicy 决定。 + /// 未注入映射时,显式 namespace 继续 fail-closed。 + pub fn with_namespace_resolver(mut self, resolver: R) -> Self + where + R: NamespaceToolResolver + 'static, + { + self.namespace_resolver = Arc::new(resolver); + self + } + + /// 只读查看当前 namespace resolver,便于上层在装配阶段复用同一映射。 + pub fn namespace_resolver(&self) -> &dyn NamespaceToolResolver { + self.namespace_resolver.as_ref() + } + + /// 为 Codex App Server 的显式 server-request 回调复用当前 Host 端口。 + /// 调用方仍需把返回的 handler 传给 `CodexServerRequestHandler` 所在的 + /// Codex client;Host 不会在此隐式创建 session、run 或后台 worker。 + pub fn codex_server_request_handler( + &self, + context: ToolContext, + ) -> CodexHostServerRequestHandler { + CodexHostServerRequestHandler::from_host(self, context) + } + + /// 为已经核对过的 Codex CLI 0.152.1 wire 创建窄 typed bridge。 + /// + /// 这个入口只改变 server-request 的字段/响应形状,不创建新的运行或 + /// 持久化边界;需要完整版本生命周期的调用方仍应使用 + /// `agent_codex::codex_0_152_1` 客户端并显式管理 session/run。 + pub fn codex_01521_server_request_handler( + &self, + context: ToolContext, + ) -> Codex01521HostServerRequestHandler { + Codex01521HostServerRequestHandler::from_host(self, context) + } + + /// 注册一个由 Core `ExternalBackend` 驱动的 Host 工具。 + /// + /// 该方法只装配工具和持久化桥,不改变默认审批策略;调用方仍需通过 + /// `with_approval` 明确允许工具。这样接入 Codex 时不会产生第二套 Host + /// 会话、权限或运行状态。 + pub fn with_external_backend( + mut self, + definition: ToolDefinition, + backend_name: impl Into, + operation: impl Into, + backend: Arc, + ) -> Result { + let backend_name = backend_name.into(); + let origin = ToolOrigin::external(&backend_name) + .map_err(|error| HostError::Config(format!("外部 backend 名称无效: {error}")))?; + let tool_name = definition.name().to_owned(); + let executor = Arc::new(ExternalBackendToolExecutor::new_with_runtime( + backend_name, + operation, + backend, + self.runtime.clone(), + )?); + let mut router = (*self.tools).clone(); + router.register_binding(ToolBinding::new(definition, origin), executor.clone())?; + self.tools = Arc::new(router); + self.external_executors.insert(tool_name, executor); + Ok(self) + } + + /// 为一个已存在 run 创建 Codex app-server session metadata sink。 + /// + /// 调用方将返回值传给 `CodexAppServerProcessBackend::with_session_metadata_sink`; + /// thread/start、turn/start 成功后,真实远端 ID 会进入当前 run 的 + /// `external_sessions`。该 helper 不启动 Codex、不发送请求,也不改变 + /// Host 的工具权限或 Core 状态。 + pub fn codex_session_metadata_sink( + &self, + run_id: &str, + backend: impl Into, + ) -> Result { + let run = self + .get_run(run_id)? + .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; + CodexRuntimeSessionMetadataSink::new(self.runtime.clone(), run.session_id, run.id, backend) + } + + /// 通过当前 Host 已注册的外部工具显式取消一个 request。 + /// + /// 取消是控制面动作,不会启动 Engine,也不会重放 `invoke`。executor + /// 会先查 durable request-id 别名,因此用新进程重开同一 SQLite 后仍能 + /// 收束已有 external session;没有对应工具或记录时返回配置错误,避免 + /// 猜测应该向哪个 backend 发送 interrupt。 + pub fn cancel_external_request( + &self, + tool_name: &str, + request_id: &str, + ) -> Result<(), HostError> { + let executor = self + .external_executors + .get(tool_name) + .ok_or_else(|| HostError::Config(format!("未注册外部工具: {tool_name}")))?; + executor.cancel(request_id) + } + + pub fn with_context_source(mut self, source: Arc) -> Self { + self.context_sources.push(source); + self + } + + /// 注入上下文压缩器。压缩仍由 Engine 在超预算时显式触发;Host 不会 + /// 自动丢弃基础消息,也不会把压缩调用记成主运行的一步。 + pub fn with_context_compressor(mut self, compressor: Arc) -> Self { + self.context_compressor = Some(compressor); + self.provider_compressor_auto = false; + self + } + + /// 在每条 Engine 审计事件完成 SQLite 提交后同步通知监听器。 + /// + /// 监听器不会参与事务,也不会让事件回滚;若监听器需要可靠地重试外部 + /// 通知,应根据 `(run_id, revision)` 自行去重。想使用闭包时可调用 + /// [`Self::with_durable_event_callback`]。 + pub fn with_durable_event_listener(mut self, listener: Arc) -> Self { + self.durable_event_listener = Some(listener); + self + } + + /// `DurableEventListener` 的闭包便捷入口。 + pub fn with_durable_event_callback(self, callback: F) -> Self + where + F: Fn(&str, i64, &EngineEvent) + Send + Sync + 'static, + { + self.with_durable_event_listener(Arc::new(callback)) + } + + /// 注入已经由调用方显式读取的 MCP resource/prompt 内容;内容保持不 + /// 可信,不会自动改变工具审批策略。 + pub fn with_mcp_context(self, source: McpContextSource) -> Self { + self.with_context_source(Arc::new(source)) + } + + /// 注入一组已显式激活的 Skill。Skill 正文只作为不可信 ContextSource + /// 进入 Engine,不会自动放行任何工具。 + pub fn with_activated_skills( + mut self, + skills: impl IntoIterator, + ) -> Self { + let source = skills + .into_iter() + .fold(SkillContextSource::new(), |source, skill| { + source.activate(skill) + }); + if !source.is_empty() { + self.context_sources.push(Arc::new(source)); + } + self + } + + /// 注入 Core 已显式激活的 Skill 上下文。 + /// + /// `SkillActivation::context()` 是唯一进入 Engine 的上下文来源;正文 + /// 是否进入 context 由上游 Skill adapter 决定。Core activation 中若带有 + /// 工具绑定,本方法会直接报错,因为绑定本身没有执行器,不能被误当成 + /// 可调用工具;调用方应把对应 binding/executor 明确注册到 `ToolRouter`, + /// 并将不带工具绑定的 activation 交给本方法注入上下文。 + pub fn with_skill_activation(mut self, activation: SkillActivation) -> Result { + activation + .validate() + .map_err(|error| HostError::Config(format!("Skill activation 无效: {error}")))?; + if !activation.tools().is_empty() { + return Err(HostError::Config(format!( + "Skill activation '{}' 包含 {} 个工具绑定,但 Host 未配置执行器", + activation.definition().name(), + activation.tools().len() + ))); + } + if !activation.context().is_empty() { + self.context_sources + .push(Arc::new(SkillActivationContextSource::new( + activation.context(), + ))); + } + Ok(self) + } + + /// 从任意 Core `SkillSource` 按名称显式激活 Skill。 + /// + /// 适配器只负责返回已经校验过的 `SkillActivation`;Host 不扫描目录、 + /// 不执行 Skill 脚本,也不把 `allowed-tools` 元数据变成权限。这样 CLI、 + /// 桌面宿主和测试可以共享同一条 Core 接线,而不必依赖 `SkillLoader` 的 + /// 文件格式。 + pub fn with_skills_from_source( + mut self, + source: &S, + names: impl IntoIterator>, + ) -> Result + where + S: SkillSource + ?Sized, + { + for name in names { + let activation = source + .activate(name.as_ref()) + .map_err(|error| HostError::Config(error.to_string()))?; + self = self.with_skill_activation(activation)?; + } + Ok(self) + } + + /// 从 SkillLoader 按名称显式加载 Skill,再注入当前 Host。 + pub fn with_skills_from_loader( + self, + loader: &SkillLoader, + names: impl IntoIterator>, + ) -> Result { + self.with_skills_from_source(loader, names) + } + + /// 使用 MCP client 完成握手、读取工具目录,并把工具注册到 Host。 + /// 默认审批策略仍然拒绝这些工具;调用方如确认允许,可随后注入 + /// `AllowList`,避免“发现工具”被误当成“授予权限”。 + pub fn with_mcp_client(self, client: McpClient) -> Result { + self.with_mcp_client_and_context(client, McpContextSelection::default()) + } + + /// 使用 MCP client 并显式读取选中的 resources/prompts 作为不可信上下文。 + /// + /// 选择为空时只执行原有握手和 tools/list,不会额外请求 resources/list 或 + /// prompts/list。选择非空时,Host 先发现目录、按 URI/name 精确匹配,再 + /// 读取选中的内容并挂到同一个 Engine `ContextSource`;未选中的远端内容 + /// 不会被隐式注入。 + pub fn with_mcp_client_and_context( + mut self, + mut client: McpClient, + selection: McpContextSelection, + ) -> Result { + validate_mcp_context_selection(&selection)?; + if !client.is_initialized() { + client + .initialize() + .map_err(|error| HostError::Config(format!("MCP 初始化失败: {error}")))?; + } + let definitions = client + .list_tools() + .map_err(|error| HostError::Config(format!("MCP tools/list 失败: {error}")))?; + let server = client.server_name().to_owned(); + + if !selection.is_empty() { + let mut context = McpContextSource::new(); + if !selection.resource_uris.is_empty() { + let resources = client.list_resources().map_err(|error| { + HostError::Config(format!("MCP resources/list 失败: {error}")) + })?; + for uri in &selection.resource_uris { + let resource = resources + .iter() + .find(|resource| resource.uri == *uri) + .ok_or_else(|| HostError::Config(format!("MCP resource 未找到: {uri}")))?; + let result = client.read_resource(uri).map_err(|error| { + HostError::Config(format!("MCP resources/read 失败: {error}")) + })?; + let source = McpContextSource::from_resource(&server, resource, &result)?; + for item in source.items() { + context.push(item.clone()); + } + } + } + if !selection.prompts.is_empty() { + let prompts = client.list_prompts().map_err(|error| { + HostError::Config(format!("MCP prompts/list 失败: {error}")) + })?; + for prompt in &selection.prompts { + if !prompts + .iter() + .any(|definition| definition.name == prompt.name) + { + return Err(HostError::Config(format!( + "MCP prompt 未找到: {}", + prompt.name + ))); + } + let result = + client + .get_prompt(&prompt.name, &prompt.arguments) + .map_err(|error| { + HostError::Config(format!("MCP prompts/get 失败: {error}")) + })?; + let source = McpContextSource::from_prompt(&server, &prompt.name, &result)?; + for item in source.items() { + context.push(item.clone()); + } + } + } + if !context.is_empty() { + self.context_sources.push(Arc::new(context)); + } + } + + let client = Arc::new(Mutex::new(client)); + let mut router = (*self.tools).clone(); + for definition in definitions { + let binding = bind_mcp_tool(&server, &definition) + .map_err(|error| HostError::Config(error.to_string()))?; + router.register_binding(binding, Arc::new(McpToolExecutor::new(client.clone())))?; + } + self.tools = Arc::new(router); + Ok(self) + } + + /// MCP 配置的便捷入口;连接仍按 argv/HTTP URL 传递,不经过 shell。 + pub fn with_mcp_server( + self, + config: &McpServerConfig, + options: agent_mcp::McpClientOptions, + ) -> Result { + let client = agent_mcp::McpClient::connect(config, options) + .map_err(|error| HostError::Config(format!("MCP 连接失败: {error}")))?; + self.with_mcp_client(client) + } + + /// MCP 配置的显式上下文装配入口;资源/prompt 选择不会自动扩大。 + pub fn with_mcp_server_and_context( + self, + config: &McpServerConfig, + options: agent_mcp::McpClientOptions, + selection: McpContextSelection, + ) -> Result { + let client = agent_mcp::McpClient::connect(config, options) + .map_err(|error| HostError::Config(format!("MCP 连接失败: {error}")))?; + self.with_mcp_client_and_context(client, selection) + } + + /// 直接运行一个已组合的 Prompt;section 会作为初始消息持久化, + /// 后续 context source 仍由 Engine 在每个 step 重新组装。 + pub fn run_prompt(&self, prompt: PromptBuilder) -> Result { + let messages = prompt + .build() + .map_err(|error| HostError::Config(error.to_string()))?; + // RunRecord.task 是展示/检索摘要,不应把 system/developer 约束或 + // 注入上下文复制进去;优先取最后一个明确的 user section。旧的 + // PromptBuilder 可能没有 user section 时才回退到全部文本。 + let task = prompt + .sections() + .iter() + .rev() + .find(|section| section.kind() == agent_runtime_core::PromptSectionKind::User) + .map(|section| { + section + .content() + .iter() + .filter_map(ContentPart::as_text) + .collect::>() + .join("\n") + }) + .filter(|task| !task.trim().is_empty()) + .unwrap_or_else(|| { + messages + .iter() + .flat_map(|message| message.content().iter().filter_map(ContentPart::as_text)) + .collect::>() + .join("\n") + }); + self.run_with_messages(task, messages) + } + + pub fn with_approval(mut self, approval: Arc) -> Self { + self.approval = approval; + self + } + + /// 查询一个 durable approval。控制端应先读取该记录,再向用户展示 + /// request;request 中不包含解析后的 token 或其它宿主密钥。 + pub fn get_approval(&self, approval_id: &str) -> Result, HostError> { + self.runtime + .get_approval(approval_id) + .map_err(host_error_from_runtime) + } + + /// 列出一个 run 的审批记录,顺序与创建时间一致,便于 UI 展示历史和 + /// 找到仍处于 pending 的请求。 + pub fn list_approvals(&self, run_id: &str) -> Result, HostError> { + self.runtime + .list_approvals_for_run(run_id) + .map_err(host_error_from_runtime) + } + + /// 查询一个 run 的工具调用 durable 记录;顺序和 JSONL 导出一致。 + pub fn list_tool_calls(&self, run_id: &str) -> Result, HostError> { + self.runtime + .list_tool_calls_for_run(run_id) + .map_err(host_error_from_runtime) + } + + /// 用 pending-only CAS 持久化人类决议。此方法只改变 approval 记录, + /// 不启动 worker;调用方必须随后显式调用 [`Self::resume_approval`]。 + pub fn resolve_approval( + &self, + approval_id: &str, + decision: ApprovalDecision, + ) -> Result { + let (status, payload) = match decision { + ApprovalDecision::Allow => ("allowed", json!({"decision": "allow"})), + ApprovalDecision::Deny { reason } => { + if reason.trim().is_empty() { + return Err(HostError::Config("审批拒绝原因不能为空".to_owned())); + } + ("denied", json!({"decision": "deny", "reason": reason})) + } + ApprovalDecision::Ask => { + return Err(HostError::Config( + "不能把 Ask 作为已决 approval 写回".to_owned(), + )); + } + }; + self.runtime + .resolve_approval(approval_id, "pending", status, payload) + .map_err(host_error_from_runtime) + } + + /// 把已经 resolved 的 approval 对应 run 放回 queued。该操作只做 durable + /// 状态迁移,不调用 Engine;CLI/调度器随后再显式启动 worker。 + pub fn resume_approval(&self, approval_id: &str) -> Result { + self.runtime + .queue_approved_run(approval_id) + .map_err(host_error_from_runtime) + } + + /// 兼容旧的诊断/导出调用方。新的控制面应优先使用 Runtime facade; + /// 该 accessor 不代表 Host 自己拥有第二份持久化状态。 + #[deprecated( + since = "0.1.0", + note = "请使用 AgentHost/RuntimeService 的窄 facade;此方法仅保留 SQLite 迁移兼容" + )] + #[allow(deprecated)] + pub fn store(&self) -> &SqliteStore { + self.runtime.store() + } + + /// 读取 run-level 审计事件而不借出 SQLite adapter。 + /// + /// 这是 Host 的只读控制面入口;需要直接构造 SQLite fixture 或使用 + /// 旧版 adapter API 的嵌入方仍可保留 `store()`,新代码应优先调用本方法。 + pub fn list_events( + &self, + run_id: &str, + after_revision: i64, + ) -> Result, HostError> { + self.runtime + .list_events(run_id, after_revision) + .map_err(host_error_from_runtime) + } + + /// 读取可由 Core reducer 重放的 runtime 生命周期事件。 + pub fn list_runtime_events(&self, runtime_id: &str) -> Result, HostError> { + self.runtime + .list_runtime_events(runtime_id) + .map_err(host_error_from_runtime) + } + + /// 查询 session 投影,供 inspect/恢复控制面使用。 + pub fn get_session(&self, session_id: &str) -> Result, HostError> { + self.runtime + .get_session(session_id) + .map_err(host_error_from_runtime) + } + + /// 读取 SQLite journal mode 的窄诊断入口;调用方不必直接借出 + /// `SqliteStore` 即可完成启动检查。 + pub fn journal_mode(&self) -> Result { + self.runtime.journal_mode().map_err(host_error_from_runtime) + } + + /// 导出一个 run 的确定性 JSONL。脱敏和排序仍由 Runtime/SQLite + /// adapter 负责,Host 不创建第二套导出实现。 + pub fn export_jsonl(&self, run_id: &str, writer: W) -> Result { + self.runtime + .export_jsonl(run_id, writer) + .map_err(host_error_from_runtime) + } + + /// 导出 runtime 快照和 reducer 事件;run-level 附属记录使用 + /// [`Self::export_jsonl`]。两个入口保持两条记录流的边界。 + pub fn export_runtime_jsonl( + &self, + runtime_id: &str, + writer: W, + ) -> Result { + self.runtime + .export_runtime_jsonl(runtime_id, writer) + .map_err(host_error_from_runtime) + } + + /// 持久化一个已经由外部适配器取得的会话/请求身份。 + /// + /// 这是显式 helper,不会启动外部调用,也不会把 token 解析后写入库; + /// `NewExternalSession.metadata` 只能携带引用和非敏感状态。 + pub fn record_external_session( + &self, + input: NewExternalSession, + ) -> Result { + self.runtime + .upsert_external_session(input) + .map_err(host_error_from_runtime) + } + + pub fn get_external_session( + &self, + id: &str, + ) -> Result, HostError> { + self.runtime + .get_external_session(id) + .map_err(host_error_from_runtime) + } + + /// 只读发现外部会话对账候选;不会观察外部系统、推进 checkpoint 或重排队。 + pub fn list_external_sessions( + &self, + statuses: &[&str], + run_id: Option<&str>, + limit: usize, + ) -> Result, HostError> { + self.runtime + .list_external_sessions(statuses, run_id, limit) + .map_err(host_error_from_runtime) + } + + /// 有界地对账一批仍可能有未知外部副作用的会话。 + /// + /// 该入口是显式调用、无后台线程的批处理:候选按 SQLite 的稳定顺序 + /// 读取,最多处理 `limit` 条;resolver 只负责查询既有调用并把已核验 + /// 结果转换成完整 `Message` 历史。Completed 会推进对应的 safe + /// checkpoint,但不会自动 `requeue` 或启动 Engine;调用方仍需按业务 + /// 决定何时恢复 run。Pending/NotFound 只更新外部会话的保守状态。 + /// + /// `limit` 受 Runtime/SQLite 的硬上限约束(当前为 + /// [`MAX_EXTERNAL_SESSION_SCAN_LIMIT`]),因此不会因为一次重启扫描 + /// 无界调用远端系统。 + pub fn reconcile_external_sessions( + &self, + limit: usize, + resolver: &dyn ExternalSessionResolver, + ) -> Result, HostError> { + if limit > MAX_EXTERNAL_SESSION_SCAN_LIMIT { + return Err(HostError::Config(format!( + "external session reconcile limit 不能超过 {}", + MAX_EXTERNAL_SESSION_SCAN_LIMIT + ))); + } + let candidates = self.runtime.list_external_sessions( + &["active", "running", "unknown", "cancel_requested"], + None, + limit, + )?; + let mut results = Vec::with_capacity(candidates.len()); + for record in candidates { + results.push(self.reconcile_external_session(&record, resolver)); + } + Ok(results) + } + + fn reconcile_external_session( + &self, + record: &ExternalSessionRecord, + resolver: &dyn ExternalSessionResolver, + ) -> ExternalSessionReconcileResult { + let base = || ExternalSessionReconcileResult { + record_id: record.id.clone(), + run_id: record.run_id.clone(), + outcome: ExternalSessionReconcileOutcome::Failed, + error: None, + }; + let mut result = base(); + + // Most external tools persist their operation; Codex session rows do + // not, so the neutral `observe` operation is the safe fallback. + let operation = record + .metadata + .get("operation") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("observe"); + let request = match ExternalObservationRequest::try_new( + record.backend.clone(), + operation, + record.external_id.clone(), + ) { + Ok(request) => request, + Err(error) => { + result.error = Some(format!("观察请求无效: {error}")); + return result; + } + }; + let resolution = match resolver.resolve(record, &request) { + Ok(resolution) => resolution, + Err(error) => { + result.error = Some(format!("resolver 失败: {error}")); + return result; + } + }; + + match resolution { + ExternalSessionResolution::Pending { metadata } => { + if let Err(error) = validate_external_reconciliation_metadata(&metadata) { + result.error = Some(error.to_string()); + return result; + } + match self + .update_external_reconciliation_metadata(record, "pending", metadata, "unknown") + { + Ok(()) => result.outcome = ExternalSessionReconcileOutcome::Pending, + Err(error) => result.error = Some(error.to_string()), + } + } + ExternalSessionResolution::NotFound { metadata } => { + if let Err(error) = validate_external_reconciliation_metadata(&metadata) { + result.error = Some(error.to_string()); + return result; + } + match self.update_external_reconciliation_metadata( + record, + "not-found", + metadata, + "unknown", + ) { + Ok(()) => result.outcome = ExternalSessionReconcileOutcome::NotFound, + Err(error) => result.error = Some(error.to_string()), + } + } + ExternalSessionResolution::Completed { messages, metadata } => { + if let Err(error) = validate_external_reconciliation_metadata(&metadata) { + result.error = Some(error.to_string()); + return result; + } + let Some(run_id) = record.run_id.as_deref() else { + result.error = Some("completed 会话缺少 run_id,拒绝写入".to_owned()); + return result; + }; + let checkpoint = match self.runtime.read_checkpoint(run_id) { + Ok(Some(checkpoint)) => checkpoint, + Ok(None) => { + result.error = Some("completed 会话没有可对账 checkpoint".to_owned()); + return result; + } + Err(error) => { + result.error = Some(error.to_string()); + return result; + } + }; + let phase = checkpoint.phase.clone(); + let external_id = match phase.as_str() { + "provider_in_flight" => checkpoint.provider_request_id.clone(), + "tool_in_flight" => checkpoint.tool_call_id.clone(), + _ => None, + }; + if external_id.as_deref() != Some(record.external_id.as_str()) { + result.error = Some(format!( + "外部会话 identity 与 checkpoint 不匹配: phase={} external_id={}", + phase, record.external_id + )); + return result; + } + match self.reconcile_external_result(run_id, &phase, &record.external_id, messages) + { + Ok(_) => match self.update_external_reconciliation_metadata( + record, + "completed", + metadata, + "completed", + ) { + Ok(()) => result.outcome = ExternalSessionReconcileOutcome::Completed, + Err(error) => result.error = Some(error.to_string()), + }, + Err(error) => result.error = Some(error.to_string()), + } + } + } + result + } + + fn update_external_reconciliation_metadata( + &self, + record: &ExternalSessionRecord, + observed_status: &str, + metadata: Value, + persisted_status: &str, + ) -> Result<(), HostError> { + let Value::Object(update) = metadata else { + return Err(HostError::Config( + "外部对账 resolver metadata 必须是 JSON object".to_owned(), + )); + }; + let mut merged = match record.metadata.clone() { + Value::Object(current) => current, + _ => serde_json::Map::new(), + }; + merged.extend(update); + merged.insert( + "reconciliation".to_owned(), + json!({ + "status": observed_status, + "source": "host-reconciler", + }), + ); + self.runtime + .update_external_session( + &record.id, + &record.external_id, + persisted_status, + Value::Object(merged), + ) + .map_err(host_error_from_runtime)?; + Ok(()) + } + + /// 暴露不包含 Engine/Provider 的 durable runtime 端口,供后台调度器或 + /// 其它程序直接管理 run;调用方不需要依赖 Host 的工具装配层。 + pub fn runtime(&self) -> &RuntimeService { + &self.runtime + } + + /// 查询 durable run 的公开记录。控制面和 CLI 使用这个 facade,只有 + /// 审计导出/诊断等 SQLite 专属操作才需要继续调用 [`Self::store`]。 + pub fn get_run(&self, run_id: &str) -> Result, HostError> { + Ok(self.runtime.get_run(run_id)?) + } + + /// 读取 run 的最新边界检查点;用于 reconciliation 观察,不返回 lease + /// token,也不会改变 run 状态。 + pub fn read_checkpoint(&self, run_id: &str) -> Result, HostError> { + Ok(self.runtime.read_checkpoint(run_id)?) + } + + /// 读取 Core runtime 快照。这个观察入口与 run/checkpoint 查询一样经过 + /// `RuntimeService`,不会把 SQLite adapter 的表结构暴露给控制面调用方。 + pub fn load_runtime_snapshot( + &self, + runtime_id: &str, + ) -> Result, HostError> { + Ok(self.runtime.load_runtime_snapshot(runtime_id)?) + } + + /// 通过显式只读适配器查询已经存在的外部调用。 + /// + /// 观察结果不会写入 SQLite、推进 checkpoint 或启动新的调用;上层在 + /// 校验厂商协议和身份后,仍需显式调用 `reconcile_provider_result` 或 + /// `reconcile_tool_result`。这样同步查询失败时也不会被误当成可安全重试。 + pub fn observe_external( + &self, + source: &dyn ExternalObservationSource, + request: &ExternalObservationRequest, + ) -> Result { + self.runtime + .observe_external(source, request) + .map_err(host_error_from_runtime) + } + + /// 记录调用方已经核对过的 Provider 响应。 + /// + /// Provider 请求本身可能已经在远端完成,进程却在写回前退出。调用方应 + /// 先用请求 ID 向 Provider 查询或凭 webhook/幂等记录确认结果,再把包含 + /// 完整历史的消息数组交给这里。该 API 只推进 checkpoint,不启动新的 + /// Provider 请求;返回 `safe` 后仍须显式调用 [`Self::requeue_safe_run`]。 + pub fn reconcile_provider_result( + &self, + run_id: &str, + provider_request_id: &str, + messages: Vec, + ) -> Result { + self.reconcile_external_result(run_id, "provider_in_flight", provider_request_id, messages) + } + + /// 记录调用方已经核对过的工具结果,并把 tool-in-flight 游标变成 safe。 + /// + /// `tool_call_id` 必须和 checkpoint 中的调用一致,消息历史必须保留旧 + /// 前缀并包含对应 `ToolResult`。Host 不会因为收到结果而再次执行工具。 + pub fn reconcile_tool_result( + &self, + run_id: &str, + tool_call_id: &str, + messages: Vec, + ) -> Result { + self.reconcile_external_result(run_id, "tool_in_flight", tool_call_id, messages) + } + + fn reconcile_external_result( + &self, + run_id: &str, + phase: &str, + external_id: &str, + messages: Vec, + ) -> Result { + let checkpoint = self + .runtime + .read_checkpoint(run_id)? + .ok_or_else(|| HostError::Config(format!("run 没有可对账 checkpoint: {run_id}")))?; + validate_reconciliation_messages(&checkpoint, phase, external_id, &messages)?; + let encoded = serde_json::to_value(&messages) + .map_err(|error| HostError::Config(format!("对账消息无法编码: {error}")))?; + // step/attempt 来自刚刚读取的 checkpoint,storage 会在同一事务内再做 + // 一次条件检查;若期间有其它恢复器写入,CAS 失败而不会覆盖新结果。 + Ok(self.runtime.record_reconciliation_result( + run_id, + phase, + external_id, + checkpoint.step, + checkpoint.attempt, + encoded, + )?) + } + + /// 将已过期的 worker 运行转入 reconciliation gate。 + /// + /// 这一步只改变 durable 状态,不启动 Engine,也不重放 Provider/工具调用。 + /// 调用方必须先对账未知的外部副作用,后续阶段才允许继续实现 checkpoint resume。 + pub fn reconcile_expired_run(&self, run_id: &str) -> Result { + Ok(self.runtime.reconcile_expired_run(run_id)?) + } + + /// 在一次有界扫描中把已经失去 worker lease 的 run 推进到 + /// reconciliation gate。 + /// + /// 这是给 CLI/宿主控制面的轻量入口:Runtime 负责稳定排序、硬上限、 + /// lease 复查和原子 recovery;Host 不启动线程、Engine 或外部调用。 + /// 返回值只包含本次实际恢复的 run,未过期或发生正常竞态的候选不会出现。 + pub fn reconcile_stale_runs(&self, limit: usize) -> Result, HostError> { + Ok(self.runtime.reconcile_stale_runs(limit)?) + } + + /// 在 Host/worker 启动边界显式恢复一批 stale run。 + /// + /// 这是 Runtime 同名入口的语义化转发,仍然是有界、无后台线程的 + /// reconciliation 扫描;它不会启动 Engine、查询外部系统或重放未知调用。 + pub fn recover_stale_runs_on_start(&self, limit: usize) -> Result, HostError> { + Ok(self.runtime.recover_stale_runs_on_start(limit)?) + } + + /// 在外部副作用已经由调用方对账后,把 safe checkpoint 重新排队。 + /// + /// 该方法只开放 `safe` 游标;它不会替 provider/tool 对未知结果做决定, + /// 也不会直接启动 Engine。RuntimeStore 的补账由随后领取该 run 的 worker + /// 在同一 lease 下完成,避免 run-level 状态和 runtime 快照出现半提交。 + pub fn requeue_safe_run(&self, run_id: &str) -> Result { + let record = self + .runtime + .get_run(run_id)? + .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; + if !matches!(record.status.as_str(), "reconciling" | "queued") { + return Err(HostError::Config(format!( + "只有 reconciling 或尚未启动的 queued run 可以 safe resume,当前为 {}: {run_id}", + record.status + ))); + } + if record.cancel_requested { + return Err(HostError::Config(format!( + "run 已请求取消,不能 safe resume: {run_id}" + ))); + } + let checkpoint = self + .runtime + .read_checkpoint(run_id)? + .ok_or_else(|| HostError::Config(format!("run 没有 checkpoint: {run_id}")))?; + if checkpoint.phase != "safe" { + return Err(HostError::Config(format!( + "checkpoint phase={} 仍需外部对账,不能 safe resume: {run_id}", + checkpoint.phase + ))); + } + // 先只提交 run-level queued。worker 领取后会再次读取 checkpoint,并在 + // Engine 启动前用 RuntimeStore CAS 补账;两步之间退出仍可安全重试。 + Ok(self.runtime.requeue_safe_run(run_id)?) + } + + /// 创建一个排队中的 run,但不启动 Engine。CLI 的后台模式用这个边界把 + /// durable 身份先写入 SQLite,再启动独立 worker。 + pub fn prepare_run(&self, task: impl Into) -> Result { + let task = task.into(); + let user_message = + Message::user(&task).map_err(|error| HostError::Config(error.to_string()))?; + self.prepare_run_with_messages(task, vec![user_message]) + } + + /// 创建一个带显式初始消息的排队 run。PromptBuilder/桌面宿主可以用它 + /// 保留 system/developer/context/user 的边界,而不是把所有内容压成一条 + /// 用户字符串。 + pub fn prepare_run_with_messages( + &self, + task: impl Into, + messages: Vec, + ) -> Result { + let metadata = self.provider_run_metadata()?; + self.prepare_run_with_messages_and_metadata(task, messages, metadata) + } + + /// 创建排队 run,并使用调用方已经解析好的非敏感装配 metadata。 + /// + /// CLI 的后台模式在真正启动 worker 前不会装配 Provider/MCP;它可以先 + /// 用配置文件得到 provider instance/protocol/model,再把这份描述交给 + /// Host/Runtime 持久化。该入口只接受 JSON object,并递归拒绝明显的 + /// secret 字段;它不会把 metadata 解释成 Provider,也不会绕过 Runtime + /// 的原子 run/session/runtime bundle。旧的 `prepare_run_with_messages` + /// 仍继续从当前 Host 装配状态生成 metadata。 + pub fn prepare_run_with_messages_and_metadata( + &self, + task: impl Into, + messages: Vec, + metadata: Value, + ) -> Result { + Self::validate_queue_metadata(&metadata)?; + let task = task.into(); + let handle = self + .runtime + .prepare_run_for_agent_with_metadata("default", task, messages, metadata)?; + Ok(HostRunHandle { + session_id: handle.session_id, + run_id: handle.run_id, + runtime_id: handle.runtime_id, + }) + } + + /// 在 worker 尚未领取 run 时,把本地准备/配置错误原子收束为 failed。 + /// + /// 该入口专供后台 worker 在构造 Provider、Skill 或 MCP Host 失败时调用: + /// 它要求 run 没有 active lease,并让 run-level、RuntimeStore、session 和 + /// checkpoint 在同一个 storage 事务中收口。已经是 failed 的 run 保持幂等; + /// 其它终态或正在执行的 run 不会被无 lease 调用方越权覆盖。 + pub fn fail_unclaimed_run( + &self, + run_id: &str, + error: impl Into, + ) -> Result { + let error = error.into(); + if error.trim().is_empty() { + return Err(HostError::Config("failed 原因不能为空".to_owned())); + } + let record = self + .runtime + .get_run(run_id)? + .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; + if record.status == "failed" { + return Ok(record); + } + if matches!( + record.status.as_str(), + "completed" | "cancelled" | "canceled" + ) { + return Err(StorageError::TerminalRun { + id: run_id.to_owned(), + status: record.status, + } + .into()); + } + if !matches!(record.status.as_str(), "queued" | "reconciling") { + return Err(HostError::Config(format!( + "只有无 lease 的 queued/reconciling run 可以失败收口,当前为 {}: {run_id}", + record.status + ))); + } + if record.cancel_requested || self.runtime.get_run_lease(run_id)?.is_some() { + return Err(HostError::Config(format!( + "run 已请求取消或仍由 worker 持有 lease,不能无 lease 失败收口: {run_id}" + ))); + } + + let runtime_id = self + .runtime + .runtime_id_for_run(run_id)? + .ok_or_else(|| HostError::Config(format!("run 缺少 runtime 身份: {run_id}")))?; + let runtime_snapshot = self + .runtime + .load_runtime_snapshot(&runtime_id)? + .ok_or_else(|| HostError::Config(format!("找不到 runtime: {runtime_id}")))?; + let run_snapshot = runtime_snapshot + .run(run_id) + .cloned() + .ok_or_else(|| HostError::Config(format!("runtime 中找不到 run: {run_id}")))?; + if run_snapshot.status().is_terminal() { + return Err(HostError::Config(format!( + "runtime run 已处于终态 {:?},不能失败收口: {run_id}", + run_snapshot.status() + ))); + } + + let mut next_runtime = runtime_snapshot.clone(); + let mut events = Vec::new(); + if run_snapshot.status() == agent_runtime_core::RunStatus::Pending { + let started = RuntimeEvent::status_changed( + &runtime_id, + next_runtime.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunStarted, + ) + .map_err(|event_error| HostError::Config(event_error.to_string()))?; + next_runtime = reduce_runtime_event(&next_runtime, &started)?; + events.push(started); + } + if let Some(run) = next_runtime.run(run_id) + && !run.status().is_terminal() + { + let failed = RuntimeEvent::failed( + &runtime_id, + next_runtime.revision() + 1, + SystemClock.now_millis(), + run_id, + error.clone(), + ) + .map_err(|event_error| HostError::Config(event_error.to_string()))?; + next_runtime = reduce_runtime_event(&next_runtime, &failed)?; + events.push(failed); + } + if events.is_empty() { + return Err(HostError::Config(format!( + "runtime 没有可失败收口的事件: {run_id}" + ))); + } + + // snapshot 来自已存在的 runtime row,因此 expected revision 必须保留 + // `Some(0)` 这类合法值,不能用 None 把它误当成“尚不存在”。 + self.runtime + .fail_run_with_runtime( + run_id, + Some(json!({"error": error})), + &runtime_id, + Some(runtime_snapshot.revision()), + &next_runtime, + &events, + ) + .map_err(host_error_from_runtime) + } + + /// 执行一个新任务,并将 Engine 事件写入 SQLite。 + pub fn run(&self, task: impl Into) -> Result { + let handle = self.prepare_run(task)?; + self.run_existing_with_cancellation(&handle.run_id, Cancellation::new()) + } + + /// 运行一组已经构造好的初始消息;与 `run_prompt` 相比,这个入口允许 + /// 调用方自行决定用于展示/持久化的任务摘要。 + pub fn run_with_messages( + &self, + task: impl Into, + messages: Vec, + ) -> Result { + let handle = self.prepare_run_with_messages(task, messages)?; + self.run_existing_with_cancellation(&handle.run_id, Cancellation::new()) + } + + /// 使用 Provider 的真实流式端口运行一次新任务;同步 `run` API 保持兼容。 + pub fn run_streaming(&self, task: impl Into) -> Result { + let handle = self.prepare_run(task)?; + self.run_existing_streaming(&handle.run_id) + } + + pub fn run_with_messages_streaming( + &self, + task: impl Into, + messages: Vec, + ) -> Result { + let handle = self.prepare_run_with_messages(task, messages)?; + self.run_existing_streaming(&handle.run_id) + } + + /// 由后台 worker 使用已持久化的 run 身份执行任务。 + pub fn run_existing(&self, run_id: &str) -> Result { + self.run_existing_with_cancellation(run_id, Cancellation::new()) + } + + pub fn run_existing_streaming(&self, run_id: &str) -> Result { + self.run_existing_with_cancellation_mode(run_id, Cancellation::new(), true) + } + + /// 执行已有 run,并允许宿主在 step 边界注入共享取消标记。 + pub fn run_existing_with_cancellation( + &self, + run_id: &str, + cancellation: Cancellation, + ) -> Result { + self.run_existing_with_cancellation_mode(run_id, cancellation, false) + } + + fn run_existing_with_cancellation_mode( + &self, + run_id: &str, + cancellation: Cancellation, + streaming: bool, + ) -> Result { + let record = self + .runtime + .get_run(run_id)? + .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; + // reconciling 是不可执行的 recovery gate。`cancel` 会保留 + // cancel_requested 标记以阻止迟到的 approval resolve;因此必须在 + // 通用取消分支前先拒绝它,否则该标记会把 reconciling 误当成 queued + // 并被 finish_unclaimed_cancelled 终态化。 + if record.status == "reconciling" { + return Err(HostError::Config(format!( + "run 处于 reconciling,完成外部调用对账后才能继续: {run_id}" + ))); + } + if record.cancel_requested || record.status == "cancel_requested" { + // running/cancel_requested 可能来自已经退出的进程,即使旧的 + // lease 行已经被清掉也不能证明外部调用没有发生。先走 Runtime + // 的 stale probe,把它放进 reconciliation gate;只有 queued + //(尚未触发 Engine)才允许直接收束为 cancelled。 + if matches!(record.status.as_str(), "running" | "cancel_requested") { + if let Some(recovered) = self.runtime.reconcile_expired_run_if_stale(run_id)? { + return Err(HostError::Config(format!( + "取消 run 的 lease 已失效,已自动进入 reconciling({});先完成外部调用对账: {run_id}", + recovered.status + ))); + } + return Err(HostError::Config(format!( + "run 的取消收口仍由持有 lease 的 worker 负责: {run_id}" + ))); + } + // 一个 queued run 尚未启动 Engine;没有 lease 时可以按显式 + // cancel 命令直接完成终态。异常残留 lease 仍拒绝越权收口。 + if self.runtime.get_run_lease(run_id)?.is_some() { + return Err(HostError::Config(format!( + "run 的取消收口仍由持有 lease 的 worker 负责: {run_id}" + ))); + } + self.finish_unclaimed_cancelled(record)?; + return Err(EngineError::Cancelled.into()); + } + if record.status == "queued" { + // Provider identity is part of the durable run contract. Validate it + // before claiming the lease so a reopened Host cannot silently use a + // different instance/protocol. + self.validate_persisted_provider_target(&record)?; + // CLI background metadata adds `providerKind` as an explicit marker; + // validate its persisted model before any lease or external call. + self.validate_persisted_provider_metadata(&record)?; + let lease = WorkerLease::new(run_id); + let (claimed, lease_record) = + self.runtime + .claim_run_with_lease(run_id, &lease, WORKER_LEASE_DURATION)?; + let checkpoint = match self.runtime.read_checkpoint_with_lease(run_id, &lease) { + Ok(checkpoint) => checkpoint, + Err(error) => { + // claim 成功后读取 checkpoint 失败也不能把 lease 留成 + // 无主 running;此时尚未启动外部调用,直接收束为 failed。 + return Err(self.fail_claimed_setup( + &claimed, + &lease, + host_error_from_runtime(error), + )); + } + }; + let approval_resume = match checkpoint.as_ref().map(|value| value.phase.as_str()) { + None | Some("safe") => None, + Some("awaiting_approval") => { + let Some(call_id) = checkpoint + .as_ref() + .and_then(|value| value.tool_call_id.as_deref()) + else { + let _ = self.runtime.release(run_id, &lease); + return Err(HostError::Config(format!( + "awaiting_approval checkpoint 缺少 tool_call_id: {run_id}" + ))); + }; + let approval = match self.runtime.get_approval_for_run_call(run_id, call_id) { + Ok(approval) => approval, + Err(error) => { + let _ = self.runtime.release(run_id, &lease); + return Err(host_error_from_runtime(error)); + } + }; + let Some(approval) = approval else { + let _ = self.runtime.release(run_id, &lease); + return Err(HostError::Config(format!( + "run 缺少 durable approval,请等待控制端 resolve: {run_id}" + ))); + }; + match approval.status.as_str() { + "allowed" | "denied" => { + match approval_resume_from_record( + &approval, + checkpoint + .as_ref() + .and_then(|value| value.provider_request_id.as_deref()), + ) { + Ok(resume) => Some(resume), + Err(error) => { + // The approval may have expired or its + // persisted binding may be corrupt. Do + // not leave the freshly claimed run in a + // worker-owned running state on this + // pre-Engine validation failure. + let _ = self.runtime.release(run_id, &lease); + return Err(error); + } + } + } + "pending" => { + let _ = self.runtime.release(run_id, &lease); + return Err(HostError::Config(format!( + "approval 尚未 resolve,不能启动 run: {}", + approval.id + ))); + } + status => { + let _ = self.runtime.release(run_id, &lease); + return Err(HostError::Config(format!( + "approval 状态 {} 不允许恢复: {}", + status, approval.id + ))); + } + } + } + Some(phase) => { + let _ = self.runtime.release(run_id, &lease); + return Err(HostError::Config(format!( + "run 有未对账的 {} checkpoint,不能启动;先完成外部调用对账: {run_id}", + phase + ))); + } + }; + return self.run_claimed_with_lease( + claimed, + lease, + lease_record.attempt, + checkpoint, + approval_resume, + cancellation, + streaming, + ); + } + if matches!(record.status.as_str(), "running" | "cancel_requested") { + // A restarted process may still leave a durable running row after + // its worker lease expires. Reconcile that stale row at the + // execution boundary so callers do not need a separate startup + // scanner; the Runtime gate never replays unknown side effects. + if let Some(recovered) = self.runtime.reconcile_expired_run_if_stale(run_id)? { + return Err(HostError::Config(format!( + "run lease 已过期,已自动进入 reconciling({});先完成外部调用对账: {run_id}", + recovered.status + ))); + } + return Err(HostError::Config(format!( + "run 已被其它 worker 领取;lease 尚未过期,不能启动第二个 worker: {run_id}" + ))); + } + Err(HostError::Config(format!( + "run 当前状态 {} 不可执行: {run_id}", + record.status + ))) + } + + fn finish_unclaimed_cancelled(&self, record: RunRecord) -> Result<(), HostError> { + self.runtime + .finish_unclaimed_cancelled_if_safe(&record.id) + .map(|_| ()) + .map_err(host_error_from_runtime) + } + + /// claim 后、Engine 启动前的本地准备失败出口。 + /// + /// 此阶段还没有触发 Provider/工具副作用,直接用 fenced failed CAS 收口是 + /// 安全的;若期间收到 cancel,则优先完成 cancelled。任何收口失败都至少 + /// 释放 lease 转入 reconciliation,避免留下无主 running 记录。 + fn fail_claimed_setup( + &self, + record: &RunRecord, + lease: &WorkerLease, + error: HostError, + ) -> HostError { + let error_text = error.to_string(); + let runtime_id = match self.runtime.runtime_id_for_run(&record.id) { + Ok(Some(runtime_id)) => runtime_id, + Ok(None) | Err(_) => { + return self.release_setup_to_reconciliation(record, lease, error); + } + }; + let mut runtime_snapshot = match self.runtime.load_runtime_snapshot(&runtime_id) { + Ok(Some(snapshot)) => snapshot, + Ok(None) | Err(_) => { + return self.release_setup_to_reconciliation(record, lease, error); + } + }; + let mut next_runtime = runtime_snapshot.clone(); + let mut events = Vec::new(); + if let Some(run) = next_runtime.run(&record.id) + && run.status() == agent_runtime_core::RunStatus::Pending + { + let started = match RuntimeEvent::status_changed( + &runtime_id, + next_runtime.revision() + 1, + SystemClock.now_millis(), + &record.id, + RuntimeEventKind::RunStarted, + ) { + Ok(event) => event, + Err(_) => { + return self.release_setup_with_runtime_to_reconciliation( + record, + lease, + error, + &mut runtime_snapshot, + &runtime_id, + ); + } + }; + next_runtime = match reduce_runtime_event(&next_runtime, &started) { + Ok(snapshot) => snapshot, + Err(_) => { + return self.release_setup_with_runtime_to_reconciliation( + record, + lease, + error, + &mut runtime_snapshot, + &runtime_id, + ); + } + }; + events.push(started); + } + if let Some(run) = next_runtime.run(&record.id) + && !run.status().is_terminal() + { + let failed = match RuntimeEvent::failed( + &runtime_id, + next_runtime.revision() + 1, + SystemClock.now_millis(), + &record.id, + error_text, + ) { + Ok(event) => event, + Err(_) => { + return self.release_setup_with_runtime_to_reconciliation( + record, + lease, + error, + &mut runtime_snapshot, + &runtime_id, + ); + } + }; + next_runtime = match reduce_runtime_event(&next_runtime, &failed) { + Ok(snapshot) => snapshot, + Err(_) => { + return self.release_setup_with_runtime_to_reconciliation( + record, + lease, + error, + &mut runtime_snapshot, + &runtime_id, + ); + } + }; + events.push(failed); + } + if events.is_empty() { + return self.release_setup_with_runtime_to_reconciliation( + record, + lease, + error, + &mut runtime_snapshot, + &runtime_id, + ); + } + // The snapshot came from an existing RuntimeStore row. Preserve a + // legitimate revision zero as `Some(0)`; `None` means "row absent". + let expected = Some(runtime_snapshot.revision()); + match self.runtime.fail_run_with_lease_and_runtime( + &record.id, + lease, + Some(json!({"error": error.to_string()})), + &runtime_id, + expected, + &next_runtime, + &events, + ) { + Ok(_) => error, + Err(_storage_error) + if self + .runtime + .is_cancel_requested(&record.id) + .unwrap_or(false) => + { + match finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + &record.id, + Some(lease), + ) { + Ok(()) => EngineError::Cancelled.into(), + Err(cancel_error) => cancel_error, + } + } + Err(storage_error) => { + // 原子 failed CAS 可能因为 lease 在提交前过期、或 runtime + // revision 被其它恢复器推进而失败。先把可读的 runtime 推到 + // reconciling,再释放 lease;否则 run-level 会是 + // reconciling,而 runtime 仍停在 Pending/Running。 + self.release_setup_with_runtime_to_reconciliation( + record, + lease, + host_error_from_runtime(storage_error), + &mut runtime_snapshot, + &runtime_id, + ) + } + } + } + + /// setup 收口的最后一道安全边界:先用 fencing CAS 释放 run-level lease, + /// 再把已加载的 runtime 快照送入 `Reconciling`。run-level gate 是其它 + /// worker 能观察到的抢占边界;如果进程在两步之间退出,Runtime 的 stale + /// recovery 会补齐 runtime/session,而不会让一个已经失去 lease 的 worker + /// 继续把 runtime-only 事件写到新 owner 的 run 上。 + fn release_setup_with_runtime_to_reconciliation( + &self, + record: &RunRecord, + lease: &WorkerLease, + error: HostError, + runtime_snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + ) -> HostError { + let original_error = error.to_string(); + // failed CAS 常见原因是 lease 过期/丢失;也可能是另一个恢复器刚好 + // 推进了 runtime revision。先释放 lease,确保旧 worker 不再拥有任何 + // 可写的 run 边界;若 fencing 失败,直接返回而不触碰 runtime。 + if let Err(release_error) = self.runtime.release(&record.id, lease) { + return host_error_from_runtime(release_error); + } + + // run 已经是 reconciling 后再提交 runtime-only gate。即使这里失败, + // stale recovery 仍可从 run-level gate 重新读取当前快照并补齐状态。 + if let Err(runtime_error) = + reconcile_runtime_with_retry(&self.runtime, runtime_snapshot, runtime_id, &record.id) + { + return HostError::Config(format!( + "setup 失败,run 已进入 reconciling 但 runtime 尚未对齐: {original_error}; {runtime_error}" + )); + } + if let Err(session_error) = + self.runtime + .update_session(&record.session_id, "reconciling", None) + { + return host_error_from_runtime(session_error); + } + error + } + + fn release_setup_to_reconciliation( + &self, + record: &RunRecord, + lease: &WorkerLease, + error: HostError, + ) -> HostError { + match self.runtime.release(&record.id, lease) { + Ok(_) => { + let _ = self + .runtime + .update_session(&record.session_id, "reconciling", None); + error + } + Err(runtime_error) => host_error_from_runtime(runtime_error), + } + } + + fn checkpoint_phase(&self, run_id: &str) -> Result, HostError> { + Ok(self + .runtime + .read_checkpoint(run_id)? + .map(|checkpoint| checkpoint.phase)) + } + + fn checkpoint_is_unknown_external(&self, run_id: &str) -> Result { + Ok(matches!( + self.checkpoint_phase(run_id)?.as_deref(), + Some("compacting" | "provider_in_flight" | "tool_in_flight") + )) + } + + /// 保留可能已经触发外部副作用的游标,并把两条运行流都放进 + /// reconciliation gate。这个出口不清 checkpoint,也不尝试重放。 + fn preserve_inflight_failure( + &self, + record: &RunRecord, + lease: &WorkerLease, + runtime_snapshot: &mut RuntimeSnapshot, + error: HostError, + ) -> HostError { + // 先提交 Core 的 reconciliation gate,再释放 lease。若进程恰好在 + // 两步之间退出,过期恢复仍会看到已对齐的 runtime;反过来先释放 + // lease 会留下 run=reconciling、runtime=running 的不可解释窗口。 + if let Some(run) = runtime_snapshot.run(&record.id) + && !run.status().is_terminal() + && run.status() != agent_runtime_core::RunStatus::Reconciling + { + let event = match RuntimeEvent::status_changed( + &runtime_snapshot.runtime_id, + runtime_snapshot.revision() + 1, + SystemClock.now_millis(), + &record.id, + RuntimeEventKind::ReconciliationRequired, + ) { + Ok(event) => event, + Err(event_error) => return HostError::Config(event_error.to_string()), + }; + if let Err(commit_error) = commit_runtime_event(&self.runtime, runtime_snapshot, event) + { + return commit_error; + } + } + if let Err(session_error) = + self.runtime + .update_session(&record.session_id, "reconciling", None) + { + return host_error_from_runtime(session_error); + } + if let Err(cleanup_error) = self.runtime.release(&record.id, lease) { + return host_error_from_runtime(cleanup_error); + } + error + } + + /// 处理 Engine 返回的非取消错误。只要 durable checkpoint 仍在 + /// in-flight,就不能把它当普通失败清掉;否则才允许进入 failed 终态。 + fn settle_engine_error( + &self, + record: &RunRecord, + lease: &WorkerLease, + runtime_snapshot: &mut RuntimeSnapshot, + error: HostError, + ) -> HostError { + let phase = match self.checkpoint_phase(&record.id) { + Ok(phase) => phase, + Err(checkpoint_error) => return checkpoint_error, + }; + let preserve = match phase.as_deref() { + Some("compacting" | "provider_in_flight" | "tool_in_flight") => true, + // Ask 是一个本地审批挂起点,没有工具副作用;只有明确的 + // ApprovalRequired 才保留它等待上层决定,普通 policy 错误可失败收口。 + Some("awaiting_approval") => matches!( + &error, + HostError::Engine(EngineError::ApprovalRequired { .. }) + ), + _ => false, + }; + if preserve { + return self.preserve_inflight_failure(record, lease, runtime_snapshot, error); + } + + let error_text = error.to_string(); + let mut terminal_events = Vec::new(); + let mut next_runtime = runtime_snapshot.clone(); + if let Some(run) = next_runtime.run(&record.id) + && run.status() == agent_runtime_core::RunStatus::Pending + { + // Engine 启动前的极窄失败窗口仍要让 Core 经过合法生命周期, + // 再在同一终态事务中写入 failed,而不是留下 Pending runtime。 + let started = RuntimeEvent::status_changed( + &next_runtime.runtime_id, + next_runtime.revision() + 1, + SystemClock.now_millis(), + &record.id, + RuntimeEventKind::RunStarted, + ) + .map_err(|event_error| HostError::Config(event_error.to_string())); + let started = match started { + Ok(event) => event, + Err(event_error) => return event_error, + }; + next_runtime = match reduce_runtime_event(&next_runtime, &started) { + Ok(snapshot) => snapshot, + Err(reduce_error) => return reduce_error, + }; + terminal_events.push(started); + } + if let Some(run) = next_runtime.run(&record.id) + && !run.status().is_terminal() + { + let failed = match RuntimeEvent::failed( + &next_runtime.runtime_id, + next_runtime.revision() + 1, + SystemClock.now_millis(), + &record.id, + error_text, + ) { + Ok(event) => event, + Err(event_error) => return HostError::Config(event_error.to_string()), + }; + next_runtime = match reduce_runtime_event(&next_runtime, &failed) { + Ok(snapshot) => snapshot, + Err(reduce_error) => return reduce_error, + }; + terminal_events.push(failed); + } + if terminal_events.is_empty() { + return error; + } + // A loaded runtime row remains present even when its revision is zero. + let expected_runtime_revision = Some(runtime_snapshot.revision()); + match self.runtime.fail_run_with_lease_and_runtime( + &record.id, + lease, + Some(json!({"error": error.to_string()})), + &runtime_snapshot.runtime_id, + expected_runtime_revision, + &next_runtime, + &terminal_events, + ) { + Ok(_) => { + *runtime_snapshot = next_runtime; + error + } + Err(_storage_error) + if self + .runtime + .is_cancel_requested(&record.id) + .unwrap_or(false) => + { + if let Err(cancel_error) = finish_cancelled( + &self.runtime, + runtime_snapshot, + &runtime_snapshot.runtime_id.clone(), + &record.id, + Some(lease), + ) { + return cancel_error; + } + EngineError::Cancelled.into() + } + Err(storage_error) => host_error_from_runtime(storage_error), + } + } + + #[allow(clippy::too_many_arguments)] + fn run_claimed_with_lease( + &self, + record: RunRecord, + lease: WorkerLease, + attempt: i64, + checkpoint: Option, + approval_resume: Option, + cancellation: Cancellation, + streaming: bool, + ) -> Result { + let run_id = record.id.as_str(); + // claim 已经成功;下面直到 Engine 启动前都属于本地准备阶段。 + // 任一准备错误都走同一个清理出口,避免留下无主 lease。 + macro_rules! setup_or_fail { + ($expression:expr) => { + match $expression { + Ok(value) => value, + Err(error) => { + let error: HostError = error.into(); + return Err(self.fail_claimed_setup(&record, &lease, error)); + } + } + }; + } + + let runtime_id = match setup_or_fail!(self.runtime.runtime_id_for_run(run_id)) { + Some(runtime_id) => runtime_id, + None => { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!("run 缺少 runtime 身份: {run_id}")), + )); + } + }; + let mut runtime_snapshot = + match setup_or_fail!(self.runtime.load_runtime_snapshot(&runtime_id)) { + Some(snapshot) => snapshot, + None => { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!("找不到 runtime: {runtime_id}")), + )); + } + }; + let run_snapshot = match runtime_snapshot.run(run_id).cloned() { + Some(snapshot) => snapshot, + None => { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!("runtime 中找不到 run: {run_id}")), + )); + } + }; + + if setup_or_fail!(self.runtime.is_cancel_requested(run_id)) { + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + run_id, + Some(&lease), + )?; + return Err(EngineError::Cancelled.into()); + } + if run_snapshot.status().is_terminal() { + let _ = self.runtime.release(run_id, &lease); + return Err(HostError::Config(format!( + "run 已结束: {:?}", + run_snapshot.status() + ))); + } + + if let Some(checkpoint) = &checkpoint { + // safe checkpoint 通常来自刚刚结束的上一轮 worker;requeue 后 + // claim 会把 attempt 加一。允许这一个精确的前代,拒绝更旧或 + // 未来代,避免把陈旧消息历史接到新执行上。 + if checkpoint.attempt != attempt && checkpoint.attempt.saturating_add(1) != attempt { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!("checkpoint attempt 与当前 worker 不一致: {run_id}")), + )); + } + if runtime_snapshot + .run(run_id) + .is_none_or(|run| run.status() != agent_runtime_core::RunStatus::Reconciling) + { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!( + "safe checkpoint 要求 runtime 处于 reconciling: {run_id}" + )), + )); + } + let restore_result = match checkpoint.phase.as_str() { + "safe" => restore_safe_checkpoint( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + run_id, + checkpoint, + ), + "awaiting_approval" => { + let Some(resume) = approval_resume.as_ref() else { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!( + "awaiting_approval checkpoint 尚未取得 resolved approval: {run_id}" + )), + )); + }; + if checkpoint.tool_call_id.as_deref() != Some(resume.call_id.as_str()) + || checkpoint.next_step != checkpoint.step + { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!( + "approval resume 与 checkpoint 不一致: {run_id}" + )), + )); + } + restore_approval_checkpoint( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + run_id, + checkpoint, + ) + } + phase => Err(HostError::Config(format!( + "未知 checkpoint phase,不能恢复: {phase}" + ))), + }; + if let Err(error) = restore_result { + return Err(self.fail_claimed_setup(&record, &lease, error)); + } + } else if runtime_snapshot + .run(run_id) + .is_some_and(|run| run.status() == agent_runtime_core::RunStatus::Reconciling) + { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config(format!( + "run 处于 reconciling 但没有 checkpoint,不能启动: {run_id}" + )), + )); + } else if run_snapshot.status() == agent_runtime_core::RunStatus::Pending { + let started = RuntimeEvent::status_changed( + &runtime_id, + runtime_snapshot.revision() + 1, + SystemClock.now_millis(), + &record.id, + RuntimeEventKind::RunStarted, + ) + .map_err(|error| HostError::Config(error.to_string())); + let started = setup_or_fail!(started); + setup_or_fail!(commit_runtime_event( + &self.runtime, + &mut runtime_snapshot, + started + )); + } + if setup_or_fail!(self.runtime.is_cancel_requested(run_id)) { + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + run_id, + Some(&lease), + )?; + return Err(EngineError::Cancelled.into()); + } + setup_or_fail!( + self.runtime + .update_session(&record.session_id, "running", None) + .map_err(host_error_from_runtime) + ); + + let (messages, start_step) = if let Some(checkpoint) = &checkpoint { + let messages = setup_or_fail!( + serde_json::from_value::>(checkpoint.messages.clone()) + .map_err(|error| HostError::Config(format!("checkpoint 消息无效: {error}"))) + ); + if messages.is_empty() { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config("checkpoint 没有可执行的消息历史".to_owned()), + )); + } + let start_step = setup_or_fail!( + usize::try_from(checkpoint.next_step) + .map_err(|_| HostError::Config("checkpoint next_step 无效".to_owned())) + ); + (messages, start_step) + } else { + let messages = match runtime_snapshot + .run(&record.id) + .map(|run| run.messages().to_vec()) + .filter(|messages| !messages.is_empty()) + { + Some(messages) => messages, + None => { + return Err(self.fail_claimed_setup( + &record, + &lease, + HostError::Config("run 没有可执行的消息历史".to_owned()), + )); + } + }; + (messages, 0) + }; + + // watcher 同时负责 SQLite 取消轮询和 lease 心跳。失去 lease 时立即 + // 停止 Engine;当前同步 Provider/工具返回后不会再写入终态。 + let watcher_done = Arc::new(AtomicBool::new(false)); + let lease_lost = Arc::new(AtomicBool::new(false)); + let watcher_done_clone = watcher_done.clone(); + let watcher_lease_lost = lease_lost.clone(); + let watcher_cancel = cancellation.clone(); + let watcher_runtime = self.runtime.clone(); + let watcher_run_id = record.id.clone(); + let watcher_lease = lease.clone(); + let watcher = thread::spawn(move || { + let mut next_heartbeat = Instant::now(); + while !watcher_done_clone.load(Ordering::Acquire) { + if watcher_runtime + .is_cancel_requested(&watcher_run_id) + .unwrap_or(false) + { + watcher_cancel.cancel(); + } + if Instant::now() >= next_heartbeat { + if watcher_runtime + .heartbeat(&watcher_run_id, &watcher_lease, WORKER_LEASE_DURATION) + .is_err() + { + watcher_lease_lost.store(true, Ordering::Release); + watcher_cancel.cancel(); + break; + } + next_heartbeat = Instant::now() + WORKER_HEARTBEAT_INTERVAL; + } + thread::sleep(Duration::from_millis(25)); + } + }); + + let pending_tool_calls = Arc::new(Mutex::new(BTreeMap::new())); + let collected = CollectedEvents::with_pending_tool_calls(pending_tool_calls.clone()); + let checkpoints = DurableCheckpoints { + runtime: self.runtime.clone(), + runtime_id: runtime_id.clone(), + run_id: record.id.clone(), + session_id: record.session_id.clone(), + lease: lease.clone(), + attempt, + pending_tool_calls, + observed: &collected, + trace_progress: Mutex::new(RuntimeTraceProgress::default()), + compaction_open: Mutex::new(false), + }; + // Registry target 的协议校验必须发生在 Engine 触发 Provider 之前。 + // `setup_or_fail!` 会把配置错误收束到当前 fenced run,避免留下无主 + // lease;直接 `with_provider` 的兼容路径仍返回原先注入的 Arc。 + let provider = setup_or_fail!(self.provider_for_execution()); + let mut engine = AgentEngine::new( + provider.as_ref(), + self.tools.as_ref(), + self.approval.as_ref(), + ) + .with_cancellation(cancellation); + for source in &self.context_sources { + engine = engine.with_context_source(source.clone()); + } + if let Some(compressor) = self.context_compressor.as_deref() { + engine = engine.with_context_compressor(compressor); + } + // The run id is the stable request namespace across attempts. Using + // the mutable run revision here would change the approval request id + // after a durable Ask and make its binding impossible to resume. + let input = AgentInput::new(record.id.clone(), &self.model, messages) + .with_session_id(record.session_id.clone()) + .with_run_id(record.id.clone()) + .with_tools(self.tools.definitions().to_vec()) + .with_start_step(start_step); + let input = if let Some(resume) = approval_resume { + let mut input = input + .with_approval_resume(resume.call_id, resume.decision) + .with_approval_resume_binding( + resume.arguments_hash, + resume.approval_token, + resume.expires_at_ms, + ); + if let Some(provider_request_id) = resume.provider_request_id { + input = input.with_approval_resume_provider_request_id(provider_request_id); + } + input + } else { + input + }; + let output = engine + .with_listener(&collected) + .with_checkpoint_listener(&checkpoints); + let output = if streaming { + output.run_streaming(input) + } else { + output.run(input) + }; + watcher_done.store(true, Ordering::Release); + let _ = watcher.join(); + + // The checkpoint listener projects the explicit Core compaction + // boundary before invoking the compressor. Refresh the local snapshot + // before replaying the remaining Engine trace so its CAS starts from + // that committed revision instead of trying to append over it. + if let Some(latest) = setup_or_fail!(self.runtime.load_runtime_snapshot(&runtime_id)) { + runtime_snapshot = latest; + } + + if lease_lost.load(Ordering::Acquire) { + // 不尝试用旧 token 写回 failed/cancelled;让 recovery 扫描把 + // 过期运行送入 reconciling,等待外部副作用对账。 + return Err(StorageError::LeaseLost { + id: run_id.to_owned(), + } + .into()); + } + + let observed_events = match &output { + Ok(output) => output.events.clone(), + Err(_) => match collected.snapshot() { + Ok(events) => events, + Err(error) => { + let error = + self.settle_engine_error(&record, &lease, &mut runtime_snapshot, error); + return Err(error); + } + }, + }; + let cancellation_requested = self.runtime.is_cancel_requested(run_id).unwrap_or(false); + // Finished 要等 run-level 的 CAS 完成后再写入 Core。这样 cancel + // 请求与完成收口竞争时,不会先把 RuntimeSnapshot 推成 completed。 + let trace_events = observed_events + .iter() + .filter(|event| !matches!(event, EngineEvent::Finished { .. })) + .cloned() + .collect::>(); + // Ask is a durable human-in-the-loop boundary. Persist its exact + // binding before the observational audit/trace writes below: if the + // process exits while recording an audit row, the awaiting checkpoint + // still has a recoverable pending approval instead of a cursor with no + // control-plane entry. The approval transaction itself is fenced by + // the same lease and runtime snapshot; it does not cover the earlier + // Engine callback-to-binding construction window. + if let Err(EngineError::ApprovalRequired { .. }) = output.as_ref() + && let Err(error) = self.persist_approval_required( + &record, + &lease, + &runtime_id, + &runtime_snapshot, + &output, + &observed_events, + ) + { + let error = + self.preserve_inflight_failure(&record, &lease, &mut runtime_snapshot, error); + return Err(error); + } + // A successful response without tools has no intermediate Core event + // that must be visible before the terminal boundary. Defer its + // assistant message so the existing terminal transaction can commit + // the message and Completed event together. Tool/approval batches keep + // their earlier durable cursors and are never folded into this path. + let defer_terminal_runtime = output.is_ok() + && !cancellation_requested + && !observed_events.iter().any(|event| { + matches!( + event, + EngineEvent::ToolRequested { .. } | EngineEvent::ToolCompleted { .. } + ) + }); + if !observed_events.is_empty() { + if let Err(error) = persist_engine_events( + &self.runtime, + &record.id, + &lease, + &observed_events, + self.durable_event_listener.as_deref(), + ) { + let error = self.settle_engine_error(&record, &lease, &mut runtime_snapshot, error); + return Err(error); + } + if !defer_terminal_runtime + && !trace_events.is_empty() + && let Err(error) = checkpoints.persist_trace(&mut runtime_snapshot, &trace_events) + { + let error = self.settle_engine_error(&record, &lease, &mut runtime_snapshot, error); + return Err(error); + } + } + + match output { + Ok(output) if !cancellation_requested => { + let (next_runtime, terminal_events) = if defer_terminal_runtime { + build_terminal_runtime_batch( + &runtime_snapshot, + &runtime_id, + &record.id, + &trace_events, + &output.text, + )? + } else { + let completed_event = RuntimeEvent::completed( + &runtime_id, + runtime_snapshot.revision() + 1, + SystemClock.now_millis(), + &record.id, + output.text.clone(), + ) + .map_err(|error| HostError::Config(error.to_string()))?; + let next_runtime = reduce_runtime_event(&runtime_snapshot, &completed_event)?; + (next_runtime, vec![completed_event]) + }; + // RuntimeStore distinguishes an existing revision-zero row + // (`Some(0)`) from an absent row (`None`). + let expected_runtime_revision = Some(runtime_snapshot.revision()); + let completed = match self.runtime.complete_run_with_lease_and_runtime( + run_id, + &lease, + Some(json!({"text": output.text, "steps": output.steps})), + &runtime_id, + expected_runtime_revision, + &next_runtime, + &terminal_events, + ) { + Ok(record) => record, + Err(_error) if self.runtime.is_cancel_requested(run_id).unwrap_or(false) => { + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + &record.id, + Some(&lease), + )?; + return Err(EngineError::Cancelled.into()); + } + Err(error) => return Err(host_error_from_runtime(error)), + }; + if completed.status != "completed" { + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + run_id, + Some(&lease), + )?; + return Err(EngineError::Cancelled.into()); + } + Ok(HostRunOutput { + session_id: record.session_id, + run_id: run_id.to_owned(), + runtime_id, + output, + }) + } + Ok(_) => { + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + &record.id, + Some(&lease), + )?; + Err(EngineError::Cancelled.into()) + } + Err(EngineError::Cancelled) => { + if self.checkpoint_is_unknown_external(run_id)? { + let error = self.preserve_inflight_failure( + &record, + &lease, + &mut runtime_snapshot, + EngineError::Cancelled.into(), + ); + return Err(error); + } + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + &record.id, + Some(&lease), + )?; + Err(EngineError::Cancelled.into()) + } + Err(error) if cancellation_requested => { + if self.checkpoint_is_unknown_external(run_id)? { + let error = self.preserve_inflight_failure( + &record, + &lease, + &mut runtime_snapshot, + error.into(), + ); + return Err(error); + } + finish_cancelled( + &self.runtime, + &mut runtime_snapshot, + &runtime_id, + run_id, + Some(&lease), + )?; + Err(EngineError::Cancelled.into()) + } + Err(error) => { + let error = + self.settle_engine_error(&record, &lease, &mut runtime_snapshot, error.into()); + Err(error) + } + } + } + + fn persist_approval_required( + &self, + record: &RunRecord, + lease: &WorkerLease, + runtime_id: &str, + runtime_snapshot: &RuntimeSnapshot, + output: &Result, + observed_events: &[EngineEvent], + ) -> Result<(), HostError> { + let error = match output { + Err(error) => error, + Ok(_) => return Ok(()), + }; + let EngineError::ApprovalRequired { + request_id, + call_id, + arguments_hash, + approval_token, + expires_at_ms, + } = error + else { + return Ok(()); + }; + let call = observed_events.iter().rev().find_map(|event| match event { + EngineEvent::ToolRequested { call, .. } if call.id() == call_id => Some(call.clone()), + _ => None, + }); + let call = call.ok_or_else(|| { + HostError::Config(format!( + "ApprovalRequired 缺少对应 ToolRequested 事件: {call_id}" + )) + })?; + let checkpoint = self + .runtime + .read_checkpoint(&record.id)? + .ok_or_else(|| HostError::Config(format!("审批缺少 checkpoint: {}", record.id)))?; + if checkpoint.phase != "awaiting_approval" + || checkpoint.tool_call_id.as_deref() != Some(call_id.as_str()) + { + return Err(HostError::Config(format!( + "审批 checkpoint 与请求不一致: {request_id}" + ))); + } + // Rebuild the canonical request from the exact binding emitted by the + // Engine. A mismatch means an event/checkpoint was mixed across runs + // and must never be persisted as a resumable approval. + let approval_request = ApprovalRequest::try_new_with_expiry( + request_id.clone(), + record.id.clone(), + call, + *expires_at_ms, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + if approval_request.arguments_hash() != arguments_hash + || approval_request.approval_token() != approval_token + || approval_request.is_expired(SystemClock.now_millis()) + { + return Err(HostError::Config(format!( + "ApprovalRequired binding 无效或已过期: {request_id}" + ))); + } + let request = serde_json::to_value(approval_request) + .map_err(|error| HostError::Config(format!("审批请求无法编码: {error}")))?; + self.runtime + .create_approval_with_checkpoint_runtime_and_lease( + NewApproval { + id: request_id.clone(), + session_id: record.session_id.clone(), + run_id: record.id.clone(), + tool_call_id: Some(call_id.clone()), + status: "pending".to_owned(), + request, + arguments_hash: arguments_hash.clone(), + approval_token: approval_token.clone(), + expires_at_ms: i64::try_from(*expires_at_ms).map_err(|_| { + HostError::Config(format!("审批 expires_at_ms 超出存储范围: {request_id}")) + })?, + }, + checkpoint_input_from_record(checkpoint), + lease, + runtime_id, + Some(runtime_snapshot.revision()), + runtime_snapshot, + ) + .map_err(host_error_from_runtime)?; + Ok(()) + } + + /// 发出跨进程可见的 cooperative cancel 请求。 + /// + /// 只有尚未领取的 queued run,或已经有 `safe`/`awaiting_approval` + /// checkpoint 的无主 run,才能由控制端直接写入 cancelled。running、 + /// cancel_requested 和 reconciling 的未知/未对账边界一律先进入 + /// reconciliation gate;这样过期 lease 或历史无 lease 不能被误判成 + /// “没有外部副作用”。持有有效 lease 的 worker 仍走 cooperative 路径, + /// 等待当前 Provider/工具调用返回后再收口。 + pub fn cancel(&self, run_id: &str) -> Result { + // 保留 request_cancel 前的状态:request_cancel 会把 queued/running/ + // reconciling 等状态统一改成 cancel_requested,之后已无法区分 + // “从未启动”的 queued 和“可能已经触发外部调用”的历史 running。 + let before = self + .runtime + .get_run(run_id)? + .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; + // 在发出 request_cancel 之前,queued run 先走 Runtime 的原子 + // expected-queued + 无 checkpoint/lease 入口,保留“尚未启动”的 + // 可辨识状态。若 worker 在此期间赢得领取竞争,入口返回 None; + // 这不是错误,下面的 request_cancel 会把它转成 cooperative gate。 + // 最终 predicate 在同一 SQLite 写事务内校验,避免旧的读后写窗口。 + if before.status == "queued" + && let Some(cancelled) = self.runtime.finish_queued_cancelled_if_unclaimed(run_id)? + { + self.runtime + .cancel_pending_approvals(run_id) + .map_err(host_error_from_runtime)?; + return Ok(cancelled); + } + + let record = self.runtime.request_cancel(run_id)?; + + // 终态请求保持幂等;不要为了读取 checkpoint 或做 stale probe + // 重新触碰一个已经完成的 runtime。 + if matches!(record.status.as_str(), "completed" | "failed" | "cancelled") { + self.runtime + .cancel_pending_approvals(run_id) + .map_err(host_error_from_runtime)?; + return Ok(record); + } + + let checkpoint = self.runtime.read_checkpoint(run_id)?; + let lease = self.runtime.get_run_lease(run_id)?; + let now = SystemClock.now_millis().min(i64::MAX as u64) as i64; + let lease_active = lease + .as_ref() + .is_some_and(|value| value.lease_expires_at > now); + let safe_checkpoint = checkpoint + .as_ref() + .is_some_and(|value| matches!(value.phase.as_str(), "safe" | "awaiting_approval")); + + // queued 没有 Engine/外部调用游标;safe 和 awaiting_approval 也 + // 明确表示没有未知的 Provider/工具副作用。过期 lease 先经过 + // stale probe 清掉 fencing,再走同一个 runtime-aware 终态事务。 + let can_finish_unclaimed = !lease_active && safe_checkpoint; + if can_finish_unclaimed { + if lease.is_some() { + // 过期 lease 不能直接调用无 lease 终态 API;先让 Runtime + // 把运行边界收进 reconciliation,再确认 fencing 已清理。 + if self + .runtime + .reconcile_expired_run_if_stale(run_id)? + .is_none() + { + // lease 可能在读取后被续期或被其它 worker 接管;保守 + // 地保留 cancel_requested,不越权写终态。 + self.runtime + .cancel_pending_approvals(run_id) + .map_err(host_error_from_runtime)?; + return Ok(self.runtime.get_run(run_id)?.unwrap_or(record)); + } + } + if self.runtime.get_run_lease(run_id)?.is_none() { + let cancelled = self + .runtime + .finish_unclaimed_cancelled_if_safe(run_id) + .map_err(host_error_from_runtime)?; + self.runtime + .cancel_pending_approvals(run_id) + .map_err(host_error_from_runtime)?; + return Ok(cancelled); + } + } + + if !lease_active { + // 无 lease、过期 lease,以及已经落在 reconciling 的历史记录, + // 都必须先经过同一个 recovery gate。该入口只写状态/事件, + // 保留 provider_in_flight、tool_in_flight、compacting 或缺失 + // checkpoint 的未知边界,不会自动重放外部调用。 + if let Some(recovered) = self.runtime.reconcile_expired_run_if_stale(run_id)? { + self.runtime + .cancel_pending_approvals(run_id) + .map_err(host_error_from_runtime)?; + return Ok(recovered); + } + } + + // A worker may still be unwinding a local approval gate. Marking the + // pending request cancelled here prevents a late control-plane resolve + // from re-queueing a run after the explicit cancel wins. + self.runtime + .cancel_pending_approvals(run_id) + .map_err(host_error_from_runtime)?; + Ok(record) + } +} + +fn finish_cancelled( + runtime: &RuntimeService, + runtime_snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, + lease: Option<&WorkerLease>, +) -> Result<(), HostError> { + // 取消请求可能先于 worker 到达;只补一次 Core 终态事件,避免重复 + // cancel 命令或 worker 竞态把 reducer 推进两次。正常路径把 run、runtime、 + // session 和 checkpoint 放在同一个 SQLite 事务中收口,避免跨表半提交。 + if runtime_snapshot.runtime_id() != runtime_id { + return Err(HostError::Config(format!( + "runtime snapshot identity 与待取消 run 不一致: {run_id}" + ))); + } + let Some(run) = runtime_snapshot.run(run_id) else { + return Err(HostError::Config(format!( + "runtime 中找不到待取消 run: {run_id}" + ))); + }; + if run.status().is_terminal() { + // 旧版本或并发控制端可能已经写入终态;专用存储入口仍负责清理 + // 遗留 checkpoint,且不会重复追加 Core 事件。 + if run.status() != agent_runtime_core::RunStatus::Cancelled { + return Err(HostError::Config(format!( + "runtime run 已处于 {:?},不能取消: {run_id}", + run.status() + ))); + } + // Do not use the run-only compatibility cleanup to project a + // runtime-only terminal event into a still non-terminal run. Such a + // split would leave the session projection stale. A repeated call is + // safe only after the durable run row is already cancelled as well. + let current = runtime + .get_run(run_id) + .map_err(host_error_from_runtime)? + .ok_or_else(|| HostError::Config(format!("找不到待取消 run: {run_id}")))?; + if !matches!(current.status.as_str(), "cancelled" | "canceled") { + return Err(HostError::Config(format!( + "run/runtime 取消终态不一致: run={} runtime=cancelled: {run_id}", + current.status + ))); + } + match lease { + Some(lease) => { + runtime + .mark_cancelled_with_lease(run_id, lease, Some(json!({"reason": "cancelled"}))) + .map_err(host_error_from_runtime)?; + } + None => { + runtime + .mark_cancelled(run_id, Some(json!({"reason": "cancelled"}))) + .map_err(host_error_from_runtime)?; + } + } + return Ok(()); + } + + let event = RuntimeEvent::status_changed( + runtime_id, + runtime_snapshot.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunCancelled, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + let next_runtime = reduce_runtime_event(runtime_snapshot, &event)?; + // This snapshot was loaded from RuntimeStore, so keep revision zero as a + // real CAS expectation instead of treating it as an insert. + let expected_runtime_revision = Some(runtime_snapshot.revision()); + match lease { + Some(lease) => { + runtime + .mark_cancelled_with_lease_and_runtime( + run_id, + lease, + Some(json!({"reason": "cancelled"})), + runtime_id, + expected_runtime_revision, + &next_runtime, + std::slice::from_ref(&event), + ) + .map_err(host_error_from_runtime)?; + } + None => { + runtime + .mark_cancelled_with_runtime( + run_id, + Some(json!({"reason": "cancelled"})), + runtime_id, + expected_runtime_revision, + &next_runtime, + std::slice::from_ref(&event), + ) + .map_err(host_error_from_runtime)?; + } + } + *runtime_snapshot = next_runtime; + Ok(()) +} + +fn approval_resume_from_record( + record: &ApprovalRecord, + provider_request_id: Option<&str>, +) -> Result { + let call_id = record + .tool_call_id + .clone() + .ok_or_else(|| HostError::Config(format!("approval 缺少 tool_call_id: {}", record.id)))?; + if record.arguments_hash.trim().is_empty() + || record.approval_token.trim().is_empty() + || record.expires_at_ms <= 0 + { + return Err(HostError::Config(format!( + "approval 缺少完整 binding: {}", + record.id + ))); + } + let request: ApprovalRequest = + serde_json::from_value(record.request.clone()).map_err(|error| { + HostError::Config(format!("approval request 无效: {}: {error}", record.id)) + })?; + if request.request_id() != record.id + || request.run_id() != record.run_id + || request.call().id() != call_id + || request.arguments_hash() != record.arguments_hash + || request.approval_token() != record.approval_token + || request.expires_at_ms() != u64::try_from(record.expires_at_ms).unwrap_or(0) + { + return Err(HostError::Config(format!( + "approval request binding 与记录不一致: {}", + record.id + ))); + } + if request.is_expired(SystemClock.now_millis()) { + return Err(HostError::Config(format!("approval 已过期: {}", record.id))); + } + let decision = match record.status.as_str() { + "allowed" => ApprovalDecision::Allow, + "denied" => { + let reason = record + .decision + .as_ref() + .and_then(|value| value.get("reason")) + .and_then(serde_json::Value::as_str) + .unwrap_or("approval denied") + .to_owned(); + ApprovalDecision::Deny { reason } + } + status => { + return Err(HostError::Config(format!( + "approval status {} 不能构造 resume: {}", + status, record.id + ))); + } + }; + Ok(ApprovalResume { + call_id, + decision, + arguments_hash: record.arguments_hash.clone(), + approval_token: record.approval_token.clone(), + expires_at_ms: u64::try_from(record.expires_at_ms).map_err(|_| { + HostError::Config(format!("approval expires_at_ms 无效: {}", record.id)) + })?, + provider_request_id: provider_request_id.map(str::to_owned), + }) +} + +fn persist_engine_events( + runtime: &RuntimeService, + run_id: &str, + lease: &WorkerLease, + events: &[EngineEvent], + listener: Option<&dyn DurableEventListener>, +) -> Result<(), HostError> { + let existing_revision = runtime + .list_events(run_id, 0) + .map_err(host_error_from_runtime)? + .last() + .map_or(0, |event| event.revision); + for (index, event) in events.iter().enumerate() { + let payload = + serde_json::to_value(event).map_err(|error| HostError::Config(error.to_string()))?; + let revision = existing_revision + index as i64; + runtime + .append_event_with_lease( + NewEvent { + id: format!("{run_id}-event-{}", revision + 1), + run_id: run_id.to_owned(), + event_type: event_type(event).to_owned(), + payload, + }, + revision, + lease, + ) + .map_err(host_error_from_runtime)?; + // append_event_with_lease commits before returning. Notify only after + // that commit, and include the revision so consumers can deduplicate + // retries without treating this as a transaction hook. + if let Some(listener) = listener { + listener.on_event_committed(run_id, revision + 1, event); + } + } + Ok(()) +} + +fn commit_runtime_event( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + event: RuntimeEvent, +) -> Result<(), HostError> { + let next = reduce_runtime_event(snapshot, &event)?; + // `snapshot` is an existing durable row; `Some(0)` is meaningful here. + let expected = Some(snapshot.revision()); + runtime.commit_runtime_snapshot(&snapshot.runtime_id, expected, &next, &[event])?; + *snapshot = next; + Ok(()) +} + +/// 只在内存中应用一个事件,供需要把 run/runtime/session 一起提交的 +/// storage 事务预先构造最终快照。真正落盘仍由 RuntimeStore/CAS 完成。 +fn reduce_runtime_event( + snapshot: &RuntimeSnapshot, + event: &RuntimeEvent, +) -> Result { + reduce(snapshot, event) + .map_err(|error| HostError::Config(format!("runtime reducer 失败: {error}"))) +} + +/// 在释放 setup lease 前,把一个仍可运行的 runtime 快照送入 +/// `Reconciling`。事件先在内存中完整归约,再以单次 CAS 提交,避免 +/// `Pending -> Running -> Reconciling` 的中间状态被恢复器观察到。 +fn reconcile_runtime_before_release( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, +) -> Result<(), HostError> { + let run = snapshot + .run(run_id) + .cloned() + .ok_or_else(|| HostError::Config(format!("runtime 中找不到 run: {run_id}")))?; + if run.status().is_terminal() { + return Err(HostError::Config(format!( + "runtime run 已处于终态 {:?},不能转入 reconciling: {run_id}", + run.status() + ))); + } + if run.status() == agent_runtime_core::RunStatus::Reconciling { + return Ok(()); + } + + let mut next = snapshot.clone(); + let mut events = Vec::new(); + if run.status() == agent_runtime_core::RunStatus::Pending { + let started = RuntimeEvent::status_changed( + runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunStarted, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + next = reduce_runtime_event(&next, &started)?; + events.push(started); + } else if run.status() == agent_runtime_core::RunStatus::Paused { + // Paused 不是正常 setup 输入,但它是 Core 可合法恢复的非终态。 + // 只为进入 reconciliation gate 补一个显式 resumed,不执行任何外部动作。 + let resumed = RuntimeEvent::status_changed( + runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunResumed, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + next = reduce_runtime_event(&next, &resumed)?; + events.push(resumed); + } + + let required = RuntimeEvent::status_changed( + runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::ReconciliationRequired, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + next = reduce_runtime_event(&next, &required)?; + events.push(required); + + // snapshot 已由 caller 从现有 runtime row 读取;即使 revision 为 0, + // 也必须用 Some(0) 表示该 row 的 CAS 期望值。 + let expected = Some(snapshot.revision()); + runtime + .commit_runtime_snapshot(runtime_id, expected, &next, &events) + .map_err(|error| { + HostError::Config(format!("runtime reconciliation commit 失败: {error}")) + })?; + *snapshot = next; + Ok(()) +} + +/// 处理 setup fallback 与其它恢复器同时推进 runtime 的单次 revision 竞态。 +/// 只重读/重试一次,避免把真正的存储故障伪装成成功;失败时由上层保留 +/// lease,让后续 recovery 仍能接管,而不是主动制造状态分裂。 +fn reconcile_runtime_with_retry( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, +) -> Result<(), HostError> { + match reconcile_runtime_before_release(runtime, snapshot, runtime_id, run_id) { + Ok(()) => Ok(()), + Err(first_error) => { + let latest = runtime + .load_runtime_snapshot(runtime_id)? + .ok_or_else(|| HostError::Config(format!("找不到 runtime: {runtime_id}")))?; + *snapshot = latest; + reconcile_runtime_before_release(runtime, snapshot, runtime_id, run_id).map_err( + |second_error| { + HostError::Config(format!( + "runtime reconciliation 首次失败: {first_error}; 重试失败: {second_error}" + )) + }, + ) + } + } +} + +/// 将 safe checkpoint 中已经完成的消息/工具结果补入 RuntimeStore。 +/// +/// worker 可能在 run-level 审计批量写入前退出,因此恢复不能只把消息交给 +/// Engine;先用 reducer 的一次 CAS 把缺失后缀补齐,再允许 `RunReconciled` +/// 暴露 Running 状态。前缀不一致时宁可停在 gate,也不猜测哪一边是真相。 +fn restore_safe_checkpoint( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, + checkpoint: &CheckpointRecord, +) -> Result<(), HostError> { + if checkpoint.phase != "safe" { + return Err(HostError::Config(format!( + "只有 safe checkpoint 可以恢复: {run_id}" + ))); + } + let expected_next_step = checkpoint.step.checked_add(1); + if expected_next_step != Some(checkpoint.next_step) { + return Err(HostError::Config(format!( + "safe checkpoint 游标无效: {run_id}" + ))); + } + if checkpoint.tool_call_id.is_some() { + return Err(HostError::Config(format!( + "safe checkpoint 不能保留未完成的 tool call: {run_id}" + ))); + } + let checkpoint_messages = + serde_json::from_value::>(checkpoint.messages.clone()) + .map_err(|error| HostError::Config(format!("checkpoint 消息无效: {error}")))?; + let run = snapshot + .run(run_id) + .cloned() + .ok_or_else(|| HostError::Config(format!("runtime 中找不到 run: {run_id}")))?; + if run.status() != agent_runtime_core::RunStatus::Reconciling { + return Err(HostError::Config(format!( + "safe checkpoint 只能从 reconciling runtime 应用,当前为 {:?}: {run_id}", + run.status() + ))); + } + if run.messages().len() > checkpoint_messages.len() + || run.messages() != &checkpoint_messages[..run.messages().len()] + { + return Err(HostError::Config(format!( + "runtime 消息与 checkpoint 前缀不一致: {run_id}" + ))); + } + + let mut checkpoint_calls = Vec::new(); + let mut checkpoint_results = Vec::new(); + for message in &checkpoint_messages { + for part in message.content() { + match part { + ContentPart::ToolCall { + id, + name, + arguments, + } => checkpoint_calls.push( + ToolCall::try_new(id, name, arguments.clone()) + .map_err(|error| HostError::Config(error.to_string()))?, + ), + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => checkpoint_results.push( + ToolResult::try_new(tool_call_id, output.clone(), *is_error) + .map_err(|error| HostError::Config(error.to_string()))?, + ), + _ => {} + } + } + } + + if run.tool_calls().len() > checkpoint_calls.len() + || run + .tool_calls() + .iter() + .zip(&checkpoint_calls) + .any(|(current, checkpoint)| current != checkpoint) + { + return Err(HostError::Config(format!( + "runtime tool call 与 checkpoint 不一致: {run_id}" + ))); + } + if run.tool_results().len() > checkpoint_results.len() + || run + .tool_results() + .iter() + .zip(&checkpoint_results) + .any(|(current, checkpoint)| current != checkpoint) + { + return Err(HostError::Config(format!( + "runtime tool result 与 checkpoint 不一致: {run_id}" + ))); + } + + let mut events = Vec::new(); + for message in &checkpoint_messages[run.messages().len()..] { + events.push( + RuntimeEvent::message_appended( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + message, + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + for call in &checkpoint_calls[run.tool_calls().len()..] { + events.push( + RuntimeEvent::tool_call_requested( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + call, + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + for result in &checkpoint_results[run.tool_results().len()..] { + events.push( + RuntimeEvent::tool_result( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + result, + result.is_error(), + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + if run.status() == agent_runtime_core::RunStatus::Reconciling { + events.push( + RuntimeEvent::reconciled( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + None, + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + if events.is_empty() { + return Ok(()); + } + + let mut next = snapshot.clone(); + for event in &events { + next = reduce(&next, event) + .map_err(|error| HostError::Config(format!("checkpoint reducer 失败: {error}")))?; + } + // The runtime row already exists, including when its revision is zero. + let expected = Some(snapshot.revision()); + // checkpoint 只更新 RuntimeStore;跨表 run/session 终态仍由上层专用 + // SQLite 事务处理。通过 Runtime facade 保持 Host 不直接调用 adapter trait。 + runtime.commit_runtime_snapshot(runtime_id, expected, &next, &events)?; + *snapshot = next; + Ok(()) +} + +/// Re-open an approval gate after its durable decision. Approval has no +/// external side effect, so the checkpoint may be replayed into Core directly; +/// unlike `restore_safe_checkpoint`, this helper never treats an in-flight +/// provider/tool result as safe and always requires an explicit decision. +fn restore_approval_checkpoint( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, + checkpoint: &CheckpointRecord, +) -> Result<(), HostError> { + if checkpoint.phase != "awaiting_approval" || checkpoint.next_step != checkpoint.step { + return Err(HostError::Config(format!( + "approval checkpoint 游标无效: {run_id}" + ))); + } + let pending_call_id = checkpoint.tool_call_id.as_deref().ok_or_else(|| { + HostError::Config(format!("approval checkpoint 缺少 tool_call_id: {run_id}")) + })?; + let checkpoint_messages = + serde_json::from_value::>(checkpoint.messages.clone()) + .map_err(|error| HostError::Config(format!("checkpoint 消息无效: {error}")))?; + let run = snapshot + .run(run_id) + .cloned() + .ok_or_else(|| HostError::Config(format!("runtime 中找不到 run: {run_id}")))?; + if run.status() != agent_runtime_core::RunStatus::Reconciling { + return Err(HostError::Config(format!( + "approval checkpoint 只能从 reconciling runtime 应用,当前为 {:?}: {run_id}", + run.status() + ))); + } + if run.messages().len() > checkpoint_messages.len() + || run.messages() != &checkpoint_messages[..run.messages().len()] + { + return Err(HostError::Config(format!( + "runtime 消息与 approval checkpoint 前缀不一致: {run_id}" + ))); + } + + let mut calls = Vec::new(); + let mut results = Vec::new(); + for message in &checkpoint_messages { + for part in message.content() { + match part { + ContentPart::ToolCall { + id, + name, + arguments, + } => calls.push( + ToolCall::try_new(id, name, arguments.clone()) + .map_err(|error| HostError::Config(error.to_string()))?, + ), + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => results.push( + ToolResult::try_new(tool_call_id, output.clone(), *is_error) + .map_err(|error| HostError::Config(error.to_string()))?, + ), + _ => {} + } + } + } + let pending_index = calls + .iter() + .position(|call| call.id() == pending_call_id) + .ok_or_else(|| HostError::Config(format!("approval call 不在 checkpoint 中: {run_id}")))?; + if results + .iter() + .any(|result| result.call_id() == pending_call_id) + || calls[pending_index + 1..] + .iter() + .any(|call| results.iter().any(|result| result.call_id() == call.id())) + { + return Err(HostError::Config(format!( + "approval checkpoint 已包含待决调用结果: {run_id}" + ))); + } + // A provider response may contain several tool calls, while the Engine + // stops at the first `Ask`. Only the prefix through the pending call is + // safe to expose to Core here; later calls have not reached an approval + // decision yet and must remain in the checkpoint message only. + let materialized_calls = &calls[..=pending_index]; + if results + .iter() + .any(|result| !calls.iter().any(|call| call.id() == result.call_id())) + { + return Err(HostError::Config(format!( + "approval checkpoint 的 tool result 引用了未知 call: {run_id}" + ))); + } + let materialized_results = results + .iter() + .filter(|result| { + materialized_calls + .iter() + .any(|call| call.id() == result.call_id()) + }) + .cloned() + .collect::>(); + if run.tool_calls().len() > materialized_calls.len() + || run + .tool_calls() + .iter() + .zip(materialized_calls) + .any(|(current, checkpoint_call)| current != checkpoint_call) + { + return Err(HostError::Config(format!( + "runtime tool call 与 approval checkpoint 不一致: {run_id}" + ))); + } + if run.tool_results().len() > materialized_results.len() + || run + .tool_results() + .iter() + .zip(&materialized_results) + .any(|(current, checkpoint_result)| current != checkpoint_result) + { + return Err(HostError::Config(format!( + "runtime tool result 与 approval checkpoint 不一致: {run_id}" + ))); + } + + let mut events = Vec::new(); + for message in &checkpoint_messages[run.messages().len()..] { + events.push( + RuntimeEvent::message_appended( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + message, + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + for call in &materialized_calls[run.tool_calls().len()..] { + events.push( + RuntimeEvent::tool_call_requested( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + call, + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + for result in &materialized_results[run.tool_results().len()..] { + events.push( + RuntimeEvent::tool_result( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + result, + result.is_error(), + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + events.push( + RuntimeEvent::reconciled( + runtime_id, + snapshot.revision() + events.len() as u64 + 1, + SystemClock.now_millis(), + run_id, + Some(pending_call_id), + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + let mut next = snapshot.clone(); + for event in &events { + next = reduce(&next, event).map_err(|error| { + HostError::Config(format!("approval checkpoint reducer 失败: {error}")) + })?; + } + // Approval recovery also starts from an existing runtime row. + let expected = Some(snapshot.revision()); + runtime.commit_runtime_snapshot(runtime_id, expected, &next, &events)?; + *snapshot = next; + Ok(()) +} + +/// 把 Engine 的观察事件压缩成 Core 可重放的生命周期事件。 +/// Step/model 事件本身仍保存在 run-level 审计表,不强行扩张 Core 枚举。 +fn persist_runtime_trace( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, + lease: &WorkerLease, + events: &[EngineEvent], + progress: &mut RuntimeTraceProgress, +) -> Result<(), HostError> { + let session_id = runtime + .get_run(run_id) + .map_err(host_error_from_runtime)? + .ok_or_else(|| HostError::Config(format!("找不到工具调用所属 run: {run_id}")))? + .session_id; + for (event_index, event) in events.iter().enumerate().skip(progress.events_through) { + match event { + EngineEvent::ModelCompleted { response, .. } => { + let mut parts = response.content().to_vec(); + for call in response.tool_calls() { + parts.push( + ContentPart::tool_call(call.id(), call.name(), call.arguments().clone()) + .map_err(|error| HostError::Config(error.to_string()))?, + ); + } + if !parts.is_empty() && event_index >= progress.messages_through { + let message = Message::new(MessageRole::Assistant, parts); + append_runtime_message(runtime, snapshot, runtime_id, run_id, message)?; + } + } + EngineEvent::ToolRequested { call, .. } => { + // The checkpoint listener closes the pre-approval boundary by + // atomically materializing this call when possible. The + // Engine trace is replayed after the run returns, so avoid a + // second reducer event for that already committed identity. + if runtime + .get_tool_call(call.id()) + .map_err(host_error_from_runtime)? + .is_some_and(|record| record.run_id == run_id) + && snapshot.run(run_id).is_some_and(|run| { + run.tool_calls().iter().any(|item| item.id() == call.id()) + }) + { + progress.events_through = event_index + 1; + continue; + } + let runtime_event = RuntimeEvent::tool_call_requested( + runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + run_id, + call, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + let next = reduce_runtime_event(snapshot, &runtime_event)?; + runtime + .create_tool_call_with_runtime_and_lease( + NewToolCall { + id: call.id().to_owned(), + session_id: session_id.clone(), + run_id: run_id.to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + Some(lease), + runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&runtime_event), + ) + .map_err(host_error_from_runtime)?; + *snapshot = next; + } + EngineEvent::ToolCompleted { result, .. } => { + let call = snapshot + .run(run_id) + .and_then(|run| { + run.tool_calls() + .iter() + .find(|call| call.id() == result.call_id()) + }) + .ok_or_else(|| { + HostError::Config(format!( + "tool result 缺少对应调用记录: {}", + result.call_id() + )) + })?; + let runtime_event = RuntimeEvent::tool_result( + runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + run_id, + result, + result.is_error(), + ) + .map_err(|error| HostError::Config(error.to_string()))?; + let tool_message = Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result( + result.call_id(), + result.output().clone(), + result.is_error(), + ) + .map_err(|error| HostError::Config(error.to_string()))?, + ], + ); + let mut next = reduce_runtime_event(snapshot, &runtime_event)?; + let mut runtime_events = vec![runtime_event]; + // checkpoint 已投影的消息无需重复追加,但 ToolResult 事件 + // 与工具行仍要落盘;两个位置不能合并成一个“已处理”标记。 + if event_index >= progress.messages_through { + let message_event = RuntimeEvent::message_appended( + runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + &tool_message, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + next = reduce_runtime_event(&next, &message_event)?; + runtime_events.push(message_event); + } + let input = NewToolCall { + id: call.id().to_owned(), + session_id: session_id.clone(), + run_id: run_id.to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }; + let status = if result.is_error() { + "error" + } else { + "completed" + }; + let checkpoint = runtime + .read_checkpoint(run_id) + .map_err(host_error_from_runtime)?; + // Engine persists one final `safe` checkpoint after the whole + // tool batch. While replaying the trace, that checkpoint can + // already contain results for later calls even though this + // Core snapshot only contains the current result. Do not + // feed that future cursor into the joint transaction for an + // intermediate call; the final ToolCompleted is the only + // point where the checkpoint and Core snapshot describe the + // same prefix. The ordinary tool/runtime transaction keeps + // the intermediate Core projection atomic without rewriting + // the batch's already-durable safe boundary. + if let Some(checkpoint) = + checkpoint.filter(|_| is_final_tool_completion(events, event_index)) + { + // The Engine has already persisted this post-result cursor. + // Reusing the exact durable value lets the result/runtime + // transition and cursor update share one fenced transaction. + runtime + .complete_tool_call_with_checkpoint_runtime_and_lease( + input, + checkpoint_input_from_record(checkpoint), + lease, + runtime_id, + Some(snapshot.revision()), + &next, + &runtime_events, + status, + result.output().clone(), + ) + .map_err(host_error_from_runtime)?; + } else { + // Intermediate batch results, and legacy runs without a + // checkpoint, use the narrower tool/runtime transaction. + // The durable batch checkpoint is left untouched until + // the final result is projected. + runtime + .complete_tool_call_with_runtime_and_lease( + input, + Some(lease), + runtime_id, + Some(snapshot.revision()), + &next, + &runtime_events, + status, + result.output().clone(), + ) + .map_err(host_error_from_runtime)?; + } + *snapshot = next; + } + // The checkpoint listener projects the compaction lifecycle and + // the exact context rewrite atomically with the next durable + // checkpoint. Keep these observations in the run-level audit + // stream but do not append a second Core event here. + EngineEvent::CompactionStarted { .. } + | EngineEvent::ContextCompacted { .. } + | EngineEvent::CompactionCompleted { .. } => {} + EngineEvent::ApprovalDenied { call_id, .. } => { + // Engine 已把拒绝作为失败 ToolResult 回填;这里无需重复追加事件。 + let _ = call_id; + } + // Finished 的 Core 事件只由 run/session/checkpoint 终态事务提交。 + EngineEvent::Finished { .. } | EngineEvent::StepStarted { .. } => {} + } + // 前一条提交完成才推进;压缩前 flush 或收尾失败后都从已提交位置 + // 继续,不让一次部分失败重复消费更早的工具结果。 + progress.events_through = event_index + 1; + } + Ok(()) +} + +/// The Engine writes the batch's `safe` checkpoint only after its final tool +/// result. During Host trace replay, earlier `ToolCompleted` events must not +/// reuse that future checkpoint in the checkpoint/runtime joint transaction. +/// Keep this decision local to the trace so no second public persistence API +/// is needed. +fn is_final_tool_completion(events: &[EngineEvent], event_index: usize) -> bool { + !events[event_index + 1..] + .iter() + .any(|event| matches!(event, EngineEvent::ToolCompleted { .. })) +} + +/// 为“没有工具调用的成功终态”预构造 Runtime 事件批次。 +/// +/// Provider 返回后,`provider_in_flight` checkpoint 已经是 durable 游标; +/// 因而不能为了追求更大的事务而提前写入未经终态确认的 assistant 消息。 +/// 这条窄批次只在 Engine 已返回 `Ok` 且观察流没有工具事件时使用:消息 +/// 事件与 `Completed` 事件一起交给现有 run/runtime/session/checkpoint 事务。 +/// 带工具、取消或错误的路径继续使用各自的 checkpoint/工具事务,不把 +/// 多次独立写入伪装成全局事务。 +fn build_terminal_runtime_batch( + snapshot: &RuntimeSnapshot, + runtime_id: &str, + run_id: &str, + events: &[EngineEvent], + final_text: &str, +) -> Result<(RuntimeSnapshot, Vec), HostError> { + let mut next = snapshot.clone(); + let mut runtime_events = Vec::new(); + for event in events { + let EngineEvent::ModelCompleted { response, .. } = event else { + continue; + }; + if !response.tool_calls().is_empty() { + return Err(HostError::Config( + "无工具终态批次不能包含 tool call 响应".to_owned(), + )); + } + let parts = response.content().to_vec(); + if parts.is_empty() { + continue; + } + let message = Message::new(MessageRole::Assistant, parts); + let runtime_event = RuntimeEvent::message_appended( + runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + &message, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + next = reduce_runtime_event(&next, &runtime_event)?; + runtime_events.push(runtime_event); + } + let completed = RuntimeEvent::completed( + runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + final_text, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + next = reduce_runtime_event(&next, &completed)?; + runtime_events.push(completed); + Ok((next, runtime_events)) +} + +/// Convert the durable checkpoint projection back into the owned command used +/// by the joint tool/checkpoint transaction. The conversion is lossless; the +/// timestamp is intentionally omitted because the adapter assigns it on write. +fn checkpoint_input_from_record(record: CheckpointRecord) -> NewCheckpoint { + NewCheckpoint { + run_id: record.run_id, + phase: record.phase, + step: record.step, + next_step: record.next_step, + messages: record.messages, + provider_request_id: record.provider_request_id, + tool_call_id: record.tool_call_id, + attempt: record.attempt, + } +} + +fn append_runtime_message( + runtime: &RuntimeService, + snapshot: &mut RuntimeSnapshot, + runtime_id: &str, + run_id: &str, + message: Message, +) -> Result<(), HostError> { + let runtime_event = RuntimeEvent::message_appended( + runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + run_id, + &message, + ) + .map_err(|error| HostError::Config(error.to_string()))?; + commit_runtime_event(runtime, snapshot, runtime_event) +} + +/// resolver metadata 复用 queued metadata 的大小和 secret-key 规则,避免 +/// 对账状态更新把凭据写入 durable external-session 行。 +fn validate_external_reconciliation_metadata(metadata: &Value) -> Result<(), HostError> { + AgentHost::validate_queue_metadata(metadata) + .map_err(|error| HostError::Config(format!("外部对账 resolver metadata 无效: {error}"))) +} + +/// 在调用 storage CAS 前用 Core 类型检查外部对账历史。 +/// +/// SQLite 适配器故意只依赖稳定 wire shape;Host 这一层可以进一步检查 +/// tool call/result 的顺序和角色,确保写成 `safe` 的历史确实能被 Runtime +/// reducer 和下一次 Engine 共同消费。这里不调用 Provider 或工具。 +fn validate_reconciliation_messages( + checkpoint: &CheckpointRecord, + phase: &str, + external_id: &str, + messages: &[Message], +) -> Result<(), HostError> { + if !matches!(phase, "provider_in_flight" | "tool_in_flight") { + return Err(HostError::Config(format!( + "不支持的对账 checkpoint phase: {phase}" + ))); + } + if messages.is_empty() { + return Err(HostError::Config("对账消息不能为空".to_owned())); + } + if checkpoint.phase != phase { + return Err(HostError::Config(format!( + "checkpoint phase 不匹配:expected={phase} actual={}", + checkpoint.phase + ))); + } + if checkpoint.step != checkpoint.next_step { + return Err(HostError::Config( + "in-flight checkpoint 的 step/next_step 游标无效".to_owned(), + )); + } + match phase { + "provider_in_flight" + if checkpoint.provider_request_id.as_deref() != Some(external_id) + || checkpoint.tool_call_id.is_some() => + { + return Err(HostError::Config( + "Provider request identity 与 checkpoint 不匹配".to_owned(), + )); + } + "tool_in_flight" if checkpoint.tool_call_id.as_deref() != Some(external_id) => { + return Err(HostError::Config( + "tool call identity 与 checkpoint 不匹配".to_owned(), + )); + } + _ => {} + } + + let checkpoint_messages = + serde_json::from_value::>(checkpoint.messages.clone()) + .map_err(|error| HostError::Config(format!("checkpoint 消息无效: {error}")))?; + if messages.len() <= checkpoint_messages.len() { + return Err(HostError::Config( + "对账消息必须包含完整 checkpoint 前缀和新增结果".to_owned(), + )); + } + if !checkpoint_messages + .iter() + .zip(messages) + .all(|(expected, actual)| expected == actual) + { + return Err(HostError::Config( + "对账消息没有保留 checkpoint 的完整前缀".to_owned(), + )); + } + + let mut calls = BTreeSet::new(); + let mut results = BTreeSet::new(); + let mut suffix_has_assistant = false; + let mut suffix_has_matching_tool_result = false; + for (message_index, message) in messages.iter().enumerate() { + let in_suffix = message_index >= checkpoint_messages.len(); + if in_suffix && message.role() == MessageRole::Assistant { + suffix_has_assistant = true; + } + for part in message.content() { + match part { + ContentPart::ToolCall { id, .. } => { + if message.role() != MessageRole::Assistant { + return Err(HostError::Config(format!( + "tool call 必须位于 assistant 消息: index={message_index}" + ))); + } + if !calls.insert(id.clone()) { + return Err(HostError::Config(format!("对账消息重复 tool call: {id}"))); + } + } + ContentPart::ToolResult { tool_call_id, .. } => { + if message.role() != MessageRole::Tool { + return Err(HostError::Config(format!( + "tool result 必须位于 tool 消息: index={message_index}" + ))); + } + // Runtime 只接受已经观察到的 call;不允许凭一个 result + // 猜测此前存在过未落盘的调用。 + if !calls.contains(tool_call_id) { + return Err(HostError::Config(format!( + "tool result 引用了尚未出现的 call: {tool_call_id}" + ))); + } + if !results.insert(tool_call_id.clone()) { + return Err(HostError::Config(format!( + "对账消息重复 tool result: {tool_call_id}" + ))); + } + if in_suffix && phase == "tool_in_flight" && tool_call_id == external_id { + suffix_has_matching_tool_result = true; + } + } + ContentPart::Text { .. } | ContentPart::Image { .. } => {} + } + } + } + + if calls.iter().any(|call_id| !results.contains(call_id)) { + return Err(HostError::Config( + "对账消息仍包含未完成的 tool call,不能标记 safe".to_owned(), + )); + } + if phase == "provider_in_flight" && !suffix_has_assistant { + return Err(HostError::Config( + "Provider 对账后缀必须包含 assistant 响应".to_owned(), + )); + } + if phase == "tool_in_flight" && !suffix_has_matching_tool_result { + return Err(HostError::Config( + "工具对账后缀必须包含对应 tool result".to_owned(), + )); + } + Ok(()) +} + +fn event_type(event: &EngineEvent) -> &'static str { + match event { + EngineEvent::StepStarted { .. } => "step_started", + EngineEvent::CompactionStarted { .. } => "compaction_started", + EngineEvent::ContextCompacted { .. } => "context_compacted", + EngineEvent::CompactionCompleted { .. } => "compaction_completed", + EngineEvent::ModelCompleted { .. } => "model_completed", + EngineEvent::ToolRequested { .. } => "tool_requested", + EngineEvent::ApprovalDenied { .. } => "approval_denied", + EngineEvent::ToolCompleted { .. } => "tool_completed", + EngineEvent::Finished { .. } => "finished", + } +} + +#[cfg(test)] +fn unique_suffix() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_codex::CodexSessionLifecycleStatus; + use agent_mcp::{ + JsonRpcRequest, JsonRpcResponse, McpClient, McpClientOptions, McpSyncTransport, + }; + use agent_runtime_core::{ + BackendResult, ModelProvider, ProviderError, ProviderErrorKind, ProviderInstanceId, + ProviderProtocolId, ProviderRequest, ProviderResponse, SkillActivation, SkillDefinition, + }; + use agent_runtime_engine::CompressionRequest; + use serde_json::Value; + use std::sync::Barrier; + use std::sync::mpsc::{self, Receiver, Sender}; + + fn temp_root(prefix: &str) -> std::path::PathBuf { + // Respect an explicit TMPDIR, but default direct test runs to + // ~/data/tmp instead of the system /tmp directory. + let parent = std::env::var_os("TMPDIR") + .filter(|value| !value.is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .map(|home| home.join("data/tmp")) + }) + .expect("TMPDIR 或 HOME 未设置,无法创建测试目录"); + std::fs::create_dir_all(&parent).unwrap(); + let root = parent.join(format!("{prefix}-{}", unique_suffix())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + struct BlockingEchoTool { + started: Sender<()>, + release: Mutex>, + } + + struct CountingTool(Arc>); + + impl ToolExecutor for CountingTool { + fn execute( + &self, + call: &ToolCall, + _context: &ToolContext, + ) -> Result { + *self.0.lock().unwrap() += 1; + ToolResult::success(call.id(), json!({"executed": true})).map_err(Into::into) + } + } + + struct FailingTool; + + impl ToolExecutor for FailingTool { + fn execute( + &self, + _call: &ToolCall, + _context: &ToolContext, + ) -> Result { + Err(ToolError::new(ToolErrorKind::Failed, "fixture failure")) + } + } + + struct AskApproval; + + impl ApprovalPolicy for AskApproval { + fn decide( + &self, + _request: &agent_runtime_core::ApprovalRequest, + ) -> Result + { + Ok(agent_runtime_core::ApprovalDecision::ask()) + } + } + + struct DenyApproval; + + impl ApprovalPolicy for DenyApproval { + fn decide( + &self, + _request: &agent_runtime_core::ApprovalRequest, + ) -> Result + { + Ok(agent_runtime_core::ApprovalDecision::deny("fixture policy")) + } + } + + struct CountingAllowApproval(Arc>); + + impl ApprovalPolicy for CountingAllowApproval { + fn decide( + &self, + _request: &agent_runtime_core::ApprovalRequest, + ) -> Result + { + *self.0.lock().unwrap() += 1; + Ok(agent_runtime_core::ApprovalDecision::Allow) + } + } + + fn codex_tool_fixture(params: Value, approval: Arc) -> (Vec, usize) { + codex_tool_fixture_with_resolver( + params, + approval, + Arc::new(StaticNamespaceToolResolver::new()), + ) + } + + fn codex_tool_fixture_with_resolver( + params: Value, + approval: Arc, + namespace_resolver: Arc, + ) -> (Vec, usize) { + codex_tool_fixture_with_context( + params, + approval, + namespace_resolver, + ToolContext::new() + .with_session_id("session-1") + .with_run_id("run-1"), + ) + } + + fn codex_tool_fixture_with_context( + params: Value, + approval: Arc, + namespace_resolver: Arc, + context: ToolContext, + ) -> (Vec, usize) { + let input = format!( + "{init}\n{request}\n{response}\n", + init = json!({"id": 1, "result": {}}), + request = json!({ + "id": "server-1", + "method": "item/tool/call", + "params": params, + }), + response = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + ); + let calls = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + let definition = + ToolDefinition::try_new("lookup", "fixture lookup", json!({"type": "object"})).unwrap(); + tools + .register(definition, Arc::new(CountingTool(calls.clone()))) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_namespace_resolver(namespace_resolver) + .with_approval(approval); + let mut handler = host.codex_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "fixture").unwrap(), + &mut handler, + ) + .unwrap(); + let (_, written) = client.into_parts(); + let messages = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect(); + (messages, *calls.lock().unwrap()) + } + + #[test] + fn static_namespace_tool_resolver_requires_explicit_mappings() { + let mut resolver = StaticNamespaceToolResolver::new(); + assert_eq!(resolver.len(), 0); + resolver + .register("alpha", "lookup", "alpha_lookup") + .unwrap(); + resolver.register("beta", "lookup", "beta_lookup").unwrap(); + + // 同一个 wire tool 名可以安全地映射到两个不同的 Host 工具; + // resolver 不通过任何分隔符猜测目标。 + assert_eq!( + resolver.resolve_tool("alpha", "lookup").unwrap(), + "alpha_lookup" + ); + assert_eq!( + resolver.resolve_tool("beta", "lookup").unwrap(), + "beta_lookup" + ); + assert_eq!(resolver.len(), 2); + + assert_eq!( + resolver.resolve_tool("missing", "lookup").unwrap_err(), + NamespaceToolResolverError::UnknownNamespace("missing".to_owned()) + ); + assert_eq!( + resolver.resolve_tool("alpha", "other").unwrap_err(), + NamespaceToolResolverError::UnknownTool { + namespace: "alpha".to_owned(), + tool: "other".to_owned(), + } + ); + assert_eq!( + resolver.resolve_tool("", "lookup").unwrap_err(), + NamespaceToolResolverError::EmptyNamespace + ); + assert_eq!( + resolver + .register("alpha", "lookup", "other_target") + .unwrap_err(), + NamespaceToolResolverError::Conflict { + namespace: "alpha".to_owned(), + tool: "lookup".to_owned(), + existing: "alpha_lookup".to_owned(), + requested: "other_target".to_owned(), + } + ); + } + + #[test] + fn codex_host_handler_resolves_explicit_namespace_before_policy() { + let resolver = StaticNamespaceToolResolver::new() + .with_mapping("fixture", "lookup", "lookup") + .unwrap(); + let (messages, calls) = codex_tool_fixture_with_resolver( + json!({ + "callId": "call-mapped-namespace", + "name": "lookup", + "arguments": {}, + "namespace": "fixture" + }), + Arc::new(AllowList::new(["lookup".to_owned()])), + Arc::new(resolver), + ); + assert_eq!(calls, 1); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["result"]["callId"], "call-mapped-namespace"); + assert_eq!(response["result"]["output"]["executed"], true); + } + + #[test] + fn codex_host_handler_rejects_empty_namespace_before_policy_or_execution() { + let approvals = Arc::new(Mutex::new(0)); + let (messages, calls) = codex_tool_fixture( + json!({ + "callId": "call-empty-namespace", + "name": "lookup", + "arguments": {}, + "namespace": "" + }), + Arc::new(CountingAllowApproval(approvals.clone())), + ); + assert_eq!(calls, 0); + assert_eq!(*approvals.lock().unwrap(), 0); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32602); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("namespace") + ); + } + + #[test] + fn codex_host_handler_allows_tool_only_after_policy() { + let (messages, calls) = codex_tool_fixture( + json!({"callId": "call-1", "name": "lookup", "arguments": {"q": "ok"}}), + Arc::new(AllowList::new(["lookup".to_owned()])), + ); + assert_eq!(calls, 1); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["result"]["callId"], "call-1"); + assert_eq!(response["result"]["output"]["executed"], true); + } + + #[test] + fn codex_host_from_host_persists_and_caches_duplicate_tool_call() { + let executions = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(AllowList::new(["lookup".to_owned()]))); + let handle = host.prepare_run("durable codex call").unwrap(); + let context = ToolContext::new() + .with_session_id(handle.session_id.clone()) + .with_run_id(handle.run_id.clone()); + let input = format!( + "{init}\n{request_one}\n{response_one}\n{request_two}\n{response_two}\n", + init = json!({"id": 1, "result": {}}), + request_one = json!({ + "id": "server-1", + "method": "item/tool/call", + "params": {"callId": "durable-call", "name": "lookup", "arguments": {"q": "ok"}} + }), + response_one = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + request_two = json!({ + "id": "server-2", + "method": "item/tool/call", + "params": {"callId": "durable-call", "name": "lookup", "arguments": {"q": "ok"}} + }), + response_two = json!({"id": 3, "result": {"turn": {"id": "turn-2"}}}), + ); + let mut handler = host.codex_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "first").unwrap(), + &mut handler, + ) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "retry").unwrap(), + &mut handler, + ) + .unwrap(); + let (_, written) = client.into_parts(); + let responses: Vec = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(*executions.lock().unwrap(), 1); + let first = responses + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + let second = responses + .iter() + .find(|message| message["id"] == "server-2") + .unwrap(); + assert_eq!(first["result"]["output"]["executed"], true); + assert_eq!(second["result"]["output"]["executed"], true); + let records = host.list_tool_calls(&handle.run_id).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].id, "durable-call"); + assert_eq!(records[0].status, "completed"); + assert_eq!(records[0].result, Some(json!({"executed": true}))); + } + + #[test] + fn codex_01521_from_host_closes_failed_tool_call_durably() { + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(FailingTool), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(AllowList::new(["lookup".to_owned()]))); + let handle = host.prepare_run("durable typed failure").unwrap(); + let request = ServerRequest01521::DynamicTool { + id: json!("typed-server-failure"), + params: agent_codex::codex_0_152_1::DynamicToolCallParams01521 { + arguments: json!({"q": "bad"}), + call_id: "typed-durable-failure".to_owned(), + thread_id: "thread-failure".to_owned(), + tool: "lookup".to_owned(), + turn_id: "turn-failure".to_owned(), + namespace: None, + }, + }; + let context = ToolContext::new() + .with_session_id(handle.session_id.clone()) + .with_run_id(handle.run_id.clone()); + let mut handler = host.codex_01521_server_request_handler(context); + let error = ::handle( + &mut handler, + &request, + ) + .unwrap_err(); + assert!(error.to_string().contains("执行失败")); + let records = host.list_tool_calls(&handle.run_id).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].id, "typed-durable-failure"); + assert_eq!(records[0].status, "error"); + assert_eq!(records[0].result, Some(json!({"error": "fixture failure"}))); + } + + #[test] + fn codex_host_handler_rejects_unmapped_namespace_before_policy_or_execution() { + let approvals = Arc::new(Mutex::new(0)); + let (messages, calls) = codex_tool_fixture( + json!({ + "callId": "call-namespace", + "name": "lookup", + "arguments": {}, + "namespace": "fixture" + }), + Arc::new(CountingAllowApproval(approvals.clone())), + ); + assert_eq!(calls, 0); + assert_eq!(*approvals.lock().unwrap(), 0); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32602); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("namespace") + ); + } + + #[test] + fn codex_host_handler_deny_and_ask_never_execute() { + let (messages, calls) = codex_tool_fixture( + json!({"callId": "call-deny", "name": "lookup", "arguments": {}}), + Arc::new(DenyApproval), + ); + assert_eq!(calls, 0); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32001); + + let (messages, calls) = codex_tool_fixture( + json!({"callId": "call-ask", "name": "lookup", "arguments": {}}), + Arc::new(AskApproval), + ); + assert_eq!(calls, 0); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32002); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("外部审批") + ); + } + + #[test] + fn codex_host_handler_rejects_unknown_and_malformed_requests() { + let input = format!( + "{init}\n{unknown}\n{malformed}\n{response}\n", + init = json!({"id": 1, "result": {}}), + unknown = json!({ + "id": "unknown-1", + "method": "future/tool/call", + "params": {"name": "lookup", "arguments": {}} + }), + malformed = json!({ + "id": "malformed-1", + "method": "item/tool/call", + "params": {"name": "lookup", "arguments": "not-json"} + }), + response = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + ); + let calls = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(calls.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(AllowList::new(["lookup".to_owned()]))); + let context = ToolContext::new().with_run_id("run-1"); + let mut handler = host.codex_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "fixture").unwrap(), + &mut handler, + ) + .unwrap(); + assert_eq!(*calls.lock().unwrap(), 0); + } + + #[test] + fn codex_host_handler_rejects_schema_mismatch_before_policy_or_execution() { + let input = format!( + "{init}\n{request}\n{response}\n", + init = json!({"id": 1, "result": {}}), + request = json!({ + "id": "schema-1", + "method": "item/tool/call", + "params": { + "callId": "call-schema-1", + "name": "lookup", + "arguments": {"text": "x"} + } + }), + response = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + ); + let executions = Arc::new(Mutex::new(0)); + let approvals = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new( + "lookup", + "fixture", + json!({ + "type": "object", + "required": ["text"], + "properties": {"text": {"type": "string", "minLength": 2}}, + "additionalProperties": false + }), + ) + .unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(CountingAllowApproval(approvals.clone()))); + let context = ToolContext::new().with_run_id("run-schema-1"); + let mut handler = host.codex_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "fixture").unwrap(), + &mut handler, + ) + .unwrap(); + let (_, written) = client.into_parts(); + let response = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|message| message["id"] == "schema-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32602); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(*executions.lock().unwrap(), 0); + } + + #[test] + fn codex_host_handler_validates_context_and_cancel_before_policy() { + let approvals = Arc::new(Mutex::new(0)); + let invalid_context: ToolContext = serde_json::from_value(json!({ + "sessionId": "", + "runId": "run-invalid-context", + "metadata": {} + })) + .unwrap(); + let (messages, calls) = codex_tool_fixture_with_context( + json!({"callId": "call-invalid-context", "name": "lookup", "arguments": {}}), + Arc::new(CountingAllowApproval(approvals.clone())), + Arc::new(StaticNamespaceToolResolver::new()), + invalid_context, + ); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32602); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(calls, 0); + + let cancelled_context = ToolContext::new() + .with_session_id("session-cancelled") + .with_run_id("run-cancelled") + .with_cancellation_probe(|| true); + let approvals = Arc::new(Mutex::new(0)); + let (messages, calls) = codex_tool_fixture_with_context( + json!({"callId": "call-cancelled", "name": "lookup", "arguments": {}}), + Arc::new(CountingAllowApproval(approvals.clone())), + Arc::new(StaticNamespaceToolResolver::new()), + cancelled_context, + ); + let response = messages + .iter() + .find(|message| message["id"] == "server-1") + .unwrap(); + assert_eq!(response["error"]["code"], -32800); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(calls, 0); + } + + #[test] + fn codex_01521_host_handler_returns_typed_dynamic_tool_shape() { + let input = format!( + "{init}\n{request}\n{response}\n", + init = json!({"id": 1, "result": {}}), + request = json!({ + "id": "typed-1", + "method": "item/tool/call", + "params": { + "arguments": {"q": "ok"}, + "callId": "typed-call-1", + "threadId": "thread-1", + "tool": "lookup", + "turnId": "turn-1", + "namespace": "fixture" + } + }), + response = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + ); + let executions = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_namespace_resolver(Arc::new( + StaticNamespaceToolResolver::new() + .with_mapping("fixture", "lookup", "lookup") + .unwrap(), + )) + .with_approval(Arc::new(AllowList::new(["lookup".to_owned()]))); + let context = ToolContext::new() + .with_session_id("session-1") + .with_run_id("run-1"); + let mut handler = host.codex_01521_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "fixture").unwrap(), + &mut handler, + ) + .unwrap(); + let (_, written) = client.into_parts(); + let response = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|message| message["id"] == "typed-1") + .unwrap(); + assert_eq!(*executions.lock().unwrap(), 1); + assert_eq!(response["result"]["success"], true); + assert_eq!(response["result"]["contentItems"][0]["type"], "inputText"); + assert_eq!( + response["result"]["contentItems"][0]["text"], + "{\"executed\":true}" + ); + assert!(response["result"].get("output").is_none()); + } + + #[test] + fn codex_01521_host_handler_rejects_unmapped_namespace_before_policy_or_execution() { + let input = format!( + "{init}\n{request}\n{response}\n", + init = json!({"id": 1, "result": {}}), + request = json!({ + "id": "typed-namespace", + "method": "item/tool/call", + "params": { + "arguments": {"q": "ok"}, + "callId": "typed-call-namespace", + "threadId": "thread-1", + "tool": "lookup", + "turnId": "turn-1", + "namespace": "fixture" + } + }), + response = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + ); + let executions = Arc::new(Mutex::new(0)); + let approvals = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(CountingAllowApproval(approvals.clone()))); + let context = ToolContext::new() + .with_session_id("session-1") + .with_run_id("run-1"); + let mut handler = host.codex_01521_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "fixture").unwrap(), + &mut handler, + ) + .unwrap(); + let (_, written) = client.into_parts(); + let response = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|message| message["id"] == "typed-namespace") + .unwrap(); + assert_eq!(response["error"]["code"], -32602); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("namespace") + ); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(*executions.lock().unwrap(), 0); + } + + #[test] + fn codex_01521_typed_handler_rejects_unmapped_namespace_before_policy_or_execution() { + let executions = Arc::new(Mutex::new(0)); + let approvals = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(CountingAllowApproval(approvals.clone()))); + let context = ToolContext::new().with_run_id("run-1"); + let mut handler = host.codex_01521_server_request_handler(context); + let request = ServerRequest01521::DynamicTool { + id: json!("typed-direct-namespace"), + params: agent_codex::codex_0_152_1::DynamicToolCallParams01521 { + arguments: json!({"q": "ok"}), + call_id: "typed-call-direct-namespace".to_owned(), + thread_id: "thread-1".to_owned(), + tool: "lookup".to_owned(), + turn_id: "turn-1".to_owned(), + namespace: Some("fixture".to_owned()), + }, + }; + let error = ::handle( + &mut handler, + &request, + ) + .unwrap_err(); + assert!(error.to_string().contains("namespace")); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(*executions.lock().unwrap(), 0); + } + + #[test] + fn codex_01521_host_handler_rejects_alias_conflict_before_policy_or_execution() { + let input = format!( + "{init}\n{request}\n{response}\n", + init = json!({"id": 1, "result": {}}), + request = json!({ + "id": "typed-conflict", + "method": "item/tool/call", + "params": { + "arguments": {}, + "callId": "typed-call-conflict", + "threadId": "thread-1", + "tool": "lookup", + "name": "other", + "turnId": "turn-1" + } + }), + response = json!({"id": 2, "result": {"turn": {"id": "turn-1"}}}), + ); + let executions = Arc::new(Mutex::new(0)); + let approvals = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(CountingAllowApproval(approvals.clone()))); + let context = ToolContext::new().with_run_id("run-1"); + let mut handler = host.codex_01521_server_request_handler(context); + let mut client = agent_codex::CodexAppServerClient::new( + std::io::Cursor::new(input.into_bytes()), + Vec::::new(), + ) + .unwrap(); + client + .initialize_with_params_and_handler(json!({}), &mut handler) + .unwrap(); + client + .turn_start_with_handler( + agent_codex::CodexTurnStartParams::text("thread-1", "fixture").unwrap(), + &mut handler, + ) + .unwrap(); + let (_, written) = client.into_parts(); + let response = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|message| message["id"] == "typed-conflict") + .unwrap(); + assert_eq!(response["error"]["code"], -32602); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(*executions.lock().unwrap(), 0); + } + + #[test] + fn codex_01521_host_handler_plugs_into_versioned_client_trait() { + let input = concat!( + r#"{"id":1,"result":{"userAgent":"codex-cli/0.152.1","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"linux"}}"#, + "\n", + r#"{"id":"typed-client-1","method":"item/tool/call","params":{"arguments":{"q":"ok"},"callId":"typed-call-client-1","threadId":"thread-1","tool":"lookup","turnId":"turn-1"}}"#, + "\n", + r#"{"id":2,"result":{"thread":{"id":"thread-1","sessionId":"session-1","cliVersion":"0.152.1"},"model":"gpt-5","modelProvider":"openai","cwd":"/tmp/project"}}"#, + "\n", + ); + let executions = Arc::new(Mutex::new(0)); + let mut tools = ToolRouter::new(); + tools + .register( + ToolDefinition::try_new("lookup", "fixture", json!({"type": "object"})).unwrap(), + Arc::new(CountingTool(executions.clone())), + ) + .unwrap(); + let host = AgentHost::new() + .unwrap() + .with_tools(tools) + .with_approval(Arc::new(AllowList::new(["lookup".to_owned()]))); + let context = ToolContext::new() + .with_session_id("session-1") + .with_run_id("run-1"); + let mut handler = host.codex_01521_server_request_handler(context); + let mut client = agent_codex::codex_0_152_1::Client01521::new( + std::io::Cursor::new(input.as_bytes().to_vec()), + Vec::::new(), + ) + .unwrap(); + client + .initialize( + agent_codex::codex_0_152_1::InitializeParams01521::new("agent-runtime", "0.1.0") + .unwrap(), + &mut handler, + ) + .unwrap(); + client + .thread_start( + agent_codex::codex_0_152_1::ThreadStartParams01521::new() + .with_model("gpt-5") + .with_model_provider("openai") + .with_cwd("/tmp/project"), + &mut handler, + ) + .unwrap(); + let inner = client.into_inner(); + let (_, written) = inner.into_parts(); + let response = String::from_utf8(written) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|message| message["id"] == "typed-client-1") + .unwrap(); + assert_eq!(*executions.lock().unwrap(), 1); + assert_eq!(response["result"]["success"], true); + assert_eq!(response["result"]["contentItems"][0]["type"], "inputText"); + } + + struct PromptProbe { + requests: Arc>>>, + } + + /// 返回两个顺序工具调用的离线 Provider,用于覆盖首个 approval gate 的 + /// durable resume。第二次 Provider 请求只在整个批次完成后返回文本。 + struct BatchApprovalProvider { + requests: Arc>>>, + } + + impl ModelProvider for BatchApprovalProvider { + fn complete(&self, request: &ProviderRequest) -> Result { + self.requests + .lock() + .expect("batch provider request lock") + .push(request.messages().to_vec()); + let has_tool_result = request + .messages() + .iter() + .any(|message| message.role() == MessageRole::Tool); + if has_tool_result { + return ProviderResponse::text( + request.request_id(), + request.model(), + "批量审批完成", + ) + .map_err(Into::into); + } + let first = ToolCall::try_new("batch-call-1", "echo", json!({"text": "first"}))?; + let second = ToolCall::try_new("batch-call-2", "echo", json!({"text": "second"}))?; + ProviderResponse::try_new(request.request_id(), request.model(), [], [first, second]) + .map_err(Into::into) + } + } + + struct FailingProvider; + + struct RecordingExternalBackend { + requests: Arc>>, + external_id: String, + unknown: bool, + } + + struct TimeoutExternalBackend { + calls: Arc>, + } + + struct CancellableExternalBackend { + started: Arc, + cancelled: Arc, + } + + struct StaticObservationSource; + + impl ExternalObservationSource for StaticObservationSource { + fn observe( + &self, + request: &ExternalObservationRequest, + ) -> Result { + ExternalObservation::completed(json!({ + "reference": request.reference_id(), + "status": "already-completed" + })) + .map_err(|error| ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string())) + } + } + + struct TimeoutObservationSource; + + impl ExternalObservationSource for TimeoutObservationSource { + fn observe( + &self, + _request: &ExternalObservationRequest, + ) -> Result { + Err(ExternalError::new( + ExternalErrorKind::Timeout, + "observation timeout", + )) + } + } + + impl ExternalBackend for TimeoutExternalBackend { + fn invoke(&self, _request: &BackendRequest) -> Result { + *self.calls.lock().unwrap() += 1; + Err(ExternalError::new( + ExternalErrorKind::Timeout, + "外部调用超时,结果未知", + )) + } + } + + impl ExternalBackend for CancellableExternalBackend { + fn invoke(&self, request: &BackendRequest) -> Result { + self.started.store(true, Ordering::Release); + while !self.cancelled.load(Ordering::Acquire) { + thread::sleep(Duration::from_millis(1)); + } + Err(ExternalError::new( + ExternalErrorKind::UnknownSideEffect, + format!("{} cancelled", request.request_id()), + )) + } + + fn cancel(&self, _request_id: &str) -> Result<(), ExternalError> { + self.cancelled.store(true, Ordering::Release); + Ok(()) + } + } + + impl ExternalBackend for RecordingExternalBackend { + fn invoke(&self, request: &BackendRequest) -> Result { + self.requests.lock().unwrap().push(request.clone()); + BackendResult::try_new(request.request_id(), json!({"answer": 7})) + .and_then(|result| result.with_external_id(self.external_id.clone())) + .map(|result| result.with_unknown_side_effect(self.unknown)) + .map_err(Into::into) + } + } + + struct ExternalCallingProvider; + + struct StaticHostCompressor; + + struct FailingHostCompressor; + + impl ContextCompressor for StaticHostCompressor { + fn compress(&self, request: &CompressionRequest) -> Result, EngineError> { + assert!(request.messages.iter().any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text.len() > request.budget) + })); + Ok(vec![Message::user("host-compressed").map_err(|error| { + EngineError::ContextOverflow(error.to_string()) + })?]) + } + } + + impl ContextCompressor for FailingHostCompressor { + fn compress(&self, _request: &CompressionRequest) -> Result, EngineError> { + Err(EngineError::ContextOverflow( + "压缩器 fixture 失败".to_owned(), + )) + } + } + + impl ModelProvider for ExternalCallingProvider { + fn complete(&self, request: &ProviderRequest) -> Result { + if request + .messages() + .iter() + .any(|message| message.role() == MessageRole::Tool) + { + return ProviderResponse::text( + request.request_id(), + request.model(), + "external-ok", + ) + .map_err(Into::into); + } + let tool = request + .tools() + .iter() + .find(|tool| tool.name() == "external.turn") + .ok_or_else(|| { + ProviderError::new( + ProviderErrorKind::EmptyResponse, + "测试 Provider 没有找到 external tool", + ) + })?; + let call = + ToolCall::try_new("external-call-1", tool.name(), json!({"prompt": "hello"}))?; + ProviderResponse::try_new(request.request_id(), request.model(), [], [call]) + .map_err(Into::into) + } + } + + impl ModelProvider for FailingProvider { + fn complete(&self, _request: &ProviderRequest) -> Result { + Err(ProviderError::new( + ProviderErrorKind::Unavailable, + "测试 Provider 不可用", + )) + } + } + + impl ModelProvider for PromptProbe { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + self.requests + .lock() + .unwrap() + .push(request.messages().to_vec()); + ProviderResponse::text(request.request_id(), request.model(), "prompt-ok") + .map_err(Into::into) + } + } + + struct McpCallingProvider; + + impl ModelProvider for McpCallingProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + if request + .messages() + .iter() + .any(|message| message.role() == MessageRole::Tool) + { + return ProviderResponse::text(request.request_id(), request.model(), "mcp-ok") + .map_err(Into::into); + } + let tool = request + .tools() + .iter() + .find(|tool| tool.name().starts_with("mcp:")) + .ok_or_else(|| { + agent_runtime_core::ProviderError::new( + agent_runtime_core::ProviderErrorKind::EmptyResponse, + "测试 Provider 没有找到 MCP 工具", + ) + })?; + let call = ToolCall::try_new("mcp-call-1", tool.name(), json!({"value": 7}))?; + ProviderResponse::try_new(request.request_id(), request.model(), [], [call]) + .map_err(Into::into) + } + } + + #[derive(Default)] + struct FakeMcpTransport { + requests: Vec, + } + + impl McpSyncTransport for FakeMcpTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + self.requests.push(request.method.clone()); + let result = match request.method.as_str() { + "initialize" => json!({"protocolVersion": agent_mcp::DEFAULT_PROTOCOL_VERSION}), + "tools/list" => json!({ + "tools": [{ + "name": "remote_echo", + "description": "remote echo", + "inputSchema": {"type": "object"} + }] + }), + "resources/list" => json!({ + "resources": [{ + "uri": "file:///workspace/README.md", + "name": "readme", + "mimeType": "text/plain" + }] + }), + "resources/read" => json!({ + "contents": [{ + "uri": "file:///workspace/README.md", + "mimeType": "text/plain", + "text": "resource context" + }] + }), + "prompts/list" => json!({ + "prompts": [{"name": "welcome", "arguments": []}] + }), + "prompts/get" => json!({ + "messages": [{ + "role": "user", + "content": {"type": "text", "text": "prompt context"} + }] + }), + "tools/call" => json!({ + "structuredContent": {"answer": 7}, + "content": [{"type": "text", "text": "seven"}] + }), + other => return Err(agent_mcp::McpError::Protocol(format!("未知方法: {other}"))), + }; + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(result), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &agent_mcp::JsonRpcNotification, + ) -> Result<(), agent_mcp::McpError> { + Ok(()) + } + } + + /// 只允许握手和目录发现;如果取消门禁失效,真正的 tools/call 会直接 + /// panic,避免测试把“没有发送”误判成一个普通的 transport 错误。 + struct CancelGateMcpTransport { + requests: Arc, + } + + impl McpSyncTransport for CancelGateMcpTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + self.requests + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if request.method == "tools/call" { + panic!("已取消的 MCP 调用不应触碰 tools/call transport"); + } + let result = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": agent_mcp::DEFAULT_PROTOCOL_VERSION, + "capabilities": {} + }), + "tools/list" => json!({ + "tools": [{ + "name": "remote_echo", + "description": "remote echo", + "inputSchema": {"type": "object"} + }] + }), + other => { + return Err(agent_mcp::McpError::Protocol(format!( + "取消门禁 fixture 收到未知方法: {other}" + ))); + } + }; + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(result), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &agent_mcp::JsonRpcNotification, + ) -> Result<(), agent_mcp::McpError> { + Ok(()) + } + } + + impl ToolExecutor for BlockingEchoTool { + fn execute( + &self, + call: &ToolCall, + _context: &ToolContext, + ) -> Result { + self.started + .send(()) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; + self.release + .lock() + .map_err(|_| ToolError::new(ToolErrorKind::Failed, "release channel 锁已损坏"))? + .recv() + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; + ToolResult::success(call.id(), json!({"released": true})).map_err(Into::into) + } + } + + #[test] + fn host_runs_fake_tool_loop_and_persists_events() { + let host = AgentHost::in_memory().unwrap(); + let result = host.run("hello").unwrap(); + assert_eq!(result.output.steps, 2); + let events = host.list_events(&result.run_id, 0).unwrap(); + assert!( + events + .iter() + .any(|event| event.event_type == "tool_completed") + ); + assert!(host.get_run(&result.run_id).unwrap().is_some()); + let tool_calls = host.list_tool_calls(&result.run_id).unwrap(); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].tool_name, "echo"); + assert_eq!(tool_calls[0].status, "completed"); + assert_eq!( + tool_calls[0] + .result + .as_ref() + .and_then(|value| value.get("received")), + Some(&json!({"text": "hello from fake provider"})) + ); + let runtime = host + .load_runtime_snapshot(&result.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&result.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Completed + ); + let runtime_run = runtime.run(&result.run_id).unwrap(); + assert_eq!(runtime_run.messages(), result.output.messages); + assert!(runtime_run.messages().iter().any(|message| { + message.role() == MessageRole::User + && message + .content() + .iter() + .any(|part| part.as_text() == Some("hello")) + })); + assert!(runtime_run.messages().iter().any(|message| { + message + .content() + .iter() + .any(|part| matches!(part, ContentPart::ToolCall { .. })) + })); + assert!(runtime_run.messages().iter().any(|message| { + message + .content() + .iter() + .any(|part| matches!(part, ContentPart::ToolResult { .. })) + })); + assert!(host.list_runtime_events(&result.runtime_id).unwrap().len() >= 5); + let mut runtime_export = Vec::new(); + let exported = host + .export_runtime_jsonl(&result.runtime_id, &mut runtime_export) + .unwrap(); + assert!(exported >= 2); + assert!( + String::from_utf8(runtime_export) + .unwrap() + .lines() + .any(|line| line.contains("runtime_snapshot")) + ); + } + + #[test] + fn multi_tool_trace_only_reuses_safe_checkpoint_for_final_result() { + let first = ToolResult::success("batch-call-1", json!({"index": 1})).unwrap(); + let second = ToolResult::success("batch-call-2", json!({"index": 2})).unwrap(); + let events = vec![ + EngineEvent::ToolCompleted { + step: 0, + result: first, + }, + EngineEvent::ToolCompleted { + step: 0, + result: second, + }, + ]; + + // Engine's durable safe checkpoint is emitted after both results. The + // first trace event must therefore use only the tool/runtime atomic + // path; otherwise its joint transaction would pair call 1's Core + // snapshot with a checkpoint that already contains call 2. + assert!(!is_final_tool_completion(&events, 0)); + assert!(is_final_tool_completion(&events, 1)); + } + + #[test] + fn checkpoint_projection_cursor_advances_only_after_commit() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("checkpoint cursor").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + let (_, lease_record) = host + .runtime + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(30)) + .unwrap(); + let mut snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let started = RuntimeEvent::status_changed( + &handle.runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .unwrap(); + commit_runtime_event(&host.runtime, &mut snapshot, started).unwrap(); + let before = snapshot.clone(); + let call = ToolCall::try_new("cursor-call", "echo", json!({"text": "cursor"})).unwrap(); + let response = + ProviderResponse::try_new("cursor-request", "fake", [], [call.clone()]).unwrap(); + let pending = Arc::new(Mutex::new(BTreeMap::new())); + let collected = CollectedEvents::with_pending_tool_calls(pending.clone()); + collected.on_event(&EngineEvent::ModelCompleted { step: 0, response }); + collected.on_event(&EngineEvent::ToolRequested { + step: 0, + call: call.clone(), + }); + let mut messages = snapshot.run(&handle.run_id).unwrap().messages().to_vec(); + messages.push(Message::new( + MessageRole::Assistant, + vec![ContentPart::tool_call(call.id(), call.name(), call.arguments().clone()).unwrap()], + )); + let checkpoint = EngineCheckpoint { + step: 0, + next_step: 0, + phase: agent_runtime_engine::CheckpointPhase::AwaitingApproval, + messages: messages.clone(), + provider_request_id: Some("cursor-request".into()), + tool_call_id: Some(call.id().into()), + }; + let mut checkpoints = DurableCheckpoints { + runtime: host.runtime.clone(), + runtime_id: handle.runtime_id.clone(), + run_id: handle.run_id.clone(), + session_id: handle.session_id.clone(), + lease: WorkerLease::with_identity("wrong-worker", "wrong-token").unwrap(), + attempt: lease_record.attempt, + pending_tool_calls: pending, + observed: &collected, + trace_progress: Mutex::new(RuntimeTraceProgress::default()), + compaction_open: Mutex::new(false), + }; + // 错 lease 拒绝整个联合提交,也不能留下“消息已写入”的内存标记。 + assert!(checkpoints.on_checkpoint(&checkpoint).is_err()); + assert_eq!( + checkpoints.trace_progress.lock().unwrap().messages_through, + 0 + ); + assert_eq!( + host.load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(), + before + ); + assert!(host.read_checkpoint(&handle.run_id).unwrap().is_none()); + assert!(host.list_tool_calls(&handle.run_id).unwrap().is_empty()); + + checkpoints.lease = lease; + checkpoints.on_checkpoint(&checkpoint).unwrap(); + assert_eq!( + checkpoints.trace_progress.lock().unwrap().messages_through, + 2 + ); + snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!(snapshot.run(&handle.run_id).unwrap().messages(), messages); + let committed = snapshot.clone(); + let trace = collected.snapshot().unwrap(); + checkpoints.persist_trace(&mut snapshot, &trace).unwrap(); + checkpoints.persist_trace(&mut snapshot, &trace).unwrap(); + assert_eq!(snapshot, committed); + } + + #[test] + fn trace_cursor_preserves_equal_messages_from_distinct_events() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("identical messages").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + host.runtime + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(30)) + .unwrap(); + let mut snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let events = [0, 1].map(|step| EngineEvent::ModelCompleted { + step, + response: ProviderResponse::text(format!("same-{step}"), "fake", "相同内容").unwrap(), + }); + let mut progress = RuntimeTraceProgress::default(); + persist_runtime_trace( + &host.runtime, + &mut snapshot, + &handle.runtime_id, + &handle.run_id, + &lease, + &events, + &mut progress, + ) + .unwrap(); + let messages = snapshot.run(&handle.run_id).unwrap().messages(); + assert_eq!(messages.len(), 3); + assert_eq!(messages[1], messages[2]); + let committed = snapshot.clone(); + // 同一观察序列重入不重复提交;相同内容的新事件则必须保留。 + persist_runtime_trace( + &host.runtime, + &mut snapshot, + &handle.runtime_id, + &handle.run_id, + &lease, + &events, + &mut progress, + ) + .unwrap(); + assert_eq!(snapshot, committed); + } + + #[test] + fn terminal_runtime_batch_keeps_assistant_message_before_completed_event() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("terminal batch").unwrap(); + let snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let started = RuntimeEvent::status_changed( + &handle.runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .unwrap(); + let running = reduce_runtime_event(&snapshot, &started).unwrap(); + let response = + ProviderResponse::text("terminal-request", "gpt-4.1-mini", "terminal reply").unwrap(); + let (completed, events) = build_terminal_runtime_batch( + &running, + &handle.runtime_id, + &handle.run_id, + &[EngineEvent::ModelCompleted { step: 0, response }], + "terminal reply", + ) + .unwrap(); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].kind(), RuntimeEventKind::MessageAppended); + assert_eq!(events[1].kind(), RuntimeEventKind::RunCompleted); + let run = completed.run(&handle.run_id).unwrap(); + assert_eq!(run.status(), agent_runtime_core::RunStatus::Completed); + assert!(run.messages().iter().any(|message| { + message.role() == MessageRole::Assistant + && message + .content() + .iter() + .any(|part| part.as_text() == Some("terminal reply")) + })); + } + + #[test] + fn text_only_run_commits_assistant_and_terminal_runtime_events_together() { + let host = AgentHost::in_memory().unwrap().with_provider( + Arc::new(FakeProvider::text("terminal-only reply")), + "fixture", + ); + let result = host.run("terminal-only input").unwrap(); + let runtime = host + .load_runtime_snapshot(&result.runtime_id) + .unwrap() + .unwrap(); + let run = runtime.run(&result.run_id).unwrap(); + assert_eq!(run.status(), agent_runtime_core::RunStatus::Completed); + let events = host.list_runtime_events(&result.runtime_id).unwrap(); + let completed_index = events + .iter() + .position(|event| event.kind() == RuntimeEventKind::RunCompleted) + .expect("terminal runtime event"); + assert!( + events[..completed_index] + .iter() + .any(|event| event.kind() == RuntimeEventKind::MessageAppended) + ); + assert!(run.messages().iter().any(|message| { + message.role() == MessageRole::Assistant + && message + .content() + .iter() + .any(|part| part.as_text() == Some("terminal-only reply")) + })); + assert!(host.read_checkpoint(&result.run_id).unwrap().is_none()); + } + + #[test] + fn host_uses_an_injected_runtime_facade_without_reopening_storage() { + let runtime = RuntimeService::in_memory().unwrap(); + let host = AgentHost::with_runtime(runtime.clone()).unwrap(); + + let handle = host + .prepare_run("injected runtime") + .expect("prepare through host"); + // RuntimeService clones share the same durable connection. Seeing the + // Host-created run through the separately held facade proves Host did + // not open a second database or retain a parallel control-plane state. + let run = runtime + .get_run(&handle.run_id) + .expect("query through injected runtime") + .expect("run exists"); + assert_eq!(run.status, "queued"); + assert_eq!( + runtime.runtime_id_for_run(&handle.run_id).unwrap(), + Some(handle.runtime_id) + ); + } + + #[test] + fn queued_run_metadata_is_bounded_and_rejects_secrets() { + let host = AgentHost::in_memory().unwrap(); + let message = Message::user("queued metadata").unwrap(); + let handle = host + .prepare_run_with_messages_and_metadata( + "queued metadata", + vec![message.clone()], + json!({"provider": "gpt-test", "providerKind": "openai"}), + ) + .unwrap(); + let session = host + .get_session(&handle.session_id) + .unwrap() + .expect("queued session"); + assert_eq!(session.metadata["provider"], "gpt-test"); + assert_eq!(session.metadata["providerKind"], "openai"); + + let non_object = host.prepare_run_with_messages_and_metadata( + "bad metadata", + vec![message.clone()], + json!("not an object"), + ); + assert!( + matches!(non_object, Err(HostError::Config(error)) if error.contains("JSON object")) + ); + + let secret = host.prepare_run_with_messages_and_metadata( + "secret metadata", + vec![message], + json!({"api_key": "must not persist"}), + ); + assert!(matches!(secret, Err(HostError::Config(error)) if error.contains("secret 字段"))); + } + + #[test] + fn queued_provider_metadata_matching_model_can_claim() { + let provider = Arc::new(FakeProvider::text("queued provider response")); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "queued-model"); + let message = Message::user("queued provider metadata").unwrap(); + let handle = host + .prepare_run_with_messages_and_metadata( + "queued provider metadata", + vec![message], + json!({"provider": "queued-model", "providerKind": "openai"}), + ) + .unwrap(); + + let output = host.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.text, "queued provider response"); + assert_eq!(provider.requests().snapshot().len(), 1); + } + + #[test] + fn queued_provider_metadata_model_mismatch_is_rejected_before_claim() { + let provider = Arc::new(FakeProvider::text("must not run")); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "current-model"); + let message = Message::user("mismatched queued provider").unwrap(); + let handle = host + .prepare_run_with_messages_and_metadata( + "mismatched queued provider", + vec![message], + json!({"provider": "persisted-model", "providerKind": "openai"}), + ) + .unwrap(); + + let error = host + .run_existing(&handle.run_id) + .expect_err("mismatched provider model must fail before claim"); + assert!(matches!( + error, + HostError::Config(message) if message.contains("Provider model") + )); + assert_eq!( + host.get_run(&handle.run_id).unwrap().unwrap().status, + "queued" + ); + assert!( + host.runtime() + .get_run_lease(&handle.run_id) + .unwrap() + .is_none() + ); + assert!(provider.requests().snapshot().is_empty()); + } + + #[test] + fn queued_provider_metadata_kind_mismatch_is_rejected_before_claim() { + let host = AgentHost::in_memory().unwrap().with_fake_provider(); + let message = Message::user("mismatched provider kind").unwrap(); + let handle = host + .prepare_run_with_messages_and_metadata( + "mismatched provider kind", + vec![message], + json!({"provider": "fake", "providerKind": "openai"}), + ) + .unwrap(); + + let error = host + .run_existing(&handle.run_id) + .expect_err("mismatched provider kind must fail before claim"); + assert!(matches!( + error, + HostError::Config(message) if message.contains("Provider kind") + )); + assert_eq!( + host.get_run(&handle.run_id).unwrap().unwrap().status, + "queued" + ); + assert!( + host.runtime() + .get_run_lease(&handle.run_id) + .unwrap() + .is_none() + ); + } + + #[test] + fn legacy_provider_metadata_without_marker_remains_compatible() { + let provider = Arc::new(FakeProvider::text("legacy provider response")); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "current-model"); + let message = Message::user("legacy provider metadata").unwrap(); + let handle = host + .prepare_run_with_messages_and_metadata( + "legacy provider metadata", + vec![message], + json!({"provider": "historical-model"}), + ) + .unwrap(); + + let output = host.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.text, "legacy provider response"); + assert_eq!(provider.requests().snapshot().len(), 1); + } + + #[test] + fn host_selects_registered_provider_by_instance_and_protocol() { + let production = Arc::new(FakeProvider::text("production response")); + let staging = Arc::new(FakeProvider::text("staging response")); + let production_descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::from("openai-prod"), + "OpenAI production", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("openai-responses")); + let staging_descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::from("openai-staging"), + "OpenAI staging", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("openai-responses")); + + let mut host = AgentHost::in_memory().unwrap(); + host.register_provider(production_descriptor, production.clone()) + .unwrap(); + host.register_provider(staging_descriptor, staging.clone()) + .unwrap(); + let target = ProviderTarget::new(ProviderInstanceId::from("openai-staging")) + .with_protocol(ProviderProtocolId::from("openai-responses")); + host.select_provider(target.clone(), "gateway-model") + .unwrap(); + + assert_eq!(host.provider_registry().len(), 2); + assert_eq!(host.provider_target(), Some(&target)); + let result = host.run("route by target").unwrap(); + assert_eq!(result.output.text, "staging response"); + assert!(production.requests().snapshot().is_empty()); + let requests = staging.requests().snapshot(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].model(), "gateway-model"); + } + + #[test] + fn selected_provider_target_is_durable_and_reopen_requires_matching_route() { + let provider = Arc::new(FakeProvider::text("durable route")); + let descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::from("durable-provider"), + "Durable provider", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("test-v1")); + let target = ProviderTarget::new(ProviderInstanceId::from("durable-provider")) + .with_protocol(ProviderProtocolId::from("test-v1")); + + let runtime = RuntimeService::in_memory().unwrap(); + let mut creator = AgentHost::with_runtime(runtime.clone()).unwrap(); + creator + .register_provider(descriptor.clone(), provider.clone()) + .unwrap(); + creator + .select_provider(target.clone(), "durable-model") + .unwrap(); + let handle = creator.prepare_run("persist route").unwrap(); + + let session = creator + .get_session(&handle.session_id) + .unwrap() + .expect("prepared run session"); + assert_eq!(session.metadata["provider"], "durable-model"); + assert_eq!( + session.metadata["providerTarget"]["instanceId"], + "durable-provider" + ); + assert_eq!(session.metadata["providerTarget"]["protocolId"], "test-v1"); + + // A reopened Host with only its default direct provider must not claim + // the queued run under a different route. The failure happens before + // lease acquisition, so the run remains queued and the selected + // provider observes no request. + let reopened_without_route = AgentHost::with_runtime(runtime.clone()).unwrap(); + let error = reopened_without_route + .run_existing(&handle.run_id) + .expect_err("missing route must fail closed"); + assert!(matches!( + error, + HostError::Config(message) if message.contains("未选择对应实例") + )); + assert_eq!( + reopened_without_route + .get_run(&handle.run_id) + .unwrap() + .unwrap() + .status, + "queued" + ); + assert!(provider.requests().snapshot().is_empty()); + + // Re-registering and selecting the persisted target is sufficient to + // resume the same run; no second session or route metadata is created. + let mut reopened = AgentHost::with_runtime(runtime).unwrap(); + reopened + .register_provider(descriptor, provider.clone()) + .unwrap(); + reopened.select_provider(target, "durable-model").unwrap(); + let output = reopened.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.text, "durable route"); + assert_eq!(provider.requests().snapshot().len(), 1); + } + + #[test] + fn host_rejects_protocol_mismatch_before_creating_selection_or_calling_provider() { + let provider = Arc::new(FakeProvider::text("must not run")); + let descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::from("openai-prod"), + "OpenAI production", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("openai-responses")); + let mut host = AgentHost::in_memory().unwrap(); + host.register_provider(descriptor, provider.clone()) + .unwrap(); + + let target = ProviderTarget::new(ProviderInstanceId::from("openai-prod")) + .with_protocol(ProviderProtocolId::from("openai-chat")); + let error = host.select_provider(target, "gateway-model").unwrap_err(); + assert!( + matches!(error, HostError::Config(message) if message.contains("protocol mismatch")) + ); + assert!(host.provider_target().is_none()); + assert!(provider.requests().snapshot().is_empty()); + } + + #[test] + fn direct_provider_injection_remains_usable_after_registry_selection() { + let registered = Arc::new(FakeProvider::text("registered")); + let direct = Arc::new(FakeProvider::text("direct")); + let descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::from("registered"), + "Registered provider", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("test-v1")); + let target = ProviderTarget::new(ProviderInstanceId::from("registered")) + .with_protocol(ProviderProtocolId::from("test-v1")); + let mut host = AgentHost::in_memory().unwrap(); + host.register_provider(descriptor, registered.clone()) + .unwrap(); + host.select_provider(target, "registered-model").unwrap(); + // The existing with_provider API is still an explicit direct injection; + // it must not accidentally execute the previously selected target. + host = host.with_provider(direct.clone(), "direct-model"); + assert!(host.provider_target().is_none()); + let result = host.run("direct path").unwrap(); + assert_eq!(result.output.text, "direct"); + assert!(registered.requests().snapshot().is_empty()); + assert_eq!(direct.requests().snapshot().len(), 1); + } + + #[test] + fn host_observes_runtime_snapshot_through_runtime_facade() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("inspect runtime").unwrap(); + + let snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let run = snapshot.run(&handle.run_id).unwrap(); + assert_eq!(run.status(), agent_runtime_core::RunStatus::Pending); + assert!(run.messages().iter().any(|message| { + message.role() == MessageRole::User + && message + .content() + .iter() + .any(|part| part.as_text() == Some("inspect runtime")) + })); + assert!( + host.load_runtime_snapshot("runtime-missing") + .unwrap() + .is_none() + ); + } + + #[test] + fn builder_and_service_aliases_share_the_host_api() { + // These names are intentionally aliases, so adopting the planned + // Builder/Service vocabulary does not create a second runtime state. + let builder: AgentBuilder = AgentBuilder::new().unwrap().with_fake_provider(); + let service: AgentService = builder; + let output = service.run("alias api").unwrap(); + assert_eq!(output.output.text, "fake provider complete"); + } + + #[test] + fn durable_listener_runs_after_each_audit_event_commit() { + let store = SqliteStore::open_in_memory().unwrap(); + let observed = Arc::new(Mutex::new(Vec::<(String, i64, usize)>::new())); + let observed_by_listener = observed.clone(); + let store_for_listener = store.clone(); + let host = AgentHost::with_store(store.clone()) + .unwrap() + .with_fake_provider() + .with_durable_event_callback(move |run_id, revision, _event| { + // The append transaction has committed before this callback; + // reading the audit stream here therefore observes the row. + let persisted = store_for_listener + .list_events(run_id, 0) + .expect("committed audit event should be readable") + .len(); + observed_by_listener + .lock() + .expect("listener observation lock") + .push((run_id.to_owned(), revision, persisted)); + }); + + let output = host.run("durable callback").unwrap(); + let rows = store.list_events(&output.run_id, 0).unwrap(); + let callbacks = observed.lock().unwrap().clone(); + assert_eq!(callbacks.len(), rows.len()); + assert!(!callbacks.is_empty()); + for (index, (run_id, revision, persisted)) in callbacks.iter().enumerate() { + assert_eq!(run_id, &output.run_id); + assert_eq!(*revision, rows[index].revision); + assert_eq!(*persisted, index + 1); + } + } + + #[test] + fn host_observes_external_reference_without_mutating_runtime() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("observe external").unwrap(); + let request = ExternalObservationRequest::try_new("provider", "get", "request-1").unwrap(); + let before = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + + let observation = host + .observe_external(&StaticObservationSource, &request) + .unwrap(); + assert_eq!( + observation.status(), + agent_runtime_core::ExternalObservationStatus::Completed + ); + assert_eq!( + observation + .output() + .and_then(|value| value["status"].as_str()), + Some("already-completed") + ); + + let after = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!(before, after); + assert!(host.read_checkpoint(&handle.run_id).unwrap().is_none()); + + let error = host + .observe_external(&TimeoutObservationSource, &request) + .expect_err("timeout should remain classified as external observation error"); + assert!(matches!( + error, + HostError::External(ref external) + if external.kind() == ExternalErrorKind::Timeout + )); + } + + #[test] + fn mcp_catalog_maps_tools_without_granting_permission() { + let catalog = McpToolCatalog::new( + "workspace", + vec![McpToolDefinition::new("read", json!({"type": "object"}))], + ); + let bindings = catalog.list_tools().unwrap(); + assert_eq!(bindings[0].tool().name(), "mcp:workspace:read"); + assert!(matches!(bindings[0].origin(), ToolOrigin::Mcp { .. })); + + let mut router = ToolRouter::new(); + router + .register_binding(bindings[0].clone(), Arc::new(EchoTool)) + .unwrap(); + assert!(matches!( + router.origin("mcp:workspace:read"), + Some(ToolOrigin::Mcp { server }) if server == "workspace" + )); + } + + #[test] + fn activated_skill_and_prompt_sections_reach_provider_as_separate_messages() { + let root = temp_root("agent-host-skill"); + let skill_dir = root.join("review"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: review\ndescription: review code\n---\n只报告证据。", + ) + .unwrap(); + let loader = SkillLoader::new(&root); + let skill = loader.activate("review").unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let host = AgentHost::in_memory() + .unwrap() + .with_provider( + Arc::new(PromptProbe { + requests: requests.clone(), + }), + "probe", + ) + .with_activated_skills([skill]); + let prompt = PromptBuilder::new() + .system("system rule") + .unwrap() + .developer("developer rule") + .unwrap() + .user("question") + .unwrap(); + let output = host.run_prompt(prompt).unwrap(); + let persisted_run = host.get_run(&output.run_id).unwrap().unwrap(); + let persisted_task = persisted_run.input["task"].as_str().unwrap(); + assert_eq!(persisted_task, "question"); + let captured = requests.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0][0].role(), MessageRole::System); + assert_eq!(captured[0][1].role(), MessageRole::Developer); + assert_eq!(captured[0][2].role(), MessageRole::User); + assert_eq!(captured[0][3].role(), MessageRole::User); + assert_eq!(captured[0][3].content()[0].as_text(), Some("只报告证据。")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn core_skill_activation_context_reaches_provider_without_injecting_body() { + let definition = SkillDefinition::try_new("review", "review code").unwrap(); + let context = ContextItem::try_new( + "skill.review", + Message::user("来自 Core activation 的上下文").unwrap(), + 20, + false, + ) + .unwrap(); + let activation = SkillActivation::try_new(definition, "仅用于 adapter 的正文").unwrap(); + let activation = activation.with_context([context]); + let requests = Arc::new(Mutex::new(Vec::new())); + let host = AgentHost::in_memory() + .unwrap() + .with_provider( + Arc::new(PromptProbe { + requests: requests.clone(), + }), + "probe", + ) + .with_skill_activation(activation) + .unwrap(); + + host.run("question").unwrap(); + let captured = requests.lock().unwrap(); + assert_eq!(captured.len(), 1); + let texts = captured[0] + .iter() + .flat_map(|message| message.content().iter().filter_map(ContentPart::as_text)) + .collect::>(); + assert!(texts.contains(&"来自 Core activation 的上下文")); + assert!(!texts.contains(&"仅用于 adapter 的正文")); + } + + #[test] + fn skill_file_core_activation_reaches_host_provider_end_to_end() { + let root = temp_root("agent-host-core-skill"); + let skill_dir = root.join("review"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: review\ndescription: review code\n---\n来自文件的 Skill 正文", + ) + .unwrap(); + + let activation = SkillLoader::new(&root).activate_core("review").unwrap(); + assert_eq!(activation.context().len(), 1); + let requests = Arc::new(Mutex::new(Vec::new())); + let host = AgentHost::in_memory() + .unwrap() + .with_provider( + Arc::new(PromptProbe { + requests: requests.clone(), + }), + "probe", + ) + .with_skill_activation(activation) + .unwrap(); + host.run("question").unwrap(); + + let captured = requests.lock().unwrap(); + assert!( + captured + .iter() + .flat_map(|request| request.iter()) + .any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text == "来自文件的 Skill 正文") + }) + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn core_skill_activation_with_tools_fails_without_explicit_executors() { + let definition = SkillDefinition::try_new("review", "review code").unwrap(); + let tool = ToolDefinition::try_new( + "skill.review.read", + "read a file", + json!({"type": "object"}), + ) + .unwrap(); + let binding = ToolBinding::new(tool, ToolOrigin::skill("review").unwrap()); + let activation = SkillActivation::try_new(definition, "body") + .unwrap() + .with_tools([binding]); + + let result = AgentHost::in_memory() + .unwrap() + .with_skill_activation(activation); + match result { + Err(HostError::Config(message)) => { + assert!(message.contains("执行器")); + assert!(message.contains("review")); + } + Err(other) => panic!("unexpected host error: {other}"), + Ok(_) => panic!("tool-bearing activation must require an executor"), + } + } + + #[test] + fn malformed_core_skill_activation_is_rejected_before_context_registration() { + let definition = SkillDefinition::try_new("review", "review code").unwrap(); + // Keep the compatibility `with_context` path covered: ContextItem's + // public serde shape can bypass its constructor, but Host must re-run + // SkillActivation's nested contract before registering a source. + let invalid_item: ContextItem = serde_json::from_value(json!({ + "sourceId": "bad source", + "message": { + "role": "user", + "content": [{"type": "text", "text": "context"}] + }, + "priority": 10, + "trusted": false, + "metadata": {} + })) + .expect("unchecked context fixture"); + let activation = SkillActivation::try_new(definition, "body") + .unwrap() + .with_context([invalid_item]); + + let result = AgentHost::in_memory() + .unwrap() + .with_skill_activation(activation); + assert!(matches!( + result, + Err(HostError::Config(message)) if message.contains("Skill activation 无效") + )); + } + + #[test] + fn mcp_client_is_bridged_to_tool_router_and_real_call_result() { + let client = McpClient::with_transport( + "workspace", + FakeMcpTransport::default(), + McpClientOptions::default(), + ); + let host = AgentHost::in_memory() + .unwrap() + .with_mcp_client(client) + .unwrap() + .with_provider(Arc::new(McpCallingProvider), "probe") + .with_approval(Arc::new(AllowList::new([ + "mcp:workspace:remote_echo".to_owned() + ]))); + let result = host.run("call remote").unwrap(); + assert_eq!(result.output.text, "mcp-ok"); + let tool_result = result + .output + .events + .iter() + .find_map(|event| match event { + EngineEvent::ToolCompleted { result, .. } => Some(result), + _ => None, + }) + .expect("MCP tool result"); + assert_eq!(tool_result.output()["answer"], 7); + assert!(!tool_result.is_error()); + } + + #[test] + fn cancelled_mcp_tool_context_blocks_transport_before_tools_call() { + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut client = McpClient::with_transport( + "workspace", + CancelGateMcpTransport { + requests: requests.clone(), + }, + McpClientOptions::default(), + ); + client.initialize().unwrap(); + client.list_tools().unwrap(); + let requests_before_call = requests.load(std::sync::atomic::Ordering::SeqCst); + + let executor = McpToolExecutor::new(Arc::new(Mutex::new(client))); + let call = ToolCall::try_new( + "cancelled-mcp-call", + "mcp:workspace:remote_echo", + json!({"value": 1}), + ) + .unwrap(); + let cancellation = Cancellation::new(); + cancellation.cancel(); + let cancellation_probe = cancellation.clone(); + let context = + ToolContext::new().with_cancellation_probe(move || cancellation_probe.is_cancelled()); + + let error = executor + .execute(&call, &context) + .expect_err("已取消的 MCP 调用必须在发送前失败"); + assert_eq!(error.kind(), ToolErrorKind::Cancelled); + assert_eq!( + requests.load(std::sync::atomic::Ordering::SeqCst), + requests_before_call, + "取消门禁后不得新增 tools/call 请求" + ); + } + + #[test] + fn malformed_mcp_tool_context_blocks_transport_before_tools_call() { + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut client = McpClient::with_transport( + "workspace", + CancelGateMcpTransport { + requests: requests.clone(), + }, + McpClientOptions::default(), + ); + client.initialize().unwrap(); + client.list_tools().unwrap(); + let requests_before_call = requests.load(std::sync::atomic::Ordering::SeqCst); + + let executor = McpToolExecutor::new(Arc::new(Mutex::new(client))); + let call = ToolCall::try_new( + "malformed-context-call", + "mcp:workspace:remote_echo", + json!({"value": 1}), + ) + .unwrap(); + // A serde-compatible context can bypass the infallible legacy ID + // setters; the adapter must reject it before touching tools/call. + let context: ToolContext = serde_json::from_value(json!({ + "sessionId": "bad id", + "runId": "run-1", + "metadata": {} + })) + .expect("unchecked context fixture"); + let error = executor + .execute(&call, &context) + .expect_err("malformed context must fail before transport"); + assert_eq!(error.kind(), ToolErrorKind::InvalidInput); + assert_eq!( + requests.load(std::sync::atomic::Ordering::SeqCst), + requests_before_call, + "context validation must not add a tools/call request" + ); + } + + #[test] + fn mcp_context_selection_injects_explicit_untrusted_resource_and_prompt() { + let requests = Arc::new(Mutex::new(Vec::new())); + let client = McpClient::with_transport( + "workspace", + FakeMcpTransport::default(), + McpClientOptions::default(), + ); + let selection = McpContextSelection::new() + .with_resource_uri("file:///workspace/README.md") + .with_prompt("welcome"); + let host = AgentHost::in_memory() + .unwrap() + .with_mcp_client_and_context(client, selection) + .unwrap() + .with_provider( + Arc::new(PromptProbe { + requests: requests.clone(), + }), + "probe", + ); + let result = host.run("use context").unwrap(); + assert_eq!(result.output.text, "prompt-ok"); + + let captured = requests.lock().unwrap(); + let messages = captured.first().expect("provider request"); + assert!(messages.iter().any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text == "resource context") + })); + assert!(messages.iter().any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text == "prompt context") + })); + + let resource = agent_mcp::McpResourceDefinition { + uri: "file:///workspace/README.md".to_owned(), + name: "readme".to_owned(), + title: None, + description: None, + mime_type: Some("text/plain".to_owned()), + size: None, + extra: BTreeMap::new(), + }; + let read = agent_mcp::McpReadResourceResult { + contents: vec![agent_mcp::McpResourceContent { + uri: resource.uri.clone(), + mime_type: resource.mime_type.clone(), + text: Some("resource context".to_owned()), + blob: None, + extra: BTreeMap::new(), + }], + extra: BTreeMap::new(), + }; + let source = McpContextSource::from_resource("workspace", &resource, &read).unwrap(); + assert!(source.items().iter().all(|item| !item.trusted())); + } + + #[test] + fn mcp_post_dispatch_errors_are_unknown_and_not_retryable() { + let cases = [ + ( + agent_mcp::McpError::Timeout { + operation: "tools/call".to_owned(), + }, + ToolErrorKind::Unknown, + ), + ( + agent_mcp::McpError::Transport("connection closed".to_owned()), + ToolErrorKind::Unknown, + ), + ( + agent_mcp::McpError::Protocol("response id mismatch".to_owned()), + ToolErrorKind::Unknown, + ), + ( + agent_mcp::McpError::HttpStatus { + status: 502, + body: "gateway".to_owned(), + }, + ToolErrorKind::Unknown, + ), + ( + agent_mcp::McpError::Remote { + code: -32000, + message: "remote failed".to_owned(), + data: None, + }, + ToolErrorKind::Unknown, + ), + ( + agent_mcp::McpError::Cancelled { + operation: "tools/call".to_owned(), + }, + ToolErrorKind::Cancelled, + ), + ( + agent_mcp::McpError::Unsupported("server request after dispatch".to_owned()), + ToolErrorKind::Unknown, + ), + ( + agent_mcp::McpError::PermissionDenied { + tool: "mcp:workspace:read".to_owned(), + }, + ToolErrorKind::PermissionDenied, + ), + ]; + for (error, expected_kind) in cases { + let mapped = mcp_error_as_tool_error(error); + assert_eq!(mapped.kind(), expected_kind); + } + } + + #[test] + fn external_backend_bridge_uses_host_run_and_persists_session() { + let requests = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(RecordingExternalBackend { + requests: requests.clone(), + external_id: "turn-1".to_owned(), + unknown: false, + }); + let definition = ToolDefinition::try_new( + "external.turn", + "调用外部 Agent turn", + json!({"type": "object", "properties": {"prompt": {"type": "string"}}}), + ) + .unwrap(); + let host = AgentHost::in_memory() + .unwrap() + .with_external_backend(definition, "codex-app-server", "turn", backend) + .unwrap() + .with_provider(Arc::new(ExternalCallingProvider), "probe") + .with_approval(Arc::new(AllowList::new(["external.turn".to_owned()]))); + assert!(matches!( + host.tools.origin("external.turn"), + Some(ToolOrigin::External { backend }) if backend == "codex-app-server" + )); + + let result = host.run("invoke external").unwrap(); + assert_eq!(result.output.text, "external-ok"); + let request = requests.lock().unwrap().first().cloned().unwrap(); + assert_eq!(request.run_id(), result.run_id); + assert_eq!(request.operation(), "turn"); + assert_eq!(request.payload()["prompt"], "hello"); + + let record = host + .get_external_session(&external_session_record_id("codex-app-server", "turn-1")) + .unwrap() + .expect("external session should be durable"); + assert_eq!(record.session_id, result.session_id); + assert_eq!(record.run_id.as_deref(), Some(result.run_id.as_str())); + assert_eq!(record.backend, "codex-app-server"); + assert_eq!(record.external_id, "turn-1"); + assert_eq!(record.status, "completed"); + } + + #[test] + fn codex_session_metadata_sink_persists_thread_and_latest_turn() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("codex metadata").unwrap(); + let sink = host + .codex_session_metadata_sink(&handle.run_id, "codex-app-server") + .unwrap(); + + sink.persist(&CodexSessionMetadata { + thread_id: Some("thread-123".to_owned()), + turn_id: None, + }) + .unwrap(); + let record_id = external_session_record_id("codex-app-server", "thread:thread-123"); + let initial = host + .get_external_session(&record_id) + .unwrap() + .expect("thread metadata should create a durable row"); + assert_eq!(initial.status, "active"); + host.runtime() + .update_external_session( + &record_id, + "thread-123", + "completed", + json!({"customAudit": "keep"}), + ) + .unwrap(); + sink.persist(&CodexSessionMetadata { + thread_id: Some("thread-123".to_owned()), + turn_id: Some("turn-456".to_owned()), + }) + .unwrap(); + + let record = host + .get_external_session(&record_id) + .unwrap() + .expect("Codex session metadata should be durable"); + assert_eq!(record.session_id, handle.session_id); + assert_eq!(record.run_id.as_deref(), Some(handle.run_id.as_str())); + assert_eq!(record.external_id, "turn-456"); + assert_eq!(record.status, "completed"); + assert_eq!(record.metadata["customAudit"], "keep"); + assert_eq!(record.metadata["threadId"], "thread-123"); + assert_eq!(record.metadata["turnId"], "turn-456"); + + sink.persist_lifecycle(&CodexSessionLifecycle { + metadata: CodexSessionMetadata { + thread_id: Some("thread-123".to_owned()), + turn_id: Some("turn-456".to_owned()), + }, + status: CodexSessionLifecycleStatus::Completed, + external_id: Some("codex-operation-1".to_owned()), + exit_code: Some(0), + cancel_result: None, + }) + .unwrap(); + let completed = host + .get_external_session(&record_id) + .unwrap() + .expect("lifecycle update should keep the durable row"); + assert_eq!(completed.status, "completed"); + assert_eq!(completed.external_id, "codex-operation-1"); + assert_eq!(completed.metadata["lifecycle"], "completed"); + assert_eq!(completed.metadata["exitCode"], 0); + assert_eq!(completed.metadata["customAudit"], "keep"); + + // A partial late metadata update must not erase the latest turn. + sink.persist(&CodexSessionMetadata { + thread_id: Some("thread-123".to_owned()), + turn_id: None, + }) + .unwrap(); + let merged = host + .get_external_session(&record_id) + .unwrap() + .expect("partial metadata update should keep the row"); + assert_eq!(merged.metadata["turnId"], "turn-456"); + assert_eq!(merged.metadata["lifecycle"], "completed"); + + sink.persist_process_lifecycle(&CodexProcessLifecycleEvent { + reason: CodexProcessLifecycleReason::NaturalExit, + exit_code: Some(0), + }) + .unwrap(); + let process_record_id = external_session_record_id( + "codex-app-server", + &format!("process:run:{}", handle.run_id), + ); + let process_record = host + .get_external_session(&process_record_id) + .unwrap() + .expect("process lifecycle should have a durable observation row"); + assert_eq!(process_record.status, "completed"); + assert_eq!( + process_record.external_id, + format!("process:{}", handle.run_id) + ); + assert_eq!(process_record.metadata["processLifecycle"], "natural_exit"); + assert_eq!(process_record.metadata["exitCode"], 0); + } + + #[test] + fn external_unknown_side_effect_is_marked_and_persisted() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("unknown external").unwrap(); + let backend = Arc::new(RecordingExternalBackend { + requests: Arc::new(Mutex::new(Vec::new())), + external_id: "turn-unknown".to_owned(), + unknown: true, + }); + let executor = + ExternalBackendToolExecutor::new("codex-app-server", "turn", backend, store.clone()) + .unwrap(); + let call = ToolCall::try_new("external-call", "external.turn", json!({})).unwrap(); + let context = ToolContext::new() + .with_session_id(handle.session_id.clone()) + .with_run_id(handle.run_id.clone()); + let error = executor.execute(&call, &context).unwrap_err(); + assert_eq!(error.kind(), ToolErrorKind::Unknown); + let record = store + .get_external_session(&external_session_record_id( + "codex-app-server", + "turn-unknown", + )) + .unwrap() + .expect("unknown external session should be durable"); + assert_eq!(record.status, "unknown"); + assert_eq!(record.metadata["sideEffectUnknown"], true); + } + + #[test] + fn external_timeout_is_unknown_and_persisted_without_retryable_kind() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("timeout external").unwrap(); + let calls = Arc::new(Mutex::new(0)); + let backend = Arc::new(TimeoutExternalBackend { + calls: calls.clone(), + }); + let executor = + ExternalBackendToolExecutor::new("codex-app-server", "turn", backend, store.clone()) + .unwrap(); + let call = ToolCall::try_new("external-timeout", "external.turn", json!({})).unwrap(); + let context = ToolContext::new() + .with_session_id(handle.session_id.clone()) + .with_run_id(handle.run_id.clone()); + + let error = executor.execute(&call, &context).unwrap_err(); + + // A timeout can happen after the remote side effect was accepted. Keep + // it in the unknown bucket so an idempotent Engine policy cannot replay it. + assert_eq!(error.kind(), ToolErrorKind::Unknown); + assert_eq!(*calls.lock().unwrap(), 1); + let record = store + .get_external_session(&external_session_record_id( + "codex-app-server", + "external-timeout", + )) + .unwrap() + .expect("timeout external session should be durable"); + assert_eq!(record.status, "unknown"); + assert_eq!(record.metadata["errorKind"], "timeout"); + assert_eq!(record.metadata["sideEffectUnknown"], true); + } + + #[test] + fn external_dispatch_and_cancel_lifecycle_is_durable() { + let runtime = RuntimeService::in_memory().unwrap(); + let host = AgentHost::with_runtime(runtime.clone()).unwrap(); + let handle = host.prepare_run("cancel external").unwrap(); + let started = Arc::new(AtomicBool::new(false)); + let cancelled = Arc::new(AtomicBool::new(false)); + let backend = Arc::new(CancellableExternalBackend { + started: started.clone(), + cancelled: cancelled.clone(), + }); + let executor = Arc::new( + ExternalBackendToolExecutor::new_with_runtime( + "codex-app-server", + "turn", + backend, + runtime.clone(), + ) + .unwrap(), + ); + let call = ToolCall::try_new("external-cancel", "external.turn", json!({})).unwrap(); + let context = ToolContext::new() + .with_session_id(handle.session_id.clone()) + .with_run_id(handle.run_id.clone()); + let worker_executor = executor.clone(); + let worker_context = context.clone(); + let worker = thread::spawn(move || worker_executor.execute(&call, &worker_context)); + + for _ in 0..200 { + if started.load(Ordering::Acquire) { + break; + } + thread::sleep(Duration::from_millis(1)); + } + assert!( + started.load(Ordering::Acquire), + "external invoke did not start" + ); + executor + .cancel("external-cancel") + .expect("cancel backend call"); + let error = worker.join().expect("external worker join").unwrap_err(); + assert_eq!(error.kind(), ToolErrorKind::Unknown); + assert!(cancelled.load(Ordering::Acquire)); + + // The request-id alias is intentionally retained so a restart can find + // the lifecycle even when the backend never returned an external ID. + let record = runtime + .get_external_session(&external_session_record_id( + "codex-app-server", + "external-cancel", + )) + .unwrap() + .expect("cancel lifecycle should be durable"); + assert_eq!(record.status, "unknown"); + assert_eq!(record.metadata["lifecycle"], "invoke_error"); + assert_eq!(record.metadata["cancelRequested"], true); + assert_eq!(record.metadata["cancelResult"], "ok"); + assert_eq!(record.metadata["sideEffectUnknown"], true); + } + + #[test] + fn reopened_host_can_cancel_durable_external_request_without_active_index() { + let store = SqliteStore::open_in_memory().unwrap(); + let first_host = AgentHost::with_store(store.clone()).unwrap(); + let handle = first_host.prepare_run("persisted external cancel").unwrap(); + let request_id = "persisted-cancel"; + store + .upsert_external_session(NewExternalSession { + id: external_session_record_id("codex-app-server", request_id), + session_id: handle.session_id.clone(), + run_id: Some(handle.run_id.clone()), + backend: "codex-app-server".to_owned(), + external_id: request_id.to_owned(), + status: "running".to_owned(), + metadata: json!({ + "requestId": request_id, + "dispatchStarted": true, + "customAudit": "kept" + }), + }) + .unwrap(); + + // A newly constructed Host has an empty in-process active index. The + // durable request-id alias is enough to find and cancel the old call. + let cancelled = Arc::new(AtomicBool::new(false)); + let backend = Arc::new(CancellableExternalBackend { + started: Arc::new(AtomicBool::new(false)), + cancelled: cancelled.clone(), + }); + let definition = ToolDefinition::try_new( + "external.turn", + "调用外部 Agent turn", + json!({"type": "object"}), + ) + .unwrap(); + let reopened = AgentHost::with_store(store.clone()) + .unwrap() + .with_external_backend(definition, "codex-app-server", "turn", backend) + .unwrap(); + reopened + .cancel_external_request("external.turn", request_id) + .expect("reopened host should cancel durable request"); + + assert!(cancelled.load(Ordering::Acquire)); + let record = store + .get_external_session(&external_session_record_id("codex-app-server", request_id)) + .unwrap() + .expect("durable request should remain queryable"); + assert_eq!(record.status, "cancelled"); + assert_eq!(record.metadata["cancelRequested"], true); + assert_eq!(record.metadata["cancelResult"], "ok"); + assert_eq!(record.metadata["customAudit"], "kept"); + } + + #[test] + fn unknown_external_result_keeps_host_run_in_reconciliation() { + let backend = Arc::new(RecordingExternalBackend { + requests: Arc::new(Mutex::new(Vec::new())), + external_id: "turn-opaque".to_owned(), + unknown: true, + }); + let definition = ToolDefinition::try_new( + "external.turn", + "调用外部 Agent turn", + json!({"type": "object"}), + ) + .unwrap(); + let host = AgentHost::in_memory() + .unwrap() + .with_external_backend(definition, "codex-app-server", "turn", backend) + .unwrap() + .with_provider(Arc::new(ExternalCallingProvider), "probe") + .with_approval(Arc::new(AllowList::new(["external.turn".to_owned()]))); + let handle = host.prepare_run("opaque external").unwrap(); + + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(matches!(error, HostError::Engine(EngineError::Tool(_)))); + let run = host.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "reconciling"); + let checkpoint = host + .read_checkpoint(&handle.run_id) + .unwrap() + .expect("opaque tool checkpoint must remain"); + assert_eq!(checkpoint.phase, "tool_in_flight"); + assert_eq!(checkpoint.tool_call_id.as_deref(), Some("external-call-1")); + assert!( + host.get_external_session(&external_session_record_id( + "codex-app-server", + "turn-opaque", + )) + .unwrap() + .is_some() + ); + // Unknown results stop before a safe checkpoint, so the model cannot + // immediately sample a retry and trigger a duplicate operation. + assert_eq!(host.list_events(&handle.run_id, 0).unwrap().len(), 3); + } + + #[test] + fn host_external_session_helper_is_explicit_and_idempotent() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("record external").unwrap(); + let input = NewExternalSession { + id: "external-helper-1".to_owned(), + session_id: handle.session_id.clone(), + run_id: Some(handle.run_id.clone()), + backend: "codex-cli".to_owned(), + external_id: "process-1".to_owned(), + status: "unknown".to_owned(), + metadata: json!({"authRef": "OPENAI_API_KEY"}), + }; + let first = host.record_external_session(input.clone()).unwrap(); + let second = host.record_external_session(input).unwrap(); + assert_eq!(first.id, second.id); + assert_eq!(second.status, "unknown"); + assert_eq!( + host.get_external_session("external-helper-1") + .unwrap() + .unwrap() + .metadata["authRef"], + "OPENAI_API_KEY" + ); + } + + #[test] + fn host_lists_external_session_candidates_without_replaying_them() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("list external candidates").unwrap(); + host.record_external_session(NewExternalSession { + id: "host-external-1".to_owned(), + session_id: handle.session_id.clone(), + run_id: Some(handle.run_id.clone()), + backend: "fixture".to_owned(), + external_id: "remote-host-1".to_owned(), + status: "unknown".to_owned(), + metadata: json!({"source": "host-test"}), + }) + .unwrap(); + + let candidates = host + .list_external_sessions(&["unknown"], Some(&handle.run_id), 1) + .unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].id, "host-external-1"); + // Candidate discovery is deliberately read-only: no replay or status + // transition happens merely by listing the row. + assert_eq!( + host.get_external_session("host-external-1") + .unwrap() + .unwrap() + .status, + "unknown" + ); + } + + #[test] + fn host_injects_context_compressor_only_when_engine_needs_it() { + let requests = Arc::new(Mutex::new(Vec::new())); + let task = "x".repeat(20_000); + let host = AgentHost::in_memory() + .unwrap() + .with_provider( + Arc::new(PromptProbe { + requests: requests.clone(), + }), + "probe", + ) + .with_context_compressor(Arc::new(StaticHostCompressor)); + + let output = host.run(task).unwrap(); + let captured = requests.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert!(captured[0].iter().any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text == "host-compressed") + })); + let runtime_events = host.list_runtime_events(&output.runtime_id).unwrap(); + assert!( + runtime_events + .iter() + .any(|event| event.kind() == RuntimeEventKind::CompactionStarted) + ); + assert!( + runtime_events + .iter() + .any(|event| event.kind() == RuntimeEventKind::CompactionCompleted) + ); + } + + #[test] + fn host_can_build_owned_provider_compressor_for_long_contexts() { + // The normal OpenAI helpers use this same owned adapter. Keep a local + // Provider fixture here so the test proves the wiring without a key or + // network request. + let requests = Arc::new(Mutex::new(Vec::new())); + let host = AgentHost::in_memory() + .unwrap() + .with_provider( + Arc::new(PromptProbe { + requests: requests.clone(), + }), + "probe", + ) + .with_provider_context_compressor(); + + host.run("x".repeat(20_000)) + .expect("owned compressor should make an oversized run executable"); + let captured = requests.lock().unwrap(); + // One request is the summary call and one is the actual run. The + // provider identity is intentionally the same; only the request id + // suffix distinguishes the compression boundary. + assert_eq!(captured.len(), 2); + assert!(captured.iter().any(|messages| { + messages.iter().any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text == "[compressed context]\nprompt-ok") + }) + })); + } + + #[test] + fn failed_compaction_keeps_a_reconcilable_durable_boundary() { + let host = AgentHost::in_memory() + .unwrap() + .with_context_compressor(Arc::new(FailingHostCompressor)); + let handle = host.prepare_run("x".repeat(20_000)).unwrap(); + let error = host + .run_existing(&handle.run_id) + .expect_err("failing compressor should stop the run"); + assert!(matches!( + error, + HostError::Engine(EngineError::ContextOverflow(_)) + )); + let record = host + .get_run(&handle.run_id) + .expect("query durable run") + .expect("run"); + assert_eq!(record.status, "reconciling"); + let checkpoint = host + .read_checkpoint(&handle.run_id) + .expect("read compaction checkpoint") + .expect("checkpoint retained for reconciliation"); + assert_eq!(checkpoint.phase, "compacting"); + let runtime_id = host + .runtime() + .runtime_id_for_run(&handle.run_id) + .expect("runtime mapping") + .expect("runtime id"); + let snapshot = host + .load_runtime_snapshot(&runtime_id) + .expect("runtime snapshot") + .expect("snapshot"); + assert_eq!( + snapshot.run(&handle.run_id).expect("runtime run").status(), + agent_runtime_core::RunStatus::Reconciling + ); + let events = host.list_runtime_events(&runtime_id).unwrap(); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::CompactionStarted) + ); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::ReconciliationRequired) + ); + } + + #[test] + fn successful_compaction_atomically_rewrites_runtime_before_provider_failure() { + // A provider failure after compression leaves an in-flight cursor for + // reconciliation. The runtime snapshot and checkpoint must still + // contain the same compressed history; otherwise a restart would try + // to resume from a context the Core snapshot never recorded. + let host = AgentHost::in_memory() + .unwrap() + .with_provider(Arc::new(FailingProvider), "probe") + .with_context_compressor(Arc::new(StaticHostCompressor)); + let handle = host.prepare_run("x".repeat(20_000)).unwrap(); + let error = host + .run_existing(&handle.run_id) + .expect_err("provider failure should preserve the post-compaction cursor"); + assert!(matches!(error, HostError::Engine(EngineError::Provider(_)))); + + let checkpoint = host + .read_checkpoint(&handle.run_id) + .expect("read provider checkpoint") + .expect("provider failure keeps an in-flight checkpoint"); + assert_eq!(checkpoint.phase, "provider_in_flight"); + let checkpoint_messages: Vec = + serde_json::from_value(checkpoint.messages).expect("checkpoint messages"); + let runtime_id = host + .runtime() + .runtime_id_for_run(&handle.run_id) + .expect("runtime mapping") + .expect("runtime id"); + let snapshot = host + .load_runtime_snapshot(&runtime_id) + .expect("runtime snapshot") + .expect("snapshot"); + let run = snapshot.run(&handle.run_id).expect("runtime run"); + assert_eq!(run.status(), agent_runtime_core::RunStatus::Reconciling); + assert_eq!(run.messages(), checkpoint_messages.as_slice()); + assert!(run.messages().iter().any(|message| { + message + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text == "host-compressed") + })); + let events = host.list_runtime_events(&runtime_id).unwrap(); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::ContextCompacted) + ); + } + + #[test] + fn prepared_run_can_be_cancelled_before_worker_starts() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("cancel me").unwrap(); + let requested = host.cancel(&handle.run_id).unwrap(); + // 无 lease 的 queued run 由控制端立即完成 cancelled 收口,不需要 + // 再等待一个不存在的 worker。 + assert_eq!(requested.status, "cancelled"); + assert!(requested.cancel_requested); + + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(error.is_cancelled()); + let run = host.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "cancelled"); + let session = host.get_session(&handle.session_id).unwrap().unwrap(); + assert_eq!(session.status, "cancelled"); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Cancelled + ); + assert!( + host.list_runtime_events(&handle.runtime_id) + .unwrap() + .iter() + .any(|event| event.kind() == RuntimeEventKind::RunCancelled) + ); + } + + #[test] + fn queued_cancel_and_worker_claim_race_keep_one_durable_owner() { + let root = temp_root("queued-cancel-race"); + let path = root.join("runtime.db"); + let setup = AgentHost::open(&path).expect("open setup host"); + let handle = setup.prepare_run("queued cancel race").expect("prepare"); + drop(setup); + + // 两个独立 Host/SQLite 连接代表控制端和 worker 进程。它们同时 + // 进入入口,验证最终只有“cancelled”或“running + cancel_requested” + // 两种合法结果,不会出现已取消但仍持有 worker lease 的裂缝。 + let cancel_host = AgentHost::open(&path).expect("open cancel host"); + let worker_host = AgentHost::open(&path).expect("open worker host"); + let barrier = Arc::new(Barrier::new(2)); + let cancel_barrier = Arc::clone(&barrier); + let worker_barrier = Arc::clone(&barrier); + let run_id = handle.run_id.clone(); + let cancel_thread = thread::spawn(move || { + cancel_barrier.wait(); + cancel_host.cancel(&run_id) + }); + let worker_run_id = handle.run_id.clone(); + let worker_thread = thread::spawn(move || { + let lease = + WorkerLease::with_identity("race-worker", "race-token").expect("lease identity"); + worker_barrier.wait(); + worker_host.runtime().claim_run_with_lease( + &worker_run_id, + &lease, + Duration::from_secs(30), + ) + }); + + let cancelled = cancel_thread + .join() + .expect("cancel thread should not panic") + .expect("cancel race should remain recoverable"); + let claim = worker_thread + .join() + .expect("worker thread should not panic"); + + let observer = AgentHost::open(&path).expect("open observer host"); + let final_run = observer + .get_run(&handle.run_id) + .expect("read final run") + .expect("final run"); + match claim { + Ok((claimed, lease)) => { + // Worker won the atomic queued guard. Host.cancel must have + // issued cooperative cancellation, leaving this lease fenced. + assert_eq!(claimed.status, "running"); + assert_eq!(cancelled.status, "cancel_requested"); + assert_eq!(final_run.status, "cancel_requested"); + assert!(final_run.cancel_requested); + assert_eq!( + observer + .runtime() + .get_run_lease(&handle.run_id) + .expect("read lease") + .map(|value| value.lease_token), + Some(lease.lease_token) + ); + } + Err(_error) => { + // Control plane won the same transaction. The worker must + // observe a terminal row and cannot manufacture a lease. + assert_eq!(cancelled.status, "cancelled"); + assert_eq!(final_run.status, "cancelled"); + assert!(final_run.cancel_requested); + assert!( + observer + .runtime() + .get_run_lease(&handle.run_id) + .expect("read lease") + .is_none() + ); + } + } + + std::fs::remove_dir_all(&root).expect("remove race fixture"); + } + + #[test] + fn cancel_historical_running_without_lease_keeps_reconciliation_gate() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("legacy running cancel").unwrap(); + + // The compatibility claim path can leave a historical running row + // without fencing metadata. A control-plane cancel must not treat + // that missing lease as proof that no Provider/tool call happened. + store.claim_run(&handle.run_id).unwrap(); + let requested = host.cancel(&handle.run_id).unwrap(); + assert_eq!(requested.status, "reconciling"); + assert!(requested.cancel_requested); + assert!(store.get_run_lease(&handle.run_id).unwrap().is_none()); + + // The cancellation marker must not turn reconciling back into a + // queued-like path on a later worker invocation. + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(error.to_string().contains("处于 reconciling")); + let run = store.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "reconciling"); + assert!(run.cancel_requested); + assert_eq!( + store + .get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "reconciling" + ); + assert_eq!( + store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap() + .run(&handle.run_id) + .unwrap() + .status(), + agent_runtime_core::RunStatus::Reconciling + ); + assert!(store.read_checkpoint(&handle.run_id).unwrap().is_none()); + } + + #[test] + fn cancel_expired_running_with_inflight_checkpoint_preserves_gate() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("expired provider cancel").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + let (_, lease_record) = store + .claim_run_with_lease( + &handle.run_id, + lease.worker_id.clone(), + lease.lease_token.clone(), + Duration::from_millis(5), + ) + .unwrap(); + let user = Message::user("expired provider cancel").unwrap(); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: serde_json::to_value([user]).unwrap(), + provider_request_id: Some("expired-request".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + &lease.worker_id, + &lease.lease_token, + ) + .unwrap(); + thread::sleep(Duration::from_millis(40)); + + // An expired lease is still evidence of a possibly accepted external + // request. Cancel enters the gate and leaves that cursor available to + // the explicit provider reconciliation API. + let requested = host.cancel(&handle.run_id).unwrap(); + assert_eq!(requested.status, "reconciling"); + assert!(requested.cancel_requested); + assert!(store.get_run_lease(&handle.run_id).unwrap().is_none()); + let checkpoint = store + .read_checkpoint(&handle.run_id) + .unwrap() + .expect("in-flight checkpoint must survive cancellation"); + assert_eq!(checkpoint.phase, "provider_in_flight"); + assert_eq!( + checkpoint.provider_request_id.as_deref(), + Some("expired-request") + ); + + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(error.to_string().contains("处于 reconciling")); + assert_eq!( + store.get_run(&handle.run_id).unwrap().unwrap().status, + "reconciling" + ); + } + + #[test] + fn finish_cancelled_rejects_runtime_only_terminal_split() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("runtime-only cancel split").unwrap(); + let lease = WorkerLease::with_identity("split-worker", "split-token").unwrap(); + let (_, lease_record) = host + .runtime + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(1)) + .unwrap(); + let runtime_service = RuntimeService::from_store(store.clone()); + let mut runtime_snapshot = runtime_service + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let started = RuntimeEvent::status_changed( + &handle.runtime_id, + runtime_snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .unwrap(); + commit_runtime_event(&runtime_service, &mut runtime_snapshot, started).unwrap(); + let cancelled = RuntimeEvent::status_changed( + &handle.runtime_id, + runtime_snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunCancelled, + ) + .unwrap(); + commit_runtime_event(&runtime_service, &mut runtime_snapshot, cancelled).unwrap(); + assert_eq!( + runtime_snapshot.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Cancelled + ); + + let error = finish_cancelled( + &host.runtime, + &mut runtime_snapshot, + &handle.runtime_id, + &handle.run_id, + Some(&lease), + ) + .expect_err("runtime-only terminal split must not be projected"); + assert!(matches!(error, HostError::Config(message) if message.contains("不一致"))); + assert_eq!( + store.get_run(&handle.run_id).unwrap().unwrap().status, + "running" + ); + assert_eq!( + store + .get_run_lease(&handle.run_id) + .unwrap() + .unwrap() + .attempt, + lease_record.attempt + ); + assert_eq!( + store + .get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "queued" + ); + } + + #[test] + fn missing_lease_running_states_reconcile_instead_of_canceling() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + + for request_cancel in [false, true] { + let task = if request_cancel { + "cancel requested after worker loss" + } else { + "worker loss without cancel" + }; + let handle = host.prepare_run(task).unwrap(); + // The legacy claim path deliberately leaves a running row without + // a lease, matching a process that exited before fencing metadata + // was written. This must still be treated as an unknown boundary. + store.claim_run(&handle.run_id).unwrap(); + if request_cancel { + store.request_cancel(&handle.run_id).unwrap(); + } + + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(error.to_string().contains("已自动进入 reconciling")); + assert_eq!( + store.get_run(&handle.run_id).unwrap().unwrap().status, + "reconciling" + ); + assert!(store.get_run_lease(&handle.run_id).unwrap().is_none()); + assert_eq!( + store + .get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "reconciling" + ); + assert_eq!( + store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap() + .run(&handle.run_id) + .unwrap() + .status(), + agent_runtime_core::RunStatus::Reconciling + ); + } + } + + #[test] + fn setup_failure_fallback_reconciles_runtime_before_releasing_expired_lease() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("setup fallback").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + let (claimed, _) = store + .claim_run_with_lease( + &handle.run_id, + lease.worker_id.clone(), + lease.lease_token.clone(), + Duration::from_millis(5), + ) + .unwrap(); + // 让 fenced failed CAS 明确落在过期 lease 分支,触发 runtime-aware + // fallback,而不是让测试依赖一次随机的提交竞争。 + thread::sleep(Duration::from_millis(40)); + + let error = host.fail_claimed_setup( + &claimed, + &lease, + HostError::Config("本地 setup 失败".to_owned()), + ); + assert!(matches!( + error, + HostError::Storage(StorageError::LeaseLost { .. }) + | HostError::Storage(StorageError::LeaseConflict { .. }) + )); + + let run = store.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "reconciling"); + assert!(store.get_run_lease(&handle.run_id).unwrap().is_none()); + assert_eq!( + store + .get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "reconciling" + ); + let runtime = store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Reconciling + ); + let events = store.list_runtime_events(&handle.runtime_id).unwrap(); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::RunStarted) + ); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::ReconciliationRequired) + ); + } + + #[test] + fn fail_unclaimed_run_atomically_records_configuration_failure() { + let host = AgentHost::in_memory().unwrap(); + let handle = host.prepare_run("configuration failure").unwrap(); + + let failed = host + .fail_unclaimed_run(&handle.run_id, "MCP 配置不可用") + .unwrap(); + assert_eq!(failed.status, "failed"); + assert!( + host.runtime() + .get_run_lease(&handle.run_id) + .unwrap() + .is_none() + ); + assert!(host.read_checkpoint(&handle.run_id).unwrap().is_none()); + assert_eq!( + host.get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "failed" + ); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Failed + ); + let events = host.list_runtime_events(&handle.runtime_id).unwrap(); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::RunStarted) + ); + assert!( + events + .iter() + .any(|event| event.kind() == RuntimeEventKind::RunFailed) + ); + + // CLI worker 在重复报告同一配置错误时不应重复写 runtime event。 + let repeated = host.fail_unclaimed_run(&handle.run_id, "同一错误").unwrap(); + assert_eq!(repeated.status, "failed"); + assert_eq!( + host.list_runtime_events(&handle.runtime_id).unwrap().len(), + events.len() + ); + } + + #[test] + fn provider_failure_preserves_inflight_checkpoint_for_reconciliation() { + let host = AgentHost::in_memory() + .unwrap() + .with_provider(Arc::new(FailingProvider), "failing-provider"); + let handle = host.prepare_run("provider failure").unwrap(); + + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(matches!(error, HostError::Engine(EngineError::Provider(_)))); + + let run = host.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "reconciling"); + assert!( + host.runtime() + .get_run_lease(&handle.run_id) + .unwrap() + .is_none() + ); + assert_eq!( + host.get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "reconciling" + ); + let checkpoint = host + .read_checkpoint(&handle.run_id) + .unwrap() + .expect("provider failure must retain its in-flight checkpoint"); + assert_eq!(checkpoint.phase, "provider_in_flight"); + assert!(checkpoint.provider_request_id.is_some()); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Reconciling + ); + } + + #[test] + fn approval_ask_preserves_gate_and_explicit_cancel_closes_it() { + let mut router = ToolRouter::new(); + let definition = + ToolDefinition::try_new("echo", "回显参数", json!({"type": "object"})).unwrap(); + let executions = Arc::new(Mutex::new(0usize)); + router + .register(definition, Arc::new(CountingTool(executions.clone()))) + .unwrap(); + let host = AgentHost::in_memory() + .unwrap() + .with_tools(router) + .with_approval(Arc::new(AskApproval)); + let handle = host.prepare_run("需要审批").unwrap(); + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(matches!( + error, + HostError::Engine(EngineError::ApprovalRequired { .. }) + )); + assert_eq!(*executions.lock().unwrap(), 0); + let run = host.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "reconciling"); + let checkpoint = host + .read_checkpoint(&handle.run_id) + .unwrap() + .expect("approval checkpoint remains observable"); + assert_eq!(checkpoint.phase, "awaiting_approval"); + assert_eq!(checkpoint.tool_call_id.as_deref(), Some("echo-call-1")); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Reconciling + ); + + let approvals = host.list_approvals(&handle.run_id).unwrap(); + assert_eq!(approvals.len(), 1); + assert_eq!(approvals[0].status, "pending"); + + // 未决 approval 可以由控制端显式关闭;取消同时阻止迟到的 resolve + // 再次把 run 放回队列。 + let cancelled = host.cancel(&handle.run_id).unwrap(); + assert_eq!(cancelled.status, "cancelled"); + assert_eq!( + host.get_approval(&approvals[0].id).unwrap().unwrap().status, + "cancelled" + ); + assert!(matches!( + host.resolve_approval(&approvals[0].id, ApprovalDecision::Allow), + Err(HostError::Storage(StorageError::ApprovalConflict { .. })) + )); + assert!(host.read_checkpoint(&handle.run_id).unwrap().is_none()); + assert_eq!( + host.load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap() + .run(&handle.run_id) + .unwrap() + .status(), + agent_runtime_core::RunStatus::Cancelled + ); + } + + #[test] + fn approval_allow_is_durable_and_resumes_without_replaying_provider_call() { + let provider = Arc::new(FakeProvider::tool_then_text( + "echo-call-1", + "echo", + json!({"text": "approved"}), + "approval resumed", + )); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "fake") + .with_approval(Arc::new(AskApproval)); + let handle = host.prepare_run("需要人工放行").unwrap(); + + assert!(matches!( + host.run_existing(&handle.run_id), + Err(HostError::Engine(EngineError::ApprovalRequired { .. })) + )); + let approval = host + .list_approvals(&handle.run_id) + .unwrap() + .into_iter() + .next() + .expect("Ask 必须创建 durable approval"); + let resolved = host + .resolve_approval(&approval.id, ApprovalDecision::Allow) + .unwrap(); + assert_eq!(resolved.status, "allowed"); + // 控制端重试同一个决议是幂等的;不同决议不能覆盖它。 + let repeated = host + .resolve_approval(&approval.id, ApprovalDecision::Allow) + .unwrap(); + assert_eq!(repeated.status, "allowed"); + assert!(matches!( + host.resolve_approval( + &approval.id, + ApprovalDecision::Deny { + reason: "late decision".to_owned(), + } + ), + Err(HostError::Storage(StorageError::ApprovalConflict { .. })) + )); + + assert_eq!(host.resume_approval(&approval.id).unwrap().status, "queued"); + let output = host.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.text, "approval resumed"); + assert_eq!( + host.get_run(&handle.run_id).unwrap().unwrap().status, + "completed" + ); + // 第一次 Provider 响应只产生 approval;resume 从 checkpoint 消费 + // 已有 tool call,第二次请求才是文本收口,不能重发第一步。 + let requests = provider.requests().snapshot(); + assert_eq!(requests.len(), 2); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let run = runtime.run(&handle.run_id).unwrap(); + assert_eq!(run.tool_calls().len(), 1); + assert_eq!(run.tool_results().len(), 1); + } + + #[test] + fn first_approval_in_multi_tool_batch_resumes_one_call_at_a_time() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = Arc::new(BatchApprovalProvider { + requests: requests.clone(), + }); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider, "fake") + .with_approval(Arc::new(AskApproval)); + let handle = host.prepare_run("批量审批").unwrap(); + + // 首次 Ask 只应把第一个调用写进 Runtime;第二个调用仍只存在于 + // checkpoint 的 assistant 消息,不能和 pending call 一起进入 Core。 + let first_error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(matches!( + first_error, + HostError::Engine(EngineError::ApprovalRequired { ref call_id, .. }) + if call_id == "batch-call-1" + )); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let run = runtime.run(&handle.run_id).unwrap(); + assert_eq!( + run.tool_calls() + .iter() + .map(|call| call.id()) + .collect::>(), + vec!["batch-call-1"] + ); + // The assistant tool-call message is part of the same approval + // checkpoint/runtime transaction, so a crash before audit replay does + // not leave the Core message prefix behind the checkpoint. + assert!( + run.messages() + .iter() + .flat_map(|message| message.content()) + .any(|part| matches!( + part, + ContentPart::ToolCall { id, .. } if id == "batch-call-1" + )) + ); + assert!(run.tool_results().is_empty()); + + let first_approval = host + .list_approvals(&handle.run_id) + .unwrap() + .into_iter() + .find(|approval| approval.tool_call_id.as_deref() == Some("batch-call-1")) + .expect("first call approval"); + host.resolve_approval(&first_approval.id, ApprovalDecision::Allow) + .unwrap(); + host.resume_approval(&first_approval.id).unwrap(); + + // 恢复首个调用后,Engine 应执行它并在第二个调用处再次 Ask;如果 + // restore_approval_checkpoint 提前物化了整个批次,这里会在 Core + // RunReconciled 阶段因两个未决调用而失败。 + let second_error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(matches!( + second_error, + HostError::Engine(EngineError::ApprovalRequired { ref call_id, .. }) + if call_id == "batch-call-2" + )); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let run = runtime.run(&handle.run_id).unwrap(); + assert_eq!(run.tool_calls().len(), 2); + assert_eq!(run.tool_results().len(), 1); + assert_eq!(run.tool_results()[0].call_id(), "batch-call-1"); + + let second_approval = host + .list_approvals(&handle.run_id) + .unwrap() + .into_iter() + .find(|approval| approval.tool_call_id.as_deref() == Some("batch-call-2")) + .expect("second call approval"); + host.resolve_approval(&second_approval.id, ApprovalDecision::Allow) + .unwrap(); + host.resume_approval(&second_approval.id).unwrap(); + let output = host.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.text, "批量审批完成"); + assert_eq!(requests.lock().unwrap().len(), 2); + } + + #[test] + fn approval_deny_is_a_durable_failed_tool_result_and_resumes() { + let provider = Arc::new(FakeProvider::tool_then_text( + "echo-call-1", + "echo", + json!({"text": "must not execute"}), + "denied but continued", + )); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "fake") + .with_approval(Arc::new(AskApproval)); + let handle = host.prepare_run("拒绝工具").unwrap(); + assert!(matches!( + host.run_existing(&handle.run_id), + Err(HostError::Engine(EngineError::ApprovalRequired { .. })) + )); + let approval = host + .list_approvals(&handle.run_id) + .unwrap() + .into_iter() + .next() + .unwrap(); + let resolved = host + .resolve_approval( + &approval.id, + ApprovalDecision::Deny { + reason: "用户拒绝".to_owned(), + }, + ) + .unwrap(); + assert_eq!(resolved.status, "denied"); + host.resume_approval(&approval.id).unwrap(); + let output = host.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.text, "denied but continued"); + let runtime = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let run = runtime.run(&handle.run_id).unwrap(); + assert_eq!(run.tool_results().len(), 1); + assert!(run.tool_results()[0].is_error()); + assert_eq!(provider.requests().snapshot().len(), 2); + } + + #[test] + fn running_worker_observes_cross_thread_cancel_at_step_boundary() { + let store = SqliteStore::open_in_memory().unwrap(); + let cancel_host = AgentHost::with_store(store.clone()).unwrap(); + let handle = cancel_host.prepare_run("cancel while tool runs").unwrap(); + + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let mut router = ToolRouter::new(); + let definition = + ToolDefinition::try_new("echo", "回显传入的 JSON 参数", json!({"type": "object"})) + .unwrap(); + router + .register( + definition, + Arc::new(BlockingEchoTool { + started: started_tx, + release: Mutex::new(release_rx), + }), + ) + .unwrap(); + let worker_host = AgentHost::with_store(store.clone()) + .unwrap() + .with_tools(router); + let run_id = handle.run_id.clone(); + let worker = thread::spawn(move || worker_host.run_existing(&run_id)); + + started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("tool should start"); + let checkpoint = store + .read_checkpoint(&handle.run_id) + .unwrap() + .expect("tool boundary should be durable before the call"); + assert_eq!(checkpoint.phase, "tool_in_flight"); + assert_eq!(checkpoint.tool_call_id.as_deref(), Some("echo-call-1")); + cancel_host.cancel(&handle.run_id).unwrap(); + // 给 SQLite watcher 一个轮询周期,再放行当前同步工具调用。 + thread::sleep(Duration::from_millis(100)); + release_tx.send(()).unwrap(); + + let result = worker.join().unwrap(); + let error = result.expect_err("worker should finish as cancelled"); + assert!(error.is_cancelled()); + let run = store.get_run(&handle.run_id).unwrap().unwrap(); + assert_eq!(run.status, "cancelled"); + assert!(store.read_checkpoint(&handle.run_id).unwrap().is_none()); + let runtime = store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Cancelled + ); + assert!( + !store + .list_runtime_events(&handle.runtime_id) + .unwrap() + .iter() + .any(|event| event.kind() == RuntimeEventKind::RunCompleted) + ); + } + + #[test] + fn expired_worker_moves_to_reconciling_without_replay() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("reconcile after crash").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + let (_, lease_record) = store + .claim_run_with_lease( + &handle.run_id, + lease.worker_id.clone(), + lease.lease_token.clone(), + Duration::from_millis(50), + ) + .unwrap(); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([{"role":"user","content":[{"type":"text","text":"reconcile after crash"}]}]), + provider_request_id: Some("request-0".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + &lease.worker_id, + &lease.lease_token, + ) + .unwrap(); + assert!(lease_record.lease_expires_at >= lease_record.heartbeat_at); + thread::sleep(Duration::from_millis(100)); + + let recovered = host.reconcile_expired_run(&handle.run_id).unwrap(); + assert_eq!(recovered.status, "reconciling"); + assert!(store.get_run_lease(&handle.run_id).unwrap().is_none()); + assert_eq!( + store + .read_checkpoint(&handle.run_id) + .unwrap() + .unwrap() + .phase, + "provider_in_flight" + ); + assert!(host.run_existing(&handle.run_id).is_err()); + let runtime = store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Reconciling + ); + assert_eq!( + store + .get_session(&handle.session_id) + .unwrap() + .unwrap() + .status, + "reconciling" + ); + } + + #[test] + fn host_reconcile_stale_runs_delegates_bounded_scan() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let stale = host.prepare_run("stale batch candidate").unwrap(); + let active = host.prepare_run("active batch candidate").unwrap(); + let queued = host.prepare_run("queued batch candidate").unwrap(); + + let stale_lease = WorkerLease::new(&stale.run_id); + store + .claim_run_with_lease( + &stale.run_id, + stale_lease.worker_id.clone(), + stale_lease.lease_token.clone(), + Duration::from_millis(5), + ) + .unwrap(); + let active_lease = WorkerLease::new(&active.run_id); + store + .claim_run_with_lease( + &active.run_id, + active_lease.worker_id.clone(), + active_lease.lease_token.clone(), + Duration::from_secs(5), + ) + .unwrap(); + thread::sleep(Duration::from_millis(40)); + + let recovered = host.recover_stale_runs_on_start(2).unwrap(); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, stale.run_id); + assert_eq!(recovered[0].status, "reconciling"); + assert_eq!( + store.get_run(&active.run_id).unwrap().unwrap().status, + "running" + ); + assert_eq!( + store.get_run(&queued.run_id).unwrap().unwrap().status, + "queued" + ); + + // The legacy control-plane spelling remains bounded as well. + assert!(host.reconcile_stale_runs(0).unwrap().is_empty()); + + // The Host-facing method keeps Runtime's hard input bound and does not + // silently widen a scan beyond the caller's requested limit. + assert!( + host.reconcile_stale_runs(agent_runtime_sqlite::MAX_STALE_RUN_SCAN_LIMIT + 1) + .unwrap_err() + .to_string() + .contains("不能超过") + ); + } + + #[test] + fn stale_cancel_requested_run_enters_reconciliation_on_restart() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("cancelled worker restart").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + let (_, lease_record) = store + .claim_run_with_lease( + &handle.run_id, + lease.worker_id.clone(), + lease.lease_token.clone(), + Duration::from_millis(5), + ) + .unwrap(); + host.cancel(&handle.run_id).unwrap(); + thread::sleep(Duration::from_millis(40)); + + // The stale lease must not block recovery merely because the row is + // still present. Unknown work remains behind the reconciliation gate. + let error = host.run_existing(&handle.run_id).unwrap_err(); + assert!(error.to_string().contains("已自动进入 reconciling")); + assert_eq!( + store.get_run(&handle.run_id).unwrap().unwrap().status, + "reconciling" + ); + assert!(store.get_run_lease(&handle.run_id).unwrap().is_none()); + assert!(lease_record.lease_expires_at <= lease_record.heartbeat_at + 100); + } + + #[test] + fn explicit_provider_and_tool_reconciliation_produce_safe_resumable_cursors() { + // Provider 响应对账:完整历史包含新的 assistant 响应,CAS 成功后才允许 + // 显式 resume-safe;没有对账前缀或响应的输入会被 Host 拒绝。 + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let provider_handle = host.prepare_run("provider reconciliation").unwrap(); + let provider_lease = WorkerLease::new(&provider_handle.run_id); + let (_, provider_lease_record) = store + .claim_run_with_lease( + &provider_handle.run_id, + provider_lease.worker_id.clone(), + provider_lease.lease_token.clone(), + Duration::from_millis(50), + ) + .unwrap(); + let provider_user = Message::user("provider reconciliation").unwrap(); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: provider_handle.run_id.clone(), + phase: "provider_in_flight".into(), + step: 0, + next_step: 0, + messages: serde_json::to_value([provider_user.clone()]).unwrap(), + provider_request_id: Some("provider-request-1".into()), + tool_call_id: None, + attempt: provider_lease_record.attempt, + }, + &provider_lease.worker_id, + &provider_lease.lease_token, + ) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + host.reconcile_expired_run(&provider_handle.run_id).unwrap(); + + assert!( + host.reconcile_provider_result( + &provider_handle.run_id, + "provider-request-1", + vec![provider_user.clone()], + ) + .is_err() + ); + let provider_assistant = Message::assistant("known provider response").unwrap(); + let provider_safe = host + .reconcile_provider_result( + &provider_handle.run_id, + "provider-request-1", + vec![provider_user, provider_assistant], + ) + .unwrap(); + assert_eq!(provider_safe.phase, "safe"); + assert_eq!(provider_safe.next_step, 1); + assert_eq!( + host.requeue_safe_run(&provider_handle.run_id) + .unwrap() + .status, + "queued" + ); + assert_eq!( + host.run_existing(&provider_handle.run_id) + .unwrap() + .output + .steps, + 2 + ); + + // Tool 响应对账:只有匹配 checkpoint 的 tool result 能把游标切成 safe。 + let tool_handle = host.prepare_run("tool reconciliation").unwrap(); + let tool_lease = WorkerLease::new(&tool_handle.run_id); + let (_, tool_lease_record) = store + .claim_run_with_lease( + &tool_handle.run_id, + tool_lease.worker_id.clone(), + tool_lease.lease_token.clone(), + Duration::from_millis(50), + ) + .unwrap(); + let tool_user = Message::user("tool reconciliation").unwrap(); + let tool_call = ToolCall::try_new("reconcile-call", "echo", json!({"text": "ok"})).unwrap(); + let tool_call_message = Message::new( + MessageRole::Assistant, + vec![ + ContentPart::tool_call( + tool_call.id(), + tool_call.name(), + tool_call.arguments().clone(), + ) + .unwrap(), + ], + ); + let tool_result_message = Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result(tool_call.id(), json!({"received": true}), false).unwrap(), + ], + ); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: tool_handle.run_id.clone(), + phase: "tool_in_flight".into(), + step: 0, + next_step: 0, + messages: serde_json::to_value([tool_user.clone(), tool_call_message.clone()]) + .unwrap(), + provider_request_id: Some("provider-request-2".into()), + tool_call_id: Some(tool_call.id().into()), + attempt: tool_lease_record.attempt, + }, + &tool_lease.worker_id, + &tool_lease.lease_token, + ) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + host.reconcile_expired_run(&tool_handle.run_id).unwrap(); + let tool_safe = host + .reconcile_tool_result( + &tool_handle.run_id, + tool_call.id(), + vec![tool_user, tool_call_message, tool_result_message], + ) + .unwrap(); + assert_eq!(tool_safe.phase, "safe"); + assert_eq!(tool_safe.tool_call_id, None); + host.requeue_safe_run(&tool_handle.run_id).unwrap(); + let output = host.run_existing(&tool_handle.run_id).unwrap(); + assert_eq!(output.output.steps, 1); + assert_eq!( + store.get_run(&tool_handle.run_id).unwrap().unwrap().status, + "completed" + ); + } + + #[test] + fn bounded_external_reconciler_commits_completed_and_keeps_unknown_conservative() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let completed_handle = host.prepare_run("automatic reconciliation").unwrap(); + let lease = WorkerLease::new(&completed_handle.run_id); + let (_, lease_record) = store + .claim_run_with_lease( + &completed_handle.run_id, + lease.worker_id.clone(), + lease.lease_token.clone(), + Duration::from_millis(50), + ) + .unwrap(); + let user = Message::user("automatic reconciliation").unwrap(); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: completed_handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: serde_json::to_value([user.clone()]).unwrap(), + provider_request_id: Some("auto-provider-1".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + &lease.worker_id, + &lease.lease_token, + ) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + host.reconcile_expired_run(&completed_handle.run_id) + .unwrap(); + + host.record_external_session(NewExternalSession { + id: "auto-completed".to_owned(), + session_id: completed_handle.session_id.clone(), + run_id: Some(completed_handle.run_id.clone()), + backend: "fake".to_owned(), + external_id: "auto-provider-1".to_owned(), + status: "unknown".to_owned(), + metadata: json!({"operation": "get"}), + }) + .unwrap(); + host.record_external_session(NewExternalSession { + id: "auto-pending".to_owned(), + session_id: completed_handle.session_id.clone(), + run_id: None, + backend: "fake".to_owned(), + external_id: "auto-pending".to_owned(), + status: "unknown".to_owned(), + metadata: json!({"operation": "get"}), + }) + .unwrap(); + host.record_external_session(NewExternalSession { + id: "auto-not-found".to_owned(), + session_id: completed_handle.session_id.clone(), + run_id: None, + backend: "fake".to_owned(), + external_id: "auto-not-found".to_owned(), + status: "unknown".to_owned(), + metadata: json!({"operation": "get"}), + }) + .unwrap(); + + let resolver = |record: &ExternalSessionRecord, + request: &ExternalObservationRequest| + -> Result { + assert_eq!(request.backend(), "fake"); + assert_eq!(request.operation(), "get"); + match record.external_id.as_str() { + "auto-provider-1" => Ok(ExternalSessionResolution::Completed { + messages: vec![ + user.clone(), + Message::assistant("already completed").unwrap(), + ], + metadata: json!({"resolver": "fake"}), + }), + "auto-pending" => Ok(ExternalSessionResolution::Pending { + metadata: json!({"poll": 1}), + }), + "auto-not-found" => Ok(ExternalSessionResolution::NotFound { + metadata: json!({"poll": 1}), + }), + other => Err(ExternalError::new( + ExternalErrorKind::InvalidInput, + format!("unexpected external id: {other}"), + )), + } + }; + + let results = host.reconcile_external_sessions(3, &resolver).unwrap(); + assert_eq!(results.len(), 3); + assert_eq!( + results + .iter() + .find(|result| result.record_id == "auto-completed") + .unwrap() + .outcome, + ExternalSessionReconcileOutcome::Completed + ); + assert_eq!( + results + .iter() + .find(|result| result.record_id == "auto-pending") + .unwrap() + .outcome, + ExternalSessionReconcileOutcome::Pending + ); + assert_eq!( + results + .iter() + .find(|result| result.record_id == "auto-not-found") + .unwrap() + .outcome, + ExternalSessionReconcileOutcome::NotFound + ); + assert_eq!( + store + .get_external_session("auto-completed") + .unwrap() + .unwrap() + .status, + "completed" + ); + assert_eq!( + store + .get_external_session("auto-pending") + .unwrap() + .unwrap() + .status, + "unknown" + ); + assert_eq!( + store + .get_external_session("auto-not-found") + .unwrap() + .unwrap() + .status, + "unknown" + ); + assert_eq!( + store + .read_checkpoint(&completed_handle.run_id) + .unwrap() + .unwrap() + .phase, + "safe" + ); + + // Completed rows leave the candidate set, so a repeated bounded scan + // does not replay the already reconciled external call. + let second = host.reconcile_external_sessions(3, &resolver).unwrap(); + assert!( + second + .iter() + .all(|result| result.record_id != "auto-completed") + ); + } + + #[test] + fn safe_checkpoint_can_be_explicitly_requeued_and_resumed() { + let store = SqliteStore::open_in_memory().unwrap(); + let host = AgentHost::with_store(store.clone()).unwrap(); + let handle = host.prepare_run("resume from safe cursor").unwrap(); + let lease = WorkerLease::new(&handle.run_id); + let (_, lease_record) = store + .claim_run_with_lease( + &handle.run_id, + lease.worker_id.clone(), + lease.lease_token.clone(), + Duration::from_millis(50), + ) + .unwrap(); + + let runtime_service = RuntimeService::from_store(store.clone()); + let mut runtime = runtime_service + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let started = RuntimeEvent::status_changed( + &handle.runtime_id, + runtime.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .unwrap(); + commit_runtime_event(&runtime_service, &mut runtime, started).unwrap(); + let mut messages = runtime.run(&handle.run_id).unwrap().messages().to_vec(); + let call = ToolCall::try_new("observed-call", "echo", json!({"text": "done"})).unwrap(); + let result = ToolResult::success(call.id(), json!({"received": true})).unwrap(); + messages.push(Message::new( + MessageRole::Assistant, + vec![ContentPart::tool_call(call.id(), call.name(), call.arguments().clone()).unwrap()], + )); + messages.push(Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result( + result.call_id(), + result.output().clone(), + result.is_error(), + ) + .unwrap(), + ], + )); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "safe".into(), + step: 0, + next_step: 1, + messages: serde_json::to_value(&messages).unwrap(), + provider_request_id: Some("request-0".into()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + &lease.worker_id, + &lease.lease_token, + ) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + host.reconcile_expired_run(&handle.run_id).unwrap(); + assert_eq!( + host.requeue_safe_run(&handle.run_id).unwrap().status, + "queued" + ); + assert_eq!( + host.requeue_safe_run(&handle.run_id).unwrap().status, + "queued" + ); + // requeue 只推进 run-level 状态;runtime 补账留给随后领取的 worker, + // 因此两步之间退出不会把 runtime 提前暴露成 Running。 + assert_eq!( + store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap() + .run(&handle.run_id) + .unwrap() + .status(), + agent_runtime_core::RunStatus::Reconciling + ); + let output = host.run_existing(&handle.run_id).unwrap(); + assert_eq!(output.output.steps, 1); + assert_eq!( + store.get_run(&handle.run_id).unwrap().unwrap().status, + "completed" + ); + assert!(store.read_checkpoint(&handle.run_id).unwrap().is_none()); + let runtime = store + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + runtime.run(&handle.run_id).unwrap().status(), + agent_runtime_core::RunStatus::Completed + ); + let restored = runtime.run(&handle.run_id).unwrap(); + assert_eq!(restored.tool_calls().len(), 1); + assert_eq!(restored.tool_results().len(), 1); + } +} diff --git a/rust/crates/agent-host/tests/message_persistence.rs b/rust/crates/agent-host/tests/message_persistence.rs new file mode 100644 index 000000000..c15ec8204 --- /dev/null +++ b/rust/crates/agent-host/tests/message_persistence.rs @@ -0,0 +1,400 @@ +use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use agent_host::{AgentHost, HostError}; +use agent_provider_fake::{FakeProvider, FakeStep, FakeToolCall}; +use agent_runtime_core::{ + ApprovalDecision, ApprovalError, ApprovalPolicy, ContentPart, Message, ProviderErrorKind, + RuntimeSnapshot, ToolCall, ToolResult, reduce, +}; +use agent_runtime_engine::{CompressionRequest, ContextCompressor, EngineError}; +use serde_json::json; + +fn assert_snapshot_matches_output(host: &AgentHost, result: &agent_host::HostRunOutput) { + // 结果消息、持久化快照和事件重放必须描述同一条消息历史。 + let snapshot = host + .load_runtime_snapshot(&result.runtime_id) + .expect("读取 RuntimeSnapshot") + .expect("RuntimeSnapshot 应存在"); + let run = snapshot.run(&result.run_id).expect("run 应存在"); + assert_eq!(run.messages(), result.output.messages.as_slice()); + // 消息正确还不够,派生工具索引也必须与当前上下文逐项一致。 + let mut calls = Vec::new(); + let mut results = Vec::new(); + for part in run.messages().iter().flat_map(|message| message.content()) { + match part { + ContentPart::ToolCall { + id, + name, + arguments, + } => { + calls.push(ToolCall::try_new(id, name, arguments.clone()).unwrap()); + } + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => { + results.push(ToolResult::try_new(tool_call_id, output.clone(), *is_error).unwrap()); + } + _ => {} + } + } + assert_eq!(run.tool_calls(), calls); + assert_eq!(run.tool_results(), results); + + let events = host + .list_runtime_events(&result.runtime_id) + .expect("读取 runtime events"); + let mut replayed = RuntimeSnapshot::try_new(&result.runtime_id).expect("创建空快照"); + for event in events { + replayed = reduce(&replayed, &event).expect("runtime event 应可重放"); + } + assert_eq!(replayed, snapshot); +} + +fn assert_tool_rows_are_terminal_and_unique(host: &AgentHost, run_id: &str, expected: usize) { + // 每个工具调用只能有一行最终结果,不能把中间 requested 状态当成完成。 + let rows = host.list_tool_calls(run_id).expect("读取工具调用记录"); + assert_eq!(rows.len(), expected); + let mut ids = BTreeSet::new(); + for row in rows { + assert!(ids.insert(row.id), "工具调用 id 不应重复"); + assert_ne!(row.status, "requested", "工具调用不应停留在 requested"); + assert!(row.result.is_some(), "已完成工具调用应有结果"); + } +} + +#[test] +fn normal_complete_persists_exactly_one_message_history_and_replay() { + let host = AgentHost::in_memory() + .expect("创建 Host") + .with_provider(Arc::new(FakeProvider::text("普通完成")), "fake"); + let result = host + .run_with_messages("普通完成", vec![Message::user("普通完成").unwrap()]) + .expect("普通运行应完成"); + + assert_eq!(result.output.text, "普通完成"); + assert_snapshot_matches_output(&host, &result); + assert_tool_rows_are_terminal_and_unique(&host, &result.run_id, 0); +} + +#[test] +fn normal_stream_persists_exactly_one_message_history_and_replay() { + let host = AgentHost::in_memory().expect("创建 Host").with_provider( + Arc::new(FakeProvider::new([FakeStep::stream_text(["流", "式"])])), + "fake", + ); + let result = host + .run_with_messages_streaming("流式完成", vec![Message::user("流式完成").unwrap()]) + .expect("流式运行应完成"); + + assert_eq!(result.output.text, "流式"); + assert!(!result.output.stream_events.is_empty()); + assert_snapshot_matches_output(&host, &result); + assert_tool_rows_are_terminal_and_unique(&host, &result.run_id, 0); +} + +#[test] +fn automatic_tool_batches_and_later_round_match_engine_in_both_modes() { + for streaming in [false, true] { + let provider = Arc::new(FakeProvider::new([ + FakeStep::ToolCalls { + text: "同一模型响应中的说明".to_owned(), + calls: (1..=3) + .map(|index| FakeToolCall { + id: format!("automatic-{index}"), + name: "echo".to_owned(), + arguments: json!({"index": index}), + }) + .collect(), + }, + FakeStep::tool_call("automatic-next", "echo", json!({"index": 4})), + FakeStep::text("同一模型响应中的说明"), + ])); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "fake"); + let result = if streaming { + host.run_with_messages_streaming("自动批次", vec![Message::user("自动批次").unwrap()]) + } else { + host.run("自动批次") + } + .expect("自动放行的连续工具轮次应完成"); + assert_eq!(result.output.messages.len(), 8); + assert_eq!(provider.requests().snapshot().len(), 3); + assert_snapshot_matches_output(&host, &result); + assert_tool_rows_are_terminal_and_unique(&host, &result.run_id, 4); + } +} + +struct AllowThenAsk { + asked: AtomicBool, +} + +impl AllowThenAsk { + fn new() -> Self { + Self { + asked: AtomicBool::new(false), + } + } +} + +impl ApprovalPolicy for AllowThenAsk { + fn decide( + &self, + request: &agent_runtime_core::ApprovalRequest, + ) -> Result { + if request.call().id() == "batch-call-3" && !self.asked.swap(true, Ordering::AcqRel) { + Ok(ApprovalDecision::Ask) + } else { + Ok(ApprovalDecision::Allow) + } + } +} + +#[test] +fn three_tool_batch_and_next_tool_round_keep_one_message_history() { + let provider = Arc::new(FakeProvider::new([ + FakeStep::tool_calls([ + FakeToolCall { + id: "batch-call-1".to_owned(), + name: "echo".to_owned(), + arguments: json!({"index": 1}), + }, + FakeToolCall { + id: "batch-call-2".to_owned(), + name: "echo".to_owned(), + arguments: json!({"index": 2}), + }, + FakeToolCall { + id: "batch-call-3".to_owned(), + name: "echo".to_owned(), + arguments: json!({"index": 3}), + }, + ]), + FakeStep::tool_call("next-round-call", "echo", json!({"index": 4})), + FakeStep::text("三工具批次完成"), + ])); + let host = AgentHost::in_memory() + .expect("创建 Host") + .with_provider(provider.clone(), "fake") + .with_approval(Arc::new(AllowThenAsk::new())); + let handle = host.prepare_run("三工具批次").expect("创建 run"); + + let first = host + .run_existing(&handle.run_id) + .expect_err("第三个调用应 Ask"); + assert!(matches!( + first, + HostError::Engine(EngineError::ApprovalRequired { ref call_id, .. }) + if call_id == "batch-call-3" + )); + let checkpoint = host.read_checkpoint(&handle.run_id).unwrap().unwrap(); + let checkpoint_messages: Vec = serde_json::from_value(checkpoint.messages).unwrap(); + let pending_snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + assert_eq!( + pending_snapshot.run(&handle.run_id).unwrap().messages(), + checkpoint_messages + ); + assert_eq!(checkpoint_messages.len(), 4); + let approval = host + .list_approvals(&handle.run_id) + .expect("读取 approval") + .into_iter() + .find(|item| item.tool_call_id.as_deref() == Some("batch-call-3")) + .expect("第三个调用应有 pending approval"); + host.resolve_approval(&approval.id, ApprovalDecision::Allow) + .expect("允许第三个调用"); + host.resume_approval(&approval.id).expect("重新排队"); + + // 恢复时不能重新请求产生首批工具调用的 Provider;只继续未完成的调用和下一轮。 + let result = host.run_existing(&handle.run_id).expect("恢复后应完成"); + assert_eq!(result.output.text, "三工具批次完成"); + assert_snapshot_matches_output(&host, &result); + assert_tool_rows_are_terminal_and_unique(&host, &result.run_id, 4); + assert_eq!(provider.remaining_steps(), 0); +} + +#[test] +fn provider_error_keeps_reconciling_snapshot_replay_consistent() { + let host = AgentHost::in_memory().expect("创建 Host").with_provider( + Arc::new(FakeProvider::new([ + FakeStep::tool_call("before-error", "echo", json!({"index": 1})), + FakeStep::Error { + kind: ProviderErrorKind::Stream, + message: "provider fixture error".to_owned(), + }, + ])), + "fake", + ); + let handle = host.prepare_run("Provider 错误").expect("创建 run"); + let error = host + .run_existing(&handle.run_id) + .expect_err("Provider 错误应返回"); + assert!(matches!(error, HostError::Engine(EngineError::Provider(_)))); + + let record = host + .get_run(&handle.run_id) + .expect("读取 run") + .expect("run 应存在"); + assert_eq!(record.status, "reconciling"); + let checkpoint = host + .read_checkpoint(&handle.run_id) + .expect("读取 checkpoint") + .expect("Provider 错误应保留 checkpoint"); + assert_eq!(checkpoint.phase, "provider_in_flight"); + let snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .expect("读取 RuntimeSnapshot") + .expect("RuntimeSnapshot 应存在"); + let events = host + .list_runtime_events(&handle.runtime_id) + .expect("读取 runtime events"); + let mut replayed = RuntimeSnapshot::try_new(&handle.runtime_id).expect("创建空快照"); + for event in events { + replayed = reduce(&replayed, &event).expect("runtime event 应可重放"); + } + assert_eq!(replayed, snapshot); + let checkpoint_messages: Vec = serde_json::from_value(checkpoint.messages).unwrap(); + assert_eq!( + snapshot.run(&handle.run_id).unwrap().messages(), + checkpoint_messages + ); + assert_eq!(checkpoint_messages.len(), 3); + assert_tool_rows_are_terminal_and_unique(&host, &handle.run_id, 1); +} + +struct ShortCompressor; + +impl ContextCompressor for ShortCompressor { + fn compress(&self, _request: &CompressionRequest) -> Result, EngineError> { + Ok(vec![Message::user("压缩后的历史").expect("构造摘要消息")]) + } +} + +#[test] +fn tool_after_context_compression_does_not_restore_old_history() { + let host = AgentHost::in_memory() + .expect("创建 Host") + .with_provider( + Arc::new(FakeProvider::new([ + FakeStep::ToolCalls { + text: "旧历史".repeat(10_000), + calls: vec![FakeToolCall { + id: "before-compression".to_owned(), + name: "echo".to_owned(), + arguments: json!({"index": 0}), + }], + }, + FakeStep::ToolCalls { + text: "第二轮旧历史".repeat(10_000), + calls: vec![FakeToolCall { + id: "compressed-call".to_owned(), + name: "echo".to_owned(), + arguments: json!({"ok": true}), + }], + }, + FakeStep::text("压缩后完成"), + ])), + "fake", + ) + .with_context_compressor(Arc::new(ShortCompressor)); + let result = host + .run_with_messages("压缩测试", vec![Message::user("开始工具后压缩").unwrap()]) + .expect("压缩后运行应完成"); + + assert_eq!(result.output.text, "压缩后完成"); + assert_eq!( + result + .output + .context_observations + .iter() + .filter(|observation| observation.compression_attempted) + .count(), + 2 + ); + assert_snapshot_matches_output(&host, &result); + assert_tool_rows_are_terminal_and_unique(&host, &result.run_id, 2); + let runtime = host + .load_runtime_snapshot(&result.runtime_id) + .expect("读取 RuntimeSnapshot") + .expect("RuntimeSnapshot 应存在"); + let run = runtime.run(&result.run_id).expect("run"); + let messages = run.messages(); + assert!(messages.iter().any(|message| { + message + .content() + .iter() + .any(|part| part.as_text() == Some("压缩后的历史")) + })); + assert!(!messages.iter().any(|message| { + message + .content() + .iter() + .any(|part| part.as_text().is_some_and(|text| text.contains("旧历史"))) + })); +} + +struct FailingCompressor; + +impl ContextCompressor for FailingCompressor { + fn compress(&self, _request: &CompressionRequest) -> Result, EngineError> { + Err(EngineError::ContextOverflow("压缩失败 fixture".into())) + } +} + +#[test] +fn failed_compression_after_tool_keeps_full_history_without_repeating_results() { + let provider = Arc::new(FakeProvider::new([ + FakeStep::ToolCalls { + text: "待压缩".repeat(10_000), + calls: vec![FakeToolCall { + id: "before-failed-compression".into(), + name: "echo".into(), + arguments: json!({"ok": true}), + }], + }, + FakeStep::text("不能调用这一步"), + ])); + let host = AgentHost::in_memory() + .unwrap() + .with_provider(provider.clone(), "fake") + .with_context_compressor(Arc::new(FailingCompressor)); + let handle = host.prepare_run("工具后压缩失败").unwrap(); + assert!(matches!( + host.run_existing(&handle.run_id), + Err(HostError::Engine(EngineError::ContextOverflow(_))) + )); + assert_eq!(provider.requests().snapshot().len(), 1); + assert_eq!( + host.get_run(&handle.run_id).unwrap().unwrap().status, + "reconciling" + ); + let checkpoint = host.read_checkpoint(&handle.run_id).unwrap().unwrap(); + assert_eq!(checkpoint.phase, "compacting"); + let messages: Vec = serde_json::from_value(checkpoint.messages).unwrap(); + assert_eq!(messages.len(), 3); + let snapshot = host + .load_runtime_snapshot(&handle.runtime_id) + .unwrap() + .unwrap(); + let run = snapshot.run(&handle.run_id).unwrap(); + assert_eq!(run.messages(), messages); + assert_eq!(run.tool_calls().len(), 1); + assert_eq!(run.tool_results().len(), 1); + assert_tool_rows_are_terminal_and_unique(&host, &handle.run_id, 1); + let replayed = host + .list_runtime_events(&handle.runtime_id) + .unwrap() + .iter() + .fold( + RuntimeSnapshot::try_new(&handle.runtime_id).unwrap(), + |snapshot, event| reduce(&snapshot, event).unwrap(), + ); + assert_eq!(replayed, snapshot); +} diff --git a/rust/crates/agent-mcp/Cargo.toml b/rust/crates/agent-mcp/Cargo.toml new file mode 100644 index 000000000..1c2db5322 --- /dev/null +++ b/rust/crates/agent-mcp/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-mcp" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "通用 Agent 的轻量 MCP 协议与传输适配接口" +license = "MIT" + +[dependencies] +reqwest = { version = "0.12", features = ["blocking"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "net", "rt", "time"] } diff --git a/rust/crates/agent-mcp/fixtures/stdio-jsonrpc-server.sh b/rust/crates/agent-mcp/fixtures/stdio-jsonrpc-server.sh new file mode 100755 index 000000000..8d0860197 --- /dev/null +++ b/rust/crates/agent-mcp/fixtures/stdio-jsonrpc-server.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +# 一个只用于测试的最小 MCP stdio server。 +# +# 它不执行 shell 参数,也不读取环境中的密钥;只按 JSON-RPC method 返回 +# 固定的 initialize、tools/list 和 tools/call fixture。测试通过 `sh` 调用 +# 本文件,因此不依赖可执行位或额外的 jq/node 运行时。 + +while IFS= read -r line; do + # 请求 id 是数字时原样回显,保证 client 的 response-id 校验仍然有效。 + id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') + [ -n "$id" ] || id=null + + case "$line" in + *'"method":"notifications/initialized"'*|*'"method": "notifications/initialized"'*) + # notification 没有 response;继续读取后续请求。 + continue + ;; + *'"method":"initialize"'*|*'"method": "initialize"'*) + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"tools\":{}}}}" + ;; + *'"method":"tools/list"'*|*'"method": "tools/list"'*) + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"tools\":[{\"name\":\"fixture_echo\",\"description\":\"fixture tool\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}}]}}" + ;; + *'"method":"tools/call"'*|*'"method": "tools/call"'*) + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"fixture-ok\"}],\"isError\":false}}" + ;; + *) + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"error\":{\"code\":-32601,\"message\":\"fixture method not found\"}}" + ;; + esac +done diff --git a/rust/crates/agent-mcp/src/lib.rs b/rust/crates/agent-mcp/src/lib.rs new file mode 100644 index 000000000..c68832f66 --- /dev/null +++ b/rust/crates/agent-mcp/src/lib.rs @@ -0,0 +1,5759 @@ +//! MCP 的协议模型与同步客户端。 +//! +//! DTO 和编码器保持无运行时依赖;在此基础上提供小型阻塞式 client,方便 +//! CLI/Host 连接 MCP。HTTP 通知订阅在私有 worker 内使用一个 current-thread +//! Tokio runtime 仅负责可取消的 SSE 读取;它不是面向调用方的通用异步调度器。 +//! 权限策略和工具副作用仍属于上层,不由本 crate 代替。 + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::env; +use std::ffi::OsStr; +use std::fmt; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use reqwest::Client as AsyncHttpClient; +use reqwest::Response as AsyncHttpResponse; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// MCP 默认优先尝试的协议版本。 +/// +/// `McpClientOptions` 默认只尝试这个版本;调用方可以显式提供一个有界的 +/// 候选列表进行协商。未知版本不会被静默当成兼容。 +pub const DEFAULT_PROTOCOL_VERSION: &str = "2025-06-18"; +/// 单次 initialize 最多尝试的协议版本数,避免错误配置造成无界重连。 +pub const MAX_PROTOCOL_VERSION_CANDIDATES: usize = 8; +/// 单条 stdio JSON-RPC 消息允许的最大正文长度(不含 CR/LF)。 +/// +/// 这是 transport 层的硬上限,避免恶意服务端通过一条没有换行的消息 +/// 让 reader 线程无限增长内存。需要更大的业务数据时应由服务端分页或 +/// 使用资源 URI,而不是把整块内容塞进一条 JSON-RPC 消息。 +pub const MAX_STDIO_MESSAGE_BYTES: usize = 1024 * 1024; +/// Streamable HTTP 响应(解压后的正文)允许的最大字节数。 +pub const MAX_HTTP_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +/// SSE 单行的最大字节数。它小于整个 HTTP 响应上限,防止单个 data 行 +/// 在解析前制造过大的临时字符串。 +pub const MAX_SSE_LINE_BYTES: usize = 1024 * 1024; +/// SSE 一个事件中累计的 data 字节上限。 +pub const MAX_SSE_EVENT_BYTES: usize = 4 * 1024 * 1024; +/// 防止恶意或损坏的 MCP 服务用循环游标让 tools/list 无限请求。 +pub const MAX_TOOL_LIST_PAGES: usize = 1024; +/// resources/list 与 prompts/list 使用相同的有限分页保护。 +pub const MAX_RESOURCE_LIST_PAGES: usize = 1024; +pub const MAX_PROMPT_LIST_PAGES: usize = 1024; +/// 重连调度器的硬上限。调用方可以降低它,但不能借配置制造无界循环。 +pub const MAX_RECONNECT_ATTEMPTS: usize = 8; +const RECONNECT_CANCEL_POLL: Duration = Duration::from_millis(10); +/// 后台通知订阅允许的最大队列容量。调用方应按消费速度选择更小的值。 +pub const MAX_NOTIFICATION_QUEUE_CAPACITY: usize = 4096; +/// HTTP 通知流检查 cooperative cancellation 的最大间隔。 +/// +/// 长连接必须周期性回到 worker 检查取消标记;调用方提供更大的间隔也不能 +/// 让 `Drop` 无限期卡在一个没有任何字节的 socket 上。它不是 SSE 空闲超时。 +pub const MAX_HTTP_NOTIFICATION_CANCEL_INTERVAL: Duration = Duration::from_secs(1); +/// stdio reader 到同步 client 的内部消息队列上限。 +/// +/// 即使调用方没有启用后台订阅,服务端也不能通过持续推送通知把无界 +/// `mpsc` 队列填满内存;队列满时 reader 会自然反压到子进程 stdout。 +pub const MAX_STDIO_MESSAGE_QUEUE_CAPACITY: usize = 4096; +/// stdio 同步 client 在等待响应时暂存的消息上限。 +/// +/// 这和 reader 的中间队列分开计数:通知可能已经被同步 client 取出, +/// 但还没来得及由调用方显式 `poll_notification` 消费。保持同样的硬上限, +/// 避免跨多轮 request 的 pending backlog 重新变成无界内存。 +const MAX_STDIO_PENDING_MESSAGES: usize = MAX_NOTIFICATION_QUEUE_CAPACITY; +const NOTIFICATION_CANCEL_POLL: Duration = Duration::from_millis(10); + +/// 一个 MCP 服务器的连接配置。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpServerConfig { + /// 在 Agent 内部使用的稳定名称,同时用于工具命名空间。 + pub name: String, + pub transport: McpTransportConfig, + /// 认证配置只记录环境变量引用,秘密只在建立连接时短暂解析。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub auth: Vec, +} + +impl fmt::Debug for McpServerConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("McpServerConfig") + .field("name", &self.name) + .field("transport", &self.transport) + .field("auth", &RedactedAuthBindings(self.auth.len())) + .finish() + } +} + +impl McpServerConfig { + pub fn new(name: impl Into, transport: McpTransportConfig) -> Self { + Self { + name: name.into(), + transport, + auth: Vec::new(), + } + } + + /// 添加一个环境变量认证引用;配置和 Debug 中都不会出现秘密值。 + pub fn with_auth(mut self, auth: McpAuthEnv) -> Self { + self.auth.push(auth); + self + } + + /// 将服务端工具映射成不会与本地工具冲突的名称。 + pub fn namespace_tool(&self, tool_name: &str) -> String { + namespace_tool_name(&self.name, tool_name) + } +} + +/// 环境变量中的秘密要注入到哪个传输位置。 +/// +/// `prefix` 只用于诸如 `Token ` 这类公开格式,不应包含凭据本身。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "target", rename_all = "snake_case")] +pub enum McpAuthTarget { + HttpBearer, + HttpHeader { + name: String, + #[serde(default)] + prefix: String, + }, + StdioEnvironment { + name: String, + }, +} + +impl fmt::Debug for McpAuthTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HttpBearer => formatter.write_str("HttpBearer"), + Self::HttpHeader { name, prefix } => formatter + .debug_struct("HttpHeader") + .field("name", name) + .field("prefix", &RedactedText(prefix)) + .finish(), + Self::StdioEnvironment { name } => formatter + .debug_struct("StdioEnvironment") + .field("name", name) + .finish(), + } + } +} + +/// MCP 认证引用。这里只保存环境变量名,绝不保存解析后的秘密。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpAuthEnv { + pub variable: String, + pub target: McpAuthTarget, +} + +impl fmt::Debug for McpAuthEnv { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("McpAuthEnv") + .field("variable", &"") + .field("target", &self.target) + .finish() + } +} + +impl McpAuthEnv { + pub fn http_bearer(variable: impl Into) -> Self { + Self { + variable: variable.into(), + target: McpAuthTarget::HttpBearer, + } + } + + pub fn http_header( + variable: impl Into, + header_name: impl Into, + prefix: impl Into, + ) -> Self { + Self { + variable: variable.into(), + target: McpAuthTarget::HttpHeader { + name: header_name.into(), + prefix: prefix.into(), + }, + } + } + + pub fn stdio_environment(variable: impl Into, child_name: impl Into) -> Self { + Self { + variable: variable.into(), + target: McpAuthTarget::StdioEnvironment { + name: child_name.into(), + }, + } + } +} + +/// MCP 支持的两种常用传输。这里只保存连接参数,不包含运行时句柄。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum McpTransportConfig { + /// 通过子进程 stdin/stdout 交换 JSON-RPC 行。 + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + }, + /// MCP Streamable HTTP:请求体为 JSON,响应可以是 JSON 或 SSE。 + StreamableHttp { + url: String, + #[serde(default)] + headers: BTreeMap, + }, +} + +impl fmt::Debug for McpTransportConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stdio { command, args, env } => formatter + .debug_struct("Stdio") + // command 也可能是把 token 拼进参数的包装脚本;调试输出只 + // 保留长度,不让可执行路径/内联脚本成为秘密旁路。 + .field("command", &RedactedText(command)) + // argv 中经常会携带 --token/--header 值;调试日志不需要这些 + // 原文,统一只保留参数数量,避免绕过 env/header 脱敏。 + .field("args", &RedactedArgs(args.len())) + .field("env", &RedactedStringMap(env)) + .finish(), + Self::StreamableHttp { url, headers } => formatter + .debug_struct("StreamableHttp") + .field("url", &RedactedUrl(url)) + .field("headers", &RedactedStringMap(headers)) + .finish(), + } + } +} + +impl McpTransportConfig { + /// 创建 stdio 配置,便于 CLI 或 host 从命令和参数组装服务器。 + pub fn stdio(command: impl Into, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Stdio { + command: command.into(), + args: args.into_iter().map(Into::into).collect(), + env: BTreeMap::new(), + } + } + + /// 创建 Streamable HTTP 配置;认证头由调用方显式加入。 + pub fn streamable_http(url: impl Into) -> Self { + Self::StreamableHttp { + url: url.into(), + headers: BTreeMap::new(), + } + } +} + +fn resolve_transport_auth_with( + config: &McpServerConfig, + mut lookup: F, +) -> Result +where + F: FnMut(&str) -> Option, +{ + let mut transport = config.transport.clone(); + for auth in &config.auth { + if auth.variable.trim().is_empty() { + return Err(McpError::Configuration( + "认证环境变量引用不能为空".to_owned(), + )); + } + let secret = lookup(&auth.variable) + .filter(|value| !value.is_empty()) + .ok_or_else(|| McpError::Authentication { + variable: auth.variable.clone(), + })?; + match (&mut transport, &auth.target) { + (McpTransportConfig::StreamableHttp { headers, .. }, McpAuthTarget::HttpBearer) => { + headers.insert("authorization".to_owned(), format!("Bearer {secret}")); + } + ( + McpTransportConfig::StreamableHttp { headers, .. }, + McpAuthTarget::HttpHeader { name, prefix }, + ) if !name.trim().is_empty() => { + headers.insert(name.clone(), format!("{prefix}{secret}")); + } + (McpTransportConfig::Stdio { env, .. }, McpAuthTarget::StdioEnvironment { name }) + if !name.trim().is_empty() => + { + env.insert(name.clone(), secret); + } + _ => { + return Err(McpError::Configuration( + "认证目标与 MCP transport 不匹配或目标名称为空".to_owned(), + )); + } + } + } + Ok(transport) +} + +/// `tools/list` 返回的工具定义。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpToolDefinition { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// MCP 使用 JSON Schema 描述工具参数,故保留为中立 JSON 值。 + #[serde(rename = "inputSchema")] + pub input_schema: Value, + #[serde( + default, + rename = "outputSchema", + skip_serializing_if = "Option::is_none" + )] + pub output_schema: Option, +} + +impl McpToolDefinition { + pub fn new(name: impl Into, input_schema: Value) -> Self { + Self { + name: name.into(), + title: None, + description: None, + input_schema, + output_schema: None, + } + } + + pub fn namespaced_name(&self, server_name: &str) -> String { + namespace_tool_name(server_name, &self.name) + } +} + +/// MCP 工具调用结果中的内容块。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpContent { + #[serde(rename = "type")] + pub kind: String, + #[serde(flatten)] + pub data: BTreeMap, +} + +/// `tools/list` 的一页结果。MCP 服务端可以通过 `nextCursor` 返回分页游标。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpToolsPage { + #[serde(default)] + pub tools: Vec, + #[serde( + default, + rename = "nextCursor", + skip_serializing_if = "Option::is_none" + )] + pub next_cursor: Option, +} + +/// `resources/list` 返回的资源元数据;协议扩展字段原样保留。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpResourceDefinition { + pub uri: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, rename = "mimeType", skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// `resources/list` 的一页结果。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpResourcesPage { + #[serde(default)] + pub resources: Vec, + #[serde( + default, + rename = "nextCursor", + skip_serializing_if = "Option::is_none" + )] + pub next_cursor: Option, +} + +/// `resources/read` 返回的单个内容项。文本和 base64 blob 都保持中立表示。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpResourceContent { + pub uri: String, + #[serde(default, rename = "mimeType", skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blob: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// `resources/read` 的结构化结果。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpReadResourceResult { + #[serde(default)] + pub contents: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// 提示词参数定义。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpPromptArgument { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub required: bool, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// `prompts/list` 返回的提示词定义。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpPromptDefinition { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub arguments: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// `prompts/list` 的一页结果。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpPromptsPage { + #[serde(default)] + pub prompts: Vec, + #[serde( + default, + rename = "nextCursor", + skip_serializing_if = "Option::is_none" + )] + pub next_cursor: Option, +} + +/// `prompts/get` 返回的角色消息;内容块沿用 MCP 的中立内容模型。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpPromptMessage { + pub role: String, + pub content: McpContent, +} + +/// `prompts/get` 的结构化结果。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpGetPromptResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub messages: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// 可持久化的 MCP 能力快照,用于检测服务端能力或 schema 漂移。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpCapabilitySnapshot { + pub initialize: Value, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub resources: Vec, + #[serde(default)] + pub prompts: Vec, +} + +impl McpCapabilitySnapshot { + /// 生成稳定的非安全指纹。它用于变更检测,不用作签名或凭据校验。 + pub fn fingerprint(&self) -> Result { + stable_capability_fingerprint(self) + } +} + +/// 对任意可序列化能力描述生成与 JSON 对象键顺序无关的稳定指纹。 +/// +/// FNV-1a 只用于本地漂移检测,不具备密码学抗碰撞能力。 +pub fn stable_capability_fingerprint(value: &impl Serialize) -> Result { + let value = serde_json::to_value(value).map_err(McpError::Serialize)?; + let mut canonical = Vec::new(); + write_canonical_json(&value, &mut canonical); + let hash = canonical.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + Ok(format!("fnv1a64:{hash:016x}")) +} + +fn write_canonical_json(value: &Value, output: &mut Vec) { + match value { + Value::Null => output.extend_from_slice(b"null"), + Value::Bool(true) => output.extend_from_slice(b"true"), + Value::Bool(false) => output.extend_from_slice(b"false"), + Value::Number(number) => output.extend_from_slice(number.to_string().as_bytes()), + Value::String(text) => { + // serde_json 对字符串的转义是确定的;Value 已在上一步完成校验。 + output.extend_from_slice( + serde_json::to_string(text) + .expect("JSON string serialization is infallible") + .as_bytes(), + ); + } + Value::Array(values) => { + output.push(b'['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + output.push(b','); + } + write_canonical_json(value, output); + } + output.push(b']'); + } + Value::Object(object) => { + output.push(b'{'); + let mut fields = object.iter().collect::>(); + fields.sort_unstable_by_key(|(left, _)| *left); + for (index, (name, value)) in fields.into_iter().enumerate() { + if index > 0 { + output.push(b','); + } + output.extend_from_slice( + serde_json::to_string(name) + .expect("JSON object key serialization is infallible") + .as_bytes(), + ); + output.push(b':'); + write_canonical_json(value, output); + } + output.push(b'}'); + } + } +} + +/// `tools/call` 的结构化结果。 +/// +/// `extra` 保留服务端扩展字段,避免在协议升级时丢失信息;`is_error` 是 +/// 工具自身的执行结果,不等同于 JSON-RPC 传输错误。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct McpToolResult { + #[serde(default)] + pub content: Vec, + #[serde(default, rename = "isError")] + pub is_error: bool, + #[serde( + default, + rename = "structuredContent", + skip_serializing_if = "Option::is_none" + )] + pub structured_content: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// 兼容调用方常用的命名。 +pub type McpCallResult = McpToolResult; + +/// 将 MCP 工具名放进统一的 Agent 工具命名空间。 +pub fn namespace_tool_name(server_name: &str, tool_name: &str) -> String { + format!("mcp:{server_name}:{tool_name}") +} + +/// JSON-RPC 2.0 的请求 ID。 +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcId { + Number(i64), + String(String), +} + +impl From for JsonRpcId { + fn from(value: i64) -> Self { + Self::Number(value) + } +} + +// 整数文字面量默认推断为 i32;提供这些常见转换让构造请求时不必显式标注类型。 +impl From for JsonRpcId { + fn from(value: i32) -> Self { + Self::Number(value as i64) + } +} + +impl From for JsonRpcId { + fn from(value: u32) -> Self { + Self::Number(value as i64) + } +} + +impl From for JsonRpcId { + fn from(value: usize) -> Self { + Self::Number(value as i64) + } +} + +impl From for JsonRpcId { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for JsonRpcId { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +/// JSON-RPC 请求。`params` 省略时会按规范省略字段。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: JsonRpcId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcRequest { + pub fn new(id: impl Into, method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_owned(), + id: id.into(), + method: method.into(), + params, + } + } + + pub fn initialize(id: impl Into, client_name: &str, client_version: &str) -> Self { + Self::initialize_with_protocol_version( + id, + client_name, + client_version, + DEFAULT_PROTOCOL_VERSION, + ) + } + + /// 使用调用方选定的协议版本构造 initialize 请求。 + pub fn initialize_with_protocol_version( + id: impl Into, + client_name: &str, + client_version: &str, + protocol_version: &str, + ) -> Self { + Self::new( + id, + "initialize", + Some(json!({ + "protocolVersion": protocol_version, + "capabilities": {}, + "clientInfo": {"name": client_name, "version": client_version} + })), + ) + } + + pub fn tools_list(id: impl Into, cursor: Option<&str>) -> Self { + let params = cursor.map(|value| json!({ "cursor": value })); + Self::new(id, "tools/list", params) + } + + pub fn tools_call(id: impl Into, tool_name: &str, arguments: Value) -> Self { + Self::new( + id, + "tools/call", + Some(json!({"name": tool_name, "arguments": arguments})), + ) + } + + pub fn resources_list(id: impl Into, cursor: Option<&str>) -> Self { + let params = cursor.map(|value| json!({ "cursor": value })); + Self::new(id, "resources/list", params) + } + + pub fn resources_read(id: impl Into, uri: &str) -> Self { + Self::new(id, "resources/read", Some(json!({ "uri": uri }))) + } + + pub fn prompts_list(id: impl Into, cursor: Option<&str>) -> Self { + let params = cursor.map(|value| json!({ "cursor": value })); + Self::new(id, "prompts/list", params) + } + + pub fn prompts_get( + id: impl Into, + prompt_name: &str, + arguments: &BTreeMap, + ) -> Self { + let mut params = serde_json::Map::new(); + params.insert("name".to_owned(), Value::String(prompt_name.to_owned())); + if !arguments.is_empty() { + params.insert("arguments".to_owned(), json!(arguments)); + } + Self::new(id, "prompts/get", Some(Value::Object(params))) + } +} + +/// 没有响应 ID 的 JSON-RPC 通知,例如初始化完成后的 `notifications/initialized`。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcNotification { + pub fn new(method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_owned(), + method: method.into(), + params, + } + } + + pub fn initialized() -> Self { + Self::new("notifications/initialized", None) + } +} + +/// JSON-RPC 错误对象。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// JSON-RPC 响应。结果与错误二选一,由服务端保证规范约束。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 可通过 stdio 或 HTTP 发送、也可从响应流解码的 JSON-RPC 消息。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcMessage { + Request(JsonRpcRequest), + Notification(JsonRpcNotification), + Response(JsonRpcResponse), +} + +impl From for JsonRpcMessage { + fn from(value: JsonRpcRequest) -> Self { + Self::Request(value) + } +} + +impl From for JsonRpcMessage { + fn from(value: JsonRpcNotification) -> Self { + Self::Notification(value) + } +} + +impl From for JsonRpcMessage { + fn from(value: JsonRpcResponse) -> Self { + Self::Response(value) + } +} + +/// 传输构造阶段可能遇到的错误。 +pub enum McpError { + Configuration(String), + Authentication { + variable: String, + }, + Serialize(serde_json::Error), + InvalidMessage(serde_json::Error), + Io(std::io::Error), + Process(String), + Transport(String), + Timeout { + operation: String, + }, + Protocol(String), + Remote { + code: i64, + message: String, + data: Option, + }, + HttpStatus { + status: u16, + body: String, + }, + /// 工具调用在发送到 transport 前被显式权限策略拒绝。 + PermissionDenied { + tool: String, + }, + /// 工具调用需要上层(通常是 Host)先完成审批;本 crate 不会自行放行。 + PermissionRequired { + tool: String, + }, + /// 重连退避或调用方提供的连接操作被取消。 + Cancelled { + operation: String, + }, + RecoveryExhausted, + Unsupported(String), +} + +/// 供 Host 做审计和恢复决策的稳定错误分类。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpErrorKind { + Configuration, + Authentication, + Encoding, + Connection, + Timeout, + Protocol, + Remote, + HttpStatus, + PermissionDenied, + PermissionRequired, + Cancelled, + RecoveryExhausted, + Unsupported, +} + +impl McpError { + pub fn kind(&self) -> McpErrorKind { + match self { + Self::Configuration(_) => McpErrorKind::Configuration, + Self::Authentication { .. } => McpErrorKind::Authentication, + Self::Serialize(_) | Self::InvalidMessage(_) => McpErrorKind::Encoding, + Self::Io(_) | Self::Process(_) | Self::Transport(_) => McpErrorKind::Connection, + Self::Timeout { .. } => McpErrorKind::Timeout, + Self::Protocol(_) => McpErrorKind::Protocol, + Self::Remote { .. } => McpErrorKind::Remote, + Self::HttpStatus { .. } => McpErrorKind::HttpStatus, + Self::PermissionDenied { .. } => McpErrorKind::PermissionDenied, + Self::PermissionRequired { .. } => McpErrorKind::PermissionRequired, + Self::Cancelled { .. } => McpErrorKind::Cancelled, + Self::RecoveryExhausted => McpErrorKind::RecoveryExhausted, + Self::Unsupported(_) => McpErrorKind::Unsupported, + } + } + + /// 只有连接/超时错误表示“重建连接可能有帮助”。是否重放具体请求仍由 + /// 上层按操作语义决定;本 crate 不会自动重放 tools/call。 + pub fn is_connection_failure(&self) -> bool { + matches!( + self.kind(), + McpErrorKind::Connection | McpErrorKind::Timeout + ) + } +} + +impl fmt::Debug for McpError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + // 错误会经过 CLI/宿主日志;请求体、远端 message/data 和传输文本都 + // 可能回显 Authorization、token 或工具参数,因此 Debug 只保留形状。 + match self { + Self::Configuration(message) => formatter + .debug_struct("Configuration") + .field("message", &RedactedText(message)) + .finish(), + Self::Authentication { variable } => formatter + .debug_struct("Authentication") + .field("variable", &RedactedText(variable)) + .finish(), + Self::Serialize(error) => formatter + .debug_struct("Serialize") + .field("category", &error.classify()) + .finish(), + Self::InvalidMessage(error) => formatter + .debug_struct("InvalidMessage") + .field("category", &error.classify()) + .finish(), + Self::Io(error) => formatter + .debug_struct("Io") + .field("kind", &error.kind()) + .finish(), + Self::Process(message) => formatter + .debug_struct("Process") + .field("message", &RedactedText(message)) + .finish(), + Self::Transport(message) => formatter + .debug_struct("Transport") + .field("message", &RedactedText(message)) + .finish(), + Self::Timeout { operation } => formatter + .debug_struct("Timeout") + .field("operation", &RedactedText(operation)) + .finish(), + Self::Protocol(message) => formatter + .debug_struct("Protocol") + .field("message", &RedactedText(message)) + .finish(), + Self::Remote { + code, + message, + data, + } => formatter + .debug_struct("Remote") + .field("code", code) + .field("message", &RedactedText(message)) + .field("has_data", &data.is_some()) + .finish(), + Self::HttpStatus { status, body } => formatter + .debug_struct("HttpStatus") + .field("status", status) + .field("body", &RedactedBytes(body.len())) + .finish(), + Self::PermissionDenied { tool } => formatter + .debug_struct("PermissionDenied") + .field("tool", &RedactedText(tool)) + .finish(), + Self::PermissionRequired { tool } => formatter + .debug_struct("PermissionRequired") + .field("tool", &RedactedText(tool)) + .finish(), + Self::Cancelled { operation } => formatter + .debug_struct("Cancelled") + .field("operation", &RedactedText(operation)) + .finish(), + Self::RecoveryExhausted => formatter.write_str("RecoveryExhausted"), + Self::Unsupported(message) => formatter + .debug_struct("Unsupported") + .field("message", &RedactedText(message)) + .finish(), + } + } +} + +impl fmt::Display for McpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // Display 会直接出现在 CLI stderr;与 Debug 一样不回显远端文本、 + // HTTP body、请求参数或命令行内容。详细排障应由调用方记录结构化 + // 分类和本地 request id,而不是把不可信原文写进终端/日志。 + Self::Configuration(_) => write!(f, "MCP 配置无效"), + Self::Authentication { .. } => write!(f, "MCP 认证环境变量不可用"), + Self::Serialize(_) => write!(f, "序列化 MCP 消息失败"), + Self::InvalidMessage(_) => write!(f, "解析 MCP 消息失败"), + Self::Io(error) => write!(f, "MCP IO 失败: {:?}", error.kind()), + Self::Process(_) => write!(f, "MCP 进程失败"), + Self::Transport(_) => write!(f, "MCP 传输失败"), + Self::Timeout { .. } => write!(f, "MCP 操作超时"), + Self::Protocol(_) => write!(f, "MCP 协议错误"), + Self::Remote { code, data, .. } => { + if data.is_some() { + write!(f, "MCP JSON-RPC 远端错误 {code} (含 data)") + } else { + write!(f, "MCP JSON-RPC 远端错误 {code}") + } + } + Self::HttpStatus { status, .. } => write!(f, "MCP HTTP 状态异常 {status}"), + Self::PermissionDenied { .. } => write!(f, "MCP 工具权限被拒绝"), + Self::PermissionRequired { .. } => write!(f, "MCP 工具需要显式审批"), + Self::Cancelled { .. } => write!(f, "MCP 操作已取消"), + Self::RecoveryExhausted => write!(f, "MCP 单次连接恢复机会已使用"), + Self::Unsupported(_) => write!(f, "MCP 暂不支持"), + } + } +} + +impl std::error::Error for McpError {} + +impl From for McpError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +/// 在 stdio 与 HTTP/SSE 入口共享的 JSON-RPC envelope 边界做最小校验。 +/// +/// `JsonRpcMessage` 使用 untagged serde 枚举;如果直接反序列化,带有 +/// `method` 和 `result` 的歧义对象可能被宽松地解释成 notification。先按 +/// 字段存在性校验 envelope,再交给 DTO 解析,既拒绝歧义帧,也保留未知扩展 +/// 字段的向前兼容性。 +fn decode_inbound_json_rpc_message(payload: &[u8]) -> Result { + let value = serde_json::from_slice::(payload).map_err(McpError::InvalidMessage)?; + validate_inbound_json_rpc_envelope(&value)?; + serde_json::from_value(value).map_err(McpError::InvalidMessage) +} + +fn decode_inbound_json_rpc_response( + payload: &[u8], + operation: &str, +) -> Result { + match decode_inbound_json_rpc_message(payload)? { + JsonRpcMessage::Response(response) => Ok(response), + JsonRpcMessage::Request(_) | JsonRpcMessage::Notification(_) => Err(McpError::Protocol( + format!("{operation} 响应不是 JSON-RPC response"), + )), + } +} + +fn validate_inbound_json_rpc_envelope(value: &Value) -> Result<(), McpError> { + let object = value + .as_object() + .ok_or_else(|| McpError::Protocol("JSON-RPC envelope 必须是对象".to_owned()))?; + + match object.get("jsonrpc") { + Some(Value::String(version)) if version == "2.0" => {} + Some(Value::String(_)) => { + return Err(McpError::Protocol( + "JSON-RPC jsonrpc 版本必须是 2.0".to_owned(), + )); + } + Some(_) => { + return Err(McpError::Protocol( + "JSON-RPC jsonrpc 字段必须是字符串 2.0".to_owned(), + )); + } + None => { + return Err(McpError::Protocol( + "JSON-RPC envelope 缺少 jsonrpc".to_owned(), + )); + } + } + + let has_method = object.contains_key("method"); + let has_result = object.contains_key("result"); + let has_error = object.contains_key("error"); + if has_method && (has_result || has_error) { + return Err(McpError::Protocol( + "JSON-RPC envelope 不能同时包含 method 与 result/error".to_owned(), + )); + } + if has_result && has_error { + return Err(McpError::Protocol( + "JSON-RPC response 不能同时包含 result 和 error".to_owned(), + )); + } + if !has_method && !has_result && !has_error { + return Err(McpError::Protocol( + "JSON-RPC envelope 缺少 method 或 result/error".to_owned(), + )); + } + + // `JsonRpcNotification` 忽略未知字段;显式检查 id,避免带无效 id 的 + // request 被 untagged 枚举误降级成 notification。 + if has_method { + if !object.get("method").is_some_and(Value::is_string) { + return Err(McpError::Protocol( + "JSON-RPC method 字段必须是字符串".to_owned(), + )); + } + if let Some(id) = object.get("id") + && !id.is_string() + && !id.is_number() + { + return Err(McpError::Protocol( + "JSON-RPC request id 必须是字符串或数字".to_owned(), + )); + } + } + + Ok(()) +} + +/// MCP 工具权限决定。适配器只负责执行调用方传入的决定,不从工具目录或 +/// reconnect 结果推导权限。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpPermissionDecision { + Allow, + Deny, + Ask, +} + +/// 一次工具权限判断的最小审计记录。 +/// +/// 不包含 arguments,避免把潜在凭据或业务数据复制进审计流;需要更细粒度 +/// 的关联时由上层把自己的 request/run id 放在外层事件中。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct McpPermissionAuditEvent { + /// 总是使用 `mcp::` 形式,避免不同 server 的名称碰撞。 + pub tool_name: String, + pub decision: McpPermissionDecision, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl McpPermissionAuditEvent { + pub fn new( + tool_name: impl Into, + decision: McpPermissionDecision, + reason: Option, + ) -> Self { + Self { + tool_name: tool_name.into(), + decision, + reason, + } + } +} + +/// 权限审计的中立接收端口。Host 可以把事件写入自己的 durable 审计表; +/// `agent-mcp` 不持有数据库,也不替调用方决定是否放行。 +pub trait McpPermissionAuditSink { + fn record(&mut self, event: McpPermissionAuditEvent); +} + +impl McpPermissionAuditSink for Vec { + fn record(&mut self, event: McpPermissionAuditEvent) { + self.push(event); + } +} + +/// 一次重连尝试的只读描述。`number` 从 1 开始;`timeout` 是传给实际连接 +/// 操作的建议预算,调度器不会强杀阻塞线程。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct McpReconnectAttempt { + pub number: usize, + pub delay: Duration, + pub timeout: Duration, +} + +/// 重连调度器产生的观察事件。它只记录握手调度,不记录或重放任何 +/// `tools/call` 请求。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpReconnectEvent { + Scheduled { + attempt: usize, + delay_ms: u64, + timeout_ms: u64, + }, + Started { + attempt: usize, + }, + Failed { + attempt: usize, + kind: McpErrorKind, + }, + Succeeded { + attempt: usize, + }, + Cancelled { + attempt: usize, + }, + Exhausted { + attempts: usize, + }, +} + +/// 可跨线程共享的 cooperative 取消令牌。它只停止等待下一次重连;无法 +/// 强杀已经进入底层同步 I/O 的 closure,调用方仍需给该 I/O 自己设置 timeout。 +#[derive(Clone, Debug, Default)] +pub struct McpReconnectCancellation(Arc); + +impl McpReconnectCancellation { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// 有界的同步重连策略。调度器只重试连接/超时错误,绝不把协议、认证或 +/// 权限错误当作可恢复断线。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct McpReconnectPolicy { + pub max_attempts: usize, + pub initial_delay: Duration, + pub max_delay: Duration, + pub backoff_multiplier: u32, + pub attempt_timeout: Duration, +} + +impl Default for McpReconnectPolicy { + fn default() -> Self { + Self { + max_attempts: 3, + initial_delay: Duration::from_millis(100), + max_delay: Duration::from_secs(2), + backoff_multiplier: 2, + attempt_timeout: Duration::from_secs(30), + } + } +} + +impl McpReconnectPolicy { + /// 校验策略并拒绝超过硬上限或无法收敛的配置。 + pub fn validate(&self) -> Result<(), McpError> { + if self.max_attempts > MAX_RECONNECT_ATTEMPTS { + return Err(McpError::Configuration(format!( + "重连次数不能超过 {MAX_RECONNECT_ATTEMPTS}" + ))); + } + if self.backoff_multiplier == 0 { + return Err(McpError::Configuration("重连退避倍数必须大于 0".to_owned())); + } + if self.initial_delay > self.max_delay { + return Err(McpError::Configuration( + "重连最大退避不能小于初始退避".to_owned(), + )); + } + if self.attempt_timeout.is_zero() { + return Err(McpError::Configuration( + "重连尝试 timeout 必须大于 0".to_owned(), + )); + } + Ok(()) + } + + /// 返回 1-based attempt 的指数退避,结果始终不超过 `max_delay`。 + pub fn delay_for(&self, attempt: usize) -> Duration { + if attempt <= 1 { + return self.initial_delay.min(self.max_delay); + } + let mut delay = self.initial_delay.min(self.max_delay); + for _ in 1..attempt { + delay = delay + .checked_mul(self.backoff_multiplier) + .unwrap_or(self.max_delay) + .min(self.max_delay); + if delay == self.max_delay { + break; + } + } + delay + } +} + +/// 在当前同步 transport 上执行有界、可取消的重连尝试。 +/// +/// `operation` 只接收握手尝试描述,没有请求缓存或工具调用参数,因此该 +/// API 无法自动重放 `tools/call`。成功重连后由上层先对账,再显式发起新的 +/// 工具调用。 +#[derive(Clone, Debug)] +pub struct McpReconnectScheduler { + policy: McpReconnectPolicy, + cancellation: McpReconnectCancellation, +} + +impl McpReconnectScheduler { + pub fn new(policy: McpReconnectPolicy) -> Result { + Self::with_cancellation(policy, McpReconnectCancellation::new()) + } + + pub fn with_cancellation( + policy: McpReconnectPolicy, + cancellation: McpReconnectCancellation, + ) -> Result { + policy.validate()?; + Ok(Self { + policy, + cancellation, + }) + } + + pub fn policy(&self) -> &McpReconnectPolicy { + &self.policy + } + + pub fn cancellation(&self) -> &McpReconnectCancellation { + &self.cancellation + } + + /// 执行重连,不需要审计时使用此便捷入口。 + pub fn run(&self, operation: F) -> Result + where + F: FnMut(McpReconnectAttempt) -> Result, + { + let mut events = Vec::new(); + self.run_with_audit(operation, &mut events) + } + + /// 执行重连并把调度结果写入调用方提供的观察流。 + pub fn run_with_audit( + &self, + mut operation: F, + audit: &mut Vec, + ) -> Result + where + F: FnMut(McpReconnectAttempt) -> Result, + { + if self.policy.max_attempts == 0 { + audit.push(McpReconnectEvent::Exhausted { attempts: 0 }); + return Err(McpError::RecoveryExhausted); + } + + for number in 1..=self.policy.max_attempts { + if self.cancellation.is_cancelled() { + audit.push(McpReconnectEvent::Cancelled { attempt: number }); + return Err(McpError::Cancelled { + operation: "MCP 重连调度".to_owned(), + }); + } + let attempt = McpReconnectAttempt { + number, + delay: self.policy.delay_for(number), + timeout: self.policy.attempt_timeout, + }; + audit.push(McpReconnectEvent::Scheduled { + attempt: number, + delay_ms: duration_millis(attempt.delay), + timeout_ms: duration_millis(attempt.timeout), + }); + if !wait_for_reconnect_delay(attempt.delay, &self.cancellation) { + audit.push(McpReconnectEvent::Cancelled { attempt: number }); + return Err(McpError::Cancelled { + operation: "MCP 重连退避".to_owned(), + }); + } + audit.push(McpReconnectEvent::Started { attempt: number }); + match operation(attempt) { + Ok(value) => { + audit.push(McpReconnectEvent::Succeeded { attempt: number }); + return Ok(value); + } + Err(error) if error.is_connection_failure() => { + let kind = error.kind(); + audit.push(McpReconnectEvent::Failed { + attempt: number, + kind, + }); + if number == self.policy.max_attempts { + audit.push(McpReconnectEvent::Exhausted { attempts: number }); + return Err(McpError::RecoveryExhausted); + } + } + Err(error) => { + audit.push(McpReconnectEvent::Failed { + attempt: number, + kind: error.kind(), + }); + return Err(error); + } + } + } + // max_attempts=0 在循环前已处理;此处仅帮助编译器识别完整返回。 + Err(McpError::RecoveryExhausted) + } +} + +fn duration_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +fn wait_for_reconnect_delay(delay: Duration, cancellation: &McpReconnectCancellation) -> bool { + let mut remaining = delay; + loop { + if cancellation.is_cancelled() { + return false; + } + if remaining.is_zero() { + return true; + } + let slice = remaining.min(RECONNECT_CANCEL_POLL); + thread::sleep(slice); + remaining = remaining.saturating_sub(slice); + } +} + +/// Debug 输出中的安全占位符。不要把这些包装器用于 serde,它们只影响日志。 +struct RedactedStringMap<'a>(&'a BTreeMap); + +impl fmt::Debug for RedactedStringMap<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut map = formatter.debug_map(); + for key in self.0.keys() { + map.entry(key, &""); + } + map.finish() + } +} + +struct RedactedArgs(usize); + +impl fmt::Debug for RedactedArgs { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "\"\"", self.0) + } +} + +struct RedactedAuthBindings(usize); + +impl fmt::Debug for RedactedAuthBindings { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "\"<{} env refs>\"", self.0) + } +} + +struct RedactedText<'a>(&'a str); + +impl fmt::Debug for RedactedText<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "\"\"", self.0.chars().count()) + } +} + +struct RedactedBytes(usize); + +impl fmt::Debug for RedactedBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "\"\"", self.0) + } +} + +struct RedactedUrl<'a>(&'a str); + +impl fmt::Debug for RedactedUrl<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{:?}", redact_url(self.0)) + } +} + +fn redact_url(url: &str) -> String { + // URL 的 path、userinfo、query 和 fragment 都可能被网关用来承载 token; + // Debug 不需要可点击地址,统一隐藏完整原文。serde 序列化仍使用原始 URL。 + let _ = url; + "".to_owned() +} + +/// 供 stdio 适配器使用的 JSON 行编码器。 +#[derive(Clone, Debug, Default)] +pub struct StdioTransport; + +impl StdioTransport { + /// MCP stdio 约定一条 JSON-RPC 消息占一行。 + pub fn encode(&self, message: &impl Serialize) -> Result, McpError> { + self.encode_message(message) + } + + pub fn encode_message(&self, message: &impl Serialize) -> Result, McpError> { + let mut bytes = serde_json::to_vec(message).map_err(McpError::Serialize)?; + bytes.push(b'\n'); + Ok(bytes) + } + + pub fn encode_notification( + &self, + notification: &JsonRpcNotification, + ) -> Result, McpError> { + self.encode_message(notification) + } + + pub fn decode_response(&self, line: &[u8]) -> Result { + let mut payload = line; + while matches!(payload.last(), Some(b'\n' | b'\r')) { + payload = &payload[..payload.len() - 1]; + } + if payload.len() > MAX_STDIO_MESSAGE_BYTES { + return Err(McpError::Protocol(format!( + "MCP stdio 消息超过 {MAX_STDIO_MESSAGE_BYTES} 字节上限" + ))); + } + decode_inbound_json_rpc_response(payload, "MCP stdio") + } +} + +/// 不依赖具体 HTTP 客户端的请求描述。 +#[derive(Clone, PartialEq, Eq)] +pub struct HttpRequest { + pub method: String, + pub url: String, + pub headers: BTreeMap, + pub body: Vec, +} + +impl fmt::Debug for HttpRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HttpRequest") + .field("method", &self.method) + .field("url", &RedactedUrl(&self.url)) + .field("headers", &RedactedStringMap(&self.headers)) + .field("body", &RedactedBytes(self.body.len())) + .finish() + } +} + +/// Streamable HTTP 请求构造器。实际发送由 `StreamableHttpClient` 完成; +/// 该类型本身仍可单独用于需要自定义 HTTP 栈的 host。 +#[derive(Clone, PartialEq, Eq)] +pub struct StreamableHttpTransport { + pub url: String, + pub headers: BTreeMap, +} + +impl fmt::Debug for StreamableHttpTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StreamableHttpTransport") + .field("url", &RedactedUrl(&self.url)) + .field("headers", &RedactedStringMap(&self.headers)) + .finish() + } +} + +impl StreamableHttpTransport { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + headers: BTreeMap::new(), + } + } + + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.insert(name.into(), value.into()); + self + } + + /// 设置后续请求使用的 MCP 协议版本。协商成功后由 `McpClient` 更新, + /// 让 initialize 之后的 HTTP 请求和通知流继续携带同一版本。 + pub fn set_protocol_version(&mut self, version: impl Into) { + self.headers + .insert("mcp-protocol-version".to_owned(), version.into()); + } + + pub fn request(&self, message: &impl Serialize) -> Result { + self.request_message(message) + } + + pub fn request_message(&self, message: &impl Serialize) -> Result { + let mut headers = self.headers.clone(); + headers + .entry("accept".to_owned()) + .or_insert_with(|| "application/json, text/event-stream".to_owned()); + headers + .entry("content-type".to_owned()) + .or_insert_with(|| "application/json".to_owned()); + headers + .entry("mcp-protocol-version".to_owned()) + .or_insert_with(|| DEFAULT_PROTOCOL_VERSION.to_owned()); + Ok(HttpRequest { + method: "POST".to_owned(), + url: self.url.clone(), + headers, + body: serde_json::to_vec(message).map_err(McpError::Serialize)?, + }) + } + + /// 构造 MCP Streamable HTTP 的长连接通知请求。 + /// + /// 通知流使用独立 GET,不携带 JSON-RPC body;`Mcp-Session-Id` 由已完成 + /// 握手的 client 传入。这里强制 `Accept: text/event-stream`,避免调用方 + /// 误把普通 JSON POST 响应当成长连接。 + pub fn notification_request(&self, session_id: Option<&str>) -> Result { + let mut headers = self.headers.clone(); + headers.insert("accept".to_owned(), "text/event-stream".to_owned()); + headers + .entry("mcp-protocol-version".to_owned()) + .or_insert_with(|| DEFAULT_PROTOCOL_VERSION.to_owned()); + if let Some(session_id) = session_id.filter(|value| !value.is_empty()) { + headers.insert("mcp-session-id".to_owned(), session_id.to_owned()); + } + Ok(HttpRequest { + method: "GET".to_owned(), + url: self.url.clone(), + headers, + body: Vec::new(), + }) + } + + pub fn decode_json_response(&self, body: &[u8]) -> Result { + if body.len() > MAX_HTTP_RESPONSE_BYTES { + return Err(McpError::Protocol(format!( + "MCP HTTP 响应超过 {MAX_HTTP_RESPONSE_BYTES} 字节上限" + ))); + } + decode_inbound_json_rpc_response(body, "MCP HTTP") + } +} + +/// 可以被 `McpClient` 使用的同步传输抽象。 +/// +/// 该 trait 不绑定 Tokio 或具体 HTTP 库;Host 如果已有自己的同步传输, +/// 可以直接实现它并注入 `McpClient::with_transport`。 +pub trait McpSyncTransport: Send { + /// 更新后续请求使用的协议版本。自定义 transport 若没有版本头, + /// 可以保持默认 no-op;MCP client 仍会校验服务端选择属于候选列表。 + fn set_protocol_version(&mut self, _version: &str) -> Result<(), McpError> { + Ok(()) + } + + /// 发送一个请求并等待同 ID 的响应。 + fn send_request( + &mut self, + request: &JsonRpcRequest, + timeout: Duration, + ) -> Result; + + /// 发送没有响应的 JSON-RPC 通知。 + fn send_notification(&mut self, notification: &JsonRpcNotification) -> Result<(), McpError>; + + /// 带超时参数的通知版本。stdio 通知只等待写入;HTTP transport 会用 + /// 该值限制服务器返回 2xx 的等待时间。 + fn send_notification_with_timeout( + &mut self, + notification: &JsonRpcNotification, + timeout: Duration, + ) -> Result<(), McpError> { + let _ = timeout; + self.send_notification(notification) + } + + /// 简短别名,便于自定义 transport 调用。 + fn request( + &mut self, + request: &JsonRpcRequest, + timeout: Duration, + ) -> Result { + self.send_request(request, timeout) + } + + /// 简短别名,便于自定义 transport 调用。 + fn notify(&mut self, notification: &JsonRpcNotification) -> Result<(), McpError> { + self.send_notification(notification) + } + + /// 同步 transport 可选择暴露已经到达的服务端通知。默认实现返回 + /// `None`,明确表示当前 transport 不提供异步订阅;调用方不得把它当成 + /// “通知已确认”或据此重放工具调用。 + fn poll_notification(&mut self) -> Result, McpError> { + Ok(None) + } + + /// 把 transport 的所有权转给一个独占后台通知订阅。 + /// + /// 只有明确支持长连接/独占读取的 transport 才应覆盖此方法;默认实现 + /// 保持自定义同步 transport 的安全边界,不会把一次性 `poll` 伪装成 + /// 后台订阅。`poll_interval` 对 stdio 表示轮询间隔,对 HTTP 表示检查 + /// cooperative cancellation 的间隔;它不是 HTTP 长连接的总时限或空闲 + /// 断开策略。 + fn into_notification_subscription( + self: Box, + queue_capacity: usize, + poll_interval: Duration, + ) -> Result { + let _ = (queue_capacity, poll_interval); + Err(McpError::Unsupported( + "当前 MCP transport 不支持后台通知订阅".to_owned(), + )) + } +} + +/// 后台通知订阅的 cooperative 取消令牌。 +/// +/// 订阅线程只负责搬运已经由 transport 读到的通知,不会发送响应、重连或 +/// 重放任何请求。调用方可以把令牌复制到其它线程,在不持有订阅对象的情况 +/// 下停止后台线程。 +#[derive(Clone, Debug, Default)] +pub struct McpNotificationCancellation(Arc); + +impl McpNotificationCancellation { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// 一个有界的后台通知队列。 +/// +/// 由已经完成握手的 `McpClient` 或独立的 Streamable HTTP client 创建。订阅 +/// 会独占 client 的 transport;订阅存活期间不能再对该 client 发起 request。 +/// 这样不需要给同一条同步 transport 引入并发请求锁,也不会把响应/通知错误 +/// 地交给不同调用方。队列溢出会停止订阅并报告协议错误,避免恶意服务端无限 +/// 制造通知耗尽宿主内存。 +pub struct McpNotificationSubscription { + receiver: Receiver>, + cancellation: McpNotificationCancellation, + terminal_error: Arc>>, + worker: Option>, +} + +impl Drop for McpNotificationSubscription { + fn drop(&mut self) { + // Drop 也必须回收后台线程。通知 poller 约定为有限时间返回;取消 + // 后 join 能进一步触发其闭包释放,从而回收被独占的 stdio child。 + self.cancellation.cancel(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +impl fmt::Debug for McpNotificationSubscription { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("McpNotificationSubscription") + .field("cancelled", &self.cancellation.is_cancelled()) + .finish_non_exhaustive() + } +} + +impl McpNotificationSubscription { + fn spawn_worker(queue_capacity: usize, worker_fn: F) -> Result + where + F: FnOnce( + SyncSender>, + McpNotificationCancellation, + Arc>>, + ) + Send + + 'static, + { + if queue_capacity == 0 || queue_capacity > MAX_NOTIFICATION_QUEUE_CAPACITY { + return Err(McpError::Configuration(format!( + "通知队列容量必须在 1..={MAX_NOTIFICATION_QUEUE_CAPACITY} 之间" + ))); + } + + let (sender, receiver) = mpsc::sync_channel(queue_capacity); + let cancellation = McpNotificationCancellation::new(); + let worker_cancellation = cancellation.clone(); + let terminal_error = Arc::new(Mutex::new(None)); + let worker_terminal_error = Arc::clone(&terminal_error); + let worker = thread::Builder::new() + .name("agent-mcp-notifications".to_owned()) + .spawn(move || worker_fn(sender, worker_cancellation, worker_terminal_error)) + .map_err(|error| McpError::Process(format!("启动 MCP 通知线程失败: {error}")))?; + + Ok(Self { + receiver, + cancellation, + terminal_error, + worker: Some(worker), + }) + } + + /// 用一个非阻塞 poller 启动后台通知搬运线程。 + /// + /// `poller` 必须在有限时间内返回;取消只能中断本 crate 自己的等待, + /// 不能强杀调用方提供的阻塞 IO。`queue_capacity` 和 `poll_interval` 都 + /// 经过校验,避免无界内存或空转线程。 + pub fn from_poller( + queue_capacity: usize, + poll_interval: Duration, + mut poller: F, + ) -> Result + where + F: FnMut() -> Result, McpError> + Send + 'static, + { + if poll_interval.is_zero() { + return Err(McpError::Configuration("通知轮询间隔必须大于 0".to_owned())); + } + + Self::spawn_worker( + queue_capacity, + move |sender, cancellation, terminal_error| { + run_notification_poller( + &mut poller, + sender, + cancellation, + poll_interval, + terminal_error, + ) + }, + ) + } + + /// 从一个已经建立的 HTTP SSE response 启动独占后台通知搬运线程。 + /// + /// response 的读取由调用方提供的闭包完成,worker 只负责队列、取消和 + /// 终止错误收口;这样 HTTP 与 stdio 共享同一套生命周期合同,而不会 + /// 引入第二份订阅对象。 + fn from_http_runner(queue_capacity: usize, runner: F) -> Result + where + F: FnOnce( + SyncSender>, + McpNotificationCancellation, + Arc>>, + ) + Send + + 'static, + { + Self::spawn_worker(queue_capacity, runner) + } + + /// 从队列中阻塞读取下一条通知。 + pub fn recv(&self) -> Result { + match self.receiver.recv() { + Ok(result) => result, + Err(_) => Err(self.disconnected_error()), + } + } + + /// 在给定预算内读取下一条通知;超时返回 `Ok(None)`。 + pub fn recv_timeout(&self, timeout: Duration) -> Result, McpError> { + match self.receiver.recv_timeout(timeout) { + Ok(result) => result.map(Some), + Err(RecvTimeoutError::Timeout) => Ok(None), + Err(RecvTimeoutError::Disconnected) => Err(self.disconnected_error()), + } + } + + /// 非阻塞读取一条通知;暂时没有通知时返回 `Ok(None)`。 + pub fn try_recv(&self) -> Result, McpError> { + match self.receiver.try_recv() { + Ok(result) => result.map(Some), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(self.disconnected_error()), + } + } + + pub fn cancellation(&self) -> McpNotificationCancellation { + self.cancellation.clone() + } + + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation.is_cancelled() + } + + /// 取消并等待后台线程退出;线程异常或队列溢出会返回对应错误。 + pub fn join(mut self) -> Result<(), McpError> { + self.cancel(); + if let Some(worker) = self.worker.take() { + worker + .join() + .map_err(|_| McpError::Process("MCP 通知线程异常退出".to_owned()))?; + } + // A poller error is normally delivered through the bounded receiver so + // consumers can observe it in order after already queued notifications. + // `join` is also a valid terminal-only API, though; drain the queue + // after the worker has stopped so that path cannot turn a poller error + // into a false success. Pending notifications are intentionally + // discarded because ownership is being consumed by `join`. + let mut queued_error = None; + while let Ok(result) = self.receiver.try_recv() { + if let Err(error) = result + && queued_error.is_none() + { + queued_error = Some(error); + } + } + if let Some(error) = queued_error { + return Err(error); + } + self.take_terminal_error() + } + + fn take_terminal_error(&self) -> Result<(), McpError> { + let mut error = self + .terminal_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn disconnected_error(&self) -> McpError { + let mut error = self + .terminal_error + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + error.take().unwrap_or_else(|| McpError::Cancelled { + operation: "MCP 通知订阅".to_owned(), + }) + } +} + +fn run_notification_poller( + poller: &mut F, + sender: SyncSender>, + cancellation: McpNotificationCancellation, + poll_interval: Duration, + terminal_error: Arc>>, +) where + F: FnMut() -> Result, McpError>, +{ + loop { + if cancellation.is_cancelled() { + return; + } + match poller() { + Ok(Some(notification)) => match sender.try_send(Ok(notification)) { + Ok(()) => {} + Err(TrySendError::Disconnected(_)) => return, + Err(TrySendError::Full(_)) => { + set_terminal_error( + &terminal_error, + McpError::Protocol(format!( + "MCP 通知队列超过 {MAX_NOTIFICATION_QUEUE_CAPACITY} 的有界容量" + )), + ); + return; + } + }, + Ok(None) => { + if !wait_for_notification_poll(poll_interval, &cancellation) { + return; + } + } + Err(error) => { + // 正常情况下队列仍有空间,按顺序把 poller 错误交给消费方; + // 队列已满时记录到 terminal_error,避免线程再次阻塞。 + match sender.try_send(Err(error)) { + Ok(()) | Err(TrySendError::Disconnected(_)) => {} + Err(TrySendError::Full(Err(error))) => { + set_terminal_error(&terminal_error, error); + } + // This branch is unreachable because the attempted send + // above always wraps an error, but keeping it explicit + // makes the queue invariant obvious if the code changes. + Err(TrySendError::Full(Ok(_))) => { + unreachable!("MCP notification poller error path sent a notification") + } + } + return; + } + } + } +} + +fn set_terminal_error(target: &Arc>>, error: McpError) { + let mut slot = target + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if slot.is_none() { + *slot = Some(error); + } +} + +fn wait_for_notification_poll(delay: Duration, cancellation: &McpNotificationCancellation) -> bool { + let mut remaining = delay; + loop { + if cancellation.is_cancelled() { + return false; + } + if remaining.is_zero() { + return true; + } + let slice = remaining.min(NOTIFICATION_CANCEL_POLL); + thread::sleep(slice); + remaining = remaining.saturating_sub(slice); + } +} + +/// `McpClient` 的默认连接选项。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct McpClientOptions { + pub client_name: String, + pub client_version: String, + pub timeout: Duration, + /// 按优先级排列的 initialize 协议版本候选。列表有界且不得重复。 + pub protocol_versions: Vec, +} + +impl Default for McpClientOptions { + fn default() -> Self { + Self { + client_name: "agent-runtime".to_owned(), + client_version: env!("CARGO_PKG_VERSION").to_owned(), + timeout: Duration::from_secs(30), + protocol_versions: vec![DEFAULT_PROTOCOL_VERSION.to_owned()], + } + } +} + +impl McpClientOptions { + pub fn new(client_name: impl Into, client_version: impl Into) -> Self { + Self { + client_name: client_name.into(), + client_version: client_version.into(), + ..Self::default() + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// 配置有界的协议版本候选;候选顺序就是尝试顺序。 + pub fn with_protocol_versions(mut self, versions: I) -> Result + where + I: IntoIterator, + S: Into, + { + let versions = versions.into_iter().map(Into::into).collect::>(); + validate_protocol_version_candidates(&versions)?; + self.protocol_versions = versions; + Ok(self) + } +} + +/// 通过 MCP stdio 约定连接子进程的同步 client。 +pub struct StdioClient { + child: Child, + stdin: ChildStdin, + messages: Receiver>, + /// wait_for_response 期间遇到的通知/未来响应先放在这里;同步 API 不会 + /// 启动后台通知任务,调用方只能显式 poll。 + pending_messages: VecDeque, + reader_thread: Option>, +} + +fn push_stdio_pending_message( + pending_messages: &mut VecDeque, + message: JsonRpcMessage, +) -> Result<(), McpError> { + if pending_messages.len() >= MAX_STDIO_PENDING_MESSAGES { + return Err(McpError::Protocol(format!( + "MCP stdio pending 消息超过 {MAX_STDIO_PENDING_MESSAGES} 条限制" + ))); + } + pending_messages.push_back(message); + Ok(()) +} + +impl fmt::Debug for StdioClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StdioClient") + .field("pid", &self.child.id()) + .finish_non_exhaustive() + } +} + +impl StdioClient { + /// 根据服务器配置启动 stdio 子进程。 + pub fn spawn(config: &McpServerConfig) -> Result { + if !matches!(config.transport, McpTransportConfig::Stdio { .. }) { + return Err(McpError::Protocol( + "服务器配置不是 stdio transport".to_owned(), + )); + } + // `McpServerConfig::auth` 保存的是引用而不是秘密值;除了 + // `McpClient::connect`,直接使用 StdioClient 的构造入口也必须在进程 + // 边界解析它,不能静默丢弃配置中的认证。 + match resolve_transport_auth_from_environment(config)? { + McpTransportConfig::Stdio { command, args, env } => { + Self::from_command(command, &args, &env) + } + McpTransportConfig::StreamableHttp { .. } => { + unreachable!("transport kind cannot change while resolving stdio authentication") + } + } + } + + /// 启动一个命令;参数按 argv 传递,不经过 shell 展开。 + pub fn from_command( + command: impl AsRef, + args: &[String], + environment: &BTreeMap, + ) -> Result { + let command_name = command.as_ref().to_string_lossy().into_owned(); + let mut process = Command::new(command); + // MCP 子进程只继承显式配置的变量;否则 stdio server 会意外拿到 + // 宿主的 OPENAI_API_KEY、云凭据或其它进程级秘密。PATH 保留用于 + // 找到解释器/可执行文件,其余环境由配置调用方逐项传入。 + process.env_clear(); + if let Some(path) = env::var_os("PATH") { + process.env("PATH", path); + } + process + .args(args) + .envs(environment.iter()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // MCP 的 stdout 是协议通道;stderr 丢弃以免把诊断文本混入响应。 + .stderr(Stdio::null()); + let mut child = process + .spawn() + .map_err(|error| McpError::Process(format!("启动 {command_name} 失败: {error}")))?; + let stdin = child.stdin.take().ok_or_else(|| { + let _ = child.kill(); + let _ = child.wait(); + McpError::Process("MCP 子进程没有可用 stdin".to_owned()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + let _ = child.kill(); + let _ = child.wait(); + McpError::Process("MCP 子进程没有可用 stdout".to_owned()) + })?; + + // 使用有界通道让 reader 在服务端洪泛通知时产生反压;否则即使 + // 上层订阅队列有界,reader 与 client 之间的中间队列仍可能无界增长。 + let (sender, messages) = mpsc::sync_channel(MAX_STDIO_MESSAGE_QUEUE_CAPACITY); + let reader_thread = Some(thread::spawn(move || read_stdio_messages(stdout, sender))); + Ok(Self { + child, + stdin, + messages, + pending_messages: VecDeque::new(), + reader_thread, + }) + } + + fn write_line(&mut self, line: &[u8]) -> Result<(), McpError> { + self.stdin.write_all(line).map_err(McpError::Io)?; + self.stdin.flush().map_err(McpError::Io) + } + + fn push_pending_message(&mut self, message: JsonRpcMessage) -> Result<(), McpError> { + push_stdio_pending_message(&mut self.pending_messages, message) + } + + fn wait_for_response( + &mut self, + expected_id: &JsonRpcId, + timeout: Duration, + ) -> Result { + let started = Instant::now(); + loop { + let remaining = timeout.saturating_sub(started.elapsed()); + let message = match self.pop_pending_response() { + Some(message) => message, + None => match self.messages.recv_timeout(remaining) { + Ok(message) => message?, + Err(RecvTimeoutError::Timeout) => { + return Err(McpError::Timeout { + operation: "stdio JSON-RPC 请求".to_owned(), + }); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(McpError::Process("MCP stdio 读取线程已退出".to_owned())); + } + }, + }; + match message { + JsonRpcMessage::Response(response) => { + if response.id.as_ref() != Some(expected_id) { + // 同步 client 一次只发送一个 request;如果未来 transport + // 支持并行请求,先保留该响应而不是误吞,交给对应调用方。 + self.push_pending_message(JsonRpcMessage::Response(response))?; + return Err(McpError::Protocol(format!( + "stdio 响应 id 不匹配:期望 {expected_id:?}" + ))); + } + return Ok(response); + } + // 服务端通知可以和响应交错;不在等待响应时执行副作用, + // 只暂存给显式 poll_notification 调用。 + message @ JsonRpcMessage::Notification(_) => { + self.push_pending_message(message)?; + } + // 服务端请求属于尚未实现的双向异步扩展,不能塞回 pending + // 队列,否则调用方永远看不到它且后台 poller 会空转。 + JsonRpcMessage::Request(_) => { + return Err(McpError::Unsupported( + "同步 MCP client 不处理服务端 request".to_owned(), + )); + } + } + } + } + + /// 只从 pending 队列取响应;通知必须留给显式 poll,不能在等待同一 + /// response 时反复弹出导致忙循环。 + fn pop_pending_response(&mut self) -> Option { + let index = self + .pending_messages + .iter() + .position(|message| matches!(message, JsonRpcMessage::Response(_)))?; + self.pending_messages.remove(index) + } + + fn poll_pending_notification(&mut self) -> Option { + if let Some(index) = self + .pending_messages + .iter() + .position(|message| matches!(message, JsonRpcMessage::Notification(_))) + { + let message = self.pending_messages.remove(index)?; + if let JsonRpcMessage::Notification(notification) = message { + return Some(notification); + } + } + None + } +} + +fn read_stdio_messages(stdout: ChildStdout, sender: SyncSender>) { + let mut reader = BufReader::new(stdout); + loop { + let mut line = Vec::new(); + // `read_until` 本身没有上限;把它包在 Take 中,最多读取正文上限 + // 加 CR/LF 的两个字节。超出后立即终止 reader,不能让服务端用 + // 无换行输出耗尽宿主内存。 + let read_result = { + let mut limited = reader + .by_ref() + .take((MAX_STDIO_MESSAGE_BYTES.saturating_add(2)) as u64); + limited.read_until(b'\n', &mut line) + }; + match read_result { + Ok(0) => { + send_stdio_terminal_error( + &sender, + McpError::Process("MCP stdio 子进程已关闭输出".to_owned()), + ); + return; + } + Ok(_) => { + while matches!(line.last(), Some(b'\n' | b'\r')) { + line.pop(); + } + if line.len() > MAX_STDIO_MESSAGE_BYTES { + // 和 EOF/IO 错误一样,超限是终止条件,不能在有界队列 + // 已满时阻塞;否则 StdioClient::Drop 无法 join reader。 + send_stdio_terminal_error( + &sender, + McpError::Protocol(format!( + "MCP stdio 消息超过 {MAX_STDIO_MESSAGE_BYTES} 字节上限" + )), + ); + return; + } + if line.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + let parsed = decode_inbound_json_rpc_message(&line); + // reader 线程不能阻塞在满队列上:否则 StdioClient::Drop + // 终止 child 后仍无法 join reader。队列满时结束 reader, + // 同步 client 会观察到断开并把该次操作标成连接失败。 + match sender.try_send(parsed) { + Ok(()) | Err(TrySendError::Disconnected(_)) => {} + Err(TrySendError::Full(_)) => return, + } + } + Err(error) => { + send_stdio_terminal_error(&sender, McpError::Io(error)); + return; + } + } + } +} + +fn send_stdio_terminal_error( + sender: &SyncSender>, + error: McpError, +) { + // The reader must never block while reporting a terminal condition. A + // full bounded queue already tells the synchronous client that it cannot + // safely keep consuming this stream; dropping the error lets Drop kill the + // child and join the reader instead of deadlocking on a sender. + let _ = sender.try_send(Err(error)); +} + +impl McpSyncTransport for StdioClient { + fn send_request( + &mut self, + request: &JsonRpcRequest, + timeout: Duration, + ) -> Result { + let encoded = StdioTransport.encode_message(request)?; + self.write_line(&encoded)?; + self.wait_for_response(&request.id, timeout) + } + + fn send_notification(&mut self, notification: &JsonRpcNotification) -> Result<(), McpError> { + let encoded = StdioTransport.encode_notification(notification)?; + self.write_line(&encoded) + } + + fn poll_notification(&mut self) -> Result, McpError> { + if let Some(notification) = self.poll_pending_notification() { + return Ok(Some(notification)); + } + match self.messages.try_recv() { + Ok(message) => match message? { + JsonRpcMessage::Notification(notification) => Ok(Some(notification)), + JsonRpcMessage::Request(_) => Err(McpError::Unsupported( + "同步 MCP client 不处理服务端 request".to_owned(), + )), + response @ JsonRpcMessage::Response(_) => { + self.push_pending_message(response)?; + Ok(None) + } + }, + Err(mpsc::TryRecvError::Empty) => Ok(None), + Err(mpsc::TryRecvError::Disconnected) => { + Err(McpError::Process("MCP stdio 读取线程已退出".to_owned())) + } + } + } + + fn into_notification_subscription( + self: Box, + queue_capacity: usize, + poll_interval: Duration, + ) -> Result { + let mut client = *self; + McpNotificationSubscription::from_poller(queue_capacity, poll_interval, move || { + client.poll_notification() + }) + } +} + +impl Drop for StdioClient { + fn drop(&mut self) { + // Child 不会在 Drop 时自动回收;先终止进程,确保 reader thread 能看到 EOF。 + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(reader_thread) = self.reader_thread.take() { + let _ = reader_thread.join(); + } + } +} + +/// 通过 Streamable HTTP JSON 响应连接 MCP 服务的同步 client。 +pub struct StreamableHttpClient { + client: Client, + transport: StreamableHttpTransport, + session_id: Option, +} + +impl fmt::Debug for StreamableHttpClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StreamableHttpClient") + .field("url", &RedactedUrl(&self.transport.url)) + .field("session_id", &self.session_id.as_deref().map(|_| "")) + .finish() + } +} + +impl StreamableHttpClient { + pub fn new(url: impl Into) -> Result { + Self::from_transport(StreamableHttpTransport::new(url)) + } + + pub fn from_config(config: &McpServerConfig) -> Result { + if !matches!(config.transport, McpTransportConfig::StreamableHttp { .. }) { + return Err(McpError::Protocol( + "服务器配置不是 Streamable HTTP transport".to_owned(), + )); + } + // 与 `McpClient::connect` 保持一致:认证引用只在创建 transport 时 + // 解析,解析后的值不会写回配置,也不会出现在 Debug 输出中。 + match resolve_transport_auth_from_environment(config)? { + McpTransportConfig::StreamableHttp { url, headers } => { + let mut transport = StreamableHttpTransport::new(url); + transport.headers = headers; + Self::from_transport(transport) + } + McpTransportConfig::Stdio { .. } => { + unreachable!("transport kind cannot change while resolving HTTP authentication") + } + } + } + + pub fn from_transport(transport: StreamableHttpTransport) -> Result { + let client = Client::builder() + .build() + .map_err(|error| McpError::Transport(format!("创建 HTTP client 失败: {error}")))?; + Ok(Self { + client, + transport, + session_id: None, + }) + } + + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.transport.headers.insert(name.into(), value.into()); + self + } + + pub fn url(&self) -> &str { + &self.transport.url + } + + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// 将已配置的 Streamable HTTP client 转成独占后台 SSE 通知订阅。 + /// + /// 订阅会发起一个 GET `text/event-stream` 请求,并接管 client 的 HTTP + /// client、headers 和 session id;返回后不能再用原 client 发请求。读取 + /// 使用有界队列,`cancel_poll_interval` 只控制 worker 检查取消标记的 + /// 频率,实际值最多为 [`MAX_HTTP_NOTIFICATION_CANCEL_INTERVAL`];它不 + /// 会给 SSE 长连接设置总时限或空闲断开策略,因此健康的 idle stream + /// 可以持续存在。这里不自动重连、不自动应答,也不重放任何请求。 + pub fn into_notification_subscription( + self, + queue_capacity: usize, + cancel_poll_interval: Duration, + ) -> Result { + if cancel_poll_interval.is_zero() { + return Err(McpError::Configuration( + "HTTP 通知流取消检查间隔必须大于 0".to_owned(), + )); + } + let cancel_poll_interval = cancel_poll_interval.min(MAX_HTTP_NOTIFICATION_CANCEL_INTERVAL); + let Self { + transport, + session_id, + .. + } = self; + let request = transport.notification_request(session_id.as_deref())?; + McpNotificationSubscription::from_http_runner( + queue_capacity, + move |sender, cancellation, terminal_error| { + run_http_notification_stream( + request, + cancel_poll_interval, + sender, + cancellation, + terminal_error, + ) + }, + ) + } + + /// `into_notification_subscription` 的语义别名,便于直接表达订阅意图。 + pub fn subscribe_notifications( + self, + queue_capacity: usize, + cancel_poll_interval: Duration, + ) -> Result { + self.into_notification_subscription(queue_capacity, cancel_poll_interval) + } + + fn post_message( + &mut self, + message: &M, + timeout: Duration, + expect_response: bool, + ) -> Result, McpError> { + let request = self.transport.request_message(message)?; + let mut builder = self.client.post(&request.url); + for (name, value) in &request.headers { + builder = builder.header(name, value); + } + if let Some(session_id) = self.session_id.as_deref() { + builder = builder.header("mcp-session-id", session_id); + } + let response = builder + .body(request.body) + .timeout(timeout) + .send() + .map_err(|error| { + if error.is_timeout() { + McpError::Timeout { + operation: "Streamable HTTP 请求".to_owned(), + } + } else { + McpError::Transport(format!("HTTP 请求失败: {error}")) + } + })?; + let status = response.status(); + if let Some(session_id) = response + .headers() + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + { + self.session_id = Some(session_id.to_owned()); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + let body = read_limited_http_body(response, MAX_HTTP_RESPONSE_BYTES)?; + if !status.is_success() { + return Err(McpError::HttpStatus { + status: status.as_u16(), + body: truncate_body(&body), + }); + } + if !expect_response { + return Ok(None); + } + if body.is_empty() { + return Err(McpError::Protocol("HTTP JSON-RPC 响应为空".to_owned())); + } + let response = decode_http_response(&body, &content_type)?; + Ok(Some(response)) + } +} + +fn run_http_notification_stream( + request: HttpRequest, + cancel_poll_interval: Duration, + sender: SyncSender>, + cancellation: McpNotificationCancellation, + terminal_error: Arc>>, +) { + // The synchronous client is retained for request APIs, but blocking + // `RequestBuilder::timeout` is a total response deadline and therefore + // cannot safely back a long-lived SSE stream. Use a small Tokio runtime + // in this dedicated worker so cancellation can drop the async response + // without waiting for a socket read or imposing a false total deadline. + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + finish_http_stream_error( + &sender, + &terminal_error, + McpError::Process(format!("创建 MCP HTTP 通知运行时失败: {error}")), + ); + return; + } + }; + runtime.block_on(run_async_http_notification_stream( + request, + cancel_poll_interval, + sender, + cancellation, + terminal_error, + )); +} + +async fn run_async_http_notification_stream( + request: HttpRequest, + cancel_poll_interval: Duration, + sender: SyncSender>, + cancellation: McpNotificationCancellation, + terminal_error: Arc>>, +) { + // The async client builder has no total timeout by default. This is + // intentional: the cancellation branch below owns the stream lifetime; + // a finite request timeout would turn an idle but healthy SSE connection + // into a false terminal error. + let client = match AsyncHttpClient::builder().build() { + Ok(client) => client, + Err(error) => { + finish_http_stream_error( + &sender, + &terminal_error, + McpError::Transport(format!("创建 HTTP 通知 client 失败: {error}")), + ); + return; + } + }; + let mut builder = client.get(&request.url); + for (name, value) in &request.headers { + builder = builder.header(name, value); + } + let send = builder.send(); + tokio::pin!(send); + let response = loop { + if cancellation.is_cancelled() { + return; + } + tokio::select! { + result = &mut send => { + break match result { + Ok(response) => response, + Err(error) => { + let error = if error.is_timeout() { + McpError::Timeout { + operation: "MCP HTTP 通知流连接".to_owned(), + } + } else { + McpError::Transport(format!("HTTP 通知流连接失败: {error}")) + }; + finish_http_stream_error(&sender, &terminal_error, error); + return; + } + }; + } + _ = tokio::time::sleep(cancel_poll_interval) => { + if cancellation.is_cancelled() { + return; + } + } + } + }; + + let status = response.status(); + if !status.is_success() { + let Some(body) = read_async_http_body_until_cancel( + response, + MAX_HTTP_RESPONSE_BYTES, + cancel_poll_interval, + &cancellation, + ) + .await + else { + return; + }; + let error = match body { + Ok(body) => McpError::HttpStatus { + status: status.as_u16(), + body: truncate_body(&body), + }, + Err(error) => error, + }; + finish_http_stream_error(&sender, &terminal_error, error); + return; + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !content_type.starts_with("text/event-stream") { + finish_http_stream_error( + &sender, + &terminal_error, + McpError::Protocol("MCP HTTP 通知流必须返回 text/event-stream".to_owned()), + ); + return; + } + + parse_async_sse_stream( + response, + cancel_poll_interval, + sender, + cancellation, + terminal_error, + ) + .await; +} + +async fn read_async_http_body_until_cancel( + mut response: AsyncHttpResponse, + max_bytes: usize, + cancel_poll_interval: Duration, + cancellation: &McpNotificationCancellation, +) -> Option, McpError>> { + let mut body = Vec::new(); + loop { + if cancellation.is_cancelled() { + return None; + } + tokio::select! { + result = response.chunk() => { + match result { + Ok(Some(chunk)) => { + if body.len().saturating_add(chunk.len()) > max_bytes { + return Some(Err(McpError::Protocol(format!( + "MCP HTTP 响应超过 {max_bytes} 字节上限" + )))); + } + body.extend_from_slice(&chunk); + } + Ok(None) => return Some(Ok(body)), + Err(error) => return Some(Err(McpError::Transport(format!( + "读取 HTTP 响应失败: {error}" + )))), + } + } + _ = tokio::time::sleep(cancel_poll_interval) => { + if cancellation.is_cancelled() { + return None; + } + } + } + } +} + +async fn parse_async_sse_stream( + mut response: AsyncHttpResponse, + cancel_poll_interval: Duration, + sender: SyncSender>, + cancellation: McpNotificationCancellation, + terminal_error: Arc>>, +) { + let mut line = Vec::new(); + let mut data_lines = Vec::new(); + let mut data_bytes = 0usize; + loop { + if cancellation.is_cancelled() { + return; + } + let chunk = tokio::select! { + result = response.chunk() => result, + _ = tokio::time::sleep(cancel_poll_interval) => { + if cancellation.is_cancelled() { + return; + } + continue; + } + }; + match chunk { + Ok(Some(chunk)) => { + for byte in chunk { + if byte == b'\n' { + if !process_sse_line( + &line, + &mut data_lines, + &mut data_bytes, + &sender, + &cancellation, + &terminal_error, + ) { + return; + } + line.clear(); + } else { + line.push(byte); + if line.len() > MAX_SSE_LINE_BYTES { + finish_http_stream_error( + &sender, + &terminal_error, + McpError::Protocol(format!( + "MCP SSE 行超过 {MAX_SSE_LINE_BYTES} 字节上限" + )), + ); + return; + } + } + } + } + Ok(None) => { + // SSE 通常以空行结束事件;对连接在最后一个事件后直接 EOF + // 的服务端仍尝试按规范派发一次,然后报告流关闭。 + if !line.is_empty() + && !process_sse_line( + &line, + &mut data_lines, + &mut data_bytes, + &sender, + &cancellation, + &terminal_error, + ) + { + return; + } + if !data_lines.is_empty() + && !dispatch_http_sse_event( + &mut data_lines, + &mut data_bytes, + &sender, + &cancellation, + &terminal_error, + ) + { + return; + } + if !cancellation.is_cancelled() { + finish_http_stream_error( + &sender, + &terminal_error, + McpError::Process("MCP HTTP 通知流已关闭".to_owned()), + ); + } + return; + } + Err(error) => { + if !cancellation.is_cancelled() { + finish_http_stream_error( + &sender, + &terminal_error, + McpError::Transport(format!("读取 MCP HTTP 通知流失败: {error}")), + ); + } + return; + } + } + } +} + +fn process_sse_line( + raw_line: &[u8], + data_lines: &mut Vec>, + data_bytes: &mut usize, + sender: &SyncSender>, + cancellation: &McpNotificationCancellation, + terminal_error: &Arc>>, +) -> bool { + let line = raw_line.strip_suffix(b"\r").unwrap_or(raw_line); + if line.is_empty() { + return dispatch_http_sse_event( + data_lines, + data_bytes, + sender, + cancellation, + terminal_error, + ); + } + // SSE comments and event/id/retry fields are intentionally ignored. MCP + // messages are carried only by data fields. + let Some(data) = line.strip_prefix(b"data:") else { + return true; + }; + let data = data.strip_prefix(b" ").unwrap_or(data); + let separator_bytes = if data_lines.is_empty() { 0 } else { 1 }; + *data_bytes = data_bytes + .saturating_add(data.len()) + .saturating_add(separator_bytes); + if *data_bytes > MAX_SSE_EVENT_BYTES { + finish_http_stream_error( + sender, + terminal_error, + McpError::Protocol(format!("MCP SSE 事件超过 {MAX_SSE_EVENT_BYTES} 字节上限")), + ); + return false; + } + data_lines.push(data.to_vec()); + true +} + +fn dispatch_http_sse_event( + data_lines: &mut Vec>, + data_bytes: &mut usize, + sender: &SyncSender>, + cancellation: &McpNotificationCancellation, + terminal_error: &Arc>>, +) -> bool { + if data_lines.is_empty() { + *data_bytes = 0; + return true; + } + let mut event = Vec::with_capacity(*data_bytes); + for (index, data) in data_lines.drain(..).enumerate() { + if index > 0 { + event.push(b'\n'); + } + event.extend_from_slice(&data); + } + *data_bytes = 0; + if event.iter().all(u8::is_ascii_whitespace) || event == b"[DONE]" { + return true; + } + if cancellation.is_cancelled() { + return false; + } + let message = match decode_inbound_json_rpc_message(&event) { + Ok(message) => message, + Err(error) => { + finish_http_stream_error(sender, terminal_error, error); + return false; + } + }; + match message { + JsonRpcMessage::Notification(notification) => match sender.try_send(Ok(notification)) { + Ok(()) => true, + Err(TrySendError::Disconnected(_)) => false, + Err(TrySendError::Full(_)) => { + finish_http_stream_error( + sender, + terminal_error, + McpError::Protocol(format!( + "MCP 通知队列超过 {MAX_NOTIFICATION_QUEUE_CAPACITY} 的有界容量" + )), + ); + false + } + }, + JsonRpcMessage::Request(_) => { + finish_http_stream_error( + sender, + terminal_error, + McpError::Unsupported("HTTP 通知流收到服务端 request;当前不会自动应答".to_owned()), + ); + false + } + JsonRpcMessage::Response(_) => { + finish_http_stream_error( + sender, + terminal_error, + McpError::Protocol("HTTP 通知流收到 JSON-RPC response".to_owned()), + ); + false + } + } +} + +fn finish_http_stream_error( + sender: &SyncSender>, + terminal_error: &Arc>>, + error: McpError, +) { + match sender.try_send(Err(error)) { + Ok(()) | Err(TrySendError::Disconnected(_)) => {} + Err(TrySendError::Full(Err(error))) => set_terminal_error(terminal_error, error), + Err(TrySendError::Full(Ok(_))) => { + unreachable!("HTTP notification stream error path sent a notification") + } + } +} + +/// 解码 Streamable HTTP 的 JSON 或 SSE 响应。 +/// +/// SSE 事件可能带有 `event:`、注释和多行 `data:`;这里只提取 JSON-RPC +/// message,其他事件留给上层的通知处理。当前同步 API 返回首个可解析响应。 +fn decode_http_response(body: &[u8], content_type: &str) -> Result { + if body.len() > MAX_HTTP_RESPONSE_BYTES { + return Err(McpError::Protocol(format!( + "MCP HTTP 响应超过 {MAX_HTTP_RESPONSE_BYTES} 字节上限" + ))); + } + if !content_type.starts_with("text/event-stream") { + return decode_inbound_json_rpc_response(body, "MCP HTTP"); + } + let text = std::str::from_utf8(body) + .map_err(|error| McpError::Protocol(format!("SSE 响应不是 UTF-8: {error}")))?; + let mut data_lines = Vec::new(); + let mut data_bytes = 0usize; + let mut first_error = None; + for line in text.lines().chain(std::iter::once("")) { + if line.len() > MAX_SSE_LINE_BYTES { + return Err(McpError::Protocol(format!( + "MCP SSE 行超过 {MAX_SSE_LINE_BYTES} 字节上限" + ))); + } + if let Some(data) = line.strip_prefix("data:") { + let data = data.strip_prefix(' ').unwrap_or(data); + let separator_bytes = if data_lines.is_empty() { 0 } else { 1 }; + data_bytes = data_bytes + .saturating_add(data.len()) + .saturating_add(separator_bytes); + if data_bytes > MAX_SSE_EVENT_BYTES { + return Err(McpError::Protocol(format!( + "MCP SSE 事件超过 {MAX_SSE_EVENT_BYTES} 字节上限" + ))); + } + data_lines.push(data); + continue; + } + if line.is_empty() && !data_lines.is_empty() { + let event = data_lines.join("\n"); + data_lines.clear(); + data_bytes = 0; + if event.trim().is_empty() || event.trim() == "[DONE]" { + continue; + } + match decode_inbound_json_rpc_message(event.as_bytes()) { + Ok(JsonRpcMessage::Response(response)) => return Ok(response), + // POST 的 SSE 响应可以夹带通知;同步 request API 不消费它, + // 继续寻找与本次请求对应的 response。服务端 request 需要双向 + // 应答,当前同步 HTTP transport 无法安全处理,明确拒绝。 + Ok(JsonRpcMessage::Notification(_)) => {} + Ok(JsonRpcMessage::Request(_)) => { + return Err(McpError::Unsupported( + "MCP HTTP SSE 响应包含服务端 request".to_owned(), + )); + } + Err(McpError::InvalidMessage(error)) if first_error.is_none() => { + first_error = Some(error); + } + Err(McpError::InvalidMessage(_)) => {} + Err(error) => return Err(error), + } + } + } + match first_error { + Some(error) => Err(McpError::InvalidMessage(error)), + None => Err(McpError::Protocol( + "SSE 响应中没有 JSON-RPC data 事件".to_owned(), + )), + } +} + +/// 从 reqwest blocking response 读取有限大小的正文。 +/// +/// `Response::bytes` 会把整个远端正文一次性收进内存;MCP 服务端返回的 +/// 错误页、工具结果或 SSE 流都属于不可信输入,因此这里最多多读一个字节 +/// 来确认是否越界,随后立即返回协议错误。 +fn read_limited_http_body( + mut response: reqwest::blocking::Response, + max_bytes: usize, +) -> Result, McpError> { + let mut body = Vec::new(); + let mut limited = response.by_ref().take(max_bytes.saturating_add(1) as u64); + limited + .read_to_end(&mut body) + .map_err(|error| McpError::Transport(format!("读取 HTTP 响应失败: {error}")))?; + if body.len() > max_bytes { + return Err(McpError::Protocol(format!( + "MCP HTTP 响应超过 {max_bytes} 字节上限" + ))); + } + Ok(body) +} + +fn truncate_body(body: &[u8]) -> String { + let text = String::from_utf8_lossy(body); + let mut result = text.chars().take(512).collect::(); + if text.chars().count() > 512 { + result.push('…'); + } + result +} + +impl McpSyncTransport for StreamableHttpClient { + fn set_protocol_version(&mut self, version: &str) -> Result<(), McpError> { + if version.trim().is_empty() { + return Err(McpError::Configuration( + "MCP protocolVersion 不能为空".to_owned(), + )); + } + self.transport.set_protocol_version(version); + Ok(()) + } + + fn send_request( + &mut self, + request: &JsonRpcRequest, + timeout: Duration, + ) -> Result { + self.post_message(request, timeout, true)? + .ok_or_else(|| McpError::Protocol("HTTP 请求没有返回 JSON-RPC 响应".to_owned())) + } + + fn send_notification(&mut self, notification: &JsonRpcNotification) -> Result<(), McpError> { + self.post_message(notification, Duration::from_secs(30), false) + .map(|_| ()) + } + + fn send_notification_with_timeout( + &mut self, + notification: &JsonRpcNotification, + timeout: Duration, + ) -> Result<(), McpError> { + self.post_message(notification, timeout, false).map(|_| ()) + } + + fn into_notification_subscription( + self: Box, + queue_capacity: usize, + read_timeout: Duration, + ) -> Result { + StreamableHttpClient::into_notification_subscription(*self, queue_capacity, read_timeout) + } +} + +fn connect_sync_transport(config: &McpServerConfig) -> Result, McpError> { + let resolved = resolve_transport_auth_from_environment(config)?; + match resolved { + McpTransportConfig::Stdio { command, args, env } => { + Ok(Box::new(StdioClient::from_command(command, &args, &env)?)) + } + McpTransportConfig::StreamableHttp { url, headers } => { + let mut transport = StreamableHttpTransport::new(url); + transport.headers = headers; + Ok(Box::new(StreamableHttpClient::from_transport(transport)?)) + } + } +} + +/// 在 transport 边界解析配置中的认证引用。 +/// +/// 统一走这一个 helper,避免直接构造器和上层 `McpClient` 路径逐渐产生 +/// 不同的认证语义。 +fn resolve_transport_auth_from_environment( + config: &McpServerConfig, +) -> Result { + resolve_transport_auth_with(config, |variable| env::var(variable).ok()) +} + +/// MCP 握手、分页和工具调用的同步会话。 +pub struct McpClient { + server_name: String, + transport: Box, + options: McpClientOptions, + next_id: i64, + initialized: bool, + initialize_result: Option, + negotiated_protocol_version: Option, + reconnect_config: Option, + connection_recovery_used: bool, +} + +impl fmt::Debug for McpClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("McpClient") + .field("server_name", &self.server_name) + .field("initialized", &self.initialized) + .field( + "negotiated_protocol_version", + &self.negotiated_protocol_version, + ) + .field("next_id", &self.next_id) + .field("connection_recovery_used", &self.connection_recovery_used) + .finish_non_exhaustive() + } +} + +impl McpClient { + pub fn with_transport( + server_name: impl Into, + transport: T, + options: McpClientOptions, + ) -> Self + where + T: McpSyncTransport + 'static, + { + Self { + server_name: server_name.into(), + transport: Box::new(transport), + options, + next_id: 1, + initialized: false, + initialize_result: None, + negotiated_protocol_version: None, + reconnect_config: None, + connection_recovery_used: false, + } + } + + pub fn connect(config: &McpServerConfig, options: McpClientOptions) -> Result { + Ok(Self { + server_name: config.name.clone(), + transport: connect_sync_transport(config)?, + options, + next_id: 1, + initialized: false, + initialize_result: None, + negotiated_protocol_version: None, + // 原始配置只含公开连接信息和环境变量引用,可安全用于重建连接。 + reconnect_config: Some(config.clone()), + connection_recovery_used: false, + }) + } + + pub fn stdio(config: &McpServerConfig, options: McpClientOptions) -> Result { + match &config.transport { + McpTransportConfig::Stdio { .. } => Self::connect(config, options), + McpTransportConfig::StreamableHttp { .. } => Err(McpError::Protocol( + "服务器配置不是 stdio transport".to_owned(), + )), + } + } + + pub fn streamable_http( + config: &McpServerConfig, + options: McpClientOptions, + ) -> Result { + match &config.transport { + McpTransportConfig::StreamableHttp { .. } => Self::connect(config, options), + McpTransportConfig::Stdio { .. } => Err(McpError::Protocol( + "服务器配置不是 Streamable HTTP transport".to_owned(), + )), + } + } + + pub fn server_name(&self) -> &str { + &self.server_name + } + + pub fn is_initialized(&self) -> bool { + self.initialized + } + + pub fn options(&self) -> &McpClientOptions { + &self.options + } + + /// 最近一次成功握手的原始 initialize 结果。 + pub fn initialize_result(&self) -> Option<&Value> { + self.initialize_result.as_ref() + } + + /// 最近一次成功握手选择的协议版本。 + pub fn negotiated_protocol_version(&self) -> Option<&str> { + self.negotiated_protocol_version.as_deref() + } + + pub fn namespace_tool(&self, tool_name: &str) -> String { + namespace_tool_name(&self.server_name, tool_name) + } + + pub fn original_tool_name<'a>(&self, tool_name: &'a str) -> Option<&'a str> { + tool_name.strip_prefix(&format!("mcp:{}:", self.server_name)) + } + + fn next_request_id(&mut self) -> JsonRpcId { + let id = self.next_id; + self.next_id = self.next_id.checked_add(1).unwrap_or(1); + JsonRpcId::Number(id) + } + + fn ensure_initialized(&mut self) -> Result<(), McpError> { + if !self.initialized { + self.initialize()?; + } + Ok(()) + } + + /// 完成 MCP initialize/initialized 握手,返回服务端能力 JSON。 + pub fn initialize(&mut self) -> Result { + self.initialize_with_timeout(self.options.timeout) + } + + /// 使用调用方提供的预算完成一次握手。 + /// + /// 公开的 [`Self::initialize`] 使用 client 默认 timeout;重连调度器则 + /// 必须把每次 `McpReconnectAttempt::timeout` 传到这里,避免策略上的 + /// attempt timeout 被较大的 client 默认值覆盖。 + fn initialize_with_timeout(&mut self, timeout: Duration) -> Result { + self.initialized = false; + self.initialize_result = None; + self.negotiated_protocol_version = None; + validate_protocol_version_candidates(&self.options.protocol_versions)?; + let candidates = self.options.protocol_versions.clone(); + let mut last_error = None; + for (index, candidate) in candidates.iter().enumerate() { + // A failed initialize leaves the server in an unknown session state. + // Only config-backed clients can safely create a fresh transport for + // the next candidate; injected transports stay single-attempt. + if index > 0 { + let config = self.reconnect_config.clone().ok_or_else(|| { + McpError::Unsupported( + "注入 transport 的 client 无法为协议协商重建连接".to_owned(), + ) + })?; + self.transport = connect_sync_transport(&config)?; + } + self.transport.set_protocol_version(candidate)?; + let id = self.next_request_id(); + let request = JsonRpcRequest::initialize_with_protocol_version( + id.clone(), + &self.options.client_name, + &self.options.client_version, + candidate, + ); + let response = match self.transport.send_request(&request, timeout) { + Ok(response) => response, + Err(error) if is_protocol_version_negotiation_error(&error) => { + last_error = Some(error); + continue; + } + Err(error) => return Err(error), + }; + let result = match response_result(&request.id, response) { + Ok(result) => result, + Err(error) if is_protocol_version_negotiation_error(&error) => { + last_error = Some(error); + continue; + } + Err(error) => return Err(error), + }; + let negotiated = match validate_negotiated_protocol_version(&result, &candidates) { + Ok(version) => version, + Err(error) => { + last_error = Some(error); + continue; + } + }; + self.transport.set_protocol_version(&negotiated)?; + self.transport + .send_notification_with_timeout(&JsonRpcNotification::initialized(), timeout)?; + self.initialized = true; + self.negotiated_protocol_version = Some(negotiated); + self.initialize_result = Some(result.clone()); + return Ok(result); + } + Err(last_error + .unwrap_or_else(|| McpError::Protocol("MCP 没有可用的协议版本候选".to_owned()))) + } + + /// 丢弃当前 transport、重新解析认证环境变量并完成一次新握手。 + /// + /// 该方法只恢复连接,不保存也不重放失败的 JSON-RPC 请求。尤其是 + /// `tools/call`,调用方必须先对账,再自行决定是否发起新的调用。 + pub fn reconnect(&mut self) -> Result { + self.reconnect_with_timeout(self.options.timeout) + } + + fn reconnect_with_timeout(&mut self, timeout: Duration) -> Result { + let config = self.reconnect_config.clone().ok_or_else(|| { + McpError::Unsupported("注入 transport 的 client 没有可重建配置".to_owned()) + })?; + let transport = connect_sync_transport(&config)?; + self.transport = transport; + self.initialized = false; + self.initialize_result = None; + self.negotiated_protocol_version = None; + self.initialize_with_timeout(timeout) + } + + /// 在当前 client 生命周期内最多执行一次显式连接恢复。 + /// + /// 即使恢复失败,机会也视为已使用,避免上层无界重连。它和 `reconnect` + /// 一样不会自动重放触发连接错误的操作。 + pub fn recover_connection_once(&mut self) -> Result { + if self.connection_recovery_used { + return Err(McpError::RecoveryExhausted); + } + self.connection_recovery_used = true; + self.reconnect() + } + + /// 使用有界策略重建连接。策略只包裹 `initialize`,不会缓存或重发之前 + /// 失败的请求;尤其不会自动重放 `tools/call`。 + pub fn reconnect_with_policy( + &mut self, + policy: McpReconnectPolicy, + cancellation: &McpReconnectCancellation, + ) -> Result { + let scheduler = McpReconnectScheduler::with_cancellation(policy, cancellation.clone())?; + scheduler.run(|attempt| self.reconnect_with_timeout(attempt.timeout)) + } + + /// 与 [`Self::reconnect_with_policy`] 相同,同时返回可持久化的调度观察值。 + pub fn reconnect_with_policy_audited( + &mut self, + policy: McpReconnectPolicy, + cancellation: &McpReconnectCancellation, + audit: &mut Vec, + ) -> Result { + let scheduler = McpReconnectScheduler::with_cancellation(policy, cancellation.clone())?; + scheduler.run_with_audit( + |attempt| self.reconnect_with_timeout(attempt.timeout), + audit, + ) + } + + /// 显式轮询已经抵达的服务端通知。该同步接口不创建后台订阅,也不对 + /// 通知作自动响应;Streamable HTTP transport 默认返回 `None`。 + pub fn poll_notification(&mut self) -> Result, McpError> { + self.transport.poll_notification() + } + + /// 将已经完成握手的 client 转成独占的后台通知订阅。 + /// + /// 订阅会接管整个 client,因此返回后不能再发起请求;这条边界避免 + /// 在同步 transport 上同时竞争 request response 和 notification。stdio + /// 使用轮询搬运,Streamable HTTP 使用独立 GET/SSE 长连接;注入的自定义 + /// transport 需要显式覆盖 trait 方法才能启用订阅。订阅线程不自动应答、 + /// 重连或重放调用。 + pub fn into_notification_subscription( + self, + queue_capacity: usize, + poll_interval: Duration, + ) -> Result { + if !self.initialized { + return Err(McpError::Protocol( + "建立 MCP 通知订阅前必须先完成 initialize".to_owned(), + )); + } + let Self { transport, .. } = self; + transport.into_notification_subscription(queue_capacity, poll_interval) + } + + /// 请求一页工具定义;首次调用会自动完成握手。 + pub fn list_tools_page(&mut self, cursor: Option<&str>) -> Result { + self.ensure_initialized()?; + let id = self.next_request_id(); + let request = JsonRpcRequest::tools_list(id, cursor); + let response = self + .transport + .send_request(&request, self.options.timeout)?; + let result = response_result(&request.id, response)?; + serde_json::from_value(result).map_err(McpError::InvalidMessage) + } + + /// 自动遍历 `nextCursor`,返回服务端全部工具定义。 + pub fn list_tools(&mut self) -> Result, McpError> { + let mut tools = Vec::new(); + let mut cursor = None; + let mut seen_cursors = BTreeSet::new(); + let mut pages = 0usize; + loop { + if pages >= MAX_TOOL_LIST_PAGES { + return Err(McpError::Protocol(format!( + "tools/list 超过最大分页数 {MAX_TOOL_LIST_PAGES}" + ))); + } + pages += 1; + let page = self.list_tools_page(cursor.as_deref())?; + tools.extend(page.tools); + match page.next_cursor { + Some(next) if !seen_cursors.insert(next.clone()) => { + return Err(McpError::Protocol("tools/list 返回循环游标".to_owned())); + } + Some(next) => cursor = Some(next), + None => return Ok(tools), + } + } + } + + /// 请求一页资源定义;首次调用会自动完成握手。 + pub fn list_resources_page( + &mut self, + cursor: Option<&str>, + ) -> Result { + self.ensure_initialized()?; + let id = self.next_request_id(); + let request = JsonRpcRequest::resources_list(id, cursor); + let response = self + .transport + .send_request(&request, self.options.timeout)?; + let result = response_result(&request.id, response)?; + serde_json::from_value(result).map_err(McpError::InvalidMessage) + } + + /// 自动遍历 `nextCursor`,返回服务端全部资源定义。 + pub fn list_resources(&mut self) -> Result, McpError> { + let mut resources = Vec::new(); + let mut cursor = None; + let mut seen_cursors = BTreeSet::new(); + let mut pages = 0usize; + loop { + if pages >= MAX_RESOURCE_LIST_PAGES { + return Err(McpError::Protocol(format!( + "resources/list 超过最大分页数 {MAX_RESOURCE_LIST_PAGES}" + ))); + } + pages += 1; + let page = self.list_resources_page(cursor.as_deref())?; + resources.extend(page.resources); + match page.next_cursor { + Some(next) if !seen_cursors.insert(next.clone()) => { + return Err(McpError::Protocol("resources/list 返回循环游标".to_owned())); + } + Some(next) => cursor = Some(next), + None => return Ok(resources), + } + } + } + + /// 读取资源内容。读取失败后不会自动重连或重放请求。 + pub fn read_resource(&mut self, uri: &str) -> Result { + self.ensure_initialized()?; + let id = self.next_request_id(); + let request = JsonRpcRequest::resources_read(id, uri); + let response = self + .transport + .send_request(&request, self.options.timeout)?; + let result = response_result(&request.id, response)?; + serde_json::from_value(result).map_err(McpError::InvalidMessage) + } + + /// 请求一页提示词定义;首次调用会自动完成握手。 + pub fn list_prompts_page(&mut self, cursor: Option<&str>) -> Result { + self.ensure_initialized()?; + let id = self.next_request_id(); + let request = JsonRpcRequest::prompts_list(id, cursor); + let response = self + .transport + .send_request(&request, self.options.timeout)?; + let result = response_result(&request.id, response)?; + serde_json::from_value(result).map_err(McpError::InvalidMessage) + } + + /// 自动遍历 `nextCursor`,返回服务端全部提示词定义。 + pub fn list_prompts(&mut self) -> Result, McpError> { + let mut prompts = Vec::new(); + let mut cursor = None; + let mut seen_cursors = BTreeSet::new(); + let mut pages = 0usize; + loop { + if pages >= MAX_PROMPT_LIST_PAGES { + return Err(McpError::Protocol(format!( + "prompts/list 超过最大分页数 {MAX_PROMPT_LIST_PAGES}" + ))); + } + pages += 1; + let page = self.list_prompts_page(cursor.as_deref())?; + prompts.extend(page.prompts); + match page.next_cursor { + Some(next) if !seen_cursors.insert(next.clone()) => { + return Err(McpError::Protocol("prompts/list 返回循环游标".to_owned())); + } + Some(next) => cursor = Some(next), + None => return Ok(prompts), + } + } + } + + /// 展开一个提示词;参数值按 MCP 协议保持为字符串。 + pub fn get_prompt( + &mut self, + prompt_name: &str, + arguments: &BTreeMap, + ) -> Result { + self.ensure_initialized()?; + let id = self.next_request_id(); + let request = JsonRpcRequest::prompts_get(id, prompt_name, arguments); + let response = self + .transport + .send_request(&request, self.options.timeout)?; + let result = response_result(&request.id, response)?; + serde_json::from_value(result).map_err(McpError::InvalidMessage) + } + + /// 按 initialize 中声明的能力发现只读目录,并生成可持久化快照。 + pub fn capability_snapshot(&mut self) -> Result { + self.ensure_initialized()?; + let initialize = self + .initialize_result + .clone() + .ok_or_else(|| McpError::Protocol("握手成功后缺少 initialize 结果".to_owned()))?; + let capabilities = initialize.get("capabilities").and_then(Value::as_object); + let mut tools = if capabilities.is_some_and(|value| value.contains_key("tools")) { + self.list_tools()? + } else { + Vec::new() + }; + let mut resources = if capabilities.is_some_and(|value| value.contains_key("resources")) { + self.list_resources()? + } else { + Vec::new() + }; + let mut prompts = if capabilities.is_some_and(|value| value.contains_key("prompts")) { + self.list_prompts()? + } else { + Vec::new() + }; + // 服务端允许任意目录顺序;按稳定标识排序,避免无意义的指纹变化。 + tools.sort_by(|left, right| left.name.cmp(&right.name)); + resources.sort_by(|left, right| left.uri.cmp(&right.uri)); + prompts.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(McpCapabilitySnapshot { + initialize, + tools, + resources, + prompts, + }) + } + + pub fn capability_fingerprint(&mut self) -> Result { + self.capability_snapshot()?.fingerprint() + } + + /// 调用一个工具。传入带本 client 命名空间的名称时会自动还原原名。 + pub fn call_tool( + &mut self, + tool_name: &str, + arguments: Value, + ) -> Result { + self.ensure_initialized()?; + let original_name = self.original_tool_name(tool_name).unwrap_or(tool_name); + let id = self.next_request_id(); + let request = JsonRpcRequest::tools_call(id, original_name, arguments); + let response = self + .transport + .send_request(&request, self.options.timeout)?; + let result = response_result(&request.id, response)?; + serde_json::from_value(result).map_err(McpError::InvalidMessage) + } + + pub fn call_namespaced_tool( + &mut self, + tool_name: &str, + arguments: Value, + ) -> Result { + if self.original_tool_name(tool_name).is_none() { + return Err(McpError::Protocol(format!( + "工具名不属于 MCP 服务 {}: {tool_name}", + self.server_name + ))); + } + self.call_tool(tool_name, arguments) + } + + /// 在发送 `tools/call` 前执行调用方提供的权限判断并写入审计 sink。 + /// + /// 这是可选的 adapter-side gate;Host 仍应把自己的 durable 审批作为 + /// 权限真相。`Deny`/`Ask` 都不会触碰 transport,避免重连或目录发现 + /// 间接绕过权限策略。 + pub fn call_tool_with_permission( + &mut self, + tool_name: &str, + arguments: Value, + mut authorize: F, + audit: &mut S, + ) -> Result + where + F: FnMut(&str, &Value) -> McpPermissionDecision, + S: McpPermissionAuditSink + ?Sized, + { + let namespaced_name = if self.original_tool_name(tool_name).is_some() { + tool_name.to_owned() + } else if tool_name.starts_with("mcp:") { + // 带 namespace 的名称必须属于当前 client;否则一个宽松的 + // authorizer 不能借此把调用转发到未知 server。 + return Err(McpError::Protocol(format!( + "工具名不属于 MCP 服务 {}: {tool_name}", + self.server_name + ))); + } else { + self.namespace_tool(tool_name) + }; + let decision = authorize(&namespaced_name, &arguments); + audit.record(McpPermissionAuditEvent::new( + namespaced_name.clone(), + decision, + None, + )); + match decision { + McpPermissionDecision::Allow => self.call_tool(tool_name, arguments), + McpPermissionDecision::Deny => Err(McpError::PermissionDenied { + tool: namespaced_name, + }), + McpPermissionDecision::Ask => Err(McpError::PermissionRequired { + tool: namespaced_name, + }), + } + } +} + +/// 校验并返回 initialize response 的版本协商结果。 +/// +/// MCP 要求服务端在 initialize result 中回显或选择一个协议版本。只有 +/// 调用方显式列出的候选版本才会被接受,并且在发送 +/// `notifications/initialized` 前更新 HTTP header,避免后续请求使用错误 +/// 的协议字段。 +fn validate_negotiated_protocol_version( + result: &Value, + candidates: &[String], +) -> Result { + let version = result + .get("protocolVersion") + .and_then(Value::as_str) + .ok_or_else(|| { + McpError::Protocol("MCP initialize result 缺少字符串 protocolVersion".to_owned()) + })?; + if !candidates.iter().any(|candidate| candidate == version) { + return Err(McpError::Protocol(format!( + "MCP protocolVersion 不支持: expected={candidates:?} actual={version}" + ))); + } + Ok(version.to_owned()) +} + +fn validate_protocol_version_candidates(candidates: &[String]) -> Result<(), McpError> { + if candidates.is_empty() { + return Err(McpError::Configuration( + "MCP protocolVersion 候选不能为空".to_owned(), + )); + } + if candidates.len() > MAX_PROTOCOL_VERSION_CANDIDATES { + return Err(McpError::Configuration(format!( + "MCP protocolVersion 候选超过 {MAX_PROTOCOL_VERSION_CANDIDATES} 条限制" + ))); + } + let mut seen = BTreeSet::new(); + for candidate in candidates { + if candidate.trim().is_empty() { + return Err(McpError::Configuration( + "MCP protocolVersion 候选不能为空字符串".to_owned(), + )); + } + if !seen.insert(candidate) { + return Err(McpError::Configuration(format!( + "MCP protocolVersion 候选重复: {candidate}" + ))); + } + } + Ok(()) +} + +fn is_protocol_version_negotiation_error(error: &McpError) -> bool { + matches!( + error, + McpError::Remote { code, message, .. } + if *code == -32601 + || *code == -32602 + || message.to_ascii_lowercase().contains("protocol") + ) +} + +fn response_result(expected_id: &JsonRpcId, response: JsonRpcResponse) -> Result { + if response.jsonrpc != "2.0" { + return Err(McpError::Protocol(format!( + "响应 jsonrpc 版本不是 2.0: {}", + response.jsonrpc + ))); + } + if response.id.as_ref() != Some(expected_id) { + return Err(McpError::Protocol(format!( + "响应 id 不匹配:期望 {expected_id:?},收到 {:?}", + response.id + ))); + } + match (response.result, response.error) { + (Some(_), Some(_)) => Err(McpError::Protocol( + "JSON-RPC 响应同时包含 result 和 error".to_owned(), + )), + (None, None) => Err(McpError::Protocol( + "JSON-RPC 响应缺少 result/error".to_owned(), + )), + (Some(result), None) => Ok(result), + (None, Some(error)) => Err(McpError::Remote { + code: error.code, + message: error.message, + data: error.data, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 使用环境变量的认证测试必须在断言失败时也恢复进程状态;锁还会阻止 + /// 本模块的两个测试同时修改环境。生产代码只在 transport 边界解析秘密, + /// 不会保存这个测试 guard。 + struct EnvironmentGuard { + _lock: std::sync::MutexGuard<'static, ()>, + name: &'static str, + previous: Option, + } + + impl EnvironmentGuard { + fn set(name: &'static str, value: &str) -> Self { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + let lock = LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = std::env::var_os(name); + // Rust 2024 marks process-environment mutation unsafe. The guard + // serializes these fixture updates and restores the prior value. + unsafe { + std::env::set_var(name, value); + } + Self { + _lock: lock, + name, + previous, + } + } + } + + impl Drop for EnvironmentGuard { + fn drop(&mut self) { + unsafe { + if let Some(value) = &self.previous { + std::env::set_var(self.name, value); + } else { + std::env::remove_var(self.name); + } + } + } + } + + #[test] + fn 工具名称包含服务端命名空间() { + let tool = McpToolDefinition::new("read_file", json!({"type": "object"})); + assert_eq!(tool.namespaced_name("workspace"), "mcp:workspace:read_file"); + } + + #[test] + fn 构造工具调用请求() { + let request = + JsonRpcRequest::tools_call(7, "mcp:workspace:read_file", json!({"path": "a.txt"})); + assert_eq!(request.jsonrpc, "2.0"); + assert_eq!(request.method, "tools/call"); + assert_eq!( + request.params.as_ref().unwrap()["name"], + "mcp:workspace:read_file" + ); + } + + #[test] + fn stdio_消息以换行结束且可解码() { + let request = JsonRpcRequest::tools_list(1, None); + let transport = StdioTransport; + let encoded = transport.encode(&request).unwrap(); + assert_eq!(encoded.last(), Some(&b'\n')); + let response = br#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#; + assert!(transport.decode_response(response).is_ok()); + } + + #[test] + fn 入站_json_rpc边界在_stdio与_sse共用且跳过通知() { + let transport = StdioTransport; + for payload in [ + br#"{"jsonrpc":"1.0","id":1,"result":{}}"#.as_slice(), + br#"{"jsonrpc":"2.0","id":1,"method":"notice","result":{}}"#, + br#"{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-1,"message":"bad"}}"#, + ] { + assert!(matches!( + transport.decode_response(payload), + Err(McpError::Protocol(_)) + )); + let sse = format!("data: {}\n\n", String::from_utf8_lossy(payload)); + assert!(matches!( + decode_http_response(sse.as_bytes(), "text/event-stream"), + Err(McpError::Protocol(_)) + )); + } + + // SSE POST 响应可在对应 response 前夹带 notification;同步请求只 + // 取 response,不应把合法通知误报成 malformed response。 + let sse = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true},\"extension\":true}\n\n"; + let response = decode_http_response(sse, "text/event-stream").unwrap(); + assert_eq!(response.id, Some(JsonRpcId::Number(1))); + assert_eq!(response.result.unwrap()["ok"], true); + } + + #[test] + fn 可构造初始化通知() { + let notification = JsonRpcNotification::initialized(); + let encoded = StdioTransport.encode_notification(¬ification).unwrap(); + assert_eq!( + serde_json::from_slice::(&encoded).unwrap()["method"], + "notifications/initialized" + ); + } + + #[test] + fn 消息枚举可以解析响应() { + let response = br#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#; + let message: JsonRpcMessage = serde_json::from_slice(response).unwrap(); + assert!(matches!(message, JsonRpcMessage::Response(_))); + } + + #[test] + fn http_请求包含默认内容类型() { + let transport = StreamableHttpTransport::new("http://localhost/mcp"); + let request = transport + .request(&JsonRpcRequest::tools_list(1, None)) + .unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.headers["content-type"], "application/json"); + assert!(request.headers["accept"].contains("text/event-stream")); + assert_eq!( + request.headers["mcp-protocol-version"], + DEFAULT_PROTOCOL_VERSION + ); + } + + #[test] + fn debug_输出不泄漏配置_transport_和_http请求中的敏感值() { + let mut env = BTreeMap::new(); + env.insert( + "Authorization".to_owned(), + "Bearer env-secret-token".to_owned(), + ); + env.insert("MCP_TOKEN".to_owned(), "another-env-secret".to_owned()); + let stdio_config = McpServerConfig::new( + "workspace", + McpTransportConfig::Stdio { + command: "wrapper-secret-command".to_owned(), + args: vec!["--token".to_owned(), "argv-secret-token".to_owned()], + env, + }, + ); + let stdio_debug = format!("{stdio_config:?}"); + for secret in [ + "env-secret-token", + "another-env-secret", + "argv-secret-token", + "wrapper-secret-command", + ] { + assert!( + !stdio_debug.contains(secret), + "泄漏了 {secret}: {stdio_debug}" + ); + } + // Debug 只脱敏;serde 仍保留原始配置,兼容现有持久化/配置读取。 + let encoded = serde_json::to_value(&stdio_config).unwrap(); + assert_eq!( + encoded["transport"]["env"]["Authorization"], + "Bearer env-secret-token" + ); + + let http_transport = StreamableHttpTransport::new( + "https://user:url-userinfo-secret@example.test/mcp/url-path-secret?access_token=url-secret-token#fragment-secret", + ) + .with_header("Authorization", "Bearer header-secret-token"); + let http_config = McpServerConfig::new( + "workspace", + McpTransportConfig::StreamableHttp { + url: http_transport.url.clone(), + headers: http_transport.headers.clone(), + }, + ); + let request = http_transport + .request(&JsonRpcRequest::tools_call( + 1, + "echo", + json!({"token": "body-secret-token"}), + )) + .unwrap(); + let debug = format!( + "config={:?}; transport={:?}; request={:?}; client={:?}", + http_config, + http_transport, + request, + StreamableHttpClient::new(http_transport.url.clone()).unwrap(), + ); + for secret in [ + "url-secret-token", + "url-userinfo-secret", + "url-path-secret", + "fragment-secret", + "header-secret-token", + "body-secret-token", + ] { + assert!(!debug.contains(secret), "泄漏了 {secret}: {debug}"); + } + assert!(debug.contains("body")); + assert!(debug.contains("redacted")); + } + + #[test] + fn debug_输出不泄漏_mcp错误中的远端文本和_http_body() { + let errors = [ + McpError::HttpStatus { + status: 401, + body: "Authorization: Bearer http-body-secret".to_owned(), + }, + McpError::Remote { + code: -32000, + message: "remote-token-secret".to_owned(), + data: Some(json!({"token": "remote-data-secret"})), + }, + McpError::Transport("transport-secret".to_owned()), + McpError::Protocol("protocol-secret".to_owned()), + ]; + for error in errors { + let debug = format!("{error:?}"); + for secret in [ + "http-body-secret", + "remote-token-secret", + "remote-data-secret", + "transport-secret", + "protocol-secret", + ] { + assert!(!debug.contains(secret), "泄漏了 {secret}: {debug}"); + } + } + } + + #[test] + fn display_输出不泄漏_mcp错误中的远端文本和_http_body() { + let errors = [ + McpError::HttpStatus { + status: 401, + body: "Authorization: Bearer display-http-secret".to_owned(), + }, + McpError::Remote { + code: -32000, + message: "display-remote-secret".to_owned(), + data: Some(json!({"token": "display-data-secret"})), + }, + McpError::Transport("display-transport-secret".to_owned()), + McpError::Protocol("display-protocol-secret".to_owned()), + ]; + for error in errors { + let display = error.to_string(); + for secret in [ + "display-http-secret", + "display-remote-secret", + "display-data-secret", + "display-transport-secret", + "display-protocol-secret", + ] { + assert!(!display.contains(secret), "泄漏了 {secret}: {display}"); + } + } + } + + #[test] + fn tools_list_拒绝循环游标而不是无限请求() { + struct CyclingTransport; + + impl McpSyncTransport for CyclingTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + let result = match request.method.as_str() { + "initialize" => json!({"protocolVersion": DEFAULT_PROTOCOL_VERSION}), + "tools/list" => { + let cursor = request + .params + .as_ref() + .and_then(|params| params.get("cursor")) + .and_then(Value::as_str); + json!({ + "tools": [], + "nextCursor": if cursor == Some("a") { "b" } else { "a" } + }) + } + _ => return Err(McpError::Protocol("unexpected method".to_owned())), + }; + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(result), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + } + + let mut client = + McpClient::with_transport("cycle", CyclingTransport, McpClientOptions::default()); + let error = client.list_tools().expect_err("循环游标应被拒绝"); + assert!(matches!(error, McpError::Protocol(message) if message.contains("循环游标"))); + } + + #[cfg(unix)] + #[test] + fn stdio_client_完成握手列工具并调用() { + let script = r#" +while IFS= read -r line; do + case "$line" in + *'"method":"initialize","params"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}' + ;; + *'"method":"tools/list"}'*) + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"echo text","inputSchema":{"type":"object"}}]}}' + ;; + *'"method":"tools/call","params"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"ok"}],"isError":false}}' + ;; + esac +done +"#; + let config = McpServerConfig::new( + "fake", + McpTransportConfig::Stdio { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script.to_owned()], + env: BTreeMap::new(), + }, + ); + let options = + McpClientOptions::new("test-client", "0.1").with_timeout(Duration::from_secs(2)); + let mut client = McpClient::stdio(&config, options).unwrap(); + let capabilities = client.initialize().unwrap(); + assert_eq!(capabilities["protocolVersion"], "2025-06-18"); + let tools = client.list_tools().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(client.namespace_tool(&tools[0].name), "mcp:fake:echo"); + let result = client + .call_namespaced_tool("mcp:fake:echo", json!({"text":"hello"})) + .unwrap(); + assert!(!result.is_error); + assert_eq!(result.content[0].data["text"], "ok"); + } + + #[cfg(unix)] + #[test] + fn stdio_client_超时不会无限等待() { + let script = "while IFS= read -r line; do sleep 1; done"; + let config = McpServerConfig::new( + "slow", + McpTransportConfig::Stdio { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script.to_owned()], + env: BTreeMap::new(), + }, + ); + let mut client = McpClient::stdio( + &config, + McpClientOptions::default().with_timeout(Duration::from_millis(40)), + ) + .unwrap(); + let error = client.initialize().unwrap_err(); + assert!(matches!(error, McpError::Timeout { .. })); + } + + #[test] + fn stdio_pending消息队列有界() { + let mut pending = VecDeque::new(); + for index in 0..MAX_STDIO_PENDING_MESSAGES { + push_stdio_pending_message( + &mut pending, + JsonRpcMessage::Notification(JsonRpcNotification::new( + format!("notifications/{index}"), + None, + )), + ) + .unwrap(); + } + + // 等待响应期间暂存的通知也必须有硬上限;否则每轮 request 都能把 + // 尚未显式 poll 的通知继续追加到 client 私有 VecDeque。 + let error = push_stdio_pending_message( + &mut pending, + JsonRpcMessage::Notification(JsonRpcNotification::new("notifications/overflow", None)), + ) + .unwrap_err(); + assert!(matches!( + error, + McpError::Protocol(message) if message.contains("pending 消息") + )); + assert_eq!(pending.len(), MAX_STDIO_PENDING_MESSAGES); + } + + #[cfg(unix)] + #[test] + fn stdio_client_拒绝超大单行消息() { + // 不带换行的输出是最容易让无界 read_until 持续增长的情况; + // reader 应在达到上限后主动结束,而不是等待子进程继续输出。 + let script = format!( + "IFS= read -r line; head -c {} /dev/zero | tr '\\0' x", + MAX_STDIO_MESSAGE_BYTES + 1 + ); + let config = McpServerConfig::new( + "oversized", + McpTransportConfig::Stdio { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + env: BTreeMap::new(), + }, + ); + let mut client = McpClient::stdio( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + let error = client.initialize().unwrap_err(); + assert!(matches!(error, McpError::Protocol(message) if message.contains("超过"))); + } + + #[cfg(unix)] + #[test] + fn stdio_fixture_server_supports_handshake_discovery_and_call() { + // 使用随 crate 分发的 fixture,而不是在测试里重新拼一份 shell + // 协议脚本;这样独立 workspace 复制验收也会覆盖真实文件边界。 + let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("stdio-jsonrpc-server.sh"); + assert!( + fixture.is_file(), + "MCP fixture 不存在: {}", + fixture.display() + ); + let config = McpServerConfig::new( + "fixture", + McpTransportConfig::Stdio { + command: "sh".to_owned(), + args: vec![fixture.to_string_lossy().into_owned()], + env: BTreeMap::new(), + }, + ); + let mut client = McpClient::stdio(&config, McpClientOptions::default()).unwrap(); + client.initialize().expect("fixture handshake"); + let tools = client.list_tools().expect("fixture tools/list"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "fixture_echo"); + let result = client + .call_tool("fixture_echo", json!({"text": "hello"})) + .expect("fixture tools/call"); + assert_eq!(result.content[0].data["text"], "fixture-ok"); + } + + #[cfg(unix)] + #[test] + fn stdio_initialize会按候选版本重建连接并完成协商() { + // 每次候选版本都使用一个新 stdio 进程;首个版本被服务端拒绝后, + // client 只重建握手连接,不会在旧连接上重放任何业务请求。 + let script = r#" +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"protocolVersion":"2025-06-18"'*|*'"protocolVersion": "2025-06-18"'*) + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"error\":{\"code\":-32602,\"message\":\"unsupported protocol version\"}}" + ;; + *'"protocolVersion":"2024-11-05"'*|*'"protocolVersion": "2024-11-05"'*) + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{}}}" + ;; + *'"method":"notifications/initialized"'*|*'"method": "notifications/initialized"'*) + continue + ;; + esac +done +"#; + let config = McpServerConfig::new( + "versioned", + McpTransportConfig::Stdio { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script.to_owned()], + env: BTreeMap::new(), + }, + ); + let options = McpClientOptions::default() + .with_timeout(Duration::from_secs(2)) + .with_protocol_versions(["2025-06-18", "2024-11-05"]) + .unwrap(); + let mut client = McpClient::stdio(&config, options).unwrap(); + let result = client.initialize().unwrap(); + assert_eq!(result["protocolVersion"], "2024-11-05"); + assert_eq!(client.negotiated_protocol_version(), Some("2024-11-05")); + } + + #[test] + fn protocol_version候选配置有界且_http头可更新() { + let error = McpClientOptions::default() + .with_protocol_versions(std::iter::repeat_n( + "x", + MAX_PROTOCOL_VERSION_CANDIDATES + 1, + )) + .unwrap_err(); + assert!(matches!(error, McpError::Configuration(message) if message.contains("超过"))); + let error = McpClientOptions::default() + .with_protocol_versions(["2025-06-18", "2025-06-18"]) + .unwrap_err(); + assert!(matches!(error, McpError::Configuration(message) if message.contains("重复"))); + + let mut request = StreamableHttpTransport::new("https://example.test") + .with_header("mcp-protocol-version", "2025-06-18"); + request.set_protocol_version("2024-11-05"); + let request = request + .request(&JsonRpcRequest::initialize_with_protocol_version( + 1, + "client", + "1", + "2024-11-05", + )) + .unwrap(); + assert_eq!(request.headers["mcp-protocol-version"], "2024-11-05"); + } + + #[cfg(unix)] + #[test] + fn stdio_reader_超限错误在满队列时也不会阻塞() { + // 先放入一条消息填满 reader 的中间队列,再写超限正文。旧实现会在 + // 发送超限错误时阻塞,导致 Drop 无法 join;终止错误必须始终走 + // try_send,即使错误本身因队列已满而被丢弃。 + let script = format!( + "printf 'x\\n'; head -c {} /dev/zero | tr '\\0' x", + MAX_STDIO_MESSAGE_BYTES + 1 + ); + let mut child = Command::new("sh") + .args(["-c", script.as_str()]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn oversized fixture"); + let stdout = child.stdout.take().expect("fixture stdout"); + let (sender, receiver) = mpsc::sync_channel(1); + let (done_sender, done_receiver) = mpsc::channel(); + let reader = thread::spawn(move || { + read_stdio_messages(stdout, sender); + let _ = done_sender.send(()); + }); + + let completed = done_receiver.recv_timeout(Duration::from_secs(2)).is_ok(); + // 即使断言失败也先释放所有 OS 资源;丢弃 receiver 会让旧的阻塞 + // send 返回,从而避免测试进程被遗留 reader 线程拖住。 + let _ = child.kill(); + let _ = child.wait(); + drop(receiver); + reader.join().expect("reader should be joinable"); + assert!(completed, "reader blocked while reporting oversized input"); + } + + #[test] + fn client_拒绝不匹配的_jsonrpc_响应() { + struct WrongId; + + impl McpSyncTransport for WrongId { + fn send_request( + &mut self, + _request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(JsonRpcId::Number(99)), + result: Some(json!({})), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + } + + let mut client = McpClient::with_transport("fake", WrongId, McpClientOptions::default()); + assert!(matches!( + client.initialize(), + Err(McpError::Protocol(message)) if message.contains("id 不匹配") + )); + } + + #[test] + fn initialize_拒绝缺失或不支持的协议版本() { + struct VersionTransport { + initialize_result: Value, + } + + impl McpSyncTransport for VersionTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + if request.method != "initialize" { + return Err(McpError::Protocol("fixture 不应收到其它请求".to_owned())); + } + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(self.initialize_result.clone()), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + panic!("不支持的握手不应发送 initialized 通知") + } + } + + for initialize_result in [ + json!({"protocolVersion": "2024-11-05"}), + json!({"capabilities": {}}), + ] { + let mut client = McpClient::with_transport( + "version-check", + VersionTransport { initialize_result }, + McpClientOptions::default(), + ); + let error = client + .initialize() + .expect_err("不兼容或缺失版本必须拒绝握手"); + assert!( + matches!(error, McpError::Protocol(message) if message.contains("protocolVersion")) + ); + assert!(!client.is_initialized()); + } + } + + #[test] + fn streamable_http_initialize按候选版本重建连接并更新协议头() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let seen_versions = Arc::new(Mutex::new(Vec::new())); + let seen_for_server = Arc::clone(&seen_versions); + let server = thread::spawn(move || { + for attempt in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request_bytes = Vec::new(); + let mut buffer = [0_u8; 2048]; + let header_end = loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "HTTP client prematurely closed request"); + request_bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = request_bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + { + break end + 4; + } + }; + let headers = String::from_utf8_lossy(&request_bytes[..header_end]); + let version = headers + .lines() + .find(|line| { + line.to_ascii_lowercase() + .starts_with("mcp-protocol-version:") + }) + .and_then(|line| line.split_once(':')) + .map(|(_, value)| value.trim().to_owned()) + .expect("protocol version header"); + seen_for_server.lock().unwrap().push(version); + let content_length = headers + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("content-length:")) + .and_then(|line| line.split_once(':')) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + while request_bytes.len() < header_end + content_length { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "HTTP body ended early"); + request_bytes.extend_from_slice(&buffer[..count]); + } + let request: Value = + serde_json::from_slice(&request_bytes[header_end..header_end + content_length]) + .unwrap(); + let response = if attempt == 0 { + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": {"code": -32602, "message": "unsupported protocol version"} + }) + } else if attempt == 1 { + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"protocolVersion": "2024-11-05", "capabilities": {}} + }) + } else { + assert_eq!(request["method"], "notifications/initialized"); + stream + .write_all( + b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + continue; + }; + let body = serde_json::to_vec(&response).unwrap(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + } + }); + + let config = McpServerConfig::new( + "versioned-http", + McpTransportConfig::StreamableHttp { + url: format!("http://{address}/mcp"), + headers: BTreeMap::new(), + }, + ); + let options = McpClientOptions::default() + .with_timeout(Duration::from_secs(2)) + .with_protocol_versions(["2025-06-18", "2024-11-05"]) + .unwrap(); + let mut client = McpClient::streamable_http(&config, options).unwrap(); + let result = client.initialize().unwrap(); + assert_eq!(result["protocolVersion"], "2024-11-05"); + assert_eq!(client.negotiated_protocol_version(), Some("2024-11-05")); + server.join().unwrap(); + assert_eq!( + *seen_versions.lock().unwrap(), + vec!["2025-06-18", "2024-11-05", "2024-11-05"] + ); + } + + #[test] + fn streamable_http_client_完成_json_握手和工具调用() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let seen_session_headers = Arc::new(Mutex::new(Vec::new())); + let seen_for_server = Arc::clone(&seen_session_headers); + let server = thread::spawn(move || { + for _ in 0..4 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request_bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let header_end = loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "HTTP client prematurely closed request"); + request_bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = request_bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + { + break end + 4; + } + }; + let headers = String::from_utf8_lossy(&request_bytes[..header_end]).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length:") + .or_else(|| line.strip_prefix("content-length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + while request_bytes.len() < header_end + content_length { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "HTTP body ended early"); + request_bytes.extend_from_slice(&buffer[..count]); + } + let request: Value = + serde_json::from_slice(&request_bytes[header_end..header_end + content_length]) + .unwrap(); + let session = headers + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("mcp-session-id:")) + .map(str::to_owned); + seen_for_server.lock().unwrap().push(session); + let method = request["method"].as_str().unwrap_or_default(); + let (status, payload) = match method { + "initialize" => ( + "200 OK", + json!({ + "jsonrpc":"2.0", + "id":request["id"], + "result":{"protocolVersion":"2025-06-18","capabilities":{}} + }), + ), + "notifications/initialized" => ("202 Accepted", Value::Null), + "tools/list" => ( + "200 OK", + json!({ + "jsonrpc":"2.0", + "id":request["id"], + "result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]} + }), + ), + "tools/call" => ( + "200 OK", + json!({ + "jsonrpc":"2.0", + "id":request["id"], + "result":{"content":[{"type":"text","text":"http-ok"}],"isError":false} + }), + ), + other => panic!("unexpected MCP method: {other}"), + }; + let body = if payload.is_null() { + Vec::new() + } else { + serde_json::to_vec(&payload).unwrap() + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nMcp-Session-Id: test-session\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + } + }); + + let url = format!("http://{address}/mcp"); + let config = McpServerConfig::new( + "http-fake", + McpTransportConfig::StreamableHttp { + url, + headers: BTreeMap::new(), + }, + ); + let mut client = McpClient::streamable_http( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + assert_eq!( + client.initialize().unwrap()["protocolVersion"], + "2025-06-18" + ); + assert_eq!(client.list_tools().unwrap()[0].name, "echo"); + let result = client.call_tool("echo", json!({})).unwrap(); + assert_eq!(result.content[0].data["text"], "http-ok"); + server.join().unwrap(); + let sessions = seen_session_headers.lock().unwrap(); + assert_eq!(sessions.len(), 4); + assert!(sessions[0].is_none()); + assert!(sessions[1].is_some()); + assert!(sessions[2].is_some()); + assert!(sessions[3].is_some()); + } + + #[test] + fn streamable_http_client_解析_sse_json_响应() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request).unwrap(); + let body = b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes()).unwrap(); + stream.write_all(body).unwrap(); + }); + let mut transport = StreamableHttpClient::new(format!("http://{address}/mcp")).unwrap(); + let request = JsonRpcRequest::tools_list(1, None); + let error = transport + .send_request(&request, Duration::from_secs(2)) + .unwrap(); + assert_eq!(error.id, Some(JsonRpcId::Number(1))); + assert_eq!(error.result.unwrap()["ok"], true); + server.join().unwrap(); + } + + #[test] + fn streamable_http_client可建立后台_sse通知订阅并协作取消() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let seen_request = Arc::new(Mutex::new(String::new())); + let seen_for_server = Arc::clone(&seen_request); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "HTTP 请求在 headers 完成前被关闭"); + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + *seen_for_server.lock().unwrap() = String::from_utf8(request).unwrap(); + + let first = b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\",\"params\":{\"source\":\"http-first\"}}\n\n"; + let second = b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\",\"params\":{\"source\":\"http-second\"}}\n\n"; + // No Content-Length deliberately: an SSE response is delimited by + // the eventual connection close, so an idle gap must not terminate + // the subscription or impose a total request timeout. + let header = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: keep-alive\r\n\r\n"; + stream.write_all(header.as_bytes()).unwrap(); + stream.write_all(first).unwrap(); + stream.flush().unwrap(); + // 间隔大于 client 的取消轮询间隔,验证连接在 idle 时仍保持。 + thread::sleep(Duration::from_millis(100)); + stream.write_all(second).unwrap(); + stream.flush().unwrap(); + // 保持连接打开,确保测试验证的是 cooperative cancel 而非 EOF。 + thread::sleep(Duration::from_millis(250)); + }); + + let mut client = StreamableHttpClient::new(format!("http://{address}/mcp")).unwrap(); + client.session_id = Some("http-session".to_owned()); + let subscription = client + .into_notification_subscription(4, Duration::from_millis(20)) + .unwrap(); + let notification = subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .expect("SSE notification should arrive"); + assert_eq!(notification.method, "notifications/tools/list_changed"); + assert_eq!(notification.params.unwrap()["source"], "http-first"); + let notification = subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .expect("idle SSE connection should deliver a later notification"); + assert_eq!(notification.params.unwrap()["source"], "http-second"); + + let cancellation = subscription.cancellation(); + cancellation.cancel(); + let started = Instant::now(); + subscription.join().unwrap(); + assert!( + started.elapsed() < Duration::from_millis(500), + "HTTP subscription cancellation should be bounded" + ); + + server.join().unwrap(); + let request = seen_request.lock().unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /mcp ")); + assert!(request.contains("accept: text/event-stream")); + assert!(request.contains("mcp-session-id: http-session")); + assert!(request.contains("mcp-protocol-version: 2025-06-18")); + } + + #[test] + fn streamable_http后台_sse通知订阅队列溢出会传播协议错误() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + } + let events = (1..=3) + .map(|index| { + format!( + "data: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/{index}\"}}\n\n" + ) + }) + .collect::(); + let body = events.as_bytes(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes()).unwrap(); + stream.write_all(body).unwrap(); + stream.flush().unwrap(); + thread::sleep(Duration::from_millis(100)); + }); + + let client = StreamableHttpClient::new(format!("http://{address}/mcp")).unwrap(); + let subscription = client + .into_notification_subscription(1, Duration::from_millis(20)) + .unwrap(); + thread::sleep(Duration::from_millis(50)); + let error = subscription.join().unwrap_err(); + assert!(matches!( + error, + McpError::Protocol(message) if message.contains("通知队列") + )); + server.join().unwrap(); + } + + #[test] + fn streamable_http后台_sse通知订阅会传播解析错误和_http状态() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for (status, body) in [ + ("200 OK", "data: {not-json}\n\n"), + ("503 Service Unavailable", "upstream unavailable"), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + } + let content_type = if status.starts_with("200") { + "text/event-stream" + } else { + "text/plain" + }; + let header = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes()).unwrap(); + stream.write_all(body.as_bytes()).unwrap(); + stream.flush().unwrap(); + } + }); + + let client = StreamableHttpClient::new(format!("http://{address}/mcp")).unwrap(); + let subscription = client + .into_notification_subscription(2, Duration::from_millis(20)) + .unwrap(); + let error = subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap_err(); + assert!(matches!(error, McpError::InvalidMessage(_))); + subscription.join().unwrap(); + + let client = StreamableHttpClient::new(format!("http://{address}/mcp")).unwrap(); + let subscription = client + .into_notification_subscription(2, Duration::from_millis(20)) + .unwrap(); + let error = subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap_err(); + assert!(matches!(error, McpError::HttpStatus { status: 503, .. })); + subscription.join().unwrap(); + server.join().unwrap(); + } + + #[test] + fn http和_sse解码拒绝超大正文或单行() { + let oversized = vec![b'x'; MAX_HTTP_RESPONSE_BYTES + 1]; + let error = decode_http_response(&oversized, "application/json").unwrap_err(); + assert!(matches!(error, McpError::Protocol(message) if message.contains("HTTP 响应"))); + + let oversized_line = format!("data: {}\n\n", "x".repeat(MAX_SSE_LINE_BYTES + 1)); + let error = + decode_http_response(oversized_line.as_bytes(), "text/event-stream").unwrap_err(); + assert!(matches!(error, McpError::Protocol(message) if message.contains("SSE 行"))); + } + + #[test] + fn resources和prompts_api_可分页读取并生成稳定能力指纹() { + struct CatalogTransport; + + impl McpSyncTransport for CatalogTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + let cursor = request + .params + .as_ref() + .and_then(|params| params.get("cursor")) + .and_then(Value::as_str); + let result = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": DEFAULT_PROTOCOL_VERSION, + "capabilities": {"tools": {}, "resources": {}, "prompts": {}} + }), + "tools/list" => json!({ + "tools": [{"name":"echo", "inputSchema":{"type":"object"}}] + }), + "resources/list" if cursor.is_none() => json!({ + "resources": [{"uri":"file:///a.txt", "name":"a", "mimeType":"text/plain"}], + "nextCursor":"page-2" + }), + "resources/list" => json!({ + "resources": [{"uri":"file:///b.txt", "name":"b"}] + }), + "resources/read" => json!({ + "contents": [{"uri":"file:///a.txt", "mimeType":"text/plain", "text":"hello"}] + }), + "prompts/list" => json!({ + "prompts": [{ + "name":"welcome", + "arguments":[{"name":"name", "required":true}] + }] + }), + "prompts/get" => json!({ + "description":"welcome prompt", + "messages":[{"role":"user", "content":{"type":"text", "text":"hello Ada"}}] + }), + other => return Err(McpError::Protocol(format!("unexpected: {other}"))), + }; + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(result), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + } + + let mut client = + McpClient::with_transport("catalog", CatalogTransport, McpClientOptions::default()); + let resources = client.list_resources().unwrap(); + assert_eq!(resources.len(), 2); + assert_eq!( + client.read_resource("file:///a.txt").unwrap().contents[0].text, + Some("hello".to_owned()) + ); + let prompts = client.list_prompts().unwrap(); + assert_eq!(prompts[0].name, "welcome"); + let arguments = BTreeMap::from([("name".to_owned(), "Ada".to_owned())]); + let prompt = client.get_prompt("welcome", &arguments).unwrap(); + assert_eq!(prompt.messages[0].content.data["text"], "hello Ada"); + + let snapshot = client.capability_snapshot().unwrap(); + assert_eq!(snapshot.tools[0].name, "echo"); + assert_eq!(snapshot.resources.len(), 2); + assert_eq!(snapshot.prompts[0].name, "welcome"); + assert_eq!( + snapshot.fingerprint().unwrap(), + snapshot.fingerprint().unwrap() + ); + } + + #[test] + fn 能力指纹忽略_json对象键顺序但会识别值变化() { + let left = json!({"capabilities":{"tools":{}, "resources":{}}, "version":1}); + let right = json!({"version":1, "capabilities":{"resources":{}, "tools":{}}}); + let changed = json!({"version":2, "capabilities":{"resources":{}, "tools":{}}}); + assert_eq!( + stable_capability_fingerprint(&left).unwrap(), + stable_capability_fingerprint(&right).unwrap() + ); + assert_ne!( + stable_capability_fingerprint(&left).unwrap(), + stable_capability_fingerprint(&changed).unwrap() + ); + } + + #[test] + fn 认证配置只持久化环境变量引用且解析值保持脱敏() { + let config = McpServerConfig::new( + "secure", + McpTransportConfig::streamable_http("https://example.test/mcp"), + ) + .with_auth(McpAuthEnv::http_bearer("MCP_TEST_TOKEN")); + let encoded = serde_json::to_string(&config).unwrap(); + assert!(encoded.contains("MCP_TEST_TOKEN")); + assert!(!encoded.contains("resolved-secret")); + + let resolved = resolve_transport_auth_with(&config, |name| { + (name == "MCP_TEST_TOKEN").then(|| "resolved-secret".to_owned()) + }) + .unwrap(); + let McpTransportConfig::StreamableHttp { headers, .. } = resolved else { + panic!("expected HTTP transport"); + }; + assert_eq!(headers["authorization"], "Bearer resolved-secret"); + assert!(!format!("{config:?}").contains("resolved-secret")); + + let error = resolve_transport_auth_with(&config, |_| None).unwrap_err(); + assert_eq!(error.kind(), McpErrorKind::Authentication); + assert!(!format!("{error:?}").contains("resolved-secret")); + } + + #[test] + fn streamable_http_from_config会应用认证环境变量引用() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + let _environment = + EnvironmentGuard::set("AGENT_MCP_FROM_CONFIG_HTTP_TOKEN", "http-config-secret"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let seen_request = Arc::new(Mutex::new(String::new())); + let seen_for_server = Arc::clone(&seen_request); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "HTTP request ended before headers"); + request.extend_from_slice(&buffer[..count]); + } + *seen_for_server.lock().unwrap() = String::from_utf8(request).unwrap(); + let body = br#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#; + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(header.as_bytes()).unwrap(); + stream.write_all(body).unwrap(); + }); + + let config = McpServerConfig::new( + "configured-http", + McpTransportConfig::streamable_http(format!("http://{address}/mcp")), + ) + .with_auth(McpAuthEnv::http_bearer("AGENT_MCP_FROM_CONFIG_HTTP_TOKEN")); + let mut client = StreamableHttpClient::from_config(&config).unwrap(); + let response = client + .send_request(&JsonRpcRequest::tools_list(1, None), Duration::from_secs(2)) + .unwrap(); + assert_eq!(response.result.unwrap()["ok"], true); + server.join().unwrap(); + + let request = seen_request.lock().unwrap().to_ascii_lowercase(); + assert!(request.contains("authorization: bearer http-config-secret")); + } + + #[cfg(unix)] + #[test] + fn stdio_spawn会把认证环境变量注入子进程() { + let _environment = + EnvironmentGuard::set("AGENT_MCP_SPAWN_AUTH_TOKEN", "stdio-config-secret"); + let script = r#" +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{},"child_token":"%s"}}\n' "$MCP_CHILD_TOKEN" + ;; + esac +done +"#; + let config = McpServerConfig::new( + "configured-stdio", + McpTransportConfig::stdio("sh", ["-c", script]), + ) + .with_auth(McpAuthEnv::stdio_environment( + "AGENT_MCP_SPAWN_AUTH_TOKEN", + "MCP_CHILD_TOKEN", + )); + let transport = StdioClient::spawn(&config).unwrap(); + let mut client = McpClient::with_transport( + "configured-stdio", + transport, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ); + let result = client.initialize().unwrap(); + assert_eq!(result["child_token"], "stdio-config-secret"); + } + + #[cfg(unix)] + #[test] + fn 单次连接恢复只重握手且不会自动重放工具调用() { + let script = r#" +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' "$id" + ;; + *'"method":"tools/call"'*) + exit 0 + ;; + esac +done +"#; + let config = McpServerConfig::new( + "recoverable", + McpTransportConfig::stdio("sh", ["-c", script]), + ); + let mut client = McpClient::connect( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + client.initialize().unwrap(); + let call_error = client.call_tool("side_effect", json!({})).unwrap_err(); + assert!(call_error.is_connection_failure()); + + // 恢复只会发 initialize/initialized;失败的 tools/call 不在内部缓存。 + client.recover_connection_once().unwrap(); + assert!(client.is_initialized()); + assert!(matches!( + client.recover_connection_once(), + Err(McpError::RecoveryExhausted) + )); + } + + #[cfg(unix)] + #[test] + fn stdio通知在同步响应间只能显式轮询() { + let script = r#" +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *\"method\":\"initialize\"*) + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{"reason":"fixture"}}' + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' "$id" + ;; + esac +done +"#; + let config = McpServerConfig::new( + "notify-stdio", + McpTransportConfig::stdio("sh", ["-c", script]), + ); + let mut client = McpClient::stdio( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + client.initialize().unwrap(); + let notification = client.poll_notification().unwrap().unwrap(); + assert_eq!(notification.method, "notifications/tools/list_changed"); + assert_eq!(notification.params.unwrap()["reason"], "fixture"); + assert_eq!(client.poll_notification().unwrap(), None); + } + + #[cfg(unix)] + #[test] + fn stdio轮询遇到服务端request会显式报不支持而不空转() { + let script = r#" +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' "$id" + ;; + *'"method":"notifications/initialized"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":99,"method":"sampling/createMessage","params":{"prompt":"fixture"}}' + ;; + esac +done +"#; + let config = McpServerConfig::new( + "server-request", + McpTransportConfig::stdio("sh", ["-c", script]), + ); + let mut client = McpClient::stdio( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + client.initialize().unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + let error = loop { + match client.poll_notification() { + Err(error) => break error, + Ok(Some(_)) => panic!("fixture unexpectedly returned a notification"), + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(2)); + } + Ok(None) => panic!("server request 未在预算内抵达 poller"), + } + }; + assert!( + matches!(error, McpError::Unsupported(message) if message.contains("服务端 request")) + ); + // Request 已经被消费并报告;下一次 poll 不会重复返回同一帧或忙循环。 + assert_eq!(client.poll_notification().unwrap(), None); + } + + #[cfg(unix)] + #[test] + fn stdio后台订阅传播服务端request错误() { + let script = r#" +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' "$id" + ;; + *'"method":"notifications/initialized"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":100,"method":"elicitation/create","params":{}}' + ;; + esac +done +"#; + let config = McpServerConfig::new( + "server-request-background", + McpTransportConfig::stdio("sh", ["-c", script]), + ); + let mut client = McpClient::stdio( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + client.initialize().unwrap(); + let subscription = client + .into_notification_subscription(2, Duration::from_millis(1)) + .unwrap(); + let error = subscription + .recv_timeout(Duration::from_secs(1)) + .expect_err("后台订阅应报告 server request"); + assert!( + matches!(error, McpError::Unsupported(message) if message.contains("服务端 request")) + ); + subscription.join().expect("订阅线程应在报告错误后退出"); + } + + #[test] + fn 错误分类区分连接协议远端和认证() { + assert_eq!( + McpError::Transport("secret".to_owned()).kind(), + McpErrorKind::Connection + ); + assert_eq!( + McpError::Protocol("bad".to_owned()).kind(), + McpErrorKind::Protocol + ); + assert_eq!( + McpError::Remote { + code: -1, + message: "remote".to_owned(), + data: None, + } + .kind(), + McpErrorKind::Remote + ); + assert!( + McpError::Timeout { + operation: "read".to_owned() + } + .is_connection_failure() + ); + } + + #[test] + fn 重连策略退避有上限并拒绝无界配置() { + let policy = McpReconnectPolicy { + max_attempts: 3, + initial_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(3), + backoff_multiplier: 2, + attempt_timeout: Duration::from_millis(20), + }; + policy.validate().unwrap(); + assert_eq!(policy.delay_for(1), Duration::from_millis(1)); + assert_eq!(policy.delay_for(2), Duration::from_millis(2)); + assert_eq!(policy.delay_for(3), Duration::from_millis(3)); + + let too_many = McpReconnectPolicy { + max_attempts: MAX_RECONNECT_ATTEMPTS + 1, + ..policy.clone() + }; + assert!(matches!( + McpReconnectScheduler::new(too_many), + Err(McpError::Configuration(_)) + )); + let zero_backoff = McpReconnectPolicy { + backoff_multiplier: 0, + ..policy.clone() + }; + assert!(matches!( + McpReconnectScheduler::new(zero_backoff), + Err(McpError::Configuration(_)) + )); + } + + #[test] + fn 重连调度只重试断线和超时并传递每次预算() { + let policy = McpReconnectPolicy { + max_attempts: 3, + initial_delay: Duration::ZERO, + max_delay: Duration::ZERO, + backoff_multiplier: 2, + attempt_timeout: Duration::from_millis(37), + }; + let scheduler = McpReconnectScheduler::new(policy).unwrap(); + let mut seen = Vec::new(); + let mut audit = Vec::new(); + let result = scheduler.run_with_audit( + |attempt| { + seen.push((attempt.number, attempt.timeout)); + match attempt.number { + 1 => Err(McpError::Transport("断线".to_owned())), + 2 => Err(McpError::Timeout { + operation: "握手".to_owned(), + }), + _ => Ok("connected"), + } + }, + &mut audit, + ); + assert_eq!(result.unwrap(), "connected"); + assert_eq!( + seen, + vec![ + (1, Duration::from_millis(37)), + (2, Duration::from_millis(37)), + (3, Duration::from_millis(37)) + ] + ); + assert!(matches!( + audit.as_slice(), + [ + McpReconnectEvent::Scheduled { attempt: 1, .. }, + McpReconnectEvent::Started { attempt: 1 }, + McpReconnectEvent::Failed { + attempt: 1, + kind: McpErrorKind::Connection + }, + McpReconnectEvent::Scheduled { attempt: 2, .. }, + McpReconnectEvent::Started { attempt: 2 }, + McpReconnectEvent::Failed { + attempt: 2, + kind: McpErrorKind::Timeout + }, + McpReconnectEvent::Scheduled { attempt: 3, .. }, + McpReconnectEvent::Started { attempt: 3 }, + McpReconnectEvent::Succeeded { attempt: 3 } + ] + )); + } + + #[test] + fn 重连次数耗尽且不会超过硬上限() { + let policy = McpReconnectPolicy { + max_attempts: 2, + initial_delay: Duration::ZERO, + max_delay: Duration::ZERO, + backoff_multiplier: 1, + attempt_timeout: Duration::from_millis(5), + }; + let scheduler = McpReconnectScheduler::new(policy).unwrap(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_in_operation = std::sync::Arc::clone(&calls); + let mut audit = Vec::new(); + let error = scheduler + .run_with_audit( + move |_attempt| { + calls_in_operation.fetch_add(1, Ordering::SeqCst); + Err::<(), _>(McpError::Transport("仍断线".to_owned())) + }, + &mut audit, + ) + .unwrap_err(); + assert!(matches!(error, McpError::RecoveryExhausted)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert!(matches!( + audit.last(), + Some(McpReconnectEvent::Exhausted { attempts: 2 }) + )); + } + + #[test] + fn 非连接错误立即停止且取消不会进入重连操作() { + let policy = McpReconnectPolicy { + max_attempts: 5, + initial_delay: Duration::ZERO, + max_delay: Duration::ZERO, + backoff_multiplier: 2, + attempt_timeout: Duration::from_millis(5), + }; + let scheduler = McpReconnectScheduler::new(policy.clone()).unwrap(); + let mut audit = Vec::new(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_in_operation = std::sync::Arc::clone(&calls); + let error = scheduler + .run_with_audit( + move |_attempt| { + calls_in_operation.fetch_add(1, Ordering::SeqCst); + Err::<(), _>(McpError::Protocol("版本不支持".to_owned())) + }, + &mut audit, + ) + .unwrap_err(); + assert!(matches!(error, McpError::Protocol(_))); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!( + !audit + .iter() + .any(|event| matches!(event, McpReconnectEvent::Exhausted { .. })) + ); + + let cancellation = McpReconnectCancellation::new(); + cancellation.cancel(); + let scheduler = McpReconnectScheduler::with_cancellation(policy, cancellation).unwrap(); + let error = scheduler + .run(|_attempt| -> Result<(), McpError> { + panic!("已取消的 scheduler 不应调用 reconnect closure") + }) + .unwrap_err(); + assert!(matches!(error, McpError::Cancelled { .. })); + } + + #[test] + fn 取消会中断退避而不等待完整延迟() { + let cancellation = McpReconnectCancellation::new(); + let worker_cancellation = cancellation.clone(); + let scheduler = McpReconnectScheduler::with_cancellation( + McpReconnectPolicy { + max_attempts: 3, + initial_delay: Duration::from_millis(250), + max_delay: Duration::from_millis(250), + backoff_multiplier: 1, + attempt_timeout: Duration::from_millis(5), + }, + cancellation, + ) + .unwrap(); + let started = Instant::now(); + let worker = thread::spawn(move || { + scheduler.run(|_attempt| -> Result<(), McpError> { + panic!("取消期间不应调用 reconnect closure") + }) + }); + thread::sleep(Duration::from_millis(20)); + worker_cancellation.cancel(); + let error = worker.join().unwrap().unwrap_err(); + assert!(matches!(error, McpError::Cancelled { .. })); + assert!(started.elapsed() < Duration::from_millis(180)); + } + + #[test] + fn 权限拒绝会审计且不会发送_tools_call() { + struct CountingTransport { + tool_calls: std::sync::Arc, + } + + impl McpSyncTransport for CountingTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + let result = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": DEFAULT_PROTOCOL_VERSION, + "capabilities": {} + }), + "tools/call" => { + self.tool_calls.fetch_add(1, Ordering::SeqCst); + json!({ + "content": [{"type": "text", "text": "ok"}], + "isError": false + }) + } + other => return Err(McpError::Protocol(format!("unexpected method: {other}"))), + }; + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(result), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + } + + let tool_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut client = McpClient::with_transport( + "secure", + CountingTransport { + tool_calls: std::sync::Arc::clone(&tool_calls), + }, + McpClientOptions::default(), + ); + client.initialize().unwrap(); + let mut audit = Vec::new(); + let denied = client + .call_tool_with_permission( + "echo", + json!({"token": "must-not-enter-audit"}), + |_tool, _arguments| McpPermissionDecision::Deny, + &mut audit, + ) + .unwrap_err(); + assert!(matches!(denied, McpError::PermissionDenied { .. })); + assert_eq!(tool_calls.load(Ordering::SeqCst), 0); + assert_eq!( + audit, + vec![McpPermissionAuditEvent::new( + "mcp:secure:echo", + McpPermissionDecision::Deny, + None + )] + ); + + let asked = client + .call_tool_with_permission( + "mcp:secure:echo", + json!({"x": 1}), + |_tool, _arguments| McpPermissionDecision::Ask, + &mut audit, + ) + .unwrap_err(); + assert!(matches!(asked, McpError::PermissionRequired { .. })); + assert_eq!(tool_calls.load(Ordering::SeqCst), 0); + let allowed = client + .call_tool_with_permission( + "echo", + json!({"x": 2}), + |_tool, _arguments| McpPermissionDecision::Allow, + &mut audit, + ) + .unwrap(); + assert!(!allowed.is_error); + assert_eq!(tool_calls.load(Ordering::SeqCst), 1); + assert_eq!(audit.len(), 3); + } + + #[test] + fn 同步通知只能显式轮询且不会伪造异步订阅() { + struct NotificationTransport { + notification: Option, + } + + impl McpSyncTransport for NotificationTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + _timeout: Duration, + ) -> Result { + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(json!({ + "protocolVersion": DEFAULT_PROTOCOL_VERSION, + "capabilities": {} + })), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + + fn poll_notification(&mut self) -> Result, McpError> { + Ok(self.notification.take()) + } + } + + let notification = JsonRpcNotification::new( + "notifications/tools/list_changed", + Some(json!({"changed": true})), + ); + let mut client = McpClient::with_transport( + "notify", + NotificationTransport { + notification: Some(notification.clone()), + }, + McpClientOptions::default(), + ); + client.initialize().unwrap(); + assert_eq!(client.poll_notification().unwrap(), Some(notification)); + assert_eq!(client.poll_notification().unwrap(), None); + } + + #[test] + fn 后台通知订阅有界转发并支持跨线程取消() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let poll_calls = std::sync::Arc::clone(&calls); + let mut notifications = VecDeque::from([ + Some(JsonRpcNotification::new( + "notifications/one", + Some(json!({"n": 1})), + )), + Some(JsonRpcNotification::new("notifications/two", None)), + ]); + let subscription = + McpNotificationSubscription::from_poller(2, Duration::from_millis(1), move || { + poll_calls.fetch_add(1, Ordering::SeqCst); + Ok(notifications.pop_front().flatten()) + }) + .unwrap(); + + let cancellation = subscription.cancellation(); + assert_eq!( + subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap() + .method, + "notifications/one" + ); + assert_eq!( + subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap() + .method, + "notifications/two" + ); + cancellation.cancel(); + subscription.join().unwrap(); + assert!(calls.load(Ordering::SeqCst) >= 2); + } + + #[test] + fn 后台通知订阅拒绝空转和无界队列配置() { + let poller = || Ok::, McpError>(None); + assert!(matches!( + McpNotificationSubscription::from_poller(0, Duration::from_millis(1), poller), + Err(McpError::Configuration(_)) + )); + let poller = || Ok::, McpError>(None); + assert!(matches!( + McpNotificationSubscription::from_poller( + MAX_NOTIFICATION_QUEUE_CAPACITY + 1, + Duration::from_millis(1), + poller + ), + Err(McpError::Configuration(_)) + )); + let poller = || Ok::, McpError>(None); + assert!(matches!( + McpNotificationSubscription::from_poller(1, Duration::ZERO, poller), + Err(McpError::Configuration(_)) + )); + } + + #[test] + fn 后台通知订阅队列溢出后报告协议错误() { + let mut sequence = VecDeque::from([ + Some(JsonRpcNotification::new("notifications/one", None)), + Some(JsonRpcNotification::new("notifications/two", None)), + ]); + let subscription = + McpNotificationSubscription::from_poller(1, Duration::from_millis(1), move || { + Ok(sequence.pop_front().flatten()) + }) + .unwrap(); + + // 等待 poller 至少尝试第二条通知;不依赖固定的线程调度顺序。 + thread::sleep(Duration::from_millis(20)); + let error = subscription.join().unwrap_err(); + assert!(matches!(error, McpError::Protocol(message) if message.contains("通知队列"))); + } + + #[test] + fn 后台通知订阅_join不会吞掉队列中的轮询错误() { + let (started_sender, started_receiver) = mpsc::channel(); + let subscription = + McpNotificationSubscription::from_poller(2, Duration::from_millis(1), move || { + let _ = started_sender.send(()); + Err::, _>(McpError::Protocol("poll failed".to_owned())) + }) + .unwrap(); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("poller should run before join"); + + let error = subscription.join().unwrap_err(); + assert!(matches!(error, McpError::Protocol(message) if message == "poll failed")); + } + + #[cfg(unix)] + #[test] + fn stdio_client可转成独占后台通知订阅() { + let script = r#" +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' "$id" + ;; + *'"method":"notifications/initialized"'*) + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{"source":"background"}}' + ;; + esac +done +"#; + let config = McpServerConfig::new( + "notify-background", + McpTransportConfig::stdio("sh", ["-c", script]), + ); + let mut client = McpClient::stdio( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(2)), + ) + .unwrap(); + client.initialize().unwrap(); + let subscription = client + .into_notification_subscription(4, Duration::from_millis(1)) + .unwrap(); + let notification = subscription + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap(); + assert_eq!(notification.method, "notifications/tools/list_changed"); + assert_eq!(notification.params.unwrap()["source"], "background"); + subscription.join().unwrap(); + } + + #[test] + fn 握手会使用调用方传入的_timeout预算() { + struct RecordingTransport { + request_timeouts: std::sync::Arc>>, + notification_timeouts: std::sync::Arc>>, + } + + impl McpSyncTransport for RecordingTransport { + fn send_request( + &mut self, + request: &JsonRpcRequest, + timeout: Duration, + ) -> Result { + self.request_timeouts.lock().unwrap().push(timeout); + Ok(JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: Some(request.id.clone()), + result: Some(json!({ + "protocolVersion": DEFAULT_PROTOCOL_VERSION, + "capabilities": {} + })), + error: None, + }) + } + + fn send_notification( + &mut self, + _notification: &JsonRpcNotification, + ) -> Result<(), McpError> { + Ok(()) + } + + fn send_notification_with_timeout( + &mut self, + _notification: &JsonRpcNotification, + timeout: Duration, + ) -> Result<(), McpError> { + self.notification_timeouts.lock().unwrap().push(timeout); + Ok(()) + } + } + + let request_timeouts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let notification_timeouts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut client = McpClient::with_transport( + "timeout", + RecordingTransport { + request_timeouts: std::sync::Arc::clone(&request_timeouts), + notification_timeouts: std::sync::Arc::clone(¬ification_timeouts), + }, + McpClientOptions::default().with_timeout(Duration::from_secs(30)), + ); + + // 重连策略会调用同一条内部路径;这里直接验证该路径不会回退到 + // client 的 30 秒默认值,且 initialized 通知使用同一份预算。 + let budget = Duration::from_millis(37); + client.initialize_with_timeout(budget).unwrap(); + assert_eq!(*request_timeouts.lock().unwrap(), vec![budget]); + assert_eq!(*notification_timeouts.lock().unwrap(), vec![budget]); + } + + #[cfg(unix)] + #[test] + fn 重连策略的_attempt_timeout会限制实际握手等待() { + // 服务端故意延迟 initialize 响应;如果策略预算被忽略,下面的 + // client 默认 1 秒 timeout 会让测试明显变慢。这里不发送任何工具 + // 调用,证明只限制握手,不会引入请求重放。 + let script = r#" +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) + sleep 1 + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}' + ;; + esac +done +"#; + let config = McpServerConfig::new( + "slow-reconnect", + McpTransportConfig::stdio("sh", ["-c", script]), + ); + let mut client = McpClient::connect( + &config, + McpClientOptions::default().with_timeout(Duration::from_secs(1)), + ) + .unwrap(); + let policy = McpReconnectPolicy { + max_attempts: 1, + initial_delay: Duration::ZERO, + max_delay: Duration::ZERO, + backoff_multiplier: 1, + attempt_timeout: Duration::from_millis(25), + }; + let started = Instant::now(); + let error = client + .reconnect_with_policy(policy, &McpReconnectCancellation::new()) + .unwrap_err(); + assert!(matches!(error, McpError::RecoveryExhausted)); + assert!( + started.elapsed() < Duration::from_millis(400), + "重连没有使用 attempt timeout: {:?}", + started.elapsed() + ); + } +} diff --git a/rust/crates/agent-provider-fake/Cargo.toml b/rust/crates/agent-provider-fake/Cargo.toml new file mode 100644 index 000000000..183d18902 --- /dev/null +++ b/rust/crates/agent-provider-fake/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agent-provider-fake" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "用于 Agent 回归测试的确定性 Provider" + +[dependencies] +agent-runtime-core.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/rust/crates/agent-provider-fake/src/lib.rs b/rust/crates/agent-provider-fake/src/lib.rs new file mode 100644 index 000000000..df5ca649f --- /dev/null +++ b/rust/crates/agent-provider-fake/src/lib.rs @@ -0,0 +1,337 @@ +//! 可脚本化的离线 Provider。 +//! +//! Fake Provider 只依赖 Core 契约,不读取环境变量、不访问网络,也不把测试 +//! 场景塞进 Engine。它既能驱动纯文本/工具循环,也能稳定复现流中断、取消和 +//! 压缩响应等边界,供所有适配器和 Host 测试复用。 + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use agent_runtime_core::{ + ContentPart, ModelProvider, ProviderError, ProviderErrorKind, ProviderRequest, + ProviderResponse, ProviderStreamEvent, ProviderStreamSink, ToolCall, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Fake Provider 的一次确定性响应。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum FakeStep { + /// 返回一条普通文本响应。 + Text { text: String }, + /// 返回一个结构化工具调用。 + ToolCall { + id: String, + name: String, + arguments: Value, + }, + /// 返回文本和一批工具调用,便于覆盖串行审批/执行。 + ToolCalls { + #[serde(default)] + text: String, + calls: Vec, + }, + /// 把文本按给定片段通过 `ModelProvider::stream` 发出。 + StreamText { chunks: Vec }, + /// 模拟 Provider 在流中途失败。 + StreamError { message: String }, + /// 模拟调用方取消或外部调用结果未知。 + Cancelled, + /// 作为压缩 Provider 测试脚本使用的摘要响应。 + Compression { summary: String }, + /// 模拟普通瞬时/上游错误。 + Error { + kind: ProviderErrorKind, + message: String, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct FakeToolCall { + pub id: String, + pub name: String, + pub arguments: Value, +} + +impl FakeStep { + pub fn text(text: impl Into) -> Self { + Self::Text { text: text.into() } + } + + pub fn tool_call(id: impl Into, name: impl Into, arguments: Value) -> Self { + Self::ToolCall { + id: id.into(), + name: name.into(), + arguments, + } + } + + pub fn tool_calls(calls: impl IntoIterator) -> Self { + Self::ToolCalls { + text: String::new(), + calls: calls.into_iter().collect(), + } + } + + pub fn stream_text(chunks: impl IntoIterator>) -> Self { + Self::StreamText { + chunks: chunks.into_iter().map(Into::into).collect(), + } + } + + pub fn compression(summary: impl Into) -> Self { + Self::Compression { + summary: summary.into(), + } + } +} + +/// 记录 Provider 实际收到的请求,便于测试消息边界和恢复游标。 +#[derive(Clone, Debug, Default)] +pub struct FakeRequestLog(Arc>>); + +impl FakeRequestLog { + pub fn snapshot(&self) -> Vec { + self.0 + .lock() + .map(|requests| requests.clone()) + .unwrap_or_default() + } +} + +/// 线程安全的确定性 Provider。每次调用消费脚本中的下一步。 +#[derive(Clone, Debug)] +pub struct FakeProvider { + steps: Arc>>, + requests: FakeRequestLog, +} + +impl FakeProvider { + pub fn new(steps: impl IntoIterator) -> Self { + Self { + steps: Arc::new(Mutex::new(steps.into_iter().collect())), + requests: FakeRequestLog::default(), + } + } + + pub fn text(text: impl Into) -> Self { + Self::new([FakeStep::text(text)]) + } + + pub fn tool_then_text( + id: impl Into, + name: impl Into, + arguments: Value, + text: impl Into, + ) -> Self { + Self::new([ + FakeStep::tool_call(id, name, arguments), + FakeStep::text(text), + ]) + } + + pub fn requests(&self) -> FakeRequestLog { + self.requests.clone() + } + + pub fn remaining_steps(&self) -> usize { + self.steps.lock().map(|steps| steps.len()).unwrap_or(0) + } + + fn next_step(&self, request: &ProviderRequest) -> Result { + if let Ok(mut requests) = self.requests.0.lock() { + requests.push(request.clone()); + } + self.steps + .lock() + .map_err(|_| ProviderError::new(ProviderErrorKind::Unavailable, "fake 脚本锁已损坏"))? + .pop_front() + .ok_or_else(|| ProviderError::new(ProviderErrorKind::Unavailable, "fake 脚本已耗尽")) + } + + fn response_for( + request: &ProviderRequest, + step: FakeStep, + ) -> Result { + match step { + FakeStep::Text { text } | FakeStep::Compression { summary: text } => { + ProviderResponse::text(request.request_id(), request.model(), text) + .map_err(Into::into) + } + FakeStep::ToolCall { + id, + name, + arguments, + } => { + let call = ToolCall::try_new(id, name, arguments)?; + ProviderResponse::try_new(request.request_id(), request.model(), [], [call]) + .map_err(Into::into) + } + FakeStep::ToolCalls { text, calls } => { + let calls = calls + .into_iter() + .map(|call| ToolCall::try_new(call.id, call.name, call.arguments)) + .collect::, _>>()?; + let content = if text.is_empty() { + Vec::new() + } else { + vec![ContentPart::text(text)?] + }; + ProviderResponse::try_new(request.request_id(), request.model(), content, calls) + .map_err(Into::into) + } + FakeStep::StreamText { chunks } => { + ProviderResponse::text(request.request_id(), request.model(), chunks.concat()) + .map_err(Into::into) + } + FakeStep::StreamError { message } => { + Err(ProviderError::new(ProviderErrorKind::Stream, message)) + } + FakeStep::Cancelled => Err(ProviderError::new( + ProviderErrorKind::Stream, + "fake provider cancelled", + )), + FakeStep::Error { kind, message } => Err(ProviderError::new(kind, message)), + } + } +} + +impl ModelProvider for FakeProvider { + fn complete(&self, request: &ProviderRequest) -> Result { + let step = self.next_step(request)?; + match step { + FakeStep::StreamError { message } => { + Err(ProviderError::new(ProviderErrorKind::Stream, message)) + } + FakeStep::Cancelled => Err(ProviderError::new( + ProviderErrorKind::Stream, + "fake provider cancelled", + )), + other => Self::response_for(request, other), + } + } + + fn stream( + &self, + request: &ProviderRequest, + sink: &mut dyn ProviderStreamSink, + ) -> Result { + let step = self.next_step(request)?; + match step { + FakeStep::StreamText { chunks } => { + let mut accumulated = String::new(); + for chunk in chunks { + accumulated.push_str(&chunk); + sink.emit(ProviderStreamEvent::TextDelta { + delta: chunk, + accumulated: accumulated.clone(), + })?; + } + sink.emit(ProviderStreamEvent::Completed)?; + ProviderResponse::text(request.request_id(), request.model(), accumulated) + .map_err(Into::into) + } + FakeStep::StreamError { message } => { + Err(ProviderError::new(ProviderErrorKind::Stream, message)) + } + FakeStep::Cancelled => Err(ProviderError::new( + ProviderErrorKind::Stream, + "fake provider cancelled", + )), + other => { + let response = Self::response_for(request, other)?; + let mut accumulated = String::new(); + for part in response.content() { + if let Some(text) = part.as_text() { + accumulated.push_str(text); + sink.emit(ProviderStreamEvent::TextDelta { + delta: text.to_owned(), + accumulated: accumulated.clone(), + })?; + } + } + for call in response.tool_calls() { + sink.emit(ProviderStreamEvent::ToolCallDelta { + call_id: call.id().to_owned(), + name: Some(call.name().to_owned()), + arguments_delta: call.arguments().to_string(), + })?; + } + sink.emit(ProviderStreamEvent::Completed)?; + Ok(response) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_runtime_core::{ProviderStreamEvent, ToolChoice, ToolDefinition}; + use serde_json::json; + + struct Sink(Vec); + impl ProviderStreamSink for Sink { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError> { + self.0.push(event); + Ok(()) + } + } + + fn request(_provider: &FakeProvider) -> ProviderRequest { + ProviderRequest::try_new( + "request-1", + "fake", + [agent_runtime_core::Message::user("hi").unwrap()], + ) + .unwrap() + .with_tools( + [ToolDefinition::try_new("echo", "echo", json!({"type":"object"})).unwrap()], + ToolChoice::Auto, + ) + .unwrap() + } + + #[test] + fn scripted_tool_then_text_is_deterministic() { + let provider = FakeProvider::tool_then_text("call-1", "echo", json!({"x": 1}), "done"); + let first = provider.complete(&request(&provider)).unwrap(); + assert_eq!(first.tool_calls()[0].id(), "call-1"); + let second_request = ProviderRequest::try_new( + "request-2", + "fake", + [agent_runtime_core::Message::user("tool result").unwrap()], + ) + .unwrap(); + let second = provider.complete(&second_request).unwrap(); + assert_eq!(second.content()[0].as_text(), Some("done")); + assert_eq!(provider.requests().snapshot().len(), 2); + } + + #[test] + fn stream_text_emits_each_chunk_and_completion() { + let provider = FakeProvider::new([FakeStep::stream_text(["你", "好"])]); + let mut sink = Sink(Vec::new()); + let response = provider.stream(&request(&provider), &mut sink).unwrap(); + assert_eq!(response.content()[0].as_text(), Some("你好")); + assert!(matches!(sink.0[0], ProviderStreamEvent::TextDelta { .. })); + assert!(matches!(sink.0[1], ProviderStreamEvent::TextDelta { .. })); + assert!(matches!(sink.0[2], ProviderStreamEvent::Completed)); + } + + #[test] + fn error_and_cancel_scenarios_are_explicit() { + let provider = FakeProvider::new([FakeStep::Cancelled]); + let error = provider.complete(&request(&provider)).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Stream); + assert!(error.message().contains("cancelled")); + } + + #[test] + fn compression_step_is_a_normal_scripted_response() { + let provider = FakeProvider::new([FakeStep::compression("summary")]); + let response = provider.complete(&request(&provider)).unwrap(); + assert_eq!(response.content()[0].as_text(), Some("summary")); + } +} diff --git a/rust/crates/agent-provider-openai/Cargo.toml b/rust/crates/agent-provider-openai/Cargo.toml new file mode 100644 index 000000000..8fee9024c --- /dev/null +++ b/rust/crates/agent-provider-openai/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agent-provider-openai" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "OpenAI Responses API 的通用 Agent Provider 适配器" + +[dependencies] +agent-runtime-core.workspace = true +# 使用系统 TLS,避免把大型 rustls/ring 编译链带进最小 CLI。 +reqwest = { version = "0.12", features = ["blocking", "json"] } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/rust/crates/agent-provider-openai/src/lib.rs b/rust/crates/agent-provider-openai/src/lib.rs new file mode 100644 index 000000000..7fa258a35 --- /dev/null +++ b/rust/crates/agent-provider-openai/src/lib.rs @@ -0,0 +1,2268 @@ +//! OpenAI Responses API 适配器。 +//! +//! 这里只做 HTTP 和厂商字段映射;Agent Loop、权限和会话仍由上层 Host +//! 负责。API key 只存在内存中的 client header,不会进入请求事件或错误文本。 + +use std::collections::BTreeMap; +use std::fmt; +use std::io::{self, BufRead, BufReader, Read}; +use std::time::Duration; + +use agent_runtime_core::{ + ContentPart, MessageRole, ModelProvider, ProviderError, ProviderErrorKind, ProviderProtocolId, + ProviderRequest, ProviderResponse, ProviderStreamEvent, ProviderStreamSink, ProviderUsage, + ToolCall, ToolChoice, +}; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{Value, json}; +use thiserror::Error; + +/// OpenAI 官方 Responses endpoint 的默认 base URL。 +pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +/// OpenAI 官方 Responses endpoint 的默认完整地址。 +pub const DEFAULT_ENDPOINT: &str = "https://api.openai.com/v1/responses"; +/// 该适配器实现的稳定 wire protocol 身份。 +pub const OPENAI_RESPONSES_PROTOCOL_ID: &str = "openai-responses"; + +const MAX_ERROR_BODY_BYTES: usize = 64 * 1024; +const MAX_SSE_LINE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Error)] +pub enum OpenAiError { + #[error("OPENAI_API_KEY 未设置")] + MissingApiKey, + #[error("OpenAI endpoint 无效: {0}")] + InvalidEndpoint(String), + #[error("OpenAI 请求失败: {0}")] + Request(String), + #[error("OpenAI 响应解析失败: {0}")] + Response(String), +} + +/// OpenAI Provider 的非秘密配置。 +/// +/// `api_key_env` 只保存环境变量名,不保存 key 本身。`endpoint` 是完整的 +/// Responses 地址;`base_url` 是网关根地址,解析时会自动补上 `/responses`。 +/// 两者同时存在时使用完整 `endpoint`,从而允许环境变量覆盖配置文件中的 base。 +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct OpenAiProviderConfig { + /// 读取 API key 的环境变量名。 + #[serde(alias = "apiKeyEnv")] + pub api_key_env: String, + /// OpenAI-compatible 网关的 base URL,例如 `https://gateway.example/v1`。 + #[serde(alias = "baseUrl", serialize_with = "serialize_safe_base_url")] + pub base_url: Option, + /// 完整 endpoint,例如 `https://gateway.example/v1/responses`。 + #[serde(serialize_with = "serialize_safe_endpoint")] + pub endpoint: Option, +} + +impl fmt::Debug for OpenAiProviderConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OpenAiProviderConfig") + .field("api_key_env", &self.api_key_env) + .field("base_url", &self.base_url.as_deref().map(redacted_endpoint)) + .field("endpoint", &self.endpoint.as_deref().map(redacted_endpoint)) + .finish() + } +} + +impl Default for OpenAiProviderConfig { + fn default() -> Self { + Self { + api_key_env: "OPENAI_API_KEY".to_owned(), + base_url: None, + endpoint: None, + } + } +} + +impl OpenAiProviderConfig { + /// 从环境读取 endpoint 配置,但不读取或持有 API key。 + /// + /// `OPENAI_ENDPOINT`(完整地址)优先于 `OPENAI_BASE_URL`(base 地址)。 + /// `OPENAI_API_KEY_ENV` 可选地改变 key 的环境变量名,默认仍是 + /// `OPENAI_API_KEY`。 + pub fn from_environment() -> Self { + let endpoint = non_empty_env("OPENAI_ENDPOINT"); + let base_url = endpoint + .is_none() + .then(|| non_empty_env("OPENAI_BASE_URL")) + .flatten(); + Self { + api_key_env: non_empty_env("OPENAI_API_KEY_ENV") + .unwrap_or_else(|| "OPENAI_API_KEY".to_owned()), + base_url, + endpoint, + } + } + + /// `from_env` 是 `from_environment` 的简写,方便嵌入方发现该入口。 + pub fn from_env() -> Self { + Self::from_environment() + } + + pub fn with_api_key_env(mut self, name: impl Into) -> Self { + self.api_key_env = name.into(); + self + } + + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = Some(base_url.into()); + // Builder 调用顺序应当表达最后一次选择;直接反序列化同时带有 + // 两个字段时仍由 resolve_endpoint 保持 endpoint 优先的兼容规则。 + self.endpoint = None; + self + } + + pub fn with_endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self.base_url = None; + self + } + + /// 以可变配置对象切换到完整 Responses endpoint。 + pub fn set_endpoint(&mut self, endpoint: impl Into) -> Result<(), OpenAiError> { + let endpoint = validate_endpoint(&endpoint.into())?; + self.endpoint = Some(endpoint); + self.base_url = None; + Ok(()) + } + + /// 以可变配置对象切换到网关 base URL;解析时会自动追加 `/responses`。 + pub fn set_base_url(&mut self, base_url: impl Into) -> Result<(), OpenAiError> { + // 先校验,避免把无效值写进一个随后难以诊断的配置对象。 + let base_url = validate_base_url(&base_url.into())?; + self.base_url = Some(base_url); + self.endpoint = None; + Ok(()) + } + + /// 将配置解析为实际请求地址。 + pub fn resolve_endpoint(&self) -> Result { + if let Some(endpoint) = self + .endpoint + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return validate_endpoint(endpoint); + } + if let Some(base_url) = self + .base_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return endpoint_from_base_url(base_url); + } + Ok(DEFAULT_ENDPOINT.to_owned()) + } +} + +/// 一个无状态、可 Clone 的 Responses Provider。 +#[derive(Clone)] +pub struct OpenAiProvider { + client: Client, + api_key: String, + endpoint: String, +} + +impl std::fmt::Debug for OpenAiProvider { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OpenAiProvider") + .field("endpoint", &redacted_endpoint(&self.endpoint)) + .field("api_key", &"") + .finish() + } +} + +impl OpenAiProvider { + pub fn new(api_key: impl Into) -> Result { + Self::with_endpoint(api_key, DEFAULT_ENDPOINT) + } + + pub fn from_env() -> Result { + Self::from_config(&OpenAiProviderConfig::from_environment()) + } + + /// 从指定环境变量读取 key,同时沿用 `OPENAI_ENDPOINT`/ + /// `OPENAI_BASE_URL` 的 endpoint 配置。 + pub fn from_api_key_env(name: impl AsRef) -> Result { + let config = OpenAiProviderConfig::from_environment().with_api_key_env(name.as_ref()); + Self::from_config(&config) + } + + /// 使用非秘密配置从环境读取 key。 + pub fn from_config(config: &OpenAiProviderConfig) -> Result { + let key = std::env::var(&config.api_key_env).map_err(|_| OpenAiError::MissingApiKey)?; + Self::with_config(key, config) + } + + /// 使用调用方已经取得的 key 和 endpoint 配置构造 Provider。 + /// + /// 该入口不会再次读取环境变量,适合 secret manager 或测试注入。 + pub fn with_config( + api_key: impl Into, + config: &OpenAiProviderConfig, + ) -> Result { + let endpoint = config.resolve_endpoint()?; + Self::build(api_key.into(), endpoint) + } + + pub fn with_endpoint( + api_key: impl Into, + endpoint: impl Into, + ) -> Result { + let endpoint = endpoint.into(); + let endpoint = validate_endpoint(&endpoint)?; + Self::build(api_key.into(), endpoint) + } + + /// 传入网关 base URL,自动补齐 Responses API 的 `/responses` 路径。 + pub fn with_base_url( + api_key: impl Into, + base_url: impl Into, + ) -> Result { + let base_url = base_url.into(); + let endpoint = endpoint_from_base_url(&base_url)?; + Self::build(api_key.into(), endpoint) + } + + /// 在已经创建的 Provider 上切换完整 Responses endpoint。 + /// + /// 先完成同一套 URL 校验,再替换内存中的地址;失败时保留旧地址, + /// 方便长生命周期宿主在配置热切换时保持可用状态。 + pub fn set_endpoint(&mut self, endpoint: impl Into) -> Result<(), OpenAiError> { + let endpoint = validate_endpoint(&endpoint.into())?; + self.endpoint = endpoint; + Ok(()) + } + + /// 在已经创建的 Provider 上切换网关 base URL,并自动补齐 `/responses`。 + /// + /// 与 [`Self::set_endpoint`] 一样,校验失败不会改变当前 endpoint。 + pub fn set_base_url(&mut self, base_url: impl Into) -> Result<(), OpenAiError> { + let endpoint = endpoint_from_base_url(&base_url.into())?; + self.endpoint = endpoint; + Ok(()) + } + + fn build(api_key: String, endpoint: String) -> Result { + if api_key.trim().is_empty() { + return Err(OpenAiError::MissingApiKey); + } + let client = Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|error| OpenAiError::Request(error.to_string()))?; + Ok(Self { + client, + api_key, + endpoint, + }) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// 暴露纯 JSON 构造,便于适配器测试而不接触网络。 + pub fn request_json(request: &ProviderRequest) -> Value { + // Responses API 的 function_call 与 function_call_output 是 input 顶层 item, + // 不能退化成普通 `tool` role 文本,否则下一轮无法关联 call_id。 + let input = responses_input(request.messages()); + let tools = request + .tools() + .iter() + .map(|tool| { + json!({ + "type": "function", + "name": tool.name(), + "description": tool.description(), + "parameters": tool.input_schema() + }) + }) + .collect::>(); + let mut body = json!({"model": request.model(), "input": input}); + if !tools.is_empty() { + body["tools"] = Value::Array(tools); + body["tool_choice"] = tool_choice_json(request.tool_choice()); + } + if let Some(max) = request.max_output_tokens() { + body["max_output_tokens"] = json!(max); + } + body + } + + /// 构造 Responses SSE 请求。普通 `complete` 保持非流式 body,避免改变 + /// 旧兼容端点的行为;只有显式调用 `ModelProvider::stream` 才设置该字段。 + pub fn stream_request_json(request: &ProviderRequest) -> Value { + let mut body = Self::request_json(request); + body["stream"] = Value::Bool(true); + body + } + + fn parse_response( + request: &ProviderRequest, + body: &Value, + ) -> Result { + Self::validate_response_status(body)?; + let output = body + .get("output") + .and_then(Value::as_array) + .ok_or_else(|| OpenAiError::Response("缺少 output 数组".to_owned()))?; + let mut content = Vec::new(); + let mut calls = Vec::new(); + for item in output { + match item.get("type").and_then(Value::as_str) { + Some("message") => { + if let Some(parts) = item.get("content").and_then(Value::as_array) { + for part in parts { + if part.get("type").and_then(Value::as_str) == Some("output_text") + && let Some(text) = part.get("text").and_then(Value::as_str) + { + content.push( + ContentPart::text(text).map_err(|error| { + OpenAiError::Response(error.to_string()) + })?, + ); + } + } + } + } + Some("function_call") => { + let id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .ok_or_else(|| { + OpenAiError::Response("function_call 缺少 call_id".to_owned()) + })?; + let name = item.get("name").and_then(Value::as_str).ok_or_else(|| { + OpenAiError::Response("function_call 缺少 name".to_owned()) + })?; + let arguments = match item.get("arguments") { + Some(Value::String(arguments)) if !arguments.trim().is_empty() => { + arguments.clone() + } + Some(Value::String(_)) => { + return Err(OpenAiError::Response( + "function_call arguments 不能为空".to_owned(), + )); + } + Some(arguments) if !arguments.is_null() => arguments.to_string(), + _ => { + return Err(OpenAiError::Response( + "function_call 缺少 arguments".to_owned(), + )); + } + }; + calls.push( + ToolCall::from_json_text(id, name, &arguments) + .map_err(|error| OpenAiError::Response(error.to_string()))?, + ); + } + _ => {} + } + } + // 某些兼容端点只填顶层 output_text;保留它作为无结构化 message 的回退。 + if content.is_empty() + && let Some(text) = body.get("output_text").and_then(Value::as_str) + && !text.is_empty() + { + content.push( + ContentPart::text(text) + .map_err(|error| OpenAiError::Response(error.to_string()))?, + ); + } + let mut response = + ProviderResponse::try_new(request.request_id(), request.model(), content, calls) + .map_err(|error| OpenAiError::Response(error.to_string()))?; + if let Some(external_id) = body.get("id").and_then(Value::as_str) + && !external_id.trim().is_empty() + { + response = response.with_external_id(external_id); + } + if let Some(usage) = parse_usage(body.get("usage")) { + response = response.with_usage(usage); + } + if let Some(status) = body.get("status").and_then(Value::as_str) { + response = response.with_finish_reason(status); + } + Ok(response) + } + + /// Responses 的 JSON/SSE envelope 可能携带一个看起来完整的 `output`, + /// 但状态其实是 cancelled/failed/incomplete。只有明确 completed(或 + /// 兼容网关省略 status)才允许把它交给 Engine,避免把半截结果当成功。 + fn validate_response_status(body: &Value) -> Result<(), OpenAiError> { + let Some(status) = body.get("status").and_then(Value::as_str) else { + return Ok(()); + }; + if status == "completed" { + return Ok(()); + } + let message = response_terminal_message(body, status); + Err(OpenAiError::Response(message)) + } + + /// 解析一个 Responses SSE `data:` payload,并把增量投递到 Core sink。 + /// + /// OpenAI 会在同一条流里交错文本、函数参数和 usage 事件;这里仅保留 + /// 中立事件所需的字段,完整 response 优先由 `response.completed` 解析。 + fn parse_stream_payload( + payload: &str, + accumulator: &mut StreamAccumulator, + sink: &mut dyn ProviderStreamSink, + ) -> Result, ProviderError> { + if payload.trim() == "[DONE]" { + return Ok(None); + } + let value: Value = serde_json::from_str(payload).map_err(|error| { + ProviderError::new( + ProviderErrorKind::Stream, + format!("OpenAI SSE JSON 无效: {error}"), + ) + })?; + let event_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match event_type { + "response.output_text.delta" => { + if let Some(delta) = value.get("delta").and_then(Value::as_str) { + accumulator.text.push_str(delta); + sink.emit(ProviderStreamEvent::TextDelta { + delta: delta.to_owned(), + accumulated: accumulator.text.clone(), + })?; + } + } + "response.output_item.added" => { + if let Some(item) = value.get("item") + && item.get("type").and_then(Value::as_str) == Some("function_call") + { + // `output_item.added` has both an item id and a function + // call id. Argument delta/done events usually refer to the + // former, while Core must expose the latter. + let item_id = item.get("id").and_then(Value::as_str); + let call_id = item + .get("call_id") + .and_then(Value::as_str) + .or(item_id) + .ok_or_else(|| { + ProviderError::new( + ProviderErrorKind::Stream, + "OpenAI SSE function_call 缺少 id/call_id", + ) + })?; + let index = accumulator.bind_tool(item_id, call_id)?; + let entry = &mut accumulator.tools[index].1; + if let Some(name) = item.get("name").and_then(Value::as_str) { + entry.name = Some(name.to_owned()); + } + } + } + "response.function_call_arguments.delta" => { + let item_id = value.get("item_id").and_then(Value::as_str); + let explicit_call_id = value.get("call_id").and_then(Value::as_str); + if item_id.is_none() && explicit_call_id.is_none() { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + "OpenAI SSE arguments.delta 缺少 item_id/call_id", + )); + } + let call_id = accumulator.resolve_call_id(item_id, explicit_call_id); + let index = accumulator.bind_tool(item_id, &call_id)?; + let entry = &mut accumulator.tools[index].1; + if let Some(name) = value.get("name").and_then(Value::as_str) { + entry.name = Some(name.to_owned()); + } + if let Some(delta) = value.get("delta").and_then(Value::as_str) { + entry.arguments.push_str(delta); + sink.emit(ProviderStreamEvent::ToolCallDelta { + call_id: call_id.clone(), + name: entry.name.clone(), + arguments_delta: delta.to_owned(), + })?; + } + } + "response.function_call_arguments.done" => { + let item_id = value.get("item_id").and_then(Value::as_str); + let explicit_call_id = value.get("call_id").and_then(Value::as_str); + if item_id.is_some() || explicit_call_id.is_some() { + let call_id = accumulator.resolve_call_id(item_id, explicit_call_id); + let index = accumulator.bind_tool(item_id, &call_id)?; + let entry = &mut accumulator.tools[index].1; + if let Some(name) = value.get("name").and_then(Value::as_str) { + entry.name = Some(name.to_owned()); + } + // The done payload is authoritative when present; retain + // deltas only for gateways that omit the final arguments. + if let Some(arguments) = value.get("arguments").and_then(Value::as_str) { + entry.arguments.clear(); + entry.arguments.push_str(arguments); + } + } else { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + "OpenAI SSE arguments.done 缺少 item_id/call_id", + )); + } + } + "response.completed" => { + let response = value.get("response").cloned().unwrap_or(value.clone()); + Self::validate_response_status(&response).map_err(|error| { + ProviderError::new(ProviderErrorKind::Upstream, error.to_string()) + })?; + accumulator.completed = true; + if let Some(id) = response.get("id").and_then(Value::as_str) { + accumulator.external_id = Some(id.to_owned()); + } + if let Some(status) = response.get("status").and_then(Value::as_str) { + accumulator.finish_reason = Some(status.to_owned()); + } + if let Some(usage) = parse_usage(response.get("usage")) { + accumulator.usage = Some(usage); + sink.emit(ProviderStreamEvent::Usage { usage })?; + } + return Ok(Some(response)); + } + "response.failed" | "response.incomplete" | "response.cancelled" | "error" => { + let status = match event_type { + "response.failed" => "failed", + "response.incomplete" => "incomplete", + "response.cancelled" => "cancelled", + _ => "error", + }; + let message = response_terminal_message(&value, status); + return Err(ProviderError::new(ProviderErrorKind::Upstream, message)); + } + // created/in_progress/content_part.done 等生命周期事件不携带可用 + // 增量,忽略它们但继续等待 completed。 + _ => {} + } + Ok(None) + } + + fn response_from_stream_accumulator( + request: &ProviderRequest, + accumulator: StreamAccumulator, + ) -> Result { + let mut calls = Vec::new(); + for (id, call) in accumulator.tools { + let name = call.name.ok_or_else(|| { + ProviderError::new( + ProviderErrorKind::Stream, + format!("OpenAI SSE function_call {id} 缺少 name"), + ) + })?; + if name.trim().is_empty() { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + format!("OpenAI SSE function_call {id} 的 name 为空"), + )); + } + if call.arguments.trim().is_empty() { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + format!("OpenAI SSE function_call {id} 缺少 arguments"), + )); + } + let arguments = call.arguments.as_str(); + calls.push( + ToolCall::from_json_text(&id, &name, arguments).map_err(|error| { + ProviderError::new(ProviderErrorKind::Stream, error.to_string()) + })?, + ); + } + let content = if accumulator.text.is_empty() { + Vec::new() + } else { + vec![ContentPart::text(accumulator.text).map_err(|error| { + ProviderError::new(ProviderErrorKind::Stream, error.to_string()) + })?] + }; + let mut response = + ProviderResponse::try_new(request.request_id(), request.model(), content, calls) + .map_err(|error| { + ProviderError::new(ProviderErrorKind::Stream, error.to_string()) + })?; + if let Some(id) = accumulator.external_id { + response = response.with_external_id(id); + } + if let Some(usage) = accumulator.usage { + response = response.with_usage(usage); + } + if let Some(reason) = accumulator.finish_reason { + response = response.with_finish_reason(reason); + } + Ok(response) + } +} + +#[derive(Default)] +struct StreamAccumulator { + text: String, + // Responses SSE 的多个 function call 必须保持服务端出现顺序;仅用 + // BTreeMap 会按 call id 排序,可能改变 Engine 的串行审批/执行顺序。 + tools: Vec<(String, StreamToolCall)>, + /// `item_id` is used by argument delta/done events; map it to the + /// function-call `call_id` exposed by the neutral Core contract. + item_to_call: BTreeMap, + external_id: Option, + usage: Option, + finish_reason: Option, + completed: bool, +} + +impl StreamAccumulator { + fn resolve_call_id(&self, item_id: Option<&str>, explicit_call_id: Option<&str>) -> String { + explicit_call_id + .or_else(|| { + item_id.and_then(|item_id| self.item_to_call.get(item_id).map(String::as_str)) + }) + .or(item_id) + .unwrap_or("unknown-call") + .to_owned() + } + + /// Return the canonical call entry, merging a provisional item-id entry if + /// a delta arrived before its `output_item.added` event. + fn bind_tool(&mut self, item_id: Option<&str>, call_id: &str) -> Result { + if let Some(item_id) = item_id { + if let Some(previous) = self.item_to_call.get(item_id) + && previous != call_id + && previous != item_id + { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + format!("OpenAI SSE item_id {item_id} 绑定了冲突的 call_id"), + )); + } + if self.item_to_call.iter().any(|(known_item, known_call)| { + known_item != item_id && known_call == call_id && known_call != known_item + }) { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + format!("OpenAI SSE 重复的 function_call call_id {call_id}"), + )); + } + self.item_to_call + .insert(item_id.to_owned(), call_id.to_owned()); + } + let canonical = self.tools.iter().position(|(key, _)| key == call_id); + let provisional = item_id + .filter(|item_id| *item_id != call_id) + .and_then(|item_id| self.tools.iter().position(|(key, _)| key == item_id)); + match (canonical, provisional) { + (Some(canonical), Some(provisional)) if canonical != provisional => { + // Keep the first-seen position to preserve server ordering. + if provisional < canonical { + let source = std::mem::take(&mut self.tools[canonical].1); + self.tools[provisional].0 = call_id.to_owned(); + merge_stream_tool(&mut self.tools[provisional].1, source); + self.tools.remove(canonical); + Ok(provisional) + } else { + let source = std::mem::take(&mut self.tools[provisional].1); + merge_stream_tool(&mut self.tools[canonical].1, source); + self.tools.remove(provisional); + Ok(canonical) + } + } + (Some(canonical), _) => Ok(canonical), + (None, Some(provisional)) => { + self.tools[provisional].0 = call_id.to_owned(); + Ok(provisional) + } + (None, None) => { + self.tools + .push((call_id.to_owned(), StreamToolCall::default())); + Ok(self.tools.len() - 1) + } + } + } +} + +#[derive(Default)] +struct StreamToolCall { + name: Option, + arguments: String, +} + +fn merge_stream_tool(target: &mut StreamToolCall, source: StreamToolCall) { + if target.name.is_none() { + target.name = source.name; + } + if target.arguments.is_empty() { + target.arguments = source.arguments; + } +} + +fn response_role(role: MessageRole) -> &'static str { + match role { + MessageRole::System => "system", + MessageRole::Developer => "developer", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + // Core 里的 Tool 消息通常由 ToolResult 组成;没有 call_id 的裸文本 + // 无法构造 function_call_output,按 user 文本保留而不是发出非法 role。 + MessageRole::Tool => "user", + } +} + +fn responses_input(messages: &[agent_runtime_core::Message]) -> Vec { + let mut items = Vec::new(); + for message in messages { + let role = response_role(message.role()); + let mut content = Vec::new(); + for part in message.content() { + match part { + ContentPart::Text { text } => { + content.push(json!({"type": "input_text", "text": text})); + } + ContentPart::Image { source } => content.push(image_input(source)), + ContentPart::ToolCall { + id, + name, + arguments, + } => { + push_message(&mut items, role, &mut content); + items.push(json!({ + "type": "function_call", + "call_id": id, + "name": name, + "arguments": arguments.to_string() + })); + } + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => { + push_message(&mut items, role, &mut content); + let output = if *is_error { + format!("tool_error: {}", output_text(output)) + } else { + output_text(output) + }; + items.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": output + })); + } + } + } + push_message(&mut items, role, &mut content); + } + items +} + +fn push_message(items: &mut Vec, role: &str, content: &mut Vec) { + if !content.is_empty() { + items.push(json!({ + "type": "message", + "role": role, + "content": std::mem::take(content) + })); + } +} + +fn image_input(source: &Value) -> Value { + match source { + Value::String(url) => json!({"type": "input_image", "image_url": url}), + Value::Object(object) => { + // 允许调用方直接提供 Responses 的 image_url/file_id/detail 字段。 + let mut item = object.clone(); + item.insert("type".to_owned(), Value::String("input_image".to_owned())); + Value::Object(item) + } + // source 是中立 JSON;未知形状原样放入 image_url,交给端点返回明确错误。 + other => json!({"type": "input_image", "image_url": other}), + } +} + +fn output_text(output: &Value) -> String { + match output { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn tool_choice_json(choice: &ToolChoice) -> Value { + match choice { + ToolChoice::Auto => json!("auto"), + ToolChoice::None => json!("none"), + ToolChoice::Required => json!("required"), + ToolChoice::Specific(name) => json!({"type": "function", "name": name}), + } +} + +fn parse_usage(value: Option<&Value>) -> Option { + let value = value?.as_object()?; + let input = value.get("input_tokens")?.as_u64()?; + let output = value.get("output_tokens")?.as_u64()?; + let total = value + .get("total_tokens") + .and_then(Value::as_u64) + .unwrap_or(input.saturating_add(output)); + Some(ProviderUsage::new(input, output, total)) +} + +fn response_terminal_message(value: &Value, status: &str) -> String { + // 失败/不完整事件的诊断通常嵌在 `response` envelope 内;兼容网关也 + // 可能把它放在事件顶层。只取短字符串字段,真正输出前还会经过 key + // 脱敏,避免把整份远端 payload 复制到错误文本。 + let response = value.get("response").unwrap_or(value); + let detail = response + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .or_else(|| { + value + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + }) + .or_else(|| { + response + .get("incomplete_details") + .and_then(|details| details.get("reason")) + .and_then(Value::as_str) + }) + .or_else(|| value.get("message").and_then(Value::as_str)) + .unwrap_or("未提供原因"); + format!("OpenAI response {status}: {detail}") +} + +fn non_empty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +/// 校验完整请求 endpoint,但保留调用方传入的字符串,避免无意义地改变 +/// 兼容网关的路径或 query。query 可以用于少数网关的路由参数,fragment +/// 则不会随 HTTP 请求发送,因此直接拒绝。 +fn validate_endpoint(value: &str) -> Result { + let endpoint = value.trim(); + if endpoint.is_empty() { + return Err(OpenAiError::InvalidEndpoint("地址不能为空".to_owned())); + } + if endpoint.chars().any(char::is_control) { + return Err(OpenAiError::InvalidEndpoint( + "地址不能包含控制字符".to_owned(), + )); + } + let url = reqwest::Url::parse(endpoint) + .map_err(|_| OpenAiError::InvalidEndpoint("地址不是有效 URL".to_owned()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(OpenAiError::InvalidEndpoint( + "地址必须使用 http 或 https".to_owned(), + )); + } + if url.host_str().is_none() { + return Err(OpenAiError::InvalidEndpoint("地址缺少主机名".to_owned())); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(OpenAiError::InvalidEndpoint( + "地址不能包含用户名或密码".to_owned(), + )); + } + if url.fragment().is_some() { + return Err(OpenAiError::InvalidEndpoint( + "地址不能包含 fragment".to_owned(), + )); + } + if query_contains_secret_key(&url) { + return Err(OpenAiError::InvalidEndpoint( + "地址 query 不能包含凭据字段".to_owned(), + )); + } + Ok(endpoint.to_owned()) +} + +/// 将 base URL 转成 Responses API 完整 endpoint。 +pub fn endpoint_from_base_url(value: &str) -> Result { + let base = validate_base_url(value)?; + let mut url = reqwest::Url::parse(&base) + .map_err(|_| OpenAiError::InvalidEndpoint("地址不是有效 URL".to_owned()))?; + let path = url.path().trim_end_matches('/'); + let path = if path.is_empty() { + "/responses".to_owned() + } else if path.ends_with("/responses") { + path.to_owned() + } else { + format!("{path}/responses") + }; + url.set_path(&path); + Ok(url.to_string()) +} + +fn validate_base_url(value: &str) -> Result { + let base = validate_endpoint(value)?; + let url = reqwest::Url::parse(&base) + .map_err(|_| OpenAiError::InvalidEndpoint("地址不是有效 URL".to_owned()))?; + if url.query().is_some() { + return Err(OpenAiError::InvalidEndpoint( + "base URL 不能包含 query".to_owned(), + )); + } + Ok(base) +} + +fn redacted_endpoint(endpoint: &str) -> String { + // 先解析并拒绝所有无法安全重建的形状。配置字段是 public 的,调用方 + // 可能绕过 builder 直接写入无效 URL;Debug 绝不能把其中的 userinfo、 + // fragment 或 malformed secret 原文带出去。 + let Ok(url) = reqwest::Url::parse(endpoint) else { + return "".to_owned(); + }; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || query_contains_secret_key(&url) + { + return "".to_owned(); + } + // Endpoint 通常不带 query;若兼容网关把普通路由参数放进 query,Debug + // 也不能泄漏具体值。 + endpoint.split_once('?').map_or_else( + || endpoint.to_owned(), + |(base, _)| format!("{base}?"), + ) +} + +fn query_contains_secret_key(url: &reqwest::Url) -> bool { + url.query_pairs().any(|(key, _)| { + // Normalize common separator variants so `x-api-key` and `api-key` + // cannot bypass the same redaction/rejection rule as `api_key`. + let key = key.to_ascii_lowercase().replace(['-', ' ', '.'], "_"); + matches!( + key.as_str(), + "api_key" + | "apikey" + | "access_key" + | "access_token" + | "authorization" + | "credential" + | "password" + | "secret" + | "signature" + | "sig" + | "token" + | "key" + | "auth" + | "bearer" + ) || key.ends_with("_token") + || key.ends_with("_secret") + || key.ends_with("_key") + }) +} + +fn serialize_safe_endpoint(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + if let Some(endpoint) = value { + validate_endpoint(endpoint) + .map_err(|error| serde::ser::Error::custom(error.to_string()))?; + } + value.serialize(serializer) +} + +fn serialize_safe_base_url(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + if let Some(base_url) = value { + validate_base_url(base_url) + .map_err(|error| serde::ser::Error::custom(error.to_string()))?; + } + value.serialize(serializer) +} + +impl ModelProvider for OpenAiProvider { + fn protocol_id(&self) -> Option { + // OpenAIProvider 固定发送 Responses API;让 Core 注册表能够用 + // 适配器自描述身份校验调用方 descriptor,避免错配协议。 + Some(ProviderProtocolId::from(OPENAI_RESPONSES_PROTOCOL_ID)) + } + + fn complete(&self, request: &ProviderRequest) -> Result { + let response = self + .client + .post(&self.endpoint) + .bearer_auth(&self.api_key) + // 将本地请求 identity 作为幂等键发给兼容 Responses 的端点; + // 这样进程中断后,调用方至少能用同一 key 查询/核对结果。 + .header("Idempotency-Key", request.request_id()) + .json(&Self::request_json(request)) + .send() + .map_err(|error| request_error(error, &self.api_key, &self.endpoint))?; + let status = response.status(); + if !status.is_success() { + let body = read_limited_response_body(response).map_err(|error| { + // 状态行已经到达,即使错误正文在读取中途断开,也要保留 + // 同一 HTTP 分类和 status_code;否则 503 会被误降成 Stream, + // Engine 无法按既有 transient retry 契约处理。 + let kind = classify_http_status(status.as_u16()); + sanitize_provider_error( + ProviderError::new(kind, error.to_string()).with_status_code(status.as_u16()), + &self.api_key, + ) + })?; + let message = serde_json::from_str::(&body) + .ok() + .and_then(|body| { + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "OpenAI 请求失败".to_owned()); + // 兼容网关偶尔会把请求头或诊断上下文回显到 error.message; + // 在交给 Host/CLI 前统一移除 key,保持 complete 与 stream 的 + // 脱敏语义一致。 + let message = sanitize_secret(&message, &self.api_key); + // 这些状态表示端点已经明确返回了可暂时恢复的 HTTP 结果,Engine + // 才会按 `Unavailable` 进入既有的有限重试预算。即使收到状态响应, + // 上游也可能已经受理请求,因此安全重试依赖兼容端点遵守本适配器 + // 发送的 `Idempotency-Key`;不支持该语义的调用方应将重试预算设为 0。 + let kind = classify_http_status(status.as_u16()); + return Err(ProviderError::new(kind, message).with_status_code(status.as_u16())); + } + let body: Value = response.json().map_err(|error| { + sanitize_provider_error( + ProviderError::new(ProviderErrorKind::Stream, error.to_string()), + &self.api_key, + ) + })?; + Self::parse_response(request, &body).map_err(|error| { + sanitize_provider_error( + ProviderError::new(ProviderErrorKind::Stream, error.to_string()), + &self.api_key, + ) + }) + } + + fn stream( + &self, + request: &ProviderRequest, + sink: &mut dyn ProviderStreamSink, + ) -> Result { + let response = self + .client + .post(&self.endpoint) + .bearer_auth(&self.api_key) + .header("Accept", "text/event-stream") + .header("Idempotency-Key", request.request_id()) + .json(&Self::stream_request_json(request)) + .send() + .map_err(|error| request_error(error, &self.api_key, &self.endpoint))?; + let status = response.status(); + if !status.is_success() { + // 错误响应只读取有限文本,并且先移除 key,避免网关把认证信息 + // 回显到 CLI/事件日志。 + let body = read_limited_response_body(response) + .unwrap_or_else(|_| "OpenAI 请求失败".to_owned()); + let message = serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "OpenAI 请求失败".to_owned()); + // 与非流式路径保持相同的状态分类;此时尚未收到任何 SSE 增量, + // 所以 Engine 的 streaming retry gate 仍可根据 `Unavailable` 决定。 + let kind = classify_http_status(status.as_u16()); + return Err( + ProviderError::new(kind, sanitize_secret(&message, &self.api_key)) + .with_status_code(status.as_u16()), + ); + } + + let mut accumulator = StreamAccumulator::default(); + let mut completed_response = None; + let mut reader = BufReader::new(response); + while let Some(line) = read_bounded_sse_line(&mut reader).map_err(|error| { + sanitize_provider_error( + ProviderError::new(ProviderErrorKind::Stream, error.to_string()), + &self.api_key, + ) + })? { + let Some(payload) = line.strip_prefix("data:") else { + continue; + }; + let payload = payload.trim_start(); + if payload == "[DONE]" { + break; + } + if let Some(response) = Self::parse_stream_payload(payload, &mut accumulator, sink) + .map_err(|error| sanitize_provider_error(error, &self.api_key))? + { + completed_response = Some(response); + } + } + + // 只有收到明确的 response.completed 并成功构造最终响应后,才向 + // Engine 发出 Completed。连接在中途断开、仅收到 [DONE] 或解析失败 + // 都必须返回错误,避免把半截结果持久化成成功运行。 + if let Some(response) = completed_response { + match Self::parse_response(request, &response) { + Ok(response) => { + sink.emit(ProviderStreamEvent::Completed) + .map_err(|error| sanitize_provider_error(error, &self.api_key))?; + return Ok(response); + } + Err(_error) if !accumulator.text.is_empty() || !accumulator.tools.is_empty() => { + // 少数兼容网关只发送 completed 的 id/status,真正的 + // output 仍通过增量事件给出;不要因为 envelope 不完整 + // 丢掉已经收到的可验证内容。 + } + Err(error) => { + return Err(sanitize_provider_error( + ProviderError::new(ProviderErrorKind::Stream, error.to_string()), + &self.api_key, + )); + } + } + } + if !accumulator.completed { + return Err(ProviderError::new( + ProviderErrorKind::Stream, + "OpenAI SSE 在连接结束前未收到 response.completed", + )); + } + let response = Self::response_from_stream_accumulator(request, accumulator)?; + sink.emit(ProviderStreamEvent::Completed) + .map_err(|error| sanitize_provider_error(error, &self.api_key))?; + Ok(response) + } +} + +fn request_error(error: reqwest::Error, secret: &str, endpoint: &str) -> ProviderError { + let kind = if error.is_timeout() { + ProviderErrorKind::Timeout + } else if error.is_connect() { + ProviderErrorKind::Unavailable + } else { + ProviderErrorKind::Stream + }; + let message = error + .to_string() + .replace(endpoint, &redacted_endpoint(endpoint)); + ProviderError::new(kind, sanitize_secret(&message, secret)) +} + +/// Map an HTTP response that has already reached the adapter to the Engine's +/// retry vocabulary. Keep this allowlist narrow: ordinary 4xx and 500 remain +/// `Upstream`, while only statuses conventionally used for a temporary gateway +/// or capacity boundary become `Unavailable`. +fn classify_http_status(status: u16) -> ProviderErrorKind { + match status { + 408 | 429 | 502 | 503 | 504 => ProviderErrorKind::Unavailable, + _ => ProviderErrorKind::Upstream, + } +} + +fn sanitize_provider_error(error: ProviderError, secret: &str) -> ProviderError { + let kind = error.kind(); + let message = sanitize_secret(error.message(), secret); + match error.status_code() { + Some(status) => ProviderError::new(kind, message).with_status_code(status), + None => ProviderError::new(kind, message), + } +} + +fn sanitize_secret(message: &str, secret: &str) -> String { + if secret.is_empty() { + message.to_owned() + } else { + message.replace(secret, "") + } +} + +fn read_limited_response_body(response: reqwest::blocking::Response) -> io::Result { + let mut bytes = Vec::new(); + response + .take((MAX_ERROR_BODY_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_ERROR_BODY_BYTES { + bytes.truncate(MAX_ERROR_BODY_BYTES); + bytes.extend_from_slice(b"..."); + } + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Read one SSE line without allowing a peer to allocate an unbounded string. +/// `BufReader::fill_buf` keeps the source buffer bounded while this function +/// enforces the line limit before appending more bytes. +fn read_bounded_sse_line(reader: &mut R) -> io::Result> { + let mut bytes = Vec::new(); + loop { + let buffer = reader.fill_buf()?; + if buffer.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + break; + } + let take = buffer + .iter() + .position(|byte| *byte == b'\n') + .map_or(buffer.len(), |index| index + 1); + if bytes.len().saturating_add(take) > MAX_SSE_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "OpenAI SSE 行超过大小限制", + )); + } + let has_newline = buffer[..take].contains(&b'\n'); + bytes.extend_from_slice(&buffer[..take]); + reader.consume(take); + if has_newline { + break; + } + } + if bytes.last() == Some(&b'\n') { + bytes.pop(); + } + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + String::from_utf8(bytes) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::io::Write as _; + use std::net::{Shutdown, TcpListener, TcpStream}; + use std::sync::{Mutex, OnceLock}; + use std::thread::JoinHandle; + + use super::*; + use agent_runtime_core::{Message, PromptBuilder, ToolCall, ToolDefinition}; + + struct CollectingSink(Vec); + + impl ProviderStreamSink for CollectingSink { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError> { + self.0.push(event); + Ok(()) + } + } + + struct FakeHttpResponse { + status: u16, + content_type: &'static str, + body: String, + } + + struct CapturedHttpRequest { + method: String, + target: String, + headers: BTreeMap, + body: Value, + } + + /// 启动只接受一个请求的本地 HTTP 端点,避免 Provider 回归测试触网。 + /// 服务端在写响应前完整读取请求头和 Content-Length 指定的 JSON body, + /// 因此测试可以同时断言实际 endpoint、认证头、幂等键和序列化结果。 + fn spawn_fake_http_server( + response: FakeHttpResponse, + ) -> (String, JoinHandle) { + spawn_fake_http_server_with_declared_length(response, None) + } + + /// Variant used by error-path tests to close the connection before the + /// advertised body length, exercising response-body read failures without + /// contacting a real endpoint. + fn spawn_fake_http_server_with_declared_length( + response: FakeHttpResponse, + declared_length: Option, + ) -> (String, JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local fake server"); + let address = listener.local_addr().expect("read fake server address"); + let endpoint = format!("http://{address}/v1/responses"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept provider request"); + let request = read_fake_http_request(&mut stream); + let reason = match response.status { + 200 => "OK", + 201 => "Created", + 400 => "Bad Request", + 401 => "Unauthorized", + 500 => "Internal Server Error", + _ => "Test Response", + }; + let body = response.body.into_bytes(); + let content_length = declared_length.unwrap_or(body.len()); + let headers = format!( + "HTTP/1.1 {} {reason}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response.status, response.content_type, content_length + ); + stream + .write_all(headers.as_bytes()) + .expect("write fake response headers"); + stream.write_all(&body).expect("write fake response body"); + stream.flush().expect("flush fake response"); + let _ = stream.shutdown(Shutdown::Both); + request + }); + (endpoint, handle) + } + + fn read_fake_http_request(stream: &mut TcpStream) -> CapturedHttpRequest { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set fake server read timeout"); + let mut bytes = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).expect("read provider request"); + assert!(read > 0, "provider closed before sending HTTP headers"); + bytes.extend_from_slice(&chunk[..read]); + if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + assert!( + bytes.len() <= 64 * 1024, + "fake request headers are too large" + ); + }; + + let header_text = String::from_utf8_lossy(&bytes[..header_end - 4]); + let mut lines = header_text.split("\r\n"); + let request_line = lines.next().expect("request line"); + let mut request_parts = request_line.split_whitespace(); + let method = request_parts.next().expect("request method").to_owned(); + let target = request_parts.next().expect("request target").to_owned(); + let mut headers = BTreeMap::new(); + for line in lines { + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.to_ascii_lowercase(), value.trim().to_owned()); + } + } + let content_length = headers + .get("content-length") + .expect("JSON request must include Content-Length") + .parse::() + .expect("valid Content-Length"); + assert!( + content_length <= 4 * 1024 * 1024, + "fake request body is too large" + ); + while bytes.len() < header_end + content_length { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).expect("read provider request body"); + assert!(read > 0, "provider closed before sending request body"); + bytes.extend_from_slice(&chunk[..read]); + } + let body = serde_json::from_slice(&bytes[header_end..header_end + content_length]) + .expect("provider request body must be JSON"); + CapturedHttpRequest { + method, + target, + headers, + body, + } + } + + /// 环境变量是进程级共享状态;涉及 `from_env` 的测试必须串行并在结束 + /// 后恢复原值,否则并行测试或调用方环境会被污染。 + fn environment_test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + struct EnvironmentSnapshot { + values: Vec<(&'static str, Option)>, + } + + impl EnvironmentSnapshot { + fn capture(names: &[&'static str]) -> Self { + Self { + values: names + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(), + } + } + } + + impl Drop for EnvironmentSnapshot { + fn drop(&mut self) { + for (name, value) in &self.values { + // Rust 2024 将进程环境修改标为 unsafe;测试锁保证本测试 + // 自身不会并发修改,快照则保证无论断言如何退出都能恢复。 + unsafe { + if let Some(value) = value { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + } + } + } + + #[test] + fn request_json_maps_messages_and_tools() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap() + .with_tools( + vec![ToolDefinition::try_new("echo", "echo", json!({"type": "object"})).unwrap()], + Default::default(), + ) + .unwrap(); + let body = OpenAiProvider::request_json(&request); + assert_eq!(body["model"], "gpt-test"); + assert_eq!(body["tools"][0]["name"], "echo"); + assert_eq!(body["tool_choice"], "auto"); + } + + #[test] + fn request_without_tools_omits_tool_fields() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + let body = OpenAiProvider::request_json(&request); + assert!(body.get("tools").is_none()); + assert!(body.get("tool_choice").is_none()); + } + + #[test] + fn complete_posts_to_local_endpoint_and_maps_response() { + let (endpoint, server) = spawn_fake_http_server(FakeHttpResponse { + status: 200, + content_type: "application/json", + body: serde_json::to_string(&json!({ + "id": "resp-complete", + "status": "completed", + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "来自本地端点"}] + }], + "usage": {"input_tokens": 4, "output_tokens": 3, "total_tokens": 7} + })) + .unwrap(), + }); + let provider = OpenAiProvider::with_endpoint("test-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-complete", + "gpt-test", + vec![Message::user("complete input").unwrap()], + ) + .unwrap(); + + let response = provider + .complete(&request) + .expect("complete should succeed"); + let captured = server.join().expect("fake server should finish"); + assert_eq!(captured.method, "POST"); + assert_eq!(captured.target, "/v1/responses"); + assert_eq!( + captured.headers.get("authorization").map(String::as_str), + Some("Bearer test-secret") + ); + assert_eq!( + captured.headers.get("idempotency-key").map(String::as_str), + Some("request-complete") + ); + assert_eq!(captured.body["model"], "gpt-test"); + assert_eq!( + captured.body["input"][0]["content"][0]["text"], + "complete input" + ); + assert!(captured.body.get("stream").is_none()); + + assert_eq!(response.content()[0].as_text(), Some("来自本地端点")); + assert_eq!(response.external_id(), Some("resp-complete")); + assert_eq!(response.finish_reason(), Some("completed")); + assert_eq!(response.usage().unwrap().total_tokens(), 7); + } + + #[test] + fn stream_posts_sse_request_to_local_endpoint_and_maps_deltas() { + let body = concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"流\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"式响应\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-stream\",\"status\":\"completed\",\"usage\":{\"input_tokens\":2,\"output_tokens\":4,\"total_tokens\":6}}}\n\n", + "data: [DONE]\n\n" + ) + .to_owned(); + let (endpoint, server) = spawn_fake_http_server(FakeHttpResponse { + status: 200, + content_type: "text/event-stream", + body, + }); + let provider = OpenAiProvider::with_endpoint("stream-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-stream", + "gpt-stream", + vec![Message::user("stream input").unwrap()], + ) + .unwrap(); + let mut sink = CollectingSink(Vec::new()); + + let response = provider + .stream(&request, &mut sink) + .expect("stream should succeed"); + let captured = server.join().expect("fake server should finish"); + assert_eq!(captured.method, "POST"); + assert_eq!(captured.target, "/v1/responses"); + assert_eq!( + captured.headers.get("authorization").map(String::as_str), + Some("Bearer stream-secret") + ); + assert_eq!( + captured.headers.get("idempotency-key").map(String::as_str), + Some("request-stream") + ); + assert_eq!( + captured.headers.get("accept").map(String::as_str), + Some("text/event-stream") + ); + assert_eq!(captured.body["model"], "gpt-stream"); + assert_eq!(captured.body["stream"], true); + assert_eq!( + captured.body["input"][0]["content"][0]["text"], + "stream input" + ); + + assert_eq!(response.content()[0].as_text(), Some("流式响应")); + assert_eq!(response.external_id(), Some("resp-stream")); + assert_eq!(response.finish_reason(), Some("completed")); + assert_eq!(response.usage().unwrap().total_tokens(), 6); + assert!(sink.0.iter().any(|event| matches!( + event, + ProviderStreamEvent::TextDelta { delta, .. } if delta == "流" + ))); + assert!( + sink.0 + .iter() + .any(|event| matches!(event, ProviderStreamEvent::Completed)) + ); + } + + #[test] + fn stream_request_sets_stream_flag_without_mutating_regular_request() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + assert!( + OpenAiProvider::request_json(&request) + .get("stream") + .is_none() + ); + assert_eq!( + OpenAiProvider::stream_request_json(&request)["stream"], + true + ); + } + + #[test] + fn request_json_keeps_function_call_pair_as_responses_items() { + let call = ToolCall::try_new("call-1", "echo", json!({"text": "hi"})).unwrap(); + let result = + agent_runtime_core::ToolResult::success("call-1", json!({"received": "hi"})).unwrap(); + let messages = vec![ + Message::user("hello").unwrap(), + Message::new( + MessageRole::Assistant, + vec![ + ContentPart::tool_call(call.id(), call.name(), call.arguments().clone()) + .unwrap(), + ], + ), + Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result(result.call_id(), result.output().clone(), false) + .unwrap(), + ], + ), + ]; + let request = ProviderRequest::try_new("request-1", "gpt-test", messages).unwrap(); + let input = OpenAiProvider::request_json(&request)["input"] + .as_array() + .unwrap() + .clone(); + assert_eq!(input[0]["type"], "message"); + assert_eq!(input[1]["type"], "function_call"); + assert_eq!(input[1]["call_id"], "call-1"); + assert_eq!(input[2]["type"], "function_call_output"); + assert_eq!(input[2]["call_id"], "call-1"); + } + + #[test] + fn request_json_maps_prompt_sections_deterministically() { + let messages = PromptBuilder::new() + .system("system rule") + .unwrap() + .developer("developer rule") + .unwrap() + .context("retrieved fact") + .unwrap() + .user("question") + .unwrap() + .build() + .unwrap(); + let request = ProviderRequest::try_new("request-prompt", "gpt-test", messages).unwrap(); + let body = OpenAiProvider::request_json(&request); + let input = body["input"].as_array().unwrap(); + assert_eq!(input.len(), 4); + assert_eq!(input[0]["type"], "message"); + assert_eq!(input[0]["role"], "system"); + assert_eq!(input[1]["role"], "developer"); + // Context 保持在 user 内容通道,但不能改变 section 的相对顺序。 + assert_eq!(input[2]["role"], "user"); + assert_eq!(input[2]["content"][0]["text"], "retrieved fact"); + assert_eq!(input[3]["role"], "user"); + assert_eq!(input[3]["content"][0]["text"], "question"); + } + + #[test] + fn response_parser_reads_calls_and_usage() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + let body = json!({ + "id": "resp-1", + "status": "completed", + "output": [{ + "type": "function_call", + "call_id": "call-1", + "name": "echo", + "arguments": "{\"text\":\"hi\"}" + }], + "usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5} + }); + let response = OpenAiProvider::parse_response(&request, &body).unwrap(); + assert_eq!(response.tool_calls()[0].id(), "call-1"); + assert_eq!(response.request_id(), "request-1"); + assert_eq!(response.external_id(), Some("resp-1")); + assert_eq!(response.finish_reason(), Some("completed")); + assert_eq!(response.usage().unwrap().total_tokens(), 5); + } + + #[test] + fn sse_payloads_emit_text_tool_usage_and_build_response() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + let mut sink = CollectingSink(Vec::new()); + let mut state = StreamAccumulator::default(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.output_text.delta","delta":"你"}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.output_text.delta","delta":"好"}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.output_item.added","item":{"type":"function_call","call_id":"call-1","name":"echo"}}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.function_call_arguments.delta","item_id":"call-1","delta":"{\"x\":"}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.function_call_arguments.delta","item_id":"call-1","delta":"1}"}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}}"#, + &mut state, + &mut sink, + ) + .unwrap(); + let response = OpenAiProvider::response_from_stream_accumulator(&request, state).unwrap(); + assert_eq!(response.content()[0].as_text(), Some("你好")); + assert_eq!(response.tool_calls()[0].name(), "echo"); + assert_eq!(response.tool_calls()[0].arguments()["x"], 1); + assert_eq!(response.external_id(), Some("resp-1")); + assert_eq!(response.usage().unwrap().total_tokens(), 5); + assert!( + sink.0 + .iter() + .any(|event| matches!(event, ProviderStreamEvent::TextDelta { .. })) + ); + assert!( + sink.0 + .iter() + .any(|event| matches!(event, ProviderStreamEvent::ToolCallDelta { .. })) + ); + assert!( + sink.0 + .iter() + .any(|event| matches!(event, ProviderStreamEvent::Usage { .. })) + ); + } + + #[test] + fn default_and_base_endpoints_are_resolved() { + let provider = OpenAiProvider::new("secret-key").unwrap(); + assert_eq!(provider.endpoint(), DEFAULT_ENDPOINT); + assert_eq!( + provider.protocol_id(), + Some(ProviderProtocolId::from(OPENAI_RESPONSES_PROTOCOL_ID)) + ); + assert_eq!( + endpoint_from_base_url("https://gateway.example/v1").unwrap(), + "https://gateway.example/v1/responses" + ); + assert_eq!( + endpoint_from_base_url("https://gateway.example/v1/").unwrap(), + "https://gateway.example/v1/responses" + ); + assert_eq!( + endpoint_from_base_url("https://gateway.example/v1/responses").unwrap(), + "https://gateway.example/v1/responses" + ); + assert_eq!( + OpenAiProvider::with_base_url("secret-key", "https://gateway.example/v1") + .unwrap() + .endpoint(), + "https://gateway.example/v1/responses" + ); + } + + #[test] + fn environment_config_prefers_endpoint_and_supports_custom_key_name() { + let _lock = environment_test_lock(); + let _snapshot = EnvironmentSnapshot::capture(&[ + "OPENAI_ENDPOINT", + "OPENAI_BASE_URL", + "OPENAI_API_KEY_ENV", + "AGENT_TEST_OPENAI_KEY", + ]); + // `from_environment` only reads configuration; `from_env` additionally + // resolves the configured key variable. Keep both paths in one contract + // test so endpoint precedence and secret indirection cannot drift apart. + unsafe { + std::env::set_var( + "OPENAI_ENDPOINT", + "https://env.example/v1/responses?tenant=test", + ); + std::env::set_var("OPENAI_BASE_URL", "https://base.example/v1"); + std::env::set_var("OPENAI_API_KEY_ENV", "AGENT_TEST_OPENAI_KEY"); + std::env::set_var("AGENT_TEST_OPENAI_KEY", "env-secret"); + } + + let config = OpenAiProviderConfig::from_environment(); + assert_eq!(config.api_key_env, "AGENT_TEST_OPENAI_KEY"); + assert_eq!( + config.endpoint.as_deref(), + Some("https://env.example/v1/responses?tenant=test") + ); + assert_eq!(config.base_url, None); + + let provider = OpenAiProvider::from_env().expect("custom key environment is present"); + assert_eq!( + provider.endpoint(), + "https://env.example/v1/responses?tenant=test" + ); + assert!(!format!("{provider:?}").contains("env-secret")); + + // Removing the full endpoint exposes the base URL fallback without + // changing the custom key selection. + unsafe { + std::env::remove_var("OPENAI_ENDPOINT"); + } + let fallback = OpenAiProviderConfig::from_environment(); + assert_eq!(fallback.endpoint, None); + assert_eq!( + fallback.resolve_endpoint().unwrap(), + "https://base.example/v1/responses" + ); + } + + #[test] + fn config_prefers_full_endpoint_and_never_contains_key() { + let config = OpenAiProviderConfig::default() + .with_api_key_env("TEAM_OPENAI_KEY") + .with_base_url("https://gateway.example/v1") + .with_endpoint("https://override.example/responses"); + assert_eq!( + config.resolve_endpoint().unwrap(), + "https://override.example/responses" + ); + let encoded = serde_json::to_string(&config).unwrap(); + assert!(encoded.contains("TEAM_OPENAI_KEY")); + assert!(!encoded.contains("secret-key")); + let provider = OpenAiProvider::with_config("secret-key", &config).unwrap(); + assert_eq!(provider.endpoint(), "https://override.example/responses"); + } + + #[test] + fn mutable_config_endpoint_setters_are_validated_and_last_choice_wins() { + let mut config = OpenAiProviderConfig::default(); + config + .set_base_url("https://gateway.example/v1") + .expect("valid base URL"); + assert_eq!( + config.resolve_endpoint().unwrap(), + "https://gateway.example/v1/responses" + ); + config + .set_endpoint("https://override.example/responses") + .expect("valid endpoint"); + assert_eq!( + config.resolve_endpoint().unwrap(), + "https://override.example/responses" + ); + assert!( + config + .set_endpoint("ftp://gateway.example/responses") + .is_err() + ); + assert!( + config + .set_base_url("https://gateway.example/v1?tenant=one") + .is_err() + ); + } + + #[test] + fn provider_endpoint_setters_validate_before_replacing_current_address() { + let mut provider = OpenAiProvider::new("secret-key").unwrap(); + provider + .set_base_url("https://gateway.example/v1") + .expect("valid base URL"); + assert_eq!(provider.endpoint(), "https://gateway.example/v1/responses"); + + provider + .set_endpoint("https://override.example/responses") + .expect("valid endpoint"); + assert_eq!(provider.endpoint(), "https://override.example/responses"); + + assert!(provider.set_endpoint("ftp://invalid.example").is_err()); + assert_eq!(provider.endpoint(), "https://override.example/responses"); + assert!( + provider + .set_base_url("https://invalid.example/v1?token=secret") + .is_err() + ); + assert_eq!(provider.endpoint(), "https://override.example/responses"); + } + + #[test] + fn stream_accumulator_keeps_function_call_arrival_order() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + let mut sink = CollectingSink(Vec::new()); + let mut state = StreamAccumulator::default(); + for payload in [ + r#"{"type":"response.output_item.added","item":{"type":"function_call","call_id":"call-z","name":"last"}}"#, + r#"{"type":"response.output_item.added","item":{"type":"function_call","call_id":"call-a","name":"first"}}"#, + r#"{"type":"response.function_call_arguments.delta","call_id":"call-z","delta":"{}"}"#, + r#"{"type":"response.function_call_arguments.delta","call_id":"call-a","delta":"{}"}"#, + ] { + OpenAiProvider::parse_stream_payload(payload, &mut state, &mut sink).unwrap(); + } + let response = OpenAiProvider::response_from_stream_accumulator(&request, state).unwrap(); + assert_eq!( + response + .tool_calls() + .iter() + .map(ToolCall::id) + .collect::>(), + ["call-z", "call-a"] + ); + } + + #[test] + fn sse_maps_item_id_to_distinct_call_id_and_done_payload() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + let mut sink = CollectingSink(Vec::new()); + let mut state = StreamAccumulator::default(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.output_item.added","item":{"id":"fc-item-1","type":"function_call","call_id":"call-1","name":"echo"}}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.function_call_arguments.delta","item_id":"fc-item-1","delta":"{\"x\":"}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.function_call_arguments.done","item_id":"fc-item-1","name":"echo","arguments":"{\"x\":1}"}"#, + &mut state, + &mut sink, + ) + .unwrap(); + OpenAiProvider::parse_stream_payload( + r#"{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}"#, + &mut state, + &mut sink, + ) + .unwrap(); + + let response = OpenAiProvider::response_from_stream_accumulator(&request, state).unwrap(); + assert_eq!(response.tool_calls().len(), 1); + assert_eq!(response.tool_calls()[0].id(), "call-1"); + assert_eq!(response.tool_calls()[0].name(), "echo"); + assert_eq!(response.tool_calls()[0].arguments()["x"], 1); + assert!(sink.0.iter().any(|event| matches!( + event, + ProviderStreamEvent::ToolCallDelta { call_id, .. } if call_id == "call-1" + ))); + } + + #[test] + fn stream_terminal_failures_are_rejected_and_include_nested_reason() { + let mut sink = CollectingSink(Vec::new()); + for (payload, expected) in [ + ( + r#"{"type":"response.failed","response":{"error":{"message":"upstream failed"}}}"#, + "upstream failed", + ), + ( + r#"{"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#, + "max_output_tokens", + ), + ( + r#"{"type":"response.cancelled","response":{"status":"cancelled"}}"#, + "cancelled", + ), + ] { + let mut state = StreamAccumulator::default(); + let error = OpenAiProvider::parse_stream_payload(payload, &mut state, &mut sink) + .expect_err("terminal failure must fail the stream"); + assert_eq!(error.kind(), ProviderErrorKind::Upstream); + assert!(error.message().contains(expected)); + } + } + + #[test] + fn response_parser_rejects_non_completed_status_and_missing_arguments() { + let request = ProviderRequest::try_new( + "request-1", + "gpt-test", + vec![Message::user("hello").unwrap()], + ) + .unwrap(); + let cancelled = json!({"status":"cancelled","output":[]}); + assert!(OpenAiProvider::parse_response(&request, &cancelled).is_err()); + let missing_arguments = json!({ + "status":"completed", + "output":[{"type":"function_call","call_id":"call-1","name":"echo"}] + }); + assert!(OpenAiProvider::parse_response(&request, &missing_arguments).is_err()); + } + + #[test] + fn http_status_classification_keeps_a_narrow_transient_allowlist() { + for (status, expected) in [ + (400, ProviderErrorKind::Upstream), + (401, ProviderErrorKind::Upstream), + (408, ProviderErrorKind::Unavailable), + (429, ProviderErrorKind::Unavailable), + (500, ProviderErrorKind::Upstream), + (502, ProviderErrorKind::Unavailable), + (503, ProviderErrorKind::Unavailable), + (504, ProviderErrorKind::Unavailable), + ] { + assert_eq!( + classify_http_status(status), + expected, + "unexpected retry classification for HTTP {status}" + ); + } + } + + #[test] + fn complete_http_transient_error_preserves_status_and_redacts_message() { + let (endpoint, server) = spawn_fake_http_server(FakeHttpResponse { + status: 503, + content_type: "application/json", + body: r#"{"error":{"message":"gateway echoed complete-secret"}}"#.to_owned(), + }); + let provider = OpenAiProvider::with_endpoint("complete-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-complete-error", + "gpt-test", + vec![Message::user("complete input").unwrap()], + ) + .unwrap(); + + let error = provider + .complete(&request) + .expect_err("HTTP 503 must return a ProviderError"); + let captured = server.join().expect("fake server should finish"); + assert_eq!(captured.method, "POST"); + assert_eq!(captured.target, "/v1/responses"); + assert_eq!(error.kind(), ProviderErrorKind::Unavailable); + assert_eq!(error.status_code(), Some(503)); + assert_eq!(error.message(), "gateway echoed "); + } + + #[test] + fn complete_http_truncated_error_body_preserves_status_and_classification() { + let body = r#"{"error":{"message":"truncated complete-secret"}}"#; + let (endpoint, server) = spawn_fake_http_server_with_declared_length( + FakeHttpResponse { + status: 503, + content_type: "application/json", + body: body.to_owned(), + }, + Some(body.len() + 8), + ); + let provider = OpenAiProvider::with_endpoint("complete-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-complete-truncated-error", + "gpt-test", + vec![Message::user("complete input").unwrap()], + ) + .unwrap(); + + let error = provider + .complete(&request) + .expect_err("truncated HTTP error body must return a ProviderError"); + server.join().expect("fake server should finish"); + assert_eq!(error.kind(), ProviderErrorKind::Unavailable); + assert_eq!(error.status_code(), Some(503)); + assert!( + !error.message().contains("complete-secret"), + "truncated body diagnostics must remain redacted: {}", + error.message() + ); + } + + #[test] + fn complete_http_permanent_error_keeps_upstream_kind_and_status() { + let (endpoint, server) = spawn_fake_http_server(FakeHttpResponse { + status: 400, + content_type: "application/json", + body: r#"{"error":{"message":"invalid request"}}"#.to_owned(), + }); + let provider = OpenAiProvider::with_endpoint("complete-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-complete-permanent-error", + "gpt-test", + vec![Message::user("complete input").unwrap()], + ) + .unwrap(); + + let error = provider + .complete(&request) + .expect_err("HTTP 400 must return a ProviderError"); + server.join().expect("fake server should finish"); + assert_eq!(error.kind(), ProviderErrorKind::Upstream); + assert_eq!(error.status_code(), Some(400)); + assert_eq!(error.message(), "invalid request"); + } + + #[test] + fn stream_http_transient_error_preserves_status_and_redacts_message() { + let (endpoint, server) = spawn_fake_http_server(FakeHttpResponse { + status: 429, + content_type: "application/json", + body: r#"{"error":{"message":"gateway echoed stream-secret"}}"#.to_owned(), + }); + let provider = OpenAiProvider::with_endpoint("stream-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-stream-error", + "gpt-stream", + vec![Message::user("stream input").unwrap()], + ) + .unwrap(); + let mut sink = CollectingSink(Vec::new()); + + let error = provider + .stream(&request, &mut sink) + .expect_err("HTTP 429 must return a ProviderError"); + let captured = server.join().expect("fake server should finish"); + assert_eq!(captured.method, "POST"); + assert_eq!(captured.target, "/v1/responses"); + assert_eq!(error.kind(), ProviderErrorKind::Unavailable); + assert_eq!(error.status_code(), Some(429)); + assert_eq!(error.message(), "gateway echoed "); + assert!(sink.0.is_empty(), "HTTP errors occur before SSE events"); + } + + #[test] + fn stream_http_permanent_error_keeps_upstream_kind_and_status() { + let (endpoint, server) = spawn_fake_http_server(FakeHttpResponse { + status: 401, + content_type: "application/json", + body: r#"{"error":{"message":"invalid stream key"}}"#.to_owned(), + }); + let provider = OpenAiProvider::with_endpoint("stream-secret", endpoint).unwrap(); + let request = ProviderRequest::try_new( + "request-stream-permanent-error", + "gpt-stream", + vec![Message::user("stream input").unwrap()], + ) + .unwrap(); + let mut sink = CollectingSink(Vec::new()); + + let error = provider + .stream(&request, &mut sink) + .expect_err("HTTP 401 must return a ProviderError"); + server.join().expect("fake server should finish"); + assert_eq!(error.kind(), ProviderErrorKind::Upstream); + assert_eq!(error.status_code(), Some(401)); + assert_eq!(error.message(), "invalid stream key"); + assert!(sink.0.is_empty(), "HTTP errors occur before SSE events"); + } + + #[test] + fn endpoint_validation_rejects_unsafe_or_ambiguous_addresses() { + for value in [ + "", + "not a url", + "ftp://gateway.example/v1", + "https://user:password@gateway.example/v1", + "https://gateway.example/v1#fragment", + "https://gateway.example/v1?api_key=query-secret", + "https://gateway.example/v1?x-api-key=query-secret", + "https://gateway.example/v1?auth=query-secret", + "https://gateway.example/v1?key=query-secret", + ] { + assert!( + OpenAiProvider::with_endpoint("secret-key", value).is_err(), + "expected endpoint to be rejected: {value:?}" + ); + } + assert!(endpoint_from_base_url("https://gateway.example/v1?tenant=one").is_err()); + } + + #[test] + fn debug_never_prints_api_key() { + let provider = OpenAiProvider::with_endpoint("secret-key", "http://localhost").unwrap(); + assert!(!format!("{provider:?}").contains("secret-key")); + } + + #[test] + fn debug_redacts_endpoint_query() { + let provider = OpenAiProvider::with_endpoint( + "secret-key", + "https://example.test/responses?tenant=one", + ) + .unwrap(); + let debug = format!("{provider:?}"); + assert!(!debug.contains("tenant=one")); + assert!(debug.contains("")); + } + + #[test] + fn config_debug_and_serialization_do_not_expose_endpoint_credentials() { + let config = OpenAiProviderConfig::default() + .with_endpoint("https://example.test/responses?tenant=one"); + let debug = format!("{config:?}"); + assert!(!debug.contains("tenant=one")); + assert!(debug.contains("")); + + let unsafe_config = OpenAiProviderConfig::default() + .with_endpoint("https://example.test/responses?api_key=query-secret"); + assert!(serde_json::to_string(&unsafe_config).is_err()); + + for endpoint in [ + "https://example.test/responses?x-api-key=query-secret", + "https://example.test/responses?auth=query-secret", + ] { + let unsafe_config = OpenAiProviderConfig::default().with_endpoint(endpoint); + assert!(serde_json::to_string(&unsafe_config).is_err()); + } + + for (endpoint, secret) in [ + ("https://user:password@example.test/responses", "password"), + ( + "https://example.test/responses#fragment-secret", + "fragment-secret", + ), + ("not a url?password=malformed-secret", "malformed-secret"), + ] { + let unsafe_config = OpenAiProviderConfig::default().with_endpoint(endpoint); + let debug = format!("{unsafe_config:?}"); + assert!( + !debug.contains(secret), + "endpoint secret leaked in Debug: {debug}" + ); + assert!(debug.contains("")); + assert!(serde_json::to_string(&unsafe_config).is_err()); + } + + // Base URL 的 query 即使不含明显凭据,也不能绕过 base→responses + // 解析约束;序列化阶段提前拒绝,避免把一个无法运行的配置落盘。 + let invalid_base = + OpenAiProviderConfig::default().with_base_url("https://example.test/v1?tenant=one"); + assert!(serde_json::to_string(&invalid_base).is_err()); + } + + #[test] + fn complete_error_secret_is_sanitized() { + assert_eq!( + sanitize_secret("gateway echoed secret-key in diagnostics", "secret-key"), + "gateway echoed in diagnostics" + ); + let error = sanitize_provider_error( + ProviderError::new( + ProviderErrorKind::Upstream, + "gateway echoed secret-key in diagnostics", + ) + .with_status_code(502), + "secret-key", + ); + assert_eq!(error.message(), "gateway echoed in diagnostics"); + assert_eq!(error.status_code(), Some(502)); + } +} diff --git a/rust/crates/agent-runtime-contracts/Cargo.toml b/rust/crates/agent-runtime-contracts/Cargo.toml new file mode 100644 index 000000000..a1bb97c51 --- /dev/null +++ b/rust/crates/agent-runtime-contracts/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "agent-runtime-contracts" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +license.workspace = true +description = "Database-neutral durable runtime commands, views, and store contract" + +[dependencies] +agent-runtime-core.workspace = true +serde_json.workspace = true diff --git a/rust/crates/agent-runtime-contracts/src/lib.rs b/rust/crates/agent-runtime-contracts/src/lib.rs new file mode 100644 index 000000000..560f3beac --- /dev/null +++ b/rust/crates/agent-runtime-contracts/src/lib.rs @@ -0,0 +1,637 @@ +//! Durable Runtime 的中立控制面合同。 +//! +//! 这些拥有值 command、view 和 `DurableStore` trait 不携带 SQLite、线程或 +//! CLI 状态,供 runtime facade 与持久化适配器共享。跨表原子性仍由具体 adapter +//! 保证。 +use agent_runtime_core::{RuntimeEvent, RuntimeSnapshot}; +use serde_json::Value; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::time::Duration; + +/// 一次原子创建 session、queued run 和 runtime 初始事件的拥有式命令。 +#[derive(Clone, Debug)] +pub struct DurableRunBundle { + pub session: DurableSessionInput, + pub run: DurableRunInput, + pub runtime_id: String, + pub snapshot: RuntimeSnapshot, + pub events: Vec, +} + +/// session 创建参数的中立表示,不携带数据库连接或事务句柄。 +#[derive(Clone, Debug)] +pub struct DurableSessionInput { + pub id: String, + pub agent_id: Option, + pub status: String, + pub metadata: Value, +} + +/// run 创建参数的中立表示。 +#[derive(Clone, Debug)] +pub struct DurableRunInput { + pub id: String, + pub session_id: String, + pub status: String, + pub input: Value, +} + +/// bundle 原子提交后返回的稳定最小身份。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableBundleResult { + pub session_id: String, + pub run_id: String, + pub runtime_id: String, +} + +/// 供 Runtime/Host 查询的中立 session 投影。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableSessionView { + pub id: String, + pub agent_id: Option, + pub status: String, + pub metadata: Value, + pub created_at: i64, + pub updated_at: i64, +} + +/// 供 Runtime/Host 查询的中立 run 投影。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableRunView { + pub id: String, + pub session_id: String, + pub status: String, + pub revision: i64, + pub input: Value, + pub output: Option, + pub cancel_requested: bool, + pub created_at: i64, + pub updated_at: i64, +} + +/// 当前 worker lease 的中立投影。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableLeaseView { + pub run_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at: i64, + pub heartbeat_at: i64, + pub attempt: i64, +} + +/// claim 的单次原子结果;run 和 lease 必须来自同一适配器事务。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableClaimResult { + pub run: DurableRunView, + pub lease: DurableLeaseView, +} + +/// Engine 边界 checkpoint 的中立输入。 +#[derive(Clone, Debug)] +pub struct DurableCheckpointInput { + pub run_id: String, + pub phase: String, + pub step: i64, + pub next_step: i64, + pub messages: Value, + pub provider_request_id: Option, + pub tool_call_id: Option, + pub attempt: i64, +} + +/// 持久化后的 checkpoint 观察值。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableCheckpointView { + pub run_id: String, + pub phase: String, + pub step: i64, + pub next_step: i64, + pub messages: Value, + pub provider_request_id: Option, + pub tool_call_id: Option, + pub attempt: i64, + pub updated_at: i64, +} + +/// checkpoint 与 runtime snapshot/event 的单事务提交命令。 +#[derive(Clone, Debug)] +pub struct DurableCheckpointRuntimeCommit { + pub checkpoint: DurableCheckpointInput, + pub runtime_id: String, + pub expected_runtime_revision: Option, + pub snapshot: RuntimeSnapshot, + pub events: Vec, +} + +/// durable approval 创建命令;token 只供内部恢复绑定,不用于展示。 +#[derive(Clone, Debug)] +pub struct DurableApprovalInput { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_call_id: Option, + pub status: String, + pub request: Value, + pub arguments_hash: String, + pub approval_token: String, + pub expires_at_ms: i64, +} + +/// 将一个等待审批的 checkpoint、当前 Core runtime 快照和 approval 记录 +/// 放进同一 durable adapter 事务的拥有值命令。 +/// +/// Engine 在 checkpoint listener 返回后才构造完整的 `ApprovalRequest`, +/// 因此该命令用于 Host 已经拿到 request binding 的收口阶段:adapter 必须 +/// 在一个事务内重新校验 live lease、awaiting checkpoint 和 runtime snapshot, +/// 再幂等写入 approval。它不会追加 runtime event;对应的 ToolRequested +/// event 已由 checkpoint 边界事务提交。 +#[derive(Clone, Debug)] +pub struct DurableApprovalCheckpointRuntimeCommit { + pub approval: DurableApprovalInput, + pub checkpoint: DurableCheckpointInput, + pub runtime_id: String, + pub expected_runtime_revision: Option, + pub snapshot: RuntimeSnapshot, + pub lease: DurableLeaseIdentity, +} + +/// approval 查询投影;调用方展示时仍应使用专用脱敏投影。 +#[derive(Clone, Debug, PartialEq)] +pub struct DurableApprovalView { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_call_id: Option, + pub status: String, + pub request: Value, + pub arguments_hash: String, + pub approval_token: String, + pub expires_at_ms: i64, + pub decision: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// approval pending-only CAS 命令。 +#[derive(Clone, Debug)] +pub struct DurableApprovalResolution { + pub approval_id: String, + pub expected_status: String, + pub status: String, + pub decision: Value, +} + +/// 外部 backend 会话登记命令;metadata 由 adapter 做非敏感 JSON 校验。 +#[derive(Clone, Debug)] +pub struct DurableExternalSessionInput { + pub id: String, + pub session_id: String, + pub run_id: Option, + pub backend: String, + pub external_id: String, + pub status: String, + pub metadata: Value, +} + +/// 外部会话的 durable 观察值。 +#[derive(Clone, Debug, PartialEq)] +pub struct DurableExternalSessionView { + pub id: String, + pub session_id: String, + pub run_id: Option, + pub backend: String, + pub external_id: String, + pub status: String, + pub metadata: Value, + pub created_at: i64, + pub updated_at: i64, +} + +/// 工具调用的 durable 创建命令;调用身份与参数会被 adapter 校验并保留。 +#[derive(Clone, Debug)] +pub struct DurableToolCallInput { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_name: String, + pub arguments: Value, + pub status: String, +} + +/// 将工具调用行和 Core runtime 事件放进同一个 adapter 事务的拥有值命令。 +/// +/// 该命令只覆盖 `tool_calls` 行与 runtime snapshot/event 的原子边界; +/// Engine checkpoint 目前仍由单独的 checkpoint command 提交,不能把这个 +/// 类型误读成 run/runtime/checkpoint 的全局事务。`lease = None` 仅供没有 +/// worker fencing 的兼容或控制面路径使用;带 worker 的运行应传入 lease。 +#[derive(Clone, Debug)] +pub struct DurableToolCallRuntimeCommit { + pub call: DurableToolCallInput, + pub runtime_id: String, + pub expected_runtime_revision: Option, + pub snapshot: RuntimeSnapshot, + pub events: Vec, + pub lease: Option, +} + +/// 将工具调用行、Engine checkpoint 和 Core runtime snapshot/event 放进同一 +/// adapter 事务的拥有值命令。 +/// +/// checkpoint 必须带 worker lease;这条更窄的合同只用于 worker 已经同时 +/// 拿到工具调用和 checkpoint 的边界。普通工具事件仍可使用上面的 +/// `DurableToolCallRuntimeCommit`,避免调用方为了凑 checkpoint 而重复写入。 +#[derive(Clone, Debug)] +pub struct DurableToolCallCheckpointRuntimeCommit { + pub call: DurableToolCallInput, + pub checkpoint: DurableCheckpointInput, + pub runtime_id: String, + pub expected_runtime_revision: Option, + pub snapshot: RuntimeSnapshot, + pub events: Vec, + pub lease: DurableLeaseIdentity, +} + +/// 工具调用的 durable 观察值,供审计、恢复和导出使用。 +#[derive(Clone, Debug, PartialEq)] +pub struct DurableToolCallView { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_name: String, + pub arguments: Value, + pub result: Option, + pub status: String, + pub created_at: i64, + pub updated_at: i64, +} + +/// 旧的 `DurableStore` 实现尚未支持工具调用与 runtime 的联合事务时, +/// 默认方法返回的明确错误。新 adapter 可以为自己的错误类型实现 +/// `From`,再覆盖联合事务方法;这样不会强迫 +/// 现有 fake/兼容实现立刻增加新的 trait 方法实现。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DurableStoreUnsupported { + operation: &'static str, +} + +impl DurableStoreUnsupported { + pub const fn new(operation: &'static str) -> Self { + Self { operation } + } + + pub const fn operation(self) -> &'static str { + self.operation + } +} + +impl Display for DurableStoreUnsupported { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "durable store operation is unsupported: {}", + self.operation + ) + } +} + +impl Error for DurableStoreUnsupported {} + +/// Runtime-aware 终态目标;适配器必须把它与 Core 最后事件保持一致。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DurableFinishTarget { + Completed, + Failed, + Cancelled, +} + +/// 终态 command 是否要求 queued 未领取保护。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DurableFinishGuard { + None, + QueuedUnclaimed, +} + +/// Runtime、run、session、checkpoint 的拥有值终态提交命令。 +#[derive(Clone, Debug)] +pub struct DurableFinishCommand { + pub run_id: String, + pub lease: Option, + pub target: DurableFinishTarget, + pub output: Option, + pub runtime_id: String, + pub expected_runtime_revision: Option, + pub snapshot: RuntimeSnapshot, + pub events: Vec, + pub guard: DurableFinishGuard, +} + +/// 终态 command 使用的 opaque worker fencing 身份。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableLeaseIdentity { + pub worker_id: String, + pub lease_token: String, +} + +/// 过期 run 的 Runtime-aware recovery 提交命令。 +#[derive(Clone, Debug)] +pub struct DurableRecoveryCommit { + pub run_id: String, + pub runtime_id: String, + pub expected_runtime_revision: Option, + pub snapshot: RuntimeSnapshot, + pub events: Vec, +} + +/// 第一阶段可替换 durable store 合同。 +/// +/// 所有参数都是拥有值,适合后续放进 trait object;实现方负责保证 +/// `create_run_bundle` 和 lease 操作的原子性,runtime snapshot CAS 则保持 +/// Core 的 `expected_revision` 语义。这里不暴露 SQL、连接或事务生命周期。 +pub trait DurableStore: Send + Sync { + type Error: std::error::Error + 'static; + + fn create_run_bundle( + &self, + bundle: DurableRunBundle, + ) -> Result; + + fn get_run(&self, run_id: &str) -> Result, Self::Error>; + + fn get_session(&self, session_id: &str) -> Result, Self::Error>; + + /// 查询 cooperative cancel 标记,不改变 run 状态。 + fn is_cancel_requested(&self, run_id: &str) -> Result; + + /// 反查 run 所属的 Core runtime 身份。 + fn runtime_id_for_run(&self, run_id: &str) -> Result, Self::Error>; + + /// 更新 session 投影;`metadata = None` 表示只更新状态。 + fn update_session( + &self, + session_id: &str, + status: &str, + metadata: Option, + ) -> Result; + + fn claim_run_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result; + + fn get_run_lease(&self, run_id: &str) -> Result, Self::Error>; + + fn heartbeat_run( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result; + + fn release_run_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result; + + fn request_cancel(&self, run_id: &str) -> Result; + + fn list_stale_run_ids(&self, limit: usize, now_ms: i64) -> Result, Self::Error>; + + /// 仅恢复带 safe checkpoint 的 run,不启动 Engine。 + fn requeue_safe_run(&self, run_id: &str) -> Result; + + fn read_checkpoint(&self, run_id: &str) -> Result, Self::Error>; + + fn read_checkpoint_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result, Self::Error>; + + fn save_checkpoint_with_lease( + &self, + checkpoint: DurableCheckpointInput, + worker_id: &str, + lease_token: &str, + ) -> Result; + + fn save_checkpoint_with_runtime_and_lease( + &self, + commit: DurableCheckpointRuntimeCommit, + worker_id: &str, + lease_token: &str, + ) -> Result; + + fn record_reconciliation_result( + &self, + run_id: &str, + phase: &str, + external_id: &str, + step: i64, + attempt: i64, + messages: Value, + ) -> Result; + + fn create_approval( + &self, + approval: DurableApprovalInput, + ) -> Result; + + /// 原子写入 approval,并在同一 adapter 事务中校验/刷新对应的 + /// awaiting checkpoint 与 runtime snapshot。旧实现默认返回明确的 + /// `Unsupported`,不破坏已有 fake/兼容 adapter。 + fn create_approval_with_checkpoint_runtime_and_lease( + &self, + _commit: DurableApprovalCheckpointRuntimeCommit, + ) -> Result + where + Self::Error: From, + { + Err( + DurableStoreUnsupported::new("create_approval_with_checkpoint_runtime_and_lease") + .into(), + ) + } + + fn get_approval(&self, approval_id: &str) -> Result, Self::Error>; + + fn list_approvals_for_run(&self, run_id: &str) + -> Result, Self::Error>; + + fn get_approval_for_run_call( + &self, + run_id: &str, + tool_call_id: &str, + ) -> Result, Self::Error>; + + fn resolve_approval( + &self, + resolution: DurableApprovalResolution, + ) -> Result; + + fn cancel_pending_approvals(&self, run_id: &str) -> Result; + + fn queue_approved_run(&self, approval_id: &str) -> Result; + + fn finish_run_with_runtime( + &self, + command: DurableFinishCommand, + ) -> Result; + + /// 兼容旧调用方的带 lease cancelled 收口;不伪造 runtime 事件。 + fn mark_cancelled_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + ) -> Result; + + /// 兼容旧调用方的无 lease cancelled 收口;不伪造 runtime 事件。 + fn mark_cancelled( + &self, + run_id: &str, + output: Option, + ) -> Result; + + fn recover_expired_run(&self, run_id: &str) -> Result; + + fn recover_expired_run_with_runtime( + &self, + commit: DurableRecoveryCommit, + ) -> Result; + + fn upsert_external_session( + &self, + session: DurableExternalSessionInput, + ) -> Result; + + fn update_external_session( + &self, + id: &str, + external_id: &str, + status: &str, + metadata: Value, + ) -> Result; + + fn create_tool_call( + &self, + call: DurableToolCallInput, + ) -> Result; + + fn complete_tool_call( + &self, + call_id: &str, + status: &str, + result: Value, + ) -> Result; + + /// 原子创建工具调用行并提交对应 Core runtime 事件。 + /// + /// 默认实现显式返回 `Unsupported`,保留旧 fake/adapter 的兼容性;支持 + /// 该合同的实现必须在自己的事务中同时校验可选 lease、runtime CAS、 + /// `call` 身份和事件序列。checkpoint 不属于本命令的原子边界。 + fn create_tool_call_with_runtime_and_lease( + &self, + _commit: DurableToolCallRuntimeCommit, + ) -> Result + where + Self::Error: From, + { + Err(DurableStoreUnsupported::new("create_tool_call_with_runtime_and_lease").into()) + } + + /// 原子更新工具调用结果并提交对应 Core runtime 事件。 + /// + /// `status`/`result` 与 `commit.call.id` 配对;适配器应拒绝身份不一致或 + /// 重复终态,并在同一事务中完成工具行和 runtime event 写入。checkpoint + /// 仍由现有 checkpoint command 单独提交。 + fn complete_tool_call_with_runtime_and_lease( + &self, + _commit: DurableToolCallRuntimeCommit, + _status: &str, + _result: Value, + ) -> Result + where + Self::Error: From, + { + Err(DurableStoreUnsupported::new("complete_tool_call_with_runtime_and_lease").into()) + } + + /// 原子创建工具调用、checkpoint 和对应 Core runtime 事件。 + /// + /// 默认实现显式返回 `Unsupported`;支持该合同的 adapter 必须在自己的 + /// 事务中同时执行 lease fencing、runtime CAS、checkpoint upsert 和工具行 + /// 写入,不能由 facade 把四次独立调用拼成假事务。 + fn create_tool_call_with_checkpoint_runtime_and_lease( + &self, + _commit: DurableToolCallCheckpointRuntimeCommit, + ) -> Result + where + Self::Error: From, + { + Err( + DurableStoreUnsupported::new("create_tool_call_with_checkpoint_runtime_and_lease") + .into(), + ) + } + + /// 原子完成工具调用、checkpoint 和对应 Core runtime 事件。 + /// + /// `status`/`result` 必须和 `commit.call.id` 及最终 runtime snapshot 一致; + /// 任一校验失败都应回滚 checkpoint、runtime event 和工具行。 + fn complete_tool_call_with_checkpoint_runtime_and_lease( + &self, + _commit: DurableToolCallCheckpointRuntimeCommit, + _status: &str, + _result: Value, + ) -> Result + where + Self::Error: From, + { + Err( + DurableStoreUnsupported::new("complete_tool_call_with_checkpoint_runtime_and_lease") + .into(), + ) + } + + fn get_tool_call(&self, call_id: &str) -> Result, Self::Error>; + + fn list_tool_calls_for_run( + &self, + run_id: &str, + ) -> Result, Self::Error>; + + fn get_external_session( + &self, + id: &str, + ) -> Result, Self::Error>; + + fn list_external_sessions( + &self, + statuses: &[&str], + run_id: Option<&str>, + limit: usize, + ) -> Result, Self::Error>; + + fn load_runtime_snapshot( + &self, + runtime_id: &str, + ) -> Result, Self::Error>; + + fn commit_runtime_snapshot( + &self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), Self::Error>; +} diff --git a/rust/crates/agent-runtime-core/Cargo.toml b/rust/crates/agent-runtime-core/Cargo.toml new file mode 100644 index 000000000..593e74663 --- /dev/null +++ b/rust/crates/agent-runtime-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "agent-runtime-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false +description = "通用 Agent 的中立契约、端口与运行状态 reducer" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/rust/crates/agent-runtime-core/src/approval.rs b/rust/crates/agent-runtime-core/src/approval.rs new file mode 100644 index 000000000..514cb6ee4 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/approval.rs @@ -0,0 +1,336 @@ +//! 工具审批端口。策略只给出决定,UI/CLI 负责把 Ask 呈现给用户。 + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::contract::{ + ContractError, CoreError, ErrorKind, validate_identifier, validate_non_empty, validate_object, +}; +use crate::tool::ToolCall; + +/// Default lifetime for a human approval request. Callers that need a shorter +/// or deterministic window can use `try_new_with_expiry`. +pub const DEFAULT_APPROVAL_TTL_MS: u64 = 15 * 60 * 1_000; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ApprovalRequest { + request_id: String, + run_id: String, + call: ToolCall, + /// SHA-256 of the canonical ToolCall JSON. This binds a decision to the + /// exact arguments shown to the approver, rather than only to call_id. + arguments_hash: String, + /// Opaque binding value persisted with the approval record. It is not an + /// API credential and does not grant permission without a matching record. + approval_token: String, + expires_at_ms: u64, + reason: Option, + metadata: Value, +} + +impl ApprovalRequest { + pub fn try_new( + request_id: impl Into, + run_id: impl Into, + call: ToolCall, + ) -> Result { + let expires_at_ms = now_millis().saturating_add(DEFAULT_APPROVAL_TTL_MS); + Self::try_new_with_expiry(request_id, run_id, call, expires_at_ms) + } + + /// Construct an approval request with an explicit expiry. The explicit form + /// keeps replay/fixture tests deterministic while production callers can + /// use `try_new`'s bounded default. + pub fn try_new_with_expiry( + request_id: impl Into, + run_id: impl Into, + call: ToolCall, + expires_at_ms: u64, + ) -> Result { + let request_id = request_id.into(); + let run_id = run_id.into(); + validate_identifier(&request_id, "approval request id")?; + validate_identifier(&run_id, "run id")?; + if expires_at_ms == 0 { + return Err(ContractError::new("approval expiry 必须大于 0")); + } + // `ToolCall` is a public serde value and may have bypassed its + // fallible constructor. Approval is an execution-adjacent boundary: + // never mint a binding token for malformed call data. + call.validate()?; + let arguments_hash = hash_tool_call(&call)?; + let approval_token = binding_token(&request_id, &run_id, &arguments_hash, expires_at_ms); + Ok(Self { + request_id, + run_id, + call, + arguments_hash, + approval_token, + expires_at_ms, + reason: None, + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "approval metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn call(&self) -> &ToolCall { + &self.call + } + + pub fn arguments_hash(&self) -> &str { + &self.arguments_hash + } + + pub fn approval_token(&self) -> &str { + &self.approval_token + } + + pub fn expires_at_ms(&self) -> u64 { + self.expires_at_ms + } + + pub fn is_expired(&self, now_ms: u64) -> bool { + now_ms >= self.expires_at_ms + } + + /// Validate a persisted request before applying a decision. This catches + /// tampered arguments, stale tokens and expired approvals at the boundary. + pub fn verify_binding( + &self, + call: &ToolCall, + approval_token: &str, + now_ms: u64, + ) -> Result<(), ContractError> { + self.validate()?; + call.validate()?; + let hash = hash_tool_call(call)?; + if hash != self.arguments_hash { + return Err(ContractError::new("approval binding 与工具调用不匹配")); + } + // Re-derive the token instead of only comparing two persisted copies. + // Otherwise a deserialized/tampered request could replace both the + // stored token and the presented token and still pass this check. + let expected_token = + binding_token(&self.request_id, &self.run_id, &hash, self.expires_at_ms); + if self.approval_token != expected_token || approval_token != expected_token { + return Err(ContractError::new("approval binding 与请求不匹配")); + } + if self.is_expired(now_ms) { + return Err(ContractError::new("approval 已过期")); + } + Ok(()) + } + + /// Re-run the persisted approval envelope invariants. The binding token + /// itself is checked by `verify_binding`, which additionally compares the + /// caller-provided call and token and enforces expiry. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.request_id, "approval request id")?; + validate_identifier(&self.run_id, "run id")?; + if self.expires_at_ms == 0 { + return Err(ContractError::new("approval expiry 必须大于 0")); + } + self.call.validate()?; + validate_non_empty(&self.arguments_hash, "approval arguments hash")?; + validate_non_empty(&self.approval_token, "approval token")?; + if let Some(reason) = &self.reason { + validate_non_empty(reason, "approval reason")?; + } + validate_object(&self.metadata, "approval metadata") + } + + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "decision", rename_all = "kebab-case")] +pub enum ApprovalDecision { + Allow, + Deny { reason: String }, + Ask, +} + +impl ApprovalDecision { + pub fn allow() -> Self { + Self::Allow + } + + pub fn deny(reason: impl Into) -> Self { + Self::Deny { + reason: reason.into(), + } + } + + pub fn ask() -> Self { + Self::Ask + } + + pub fn is_allowed(&self) -> bool { + matches!(self, Self::Allow) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ApprovalErrorKind { + InvalidInput, + Unavailable, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApprovalError { + kind: ApprovalErrorKind, + message: String, +} + +impl ApprovalError { + pub fn new(kind: ApprovalErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> ApprovalErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ApprovalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ApprovalError {} + +impl From for ApprovalError { + fn from(value: ContractError) -> Self { + Self::new(ApprovalErrorKind::InvalidInput, value.message()) + } +} + +impl From for CoreError { + fn from(value: ApprovalError) -> Self { + Self::new(ErrorKind::Approval, value.message) + } +} + +fn hash_tool_call(call: &ToolCall) -> Result { + let encoded = serde_json::to_vec(call).map_err(|error| { + ContractError::new(format!("tool call 无法计算 approval hash: {error}")) + })?; + let digest = Sha256::digest(encoded); + Ok(format!("{digest:x}")) +} + +fn binding_token( + request_id: &str, + run_id: &str, + arguments_hash: &str, + expires_at_ms: u64, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(request_id.as_bytes()); + hasher.update([0]); + hasher.update(run_id.as_bytes()); + hasher.update([0]); + hasher.update(arguments_hash.as_bytes()); + hasher.update([0]); + hasher.update(expires_at_ms.to_be_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +/// 默认策略可由宿主实现为“全允许”“全拒绝”或交互式询问。 +pub trait ApprovalPolicy: Send + Sync { + fn decide(&self, request: &ApprovalRequest) -> Result; + + /// 轻量调用方只关心布尔结果时的便捷入口。 + /// + /// 完整 engine 应使用 `decide` 保留 request/run 身份;这里使用固定的 + /// 合成身份仅为兼容无会话的离线 smoke 和旧式 allow-list。 + fn allow(&self, call: &ToolCall) -> bool { + ApprovalRequest::try_new("approval", "run", call.clone()) + .ok() + .and_then(|request| self.decide(&request).ok()) + .is_some_and(|decision| decision.is_allowed()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request() -> (ApprovalRequest, ToolCall) { + let call = ToolCall::try_new("call-approval", "echo", json!({"text": "ok"})).expect("call"); + let request = + ApprovalRequest::try_new_with_expiry("approval-1", "run-1", call.clone(), 1_000) + .expect("request"); + (request, call) + } + + #[test] + fn verify_binding_rejects_tampered_persisted_token() { + let (mut request, call) = request(); + request.approval_token = "tampered-token".to_owned(); + + // Comparing the supplied token with the same persisted value is not + // sufficient; the token must still be derived from request identity. + assert!( + request + .verify_binding(&call, request.approval_token(), 999) + .is_err() + ); + } + + #[test] + fn verify_binding_rejects_tampered_persisted_hash() { + let (mut request, call) = request(); + request.arguments_hash = "tampered-hash".to_owned(); + + assert!( + request + .verify_binding(&call, request.approval_token(), 999) + .is_err() + ); + } +} diff --git a/rust/crates/agent-runtime-core/src/capability.rs b/rust/crates/agent-runtime-core/src/capability.rs new file mode 100644 index 000000000..278d69845 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/capability.rs @@ -0,0 +1,143 @@ +//! 能力目录。目录只描述能力和稳定函数名,不包含执行权限。 + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::contract::{ContractError, validate_identifier, validate_non_empty, validate_object}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityDefinition { + id: String, + function_name: String, + description: String, + input_schema: Value, + dispatch: D, +} + +impl CapabilityDefinition { + pub fn try_new( + id: impl Into, + function_name: impl Into, + description: impl Into, + input_schema: Value, + dispatch: D, + ) -> Result { + let id = id.into(); + let function_name = function_name.into(); + let description = description.into(); + validate_identifier(&id, "capability id")?; + validate_identifier(&function_name, "capability function name")?; + validate_non_empty(&description, "capability description")?; + validate_object(&input_schema, "capability inputSchema")?; + Ok(Self { + id, + function_name, + description, + input_schema, + dispatch, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + pub fn function_name(&self) -> &str { + &self.function_name + } + pub fn description(&self) -> &str { + &self.description + } + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + pub fn dispatch(&self) -> &D { + &self.dispatch + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityRegistryError { + message: String, +} + +impl CapabilityRegistryError { + pub fn message(&self) -> &str { + &self.message + } +} +impl std::fmt::Display for CapabilityRegistryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for CapabilityRegistryError {} +impl From for CapabilityRegistryError { + fn from(value: ContractError) -> Self { + Self { + message: value.message().to_owned(), + } + } +} + +#[derive(Clone, Debug)] +pub struct CapabilityRegistry { + definitions: Vec>, + by_id: BTreeMap, + by_function_name: BTreeMap, +} + +impl CapabilityRegistry { + pub fn try_new( + definitions: impl IntoIterator>, + ) -> Result { + let definitions = definitions.into_iter().collect::>(); + let mut by_id = BTreeMap::new(); + let mut by_function_name = BTreeMap::new(); + for (index, definition) in definitions.iter().enumerate() { + if by_id.insert(definition.id.clone(), index).is_some() { + return Err(CapabilityRegistryError { + message: format!("capability id 重复:{}", definition.id), + }); + } + if by_function_name + .insert(definition.function_name.clone(), index) + .is_some() + { + return Err(CapabilityRegistryError { + message: format!( + "capability function name 重复:{}", + definition.function_name + ), + }); + } + } + Ok(Self { + definitions, + by_id, + by_function_name, + }) + } + + pub fn get(&self, id: &str) -> Option<&CapabilityDefinition> { + self.by_id + .get(id) + .and_then(|index| self.definitions.get(*index)) + } + + pub fn get_by_function_name(&self, name: &str) -> Option<&CapabilityDefinition> { + self.by_function_name + .get(name) + .and_then(|index| self.definitions.get(*index)) + } + + pub fn iter(&self) -> impl ExactSizeIterator> { + self.definitions.iter() + } + pub fn len(&self) -> usize { + self.definitions.len() + } + pub fn is_empty(&self) -> bool { + self.definitions.is_empty() + } +} diff --git a/rust/crates/agent-runtime-core/src/catalog.rs b/rust/crates/agent-runtime-core/src/catalog.rs new file mode 100644 index 000000000..6d7190ed0 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/catalog.rs @@ -0,0 +1,129 @@ +//! Agent 描述和运行配置目录。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::capability::CapabilityRegistry; +use crate::contract::{ContractError, validate_identifier, validate_object}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDescriptor { + id: String, + role: String, + capability_ids: Vec, + metadata: Value, +} + +impl AgentDescriptor { + pub fn try_new( + id: impl Into, + role: impl Into, + capability_ids: impl IntoIterator>, + ) -> Result { + let id = id.into(); + let role = role.into(); + validate_identifier(&id, "agent id")?; + validate_identifier(&role, "agent role")?; + let mut ids = Vec::new(); + for capability_id in capability_ids { + let capability_id = capability_id.into(); + validate_identifier(&capability_id, "capability id")?; + if ids.contains(&capability_id) { + return Err(ContractError::new("agent capability 重复")); + } + ids.push(capability_id); + } + let descriptor = Self { + id, + role, + capability_ids: ids, + metadata: Value::Object(Map::new()), + }; + descriptor.validate()?; + Ok(descriptor) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "agent metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// Re-run descriptor invariants before a catalog exposes an agent for + /// capability resolution. `AgentDescriptor` intentionally keeps its + /// serde/legacy construction path for persisted configuration, so the + /// catalog is the final Core registration boundary for this check. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.id, "agent id")?; + validate_identifier(&self.role, "agent role")?; + for capability_id in &self.capability_ids { + validate_identifier(capability_id, "capability id")?; + } + let mut seen = BTreeMap::new(); + for capability_id in &self.capability_ids { + if seen.insert(capability_id, ()).is_some() { + return Err(ContractError::new("agent capability 重复")); + } + } + validate_object(&self.metadata, "agent metadata") + } + pub fn id(&self) -> &str { + &self.id + } + pub fn role(&self) -> &str { + &self.role + } + pub fn capability_ids(&self) -> &[String] { + &self.capability_ids + } + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug)] +pub struct AgentCatalog { + agents: Vec, + by_id: BTreeMap, +} + +impl AgentCatalog { + pub fn try_new( + agents: impl IntoIterator, + ) -> Result { + let agents = agents.into_iter().collect::>(); + let mut by_id = BTreeMap::new(); + for (index, agent) in agents.iter().enumerate() { + agent.validate()?; + if by_id.insert(agent.id.clone(), index).is_some() { + return Err(ContractError::new("agent id 重复")); + } + } + Ok(Self { agents, by_id }) + } + pub fn get(&self, id: &str) -> Option<&AgentDescriptor> { + self.by_id.get(id).and_then(|index| self.agents.get(*index)) + } + pub fn iter(&self) -> impl ExactSizeIterator { + self.agents.iter() + } + pub fn validate_capabilities( + &self, + capabilities: &CapabilityRegistry, + ) -> Result<(), ContractError> { + for agent in &self.agents { + for id in &agent.capability_ids { + if capabilities.get(id).is_none() { + return Err(ContractError::new(format!( + "agent {} 引用了未知 capability:{id}", + agent.id + ))); + } + } + } + Ok(()) + } +} diff --git a/rust/crates/agent-runtime-core/src/completion.rs b/rust/crates/agent-runtime-core/src/completion.rs new file mode 100644 index 000000000..e55659b29 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/completion.rs @@ -0,0 +1,63 @@ +//! 可由宿主注入的完成判定策略。 + +use crate::contract::{ContractError, validate_identifier, validate_non_empty}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletionBlocker { + code: String, + summary: String, +} + +impl CompletionBlocker { + pub fn try_new( + code: impl Into, + summary: impl Into, + ) -> Result { + let code = code.into(); + let summary = summary.into(); + validate_identifier(&code, "completion blocker code")?; + validate_non_empty(&summary, "completion blocker summary")?; + Ok(Self { code, summary }) + } + pub fn code(&self) -> &str { + &self.code + } + pub fn summary(&self) -> &str { + &self.summary + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CompletionDecision { + Ready, + Blocked(Vec), +} + +impl CompletionDecision { + pub fn ready() -> Self { + Self::Ready + } + pub fn blocked( + blockers: impl IntoIterator, + ) -> Result { + let blockers = blockers.into_iter().collect::>(); + if blockers.is_empty() { + return Err(ContractError::new("blocked completion 至少需要 blocker")); + } + Ok(Self::Blocked(blockers)) + } + pub fn is_ready(&self) -> bool { + matches!(self, Self::Ready) + } + pub fn blockers(&self) -> &[CompletionBlocker] { + match self { + Self::Ready => &[], + Self::Blocked(items) => items, + } + } +} + +pub trait CompletionPolicy: Send + Sync { + fn id(&self) -> &str; + fn evaluate(&self, context: &Context) -> CompletionDecision; +} diff --git a/rust/crates/agent-runtime-core/src/context.rs b/rust/crates/agent-runtime-core/src/context.rs new file mode 100644 index 000000000..cbf293fb0 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/context.rs @@ -0,0 +1,197 @@ +//! Context engine 使用的可插拔上下文源。 + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::contract::{ContractError, CoreError, ErrorKind, validate_identifier, validate_object}; +use crate::message::Message; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContextRequest { + pub session_id: Option, + pub run_id: Option, + pub token_budget: Option, + pub messages: Vec, + pub metadata: Value, +} + +impl ContextRequest { + pub fn new() -> Self { + Self { + session_id: None, + run_id: None, + token_budget: None, + messages: Vec::new(), + metadata: Value::Object(Map::new()), + } + } + + pub fn with_session_id(mut self, id: impl Into) -> Self { + self.session_id = Some(id.into()); + self + } + + pub fn with_run_id(mut self, id: impl Into) -> Self { + self.run_id = Some(id.into()); + self + } + + pub fn with_token_budget(mut self, budget: u32) -> Result { + if budget == 0 { + return Err(ContractError::new("context token budget 必须大于 0")); + } + self.token_budget = Some(budget); + Ok(self) + } + + pub fn with_messages(mut self, messages: impl IntoIterator) -> Self { + self.messages = messages.into_iter().collect(); + self + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "context metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// Re-run the request contract at a source boundary. + /// + /// `ContextRequest` is a public serde value and the legacy fluent setters + /// intentionally stay infallible for compatibility. A context source can + /// therefore receive a value that did not pass those constructors; validate + /// it before invoking an adapter that may perform I/O or other observable + /// work. + pub fn validate(&self) -> Result<(), ContractError> { + if let Some(session_id) = &self.session_id { + validate_identifier(session_id, "session id")?; + } + if let Some(run_id) = &self.run_id { + validate_identifier(run_id, "run id")?; + } + if self.token_budget == Some(0) { + return Err(ContractError::new("context token budget 必须大于 0")); + } + for message in &self.messages { + message.validate()?; + } + validate_object(&self.metadata, "context metadata") + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContextItem { + source_id: String, + message: Message, + priority: i32, + trusted: bool, + metadata: Value, +} + +impl ContextItem { + pub fn try_new( + source_id: impl Into, + message: Message, + priority: i32, + trusted: bool, + ) -> Result { + let source_id = source_id.into(); + validate_identifier(&source_id, "context source id")?; + // `Message` 仍保留 `new` 和 serde 作为兼容入口,因此这里不能假定 + // 调用方一定经过 `Message::try_new`;ContextItem 是上下文端口的值 + // 对象边界,先拒绝非法消息,避免把坏数据交给 ContextSource/Engine。 + message.validate()?; + Ok(Self { + source_id, + message, + priority, + trusted, + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "context item metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn source_id(&self) -> &str { + &self.source_id + } + + pub fn message(&self) -> &Message { + &self.message + } + + pub fn priority(&self) -> i32 { + self.priority + } + + pub fn trusted(&self) -> bool { + self.trusted + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +pub type ContextContribution = ContextItem; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContextErrorKind { + InvalidInput, + Unavailable, + BudgetExceeded, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContextError { + kind: ContextErrorKind, + message: String, +} + +impl ContextError { + pub fn new(kind: ContextErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> ContextErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ContextError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ContextError {} + +impl From for ContextError { + fn from(value: ContractError) -> Self { + Self::new(ContextErrorKind::InvalidInput, value.message()) + } +} + +impl From for CoreError { + fn from(value: ContextError) -> Self { + Self::new(ErrorKind::Context, value.message) + } +} + +/// 一个上下文源只负责提供候选项,不负责最终排序或 token 计费。 +pub trait ContextSource: Send + Sync { + fn contribute(&self, request: &ContextRequest) -> Result, ContextError>; +} diff --git a/rust/crates/agent-runtime-core/src/contract.rs b/rust/crates/agent-runtime-core/src/contract.rs new file mode 100644 index 000000000..0d8d6a9fc --- /dev/null +++ b/rust/crates/agent-runtime-core/src/contract.rs @@ -0,0 +1,194 @@ +//! Core 中立契约的轻量校验工具。 +//! +//! 这里故意不引入 UUID、HTTP 或数据库类型。宿主可以把自己的 ID 映射为 +//! 字符串,内核只保证它们适合事件和 JSON 持久化。 + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const IDENTIFIER_MAX_CHARS: usize = 128; + +/// 通用契约错误。适配器可以在自己的错误类型中包装它。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractError { + message: String, +} + +impl ContractError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for ContractError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ContractError {} + +/// 运行时使用的稳定标识。 +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Id(String); + +impl Id { + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier(&value, "id")?; + Ok(Self(value)) + } + + pub fn new(value: impl Into) -> Self { + // 便于内部构造已验证 ID;外部边界应优先使用 try_new。 + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(value: Id) -> Self { + value.0 + } +} + +impl<'de> Deserialize<'de> for Id { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::try_new(value).map_err(serde::de::Error::custom) + } +} + +impl TryFrom for Id { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::try_new(value) + } +} + +impl AsRef for Id { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +// 这些别名保持上层 API 轻量:具体宿主可以直接使用自己的 String ID, +// 也可以在边界处换成 Id 新类型做更严格校验。 +pub type SessionId = String; +pub type RunId = String; +pub type CallId = String; +pub type EventId = String; +pub type Revision = u64; + +pub(crate) fn validate_identifier(value: &str, field: &str) -> Result<(), ContractError> { + if value != value.trim() { + return Err(ContractError::new(format!("{field} 不得包含首尾空白"))); + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return Err(ContractError::new(format!("{field} 不能为空"))); + }; + if value.chars().count() > IDENTIFIER_MAX_CHARS + || !first.is_ascii_alphanumeric() + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/')) + { + return Err(ContractError::new(format!( + "{field} 不是合法稳定标识:{value}" + ))); + } + Ok(()) +} + +pub(crate) fn validate_non_empty(value: &str, field: &str) -> Result<(), ContractError> { + if value.trim().is_empty() { + Err(ContractError::new(format!("{field} 不能为空"))) + } else { + Ok(()) + } +} + +pub(crate) fn validate_object(value: &Value, field: &str) -> Result<(), ContractError> { + if value.is_object() { + Ok(()) + } else { + Err(ContractError::new(format!("{field} 必须是 JSON object"))) + } +} + +/// 用于输出中立错误的类别;不携带厂商特定状态码。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ErrorKind { + InvalidInput, + NotFound, + Conflict, + InvalidTransition, + Provider, + Tool, + Context, + Approval, + Store, + External, + Cancelled, +} + +/// 一个可复用的端口错误。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreError { + kind: ErrorKind, + message: String, +} + +impl CoreError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> ErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for CoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for CoreError {} + +impl From for CoreError { + fn from(value: ContractError) -> Self { + Self::new(ErrorKind::InvalidInput, value.message) + } +} diff --git a/rust/crates/agent-runtime-core/src/extension.rs b/rust/crates/agent-runtime-core/src/extension.rs new file mode 100644 index 000000000..00c7c1c02 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/extension.rs @@ -0,0 +1,387 @@ +//! MCP/Skill 的内核扩展端口。 +//! +//! 这里只描述“能力如何进入 Agent”,不规定 JSON-RPC、文件路径或安装方式。 +//! 具体 MCP/Skill crate 将自己的 DTO 转换成这些类型即可。 + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::context::ContextItem; +use crate::contract::{ContractError, validate_identifier, validate_non_empty, validate_object}; +use crate::tool::ToolDefinition; + +/// 工具从哪里来;来源信息用于审计和上下文标记,不代表权限。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + tag = "kind", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ToolOrigin { + Local, + Mcp { + server: String, + }, + Skill { + skill: String, + }, + /// 外部 Agent/backend 提供的工具;来源只用于审计,不代表已获批权限。 + External { + backend: String, + }, +} + +impl ToolOrigin { + pub fn mcp(server: impl Into) -> Result { + let server = server.into(); + validate_identifier(&server, "MCP server id")?; + Ok(Self::Mcp { server }) + } + + pub fn skill(skill: impl Into) -> Result { + let skill = skill.into(); + validate_identifier(&skill, "Skill id")?; + Ok(Self::Skill { skill }) + } + + pub fn external(backend: impl Into) -> Result { + let backend = backend.into(); + validate_identifier(&backend, "external backend id")?; + Ok(Self::External { backend }) + } + + /// Re-run the identifier checks for values decoded from a public wire + /// representation. The enum remains serde-compatible for old snapshots, + /// so callers at an execution/registration boundary must not assume that + /// one of the fallible constructors was used. + pub fn validate(&self) -> Result<(), ContractError> { + match self { + Self::Local => Ok(()), + Self::Mcp { server } => validate_identifier(server, "MCP server id"), + Self::Skill { skill } => validate_identifier(skill, "Skill id"), + Self::External { backend } => validate_identifier(backend, "external backend id"), + } + } +} + +/// 带来源的模型工具绑定。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "ToolBindingWire" +)] +pub struct ToolBinding { + tool: ToolDefinition, + origin: ToolOrigin, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ToolBindingWire { + tool: ToolDefinition, + origin: ToolOrigin, +} + +impl TryFrom for ToolBinding { + type Error = ContractError; + + fn try_from(value: ToolBindingWire) -> Result { + Self::try_new(value.tool, value.origin) + } +} + +impl ToolBinding { + /// Fallible constructor for untrusted or dynamically loaded bindings. + pub fn try_new(tool: ToolDefinition, origin: ToolOrigin) -> Result { + tool.validate()?; + origin.validate()?; + Ok(Self { tool, origin }) + } + + /// Compatibility constructor for statically assembled bindings. Dynamic + /// adapters should prefer [`Self::try_new`]; `validate` is available for a + /// final check before registration or execution. + pub fn new(tool: ToolDefinition, origin: ToolOrigin) -> Self { + Self { tool, origin } + } + + /// Re-run both nested contracts after serde or compatibility construction. + pub fn validate(&self) -> Result<(), ContractError> { + self.tool.validate()?; + self.origin.validate() + } + + pub fn tool(&self) -> &ToolDefinition { + &self.tool + } + + pub fn origin(&self) -> &ToolOrigin { + &self.origin + } +} + +/// 可列出模型工具的来源。MCP adapter 通常实现该端口并另行实现 ToolExecutor。 +pub trait ToolSource: Send + Sync { + fn list_tools(&self) -> Result, ExtensionError>; +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExtensionErrorKind { + InvalidInput, + Unavailable, + NotFound, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExtensionError { + kind: ExtensionErrorKind, + message: String, +} + +impl ExtensionError { + pub fn new(kind: ExtensionErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> ExtensionErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ExtensionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ExtensionError {} + +impl From for ExtensionError { + fn from(value: ContractError) -> Self { + Self::new(ExtensionErrorKind::InvalidInput, value.message()) + } +} + +/// Skill 的稳定目录项。正文和脚本由外部 SkillSource 按需加载。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "SkillDefinitionWire" +)] +pub struct SkillDefinition { + name: String, + description: String, + version: Option, + metadata: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SkillDefinitionWire { + name: String, + description: String, + version: Option, + metadata: Value, +} + +impl TryFrom for SkillDefinition { + type Error = ContractError; + + fn try_from(value: SkillDefinitionWire) -> Result { + let mut definition = Self::try_new(value.name, value.description)?; + if let Some(version) = value.version { + definition = definition.with_version(version); + } + definition.with_metadata(value.metadata) + } +} + +impl SkillDefinition { + pub fn try_new( + name: impl Into, + description: impl Into, + ) -> Result { + let name = name.into(); + let description = description.into(); + validate_identifier(&name, "Skill name")?; + validate_non_empty(&description, "Skill description")?; + let definition = Self { + name, + description, + version: None, + metadata: Value::Object(Map::new()), + }; + definition.validate()?; + Ok(definition) + } + + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "Skill metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// Re-run the stable directory-item invariants before activation or + /// registration. This also covers values decoded through serde. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.name, "Skill name")?; + validate_non_empty(&self.description, "Skill description")?; + validate_object(&self.metadata, "Skill metadata") + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +/// 显式激活后的 Skill 内容;激活本身不自动授予工具权限。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "SkillActivationWire" +)] +pub struct SkillActivation { + definition: SkillDefinition, + body: String, + context: Vec, + tools: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SkillActivationWire { + definition: SkillDefinition, + body: String, + context: Vec, + tools: Vec, +} + +impl TryFrom for SkillActivation { + type Error = ContractError; + + fn try_from(value: SkillActivationWire) -> Result { + Self::try_new(value.definition, value.body)? + .try_with_context(value.context)? + .try_with_tools(value.tools) + } +} + +impl SkillActivation { + pub fn try_new( + definition: SkillDefinition, + body: impl Into, + ) -> Result { + let body = body.into(); + validate_non_empty(&body, "Skill body")?; + let activation = Self { + definition, + body, + context: Vec::new(), + tools: Vec::new(), + }; + activation.validate()?; + Ok(activation) + } + + pub fn with_context(mut self, context: impl IntoIterator) -> Self { + self.context = context.into_iter().collect(); + self + } + + /// Fallible counterpart for adapters that receive context from a wire or + /// persisted representation. The old infallible method remains available + /// for statically validated callers. + pub fn try_with_context( + mut self, + context: impl IntoIterator, + ) -> Result { + self.context = context.into_iter().collect(); + self.validate()?; + Ok(self) + } + + pub fn with_tools(mut self, tools: impl IntoIterator) -> Self { + self.tools = tools.into_iter().collect(); + self + } + + /// Fallible counterpart for dynamically loaded tool bindings. + pub fn try_with_tools( + mut self, + tools: impl IntoIterator, + ) -> Result { + self.tools = tools.into_iter().collect(); + self.validate()?; + Ok(self) + } + + /// Re-run all nested definition, context and binding invariants before a + /// Skill activation is handed to Host/Engine. + pub fn validate(&self) -> Result<(), ContractError> { + self.definition.validate()?; + validate_non_empty(&self.body, "Skill body")?; + for item in &self.context { + let rebuilt = ContextItem::try_new( + item.source_id(), + item.message().clone(), + item.priority(), + item.trusted(), + )?; + rebuilt.with_metadata(item.metadata().clone())?; + } + for binding in &self.tools { + binding.validate()?; + } + Ok(()) + } + + pub fn definition(&self) -> &SkillDefinition { + &self.definition + } + + pub fn body(&self) -> &str { + &self.body + } + + pub fn context(&self) -> &[ContextItem] { + &self.context + } + + pub fn tools(&self) -> &[ToolBinding] { + &self.tools + } +} + +/// Skill loader/registry 的最小内核端口;文件发现和 frontmatter 解析在适配器。 +pub trait SkillSource: Send + Sync { + fn list_skills(&self) -> Result, ExtensionError>; + fn activate(&self, name: &str) -> Result; +} diff --git a/rust/crates/agent-runtime-core/src/lib.rs b/rust/crates/agent-runtime-core/src/lib.rs new file mode 100644 index 000000000..495106664 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/lib.rs @@ -0,0 +1,73 @@ +//! 可移植的 Agent 运行时内核。 +//! +//! 本 crate 只包含中立值类型、纯状态 reducer 和实现端口;不要在这里加入 +//! Tokio、HTTP、数据库、MCP 传输或具体产品规则。上层 engine/host 可以按需 +//! 将这些同步端口包装为异步任务。 + +mod approval; +mod capability; +mod catalog; +mod completion; +mod context; +mod contract; +mod extension; +mod message; +mod ports; +mod prompt; +mod provider; +mod runtime; +mod tool; + +pub use approval::{ + ApprovalDecision, ApprovalError, ApprovalErrorKind, ApprovalPolicy, ApprovalRequest, + DEFAULT_APPROVAL_TTL_MS, +}; +pub use capability::{CapabilityDefinition, CapabilityRegistry, CapabilityRegistryError}; +pub use catalog::{AgentCatalog, AgentDescriptor}; +pub use completion::{CompletionBlocker, CompletionDecision, CompletionPolicy}; +pub use context::{ + ContextContribution, ContextError, ContextErrorKind, ContextItem, ContextRequest, ContextSource, +}; +pub use contract::{ + CallId, ContractError, CoreError, ErrorKind, EventId, Id, Revision, RunId, SessionId, +}; +pub use extension::{ + ExtensionError, ExtensionErrorKind, SkillActivation, SkillDefinition, SkillSource, ToolBinding, + ToolOrigin, ToolSource, +}; +pub use message::{ContentPart, Message, MessageRole}; +pub use ports::{ + BackendRequest, BackendResult, EventSink, ExternalBackend, ExternalError, ExternalErrorKind, + ExternalObservation, ExternalObservationRequest, ExternalObservationSource, + ExternalObservationStatus, MAX_EXTERNAL_OBSERVATION_JSON_BYTES, NoopEventSink, VecEventSink, + backend_result_as_tool, +}; +pub use prompt::{PromptBuilder, PromptSection, PromptSectionKind}; +pub use provider::{ + AsyncModelProvider, ModelProvider, ProviderAdapter, ProviderCapability, ProviderContentPart, + ProviderDescriptor, ProviderError, ProviderErrorKind, ProviderFuture, ProviderId, + ProviderInstanceId, ProviderMessage, ProviderProtocolId, ProviderRegistry, + ProviderRegistryError, ProviderRequest, ProviderResponse, ProviderStreamEvent, + ProviderStreamSink, ProviderTarget, ProviderToolCall, ProviderToolChoice, + ProviderToolDefinition, ProviderUsage, ToolChoice, +}; +pub use runtime::{ + InMemoryRuntimeStore, RUN_SNAPSHOT_SCHEMA_VERSION, RUNTIME_EVENT_SCHEMA_VERSION, + RUNTIME_SNAPSHOT_SCHEMA_VERSION, RunSnapshot, RunStatus, RuntimeClock, RuntimeError, + RuntimeErrorKind, RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, RuntimeStore, + SharedRuntimeStore, StoreError, StoreErrorKind, SystemClock, apply_event, message_history_hash, + reduce, +}; +pub use tool::{ + AsyncToolExecutor, BoxFuture, ToolCall, ToolContext, ToolDefinition, ToolError, ToolErrorKind, + ToolExecutor, ToolRegistry, ToolResult, +}; + +// Provider/消息的旧命名别名,便于从已有运行时逐步迁移。 +pub type ProviderRole = MessageRole; + +// 迁移别名:单 Agent 中 action/observation 直接对应一次工具调用/结果; +// 多 Agent 扩展若需要更丰富字段,可在 orchestration crate 中包一层。 +pub type RuntimeAction = ToolCall; +pub type RuntimeObservation = ToolResult; +pub type RunRecord = RunSnapshot; diff --git a/rust/crates/agent-runtime-core/src/message.rs b/rust/crates/agent-runtime-core/src/message.rs new file mode 100644 index 000000000..452fde189 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/message.rs @@ -0,0 +1,191 @@ +//! 与具体模型厂商无关的消息类型。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::contract::{ContractError, validate_identifier, validate_non_empty, validate_object}; + +/// 对话中的角色。Developer 单独保留,避免把系统约束误当作用户内容。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum MessageRole { + System, + Developer, + User, + Assistant, + Tool, +} + +/// 消息内容块。未知厂商字段应由适配器放入 Image 的 source 或 metadata 中。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ContentPart { + Text { + text: String, + }, + Image { + source: Value, + }, + ToolCall { + id: String, + name: String, + arguments: Value, + }, + ToolResult { + tool_call_id: String, + output: Value, + is_error: bool, + }, +} + +impl ContentPart { + pub fn text(text: impl Into) -> Result { + let text = text.into(); + validate_non_empty(&text, "message text")?; + Ok(Self::Text { text }) + } + + pub fn image(source: Value) -> Result { + if !source.is_object() { + return Err(ContractError::new("image source 必须是 JSON object")); + } + Ok(Self::Image { source }) + } + + pub fn tool_call( + id: impl Into, + name: impl Into, + arguments: Value, + ) -> Result { + let id = id.into(); + let name = name.into(); + validate_identifier(&id, "tool call id")?; + validate_identifier(&name, "tool name")?; + if !arguments.is_object() { + return Err(ContractError::new("tool call arguments 必须是 JSON object")); + } + Ok(Self::ToolCall { + id, + name, + arguments, + }) + } + + pub fn tool_result( + tool_call_id: impl Into, + output: Value, + is_error: bool, + ) -> Result { + let tool_call_id = tool_call_id.into(); + validate_identifier(&tool_call_id, "tool call id")?; + Ok(Self::ToolResult { + tool_call_id, + output, + is_error, + }) + } + + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text { text } => Some(text), + _ => None, + } + } + + /// 重新执行构造器使用的字段校验。 + /// + /// `ContentPart` 公开支持 serde 解码,不能假定每个实例都经过 + /// `try_new`;Prompt/Provider 等边界在接收外部值时可以复用这个窄校验。 + pub fn validate(&self) -> Result<(), ContractError> { + match self { + Self::Text { text } => validate_non_empty(text, "message text"), + Self::Image { source } => validate_object(source, "image source"), + Self::ToolCall { + id, + name, + arguments, + } => validate_identifier(id, "tool call id") + .and_then(|_| validate_identifier(name, "tool name")) + .and_then(|_| validate_object(arguments, "tool call arguments")), + Self::ToolResult { tool_call_id, .. } => { + validate_identifier(tool_call_id, "tool call id") + } + } + } +} + +/// 一个模型请求中的消息。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Message { + role: MessageRole, + content: Vec, +} + +impl Message { + pub fn try_new( + role: MessageRole, + content: impl IntoIterator, + ) -> Result { + let content = content.into_iter().collect::>(); + if content.is_empty() { + return Err(ContractError::new("message content 不能为空")); + } + for part in &content { + part.validate()?; + } + Ok(Self { role, content }) + } + + /// 对已存在的消息重新执行完整内容校验,不改变其所有权。 + pub fn validate(&self) -> Result<(), ContractError> { + if self.content.is_empty() { + return Err(ContractError::new("message content 不能为空")); + } + for part in &self.content { + part.validate()?; + } + Ok(()) + } + + pub fn new(role: MessageRole, content: Vec) -> Self { + // 适用于已由上游校验的内容;网络/用户输入边界请使用 try_new。 + Self { role, content } + } + + pub fn text(role: MessageRole, text: impl Into) -> Result { + Self::try_new(role, [ContentPart::text(text)?]) + } + + pub fn system(text: impl Into) -> Result { + Self::text(MessageRole::System, text) + } + + pub fn developer(text: impl Into) -> Result { + Self::text(MessageRole::Developer, text) + } + + pub fn user(text: impl Into) -> Result { + Self::text(MessageRole::User, text) + } + + pub fn assistant(text: impl Into) -> Result { + Self::text(MessageRole::Assistant, text) + } + + pub fn role(&self) -> MessageRole { + self.role + } + + pub fn content(&self) -> &[ContentPart] { + &self.content + } + + pub fn into_parts(self) -> Vec { + self.content + } +} diff --git a/rust/crates/agent-runtime-core/src/ports.rs b/rust/crates/agent-runtime-core/src/ports.rs new file mode 100644 index 000000000..f8e49468b --- /dev/null +++ b/rust/crates/agent-runtime-core/src/ports.rs @@ -0,0 +1,579 @@ +//! 不同宿主实现的扩展端口。 + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::contract::{ContractError, CoreError, ErrorKind, validate_identifier, validate_object}; +use crate::runtime::RuntimeEvent; +use crate::tool::{ToolCall, ToolResult}; + +/// 单个外部对账观察值允许携带的 JSON 编码上限。 +/// +/// 观察端口只传递不透明 JSON,不把厂商响应格式带入 Core;仍需要一个硬 +/// 上限,避免适配器把未限制的远端正文直接塞进内存或后续上下文。需要更大 +/// 的厂商结果时,适配器应先在自己的边界做裁剪或存外部引用。 +pub const MAX_EXTERNAL_OBSERVATION_JSON_BYTES: usize = 1_048_576; + +/// 只观察已经提交的事件,不直接改变 RuntimeSnapshot。 +pub trait EventSink: Send { + fn emit(&mut self, event: &RuntimeEvent) -> Result<(), CoreError>; +} + +#[derive(Default)] +pub struct NoopEventSink; + +impl EventSink for NoopEventSink { + fn emit(&mut self, _event: &RuntimeEvent) -> Result<(), CoreError> { + Ok(()) + } +} + +/// 测试和 CLI 使用的简单事件收集器。 +#[derive(Clone, Debug, Default)] +pub struct VecEventSink { + events: Vec, +} + +impl VecEventSink { + pub fn new() -> Self { + Self::default() + } + + pub fn events(&self) -> &[RuntimeEvent] { + &self.events + } + + pub fn into_events(self) -> Vec { + self.events + } +} + +impl EventSink for VecEventSink { + fn emit(&mut self, event: &RuntimeEvent) -> Result<(), CoreError> { + self.events.push(event.clone()); + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "BackendRequestWire" +)] +pub struct BackendRequest { + request_id: String, + run_id: String, + operation: String, + payload: Value, + metadata: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct BackendRequestWire { + request_id: String, + run_id: String, + operation: String, + payload: Value, + metadata: Value, +} + +impl TryFrom for BackendRequest { + type Error = ContractError; + + fn try_from(value: BackendRequestWire) -> Result { + Self::try_new( + value.request_id, + value.run_id, + value.operation, + value.payload, + )? + .with_metadata(value.metadata) + } +} + +impl BackendRequest { + pub fn try_new( + request_id: impl Into, + run_id: impl Into, + operation: impl Into, + payload: Value, + ) -> Result { + let request_id = request_id.into(); + let run_id = run_id.into(); + let operation = operation.into(); + validate_identifier(&request_id, "backend request id")?; + validate_identifier(&run_id, "run id")?; + validate_identifier(&operation, "backend operation")?; + let request = Self { + request_id, + run_id, + operation, + payload, + metadata: Value::Object(Map::new()), + }; + request.validate()?; + Ok(request) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "backend request metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// Re-run the request contract before an external adapter is invoked. + /// Backend requests can be decoded from persisted/configuration JSON, so + /// callers must not rely solely on `try_new` having been used. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.request_id, "backend request id")?; + validate_identifier(&self.run_id, "run id")?; + validate_identifier(&self.operation, "backend operation")?; + validate_object(&self.metadata, "backend request metadata") + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn operation(&self) -> &str { + &self.operation + } + + pub fn payload(&self) -> &Value { + &self.payload + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "BackendResultWire" +)] +pub struct BackendResult { + request_id: String, + output: Value, + external_id: Option, + side_effect_unknown: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct BackendResultWire { + request_id: String, + output: Value, + external_id: Option, + side_effect_unknown: bool, +} + +impl TryFrom for BackendResult { + type Error = ContractError; + + fn try_from(value: BackendResultWire) -> Result { + let result = Self::try_new(value.request_id, value.output)?; + let result = match value.external_id { + Some(external_id) => result.with_external_id(external_id)?, + None => result, + }; + Ok(result.with_unknown_side_effect(value.side_effect_unknown)) + } +} + +impl BackendResult { + pub fn try_new(request_id: impl Into, output: Value) -> Result { + let request_id = request_id.into(); + validate_identifier(&request_id, "backend request id")?; + let result = Self { + request_id, + output, + external_id: None, + side_effect_unknown: false, + }; + result.validate()?; + Ok(result) + } + + pub fn with_external_id(mut self, id: impl Into) -> Result { + let id = id.into(); + validate_identifier(&id, "external id")?; + self.external_id = Some(id); + Ok(self) + } + + pub fn with_unknown_side_effect(mut self, unknown: bool) -> Self { + self.side_effect_unknown = unknown; + self + } + + /// Re-run identity fields before a result can be converted into a normal + /// tool result. The output remains opaque JSON by design. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.request_id, "backend request id")?; + if let Some(external_id) = &self.external_id { + validate_identifier(external_id, "external id")?; + } + Ok(()) + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub fn output(&self) -> &Value { + &self.output + } + + pub fn external_id(&self) -> Option<&str> { + self.external_id.as_deref() + } + + pub fn side_effect_unknown(&self) -> bool { + self.side_effect_unknown + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExternalErrorKind { + InvalidInput, + Unavailable, + Timeout, + UnknownSideEffect, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExternalError { + kind: ExternalErrorKind, + message: String, +} + +impl ExternalError { + pub fn new(kind: ExternalErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> ExternalErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ExternalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ExternalError {} + +impl From for ExternalError { + fn from(value: ContractError) -> Self { + Self::new(ExternalErrorKind::InvalidInput, value.message()) + } +} + +impl From for CoreError { + fn from(value: ExternalError) -> Self { + Self::new(ErrorKind::External, value.message) + } +} + +/// Codex、远程执行器等外部 Agent 后端实现的端口。 +pub trait ExternalBackend: Send + Sync { + fn invoke(&self, request: &BackendRequest) -> Result; + + fn cancel(&self, _request_id: &str) -> Result<(), ExternalError> { + Ok(()) + } +} + +/// 外部调用的只读对账请求。 +/// +/// `reference_id` 可以是 Provider request id、工具调用 id 或远端系统返回的 +/// external id。它只描述“查哪个已存在的调用”,不代表要重新发起调用; +/// `metadata` 用于租户/版本等非敏感路由信息,具体协议字段留在适配器。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "ExternalObservationRequestWire" +)] +pub struct ExternalObservationRequest { + backend: String, + operation: String, + reference_id: String, + metadata: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ExternalObservationRequestWire { + backend: String, + operation: String, + reference_id: String, + metadata: Value, +} + +impl TryFrom for ExternalObservationRequest { + type Error = ContractError; + + fn try_from(value: ExternalObservationRequestWire) -> Result { + Self::try_new(value.backend, value.operation, value.reference_id)? + .with_metadata(value.metadata) + } +} + +impl ExternalObservationRequest { + pub fn try_new( + backend: impl Into, + operation: impl Into, + reference_id: impl Into, + ) -> Result { + let backend = backend.into(); + let operation = operation.into(); + let reference_id = reference_id.into(); + validate_identifier(&backend, "external observation backend")?; + validate_identifier(&operation, "external observation operation")?; + validate_identifier(&reference_id, "external observation reference id")?; + Ok(Self { + backend, + operation, + reference_id, + metadata: Value::Object(Map::new()), + }) + } + + /// 只允许 object 元数据,并限制其编码大小;元数据不得包含 secret 原文。 + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_bounded_object(&metadata, "external observation metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn backend(&self) -> &str { + &self.backend + } + + pub fn operation(&self) -> &str { + &self.operation + } + + pub fn reference_id(&self) -> &str { + &self.reference_id + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +/// 只读查询能够确认的最小状态集合。 +/// +/// `Completed` 才带有可供调用方转换为 Core 消息/工具结果的 output;`Pending` +/// 和 `NotFound` 都不能被当作成功结果。查询失败则应通过 +/// [`ExternalError`] 返回,而不是伪造一个 `Completed`。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExternalObservationStatus { + Completed, + Pending, + NotFound, +} + +/// 外部系统对既有调用的只读观察结果。 +/// +/// Core 不解释 `output` 的厂商结构,也不会据此自动推进 checkpoint、重排队或 +/// 重放调用。宿主必须根据业务/协议校验结果后,显式调用 Host/Runtime 的 +/// reconciliation API。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + try_from = "ExternalObservationWire" +)] +pub struct ExternalObservation { + status: ExternalObservationStatus, + output: Option, + external_id: Option, + metadata: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ExternalObservationWire { + status: ExternalObservationStatus, + output: Option, + external_id: Option, + metadata: Value, +} + +impl TryFrom for ExternalObservation { + type Error = ContractError; + + fn try_from(value: ExternalObservationWire) -> Result { + let mut observation = match value.status { + ExternalObservationStatus::Completed => { + Self::completed(value.output.ok_or_else(|| { + ContractError::new("completed external observation 必须包含 output") + })?)? + } + ExternalObservationStatus::Pending => { + if value.output.is_some() { + return Err(ContractError::new( + "pending external observation 不得包含 output", + )); + } + Self::pending() + } + ExternalObservationStatus::NotFound => { + if value.output.is_some() { + return Err(ContractError::new( + "not-found external observation 不得包含 output", + )); + } + Self::not_found() + } + }; + if let Some(external_id) = value.external_id { + observation = observation.with_external_id(external_id)?; + } + observation.with_metadata(value.metadata) + } +} + +impl ExternalObservation { + /// 构造已完成观察;output 仍是不透明 JSON,大小受统一上限约束。 + pub fn completed(output: Value) -> Result { + validate_bounded_json(&output, "external observation output")?; + Ok(Self { + status: ExternalObservationStatus::Completed, + output: Some(output), + external_id: None, + metadata: Value::Object(Map::new()), + }) + } + + pub fn pending() -> Self { + Self { + status: ExternalObservationStatus::Pending, + output: None, + external_id: None, + metadata: Value::Object(Map::new()), + } + } + + pub fn not_found() -> Self { + Self { + status: ExternalObservationStatus::NotFound, + output: None, + external_id: None, + metadata: Value::Object(Map::new()), + } + } + + /// 保留远端返回的稳定 ID;它只用于后续显式对账关联,不触发任何调用。 + pub fn with_external_id( + mut self, + external_id: impl Into, + ) -> Result { + let external_id = external_id.into(); + validate_identifier(&external_id, "observed external id")?; + self.external_id = Some(external_id); + Ok(self) + } + + /// 只允许 object 元数据,并限制其编码大小;不得存放 secret 原文。 + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_bounded_object(&metadata, "external observation metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn status(&self) -> ExternalObservationStatus { + self.status + } + + pub fn output(&self) -> Option<&Value> { + self.output.as_ref() + } + + pub fn external_id(&self) -> Option<&str> { + self.external_id.as_deref() + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +/// 外部系统的只读对账观察端口。 +/// +/// 这是 `ExternalBackend::invoke` 的故意分离:实现该 trait 的适配器只能查询 +/// 已存在的引用,Core/Runtime 也不会自动调用它。同步端口便于无运行时依赖的 +/// 宿主实现;异步/HTTP 适配器可在自己的 crate 中完成阻塞或 future 到此端口的 +/// 转换。 +pub trait ExternalObservationSource: Send + Sync { + fn observe( + &self, + request: &ExternalObservationRequest, + ) -> Result; +} + +fn validate_bounded_object(value: &Value, field: &str) -> Result<(), ContractError> { + validate_object(value, field)?; + validate_bounded_json(value, field) +} + +fn validate_bounded_json(value: &Value, field: &str) -> Result<(), ContractError> { + let encoded = serde_json::to_vec(value) + .map_err(|error| ContractError::new(format!("{field} 无法编码: {error}")))?; + if encoded.len() > MAX_EXTERNAL_OBSERVATION_JSON_BYTES { + return Err(ContractError::new(format!( + "{field} 超过 {} bytes 上限", + MAX_EXTERNAL_OBSERVATION_JSON_BYTES + ))); + } + Ok(()) +} + +/// 将已确认完成的同步后端结果纳入普通工具结果。 +/// +/// `side_effect_unknown` 是一个明确的 reconciliation gate:未知结果不能 +/// 被转换成成功的 `ToolResult`,否则调用方可能把它写入 safe checkpoint +/// 并在恢复时重复执行外部副作用。需要保留该状态的宿主应直接记录 +/// `BackendResult`,待显式观察/对账后再构造工具结果。 +pub fn backend_result_as_tool( + result: &BackendResult, + call: &ToolCall, +) -> Result { + result.validate()?; + call.validate()?; + if result.request_id() != call.id() { + return Err(ContractError::new(format!( + "backend result request id 不匹配: expected={} actual={}", + call.id(), + result.request_id() + ))); + } + if result.side_effect_unknown() { + return Err(ContractError::new( + "backend result 副作用未知,必须先完成显式对账", + )); + } + ToolResult::success(call.id(), result.output().clone()) +} diff --git a/rust/crates/agent-runtime-core/src/prompt.rs b/rust/crates/agent-runtime-core/src/prompt.rs new file mode 100644 index 000000000..5e3533bc6 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/prompt.rs @@ -0,0 +1,324 @@ +//! 与 Provider 无关的 Prompt 组合契约。 +//! +//! `PromptSection` 让宿主在构造消息前明确区分系统约束、开发者约束、用户 +//! 输入和检索上下文。组合器只负责确定性地保留 section 顺序,不执行模板、 +//! 插值或权限判断;这些产品规则应由上层适配器显式提供。 + +use serde::{Deserialize, Serialize}; + +use crate::contract::{ContractError, validate_identifier}; +use crate::message::{ContentPart, Message, MessageRole}; + +/// Prompt section 的语义所有者。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PromptSectionKind { + System, + Developer, + User, + Context, +} + +impl PromptSectionKind { + /// 映射到 Core 消息角色。为保持旧 `MessageRole` API 兼容,Context 在 + /// 消息通道使用 user;section 自身仍保留完整 context 语义。 + pub fn role(self) -> MessageRole { + match self { + Self::System => MessageRole::System, + Self::Developer => MessageRole::Developer, + Self::User => MessageRole::User, + Self::Context => MessageRole::User, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::Developer => "developer", + Self::User => "user", + Self::Context => "context", + } + } +} + +/// 一个不可变 Prompt section。 +/// +/// `source_id` 和 `priority` 只描述上下文来源,便于审计和与 +/// `ContextAssembler` 对接;它们不会自动改变工具权限,也不会被拼进文本。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PromptSection { + kind: PromptSectionKind, + content: Vec, + source_id: Option, + priority: i32, +} + +impl PromptSection { + pub fn try_new( + kind: PromptSectionKind, + content: impl IntoIterator, + ) -> Result { + let content = content.into_iter().collect::>(); + if content.is_empty() { + return Err(ContractError::new("prompt section content 不能为空")); + } + // `ContentPart` 的 enum variants 公开且支持 serde,调用方可能绕过 + // `ContentPart::text/image/...` 构造器;section 作为新的值对象边界 + // 重新执行同一套字段校验,避免非法内容延迟到 Provider 才暴露。 + for part in &content { + part.validate()?; + } + Ok(Self { + kind, + content, + source_id: None, + priority: 0, + }) + } + + pub fn text(kind: PromptSectionKind, text: impl Into) -> Result { + Self::try_new(kind, [ContentPart::text(text)?]) + } + + pub fn system(text: impl Into) -> Result { + Self::text(PromptSectionKind::System, text) + } + + pub fn developer(text: impl Into) -> Result { + Self::text(PromptSectionKind::Developer, text) + } + + pub fn user(text: impl Into) -> Result { + Self::text(PromptSectionKind::User, text) + } + + pub fn context(text: impl Into) -> Result { + Self::text(PromptSectionKind::Context, text) + } + + /// 带来源和优先级的上下文快捷构造。 + pub fn context_from( + source_id: impl Into, + text: impl Into, + priority: i32, + ) -> Result { + Ok(Self::context(text)? + .with_source_id(source_id)? + .with_priority(priority)) + } + + pub fn with_source_id(mut self, source_id: impl Into) -> Result { + let source_id = source_id.into(); + validate_identifier(&source_id, "prompt source id")?; + self.source_id = Some(source_id); + Ok(self) + } + + pub fn with_priority(mut self, priority: i32) -> Self { + self.priority = priority; + self + } + + pub fn kind(&self) -> PromptSectionKind { + self.kind + } + + pub fn role(&self) -> MessageRole { + self.kind.role() + } + + pub fn content(&self) -> &[ContentPart] { + &self.content + } + + pub fn source_id(&self) -> Option<&str> { + self.source_id.as_deref() + } + + pub fn priority(&self) -> i32 { + self.priority + } + + /// 生成一条稳定的 Core Message;每个 section 对应一条 message,不做隐式 + /// 合并,避免相邻 section 的边界在恢复或审计时丢失。 + /// + /// 这是保留给已校验调用方的兼容入口;外部/serde 输入请使用 + /// [`PromptBuilder::build`] 或 [`PromptBuilder::into_messages`],它们会返回 + /// 校验错误而不是构造未验证消息。 + pub fn to_message(&self) -> Message { + Message::new(self.role(), self.content.clone()) + } + + fn try_to_message(&self) -> Result { + Message::try_new(self.role(), self.content.clone()) + } + + /// 兼容旧调用方的 infallible 入口,调用方须保证 section 内容已经校验。 + /// 新的外部输入路径应使用 [`PromptBuilder::into_messages`]。 + pub fn into_message(self) -> Message { + Message::new(self.role(), self.content) + } +} + +/// 轻量 Prompt 组合器。 +/// +/// `build` 保留调用方加入 section 的顺序。只要输入顺序相同,输出消息及其 +/// 角色边界就相同;组合器不依赖 HashMap 或时间等非确定性状态。 +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PromptBuilder { + sections: Vec, +} + +impl PromptBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn with_section(mut self, section: PromptSection) -> Self { + self.sections.push(section); + self + } + + pub fn push(&mut self, section: PromptSection) -> &mut Self { + self.sections.push(section); + self + } + + pub fn sections(&self) -> &[PromptSection] { + &self.sections + } + + pub fn is_empty(&self) -> bool { + self.sections.is_empty() + } + + pub fn len(&self) -> usize { + self.sections.len() + } + + pub fn system(self, text: impl Into) -> Result { + Ok(self.with_section(PromptSection::system(text)?)) + } + + pub fn developer(self, text: impl Into) -> Result { + Ok(self.with_section(PromptSection::developer(text)?)) + } + + pub fn user(self, text: impl Into) -> Result { + Ok(self.with_section(PromptSection::user(text)?)) + } + + pub fn context(self, text: impl Into) -> Result { + Ok(self.with_section(PromptSection::context(text)?)) + } + + pub fn context_from( + self, + source_id: impl Into, + text: impl Into, + priority: i32, + ) -> Result { + Ok(self.with_section(PromptSection::context_from(source_id, text, priority)?)) + } + + /// 将已组合的 section 按加入顺序转成消息。 + pub fn build(&self) -> Result, ContractError> { + if self.sections.is_empty() { + return Err(ContractError::new("prompt 至少需要一个 section")); + } + self.sections + .iter() + .map(PromptSection::try_to_message) + .collect() + } + + /// `build` 的语义别名,方便调用方按 Provider 请求语境命名。 + pub fn build_messages(&self) -> Result, ContractError> { + self.build() + } + + pub fn into_messages(self) -> Result, ContractError> { + if self.sections.is_empty() { + return Err(ContractError::new("prompt 至少需要一个 section")); + } + self.sections + .into_iter() + .map(|section| Message::try_new(section.role(), section.content)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builder_preserves_role_boundaries_and_order() { + let builder = PromptBuilder::new() + .system("system") + .unwrap() + .developer("developer") + .unwrap() + .context("retrieved") + .unwrap() + .user("question") + .unwrap(); + let messages = builder.build().unwrap(); + assert_eq!(messages.len(), 4); + assert_eq!(messages[0].role(), MessageRole::System); + assert_eq!(messages[1].role(), MessageRole::Developer); + assert_eq!(messages[2].role(), MessageRole::User); + assert_eq!(messages[3].role(), MessageRole::User); + assert_eq!(messages[2].content()[0].as_text(), Some("retrieved")); + } + + #[test] + fn context_source_and_priority_are_stable_metadata() { + let section = PromptSection::context_from("memory:recent", "facts", 7).unwrap(); + assert_eq!(section.kind(), PromptSectionKind::Context); + assert_eq!(section.source_id(), Some("memory:recent")); + assert_eq!(section.priority(), 7); + assert_eq!(section.to_message().role(), MessageRole::User); + } + + #[test] + fn empty_prompt_and_invalid_source_fail_closed() { + assert!(PromptBuilder::new().build().is_err()); + assert!(PromptSection::context_from("bad source", "facts", 0).is_err()); + } + + #[test] + fn section_constructor_rejects_invalid_deserialized_content() { + let invalid = ContentPart::Text { + text: String::new(), + }; + assert!(PromptSection::try_new(PromptSectionKind::User, [invalid]).is_err()); + } + + #[test] + fn prompt_serialization_round_trips_without_reordering() { + let mut builder = PromptBuilder::new(); + builder.push(PromptSection::system("one").unwrap()); + builder.push(PromptSection::context("two").unwrap()); + let encoded = serde_json::to_value(&builder).unwrap(); + let decoded: PromptBuilder = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.build().unwrap(), builder.build().unwrap()); + } + + #[test] + fn deserialized_invalid_content_is_rejected_before_provider_request() { + let value = serde_json::json!({ + "sections": [{ + "kind": "user", + "content": [{"type": "text", "text": ""}], + "sourceId": null, + "priority": 0 + }] + }); + let builder: PromptBuilder = serde_json::from_value(value).expect("serde value"); + assert!(builder.build().is_err()); + assert!(builder.into_messages().is_err()); + } +} diff --git a/rust/crates/agent-runtime-core/src/provider.rs b/rust/crates/agent-runtime-core/src/provider.rs new file mode 100644 index 000000000..43a470724 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/provider.rs @@ -0,0 +1,1458 @@ +//! 模型 Provider 的中立请求/响应契约。 +//! +//! HTTP、SSE、重试和厂商 SDK 都在 adapter crate;这里仅描述一次调用的值和 +//! 一个最小 object-safe 端口,engine 可以直接用同步方法或自行包装异步调用。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::contract::{ContractError, validate_identifier, validate_non_empty, validate_object}; +use crate::message::{ContentPart, Message}; +use crate::prompt::PromptBuilder; +use crate::tool::{ToolCall, ToolDefinition}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderCapability { + Streaming, + FunctionTools, + ImageInput, + Reasoning, + WebSearch, +} + +/// 配置中的 Provider 实例身份,例如 `openai-prod`。 +/// +/// 这是“用哪个已配置实例”的身份,不表示它使用的 wire protocol。它与 +/// [`ProviderProtocolId`] 使用不同的 Rust 类型,避免把一个协议的请求误发到 +/// 另一个适配器。旧的 [`ProviderId`] 只是这个类型的兼容别名;别名不会把 +/// `ProviderProtocolId` 变成同一个类型。 +/// +/// ```compile_fail +/// use agent_runtime_core::{ProviderId, ProviderProtocolId}; +/// +/// // Provider 实例身份不能隐式当成 wire protocol 身份传递。 +/// let _: ProviderProtocolId = ProviderId::from("openai-prod"); +/// ``` +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ProviderInstanceId(String); + +impl ProviderInstanceId { + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier(&value, "provider instance id")?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ProviderInstanceId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::try_new(value).map_err(serde::de::Error::custom) + } +} + +impl TryFrom for ProviderInstanceId { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::try_new(value) + } +} + +impl From<&str> for ProviderInstanceId { + fn from(value: &str) -> Self { + // 适用于代码中已知的静态 ID;动态配置请使用 try_new。 + Self(value.to_owned()) + } +} + +impl From for String { + fn from(value: ProviderInstanceId) -> Self { + value.0 + } +} + +impl AsRef for ProviderInstanceId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for ProviderInstanceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Provider 实例所遵循的 wire protocol,例如 `openai-responses`。 +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ProviderProtocolId(String); + +impl ProviderProtocolId { + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier(&value, "provider protocol id")?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ProviderProtocolId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::try_new(value).map_err(serde::de::Error::custom) + } +} + +impl TryFrom for ProviderProtocolId { + type Error = ContractError; + + fn try_from(value: String) -> Result { + Self::try_new(value) + } +} + +impl From<&str> for ProviderProtocolId { + fn from(value: &str) -> Self { + // 适用于代码中已知的静态协议 ID;动态配置请使用 try_new。 + Self(value.to_owned()) + } +} + +impl From for String { + fn from(value: ProviderProtocolId) -> Self { + value.0 + } +} + +impl AsRef for ProviderProtocolId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for ProviderProtocolId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 旧 API 的 Provider 实例别名,仅为迁移已有调用方保留;它不再用于协议 +/// 字段,协议必须使用独立的 [`ProviderProtocolId`]。新代码应直接使用 +/// [`ProviderInstanceId`],以便在字段名上表达“实例”语义。 +pub type ProviderId = ProviderInstanceId; + +/// 可选的实例/协议双身份;单 Agent Host 可以只提供实例身份。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderTarget { + #[serde(alias = "id", alias = "providerId")] + instance_id: ProviderInstanceId, + protocol_id: Option, +} + +impl ProviderTarget { + pub fn new(instance_id: ProviderInstanceId) -> Self { + Self { + instance_id, + protocol_id: None, + } + } + + pub fn with_protocol(mut self, protocol_id: ProviderProtocolId) -> Self { + self.protocol_id = Some(protocol_id); + self + } + + pub fn instance_id(&self) -> &ProviderInstanceId { + &self.instance_id + } + + /// `provider_id` 是实例身份的业务别名;它不会返回 protocol ID。 + pub fn provider_id(&self) -> &ProviderInstanceId { + &self.instance_id + } + + pub fn protocol_id(&self) -> Option<&ProviderProtocolId> { + self.protocol_id.as_ref() + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderDescriptor { + #[serde(alias = "id", alias = "providerId")] + instance_id: ProviderInstanceId, + protocol_id: Option, + display_name: String, + capabilities: BTreeSet, + metadata: Value, +} + +impl ProviderDescriptor { + pub fn try_new( + instance_id: ProviderInstanceId, + display_name: impl Into, + capabilities: impl IntoIterator, + ) -> Result { + let display_name = display_name.into(); + // `ProviderInstanceId::from(&str)` is retained as a convenient + // compatibility constructor for static IDs, so re-check the value at + // this public descriptor boundary instead of assuming the newtype was + // always created through `try_new`. + validate_identifier(instance_id.as_str(), "provider instance id")?; + validate_non_empty(&display_name, "provider display name")?; + Ok(Self { + instance_id, + protocol_id: None, + display_name, + capabilities: capabilities.into_iter().collect(), + metadata: Value::Object(Map::new()), + }) + } + + /// Re-run the descriptor invariants before registry insertion or other + /// adapter dispatch. `ProviderDescriptor` intentionally remains serde + /// compatible for persisted configuration, so a decoded value can bypass + /// `try_new`/`with_metadata`; callers need one small explicit validation + /// hook at that boundary. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(self.instance_id.as_str(), "provider instance id")?; + if let Some(protocol_id) = &self.protocol_id { + validate_identifier(protocol_id.as_str(), "provider protocol id")?; + } + validate_non_empty(&self.display_name, "provider display name")?; + validate_object(&self.metadata, "provider metadata") + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "provider metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn with_protocol_id(mut self, protocol_id: ProviderProtocolId) -> Self { + self.protocol_id = Some(protocol_id); + self + } + + pub fn instance_id(&self) -> &ProviderInstanceId { + &self.instance_id + } + + /// `provider_id` 明确表示“Provider 实例 ID”,不是协议 ID。 + pub fn provider_id(&self) -> &ProviderInstanceId { + &self.instance_id + } + + /// 兼容早期 `id()` 访问器;新代码应使用 [`Self::instance_id`]。 + #[deprecated(note = "use instance_id() or provider_id()")] + pub fn id(&self) -> &ProviderInstanceId { + &self.instance_id + } + + pub fn protocol_id(&self) -> Option<&ProviderProtocolId> { + self.protocol_id.as_ref() + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn capabilities(&self) -> &BTreeSet { + &self.capabilities + } + + pub fn supports(&self, capability: ProviderCapability) -> bool { + self.capabilities.contains(&capability) + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", content = "name", rename_all = "kebab-case")] +pub enum ToolChoice { + #[default] + Auto, + None, + Required, + Specific(String), +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderRequest { + request_id: String, + model: String, + messages: Vec, + tools: Vec, + tool_choice: ToolChoice, + max_output_tokens: Option, + metadata: Value, +} + +impl ProviderRequest { + pub fn try_new( + request_id: impl Into, + model: impl Into, + messages: impl IntoIterator, + ) -> Result { + let request_id = request_id.into(); + let model = model.into(); + let messages = messages.into_iter().collect::>(); + validate_identifier(&request_id, "provider request id")?; + validate_non_empty(&model, "provider model")?; + if messages.is_empty() { + return Err(ContractError::new("provider messages 不能为空")); + } + // `Message` 也能从 serde 或兼容的 `Message::new` 入口产生;Provider + // request 是进入适配器前的最后一个 Core 值对象边界,不能把非法 + // content 留给具体 wire adapter 猜测处理。 + for message in &messages { + message.validate()?; + } + Ok(Self { + request_id, + model, + messages, + tools: Vec::new(), + tool_choice: ToolChoice::Auto, + max_output_tokens: None, + metadata: Value::Object(Map::new()), + }) + } + + /// 从一等 Prompt 组合契约创建 Provider 请求。 + /// + /// 该入口只负责把 section 按既定顺序展开为 Core messages;工具目录、 + /// 选择策略和输出上限仍通过现有 builder 方法显式配置。 + pub fn try_from_prompt( + request_id: impl Into, + model: impl Into, + prompt: &PromptBuilder, + ) -> Result { + Self::try_new(request_id, model, prompt.build_messages()?) + } + + pub fn with_tools( + mut self, + tools: impl IntoIterator, + choice: ToolChoice, + ) -> Result { + self.tools = tools.into_iter().collect(); + self.tool_choice = choice; + self.validate_tools()?; + Ok(self) + } + + pub fn with_max_output_tokens(mut self, max: u32) -> Result { + if max == 0 { + return Err(ContractError::new("max output tokens 必须大于 0")); + } + self.max_output_tokens = Some(max); + Ok(self) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "provider request metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub fn model(&self) -> &str { + &self.model + } + + pub fn messages(&self) -> &[Message] { + &self.messages + } + + pub fn tools(&self) -> &[ToolDefinition] { + &self.tools + } + + pub fn tool_choice(&self) -> &ToolChoice { + &self.tool_choice + } + + pub fn max_output_tokens(&self) -> Option { + self.max_output_tokens + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } + + fn validate_tools(&self) -> Result<(), ContractError> { + for tool in &self.tools { + tool.validate()?; + } + let names = self + .tools + .iter() + .map(|tool| tool.name()) + .collect::>(); + if names.len() != self.tools.len() { + return Err(ContractError::new("provider tool name 重复")); + } + if matches!(self.tool_choice, ToolChoice::Required) && self.tools.is_empty() { + return Err(ContractError::new("required tool choice 需要工具")); + } + if let ToolChoice::Specific(name) = &self.tool_choice + && !names.contains(&name.as_str()) + { + return Err(ContractError::new("specific tool choice 引用了未知工具")); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderUsage { + input_tokens: u64, + output_tokens: u64, + total_tokens: u64, +} + +impl ProviderUsage { + pub fn new(input_tokens: u64, output_tokens: u64, total_tokens: u64) -> Self { + Self { + input_tokens, + output_tokens, + total_tokens, + } + } + + pub fn input_tokens(&self) -> u64 { + self.input_tokens + } + + pub fn output_tokens(&self) -> u64 { + self.output_tokens + } + + pub fn total_tokens(&self) -> u64 { + self.total_tokens + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProviderResponse { + request_id: String, + model: String, + content: Vec, + tool_calls: Vec, + finish_reason: Option, + usage: Option, + /// 厂商返回的可查询响应身份;不改变 Core 内部的本地 request_id。 + #[serde(default, skip_serializing_if = "Option::is_none")] + external_id: Option, +} + +impl ProviderResponse { + pub fn try_new( + request_id: impl Into, + model: impl Into, + content: impl IntoIterator, + tool_calls: impl IntoIterator, + ) -> Result { + let request_id = request_id.into(); + let model = model.into(); + validate_identifier(&request_id, "provider response id")?; + validate_non_empty(&model, "provider response model")?; + let content = content.into_iter().collect::>(); + let tool_calls = tool_calls.into_iter().collect::>(); + if content.is_empty() && tool_calls.is_empty() { + return Err(ContractError::new("provider response 不能为空")); + } + // Provider response 的 content/ToolCall 同样可能来自 serde 解码的 + // 不可信适配器值;在构造成功前重跑 Core 字段校验,避免默认 stream + // 或直接使用该 response 的调用方先观察到 malformed 数据。 + for part in &content { + part.validate()?; + } + for call in &tool_calls { + call.validate()?; + } + Ok(Self { + request_id, + model, + content, + tool_calls, + finish_reason: None, + usage: None, + external_id: None, + }) + } + + pub fn text( + request_id: impl Into, + model: impl Into, + text: impl Into, + ) -> Result { + Self::try_new(request_id, model, [ContentPart::text(text)?], []) + } + + /// Re-run the response envelope contract before publishing stream events. + /// + /// Provider implementations may return a value decoded directly from + /// serde, so Engine-level validation is not enough: the default stream + /// implementation must not notify observers about malformed deltas first. + /// This method deliberately checks only the neutral Core shape; the + /// Engine owns the stricter rule that structured tool parts belong in the + /// dedicated `tool_calls` field. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.request_id, "provider response id")?; + validate_non_empty(&self.model, "provider response model")?; + if self.content.is_empty() && self.tool_calls.is_empty() { + return Err(ContractError::new("provider response 不能为空")); + } + for part in &self.content { + part.validate()?; + } + for call in &self.tool_calls { + call.validate()?; + } + Ok(()) + } + + pub fn with_finish_reason(mut self, reason: impl Into) -> Self { + self.finish_reason = Some(reason.into()); + self + } + + pub fn with_usage(mut self, usage: ProviderUsage) -> Self { + self.usage = Some(usage); + self + } + + /// 保留厂商返回的响应 ID,供宿主在未知外部结果时查询或人工对账。 + pub fn with_external_id(mut self, external_id: impl Into) -> Self { + self.external_id = Some(external_id.into()); + self + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub fn model(&self) -> &str { + &self.model + } + + pub fn content(&self) -> &[ContentPart] { + &self.content + } + + pub fn tool_calls(&self) -> &[ToolCall] { + &self.tool_calls + } + + pub fn finish_reason(&self) -> Option<&str> { + self.finish_reason.as_deref() + } + + pub fn usage(&self) -> Option { + self.usage + } + + pub fn external_id(&self) -> Option<&str> { + self.external_id.as_deref() + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ProviderStreamEvent { + TextDelta { + delta: String, + accumulated: String, + }, + ToolCallDelta { + call_id: String, + name: Option, + arguments_delta: String, + }, + Usage { + usage: ProviderUsage, + }, + Completed, +} + +pub trait ProviderStreamSink { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError>; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderErrorKind { + InvalidRequest, + Unavailable, + Timeout, + Upstream, + Stream, + EmptyResponse, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderError { + kind: ProviderErrorKind, + message: String, + status_code: Option, +} + +impl ProviderError { + pub fn new(kind: ProviderErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + status_code: None, + } + } + + pub fn with_status_code(mut self, code: u16) -> Self { + self.status_code = Some(code); + self + } + + pub fn kind(&self) -> ProviderErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +impl fmt::Display for ProviderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ProviderError {} + +impl From for ProviderError { + fn from(value: ContractError) -> Self { + Self::new(ProviderErrorKind::InvalidRequest, value.message()) + } +} + +/// Engine 首选的同步 Provider 端口;网络适配器可在 Host 中包一层运行时。 +pub trait ModelProvider: Send + Sync { + fn complete(&self, request: &ProviderRequest) -> Result; + + /// 返回适配器实际实现的 wire protocol(如果适配器能够自描述)。 + /// + /// 这是可选的默认端口,以免要求所有自定义 Provider 在一次升级中 + /// 重写实现。内置的协议适配器应返回稳定的 [`ProviderProtocolId`]; + /// 返回 `None` 的旧/自定义 Provider 仍由调用方提供 descriptor。 + fn protocol_id(&self) -> Option { + None + } + + /// 默认实现把完整响应转换成最小事件流,真正的 SSE adapter 可覆盖它。 + fn stream( + &self, + request: &ProviderRequest, + sink: &mut dyn ProviderStreamSink, + ) -> Result { + let response = self.complete(request)?; + // Validate before the first observer callback. A custom Provider can + // construct this public response through serde or a compatibility + // constructor, and publishing its fields would otherwise expose a + // malformed event prefix even when Engine later rejects the response. + response.validate().map_err(ProviderError::from)?; + let mut accumulated = String::new(); + for part in response.content() { + if let Some(text) = part.as_text() { + accumulated.push_str(text); + sink.emit(ProviderStreamEvent::TextDelta { + delta: text.to_string(), + accumulated: accumulated.clone(), + })?; + } + } + // `complete` 返回的就是原生流式适配器最终组装的中立响应;回退事件流 + // 也必须保留其中的结构化工具调用和 usage,不能因为 Provider 没有 + // 原生 stream 实现就静默丢失这些信息。 + for call in response.tool_calls() { + sink.emit(ProviderStreamEvent::ToolCallDelta { + call_id: call.id().to_owned(), + name: Some(call.name().to_owned()), + arguments_delta: call.arguments_json(), + })?; + } + if let Some(usage) = response.usage() { + sink.emit(ProviderStreamEvent::Usage { usage })?; + } + sink.emit(ProviderStreamEvent::Completed)?; + Ok(response) + } +} + +/// 原生异步 Provider 的可选端口。返回 boxed future 保持 trait object-safe, +/// 且 core 不绑定 Tokio/futures crate。 +pub type ProviderFuture<'a, T> = Pin + Send + 'a>>; + +pub trait AsyncModelProvider: Send + Sync { + fn descriptor(&self) -> &ProviderDescriptor; + fn complete<'a>( + &'a self, + request: ProviderRequest, + ) -> ProviderFuture<'a, Result>; +} + +/// 早期版本使用的名称,作为异步端口别名保留。 +pub trait ProviderAdapter: AsyncModelProvider {} + +impl ProviderAdapter for T {} + +/// Provider 实例/协议解析失败。实例不存在与协议不匹配分开表达,调用方可以 +/// 在配置错误和路由错误之间做稳定分类,而不必解析错误字符串。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProviderRegistryError { + DuplicateInstance { + instance_id: String, + }, + UnknownInstance { + instance_id: String, + }, + ProtocolMismatch { + instance_id: String, + expected: String, + actual: Option, + }, +} + +impl fmt::Display for ProviderRegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateInstance { instance_id } => { + write!(f, "duplicate provider instance: {instance_id}") + } + Self::UnknownInstance { instance_id } => { + write!(f, "unknown provider instance: {instance_id}") + } + Self::ProtocolMismatch { + instance_id, + expected, + actual, + } => write!( + f, + "provider instance {instance_id} protocol mismatch: expected {expected}, actual {}", + actual.as_deref().unwrap_or("") + ), + } + } +} + +impl std::error::Error for ProviderRegistryError {} + +/// 一个按 Provider 实例 ID 查找的简单注册表,避免 Host 自行维护重复 map。 +/// 自描述适配器在注册时就会校验/补齐协议;调用方显式提供 +/// [`ProviderTarget`] 时,解析边界还会再次核对。单 Agent 路径可以只按实例 +/// ID 获取,不必虚构一个协议字段。 +#[derive(Default)] +pub struct ProviderRegistry { + providers: BTreeMap>, + descriptors: BTreeMap, +} + +impl ProviderRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register( + &mut self, + descriptor: ProviderDescriptor, + provider: Arc, + ) -> Result<(), ContractError> { + self.register_checked(descriptor, provider) + .map_err(|error| match error { + ProviderRegistryError::DuplicateInstance { .. } => { + ContractError::new("provider instance id 重复") + } + other => ContractError::new(other.to_string()), + }) + } + + /// 注册 Provider 并保留可按类型处理的实例/协议错误。 + /// + /// 旧的 [`Self::register`] 仍返回 `ContractError` 以保持兼容;新控制面 + /// 应优先使用这个入口,避免从展示字符串中解析重复实例或协议错配。 + pub fn register_checked( + &mut self, + mut descriptor: ProviderDescriptor, + provider: Arc, + ) -> Result<(), ProviderRegistryError> { + let instance_id = descriptor.instance_id.clone(); + if let Err(error) = descriptor.validate() { + // Keep the pre-existing typed error enum source-compatible rather + // than adding an `InvalidDescriptor` variant. The `actual` field + // carries the stable contract error for callers that need detail; + // the legacy `register` wrapper still exposes ContractError. + return Err(ProviderRegistryError::ProtocolMismatch { + instance_id: instance_id.as_str().to_owned(), + expected: "valid provider descriptor".to_owned(), + actual: Some(error.message().to_owned()), + }); + } + if self.providers.contains_key(&instance_id) { + return Err(ProviderRegistryError::DuplicateInstance { + instance_id: instance_id.as_str().to_owned(), + }); + } + + // 能自描述的适配器是协议身份的最终来源:拒绝把 Responses + // provider 伪装成 Chat provider;descriptor 未填写时则补上适配器 + // 自己报告的协议,让单 Agent 装配仍可省略重复配置。 + if let Some(actual) = provider.protocol_id() { + match descriptor.protocol_id.as_ref() { + Some(expected) if expected != &actual => { + return Err(ProviderRegistryError::ProtocolMismatch { + instance_id: instance_id.as_str().to_owned(), + expected: expected.as_str().to_owned(), + actual: Some(actual.as_str().to_owned()), + }); + } + None => descriptor.protocol_id = Some(actual), + Some(_) => {} + } + } + self.descriptors.insert(instance_id.clone(), descriptor); + self.providers.insert(instance_id, provider); + Ok(()) + } + + /// 按实例 ID 获取 Provider;这是单 Agent 最小路径。 + pub fn get(&self, instance_id: &str) -> Option> { + self.providers + .iter() + .find(|(id, _)| id.as_str() == instance_id) + .map(|(_, provider)| provider.clone()) + } + + pub fn get_instance(&self, instance_id: &ProviderInstanceId) -> Option> { + self.providers.get(instance_id).cloned() + } + + pub fn descriptor(&self, instance_id: &str) -> Option<&ProviderDescriptor> { + self.descriptors + .iter() + .find(|(id, _)| id.as_str() == instance_id) + .map(|(_, descriptor)| descriptor) + } + + pub fn descriptor_for( + &self, + target: &ProviderTarget, + ) -> Result<&ProviderDescriptor, ProviderRegistryError> { + let descriptor = self.descriptors.get(target.instance_id()).ok_or_else(|| { + ProviderRegistryError::UnknownInstance { + instance_id: target.instance_id().as_str().to_owned(), + } + })?; + if let Some(expected) = target.protocol_id() + && descriptor.protocol_id.as_ref() != Some(expected) + { + return Err(ProviderRegistryError::ProtocolMismatch { + instance_id: target.instance_id().as_str().to_owned(), + expected: expected.as_str().to_owned(), + actual: descriptor + .protocol_id + .as_ref() + .map(|id| id.as_str().to_owned()), + }); + } + // Re-check the adapter at resolution time as well. A custom Provider + // may expose a dynamic implementation, and the registry must not rely + // only on a stale caller-supplied descriptor. + if let Some(provider) = self.providers.get(target.instance_id()) + && let Some(actual) = provider.protocol_id() + && descriptor.protocol_id.as_ref() != Some(&actual) + { + return Err(ProviderRegistryError::ProtocolMismatch { + instance_id: target.instance_id().as_str().to_owned(), + expected: descriptor + .protocol_id + .as_ref() + .map_or_else(|| "".to_owned(), |id| id.as_str().to_owned()), + actual: Some(actual.as_str().to_owned()), + }); + } + Ok(descriptor) + } + + /// 解析实例并校验调用方预期的 protocol,再返回 Provider 实例。 + pub fn resolve( + &self, + target: &ProviderTarget, + ) -> Result, ProviderRegistryError> { + self.descriptor_for(target)?; + self.get_instance(target.instance_id()).ok_or_else(|| { + ProviderRegistryError::UnknownInstance { + instance_id: target.instance_id().as_str().to_owned(), + } + }) + } + + pub fn len(&self) -> usize { + self.providers.len() + } + + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } +} + +// 兼容早期 core 命名,避免上层迁移时同时改值对象。 +pub type ProviderMessage = Message; +pub type ProviderContentPart = ContentPart; +pub type ProviderToolDefinition = ToolDefinition; +pub type ProviderToolCall = ToolCall; +pub type ProviderToolChoice = ToolChoice; + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::MessageRole; + use crate::tool::ToolRegistry; + use serde_json::json; + use std::any::TypeId; + + struct TextProvider; + + impl ModelProvider for TextProvider { + fn complete(&self, request: &ProviderRequest) -> Result { + ProviderResponse::text(request.request_id(), request.model(), "ok").map_err(Into::into) + } + } + + struct ProtocolTextProvider(ProviderProtocolId); + + impl ModelProvider for ProtocolTextProvider { + fn complete(&self, request: &ProviderRequest) -> Result { + ProviderResponse::text(request.request_id(), request.model(), "ok").map_err(Into::into) + } + + fn protocol_id(&self) -> Option { + Some(self.0.clone()) + } + } + + struct StructuredResponseProvider; + + impl ModelProvider for StructuredResponseProvider { + fn complete(&self, request: &ProviderRequest) -> Result { + let call = ToolCall::try_new("call-1", "echo", json!({"text": "hello"}))?; + ProviderResponse::try_new( + request.request_id(), + request.model(), + [ContentPart::text("answer")?], + [call], + ) + .map(|response| response.with_usage(ProviderUsage::new(3, 2, 5))) + .map_err(Into::into) + } + } + + #[test] + fn default_stream_preserves_complete_tool_calls_and_usage() { + let request = ProviderRequest::try_new( + "request-structured", + "fake", + [Message::user("hello").expect("message")], + ) + .expect("request"); + let mut events = Vec::new(); + let response = StructuredResponseProvider + .stream(&request, &mut CollectingSink(&mut events)) + .expect("stream"); + + assert_eq!(response.tool_calls().len(), 1); + assert_eq!( + events, + vec![ + ProviderStreamEvent::TextDelta { + delta: "answer".to_owned(), + accumulated: "answer".to_owned(), + }, + ProviderStreamEvent::ToolCallDelta { + call_id: "call-1".to_owned(), + name: Some("echo".to_owned()), + arguments_delta: r#"{"text":"hello"}"#.to_owned(), + }, + ProviderStreamEvent::Usage { + usage: ProviderUsage::new(3, 2, 5), + }, + ProviderStreamEvent::Completed, + ] + ); + } + + #[test] + fn default_stream_rejects_malformed_response_before_observers() { + struct MalformedProvider; + + impl ModelProvider for MalformedProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + // Direct serde decoding intentionally bypasses the fallible + // response constructor; stream must validate before emitting. + serde_json::from_value(serde_json::json!({ + "requestId": request.request_id(), + "model": request.model(), + "content": [{"type": "text", "text": ""}], + "toolCalls": [], + "finishReason": null, + "usage": null + })) + .map_err(|error| { + ProviderError::new(ProviderErrorKind::InvalidRequest, error.to_string()) + }) + } + } + + let request = ProviderRequest::try_new( + "request-malformed-stream", + "fake", + [Message::user("hello").expect("message")], + ) + .expect("request"); + let mut events = Vec::new(); + let error = MalformedProvider + .stream(&request, &mut CollectingSink(&mut events)) + .expect_err("malformed response must be rejected"); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert!(events.is_empty()); + } + + #[test] + fn provider_constructors_reject_invalid_nested_messages_parts_and_calls() { + let invalid_message = Message::new(MessageRole::User, Vec::new()); + assert!(ProviderRequest::try_new("request-invalid", "fake", [invalid_message]).is_err()); + + let invalid_part = ContentPart::Text { + text: String::new(), + }; + assert!(ProviderResponse::try_new("response-invalid", "fake", [invalid_part], []).is_err()); + + // serde can bypass ToolCall::try_new; ProviderResponse must still + // reject a non-object arguments value before any stream/adapter use. + let invalid_call: ToolCall = serde_json::from_value(json!({ + "id": "call-invalid", + "name": "echo", + "arguments": [] + })) + .expect("unchecked serde fixture"); + assert!( + ProviderResponse::try_new("response-invalid-call", "fake", [], [invalid_call]).is_err() + ); + } + + #[test] + fn serde_invalid_tool_definition_is_rejected_by_request_and_registry() { + let invalid: ToolDefinition = serde_json::from_value(json!({ + "name": "echo", + "description": "echo", + "inputSchema": [], + "metadata": {} + })) + .expect("unchecked serde fixture"); + + assert!(invalid.validate().is_err()); + assert!(ToolRegistry::new().register(invalid.clone()).is_err()); + + let request = ProviderRequest::try_new( + "request-invalid-tool", + "fake", + [Message::user("hello").expect("message")], + ) + .expect("request"); + assert!(request.with_tools([invalid], ToolChoice::Auto).is_err()); + } + + struct CollectingSink<'a>(&'a mut Vec); + + impl ProviderStreamSink for CollectingSink<'_> { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError> { + self.0.push(event); + Ok(()) + } + } + + #[test] + fn instance_and_protocol_ids_validate_and_serialize_independently() { + let instance = ProviderInstanceId::try_new("openai-prod").unwrap(); + let protocol = ProviderProtocolId::try_new("openai-responses").unwrap(); + // TypeId catches an accidental type alias even when both values happen + // to contain the same string at the JSON boundary. + assert_ne!( + TypeId::of::(), + TypeId::of::() + ); + assert_eq!(instance.as_str(), "openai-prod"); + assert_eq!(protocol.as_str(), "openai-responses"); + assert_ne!( + serde_json::to_value(&instance).unwrap(), + serde_json::to_value(&protocol).unwrap() + ); + assert!(ProviderInstanceId::try_new("bad id").is_err()); + assert!(ProviderProtocolId::try_new("bad id").is_err()); + assert_eq!( + ProviderInstanceId::from("openai-prod").as_str(), + "openai-prod" + ); + assert_eq!( + ProviderProtocolId::from("openai-responses").as_str(), + "openai-responses" + ); + assert!(serde_json::from_value::(json!("bad id")).is_err()); + assert!(serde_json::from_value::(json!("bad id")).is_err()); + } + + #[test] + fn provider_target_round_trips_with_distinct_protocol_type() { + let target = ProviderTarget::new(ProviderInstanceId::from("openai-prod")) + .with_protocol(ProviderProtocolId::from("openai-responses")); + let encoded = serde_json::to_value(&target).unwrap(); + assert_eq!(encoded["instanceId"], "openai-prod"); + assert_eq!(encoded["protocolId"], "openai-responses"); + let decoded: ProviderTarget = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.instance_id().as_str(), "openai-prod"); + assert_eq!(decoded.provider_id().as_str(), "openai-prod"); + assert_eq!( + decoded.protocol_id().map(ProviderProtocolId::as_str), + Some("openai-responses") + ); + + // Older persisted targets used either `id` or `providerId`; retain + // input compatibility while keeping the explicit `instanceId` output. + for legacy_key in ["id", "providerId"] { + let legacy = serde_json::json!({ + legacy_key: "openai-prod", + "protocolId": "openai-responses" + }); + let decoded: ProviderTarget = serde_json::from_value(legacy).unwrap(); + assert_eq!(decoded.instance_id().as_str(), "openai-prod"); + assert_eq!( + serde_json::to_value(decoded).unwrap()["instanceId"], + "openai-prod" + ); + } + assert!( + serde_json::from_value::(serde_json::json!({ + "instanceId": "openai-prod", + "providerId": "openai-prod" + })) + .is_err() + ); + } + + #[test] + fn descriptor_and_registry_check_expected_protocol() { + let instance = ProviderInstanceId::try_new("openai-prod").unwrap(); + let protocol = ProviderProtocolId::try_new("openai-responses").unwrap(); + let descriptor = ProviderDescriptor::try_new( + instance.clone(), + "OpenAI production", + [ + ProviderCapability::Streaming, + ProviderCapability::FunctionTools, + ], + ) + .unwrap() + .with_protocol_id(protocol.clone()); + let encoded = serde_json::to_value(&descriptor).unwrap(); + assert_eq!(encoded["instanceId"], "openai-prod"); + assert_eq!(encoded["protocolId"], "openai-responses"); + // `id` remains an input alias only for old snapshots; new output is + // explicit about the instance/protocol distinction. + let legacy = json!({ + "id": "openai-prod", + "protocolId": "openai-responses", + "displayName": "OpenAI production", + "capabilities": ["streaming"], + "metadata": {} + }); + let decoded: ProviderDescriptor = serde_json::from_value(legacy).unwrap(); + assert_eq!(decoded.instance_id(), &instance); + + let mut registry = ProviderRegistry::new(); + registry + .register(descriptor, Arc::new(TextProvider)) + .unwrap(); + assert!(registry.get("openai-prod").is_some()); + assert!( + registry + .resolve(&ProviderTarget::new(instance.clone()).with_protocol(protocol)) + .is_ok() + ); + let mismatch = registry + .resolve( + &ProviderTarget::new(instance.clone()) + .with_protocol(ProviderProtocolId::try_new("openai-chat").unwrap()), + ) + .err() + .expect("协议不匹配必须失败"); + assert!(matches!( + mismatch, + ProviderRegistryError::ProtocolMismatch { + expected, + actual: Some(_), + .. + } if expected == "openai-chat" + )); + let unknown = registry + .resolve(&ProviderTarget::new(ProviderInstanceId::from("missing"))) + .err() + .expect("未知实例必须失败"); + assert!(matches!( + unknown, + ProviderRegistryError::UnknownInstance { instance_id } if instance_id == "missing" + )); + } + + #[test] + fn registry_allows_single_agent_lookup_without_protocol() { + let descriptor = + ProviderDescriptor::try_new(ProviderInstanceId::from("fake-local"), "Fake", []) + .unwrap(); + let mut registry = ProviderRegistry::new(); + registry + .register(descriptor, Arc::new(TextProvider)) + .unwrap(); + assert!( + registry + .resolve(&ProviderTarget::new(ProviderInstanceId::from("fake-local"))) + .is_ok() + ); + } + + #[test] + fn registry_keeps_same_protocol_instances_isolated() { + let protocol = ProviderProtocolId::from("openai-responses"); + let production = ProviderDescriptor::try_new( + ProviderInstanceId::from("openai-prod"), + "OpenAI production", + [], + ) + .unwrap() + .with_protocol_id(protocol.clone()); + let staging = ProviderDescriptor::try_new( + ProviderInstanceId::from("openai-staging"), + "OpenAI staging", + [], + ) + .unwrap() + .with_protocol_id(protocol.clone()); + + let mut registry = ProviderRegistry::new(); + registry + .register(production, Arc::new(TextProvider)) + .unwrap(); + registry.register(staging, Arc::new(TextProvider)).unwrap(); + + assert_eq!(registry.len(), 2); + for instance_id in ["openai-prod", "openai-staging"] { + let target = ProviderTarget::new(ProviderInstanceId::from(instance_id)) + .with_protocol(protocol.clone()); + assert!(registry.resolve(&target).is_ok(), "{instance_id}"); + } + assert!(registry.get("openai-prod").is_some()); + assert!(registry.get("openai-staging").is_some()); + } + + #[test] + fn registry_uses_adapter_protocol_identity_when_available() { + let actual = ProviderProtocolId::from("openai-responses"); + let instance = ProviderInstanceId::from("openai-prod"); + let descriptor = ProviderDescriptor::try_new(instance.clone(), "OpenAI", []) + .unwrap() + .with_protocol_id(actual.clone()); + let mut registry = ProviderRegistry::new(); + registry + .register( + descriptor, + Arc::new(ProtocolTextProvider(actual.clone())) as Arc, + ) + .expect("匹配的适配器协议应允许注册"); + assert_eq!( + registry.descriptor("openai-prod").unwrap().protocol_id(), + Some(&actual) + ); + + let mismatch = ProviderDescriptor::try_new( + ProviderInstanceId::from("openai-chat-instance"), + "OpenAI Chat", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("openai-chat")); + let error = registry + .register( + mismatch, + Arc::new(ProtocolTextProvider(actual)) as Arc, + ) + .expect_err("descriptor 与适配器协议不一致必须拒绝"); + assert!(error.message().contains("protocol mismatch")); + } + + #[test] + fn registry_derives_protocol_from_self_describing_adapter() { + let actual = ProviderProtocolId::from("self-describing"); + let descriptor = ProviderDescriptor::try_new( + ProviderInstanceId::from("self-describing-instance"), + "Self describing", + [], + ) + .unwrap(); + let mut registry = ProviderRegistry::new(); + registry + .register( + descriptor, + Arc::new(ProtocolTextProvider(actual.clone())) as Arc, + ) + .unwrap(); + assert_eq!( + registry + .descriptor("self-describing-instance") + .unwrap() + .protocol_id(), + Some(&actual) + ); + } + + #[test] + fn register_checked_preserves_typed_duplicate_and_protocol_errors() { + let actual = ProviderProtocolId::from("openai-responses"); + let mut registry = ProviderRegistry::new(); + let descriptor = + ProviderDescriptor::try_new(ProviderInstanceId::from("typed-instance"), "Typed", []) + .unwrap() + .with_protocol_id(actual.clone()); + registry + .register_checked( + descriptor.clone(), + Arc::new(ProtocolTextProvider(actual.clone())) as Arc, + ) + .unwrap(); + + let duplicate = registry + .register_checked( + descriptor, + Arc::new(ProtocolTextProvider(actual.clone())) as Arc, + ) + .expect_err("duplicate instance should remain typed"); + assert!(matches!( + duplicate, + ProviderRegistryError::DuplicateInstance { instance_id } + if instance_id == "typed-instance" + )); + + let mismatch = registry + .register_checked( + ProviderDescriptor::try_new( + ProviderInstanceId::from("mismatch-instance"), + "Mismatch", + [], + ) + .unwrap() + .with_protocol_id(ProviderProtocolId::from("openai-chat")), + Arc::new(ProtocolTextProvider(actual)) as Arc, + ) + .expect_err("protocol mismatch should remain typed"); + assert!(matches!( + mismatch, + ProviderRegistryError::ProtocolMismatch { + instance_id, + expected, + actual: Some(actual), + } if instance_id == "mismatch-instance" + && expected == "openai-chat" + && actual == "openai-responses" + )); + assert_eq!(registry.len(), 1); + } + + #[test] + fn registry_rejects_serde_invalid_descriptor_before_insert() { + // Derive-based serde can construct a descriptor without going through + // `try_new`/`with_metadata`; registry insertion is the last Core + // boundary before an adapter becomes selectable. + let invalid: ProviderDescriptor = serde_json::from_value(json!({ + "instanceId": "invalid-descriptor", + "protocolId": "openai-responses", + "displayName": "", + "capabilities": [], + "metadata": [] + })) + .expect("fixture should deserialize as an unchecked wire value"); + assert!(invalid.validate().is_err()); + + let mut registry = ProviderRegistry::new(); + let error = registry + .register_checked(invalid, Arc::new(TextProvider)) + .expect_err("invalid descriptor must not be registered"); + assert!(matches!( + error, + ProviderRegistryError::ProtocolMismatch { + expected, + actual: Some(actual), + .. + } if expected == "valid provider descriptor" + && actual.contains("provider display name") + )); + assert!(registry.is_empty()); + + // The fallible constructor also rejects an unchecked static ID before + // it can be wrapped in a descriptor. + assert!( + ProviderDescriptor::try_new(ProviderInstanceId::from("bad id"), "Display", []).is_err() + ); + } +} diff --git a/rust/crates/agent-runtime-core/src/runtime.rs b/rust/crates/agent-runtime-core/src/runtime.rs new file mode 100644 index 000000000..a9d5a4839 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/runtime.rs @@ -0,0 +1,2021 @@ +//! 运行状态、事件和纯 reducer。 +//! +//! Reducer 只复制值并应用一个已经提交的事件,不做 I/O、不启动任务,也不 +//! 重放未知副作用。宿主负责把事件和快照通过 RuntimeStore 原子保存。 + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::contract::{ContractError, validate_identifier, validate_non_empty, validate_object}; +use crate::message::{ContentPart, Message}; +use crate::tool::{ToolCall, ToolResult}; + +pub const RUNTIME_SNAPSHOT_SCHEMA_VERSION: &str = "agent-runtime-runtime.v1"; +pub const RUN_SNAPSHOT_SCHEMA_VERSION: &str = "agent-runtime-run.v1"; +/// RuntimeEvent 的 wire/persistence 版本。 +/// +/// 事件会被写入 SQLite、JSONL 和 RuntimeStore;版本字段必须随事件一起 +/// 持久化,reducer 只接受当前明确支持的版本,避免未知事件形状被静默重放。 +pub const RUNTIME_EVENT_SCHEMA_VERSION: &str = "agent-runtime-event.v1"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RunStatus { + Pending, + Running, + /// 正在压缩上下文;压缩完成后必须通过事件回到 `Running`。 + /// + /// 这是一个显式的非终态,避免压缩期间的 checkpoint/replay 把 run + /// 误认为可继续执行模型或工具调用。 + Compacting, + WaitingForTool, + WaitingApproval, + Paused, + Completed, + Failed, + Cancelled, + Reconciling, +} + +impl RunStatus { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } + + pub fn is_waiting(self) -> bool { + matches!( + self, + Self::Compacting | Self::WaitingForTool | Self::WaitingApproval | Self::Reconciling + ) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RunSnapshot { + pub schema_version: String, + pub run_id: String, + pub agent_id: String, + pub task: String, + pub revision: u64, + pub status: RunStatus, + pub messages: Vec, + pub tool_calls: Vec, + pub tool_results: Vec, + pub final_text: Option, + pub error: Option, + pub metadata: Value, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +impl RunSnapshot { + pub fn try_new( + run_id: impl Into, + agent_id: impl Into, + task: impl Into, + now_ms: u64, + ) -> Result { + let run_id = run_id.into(); + let agent_id = agent_id.into(); + let task = task.into(); + validate_identifier(&run_id, "run id")?; + validate_identifier(&agent_id, "agent id")?; + validate_non_empty(&task, "run task")?; + Ok(Self { + schema_version: RUN_SNAPSHOT_SCHEMA_VERSION.to_owned(), + run_id, + agent_id, + task, + revision: 0, + status: RunStatus::Pending, + messages: Vec::new(), + tool_calls: Vec::new(), + tool_results: Vec::new(), + final_text: None, + error: None, + metadata: Value::Object(Map::new()), + created_at_ms: now_ms, + updated_at_ms: now_ms, + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "run metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// Re-validates a snapshot decoded from a durable or wire representation. + /// + /// Serde intentionally remains available for the public wire structs, so a + /// decoded value may bypass the fallible constructors. Storage adapters + /// must call this boundary before exposing the value to Runtime/Engine. + pub fn validate(&self) -> Result<(), RuntimeError> { + validate_identifier(&self.run_id, "run id")?; + validate_identifier(&self.agent_id, "agent id")?; + validate_non_empty(&self.task, "run task")?; + if self.schema_version != RUN_SNAPSHOT_SCHEMA_VERSION { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "run schema version 不支持", + )); + } + if !self.metadata.is_object() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "run metadata 必须是 JSON object", + )); + } + for message in &self.messages { + validate_message_payload(message)?; + } + for call in &self.tool_calls { + validate_tool_call_payload(call)?; + } + for result in &self.tool_results { + validate_tool_result_payload(result)?; + } + let mut call_ids = std::collections::BTreeSet::new(); + for call in &self.tool_calls { + if !call_ids.insert(call.id()) { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "同一 run 的 tool call id 不能重复", + )); + } + } + let mut result_ids = std::collections::BTreeSet::new(); + for result in &self.tool_results { + if !call_ids.contains(result.call_id()) { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "tool result 引用了未知 call", + )); + } + if !result_ids.insert(result.call_id()) { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "同一 tool call 不能重复提交 result", + )); + } + } + if self.status == RunStatus::Completed && self.error.is_some() + || self.status == RunStatus::Failed && self.final_text.is_some() + || !self.status.is_terminal() && (self.final_text.is_some() || self.error.is_some()) + { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "run status 与终态字段不一致", + )); + } + Ok(()) + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + pub fn agent_id(&self) -> &str { + &self.agent_id + } + pub fn task(&self) -> &str { + &self.task + } + pub fn revision(&self) -> u64 { + self.revision + } + pub fn status(&self) -> RunStatus { + self.status + } + pub fn messages(&self) -> &[Message] { + &self.messages + } + pub fn tool_calls(&self) -> &[ToolCall] { + &self.tool_calls + } + pub fn tool_results(&self) -> &[ToolResult] { + &self.tool_results + } + pub fn final_text(&self) -> Option<&str> { + self.final_text.as_deref() + } + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +/// 一个运行时可以承载一个或多个独立 run;单 Agent Host 通常只使用一个。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeSnapshot { + pub schema_version: String, + pub runtime_id: String, + pub revision: u64, + pub runs: Vec, + pub metadata: Value, +} + +impl RuntimeSnapshot { + pub fn try_new(runtime_id: impl Into) -> Result { + let runtime_id = runtime_id.into(); + validate_identifier(&runtime_id, "runtime id")?; + Ok(Self { + schema_version: RUNTIME_SNAPSHOT_SCHEMA_VERSION.to_owned(), + runtime_id, + revision: 0, + runs: Vec::new(), + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "runtime metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// Re-validates a decoded durable snapshot, including every nested run. + pub fn validate(&self) -> Result<(), RuntimeError> { + validate_identifier(&self.runtime_id, "runtime id")?; + if self.schema_version != RUNTIME_SNAPSHOT_SCHEMA_VERSION { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "runtime schema version 不支持", + )); + } + if !self.metadata.is_object() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "runtime metadata 必须是 JSON object", + )); + } + let mut run_ids = std::collections::BTreeSet::new(); + for run in &self.runs { + if !run_ids.insert(run.run_id.as_str()) { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "runtime 中的 run id 不能重复", + )); + } + if run.revision > self.revision { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "run revision 不能超过 runtime revision", + )); + } + run.validate()?; + } + Ok(()) + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + pub fn revision(&self) -> u64 { + self.revision + } + pub fn runs(&self) -> &[RunSnapshot] { + &self.runs + } + pub fn run(&self, run_id: &str) -> Option<&RunSnapshot> { + self.runs.iter().find(|r| r.run_id == run_id) + } + pub fn metadata(&self) -> &Value { + &self.metadata + } + + pub fn run_mut(&mut self, run_id: &str) -> Option<&mut RunSnapshot> { + self.runs.iter_mut().find(|r| r.run_id == run_id) + } +} + +/// 事件类型保持小而稳定;detail 承载未来扩展字段。 +/// +/// `CompactionStarted`/`CompactionCompleted` 是一对可重放的状态边界: +/// 只有 `running -> compacting -> running` 的顺序会被 reducer 接受。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeEventKind { + RuntimeCreated, + RunCreated, + RunStarted, + CompactionStarted, + /// Replace the durable message context after a successful compression. + /// + /// Keeping this separate from `CompactionCompleted` makes the rewrite + /// explicit in the event log: a reducer replay can verify the exact + /// message hash instead of assuming that a status edge also changed data. + ContextCompacted, + CompactionCompleted, + MessageAppended, + ToolCallRequested, + ToolCallStarted, + ToolCallCompleted, + ToolCallFailed, + ReconciliationRequired, + ApprovalRequested, + ApprovalResolved, + RunPaused, + RunResumed, + RunCompleted, + RunFailed, + RunCancelled, + RunReconciled, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeEvent { + pub schema_version: String, + pub runtime_id: String, + pub revision: u64, + pub occurred_at_ms: u64, + pub kind: RuntimeEventKind, + pub run_id: Option, + pub detail: Value, +} + +impl RuntimeEvent { + /// 构造当前支持版本的事件。保留这个签名,避免调用方为版本字段承担 + /// 重复样板;需要测试/读取显式版本时使用 `new_with_schema_version`。 + pub fn new( + runtime_id: impl Into, + revision: u64, + occurred_at_ms: u64, + kind: RuntimeEventKind, + run_id: Option, + detail: Value, + ) -> Result { + Self::new_with_schema_version( + RUNTIME_EVENT_SCHEMA_VERSION, + runtime_id, + revision, + occurred_at_ms, + kind, + run_id, + detail, + ) + } + + /// 从持久化边界构造事件并保留其 schema 版本。 + /// + /// 构造器只拒绝空版本;是否为内核当前支持的版本由 reducer 在真正 + /// 应用事件前校验。这样未知版本不会在存储读取阶段被误认为已兼容。 + pub fn new_with_schema_version( + schema_version: impl Into, + runtime_id: impl Into, + revision: u64, + occurred_at_ms: u64, + kind: RuntimeEventKind, + run_id: Option, + detail: Value, + ) -> Result { + let schema_version = schema_version.into(); + validate_non_empty(&schema_version, "runtime event schema version")?; + let runtime_id = runtime_id.into(); + validate_identifier(&runtime_id, "runtime id")?; + if let Some(run_id) = &run_id { + validate_identifier(run_id, "run id")?; + } + if !detail.is_object() { + return Err(ContractError::new( + "runtime event detail 必须是 JSON object", + )); + } + Ok(Self { + schema_version, + runtime_id, + revision, + occurred_at_ms, + kind, + run_id, + detail, + }) + } + + /// 以显式版本复制事件,便于适配器保留原始 wire 版本。 + pub fn with_schema_version( + mut self, + schema_version: impl Into, + ) -> Result { + let schema_version = schema_version.into(); + validate_non_empty(&schema_version, "runtime event schema version")?; + self.schema_version = schema_version; + Ok(self) + } + + pub fn schema_version(&self) -> &str { + &self.schema_version + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + pub fn revision(&self) -> u64 { + self.revision + } + pub fn occurred_at_ms(&self) -> u64 { + self.occurred_at_ms + } + pub fn kind(&self) -> RuntimeEventKind { + self.kind + } + pub fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } + pub fn detail(&self) -> &Value { + &self.detail + } + + pub fn runtime_created( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::RuntimeCreated, + None, + json_object(), + ) + } + + pub fn run_created( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run: &RunSnapshot, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::RunCreated, + Some(run.run_id.clone()), + serde_json::to_value(run).map_err(|e| ContractError::new(e.to_string()))?, + ) + } + + pub fn status_changed( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + kind: RuntimeEventKind, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + kind, + Some(run_id.into()), + json_object(), + ) + } + + /// 标记上下文压缩开始。压缩结果本身由 Engine/适配器负责,Core 只 + /// 记录可重放的状态边界。 + pub fn compaction_started( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + ) -> Result { + Self::status_changed( + runtime_id, + revision, + now_ms, + run_id, + RuntimeEventKind::CompactionStarted, + ) + } + + /// 标记上下文压缩完成,并让 run 回到可继续执行的 `Running` 状态。 + pub fn compaction_completed( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + ) -> Result { + Self::status_changed( + runtime_id, + revision, + now_ms, + run_id, + RuntimeEventKind::CompactionCompleted, + ) + } + + /// Record the exact message history produced by a compression boundary. + /// The reducer validates the embedded SHA-256 before replacing the run's + /// context, so a truncated or mixed checkpoint cannot silently become the + /// new durable history. + pub fn context_compacted( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + step: usize, + messages: &[Message], + ) -> Result { + if messages.is_empty() { + return Err(ContractError::new("压缩后的消息历史不能为空")); + } + for message in messages { + validate_message_payload(message) + .map_err(|error| ContractError::new(format!("压缩后的消息无效: {error}")))?; + } + let (tool_calls, tool_results) = extract_message_tools(messages)?; + let messages_hash = message_history_hash(messages)?; + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::ContextCompacted, + Some(run_id.into()), + serde_json::json!({ + "step": step, + "messages": messages, + "messages_hash": messages_hash, + "tool_calls": tool_calls, + "tool_results": tool_results, + }), + ) + } + + pub fn message_appended( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + message: &Message, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::MessageAppended, + Some(run_id.into()), + serde_json::to_value(message).map_err(|e| ContractError::new(e.to_string()))?, + ) + } + + pub fn tool_call_requested( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + call: &ToolCall, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::ToolCallRequested, + Some(run_id.into()), + serde_json::to_value(call).map_err(|e| ContractError::new(e.to_string()))?, + ) + } + + pub fn tool_result( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + result: &ToolResult, + failed: bool, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + if failed { + RuntimeEventKind::ToolCallFailed + } else { + RuntimeEventKind::ToolCallCompleted + }, + Some(run_id.into()), + serde_json::to_value(result).map_err(|e| ContractError::new(e.to_string()))?, + ) + } + + pub fn completed( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + summary: impl Into, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::RunCompleted, + Some(run_id.into()), + serde_json::json!({"summary": summary.into()}), + ) + } + + pub fn failed( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + error: impl Into, + ) -> Result { + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::RunFailed, + Some(run_id.into()), + serde_json::json!({"error": error.into()}), + ) + } + + /// Mark a reconciliation gate as safe to reopen. The explicit evidence + /// fields prevent a bare status transition from accidentally replaying an + /// unknown provider/tool side effect. + pub fn reconciled( + runtime_id: impl Into, + revision: u64, + now_ms: u64, + run_id: impl Into, + pending_approval_call_id: Option<&str>, + ) -> Result { + let mut detail = Map::new(); + detail.insert("reconciled".to_owned(), Value::Bool(true)); + detail.insert("external_pending".to_owned(), Value::Bool(false)); + if let Some(call_id) = pending_approval_call_id { + validate_identifier(call_id, "pending approval call id")?; + detail.insert( + "pending_approval_call_id".to_owned(), + Value::String(call_id.to_owned()), + ); + } + Self::new( + runtime_id, + revision, + now_ms, + RuntimeEventKind::RunReconciled, + Some(run_id.into()), + Value::Object(detail), + ) + } +} + +fn json_object() -> Value { + Value::Object(Map::new()) +} + +/// Return a deterministic SHA-256 for a message history. +/// +/// `Message` uses a stable serde representation, so hashing the JSON bytes is +/// sufficient for the local durable-boundary contract. The helper stays in +/// Core (rather than an adapter) so every store/reducer observes the same +/// digest without importing a transport or database concern. +pub fn message_history_hash(messages: &[Message]) -> Result { + let encoded = serde_json::to_vec(messages) + .map_err(|error| ContractError::new(format!("消息历史无法计算 hash: {error}")))?; + Ok(format!("{:x}", Sha256::digest(encoded))) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeErrorKind { + InvalidInput, + NotFound, + Conflict, + InvalidTransition, + Store, + InvalidEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimeError { + kind: RuntimeErrorKind, + message: String, +} + +impl RuntimeError { + pub fn new(kind: RuntimeErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + pub fn kind(&self) -> RuntimeErrorKind { + self.kind + } + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for RuntimeError {} +impl From for RuntimeError { + fn from(value: ContractError) -> Self { + Self::new(RuntimeErrorKind::InvalidInput, value.message()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreErrorKind { + Conflict, + Unavailable, + InvalidSnapshot, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreError { + kind: StoreErrorKind, + message: String, +} + +impl StoreError { + pub fn new(kind: StoreErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + pub fn kind(&self) -> StoreErrorKind { + self.kind + } + pub fn message(&self) -> &str { + &self.message + } + pub fn conflict(message: impl Into) -> Self { + Self::new(StoreErrorKind::Conflict, message) + } +} +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for StoreError {} + +/// Snapshot 与事件必须在一次 commit 中以 expected_revision 做 CAS。 +pub trait RuntimeStore: Send { + fn load(&self, runtime_id: &str) -> Result, StoreError>; + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), StoreError>; +} + +/// 为需要跨线程共享一个 RuntimeStore 的宿主提供最小同步适配器。 +/// +/// `RuntimeStore::commit` 本身是 `&mut self` CAS 合同;调用方若把同一个 +/// Store 交给多个 RuntimeSnapshotService,不能靠每个 service 各自持有一份 +/// clone 来保证串行。这个 wrapper 只增加标准库的 `Arc>`,不改变 +/// 底层 Store 的事件/快照语义,也不把锁策略带入 Core reducer。 +/// +/// 锁中毒会按 `Unavailable` 返回,而不是在控制面 panic。`with_store`/ +/// `with_store_mut` 只用于适配器专属的窄操作;普通 load/commit 通过 +/// `RuntimeStore` trait 自动获得同一把锁。 +pub struct SharedRuntimeStore { + inner: Arc>, +} + +impl Clone for SharedRuntimeStore { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl std::fmt::Debug for SharedRuntimeStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SharedRuntimeStore") + .finish_non_exhaustive() + } +} + +impl SharedRuntimeStore { + /// 用一个已经构造好的 Store 创建共享 wrapper。 + pub fn new(store: S) -> Self { + Self { + inner: Arc::new(Mutex::new(store)), + } + } + + /// 在共享 Store 上执行只读的适配器专属操作。 + pub fn with_store(&self, operation: impl FnOnce(&S) -> R) -> Result { + let store = self.inner.lock().map_err(|_| { + StoreError::new(StoreErrorKind::Unavailable, "RuntimeStore 共享锁已中毒") + })?; + Ok(operation(&store)) + } + + /// 在共享 Store 上执行可变的适配器专属操作。 + pub fn with_store_mut(&self, operation: impl FnOnce(&mut S) -> R) -> Result { + let mut store = self.inner.lock().map_err(|_| { + StoreError::new(StoreErrorKind::Unavailable, "RuntimeStore 共享锁已中毒") + })?; + Ok(operation(&mut store)) + } +} + +impl RuntimeStore for SharedRuntimeStore +where + S: RuntimeStore, +{ + fn load(&self, runtime_id: &str) -> Result, StoreError> { + self.with_store(|store| store.load(runtime_id))? + } + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), StoreError> { + self.with_store_mut(|store| store.commit(runtime_id, expected_revision, snapshot, events))? + } +} + +/// 纯 reducer:输入旧快照和一个事件,输出新快照;不会修改外部状态。 +pub fn reduce( + snapshot: &RuntimeSnapshot, + event: &RuntimeEvent, +) -> Result { + let mut next = snapshot.clone(); + apply_event(&mut next, event)?; + Ok(next) +} + +pub fn apply_event( + snapshot: &mut RuntimeSnapshot, + event: &RuntimeEvent, +) -> Result<(), RuntimeError> { + if event.schema_version != RUNTIME_EVENT_SCHEMA_VERSION { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + format!( + "runtime event schema version 不支持: {}", + event.schema_version + ), + )); + } + if snapshot.runtime_id != event.runtime_id { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "event runtime id 不匹配", + )); + } + // revision 从 1 开始;runtime 创建事件也占用第一个 revision。 + let expected = snapshot + .revision + .checked_add(1) + .ok_or_else(|| RuntimeError::new(RuntimeErrorKind::Conflict, "revision 溢出"))?; + if event.revision != expected { + return Err(RuntimeError::new( + RuntimeErrorKind::Conflict, + format!( + "event revision 不连续:expected={expected} actual={}", + event.revision + ), + )); + } + match event.kind { + RuntimeEventKind::RuntimeCreated => { + if snapshot.revision != 0 || !snapshot.runs.is_empty() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + "runtime 已创建", + )); + } + } + RuntimeEventKind::RunCreated => { + let run: RunSnapshot = serde_json::from_value(event.detail.clone()) + .map_err(|e| RuntimeError::new(RuntimeErrorKind::InvalidEvent, e.to_string()))?; + if event.run_id.as_deref() != Some(run.run_id.as_str()) + || snapshot.run(&run.run_id).is_some() + { + return Err(RuntimeError::new( + RuntimeErrorKind::Conflict, + "run identity 重复或不匹配", + )); + } + next_validate_run(&run)?; + snapshot.runs.push(run); + } + RuntimeEventKind::RunStarted => { + set_status(snapshot, event, &[RunStatus::Pending], RunStatus::Running)? + } + RuntimeEventKind::CompactionStarted => set_status( + snapshot, + event, + &[RunStatus::Running], + RunStatus::Compacting, + )?, + RuntimeEventKind::ContextCompacted => { + let run = find_run(snapshot, event)?; + require_status(run, &[RunStatus::Compacting], "rewrite compacted context")?; + let messages_value = event.detail.get("messages").ok_or_else(|| { + RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted 缺少 messages", + ) + })?; + let messages: Vec = + serde_json::from_value(messages_value.clone()).map_err(|error| { + RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string()) + })?; + if messages.is_empty() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted messages 不能为空", + )); + } + for message in &messages { + validate_message_payload(message)?; + } + let (tool_calls, tool_results) = extract_message_tools(&messages).map_err(|error| { + RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string()) + })?; + let expected_hash = event + .detail + .get("messages_hash") + .and_then(Value::as_str) + .ok_or_else(|| { + RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted 缺少 messages_hash", + ) + })?; + let actual_hash = message_history_hash(&messages).map_err(|error| { + RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string()) + })?; + if expected_hash != actual_hash { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted messages_hash 不匹配", + )); + } + let encoded_calls = event.detail.get("tool_calls").ok_or_else(|| { + RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted 缺少 tool_calls", + ) + })?; + let encoded_results = event.detail.get("tool_results").ok_or_else(|| { + RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted 缺少 tool_results", + ) + })?; + let declared_calls: Vec = serde_json::from_value(encoded_calls.clone()) + .map_err(|error| { + RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string()) + })?; + let declared_results: Vec = serde_json::from_value(encoded_results.clone()) + .map_err(|error| { + RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string()) + })?; + if declared_calls != tool_calls || declared_results != tool_results { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "ContextCompacted tool 索引与消息历史不一致", + )); + } + // Validate every derived field before mutating the caller-owned + // snapshot. `apply_event` is public and some stores use it + // directly; returning an error must therefore leave both the + // message history and revision untouched, not only the cloning + // path used by `reduce`. + run.messages = messages; + run.tool_calls = declared_calls; + run.tool_results = declared_results; + } + RuntimeEventKind::CompactionCompleted => set_status( + snapshot, + event, + &[RunStatus::Compacting], + RunStatus::Running, + )?, + RuntimeEventKind::MessageAppended => { + let run = find_run(snapshot, event)?; + if run.status.is_terminal() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + "终态 run 不能追加消息", + )); + } + let message: Message = serde_json::from_value(event.detail.clone()) + .map_err(|e| RuntimeError::new(RuntimeErrorKind::InvalidEvent, e.to_string()))?; + // `serde` can construct public wire structs without running their + // fallible constructors. Re-check the nested value before it is + // accepted into reducer state. + validate_message_payload(&message)?; + run.messages.push(message); + } + RuntimeEventKind::ToolCallRequested => { + let call: ToolCall = serde_json::from_value(event.detail.clone()) + .map_err(|e| RuntimeError::new(RuntimeErrorKind::InvalidEvent, e.to_string()))?; + validate_tool_call_payload(&call)?; + let run = find_run(snapshot, event)?; + // 同一轮模型可能返回多个调用;后续调用到达时 run 已在 waiting-for-tool, + // 仍允许追加,直到 Host 按顺序提交对应结果。 + require_status( + run, + &[ + RunStatus::Running, + RunStatus::WaitingForTool, + RunStatus::Reconciling, + ], + "request tool", + )?; + if run + .tool_calls + .iter() + .any(|previous| previous.id() == call.id()) + { + return Err(RuntimeError::new( + RuntimeErrorKind::Conflict, + "同一 run 的 tool call id 不能重复", + )); + } + run.tool_calls.push(call); + // 对账时只补齐已经观察到的调用,不提前把 gate 暴露成可运行; + // `RunReconciled` 会在整个 checkpoint 应用完成后统一切回 Running。 + if run.status != RunStatus::Reconciling { + run.status = RunStatus::WaitingForTool; + } + } + RuntimeEventKind::ToolCallStarted => set_status( + snapshot, + event, + &[RunStatus::WaitingForTool], + RunStatus::WaitingForTool, + )?, + RuntimeEventKind::ToolCallCompleted | RuntimeEventKind::ToolCallFailed => { + let result: ToolResult = serde_json::from_value(event.detail.clone()) + .map_err(|e| RuntimeError::new(RuntimeErrorKind::InvalidEvent, e.to_string()))?; + validate_tool_result_payload(&result)?; + let run = find_run(snapshot, event)?; + require_status( + run, + &[ + RunStatus::WaitingForTool, + RunStatus::Running, + RunStatus::Reconciling, + ], + "tool result", + )?; + if !run + .tool_calls + .iter() + .any(|call| call.id() == result.call_id()) + { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "tool result 引用了未知 call", + )); + } + if run + .tool_results + .iter() + .any(|previous| previous.call_id() == result.call_id()) + { + return Err(RuntimeError::new( + RuntimeErrorKind::Conflict, + "同一 tool call 不能重复提交 result", + )); + } + run.tool_results.push(result); + let all_calls_observed = run.tool_calls.iter().all(|call| { + run.tool_results + .iter() + .any(|item| item.call_id() == call.id()) + }); + if run.status != RunStatus::Reconciling { + run.status = if all_calls_observed { + RunStatus::Running + } else { + RunStatus::WaitingForTool + }; + } + } + RuntimeEventKind::ReconciliationRequired => { + // 审批进程中断时只允许进入显式对账门(或由 RunCancelled 收口)。 + // Core 不把旧 ApprovalRequest 自动恢复为 allowed/denied;宿主必须 + // 明确处理该 gate,随后才可用 RunReconciled 重新开放运行。 + set_status( + snapshot, + event, + &[ + RunStatus::WaitingForTool, + RunStatus::WaitingApproval, + RunStatus::Running, + RunStatus::Compacting, + ], + RunStatus::Reconciling, + )?; + } + RuntimeEventKind::ApprovalRequested => set_status( + snapshot, + event, + &[RunStatus::Running, RunStatus::WaitingForTool], + RunStatus::WaitingApproval, + )?, + RuntimeEventKind::ApprovalResolved => set_status( + snapshot, + event, + &[RunStatus::WaitingApproval], + RunStatus::Running, + )?, + RuntimeEventKind::RunPaused => { + set_status(snapshot, event, &[RunStatus::Running], RunStatus::Paused)? + } + RuntimeEventKind::RunResumed => { + set_status(snapshot, event, &[RunStatus::Paused], RunStatus::Running)? + } + RuntimeEventKind::RunCompleted => finish_status(snapshot, event, RunStatus::Completed)?, + RuntimeEventKind::RunFailed => finish_status(snapshot, event, RunStatus::Failed)?, + RuntimeEventKind::RunCancelled => finish_status(snapshot, event, RunStatus::Cancelled)?, + RuntimeEventKind::RunReconciled => reconcile_status(snapshot, event)?, + } + snapshot.revision = event.revision; + if let Some(run_id) = event.run_id.as_deref() + && let Some(run) = snapshot.run_mut(run_id) + { + run.revision = run.revision.saturating_add(1); + run.updated_at_ms = event.occurred_at_ms; + } + Ok(()) +} + +fn find_run<'a>( + snapshot: &'a mut RuntimeSnapshot, + event: &RuntimeEvent, +) -> Result<&'a mut RunSnapshot, RuntimeError> { + let id = event.run_id.as_deref().ok_or_else(|| { + RuntimeError::new(RuntimeErrorKind::InvalidEvent, "run event 缺少 run id") + })?; + snapshot + .run_mut(id) + .ok_or_else(|| RuntimeError::new(RuntimeErrorKind::NotFound, format!("run 不存在:{id}"))) +} + +fn require_status( + run: &RunSnapshot, + allowed: &[RunStatus], + operation: &str, +) -> Result<(), RuntimeError> { + if allowed.contains(&run.status) { + Ok(()) + } else { + Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + format!("{operation} 不允许 run 处于 {:?}", run.status), + )) + } +} + +fn set_status( + snapshot: &mut RuntimeSnapshot, + event: &RuntimeEvent, + allowed: &[RunStatus], + status: RunStatus, +) -> Result<(), RuntimeError> { + let run = find_run(snapshot, event)?; + require_status(run, allowed, "status transition")?; + run.status = status; + Ok(()) +} + +fn reconcile_status( + snapshot: &mut RuntimeSnapshot, + event: &RuntimeEvent, +) -> Result<(), RuntimeError> { + let run = find_run(snapshot, event)?; + require_status(run, &[RunStatus::Reconciling], "reconcile run")?; + if event.detail.get("reconciled") != Some(&Value::Bool(true)) + || event.detail.get("external_pending") != Some(&Value::Bool(false)) + { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "RunReconciled 缺少明确的无外部待决证据", + )); + } + let unresolved = run + .tool_calls + .iter() + .filter(|call| { + !run.tool_results + .iter() + .any(|result| result.call_id() == call.id()) + }) + .map(|call| call.id()) + .collect::>(); + let pending_approval = event + .detail + .get("pending_approval_call_id") + .and_then(Value::as_str); + match (unresolved.as_slice(), pending_approval) { + ([], None) => {} + ([call_id], Some(pending)) if *call_id == pending => {} + _ => { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + "RunReconciled 仍有未对账的 tool call", + )); + } + } + run.status = RunStatus::Running; + Ok(()) +} + +fn finish_status( + snapshot: &mut RuntimeSnapshot, + event: &RuntimeEvent, + status: RunStatus, +) -> Result<(), RuntimeError> { + let run = find_run(snapshot, event)?; + if run.status.is_terminal() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + "run 已经处于终态", + )); + } + if status == RunStatus::Completed && run.status != RunStatus::Running { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + "只有 running run 可以完成", + )); + } + if status == RunStatus::Failed && run.status == RunStatus::Pending { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidTransition, + "pending run 不能直接失败", + )); + } + run.status = status; + match status { + RunStatus::Completed => { + run.final_text = event + .detail + .get("summary") + .and_then(Value::as_str) + .map(str::to_owned); + } + RunStatus::Failed => { + run.error = event + .detail + .get("error") + .and_then(Value::as_str) + .map(str::to_owned); + } + _ => {} + } + Ok(()) +} + +fn next_validate_run(run: &RunSnapshot) -> Result<(), RuntimeError> { + validate_identifier(&run.run_id, "run id")?; + validate_identifier(&run.agent_id, "agent id")?; + validate_non_empty(&run.task, "run task")?; + if run.schema_version != RUN_SNAPSHOT_SCHEMA_VERSION { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "run schema version 不支持", + )); + } + if run.revision != 0 { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "新建 run 的 revision 必须为 0", + )); + } + if run.status != RunStatus::Pending { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "新建 run 的 status 必须为 pending", + )); + } + if !run.messages.is_empty() || !run.tool_calls.is_empty() || !run.tool_results.is_empty() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "新建 run 不能携带历史消息或 tool 记录", + )); + } + if run.final_text.is_some() || run.error.is_some() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "新建 run 不能携带终态字段", + )); + } + // A RunCreated event is decoded from untrusted JSON. The run envelope + // checks above do not validate nested values because serde bypasses the + // fallible constructors for Message/ToolCall/ToolResult. + for message in &run.messages { + validate_message_payload(message)?; + } + for call in &run.tool_calls { + validate_tool_call_payload(call)?; + } + for result in &run.tool_results { + validate_tool_result_payload(result)?; + } + Ok(()) +} + +/// Re-validate a message at the event/replay boundary. +/// +/// Keeping this check in the reducer preserves the public wire structs while +/// making replay fail closed when a caller decoded JSON directly with serde. +fn validate_message_payload(message: &Message) -> Result<(), RuntimeError> { + if message.content().is_empty() { + return Err(RuntimeError::new( + RuntimeErrorKind::InvalidEvent, + "message content 不能为空", + )); + } + for part in message.content() { + validate_content_part_payload(part)?; + } + Ok(()) +} + +fn validate_content_part_payload(part: &ContentPart) -> Result<(), RuntimeError> { + let result = match part { + ContentPart::Text { text } => validate_non_empty(text, "message text"), + ContentPart::Image { source } => validate_object(source, "image source"), + ContentPart::ToolCall { + id, + name, + arguments, + } => { + validate_identifier(id, "tool call id")?; + validate_identifier(name, "tool name")?; + validate_object(arguments, "tool call arguments") + } + ContentPart::ToolResult { tool_call_id, .. } => { + validate_identifier(tool_call_id, "tool call id") + } + }; + result.map_err(|error| RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string())) +} + +fn validate_tool_call_payload(call: &ToolCall) -> Result<(), RuntimeError> { + validate_identifier(call.id(), "tool call id") + .and_then(|_| validate_identifier(call.name(), "tool name")) + .and_then(|_| validate_object(call.arguments(), "tool call arguments")) + .map_err(|error| RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string())) +} + +fn validate_tool_result_payload(result: &ToolResult) -> Result<(), RuntimeError> { + validate_identifier(result.call_id(), "tool call id") + .and_then(|_| { + result.summary().map_or(Ok(()), |summary| { + validate_non_empty(summary, "tool result summary") + }) + }) + .and_then(|_| validate_object(result.metadata(), "tool result metadata")) + .map_err(|error| RuntimeError::new(RuntimeErrorKind::InvalidEvent, error.to_string())) +} + +/// Rebuild the derived tool indexes that accompany a compacted message +/// history. Compaction is a context rewrite, so stale call/result vectors must +/// not survive beside the new message prefix and confuse recovery validation. +fn extract_message_tools( + messages: &[Message], +) -> Result<(Vec, Vec), ContractError> { + let mut calls = Vec::new(); + let mut results = Vec::new(); + for message in messages { + for part in message.content() { + match part { + ContentPart::ToolCall { + id, + name, + arguments, + } => { + if message.role() != crate::message::MessageRole::Assistant { + return Err(ContractError::new( + "压缩消息中的 tool call 必须位于 assistant", + )); + } + if calls.iter().any(|call: &ToolCall| call.id() == id) { + return Err(ContractError::new("压缩消息中的 tool call id 重复")); + } + calls.push(ToolCall::try_new(id, name, arguments.clone())?); + } + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => { + if message.role() != crate::message::MessageRole::Tool { + return Err(ContractError::new("压缩消息中的 tool result 必须位于 tool")); + } + if !calls.iter().any(|call| call.id() == tool_call_id) { + return Err(ContractError::new( + "压缩消息中的 tool result 引用了未知 call", + )); + } + if results + .iter() + .any(|result: &ToolResult| result.call_id() == tool_call_id) + { + return Err(ContractError::new("压缩消息中的 tool result 重复")); + } + results.push(ToolResult::try_new( + tool_call_id, + output.clone(), + *is_error, + )?); + } + ContentPart::Text { .. } | ContentPart::Image { .. } => {} + } + } + } + Ok((calls, results)) +} + +/// 可测试时钟;默认实现只依赖 std。 +pub trait RuntimeClock: Send + Sync { + fn now_millis(&self) -> u64; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemClock; + +impl SystemClock { + /// 直接调用的便捷方法;RuntimeClock trait 版本仍供依赖注入使用。 + pub fn now_millis(&self) -> u64 { + ::now_millis(self) + } +} + +impl RuntimeClock for SystemClock { + fn now_millis(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) + } +} + +/// 一个极小内存 Store,供 engine/端口测试使用;生产 Host 应注入 SQLite 等实现。 +#[derive(Clone, Debug, Default)] +pub struct InMemoryRuntimeStore { + snapshots: BTreeMap, + events: BTreeMap>, +} + +impl InMemoryRuntimeStore { + pub fn new() -> Self { + Self::default() + } + pub fn events(&self, runtime_id: &str) -> &[RuntimeEvent] { + self.events.get(runtime_id).map_or(&[], Vec::as_slice) + } + pub fn snapshot(&self, runtime_id: &str) -> Option<&RuntimeSnapshot> { + self.snapshots.get(runtime_id) + } +} + +impl RuntimeStore for InMemoryRuntimeStore { + fn load(&self, runtime_id: &str) -> Result, StoreError> { + let snapshot = self.snapshots.get(runtime_id).cloned(); + if let Some(snapshot) = &snapshot { + snapshot.validate().map_err(|error| { + StoreError::new(StoreErrorKind::InvalidSnapshot, error.to_string()) + })?; + } + Ok(snapshot) + } + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), StoreError> { + let actual = self + .snapshots + .get(runtime_id) + .map(RuntimeSnapshot::revision); + if actual != expected_revision { + return Err(StoreError::conflict(format!( + "revision conflict: expected={expected_revision:?} actual={actual:?}" + ))); + } + if snapshot.runtime_id != runtime_id || events.is_empty() { + return Err(StoreError::new( + StoreErrorKind::InvalidSnapshot, + "snapshot/event identity 无效", + )); + } + snapshot + .validate() + .map_err(|error| StoreError::new(StoreErrorKind::InvalidSnapshot, error.to_string()))?; + let previous = expected_revision.unwrap_or(0); + if snapshot.revision <= previous + || events.last().map(RuntimeEvent::revision) != Some(snapshot.revision) + || events.len() as u64 != snapshot.revision.saturating_sub(previous) + { + return Err(StoreError::new( + StoreErrorKind::InvalidSnapshot, + "snapshot revision 无效", + )); + } + for (offset, event) in events.iter().enumerate() { + let expected = previous.checked_add(offset as u64 + 1).ok_or_else(|| { + StoreError::new( + StoreErrorKind::InvalidSnapshot, + "runtime event revision 溢出", + ) + })?; + if event.runtime_id() != runtime_id + || event.schema_version() != RUNTIME_EVENT_SCHEMA_VERSION + || event.revision() != expected + || !event.detail().is_object() + { + return Err(StoreError::new( + StoreErrorKind::InvalidSnapshot, + "runtime event revision 或 identity 无效", + )); + } + } + + // Keep the in-memory adapter honest with the durable adapters: a caller + // must provide the snapshot produced by replaying this exact event + // batch, not merely a matching revision number. This also makes the + // core conformance store useful for catching reducer/serialization + // regressions before SQLite is involved. + let mut reduced = if let Some(current) = self.snapshots.get(runtime_id) { + current.clone() + } else { + RuntimeSnapshot::try_new(runtime_id).map_err(|error| { + StoreError::new(StoreErrorKind::InvalidSnapshot, error.to_string()) + })? + }; + for event in events { + reduced = reduce(&reduced, event).map_err(|error| { + StoreError::new(StoreErrorKind::InvalidSnapshot, error.to_string()) + })?; + } + if reduced != *snapshot { + return Err(StoreError::new( + StoreErrorKind::InvalidSnapshot, + "runtime 事件重放结果与 snapshot 不一致", + )); + } + self.snapshots + .insert(runtime_id.to_owned(), snapshot.clone()); + self.events + .entry(runtime_id.to_owned()) + .or_default() + .extend_from_slice(events); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::{ContentPart, MessageRole}; + use serde_json::json; + use std::sync::{Arc, Barrier}; + + fn runtime_with_started_run() -> RuntimeSnapshot { + let mut snapshot = RuntimeSnapshot::try_new("runtime-test").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-test", 1, 1).expect("runtime event"), + ) + .expect("create runtime"); + let run = RunSnapshot::try_new("run-test", "agent-test", "task", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-test", 2, 2, &run).expect("run event"), + ) + .expect("create run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-test", + 3, + 3, + "run-test", + RuntimeEventKind::RunStarted, + ) + .expect("start event"), + ) + .expect("start run"); + snapshot + } + + #[test] + fn terminal_runs_reject_message_appends() { + let terminal_events = [ + RuntimeEvent::completed("runtime-test", 4, 4, "run-test", "done") + .expect("completed event"), + RuntimeEvent::failed("runtime-test", 4, 4, "run-test", "failed").expect("failed event"), + RuntimeEvent::status_changed( + "runtime-test", + 4, + 4, + "run-test", + RuntimeEventKind::RunCancelled, + ) + .expect("cancelled event"), + ]; + + for terminal_event in terminal_events { + let mut snapshot = runtime_with_started_run(); + apply_event(&mut snapshot, &terminal_event).expect("finish run"); + let message = Message::try_new( + MessageRole::User, + [ContentPart::text("late message").expect("text")], + ) + .expect("message"); + let append = RuntimeEvent::message_appended("runtime-test", 5, 5, "run-test", &message) + .expect("append event"); + + let error = apply_event(&mut snapshot, &append).expect_err("terminal append rejected"); + assert_eq!(error.kind(), RuntimeErrorKind::InvalidTransition); + assert!(snapshot.run("run-test").expect("run").messages().is_empty()); + } + } + + #[test] + fn interrupted_approval_enters_reconciliation_without_implicit_resolution() { + let mut snapshot = runtime_with_started_run(); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-test", + 4, + 4, + "run-test", + RuntimeEventKind::ApprovalRequested, + ) + .expect("approval request"), + ) + .expect("wait for approval"); + assert_eq!( + snapshot.run("run-test").expect("run").status(), + RunStatus::WaitingApproval + ); + + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-test", + 5, + 5, + "run-test", + RuntimeEventKind::ReconciliationRequired, + ) + .expect("reconciliation event"), + ) + .expect("interrupted approval enters reconciliation"); + assert_eq!( + snapshot.run("run-test").expect("run").status(), + RunStatus::Reconciling + ); + + let implicit_resolution = RuntimeEvent::status_changed( + "runtime-test", + 6, + 6, + "run-test", + RuntimeEventKind::ApprovalResolved, + ) + .expect("approval resolution event"); + let error = apply_event(&mut snapshot, &implicit_resolution) + .expect_err("reconciliation must not implicitly resume old approval"); + assert_eq!(error.kind(), RuntimeErrorKind::InvalidTransition); + } + + #[test] + fn context_compaction_rewrites_messages_and_derived_tool_indexes() { + let mut snapshot = runtime_with_started_run(); + let started = RuntimeEvent::compaction_started( + "runtime-test", + snapshot.revision() + 1, + 4, + "run-test", + ) + .expect("compaction start"); + snapshot = reduce(&snapshot, &started).expect("start compaction"); + let messages = vec![Message::user("compressed summary").expect("message")]; + let compacted = RuntimeEvent::context_compacted( + "runtime-test", + snapshot.revision() + 1, + 4, + "run-test", + 0, + &messages, + ) + .expect("context event"); + snapshot = reduce(&snapshot, &compacted).expect("rewrite context"); + assert_eq!(snapshot.run("run-test").unwrap().messages(), messages); + assert!(snapshot.run("run-test").unwrap().tool_calls().is_empty()); + assert_eq!( + snapshot.run("run-test").unwrap().status(), + RunStatus::Compacting + ); + + let completed = RuntimeEvent::compaction_completed( + "runtime-test", + snapshot.revision() + 1, + 5, + "run-test", + ) + .expect("completed event"); + snapshot = reduce(&snapshot, &completed).expect("complete compaction"); + assert_eq!( + snapshot.run("run-test").unwrap().status(), + RunStatus::Running + ); + } + + #[test] + fn context_compaction_rejects_tampered_message_hash() { + let mut snapshot = runtime_with_started_run(); + let started = RuntimeEvent::compaction_started( + "runtime-test", + snapshot.revision() + 1, + 4, + "run-test", + ) + .expect("compaction start"); + snapshot = reduce(&snapshot, &started).expect("start compaction"); + let mut event = RuntimeEvent::context_compacted( + "runtime-test", + snapshot.revision() + 1, + 4, + "run-test", + 0, + &[Message::user("compressed summary").expect("message")], + ) + .expect("context event"); + event.detail["messages_hash"] = Value::String("tampered".to_owned()); + let error = reduce(&snapshot, &event).expect_err("tampered hash must fail"); + assert_eq!(error.kind(), RuntimeErrorKind::InvalidEvent); + assert_eq!( + snapshot.run("run-test").unwrap().status(), + RunStatus::Compacting + ); + } + + #[test] + fn invalid_context_compaction_does_not_partially_mutate_apply_target() { + let mut snapshot = runtime_with_started_run(); + let started = RuntimeEvent::compaction_started( + "runtime-test", + snapshot.revision() + 1, + 4, + "run-test", + ) + .expect("compaction start"); + apply_event(&mut snapshot, &started).expect("start compaction"); + let before = snapshot.clone(); + + let message = Message::user("compressed summary").expect("message"); + let mut event = RuntimeEvent::context_compacted( + "runtime-test", + snapshot.revision() + 1, + 4, + "run-test", + 0, + std::slice::from_ref(&message), + ) + .expect("context event"); + // Keep the message hash valid but make the derived index disagree with + // the message list. This reaches the last validation branch and would + // expose a partial mutation if assignment happened too early. + event.detail["tool_calls"] = Value::Array(vec![json!({ + "id": "unexpected-call", + "name": "echo", + "arguments": {} + })]); + + let error = apply_event(&mut snapshot, &event).expect_err("index mismatch must fail"); + assert_eq!(error.kind(), RuntimeErrorKind::InvalidEvent); + assert_eq!(snapshot, before); + } + + #[test] + fn run_created_requires_an_initial_snapshot() { + let base = RunSnapshot::try_new("run-test", "agent-test", "task", 1).expect("run"); + + let mut invalid_runs = Vec::new(); + let mut run = base.clone(); + run.revision = 1; + invalid_runs.push(run); + + let mut run = base.clone(); + run.status = RunStatus::Running; + invalid_runs.push(run); + + let mut run = base.clone(); + run.messages.push( + Message::try_new( + MessageRole::User, + [ContentPart::text("history").expect("text")], + ) + .expect("message"), + ); + invalid_runs.push(run); + + let mut run = base.clone(); + run.tool_calls + .push(ToolCall::try_new("call-test", "echo", json!({"ok": true})).expect("tool call")); + invalid_runs.push(run); + + let mut run = base.clone(); + run.tool_results + .push(ToolResult::success("call-test", json!({"ok": true})).expect("tool result")); + invalid_runs.push(run); + + let mut run = base.clone(); + run.final_text = Some("already done".to_owned()); + invalid_runs.push(run); + + let mut run = base; + run.error = Some("already failed".to_owned()); + invalid_runs.push(run); + + for invalid_run in invalid_runs { + let mut snapshot = RuntimeSnapshot::try_new("runtime-test").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-test", 1, 1).expect("runtime event"), + ) + .expect("create runtime"); + let event = + RuntimeEvent::run_created("runtime-test", 2, 2, &invalid_run).expect("run event"); + + let error = apply_event(&mut snapshot, &event).expect_err("invalid run rejected"); + assert_eq!(error.kind(), RuntimeErrorKind::InvalidEvent); + assert!(snapshot.runs().is_empty()); + assert_eq!(snapshot.revision(), 1); + } + } + + #[test] + fn in_memory_store_rejects_snapshot_not_derived_from_events() { + let mut store = InMemoryRuntimeStore::new(); + let runtime_id = "runtime-store-contract"; + let runtime_created = RuntimeEvent::runtime_created(runtime_id, 1, 1).expect("event"); + let mut wrong_snapshot = RuntimeSnapshot::try_new(runtime_id).expect("snapshot"); + wrong_snapshot.revision = 1; + wrong_snapshot.metadata = json!({"tampered": true}); + + let error = store + .commit( + runtime_id, + None, + &wrong_snapshot, + std::slice::from_ref(&runtime_created), + ) + .expect_err("mismatched snapshot must be rejected"); + assert_eq!(error.kind(), StoreErrorKind::InvalidSnapshot); + assert!(store.snapshot(runtime_id).is_none()); + assert!(store.events(runtime_id).is_empty()); + } + + #[test] + fn shared_runtime_store_serializes_concurrent_cas() { + let runtime_id = "runtime-shared-store"; + let mut shared = SharedRuntimeStore::new(InMemoryRuntimeStore::new()); + let initial = RuntimeSnapshot::try_new(runtime_id).expect("snapshot"); + let created = RuntimeEvent::runtime_created(runtime_id, 1, 1).expect("event"); + let after_created = reduce(&initial, &created).expect("reduced snapshot"); + RuntimeStore::commit( + &mut shared, + runtime_id, + None, + &after_created, + std::slice::from_ref(&created), + ) + .expect("initial commit"); + + let run_one = RunSnapshot::try_new("run-shared-one", "agent", "one", 2).expect("run"); + let event_one = RuntimeEvent::run_created(runtime_id, 2, 2, &run_one).expect("event"); + let snapshot_one = reduce(&after_created, &event_one).expect("snapshot"); + let run_two = RunSnapshot::try_new("run-shared-two", "agent", "two", 2).expect("run"); + let event_two = RuntimeEvent::run_created(runtime_id, 2, 2, &run_two).expect("event"); + let snapshot_two = reduce(&after_created, &event_two).expect("snapshot"); + + // Both workers read revision 1 before either attempts the CAS. The + // shared wrapper serializes the commits, so exactly one revision-2 + // write wins and the other receives the normal typed conflict. + let barrier = Arc::new(Barrier::new(2)); + let first = shared.clone(); + let second = shared.clone(); + let (first_result, second_result) = std::thread::scope(|scope| { + let first_barrier = Arc::clone(&barrier); + let first_handle = scope.spawn(move || { + let loaded = RuntimeStore::load(&first, runtime_id).expect("first load"); + assert_eq!(loaded.expect("first snapshot").revision(), 1); + first_barrier.wait(); + let mut store = first; + RuntimeStore::commit( + &mut store, + runtime_id, + Some(1), + &snapshot_one, + std::slice::from_ref(&event_one), + ) + }); + let second_barrier = Arc::clone(&barrier); + let second_handle = scope.spawn(move || { + let loaded = RuntimeStore::load(&second, runtime_id).expect("second load"); + assert_eq!(loaded.expect("second snapshot").revision(), 1); + second_barrier.wait(); + let mut store = second; + RuntimeStore::commit( + &mut store, + runtime_id, + Some(1), + &snapshot_two, + std::slice::from_ref(&event_two), + ) + }); + ( + first_handle.join().expect("first worker"), + second_handle.join().expect("second worker"), + ) + }); + + let conflicts = [&first_result, &second_result] + .into_iter() + .filter( + |result| matches!(result, Err(error) if error.kind() == StoreErrorKind::Conflict), + ) + .count(); + assert_eq!(conflicts, 1); + assert_eq!( + shared + .load(runtime_id) + .expect("final load") + .expect("final snapshot") + .revision(), + 2 + ); + assert_eq!( + shared + .with_store(|store| store.events(runtime_id).len()) + .expect("event count"), + 2 + ); + } + + #[test] + fn shared_runtime_store_maps_poisoned_lock_to_unavailable() { + let shared = SharedRuntimeStore::new(InMemoryRuntimeStore::new()); + let poisoner = shared.clone(); + + // A panic while the wrapper owns its mutex intentionally poisons the + // lock. The adapter must expose a typed unavailable error to callers + // instead of turning a control-plane failure into a second panic. + let join = std::thread::spawn(move || { + poisoner.with_store_mut(|_| -> () { + panic!("intentional test lock poison"); + }) + }); + assert!(join.join().is_err()); + + let error = shared + .with_store(|_| ()) + .expect_err("poisoned shared lock must be unavailable"); + assert_eq!(error.kind(), StoreErrorKind::Unavailable); + + // The public RuntimeStore implementation uses the same mapping for + // ordinary load/commit calls, so no caller has to inspect a mutex. + let load_error = RuntimeStore::load(&shared, "runtime-poisoned") + .expect_err("trait load must preserve unavailable classification"); + assert_eq!(load_error.kind(), StoreErrorKind::Unavailable); + } +} diff --git a/rust/crates/agent-runtime-core/src/tool.rs b/rust/crates/agent-runtime-core/src/tool.rs new file mode 100644 index 000000000..dbcbe2787 --- /dev/null +++ b/rust/crates/agent-runtime-core/src/tool.rs @@ -0,0 +1,445 @@ +//! 工具定义、调用和执行端口。 + +use std::collections::BTreeMap; +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::contract::{ + ContractError, CoreError, ErrorKind, validate_identifier, validate_non_empty, validate_object, +}; + +/// 模型可见的工具描述。执行权限不由该结构授予,而由 ApprovalPolicy 决定。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ToolDefinition { + name: String, + description: String, + input_schema: Value, + #[serde(default)] + metadata: Value, +} + +impl ToolDefinition { + pub fn try_new( + name: impl Into, + description: impl Into, + input_schema: Value, + ) -> Result { + let name = name.into(); + let description = description.into(); + validate_identifier(&name, "tool name")?; + validate_non_empty(&description, "tool description")?; + validate_object(&input_schema, "tool inputSchema")?; + Ok(Self { + name, + description, + input_schema, + metadata: Value::Object(Map::new()), + }) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "tool metadata")?; + self.metadata = metadata; + Ok(self) + } + + /// 重新执行构造器使用的字段、schema 和 metadata 校验。 + /// + /// 工具定义支持 serde 解码,不能假定值一定经过 `try_new`/`with_metadata`; + /// Registry 和 Provider request 在把定义交给执行或 wire adapter 前会调用 + /// 这个窄入口,避免非法定义只在更深层才失败。 + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.name, "tool name")?; + validate_non_empty(&self.description, "tool description")?; + validate_object(&self.input_schema, "tool inputSchema")?; + validate_object(&self.metadata, "tool metadata") + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +/// Provider 返回给 engine 的结构化工具调用。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ToolCall { + id: String, + name: String, + arguments: Value, +} + +impl ToolCall { + pub fn try_new( + id: impl Into, + name: impl Into, + arguments: Value, + ) -> Result { + let id = id.into(); + let name = name.into(); + validate_identifier(&id, "tool call id")?; + validate_identifier(&name, "tool name")?; + validate_object(&arguments, "tool call arguments")?; + Ok(Self { + id, + name, + arguments, + }) + } + + /// Re-run the call contract at a port boundary. + /// + /// The wire struct remains serde-compatible, so persisted/provider values + /// can bypass `try_new`; adapters and approval code should call this before + /// treating the call as executable state. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.id, "tool call id")?; + validate_identifier(&self.name, "tool name")?; + validate_object(&self.arguments, "tool call arguments") + } + + /// 将 Provider 常见的 JSON 字符串参数解析为中立调用。 + pub fn from_json_text( + id: impl Into, + name: impl Into, + arguments: &str, + ) -> Result { + let value: Value = serde_json::from_str(arguments) + .map_err(|error| ContractError::new(format!("tool arguments 不是 JSON:{error}")))?; + Self::try_new(id, name, value) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn arguments(&self) -> &Value { + &self.arguments + } + + pub fn arguments_json(&self) -> String { + self.arguments.to_string() + } +} + +/// 工具执行结果;output 可以是任意 JSON 值,但 summary 便于日志和上下文压缩。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ToolResult { + call_id: String, + output: Value, + is_error: bool, + summary: Option, + metadata: Value, +} + +impl ToolResult { + pub fn success(call_id: impl Into, output: Value) -> Result { + Self::try_new(call_id, output, false) + } + + pub fn failure(call_id: impl Into, output: Value) -> Result { + Self::try_new(call_id, output, true) + } + + pub fn try_new( + call_id: impl Into, + output: Value, + is_error: bool, + ) -> Result { + let call_id = call_id.into(); + validate_identifier(&call_id, "tool call id")?; + Ok(Self { + call_id, + output, + is_error, + summary: None, + metadata: Value::Object(Map::new()), + }) + } + + /// Re-run the result contract after serde or an executor compatibility + /// path has produced a value. Output stays intentionally opaque JSON, but + /// identity, summary and metadata are still bounded Core fields. + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.call_id, "tool call id")?; + if let Some(summary) = &self.summary { + validate_non_empty(summary, "tool result summary")?; + } + validate_object(&self.metadata, "tool result metadata") + } + + pub fn with_summary(mut self, summary: impl Into) -> Result { + let summary = summary.into(); + validate_non_empty(&summary, "tool result summary")?; + self.summary = Some(summary); + Ok(self) + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "tool result metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn call_id(&self) -> &str { + &self.call_id + } + + pub fn output(&self) -> &Value { + &self.output + } + + pub fn is_error(&self) -> bool { + self.is_error + } + + pub fn summary(&self) -> Option<&str> { + self.summary.as_deref() + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } +} + +/// 工具执行期间可用的只读取消探针。 +/// +/// 探针只暴露读取操作;Engine/宿主持有真正的取消源,并通过 +/// [`ToolContext::with_cancellation_probe`] 将一个有界、不可序列化的视图 +/// 传给工具。这样 Core 不依赖具体的线程或 async runtime,也不会让工具 +/// 通过上下文反向修改运行状态。 +type CancellationProbe = dyn Fn() -> bool + Send + Sync; + +#[derive(Clone)] +struct ToolCancellationProbe(Arc); + +impl fmt::Debug for ToolCancellationProbe { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl PartialEq for ToolCancellationProbe { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +/// 工具执行期间可用的只读运行信息。 +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ToolContext { + session_id: Option, + run_id: Option, + metadata: Value, + /// Runtime-only cancellation view; it is intentionally absent from wire + /// serialization so persisted contexts stay deterministic and portable. + #[serde(skip)] + cancellation_probe: Option, +} + +impl ToolContext { + pub fn new() -> Self { + Self { + session_id: None, + run_id: None, + metadata: Value::Object(Map::new()), + cancellation_probe: None, + } + } + + pub fn with_session_id(mut self, id: impl Into) -> Self { + self.session_id = Some(id.into()); + self + } + + pub fn with_run_id(mut self, id: impl Into) -> Self { + self.run_id = Some(id.into()); + self + } + + pub fn with_metadata(mut self, metadata: Value) -> Result { + validate_object(&metadata, "tool context metadata")?; + self.metadata = metadata; + Ok(self) + } + + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + pub fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } + + pub fn metadata(&self) -> &Value { + &self.metadata + } + + /// Re-run the serializable context invariants before an executor observes + /// the context. The fluent ID setters remain infallible for compatibility + /// and serde can also construct this value directly, so side-effect ports + /// must not assume the context was built through `new` alone. + pub fn validate(&self) -> Result<(), ContractError> { + if let Some(session_id) = &self.session_id { + validate_identifier(session_id, "tool context session id")?; + } + if let Some(run_id) = &self.run_id { + validate_identifier(run_id, "tool context run id")?; + } + validate_object(&self.metadata, "tool context metadata") + } + + /// 附加一个只读取消探针;旧工具可以继续只实现 `execute` 并忽略它。 + /// + /// 探针不会进入序列化结果,也不会让工具获得取消源的写权限。闭包应 + /// 保持快速、无阻塞;长时间运行的工具负责在自己的安全检查点轮询 + /// [`Self::is_cancelled`] 并尽快返回 `ToolErrorKind::Cancelled`。 + pub fn with_cancellation_probe(mut self, probe: F) -> Self + where + F: Fn() -> bool + Send + Sync + 'static, + { + self.cancellation_probe = Some(ToolCancellationProbe(Arc::new(probe))); + self + } + + /// 读取宿主/Engine 发出的 cooperative cancel 信号。 + pub fn is_cancelled(&self) -> bool { + self.cancellation_probe + .as_ref() + .is_some_and(|probe| (probe.0)()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ToolErrorKind { + InvalidInput, + NotFound, + PermissionDenied, + Timeout, + Failed, + Cancelled, + Unknown, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolError { + kind: ToolErrorKind, + message: String, +} + +impl ToolError { + pub fn new(kind: ToolErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> ToolErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for ToolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ToolError {} + +impl From for ToolError { + fn from(value: ContractError) -> Self { + Self::new(ToolErrorKind::InvalidInput, value.message()) + } +} + +/// 同步工具端口。Engine 可在自己的 async runtime 中使用 spawn_blocking 包装。 +pub trait ToolExecutor: Send + Sync { + fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result; +} + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// 需要原生异步 I/O 的适配器可实现此端口;core 不依赖任何 async runtime。 +pub trait AsyncToolExecutor: Send + Sync { + fn execute<'a>( + &'a self, + call: &'a ToolCall, + context: &'a ToolContext, + ) -> BoxFuture<'a, Result>; +} + +/// 简单的名称到工具定义索引。真正的执行仍由 ToolExecutor 注入。 +#[derive(Clone, Debug, Default)] +pub struct ToolRegistry { + definitions: BTreeMap, +} + +impl ToolRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, definition: ToolDefinition) -> Result<(), ContractError> { + definition.validate()?; + if self.definitions.contains_key(&definition.name) { + return Err(ContractError::new("tool name 重复")); + } + self.definitions.insert(definition.name.clone(), definition); + Ok(()) + } + + pub fn get(&self, name: &str) -> Option<&ToolDefinition> { + self.definitions.get(name) + } + + pub fn iter(&self) -> impl Iterator { + self.definitions.values() + } + + pub fn len(&self) -> usize { + self.definitions.len() + } + + pub fn is_empty(&self) -> bool { + self.definitions.is_empty() + } +} + +/// 历史/宿主代码有时把执行错误统一为 CoreError;提供显式转换辅助。 +impl From for CoreError { + fn from(value: ToolError) -> Self { + Self::new(ErrorKind::Tool, value.message) + } +} diff --git a/rust/crates/agent-runtime-core/tests/core_contracts.rs b/rust/crates/agent-runtime-core/tests/core_contracts.rs new file mode 100644 index 000000000..ca6aa0439 --- /dev/null +++ b/rust/crates/agent-runtime-core/tests/core_contracts.rs @@ -0,0 +1,881 @@ +use agent_runtime_core::{ + AgentCatalog, AgentDescriptor, ApprovalRequest, BackendRequest, BackendResult, + CapabilityDefinition, CapabilityRegistry, CompletionBlocker, CompletionDecision, + CompletionPolicy, ContentPart, ContextItem, ContextRequest, ExternalError, ExternalErrorKind, + ExternalObservation, ExternalObservationRequest, ExternalObservationSource, + ExternalObservationStatus, InMemoryRuntimeStore, MAX_EXTERNAL_OBSERVATION_JSON_BYTES, Message, + MessageRole, ModelProvider, PromptBuilder, ProviderRequest, ProviderResponse, + ProviderStreamEvent, ProviderStreamSink, RUNTIME_EVENT_SCHEMA_VERSION, RunSnapshot, RunStatus, + RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, RuntimeStore, StoreErrorKind, ToolCall, + ToolDefinition, ToolRegistry, ToolResult, apply_event, backend_result_as_tool, reduce, +}; +use serde_json::{Value, json}; + +#[test] +fn reducer_applies_basic_single_agent_lifecycle() { + let mut snapshot = RuntimeSnapshot::try_new("runtime-1").expect("runtime"); + let created = RuntimeEvent::runtime_created("runtime-1", 1, 10).expect("created event"); + apply_event(&mut snapshot, &created).expect("create runtime"); + let run = RunSnapshot::try_new("run-1", "agent", "完成任务", 10).expect("run"); + let event = RuntimeEvent::run_created("runtime-1", 2, 11, &run).expect("run event"); + apply_event(&mut snapshot, &event).expect("create run"); + let started = RuntimeEvent::new( + "runtime-1", + 3, + 12, + RuntimeEventKind::RunStarted, + Some("run-1".to_owned()), + json!({}), + ) + .expect("start event"); + let next = reduce(&snapshot, &started).expect("reduce"); + assert_eq!(next.revision(), 3); + assert_eq!(next.run("run-1").expect("run").status(), RunStatus::Running); +} + +#[test] +fn compaction_lifecycle_is_serializable_and_replayable() { + let mut snapshot = RuntimeSnapshot::try_new("runtime-compaction").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-compaction", 1, 10).expect("runtime event"), + ) + .expect("create runtime"); + let run = RunSnapshot::try_new("run-compaction", "agent", "压缩上下文", 10).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-compaction", 2, 11, &run).expect("run event"), + ) + .expect("create run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-compaction", + 3, + 12, + "run-compaction", + RuntimeEventKind::RunStarted, + ) + .expect("start event"), + ) + .expect("start run"); + + let started = RuntimeEvent::compaction_started("runtime-compaction", 4, 13, "run-compaction") + .expect("compaction start event"); + let completed = + RuntimeEvent::compaction_completed("runtime-compaction", 5, 14, "run-compaction") + .expect("compaction completion event"); + + // The new events add no envelope fields, so they use the existing v1 + // persistence contract and can be decoded before reducer replay. + let encoded = serde_json::to_value(&started).expect("serialize start event"); + assert_eq!( + encoded.get("kind").and_then(Value::as_str), + Some("compaction-started") + ); + let decoded_started: RuntimeEvent = serde_json::from_value(encoded).expect("decode start"); + assert_eq!(decoded_started, started); + let decoded_completed: RuntimeEvent = serde_json::from_value( + serde_json::to_value(&completed).expect("serialize completion event"), + ) + .expect("decode completion"); + assert_eq!(decoded_completed, completed); + + let compacting = reduce(&snapshot, &decoded_started).expect("enter compacting"); + assert_eq!( + compacting.run("run-compaction").expect("run").status(), + RunStatus::Compacting + ); + assert!(RunStatus::Compacting.is_waiting()); + let restored = reduce(&compacting, &decoded_completed).expect("finish compaction"); + assert_eq!(restored.revision(), 5); + assert_eq!( + restored.run("run-compaction").expect("run").status(), + RunStatus::Running + ); + + // Replaying the same event sequence from the pre-compaction snapshot must + // produce byte-equivalent state, including per-run revision timestamps. + let replayed = reduce( + &reduce(&snapshot, &started).expect("replay start"), + &completed, + ) + .expect("replay completion"); + assert_eq!(replayed, restored); +} + +#[test] +fn illegal_compaction_transitions_leave_snapshot_unchanged() { + let mut snapshot = RuntimeSnapshot::try_new("runtime-compaction-invalid").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-compaction-invalid", 1, 1).expect("runtime"), + ) + .expect("runtime"); + let run = RunSnapshot::try_new("run-compaction-invalid", "agent", "任务", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-compaction-invalid", 2, 2, &run).expect("run"), + ) + .expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-compaction-invalid", + 3, + 3, + "run-compaction-invalid", + RuntimeEventKind::RunStarted, + ) + .expect("start"), + ) + .expect("start"); + + // Completion without a matching start cannot skip the explicit compacting + // state, and apply_event must not consume its revision on failure. + let before = snapshot.clone(); + let error = apply_event( + &mut snapshot, + &RuntimeEvent::compaction_completed( + "runtime-compaction-invalid", + 4, + 4, + "run-compaction-invalid", + ) + .expect("completion"), + ) + .expect_err("completion before start rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidTransition + ); + assert_eq!(snapshot, before); + + apply_event( + &mut snapshot, + &RuntimeEvent::compaction_started( + "runtime-compaction-invalid", + 4, + 4, + "run-compaction-invalid", + ) + .expect("start compaction"), + ) + .expect("start compaction"); + let before_duplicate = snapshot.clone(); + let error = apply_event( + &mut snapshot, + &RuntimeEvent::compaction_started( + "runtime-compaction-invalid", + 5, + 5, + "run-compaction-invalid", + ) + .expect("duplicate start"), + ) + .expect_err("duplicate start rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidTransition + ); + assert_eq!(snapshot, before_duplicate); +} + +#[test] +fn runtime_event_schema_is_serialized_and_checked_by_reducer() { + let event = RuntimeEvent::runtime_created("runtime-schema", 1, 10).expect("event"); + assert_eq!(event.schema_version(), RUNTIME_EVENT_SCHEMA_VERSION); + let encoded = serde_json::to_value(&event).expect("serialize event"); + assert_eq!( + encoded + .get("schemaVersion") + .and_then(|value| value.as_str()), + Some(RUNTIME_EVENT_SCHEMA_VERSION) + ); + let decoded: RuntimeEvent = serde_json::from_value(encoded).expect("deserialize event"); + assert_eq!(decoded, event); + + let mut unsupported = event + .with_schema_version("agent-runtime-event.v2") + .expect("version"); + let mut snapshot = RuntimeSnapshot::try_new("runtime-schema").expect("snapshot"); + let error = apply_event(&mut snapshot, &unsupported).expect_err("unknown schema rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidEvent + ); + assert_eq!(snapshot.revision(), 0); + + // The public field remains useful to adapters that decode untrusted JSON; + // the reducer is the final authority even if a caller mutates it directly. + unsupported.schema_version = "".to_owned(); + let error = apply_event(&mut snapshot, &unsupported).expect_err("empty schema rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidEvent + ); +} + +#[test] +fn message_tool_and_result_round_trip_as_json() { + let message = Message::try_new( + MessageRole::User, + [ContentPart::text("hello").expect("text")], + ) + .expect("message"); + let call = ToolCall::try_new("call-1", "read_file", json!({"path": "a.txt"})).expect("call"); + let result = ToolResult::success("call-1", json!({"ok": true})) + .expect("result") + .with_summary("读取完成") + .expect("summary"); + let value = serde_json::to_value((&message, &call, &result)).expect("serialize"); + let (message2, call2, result2): (Message, ToolCall, ToolResult) = + serde_json::from_value(value).expect("deserialize"); + assert_eq!(message2, message); + assert_eq!(call2, call); + assert_eq!(result2, result); +} + +#[test] +fn unknown_backend_result_cannot_become_a_success_tool_result() { + let call = ToolCall::try_new("call-unknown", "remote", json!({})).expect("call"); + let result = BackendResult::try_new(call.id(), json!({"accepted": true})) + .expect("backend result") + .with_unknown_side_effect(true); + let error = backend_result_as_tool(&result, &call) + .expect_err("unknown external side effect must remain behind reconciliation"); + assert!(error.message().contains("副作用未知")); + + let confirmed = + BackendResult::try_new(call.id(), json!({"accepted": true})).expect("confirmed result"); + let tool = backend_result_as_tool(&confirmed, &call).expect("confirmed result converts"); + assert_eq!(tool.call_id(), call.id()); + assert_eq!(tool.output()["accepted"], true); +} + +#[test] +fn extension_and_backend_wire_values_fail_closed_at_core_boundaries() { + // Nested extension values use fallible serde adapters, so malformed + // definitions cannot reach a source/registration implementation. + assert!( + serde_json::from_value::(json!({ + "tool": { + "name": "echo", + "description": "echo", + "inputSchema": [], + "metadata": {} + }, + "origin": {"kind": "mcp", "server": "bad server"} + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "name": "bad name", + "description": "body", + "version": null, + "metadata": {} + })) + .is_err() + ); + + // AgentDescriptor remains readable for compatibility, but catalog + // construction is the registration gate that re-runs its invariants. + let invalid_agent: AgentDescriptor = serde_json::from_value(json!({ + "id": "agent-1", + "role": "worker", + "capabilityIds": ["read", "read"], + "metadata": {} + })) + .expect("descriptor wire shape is readable"); + assert!(AgentCatalog::try_new([invalid_agent]).is_err()); + + // Backend request/result identity and object metadata are checked before + // an external call can be converted into an executable tool result. + assert!( + serde_json::from_value::(json!({ + "requestId": "request-1", + "runId": "run-1", + "operation": "turn", + "payload": {}, + "metadata": [] + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "requestId": "request-1", + "output": {}, + "externalId": "bad id", + "sideEffectUnknown": false + })) + .is_err() + ); + + let call = ToolCall::try_new("call-1", "remote", json!({})).expect("call"); + let mismatched = BackendResult::try_new("other-request", json!({})).expect("result"); + assert!(backend_result_as_tool(&mismatched, &call).is_err()); +} + +#[test] +fn context_item_constructor_rejects_invalid_nested_message() { + // `Message::new` is an intentional compatibility escape hatch; the + // context value object must still enforce the nested message contract. + let invalid = Message::new( + MessageRole::User, + vec![ContentPart::Text { + text: String::new(), + }], + ); + let error = ContextItem::try_new("fixture", invalid, 0, false) + .expect_err("invalid nested message must be rejected at Core boundary"); + assert!(error.message().contains("message text 不能为空")); +} + +#[test] +fn context_request_revalidates_serde_and_legacy_fields() { + // Public context requests can arrive from a persisted JSON document, while + // the fluent setters remain infallible for compatibility. The source + // boundary must still reject every invalid field before dispatch. + let invalid: ContextRequest = serde_json::from_value(json!({ + "sessionId": "bad id", + "runId": "run-1", + "tokenBudget": 0, + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": ""}] + }], + "metadata": [] + })) + .expect("wire values intentionally bypass fluent constructors"); + let error = invalid + .validate() + .expect_err("invalid context request must be rejected"); + assert!(error.message().contains("session id")); + + let invalid_message = + ContextRequest::new().with_messages([Message::new(MessageRole::User, Vec::new())]); + let error = invalid_message + .validate() + .expect_err("legacy empty message must be rejected"); + assert!(error.message().contains("message content")); +} + +#[test] +fn in_memory_store_enforces_revision_cas() { + let mut store = InMemoryRuntimeStore::new(); + let snapshot = RuntimeSnapshot::try_new("runtime-1").expect("runtime"); + let event = RuntimeEvent::runtime_created("runtime-1", 1, 1).expect("event"); + let mut committed = snapshot.clone(); + apply_event(&mut committed, &event).expect("apply"); + store + .commit("runtime-1", None, &committed, &[event]) + .expect("initial commit"); + let error = store + .commit("runtime-1", None, &committed, &[]) + .expect_err("stale CAS"); + assert_eq!(error.kind(), StoreErrorKind::Conflict); + assert_eq!(store.snapshot("runtime-1").expect("snapshot").revision(), 1); +} + +#[test] +fn reducer_rejects_invalid_transition_and_supports_reconciliation() { + let mut snapshot = RuntimeSnapshot::try_new("runtime-1").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-1", 1, 1).expect("event"), + ) + .expect("runtime"); + let run = RunSnapshot::try_new("run-1", "agent", "任务", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-1", 2, 2, &run).expect("event"), + ) + .expect("run"); + + let invalid = RuntimeEvent::completed("runtime-1", 3, 3, "run-1", "过早完成").expect("event"); + assert!(matches!( + reduce(&snapshot, &invalid), + Err(error) if error.kind() == agent_runtime_core::RuntimeErrorKind::InvalidTransition + )); + + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed("runtime-1", 3, 3, "run-1", RuntimeEventKind::RunStarted) + .expect("start"), + ) + .expect("start"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-1", + 4, + 4, + "run-1", + RuntimeEventKind::ReconciliationRequired, + ) + .expect("reconciliation"), + ) + .expect("reconciliation"); + assert_eq!( + snapshot.run("run-1").expect("run").status(), + RunStatus::Reconciling + ); +} + +#[test] +fn reducer_revalidates_serde_payloads_before_accepting_them() { + // Runtime events are often decoded from persisted JSON. `serde` can build + // the public wire structs without running their fallible constructors, so + // the reducer must enforce the same invariants before mutating state. + let mut snapshot = RuntimeSnapshot::try_new("runtime-wire-validation").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-wire-validation", 1, 1).expect("runtime"), + ) + .expect("runtime"); + let run = RunSnapshot::try_new("run-wire-validation", "agent", "任务", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-wire-validation", 2, 2, &run).expect("run"), + ) + .expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-wire-validation", + 3, + 3, + "run-wire-validation", + RuntimeEventKind::RunStarted, + ) + .expect("start"), + ) + .expect("start"); + + let malformed_message = RuntimeEvent::new( + "runtime-wire-validation", + 4, + 4, + RuntimeEventKind::MessageAppended, + Some("run-wire-validation".to_owned()), + json!({ + "role": "user", + "content": [{"type": "text", "text": ""}] + }), + ) + .expect("event envelope"); + let error = apply_event(&mut snapshot, &malformed_message) + .expect_err("empty text decoded through serde must be rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidEvent + ); + assert_eq!(snapshot.revision(), 3); + assert!( + snapshot + .run("run-wire-validation") + .unwrap() + .messages() + .is_empty() + ); + + let malformed_call = RuntimeEvent::new( + "runtime-wire-validation", + 4, + 4, + RuntimeEventKind::ToolCallRequested, + Some("run-wire-validation".to_owned()), + json!({"id": "call-wire", "name": "echo", "arguments": "not-an-object"}), + ) + .expect("event envelope"); + let error = apply_event(&mut snapshot, &malformed_call) + .expect_err("non-object tool arguments decoded through serde must be rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidEvent + ); + assert!( + snapshot + .run("run-wire-validation") + .unwrap() + .tool_calls() + .is_empty() + ); +} + +#[test] +fn reconciliation_can_apply_observed_tool_result_before_reopening_run() { + let mut snapshot = RuntimeSnapshot::try_new("runtime-restore").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-restore", 1, 1).expect("runtime"), + ) + .expect("runtime"); + let run = RunSnapshot::try_new("run-restore", "agent", "恢复", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-restore", 2, 2, &run).expect("run"), + ) + .expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-restore", + 3, + 3, + "run-restore", + RuntimeEventKind::RunStarted, + ) + .expect("start"), + ) + .expect("start"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-restore", + 4, + 4, + "run-restore", + RuntimeEventKind::ReconciliationRequired, + ) + .expect("gate"), + ) + .expect("gate"); + + let call = ToolCall::try_new("call-restore", "echo", json!({"ok": true})).expect("call"); + apply_event( + &mut snapshot, + &RuntimeEvent::tool_call_requested("runtime-restore", 5, 5, "run-restore", &call) + .expect("observed call"), + ) + .expect("observed call"); + let result = ToolResult::success("call-restore", json!({"done": true})).expect("result"); + apply_event( + &mut snapshot, + &RuntimeEvent::tool_result("runtime-restore", 6, 6, "run-restore", &result, false) + .expect("observed result"), + ) + .expect("observed result"); + assert_eq!( + snapshot.run("run-restore").expect("run").status(), + RunStatus::Reconciling + ); + apply_event( + &mut snapshot, + &RuntimeEvent::reconciled("runtime-restore", 7, 7, "run-restore", None).expect("reopen"), + ) + .expect("reopen"); + let restored = snapshot.run("run-restore").expect("run"); + assert_eq!(restored.status(), RunStatus::Running); + assert_eq!(restored.tool_calls(), &[call]); + assert_eq!(restored.tool_results(), &[result]); +} + +#[test] +fn reconciliation_requires_explicit_external_evidence() { + let mut snapshot = RuntimeSnapshot::try_new("runtime-reconcile-evidence").expect("runtime"); + apply_event( + &mut snapshot, + &RuntimeEvent::runtime_created("runtime-reconcile-evidence", 1, 1).expect("runtime"), + ) + .expect("runtime"); + let run = RunSnapshot::try_new("run-reconcile-evidence", "agent", "恢复", 1).expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::run_created("runtime-reconcile-evidence", 2, 2, &run).expect("run"), + ) + .expect("run"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-reconcile-evidence", + 3, + 3, + "run-reconcile-evidence", + RuntimeEventKind::RunStarted, + ) + .expect("start"), + ) + .expect("start"); + apply_event( + &mut snapshot, + &RuntimeEvent::status_changed( + "runtime-reconcile-evidence", + 4, + 4, + "run-reconcile-evidence", + RuntimeEventKind::ReconciliationRequired, + ) + .expect("gate"), + ) + .expect("gate"); + + // A generic status_changed event has an empty detail object. It must not + // be enough to reopen a run and accidentally replay an unknown side effect. + let bare = RuntimeEvent::status_changed( + "runtime-reconcile-evidence", + 5, + 5, + "run-reconcile-evidence", + RuntimeEventKind::RunReconciled, + ) + .expect("bare event"); + let error = apply_event(&mut snapshot, &bare).expect_err("bare evidence rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidEvent + ); + assert_eq!( + snapshot.run("run-reconcile-evidence").unwrap().status(), + RunStatus::Reconciling + ); + + let mismatched = RuntimeEvent::reconciled( + "runtime-reconcile-evidence", + 5, + 5, + "run-reconcile-evidence", + Some("another-call"), + ) + .expect("mismatched event"); + let error = + apply_event(&mut snapshot, &mismatched).expect_err("unresolved call evidence rejected"); + assert_eq!( + error.kind(), + agent_runtime_core::RuntimeErrorKind::InvalidTransition + ); + assert_eq!( + snapshot.run("run-reconcile-evidence").unwrap().status(), + RunStatus::Reconciling + ); +} + +#[test] +fn external_observation_port_is_bounded_opaque_and_read_only() { + struct Fixture; + + impl ExternalObservationSource for Fixture { + fn observe( + &self, + request: &ExternalObservationRequest, + ) -> Result { + assert_eq!(request.backend(), "provider"); + assert_eq!(request.operation(), "get"); + assert_eq!(request.reference_id(), "req-1"); + ExternalObservation::completed(json!({"vendorState": "done"})).map_err(|error| { + ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string()) + }) + } + } + + let request = ExternalObservationRequest::try_new("provider", "get", "req-1") + .expect("request") + .with_metadata(json!({"tenant": "test"})) + .expect("metadata"); + let observed = Fixture.observe(&request).expect("observation"); + assert_eq!(observed.status(), ExternalObservationStatus::Completed); + assert_eq!(observed.output().unwrap()["vendorState"], "done"); + + // The port only returns an opaque observation; it does not produce a tool + // result or mutate a runtime snapshot. Reconciliation remains an explicit + // Host/Runtime command. + let encoded = serde_json::to_value(&observed).expect("encode"); + let decoded: ExternalObservation = serde_json::from_value(encoded).expect("decode"); + assert_eq!(decoded, observed); +} + +#[test] +fn external_observation_rejects_invalid_shape_and_oversized_json() { + assert!(ExternalObservationRequest::try_new("bad backend", "get", "req-1").is_err()); + assert!( + ExternalObservationRequest::try_new("provider", "get", "req-1") + .expect("request") + .with_metadata(json!(["metadata must be an object"])) + .is_err() + ); + + let oversized = Value::String("x".repeat(MAX_EXTERNAL_OBSERVATION_JSON_BYTES)); + assert!(ExternalObservation::completed(oversized).is_err()); + + let pending_with_output = json!({ + "status": "pending", + "output": {"shouldNot": "be present"}, + "externalId": null, + "metadata": {} + }); + assert!(serde_json::from_value::(pending_with_output).is_err()); +} + +#[test] +fn approval_binding_is_argument_and_expiry_bound() { + let call = ToolCall::try_new("call-approval", "echo", json!({"text": "ok"})).expect("call"); + let request = ApprovalRequest::try_new_with_expiry("approval-1", "run-1", call.clone(), 1_000) + .expect("request"); + assert!(!request.arguments_hash().is_empty()); + assert!(!request.approval_token().is_empty()); + request + .verify_binding(&call, request.approval_token(), 999) + .expect("matching binding"); + + let changed_call = ToolCall::try_new("call-approval", "echo", json!({"text": "changed"})) + .expect("changed call"); + assert!( + request + .verify_binding(&changed_call, request.approval_token(), 999) + .is_err() + ); + assert!(request.verify_binding(&call, "wrong-token", 999).is_err()); + assert!( + request + .verify_binding(&call, request.approval_token(), 1_000) + .is_err() + ); +} + +struct TextProvider; + +impl ModelProvider for TextProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + ProviderResponse::text(request.request_id(), request.model(), "ok").map_err(Into::into) + } +} + +#[derive(Default)] +struct Events(Vec); + +impl ProviderStreamSink for Events { + fn emit( + &mut self, + event: ProviderStreamEvent, + ) -> Result<(), agent_runtime_core::ProviderError> { + self.0.push(event); + Ok(()) + } +} + +#[test] +fn provider_default_stream_and_tool_registry_are_deterministic() { + let request = ProviderRequest::try_new( + "request-1", + "fake", + [Message::user("hello").expect("message")], + ) + .expect("request"); + let mut events = Events::default(); + let response = TextProvider.stream(&request, &mut events).expect("stream"); + assert_eq!(response.content()[0].as_text(), Some("ok")); + assert!(matches!( + events.0.last(), + Some(ProviderStreamEvent::Completed) + )); + + let definition = + ToolDefinition::try_new("echo", "echo", json!({"type": "object"})).expect("definition"); + let mut registry = ToolRegistry::new(); + registry.register(definition.clone()).expect("register"); + assert!(registry.register(definition).is_err()); + assert_eq!(registry.len(), 1); +} + +#[test] +fn provider_request_can_be_built_from_prompt_sections() { + let prompt = PromptBuilder::new() + .system("policy") + .expect("system") + .developer("format") + .expect("developer") + .context("facts") + .expect("context") + .user("question") + .expect("user"); + let request = + ProviderRequest::try_from_prompt("prompt-request", "fake", &prompt).expect("request"); + assert_eq!(request.messages().len(), 4); + assert_eq!(request.messages()[0].role(), MessageRole::System); + assert_eq!(request.messages()[1].role(), MessageRole::Developer); + // Context section 在旧 MessageRole 合同中以 user 通道传递,语义仍由 + // PromptSectionKind 保留,Provider adapter 可据此执行相同映射。 + assert_eq!(request.messages()[2].role(), MessageRole::User); + assert_eq!(request.messages()[3].role(), MessageRole::User); +} + +struct FixtureCompletion; + +impl CompletionPolicy<()> for FixtureCompletion { + fn id(&self) -> &str { + "fixture" + } + + fn evaluate(&self, _context: &()) -> CompletionDecision { + CompletionDecision::Ready + } +} + +#[test] +fn catalog_and_completion_contracts_are_small_and_fail_closed() { + let read = CapabilityDefinition::try_new( + "read", + "read_file", + "读取文件", + json!({"type": "object"}), + (), + ) + .expect("capability"); + let write = CapabilityDefinition::try_new( + "write", + "write_file", + "写入文件", + json!({"type": "object"}), + (), + ) + .expect("capability"); + let registry = CapabilityRegistry::try_new([read, write]).expect("registry"); + assert_eq!( + registry.get_by_function_name("read_file").unwrap().id(), + "read" + ); + assert!( + CapabilityRegistry::try_new([ + CapabilityDefinition::try_new( + "read", + "another_function", + "重复 id", + json!({"type": "object"}), + (), + ) + .unwrap(), + CapabilityDefinition::try_new( + "other", + "another_function", + "重复 function", + json!({"type": "object"}), + (), + ) + .unwrap(), + ]) + .is_err() + ); + + let agent = AgentDescriptor::try_new("writer", "worker", ["write"]).expect("agent"); + let catalog = AgentCatalog::try_new([agent]).expect("catalog"); + catalog + .validate_capabilities(®istry) + .expect("references"); + assert!( + AgentCatalog::try_new( + [AgentDescriptor::try_new("writer", "worker", ["unknown"]).unwrap(),] + ) + .unwrap() + .validate_capabilities(®istry) + .is_err() + ); + + let blocker = CompletionBlocker::try_new("missing-evidence", "需要证据").unwrap(); + let blocked = CompletionDecision::blocked([blocker]).unwrap(); + assert!(!blocked.is_ready()); + assert!(FixtureCompletion.evaluate(&()).is_ready()); +} diff --git a/rust/crates/agent-runtime-core/tests/tool_context_contracts.rs b/rust/crates/agent-runtime-core/tests/tool_context_contracts.rs new file mode 100644 index 000000000..1bc538323 --- /dev/null +++ b/rust/crates/agent-runtime-core/tests/tool_context_contracts.rs @@ -0,0 +1,24 @@ +use agent_runtime_core::ToolContext; +use serde_json::json; + +#[test] +fn tool_context_revalidates_serde_ids_and_metadata() { + // ToolContext remains serde-compatible for persisted fixtures, so a + // caller can bypass the infallible legacy setters. The executor boundary + // must still reject an invalid identity or metadata shape. + let invalid: ToolContext = serde_json::from_value(json!({ + "sessionId": "bad id", + "runId": "run-1", + "metadata": [] + })) + .expect("unchecked serde fixture"); + assert!(invalid.validate().is_err()); + + let valid: ToolContext = serde_json::from_value(json!({ + "sessionId": "session-1", + "runId": "run-1", + "metadata": {"source": "test"} + })) + .expect("valid context"); + valid.validate().expect("valid context passes"); +} diff --git a/rust/crates/agent-runtime-engine/Cargo.toml b/rust/crates/agent-runtime-engine/Cargo.toml new file mode 100644 index 000000000..7546df140 --- /dev/null +++ b/rust/crates/agent-runtime-engine/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-runtime-engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "通用 Agent 的单智能体执行循环" + +[dependencies] +agent-runtime-core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/rust/crates/agent-runtime-engine/src/lib.rs b/rust/crates/agent-runtime-engine/src/lib.rs new file mode 100644 index 000000000..74d4fd489 --- /dev/null +++ b/rust/crates/agent-runtime-engine/src/lib.rs @@ -0,0 +1,5461 @@ +//! 通用单 Agent 执行循环。 +//! +//! Engine 只编排 Core 提供的中立端口:模型、工具和上下文源。真正的网络、 +//! 文件系统和 MCP 进程都由宿主注入,因此这个 crate 可以在 CLI、桌面端或 +//! 服务端复用。 + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use agent_runtime_core::{ + ApprovalDecision, ApprovalPolicy, ApprovalRequest, ContentPart, ContextItem, ContextRequest, + ContextSource, ContractError, Message, MessageRole, ModelProvider, PromptBuilder, + ProviderError, ProviderErrorKind, ProviderRequest, ProviderResponse, ProviderStreamEvent, + ProviderStreamSink, ToolCall, ToolContext, ToolDefinition, ToolExecutor, ToolResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; + +/// 单次运行的输入。调用方可以把同一份消息历史用于恢复或重试。 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AgentInput { + pub request_id: String, + pub model: String, + pub messages: Vec, + /// 可选身份只用于上下文源和工具审计,不会被 Provider 自动写入 prompt。 + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub run_id: Option, + #[serde(default)] + pub tools: Vec, + #[serde(default = "default_max_steps")] + pub max_steps: usize, + /// Provider 在明确的瞬时错误(连接/超时)上最多重试次数;流已经 + /// 产生增量后不会重试,避免把未知外部结果重复发送给上游。 + #[serde(default = "default_provider_retries")] + pub max_provider_retries: usize, + /// 单次 Provider 尝试的软超时边界(毫秒)。同步 Provider 无法被线程 + /// 安全强杀,因此 Engine 会在返回/流事件边界检查该 deadline;适配器 + /// 仍应在自己的 I/O 层实现硬超时,并在收到取消后尽快返回。 + #[serde(default)] + pub provider_timeout_ms: Option, + /// 恢复时从已验证的 safe cursor 开始;新 run 默认为 0。 + #[serde(default)] + pub start_step: usize, + #[serde(default = "default_context_budget")] + pub context_budget: usize, + /// Maximum serialized tool-result size admitted into the next prompt. + /// Oversized results are replaced by a bounded preview with an explicit + /// truncation reason so storage and providers never receive unbounded data. + /// The builder clamps this value to 256 bytes; deserialized values below + /// that floor are rejected before any Provider or tool call. + #[serde(default = "default_tool_output_budget")] + pub max_tool_output_bytes: usize, + /// Explicit approval continuation. When set, the engine consumes the + /// checkpoint's already returned tool call before issuing another provider + /// request; it never replays the provider request that produced the call. + #[serde(default)] + pub approval_resume: Option, +} + +/// A durable approval resolution handed back by the host at an explicit +/// resume boundary. Every binding field comes from the persisted request; an +/// absent binding is rejected instead of being treated as a legacy allow. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ApprovalResume { + pub call_id: String, + pub decision: ApprovalDecision, + pub arguments_hash: String, + pub approval_token: String, + pub expires_at_ms: u64, + #[serde(default)] + pub provider_request_id: Option, +} + +fn default_max_steps() -> usize { + 32 +} + +fn default_provider_retries() -> usize { + 2 +} + +fn default_context_budget() -> usize { + 16_000 +} + +/// The truncation envelope carries a reason and byte counters, so a JSON +/// result cannot be represented safely below this floor. The builder already +/// clamps to it; `run_internal` validates deserialized inputs as well. +const MIN_TOOL_OUTPUT_BUDGET: usize = 256; + +fn default_tool_output_budget() -> usize { + 16_000 +} + +fn current_time_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +impl AgentInput { + pub fn new( + request_id: impl Into, + model: impl Into, + messages: Vec, + ) -> Self { + Self { + request_id: request_id.into(), + model: model.into(), + messages, + session_id: None, + run_id: None, + tools: Vec::new(), + max_steps: default_max_steps(), + max_provider_retries: default_provider_retries(), + provider_timeout_ms: None, + start_step: 0, + context_budget: default_context_budget(), + max_tool_output_bytes: default_tool_output_budget(), + approval_resume: None, + } + } + + /// 用 PromptBuilder 创建输入,避免宿主自行拼接角色消息。 + pub fn try_from_prompt( + request_id: impl Into, + model: impl Into, + prompt: &PromptBuilder, + ) -> Result { + Ok(Self::new(request_id, model, prompt.build_messages()?)) + } + + /// 替换当前输入消息为 PromptBuilder 的确定性展开结果。 + pub fn with_prompt(mut self, prompt: &PromptBuilder) -> Result { + self.messages = prompt.build_messages()?; + Ok(self) + } + + pub fn with_tools(mut self, tools: Vec) -> Self { + self.tools = tools; + self + } + + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } + + pub fn with_run_id(mut self, run_id: impl Into) -> Self { + self.run_id = Some(run_id.into()); + self + } + + pub fn with_max_steps(mut self, max_steps: usize) -> Self { + self.max_steps = max_steps.max(1); + self + } + + pub fn with_provider_retries(mut self, retries: usize) -> Self { + self.max_provider_retries = retries; + self + } + + /// 设置单次 Provider 尝试的软超时;真正的网络硬超时由 Provider 适配器 + /// 负责。零值表示在下一次 Provider 边界立即超时,便于调用方 fail fast。 + pub fn with_provider_timeout(mut self, timeout: Duration) -> Self { + let millis = timeout.as_millis().min(u64::MAX as u128) as u64; + self.provider_timeout_ms = Some(millis); + self + } + + pub fn with_provider_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.provider_timeout_ms = Some(timeout_ms); + self + } + + pub fn without_provider_timeout(mut self) -> Self { + self.provider_timeout_ms = None; + self + } + + fn provider_timeout(&self) -> Option { + self.provider_timeout_ms.map(Duration::from_millis) + } + + pub fn with_start_step(mut self, start_step: usize) -> Self { + self.start_step = start_step; + self + } + + pub fn with_context_budget(mut self, budget: usize) -> Self { + self.context_budget = budget.max(256); + self + } + + pub fn with_max_tool_output_bytes(mut self, budget: usize) -> Self { + self.max_tool_output_bytes = budget.max(MIN_TOOL_OUTPUT_BUDGET); + self + } + + /// Attach an approval decision for an explicit continuation. This is not + /// an implicit retry: the caller must have persisted and resolved the + /// matching approval record first. + pub fn with_approval_resume( + mut self, + call_id: impl Into, + decision: ApprovalDecision, + ) -> Self { + self.approval_resume = Some(ApprovalResume { + call_id: call_id.into(), + decision, + arguments_hash: String::new(), + approval_token: String::new(), + expires_at_ms: 0, + provider_request_id: None, + }); + self + } + + /// Attach the exact durable approval binding. The short builder above is + /// retained for source compatibility but cannot pass resume validation + /// without this binding. + pub fn with_approval_resume_binding( + mut self, + arguments_hash: impl Into, + approval_token: impl Into, + expires_at_ms: u64, + ) -> Self { + if let Some(resume) = self.approval_resume.as_mut() { + resume.arguments_hash = arguments_hash.into(); + resume.approval_token = approval_token.into(); + resume.expires_at_ms = expires_at_ms; + } + self + } + + /// Preserve the original provider request identity in continuation + /// checkpoints without issuing that request again. + pub fn with_approval_resume_provider_request_id( + mut self, + provider_request_id: impl Into, + ) -> Self { + if let Some(resume) = self.approval_resume.as_mut() { + resume.provider_request_id = Some(provider_request_id.into()); + } + self + } +} + +/// 一个明确的默认策略:只允许调用方显式放行的工具。 +#[derive(Clone, Debug, Default)] +pub struct AllowList { + names: std::collections::BTreeSet, +} + +impl AllowList { + pub fn new(names: impl IntoIterator) -> Self { + Self { + names: names.into_iter().collect(), + } + } +} + +impl ApprovalPolicy for AllowList { + fn decide( + &self, + request: &ApprovalRequest, + ) -> Result { + if self.names.contains(request.call().name()) { + Ok(ApprovalDecision::allow()) + } else { + Ok(ApprovalDecision::deny("工具未在 allow list 中")) + } + } +} + +/// 工具失败重试的显式策略。 +/// +/// 默认是 `Never`。只有调用方明确声明工具具有幂等语义,并且选择了要重试 +/// 的错误类别,Engine 才会再次执行;`Unknown`/`Cancelled` 永远不会自动重放, +/// 因为它们可能已经产生了无法确认的外部副作用。 +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ToolRetryPolicy { + #[default] + Never, + Idempotent { + max_retries: usize, + retry_on_timeout: bool, + retry_on_failed: bool, + }, +} + +impl ToolRetryPolicy { + pub const fn never() -> Self { + Self::Never + } + + /// 幂等工具的常用最小策略:只重试明确的 Timeout。 + pub const fn idempotent(max_retries: usize) -> Self { + Self::Idempotent { + max_retries, + retry_on_timeout: true, + retry_on_failed: false, + } + } + + /// 需要把可重试错误类别写清楚时使用此构造器。 + pub const fn idempotent_on( + max_retries: usize, + retry_on_timeout: bool, + retry_on_failed: bool, + ) -> Self { + Self::Idempotent { + max_retries, + retry_on_timeout, + retry_on_failed, + } + } + + fn allows(&self, error: &agent_runtime_core::ToolError, retry_count: usize) -> bool { + let Self::Idempotent { + max_retries, + retry_on_timeout, + retry_on_failed, + } = self + else { + return false; + }; + if retry_count >= *max_retries { + return false; + } + // Unknown means the side effect outcome is not known. Even an idempotent + // declaration cannot turn that into a safe automatic replay. + match error.kind() { + agent_runtime_core::ToolErrorKind::Unknown + | agent_runtime_core::ToolErrorKind::Cancelled => false, + agent_runtime_core::ToolErrorKind::Timeout => *retry_on_timeout, + agent_runtime_core::ToolErrorKind::Failed => *retry_on_failed, + _ => false, + } + } +} + +/// 按调用动态返回工具重试策略;没有注入时 Engine 使用 `Never`。 +pub trait ToolRetryPolicyProvider: Send + Sync { + fn policy(&self, call: &ToolCall) -> ToolRetryPolicy; +} + +/// 便于 CLI/日志/持久化的 Engine 事件;事件是观察值,不直接改变状态。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EngineEvent { + StepStarted { + step: usize, + }, + /// Durable Host trace boundary for a context rewrite. The actual + /// compressed messages stay in the checkpoint; this event only marks the + /// state-machine transition and carries no provider response body. + CompactionStarted { + step: usize, + }, + /// The exact message history returned by the compressor. Host uses the + /// durable checkpoint as the write source, while this observation keeps + /// the rewrite visible to event consumers and test fixtures. + ContextCompacted { + step: usize, + messages: Vec, + }, + CompactionCompleted { + step: usize, + }, + ModelCompleted { + step: usize, + response: ProviderResponse, + }, + ToolRequested { + step: usize, + call: ToolCall, + }, + ApprovalDenied { + step: usize, + call_id: String, + }, + ToolCompleted { + step: usize, + result: ToolResult, + }, + Finished { + step: usize, + text: String, + }, +} + +/// Provider 流式事件在 Engine 边界上的观察值。 +/// +/// Core 的 `ProviderStreamEvent` 不携带执行 step;这里补上 step 后,宿主可以 +/// 在同一个 run 中区分多轮流式输出。该事件只用于观察和 UI 增量渲染,不会 +/// 取代最终的 `ModelCompleted` 事件或 durable 消息边界。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EngineStreamEvent { + step: usize, + event: ProviderStreamEvent, +} + +impl EngineStreamEvent { + pub fn step(&self) -> usize { + self.step + } + + pub fn event(&self) -> &ProviderStreamEvent { + &self.event + } + + /// 消费事件中的 Provider 值,便于转发给不需要 Engine 包装层的宿主。 + pub fn into_event(self) -> ProviderStreamEvent { + self.event + } +} + +/// 流式 Provider 事件监听器。回调只观察事件,不授予修改运行状态的能力。 +pub trait StreamEventListener: Send + Sync { + fn on_stream_event(&self, event: &EngineStreamEvent); +} + +pub trait EventListener: Send + Sync { + fn on_event(&self, event: &EngineEvent); +} + +/// Engine 对“下一次可安全继续的位置”做出的最小持久化契约。 +/// +/// 监听器由 Host 注入,Engine 不关心数据最终落在 SQLite、日志还是别的 +/// 存储里。`provider_in_flight`/`tool_in_flight` 明确表示存在未知外部副作用, +/// 这类检查点只能进入对账门,不应被宿主直接重放。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckpointPhase { + /// 压缩器正在重写上下文;结果尚未成为可安全恢复的消息边界。 + /// + /// 压缩通常由注入的 Provider 实现,因而即使它看起来像“内部”工作, + /// 也必须先落一个显式检查点,避免进程在压缩中退出后被误判为 safe。 + Compacting, + ProviderInFlight, + AwaitingApproval, + ToolInFlight, + Safe, +} + +impl CheckpointPhase { + pub fn as_str(self) -> &'static str { + match self { + Self::Compacting => "compacting", + Self::ProviderInFlight => "provider_in_flight", + Self::AwaitingApproval => "awaiting_approval", + Self::ToolInFlight => "tool_in_flight", + Self::Safe => "safe", + } + } +} + +/// 一个检查点只携带继续编排所需的消息边界和外部调用身份;不保存 +/// provider secret、工具 token 或其它连接凭据。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineCheckpoint { + pub step: usize, + pub next_step: usize, + pub phase: CheckpointPhase, + pub messages: Vec, + pub provider_request_id: Option, + pub tool_call_id: Option, +} + +pub trait CheckpointListener: Send + Sync { + fn on_checkpoint(&self, checkpoint: &EngineCheckpoint) -> Result<(), String>; +} + +/// 上下文超过基础消息预算时的可插拔压缩端口。 +/// +/// 压缩器只返回新的中立消息历史,不拥有运行状态或工具权限;默认不配置 +/// 压缩器时 Engine 会 fail closed,而不是静默丢弃 system/developer 指令。 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CompressionRequest { + pub request_id: String, + pub model: String, + pub messages: Vec, + pub budget: usize, + pub session_id: Option, + pub run_id: Option, +} + +impl CompressionRequest { + /// Re-run the same request/identity contract used by the main Provider + /// boundary. Compressors are public extension points and may be called + /// directly by a host, so they cannot rely on `AgentEngine::run_internal` + /// having validated a request first. + pub fn validate(&self) -> Result<(), EngineError> { + if self.budget == 0 { + return Err(EngineError::InvalidInput( + "compression budget 必须大于 0".to_owned(), + )); + } + let mut context = ContextRequest::new().with_messages(self.messages.clone()); + if let Some(session_id) = self.session_id.as_deref() { + context = context.with_session_id(session_id); + } + if let Some(run_id) = self.run_id.as_deref() { + context = context.with_run_id(run_id); + } + context + .validate() + .map_err(|error| EngineError::InvalidInput(format!("压缩上下文无效: {error}")))?; + ProviderRequest::try_new( + self.request_id.clone(), + self.model.clone(), + self.messages.clone(), + ) + .map(|_| ()) + .map_err(|error| EngineError::InvalidInput(format!("压缩请求无效: {error}"))) + } +} + +pub trait ContextCompressor: Send + Sync { + fn compress(&self, request: &CompressionRequest) -> Result, EngineError>; +} + +/// 将一个普通 ModelProvider 作为最小压缩适配器。真实应用可注入专用摘要 +/// 模型;这里不把摘要调用伪装成主运行的工具/事件步骤。 +pub struct ProviderContextCompressor<'a> { + provider: &'a dyn ModelProvider, +} + +impl<'a> ProviderContextCompressor<'a> { + pub fn new(provider: &'a dyn ModelProvider) -> Self { + Self { provider } + } +} + +impl ContextCompressor for ProviderContextCompressor<'_> { + fn compress(&self, request: &CompressionRequest) -> Result, EngineError> { + request + .validate() + .map_err(|error| EngineError::ContextOverflow(error.to_string()))?; + // `CompressionRequest` is a public serde-facing value. Do not assume + // that callers used the fallible Core constructors before handing it + // to an adapter; otherwise malformed history would cross a Provider + // boundary during the recovery/compression path. + for message in &request.messages { + validate_message_boundary(message).map_err(|error| { + EngineError::ContextOverflow(format!("压缩输入包含非法消息: {error}")) + })?; + } + let provider_request_id = format!("{}-compression", request.request_id); + let provider_request = ProviderRequest::try_new( + provider_request_id.clone(), + request.model.clone(), + request.messages.clone(), + ) + .map_err(|error| EngineError::ContextOverflow(error.to_string()))?; + let response = self + .provider + .complete(&provider_request) + .map_err(|error| EngineError::ContextOverflow(error.to_string()))?; + // Compression uses the same Provider identity contract as a normal + // step. A late/misrouted summary must not be silently accepted as the + // current run's context. + if response.request_id() != provider_request.request_id() + || response.model() != provider_request.model() + { + return Err(EngineError::ProviderResponseMismatch { + expected_request_id: provider_request.request_id().to_owned(), + actual_request_id: response.request_id().to_owned(), + expected_model: provider_request.model().to_owned(), + actual_model: response.model().to_owned(), + }); + } + for part in response.content() { + validate_content_part_boundary(part).map_err(|error| { + EngineError::ContextOverflow(format!("压缩 Provider 返回非法 content: {error}")) + })?; + if matches!( + part, + ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } + ) { + return Err(EngineError::ContextOverflow( + "压缩 Provider content 不得包含结构化工具历史".to_owned(), + )); + } + } + if !response.tool_calls().is_empty() { + for call in response.tool_calls() { + validate_tool_call_boundary(call).map_err(|error| { + EngineError::ContextOverflow(format!( + "压缩 Provider 返回非法 tool call: {error}" + )) + })?; + } + return Err(EngineError::ContextOverflow( + "压缩 Provider 返回了工具调用".to_owned(), + )); + } + let summary = response + .content() + .iter() + .filter_map(ContentPart::as_text) + .collect::>() + .join(""); + if summary.trim().is_empty() { + return Err(EngineError::ContextOverflow( + "压缩 Provider 返回空摘要".to_owned(), + )); + } + let message = Message::user(format!("[compressed context]\n{summary}")) + .map_err(|error| EngineError::ContextOverflow(error.to_string()))?; + Ok(vec![message]) + } +} + +/// 拥有 Provider 引用的压缩适配器,适合与宿主的 `Arc` 组装一起使用。 +/// +/// 它不复制压缩规则;每次调用都通过现有的借用适配器,保持请求校验、 +/// Provider identity 检查和摘要格式与 `ProviderContextCompressor` 一致。 +#[derive(Clone)] +pub struct OwnedProviderContextCompressor { + provider: Arc, +} + +impl OwnedProviderContextCompressor { + pub fn new(provider: Arc) -> Self { + Self { provider } + } +} + +impl ContextCompressor for OwnedProviderContextCompressor { + fn compress(&self, request: &CompressionRequest) -> Result, EngineError> { + ProviderContextCompressor::new(self.provider.as_ref()).compress(request) + } +} + +#[derive(Debug, Error)] +pub enum EngineError { + #[error("运行输入无效: {0}")] + InvalidInput(String), + #[error("Provider 调用失败: {0}")] + Provider(String), + #[error( + "Provider 响应 identity 不匹配: request_id expected={expected_request_id} actual={actual_request_id}, model expected={expected_model} actual={actual_model}" + )] + ProviderResponseMismatch { + expected_request_id: String, + actual_request_id: String, + expected_model: String, + actual_model: String, + }, + #[error("Provider 调用超过超时边界: step={step}, timeout_ms={timeout_ms}")] + ProviderTimeout { step: usize, timeout_ms: u64 }, + #[error("工具调用失败: {0}")] + Tool(String), + #[error("工具参数校验失败: {0}")] + ToolArguments(#[from] ToolArgumentError), + #[error("上下文组装失败: {0}")] + Context(String), + #[error("上下文超出预算且压缩失败: {0}")] + ContextOverflow(String), + #[error("工具审批失败: {0}")] + Approval(String), + #[error("工具审批需要宿主决议: request_id={request_id}, call_id={call_id}")] + ApprovalRequired { + request_id: String, + call_id: String, + arguments_hash: String, + approval_token: String, + expires_at_ms: u64, + }, + #[error("检查点写入失败: {0}")] + Checkpoint(String), + #[error("运行超过最大 step 数 {0}")] + StepLimit(usize), + #[error("运行已取消")] + Cancelled, +} + +/// 工具参数校验失败时保留工具名、JSON 路径和具体原因,供宿主记录结构化诊断。 +#[derive(Clone, Debug, Eq, PartialEq, Error)] +#[error("工具 `{tool_name}` 在 `{path}` 参数无效: {reason}")] +pub struct ToolArgumentError { + tool_name: String, + path: String, + reason: String, +} + +impl ToolArgumentError { + pub fn tool_name(&self) -> &str { + &self.tool_name + } + + pub fn path(&self) -> &str { + &self.path + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl ToolArgumentError { + fn new( + tool_name: impl Into, + path: impl Into, + reason: impl Into, + ) -> Self { + Self { + tool_name: tool_name.into(), + path: path.into(), + reason: reason.into(), + } + } +} + +/// 可跨线程共享的取消标记,避免把具体 async runtime 带入 Core。 +#[derive(Clone, Debug, Default)] +pub struct Cancellation(Arc); + +impl Cancellation { + /// 创建一个可由宿主与 Engine 共享的取消标记。 + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +/// 一次上下文组装的可观察结果。 +/// +/// 基础消息始终保留,即使它们本身已经超过预算;这样不会静默丢掉用户 +/// 对话或工具结果。超出预算的候选上下文会被跳过,并通过 `skipped`/`truncated` +/// 暴露给宿主,方便日志、指标或下一轮压缩策略使用。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContextAssembly { + messages: Vec, + selected: Vec, + skipped: Vec, + budget: usize, + estimated_size: usize, + base_estimated_size: usize, + overflowed: bool, +} + +impl ContextAssembly { + pub fn messages(&self) -> &[Message] { + &self.messages + } + + pub fn into_messages(self) -> Vec { + self.messages + } + + pub fn selected(&self) -> &[ContextItem] { + &self.selected + } + + pub fn skipped(&self) -> &[ContextItem] { + &self.skipped + } + + pub fn budget(&self) -> usize { + self.budget + } + + /// 估算值使用 UTF-8 字节数,不冒充 Provider 的真实 token 计费。 + pub fn estimated_size(&self) -> usize { + self.estimated_size + } + + pub fn base_estimated_size(&self) -> usize { + self.base_estimated_size + } + + /// 基础消息已经超过预算时为 true。 + pub fn overflowed(&self) -> bool { + self.overflowed + } + + /// 至少有一个候选项因预算被跳过。 + pub fn truncated(&self) -> bool { + !self.skipped.is_empty() + } + + pub fn remaining_budget(&self) -> usize { + self.budget.saturating_sub(self.estimated_size) + } + + /// 把一次组装决策转换为可供宿主记录的轻量统计,不暴露完整消息正文。 + fn observation(&self, step: usize) -> ContextObservation { + let selected_trusted_items = self.selected.iter().filter(|item| item.trusted()).count(); + let selected_untrusted_items = self.selected.len() - selected_trusted_items; + let skipped_trusted_items = self.skipped.iter().filter(|item| item.trusted()).count(); + let skipped_untrusted_items = self.skipped.len() - skipped_trusted_items; + ContextObservation { + step, + budget: self.budget, + base_estimated_size: self.base_estimated_size, + estimated_size: self.estimated_size, + selected_items: self.selected.len(), + skipped_items: self.skipped.len(), + selected_trusted_items, + selected_untrusted_items, + skipped_trusted_items, + skipped_untrusted_items, + overflowed: self.overflowed, + compression_attempted: false, + compression_input_size: None, + compression_output_size: None, + } + } +} + +/// 一次 Engine step 的上下文预算观测值。 +/// +/// 这里只记录大小和淘汰数量,不记录消息正文或凭据;宿主可以把它写入 +/// 指标/日志而不把上下文内容复制到持久化事件。大小单位是 UTF-8 字节,和 +/// [`ContextAssembly`] 的确定性估算保持一致,不冒充 Provider token 计费。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContextObservation { + pub step: usize, + pub budget: usize, + pub base_estimated_size: usize, + pub estimated_size: usize, + pub selected_items: usize, + pub skipped_items: usize, + /// Counts only candidate context items; base conversation messages are not + /// classified because they do not carry a `ContextItem::trusted` bit. + #[serde(default)] + pub selected_trusted_items: usize, + #[serde(default)] + pub selected_untrusted_items: usize, + #[serde(default)] + pub skipped_trusted_items: usize, + #[serde(default)] + pub skipped_untrusted_items: usize, + pub overflowed: bool, + pub compression_attempted: bool, + pub compression_input_size: Option, + pub compression_output_size: Option, +} + +impl ContextObservation { + pub fn remaining_budget(&self) -> usize { + self.budget.saturating_sub(self.estimated_size) + } + + pub fn truncated(&self) -> bool { + self.skipped_items > 0 + } +} + +/// 上下文预算器:按优先级保留候选项,用 UTF-8 字节数近似 token 数,保持实现可预测。 +#[derive(Default)] +pub struct ContextAssembler { + sources: Vec>, +} + +impl ContextAssembler { + pub fn new() -> Self { + Self::default() + } + + pub fn add_source(mut self, source: Arc) -> Self { + self.sources.push(source); + self + } + + pub fn source_count(&self) -> usize { + self.sources.len() + } + + pub fn assemble( + &self, + base: &[Message], + session_id: Option<&str>, + run_id: Option<&str>, + budget: usize, + ) -> Result, EngineError> { + Ok(self + .assemble_detailed(base, session_id, run_id, budget)? + .into_messages()) + } + + /// 组装并返回预算决策详情;旧 `assemble` API 只取其中的 messages。 + pub fn assemble_detailed( + &self, + base: &[Message], + session_id: Option<&str>, + run_id: Option<&str>, + budget: usize, + ) -> Result { + if budget == 0 { + return Err(EngineError::Context("上下文预算必须大于 0".to_owned())); + } + for message in base { + validate_message_boundary(message) + .map_err(|error| EngineError::Context(format!("基础消息无效: {error}")))?; + } + let mut request = ContextRequest::new().with_messages(base.to_vec()); + if let Some(session_id) = session_id { + request = request.with_session_id(session_id); + } + if let Some(run_id) = run_id { + request = request.with_run_id(run_id); + } + request = request + .with_token_budget(((budget / 4).max(1)).min(u32::MAX as usize) as u32) + .map_err(|error| EngineError::Context(error.to_string()))?; + // The request is public and can also be assembled by a custom caller; + // re-run the Core contract before invoking any ContextSource. This is + // the last no-side-effect boundary for source implementations. + request + .validate() + .map_err(|error| EngineError::Context(error.to_string()))?; + + // 记录 source/contribution 的单调序号,显式保证相同优先级时仍按 + // source 注册顺序和 source 返回顺序排列,而不是依赖排序实现细节。 + let mut items: Vec<(usize, ContextItem)> = Vec::new(); + let mut sequence = 0usize; + for source in &self.sources { + for item in source + .contribute(&request) + .map_err(|error| EngineError::Context(error.to_string()))? + { + validate_context_item_boundary(&item).map_err(|error| { + EngineError::Context(format!("上下文源返回非法 item: {error}")) + })?; + items.push((sequence, item)); + sequence = sequence.saturating_add(1); + } + } + + items.sort_by(|(left_order, left), (right_order, right)| { + right + .priority() + .cmp(&left.priority()) + .then_with(|| left_order.cmp(right_order)) + }); + + let mut messages = base.to_vec(); + let base_estimated_size = estimate_messages(&messages); + let mut used = base_estimated_size; + let mut selected = Vec::new(); + let mut skipped = Vec::new(); + for (_, item) in items { + let output_message = if item.trusted() { + item.message().clone() + } else { + downgrade_untrusted_context_message(item.message())? + }; + let cost = estimate_message(&output_message); + if used.saturating_add(cost) > budget { + skipped.push(item); + continue; + } + // 不可信上下文只能以普通用户消息出站;selected/skipped 仍保留 + // 原始 item,便于宿主审计其来源和 trust 统计。结构化 tool-call/ + // tool-result 也会先转成普通文本,不能伪造一段真实工具历史。 + messages.push(output_message); + used = used.saturating_add(cost); + selected.push(item); + } + Ok(ContextAssembly { + messages, + selected, + skipped, + budget, + estimated_size: used, + base_estimated_size, + overflowed: base_estimated_size > budget, + }) + } + + /// 直接接受 PromptBuilder,便于宿主在同一条 API 上组合系统/开发者/用户 + /// section 和检索上下文。Builder 的消息顺序保持不变。 + pub fn assemble_prompt( + &self, + prompt: &PromptBuilder, + session_id: Option<&str>, + run_id: Option<&str>, + budget: usize, + ) -> Result { + let base = prompt + .build() + .map_err(|error| EngineError::InvalidInput(error.to_string()))?; + self.assemble_detailed(&base, session_id, run_id, budget) + } +} + +/// 估算一组消息的上下文占用;单位是 UTF-8 字节,只用于确定性预算裁剪。 +pub fn estimate_messages_size(messages: &[Message]) -> usize { + messages.iter().map(estimate_message).sum() +} + +/// 估算单条消息的上下文占用;不声称等于任一 Provider 的 token 数。 +pub fn estimate_message_size(message: &Message) -> usize { + message + .content() + .iter() + .map(|part| match part { + ContentPart::Text { text } => text.len(), + ContentPart::Image { source } => source.to_string().len(), + ContentPart::ToolCall { + name, arguments, .. + } => name.len() + arguments.to_string().len(), + ContentPart::ToolResult { output, .. } => output.to_string().len(), + }) + .sum() +} + +fn estimate_messages(messages: &[Message]) -> usize { + estimate_messages_size(messages) +} + +fn estimate_message(message: &Message) -> usize { + estimate_message_size(message) +} + +/// 将不可信上下文限制在 User 通道。 +/// +/// 除了角色本身,`ToolCall`/`ToolResult` 也有结构化语义;若原样交给 +/// Provider,某些适配器会把它们编码成真实的 function-call history。把 +/// 这些块渲染成普通文本可保留审计线索,同时阻断外部 source 伪造工具历史。 +fn downgrade_untrusted_context_message(message: &Message) -> Result { + let content = message + .content() + .iter() + .map(|part| match part { + ContentPart::Text { text } => ContentPart::text(text.clone()), + ContentPart::Image { source } => ContentPart::image(source.clone()), + ContentPart::ToolCall { + id, + name, + arguments, + } => ContentPart::text(format!( + "[untrusted tool-call data] id={id}; name={name}; arguments={arguments}" + )), + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => ContentPart::text(format!( + "[untrusted tool-result data] call_id={tool_call_id}; is_error={is_error}; output={output}" + )), + }) + .collect::, _>>() + .map_err(|error| EngineError::Context(format!("不可信上下文降权失败: {error}")))?; + Message::try_new(MessageRole::User, content) + .map_err(|error| EngineError::Context(format!("不可信上下文消息无效: {error}"))) +} + +/// Compression may summarize history, but it cannot rewrite system/developer +/// instructions. Keep those messages in their original order and accept only +/// non-instruction output from the compressor afterwards. Compressor output is +/// an extension boundary, not a trusted runtime checkpoint: rejecting every +/// Tool role or structured tool part here prevents a summary adapter from +/// fabricating tool history/results that could be replayed by the next +/// Provider request. The next budget check still fails closed when the +/// preserved instructions alone do not fit. +fn preserve_instruction_messages( + original: &[Message], + compressed: Vec, +) -> Result, EngineError> { + let mut preserved = original + .iter() + .filter(|message| matches!(message.role(), MessageRole::System | MessageRole::Developer)) + .cloned() + .collect::>(); + for message in compressed { + validate_message_boundary(&message).map_err(|error| { + EngineError::ContextOverflow(format!("压缩器返回非法消息: {error}")) + })?; + // A compressor may be supplied by an adapter or plugin and therefore + // must not be allowed to smuggle protocol-level tool state into the + // durable prompt. Reject both the dedicated Tool role and structured + // tool parts, including calls/results hidden under User/Assistant. + if message.role() == MessageRole::Tool + || message.content().iter().any(|part| { + matches!( + part, + ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } + ) + }) + { + return Err(EngineError::ContextOverflow( + "压缩器不得返回工具角色或结构化工具历史".to_owned(), + )); + } + if !matches!(message.role(), MessageRole::System | MessageRole::Developer) { + preserved.push(message); + } + } + if preserved.is_empty() { + return Err(EngineError::ContextOverflow( + "压缩器返回空消息历史".to_owned(), + )); + } + Ok(preserved) +} + +/// Re-run the Core constructors at every Engine-owned input boundary. +/// +/// Core's wire structs intentionally remain lightweight serde values so they +/// can be persisted and replayed. That means a custom deserializer or a +/// `Message::new` call can still construct values that the fallible builders +/// would reject. Engine must validate before invoking a source, Provider, or +/// tool, because those ports may have observable side effects. +fn validate_content_part_boundary(part: &ContentPart) -> Result<(), String> { + match part { + ContentPart::Text { text } => ContentPart::text(text.clone()) + .map(|_| ()) + .map_err(|error| error.to_string()), + ContentPart::Image { source } => ContentPart::image(source.clone()) + .map(|_| ()) + .map_err(|error| error.to_string()), + ContentPart::ToolCall { + id, + name, + arguments, + } => ContentPart::tool_call(id.clone(), name.clone(), arguments.clone()) + .map(|_| ()) + .map_err(|error| error.to_string()), + ContentPart::ToolResult { + tool_call_id, + output, + is_error, + } => ContentPart::tool_result(tool_call_id.clone(), output.clone(), *is_error) + .map(|_| ()) + .map_err(|error| error.to_string()), + } +} + +fn validate_message_boundary(message: &Message) -> Result<(), String> { + for part in message.content() { + validate_content_part_boundary(part)?; + } + Message::try_new(message.role(), message.content().iter().cloned()) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_tool_definition_boundary(definition: &ToolDefinition) -> Result<(), String> { + let metadata = definition.metadata().clone(); + let rebuilt = ToolDefinition::try_new( + definition.name(), + definition.description(), + definition.input_schema().clone(), + ) + .map_err(|error| error.to_string())?; + rebuilt + .with_metadata(metadata) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_context_item_boundary(item: &ContextItem) -> Result<(), String> { + validate_message_boundary(item.message())?; + let metadata = item.metadata().clone(); + let rebuilt = ContextItem::try_new( + item.source_id(), + item.message().clone(), + item.priority(), + item.trusted(), + ) + .map_err(|error| error.to_string())?; + rebuilt + .with_metadata(metadata) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_agent_input_boundary(input: &AgentInput) -> Result<(), EngineError> { + if input.context_budget == 0 { + return Err(EngineError::InvalidInput( + "context_budget 必须大于 0".to_owned(), + )); + } + for message in &input.messages { + validate_message_boundary(message) + .map_err(|error| EngineError::InvalidInput(format!("消息无效: {error}")))?; + } + for definition in &input.tools { + validate_tool_definition_boundary(definition) + .map_err(|error| EngineError::InvalidInput(format!("工具定义无效: {error}")))?; + } + // Session/run IDs are optional on the public AgentInput and are copied + // into ContextRequest/ToolContext later. Validate them before a source or + // Provider can observe an otherwise malformed request. + let mut context_request = ContextRequest::new().with_messages(input.messages.clone()); + if let Some(session_id) = input.session_id.as_deref() { + context_request = context_request.with_session_id(session_id); + } + if let Some(run_id) = input.run_id.as_deref() { + context_request = context_request.with_run_id(run_id); + } + context_request + .validate() + .map_err(|error| EngineError::InvalidInput(format!("上下文请求无效: {error}")))?; + // Reuse Core's request-level checks (request/model identity, non-empty + // history and duplicate tool names) without sending anything to a port. + ProviderRequest::try_new( + input.request_id.clone(), + input.model.clone(), + input.messages.clone(), + ) + .map_err(|error| EngineError::InvalidInput(error.to_string()))? + .with_tools(input.tools.clone(), Default::default()) + .map_err(|error| EngineError::InvalidInput(error.to_string()))?; + Ok(()) +} + +/// Validate a Provider response before publishing it as an Engine event or +/// turning it into tool/message history. `ProviderResponse::try_new` rechecks +/// nested fields, but public serde-compatible values (and compatibility +/// fixtures) can still bypass fallible constructors; adapters and fakes must +/// therefore be treated as untrusted at this boundary too. +fn validate_provider_response_boundary(response: &ProviderResponse) -> Result<(), EngineError> { + if response.content().is_empty() && response.tool_calls().is_empty() { + return Err(EngineError::InvalidInput( + "Provider 响应不能为空".to_owned(), + )); + } + for part in response.content() { + validate_content_part_boundary(part).map_err(|error| { + EngineError::InvalidInput(format!("Provider 响应 content 无效: {error}")) + })?; + // `ProviderResponse` has a dedicated `tool_calls` field. Accepting a + // structured call/result in `content` would turn it into an assistant + // history part in `response_assistant_message`, bypassing the normal + // tool approval/result binding (and a lone ToolResult could even look + // like a successful empty final response). Keep this wire partition + // explicit: content is text/image only; calls stay in `tool_calls`. + if matches!( + part, + ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } + ) { + return Err(EngineError::InvalidInput( + "Provider 响应 content 不得包含结构化 tool call/tool result;请分别使用 tool_calls 或工具结果回填" + .to_owned(), + )); + } + } + for call in response.tool_calls() { + validate_tool_call_boundary(call).map_err(|error| { + EngineError::InvalidInput(format!("Provider 响应 tool call 无效: {error}")) + })?; + } + validate_tool_call_batch(response) +} + +fn validate_tool_call_boundary(call: &ToolCall) -> Result<(), String> { + ToolCall::try_new(call.id(), call.name(), call.arguments().clone()) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_tool_result_boundary(result: &ToolResult) -> Result<(), String> { + let mut rebuilt = + ToolResult::try_new(result.call_id(), result.output().clone(), result.is_error()) + .map_err(|error| error.to_string())?; + if let Some(summary) = result.summary() { + rebuilt = rebuilt + .with_summary(summary) + .map_err(|error| error.to_string())?; + } + rebuilt + .with_metadata(result.metadata().clone()) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +/// Keep a tool result usable as JSON while bounding what is fed back into the +/// model and durable event stream. The original executor result is never +/// retried or silently discarded; the replacement records byte counts and a +/// machine-readable reason for operators. +fn bound_tool_result(result: ToolResult, max_bytes: usize) -> Result { + // Tool executors are an external side-effect port too. Validate their + // serde/custom-constructor output before emitting a completion event or + // copying it into the next Provider prompt. + validate_tool_result_boundary(&result) + .map_err(|error| EngineError::Tool(format!("工具结果无效: {error}")))?; + if max_bytes == 0 { + return Err(EngineError::Tool( + "工具结果预算必须大于 0,无法编码截断结果".to_owned(), + )); + } + let encoded = serde_json::to_string(result.output()) + .map_err(|error| EngineError::Tool(format!("工具结果无法编码: {error}")))?; + if encoded.len() <= max_bytes { + return Ok(result); + } + + // The truncation envelope has a fixed JSON overhead. Start with a + // conservative preview budget and reduce it until the *serialized output + // value* fits. A config loaded through serde can bypass the builder's + // 256-byte floor (including a budget of 1), so never return a candidate + // merely because the preview budget reached zero: if even the empty + // envelope cannot fit, fail closed instead of admitting an oversized value. + let mut preview_budget = max_bytes.saturating_sub(160); + let output = loop { + let preview = if preview_budget == 0 { + String::new() + } else { + truncate_utf8(&encoded, preview_budget) + }; + let output = json!({ + "truncated": true, + "reason": "tool_output_budget", + "original_bytes": encoded.len(), + "max_bytes": max_bytes, + "preview": preview, + }); + match serde_json::to_string(&output) { + Ok(serialized) if serialized.len() <= max_bytes => break output, + Ok(_) if preview_budget > 0 => { + preview_budget = preview_budget.saturating_sub(16); + } + Ok(_) => { + return Err(EngineError::Tool(format!( + "工具结果预算 {max_bytes} 字节不足以编码截断标记" + ))); + } + Err(error) => { + return Err(EngineError::Tool(format!( + "截断后的工具结果无法编码: {error}" + ))); + } + } + }; + let summary = format!( + "tool output truncated: {} bytes > {} byte budget", + encoded.len(), + max_bytes + ); + let metadata = json!({ + "truncated": true, + "reason": "tool_output_budget", + "original_bytes": encoded.len(), + "max_bytes": max_bytes, + }); + let bounded = ToolResult::try_new(result.call_id(), output, result.is_error()) + .map_err(|error| EngineError::Tool(error.to_string()))? + .with_summary(summary) + .map_err(|error| EngineError::Tool(error.to_string()))? + .with_metadata(metadata) + .map_err(|error| EngineError::Tool(error.to_string()))?; + // Preserve the original error bit and call identity; summary/metadata are + // deliberately generated by the runtime rather than copied from unbounded + // provider-controlled text. + if bounded.call_id() != result.call_id() { + return Err(EngineError::Tool( + "工具结果截断后 call id 不匹配".to_owned(), + )); + } + // Keep the hard-bound assertion adjacent to construction. This protects + // the contract if the envelope fields change later or a serde alias adds + // bytes that the preview loop did not account for. + let serialized_output = serde_json::to_string(bounded.output()) + .map_err(|error| EngineError::Tool(format!("截断后的工具结果无法编码: {error}")))?; + if serialized_output.len() > max_bytes { + return Err(EngineError::Tool(format!( + "截断后的工具结果仍超过预算: {} > {}", + serialized_output.len(), + max_bytes + ))); + } + validate_tool_result_boundary(&bounded) + .map_err(|error| EngineError::Tool(format!("截断后的工具结果无效: {error}")))?; + Ok(bounded) +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_owned(); + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &value[..end]) +} + +/// 将一次 Provider 响应规范化成一条 assistant 消息。 +/// +/// `ProviderResponse` 为了适配不同协议,把普通 content 和结构化 +/// `tool_calls` 分成两个字段;运行历史却需要一个稳定的消息边界。Host 的 +/// runtime trace 也采用同一规则:先保留 response content,再按 Provider +/// 返回顺序追加 tool-call content。Engine、checkpoint 和 Host 因而不会因 +/// 一个响应包含多个 text part 而产生不同的消息前缀。 +fn response_assistant_message(response: &ProviderResponse) -> Result, EngineError> { + let mut parts = response.content().to_vec(); + for call in response.tool_calls() { + parts.push( + ContentPart::tool_call(call.id(), call.name(), call.arguments().clone()) + .map_err(|error| EngineError::Tool(error.to_string()))?, + ); + } + if parts.is_empty() { + return Ok(None); + } + Ok(Some(Message::new(MessageRole::Assistant, parts))) +} + +/// Provider 的一个响应批次内,call ID 必须唯一。 +/// +/// Core reducer 会在落盘时拒绝重复 ID,但等到那里才发现会让批次中的 +/// 第一个调用已经触发工具副作用。Engine 在发出任何 `ToolRequested` 之前 +/// 先做这个轻量预检,把 malformed response 留在当前 provider 边界。 +fn validate_tool_call_batch(response: &ProviderResponse) -> Result<(), EngineError> { + let mut ids = BTreeSet::new(); + for call in response.tool_calls() { + if !ids.insert(call.id()) { + return Err(EngineError::InvalidInput(format!( + "Provider 响应包含重复 tool call id: {}", + call.id() + ))); + } + } + Ok(()) +} + +/// 在 Engine 内提供一个小而确定的 JSON Schema 子集校验器。 +/// +/// 工具 schema 来自 Provider/MCP 等外部适配器,不能把“能够构造 +/// `ToolDefinition`”误当成“调用参数有效”。这里覆盖工具调用最常用的 +/// object/array/标量约束;未实现的注解关键字(如 `description`、`format`) +/// 会被忽略,避免把 Engine 绑定到某个 JSON Schema 版本或额外运行时。 +/// 校验一次工具调用是否满足工具声明的输入 schema。 +/// +/// 该函数与 `AgentEngine` 使用同一实现,宿主可以在入队或审批 UI 中提前 +/// 做预检;Engine 仍会在真正执行前再次校验,避免调用方绕过运行时边界。 +pub fn validate_tool_arguments( + call: &ToolCall, + definition: &ToolDefinition, +) -> Result<(), ToolArgumentError> { + // This helper is public and is often used by approval UIs or enqueue + // paths before AgentEngine gets a chance to run its own boundary checks. + // Both Core values are serde-compatible, so callers may hand us a value + // that bypassed `try_new`; do not let the schema validator turn that into + // an apparent success. + call.validate().map_err(|error| { + ToolArgumentError::new(call.name(), "$", format!("tool call 无效: {error}")) + })?; + let error_tool_name = if call.name().is_empty() { + definition.name() + } else { + call.name() + }; + definition.validate().map_err(|error| { + ToolArgumentError::new( + error_tool_name, + "$", + format!("tool definition 无效: {error}"), + ) + })?; + if call.name() != definition.name() { + return Err(ToolArgumentError::new( + call.name(), + "$", + format!( + "工具名与定义不匹配: expected={}, actual={}", + definition.name(), + call.name() + ), + )); + } + validate_schema_value( + call.arguments(), + definition.input_schema(), + "$".to_owned(), + call.name(), + ) +} + +fn validate_schema_value( + value: &Value, + schema: &Value, + path: String, + tool_name: &str, +) -> Result<(), ToolArgumentError> { + let Some(schema_object) = schema.as_object() else { + return Err(ToolArgumentError::new( + tool_name, + path, + "schema 必须是 JSON object", + )); + }; + + if let Some(constant) = schema_object.get("const") + && value != constant + { + return Err(ToolArgumentError::new(tool_name, path, "不匹配 const")); + } + if let Some(enumeration) = schema_object.get("enum") { + let Some(values) = enumeration.as_array() else { + return Err(ToolArgumentError::new( + tool_name, + path, + "schema enum 必须是数组", + )); + }; + if !values.iter().any(|candidate| candidate == value) { + return Err(ToolArgumentError::new(tool_name, path, "不匹配 enum")); + } + } + + if let Some(type_spec) = schema_object.get("type") { + let matches = match type_spec { + Value::String(kind) => json_type_matches(value, kind), + Value::Array(kinds) => kinds.iter().any(|kind| { + kind.as_str() + .is_some_and(|kind| json_type_matches(value, kind)) + }), + _ => { + return Err(ToolArgumentError::new( + tool_name, + path, + "schema type 必须是字符串或字符串数组", + )); + } + }; + if !matches { + return Err(ToolArgumentError::new( + tool_name, + path, + format!("类型应为 {type_spec}"), + )); + } + } + + if value.is_object() { + validate_object_schema(value, schema_object, path.clone(), tool_name)?; + } else if let Some(items_schema) = schema_object.get("items") { + // `items` 对非数组值没有约束意义;type=array 会在上面给出更直接的错误。 + if let Some(items) = value.as_array() { + for (index, item) in items.iter().enumerate() { + validate_schema_value(item, items_schema, format!("{path}[{index}]"), tool_name)?; + } + } + } + + if let Some(items) = value.as_array() { + validate_array_bounds(items, schema_object, &path, tool_name)?; + } + if let Some(text) = value.as_str() { + validate_string_bounds(text, schema_object, &path, tool_name)?; + } + if let Some(number) = value.as_f64() { + validate_number_bounds(number, schema_object, &path, tool_name)?; + } + Ok(()) +} + +fn json_type_matches(value: &Value, kind: &str) -> bool { + match kind { + "null" => value.is_null(), + "boolean" => value.is_boolean(), + "object" => value.is_object(), + "array" => value.is_array(), + "string" => value.is_string(), + // JSON Schema treats integer as a number with no fractional part. JSON + // numbers are represented losslessly for the common integer path below. + "integer" => value.as_i64().is_some() || value.as_u64().is_some(), + "number" => value.is_number(), + _ => false, + } +} + +fn validate_object_schema( + value: &Value, + schema: &serde_json::Map, + path: String, + tool_name: &str, +) -> Result<(), ToolArgumentError> { + let object = value + .as_object() + .expect("validate_object_schema only receives objects"); + if let Some(required) = schema.get("required") { + let Some(required) = required.as_array() else { + return Err(ToolArgumentError::new( + tool_name, + path.clone(), + "schema required 必须是字符串数组", + )); + }; + for field in required { + let Some(field) = field.as_str() else { + return Err(ToolArgumentError::new( + tool_name, + path.clone(), + "schema required 项必须是字符串", + )); + }; + if !object.contains_key(field) { + return Err(ToolArgumentError::new( + tool_name, + child_path(&path, field), + "缺少 required 参数", + )); + } + } + } + + let properties = match schema.get("properties") { + None => None, + Some(properties) => Some(properties.as_object().ok_or_else(|| { + ToolArgumentError::new(tool_name, path.clone(), "schema properties 必须是 object") + })?), + }; + let additional = schema.get("additionalProperties"); + for (field, field_value) in object { + let field_path = child_path(&path, field); + if let Some(field_schema) = properties.and_then(|properties| properties.get(field)) { + validate_schema_value(field_value, field_schema, field_path, tool_name)?; + continue; + } + match additional { + Some(Value::Bool(false)) => { + return Err(ToolArgumentError::new( + tool_name, + field_path, + "不允许额外参数", + )); + } + Some(additional_schema @ Value::Object(_)) => { + validate_schema_value(field_value, additional_schema, field_path, tool_name)?; + } + // `true`, omitted, or an unsupported annotation leaves unknown + // fields open, matching JSON Schema's default behavior. + _ => {} + } + } + Ok(()) +} + +fn child_path(path: &str, field: &str) -> String { + if field + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + format!("{path}.{field}") + } else { + format!("{path}[{field:?}]") + } +} + +fn validate_array_bounds( + values: &[Value], + schema: &serde_json::Map, + path: &str, + tool_name: &str, +) -> Result<(), ToolArgumentError> { + validate_len_bound( + values.len(), + schema, + "minItems", + |actual, expected| actual >= expected, + path, + tool_name, + "数组项数少于 minItems", + )?; + validate_len_bound( + values.len(), + schema, + "maxItems", + |actual, expected| actual <= expected, + path, + tool_name, + "数组项数多于 maxItems", + ) +} + +fn validate_string_bounds( + value: &str, + schema: &serde_json::Map, + path: &str, + tool_name: &str, +) -> Result<(), ToolArgumentError> { + validate_len_bound( + value.chars().count(), + schema, + "minLength", + |actual, expected| actual >= expected, + path, + tool_name, + "字符串长度小于 minLength", + )?; + validate_len_bound( + value.chars().count(), + schema, + "maxLength", + |actual, expected| actual <= expected, + path, + tool_name, + "字符串长度大于 maxLength", + ) +} + +fn validate_len_bound bool>( + actual: usize, + schema: &serde_json::Map, + key: &str, + predicate: F, + path: &str, + tool_name: &str, + reason: &str, +) -> Result<(), ToolArgumentError> { + let Some(expected) = schema.get(key) else { + return Ok(()); + }; + let Some(expected) = expected + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + else { + return Err(ToolArgumentError::new( + tool_name, + path, + format!("schema {key} 必须是非负整数"), + )); + }; + if predicate(actual, expected) { + Ok(()) + } else { + Err(ToolArgumentError::new(tool_name, path, reason)) + } +} + +fn validate_number_bounds( + value: f64, + schema: &serde_json::Map, + path: &str, + tool_name: &str, +) -> Result<(), ToolArgumentError> { + validate_number_bound( + value, + schema, + "minimum", + |actual, expected| actual >= expected, + path, + tool_name, + "数字小于 minimum", + )?; + validate_number_bound( + value, + schema, + "maximum", + |actual, expected| actual <= expected, + path, + tool_name, + "数字大于 maximum", + )?; + validate_number_bound( + value, + schema, + "exclusiveMinimum", + |actual, expected| actual > expected, + path, + tool_name, + "数字不大于 exclusiveMinimum", + )?; + validate_number_bound( + value, + schema, + "exclusiveMaximum", + |actual, expected| actual < expected, + path, + tool_name, + "数字不小于 exclusiveMaximum", + ) +} + +fn validate_number_bound bool>( + value: f64, + schema: &serde_json::Map, + key: &str, + predicate: F, + path: &str, + tool_name: &str, + reason: &str, +) -> Result<(), ToolArgumentError> { + let Some(expected) = schema.get(key) else { + return Ok(()); + }; + let Some(expected) = expected.as_f64() else { + return Err(ToolArgumentError::new( + tool_name, + path, + format!("schema {key} 必须是数字"), + )); + }; + if predicate(value, expected) { + Ok(()) + } else { + Err(ToolArgumentError::new(tool_name, path, reason)) + } +} + +/// 单 Agent Engine。一次只执行一个 run,Host 负责并发隔离和持久化。 +pub struct AgentEngine<'a> { + provider: &'a dyn ModelProvider, + tools: &'a dyn ToolExecutor, + approval: &'a dyn ApprovalPolicy, + context: ContextAssembler, + listener: Option<&'a dyn EventListener>, + stream_listener: Option<&'a dyn StreamEventListener>, + checkpoint_listener: Option<&'a dyn CheckpointListener>, + compressor: Option<&'a dyn ContextCompressor>, + cancellation: Cancellation, + tool_retry_policy: Option<&'a dyn ToolRetryPolicyProvider>, +} + +struct EngineStreamSink<'engine, 'events, 'a> { + engine: &'engine AgentEngine<'a>, + step: usize, + events: &'events mut Vec, + deadline: Option, + timed_out: Arc, +} + +impl ProviderStreamSink for EngineStreamSink<'_, '_, '_> { + fn emit(&mut self, event: ProviderStreamEvent) -> Result<(), ProviderError> { + if self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.timed_out.store(true, Ordering::Release); + return Err(ProviderError::new( + ProviderErrorKind::Timeout, + "Provider 调用超过超时边界", + )); + } + let wrapped = EngineStreamEvent { + step: self.step, + event, + }; + self.events.push(wrapped); + // The event is accepted into the Engine-owned stream before observers + // are notified. Observers cannot mutate the stream or runtime state; + // durable Host persistence still happens at its explicit commit gate. + if let Some(listener) = self.engine.stream_listener { + listener.on_stream_event(self.events.last().expect("just pushed stream event")); + } + // A listener may request cancellation after rendering a delta. Stop the + // stream before the provider can emit another externally visible event. + if self.engine.cancellation.is_cancelled() { + return Err(ProviderError::new(ProviderErrorKind::Stream, "运行已取消")); + } + if self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.timed_out.store(true, Ordering::Release); + return Err(ProviderError::new( + ProviderErrorKind::Timeout, + "Provider 调用超过超时边界", + )); + } + Ok(()) + } +} + +impl<'a> AgentEngine<'a> { + pub fn new( + provider: &'a dyn ModelProvider, + tools: &'a dyn ToolExecutor, + approval: &'a dyn ApprovalPolicy, + ) -> Self { + Self { + provider, + tools, + approval, + context: ContextAssembler::new(), + listener: None, + stream_listener: None, + checkpoint_listener: None, + compressor: None, + cancellation: Cancellation::default(), + tool_retry_policy: None, + } + } + + pub fn with_context_source(mut self, source: Arc) -> Self { + self.context = self.context.add_source(source); + self + } + + pub fn with_listener(mut self, listener: &'a dyn EventListener) -> Self { + self.listener = Some(listener); + self + } + + /// 注册 Provider 流式事件回调。回调在 Provider 线程/调用栈内同步执行, + /// 因而不需要把 Tokio 或其它 async runtime 引入 Engine。 + pub fn with_stream_listener(mut self, listener: &'a dyn StreamEventListener) -> Self { + self.stream_listener = Some(listener); + self + } + + pub fn with_checkpoint_listener(mut self, listener: &'a dyn CheckpointListener) -> Self { + self.checkpoint_listener = Some(listener); + self + } + + pub fn with_context_compressor(mut self, compressor: &'a dyn ContextCompressor) -> Self { + self.compressor = Some(compressor); + self + } + + pub fn cancellation(&self) -> Cancellation { + self.cancellation.clone() + } + + /// 注入宿主持有的取消标记,便于后台控制器在运行期间发出取消信号。 + /// + /// 未显式注入时仍使用 `new` 创建的内部标记,保持原有 API 和行为不变。 + pub fn with_cancellation(mut self, cancellation: Cancellation) -> Self { + self.cancellation = cancellation; + self + } + + /// 注入工具重试策略。未注入时所有工具错误都只返回一次,避免把未知 + /// 外部副作用隐式变成重复调用。 + pub fn with_tool_retry_policy(mut self, policy: &'a dyn ToolRetryPolicyProvider) -> Self { + self.tool_retry_policy = Some(policy); + self + } + + pub fn run(&self, input: AgentInput) -> Result { + self.run_internal(input, false) + } + + /// 通过 Core 的 `ModelProvider::stream` 消费 Provider 事件后执行同一套 + /// 工具/审批循环。Provider 没有覆盖 `stream` 时,Core 默认实现仍会调用 + /// `complete`,所以旧的同步 Provider 无需修改即可运行。 + pub fn run_streaming(&self, input: AgentInput) -> Result { + self.run_internal(input, true) + } + + /// `run_streaming` 的简短别名,方便 CLI/宿主按能力开关选择执行模式。 + pub fn run_stream(&self, input: AgentInput) -> Result { + self.run_streaming(input) + } + + fn run_internal(&self, input: AgentInput, streaming: bool) -> Result { + if input.messages.is_empty() { + return Err(EngineError::InvalidInput("至少需要一条消息".to_owned())); + } + if input.max_steps == 0 { + return Err(EngineError::InvalidInput("max_steps 必须大于 0".to_owned())); + } + // `AgentInput` is public and serde can bypass the builder's minimum. + // Reject an undersized budget before the first checkpoint, Provider + // call, or tool side effect instead of emitting an oversized + // truncation envelope (or silently changing the caller's value). + if input.max_tool_output_bytes < MIN_TOOL_OUTPUT_BUDGET { + return Err(EngineError::InvalidInput(format!( + "max_tool_output_bytes 必须至少为 {MIN_TOOL_OUTPUT_BUDGET}" + ))); + } + // AgentInput is public and commonly arrives through serde. Validate + // all nested Core values before asking any ContextSource or Provider + // to do work; those ports are allowed to have observable side effects. + validate_agent_input_boundary(&input)?; + let end_step = input + .start_step + .checked_add(input.max_steps) + .ok_or_else(|| { + EngineError::InvalidInput("start_step + max_steps 超出 usize 范围".to_owned()) + })?; + + let provider_timeout = input.provider_timeout(); + let initial_start_step = input.start_step; + // Keep the input metadata available to the explicit approval + // continuation while the mutable message history advances. + let mut messages = input.messages.clone(); + let mut events = Vec::new(); + let mut stream_events = Vec::new(); + let mut context_observations = Vec::new(); + let mut loop_start_step = initial_start_step; + if let Some(resume) = input.approval_resume.clone() { + let (next_step, resumed_messages) = + self.resume_approval_step(&input, resume, messages, &mut events)?; + messages = resumed_messages; + loop_start_step = next_step; + } + for step in loop_start_step..end_step { + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + self.emit(&mut events, EngineEvent::StepStarted { step }); + let mut assembly = self.context.assemble_detailed( + &messages, + input.session_id.as_deref(), + input.run_id.as_deref(), + input.context_budget, + )?; + // A source can observe a host cancellation while assembling. Do + // not proceed to compression or the Provider after that boundary. + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + let mut context_observation = assembly.observation(step); + if assembly.overflowed() { + let Some(compressor) = self.compressor else { + return Err(EngineError::ContextOverflow(format!( + "基础消息估算 {} 字节,预算 {} 字节,未配置压缩器", + assembly.base_estimated_size(), + assembly.budget() + ))); + }; + let compression_request_id = format!("{}-{step}", input.request_id); + // Compression has its own durable boundary. A compressor may + // call a remote summary model, so a crash here must not leave + // a stale `safe` cursor that silently reuses the old history. + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::Compacting, + messages: messages.clone(), + provider_request_id: Some(compression_request_id.clone()), + tool_call_id: None, + })?; + self.emit(&mut events, EngineEvent::CompactionStarted { step }); + let compression_input_size = estimate_messages_size(&messages); + let compression_request = CompressionRequest { + request_id: compression_request_id, + model: input.model.clone(), + messages: messages.clone(), + budget: input.context_budget, + session_id: input.session_id.clone(), + run_id: input.run_id.clone(), + }; + compression_request + .validate() + .map_err(|error| EngineError::ContextOverflow(error.to_string()))?; + let compression_result = compressor.compress(&compression_request); + // Check cancellation even when the compressor returned an + // error: once it observed cancellation, no later provider + // boundary should be entered. + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + let compressed = compression_result?; + if compressed.is_empty() { + return Err(EngineError::ContextOverflow( + "压缩器返回空消息历史".to_owned(), + )); + } + let compression_output_size = estimate_messages_size(&compressed); + messages = preserve_instruction_messages(&messages, compressed)?; + assembly = self.context.assemble_detailed( + &messages, + input.session_id.as_deref(), + input.run_id.as_deref(), + input.context_budget, + )?; + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + if assembly.overflowed() { + return Err(EngineError::ContextOverflow(format!( + "压缩后基础消息仍为 {} 字节,预算 {} 字节", + assembly.base_estimated_size(), + assembly.budget() + ))); + } + context_observation = assembly.observation(step); + context_observation.compression_attempted = true; + context_observation.compression_input_size = Some(compression_input_size); + context_observation.compression_output_size = Some(compression_output_size); + self.emit( + &mut events, + EngineEvent::ContextCompacted { + step, + messages: messages.clone(), + }, + ); + self.emit(&mut events, EngineEvent::CompactionCompleted { step }); + } + context_observations.push(context_observation); + let assembled = assembly.into_messages(); + let request = ProviderRequest::try_new( + format!("{}-{step}", input.request_id), + input.model.clone(), + assembled, + ) + .map_err(|error| EngineError::InvalidInput(error.to_string()))? + .with_tools(input.tools.clone(), Default::default()) + .map_err(|error| EngineError::InvalidInput(error.to_string()))?; + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::ProviderInFlight, + messages: messages.clone(), + provider_request_id: Some(request.request_id().to_owned()), + tool_call_id: None, + })?; + let response = self.provider_call( + &request, + step, + streaming, + input.max_provider_retries, + provider_timeout, + &mut stream_events, + )?; + // Provider adapter 必须把请求的稳定 identity 原样带回。先做校验, + // 再发出观察事件或把响应并入消息历史,避免错配响应污染 durable + // checkpoint;这也是多 worker/重试时区分迟到响应的最小边界。 + if response.request_id() != request.request_id() || response.model() != request.model() + { + return Err(EngineError::ProviderResponseMismatch { + expected_request_id: request.request_id().to_owned(), + actual_request_id: response.request_id().to_owned(), + expected_model: request.model().to_owned(), + actual_model: response.model().to_owned(), + }); + } + validate_provider_response_boundary(&response)?; + self.emit( + &mut events, + EngineEvent::ModelCompleted { + step, + response: response.clone(), + }, + ); + // Provider 返回后先观察一次取消;这样取消不会让当前响应继续触发 + // 新的工具副作用,已在同步调用中的动作仍由宿主自行收口。 + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + + // 一个 Provider 响应只对应一条 assistant 消息;它的 content 在前, + // 结构化 tool calls 在后。这个边界同时用于下一轮 prompt 和 durable + // checkpoint,必须与 Host 的 runtime trace 保持逐项相等。 + if let Some(message) = response_assistant_message(&response)? { + messages.push(message); + } + if response.tool_calls().is_empty() { + let text = response + .content() + .iter() + .filter_map(ContentPart::as_text) + .collect::>() + .join(""); + // 最终文本尚未和 run 终态在同一事务里提交;保留 + // provider_in_flight 检查点,避免恢复器把一次可能只完成了 + // 一半的 Provider 调用误判成可安全再次请求。 + self.emit( + &mut events, + EngineEvent::Finished { + step, + text: text.clone(), + }, + ); + return Ok(AgentOutput { + text, + steps: step - input.start_step + 1, + events, + stream_events, + context_observations, + messages, + }); + } + + let approval_run_id = input.run_id.as_deref().unwrap_or(&input.request_id); + for (call_index, call) in response.tool_calls().iter().enumerate() { + // 同一响应可能包含多个工具调用;取消后不再启动后续调用。 + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + // 在审批和任何副作用之前校验 Provider 给出的参数。未知工具 + // 仍交给 ToolExecutor 产生其领域错误;已声明工具则必须满足 + // 其 schema,避免把 malformed JSON 送入外部系统。 + if let Some(definition) = input.tools.iter().find(|tool| tool.name() == call.name()) + { + validate_tool_arguments(call, definition)?; + } + self.emit( + &mut events, + EngineEvent::ToolRequested { + step, + call: call.clone(), + }, + ); + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::AwaitingApproval, + messages: messages.clone(), + provider_request_id: Some(request.request_id().to_owned()), + tool_call_id: Some(call.id().to_owned()), + })?; + let approval_request = ApprovalRequest::try_new( + format!("{}-approval-{step}-{call_index}", input.request_id), + approval_run_id, + call.clone(), + ) + .map_err(|error| EngineError::InvalidInput(error.to_string()))?; + let decision = self + .approval + .decide(&approval_request) + .map_err(|error| EngineError::Approval(error.to_string()))?; + match decision { + ApprovalDecision::Ask => { + // `Ask` 是未决的人机协同边界,不等同于 deny:不能 + // 伪造失败结果,也不能让 Engine 在没有新决议时执行工具。 + // awaiting_approval checkpoint 会保留,宿主可据此进入 + // reconciliation/cancel;本 Engine 不自动恢复 approval。 + return Err(EngineError::ApprovalRequired { + request_id: approval_request.request_id().to_owned(), + call_id: call.id().to_owned(), + arguments_hash: approval_request.arguments_hash().to_owned(), + approval_token: approval_request.approval_token().to_owned(), + expires_at_ms: approval_request.expires_at_ms(), + }); + } + ApprovalDecision::Deny { .. } => { + self.emit( + &mut events, + EngineEvent::ApprovalDenied { + step, + call_id: call.id().to_owned(), + }, + ); + let denied = + ToolResult::failure(call.id(), json!({"error": "approval denied"})) + .map_err(|error| EngineError::Tool(error.to_string()))?; + // 拒绝也要产生一个完成的失败结果,保证恢复时不会把调用 + // 永久留在 waiting-for-tool 状态。 + self.emit( + &mut events, + EngineEvent::ToolCompleted { + step, + result: denied.clone(), + }, + ); + messages.push(Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result( + denied.call_id(), + denied.output().clone(), + true, + ) + .map_err(|error| EngineError::Tool(error.to_string()))?, + ], + )); + if let Some(next_call) = response.tool_calls().get(call_index + 1) { + // 拒绝没有外部副作用,但上一条 awaiting checkpoint 仍指向 + // 已经处理完的 call。先把完整的拒绝结果和下一条 call 身份 + // 写入 durable checkpoint,再进入下一轮,避免进程恰好在 + // 两个审批之间退出时留下一个无法对账的旧 awaiting 状态。 + // 下一条 call 仍必须重新经过 ApprovalPolicy;这里不执行、 + // 不预写 ToolResult,也不把批次提前标记为 safe。 + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::AwaitingApproval, + messages: messages.clone(), + provider_request_id: Some(request.request_id().to_owned()), + tool_call_id: Some(next_call.id().to_owned()), + })?; + } else { + self.checkpoint(&EngineCheckpoint { + step, + next_step: step + 1, + phase: CheckpointPhase::Safe, + messages: messages.clone(), + provider_request_id: Some(request.request_id().to_owned()), + tool_call_id: None, + })?; + } + continue; + } + ApprovalDecision::Allow => {} + } + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::ToolInFlight, + messages: messages.clone(), + provider_request_id: Some(request.request_id().to_owned()), + tool_call_id: Some(call.id().to_owned()), + })?; + let tool_context = self.tool_context(&input); + let result = bound_tool_result( + self.execute_tool(call, &tool_context)?, + input.max_tool_output_bytes, + )?; + if result.call_id() != call.id() { + // 不把错配结果写进事件或消息历史;否则 Host 的 Core + // reducer 会看到一个未知 call,且 checkpoint 无法安全恢复。 + return Err(EngineError::Tool(format!( + "工具结果 call id 不匹配:expected={} actual={}", + call.id(), + result.call_id() + ))); + } + self.emit( + &mut events, + EngineEvent::ToolCompleted { + step, + result: result.clone(), + }, + ); + messages.push(Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result( + result.call_id(), + result.output().clone(), + result.is_error(), + ) + .map_err(|error| EngineError::Tool(error.to_string()))?, + ], + )); + if call_index + 1 == response.tool_calls().len() { + self.checkpoint(&EngineCheckpoint { + step, + next_step: step + 1, + phase: CheckpointPhase::Safe, + messages: messages.clone(), + provider_request_id: Some(request.request_id().to_owned()), + tool_call_id: None, + })?; + } + } + } + Err(EngineError::StepLimit(input.max_steps)) + } + + /// Continue a run from an awaiting-approval checkpoint. The checkpoint + /// already contains the provider's assistant message and tool calls, so + /// this path deliberately skips `provider_call` and only executes the + /// explicitly resolved call. Unknown provider/tool side effects are not + /// inferred or replayed here. + fn resume_approval_step( + &self, + input: &AgentInput, + resume: ApprovalResume, + mut messages: Vec, + events: &mut Vec, + ) -> Result<(usize, Vec), EngineError> { + let step = input.start_step; + if resume.call_id.trim().is_empty() { + return Err(EngineError::InvalidInput( + "approval resume call_id 不能为空".to_owned(), + )); + } + let assistant = messages + .iter() + .rev() + .find(|message| message.role() == MessageRole::Assistant) + .ok_or_else(|| { + EngineError::InvalidInput( + "approval checkpoint 缺少 assistant tool call 消息".to_owned(), + ) + })?; + let calls = assistant + .content() + .iter() + .filter_map(|part| match part { + ContentPart::ToolCall { + id, + name, + arguments, + } => Some( + ToolCall::try_new(id, name, arguments.clone()) + .map_err(|error| EngineError::InvalidInput(error.to_string())), + ), + _ => None, + }) + .collect::, _>>()?; + let pending_index = calls + .iter() + .position(|call| call.id() == resume.call_id) + .ok_or_else(|| { + EngineError::InvalidInput(format!( + "approval resume call 不在 checkpoint 中: {}", + resume.call_id + )) + })?; + // A resolved call must not already have a result. Calls before it may + // have been explicitly denied in the same provider batch. + let resolved_ids = messages + .iter() + .flat_map(|message| message.content()) + .filter_map(|part| match part { + ContentPart::ToolResult { tool_call_id, .. } => Some(tool_call_id.as_str()), + _ => None, + }) + .collect::>(); + if resolved_ids.contains(resume.call_id.as_str()) { + return Err(EngineError::InvalidInput(format!( + "approval call 已有结果,拒绝重复执行: {}", + resume.call_id + ))); + } + if calls[..pending_index] + .iter() + .any(|call| !resolved_ids.contains(call.id())) + { + return Err(EngineError::InvalidInput( + "approval checkpoint 在待决调用前仍有未完成 tool call".to_owned(), + )); + } + + if resume.arguments_hash.trim().is_empty() + || resume.approval_token.trim().is_empty() + || resume.expires_at_ms == 0 + { + return Err(EngineError::InvalidInput( + "approval resume 缺少参数哈希、令牌或过期时间".to_owned(), + )); + } + + let provider_request_id = resume + .provider_request_id + .clone() + .unwrap_or_else(|| format!("{}-{step}", input.request_id)); + let approval_run_id = input.run_id.as_deref().unwrap_or(&input.request_id); + let pending_request = ApprovalRequest::try_new_with_expiry( + format!("{}-approval-{step}-{pending_index}", input.request_id), + approval_run_id, + calls[pending_index].clone(), + resume.expires_at_ms, + ) + .map_err(|error| EngineError::InvalidInput(error.to_string()))?; + if pending_request.arguments_hash() != resume.arguments_hash + || pending_request.approval_token() != resume.approval_token + || pending_request.is_expired(current_time_millis()) + { + return Err(EngineError::InvalidInput( + "approval resume binding 无效或已过期".to_owned(), + )); + } + for (call_index, call) in calls.iter().enumerate().skip(pending_index) { + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + if let Some(definition) = input.tools.iter().find(|tool| tool.name() == call.name()) { + validate_tool_arguments(call, definition)?; + } + + let approval_request = if call_index == pending_index { + pending_request.clone() + } else { + self.emit( + events, + EngineEvent::ToolRequested { + step, + call: call.clone(), + }, + ); + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::AwaitingApproval, + messages: messages.clone(), + provider_request_id: Some(provider_request_id.clone()), + tool_call_id: Some(call.id().to_owned()), + })?; + ApprovalRequest::try_new( + format!("{}-approval-{step}-{call_index}", input.request_id), + approval_run_id, + call.clone(), + ) + .map_err(|error| EngineError::InvalidInput(error.to_string()))? + }; + let decision = if call_index == pending_index { + resume.decision.clone() + } else { + self.approval + .decide(&approval_request) + .map_err(|error| EngineError::Approval(error.to_string()))? + }; + + match decision { + ApprovalDecision::Ask => { + return Err(EngineError::ApprovalRequired { + request_id: approval_request.request_id().to_owned(), + call_id: call.id().to_owned(), + arguments_hash: approval_request.arguments_hash().to_owned(), + approval_token: approval_request.approval_token().to_owned(), + expires_at_ms: approval_request.expires_at_ms(), + }); + } + ApprovalDecision::Deny { .. } => { + self.emit( + events, + EngineEvent::ApprovalDenied { + step, + call_id: call.id().to_owned(), + }, + ); + let denied = + ToolResult::failure(call.id(), json!({"error": "approval denied"})) + .map_err(|error| EngineError::Tool(error.to_string()))?; + self.emit( + events, + EngineEvent::ToolCompleted { + step, + result: denied.clone(), + }, + ); + messages.push(Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result( + denied.call_id(), + denied.output().clone(), + true, + ) + .map_err(|error| EngineError::Tool(error.to_string()))?, + ], + )); + } + ApprovalDecision::Allow => { + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::ToolInFlight, + messages: messages.clone(), + provider_request_id: Some(provider_request_id.clone()), + tool_call_id: Some(call.id().to_owned()), + })?; + let tool_context = self.tool_context(input); + let result = bound_tool_result( + self.execute_tool(call, &tool_context)?, + input.max_tool_output_bytes, + )?; + if result.call_id() != call.id() { + return Err(EngineError::Tool(format!( + "工具结果 call id 不匹配:expected={} actual={}", + call.id(), + result.call_id() + ))); + } + self.emit( + events, + EngineEvent::ToolCompleted { + step, + result: result.clone(), + }, + ); + messages.push(Message::new( + MessageRole::Tool, + vec![ + ContentPart::tool_result( + result.call_id(), + result.output().clone(), + result.is_error(), + ) + .map_err(|error| EngineError::Tool(error.to_string()))?, + ], + )); + } + } + + if call_index + 1 < calls.len() { + let next_call = &calls[call_index + 1]; + self.checkpoint(&EngineCheckpoint { + step, + next_step: step, + phase: CheckpointPhase::AwaitingApproval, + messages: messages.clone(), + provider_request_id: Some(provider_request_id.clone()), + tool_call_id: Some(next_call.id().to_owned()), + })?; + } + } + + let next_step = step + .checked_add(1) + .ok_or_else(|| EngineError::InvalidInput("approval step 溢出".to_owned()))?; + self.checkpoint(&EngineCheckpoint { + step, + next_step, + phase: CheckpointPhase::Safe, + messages: messages.clone(), + provider_request_id: Some(provider_request_id), + tool_call_id: None, + })?; + Ok((next_step, messages)) + } + + fn emit(&self, events: &mut Vec, event: EngineEvent) { + // Store the event in the Engine result first, then notify observers. + // This keeps callbacks observational and ensures an error path can + // still recover the complete accepted prefix for Host persistence. + events.push(event); + if let Some(listener) = self.listener { + listener.on_event(events.last().expect("just pushed engine event")); + } + } + + fn execute_tool( + &self, + call: &ToolCall, + context: &ToolContext, + ) -> Result { + // ToolContext is serde-compatible and its legacy ID setters are + // infallible; re-check it and the call immediately before the first + // executor invocation so malformed input cannot reach a side effect. + context + .validate() + .map_err(|error| EngineError::Tool(format!("工具上下文无效: {error}")))?; + call.validate() + .map_err(|error| EngineError::Tool(format!("工具调用无效: {error}")))?; + let policy = self + .tool_retry_policy + .map_or(ToolRetryPolicy::Never, |provider| provider.policy(call)); + let mut retry_count = 0usize; + loop { + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + match self.tools.execute(call, context) { + // A tool that observes cancellation while its operation is in + // flight should return the explicit `Cancelled` ToolError. + // A successful result remains a valid local completion; the + // outer Host cancellation boundary will then close the run + // without needlessly turning a completed local call into an + // unknown external side effect. + Ok(result) => return Ok(result), + Err(error) if error.kind() == agent_runtime_core::ToolErrorKind::Cancelled => { + // Tool implementations can report a cooperative stop + // directly after polling `ToolContext::is_cancelled`. + return Err(EngineError::Cancelled); + } + Err(error) if policy.allows(&error, retry_count) => { + retry_count += 1; + continue; + } + Err(error) => return Err(EngineError::Tool(error.to_string())), + } + } + } + + /// Build the per-call context and attach a read-only view of the Engine's + /// shared cancellation flag. The view is omitted from serialization and + /// old ToolExecutor implementations can continue to ignore it. + fn tool_context(&self, input: &AgentInput) -> ToolContext { + let cancellation = self.cancellation.clone(); + let mut context = + ToolContext::new().with_cancellation_probe(move || cancellation.is_cancelled()); + if let Some(session_id) = input.session_id.as_deref() { + context = context.with_session_id(session_id); + } + if let Some(run_id) = input.run_id.as_deref() { + context = context.with_run_id(run_id); + } + context + } + + fn provider_call( + &self, + request: &ProviderRequest, + step: usize, + streaming: bool, + max_retries: usize, + timeout: Option, + stream_events: &mut Vec, + ) -> Result { + let mut retries = 0usize; + loop { + let before_events = stream_events.len(); + let started = Instant::now(); + let deadline = timeout.and_then(|duration| started.checked_add(duration)); + let timed_out = Arc::new(AtomicBool::new(false)); + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + timed_out.store(true, Ordering::Release); + } + let result = if streaming { + let mut sink = EngineStreamSink { + engine: self, + step, + events: stream_events, + deadline, + timed_out: timed_out.clone(), + }; + if timed_out.load(Ordering::Acquire) { + Err(ProviderError::new( + ProviderErrorKind::Timeout, + "Provider 调用超过超时边界", + )) + } else { + self.provider.stream(request, &mut sink) + } + } else { + if timed_out.load(Ordering::Acquire) { + Err(ProviderError::new( + ProviderErrorKind::Timeout, + "Provider 调用超过超时边界", + )) + } else { + self.provider.complete(request) + } + }; + // A synchronous provider cannot be safely force-killed by a generic + // crate. Treat a late response as a timed-out/unknown boundary and + // never feed it into the retry loop; adapters should enforce a hard + // I/O timeout when they can. + let deadline_exceeded = timed_out.load(Ordering::Acquire) + || deadline.is_some_and(|deadline| Instant::now() >= deadline); + if self.cancellation.is_cancelled() { + return Err(EngineError::Cancelled); + } + if deadline_exceeded { + return Err(EngineError::ProviderTimeout { + step, + timeout_ms: timeout.map_or(0, |duration| { + duration.as_millis().min(u64::MAX as u128) as u64 + }), + }); + } + match result { + Ok(response) => return Ok(response), + Err(error) + if retries < max_retries + && matches!( + error.kind(), + ProviderErrorKind::Unavailable | ProviderErrorKind::Timeout + ) + && (!streaming || stream_events.len() == before_events) => + { + retries += 1; + continue; + } + Err(error) => return Err(EngineError::Provider(error.to_string())), + } + } + } + + fn checkpoint(&self, checkpoint: &EngineCheckpoint) -> Result<(), EngineError> { + if let Some(listener) = self.checkpoint_listener { + listener + .on_checkpoint(checkpoint) + .map_err(EngineError::Checkpoint)?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AgentOutput { + pub text: String, + pub steps: usize, + pub events: Vec, + /// 仅 `run_streaming` 填充;`run` 保持空数组,便于旧调用方兼容。 + #[serde(default)] + pub stream_events: Vec, + /// 每个已进入 Provider 请求的 step 的上下文预算统计;只含大小和计数, + /// 不复制消息正文。旧消费者反序列化时缺失该字段按空数组处理。 + #[serde(default)] + pub context_observations: Vec, + pub messages: Vec, +} + +/// 离线示例 Provider:第一轮可按约定发起工具调用,之后返回最终文本。 +#[derive(Clone, Debug)] +pub struct EchoProvider; + +impl ModelProvider for EchoProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + let has_tool_result = request + .messages() + .iter() + .any(|message| message.role() == MessageRole::Tool); + if !has_tool_result && !request.tools().is_empty() { + let tool = &request.tools()[0]; + let call = ToolCall::try_new( + "echo-call-1", + tool.name(), + json!({"text": "hello from fake provider"}), + )?; + return ProviderResponse::try_new(request.request_id(), request.model(), [], [call]) + .map_err(Into::into); + } + let text = request + .messages() + .iter() + .rev() + .flat_map(|message| message.content()) + .find_map(ContentPart::as_text) + .unwrap_or("ok"); + ProviderResponse::text( + request.request_id(), + request.model(), + format!("echo: {text}"), + ) + .map_err(Into::into) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::thread; + use std::time::Duration; + + struct EchoTool; + impl ToolExecutor for EchoTool { + fn execute( + &self, + call: &ToolCall, + _context: &ToolContext, + ) -> Result { + ToolResult::success(call.id(), json!({"received": call.arguments()})) + .map_err(Into::into) + } + } + + struct CooperativeCancellationTool { + started: std::sync::mpsc::SyncSender<()>, + observed: Arc, + } + + impl ToolExecutor for CooperativeCancellationTool { + fn execute( + &self, + call: &ToolCall, + context: &ToolContext, + ) -> Result { + self.started.send(()).map_err(|_| { + agent_runtime_core::ToolError::new( + agent_runtime_core::ToolErrorKind::Failed, + "工具启动通知失败", + ) + })?; + // A real adapter would poll at its own I/O safe points. The short + // sleep keeps this fixture bounded while making the cooperative + // cancellation boundary observable from another thread. + for _ in 0..1_000 { + if context.is_cancelled() { + self.observed.store(true, Ordering::Release); + return Err(agent_runtime_core::ToolError::new( + agent_runtime_core::ToolErrorKind::Cancelled, + "工具观察到取消", + )); + } + thread::sleep(Duration::from_millis(1)); + } + ToolResult::success(call.id(), json!({"ok": true})).map_err(Into::into) + } + } + + struct RetryProvider { + failures_left: Mutex, + calls: Arc>, + } + + struct SlowProvider { + delay: Duration, + calls: Arc>, + } + + impl ModelProvider for SlowProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + *self.calls.lock().unwrap() += 1; + thread::sleep(self.delay); + ProviderResponse::text(request.request_id(), request.model(), "late") + .map_err(Into::into) + } + } + + struct CancellingProvider { + cancellation: Cancellation, + } + + impl ModelProvider for CancellingProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + // Simulate an adapter observing cancellation while its I/O call is + // returning. Engine must discard the response at this boundary. + self.cancellation.cancel(); + ProviderResponse::text(request.request_id(), request.model(), "discarded") + .map_err(Into::into) + } + } + + struct FlakyTool { + failures_left: Mutex, + kind: agent_runtime_core::ToolErrorKind, + calls: Arc>, + } + + impl ToolExecutor for FlakyTool { + fn execute( + &self, + call: &ToolCall, + _context: &ToolContext, + ) -> Result { + *self.calls.lock().unwrap() += 1; + let mut failures = self.failures_left.lock().unwrap(); + if *failures > 0 { + *failures -= 1; + return Err(agent_runtime_core::ToolError::new( + self.kind, + "transient tool failure", + )); + } + ToolResult::success(call.id(), json!({"ok": true})).map_err(Into::into) + } + } + + struct RetryEchoPolicy { + policy: ToolRetryPolicy, + } + + impl ToolRetryPolicyProvider for RetryEchoPolicy { + fn policy(&self, call: &ToolCall) -> ToolRetryPolicy { + if call.name() == "echo" { + self.policy + } else { + ToolRetryPolicy::Never + } + } + } + + struct StaticCompressor; + impl ContextCompressor for StaticCompressor { + fn compress(&self, request: &CompressionRequest) -> Result, EngineError> { + assert!(!request.messages.is_empty()); + Ok(vec![Message::user("压缩摘要").unwrap()]) + } + } + + impl ModelProvider for RetryProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + *self.calls.lock().unwrap() += 1; + let mut failures = self.failures_left.lock().unwrap(); + if *failures > 0 { + *failures -= 1; + return Err(ProviderError::new( + ProviderErrorKind::Unavailable, + "temporary outage", + )); + } + ProviderResponse::text(request.request_id(), request.model(), "retried") + .map_err(Into::into) + } + } + + /// 首轮返回带普通文本和两个 tool call 的响应,后续看到结果后结束。 + /// 该 Provider 用来锁定 Engine 的批次消息边界,而不是模拟厂商协议细节。 + struct MixedBatchProvider { + requests: Arc>>>, + } + + impl ModelProvider for MixedBatchProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + self.requests + .lock() + .unwrap() + .push(request.messages().to_vec()); + if request + .messages() + .iter() + .any(|message| message.role() == MessageRole::Tool) + { + return ProviderResponse::text(request.request_id(), request.model(), "batch-done") + .map_err(Into::into); + } + let tool_name = request + .tools() + .first() + .map(ToolDefinition::name) + .unwrap_or("echo"); + let first = ToolCall::try_new("batch-call-1", tool_name, json!({"index": 1}))?; + let second = ToolCall::try_new("batch-call-2", tool_name, json!({"index": 2}))?; + ProviderResponse::try_new( + request.request_id(), + request.model(), + [ + ContentPart::text("batch-prefix")?, + ContentPart::text("batch-detail")?, + ], + [first, second], + ) + .map_err(Into::into) + } + } + + struct WrongIdTool; + impl ToolExecutor for WrongIdTool { + fn execute( + &self, + _call: &ToolCall, + _context: &ToolContext, + ) -> Result { + ToolResult::success("unrelated-call", json!({"ok": true})).map_err(Into::into) + } + } + + struct MismatchedProvider; + impl ModelProvider for MismatchedProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + ProviderResponse::text( + format!("{}-late", request.request_id()), + request.model(), + "unexpected response", + ) + .map_err(Into::into) + } + } + + struct StructuredContentProvider { + tool_call: bool, + } + + impl ModelProvider for StructuredContentProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + // 模拟适配器直接反序列化的 wire,绕过 ProviderResponse::try_new, + // 验证压缩器不会从混合 content 中静默丢掉结构化工具块。 + let structured = if self.tool_call { + json!({ + "type": "tool-call", + "id": "compression-call", + "name": "echo", + "arguments": {"text": "not a summary"} + }) + } else { + json!({ + "type": "tool-result", + "toolCallId": "compression-call", + "output": {"ok": true}, + "isError": false + }) + }; + serde_json::from_value(json!({ + "requestId": request.request_id(), + "model": request.model(), + "content": [ + {"type": "text", "text": "summary"}, + structured, + ], + "toolCalls": [], + "finishReason": null, + "usage": null, + })) + .map_err(|error| { + agent_runtime_core::ProviderError::new( + agent_runtime_core::ProviderErrorKind::InvalidRequest, + format!("structured compression fixture: {error}"), + ) + }) + } + } + + struct CountingProvider { + calls: Arc>, + } + + impl ModelProvider for CountingProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + *self.calls.lock().unwrap() += 1; + ProviderResponse::text(request.request_id(), request.model(), "ok").map_err(Into::into) + } + } + + struct MalformedContextSource; + + impl ContextSource for MalformedContextSource { + fn contribute( + &self, + _request: &ContextRequest, + ) -> Result, agent_runtime_core::ContextError> { + // Derive-based serde accepts this wire value even though the Core + // constructor would reject its empty text part. Engine must + // revalidate it before putting it in a Provider request. + let item = serde_json::from_value(json!({ + "sourceId": "malformed-source", + "message": { + "role": "user", + "content": [{"type": "text", "text": ""}] + }, + "priority": 1, + "trusted": false, + "metadata": {} + })) + .expect("malformed context item should deserialize as a wire value"); + Ok(vec![item]) + } + } + + struct MalformedCompressor; + + impl ContextCompressor for MalformedCompressor { + fn compress(&self, _request: &CompressionRequest) -> Result, EngineError> { + // Custom compressors can construct unchecked values through the + // compatibility `Message::new` constructor. + Ok(vec![Message::new(MessageRole::User, Vec::new())]) + } + } + + struct CancellingCompressor { + cancellation: Cancellation, + } + + impl ContextCompressor for CancellingCompressor { + fn compress(&self, _request: &CompressionRequest) -> Result, EngineError> { + self.cancellation.cancel(); + Ok(vec![Message::user("summary").unwrap()]) + } + } + + #[derive(Clone, Copy)] + enum MalformedResponseKind { + EmptyText, + NonObjectToolArguments, + StructuredToolContent, + StructuredToolCallContent, + } + + struct MalformedResponseProvider { + kind: MalformedResponseKind, + } + + fn unchecked_provider_response( + request: &ProviderRequest, + content: Value, + tool_calls: Value, + ) -> Result { + // 这里模拟未经 Core 构造器的外部 wire。ProviderResponse::try_new + // 现在会拒绝同样的值,因此 Engine 边界测试必须从 serde 解码进入, + // 验证“已解码但未校验”的适配器输出仍会在副作用前 fail-closed。 + serde_json::from_value(json!({ + "requestId": request.request_id(), + "model": request.model(), + "content": content, + "toolCalls": tool_calls, + "finishReason": null, + "usage": null, + })) + .map_err(|error| { + agent_runtime_core::ProviderError::new( + agent_runtime_core::ProviderErrorKind::InvalidRequest, + format!("malformed provider fixture: {error}"), + ) + }) + } + + impl ModelProvider for MalformedResponseProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + match self.kind { + MalformedResponseKind::EmptyText => unchecked_provider_response( + request, + json!([{"type": "text", "text": ""}]), + json!([]), + ), + MalformedResponseKind::NonObjectToolArguments => { + // serde accepts this unchecked ToolCall wire value; the + // Engine response boundary must reject it before schema, + // approval, or execution handling. + unchecked_provider_response( + request, + json!([]), + json!([{ + "id": "malformed-call", + "name": "echo", + "arguments": [] + }]), + ) + } + MalformedResponseKind::StructuredToolContent => { + // The part itself is valid, but structured tool data in + // `content` violates ProviderResponse's normalized wire + // partition and must not become assistant history. + unchecked_provider_response( + request, + json!([{ + "type": "tool-result", + "toolCallId": "foreign-call", + "output": {"answer": 7}, + "isError": false + }]), + json!([]), + ) + } + MalformedResponseKind::StructuredToolCallContent => unchecked_provider_response( + request, + json!([{ + "type": "tool-call", + "id": "foreign-call", + "name": "echo", + "arguments": {"text": "not a response tool_calls entry"} + }]), + json!([]), + ), + } + } + } + + struct DuplicateToolCallProvider; + impl ModelProvider for DuplicateToolCallProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + let first = ToolCall::try_new("duplicate-call", "echo", json!({"index": 1}))?; + let second = ToolCall::try_new("duplicate-call", "echo", json!({"index": 2}))?; + ProviderResponse::try_new(request.request_id(), request.model(), [], [first, second]) + .map_err(Into::into) + } + } + + struct Allow; + impl ApprovalPolicy for Allow { + fn decide( + &self, + _request: &ApprovalRequest, + ) -> Result { + Ok(ApprovalDecision::allow()) + } + } + + struct StreamingProvider { + complete_calls: Arc>, + stream_calls: Arc>, + } + + impl ModelProvider for StreamingProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + *self.complete_calls.lock().unwrap() += 1; + ProviderResponse::text(request.request_id(), request.model(), "sync fallback") + .map_err(Into::into) + } + + fn stream( + &self, + request: &ProviderRequest, + sink: &mut dyn ProviderStreamSink, + ) -> Result { + *self.stream_calls.lock().unwrap() += 1; + sink.emit(ProviderStreamEvent::TextDelta { + delta: "流".to_owned(), + accumulated: "流".to_owned(), + })?; + sink.emit(ProviderStreamEvent::TextDelta { + delta: "式".to_owned(), + accumulated: "流式".to_owned(), + })?; + sink.emit(ProviderStreamEvent::Completed)?; + ProviderResponse::text(request.request_id(), request.model(), "流式") + .map_err(Into::into) + } + } + + /// 只发出一个增量就模拟流中断,验证 Engine 不会把半截流当成完成响应。 + struct StreamErrorProvider; + + impl ModelProvider for StreamErrorProvider { + fn complete( + &self, + _request: &ProviderRequest, + ) -> Result { + panic!("streaming run must not fall back to complete"); + } + + fn stream( + &self, + _request: &ProviderRequest, + sink: &mut dyn ProviderStreamSink, + ) -> Result { + sink.emit(ProviderStreamEvent::TextDelta { + delta: "partial".to_owned(), + accumulated: "partial".to_owned(), + })?; + Err(ProviderError::new( + ProviderErrorKind::Stream, + "fake provider stream interrupted", + )) + } + } + + struct StreamCollect(Mutex>); + + impl StreamEventListener for StreamCollect { + fn on_stream_event(&self, event: &EngineStreamEvent) { + self.0.lock().unwrap().push(event.clone()); + } + } + + struct CancelOnFirstStream { + cancellation: Cancellation, + events: Mutex>, + } + + impl StreamEventListener for CancelOnFirstStream { + fn on_stream_event(&self, event: &EngineStreamEvent) { + self.events.lock().unwrap().push(event.clone()); + self.cancellation.cancel(); + } + } + + struct SingleCallProvider { + arguments: Value, + } + + impl ModelProvider for SingleCallProvider { + fn complete( + &self, + request: &ProviderRequest, + ) -> Result { + if request + .messages() + .iter() + .any(|message| message.role() == MessageRole::Tool) + { + return ProviderResponse::text(request.request_id(), request.model(), "done") + .map_err(Into::into); + } + let tool = request.tools().first().expect("schema tool"); + let call = ToolCall::try_new("schema-call-1", tool.name(), self.arguments.clone())?; + ProviderResponse::try_new(request.request_id(), request.model(), [], [call]) + .map_err(Into::into) + } + } + + /// 只拒绝批次中的第一条调用,验证拒绝后仍可安全推进到下一条审批。 + struct DenyFirstApproval; + impl ApprovalPolicy for DenyFirstApproval { + fn decide( + &self, + request: &ApprovalRequest, + ) -> Result { + if request.call().id() == "batch-call-1" { + Ok(ApprovalDecision::deny("测试拒绝")) + } else { + Ok(ApprovalDecision::allow()) + } + } + } + + struct Collect(Mutex>); + impl EventListener for Collect { + fn on_event(&self, event: &EngineEvent) { + self.0.lock().unwrap().push(event.clone()); + } + } + + struct CollectCheckpoints(Mutex>); + + impl CheckpointListener for CollectCheckpoints { + fn on_checkpoint(&self, checkpoint: &EngineCheckpoint) -> Result<(), String> { + self.0.lock().unwrap().push(checkpoint.clone()); + Ok(()) + } + } + + struct CancelOnToolCompleted { + cancellation: Cancellation, + events: Mutex>, + } + + impl EventListener for CancelOnToolCompleted { + fn on_event(&self, event: &EngineEvent) { + self.events.lock().unwrap().push(event.clone()); + if matches!(event, EngineEvent::ToolCompleted { .. }) { + self.cancellation.cancel(); + } + } + } + + type IdentityPair = (Option, Option); + + struct ContextProbe(Arc>>); + impl ContextSource for ContextProbe { + fn contribute( + &self, + request: &ContextRequest, + ) -> Result, agent_runtime_core::ContextError> { + self.0 + .lock() + .unwrap() + .push((request.session_id.clone(), request.run_id.clone())); + Ok(Vec::new()) + } + } + + struct FixedContextSource(Vec<(String, String, i32)>); + + impl ContextSource for FixedContextSource { + fn contribute( + &self, + _request: &ContextRequest, + ) -> Result, agent_runtime_core::ContextError> { + self.0 + .iter() + .map(|(source_id, text, priority)| { + ContextItem::try_new( + source_id, + Message::user(text).map_err(agent_runtime_core::ContextError::from)?, + *priority, + false, + ) + .map_err(agent_runtime_core::ContextError::from) + }) + .collect() + } + } + + struct TrustedContextSource(Vec<(String, String, i32, bool)>); + + impl ContextSource for TrustedContextSource { + fn contribute( + &self, + _request: &ContextRequest, + ) -> Result, agent_runtime_core::ContextError> { + self.0 + .iter() + .map(|(source_id, text, priority, trusted)| { + ContextItem::try_new( + source_id, + Message::user(text).map_err(agent_runtime_core::ContextError::from)?, + *priority, + *trusted, + ) + .map_err(agent_runtime_core::ContextError::from) + }) + .collect() + } + } + + struct RoleContextSource(Vec<(String, Message, i32, bool)>); + + impl ContextSource for RoleContextSource { + fn contribute( + &self, + _request: &ContextRequest, + ) -> Result, agent_runtime_core::ContextError> { + self.0 + .iter() + .map(|(source_id, message, priority, trusted)| { + ContextItem::try_new(source_id, message.clone(), *priority, *trusted) + .map_err(agent_runtime_core::ContextError::from) + }) + .collect() + } + } + + struct IdentityTool(Arc>); + impl ToolExecutor for IdentityTool { + fn execute( + &self, + call: &ToolCall, + context: &ToolContext, + ) -> Result { + *self.0.lock().unwrap() = ( + context.session_id().map(str::to_owned), + context.run_id().map(str::to_owned), + ); + ToolResult::success(call.id(), json!({"ok": true})).map_err(Into::into) + } + } + + struct ApprovalProbe(Arc>>); + impl ApprovalPolicy for ApprovalProbe { + fn decide( + &self, + request: &ApprovalRequest, + ) -> Result { + self.0.lock().unwrap().push(( + request.request_id().to_owned(), + request.run_id().to_owned(), + request.call().id().to_owned(), + )); + Ok(ApprovalDecision::allow()) + } + } + + struct AskApproval; + impl ApprovalPolicy for AskApproval { + fn decide( + &self, + _request: &ApprovalRequest, + ) -> Result { + Ok(ApprovalDecision::ask()) + } + } + + struct CountingTool(Arc>); + impl ToolExecutor for CountingTool { + fn execute( + &self, + call: &ToolCall, + _context: &ToolContext, + ) -> Result { + *self.0.lock().unwrap() += 1; + ToolResult::success(call.id(), json!({"executed": true})).map_err(Into::into) + } + } + + struct MalformedToolResultTool; + + impl ToolExecutor for MalformedToolResultTool { + fn execute( + &self, + call: &ToolCall, + _context: &ToolContext, + ) -> Result { + // The wire shape is accepted by serde, but an empty summary is + // rejected by ToolResult::with_summary. Engine must stop before + // publishing this executor result to the runtime trace. + serde_json::from_value(json!({ + "callId": call.id(), + "output": {"ok": true}, + "isError": false, + "summary": "", + "metadata": {} + })) + .map_err(|error| { + agent_runtime_core::ToolError::new( + agent_runtime_core::ToolErrorKind::InvalidInput, + error.to_string(), + ) + }) + } + } + + #[test] + fn fake_provider_can_finish_after_tool_call() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let listener = Collect(Mutex::new(Vec::new())); + let engine = AgentEngine::new(&provider, &tool, &allow).with_listener(&listener); + let output = engine + .run( + AgentInput::new("run-1", "fake", vec![Message::user("hello").unwrap()]) + .with_tools(vec![definition]), + ) + .unwrap(); + assert!(output.text.starts_with("echo:")); + assert_eq!(output.steps, 2); + assert_eq!(output.context_observations.len(), 2); + assert!( + output + .context_observations + .iter() + .all(|observation| !observation.overflowed) + ); + assert!( + listener + .0 + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, EngineEvent::ToolCompleted { .. })) + ); + } + + #[test] + fn transient_provider_errors_are_retried_with_the_same_request_identity() { + let calls = Arc::new(Mutex::new(0)); + let provider = RetryProvider { + failures_left: Mutex::new(2), + calls: calls.clone(), + }; + let output = AgentEngine::new(&provider, &EchoTool, &Allow) + .run(AgentInput::new( + "retry-run", + "fake", + vec![Message::user("hello").unwrap()], + )) + .unwrap(); + assert_eq!(output.text, "retried"); + assert_eq!(*calls.lock().unwrap(), 3); + } + + #[test] + fn retry_budget_can_be_disabled() { + let calls = Arc::new(Mutex::new(0)); + let provider = RetryProvider { + failures_left: Mutex::new(1), + calls: calls.clone(), + }; + let error = AgentEngine::new(&provider, &EchoTool, &Allow) + .run( + AgentInput::new( + "no-retry-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_provider_retries(0), + ) + .unwrap_err(); + assert!(matches!(error, EngineError::Provider(message) if message == "temporary outage")); + assert_eq!(*calls.lock().unwrap(), 1); + } + + #[test] + fn provider_timeout_is_a_terminal_boundary_and_is_not_retried() { + let calls = Arc::new(Mutex::new(0)); + let provider = SlowProvider { + delay: Duration::from_millis(20), + calls: calls.clone(), + }; + let error = AgentEngine::new(&provider, &EchoTool, &Allow) + .run( + AgentInput::new( + "provider-timeout-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_provider_retries(3) + .with_provider_timeout_ms(1), + ) + .expect_err("late synchronous Provider responses must hit the timeout boundary"); + assert!(matches!( + error, + EngineError::ProviderTimeout { + step: 0, + timeout_ms: 1 + } + )); + // A response that crossed the deadline may have had an external effect; + // the generic Engine therefore never retries it. + assert_eq!(*calls.lock().unwrap(), 1); + } + + #[test] + fn provider_cancellation_is_checked_when_call_returns() { + let cancellation = Cancellation::new(); + let provider = CancellingProvider { + cancellation: cancellation.clone(), + }; + let error = AgentEngine::new(&provider, &EchoTool, &Allow) + .with_cancellation(cancellation.clone()) + .run(AgentInput::new( + "provider-cancel-run", + "fake", + vec![Message::user("hello").unwrap()], + )) + .expect_err("cancel observed at Provider boundary must discard its response"); + assert!(matches!(error, EngineError::Cancelled)); + assert!(cancellation.is_cancelled()); + } + + #[test] + fn default_tool_policy_does_not_retry_errors() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let calls = Arc::new(Mutex::new(0)); + let tool = FlakyTool { + failures_left: Mutex::new(1), + kind: agent_runtime_core::ToolErrorKind::Timeout, + calls: calls.clone(), + }; + let error = AgentEngine::new(&EchoProvider, &tool, &Allow) + .run( + AgentInput::new( + "tool-no-retry-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect_err("tools are not retried without an explicit policy"); + assert!(matches!(error, EngineError::Tool(message) if message == "transient tool failure")); + assert_eq!(*calls.lock().unwrap(), 1); + } + + #[test] + fn declared_idempotent_tool_can_retry_selected_timeout() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let calls = Arc::new(Mutex::new(0)); + let tool = FlakyTool { + failures_left: Mutex::new(1), + kind: agent_runtime_core::ToolErrorKind::Timeout, + calls: calls.clone(), + }; + let policy = RetryEchoPolicy { + policy: ToolRetryPolicy::idempotent(1), + }; + let output = AgentEngine::new(&EchoProvider, &tool, &Allow) + .with_tool_retry_policy(&policy) + .run( + AgentInput::new( + "tool-idempotent-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect("explicit idempotent timeout policy should retry once"); + assert!(output.text.starts_with("echo:")); + assert_eq!(*calls.lock().unwrap(), 2); + } + + #[test] + fn unknown_tool_outcome_is_never_retried_even_when_declared_idempotent() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let calls = Arc::new(Mutex::new(0)); + let tool = FlakyTool { + failures_left: Mutex::new(1), + kind: agent_runtime_core::ToolErrorKind::Unknown, + calls: calls.clone(), + }; + let policy = RetryEchoPolicy { + policy: ToolRetryPolicy::idempotent_on(3, true, true), + }; + let error = AgentEngine::new(&EchoProvider, &tool, &Allow) + .with_tool_retry_policy(&policy) + .run( + AgentInput::new( + "tool-unknown-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect_err("unknown side effects must remain in reconciliation"); + assert!(matches!(error, EngineError::Tool(message) if message == "transient tool failure")); + assert_eq!(*calls.lock().unwrap(), 1); + } + + #[test] + fn context_overflow_fails_closed_without_compressor() { + let long = "x".repeat(512); + let error = AgentEngine::new(&EchoProvider, &EchoTool, &Allow) + .run( + AgentInput::new("overflow-run", "fake", vec![Message::user(long).unwrap()]) + .with_context_budget(256), + ) + .unwrap_err(); + assert!(matches!(error, EngineError::ContextOverflow(_))); + } + + #[test] + fn context_overflow_uses_explicit_compressor_before_provider() { + let long = "x".repeat(512); + let compressor = StaticCompressor; + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let events = Collect(Mutex::new(Vec::new())); + let output = AgentEngine::new(&EchoProvider, &EchoTool, &Allow) + .with_context_compressor(&compressor) + .with_checkpoint_listener(&checkpoints) + .with_listener(&events) + .run( + AgentInput::new("compress-run", "fake", vec![Message::user(long).unwrap()]) + .with_context_budget(256), + ) + .unwrap(); + assert!(output.text.starts_with("echo:")); + assert_eq!(output.messages[0].content()[0].as_text(), Some("压缩摘要")); + assert_eq!(output.context_observations.len(), 1); + let observation = &output.context_observations[0]; + assert!(observation.compression_attempted); + assert_eq!(observation.compression_input_size, Some(512)); + assert_eq!(observation.compression_output_size, Some("压缩摘要".len())); + assert!(!observation.overflowed); + let checkpoints = checkpoints.0.lock().unwrap(); + assert_eq!( + checkpoints.first().map(|item| item.phase), + Some(CheckpointPhase::Compacting) + ); + assert!( + checkpoints + .iter() + .any(|item| item.phase == CheckpointPhase::ProviderInFlight) + ); + let events = events.0.lock().unwrap(); + assert!( + events + .iter() + .any(|event| matches!(event, EngineEvent::CompactionStarted { step: 0 })) + ); + assert!( + events + .iter() + .any(|event| matches!(event, EngineEvent::CompactionCompleted { step: 0 })) + ); + } + + #[test] + fn serde_input_is_revalidated_before_context_or_provider_side_effects() { + let input: AgentInput = serde_json::from_value(json!({ + "request_id": "malformed-input", + "model": "fake", + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": ""}] + }] + })) + .expect("derive serde accepts the unchecked wire value"); + let context_calls = Arc::new(Mutex::new(Vec::new())); + let provider_calls = Arc::new(Mutex::new(0)); + let error = AgentEngine::new( + &CountingProvider { + calls: provider_calls.clone(), + }, + &EchoTool, + &Allow, + ) + .with_context_source(Arc::new(ContextProbe(context_calls.clone()))) + .run(input) + .expect_err("malformed serde input must fail before any port call"); + + assert!(matches!(error, EngineError::InvalidInput(message) if + message.contains("消息无效") && message.contains("message text"))); + assert!(context_calls.lock().unwrap().is_empty()); + assert_eq!(*provider_calls.lock().unwrap(), 0); + } + + #[test] + fn serde_input_rejects_invalid_context_identity_before_source_call() { + let input: AgentInput = serde_json::from_value(json!({ + "request_id": "identity-input", + "model": "fake", + "session_id": "bad id", + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": "hello"}] + }] + })) + .expect("identity fixture should deserialize before boundary validation"); + let context_calls = Arc::new(Mutex::new(Vec::new())); + let provider_calls = Arc::new(Mutex::new(0)); + let error = AgentEngine::new( + &CountingProvider { + calls: provider_calls.clone(), + }, + &EchoTool, + &Allow, + ) + .with_context_source(Arc::new(ContextProbe(context_calls.clone()))) + .run(input) + .expect_err("invalid session identity must fail before a source call"); + + assert!(matches!(error, EngineError::InvalidInput(message) + if message.contains("上下文请求无效") && message.contains("session id"))); + assert!(context_calls.lock().unwrap().is_empty()); + assert_eq!(*provider_calls.lock().unwrap(), 0); + } + + #[test] + fn malformed_context_item_is_rejected_before_provider_call() { + let provider_calls = Arc::new(Mutex::new(0)); + let error = AgentEngine::new( + &CountingProvider { + calls: provider_calls.clone(), + }, + &EchoTool, + &Allow, + ) + .with_context_source(Arc::new(MalformedContextSource)) + .run(AgentInput::new( + "context-malformed", + "fake", + vec![Message::user("hello").unwrap()], + )) + .expect_err("malformed source payload must fail before Provider"); + + assert!(matches!(error, EngineError::Context(message) if + message.contains("非法 item") && message.contains("message text"))); + assert_eq!(*provider_calls.lock().unwrap(), 0); + } + + #[test] + fn malformed_compressor_output_is_rejected_before_provider_call() { + let provider_calls = Arc::new(Mutex::new(0)); + let error = AgentEngine::new( + &CountingProvider { + calls: provider_calls.clone(), + }, + &EchoTool, + &Allow, + ) + .with_context_compressor(&MalformedCompressor) + .run( + AgentInput::new( + "compress-malformed", + "fake", + vec![Message::user("x".repeat(512)).unwrap()], + ) + .with_context_budget(256), + ) + .expect_err("compressor output must be validated before Provider"); + + assert!(matches!(error, EngineError::ContextOverflow(message) if + message.contains("压缩器返回非法消息") && message.contains("content"))); + assert_eq!(*provider_calls.lock().unwrap(), 0); + } + + #[test] + fn compressor_response_identity_is_checked_at_compression_boundary() { + let compressor = ProviderContextCompressor::new(&MismatchedProvider); + let error = compressor + .compress(&CompressionRequest { + request_id: "compression-run".to_owned(), + model: "fake".to_owned(), + messages: vec![Message::user("history").unwrap()], + budget: 256, + session_id: None, + run_id: None, + }) + .expect_err("a late compression response must not be accepted"); + + assert!(matches!( + error, + EngineError::ProviderResponseMismatch { + expected_request_id, + actual_request_id, + expected_model, + actual_model, + } if expected_request_id == "compression-run-compression" + && actual_request_id == "compression-run-compression-late" + && expected_model == "fake" + && actual_model == "fake" + )); + } + + #[test] + fn compression_request_revalidates_public_budget_and_identity() { + let calls = Arc::new(Mutex::new(0)); + let provider = CountingProvider { + calls: calls.clone(), + }; + let compressor = ProviderContextCompressor::new(&provider); + let error = compressor + .compress(&CompressionRequest { + request_id: "compression-run".to_owned(), + model: "fake".to_owned(), + messages: vec![Message::user("history").unwrap()], + budget: 0, + session_id: Some("bad session id".to_owned()), + run_id: None, + }) + .expect_err("invalid public compression request must fail before Provider"); + assert!(matches!(error, EngineError::ContextOverflow(message) + if message.contains("compression budget"))); + assert_eq!(*calls.lock().unwrap(), 0); + + let error = compressor + .compress(&CompressionRequest { + request_id: "compression-run".to_owned(), + model: "fake".to_owned(), + messages: vec![Message::user("history").unwrap()], + budget: 256, + session_id: Some("bad session id".to_owned()), + run_id: None, + }) + .expect_err("invalid compression identity must fail before Provider"); + assert!(matches!(error, EngineError::ContextOverflow(message) + if message.contains("压缩上下文无效") && message.contains("session id"))); + assert_eq!(*calls.lock().unwrap(), 0); + } + + #[test] + fn compressor_rejects_structured_content_in_summary_response() { + for tool_call in [false, true] { + let provider = StructuredContentProvider { tool_call }; + let compressor = ProviderContextCompressor::new(&provider); + let error = compressor + .compress(&CompressionRequest { + request_id: "compression-structured".to_owned(), + model: "fake".to_owned(), + messages: vec![Message::user("history").unwrap()], + budget: 256, + session_id: None, + run_id: None, + }) + .expect_err("structured content must not be silently dropped"); + assert!(matches!( + error, + EngineError::ContextOverflow(message) + if message.contains("结构化工具历史") + )); + } + } + + #[test] + fn owned_provider_compressor_keeps_provider_alive_for_host_assembly() { + // Host stores the compressor behind an Arc while each Engine only + // borrows it for one run; the owned adapter must use the same boundary + // checks as the short-lived borrowed adapter. + let compressor = OwnedProviderContextCompressor::new(Arc::new(EchoProvider)); + let messages = compressor + .compress(&CompressionRequest { + request_id: "owned-compression".to_owned(), + model: "fake".to_owned(), + messages: vec![Message::user("history").unwrap()], + budget: 256, + session_id: None, + run_id: None, + }) + .expect("owned provider compressor should return a summary"); + assert_eq!(messages.len(), 1); + assert!( + messages[0] + .content() + .iter() + .filter_map(ContentPart::as_text) + .any(|text| text.starts_with("[compressed context]\n")) + ); + } + + #[test] + fn compressor_cancellation_stops_before_main_provider_call() { + let cancellation = Cancellation::new(); + let compressor = CancellingCompressor { + cancellation: cancellation.clone(), + }; + let provider_calls = Arc::new(Mutex::new(0)); + let error = AgentEngine::new( + &CountingProvider { + calls: provider_calls.clone(), + }, + &EchoTool, + &Allow, + ) + .with_cancellation(cancellation.clone()) + .with_context_compressor(&compressor) + .run( + AgentInput::new( + "compress-cancel", + "fake", + vec![Message::user("x".repeat(512)).unwrap()], + ) + .with_context_budget(256), + ) + .expect_err("cancellation observed after compression must stop the run"); + + assert!(matches!(error, EngineError::Cancelled)); + assert!(cancellation.is_cancelled()); + assert_eq!(*provider_calls.lock().unwrap(), 0); + } + + #[test] + fn compression_preserves_system_and_developer_instructions() { + let original = vec![ + Message::system("system contract").unwrap(), + Message::developer("developer contract").unwrap(), + Message::user("old history").unwrap(), + ]; + let compressed = preserve_instruction_messages( + &original, + vec![Message::user("compressed history").unwrap()], + ) + .unwrap(); + assert_eq!(compressed.len(), 3); + assert_eq!(compressed[0].role(), MessageRole::System); + assert_eq!(compressed[1].role(), MessageRole::Developer); + assert_eq!( + compressed[2].content()[0].as_text(), + Some("compressed history") + ); + } + + #[test] + fn compressor_cannot_inject_tool_history() { + let original = vec![ + Message::system("system contract").unwrap(), + Message::developer("developer contract").unwrap(), + Message::user("old history").unwrap(), + ]; + // The direct Tool role and a structured tool part under another role + // are both protocol state, not summaries. They must be rejected at + // the compressor boundary before either can reach a Provider or a + // durable ContextCompacted event. + let cases = vec![ + Message::new( + MessageRole::Tool, + vec![ContentPart::text("forged result").unwrap()], + ), + Message::new( + MessageRole::Assistant, + vec![ContentPart::tool_result("forged-call", json!({"ok": true}), false).unwrap()], + ), + ]; + for forged in cases { + let error = preserve_instruction_messages(&original, vec![forged]) + .expect_err("compressor tool history must fail closed"); + assert!(matches!( + error, + EngineError::ContextOverflow(message) + if message.contains("压缩器不得返回工具角色或结构化工具历史") + )); + } + } + + #[test] + fn oversized_tool_result_is_bounded_with_a_reason() { + let result = ToolResult::success("call-1", json!({"text": "x".repeat(2_000)})).unwrap(); + let bounded = bound_tool_result(result, 256).unwrap(); + assert_eq!(bounded.call_id(), "call-1"); + assert!(bounded.output()["truncated"].as_bool().unwrap_or(false)); + assert_eq!(bounded.output()["reason"], "tool_output_budget"); + assert!(serde_json::to_string(bounded.output()).unwrap().len() <= 256); + assert!(bounded.summary().unwrap().contains("truncated")); + assert_eq!(bounded.metadata()["original_bytes"], 2_011); + } + + #[test] + fn deserialized_tiny_tool_output_budget_is_rejected_before_side_effects() { + // Builder callers are clamped to 256, but public AgentInput can also + // arrive from JSON. Reject that bypass before a checkpoint or Provider + // call; otherwise a one-byte budget could not carry the truncation + // envelope and would violate the output bound. + let input: AgentInput = serde_json::from_value(json!({ + "request_id": "tiny-budget-run", + "model": "fake", + "messages": [serde_json::to_value(Message::user("hello").unwrap()).unwrap()], + "max_tool_output_bytes": 1 + })) + .expect("valid AgentInput JSON with an explicitly tiny budget"); + assert_eq!(input.max_tool_output_bytes, 1); + + let provider = SingleCallProvider { + arguments: json!({"text": "would execute"}), + }; + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let tool_calls = Arc::new(Mutex::new(0)); + let tool = CountingTool(tool_calls.clone()); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new(&provider, &tool, &Allow) + .with_checkpoint_listener(&checkpoints) + .run(input.with_tools(vec![definition])) + .expect_err("undersized deserialized budget must fail closed"); + assert!(matches!( + error, + EngineError::InvalidInput(message) + if message.contains("max_tool_output_bytes") + && message.contains("256") + )); + // The first ProviderInFlight checkpoint is emitted before the Provider + // call. An empty list therefore proves the rejection precedes both the + // Provider boundary and any eventual ToolResult/message path. + assert!(checkpoints.0.lock().unwrap().is_empty()); + assert_eq!(*tool_calls.lock().unwrap(), 0); + } + + #[test] + fn streaming_run_consumes_provider_events_without_using_complete() { + let complete_calls = Arc::new(Mutex::new(0)); + let stream_calls = Arc::new(Mutex::new(0)); + let provider = StreamingProvider { + complete_calls: complete_calls.clone(), + stream_calls: stream_calls.clone(), + }; + let stream_listener = StreamCollect(Mutex::new(Vec::new())); + let output = AgentEngine::new(&provider, &EchoTool, &Allow) + .with_stream_listener(&stream_listener) + .run_streaming(AgentInput::new( + "stream-run", + "fake", + vec![Message::user("hello").unwrap()], + )) + .unwrap(); + + assert_eq!(output.text, "流式"); + assert_eq!(*complete_calls.lock().unwrap(), 0); + assert_eq!(*stream_calls.lock().unwrap(), 1); + assert_eq!(output.stream_events.len(), 3); + assert_eq!( + stream_listener.0.lock().unwrap().as_slice(), + output.stream_events + ); + assert!(matches!( + output.stream_events[0].event(), + ProviderStreamEvent::TextDelta { delta, accumulated } + if delta == "流" && accumulated == "流" + )); + assert!(matches!( + output.stream_events.last().map(EngineStreamEvent::event), + Some(ProviderStreamEvent::Completed) + )); + } + + #[test] + fn streaming_provider_error_does_not_emit_completion() { + let events = Collect(Mutex::new(Vec::new())); + let stream_events = StreamCollect(Mutex::new(Vec::new())); + let error = AgentEngine::new(&StreamErrorProvider, &EchoTool, &Allow) + .with_listener(&events) + .with_stream_listener(&stream_events) + .run_streaming(AgentInput::new( + "stream-error-run", + "fake", + vec![Message::user("hello").unwrap()], + )) + .expect_err("a provider stream interruption must fail the run"); + + assert!(matches!( + error, + EngineError::Provider(message) if message == "fake provider stream interrupted" + )); + let stream_events = stream_events.0.lock().unwrap(); + assert!(stream_events.iter().any(|event| matches!( + event.event(), + ProviderStreamEvent::TextDelta { delta, .. } if delta == "partial" + ))); + assert!( + stream_events + .iter() + .all(|event| !matches!(event.event(), ProviderStreamEvent::Completed)) + ); + let events = events.0.lock().unwrap(); + assert!(events.iter().all(|event| { + !matches!( + event, + EngineEvent::ModelCompleted { .. } | EngineEvent::Finished { .. } + ) + })); + } + + #[test] + fn synchronous_run_keeps_complete_compatibility_and_no_stream_trace() { + let complete_calls = Arc::new(Mutex::new(0)); + let stream_calls = Arc::new(Mutex::new(0)); + let provider = StreamingProvider { + complete_calls: complete_calls.clone(), + stream_calls: stream_calls.clone(), + }; + let output = AgentEngine::new(&provider, &EchoTool, &Allow) + .run(AgentInput::new( + "sync-run", + "fake", + vec![Message::user("hello").unwrap()], + )) + .unwrap(); + + assert_eq!(output.text, "sync fallback"); + assert_eq!(*complete_calls.lock().unwrap(), 1); + assert_eq!(*stream_calls.lock().unwrap(), 0); + assert!(output.stream_events.is_empty()); + } + + #[test] + fn streaming_listener_can_propagate_cancellation_before_next_delta() { + let cancellation = Cancellation::new(); + let listener = CancelOnFirstStream { + cancellation: cancellation.clone(), + events: Mutex::new(Vec::new()), + }; + let provider = StreamingProvider { + complete_calls: Arc::new(Mutex::new(0)), + stream_calls: Arc::new(Mutex::new(0)), + }; + let error = AgentEngine::new(&provider, &EchoTool, &Allow) + .with_cancellation(cancellation.clone()) + .with_stream_listener(&listener) + .run_streaming(AgentInput::new( + "stream-cancel-run", + "fake", + vec![Message::user("hello").unwrap()], + )) + .expect_err("listener cancellation should stop an in-flight stream"); + assert!(matches!(error, EngineError::Cancelled)); + assert!(cancellation.is_cancelled()); + assert_eq!(listener.events.lock().unwrap().len(), 1); + } + + #[test] + fn invalid_tool_arguments_are_rejected_before_approval_or_execution() { + let definition = ToolDefinition::try_new( + "schema_tool", + "requires text", + json!({ + "type": "object", + "properties": {"text": {"type": "string", "minLength": 2}}, + "required": ["text"], + "additionalProperties": false + }), + ) + .unwrap(); + let executions = Arc::new(Mutex::new(0)); + let approvals = Arc::new(Mutex::new(0)); + struct CountApproval(Arc>); + impl ApprovalPolicy for CountApproval { + fn decide( + &self, + _request: &ApprovalRequest, + ) -> Result { + *self.0.lock().unwrap() += 1; + Ok(ApprovalDecision::allow()) + } + } + + let error = AgentEngine::new( + &SingleCallProvider { + arguments: json!({"text": "x", "unexpected": true}), + }, + &CountingTool(executions.clone()), + &CountApproval(approvals.clone()), + ) + .run( + AgentInput::new( + "schema-run", + "fake", + vec![Message::user("validate").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect_err("invalid arguments must stop before approval"); + + assert!(matches!(error, EngineError::ToolArguments(ref detail) if + detail.tool_name() == "schema_tool" + && detail.path() == "$.text" + && detail.reason().contains("minLength"))); + assert_eq!(*approvals.lock().unwrap(), 0); + assert_eq!(*executions.lock().unwrap(), 0); + } + + #[test] + fn valid_tool_arguments_pass_basic_schema_constraints() { + let definition = ToolDefinition::try_new( + "schema_tool", + "requires text", + json!({ + "type": "object", + "properties": {"text": {"type": "string", "minLength": 2}}, + "required": ["text"], + "additionalProperties": false + }), + ) + .unwrap(); + let output = AgentEngine::new( + &SingleCallProvider { + arguments: json!({"text": "ok"}), + }, + &EchoTool, + &Allow, + ) + .run( + AgentInput::new( + "schema-valid-run", + "fake", + vec![Message::user("validate").unwrap()], + ) + .with_tools(vec![definition]), + ) + .unwrap(); + assert_eq!(output.text, "done"); + } + + #[test] + fn public_tool_argument_validator_rechecks_core_contracts_and_name() { + let definition = ToolDefinition::try_new( + "schema_tool", + "accepts an object", + json!({"type": "object"}), + ) + .unwrap(); + + // Derive-based serde intentionally accepts this wire value; the public + // preflight helper must not treat an empty call id as a valid schema + // match merely because the arguments object is well shaped. + let malformed_call: ToolCall = serde_json::from_value(json!({ + "id": "", + "name": "schema_tool", + "arguments": {} + })) + .expect("malformed call should deserialize before the boundary"); + let error = validate_tool_arguments(&malformed_call, &definition) + .expect_err("malformed Core call must fail public preflight"); + assert_eq!(error.tool_name(), "schema_tool"); + assert_eq!(error.path(), "$"); + assert!(error.reason().contains("tool call")); + + let mismatched_call = ToolCall::try_new("valid-call", "other_tool", json!({})).unwrap(); + let error = validate_tool_arguments(&mismatched_call, &definition) + .expect_err("a call for another tool must not pass this definition"); + assert_eq!(error.tool_name(), "other_tool"); + assert!(error.reason().contains("不匹配")); + + // The schema validator itself expects an object, so a malformed + // definition must be rejected before it can report a misleading + // argument result. + let malformed_definition: ToolDefinition = serde_json::from_value(json!({ + "name": "schema_tool", + "description": "accepts an object", + "inputSchema": [], + "metadata": {} + })) + .expect("malformed definition should deserialize before validation"); + let valid_call = ToolCall::try_new("valid-call", "schema_tool", json!({})).unwrap(); + let error = validate_tool_arguments(&valid_call, &malformed_definition) + .expect_err("malformed Core definition must fail public preflight"); + assert_eq!(error.tool_name(), "schema_tool"); + assert!(error.reason().contains("tool definition")); + assert!(error.reason().contains("inputSchema")); + } + + #[test] + fn checkpoints_mark_external_boundaries_and_safe_cursor() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + AgentEngine::new(&provider, &tool, &allow) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new("run-1", "fake", vec![Message::user("hello").unwrap()]) + .with_tools(vec![definition]), + ) + .unwrap(); + + let checkpoints = checkpoints.0.lock().unwrap(); + assert!(matches!( + checkpoints.first().map(|checkpoint| checkpoint.phase), + Some(CheckpointPhase::ProviderInFlight) + )); + assert!( + checkpoints + .iter() + .any(|checkpoint| checkpoint.phase == CheckpointPhase::AwaitingApproval) + ); + assert!( + checkpoints + .iter() + .any(|checkpoint| checkpoint.phase == CheckpointPhase::ToolInFlight) + ); + assert!(checkpoints.iter().any(|checkpoint| { + checkpoint.phase == CheckpointPhase::Safe && checkpoint.next_step == 1 + })); + } + + #[test] + fn mixed_provider_response_uses_one_assistant_message_boundary() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = MixedBatchProvider { + requests: requests.clone(), + }; + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let output = AgentEngine::new(&provider, &EchoTool, &Allow) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new("mixed-run", "fake", vec![Message::user("hello").unwrap()]) + .with_tools(vec![definition]), + ) + .unwrap(); + + let assistant = output + .messages + .iter() + .find(|message| { + message.role() == MessageRole::Assistant + && message + .content() + .iter() + .any(|part| matches!(part, ContentPart::ToolCall { .. })) + }) + .expect("assistant batch message"); + assert_eq!(assistant.content().len(), 4); + assert_eq!(assistant.content()[0].as_text(), Some("batch-prefix")); + assert_eq!(assistant.content()[1].as_text(), Some("batch-detail")); + assert!(matches!( + &assistant.content()[2], + ContentPart::ToolCall { id, .. } if id == "batch-call-1" + )); + assert!(matches!( + &assistant.content()[3], + ContentPart::ToolCall { id, .. } if id == "batch-call-2" + )); + + // checkpoint 与下一次 Provider 请求必须携带和 Host runtime trace + // 持久化完全相同的 assistant 消息形状。 + let checkpoints = checkpoints.0.lock().unwrap(); + let awaiting = checkpoints + .iter() + .find(|checkpoint| checkpoint.phase == CheckpointPhase::AwaitingApproval) + .expect("approval checkpoint"); + assert_eq!(awaiting.messages.len(), 2); + assert_eq!(&awaiting.messages[1], assistant); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(&requests[1][1], assistant); + } + + #[test] + fn multi_tool_call_batch_has_prefix_checkpoints_and_safe_resume_cursor() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let provider = MixedBatchProvider { + requests: Arc::new(Mutex::new(Vec::new())), + }; + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let output = AgentEngine::new(&provider, &EchoTool, &Allow) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new("batch-run", "fake", vec![Message::user("hello").unwrap()]) + .with_tools(vec![definition.clone()]), + ) + .unwrap(); + + let checkpoints = checkpoints.0.lock().unwrap(); + let phases = checkpoints + .iter() + .map(|checkpoint| checkpoint.phase) + .collect::>(); + assert_eq!( + phases, + vec![ + CheckpointPhase::ProviderInFlight, + CheckpointPhase::AwaitingApproval, + CheckpointPhase::ToolInFlight, + CheckpointPhase::AwaitingApproval, + CheckpointPhase::ToolInFlight, + CheckpointPhase::Safe, + CheckpointPhase::ProviderInFlight, + ] + ); + + let first_in_flight = &checkpoints[2]; + assert_eq!( + first_in_flight.tool_call_id.as_deref(), + Some("batch-call-1") + ); + assert_eq!(first_in_flight.step, 0); + assert_eq!(first_in_flight.next_step, 0); + assert!(first_in_flight.messages.iter().all(|message| { + !message.content().iter().any(|part| { + matches!(part, ContentPart::ToolResult { tool_call_id, .. } if tool_call_id == "batch-call-2") + }) + })); + + let second_in_flight = &checkpoints[4]; + assert_eq!( + second_in_flight.tool_call_id.as_deref(), + Some("batch-call-2") + ); + assert_eq!( + second_in_flight.messages.len(), + first_in_flight.messages.len() + 1 + ); + assert!(second_in_flight.messages.iter().any(|message| { + message.content().iter().any(|part| { + matches!(part, ContentPart::ToolResult { tool_call_id, .. } if tool_call_id == "batch-call-1") + }) + })); + + // 最小 durable 规则是:一个规范的 assistant 批次消息,随后每个 call + // 一条 tool result 消息;整个批次完成后才写 safe,游标只前进一个 + // Provider step。 + let safe = &checkpoints[5]; + assert_eq!(safe.phase, CheckpointPhase::Safe); + assert_eq!(safe.step, 0); + assert_eq!(safe.next_step, 1); + assert_eq!(safe.tool_call_id, None); + assert_eq!(safe.messages.len() + 1, output.messages.len()); + assert_eq!( + &output.messages[..safe.messages.len()], + safe.messages.as_slice() + ); + + // safe 游标足以在不重放第一批 Provider/工具调用的情况下继续;下一次 + // Provider 调用从绝对 step 1 开始。 + let resumed = AgentEngine::new(&provider, &EchoTool, &Allow).run( + AgentInput::new("batch-resume", "fake", safe.messages.clone()) + .with_tools(vec![definition]) + .with_start_step(safe.next_step) + .with_max_steps(1), + ); + let resumed = resumed.expect("safe cursor should resume"); + assert_eq!(resumed.steps, 1); + assert_eq!(resumed.text, "batch-done"); + } + + #[test] + fn intermediate_denial_advances_checkpoint_before_next_approval() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let executions = Arc::new(Mutex::new(0)); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let output = AgentEngine::new( + &MixedBatchProvider { + requests: Arc::new(Mutex::new(Vec::new())), + }, + &CountingTool(executions.clone()), + &DenyFirstApproval, + ) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new( + "deny-batch-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect("the second call can continue after the first is denied"); + + // 拒绝的 call 不执行;只有第二条被允许的 call 触发一次工具副作用。 + assert_eq!(*executions.lock().unwrap(), 1); + assert_eq!(output.text, "batch-done"); + + let checkpoints = checkpoints.0.lock().unwrap(); + let transition_index = checkpoints + .windows(2) + .position(|window| { + window[0].phase == CheckpointPhase::AwaitingApproval + && window[0].tool_call_id.as_deref() == Some("batch-call-1") + && window[1].phase == CheckpointPhase::AwaitingApproval + && window[1].tool_call_id.as_deref() == Some("batch-call-2") + && window[1].messages.iter().any(|message| { + message.content().iter().any(|part| { + matches!( + part, + ContentPart::ToolResult { tool_call_id, is_error, .. } + if tool_call_id == "batch-call-1" && *is_error + ) + }) + }) + }) + .expect("denial must durably advance the checkpoint to the next call"); + + // 从这个边界开始,旧的已拒绝 call 不应再次成为 awaiting identity。 + let advanced = &checkpoints[transition_index + 1]; + assert_eq!(advanced.tool_call_id.as_deref(), Some("batch-call-2")); + assert!( + !checkpoints[transition_index + 1..] + .iter() + .any(|checkpoint| { + checkpoint.phase == CheckpointPhase::AwaitingApproval + && checkpoint.tool_call_id.as_deref() == Some("batch-call-1") + }) + ); + assert!(checkpoints.iter().any(|checkpoint| { + checkpoint.phase == CheckpointPhase::Safe && checkpoint.tool_call_id.is_none() + })); + } + + #[test] + fn mismatched_tool_result_is_rejected_before_completion_event() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let events = Collect(Mutex::new(Vec::new())); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new(&EchoProvider, &WrongIdTool, &Allow) + .with_listener(&events) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new( + "mismatch-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect_err("wrong result identity must stop the loop"); + assert!(matches!( + error, + EngineError::Tool(message) if message.contains("call id 不匹配") + )); + assert!( + !events + .0 + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, EngineEvent::ToolCompleted { .. })) + ); + let checkpoints = checkpoints.0.lock().unwrap(); + assert_eq!( + checkpoints.last().map(|checkpoint| checkpoint.phase), + Some(CheckpointPhase::ToolInFlight) + ); + assert_eq!( + checkpoints + .last() + .and_then(|checkpoint| checkpoint.tool_call_id.as_deref()), + Some("echo-call-1") + ); + } + + #[test] + fn malformed_tool_result_is_rejected_before_completion_event_or_history() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let events = Collect(Mutex::new(Vec::new())); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new(&EchoProvider, &MalformedToolResultTool, &Allow) + .with_listener(&events) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new( + "malformed-tool-result", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect_err("unchecked ToolResult must fail closed"); + + assert!(matches!(error, EngineError::Tool(message) if + message.contains("工具结果无效") && message.contains("tool result summary"))); + let events = events.0.lock().unwrap(); + assert!(events.iter().any(|event| { + matches!(event, EngineEvent::ToolRequested { call, .. } if call.id() == "echo-call-1") + })); + assert!(!events.iter().any(|event| { + matches!( + event, + EngineEvent::ToolCompleted { .. } | EngineEvent::Finished { .. } + ) + })); + let checkpoints = checkpoints.0.lock().unwrap(); + assert_eq!( + checkpoints.last().map(|checkpoint| checkpoint.phase), + Some(CheckpointPhase::ToolInFlight) + ); + assert!( + !checkpoints + .iter() + .any(|checkpoint| checkpoint.phase == CheckpointPhase::Safe) + ); + } + + #[test] + fn mismatched_provider_response_identity_is_rejected_before_event_or_history() { + let events = Collect(Mutex::new(Vec::new())); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new(&MismatchedProvider, &EchoTool, &Allow) + .with_listener(&events) + .with_checkpoint_listener(&checkpoints) + .run(AgentInput::new( + "request-1", + "fake", + vec![Message::user("hello").unwrap()], + )) + .expect_err("错配 Provider 响应必须停止执行"); + assert!(matches!( + error, + EngineError::ProviderResponseMismatch { .. } + )); + assert!( + events + .0 + .lock() + .unwrap() + .iter() + .all(|event| matches!(event, EngineEvent::StepStarted { .. })) + ); + assert_eq!(checkpoints.0.lock().unwrap().len(), 1); + } + + #[test] + fn malformed_provider_response_is_rejected_before_events_or_tool_side_effects() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + + for kind in [ + MalformedResponseKind::EmptyText, + MalformedResponseKind::NonObjectToolArguments, + MalformedResponseKind::StructuredToolContent, + MalformedResponseKind::StructuredToolCallContent, + ] { + let executions = Arc::new(Mutex::new(0)); + let events = Collect(Mutex::new(Vec::new())); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new( + &MalformedResponseProvider { kind }, + &CountingTool(executions.clone()), + &Allow, + ) + .with_listener(&events) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new( + "malformed-provider-response", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition.clone()]), + ) + .expect_err("unchecked Provider response must fail closed"); + + assert!(matches!(error, EngineError::InvalidInput(message) if + message.contains("Provider 响应") && + ((matches!(kind, MalformedResponseKind::EmptyText) + && message.contains("content 无效") + && message.contains("message text")) + || (matches!(kind, MalformedResponseKind::NonObjectToolArguments) + && message.contains("tool call 无效") + && message.contains("tool call arguments")) + || (matches!(kind, MalformedResponseKind::StructuredToolContent) + && message.contains("content 不得包含结构化")) + || (matches!(kind, MalformedResponseKind::StructuredToolCallContent) + && message.contains("content 不得包含结构化"))))); + assert_eq!(*executions.lock().unwrap(), 0); + assert!(events.0.lock().unwrap().iter().all(|event| { + !matches!( + event, + EngineEvent::ModelCompleted { .. } + | EngineEvent::ToolRequested { .. } + | EngineEvent::ToolCompleted { .. } + | EngineEvent::Finished { .. } + ) + })); + // The pre-call in-flight checkpoint is expected; no later + // checkpoint may claim that malformed output was accepted. + let checkpoints = checkpoints.0.lock().unwrap(); + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].phase, CheckpointPhase::ProviderInFlight); + } + } + + #[test] + fn duplicate_tool_call_ids_are_rejected_before_any_tool_side_effect() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let executions = Arc::new(Mutex::new(0)); + let events = Collect(Mutex::new(Vec::new())); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new( + &DuplicateToolCallProvider, + &CountingTool(executions.clone()), + &Allow, + ) + .with_listener(&events) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new( + "duplicate-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ) + .expect_err("duplicate provider call ids must fail before dispatch"); + + assert!(matches!( + error, + EngineError::InvalidInput(message) if message.contains("重复 tool call id") + )); + assert_eq!(*executions.lock().unwrap(), 0); + assert!( + !events + .0 + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, EngineEvent::ToolRequested { .. })) + ); + let checkpoints = checkpoints.0.lock().unwrap(); + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].phase, CheckpointPhase::ProviderInFlight); + } + + #[test] + fn checkpoint_listener_failure_stops_before_provider() { + struct Reject; + impl CheckpointListener for Reject { + fn on_checkpoint(&self, _checkpoint: &EngineCheckpoint) -> Result<(), String> { + Err("durable store unavailable".to_owned()) + } + } + + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let error = AgentEngine::new(&provider, &tool, &allow) + .with_checkpoint_listener(&Reject) + .run(AgentInput::new( + "run-1", + "fake", + vec![Message::user("hello").unwrap()], + )) + .unwrap_err(); + assert!( + matches!(error, EngineError::Checkpoint(message) if message.contains("unavailable")) + ); + } + + #[test] + fn final_provider_response_keeps_in_flight_checkpoint_until_host_commit() { + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + AgentEngine::new(&provider, &tool, &allow) + .with_checkpoint_listener(&checkpoints) + .run(AgentInput::new( + "run-1", + "fake", + vec![Message::user("hello").unwrap()], + )) + .unwrap(); + let checkpoints = checkpoints.0.lock().unwrap(); + assert_eq!(checkpoints.len(), 1); + assert_eq!(checkpoints[0].phase, CheckpointPhase::ProviderInFlight); + } + + #[test] + fn safe_cursor_starts_engine_at_absolute_step_without_changing_step_budget() { + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let listener = Collect(Mutex::new(Vec::new())); + let output = AgentEngine::new(&provider, &tool, &allow) + .with_listener(&listener) + .run( + AgentInput::new("run-1", "fake", vec![Message::user("hello").unwrap()]) + .with_start_step(4) + .with_max_steps(1), + ) + .unwrap(); + assert_eq!(output.steps, 1); + assert!( + listener + .0 + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, EngineEvent::StepStarted { step: 4 })) + ); + } + + #[test] + fn cancelled_run_stops_before_provider() { + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let engine = AgentEngine::new(&provider, &tool, &allow); + engine.cancellation().cancel(); + let error = engine + .run(AgentInput::new( + "run-1", + "fake", + vec![Message::user("hello").unwrap()], + )) + .unwrap_err(); + assert!(matches!(error, EngineError::Cancelled)); + } + + #[test] + fn injected_cancellation_is_observed_at_step_boundary() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let provider = EchoProvider; + let tool = EchoTool; + let allow = Allow; + let cancellation = Cancellation::new(); + let listener = CancelOnToolCompleted { + cancellation: cancellation.clone(), + events: Mutex::new(Vec::new()), + }; + let engine = AgentEngine::new(&provider, &tool, &allow) + .with_cancellation(cancellation.clone()) + .with_listener(&listener); + + let error = engine + .run( + AgentInput::new("run-1", "fake", vec![Message::user("hello").unwrap()]) + .with_tools(vec![definition]), + ) + .expect_err("cancellation should stop before the next provider step"); + + assert!(matches!(error, EngineError::Cancelled)); + assert!(cancellation.is_cancelled()); + let events = listener.events.lock().unwrap(); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, EngineEvent::ModelCompleted { .. })) + .count(), + 1, + "the shared token must prevent a second provider call" + ); + assert!( + events + .iter() + .any(|event| matches!(event, EngineEvent::ToolCompleted { .. })) + ); + assert!( + !events + .iter() + .any(|event| matches!(event, EngineEvent::Finished { .. })) + ); + } + + #[test] + fn tool_can_observe_cooperative_cancellation_during_execution() { + let definition = + ToolDefinition::try_new("cooperative", "可观察取消的工具", json!({"type": "object"})) + .unwrap(); + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1); + let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); + let observed = Arc::new(AtomicBool::new(false)); + let cancellation = Cancellation::new(); + let worker_cancellation = cancellation.clone(); + let worker_observed = observed.clone(); + let worker = thread::spawn(move || { + let provider = EchoProvider; + let tool = CooperativeCancellationTool { + started: started_tx, + observed: worker_observed, + }; + let allow = Allow; + let result = AgentEngine::new(&provider, &tool, &allow) + .with_cancellation(worker_cancellation) + .run( + AgentInput::new( + "cooperative-cancel-run", + "fake", + vec![Message::user("hello").unwrap()], + ) + .with_tools(vec![definition]), + ); + result_tx.send(result).expect("engine result receiver"); + }); + + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("tool should start before cancellation"); + cancellation.cancel(); + let result = result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("cooperative tool should stop within the bounded wait"); + assert!(matches!(result, Err(EngineError::Cancelled))); + assert!( + observed.load(Ordering::Acquire), + "tool must observe the shared cancellation view" + ); + worker.join().expect("engine worker should finish"); + } + + #[test] + fn identity_reaches_context_source_and_tool_executor() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let provider = EchoProvider; + let tool_identity = Arc::new(Mutex::new((None, None))); + let tool = IdentityTool(tool_identity.clone()); + let allow = Allow; + let context_identity = Arc::new(Mutex::new(Vec::new())); + let context = ContextProbe(context_identity.clone()); + let engine = + AgentEngine::new(&provider, &tool, &allow).with_context_source(Arc::new(context)); + engine + .run( + AgentInput::new("run-1", "fake", vec![Message::user("hello").unwrap()]) + .with_session_id("session-1") + .with_run_id("run-1") + .with_tools(vec![definition]), + ) + .unwrap(); + assert_eq!( + context_identity.lock().unwrap().first().cloned(), + Some((Some("session-1".to_owned()), Some("run-1".to_owned()))) + ); + assert_eq!( + *tool_identity.lock().unwrap(), + (Some("session-1".to_owned()), Some("run-1".to_owned())) + ); + } + + #[test] + fn context_assembly_is_priority_ordered_and_stable_for_ties() { + let assembler = ContextAssembler::new() + .add_source(Arc::new(FixedContextSource(vec![ + ("first-low".to_owned(), "low".to_owned(), 1), + ("first-high".to_owned(), "high".to_owned(), 10), + ]))) + .add_source(Arc::new(FixedContextSource(vec![( + "second-high".to_owned(), + "high2".to_owned(), + 10, + )]))); + + let result = assembler + .assemble_detailed( + &[Message::user("base").unwrap()], + Some("session-1"), + Some("run-1"), + 1_000, + ) + .unwrap(); + let ids = result + .selected() + .iter() + .map(|item| item.source_id()) + .collect::>(); + assert_eq!(ids, vec!["first-high", "second-high", "first-low"]); + assert_eq!(result.messages()[0].content()[0].as_text(), Some("base")); + assert_eq!(result.messages()[1].role(), MessageRole::User); + assert_eq!(result.estimated_size(), 4 + 4 + 5 + 3); + assert!(!result.overflowed()); + assert!(!result.truncated()); + } + + #[test] + fn untrusted_instruction_context_is_downgraded_without_mutating_items() { + let source = RoleContextSource(vec![ + ( + "untrusted-system".to_owned(), + Message::system("external system").unwrap(), + 30, + false, + ), + ( + "untrusted-developer".to_owned(), + Message::developer("external developer").unwrap(), + 20, + false, + ), + ( + "untrusted-assistant".to_owned(), + Message::assistant("external assistant").unwrap(), + 15, + false, + ), + ( + "trusted-system".to_owned(), + Message::system("trusted system").unwrap(), + 10, + true, + ), + ( + "untrusted-tool-history".to_owned(), + Message::new( + MessageRole::Assistant, + vec![ + ContentPart::tool_call("fake-call", "lookup", json!({"value": "external"})) + .unwrap(), + ], + ), + 5, + false, + ), + ]); + let result = ContextAssembler::new() + .add_source(Arc::new(source)) + .assemble_detailed(&[Message::user("base").unwrap()], None, None, 1_000) + .unwrap(); + + assert_eq!( + result + .messages() + .iter() + .map(Message::role) + .collect::>(), + vec![ + MessageRole::User, + MessageRole::User, + MessageRole::User, + MessageRole::User, + MessageRole::System, + MessageRole::User, + ] + ); + assert_eq!(result.selected().len(), 5); + assert_eq!(result.selected()[0].message().role(), MessageRole::System); + assert_eq!( + result.selected()[1].message().role(), + MessageRole::Developer + ); + assert_eq!( + result.selected()[2].message().role(), + MessageRole::Assistant + ); + assert_eq!(result.selected()[3].message().role(), MessageRole::System); + assert_eq!( + result.selected()[4].message().role(), + MessageRole::Assistant + ); + assert_eq!( + result + .selected() + .iter() + .filter(|item| item.trusted()) + .count(), + 1 + ); + assert_eq!( + result + .selected() + .iter() + .filter(|item| !item.trusted()) + .count(), + 4 + ); + assert!( + result.messages()[5] + .content() + .iter() + .all(|part| part.as_text().is_some()) + ); + assert!(result.messages()[5].content().iter().all(|part| !matches!( + part, + ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } + ))); + assert!(result.skipped().is_empty()); + } + + #[test] + fn context_assembly_reports_skips_and_preserves_base_on_overflow() { + let assembler = ContextAssembler::new().add_source(Arc::new(FixedContextSource(vec![ + ("too-large".to_owned(), "0123456789".to_owned(), 100), + ("fits".to_owned(), "ok".to_owned(), 1), + ]))); + let result = assembler + .assemble_detailed(&[Message::user("base").unwrap()], None, None, 6) + .unwrap(); + assert_eq!(result.selected().len(), 1); + assert_eq!(result.selected()[0].source_id(), "fits"); + assert_eq!(result.skipped()[0].source_id(), "too-large"); + assert!(result.truncated()); + assert_eq!(result.remaining_budget(), 0); + + let base_overflow = assembler + .assemble_detailed(&[Message::user("0123456789").unwrap()], None, None, 4) + .unwrap(); + assert!(base_overflow.overflowed()); + assert_eq!(base_overflow.messages().len(), 1); + assert!(base_overflow.selected().is_empty()); + } + + #[test] + fn context_observation_reports_trust_for_selected_and_skipped_items() { + let source = TrustedContextSource(vec![ + ("trusted-fit".to_owned(), "t".to_owned(), 20, true), + ("untrusted-fit".to_owned(), "u".to_owned(), 10, false), + ("trusted-skip".to_owned(), "x".repeat(300), 0, true), + ("untrusted-skip".to_owned(), "y".repeat(300), -1, false), + ]); + let output = AgentEngine::new(&EchoProvider, &EchoTool, &Allow) + .with_context_source(Arc::new(source)) + .run( + AgentInput::new( + "trust-observation", + "fake", + vec![Message::user("base").unwrap()], + ) + .with_context_budget(256), + ) + .expect("text-only run should complete"); + + let observation = output + .context_observations + .first() + .expect("one provider step should emit one observation"); + assert_eq!(observation.selected_items, 2); + assert_eq!(observation.selected_trusted_items, 1); + assert_eq!(observation.selected_untrusted_items, 1); + assert_eq!(observation.skipped_items, 2); + assert_eq!(observation.skipped_trusted_items, 1); + assert_eq!(observation.skipped_untrusted_items, 1); + assert_eq!( + observation.selected_trusted_items + observation.selected_untrusted_items, + observation.selected_items + ); + assert_eq!( + observation.skipped_trusted_items + observation.skipped_untrusted_items, + observation.skipped_items + ); + } + + #[test] + fn context_observation_accepts_legacy_payload_without_trust_counts() { + // The observation is persisted by hosts, so newly added counters must + // not make an older JSONL/event payload impossible to read. + let legacy = json!({ + "step": 2, + "budget": 128, + "baseEstimatedSize": 4, + "estimatedSize": 12, + "selectedItems": 1, + "skippedItems": 0, + "overflowed": false, + "compressionAttempted": false, + "compressionInputSize": null, + "compressionOutputSize": null + }); + let observation: ContextObservation = + serde_json::from_value(legacy).expect("legacy observation remains readable"); + assert_eq!(observation.selected_trusted_items, 0); + assert_eq!(observation.selected_untrusted_items, 0); + assert_eq!(observation.skipped_trusted_items, 0); + assert_eq!(observation.skipped_untrusted_items, 0); + } + + #[test] + fn context_assembly_rejects_zero_budget_and_accepts_prompt_builder() { + let assembler = ContextAssembler::new(); + assert!(matches!( + assembler.assemble_detailed(&[Message::user("base").unwrap()], None, None, 0), + Err(EngineError::Context(message)) if message.contains("大于 0") + )); + + let prompt = PromptBuilder::new() + .system("policy") + .unwrap() + .context("facts") + .unwrap() + .user("question") + .unwrap(); + let result = assembler.assemble_prompt(&prompt, None, None, 100).unwrap(); + assert_eq!( + result + .messages() + .iter() + .map(Message::role) + .collect::>(), + vec![MessageRole::System, MessageRole::User, MessageRole::User] + ); + + let input = AgentInput::try_from_prompt("prompt-run", "fake", &prompt).unwrap(); + assert_eq!(input.messages.len(), 3); + let replaced = AgentInput::new("replace-run", "fake", vec![Message::user("old").unwrap()]) + .with_prompt(&prompt) + .unwrap(); + assert_eq!(replaced.messages, input.messages); + } + + #[test] + fn context_budget_is_utf8_byte_based_and_does_not_split_messages() { + let assembler = ContextAssembler::new().add_source(Arc::new(FixedContextSource(vec![( + "zh".to_owned(), + "你好".to_owned(), + 1, + )]))); + // "你好" 占 6 个 UTF-8 字节;预算不足时整条候选被跳过,不截半条消息。 + let skipped = assembler + .assemble_detailed(&[Message::user("x").unwrap()], None, None, 6) + .unwrap(); + assert!(skipped.selected().is_empty()); + assert_eq!(skipped.skipped().len(), 1); + + let selected = assembler + .assemble_detailed(&[Message::user("x").unwrap()], None, None, 7) + .unwrap(); + assert_eq!(selected.selected().len(), 1); + assert_eq!(selected.estimated_size(), 7); + } + + #[test] + fn approval_uses_run_identity_and_unique_call_request_id() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let provider = EchoProvider; + let tool = EchoTool; + let approvals = Arc::new(Mutex::new(Vec::new())); + let approval = ApprovalProbe(approvals.clone()); + AgentEngine::new(&provider, &tool, &approval) + .run( + AgentInput::new("request-1", "fake", vec![Message::user("hello").unwrap()]) + .with_run_id("run-1") + .with_tools(vec![definition]), + ) + .unwrap(); + let records = approvals.lock().unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].1, "run-1"); + assert_eq!(records[0].0, "request-1-approval-0-0"); + } + + #[test] + fn approval_ask_stops_without_tool_execution_or_fake_denial() { + let definition = + ToolDefinition::try_new("echo", "echo input", json!({"type": "object"})).unwrap(); + let executions = Arc::new(Mutex::new(0)); + let events = Collect(Mutex::new(Vec::new())); + let checkpoints = CollectCheckpoints(Mutex::new(Vec::new())); + let error = AgentEngine::new( + &EchoProvider, + &CountingTool(executions.clone()), + &AskApproval, + ) + .with_listener(&events) + .with_checkpoint_listener(&checkpoints) + .run( + AgentInput::new("ask-run", "fake", vec![Message::user("hello").unwrap()]) + .with_tools(vec![definition]), + ) + .expect_err("Ask must wait for an explicit host decision"); + + assert!(matches!( + error, + EngineError::ApprovalRequired { + request_id, call_id, .. + } + if request_id == "ask-run-approval-0-0" && call_id == "echo-call-1" + )); + assert_eq!(*executions.lock().unwrap(), 0); + let events = events.0.lock().unwrap(); + assert!( + !events + .iter() + .any(|event| matches!(event, EngineEvent::ApprovalDenied { .. })) + ); + assert!( + !events + .iter() + .any(|event| matches!(event, EngineEvent::ToolCompleted { .. })) + ); + let checkpoints = checkpoints.0.lock().unwrap(); + let pending = checkpoints.last().expect("approval checkpoint"); + assert_eq!(pending.phase, CheckpointPhase::AwaitingApproval); + assert_eq!(pending.tool_call_id.as_deref(), Some("echo-call-1")); + } +} diff --git a/rust/crates/agent-runtime-orchestration/Cargo.toml b/rust/crates/agent-runtime-orchestration/Cargo.toml new file mode 100644 index 000000000..139d34c3a --- /dev/null +++ b/rust/crates/agent-runtime-orchestration/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-runtime-orchestration" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "通用 Agent 的 DAG 编排与委派基础" + +[dependencies] +agent-runtime-core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/rust/crates/agent-runtime-orchestration/src/lib.rs b/rust/crates/agent-runtime-orchestration/src/lib.rs new file mode 100644 index 000000000..876033d34 --- /dev/null +++ b/rust/crates/agent-runtime-orchestration/src/lib.rs @@ -0,0 +1,4340 @@ +//! 单 Agent Core/Engine 之上的最小 DAG 扩展。 +//! +//! 本 crate 做图的校验、ready/wave 计算、受限提案,以及 Coordinator 控制面 +//! (并发配额、消息去重和节点隔离);不拥有 Provider、线程、Runtime run 或业务 +//! 完成真相。它只定义可替换的快照持久化端口和轻量 JSON/内存实现,Host 可以把 +//! 每个 Delegation 映射成独立 Runtime run,用 Join 汇总结果,并在新的 epoch 中 +//! 安装经过校验的 GraphProposal。 + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use agent_runtime_core::AgentCatalog; +use serde::{Deserialize, Deserializer, Serialize}; +use thiserror::Error; + +const MAX_ID_CHARS: usize = 128; +/// 默认候选图允许的最大任务数。 +pub const DEFAULT_GRAPH_MAX_TASKS: usize = 128; +/// 默认候选图允许的最大依赖边数。 +pub const DEFAULT_GRAPH_MAX_EDGES: usize = 512; +/// 默认候选图允许的最大层深(根任务深度为 1)。 +pub const DEFAULT_GRAPH_MAX_DEPTH: usize = 32; +/// 默认单个前置任务允许的最大直接下游数。 +pub const DEFAULT_GRAPH_MAX_OUT_DEGREE: usize = 32; +/// Coordinator 快照的稳定版本标识。 +/// +/// 快照只覆盖内存协调控制面;真正的 Runtime/TaskGraph 持久化仍由宿主负责。 +pub const COORDINATOR_SNAPSHOT_SCHEMA_VERSION: &str = "agent-runtime-coordinator.v1"; +/// TaskGraph + Coordinator 的 durable 快照版本标识。 +pub const ORCHESTRATION_SNAPSHOT_SCHEMA_VERSION: &str = "agent-runtime-orchestration.v1"; + +/// Coordinator 快照的最大持久化大小。 +/// +/// 控制面状态不应成为无界日志;文件适配器在读取前先拒绝超出上限的输入, +/// 具体任务结果仍由各个 Runtime 自己保存。 +pub const MAX_COORDINATOR_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; + +static NEXT_SNAPSHOT_TEMP_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum OrchestrationError { + #[error("无效输入: {0}")] + InvalidInput(String), + #[error("任务重复: {0}")] + DuplicateTask(String), + #[error("依赖无效: {0}")] + InvalidDependency(String), + #[error("图包含环")] + Cycle, + #[error("任务依赖未满足: {0}")] + DependencyBlocked(String), + #[error("未知任务: {0}")] + UnknownTask(String), + #[error("提案违反限制: {0}")] + Limit(String), + #[error("提案为空")] + EmptyProposal, + #[error("并发配额不足: scope={scope}, limit={limit}, active={active}, requested={requested}")] + QuotaExceeded { + scope: String, + limit: usize, + active: usize, + requested: usize, + }, + #[error("运行重复: {0}")] + DuplicateRun(String), + #[error("未知运行: {0}")] + UnknownRun(String), + #[error("节点已隔离: {0}")] + NodeIsolated(String), + #[error("消息 ID 冲突: {0}")] + MessageConflict(String), + #[error("编排快照 revision 冲突: expected={expected:?}, actual={actual:?}")] + RevisionConflict { + expected: Option, + actual: Option, + }, +} + +fn validate_id(value: &str, label: &str) -> Result<(), OrchestrationError> { + if value.trim().is_empty() || value.chars().count() > MAX_ID_CHARS { + return Err(OrchestrationError::InvalidInput(format!( + "{label} 必须是非空且不超过 {MAX_ID_CHARS} 字符" + ))); + } + if value.chars().any(char::is_control) { + return Err(OrchestrationError::InvalidInput(format!( + "{label} 不能包含控制字符" + ))); + } + Ok(()) +} + +fn validate_isolation_reason(reason: &str) -> Result<(), OrchestrationError> { + if reason.trim().is_empty() { + return Err(OrchestrationError::InvalidInput( + "节点隔离原因不能为空".to_owned(), + )); + } + if reason.chars().count() > MAX_MESSAGE_CHARS { + return Err(OrchestrationError::InvalidInput(format!( + "节点隔离原因不能超过 {MAX_MESSAGE_CHARS} 字符" + ))); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TaskStatus { + Pending, + Running, + Waiting, + Completed, + Failed, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TaskNode { + pub id: String, + pub agent_id: String, + #[serde(default = "default_task_status")] + pub status: TaskStatus, + #[serde(default)] + pub dependencies: Vec, +} + +fn default_task_status() -> TaskStatus { + TaskStatus::Pending +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TaskNodeInput { + id: String, + agent_id: String, + #[serde(default = "default_task_status")] + status: TaskStatus, + #[serde(default)] + dependencies: Vec, +} + +impl<'de> Deserialize<'de> for TaskNode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = TaskNodeInput::deserialize(deserializer)?; + let node = Self { + id: input.id, + agent_id: input.agent_id, + status: input.status, + dependencies: input.dependencies, + }; + node.validate().map_err(serde::de::Error::custom)?; + Ok(node) + } +} + +impl TaskNode { + pub fn try_new( + id: impl Into, + agent_id: impl Into, + dependencies: impl IntoIterator>, + ) -> Result { + let node = Self { + id: id.into(), + agent_id: agent_id.into(), + status: TaskStatus::Pending, + dependencies: dependencies.into_iter().map(Into::into).collect(), + }; + node.validate()?; + Ok(node) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + validate_id(&self.id, "task id")?; + validate_id(&self.agent_id, "agent id")?; + let mut seen = BTreeSet::new(); + for dependency in &self.dependencies { + validate_id(dependency, "task dependency")?; + if dependency == &self.id { + return Err(OrchestrationError::InvalidDependency(format!( + "task {} 不能依赖自身", + self.id + ))); + } + if !seen.insert(dependency) { + return Err(OrchestrationError::InvalidDependency(format!( + "task {} 重复依赖 {dependency}", + self.id + ))); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TaskGraph { + pub goal: String, + pub tasks: Vec, + #[serde(skip)] + index: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TaskGraphInput { + goal: String, + tasks: Vec, +} + +impl<'de> Deserialize<'de> for TaskGraph { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = TaskGraphInput::deserialize(deserializer)?; + Self::try_new(input.goal, input.tasks).map_err(serde::de::Error::custom) + } +} + +impl TaskGraph { + pub fn try_new( + goal: impl Into, + tasks: impl IntoIterator, + ) -> Result { + let goal = goal.into(); + if goal.trim().is_empty() { + return Err(OrchestrationError::InvalidInput("goal 不能为空".to_owned())); + } + let tasks = tasks.into_iter().collect::>(); + if tasks.is_empty() { + return Err(OrchestrationError::InvalidInput( + "图至少需要一个任务".to_owned(), + )); + } + let mut index = BTreeMap::new(); + for (position, task) in tasks.iter().enumerate() { + task.validate()?; + if index.insert(task.id.clone(), position).is_some() { + return Err(OrchestrationError::DuplicateTask(task.id.clone())); + } + } + for task in &tasks { + for dependency in &task.dependencies { + if !index.contains_key(dependency) { + return Err(OrchestrationError::InvalidDependency(format!( + "{} 引用了未知任务 {dependency}", + task.id + ))); + } + } + } + let graph = Self { goal, tasks, index }; + graph.validate_acyclic()?; + Ok(graph) + } + + pub fn task(&self, id: &str) -> Option<&TaskNode> { + self.index + .get(id) + .and_then(|position| self.tasks.get(*position)) + } + + /// 返回当前图中的任务数量。 + pub fn task_count(&self) -> usize { + self.tasks.len() + } + + /// `node_count` 是面向图调用方的同义入口。 + pub fn node_count(&self) -> usize { + self.task_count() + } + + /// 返回当前图中的依赖边数量。 + pub fn edge_count(&self) -> usize { + self.tasks.iter().map(|task| task.dependencies.len()).sum() + } + + /// 返回图的最长依赖层数;无依赖的根任务深度为 1。 + pub fn depth(&self) -> usize { + if self.tasks.is_empty() { + return 0; + } + + let mut indegree = self + .tasks + .iter() + .map(|task| (task.id.clone(), task.dependencies.len())) + .collect::>(); + let mut layers = self + .tasks + .iter() + .map(|task| (task.id.clone(), 1usize)) + .collect::>(); + let mut queue = VecDeque::from_iter( + indegree + .iter() + .filter(|(_, degree)| **degree == 0) + .map(|(id, _)| id.clone()), + ); + let mut max_depth = 1; + + while let Some(id) = queue.pop_front() { + let current_depth = layers.get(&id).copied().unwrap_or(1); + max_depth = max_depth.max(current_depth); + for task in &self.tasks { + if !task.dependencies.iter().any(|dependency| dependency == &id) { + continue; + } + let next_depth = current_depth.saturating_add(1); + let layer = layers + .get_mut(&task.id) + .expect("task layers are initialized from the graph"); + *layer = (*layer).max(next_depth); + let degree = indegree + .get_mut(&task.id) + .expect("task indegree is initialized from the graph"); + *degree -= 1; + if *degree == 0 { + queue.push_back(task.id.clone()); + } + } + } + max_depth + } + + /// 返回一个任务的直接下游数量。 + pub fn fan_out(&self, task_id: &str) -> Option { + self.task(task_id)?; + Some( + self.tasks + .iter() + .filter(|task| task.dependencies.iter().any(|id| id == task_id)) + .count(), + ) + } + + /// `out_degree` 是 `fan_out` 的图术语别名。 + pub fn out_degree(&self, task_id: &str) -> Option { + self.fan_out(task_id) + } + + /// 校验图中每个任务都绑定到已注册的 Agent。 + pub fn validate_agents(&self, catalog: &AgentCatalog) -> Result<(), OrchestrationError> { + for task in &self.tasks { + if catalog.get(&task.agent_id).is_none() { + return Err(OrchestrationError::InvalidInput(format!( + "task {} 引用了未注册 Agent:{}", + task.id, task.agent_id + ))); + } + } + Ok(()) + } + + /// 按给定目录和资源限制,以不可变方式应用一个增量图提案。 + /// + /// 结构化的 [`GraphProposal::apply`] 仍保留为兼容入口;需要接收不可信 + /// planner 输入的宿主应使用本方法,确保完整候选图(而非仅 delta)通过 + /// 任务数、边数、层深、扇出和 Agent 目录校验。 + pub fn apply_proposal( + &self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + self.expand_with_proposal(proposal, catalog, limits) + .map(GraphExpansion::into_graph) + } + + /// 返回完整候选图及本次提案的已校验增量。 + pub fn expand_with_proposal( + &self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + limits.validate()?; + proposal.validate()?; + self.validate_agents(catalog)?; + + let resulting_task_count = self + .task_count() + .checked_add(proposal.nodes.len()) + .ok_or_else(|| OrchestrationError::Limit("proposal task 数量计算溢出".to_owned()))?; + if self.task_count() > limits.max_tasks { + return Err(OrchestrationError::Limit(format!( + "现有 task 数量 {} 已超过 maxTasks {}", + self.task_count(), + limits.max_tasks + ))); + } + if resulting_task_count > limits.max_tasks { + return Err(OrchestrationError::Limit(format!( + "扩图后 task 数量 {} 超过 maxTasks {}", + resulting_task_count, limits.max_tasks + ))); + } + + for node in &proposal.nodes { + if self.task(&node.id).is_some() { + return Err(OrchestrationError::DuplicateTask(node.id.clone())); + } + if catalog.get(&node.agent_id).is_none() { + return Err(OrchestrationError::InvalidInput(format!( + "proposal task {} 引用了未注册 Agent:{}", + node.id, node.agent_id + ))); + } + } + + let resulting_edge_count = self + .edge_count() + .checked_add(proposal.edges.len()) + .ok_or_else(|| OrchestrationError::Limit("proposal edge 数量计算溢出".to_owned()))?; + if resulting_edge_count > limits.max_edges { + return Err(OrchestrationError::Limit(format!( + "扩图后 dependency edge 数量 {} 超过 maxEdges {}", + resulting_edge_count, limits.max_edges + ))); + } + + let candidate = proposal.apply(self)?; + if candidate.depth() > limits.max_depth { + return Err(OrchestrationError::Limit(format!( + "扩图后 graph depth {} 超过 maxDepth {}", + candidate.depth(), + limits.max_depth + ))); + } + if let Some((task_id, degree)) = candidate + .tasks + .iter() + .filter_map(|task| candidate.fan_out(&task.id).map(|degree| (&task.id, degree))) + .find(|(_, degree)| *degree > limits.max_out_degree) + { + return Err(OrchestrationError::Limit(format!( + "task {task_id} 的 fan-out {degree} 超过 maxOutDegree {}", + limits.max_out_degree + ))); + } + Ok(GraphExpansion { + graph: candidate, + added_task_ids: proposal.nodes.iter().map(|node| node.id.clone()).collect(), + added_edges: proposal.edges.clone(), + }) + } + + /// 参数顺序与部分宿主配置代码兼容的别名。 + pub fn apply_proposal_with_limits( + &self, + proposal: &GraphProposal, + limits: &GraphLimits, + catalog: &AgentCatalog, + ) -> Result { + self.apply_proposal(proposal, catalog, limits) + } + + /// `expand` 是面向宿主的简短别名。 + pub fn expand( + &self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + self.apply_proposal(proposal, catalog, limits) + } + + /// 更新一个任务的状态。 + /// + /// 只有启动任务需要检查依赖;终态和取消状态由宿主根据自己的运行结果 + /// 写入。这样 orchestration 不会复制单 Agent reducer 的完整状态机。 + pub fn set_task_status( + &mut self, + id: impl AsRef, + status: TaskStatus, + ) -> Result<(), OrchestrationError> { + let id = id.as_ref(); + let position = *self + .index + .get(id) + .ok_or_else(|| OrchestrationError::UnknownTask(id.to_owned()))?; + if status == TaskStatus::Running { + let task = self.tasks.get(position).expect("任务索引由 try_new 建立"); + if let Some(dependency) = task.dependencies.iter().find(|dependency| { + self.task(dependency.as_str()) + .is_none_or(|task| task.status != TaskStatus::Completed) + }) { + return Err(OrchestrationError::DependencyBlocked(format!( + "{id} 依赖 {dependency} 尚未完成" + ))); + } + } + self.tasks[position].status = status; + Ok(()) + } + + /// 以不可变视角创建状态更新后的新 epoch,便于调用方在并发协调时避免 + /// 共享可变图;原图不会被修改。 + pub fn with_task_status( + &self, + id: impl AsRef, + status: TaskStatus, + ) -> Result { + let mut next = self.clone(); + next.set_task_status(id, status)?; + Ok(next) + } + + /// 将种子任务及其全部下游任务重置为 Pending,返回稳定顺序的受影响任务。 + /// + /// 这是显式修复入口,不会自动重跑任何 Runtime run;宿主仍需按返回值重新 + /// 创建或排队对应运行。 + pub fn repair_downstream( + &mut self, + seeds: &[impl AsRef], + ) -> Result, OrchestrationError> { + let impacted = self.expand_downstream(seeds)?; + for id in &impacted { + let position = self + .index + .get(id) + .copied() + .expect("expand_downstream 已验证任务存在"); + self.tasks[position].status = TaskStatus::Pending; + } + Ok(impacted) + } + + /// 不修改原图地执行一次下游修复。 + pub fn repaired_downstream( + &self, + seeds: &[impl AsRef], + ) -> Result<(Self, Vec), OrchestrationError> { + let mut next = self.clone(); + let impacted = next.repair_downstream(seeds)?; + Ok((next, impacted)) + } + + pub fn ready_task_ids(&self) -> Vec<&str> { + let completed = self + .tasks + .iter() + .filter(|task| task.status == TaskStatus::Completed) + .map(|task| task.id.as_str()) + .collect::>(); + self.tasks + .iter() + .filter(|task| { + task.status == TaskStatus::Pending + && task + .dependencies + .iter() + .all(|dependency| completed.contains(dependency.as_str())) + }) + .map(|task| task.id.as_str()) + .collect() + } + + /// 根据当前图生成依赖波次;同一波次内的任务互不依赖,可由 Host 并发运行。 + pub fn dependency_waves(&self) -> Result>, OrchestrationError> { + let mut done = self + .tasks + .iter() + .filter(|task| task.status == TaskStatus::Completed) + .map(|task| task.id.clone()) + .collect::>(); + let mut pending = self + .tasks + .iter() + .filter(|task| task.status == TaskStatus::Pending) + .map(|task| task.id.clone()) + .collect::>(); + let mut waves = Vec::new(); + while !pending.is_empty() { + let wave = pending + .iter() + .filter(|id| { + self.task(id).is_some_and(|task| { + task.dependencies + .iter() + .all(|dependency| done.contains(dependency)) + }) + }) + .cloned() + .collect::>(); + if wave.is_empty() { + return Err(OrchestrationError::DependencyBlocked( + pending.into_iter().collect::>().join(", "), + )); + } + for id in &wave { + pending.remove(id); + done.insert(id.clone()); + } + waves.push(wave); + } + Ok(waves) + } + + pub fn expand_downstream( + &self, + seeds: &[impl AsRef], + ) -> Result, OrchestrationError> { + let mut impacted = BTreeSet::new(); + for seed in seeds { + let id = seed.as_ref(); + if self.task(id).is_none() { + return Err(OrchestrationError::UnknownTask(id.to_owned())); + } + impacted.insert(id.to_owned()); + } + let mut changed = true; + while changed { + changed = false; + for task in &self.tasks { + if !impacted.contains(&task.id) + && task + .dependencies + .iter() + .any(|dependency| impacted.contains(dependency)) + { + impacted.insert(task.id.clone()); + changed = true; + } + } + } + Ok(self + .tasks + .iter() + .filter(|task| impacted.contains(&task.id)) + .map(|task| task.id.clone()) + .collect()) + } + + fn validate_acyclic(&self) -> Result<(), OrchestrationError> { + let mut indegree = self + .tasks + .iter() + .map(|task| (task.id.clone(), task.dependencies.len())) + .collect::>(); + let mut queue = VecDeque::from_iter( + indegree + .iter() + .filter(|(_, degree)| **degree == 0) + .map(|(id, _)| id.clone()), + ); + let mut visited = 0; + while let Some(id) = queue.pop_front() { + visited += 1; + for task in &self.tasks { + if task.dependencies.iter().any(|dependency| dependency == &id) { + let degree = indegree.get_mut(&task.id).expect("task index validated"); + *degree -= 1; + if *degree == 0 { + queue.push_back(task.id.clone()); + } + } + } + } + if visited == self.tasks.len() { + Ok(()) + } else { + Err(OrchestrationError::Cycle) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Delegation { + pub id: String, + pub parent_run_id: String, + pub child_agent_id: String, + pub task: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct DelegationInput { + id: String, + parent_run_id: String, + child_agent_id: String, + task: String, +} + +impl<'de> Deserialize<'de> for Delegation { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = DelegationInput::deserialize(deserializer)?; + Self::try_new( + input.id, + input.parent_run_id, + input.child_agent_id, + input.task, + ) + .map_err(serde::de::Error::custom) + } +} + +impl Delegation { + pub fn try_new( + id: impl Into, + parent_run_id: impl Into, + child_agent_id: impl Into, + task: impl Into, + ) -> Result { + let value = Self { + id: id.into(), + parent_run_id: parent_run_id.into(), + child_agent_id: child_agent_id.into(), + task: task.into(), + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + validate_id(&self.id, "delegation id")?; + validate_id(&self.parent_run_id, "parent run id")?; + validate_id(&self.child_agent_id, "child agent id")?; + if self.task.trim().is_empty() { + return Err(OrchestrationError::InvalidInput( + "delegation task 不能为空".to_owned(), + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Join { + pub id: String, + pub delegation_ids: Vec, + pub strategy: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct JoinInput { + id: String, + delegation_ids: Vec, + strategy: String, +} + +impl<'de> Deserialize<'de> for Join { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = JoinInput::deserialize(deserializer)?; + Self::try_new(input.id, input.delegation_ids, input.strategy) + .map_err(serde::de::Error::custom) + } +} + +impl Join { + pub fn try_new( + id: impl Into, + delegation_ids: impl IntoIterator>, + strategy: impl Into, + ) -> Result { + let id = id.into(); + let strategy = strategy.into(); + validate_id(&id, "join id")?; + validate_id(&strategy, "join strategy")?; + let delegation_ids = delegation_ids + .into_iter() + .map(Into::into) + .collect::>(); + if delegation_ids.is_empty() { + return Err(OrchestrationError::InvalidInput( + "join 至少需要一个 delegation".to_owned(), + )); + } + let join = Self { + id, + delegation_ids, + strategy, + }; + join.validate()?; + Ok(join) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + validate_id(&self.id, "join id")?; + validate_id(&self.strategy, "join strategy")?; + if self.delegation_ids.is_empty() { + return Err(OrchestrationError::InvalidInput( + "join 至少需要一个 delegation".to_owned(), + )); + } + let mut seen = BTreeSet::new(); + for delegation_id in &self.delegation_ids { + validate_id(delegation_id, "join delegation id")?; + if !seen.insert(delegation_id) { + return Err(OrchestrationError::InvalidInput(format!( + "join 重复 delegation {delegation_id}" + ))); + } + } + Ok(()) + } +} + +/// 候选任务图的资源边界。 +/// +/// 限制始终针对提案应用后的完整候选图计算;根任务的层深为 1, +/// `max_out_degree` 统计一个前置任务的直接下游数量。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphLimits { + #[serde(alias = "maxNodes")] + pub max_tasks: usize, + pub max_edges: usize, + pub max_depth: usize, + #[serde(alias = "maxFanOut")] + pub max_out_degree: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GraphLimitsInput { + #[serde(alias = "maxNodes")] + max_tasks: usize, + max_edges: usize, + max_depth: usize, + #[serde(alias = "maxFanOut")] + max_out_degree: usize, +} + +impl<'de> Deserialize<'de> for GraphLimits { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = GraphLimitsInput::deserialize(deserializer)?; + Self::try_new( + input.max_tasks, + input.max_edges, + input.max_depth, + input.max_out_degree, + ) + .map_err(serde::de::Error::custom) + } +} + +impl Default for GraphLimits { + fn default() -> Self { + Self { + max_tasks: DEFAULT_GRAPH_MAX_TASKS, + max_edges: DEFAULT_GRAPH_MAX_EDGES, + max_depth: DEFAULT_GRAPH_MAX_DEPTH, + max_out_degree: DEFAULT_GRAPH_MAX_OUT_DEGREE, + } + } +} + +impl GraphLimits { + pub const fn new( + max_tasks: usize, + max_edges: usize, + max_depth: usize, + max_out_degree: usize, + ) -> Self { + Self { + max_tasks, + max_edges, + max_depth, + max_out_degree, + } + } + + pub fn try_new( + max_tasks: usize, + max_edges: usize, + max_depth: usize, + max_out_degree: usize, + ) -> Result { + let limits = Self::new(max_tasks, max_edges, max_depth, max_out_degree); + limits.validate().map(|()| limits) + } + + pub fn with_max_tasks(mut self, value: usize) -> Self { + self.max_tasks = value; + self + } + + pub fn with_max_nodes(self, value: usize) -> Self { + self.with_max_tasks(value) + } + + pub fn with_max_edges(mut self, value: usize) -> Self { + self.max_edges = value; + self + } + + pub fn with_max_depth(mut self, value: usize) -> Self { + self.max_depth = value; + self + } + + pub fn with_max_out_degree(mut self, value: usize) -> Self { + self.max_out_degree = value; + self + } + + pub fn with_max_fan_out(self, value: usize) -> Self { + self.with_max_out_degree(value) + } + + pub fn max_nodes(&self) -> usize { + self.max_tasks + } + + pub fn max_fan_out(&self) -> usize { + self.max_out_degree + } + + pub fn validate(&self) -> Result<(), OrchestrationError> { + let invalid = [ + (self.max_tasks, "maxTasks"), + (self.max_edges, "maxEdges"), + (self.max_depth, "maxDepth"), + (self.max_out_degree, "maxOutDegree"), + ] + .into_iter() + .find(|(value, _)| *value == 0); + if let Some((_, field)) = invalid { + return Err(OrchestrationError::Limit(format!( + "graph limits 的 {field} 必须大于 0" + ))); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphProposal { + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GraphProposalInput { + nodes: Vec, + edges: Vec, +} + +impl<'de> Deserialize<'de> for GraphProposal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = GraphProposalInput::deserialize(deserializer)?; + Self::try_new(input.nodes, input.edges).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TaskNodeProposal { + pub id: String, + pub agent_id: String, +} + +/// Planner-facing name for a proposed task. +/// +/// `TaskNodeProposal` remains the historical public name used by this crate; +/// the alias keeps both names source-compatible while the proposal contract is +/// shared with hosts that call the value `TaskProposal`. +pub type TaskProposal = TaskNodeProposal; + +impl TaskNodeProposal { + pub fn try_new( + id: impl Into, + agent_id: impl Into, + ) -> Result { + let value = Self { + id: id.into(), + agent_id: agent_id.into(), + }; + validate_id(&value.id, "proposal task id")?; + validate_id(&value.agent_id, "proposal task agent id")?; + Ok(value) + } + + pub fn new( + id: impl Into, + agent_id: impl Into, + ) -> Result { + Self::try_new(id, agent_id) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphEdge { + pub from: String, + pub to: String, +} + +impl GraphEdge { + pub fn try_new( + from: impl Into, + to: impl Into, + ) -> Result { + let value = Self { + from: from.into(), + to: to.into(), + }; + validate_id(&value.from, "proposal edge from")?; + validate_id(&value.to, "proposal edge to")?; + if value.from == value.to { + return Err(OrchestrationError::InvalidDependency( + "proposal 自环".to_owned(), + )); + } + Ok(value) + } + + pub fn new(from: impl Into, to: impl Into) -> Result { + Self::try_new(from, to) + } + + pub fn from(&self) -> &str { + &self.from + } + + pub fn to(&self) -> &str { + &self.to + } +} + +impl GraphProposal { + pub fn try_new( + nodes: impl IntoIterator, + edges: impl IntoIterator, + ) -> Result { + let proposal = Self { + nodes: nodes.into_iter().collect(), + edges: edges.into_iter().collect(), + }; + proposal.validate()?; + Ok(proposal) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + if self.nodes.is_empty() && self.edges.is_empty() { + return Err(OrchestrationError::EmptyProposal); + } + let mut ids = BTreeSet::new(); + for node in &self.nodes { + validate_id(&node.id, "proposal task id")?; + validate_id(&node.agent_id, "proposal agent id")?; + if !ids.insert(&node.id) { + return Err(OrchestrationError::DuplicateTask(node.id.clone())); + } + } + let mut edges = BTreeSet::new(); + for edge in &self.edges { + validate_id(&edge.from, "proposal edge from")?; + validate_id(&edge.to, "proposal edge to")?; + if edge.from == edge.to { + return Err(OrchestrationError::InvalidDependency( + "proposal 自环".to_owned(), + )); + } + if !edges.insert((&edge.from, &edge.to)) { + return Err(OrchestrationError::InvalidDependency(format!( + "proposal 重复边: {} -> {}", + edge.from, edge.to + ))); + } + } + Ok(()) + } + + /// 将新增节点/边应用到旧图;现有节点不会被原地改写,调用方可把结果视为新 epoch。 + pub fn apply(&self, base: &TaskGraph) -> Result { + self.validate()?; + let mut tasks = base.tasks.clone(); + let mut by_id = tasks + .iter() + .map(|task| (task.id.clone(), task.dependencies.clone())) + .collect::>(); + for node in &self.nodes { + if by_id.contains_key(&node.id) { + return Err(OrchestrationError::DuplicateTask(node.id.clone())); + } + tasks.push(TaskNode::try_new( + &node.id, + &node.agent_id, + std::iter::empty::(), + )?); + by_id.insert(node.id.clone(), Vec::new()); + } + for edge in &self.edges { + if !by_id.contains_key(&edge.from) || !by_id.contains_key(&edge.to) { + return Err(OrchestrationError::UnknownTask(format!( + "{} -> {}", + edge.from, edge.to + ))); + } + if base.task(&edge.to).is_some() { + return Err(OrchestrationError::InvalidDependency(format!( + "不能在新 epoch 外原地修改既有任务依赖: {} -> {}", + edge.from, edge.to + ))); + } + let dependencies = by_id.get_mut(&edge.to).expect("checked above"); + if !dependencies.contains(&edge.from) { + dependencies.push(edge.from.clone()); + } + } + for task in &mut tasks { + if let Some(dependencies) = by_id.get(&task.id) { + task.dependencies = dependencies.clone(); + } + } + TaskGraph::try_new(base.goal.clone(), tasks) + } + + /// 使用 Agent 目录和图资源限制应用提案。 + pub fn apply_with_limits( + &self, + base: &TaskGraph, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + base.apply_proposal(self, catalog, limits) + } + + /// 参数顺序与配置驱动宿主兼容的别名。 + pub fn apply_with_graph_limits( + &self, + base: &TaskGraph, + limits: &GraphLimits, + catalog: &AgentCatalog, + ) -> Result { + base.apply_proposal(self, catalog, limits) + } +} + +/// 经过完整校验的候选图及其新增节点/边摘要。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphExpansion { + graph: TaskGraph, + added_task_ids: Vec, + added_edges: Vec, +} + +/// `AppliedGraphProposal` 是给宿主持久化层使用的描述性别名。 +pub type AppliedGraphProposal = GraphExpansion; + +impl GraphExpansion { + pub fn graph(&self) -> &TaskGraph { + &self.graph + } + + pub fn into_graph(self) -> TaskGraph { + self.graph + } + + pub fn added_task_ids(&self) -> &[String] { + &self.added_task_ids + } + + pub fn added_edges(&self) -> &[GraphEdge] { + &self.added_edges + } +} + +const MAX_MESSAGE_CHARS: usize = 16 * 1024; + +/// 多运行协调器的有界并发配置。 +/// +/// 该配置只约束协调器持有的活动 run 数,不替代 Runtime reducer,也不决定 +/// worker 如何执行。真正的线程/进程调度仍由 Host 或上层执行器负责。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CoordinatorQuota { + max_active_runs: usize, + max_active_runs_per_agent: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CoordinatorQuotaInput { + max_active_runs: usize, + max_active_runs_per_agent: usize, +} + +impl<'de> Deserialize<'de> for CoordinatorQuota { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = CoordinatorQuotaInput::deserialize(deserializer)?; + Self::try_new(input.max_active_runs, input.max_active_runs_per_agent) + .map_err(serde::de::Error::custom) + } +} + +impl CoordinatorQuota { + pub fn try_new( + max_active_runs: usize, + max_active_runs_per_agent: usize, + ) -> Result { + if max_active_runs == 0 || max_active_runs_per_agent == 0 { + return Err(OrchestrationError::InvalidInput( + "coordinator 并发配额必须大于零".to_owned(), + )); + } + Ok(Self { + max_active_runs, + max_active_runs_per_agent, + }) + } + + pub fn max_active_runs(self) -> usize { + self.max_active_runs + } + + pub fn max_active_runs_per_agent(self) -> usize { + self.max_active_runs_per_agent + } +} + +impl Default for CoordinatorQuota { + fn default() -> Self { + Self { + max_active_runs: 16, + max_active_runs_per_agent: 4, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CoordinatorRunRequest { + pub run_id: String, + pub task_id: String, + pub agent_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CoordinatorRunRequestInput { + run_id: String, + task_id: String, + agent_id: String, +} + +impl<'de> Deserialize<'de> for CoordinatorRunRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = CoordinatorRunRequestInput::deserialize(deserializer)?; + Self::try_new(input.run_id, input.task_id, input.agent_id).map_err(serde::de::Error::custom) + } +} + +impl CoordinatorRunRequest { + pub fn try_new( + run_id: impl Into, + task_id: impl Into, + agent_id: impl Into, + ) -> Result { + let request = Self { + run_id: run_id.into(), + task_id: task_id.into(), + agent_id: agent_id.into(), + }; + request.validate()?; + Ok(request) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + validate_id(&self.run_id, "coordinator run id")?; + validate_id(&self.task_id, "coordinator task id")?; + validate_id(&self.agent_id, "coordinator agent id")?; + Ok(()) + } +} + +/// 一个由宿主消费的 ready-task 计划项。 +/// +/// 计划只携带任务与其声明的 Agent,不生成 Runtime run ID;run ID 仍由宿主 +/// 负责分配。这样 orchestration 不需要引入 UUID、Runtime 或业务命名规则。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReadyTaskCandidate { + pub task_id: String, + pub agent_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ReadyTaskCandidateInput { + task_id: String, + agent_id: String, +} + +impl<'de> Deserialize<'de> for ReadyTaskCandidate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = ReadyTaskCandidateInput::deserialize(deserializer)?; + Self::try_new(input.task_id, input.agent_id).map_err(serde::de::Error::custom) + } +} + +impl ReadyTaskCandidate { + pub fn try_new( + task_id: impl Into, + agent_id: impl Into, + ) -> Result { + let candidate = Self { + task_id: task_id.into(), + agent_id: agent_id.into(), + }; + candidate.validate()?; + Ok(candidate) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + validate_id(&self.task_id, "ready task id")?; + validate_id(&self.agent_id, "ready agent id")?; + Ok(()) + } +} + +/// 基于某个编排 revision 生成的 ready-task 派发计划。 +/// +/// 计划本身不占用任务,也不写 Store。宿主拿到计划后调用 +/// [`PersistentCoordinator::claim_ready_tasks`],该方法会再次检查 revision、 +/// 任务状态、Agent 归属、隔离和配额,并通过现有 `dispatch_wave` 一次性提交。 +/// `candidates` 可以为空,表示当前 revision 没有可派发任务;对空计划的 claim +/// 是无写入的幂等操作。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReadyTaskDispatchPlan { + pub expected_revision: u64, + pub candidates: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ReadyTaskDispatchPlanInput { + expected_revision: u64, + candidates: Vec, +} + +impl<'de> Deserialize<'de> for ReadyTaskDispatchPlan { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = ReadyTaskDispatchPlanInput::deserialize(deserializer)?; + Self::try_new(input.expected_revision, input.candidates).map_err(serde::de::Error::custom) + } +} + +impl ReadyTaskDispatchPlan { + pub fn try_new( + expected_revision: u64, + candidates: impl IntoIterator, + ) -> Result { + let plan = Self { + expected_revision, + candidates: candidates.into_iter().collect(), + }; + plan.validate()?; + Ok(plan) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + let mut seen_tasks = BTreeSet::new(); + for candidate in &self.candidates { + candidate.validate()?; + if !seen_tasks.insert(candidate.task_id.as_str()) { + return Err(OrchestrationError::DuplicateTask(candidate.task_id.clone())); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CoordinatorRun { + pub run_id: String, + pub task_id: String, + pub agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CoordinatorMessage { + pub message_id: String, + pub from_node: String, + pub to_node: String, + pub payload: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CoordinatorMessageInput { + message_id: String, + from_node: String, + to_node: String, + payload: serde_json::Value, +} + +impl<'de> Deserialize<'de> for CoordinatorMessage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = CoordinatorMessageInput::deserialize(deserializer)?; + Self::try_new( + input.message_id, + input.from_node, + input.to_node, + input.payload, + ) + .map_err(serde::de::Error::custom) + } +} + +impl CoordinatorMessage { + pub fn try_new( + message_id: impl Into, + from_node: impl Into, + to_node: impl Into, + payload: impl Into, + ) -> Result { + let message = Self { + message_id: message_id.into(), + from_node: from_node.into(), + to_node: to_node.into(), + payload: payload.into(), + }; + message.validate()?; + Ok(message) + } + + /// 文本消息的便捷构造;结构化消息可直接传入 `serde_json::Value`。 + pub fn try_text( + message_id: impl Into, + from_node: impl Into, + to_node: impl Into, + payload: impl Into, + ) -> Result { + Self::try_new( + message_id, + from_node, + to_node, + serde_json::Value::String(payload.into()), + ) + } + + fn validate(&self) -> Result<(), OrchestrationError> { + validate_id(&self.message_id, "coordinator message id")?; + validate_id(&self.from_node, "coordinator message source")?; + validate_id(&self.to_node, "coordinator message target")?; + let encoded = serde_json::to_string(&self.payload).map_err(|error| { + OrchestrationError::InvalidInput(format!("coordinator 消息 payload 无法编码: {error}")) + })?; + if encoded.chars().count() > MAX_MESSAGE_CHARS { + return Err(OrchestrationError::InvalidInput(format!( + "coordinator 消息 payload 不能超过 {MAX_MESSAGE_CHARS} 字符" + ))); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MessageDelivery { + Delivered, + Duplicate, +} + +/// Coordinator 的可序列化状态边界。 +/// +/// `active_tasks`、`active_by_agent` 等索引是运行时派生数据,不写入快照;导入 +/// 时会从 `active_runs` 重新建立。收件箱保留消息 ID 顺序,`messages` 则保留 +/// 去重账本,因此已经消费过的消息仍能在恢复后继续被识别为重复投递。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CoordinatorSnapshot { + pub schema_version: String, + pub quota: CoordinatorQuota, + pub active_runs: Vec, + pub isolated_nodes: BTreeMap, + pub messages: Vec, + pub mailboxes: BTreeMap>, +} + +/// Coordinator 控制面的可替换持久化端口。 +/// +/// 该端口只承载经过校验的 [`CoordinatorSnapshot`],不把 SQLite、线程或具体 +/// Runtime 生命周期带进编排 crate。宿主可以实现自己的数据库/对象存储适配器, +/// 或直接使用下面的内存、JSON 文件实现。 +pub trait CoordinatorStore: Send + Sync { + /// 读取当前快照;没有持久化状态时返回 `Ok(None)`。 + fn load_snapshot(&self) -> Result, OrchestrationError>; + + /// 原子保存一个完整快照。实现方不得把半成品快照暴露给读取者。 + fn save_snapshot(&self, snapshot: &CoordinatorSnapshot) -> Result<(), OrchestrationError>; +} + +/// TaskGraph 与 Coordinator 控制面一起保存的 durable 快照。 +/// +/// 旧的 [`CoordinatorStore`] 只保存活动 run、消息账本和隔离标记,无法在 +/// 进程重启后恢复任务图本身。这个合同把两者放进同一个有版本的文档,供 +/// `PersistentCoordinator` 以一次原子写入更新。它仍然只保存编排控制面, +/// 不保存单 Agent Runtime 的消息/结果真相。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OrchestrationSnapshot { + pub schema_version: String, + /// 单调递增的控制面版本;首次创建使用 0。 + pub revision: u64, + pub graph: TaskGraph, + pub coordinator: CoordinatorSnapshot, +} + +/// `PersistentCoordinator` 使用的持久化端口。 +/// +/// `expected_revision` 是 CAS 保护:`None` 只允许创建新快照,`Some(n)` 只 +/// 允许覆盖当前 revision 为 `n` 的快照。实现方必须保证一次写入要么看到 +/// 完整新快照,要么继续看到旧快照,不能暴露半个 JSON 文档。 +pub trait OrchestrationSnapshotStore: Send + Sync { + fn load_snapshot(&self) -> Result, OrchestrationError>; + + fn save_snapshot( + &self, + expected_revision: Option, + snapshot: &OrchestrationSnapshot, + ) -> Result<(), OrchestrationError>; +} + +fn validate_orchestration_snapshot( + snapshot: &OrchestrationSnapshot, +) -> Result<(), OrchestrationError> { + if snapshot.schema_version != ORCHESTRATION_SNAPSHOT_SCHEMA_VERSION { + return Err(OrchestrationError::InvalidInput(format!( + "不支持的 orchestration 快照版本: {}", + snapshot.schema_version + ))); + } + Coordinator::from_snapshot(snapshot.coordinator.clone())?; + + // 活动 run 必须指向图中存在且处于 Running 的节点;否则恢复后配额索引 + // 虽然看似有效,下一轮 ready 计算却会与活动 run 互相矛盾。反过来,图中 + // 每个 Running 节点也必须有且仅有一个对应活动 run;隔离释放 run 后由 + // PersistentCoordinator 将节点写成 Waiting,不能把不完整的 Running 状态 + // 写进可恢复快照。 + let active_runs_by_task = snapshot + .coordinator + .active_runs + .iter() + .map(|run| (run.task_id.as_str(), run)) + .collect::>(); + for run in &snapshot.coordinator.active_runs { + let Some(task) = snapshot.graph.task(&run.task_id) else { + return Err(OrchestrationError::InvalidInput(format!( + "活动 run {} 引用了未知任务 {}", + run.run_id, run.task_id + ))); + }; + if task.agent_id != run.agent_id { + return Err(OrchestrationError::InvalidInput(format!( + "活动 run {} 的 agent 与任务 {} 不一致", + run.run_id, run.task_id + ))); + } + if task.status != TaskStatus::Running { + return Err(OrchestrationError::InvalidInput(format!( + "活动 run {} 的任务 {} 不是 running", + run.run_id, run.task_id + ))); + } + } + for task in &snapshot.graph.tasks { + if task.status != TaskStatus::Running { + continue; + } + let Some(run) = active_runs_by_task.get(task.id.as_str()) else { + return Err(OrchestrationError::InvalidInput(format!( + "running 任务 {} 没有对应活动 run", + task.id + ))); + }; + if run.agent_id != task.agent_id { + return Err(OrchestrationError::InvalidInput(format!( + "running 任务 {} 的 agent 与活动 run {} 不一致", + task.id, run.run_id + ))); + } + } + Ok(()) +} + +/// 进程内的完整编排快照存储,带 revision CAS。 +#[derive(Clone, Debug, Default)] +pub struct InMemoryOrchestrationSnapshotStore { + snapshot: Arc>>, +} + +impl InMemoryOrchestrationSnapshotStore { + pub fn new() -> Self { + Self::default() + } +} + +impl OrchestrationSnapshotStore for InMemoryOrchestrationSnapshotStore { + fn load_snapshot(&self) -> Result, OrchestrationError> { + self.snapshot + .lock() + .map(|snapshot| snapshot.clone()) + .map_err(|_| OrchestrationError::InvalidInput("编排快照存储锁已损坏".to_owned())) + } + + fn save_snapshot( + &self, + expected_revision: Option, + snapshot: &OrchestrationSnapshot, + ) -> Result<(), OrchestrationError> { + validate_orchestration_snapshot(snapshot)?; + let mut current = self + .snapshot + .lock() + .map_err(|_| OrchestrationError::InvalidInput("编排快照存储锁已损坏".to_owned()))?; + let actual = current.as_ref().map(|value| value.revision); + if actual != expected_revision { + return Err(OrchestrationError::RevisionConflict { + expected: expected_revision, + actual, + }); + } + *current = Some(snapshot.clone()); + Ok(()) + } +} + +/// 使用临时文件 + rename 保存完整编排快照的轻量适配器。 +/// +/// 同一实例内写入串行化,另外使用与快照同目录的 sidecar advisory lock +/// 把“读取 revision → CAS → rename”收成跨进程临界区。读取仍只会看到旧文件 +/// 或完整新文件,不会看到截断 JSON;锁文件本身不承载业务状态,丢失后可由 +/// 下一次写入重新创建。 +#[derive(Debug)] +pub struct JsonFileOrchestrationSnapshotStore { + path: PathBuf, + write_lock: Mutex<()>, +} + +impl JsonFileOrchestrationSnapshotStore { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if path.as_os_str().is_empty() { + return Err(OrchestrationError::InvalidInput( + "orchestration 快照路径不能为空".to_owned(), + )); + } + Ok(Self { + path, + write_lock: Mutex::new(()), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl OrchestrationSnapshotStore for JsonFileOrchestrationSnapshotStore { + fn load_snapshot(&self) -> Result, OrchestrationError> { + let file = match OpenOptions::new().read(true).open(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(file_io_error("读取 orchestration 快照", error)), + }; + let metadata = file + .metadata() + .map_err(|error| file_io_error("读取 orchestration 快照元数据", error))?; + let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if size > MAX_COORDINATOR_SNAPSHOT_BYTES { + return Err(OrchestrationError::InvalidInput(format!( + "orchestration 快照超过 {} 字节上限", + MAX_COORDINATOR_SNAPSHOT_BYTES + ))); + } + let mut encoded = Vec::with_capacity(size.min(MAX_COORDINATOR_SNAPSHOT_BYTES + 1)); + file.take((MAX_COORDINATOR_SNAPSHOT_BYTES as u64) + 1) + .read_to_end(&mut encoded) + .map_err(|error| file_io_error("读取 orchestration 快照内容", error))?; + if encoded.len() > MAX_COORDINATOR_SNAPSHOT_BYTES { + return Err(OrchestrationError::InvalidInput(format!( + "orchestration 快照超过 {} 字节上限", + MAX_COORDINATOR_SNAPSHOT_BYTES + ))); + } + let snapshot = + serde_json::from_slice::(&encoded).map_err(|error| { + OrchestrationError::InvalidInput(format!("orchestration 快照无法解码: {error}")) + })?; + validate_orchestration_snapshot(&snapshot)?; + Ok(Some(snapshot)) + } + + fn save_snapshot( + &self, + expected_revision: Option, + snapshot: &OrchestrationSnapshot, + ) -> Result<(), OrchestrationError> { + validate_orchestration_snapshot(snapshot)?; + let encoded = serde_json::to_vec(snapshot).map_err(|error| { + OrchestrationError::InvalidInput(format!("orchestration 快照无法编码: {error}")) + })?; + if encoded.len() > MAX_COORDINATOR_SNAPSHOT_BYTES { + return Err(OrchestrationError::InvalidInput(format!( + "orchestration 快照超过 {} 字节上限", + MAX_COORDINATOR_SNAPSHOT_BYTES + ))); + } + + let _guard = self.write_lock.lock().map_err(|_| { + OrchestrationError::InvalidInput("orchestration 文件写锁已损坏".to_owned()) + })?; + // 先创建父目录,再打开 sidecar;否则首次写入嵌套目录时锁文件无法 + // 创建,而目标快照本身尚未有机会落盘。 + if let Some(parent) = self + .path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|error| file_io_error("创建 orchestration 快照目录", error))?; + } + let lock_path = orchestration_lock_path(&self.path); + let lock_file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| file_io_error("创建 orchestration sidecar 锁", error))?; + lock_file + .lock() + .map_err(|error| file_io_error("获取 orchestration sidecar 锁", error))?; + // CAS 必须同时受进程内 Mutex 和 sidecar 文件锁保护;这样两个进程 + // 不会都基于同一旧 revision 通过检查。lock_file 持有到 rename 完成, + // 离开作用域时自动释放 advisory lock。 + let actual = self.load_snapshot()?.map(|value| value.revision); + if actual != expected_revision { + return Err(OrchestrationError::RevisionConflict { + expected: expected_revision, + actual, + }); + } + let temp_path = temporary_orchestration_snapshot_path(&self.path); + let write_result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .map_err(|error| file_io_error("创建 orchestration 临时快照", error))?; + file.write_all(&encoded) + .map_err(|error| file_io_error("写入 orchestration 临时快照", error))?; + file.sync_all() + .map_err(|error| file_io_error("同步 orchestration 临时快照", error))?; + fs::rename(&temp_path, &self.path) + .map_err(|error| file_io_error("替换 orchestration 快照", error)) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + write_result + } +} + +fn orchestration_lock_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("orchestration.json"); + path.with_file_name(format!(".{file_name}.lock")) +} + +fn temporary_orchestration_snapshot_path(path: &Path) -> PathBuf { + let id = NEXT_SNAPSHOT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("orchestration.json"); + path.with_file_name(format!( + ".{file_name}.orchestration-tmp-{}-{id}", + std::process::id() + )) +} + +/// 进程内 Coordinator 快照存储,适合测试和短生命周期宿主。 +#[derive(Clone, Debug, Default)] +pub struct InMemoryCoordinatorStore { + snapshot: Arc>>, +} + +impl InMemoryCoordinatorStore { + pub fn new() -> Self { + Self::default() + } +} + +impl CoordinatorStore for InMemoryCoordinatorStore { + fn load_snapshot(&self) -> Result, OrchestrationError> { + self.snapshot + .lock() + .map(|snapshot| snapshot.clone()) + .map_err(|_| OrchestrationError::InvalidInput("coordinator 存储锁已损坏".to_owned())) + } + + fn save_snapshot(&self, snapshot: &CoordinatorSnapshot) -> Result<(), OrchestrationError> { + validate_snapshot(snapshot)?; + self.snapshot + .lock() + .map(|mut current| { + *current = Some(snapshot.clone()); + }) + .map_err(|_| OrchestrationError::InvalidInput("coordinator 存储锁已损坏".to_owned())) + } +} + +/// 使用同目录临时文件 + rename 保存 Coordinator 快照的文件适配器。 +/// +/// 这是一个轻量宿主适配器,不是跨主机锁服务:同一实例内的写入会串行化, +/// 多进程同时写入时由最后一次成功 rename 胜出。读取始终看到旧文件或完整的 +/// 新文件,不会看到截断 JSON。 +#[derive(Debug)] +pub struct JsonFileCoordinatorStore { + path: PathBuf, + write_lock: Mutex<()>, +} + +impl JsonFileCoordinatorStore { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if path.as_os_str().is_empty() { + return Err(OrchestrationError::InvalidInput( + "coordinator 快照路径不能为空".to_owned(), + )); + } + Ok(Self { + path, + write_lock: Mutex::new(()), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl CoordinatorStore for JsonFileCoordinatorStore { + fn load_snapshot(&self) -> Result, OrchestrationError> { + let file = match OpenOptions::new().read(true).open(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(file_io_error("读取 coordinator 快照", error)), + }; + let metadata = file + .metadata() + .map_err(|error| file_io_error("读取 coordinator 快照元数据", error))?; + let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if size > MAX_COORDINATOR_SNAPSHOT_BYTES { + return Err(OrchestrationError::InvalidInput(format!( + "coordinator 快照超过 {} 字节上限", + MAX_COORDINATOR_SNAPSHOT_BYTES + ))); + } + // 元数据和实际读取之间可能发生替换/增长;即使文件在检查后变大, + // `take` 也把本次读取限制在上限加一个字节,避免把适配器变成无界内存入口。 + let mut encoded = Vec::with_capacity(size.min(MAX_COORDINATOR_SNAPSHOT_BYTES + 1)); + file.take((MAX_COORDINATOR_SNAPSHOT_BYTES as u64) + 1) + .read_to_end(&mut encoded) + .map_err(|error| file_io_error("读取 coordinator 快照内容", error))?; + if encoded.len() > MAX_COORDINATOR_SNAPSHOT_BYTES { + return Err(OrchestrationError::InvalidInput(format!( + "coordinator 快照超过 {} 字节上限", + MAX_COORDINATOR_SNAPSHOT_BYTES + ))); + } + let snapshot = + serde_json::from_slice::(&encoded).map_err(|error| { + OrchestrationError::InvalidInput(format!("coordinator 快照无法解码: {error}")) + })?; + validate_snapshot(&snapshot)?; + Ok(Some(snapshot)) + } + + fn save_snapshot(&self, snapshot: &CoordinatorSnapshot) -> Result<(), OrchestrationError> { + validate_snapshot(snapshot)?; + let encoded = serde_json::to_vec(snapshot).map_err(|error| { + OrchestrationError::InvalidInput(format!("coordinator 快照无法编码: {error}")) + })?; + if encoded.len() > MAX_COORDINATOR_SNAPSHOT_BYTES { + return Err(OrchestrationError::InvalidInput(format!( + "coordinator 快照超过 {} 字节上限", + MAX_COORDINATOR_SNAPSHOT_BYTES + ))); + } + + let _guard = self.write_lock.lock().map_err(|_| { + OrchestrationError::InvalidInput("coordinator 文件写锁已损坏".to_owned()) + })?; + if let Some(parent) = self + .path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|error| file_io_error("创建 coordinator 快照目录", error))?; + } + + let temp_path = temporary_snapshot_path(&self.path); + let write_result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .map_err(|error| file_io_error("创建 coordinator 临时快照", error))?; + file.write_all(&encoded) + .map_err(|error| file_io_error("写入 coordinator 临时快照", error))?; + file.sync_all() + .map_err(|error| file_io_error("同步 coordinator 临时快照", error))?; + fs::rename(&temp_path, &self.path) + .map_err(|error| file_io_error("替换 coordinator 快照", error)) + })(); + if write_result.is_err() { + // 只删除本次明确创建的临时文件;不触碰目录中的其它内容。 + let _ = fs::remove_file(&temp_path); + } + write_result + } +} + +fn validate_snapshot(snapshot: &CoordinatorSnapshot) -> Result<(), OrchestrationError> { + // 复用 Coordinator 的完整恢复校验,避免不同 Store 保存出无法导入的状态。 + Coordinator::from_snapshot(snapshot.clone()).map(|_| ()) +} + +fn file_io_error(operation: &str, error: std::io::Error) -> OrchestrationError { + OrchestrationError::InvalidInput(format!("{operation}失败: {error}")) +} + +fn temporary_snapshot_path(path: &Path) -> PathBuf { + let id = NEXT_SNAPSHOT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("coordinator.json"); + path.with_file_name(format!(".{file_name}.tmp-{}-{id}", std::process::id())) +} + +/// 多 Agent 的轻量协调控制面。 +/// +/// Coordinator 只保留活动 run、消息去重账本和节点隔离标记;它不启动线程、 +/// 不执行单 Agent reducer,也不承担持久化。宿主可以把 `dispatch_wave` 返回的 +/// run 交给任意执行器,并在完成/失败后调用相应的收口 API。 +#[derive(Clone, Debug)] +pub struct Coordinator { + quota: CoordinatorQuota, + active_runs: BTreeMap, + active_tasks: BTreeMap, + active_by_agent: BTreeMap, + isolated_nodes: BTreeMap, + messages: BTreeMap, + mailboxes: BTreeMap>, +} + +impl Coordinator { + pub fn new(quota: CoordinatorQuota) -> Self { + Self { + quota, + active_runs: BTreeMap::new(), + active_tasks: BTreeMap::new(), + active_by_agent: BTreeMap::new(), + isolated_nodes: BTreeMap::new(), + messages: BTreeMap::new(), + mailboxes: BTreeMap::new(), + } + } + + pub fn try_new(quota: CoordinatorQuota) -> Result { + // `CoordinatorQuota` 通常来自 try_new;再次校验可避免未经校验的反序列化 + // 配置把协调器置于永远无法接收 run 的状态。 + CoordinatorQuota::try_new(quota.max_active_runs, quota.max_active_runs_per_agent)?; + Ok(Self::new(quota)) + } + + pub fn quota(&self) -> CoordinatorQuota { + self.quota + } + + /// 导出当前协调控制面的内存状态。 + /// + /// 该快照不包含 TaskGraph,也不暗示任何自动恢复或重放行为;调用方可以在 + /// 自己选择的存储中保存它,并通过 [`Self::from_snapshot`] 显式恢复。 + pub fn snapshot(&self) -> CoordinatorSnapshot { + CoordinatorSnapshot { + schema_version: COORDINATOR_SNAPSHOT_SCHEMA_VERSION.to_owned(), + quota: self.quota, + active_runs: self.active_runs.values().cloned().collect(), + isolated_nodes: self.isolated_nodes.clone(), + messages: self.messages.values().cloned().collect(), + mailboxes: self + .mailboxes + .iter() + .map(|(node_id, message_ids)| { + ( + node_id.clone(), + message_ids.iter().cloned().collect::>(), + ) + }) + .collect(), + } + } + + /// 从结构化快照显式恢复 Coordinator,并重建所有派生索引。 + pub fn from_snapshot(snapshot: CoordinatorSnapshot) -> Result { + if snapshot.schema_version != COORDINATOR_SNAPSHOT_SCHEMA_VERSION { + return Err(OrchestrationError::InvalidInput(format!( + "不支持的 coordinator 快照版本: {}", + snapshot.schema_version + ))); + } + let quota = CoordinatorQuota::try_new( + snapshot.quota.max_active_runs, + snapshot.quota.max_active_runs_per_agent, + )?; + + // 先恢复隔离标记,避免导入一个同时占用隔离节点配额的无效状态。 + let mut coordinator = Self { + quota, + active_runs: BTreeMap::new(), + active_tasks: BTreeMap::new(), + active_by_agent: BTreeMap::new(), + isolated_nodes: BTreeMap::new(), + messages: BTreeMap::new(), + mailboxes: BTreeMap::new(), + }; + for (node_id, reason) in snapshot.isolated_nodes { + validate_id(&node_id, "coordinator node id")?; + validate_isolation_reason(&reason)?; + coordinator.isolated_nodes.insert(node_id, reason); + } + + for run in snapshot.active_runs { + let request = CoordinatorRunRequest::try_new( + run.run_id.clone(), + run.task_id.clone(), + run.agent_id.clone(), + )?; + // `try_start_run` 同时检查重复键、隔离节点和两级配额,并在成功后 + // 通过同一条内部路径重建 active_tasks/active_by_agent 索引。 + coordinator.ensure_run_available(&request, 1)?; + coordinator.insert_active_run(run); + } + + for message in snapshot.messages { + message.validate()?; + if coordinator + .messages + .insert(message.message_id.clone(), message) + .is_some() + { + return Err(OrchestrationError::InvalidInput( + "coordinator 快照包含重复消息 ID".to_owned(), + )); + } + } + + let mut queued_ids = BTreeSet::new(); + for (node_id, message_ids) in snapshot.mailboxes { + validate_id(&node_id, "coordinator mailbox node id")?; + let mut mailbox = VecDeque::with_capacity(message_ids.len()); + for message_id in message_ids { + validate_id(&message_id, "coordinator mailbox message id")?; + let message = coordinator.messages.get(&message_id).ok_or_else(|| { + OrchestrationError::InvalidInput(format!( + "coordinator mailbox 引用了未知消息: {message_id}" + )) + })?; + if message.to_node != node_id { + return Err(OrchestrationError::InvalidInput(format!( + "coordinator mailbox 与消息目标不一致: {message_id}" + ))); + } + if !queued_ids.insert(message_id.clone()) { + return Err(OrchestrationError::InvalidInput(format!( + "coordinator 消息重复出现在 mailbox: {message_id}" + ))); + } + mailbox.push_back(message_id); + } + coordinator.mailboxes.insert(node_id, mailbox); + } + + Ok(coordinator) + } + + /// 将协调器快照编码为 JSON;编码结果只包含上述显式状态,不包含派生索引。 + pub fn export_snapshot_json(&self) -> Result { + serde_json::to_string(&self.snapshot()).map_err(|error| { + OrchestrationError::InvalidInput(format!("coordinator 快照无法编码: {error}")) + }) + } + + /// 从 JSON 显式导入协调器快照。 + pub fn import_snapshot_json(encoded: &str) -> Result { + let snapshot = serde_json::from_str::(encoded).map_err(|error| { + OrchestrationError::InvalidInput(format!("coordinator 快照无法解码: {error}")) + })?; + Self::from_snapshot(snapshot) + } + + /// 把当前控制面快照交给调用方选择的持久化适配器。 + pub fn save_to( + &self, + store: &S, + ) -> Result<(), OrchestrationError> { + store.save_snapshot(&self.snapshot()) + } + + /// 从调用方选择的持久化适配器恢复 Coordinator。 + pub fn load_from( + store: &S, + ) -> Result, OrchestrationError> { + store.load_snapshot()?.map(Self::from_snapshot).transpose() + } + + /// 保留一个 run 配额,但不触碰 TaskGraph;适用于图外的独立运行。 + pub fn try_start_run( + &mut self, + run_id: impl Into, + task_id: impl Into, + agent_id: impl Into, + ) -> Result { + let request = CoordinatorRunRequest::try_new(run_id, task_id, agent_id)?; + self.ensure_run_available(&request, 1)?; + let run = CoordinatorRun { + run_id: request.run_id, + task_id: request.task_id, + agent_id: request.agent_id, + }; + self.insert_active_run(run.clone()); + Ok(run) + } + + /// 将同一 ready wave 的多个任务一次性占用为 Running。 + /// + /// 所有校验和配额检查都在图及协调器状态修改前完成,因此拒绝时不会留下 + /// 半个波次或半个配额。worker 并发执行仍由调用方决定。 + pub fn dispatch_wave( + &mut self, + graph: &mut TaskGraph, + requests: I, + ) -> Result, OrchestrationError> + where + I: IntoIterator, + { + let requests = requests.into_iter().collect::>(); + if requests.is_empty() { + return Err(OrchestrationError::InvalidInput( + "coordinator 波次不能为空".to_owned(), + )); + } + + let ready = graph.ready_task_ids().into_iter().collect::>(); + let mut seen_tasks = BTreeSet::new(); + let mut seen_runs = BTreeSet::new(); + let mut requested_by_agent = BTreeMap::::new(); + for request in &requests { + request.validate()?; + if !seen_runs.insert(request.run_id.as_str()) { + return Err(OrchestrationError::DuplicateRun(request.run_id.clone())); + } + if !seen_tasks.insert(request.task_id.as_str()) { + return Err(OrchestrationError::DuplicateTask(request.task_id.clone())); + } + if graph.task(&request.task_id).is_none() { + return Err(OrchestrationError::UnknownTask(request.task_id.clone())); + } + if graph + .task(&request.task_id) + .is_some_and(|task| task.agent_id != request.agent_id) + { + return Err(OrchestrationError::InvalidInput(format!( + "task {} 的 agent 与 run 请求不一致", + request.task_id + ))); + } + if !ready.contains(request.task_id.as_str()) { + return Err(OrchestrationError::DependencyBlocked(format!( + "{} 尚未处于当前 ready wave", + request.task_id + ))); + } + if self.is_node_isolated(&request.task_id) { + return Err(OrchestrationError::NodeIsolated(request.task_id.clone())); + } + if self.active_runs.contains_key(&request.run_id) { + return Err(OrchestrationError::DuplicateRun(request.run_id.clone())); + } + if self.active_tasks.contains_key(&request.task_id) { + return Err(OrchestrationError::DuplicateRun(format!( + "task {} 已有活动运行", + request.task_id + ))); + } + *requested_by_agent + .entry(request.agent_id.clone()) + .or_default() += 1; + } + + let active = self.active_runs.len(); + if active.saturating_add(requests.len()) > self.quota.max_active_runs { + return Err(OrchestrationError::QuotaExceeded { + scope: "all-runs".to_owned(), + limit: self.quota.max_active_runs, + active, + requested: requests.len(), + }); + } + for (agent_id, requested) in &requested_by_agent { + let active_for_agent = self.active_by_agent.get(agent_id).copied().unwrap_or(0); + if active_for_agent.saturating_add(*requested) > self.quota.max_active_runs_per_agent { + return Err(OrchestrationError::QuotaExceeded { + scope: format!("agent:{agent_id}"), + limit: self.quota.max_active_runs_per_agent, + active: active_for_agent, + requested: *requested, + }); + } + } + + // 在副本上推进图,确保后续任何意外校验失败都不会污染调用方的 epoch。 + let mut next_graph = graph.clone(); + for request in &requests { + next_graph.set_task_status(&request.task_id, TaskStatus::Running)?; + } + *graph = next_graph; + + let mut started = Vec::with_capacity(requests.len()); + for request in requests { + let run = CoordinatorRun { + run_id: request.run_id, + task_id: request.task_id, + agent_id: request.agent_id, + }; + self.insert_active_run(run.clone()); + started.push(run); + } + Ok(started) + } + + pub fn finish_run(&mut self, run_id: &str) -> Result { + self.remove_active_run(run_id) + .ok_or_else(|| OrchestrationError::UnknownRun(run_id.to_owned())) + } + + /// 原子取消一个活动 run,并同步把它的图节点标记为 `Cancelled`。 + /// + /// 低层 Coordinator 同时持有图和活动索引时也应走这条入口,避免调用方 + /// 先释放配额、再更新图状态而在中途失败。真正的 Runtime 取消信号仍由 + /// 宿主负责;这里仅维护编排控制面的状态。 + pub fn cancel_run( + &mut self, + graph: &mut TaskGraph, + run_id: &str, + ) -> Result { + let run = self + .active_run(run_id) + .cloned() + .ok_or_else(|| OrchestrationError::UnknownRun(run_id.to_owned()))?; + // 先在副本中校验状态更新,再修改活动索引,确保错误不会留下半个取消。 + let mut next_graph = graph.clone(); + next_graph.set_task_status(&run.task_id, TaskStatus::Cancelled)?; + self.remove_active_run(run_id); + *graph = next_graph; + Ok(run) + } + + pub fn active_run_count(&self) -> usize { + self.active_runs.len() + } + + pub fn active_run_count_for_agent(&self, agent_id: &str) -> usize { + self.active_by_agent.get(agent_id).copied().unwrap_or(0) + } + + pub fn active_run(&self, run_id: &str) -> Option<&CoordinatorRun> { + self.active_runs.get(run_id) + } + + /// 隔离单个节点,并释放该节点仍占用的活动 run 配额。 + /// + /// 重复隔离是幂等的:第一次故障原因保留,返回空的释放列表。 + pub fn isolate_node( + &mut self, + node_id: impl Into, + reason: impl Into, + ) -> Result, OrchestrationError> { + let node_id = node_id.into(); + let reason = reason.into(); + validate_id(&node_id, "coordinator node id")?; + validate_isolation_reason(&reason)?; + if self.isolated_nodes.contains_key(&node_id) { + return Ok(Vec::new()); + } + + let run_ids = self + .active_runs + .values() + .filter(|run| run.task_id == node_id) + .map(|run| run.run_id.clone()) + .collect::>(); + for run_id in &run_ids { + self.remove_active_run(run_id); + } + self.isolated_nodes.insert(node_id, reason); + Ok(run_ids) + } + + /// 同步标记图节点失败并隔离它;下游是否重跑必须另行调用 repair_downstream。 + pub fn fail_node( + &mut self, + graph: &mut TaskGraph, + node_id: impl AsRef, + reason: impl Into, + ) -> Result, OrchestrationError> { + let node_id = node_id.as_ref(); + let reason = reason.into(); + validate_id(node_id, "coordinator node id")?; + validate_isolation_reason(&reason)?; + graph.set_task_status(node_id, TaskStatus::Failed)?; + self.isolate_node(node_id.to_owned(), reason) + } + + pub fn is_node_isolated(&self, node_id: &str) -> bool { + self.isolated_nodes.contains_key(node_id) + } + + pub fn isolation_reason(&self, node_id: &str) -> Option<&str> { + self.isolated_nodes.get(node_id).map(String::as_str) + } + + /// 显式修复图及协调器状态;不会自动启动新的 run。 + pub fn repair_downstream( + &mut self, + graph: &mut TaskGraph, + seeds: &[impl AsRef], + ) -> Result, OrchestrationError> { + let impacted = graph.expand_downstream(seeds)?; + // 修复会把受影响节点重新置为 Pending,因此不能保留旧的活动 run 预约。 + let to_release = self + .active_runs + .values() + .filter(|run| impacted.iter().any(|id| id == &run.task_id)) + .map(|run| run.run_id.clone()) + .collect::>(); + for run_id in &to_release { + self.remove_active_run(run_id); + } + let repaired = graph.repair_downstream(seeds)?; + for node_id in &repaired { + self.isolated_nodes.remove(node_id); + } + Ok(repaired) + } + + /// 投递消息并按 message_id 去重;相同 ID 但内容不同会被拒绝。 + pub fn deliver_message( + &mut self, + message: CoordinatorMessage, + ) -> Result { + message.validate()?; + if self.is_node_isolated(&message.from_node) { + return Err(OrchestrationError::NodeIsolated(message.from_node)); + } + if self.is_node_isolated(&message.to_node) { + return Err(OrchestrationError::NodeIsolated(message.to_node)); + } + if let Some(previous) = self.messages.get(&message.message_id) { + if previous == &message { + return Ok(MessageDelivery::Duplicate); + } + return Err(OrchestrationError::MessageConflict(message.message_id)); + } + let message_id = message.message_id.clone(); + let target = message.to_node.clone(); + self.messages.insert(message_id.clone(), message); + self.mailboxes + .entry(target) + .or_default() + .push_back(message_id); + Ok(MessageDelivery::Delivered) + } + + /// 取出节点当前收件箱中的消息;去重账本会保留到协调器生命周期结束。 + pub fn receive_messages( + &mut self, + node_id: &str, + ) -> Result, OrchestrationError> { + validate_id(node_id, "coordinator node id")?; + if self.is_node_isolated(node_id) { + return Err(OrchestrationError::NodeIsolated(node_id.to_owned())); + } + let ids = self.mailboxes.remove(node_id).unwrap_or_default(); + Ok(ids + .into_iter() + .filter_map(|message_id| self.messages.get(&message_id).cloned()) + .collect()) + } + + pub fn pending_message_count(&self, node_id: &str) -> usize { + self.mailboxes.get(node_id).map_or(0, VecDeque::len) + } + + fn ensure_run_available( + &self, + request: &CoordinatorRunRequest, + requested: usize, + ) -> Result<(), OrchestrationError> { + if self.active_runs.contains_key(&request.run_id) { + return Err(OrchestrationError::DuplicateRun(request.run_id.clone())); + } + if self.active_tasks.contains_key(&request.task_id) { + return Err(OrchestrationError::DuplicateRun(format!( + "task {} 已有活动运行", + request.task_id + ))); + } + if self.is_node_isolated(&request.task_id) { + return Err(OrchestrationError::NodeIsolated(request.task_id.clone())); + } + if self.active_runs.len().saturating_add(requested) > self.quota.max_active_runs { + return Err(OrchestrationError::QuotaExceeded { + scope: "all-runs".to_owned(), + limit: self.quota.max_active_runs, + active: self.active_runs.len(), + requested, + }); + } + let active_for_agent = self + .active_by_agent + .get(&request.agent_id) + .copied() + .unwrap_or(0); + if active_for_agent.saturating_add(requested) > self.quota.max_active_runs_per_agent { + return Err(OrchestrationError::QuotaExceeded { + scope: format!("agent:{}", request.agent_id), + limit: self.quota.max_active_runs_per_agent, + active: active_for_agent, + requested, + }); + } + Ok(()) + } + + fn insert_active_run(&mut self, run: CoordinatorRun) { + self.active_tasks + .insert(run.task_id.clone(), run.run_id.clone()); + *self + .active_by_agent + .entry(run.agent_id.clone()) + .or_default() += 1; + self.active_runs.insert(run.run_id.clone(), run); + } + + fn remove_active_run(&mut self, run_id: &str) -> Option { + let run = self.active_runs.remove(run_id)?; + self.active_tasks.remove(&run.task_id); + if let Some(count) = self.active_by_agent.get_mut(&run.agent_id) { + if *count <= 1 { + self.active_by_agent.remove(&run.agent_id); + } else { + *count -= 1; + } + } + Some(run) + } +} + +impl Default for Coordinator { + fn default() -> Self { + Self::new(CoordinatorQuota::default()) + } +} + +/// 把任务图和协调器状态作为一个原子控制面管理。 +/// +/// 这个类型是“持久化调度控制器”,不是线程池:它只在每次状态变更后提交 +/// 快照,实际的 Agent Runtime 创建、worker 生命周期和 Provider 调用仍由宿主 +/// 决定。这样多 Agent 扩展可以先拥有可靠的图/配额/消息恢复边界,再接入任意 +/// 执行器,而不用复制单 Agent reducer。 +#[derive(Debug)] +pub struct PersistentCoordinator { + store: S, + graph: TaskGraph, + coordinator: Coordinator, + revision: u64, +} + +impl PersistentCoordinator +where + S: OrchestrationSnapshotStore, +{ + /// 创建并立即保存一个新的编排控制面。已有快照不会被覆盖。 + pub fn new( + graph: TaskGraph, + quota: CoordinatorQuota, + store: S, + ) -> Result { + let coordinator = Coordinator::try_new(quota)?; + let controller = Self { + store, + graph, + coordinator, + revision: 0, + }; + controller.persist(None)?; + Ok(controller) + } + + /// 从已有快照恢复;没有快照时返回 `None`,不会隐式创建空图。 + pub fn load(store: S) -> Result, OrchestrationError> { + let Some(snapshot) = store.load_snapshot()? else { + return Ok(None); + }; + validate_orchestration_snapshot(&snapshot)?; + let coordinator = Coordinator::from_snapshot(snapshot.coordinator)?; + Ok(Some(Self { + store, + graph: snapshot.graph, + coordinator, + revision: snapshot.revision, + })) + } + + /// 使用调用方已经取得的完整快照构造控制器,不写回存储。 + pub fn from_snapshot( + snapshot: OrchestrationSnapshot, + store: S, + ) -> Result { + validate_orchestration_snapshot(&snapshot)?; + let coordinator = Coordinator::from_snapshot(snapshot.coordinator)?; + Ok(Self { + store, + graph: snapshot.graph, + coordinator, + revision: snapshot.revision, + }) + } + + pub fn store(&self) -> &S { + &self.store + } + + pub fn graph(&self) -> &TaskGraph { + &self.graph + } + + pub fn coordinator(&self) -> &Coordinator { + &self.coordinator + } + + pub fn revision(&self) -> u64 { + self.revision + } + + /// 按图中稳定顺序生成一个受当前配额和隔离状态约束的 ready-task 计划。 + /// + /// 该方法只读,不领取任务;`max_tasks` 为本次计划的上限,必须大于零。 + /// 计划中的 `expected_revision` 是宿主稍后 claim 时使用的乐观并发凭证。 + /// run ID 不在这里生成,因为它属于宿主的 Runtime 命名/持久化策略。 + pub fn plan_ready_tasks( + &self, + max_tasks: usize, + ) -> Result { + if max_tasks == 0 { + return Err(OrchestrationError::InvalidInput( + "ready task 计划上限必须大于零".to_owned(), + )); + } + + let quota = self.coordinator.quota(); + let global_room = quota + .max_active_runs() + .saturating_sub(self.coordinator.active_run_count()); + let target_count = max_tasks.min(global_room); + let mut active_by_agent = self.coordinator.active_by_agent.clone(); + let mut candidates = Vec::with_capacity(target_count); + + // `ready_task_ids` 已经按图的稳定顺序返回;按此顺序选取可用任务, + // 让多个宿主在同一 revision 上得到相同的计划,冲突时只需 reload。 + for task_id in self.graph.ready_task_ids() { + if candidates.len() >= target_count { + break; + } + if self.coordinator.is_node_isolated(task_id) { + continue; + } + let task = self + .graph + .task(task_id) + .expect("ready_task_ids 返回的任务必须存在"); + let active_for_agent = active_by_agent.get(&task.agent_id).copied().unwrap_or(0); + if active_for_agent >= quota.max_active_runs_per_agent() { + continue; + } + candidates.push(ReadyTaskCandidate { + task_id: task.id.clone(), + agent_id: task.agent_id.clone(), + }); + active_by_agent.insert(task.agent_id.clone(), active_for_agent + 1); + } + + ReadyTaskDispatchPlan::try_new(self.revision, candidates) + } + + /// 原子领取一个 ready-task 计划,并为每个候选任务绑定宿主生成的 run ID。 + /// + /// `expected_revision` 不匹配时不会读取或修改图状态;匹配后仍会复用 + /// `Coordinator::dispatch_wave` 的全部校验和 Store CAS,因此计划过期、被 + /// 隔离、重复领取或超出配额都会在持久化前失败且不留下半个 claim。 + pub fn claim_ready_tasks( + &mut self, + plan: &ReadyTaskDispatchPlan, + run_id_source: I, + ) -> Result, OrchestrationError> + where + I: IntoIterator, + R: Into, + { + plan.validate()?; + if plan.expected_revision != self.revision { + return Err(OrchestrationError::RevisionConflict { + expected: Some(plan.expected_revision), + actual: Some(self.revision), + }); + } + + // 最多读取候选数加一个 ID,既能检测多余输入,也不会让一个恶意无限 + // iterator 把 claim 入口变成无界内存消耗。 + let expected_count = plan.candidates.len(); + let mut collected_run_ids = Vec::with_capacity(expected_count); + for run_id in run_id_source { + if collected_run_ids.len() >= expected_count { + return Err(OrchestrationError::InvalidInput( + "ready task 计划与 run ID 数量不一致".to_owned(), + )); + } + collected_run_ids.push(run_id.into()); + } + if collected_run_ids.len() != expected_count { + return Err(OrchestrationError::InvalidInput( + "ready task 计划与 run ID 数量不一致".to_owned(), + )); + } + if expected_count == 0 { + return Ok(Vec::new()); + } + + let requests = + plan.candidates + .iter() + .zip(collected_run_ids) + .map(|(candidate, run_id)| { + let task = self.graph.task(&candidate.task_id).ok_or_else(|| { + OrchestrationError::UnknownTask(candidate.task_id.clone()) + })?; + if task.agent_id != candidate.agent_id { + return Err(OrchestrationError::InvalidInput(format!( + "task {} 的 agent 与 ready 计划不一致", + candidate.task_id + ))); + } + CoordinatorRunRequest::try_new( + run_id, + candidate.task_id.clone(), + candidate.agent_id.clone(), + ) + }) + .collect::, _>>()?; + + // 统一复用已有的 ready/dependency/isolation/quota 校验和 CAS 提交路径。 + self.dispatch_wave(requests) + } + + pub fn snapshot(&self) -> OrchestrationSnapshot { + OrchestrationSnapshot { + schema_version: ORCHESTRATION_SNAPSHOT_SCHEMA_VERSION.to_owned(), + revision: self.revision, + graph: self.graph.clone(), + coordinator: self.coordinator.snapshot(), + } + } + + /// 重新从 Store 读取最新状态,供 CAS 冲突后的调用方显式恢复。 + pub fn reload(&mut self) -> Result<(), OrchestrationError> { + let snapshot = self + .store + .load_snapshot()? + .ok_or_else(|| OrchestrationError::InvalidInput("编排快照不存在".to_owned()))?; + validate_orchestration_snapshot(&snapshot)?; + self.graph = snapshot.graph; + self.coordinator = Coordinator::from_snapshot(snapshot.coordinator)?; + self.revision = snapshot.revision; + Ok(()) + } + + /// 显式重写当前 revision;一般只在恢复/迁移代码中使用。 + pub fn save(&self) -> Result<(), OrchestrationError> { + self.persist(Some(self.revision)) + } + + /// 原子派发一个 ready wave;图状态和活动 run 配额要么一起更新,要么都 + /// 保持旧值。Store 冲突时内存中的控制器也保持旧 epoch,调用方可 reload。 + pub fn dispatch_wave( + &mut self, + requests: I, + ) -> Result, OrchestrationError> + where + I: IntoIterator, + { + self.mutate(|graph, coordinator| coordinator.dispatch_wave(graph, requests)) + } + + /// 结束一个活动 run 并将对应任务置为 Completed。 + pub fn complete_run(&mut self, run_id: &str) -> Result { + self.mutate(|graph, coordinator| { + let run = coordinator + .active_run(run_id) + .cloned() + .ok_or_else(|| OrchestrationError::UnknownRun(run_id.to_owned()))?; + graph.set_task_status(&run.task_id, TaskStatus::Completed)?; + coordinator.finish_run(run_id) + }) + } + + /// 取消一个活动 run,并在同一 revision-CAS 中释放配额、持久化图状态。 + /// + /// Runtime 的 cooperative cancel/lease 收口由宿主负责;该方法只更新 + /// orchestration 控制面,依赖它的下游任务仍需显式 repair。 + pub fn cancel_run(&mut self, run_id: &str) -> Result { + self.mutate(|graph, coordinator| coordinator.cancel_run(graph, run_id)) + } + + /// 结束一个活动 run 并将对应任务置为 Failed;是否隔离节点由调用方另行 + /// 选择,避免把失败策略硬编码进持久化控制器。 + pub fn fail_run(&mut self, run_id: &str) -> Result { + self.mutate(|graph, coordinator| { + let run = coordinator + .active_run(run_id) + .cloned() + .ok_or_else(|| OrchestrationError::UnknownRun(run_id.to_owned()))?; + graph.set_task_status(&run.task_id, TaskStatus::Failed)?; + coordinator.finish_run(run_id) + }) + } + + /// 失败并隔离节点,同时释放该节点活动 run;下游修复仍须显式调用。 + pub fn fail_node( + &mut self, + node_id: impl AsRef, + reason: impl Into, + ) -> Result, OrchestrationError> { + let node_id = node_id.as_ref().to_owned(); + let reason = reason.into(); + self.mutate(|graph, coordinator| coordinator.fail_node(graph, &node_id, reason)) + } + + pub fn isolate_node( + &mut self, + node_id: impl Into, + reason: impl Into, + ) -> Result, OrchestrationError> { + let node_id = node_id.into(); + let reason = reason.into(); + self.mutate(|graph, coordinator| { + let released = coordinator.isolate_node(&node_id, reason)?; + + // 低层 Coordinator 只维护控制面索引;PersistentCoordinator 同时拥有 + // TaskGraph,因此释放活动 run 后不能留下 Running 节点。Waiting 表示 + // 可恢复的隔离态,后续必须显式调用 repair_downstream。 + let should_wait = graph.task(&node_id).is_some_and(|task| { + matches!(task.status, TaskStatus::Pending | TaskStatus::Running) + }); + if should_wait { + graph.set_task_status(&node_id, TaskStatus::Waiting)?; + } + Ok(released) + }) + } + + /// 显式修复种子节点及全部下游节点,并释放受影响的活动 run。 + pub fn repair_downstream( + &mut self, + seeds: &[impl AsRef], + ) -> Result, OrchestrationError> { + self.mutate(|graph, coordinator| coordinator.repair_downstream(graph, seeds)) + } + + /// 投递消息并持久化去重账本;重复投递是幂等的,不产生新 revision。 + pub fn deliver_message( + &mut self, + message: CoordinatorMessage, + ) -> Result { + self.mutate(|_, coordinator| coordinator.deliver_message(message)) + } + + pub fn receive_messages( + &mut self, + node_id: &str, + ) -> Result, OrchestrationError> { + self.mutate(|_, coordinator| coordinator.receive_messages(node_id)) + } + + /// 把经过校验的 GraphProposal 安装为新图 epoch;现有活动 run 和消息账本 + /// 原样保留,提案应用与快照写入在同一次控制面更新中完成。 + pub fn apply_proposal( + &mut self, + proposal: &GraphProposal, + ) -> Result { + let next_graph = proposal.apply(&self.graph)?; + let result = next_graph.clone(); + self.mutate(|graph, _| { + *graph = next_graph; + Ok(result.clone()) + }) + } + + /// 在同一 revision-CAS 更新中安装经过 Agent 目录和资源边界校验的提案。 + /// + /// 候选图先在当前 epoch 的不可变副本上构造;只有完整快照成功写入后, + /// 图、Coordinator 状态和 revision 才会一起替换,CAS 冲突不会泄漏半成品。 + pub fn apply_proposal_with_limits( + &mut self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + let next_graph = self.graph.apply_proposal(proposal, catalog, limits)?; + let result = next_graph.clone(); + self.mutate(|graph, _| { + *graph = next_graph; + Ok(result.clone()) + }) + } + + fn mutate(&mut self, operation: F) -> Result + where + F: FnOnce(&mut TaskGraph, &mut Coordinator) -> Result, + { + let mut next_graph = self.graph.clone(); + let mut next_coordinator = self.coordinator.clone(); + let result = operation(&mut next_graph, &mut next_coordinator)?; + // Idempotent operations such as delivering an already-known message do + // not consume a revision or perform an unnecessary disk write. + if next_graph == self.graph && next_coordinator.snapshot() == self.coordinator.snapshot() { + return Ok(result); + } + let next_revision = self + .revision + .checked_add(1) + .ok_or_else(|| OrchestrationError::InvalidInput("编排 revision 溢出".to_owned()))?; + let next_snapshot = OrchestrationSnapshot { + schema_version: ORCHESTRATION_SNAPSHOT_SCHEMA_VERSION.to_owned(), + revision: next_revision, + graph: next_graph.clone(), + coordinator: next_coordinator.snapshot(), + }; + self.store + .save_snapshot(Some(self.revision), &next_snapshot)?; + self.graph = next_graph; + self.coordinator = next_coordinator; + self.revision = next_revision; + Ok(result) + } + + fn persist(&self, expected_revision: Option) -> Result<(), OrchestrationError> { + let snapshot = self.snapshot(); + self.store.save_snapshot(expected_revision, &snapshot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Barrier, Mutex}; + use std::thread; + + fn test_temp_path(prefix: &str) -> std::path::PathBuf { + // Respect an explicit TMPDIR, but keep direct test runs out of /tmp by + // defaulting to the repository-wide ~/data/tmp location. + let parent = std::env::var_os("TMPDIR") + .filter(|value| !value.is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .map(|home| home.join("data/tmp")) + }) + .expect("TMPDIR 或 HOME 未设置,无法创建测试快照"); + fs::create_dir_all(&parent).expect("创建测试临时目录"); + let id = NEXT_SNAPSHOT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + parent.join(format!("{prefix}-{}-{id}.json", std::process::id())) + } + + fn graph() -> TaskGraph { + TaskGraph::try_new( + "goal", + [ + TaskNode::try_new("a", "agent-a", std::iter::empty::()).unwrap(), + TaskNode::try_new("b", "agent-b", ["a"]).unwrap(), + TaskNode::try_new("c", "agent-c", ["a"]).unwrap(), + ], + ) + .unwrap() + } + + fn graph_catalog() -> AgentCatalog { + AgentCatalog::try_new([ + agent_runtime_core::AgentDescriptor::try_new( + "agent-a", + "role-a", + std::iter::empty::<&str>(), + ) + .unwrap(), + agent_runtime_core::AgentDescriptor::try_new( + "agent-b", + "role-b", + std::iter::empty::<&str>(), + ) + .unwrap(), + agent_runtime_core::AgentDescriptor::try_new( + "agent-c", + "role-c", + std::iter::empty::<&str>(), + ) + .unwrap(), + agent_runtime_core::AgentDescriptor::try_new( + "agent-d", + "role-d", + std::iter::empty::<&str>(), + ) + .unwrap(), + agent_runtime_core::AgentDescriptor::try_new( + "agent-e", + "role-e", + std::iter::empty::<&str>(), + ) + .unwrap(), + ]) + .unwrap() + } + + #[test] + fn ready_and_waves_are_deterministic() { + let graph = graph(); + assert_eq!(graph.ready_task_ids(), ["a"]); + let waves = graph.dependency_waves().unwrap(); + assert_eq!(waves, vec![vec!["a"], vec!["b", "c"]]); + } + + #[test] + fn status_updates_unlock_dependencies_and_repair_downstream() { + let mut current = graph(); + assert!(matches!( + current.set_task_status("b", TaskStatus::Running), + Err(OrchestrationError::DependencyBlocked(_)) + )); + + current.set_task_status("a", TaskStatus::Running).unwrap(); + current.set_task_status("a", TaskStatus::Completed).unwrap(); + assert_eq!(current.ready_task_ids(), ["b", "c"]); + current.set_task_status("b", TaskStatus::Running).unwrap(); + current.set_task_status("b", TaskStatus::Completed).unwrap(); + current.set_task_status("c", TaskStatus::Cancelled).unwrap(); + assert!(current.dependency_waves().unwrap().is_empty()); + + // 上游失败时,下游仍是 Pending,但不能被误报为可执行波次。 + let mut blocked = graph(); + blocked.set_task_status("a", TaskStatus::Failed).unwrap(); + assert!(matches!( + blocked.dependency_waves(), + Err(OrchestrationError::DependencyBlocked(_)) + )); + + let repaired = current.repair_downstream(&["a"]).unwrap(); + assert_eq!(repaired, ["a", "b", "c"]); + assert!( + current + .tasks + .iter() + .all(|task| task.status == TaskStatus::Pending) + ); + assert_eq!( + current.dependency_waves().unwrap(), + vec![vec!["a"], vec!["b", "c"]] + ); + } + + #[test] + fn immutable_status_update_keeps_original_epoch() { + let graph = graph(); + let next = graph.with_task_status("a", TaskStatus::Completed).unwrap(); + assert_eq!(graph.task("a").unwrap().status, TaskStatus::Pending); + assert_eq!(next.task("a").unwrap().status, TaskStatus::Completed); + assert_eq!(next.ready_task_ids(), ["b", "c"]); + } + + #[test] + fn independent_tasks_in_one_wave_can_run_concurrently() { + let graph = Arc::new(Mutex::new(graph())); + { + let mut graph = graph.lock().unwrap(); + graph.set_task_status("a", TaskStatus::Completed).unwrap(); + } + let wave = graph + .lock() + .unwrap() + .dependency_waves() + .unwrap() + .pop() + .unwrap(); + let barrier = Arc::new(Barrier::new(wave.len())); + let handles = wave + .into_iter() + .map(|id| { + let graph = Arc::clone(&graph); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + assert_eq!(graph.lock().unwrap().task(&id).unwrap().dependencies, ["a"]); + // 两个 worker 都到达屏障后才继续,证明波次可以并行交给宿主。 + barrier.wait(); + let mut graph = graph.lock().unwrap(); + graph.set_task_status(&id, TaskStatus::Running).unwrap(); + graph.set_task_status(&id, TaskStatus::Completed).unwrap(); + id + }) + }) + .collect::>(); + let mut completed = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + completed.sort(); + assert_eq!(completed, ["b", "c"]); + assert!( + graph + .lock() + .unwrap() + .tasks + .iter() + .all(|task| task.status == TaskStatus::Completed) + ); + } + + #[test] + fn cycles_and_unknown_dependencies_fail() { + let a = TaskNode::try_new("a", "agent", ["b"]).unwrap(); + let b = TaskNode::try_new("b", "agent", ["a"]).unwrap(); + assert_eq!( + TaskGraph::try_new("goal", [a, b]).unwrap_err(), + OrchestrationError::Cycle + ); + let unknown = TaskNode::try_new("a", "agent", ["missing"]).unwrap(); + assert!(matches!( + TaskGraph::try_new("goal", [unknown]), + Err(OrchestrationError::InvalidDependency(_)) + )); + } + + #[test] + fn proposal_creates_new_epoch_without_mutating_base() { + let base = graph(); + let proposal = GraphProposal::try_new( + [TaskNodeProposal { + id: "d".into(), + agent_id: "agent-d".into(), + }], + [GraphEdge { + from: "b".into(), + to: "d".into(), + }], + ) + .unwrap(); + let next = proposal.apply(&base).unwrap(); + assert!(base.task("d").is_none()); + assert_eq!(next.task("d").unwrap().dependencies, ["b"]); + } + + #[test] + fn graph_round_trip_rebuilds_lookup_index() { + let original = graph(); + let encoded = serde_json::to_string(&original).unwrap(); + let restored: TaskGraph = serde_json::from_str(&encoded).unwrap(); + assert_eq!(restored.task("b").unwrap().agent_id, "agent-b"); + assert_eq!(restored.ready_task_ids(), ["a"]); + } + + #[test] + fn proposal_cannot_mutate_existing_task_dependencies_in_place() { + let base = graph(); + let proposal = GraphProposal::try_new( + [TaskNodeProposal { + id: "d".into(), + agent_id: "agent-d".into(), + }], + [GraphEdge { + from: "a".into(), + to: "b".into(), + }], + ) + .unwrap(); + assert!(proposal.apply(&base).is_err()); + } + + #[test] + fn checked_proposal_enforces_catalog_and_graph_limits_atomically() { + let base = graph(); + let catalog = graph_catalog(); + assert_eq!(base.task_count(), 3); + assert_eq!(base.node_count(), 3); + assert_eq!(base.edge_count(), 2); + assert_eq!(base.depth(), 2); + assert_eq!(base.fan_out("a"), Some(2)); + assert_eq!(base.fan_out("missing"), None); + + let valid = GraphProposal::try_new( + [TaskProposal::try_new("d", "agent-d").unwrap()], + [GraphEdge::try_new("b", "d").unwrap()], + ) + .unwrap(); + let candidate = base + .apply_proposal(&valid, &catalog, &GraphLimits::default()) + .unwrap(); + assert_eq!(candidate.task_count(), 4); + assert_eq!(candidate.edge_count(), 3); + assert_eq!(candidate.depth(), 3); + assert_eq!(candidate.task("d").unwrap().status, TaskStatus::Pending); + assert_eq!(candidate.task("d").unwrap().dependencies, ["b"]); + assert_eq!(base.task("d"), None, "失败或成功都不能原地改写旧 epoch"); + + let unknown_agent = GraphProposal::try_new( + [TaskProposal::try_new("unknown", "agent-missing").unwrap()], + std::iter::empty::(), + ) + .unwrap(); + assert!(matches!( + base.apply_proposal(&unknown_agent, &catalog, &GraphLimits::default()), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("未注册 Agent") + )); + + let limits = GraphLimits::new(3, 3, 2, 2); + assert!(matches!( + base.apply_proposal(&valid, &catalog, &limits), + Err(OrchestrationError::Limit(message)) if message.contains("maxTasks") + )); + assert!(matches!( + base.apply_proposal( + &valid, + &catalog, + &GraphLimits::new(8, 2, 8, 8) + ), + Err(OrchestrationError::Limit(message)) if message.contains("maxEdges") + )); + assert!(matches!( + base.apply_proposal( + &valid, + &catalog, + &GraphLimits::new(8, 8, 2, 8) + ), + Err(OrchestrationError::Limit(message)) if message.contains("maxDepth") + )); + + let fan_out = GraphProposal::try_new( + [ + TaskProposal::try_new("d", "agent-d").unwrap(), + TaskProposal::try_new("e", "agent-e").unwrap(), + ], + [ + GraphEdge::try_new("a", "d").unwrap(), + GraphEdge::try_new("a", "e").unwrap(), + ], + ) + .unwrap(); + assert!(matches!( + base.apply_proposal( + &fan_out, + &catalog, + &GraphLimits::new(8, 8, 8, 2) + ), + Err(OrchestrationError::Limit(message)) if message.contains("fan-out") + )); + + assert!(matches!( + GraphLimits::try_new(0, 1, 1, 1), + Err(OrchestrationError::Limit(message)) if message.contains("maxTasks") + )); + let encoded = serde_json::to_value(GraphLimits::default()).unwrap(); + assert_eq!( + encoded, + serde_json::json!({ + "maxTasks": 128, + "maxEdges": 512, + "maxDepth": 32, + "maxOutDegree": 32 + }) + ); + assert!(serde_json::from_value::(encoded).is_ok()); + assert!( + serde_json::from_str::( + r#"{"maxTasks":1,"maxEdges":1,"maxDepth":1,"maxOutDegree":1,"extra":true}"# + ) + .is_err() + ); + } + + #[test] + fn delegation_and_join_validate_boundaries() { + assert!(Delegation::try_new("d", "run", "agent", "do it").is_ok()); + assert!(Join::try_new("j", ["d"], "all").is_ok()); + assert!(Join::try_new("j", std::iter::empty::(), "all").is_err()); + assert!(matches!( + Join::try_new("j", ["d", "d"], "all"), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("重复 delegation") + )); + assert!(Join::try_new("j", [""], "all").is_err()); + } + + #[test] + fn public_contract_deserialization_reapplies_constructor_validation() { + let invalid_node = serde_json::from_str::( + r#"{"id":"a","agentId":"agent","dependencies":["a"]}"#, + ); + assert!(invalid_node.is_err()); + + let invalid_delegation = serde_json::from_str::( + r#"{"id":"d","parentRunId":"run","childAgentId":"agent","task":""}"#, + ); + assert!(invalid_delegation.is_err()); + + let invalid_join = serde_json::from_str::( + r#"{"id":"j","delegationIds":["d","d"],"strategy":"all"}"#, + ); + assert!(invalid_join.is_err()); + + let invalid_proposal = serde_json::from_str::( + r#"{"nodes":[{"id":"n","agentId":"a"}],"edges":[{"from":"n","to":"n"}]}"#, + ); + assert!(invalid_proposal.is_err()); + + let invalid_quota = serde_json::from_str::( + r#"{"maxActiveRuns":0,"maxActiveRunsPerAgent":1}"#, + ); + assert!(invalid_quota.is_err()); + + let invalid_run = serde_json::from_str::( + r#"{"runId":"","taskId":"task","agentId":"agent"}"#, + ); + assert!(invalid_run.is_err()); + + let invalid_message = serde_json::from_str::( + r#"{"messageId":"m","fromNode":"","toNode":"node","payload":"x"}"#, + ); + assert!(invalid_message.is_err()); + } + + #[test] + fn coordinator_rejects_runs_over_bounded_quota_without_partial_state() { + let quota = CoordinatorQuota::try_new(1, 1).unwrap(); + let mut coordinator = Coordinator::new(quota); + coordinator + .try_start_run("run-a", "node-a", "agent-a") + .unwrap(); + + let error = coordinator + .try_start_run("run-b", "node-b", "agent-b") + .unwrap_err(); + assert!(matches!( + error, + OrchestrationError::QuotaExceeded { + scope, + limit: 1, + active: 1, + requested: 1, + } if scope == "all-runs" + )); + assert_eq!(coordinator.active_run_count(), 1); + assert!(coordinator.active_run("run-b").is_none()); + } + + #[test] + fn coordinator_dispatch_rejects_per_agent_quota_without_partial_state() { + let mut graph = TaskGraph::try_new( + "goal", + [ + TaskNode::try_new("node-a", "agent-a", std::iter::empty::()).unwrap(), + TaskNode::try_new("node-b", "agent-a", std::iter::empty::()).unwrap(), + ], + ) + .unwrap(); + let mut coordinator = Coordinator::new(CoordinatorQuota::try_new(2, 1).unwrap()); + + let error = coordinator + .dispatch_wave( + &mut graph, + [ + CoordinatorRunRequest::try_new("run-a", "node-a", "agent-a").unwrap(), + CoordinatorRunRequest::try_new("run-b", "node-b", "agent-a").unwrap(), + ], + ) + .unwrap_err(); + assert!(matches!( + error, + OrchestrationError::QuotaExceeded { + scope, + limit: 1, + active: 0, + requested: 2, + } if scope == "agent:agent-a" + )); + assert_eq!(coordinator.active_run_count(), 0); + assert!( + graph + .tasks + .iter() + .all(|task| task.status == TaskStatus::Pending) + ); + } + + #[test] + fn coordinator_dispatches_independent_tasks_in_one_wave() { + let mut graph = graph(); + graph.set_task_status("a", TaskStatus::Completed).unwrap(); + let mut coordinator = Coordinator::new(CoordinatorQuota::try_new(2, 1).unwrap()); + let started = coordinator + .dispatch_wave( + &mut graph, + [ + CoordinatorRunRequest::try_new("run-b", "b", "agent-b").unwrap(), + CoordinatorRunRequest::try_new("run-c", "c", "agent-c").unwrap(), + ], + ) + .unwrap(); + + assert_eq!( + started + .iter() + .map(|run| run.run_id.as_str()) + .collect::>(), + ["run-b", "run-c"] + ); + assert_eq!(coordinator.active_run_count(), 2); + assert_eq!(graph.task("b").unwrap().status, TaskStatus::Running); + assert_eq!(graph.task("c").unwrap().status, TaskStatus::Running); + coordinator.finish_run("run-b").unwrap(); + coordinator.finish_run("run-c").unwrap(); + assert_eq!(coordinator.active_run_count(), 0); + } + + #[test] + fn coordinator_cancel_run_updates_graph_and_releases_quota_atomically() { + let mut graph = graph(); + let mut coordinator = Coordinator::new(CoordinatorQuota::try_new(1, 1).unwrap()); + coordinator + .dispatch_wave( + &mut graph, + [CoordinatorRunRequest::try_new("run-a", "a", "agent-a").unwrap()], + ) + .unwrap(); + + let cancelled = coordinator.cancel_run(&mut graph, "run-a").unwrap(); + assert_eq!(cancelled.run_id, "run-a"); + assert_eq!(graph.task("a").unwrap().status, TaskStatus::Cancelled); + assert_eq!(coordinator.active_run_count(), 0); + + // 取消后配额已经释放,但依赖该节点的任务不会被误报为 ready。 + assert!( + coordinator + .try_start_run("run-independent", "node-independent", "agent-a") + .is_ok() + ); + assert!(graph.ready_task_ids().is_empty()); + } + + #[test] + fn coordinator_message_delivery_is_idempotent() { + let mut coordinator = Coordinator::default(); + let message = CoordinatorMessage::try_text("m-1", "node-a", "node-b", "hello").unwrap(); + assert_eq!( + coordinator.deliver_message(message.clone()).unwrap(), + MessageDelivery::Delivered + ); + assert_eq!(coordinator.pending_message_count("node-b"), 1); + assert_eq!( + coordinator.deliver_message(message.clone()).unwrap(), + MessageDelivery::Duplicate + ); + assert_eq!(coordinator.pending_message_count("node-b"), 1); + + let received = coordinator.receive_messages("node-b").unwrap(); + assert_eq!(received, std::slice::from_ref(&message)); + assert!(coordinator.receive_messages("node-b").unwrap().is_empty()); + assert_eq!( + coordinator.deliver_message(message).unwrap(), + MessageDelivery::Duplicate + ); + let conflict = CoordinatorMessage::try_text("m-1", "node-a", "node-b", "changed").unwrap(); + assert!(matches!( + coordinator.deliver_message(conflict), + Err(OrchestrationError::MessageConflict(id)) if id == "m-1" + )); + } + + #[test] + fn coordinator_rejects_messages_to_or_from_isolated_nodes() { + let mut coordinator = Coordinator::default(); + coordinator + .isolate_node("node-a", "temporarily unavailable") + .unwrap(); + + let from_isolated = + CoordinatorMessage::try_text("m-from", "node-a", "node-b", "hello").unwrap(); + assert!(matches!( + coordinator.deliver_message(from_isolated), + Err(OrchestrationError::NodeIsolated(id)) if id == "node-a" + )); + + let to_isolated = + CoordinatorMessage::try_text("m-to", "node-b", "node-a", "hello").unwrap(); + assert!(matches!( + coordinator.deliver_message(to_isolated), + Err(OrchestrationError::NodeIsolated(id)) if id == "node-a" + )); + assert!(matches!( + coordinator.receive_messages("node-a"), + Err(OrchestrationError::NodeIsolated(id)) if id == "node-a" + )); + } + + #[test] + fn coordinator_isolates_one_failed_node_and_keeps_siblings_running() { + let mut graph = TaskGraph::try_new( + "goal", + [ + TaskNode::try_new("node-a", "agent-a", std::iter::empty::()).unwrap(), + TaskNode::try_new("node-b", "agent-b", std::iter::empty::()).unwrap(), + ], + ) + .unwrap(); + let mut coordinator = Coordinator::new(CoordinatorQuota::try_new(2, 1).unwrap()); + coordinator + .dispatch_wave( + &mut graph, + [ + CoordinatorRunRequest::try_new("run-a", "node-a", "agent-a").unwrap(), + CoordinatorRunRequest::try_new("run-b", "node-b", "agent-b").unwrap(), + ], + ) + .unwrap(); + + let released = coordinator + .fail_node(&mut graph, "node-a", "provider failed") + .unwrap(); + assert_eq!(released, ["run-a"]); + assert_eq!(graph.task("node-a").unwrap().status, TaskStatus::Failed); + assert!(coordinator.is_node_isolated("node-a")); + assert_eq!( + coordinator.isolation_reason("node-a"), + Some("provider failed") + ); + assert!(coordinator.active_run("run-a").is_none()); + assert!(coordinator.active_run("run-b").is_some()); + assert!(matches!( + coordinator.try_start_run("run-a2", "node-a", "agent-a"), + Err(OrchestrationError::NodeIsolated(id)) if id == "node-a" + )); + coordinator.finish_run("run-b").unwrap(); + } + + #[test] + fn coordinator_repair_is_explicit_and_releases_affected_runs() { + let mut graph = graph(); + graph.set_task_status("a", TaskStatus::Completed).unwrap(); + let mut coordinator = Coordinator::new(CoordinatorQuota::try_new(2, 1).unwrap()); + coordinator + .dispatch_wave( + &mut graph, + [ + CoordinatorRunRequest::try_new("run-b", "b", "agent-b").unwrap(), + CoordinatorRunRequest::try_new("run-c", "c", "agent-c").unwrap(), + ], + ) + .unwrap(); + coordinator.isolate_node("a", "upstream failed").unwrap(); + + let repaired = coordinator.repair_downstream(&mut graph, &["a"]).unwrap(); + assert_eq!(repaired, ["a", "b", "c"]); + assert!(!coordinator.is_node_isolated("a")); + assert_eq!(coordinator.active_run_count(), 0); + assert!( + graph + .tasks + .iter() + .all(|task| task.status == TaskStatus::Pending) + ); + + let restarted = coordinator + .dispatch_wave( + &mut graph, + [CoordinatorRunRequest::try_new("run-a2", "a", "agent-a").unwrap()], + ) + .unwrap(); + assert_eq!(restarted[0].task_id, "a"); + assert_eq!(graph.task("a").unwrap().status, TaskStatus::Running); + } + + #[test] + fn coordinator_repair_rejects_unknown_seed_without_partial_state() { + let mut graph = graph(); + graph.set_task_status("a", TaskStatus::Completed).unwrap(); + let mut coordinator = Coordinator::new(CoordinatorQuota::try_new(2, 1).unwrap()); + coordinator + .dispatch_wave( + &mut graph, + [CoordinatorRunRequest::try_new("run-b", "b", "agent-b").unwrap()], + ) + .unwrap(); + let graph_before = graph.clone(); + let snapshot_before = coordinator.snapshot(); + + assert!(matches!( + coordinator.repair_downstream(&mut graph, &["missing"]), + Err(OrchestrationError::UnknownTask(id)) if id == "missing" + )); + assert_eq!(graph, graph_before); + assert_eq!(coordinator.snapshot(), snapshot_before); + } + + #[test] + fn coordinator_snapshot_round_trip_rebuilds_indexes_and_message_ledger() { + let quota = CoordinatorQuota::try_new(2, 1).unwrap(); + let mut coordinator = Coordinator::new(quota); + coordinator + .try_start_run("run-a", "node-a", "agent-a") + .unwrap(); + coordinator + .isolate_node("node-x", "temporarily unavailable") + .unwrap(); + + let consumed = + CoordinatorMessage::try_text("m-consumed", "node-a", "node-b", "one").unwrap(); + let queued = CoordinatorMessage::try_text("m-queued", "node-a", "node-c", "two").unwrap(); + coordinator.deliver_message(consumed.clone()).unwrap(); + coordinator.deliver_message(queued.clone()).unwrap(); + assert_eq!( + coordinator.receive_messages("node-b").unwrap(), + std::slice::from_ref(&consumed) + ); + + let encoded = coordinator.export_snapshot_json().unwrap(); + let restored = Coordinator::import_snapshot_json(&encoded).unwrap(); + assert_eq!(restored.snapshot(), coordinator.snapshot()); + assert_eq!(restored.active_run_count_for_agent("agent-a"), 1); + assert_eq!(restored.pending_message_count("node-c"), 1); + assert_eq!( + restored.isolation_reason("node-x"), + Some("temporarily unavailable") + ); + + // 派生索引和已消费消息的去重账本都在导入后继续生效。 + let mut restored = restored; + assert!(matches!( + restored.try_start_run("run-a-duplicate", "node-a", "agent-a"), + Err(OrchestrationError::DuplicateRun(_)) + )); + assert_eq!( + restored.deliver_message(consumed).unwrap(), + MessageDelivery::Duplicate + ); + assert_eq!( + restored.receive_messages("node-c").unwrap(), + std::slice::from_ref(&queued) + ); + assert_eq!( + restored.deliver_message(queued).unwrap(), + MessageDelivery::Duplicate + ); + } + + #[test] + fn coordinator_snapshot_restore_preserves_quota_isolation_and_message_dedupe() { + let quota = CoordinatorQuota::try_new(2, 1).unwrap(); + let mut coordinator = Coordinator::new(quota); + coordinator + .try_start_run("run-a", "node-a", "agent-a") + .unwrap(); + coordinator + .isolate_node("node-x", "provider unavailable") + .unwrap(); + let message = CoordinatorMessage::try_text("m-1", "node-a", "node-b", "hello").unwrap(); + assert_eq!( + coordinator.deliver_message(message.clone()).unwrap(), + MessageDelivery::Delivered + ); + + let store = InMemoryCoordinatorStore::new(); + coordinator.save_to(&store).unwrap(); + let mut restored = Coordinator::load_from(&store).unwrap().unwrap(); + + assert_eq!(restored.pending_message_count("node-b"), 1); + assert_eq!( + restored.deliver_message(message).unwrap(), + MessageDelivery::Duplicate + ); + assert!(matches!( + restored.try_start_run("run-b", "node-b", "agent-a"), + Err(OrchestrationError::QuotaExceeded { scope, .. }) + if scope == "agent:agent-a" + )); + assert!(matches!( + restored.try_start_run("run-x", "node-x", "agent-x"), + Err(OrchestrationError::NodeIsolated(id)) if id == "node-x" + )); + assert!(matches!( + restored.deliver_message( + CoordinatorMessage::try_text("m-x", "node-x", "node-b", "blocked").unwrap() + ), + Err(OrchestrationError::NodeIsolated(id)) if id == "node-x" + )); + } + + #[test] + fn coordinator_snapshot_rejects_unknown_version_and_mailbox_mismatch() { + let coordinator = Coordinator::default(); + let mut snapshot = coordinator.snapshot(); + snapshot.schema_version = "agent-runtime-coordinator.v0".to_owned(); + assert!(matches!( + Coordinator::from_snapshot(snapshot), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("不支持的 coordinator 快照版本") + )); + + let mut snapshot = coordinator.snapshot(); + let message = CoordinatorMessage::try_text("m-1", "node-a", "node-b", "hello").unwrap(); + snapshot.messages.push(message); + snapshot + .mailboxes + .insert("node-c".to_owned(), vec!["m-1".to_owned()]); + assert!(matches!( + Coordinator::from_snapshot(snapshot), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("mailbox 与消息目标不一致") + )); + } + + #[test] + fn coordinator_store_round_trip_and_empty_load_are_explicit() { + let store = InMemoryCoordinatorStore::new(); + assert!(Coordinator::load_from(&store).unwrap().is_none()); + + let mut coordinator = Coordinator::default(); + coordinator + .try_start_run("run-a", "node-a", "agent-a") + .unwrap(); + coordinator.save_to(&store).unwrap(); + let restored = Coordinator::load_from(&store).unwrap().unwrap(); + assert_eq!(restored.snapshot(), coordinator.snapshot()); + } + + fn assert_store_round_trip(store: &S) { + assert!(Coordinator::load_from(store).unwrap().is_none()); + + let mut coordinator = Coordinator::default(); + coordinator + .try_start_run("run-contract", "node-contract", "agent-contract") + .unwrap(); + let expected = coordinator.snapshot(); + coordinator.save_to(store).unwrap(); + let restored = Coordinator::load_from(store).unwrap().unwrap(); + assert_eq!(restored.snapshot(), expected); + + // Store 合同要求拒绝无法由 Coordinator 导入的快照,并保留之前的有效值。 + let mut invalid = expected.clone(); + invalid.schema_version = "invalid-version".to_owned(); + assert!(store.save_snapshot(&invalid).is_err()); + assert_eq!( + Coordinator::load_from(store).unwrap().unwrap().snapshot(), + expected + ); + } + + #[test] + fn coordinator_store_contract_is_shared_by_memory_and_json_file_adapters() { + let memory = InMemoryCoordinatorStore::new(); + assert_store_round_trip(&memory); + + let path = test_temp_path("agent-runtime-coordinator-contract"); + let _ = std::fs::remove_file(&path); + let file = JsonFileCoordinatorStore::new(&path).unwrap(); + assert_store_round_trip(&file); + let _ = std::fs::remove_file(path); + } + + #[test] + fn json_file_store_replaces_atomically_and_rejects_corrupt_or_oversized_data() { + let path = test_temp_path("agent-runtime-coordinator-test"); + let _ = std::fs::remove_file(&path); + let store = JsonFileCoordinatorStore::new(&path).unwrap(); + let mut coordinator = Coordinator::default(); + coordinator + .try_start_run("run-a", "node-a", "agent-a") + .unwrap(); + coordinator.save_to(&store).unwrap(); + let restored = Coordinator::load_from(&store).unwrap().unwrap(); + assert_eq!(restored.snapshot(), coordinator.snapshot()); + + // 写入非 JSON 内容时,读取会失败而不会返回半个控制面状态。 + std::fs::write(&path, b"{").unwrap(); + assert!(matches!( + store.load_snapshot(), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("无法解码") + )); + + // 大文件在解析前被拒绝,避免把文件适配器变成无界内存入口。 + std::fs::write(&path, vec![b'x'; MAX_COORDINATOR_SNAPSHOT_BYTES + 1]).unwrap(); + assert!(matches!( + store.load_snapshot(), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("字节上限") + )); + let _ = std::fs::remove_file(path); + } + + #[test] + fn coordinator_store_does_not_save_invalid_snapshot() { + let store = InMemoryCoordinatorStore::new(); + let mut snapshot = Coordinator::default().snapshot(); + snapshot.schema_version = "unknown-version".to_owned(); + assert!(store.save_snapshot(&snapshot).is_err()); + assert!(store.load_snapshot().unwrap().is_none()); + } + + #[test] + fn persistent_coordinator_round_trips_graph_and_control_state() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut controller = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + store.clone(), + ) + .unwrap(); + assert_eq!(controller.revision(), 0); + + controller + .dispatch_wave([CoordinatorRunRequest::try_new("run-a", "a", "agent-a").unwrap()]) + .unwrap(); + controller.complete_run("run-a").unwrap(); + controller + .dispatch_wave([ + CoordinatorRunRequest::try_new("run-b", "b", "agent-b").unwrap(), + CoordinatorRunRequest::try_new("run-c", "c", "agent-c").unwrap(), + ]) + .unwrap(); + let message = CoordinatorMessage::try_text("m-1", "b", "c", "hello").unwrap(); + assert_eq!( + controller.deliver_message(message.clone()).unwrap(), + MessageDelivery::Delivered + ); + + let restored = PersistentCoordinator::load(store) + .unwrap() + .expect("snapshot should exist"); + assert_eq!(restored.revision(), controller.revision()); + assert_eq!(restored.graph(), controller.graph()); + assert_eq!( + restored.coordinator().active_run_count_for_agent("agent-b"), + 1 + ); + assert_eq!(restored.coordinator().pending_message_count("c"), 1); + assert_eq!( + restored.graph().task("a").unwrap().status, + TaskStatus::Completed + ); + + let mut restored = restored; + assert_eq!( + restored.receive_messages("c").unwrap(), + std::slice::from_ref(&message) + ); + assert_eq!(restored.coordinator().pending_message_count("c"), 0); + } + + #[test] + fn persistent_cancel_run_commits_cancelled_state_and_releases_quota() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut controller = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(1, 1).unwrap(), + store.clone(), + ) + .unwrap(); + controller + .dispatch_wave([CoordinatorRunRequest::try_new("run-a", "a", "agent-a").unwrap()]) + .unwrap(); + let cancelled = controller.cancel_run("run-a").unwrap(); + + assert_eq!(cancelled.run_id, "run-a"); + assert_eq!(controller.revision(), 2); + assert_eq!( + controller.graph().task("a").unwrap().status, + TaskStatus::Cancelled + ); + assert_eq!(controller.coordinator().active_run_count(), 0); + assert!(controller.graph().ready_task_ids().is_empty()); + + let restored = PersistentCoordinator::load(store) + .unwrap() + .expect("cancelled snapshot should be recoverable"); + assert_eq!(restored.revision(), 2); + assert_eq!( + restored.graph().task("a").unwrap().status, + TaskStatus::Cancelled + ); + assert_eq!(restored.coordinator().active_run_count(), 0); + } + + #[test] + fn persistent_coordinator_cas_conflict_does_not_leak_uncommitted_state() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut first = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + store.clone(), + ) + .unwrap(); + let mut stale = PersistentCoordinator::load(store.clone()) + .unwrap() + .expect("stale controller"); + + first + .deliver_message(CoordinatorMessage::try_text("m-1", "a", "b", "first").unwrap()) + .unwrap(); + let error = stale + .deliver_message(CoordinatorMessage::try_text("m-2", "a", "b", "stale").unwrap()) + .expect_err("stale write must fail CAS"); + assert!(matches!(error, OrchestrationError::RevisionConflict { .. })); + assert_eq!(stale.revision(), 0); + assert_eq!(stale.coordinator().pending_message_count("b"), 0); + + stale.reload().unwrap(); + assert_eq!(stale.revision(), 1); + assert_eq!(stale.coordinator().pending_message_count("b"), 1); + } + + #[test] + fn persistent_apply_proposal_is_atomic_and_revision_cas_protected() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut first = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + store.clone(), + ) + .unwrap(); + let mut stale = PersistentCoordinator::load(store.clone()) + .unwrap() + .expect("stale controller"); + + // Keep an active run in the same snapshot so proposal installation must + // preserve Coordinator state while replacing only the graph epoch. + first + .dispatch_wave([CoordinatorRunRequest::try_new("run-a", "a", "agent-a").unwrap()]) + .unwrap(); + let proposal = GraphProposal::try_new( + [TaskProposal::try_new("d", "agent-d").unwrap()], + [GraphEdge::try_new("a", "d").unwrap()], + ) + .unwrap(); + let candidate = first.apply_proposal(&proposal).unwrap(); + assert_eq!(candidate.task("d").unwrap().dependencies, ["a"]); + assert_eq!(first.revision(), 2); + assert_eq!(first.coordinator().active_run_count(), 1); + assert_eq!(first.graph().task("d").unwrap().status, TaskStatus::Pending); + + let committed = store.load_snapshot().unwrap().expect("committed snapshot"); + assert_eq!(committed.revision, 2); + assert!(committed.graph.task("d").is_some()); + assert_eq!(committed.coordinator.active_runs.len(), 1); + + // The stale controller computes a candidate locally, but its old + // revision cannot overwrite the committed graph. Its in-memory state + // and the durable snapshot both remain unchanged after the conflict. + let stale_before = stale.snapshot(); + let stale_proposal = GraphProposal::try_new( + [TaskProposal::try_new("e", "agent-e").unwrap()], + [GraphEdge::try_new("a", "e").unwrap()], + ) + .unwrap(); + let error = stale + .apply_proposal(&stale_proposal) + .expect_err("stale proposal must fail revision CAS"); + assert!(matches!(error, OrchestrationError::RevisionConflict { .. })); + assert_eq!(stale.snapshot(), stale_before); + assert_eq!(store.load_snapshot().unwrap().unwrap(), committed); + + // A proposal rejected before mutate also leaves the active run, graph, + // revision and durable snapshot untouched. + let invalid = GraphProposal::try_new( + [TaskProposal::try_new("f", "agent-d").unwrap()], + [GraphEdge::try_new("a", "b").unwrap()], + ) + .unwrap(); + assert!(first.apply_proposal(&invalid).is_err()); + assert_eq!(first.revision(), committed.revision); + assert_eq!(first.coordinator().active_run_count(), 1); + assert_eq!(store.load_snapshot().unwrap().unwrap(), committed); + } + + #[test] + fn persistent_file_store_recovers_graph_after_reopen() { + let path = test_temp_path("agent-runtime-orchestration"); + let _ = fs::remove_file(&path); + { + let store = JsonFileOrchestrationSnapshotStore::new(&path).unwrap(); + let mut controller = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + store, + ) + .unwrap(); + controller + .dispatch_wave([CoordinatorRunRequest::try_new("run-a", "a", "agent-a").unwrap()]) + .unwrap(); + } + let reopened = JsonFileOrchestrationSnapshotStore::new(&path).unwrap(); + let restored = PersistentCoordinator::load(reopened) + .unwrap() + .expect("reopened snapshot"); + assert_eq!(restored.revision(), 1); + assert_eq!( + restored.graph().task("a").unwrap().status, + TaskStatus::Running + ); + assert_eq!(restored.coordinator().active_run_count(), 1); + let lock_path = orchestration_lock_path(&path); + let _ = fs::remove_file(&path); + // The orchestration store creates a same-directory advisory lock on + // the first write. Remove that sidecar with the fixture so repeated + // test runs do not accumulate control-plane artifacts in temp. + let _ = fs::remove_file(lock_path); + } + + #[test] + fn persistent_file_store_serializes_cross_instance_revision_cas() { + let path = test_temp_path("agent-runtime-orchestration-lock"); + let lock_path = orchestration_lock_path(&path); + let _ = fs::remove_file(&path); + let _ = fs::remove_file(&lock_path); + + // 先写入 revision 0,两个独立 store 随后都基于同一旧值竞争 revision 1。 + // sidecar 锁让其中一个完成 rename 后,另一个在临界区内重新读取到 + // revision 1 并返回 CAS 冲突,而不是双双声称写入成功。 + let seed = JsonFileOrchestrationSnapshotStore::new(&path).unwrap(); + let initial = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + InMemoryOrchestrationSnapshotStore::new(), + ) + .unwrap(); + let snapshot = initial.snapshot(); + seed.save_snapshot(None, &snapshot).unwrap(); + + let next = OrchestrationSnapshot { + revision: 1, + ..snapshot.clone() + }; + let path_a = path.clone(); + let path_b = path.clone(); + let next_a = next.clone(); + let next_b = next; + let (first, second) = std::thread::scope(|scope| { + let a = scope.spawn(|| { + JsonFileOrchestrationSnapshotStore::new(path_a) + .unwrap() + .save_snapshot(Some(0), &next_a) + }); + let b = scope.spawn(|| { + JsonFileOrchestrationSnapshotStore::new(path_b) + .unwrap() + .save_snapshot(Some(0), &next_b) + }); + (a.join().unwrap(), b.join().unwrap()) + }); + let successes = (if first.is_ok() { 1 } else { 0 }) + (if second.is_ok() { 1 } else { 0 }); + assert_eq!(successes, 1, "exactly one CAS writer may win"); + assert!(matches!( + (first, second), + (Err(OrchestrationError::RevisionConflict { .. }), Ok(())) + | (Ok(()), Err(OrchestrationError::RevisionConflict { .. })) + )); + assert_eq!( + JsonFileOrchestrationSnapshotStore::new(&path) + .unwrap() + .load_snapshot() + .unwrap() + .unwrap() + .revision, + 1 + ); + + let _ = fs::remove_file(path); + let _ = fs::remove_file(lock_path); + } + + #[test] + fn persistent_isolation_moves_active_task_to_waiting_and_survives_reload() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut controller = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + store.clone(), + ) + .unwrap(); + controller + .dispatch_wave([CoordinatorRunRequest::try_new("run-a", "a", "agent-a").unwrap()]) + .unwrap(); + + let released = controller + .isolate_node("a", "provider temporarily unavailable") + .unwrap(); + assert_eq!(released, ["run-a"]); + assert_eq!( + controller.graph().task("a").unwrap().status, + TaskStatus::Waiting + ); + assert_eq!(controller.coordinator().active_run_count(), 0); + assert!(controller.graph().ready_task_ids().is_empty()); + + // The persisted epoch must not resurrect a Running task without a run. + let mut restored = PersistentCoordinator::load(store) + .unwrap() + .expect("isolated snapshot should be recoverable"); + assert_eq!( + restored.graph().task("a").unwrap().status, + TaskStatus::Waiting + ); + assert_eq!(restored.coordinator().active_run_count(), 0); + assert!(matches!( + restored.graph().dependency_waves(), + Err(OrchestrationError::DependencyBlocked(_)) + )); + + // Repair is the explicit recovery boundary: it clears quarantine and + // makes the seed task dispatchable in the next epoch. + assert_eq!(restored.repair_downstream(&["a"]).unwrap(), ["a", "b", "c"]); + assert_eq!(restored.graph().ready_task_ids(), ["a"]); + let started = restored + .dispatch_wave([CoordinatorRunRequest::try_new("run-a2", "a", "agent-a").unwrap()]) + .unwrap(); + assert_eq!(started[0].run_id, "run-a2"); + assert_eq!( + restored.graph().task("a").unwrap().status, + TaskStatus::Running + ); + } + + #[test] + fn persistent_snapshot_rejects_active_run_graph_mismatch() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let graph = graph(); + let coordinator = { + let mut coordinator = Coordinator::default(); + coordinator.try_start_run("run-a", "a", "agent-a").unwrap(); + coordinator.snapshot() + }; + let snapshot = OrchestrationSnapshot { + schema_version: ORCHESTRATION_SNAPSHOT_SCHEMA_VERSION.to_owned(), + revision: 0, + graph, + coordinator, + }; + assert!(matches!( + store.save_snapshot(None, &snapshot), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("不是 running") + )); + assert!(store.load_snapshot().unwrap().is_none()); + } + + #[test] + fn persistent_snapshot_rejects_running_task_without_active_run() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut graph = graph(); + graph.set_task_status("a", TaskStatus::Running).unwrap(); + let snapshot = OrchestrationSnapshot { + schema_version: ORCHESTRATION_SNAPSHOT_SCHEMA_VERSION.to_owned(), + revision: 0, + graph, + coordinator: Coordinator::default().snapshot(), + }; + + // 反向不变量同样是持久化合同的一部分,避免恢复后 Running 永远卡在 + // 图中却没有可完成、可释放的活动 run。 + assert!(matches!( + store.save_snapshot(None, &snapshot), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("running 任务 a 没有对应活动 run") + )); + assert!(store.load_snapshot().unwrap().is_none()); + assert!(matches!( + PersistentCoordinator::from_snapshot(snapshot, store), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("running 任务 a 没有对应活动 run") + )); + } + + #[test] + fn ready_plan_is_bounded_deterministic_and_claims_in_one_epoch() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut controller = PersistentCoordinator::new( + graph(), + CoordinatorQuota::try_new(2, 1).unwrap(), + store.clone(), + ) + .unwrap(); + + let plan = controller.plan_ready_tasks(8).unwrap(); + assert_eq!(plan.expected_revision, 0); + assert_eq!( + plan.candidates + .iter() + .map(|candidate| candidate.task_id.as_str()) + .collect::>(), + ["a"] + ); + let started = controller.claim_ready_tasks(&plan, ["run-a"]).unwrap(); + assert_eq!(started[0].task_id, "a"); + assert_eq!(controller.revision(), 1); + assert_eq!( + controller.graph().task("a").unwrap().status, + TaskStatus::Running + ); + assert_eq!( + PersistentCoordinator::load(store) + .unwrap() + .unwrap() + .revision(), + 1 + ); + + // 同一计划不能被重复领取;revision CAS 在任何图/配额变更前拒绝它。 + assert!(matches!( + controller.claim_ready_tasks(&plan, ["run-a-retry"]), + Err(OrchestrationError::RevisionConflict { + expected: Some(0), + actual: Some(1) + }) + )); + + controller.complete_run("run-a").unwrap(); + let next_plan = controller.plan_ready_tasks(2).unwrap(); + assert_eq!( + next_plan + .candidates + .iter() + .map(|candidate| candidate.task_id.as_str()) + .collect::>(), + ["b", "c"] + ); + let started = controller + .claim_ready_tasks(&next_plan, ["run-b", "run-c"]) + .unwrap(); + assert_eq!( + started + .iter() + .map(|run| run.run_id.as_str()) + .collect::>(), + ["run-b", "run-c"] + ); + } + + #[test] + fn ready_plan_honors_isolation_and_per_agent_quota() { + let graph = TaskGraph::try_new( + "goal", + [ + TaskNode::try_new("a", "agent-a", std::iter::empty::()).unwrap(), + TaskNode::try_new("b", "agent-a", std::iter::empty::()).unwrap(), + TaskNode::try_new("c", "agent-b", std::iter::empty::()).unwrap(), + ], + ) + .unwrap(); + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut controller = + PersistentCoordinator::new(graph, CoordinatorQuota::try_new(3, 1).unwrap(), store) + .unwrap(); + + // 同一个 Agent 的第二个 ready 任务被跳过,但其它 Agent 仍可入计划。 + let plan = controller.plan_ready_tasks(3).unwrap(); + assert_eq!( + plan.candidates + .iter() + .map(|candidate| candidate.task_id.as_str()) + .collect::>(), + ["a", "c"] + ); + + // 隔离 a 后释放其配额;隔离节点本身不会再次进入 ready 计划,b 可以 + // 在下一个 revision 中使用 agent-a 的空闲配额。 + controller + .isolate_node("a", "agent-a temporarily unavailable") + .unwrap(); + let plan = controller.plan_ready_tasks(3).unwrap(); + assert_eq!( + plan.candidates + .iter() + .map(|candidate| candidate.task_id.as_str()) + .collect::>(), + ["b", "c"] + ); + assert!( + plan.candidates + .iter() + .all(|candidate| candidate.task_id != "a") + ); + } + + #[test] + fn ready_plan_claim_rejects_stale_or_mismatched_run_ids_without_state_leak() { + let store = InMemoryOrchestrationSnapshotStore::new(); + let mut controller = + PersistentCoordinator::new(graph(), CoordinatorQuota::try_new(2, 1).unwrap(), store) + .unwrap(); + let plan = controller.plan_ready_tasks(1).unwrap(); + + controller + .deliver_message(CoordinatorMessage::try_text("m-1", "a", "b", "advance").unwrap()) + .unwrap(); + let before = controller.snapshot(); + assert!(matches!( + controller.claim_ready_tasks(&plan, ["run-a"]), + Err(OrchestrationError::RevisionConflict { + expected: Some(0), + actual: Some(1) + }) + )); + assert_eq!(controller.snapshot(), before); + + let fresh = controller.plan_ready_tasks(1).unwrap(); + let before = controller.snapshot(); + assert!(matches!( + controller.claim_ready_tasks(&fresh, std::iter::empty::()), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("run ID 数量不一致") + )); + assert_eq!(controller.snapshot(), before); + assert!(matches!( + controller.claim_ready_tasks(&fresh, ["run-a", "run-extra"]), + Err(OrchestrationError::InvalidInput(message)) + if message.contains("run ID 数量不一致") + )); + assert_eq!(controller.snapshot(), before); + } + + #[test] + fn ready_plan_constructor_and_json_deserialization_reapply_validation() { + assert!(matches!( + ReadyTaskDispatchPlan::try_new( + 0, + [ + ReadyTaskCandidate::try_new("a", "agent").unwrap(), + ReadyTaskCandidate::try_new("a", "agent").unwrap(), + ], + ), + Err(OrchestrationError::DuplicateTask(id)) if id == "a" + )); + assert!( + serde_json::from_str::( + r#"{"expectedRevision":0,"candidates":[{"taskId":"","agentId":"agent"}]}"# + ) + .is_err() + ); + assert!(serde_json::from_str::( + r#"{"expectedRevision":0,"candidates":[{"taskId":"a","agentId":"agent"},{"taskId":"a","agentId":"agent"}]}"# + ) + .is_err()); + assert!(matches!( + ReadyTaskDispatchPlan::try_new(0, std::iter::empty::()) + .unwrap() + .candidates, + candidates if candidates.is_empty() + )); + } +} diff --git a/rust/crates/agent-runtime-sqlite/Cargo.toml b/rust/crates/agent-runtime-sqlite/Cargo.toml new file mode 100644 index 000000000..71bf781a6 --- /dev/null +++ b/rust/crates/agent-runtime-sqlite/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "agent-runtime-sqlite" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false +description = "SQLite durable runtime adapter and single-agent runtime service" + +[dependencies] +agent-runtime-core.workspace = true +agent-runtime-contracts.workspace = true +agent-runtime.workspace = true +agent-storage-sqlite = { workspace = true, features = ["core-adapter"] } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +agent-runtime-core.workspace = true diff --git a/rust/crates/agent-runtime-sqlite/src/durable_sqlite.rs b/rust/crates/agent-runtime-sqlite/src/durable_sqlite.rs new file mode 100644 index 000000000..cbe379614 --- /dev/null +++ b/rust/crates/agent-runtime-sqlite/src/durable_sqlite.rs @@ -0,0 +1,755 @@ +//! SQLite-backed implementation of the generic DurableStore facade. +//! +//! The adapter is kept in this crate so the portable `agent-runtime` facade +//! remains independent of SQLite names and dependencies. + +use super::*; +use agent_runtime_core::RuntimeStore; +use agent_storage_sqlite::{ + NewRun, NewSession, RunLeaseRecord, RunRecord, SqliteStore, StorageError, +}; +use serde_json::Value; +use thiserror::Error; + +/// SQLite 的第一阶段 DurableStore 适配器。 +/// +/// 该 wrapper 只拥有一个可 Clone 的 SQLite 句柄;它不向合同泄漏表结构, +/// 也不复制 Runtime 的第二份状态。后续 Runtime 泛型化时可直接替换它。 +#[derive(Clone, Debug)] +pub struct SqliteDurableStore { + store: SqliteStore, +} + +impl SqliteDurableStore { + pub fn new(store: SqliteStore) -> Self { + Self { store } + } + + /// 仅供 Runtime 的兼容诊断/旧 API 访问底层句柄;DurableStore 合同本身 + /// 不暴露该具体类型。RuntimeService 不再单独持有第二份 SqliteStore。 + pub(crate) fn sqlite_store(&self) -> &SqliteStore { + &self.store + } +} + +#[derive(Debug, Error)] +pub enum SqliteDurableStoreError { + #[error("SQLite durable store: {0}")] + Storage(#[from] StorageError), + #[error("Runtime snapshot store: {0}")] + Runtime(#[from] agent_runtime_core::StoreError), +} + +impl From for SqliteDurableStoreError { + fn from(error: DurableStoreUnsupported) -> Self { + Self::Storage(StorageError::InvalidInput(error.to_string())) + } +} + +impl DurableStore for SqliteDurableStore { + type Error = SqliteDurableStoreError; + + fn create_run_bundle( + &self, + bundle: DurableRunBundle, + ) -> std::result::Result { + let session_id = bundle.session.id.clone(); + let run_id = bundle.run.id.clone(); + let runtime_id = bundle.runtime_id.clone(); + self.store.create_run_bundle( + NewSession { + id: bundle.session.id, + agent_id: bundle.session.agent_id, + status: bundle.session.status, + metadata: bundle.session.metadata, + }, + NewRun { + id: bundle.run.id, + session_id: bundle.run.session_id, + status: bundle.run.status, + input: bundle.run.input, + }, + &bundle.runtime_id, + &bundle.snapshot, + &bundle.events, + )?; + Ok(DurableBundleResult { + session_id, + run_id, + runtime_id, + }) + } + + fn get_run(&self, run_id: &str) -> std::result::Result, Self::Error> { + Ok(self.store.get_run(run_id)?.map(run_view)) + } + + fn get_session( + &self, + session_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self.store.get_session(session_id)?.map(session_view)) + } + + fn is_cancel_requested(&self, run_id: &str) -> std::result::Result { + Ok(self.store.is_cancel_requested(run_id)?) + } + + fn runtime_id_for_run(&self, run_id: &str) -> std::result::Result, Self::Error> { + Ok(self.store.runtime_id_for_run(run_id)?) + } + + fn update_session( + &self, + session_id: &str, + status: &str, + metadata: Option, + ) -> std::result::Result { + Ok(session_view( + self.store.update_session(session_id, status, metadata)?, + )) + } + + fn claim_run_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> std::result::Result { + let (run, lease) = + self.store + .claim_run_with_lease(run_id, worker_id, lease_token, lease_duration)?; + Ok(DurableClaimResult { + run: run_view(run), + lease: lease_view(lease), + }) + } + + fn get_run_lease( + &self, + run_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self.store.get_run_lease(run_id)?.map(lease_view)) + } + + fn heartbeat_run( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> std::result::Result { + Ok(lease_view(self.store.heartbeat_run( + run_id, + worker_id, + lease_token, + lease_duration, + )?)) + } + + fn release_run_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> std::result::Result { + Ok(run_view(self.store.release_run_lease( + run_id, + worker_id, + lease_token, + )?)) + } + + fn request_cancel(&self, run_id: &str) -> std::result::Result { + Ok(run_view(self.store.request_cancel(run_id)?)) + } + + fn list_stale_run_ids( + &self, + limit: usize, + now_ms: i64, + ) -> std::result::Result, Self::Error> { + Ok(self.store.list_stale_run_ids(limit, now_ms)?) + } + + fn requeue_safe_run(&self, run_id: &str) -> std::result::Result { + Ok(run_view(self.store.requeue_reconciled_run(run_id)?)) + } + + fn read_checkpoint( + &self, + run_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self.store.read_checkpoint(run_id)?.map(checkpoint_view)) + } + + fn read_checkpoint_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> std::result::Result, Self::Error> { + Ok(self + .store + .get_checkpoint(run_id, worker_id, lease_token)? + .map(checkpoint_view)) + } + + fn save_checkpoint_with_lease( + &self, + checkpoint: DurableCheckpointInput, + worker_id: &str, + lease_token: &str, + ) -> std::result::Result { + Ok(checkpoint_view(self.store.save_checkpoint_with_lease( + checkpoint_input(checkpoint), + worker_id, + lease_token, + )?)) + } + + fn save_checkpoint_with_runtime_and_lease( + &self, + commit: DurableCheckpointRuntimeCommit, + worker_id: &str, + lease_token: &str, + ) -> std::result::Result { + Ok(checkpoint_view( + self.store.save_checkpoint_with_runtime_and_lease( + checkpoint_input(commit.checkpoint), + worker_id, + lease_token, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?, + )) + } + + fn record_reconciliation_result( + &self, + run_id: &str, + phase: &str, + external_id: &str, + step: i64, + attempt: i64, + messages: Value, + ) -> std::result::Result { + Ok(checkpoint_view(self.store.record_reconciliation_result( + run_id, + phase, + external_id, + step, + attempt, + messages, + )?)) + } + + fn create_approval( + &self, + approval: DurableApprovalInput, + ) -> std::result::Result { + Ok(approval_view( + self.store.create_approval(approval_input(approval))?, + )) + } + + fn create_approval_with_checkpoint_runtime_and_lease( + &self, + commit: DurableApprovalCheckpointRuntimeCommit, + ) -> std::result::Result { + Ok(approval_view( + self.store + .create_approval_with_checkpoint_runtime_and_lease(commit)?, + )) + } + + fn get_approval( + &self, + approval_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self.store.get_approval(approval_id)?.map(approval_view)) + } + + fn list_approvals_for_run( + &self, + run_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self + .store + .list_approvals_for_run(run_id)? + .into_iter() + .map(approval_view) + .collect()) + } + + fn get_approval_for_run_call( + &self, + run_id: &str, + tool_call_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self + .store + .get_approval_for_run_call(run_id, tool_call_id)? + .map(approval_view)) + } + + fn resolve_approval( + &self, + resolution: DurableApprovalResolution, + ) -> std::result::Result { + Ok(approval_view(self.store.resolve_approval( + &resolution.approval_id, + &resolution.expected_status, + &resolution.status, + resolution.decision, + )?)) + } + + fn cancel_pending_approvals(&self, run_id: &str) -> std::result::Result { + Ok(self.store.cancel_pending_approvals(run_id)?) + } + + fn queue_approved_run( + &self, + approval_id: &str, + ) -> std::result::Result { + Ok(run_view(self.store.queue_approved_run(approval_id)?)) + } + + fn finish_run_with_runtime( + &self, + command: DurableFinishCommand, + ) -> std::result::Result { + let DurableFinishCommand { + run_id, + lease, + target, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + guard, + } = command; + let result = match (target, guard, lease) { + (DurableFinishTarget::Cancelled, DurableFinishGuard::QueuedUnclaimed, None) => { + self.store.mark_cancelled_if_queued_unclaimed_with_runtime( + &run_id, + output, + &runtime_id, + expected_runtime_revision, + &snapshot, + &events, + )? + } + (DurableFinishTarget::Completed, DurableFinishGuard::None, Some(lease)) => { + self.store.complete_run_with_lease_and_runtime( + &run_id, + &lease.worker_id, + &lease.lease_token, + output, + &runtime_id, + expected_runtime_revision, + &snapshot, + &events, + )? + } + (DurableFinishTarget::Failed, DurableFinishGuard::None, Some(lease)) => { + self.store.fail_run_with_lease_and_runtime( + &run_id, + &lease.worker_id, + &lease.lease_token, + output, + &runtime_id, + expected_runtime_revision, + &snapshot, + &events, + )? + } + (DurableFinishTarget::Cancelled, DurableFinishGuard::None, Some(lease)) => { + self.store.mark_cancelled_with_lease_and_runtime( + &run_id, + &lease.worker_id, + &lease.lease_token, + output, + &runtime_id, + expected_runtime_revision, + &snapshot, + &events, + )? + } + (DurableFinishTarget::Failed, DurableFinishGuard::None, None) => { + self.store.fail_run_with_runtime( + &run_id, + output, + &runtime_id, + expected_runtime_revision, + &snapshot, + &events, + )? + } + (DurableFinishTarget::Cancelled, DurableFinishGuard::None, None) => { + self.store.mark_cancelled_with_runtime( + &run_id, + output, + &runtime_id, + expected_runtime_revision, + &snapshot, + &events, + )? + } + _ => { + return Err(SqliteDurableStoreError::Storage( + StorageError::InvalidInput( + "终态 command 的 target、guard 和 lease 组合无效".to_owned(), + ), + )); + } + }; + Ok(run_view(result)) + } + + fn mark_cancelled_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + ) -> std::result::Result { + Ok(run_view(self.store.mark_cancelled_with_lease( + run_id, + worker_id, + lease_token, + output, + )?)) + } + + fn mark_cancelled( + &self, + run_id: &str, + output: Option, + ) -> std::result::Result { + Ok(run_view(self.store.mark_cancelled(run_id, output)?)) + } + + fn recover_expired_run( + &self, + run_id: &str, + ) -> std::result::Result { + Ok(run_view(self.store.recover_expired_run(run_id)?)) + } + + fn recover_expired_run_with_runtime( + &self, + commit: DurableRecoveryCommit, + ) -> std::result::Result { + Ok(run_view(self.store.recover_expired_run_with_runtime( + &commit.run_id, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?)) + } + + fn upsert_external_session( + &self, + session: DurableExternalSessionInput, + ) -> std::result::Result { + Ok(external_session_view(self.store.upsert_external_session( + external_session_input(session), + )?)) + } + + fn update_external_session( + &self, + id: &str, + external_id: &str, + status: &str, + metadata: Value, + ) -> std::result::Result { + Ok(external_session_view(self.store.update_external_session( + id, + external_id, + status, + metadata, + )?)) + } + + fn create_tool_call( + &self, + call: DurableToolCallInput, + ) -> std::result::Result { + Ok(tool_call_view( + self.store.insert_tool_call(tool_call_input(call))?, + )) + } + + fn complete_tool_call( + &self, + call_id: &str, + status: &str, + result: Value, + ) -> std::result::Result { + Ok(tool_call_view( + self.store.complete_tool_call(call_id, status, result)?, + )) + } + + fn create_tool_call_with_runtime_and_lease( + &self, + commit: DurableToolCallRuntimeCommit, + ) -> std::result::Result { + Ok(tool_call_view( + self.store.create_tool_call_with_runtime_and_lease(commit)?, + )) + } + + fn complete_tool_call_with_runtime_and_lease( + &self, + commit: DurableToolCallRuntimeCommit, + status: &str, + result: Value, + ) -> std::result::Result { + Ok(tool_call_view( + self.store + .complete_tool_call_with_runtime_and_lease(commit, status, result)?, + )) + } + + fn create_tool_call_with_checkpoint_runtime_and_lease( + &self, + commit: DurableToolCallCheckpointRuntimeCommit, + ) -> std::result::Result { + Ok(tool_call_view( + self.store + .create_tool_call_with_checkpoint_runtime_and_lease(commit)?, + )) + } + + fn complete_tool_call_with_checkpoint_runtime_and_lease( + &self, + commit: DurableToolCallCheckpointRuntimeCommit, + status: &str, + result: Value, + ) -> std::result::Result { + Ok(tool_call_view( + self.store + .complete_tool_call_with_checkpoint_runtime_and_lease(commit, status, result)?, + )) + } + + fn get_tool_call( + &self, + call_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self.store.get_tool_call(call_id)?.map(tool_call_view)) + } + + fn list_tool_calls_for_run( + &self, + run_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self + .store + .list_tool_calls_for_run(run_id)? + .into_iter() + .map(tool_call_view) + .collect()) + } + + fn get_external_session( + &self, + id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self + .store + .get_external_session(id)? + .map(external_session_view)) + } + + fn list_external_sessions( + &self, + statuses: &[&str], + run_id: Option<&str>, + limit: usize, + ) -> std::result::Result, Self::Error> { + Ok(self + .store + .list_external_sessions(statuses, run_id, limit)? + .into_iter() + .map(external_session_view) + .collect()) + } + + fn load_runtime_snapshot( + &self, + runtime_id: &str, + ) -> std::result::Result, Self::Error> { + Ok(self.store.load_runtime_snapshot(runtime_id)?) + } + + fn commit_runtime_snapshot( + &self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> std::result::Result<(), Self::Error> { + let mut store = self.store.clone(); + RuntimeStore::commit(&mut store, runtime_id, expected_revision, snapshot, events)?; + Ok(()) + } +} + +fn run_view(run: RunRecord) -> DurableRunView { + DurableRunView { + id: run.id, + session_id: run.session_id, + status: run.status, + revision: run.revision, + input: run.input, + output: run.output, + cancel_requested: run.cancel_requested, + created_at: run.created_at, + updated_at: run.updated_at, + } +} + +fn session_view(session: agent_storage_sqlite::SessionRecord) -> DurableSessionView { + DurableSessionView { + id: session.id, + agent_id: session.agent_id, + status: session.status, + metadata: session.metadata, + created_at: session.created_at, + updated_at: session.updated_at, + } +} + +fn tool_call_input(input: DurableToolCallInput) -> agent_storage_sqlite::NewToolCall { + agent_storage_sqlite::NewToolCall { + id: input.id, + session_id: input.session_id, + run_id: input.run_id, + tool_name: input.tool_name, + arguments: input.arguments, + status: input.status, + } +} + +fn tool_call_view(record: agent_storage_sqlite::ToolCallRecord) -> DurableToolCallView { + DurableToolCallView { + id: record.id, + session_id: record.session_id, + run_id: record.run_id, + tool_name: record.tool_name, + arguments: record.arguments, + result: record.result, + status: record.status, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn lease_view(lease: RunLeaseRecord) -> DurableLeaseView { + DurableLeaseView { + run_id: lease.run_id, + worker_id: lease.worker_id, + lease_token: lease.lease_token, + lease_expires_at: lease.lease_expires_at, + heartbeat_at: lease.heartbeat_at, + attempt: lease.attempt, + } +} + +fn checkpoint_input(input: DurableCheckpointInput) -> agent_storage_sqlite::NewCheckpoint { + agent_storage_sqlite::NewCheckpoint { + run_id: input.run_id, + phase: input.phase, + step: input.step, + next_step: input.next_step, + messages: input.messages, + provider_request_id: input.provider_request_id, + tool_call_id: input.tool_call_id, + attempt: input.attempt, + } +} + +fn checkpoint_view(record: agent_storage_sqlite::CheckpointRecord) -> DurableCheckpointView { + DurableCheckpointView { + run_id: record.run_id, + phase: record.phase, + step: record.step, + next_step: record.next_step, + messages: record.messages, + provider_request_id: record.provider_request_id, + tool_call_id: record.tool_call_id, + attempt: record.attempt, + updated_at: record.updated_at, + } +} + +fn approval_input(input: DurableApprovalInput) -> agent_storage_sqlite::NewApproval { + agent_storage_sqlite::NewApproval { + id: input.id, + session_id: input.session_id, + run_id: input.run_id, + tool_call_id: input.tool_call_id, + status: input.status, + request: input.request, + arguments_hash: input.arguments_hash, + approval_token: input.approval_token, + expires_at_ms: input.expires_at_ms, + } +} + +fn approval_view(record: agent_storage_sqlite::ApprovalRecord) -> DurableApprovalView { + DurableApprovalView { + id: record.id, + session_id: record.session_id, + run_id: record.run_id, + tool_call_id: record.tool_call_id, + status: record.status, + request: record.request, + arguments_hash: record.arguments_hash, + approval_token: record.approval_token, + expires_at_ms: record.expires_at_ms, + decision: record.decision, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +fn external_session_input( + input: DurableExternalSessionInput, +) -> agent_storage_sqlite::NewExternalSession { + agent_storage_sqlite::NewExternalSession { + id: input.id, + session_id: input.session_id, + run_id: input.run_id, + backend: input.backend, + external_id: input.external_id, + status: input.status, + metadata: input.metadata, + } +} + +fn external_session_view( + record: agent_storage_sqlite::ExternalSessionRecord, +) -> DurableExternalSessionView { + DurableExternalSessionView { + id: record.id, + session_id: record.session_id, + run_id: record.run_id, + backend: record.backend, + external_id: record.external_id, + status: record.status, + metadata: record.metadata, + created_at: record.created_at, + updated_at: record.updated_at, + } +} diff --git a/rust/crates/agent-runtime-sqlite/src/lib.rs b/rust/crates/agent-runtime-sqlite/src/lib.rs new file mode 100644 index 000000000..82ee0c19e --- /dev/null +++ b/rust/crates/agent-runtime-sqlite/src/lib.rs @@ -0,0 +1,3649 @@ +//! SQLite durable Agent Runtime 控制面。 +//! +//! 该 crate 是 `agent-runtime` portable 合同的 SQLite 装配器:它负责 +//! run/session/lease/checkpoint/recovery 以及跨表事务,但不启动 Engine、 +//! Provider、MCP、Skill 或 CLI 生命周期。 + +use std::fs::File; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub use agent_runtime::{ + DynRuntimeStore, PreparedRun, RunHandle, RuntimeRunHandle, RuntimeSnapshotService, WorkerLease, +}; +use agent_runtime_core::{ExternalError, StoreError, StoreErrorKind}; +use agent_runtime_core::{ + ExternalObservation, ExternalObservationRequest, ExternalObservationSource, Message, + RunSnapshot, RunStatus, RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, SystemClock, reduce, +}; +/// Re-export adapter records for the small compatibility surface that still +/// accepts a concrete SQLite store. New control-plane callers should use the +/// Runtime methods below instead of reaching into adapter tables directly. +pub use agent_storage_sqlite::{ + ApprovalRecord, CheckpointRecord, EventRecord, ExternalSessionRecord, + MAX_EXTERNAL_SESSION_SCAN_LIMIT, MAX_EXTERNAL_SESSION_STATUS_FILTERS, MAX_STALE_RUN_SCAN_LIMIT, + NewApproval, NewCheckpoint, NewEvent, NewExternalSession, NewRun, NewSession, NewToolCall, + RunLeaseRecord, RunRecord, SessionRecord, SqliteStore, StorageError, ToolCallRecord, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use thiserror::Error; + +pub use agent_runtime::{ + DurableApprovalCheckpointRuntimeCommit, DurableApprovalInput, DurableApprovalResolution, + DurableApprovalView, DurableBundleResult, DurableCheckpointInput, + DurableCheckpointRuntimeCommit, DurableCheckpointView, DurableClaimResult, + DurableExternalSessionInput, DurableExternalSessionView, DurableFinishCommand, + DurableFinishGuard, DurableFinishTarget, DurableLeaseIdentity, DurableLeaseView, + DurableRecoveryCommit, DurableRunBundle, DurableRunInput, DurableRunView, DurableRuntime, + DurableSessionInput, DurableSessionView, DurableStore, DurableStoreUnsupported, + DurableToolCallCheckpointRuntimeCommit, DurableToolCallInput, DurableToolCallRuntimeCommit, + DurableToolCallView, +}; +mod durable_sqlite; +pub use durable_sqlite::{SqliteDurableStore, SqliteDurableStoreError}; + +/// SQLite Runtime 服务边界上的统一错误。 +#[derive(Debug, Error)] +pub enum RuntimeServiceError { + #[error("存储错误: {0}")] + Storage(#[from] StorageError), + #[error("外部观察错误: {0}")] + External(#[from] ExternalError), + #[error("内核错误: {0}")] + Core(String), + /// `RuntimeStore` 的可替换边界保留 Core 的稳定错误分类。 + /// + /// 这和 SQLite adapter 的 `StorageError` 是两层不同的合同:前者供 + /// runtime-only facade 的调用方按 `Conflict`/`InvalidSnapshot`/ + /// `Unavailable` 做可移植处理,后者仍用于 durable run 控制面。 + #[error("运行时存储协议错误: {0}")] + StoreProtocol(#[from] StoreError), + /// 旧的字符串错误入口保留给尚未迁移的内部调用方;新的 + /// `RuntimeStore` 路径必须使用 [`Self::StoreProtocol`],避免丢失分类。 + #[error("运行时存储协议错误: {0}")] + Store(String), + #[error("输入无效: {0}")] + InvalidInput(String), +} + +/// 简短别名,便于宿主只依赖一个错误名字。 +pub type RuntimeError = RuntimeServiceError; +pub type Result = std::result::Result; + +fn durable_store_error(error: SqliteDurableStoreError) -> RuntimeServiceError { + match error { + SqliteDurableStoreError::Storage(error) => RuntimeServiceError::Storage(error), + SqliteDurableStoreError::Runtime(error) => RuntimeServiceError::StoreProtocol(error), + } +} + +fn durable_run_record(view: DurableRunView) -> RunRecord { + RunRecord { + id: view.id, + session_id: view.session_id, + status: view.status, + revision: view.revision, + input: view.input, + output: view.output, + cancel_requested: view.cancel_requested, + created_at: view.created_at, + updated_at: view.updated_at, + } +} + +fn durable_session_record(view: DurableSessionView) -> SessionRecord { + SessionRecord { + id: view.id, + agent_id: view.agent_id, + status: view.status, + metadata: view.metadata, + created_at: view.created_at, + updated_at: view.updated_at, + } +} + +fn durable_lease_record(view: DurableLeaseView) -> RunLeaseRecord { + RunLeaseRecord { + run_id: view.run_id, + worker_id: view.worker_id, + lease_token: view.lease_token, + lease_expires_at: view.lease_expires_at, + heartbeat_at: view.heartbeat_at, + attempt: view.attempt, + } +} + +fn durable_checkpoint_input(input: NewCheckpoint) -> DurableCheckpointInput { + DurableCheckpointInput { + run_id: input.run_id, + phase: input.phase, + step: input.step, + next_step: input.next_step, + messages: input.messages, + provider_request_id: input.provider_request_id, + tool_call_id: input.tool_call_id, + attempt: input.attempt, + } +} + +fn durable_checkpoint_record(view: DurableCheckpointView) -> CheckpointRecord { + CheckpointRecord { + run_id: view.run_id, + phase: view.phase, + step: view.step, + next_step: view.next_step, + messages: view.messages, + provider_request_id: view.provider_request_id, + tool_call_id: view.tool_call_id, + attempt: view.attempt, + updated_at: view.updated_at, + } +} + +fn durable_approval_input(input: NewApproval) -> DurableApprovalInput { + DurableApprovalInput { + id: input.id, + session_id: input.session_id, + run_id: input.run_id, + tool_call_id: input.tool_call_id, + status: input.status, + request: input.request, + arguments_hash: input.arguments_hash, + approval_token: input.approval_token, + expires_at_ms: input.expires_at_ms, + } +} + +fn durable_approval_checkpoint_runtime_commit( + approval: NewApproval, + checkpoint: NewCheckpoint, + lease: &WorkerLease, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, +) -> DurableApprovalCheckpointRuntimeCommit { + DurableApprovalCheckpointRuntimeCommit { + approval: durable_approval_input(approval), + checkpoint: durable_checkpoint_input(checkpoint), + runtime_id: runtime_id.to_owned(), + expected_runtime_revision, + snapshot: snapshot.clone(), + lease: DurableLeaseIdentity { + worker_id: lease.worker_id().to_owned(), + lease_token: lease.lease_token().to_owned(), + }, + } +} + +fn durable_approval_record(view: DurableApprovalView) -> ApprovalRecord { + ApprovalRecord { + id: view.id, + session_id: view.session_id, + run_id: view.run_id, + tool_call_id: view.tool_call_id, + status: view.status, + request: view.request, + arguments_hash: view.arguments_hash, + approval_token: view.approval_token, + expires_at_ms: view.expires_at_ms, + decision: view.decision, + created_at: view.created_at, + updated_at: view.updated_at, + } +} + +fn durable_external_session_input(input: NewExternalSession) -> DurableExternalSessionInput { + DurableExternalSessionInput { + id: input.id, + session_id: input.session_id, + run_id: input.run_id, + backend: input.backend, + external_id: input.external_id, + status: input.status, + metadata: input.metadata, + } +} + +fn durable_external_session_record(view: DurableExternalSessionView) -> ExternalSessionRecord { + ExternalSessionRecord { + id: view.id, + session_id: view.session_id, + run_id: view.run_id, + backend: view.backend, + external_id: view.external_id, + status: view.status, + metadata: view.metadata, + created_at: view.created_at, + updated_at: view.updated_at, + } +} + +fn durable_tool_call_input(input: NewToolCall) -> DurableToolCallInput { + DurableToolCallInput { + id: input.id, + session_id: input.session_id, + run_id: input.run_id, + tool_name: input.tool_name, + arguments: input.arguments, + status: input.status, + } +} + +fn durable_tool_call_record(view: DurableToolCallView) -> ToolCallRecord { + ToolCallRecord { + id: view.id, + session_id: view.session_id, + run_id: view.run_id, + tool_name: view.tool_name, + arguments: view.arguments, + result: view.result, + status: view.status, + created_at: view.created_at, + updated_at: view.updated_at, + } +} + +fn durable_tool_call_runtime_commit( + input: NewToolCall, + lease: Option<&WorkerLease>, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], +) -> DurableToolCallRuntimeCommit { + DurableToolCallRuntimeCommit { + call: durable_tool_call_input(input), + runtime_id: runtime_id.to_owned(), + expected_runtime_revision, + snapshot: snapshot.clone(), + events: events.to_vec(), + lease: lease.map(|lease| DurableLeaseIdentity { + worker_id: lease.worker_id().to_owned(), + lease_token: lease.lease_token().to_owned(), + }), + } +} + +fn durable_tool_call_checkpoint_runtime_commit( + input: NewToolCall, + checkpoint: NewCheckpoint, + lease: &WorkerLease, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], +) -> DurableToolCallCheckpointRuntimeCommit { + DurableToolCallCheckpointRuntimeCommit { + call: durable_tool_call_input(input), + checkpoint: durable_checkpoint_input(checkpoint), + runtime_id: runtime_id.to_owned(), + expected_runtime_revision, + snapshot: snapshot.clone(), + events: events.to_vec(), + lease: DurableLeaseIdentity { + worker_id: lease.worker_id().to_owned(), + lease_token: lease.lease_token().to_owned(), + }, + } +} + +#[allow(clippy::too_many_arguments)] +fn durable_finish_command( + run_id: &str, + lease: Option<&WorkerLease>, + target: DurableFinishTarget, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + guard: DurableFinishGuard, +) -> DurableFinishCommand { + DurableFinishCommand { + run_id: run_id.to_owned(), + lease: lease.map(|lease| DurableLeaseIdentity { + worker_id: lease.worker_id().to_owned(), + lease_token: lease.lease_token().to_owned(), + }), + target, + output, + runtime_id: runtime_id.to_owned(), + expected_runtime_revision, + snapshot: snapshot.clone(), + events: events.to_vec(), + guard, + } +} + +fn durable_recovery_commit( + run_id: &str, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], +) -> DurableRecoveryCommit { + DurableRecoveryCommit { + run_id: run_id.to_owned(), + runtime_id: runtime_id.to_owned(), + expected_runtime_revision, + snapshot: snapshot.clone(), + events: events.to_vec(), + } +} + +impl RuntimeServiceError { + /// 返回可替换 `RuntimeStore` 错误的稳定分类。 + /// + /// SQLite durable 操作属于另一层 `StorageError`,因此这里仅对 + /// `StoreProtocol` 返回分类;字符串兼容分支不会被猜测或反向解析。 + pub fn store_error_kind(&self) -> Option { + match self { + Self::StoreProtocol(error) => Some(error.kind()), + _ => None, + } + } +} + +/// claim 便捷入口返回的 lease 与数据库记录。 +#[derive(Clone, Debug)] +pub struct ClaimedRun { + pub run: RunRecord, + pub lease: WorkerLease, + pub record: RunLeaseRecord, +} + +/// 一次调用方驱动的 stale reconciliation 结果。 +/// +/// `scanned` 是已经尝试处理的候选数;当 `cancelled` 为 true 时,剩余候选会 +/// 留给调用方下一次调用。Runtime 不创建线程,也不在取消时自动重放外部调用。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReconcileBatch { + pub recovered: Vec, + pub scanned: usize, + pub cancelled: bool, +} + +/// 只协调 durable 状态,不包含 Engine/Provider 依赖;SQLite 连接和跨表事务 +/// 由本 crate 内的 adapter 持有。 +#[derive(Clone, Debug)] +pub struct RuntimeService { + /// The service is a SQLite convenience shell around the generic facade. + /// Keeping the owned facade here makes the adapter seam explicit instead + /// of letting each method call a second, parallel store field. + durable: DurableRuntime, +} + +impl RuntimeService { + /// 打开文件数据库。父目录由调用方负责创建。 + pub fn open(path: impl AsRef) -> Result { + Ok(Self { + durable: DurableRuntime::new(SqliteDurableStore::new(SqliteStore::open(path)?)), + }) + } + + /// 打开内存数据库,适合测试和短生命周期的单 Agent 程序。 + pub fn in_memory() -> Result { + Ok(Self { + durable: DurableRuntime::new(SqliteDurableStore::new(SqliteStore::open_in_memory()?)), + }) + } + + /// 从已有 SQLite store 组装 Runtime;不会复制连接或迁移数据。 + pub fn from_store(store: SqliteStore) -> Self { + Self { + durable: DurableRuntime::new(SqliteDurableStore::new(store)), + } + } + + /// 只暴露存储适配器的窄入口,供 Runtime 尚未抽取的 SQLite 专属操作使用。 + /// 常规 run/lease/checkpoint/reconciliation 控制面应优先调用本类型的 facade。 + #[deprecated( + since = "0.1.0", + note = "请使用 RuntimeService 的窄 facade;此方法仅保留 SQLite 迁移兼容" + )] + pub fn store(&self) -> &SqliteStore { + self.durable.store().sqlite_store() + } + + /// 取出共享连接包装器,适合需要把 Store 交给独立 worker 的宿主。 + pub fn store_clone(&self) -> SqliteStore { + self.durable.store().sqlite_store().clone() + } + + /// 返回一个只覆盖 Core snapshot/event 合同的动态 Store facade。 + /// + /// 这个入口让 Runtime-only 调度器或测试通过 `RuntimeStore` trait + /// 使用同一份 durable SQLite 状态,而不必调用已经标记为兼容用途的 + /// `store()` 访问器。它不会把 run/session/lease/checkpoint 的跨表 + /// 事务伪装成可替换能力;需要那些控制面操作仍应调用本类型的窄方法。 + /// 返回值持有的是共享连接句柄,因此提交会立即对当前 Runtime 可见。 + pub fn snapshot_store(&self) -> RuntimeSnapshotService { + RuntimeSnapshotService::new(DynRuntimeStore::new(Box::new( + self.durable.store().sqlite_store().clone(), + ))) + } + + /// 返回第一阶段的中立 durable store 适配器。 + /// + /// 当前实现仍由 SQLite 提供,方法暴露 bundle、查询、lease、checkpoint、 + /// approval、外部会话和 runtime snapshot/CAS 合同;Runtime facade 的终态、 + /// queued cancel 与过期 recovery 也通过拥有值 command 路由到同一适配器, + /// 避免把跨表原子操作拆成多次调用。完整可替换持久化实现仍未完成。 + pub fn durable_store(&self) -> SqliteDurableStore { + self.durable.store().clone() + } + + /// 将当前 adapter 装配为通用 `DurableRuntime` facade。 + /// + /// 该入口主要用于把控制面传给不依赖 SQLite 类型的上层代码;它与 + /// `durable_store()` 共享同一个 Clone 句柄,不复制数据库连接或状态。 + pub fn durable_runtime(&self) -> DurableRuntime { + self.durable.clone() + } + + /// Consume the SQLite convenience shell and return the generic facade. + /// + /// This is the migration seam for a Host or scheduler that wants to own a + /// `DurableRuntime` directly; consuming the service avoids exposing a + /// second SQLite accessor or copying durable state. + pub fn into_durable_runtime(self) -> DurableRuntime { + self.durable + } + + /// 返回当前 SQLite journal mode,供启动诊断使用。 + /// + /// 这是只读诊断值,不代表 Runtime 状态,也不会修改 durable 数据。 + pub fn journal_mode(&self) -> Result { + Ok(self.durable.store().sqlite_store().journal_mode()?) + } + + /// 通过 Runtime facade 导出一个 run 的确定性 JSONL 诊断记录。 + /// + /// 排序和敏感字段投影仍由 SQLite adapter 负责;Runtime 只隐藏底层 + /// adapter 句柄,避免 CLI 为常用诊断直接依赖具体表接口。 + pub fn export_jsonl(&self, run_id: &str, writer: W) -> Result { + Ok(self + .durable + .store() + .sqlite_store() + .export_jsonl(run_id, writer)?) + } + + /// 查询和更新 durable approval 的控制面入口。审批记录属于运行恢复的 + /// 一部分,Runtime 暴露这些窄方法后,Host 不必直接依赖 SQLite 表操作。 + pub fn get_approval(&self, approval_id: &str) -> Result> { + self.durable_store() + .get_approval(approval_id) + .map(|approval| approval.map(durable_approval_record)) + .map_err(durable_store_error) + } + + pub fn list_approvals_for_run(&self, run_id: &str) -> Result> { + self.durable_store() + .list_approvals_for_run(run_id) + .map(|approvals| approvals.into_iter().map(durable_approval_record).collect()) + .map_err(durable_store_error) + } + + pub fn get_approval_for_run_call( + &self, + run_id: &str, + tool_call_id: &str, + ) -> Result> { + self.durable_store() + .get_approval_for_run_call(run_id, tool_call_id) + .map(|approval| approval.map(durable_approval_record)) + .map_err(durable_store_error) + } + + pub fn create_approval(&self, input: NewApproval) -> Result { + self.durable_store() + .create_approval(durable_approval_input(input)) + .map(durable_approval_record) + .map_err(durable_store_error) + } + + /// Atomically persist a pending approval with its awaiting checkpoint and + /// already-committed runtime snapshot. The adapter verifies the runtime + /// event history but does not append another event. + #[allow(clippy::too_many_arguments)] + pub fn create_approval_with_checkpoint_runtime_and_lease( + &self, + approval: NewApproval, + checkpoint: NewCheckpoint, + lease: &WorkerLease, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + ) -> Result { + self.durable_store() + .create_approval_with_checkpoint_runtime_and_lease( + durable_approval_checkpoint_runtime_commit( + approval, + checkpoint, + lease, + runtime_id, + expected_runtime_revision, + snapshot, + ), + ) + .map(durable_approval_record) + .map_err(durable_store_error) + } + + pub fn resolve_approval( + &self, + approval_id: &str, + expected_status: &str, + status: &str, + decision: serde_json::Value, + ) -> Result { + self.durable_store() + .resolve_approval(DurableApprovalResolution { + approval_id: approval_id.to_owned(), + expected_status: expected_status.to_owned(), + status: status.to_owned(), + decision, + }) + .map(durable_approval_record) + .map_err(durable_store_error) + } + + pub fn queue_approved_run(&self, approval_id: &str) -> Result { + self.durable_store() + .queue_approved_run(approval_id) + .map(durable_run_record) + .map_err(durable_store_error) + } + + pub fn cancel_pending_approvals(&self, run_id: &str) -> Result { + self.durable_store() + .cancel_pending_approvals(run_id) + .map_err(durable_store_error) + } + + /// 记录 Engine 已经发出的工具调用;重复相同 identity 的写入保持幂等。 + pub fn create_tool_call(&self, input: NewToolCall) -> Result { + self.durable_store() + .create_tool_call(durable_tool_call_input(input)) + .map(durable_tool_call_record) + .map_err(durable_store_error) + } + + /// 收束工具调用结果;未知/错误结果仍按调用方传入的状态保留,不伪造成功。 + pub fn complete_tool_call( + &self, + call_id: &str, + status: &str, + result: serde_json::Value, + ) -> Result { + self.durable_store() + .complete_tool_call(call_id, status, result) + .map(durable_tool_call_record) + .map_err(durable_store_error) + } + + /// Atomically persist a requested tool-call row with its Core runtime + /// event batch. The SQLite adapter keeps the tool row and runtime CAS in + /// one IMMEDIATE transaction; checkpoint persistence remains separate. + #[allow(clippy::too_many_arguments)] + pub fn create_tool_call_with_runtime_and_lease( + &self, + input: NewToolCall, + lease: Option<&WorkerLease>, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .create_tool_call_with_runtime_and_lease(durable_tool_call_runtime_commit( + input, + lease, + runtime_id, + expected_runtime_revision, + snapshot, + events, + )) + .map(durable_tool_call_record) + .map_err(durable_store_error) + } + + /// Atomically persist a tool-call result and its Core runtime event batch. + #[allow(clippy::too_many_arguments)] + pub fn complete_tool_call_with_runtime_and_lease( + &self, + input: NewToolCall, + lease: Option<&WorkerLease>, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + status: &str, + result: serde_json::Value, + ) -> Result { + self.durable_store() + .complete_tool_call_with_runtime_and_lease( + durable_tool_call_runtime_commit( + input, + lease, + runtime_id, + expected_runtime_revision, + snapshot, + events, + ), + status, + result, + ) + .map(durable_tool_call_record) + .map_err(durable_store_error) + } + + /// Atomically persist a requested tool row, checkpoint and Core runtime + /// event batch through the DurableStore contract. + #[allow(clippy::too_many_arguments)] + pub fn create_tool_call_with_checkpoint_runtime_and_lease( + &self, + input: NewToolCall, + checkpoint: NewCheckpoint, + lease: &WorkerLease, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .create_tool_call_with_checkpoint_runtime_and_lease( + durable_tool_call_checkpoint_runtime_commit( + input, + checkpoint, + lease, + runtime_id, + expected_runtime_revision, + snapshot, + events, + ), + ) + .map(durable_tool_call_record) + .map_err(durable_store_error) + } + + /// Atomically persist a completed tool row, checkpoint and Core runtime + /// event batch; adapter errors retain the transaction rollback boundary. + #[allow(clippy::too_many_arguments)] + pub fn complete_tool_call_with_checkpoint_runtime_and_lease( + &self, + input: NewToolCall, + checkpoint: NewCheckpoint, + lease: &WorkerLease, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + status: &str, + result: serde_json::Value, + ) -> Result { + self.durable_store() + .complete_tool_call_with_checkpoint_runtime_and_lease( + durable_tool_call_checkpoint_runtime_commit( + input, + checkpoint, + lease, + runtime_id, + expected_runtime_revision, + snapshot, + events, + ), + status, + result, + ) + .map(durable_tool_call_record) + .map_err(durable_store_error) + } + + pub fn get_tool_call(&self, call_id: &str) -> Result> { + self.durable_store() + .get_tool_call(call_id) + .map(|call| call.map(durable_tool_call_record)) + .map_err(durable_store_error) + } + + /// 按 run 读取工具调用,顺序与 SQLite JSONL 导出一致。 + pub fn list_tool_calls_for_run(&self, run_id: &str) -> Result> { + self.durable_store() + .list_tool_calls_for_run(run_id) + .map(|calls| calls.into_iter().map(durable_tool_call_record).collect()) + .map_err(durable_store_error) + } + + /// 外部会话身份是恢复/对账控制面的一部分;metadata 仍由调用方负责 + /// 保持非敏感,Runtime 不解析或持有 token。 + pub fn upsert_external_session( + &self, + input: NewExternalSession, + ) -> Result { + self.durable_store() + .upsert_external_session(durable_external_session_input(input)) + .map(durable_external_session_record) + .map_err(durable_store_error) + } + + /// 收束一个已经登记的外部会话,并允许后端在完成响应后补上真正的 + /// external_id。更新仍由 SQLite adapter 以本地主键执行,Host 不需要 + /// 重新拼接表字段或留下一个旧的 `running` 记录。 + pub fn update_external_session( + &self, + id: &str, + external_id: &str, + status: &str, + metadata: serde_json::Value, + ) -> Result { + self.durable_store() + .update_external_session(id, external_id, status, metadata) + .map(durable_external_session_record) + .map_err(durable_store_error) + } + + pub fn get_external_session(&self, id: &str) -> Result> { + self.durable_store() + .get_external_session(id) + .map(|session| session.map(durable_external_session_record)) + .map_err(durable_store_error) + } + + /// 只读发现外部会话对账候选;不会观察外部系统、推进 checkpoint 或重排队。 + pub fn list_external_sessions( + &self, + statuses: &[&str], + run_id: Option<&str>, + limit: usize, + ) -> Result> { + self.durable_store() + .list_external_sessions(statuses, run_id, limit) + .map(|sessions| { + sessions + .into_iter() + .map(durable_external_session_record) + .collect() + }) + .map_err(durable_store_error) + } + + /// 查询当前 run 的 lease。这个只读控制面由 Runtime 统一暴露,Host/CLI + /// 不需要为了判断 worker 是否仍存活而直接依赖 SQLite 表结构。 + pub fn get_run_lease(&self, run_id: &str) -> Result> { + self.durable_store() + .get_run_lease(run_id) + .map(|lease| lease.map(durable_lease_record)) + .map_err(durable_store_error) + } + + /// 查询跨进程可见的 cooperative cancel 标记。 + pub fn is_cancel_requested(&self, run_id: &str) -> Result { + self.durable_store() + .is_cancel_requested(run_id) + .map_err(durable_store_error) + } + + /// 反查 run 所属的 Core runtime 身份。 + pub fn runtime_id_for_run(&self, run_id: &str) -> Result> { + self.durable_store() + .runtime_id_for_run(run_id) + .map_err(durable_store_error) + } + + /// 在当前 worker lease 下读取 checkpoint。 + /// + /// 读取也经过 lease fencing;因此执行循环不会在已经失去 lease 后继续 + /// 消费旧游标。恢复器需要在 lease 失效后观察 checkpoint 时,应使用 + /// [`Self::read_checkpoint`] 这个无 token 的只读入口。 + pub fn read_checkpoint_with_lease( + &self, + run_id: &str, + lease: &WorkerLease, + ) -> Result> { + self.durable_store() + .read_checkpoint_with_lease(run_id, lease.worker_id(), lease.lease_token()) + .map(|checkpoint| checkpoint.map(durable_checkpoint_record)) + .map_err(durable_store_error) + } + + /// 在当前 worker lease 下保存 Engine 边界 checkpoint。 + /// + /// checkpoint 的 SQL/CAS 细节留在 SQLite adapter;Runtime 只把 opaque + /// lease 身份传给 adapter,Host 不再需要组装 worker/token 参数。 + pub fn save_checkpoint_with_lease( + &self, + input: NewCheckpoint, + lease: &WorkerLease, + ) -> Result { + self.durable_store() + .save_checkpoint_with_lease( + durable_checkpoint_input(input), + lease.worker_id(), + lease.lease_token(), + ) + .map(durable_checkpoint_record) + .map_err(durable_store_error) + } + + /// Atomically persist a checkpoint together with the Core runtime events + /// that produced it. Host uses this specifically for context compaction, + /// where replacing messages and moving the checkpoint independently would + /// make a crash leave two incompatible recovery truths. + #[allow(clippy::too_many_arguments)] + pub fn save_checkpoint_with_runtime_and_lease( + &self, + input: NewCheckpoint, + lease: &WorkerLease, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .save_checkpoint_with_runtime_and_lease( + DurableCheckpointRuntimeCommit { + checkpoint: durable_checkpoint_input(input), + runtime_id: runtime_id.to_owned(), + expected_runtime_revision, + snapshot: snapshot.clone(), + events: events.to_vec(), + }, + lease.worker_id(), + lease.lease_token(), + ) + .map(durable_checkpoint_record) + .map_err(durable_store_error) + } + + /// 读取 run-level durable 审计事件。Runtime 只暴露稳定记录,不解释 + /// Engine 专属 payload。 + pub fn list_events(&self, run_id: &str, after_revision: i64) -> Result> { + Ok(self + .durable + .store() + .sqlite_store() + .list_events(run_id, after_revision)?) + } + + /// 读取 Core runtime 的生命周期事件。 + /// + /// 这条记录流和上面的 run-level 审计事件刻意分开:前者可由 Core + /// reducer 重放,后者是 Host/Engine 的诊断投影。把它放在 Runtime + /// facade 后,控制面不必为了查看可重放状态而借出 SQLite adapter。 + pub fn list_runtime_events(&self, runtime_id: &str) -> Result> { + Ok(self + .durable + .store() + .sqlite_store() + .list_runtime_events(runtime_id)?) + } + + /// 导出一个 Core runtime 的快照与生命周期事件 JSONL。 + /// + /// run-level 附属记录仍由 [`Self::export_jsonl`] 导出;两个入口保持 + /// 明确的记录流边界,避免调用方把 Engine 审计行误当成 reducer 输入。 + pub fn export_runtime_jsonl(&self, runtime_id: &str, writer: W) -> Result { + Ok(self + .durable + .store() + .sqlite_store() + .export_runtime_jsonl(runtime_id, writer)?) + } + + /// 在当前 worker lease 下追加一条 run-level 审计事件。 + /// + /// `NewEvent.payload` 仍由上层编码;Runtime 只统一 lease fencing 和 + /// revision CAS,使 Host 不需要再次拼 worker/token 参数。 + pub fn append_event_with_lease( + &self, + input: NewEvent, + expected_revision: i64, + lease: &WorkerLease, + ) -> Result { + Ok(self + .durable + .store() + .sqlite_store() + .append_event_with_lease( + input, + expected_revision, + lease.worker_id(), + lease.lease_token(), + )?) + } + + /// 记录调用方已经在外部确认的结果,并把 in-flight checkpoint 原子推进 + /// 为 safe。Runtime 不执行 Provider/工具,也不替调用方判断副作用是否 + /// 成功;它只负责把已编码的结果交给持久化适配器做条件更新。 + pub fn record_reconciliation_result( + &self, + run_id: &str, + phase: &str, + external_id: &str, + step: i64, + attempt: i64, + messages: serde_json::Value, + ) -> Result { + self.durable_store() + .record_reconciliation_result(run_id, phase, external_id, step, attempt, messages) + .map(durable_checkpoint_record) + .map_err(durable_store_error) + } + + /// 通过只读适配器观察一个已经存在的外部调用。 + /// + /// Runtime 只负责转发这个显式查询并保留错误分类;它不会把观察结果 + /// 写入 checkpoint、生成消息、重排队或重新调用 Provider/工具。调用方 + /// 完成厂商协议和身份校验后,仍必须显式调用 reconciliation API 才能 + /// 推进 durable 游标。 + pub fn observe_external( + &self, + source: &dyn ExternalObservationSource, + request: &ExternalObservationRequest, + ) -> Result { + source + .observe(request) + .map_err(RuntimeServiceError::External) + } + + /// 生成身份并创建 queued run。runtime/session/run、Core reducer 事件和 + /// SQLite 记录通过 SqliteDurableStore 的 create_run_bundle 适配器一次事务落盘; + /// 这里不调用 Engine,也不触碰 Provider 或任何外部适配器。 + pub fn prepare_run_with_messages( + &self, + task: impl Into, + messages: Vec, + ) -> Result { + self.prepare_run_for_agent_with_metadata("default", task, messages, json!({})) + } + + /// 带显式 agent 身份的同一准备流程;单 Agent 默认入口仍使用 + /// prepare_run_with_messages。 + pub fn prepare_run_for_agent( + &self, + agent_id: impl Into, + task: impl Into, + messages: Vec, + ) -> Result { + self.prepare_run_for_agent_with_metadata(agent_id, task, messages, json!({})) + } + + /// 创建 queued run,并让上层只注入可持久化的非敏感会话元数据。 + /// + /// Provider 名称、配置版本等观察信息可以放在 metadata;密钥、cookie + /// 和完整环境变量仍由调用方留在进程外,不会因为这个入口被写入 SQLite。 + pub fn prepare_run_for_agent_with_metadata( + &self, + agent_id: impl Into, + task: impl Into, + messages: Vec, + metadata: serde_json::Value, + ) -> Result { + let agent_id = agent_id.into(); + let task = task.into(); + if task.trim().is_empty() { + return Err(RuntimeServiceError::InvalidInput("任务不能为空".to_owned())); + } + if messages.is_empty() { + return Err(RuntimeServiceError::InvalidInput( + "初始消息不能为空".to_owned(), + )); + } + + // 保留历史可读前缀,把 bundle 共享后缀升级为可排序的 UUIDv7;一个 + // 后缀继续把 session/run/runtime 绑定在一起,保持旧的关联行为。 + let suffix = uuidv7(); + let session_id = format!("session-{suffix}"); + let run_id = format!("run-{suffix}"); + let runtime_id = format!("runtime-{suffix}"); + let now = SystemClock.now_millis(); + + // 所有事件先经过纯 reducer,之后交给 storage 在一个事务中重放并写入。 + let mut snapshot = RuntimeSnapshot::try_new(&runtime_id).map_err(core_error)?; + let mut events = Vec::with_capacity(messages.len() + 2); + let runtime_created = + RuntimeEvent::runtime_created(&runtime_id, 1, now).map_err(core_error)?; + snapshot = reduce(&snapshot, &runtime_created).map_err(core_error)?; + events.push(runtime_created); + + let run_snapshot = + RunSnapshot::try_new(&run_id, &agent_id, &task, now).map_err(core_error)?; + let run_created = + RuntimeEvent::run_created(&runtime_id, snapshot.revision() + 1, now, &run_snapshot) + .map_err(core_error)?; + snapshot = reduce(&snapshot, &run_created).map_err(core_error)?; + events.push(run_created); + + for message in &messages { + let event = RuntimeEvent::message_appended( + &runtime_id, + snapshot.revision() + 1, + now, + &run_id, + message, + ) + .map_err(core_error)?; + snapshot = reduce(&snapshot, &event).map_err(core_error)?; + events.push(event); + } + + self.durable_store() + .create_run_bundle(DurableRunBundle { + session: DurableSessionInput { + id: session_id.clone(), + agent_id: Some(agent_id), + status: "queued".to_owned(), + metadata, + }, + run: DurableRunInput { + id: run_id.clone(), + session_id: session_id.clone(), + status: "queued".to_owned(), + input: json!({"task": task}), + }, + runtime_id: runtime_id.clone(), + snapshot: snapshot.clone(), + events, + }) + .map_err(durable_store_error)?; + + Ok(RuntimeRunHandle { + session_id, + run_id, + runtime_id, + }) + } + + /// 生成一个只带用户消息的 queued run。 + pub fn prepare_run(&self, task: impl Into) -> Result { + let task = task.into(); + let message = Message::user(&task).map_err(core_error)?; + self.prepare_run_with_messages(task, vec![message]) + } + + /// 读取最新 checkpoint;不会改变 run 状态,也不会返回 worker token。 + pub fn read_checkpoint(&self, run_id: &str) -> Result> { + self.durable_store() + .read_checkpoint(run_id) + .map(|checkpoint| checkpoint.map(durable_checkpoint_record)) + .map_err(durable_store_error) + } + + /// 领取 queued run 的可选便捷入口。Runtime 只写 lease,不启动 Engine。 + pub fn claim_run(&self, run_id: &str) -> Result { + let lease = WorkerLease::new(run_id); + let (run, record) = self.claim_run_with_lease(run_id, &lease, Duration::from_secs(30))?; + Ok(ClaimedRun { run, lease, record }) + } + + /// 使用调用方提供的 worker lease 领取 queued run。 + pub fn claim_run_with_lease( + &self, + run_id: &str, + lease: &WorkerLease, + duration: Duration, + ) -> Result<(RunRecord, RunLeaseRecord)> { + let claimed = self + .durable_store() + .claim_run_with_lease(run_id, lease.worker_id(), lease.lease_token(), duration) + .map_err(durable_store_error)?; + Ok(( + durable_run_record(claimed.run), + durable_lease_record(claimed.lease), + )) + } + + /// 续租和释放也只转发 durable fencing 操作,不管理 worker 生命周期。 + pub fn heartbeat( + &self, + run_id: &str, + lease: &WorkerLease, + duration: Duration, + ) -> Result { + self.durable_store() + .heartbeat_run(run_id, lease.worker_id(), lease.lease_token(), duration) + .map(durable_lease_record) + .map_err(durable_store_error) + } + + pub fn release(&self, run_id: &str, lease: &WorkerLease) -> Result { + self.durable_store() + .release_run_lease(run_id, lease.worker_id(), lease.lease_token()) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// lease 过期后进入 Core 的 Reconciling gate。 + /// + /// 该方法只提交生命周期事件和 durable 状态,不查询、重放或猜测任何 + /// Provider/工具结果。未知外部副作用必须由上层适配器完成对账后,才能 + /// 使用 requeue_safe_run。 + pub fn reconcile_expired_run(&self, run_id: &str) -> Result { + let runtime_id = self.runtime_id_for_run(run_id)?.ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("run 缺少 runtime: {run_id}")) + })?; + let snapshot = self + .durable_store() + .load_runtime_snapshot(&runtime_id) + .map_err(durable_store_error)? + .ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("找不到 runtime: {runtime_id}")) + })?; + let run_snapshot = snapshot.run(run_id).cloned().ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("runtime 缺少 run: {run_id}")) + })?; + + let mut next = snapshot.clone(); + let mut events = Vec::with_capacity(2); + if run_snapshot.status() == RunStatus::Pending { + let event = RuntimeEvent::status_changed( + &runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunStarted, + ) + .map_err(core_error)?; + next = reduce(&next, &event).map_err(core_error)?; + events.push(event); + } + if let Some(run) = next.run(run_id) + && run.status() != RunStatus::Reconciling + { + let event = RuntimeEvent::status_changed( + &runtime_id, + next.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::ReconciliationRequired, + ) + .map_err(core_error)?; + next = reduce(&next, &event).map_err(core_error)?; + events.push(event); + } + + // Storage 在一个 IMMEDIATE 事务中再次校验 lease 过期、runtime CAS、 + // reducer 结果和 session 投影;失败时 run/lease/runtime/session 全部回滚。 + self.durable_store() + .recover_expired_run_with_runtime(durable_recovery_commit( + run_id, + &runtime_id, + Some(snapshot.revision()), + &next, + &events, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 在 worker/进程重新启动时检查一个已知 run 是否已经失去 lease。 + /// + /// 这是一个无副作用的发现入口:有效 lease 仍归原 worker 所有并返回 + /// `None`;`running`/`cancel_requested` 且 lease 已过期(或历史数据没有 + /// lease)的 run 会调用 [`Self::reconcile_expired_run`]。历史上已经进入 + /// `reconciling` 但残留过期 lease 的记录也会走同一入口清理 fencing + /// token。它不会 + /// 启动 Engine、查询外部系统或自动重放未知 Provider/工具调用。调用方 + /// 通常在收到 `Some` 后把控制权交给人工/适配器对账,再显式 safe resume。 + pub fn reconcile_expired_run_if_stale(&self, run_id: &str) -> Result> { + let Some(run) = self.get_run(run_id)? else { + return Err(RuntimeServiceError::InvalidInput(format!( + "找不到 run: {run_id}" + ))); + }; + if run.status == "reconciling" { + let Some(lease) = self.get_run_lease(run_id)? else { + return Ok(None); + }; + let now = SystemClock.now_millis().min(i64::MAX as u64) as i64; + if lease.lease_expires_at > now { + return Ok(None); + } + return self + .durable_store() + .recover_expired_run(run_id) + .map(|run| Some(durable_run_record(run))) + .map_err(durable_store_error); + } + if !matches!(run.status.as_str(), "running" | "cancel_requested") { + return Ok(None); + } + if let Some(lease) = self.get_run_lease(run_id)? { + let now = SystemClock.now_millis().min(i64::MAX as u64) as i64; + if lease.lease_expires_at > now { + return Ok(None); + } + } + self.reconcile_expired_run(run_id).map(Some) + } + + /// 在一次有界扫描中把已经失去 lease 的 run 推进到 reconciliation gate。 + /// + /// Storage 先按稳定顺序返回候选身份;这里对每个身份重新读取当前状态和 + /// lease,再调用同一个原子 recovery 入口。因此扫描和实际恢复之间若有 + /// worker 心跳、其它恢复器或终态写入,竞态会返回 `None` 或可识别的 + /// lease/终态冲突并跳过当前项;其它错误仍会终止扫描并上抛。 + /// 本方法不启动 Engine、不创建线程、不查询外部系统,也不重放未知副作用。 + /// `limit` 受 [`MAX_STALE_RUN_SCAN_LIMIT`] 硬上限约束,传入 `0` 不做查询。 + pub fn reconcile_stale_runs(&self, limit: usize) -> Result> { + self.reconcile_stale_runs_with_cancel(limit, || false) + .map(|batch| batch.recovered) + } + + /// 在进程/worker 启动边界显式恢复一批 stale run。 + /// + /// 这是 [`Self::reconcile_stale_runs`] 的命名入口,方便启动代码表达 + /// 调用时机,同时复用同一套稳定排序、硬上限和逐项原子 recovery。它只 + /// 把失去 lease 的 run 推进到 Core 的 `Reconciling` gate,不启动线程或 + /// Engine,不查询外部系统,也不自动对账、重放 Provider/工具调用。调用 + /// 方应在返回后自行完成外部核验,再显式调用 safe-resume 入口。 + pub fn recover_stale_runs_on_start(&self, limit: usize) -> Result> { + self.reconcile_stale_runs(limit) + } + + /// 执行一次可取消的 stale reconciliation 扫描。 + /// + /// `should_cancel` 由调用方提供,在开始处理和每个候选之间检查;返回 true + /// 时立即停止,不会启动线程、Engine 或外部调用。该方法复用 + /// [`Self::reconcile_stale_runs`] 的稳定排序、硬上限和原子 recovery 语义, + /// 适合宿主在自己的 worker/事件循环中反复调用。 + pub fn reconcile_stale_runs_with_cancel( + &self, + limit: usize, + mut should_cancel: F, + ) -> Result + where + F: FnMut() -> bool, + { + if limit > MAX_STALE_RUN_SCAN_LIMIT { + return Err(RuntimeServiceError::Storage(StorageError::InvalidInput( + format!("stale run 扫描 limit 不能超过 {}", MAX_STALE_RUN_SCAN_LIMIT), + ))); + } + if limit == 0 { + return Ok(ReconcileBatch { + recovered: Vec::new(), + scanned: 0, + cancelled: false, + }); + } + if should_cancel() { + return Ok(ReconcileBatch { + recovered: Vec::new(), + scanned: 0, + cancelled: true, + }); + } + + let now = SystemClock.now_millis().min(i64::MAX as u64) as i64; + let run_ids = self + .durable_store() + .list_stale_run_ids(limit, now) + .map_err(durable_store_error)?; + let mut recovered = Vec::with_capacity(run_ids.len()); + let mut scanned = 0; + for run_id in run_ids { + if should_cancel() { + return Ok(ReconcileBatch { + recovered, + scanned, + cancelled: true, + }); + } + scanned += 1; + match self.reconcile_expired_run_if_stale(&run_id) { + Ok(Some(run)) => recovered.push(run), + Ok(None) => {} + // 候选查询与原子 recovery 之间可能刚好发生 heartbeat、 + // 终态收口或其它恢复器抢先处理;这些是扫描的正常竞态, + // 跳过当前项继续处理后续有界候选,不掩盖真正的存储/数据错误。 + Err(RuntimeServiceError::Storage( + StorageError::LeaseConflict { .. } | StorageError::TerminalRun { .. }, + )) => {} + Err(error) => return Err(error), + } + } + Ok(ReconcileBatch { + recovered, + scanned, + cancelled: false, + }) + } + + /// 只有 safe checkpoint 才能重新排队;不会启动 Engine 或重放副作用。 + pub fn requeue_safe_run(&self, run_id: &str) -> Result { + let checkpoint = self + .durable_store() + .read_checkpoint(run_id) + .map_err(durable_store_error)? + .ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("run 没有 checkpoint: {run_id}")) + })?; + if checkpoint.phase != "safe" { + return Err(RuntimeServiceError::InvalidInput(format!( + "checkpoint phase={} 仍需外部对账: {run_id}", + checkpoint.phase + ))); + } + self.durable_store() + .requeue_safe_run(run_id) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 发出跨进程可见的 cooperative cancel 请求;持有 lease 的 worker 仍须 + /// 在自己的边界完成最终收口。 + pub fn request_cancel(&self, run_id: &str) -> Result { + self.durable_store() + .request_cancel(run_id) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 在没有 active worker lease 时把 run、Core runtime、session 和 checkpoint + /// 一起收束为 cancelled。 + /// + /// 这是保留给旧调用方的低层兼容入口:它只保证 SQLite 的无 lease 原子 + /// 终态事务,不检查 checkpoint phase,也不承担 Host/CLI 的取消阶段策略。 + /// 控制面应优先调用 [`Self::finish_unclaimed_cancelled_if_safe`],避免把 + /// 可能已经触发 Provider/工具调用的 running/reconciling run 直接终态化。 + pub fn finish_unclaimed_cancelled(&self, run_id: &str) -> Result { + let record = self + .get_run(run_id)? + .ok_or_else(|| RuntimeServiceError::InvalidInput(format!("找不到 run: {run_id}")))?; + let runtime_id = self.runtime_id_for_run(run_id)?.ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("run 缺少 runtime: {run_id}")) + })?; + let snapshot = self + .durable_store() + .load_runtime_snapshot(&runtime_id) + .map_err(durable_store_error)? + .ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("找不到 runtime: {runtime_id}")) + })?; + let run_snapshot = snapshot.run(run_id).cloned().ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("runtime 缺少 run: {run_id}")) + })?; + + if run_snapshot.status().is_terminal() { + if run_snapshot.status() == RunStatus::Cancelled { + // A runtime-only terminal event is intentionally allowed by + // `commit_runtime_snapshot`, but it must not be silently + // projected into a non-terminal run through the legacy + // run-only cancellation API. That would leave the session + // projection stale and make the cross-table invariant harder + // to repair. Only the already-projected cancelled state is + // safe to clean up idempotently here. + if !matches!(record.status.as_str(), "cancelled" | "canceled") { + return Err(RuntimeServiceError::InvalidInput(format!( + "run/runtime 取消终态不一致: run={} runtime={run_id}", + record.status + ))); + } + return self + .durable_store() + .mark_cancelled(run_id, Some(json!({"reason": "cancelled"}))) + .map(durable_run_record) + .map_err(durable_store_error); + } + return Err(RuntimeServiceError::InvalidInput(format!( + "runtime run 已处于 {:?},不能取消: {run_id}", + run_snapshot.status() + ))); + } + + // The run-level row may have been completed by an older/non-runtime + // caller while this snapshot was being read. Do not report success + // for that split state; the atomic runtime-aware path must be used to + // repair it explicitly. + if matches!(record.status.as_str(), "cancelled" | "canceled") { + return Err(RuntimeServiceError::InvalidInput(format!( + "run/runtime 取消终态不一致: run={} runtime={:?}: {run_id}", + record.status, + run_snapshot.status() + ))); + } + + let event = RuntimeEvent::status_changed( + &runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunCancelled, + ) + .map_err(core_error)?; + let next = reduce(&snapshot, &event).map_err(core_error)?; + // mark_cancelled_with_runtime 会在同一个 SQLite 事务里校验 lease、 + // reducer 结果、runtime CAS、session 状态和 checkpoint 清理。 + self.durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + None, + DurableFinishTarget::Cancelled, + Some(json!({"reason": "cancelled"})), + &runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + DurableFinishGuard::None, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 尝试以原子 expected-queued 条件收束一个尚未领取的 run。 + /// + /// Runtime 会先准备 Core 的取消事件,但真正的 queued、无 checkpoint、 + /// 无 lease 条件由 SQLite 在同一 `IMMEDIATE` 事务中再次校验。worker 若 + /// 在准备期间抢到 run,返回 `Ok(None)`;调用方随后应走 + /// [`Self::request_cancel`],让持有 lease 的 worker cooperative 收口。 + /// 这个入口不会放宽旧的 [`Self::finish_unclaimed_cancelled`] 兼容语义。 + pub fn finish_queued_cancelled_if_unclaimed(&self, run_id: &str) -> Result> { + let record = self + .get_run(run_id)? + .ok_or_else(|| RuntimeServiceError::InvalidInput(format!("找不到 run: {run_id}")))?; + if record.status != "queued" || record.cancel_requested { + return Ok(None); + } + + let runtime_id = self.runtime_id_for_run(run_id)?.ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("run 缺少 runtime: {run_id}")) + })?; + let snapshot = self + .durable_store() + .load_runtime_snapshot(&runtime_id) + .map_err(durable_store_error)? + .ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!("找不到 runtime: {runtime_id}")) + })?; + let Some(run_snapshot) = snapshot.run(run_id) else { + return Err(RuntimeServiceError::InvalidInput(format!( + "runtime 缺少 run: {run_id}" + ))); + }; + // queued row 必须仍对应 Core Pending。worker claim 只负责 durable + // lease,不会悄悄推进 Core;若这里已经出现其它状态,这是两个投影 + // 的数据裂缝,不能把它伪装成普通领取竞争再写一个孤立的 + // cancel_requested 标记。 + if run_snapshot.status() != RunStatus::Pending { + return Err(RuntimeServiceError::InvalidInput(format!( + "queued run 的 runtime 状态不一致: {:?}: {run_id}", + run_snapshot.status() + ))); + } + + let event = RuntimeEvent::status_changed( + &runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + run_id, + RuntimeEventKind::RunCancelled, + ) + .map_err(core_error)?; + let next = reduce(&snapshot, &event).map_err(core_error)?; + match self + .durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + None, + DurableFinishTarget::Cancelled, + Some(json!({"reason": "cancelled"})), + &runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + DurableFinishGuard::QueuedUnclaimed, + )) { + Ok(record) => Ok(Some(durable_run_record(record))), + // The storage transaction owns the final predicate. A claim or + // checkpoint write that wins the race is expected control flow, + // not a Runtime failure. + Err(SqliteDurableStoreError::Storage(StorageError::QueuedCancelConflict { + .. + })) => Ok(None), + Err(error) => Err(durable_store_error(error)), + } + } + + /// 仅在已知没有未知外部副作用时执行无主取消。 + /// + /// `queued` 且没有 checkpoint 表示 Engine 尚未启动;已有 `safe` 或 + /// `awaiting_approval` checkpoint 也表示调用边界已知可安全终止。其余 + /// running/cancel_requested/reconciling 状态(包括没有 checkpoint 的 + /// 历史记录,以及 provider/tool/compacting 游标)都会被拒绝,调用方 + /// 必须先走 [`Self::reconcile_expired_run_if_stale`] 和显式外部对账。 + /// + /// 这里的 run/checkpoint/lease 检查是终态 CAS 前的阶段门禁;底层原子 + /// 事务还会再次校验 run 没有 lease,因而领取竞争不会越权覆盖 worker。 + pub fn finish_unclaimed_cancelled_if_safe(&self, run_id: &str) -> Result { + let record = self + .get_run(run_id)? + .ok_or_else(|| RuntimeServiceError::InvalidInput(format!("找不到 run: {run_id}")))?; + + // 已经取消的记录保持幂等;其它终态继续由兼容入口返回原有错误。 + if record.status == "cancelled" || record.status == "canceled" { + return self.finish_unclaimed_cancelled(run_id); + } + + // 任意残留 lease(包括已经过期但尚未经过 recovery 的 token)都 + // 先交给 reconciliation 清理,不能直接当成无主 run。 + if self.get_run_lease(run_id)?.is_some() { + return Err(RuntimeServiceError::InvalidInput(format!( + "run 仍有 lease,不能执行无主安全取消: {run_id}" + ))); + } + + let checkpoint = self + .durable_store() + .read_checkpoint(run_id) + .map_err(durable_store_error)? + .map(durable_checkpoint_record); + let safe_phase = checkpoint + .as_ref() + .is_some_and(|value| matches!(value.phase.as_str(), "safe" | "awaiting_approval")); + let queued_before_engine = record.status == "queued" && checkpoint.is_none(); + if !queued_before_engine && !safe_phase { + let phase = checkpoint + .as_ref() + .map(|value| value.phase.as_str()) + .unwrap_or("missing"); + return Err(RuntimeServiceError::InvalidInput(format!( + "run 当前状态 {}、checkpoint phase={} 仍需 reconciliation,不能直接取消: {run_id}", + record.status, phase + ))); + } + + // A queued run is the one case where the preflight above cannot be + // the final decision: a worker may claim it after the read. Reuse + // the guarded transaction instead of falling through to the legacy + // unleased terminalizer, so direct Runtime callers get the same + // fencing semantics as Host.cancel. + if queued_before_engine { + return self + .finish_queued_cancelled_if_unclaimed(run_id)? + .ok_or_else(|| { + RuntimeServiceError::InvalidInput(format!( + "queued run 在取消提交前已发生领取或状态变化: {run_id}" + )) + }); + } + + // 旧入口中的跨表 Runtime/Session/Checkpoint 事务仍是唯一终态写入 + // 点;阶段门禁通过后复用它,避免复制一套 CAS 实现。 + self.finish_unclaimed_cancelled(run_id) + } + + /// 查询业务 run,供通用程序做 inspect 而无需直接依赖表结构。 + pub fn get_run(&self, run_id: &str) -> Result> { + self.durable_store() + .get_run(run_id) + .map(|run| run.map(durable_run_record)) + .map_err(durable_store_error) + } + + /// 查询 session 投影,供 Host/CLI 做只读展示而无需直接访问 SQLite。 + pub fn get_session(&self, session_id: &str) -> Result> { + self.durable_store() + .get_session(session_id) + .map(|session| session.map(durable_session_record)) + .map_err(durable_store_error) + } + + /// 读取 Core runtime 快照,仍然是只读观察操作。 + pub fn load_runtime_snapshot(&self, runtime_id: &str) -> Result> { + self.durable_store() + .load_runtime_snapshot(runtime_id) + .map_err(durable_store_error) + } + + /// 提交一批已经由 Core reducer 生成的 RuntimeSnapshot/RuntimeEvent。 + /// + /// 这是 Runtime-only 的 CAS 入口:它不会触碰 run、session、lease 或 + /// 审计表。需要跨表原子收口的调用方仍应使用 storage adapter 的专用 + /// 事务 API,避免把这里的独立提交误当成全局事务。 + pub fn commit_runtime_snapshot( + &self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<()> { + self.durable_store() + .commit_runtime_snapshot(runtime_id, expected_revision, snapshot, events) + .map_err(durable_store_error) + } + + /// 通过 Runtime 暴露跨表终态收口。 + /// + /// SQLite adapter 仍然是事务的唯一实现者:这些窄 facade 只转发已经 + /// 校验过的 snapshot/event 和 opaque [`WorkerLease`],不把跨表 CAS 拆成 + /// 多次写入。调用方不能把一组未绑定的 worker/token 字符串误配到事务。 + /// Host 因而不需要直接知道 `runs`、`runtime_states`、`sessions` 和 + /// `run_checkpoints` 的表布局。 + #[allow(clippy::too_many_arguments)] + pub fn complete_run_with_lease_and_runtime( + &self, + run_id: &str, + lease: &WorkerLease, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + Some(lease), + DurableFinishTarget::Completed, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + DurableFinishGuard::None, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 持有 opaque worker lease 的 failed 终态收口;事务语义由 SQLite adapter + /// 保持。Host 不需要接触底层 worker/token 参数。 + #[allow(clippy::too_many_arguments)] + pub fn fail_run_with_lease_and_runtime( + &self, + run_id: &str, + lease: &WorkerLease, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + Some(lease), + DurableFinishTarget::Failed, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + DurableFinishGuard::None, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 无 lease 的本地准备失败收口;仅适用于调用方已确认没有 active worker。 + #[allow(clippy::too_many_arguments)] + pub fn fail_run_with_runtime( + &self, + run_id: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + None, + DurableFinishTarget::Failed, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + DurableFinishGuard::None, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 持有 opaque lease 的 cancelled 终态收口;保留 SQLite 的 + /// run/runtime/session 原子更新和 checkpoint 清理顺序。 + #[allow(clippy::too_many_arguments)] + pub fn mark_cancelled_with_lease_and_runtime( + &self, + run_id: &str, + lease: &WorkerLease, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + Some(lease), + DurableFinishTarget::Cancelled, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + DurableFinishGuard::None, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 无 lease 的 cancelled 终态收口。 + #[allow(clippy::too_many_arguments)] + pub fn mark_cancelled_with_runtime( + &self, + run_id: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.durable_store() + .finish_run_with_runtime(durable_finish_command( + run_id, + None, + DurableFinishTarget::Cancelled, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + DurableFinishGuard::None, + )) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 终态已由并发调用方写入时的幂等清理入口;lease 仍以 opaque 句柄传入。 + pub fn mark_cancelled_with_lease( + &self, + run_id: &str, + lease: &WorkerLease, + output: Option, + ) -> Result { + self.durable_store() + .mark_cancelled_with_lease(run_id, lease.worker_id(), lease.lease_token(), output) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 无 lease 终态的兼容清理入口。 + pub fn mark_cancelled( + &self, + run_id: &str, + output: Option, + ) -> Result { + self.durable_store() + .mark_cancelled(run_id, output) + .map(durable_run_record) + .map_err(durable_store_error) + } + + /// 更新 session 投影的窄入口。它不是独立事务编排;真正的跨表终态 + /// 仍使用上面的专用方法,避免调用方把本方法误当成原子收口。 + pub fn update_session( + &self, + session_id: &str, + status: &str, + metadata: Option, + ) -> Result { + self.durable_store() + .update_session(session_id, status, metadata) + .map(durable_session_record) + .map_err(durable_store_error) + } +} + +fn core_error(error: impl std::fmt::Display) -> RuntimeServiceError { + RuntimeServiceError::Core(error.to_string()) +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +const UUIDV7_MAX_TIMESTAMP_MS: u64 = (1u64 << 48) - 1; +const UUIDV7_RANDOM_MASK: u128 = (1u128 << 74) - 1; + +/// 让 UUIDv7 在同一毫秒生成多个身份(或墙上时钟回拨)时仍保持单调的状态。 +/// +/// 随机部分优先使用操作系统熵源;没有 `/dev/urandom` 的平台使用标准库 +/// 回退方案。进程内单调状态和时间戳仍会避免正常的同进程碰撞。 +#[derive(Debug)] +struct UuidV7State { + timestamp_ms: u64, + random: u128, + process_id: u32, +} + +static UUIDV7_STATE: OnceLock> = OnceLock::new(); + +fn uuidv7() -> String { + let state = UUIDV7_STATE.get_or_init(|| Mutex::new(UuidV7State::new())); + let mut state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let process_id = std::process::id(); + let now = unix_timestamp_millis(); + + // fork 子进程会继承 mutex 中的字节;检测到进程 ID 变化时重新播种, + // 避免父子进程共享同一条单调序列。 + if state.process_id != process_id { + state.process_id = process_id; + state.timestamp_ms = now; + state.random = entropy_74(); + } else if now > state.timestamp_ms { + state.timestamp_ms = now; + state.random = entropy_74(); + } else { + // 对相同或回拨的墙上时钟毫秒保持逻辑时间戳单调。 + state.random = (state.random + 1) & UUIDV7_RANDOM_MASK; + if state.random == 0 { + // 单个毫秒耗尽 74 位随机空间在实践中不可能发生;若真的发生, + // 前进逻辑时间戳作为确定性的无碰撞回退。 + if state.timestamp_ms < UUIDV7_MAX_TIMESTAMP_MS { + state.timestamp_ms += 1; + state.random = entropy_74(); + } else { + state.random = 1; + } + } + } + + encode_uuidv7(state.timestamp_ms, state.random) +} + +impl UuidV7State { + fn new() -> Self { + Self { + timestamp_ms: 0, + random: entropy_74(), + process_id: std::process::id(), + } + } +} + +fn unix_timestamp_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + duration + .as_millis() + .min(u128::from(UUIDV7_MAX_TIMESTAMP_MS)) as u64 + }) +} + +fn entropy_74() -> u128 { + let mut bytes = [0u8; 16]; + if let Ok(mut source) = File::open("/dev/urandom") + && source.read_exact(&mut bytes).is_ok() + { + return u128::from_le_bytes(bytes) & UUIDV7_RANDOM_MASK; + } + + // 没有 `/dev/urandom` 时使用可移植回退。它不是密码学熵源;UUID 唯一性 + // 还由上面的进程内单调状态保护。 + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let counter = u128::from(NEXT_ID.fetch_add(1, Ordering::Relaxed)); + let process = u128::from(std::process::id()); + mix_entropy(nanos ^ counter.rotate_left(29) ^ process.rotate_left(61)) +} + +fn mix_entropy(mut value: u128) -> u128 { + value ^= value >> 64; + value = value.wrapping_mul(0x9e3779b97f4a7c15f39cc0605cedc83u128); + value ^= value >> 32; + value = value.wrapping_mul(0xbf58476d1ce4e5b99e3779b97f4a7c15u128); + value ^= value >> 64; + value & UUIDV7_RANDOM_MASK +} + +fn encode_uuidv7(timestamp_ms: u64, random: u128) -> String { + let random_a = ((random >> 62) & 0x0fff) as u16; + let random_b = (random & ((1u128 << 62) - 1)) as u64; + let mut bytes = [0u8; 16]; + + bytes[0] = (timestamp_ms >> 40) as u8; + bytes[1] = (timestamp_ms >> 32) as u8; + bytes[2] = (timestamp_ms >> 24) as u8; + bytes[3] = (timestamp_ms >> 16) as u8; + bytes[4] = (timestamp_ms >> 8) as u8; + bytes[5] = timestamp_ms as u8; + bytes[6] = 0x70 | ((random_a >> 8) as u8 & 0x0f); + bytes[7] = random_a as u8; + bytes[8] = 0x80 | ((random_b >> 56) as u8 & 0x3f); + bytes[9] = (random_b >> 48) as u8; + bytes[10] = (random_b >> 40) as u8; + bytes[11] = (random_b >> 32) as u8; + bytes[12] = (random_b >> 24) as u8; + bytes[13] = (random_b >> 16) as u8; + bytes[14] = (random_b >> 8) as u8; + bytes[15] = random_b as u8; + + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = [0u8; 36]; + let mut cursor = 0; + for (index, byte) in bytes.into_iter().enumerate() { + if matches!(index, 4 | 6 | 8 | 10) { + output[cursor] = b'-'; + cursor += 1; + } + output[cursor] = HEX[(byte >> 4) as usize]; + output[cursor + 1] = HEX[(byte & 0x0f) as usize]; + cursor += 2; + } + String::from_utf8(output.to_vec()).expect("UUID hex output is valid UTF-8") +} + +#[cfg(test)] +fn unique_suffix() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()); + let counter = NEXT_ID.fetch_add(1, Ordering::Relaxed); + format!("{millis}-{counter}") +} + +#[cfg(test)] +#[allow(deprecated)] +mod tests { + use super::*; + use agent_runtime_core::{ + ContentPart, ExternalErrorKind, ExternalObservationStatus, Message, MessageRole, + RuntimeEventKind, ToolCall, + }; + use agent_storage_sqlite::NewCheckpoint; + use std::cell::Cell; + use std::path::PathBuf; + use std::time::Instant; + + fn wait_until(target: i64) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_millis() as i64; + if now >= target { + return; + } + assert!(Instant::now() < deadline, "lease did not expire"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + /// File-backed restart fixture without adding a test-only dependency to the + /// runtime crate. The guard removes SQLite sidecars even when a test fails. + struct TempRuntimeDatabase { + path: PathBuf, + } + + impl TempRuntimeDatabase { + fn new() -> Self { + // 遵守独立 workspace 的临时目录约定;runner 会注入专用 TMPDIR, + // 本地直接运行时则回退到 ~/data/tmp。Drop 仍会删除所有 SQLite sidecar。 + let parent = std::env::var_os("TMPDIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("data/tmp")) + }) + .expect("TMPDIR 或 HOME 未设置,无法创建临时数据库"); + std::fs::create_dir_all(&parent).expect("创建临时数据库目录"); + let path = parent.join(format!( + "agent-runtime-startup-{}-{}.db", + std::process::id(), + unique_suffix() + )); + assert!( + !path.exists(), + "temporary database path unexpectedly exists" + ); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempRuntimeDatabase { + fn drop(&mut self) { + for suffix in ["", "-wal", "-shm"] { + let path = if suffix.is_empty() { + self.path.clone() + } else { + PathBuf::from(format!("{}{}", self.path.display(), suffix)) + }; + let _ = std::fs::remove_file(path); + } + } + } + + fn user(text: &str) -> Message { + Message::user(text).expect("message") + } + + /// Build the exact durable prefix emitted before an Engine Ask result. + /// Keeping this fixture in the SQLite facade tests makes the transaction + /// contract observable without invoking a Provider or Tool. + fn approval_checkpoint_fixture() -> ( + RuntimeService, + RuntimeRunHandle, + WorkerLease, + RuntimeSnapshot, + NewCheckpoint, + NewApproval, + ) { + let service = RuntimeService::in_memory().expect("runtime"); + let user_message = user("approval transaction"); + let handle = service + .prepare_run_with_messages("approval transaction", vec![user_message]) + .expect("prepare"); + let lease = WorkerLease { + worker_id: "approval-txn-worker".to_owned(), + lease_token: "approval-txn-token".to_owned(), + }; + let (_, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(30)) + .expect("claim"); + let before = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load initial snapshot") + .expect("initial snapshot"); + let call = + ToolCall::try_new("approval-call", "echo", json!({"text": "ok"})).expect("tool call"); + let assistant = Message::new( + MessageRole::Assistant, + vec![ + ContentPart::tool_call(call.id(), call.name(), call.arguments().clone()) + .expect("tool call part"), + ], + ); + let started = RuntimeEvent::status_changed( + &handle.runtime_id, + before.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .expect("started event"); + let after_started = reduce(&before, &started).expect("reduce started"); + let message_event = RuntimeEvent::message_appended( + &handle.runtime_id, + after_started.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + &assistant, + ) + .expect("assistant event"); + let after_message = reduce(&after_started, &message_event).expect("reduce message"); + let requested = RuntimeEvent::tool_call_requested( + &handle.runtime_id, + after_message.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + &call, + ) + .expect("requested event"); + let snapshot = reduce(&after_message, &requested).expect("reduce requested"); + service + .commit_runtime_snapshot( + &handle.runtime_id, + Some(before.revision()), + &snapshot, + &[started, message_event, requested], + ) + .expect("persist running tool prefix"); + + let messages = snapshot + .run(&handle.run_id) + .expect("run snapshot") + .messages() + .to_vec(); + let checkpoint = NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "awaiting_approval".to_owned(), + step: 0, + next_step: 0, + messages: serde_json::to_value(messages).expect("checkpoint messages"), + provider_request_id: Some("approval-provider".to_owned()), + tool_call_id: Some(call.id().to_owned()), + attempt: lease_record.attempt, + }; + service + .save_checkpoint_with_lease(checkpoint.clone(), &lease) + .expect("persist awaiting checkpoint"); + let expires_at_ms = SystemClock.now_millis() as i64 + 60_000; + let approval = NewApproval { + id: "approval-transaction".to_owned(), + session_id: handle.session_id.clone(), + run_id: handle.run_id.clone(), + tool_call_id: Some(call.id().to_owned()), + status: "pending".to_owned(), + request: json!({ + "requestId": "approval-transaction", + "runId": handle.run_id, + "call": {"id": call.id(), "name": call.name(), "arguments": call.arguments()}, + "argumentsHash": "approval-hash", + "approvalToken": "approval-token", + "expiresAtMs": expires_at_ms + }), + arguments_hash: "approval-hash".to_owned(), + approval_token: "approval-token".to_owned(), + expires_at_ms, + }; + (service, handle, lease, snapshot, checkpoint, approval) + } + + #[test] + fn approval_checkpoint_runtime_transaction_is_idempotent() { + let (service, handle, lease, snapshot, checkpoint, approval) = + approval_checkpoint_fixture(); + let created = service + .create_approval_with_checkpoint_runtime_and_lease( + approval.clone(), + checkpoint.clone(), + &lease, + &handle.runtime_id, + Some(snapshot.revision()), + &snapshot, + ) + .expect("approval transaction"); + assert_eq!(created.id, approval.id); + assert_eq!(created.status, "pending"); + let stored_checkpoint = service + .read_checkpoint(&handle.run_id) + .expect("checkpoint") + .expect("checkpoint row"); + assert_eq!(stored_checkpoint.run_id, checkpoint.run_id); + assert_eq!(stored_checkpoint.phase, checkpoint.phase); + assert_eq!(stored_checkpoint.step, checkpoint.step); + assert_eq!(stored_checkpoint.next_step, checkpoint.next_step); + assert_eq!(stored_checkpoint.messages, checkpoint.messages); + assert_eq!( + stored_checkpoint.provider_request_id, + checkpoint.provider_request_id + ); + assert_eq!(stored_checkpoint.tool_call_id, checkpoint.tool_call_id); + assert_eq!(stored_checkpoint.attempt, checkpoint.attempt); + + // A retry with the same binding is a no-op and does not create a + // second approval row or advance the runtime revision. + let retried = service + .create_approval_with_checkpoint_runtime_and_lease( + approval, + checkpoint, + &lease, + &handle.runtime_id, + Some(snapshot.revision()), + &snapshot, + ) + .expect("idempotent approval transaction"); + assert_eq!(retried, created); + assert_eq!( + service + .list_approvals_for_run(&handle.run_id) + .expect("approval list") + .len(), + 1 + ); + } + + #[test] + fn approval_checkpoint_runtime_transaction_rolls_back_on_fencing_or_cas_failure() { + let (service, handle, lease, snapshot, checkpoint, approval) = + approval_checkpoint_fixture(); + let mut wrong_lease = lease.clone(); + wrong_lease.lease_token.push_str("-wrong"); + let lease_error = service + .create_approval_with_checkpoint_runtime_and_lease( + approval.clone(), + checkpoint.clone(), + &wrong_lease, + &handle.runtime_id, + Some(snapshot.revision()), + &snapshot, + ) + .expect_err("wrong lease must reject approval transaction"); + assert!(lease_error.to_string().contains("lease")); + assert!( + service + .get_approval(&approval.id) + .expect("approval lookup") + .is_none() + ); + + let revision_error = service + .create_approval_with_checkpoint_runtime_and_lease( + approval, + checkpoint, + &lease, + &handle.runtime_id, + Some(snapshot.revision().saturating_sub(1)), + &snapshot, + ) + .expect_err("stale runtime revision must reject approval transaction"); + assert!(revision_error.to_string().contains("revision")); + assert!( + service + .get_approval("approval-transaction") + .expect("approval lookup") + .is_none() + ); + } + + #[test] + fn approval_checkpoint_runtime_transaction_rejects_mismatched_checkpoint_identity() { + let (service, handle, lease, snapshot, mut checkpoint, approval) = + approval_checkpoint_fixture(); + checkpoint.tool_call_id = Some("different-call".to_owned()); + let error = service + .create_approval_with_checkpoint_runtime_and_lease( + approval, + checkpoint, + &lease, + &handle.runtime_id, + Some(snapshot.revision()), + &snapshot, + ) + .expect_err("checkpoint identity mismatch must reject"); + assert!(error.to_string().contains("awaiting_approval")); + assert!( + service + .get_approval("approval-transaction") + .expect("approval lookup") + .is_none() + ); + } + + struct StaticObservationSource; + + impl ExternalObservationSource for StaticObservationSource { + fn observe( + &self, + request: &ExternalObservationRequest, + ) -> std::result::Result { + ExternalObservation::completed(json!({ + "reference": request.reference_id(), + "observed": true + })) + .map_err(|error| ExternalError::new(ExternalErrorKind::InvalidInput, error.to_string())) + } + } + + struct FailingObservationSource; + + impl ExternalObservationSource for FailingObservationSource { + fn observe( + &self, + _request: &ExternalObservationRequest, + ) -> std::result::Result { + Err(ExternalError::new( + ExternalErrorKind::Timeout, + "observation timeout", + )) + } + } + + #[test] + fn prepare_writes_bundle_and_core_events_atomically() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run_with_messages("hello", vec![user("hello")]) + .expect("prepare"); + + let snapshot = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load") + .expect("snapshot"); + let run = snapshot.run(&handle.run_id).expect("run"); + assert_eq!(run.status(), RunStatus::Pending); + assert_eq!(run.messages().len(), 1); + assert_eq!(snapshot.revision(), 3); + let events = service + .list_runtime_events(&handle.runtime_id) + .expect("events"); + assert_eq!(events.len(), 3); + assert_eq!(events[0].kind(), RuntimeEventKind::RuntimeCreated); + assert_eq!(events[1].kind(), RuntimeEventKind::RunCreated); + assert_eq!(events[2].kind(), RuntimeEventKind::MessageAppended); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run query") + .unwrap() + .status, + "queued" + ); + assert!( + service + .get_session(&handle.session_id) + .expect("session") + .is_some() + ); + + let mut export = Vec::new(); + let count = service + .export_runtime_jsonl(&handle.runtime_id, &mut export) + .expect("runtime export"); + assert_eq!(count, events.len() + 1); + assert!( + String::from_utf8(export) + .expect("runtime export utf8") + .lines() + .any(|line| line.contains("runtime_snapshot")) + ); + } + + #[test] + fn external_observation_is_read_only_and_preserves_adapter_error() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("observe").expect("prepare"); + let request = + ExternalObservationRequest::try_new("provider", "get", "request-1").expect("request"); + let before = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load before") + .expect("snapshot before"); + + let observation = service + .observe_external(&StaticObservationSource, &request) + .expect("observation"); + assert_eq!(observation.status(), ExternalObservationStatus::Completed); + assert_eq!( + observation + .output() + .and_then(|value| value["observed"].as_bool()), + Some(true) + ); + + // Observing an existing external reference never advances durable state. + let after = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load after") + .expect("snapshot after"); + assert_eq!(before, after); + + let error = service + .observe_external(&FailingObservationSource, &request) + .expect_err("timeout should remain an external error"); + assert!(matches!( + error, + RuntimeServiceError::External(ref external) + if external.kind() == ExternalErrorKind::Timeout + )); + } + + #[test] + fn runtime_lists_external_session_candidates_without_mutation() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("list external candidates") + .expect("prepare"); + service + .upsert_external_session(NewExternalSession { + id: "runtime-external-1".to_owned(), + session_id: handle.session_id.clone(), + run_id: Some(handle.run_id.clone()), + backend: "fixture".to_owned(), + external_id: "remote-runtime-1".to_owned(), + status: "unknown".to_owned(), + metadata: json!({"source": "runtime-test"}), + }) + .expect("record external session"); + let before = service.get_run(&handle.run_id).expect("run before"); + + let store = service.durable_store(); + let candidates = store + .list_external_sessions(&["unknown"], Some(&handle.run_id), 1) + .expect("list candidates"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].id, "runtime-external-1"); + assert_eq!( + store + .get_external_session("runtime-external-1") + .expect("get candidate") + .expect("candidate exists") + .status, + "unknown" + ); + assert_eq!(service.get_run(&handle.run_id).expect("run after"), before); + } + + #[test] + fn durable_store_contract_covers_bundle_queries_and_runtime_cas() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("durable store contract") + .expect("prepare"); + let store = service.durable_store(); + + let run = store + .get_run(&handle.run_id) + .expect("run query") + .expect("run exists"); + assert_eq!(run.id, handle.run_id); + assert_eq!(run.session_id, handle.session_id); + assert_eq!(run.status, "queued"); + assert_eq!(run.revision, 0); + + let session = store + .get_session(&handle.session_id) + .expect("session query") + .expect("session exists"); + assert_eq!(session.id, handle.session_id); + assert_eq!(session.status, "queued"); + assert_eq!(session.metadata, json!({})); + assert!(session.created_at > 0); + assert!(session.updated_at >= session.created_at); + assert_eq!( + store + .runtime_id_for_run(&handle.run_id) + .expect("runtime identity"), + Some(handle.runtime_id.clone()) + ); + assert!( + !store + .is_cancel_requested(&handle.run_id) + .expect("cancel marker") + ); + let session = store + .update_session(&handle.session_id, "queued", None) + .expect("session update"); + assert_eq!(session.status, "queued"); + + let snapshot = store + .load_runtime_snapshot(&handle.runtime_id) + .expect("snapshot query") + .expect("snapshot exists"); + let event = RuntimeEvent::status_changed( + &handle.runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .expect("runtime event"); + let next = reduce(&snapshot, &event).expect("reduced snapshot"); + store + .commit_runtime_snapshot( + &handle.runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + ) + .expect("runtime CAS"); + assert_eq!( + service + .load_runtime_snapshot(&handle.runtime_id) + .expect("facade load") + .expect("facade snapshot") + .revision(), + next.revision() + ); + + let error = store + .commit_runtime_snapshot( + &handle.runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + ) + .expect_err("stale durable CAS must retain typed conflict"); + assert!(matches!( + error, + SqliteDurableStoreError::Runtime(ref store_error) + if store_error.kind() == agent_runtime_core::StoreErrorKind::Conflict + )); + } + + #[test] + fn generic_durable_runtime_facade_owns_and_returns_the_adapter() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("generic durable runtime") + .expect("prepare"); + let facade = service.durable_runtime(); + let run = facade + .get_run(&handle.run_id) + .expect("generic query") + .expect("run exists"); + assert_eq!(run.id, handle.run_id); + assert_eq!( + facade.runtime_id_for_run(&run.id).expect("runtime id"), + Some(handle.runtime_id) + ); + + // The generic facade can be handed back to the caller without exposing + // any RuntimeService or SQLite-specific ownership requirement. + let adapter = facade.into_store(); + let round_trip = DurableRuntime::new(adapter); + assert_eq!( + round_trip + .get_session(&handle.session_id) + .expect("generic session query") + .expect("session exists") + .id, + handle.session_id + ); + + // The SQLite convenience shell can also be consumed at the boundary; + // this is the hand-off used by a Host/scheduler that owns only the + // generic facade from this point onward. + let owned = service.into_durable_runtime(); + assert_eq!( + owned + .get_run(&handle.run_id) + .expect("owned generic query") + .expect("run exists") + .id, + handle.run_id + ); + } + + #[test] + fn durable_store_contract_preserves_atomic_lease_views() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("durable lease contract") + .expect("prepare"); + let store = service.durable_store(); + + let claimed = store + .claim_run_with_lease( + &handle.run_id, + "contract-worker", + "contract-token", + Duration::from_secs(2), + ) + .expect("claim"); + assert_eq!(claimed.run.status, "running"); + assert_eq!(claimed.lease.run_id, handle.run_id); + assert_eq!(claimed.lease.worker_id, "contract-worker"); + assert_eq!(claimed.lease.lease_token, "contract-token"); + assert_eq!(claimed.lease.attempt, 1); + + let renewed = store + .heartbeat_run( + &handle.run_id, + "contract-worker", + "contract-token", + Duration::from_secs(2), + ) + .expect("heartbeat"); + assert_eq!(renewed.run_id, handle.run_id); + assert!(renewed.lease_expires_at >= renewed.heartbeat_at); + + let cancelled = store + .request_cancel(&handle.run_id) + .expect("cooperative cancel"); + assert_eq!(cancelled.status, "cancel_requested"); + assert!(cancelled.cancel_requested); + + let released = store + .release_run_lease(&handle.run_id, "contract-worker", "contract-token") + .expect("release"); + assert_eq!(released.id, handle.run_id); + assert_eq!(released.status, "reconciling"); + assert!( + store + .get_run_lease(&handle.run_id) + .expect("lease query") + .is_none() + ); + } + + #[test] + fn durable_store_contract_fences_checkpoint_reads_and_writes() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("durable checkpoint contract") + .expect("prepare"); + let store = service.durable_store(); + let claimed = store + .claim_run_with_lease( + &handle.run_id, + "checkpoint-worker", + "checkpoint-token", + Duration::from_secs(2), + ) + .expect("claim"); + let checkpoint = DurableCheckpointInput { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([{"role":"user","content":[{"type":"text","text":"checkpoint"}]}]), + provider_request_id: Some("checkpoint-request".to_owned()), + tool_call_id: None, + attempt: claimed.lease.attempt, + }; + let saved = store + .save_checkpoint_with_lease(checkpoint, "checkpoint-worker", "checkpoint-token") + .expect("save checkpoint"); + assert_eq!(saved.run_id, handle.run_id); + assert_eq!(saved.phase, "provider_in_flight"); + assert_eq!( + saved.provider_request_id.as_deref(), + Some("checkpoint-request") + ); + + let read = store + .read_checkpoint_with_lease(&handle.run_id, "checkpoint-worker", "checkpoint-token") + .expect("fenced checkpoint read") + .expect("checkpoint exists"); + assert_eq!(read, saved); + + let error = store + .save_checkpoint_with_lease( + DurableCheckpointInput { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: Some("wrong-worker".to_owned()), + tool_call_id: None, + attempt: claimed.lease.attempt, + }, + "other-worker", + "other-token", + ) + .expect_err("wrong lease must not overwrite checkpoint"); + assert!(matches!( + error, + SqliteDurableStoreError::Storage(StorageError::LeaseConflict { .. }) + )); + assert_eq!( + store + .read_checkpoint(&handle.run_id) + .expect("unfenced checkpoint read") + .expect("checkpoint remains") + .provider_request_id + .as_deref(), + Some("checkpoint-request") + ); + } + + #[test] + fn durable_store_contract_preserves_approval_identity_and_pending_cas() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("durable approval contract") + .expect("prepare"); + let store = service.durable_store(); + let expires_at_ms = SystemClock.now_millis() as i64 + 60_000; + let approval = DurableApprovalInput { + id: "approval-contract".to_owned(), + session_id: handle.session_id.clone(), + run_id: handle.run_id.clone(), + tool_call_id: Some("approval-call".to_owned()), + status: "pending".to_owned(), + request: json!({ + "requestId": "approval-contract", + "runId": handle.run_id, + "call": {"id":"approval-call","name":"echo","arguments":{"text":"ok"}}, + "argumentsHash": "approval-hash", + "approvalToken": "approval-token", + "expiresAtMs": expires_at_ms + }), + arguments_hash: "approval-hash".to_owned(), + approval_token: "approval-token".to_owned(), + expires_at_ms, + }; + let created = store + .create_approval(approval.clone()) + .expect("create approval"); + assert_eq!(created.id, "approval-contract"); + assert_eq!(created.status, "pending"); + assert_eq!(created.tool_call_id.as_deref(), Some("approval-call")); + assert_eq!( + store + .create_approval(approval) + .expect("idempotent approval retry"), + created + ); + assert_eq!( + store + .get_approval_for_run_call(&handle.run_id, "approval-call") + .expect("approval lookup") + .expect("approval exists"), + created + ); + assert_eq!( + store + .list_approvals_for_run(&handle.run_id) + .expect("approval list") + .len(), + 1 + ); + + let resolved = store + .resolve_approval(DurableApprovalResolution { + approval_id: "approval-contract".to_owned(), + expected_status: "pending".to_owned(), + status: "allowed".to_owned(), + decision: json!({"decision":"allow"}), + }) + .expect("resolve approval"); + assert_eq!(resolved.status, "allowed"); + let conflict = store + .resolve_approval(DurableApprovalResolution { + approval_id: "approval-contract".to_owned(), + expected_status: "pending".to_owned(), + status: "denied".to_owned(), + decision: json!({"decision":"deny"}), + }) + .expect_err("resolved approval must remain pending-only CAS"); + assert!(matches!( + conflict, + SqliteDurableStoreError::Storage(StorageError::ApprovalConflict { .. }) + )); + } + + #[test] + fn runtime_facade_commits_runtime_only_snapshot_with_cas() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("facade commit").expect("prepare"); + let snapshot = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load") + .expect("snapshot"); + let event = RuntimeEvent::status_changed( + &handle.runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .expect("event"); + let next = reduce(&snapshot, &event).expect("reduce"); + + service + .commit_runtime_snapshot( + &handle.runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + ) + .expect("commit"); + assert_eq!( + service + .load_runtime_snapshot(&handle.runtime_id) + .expect("reload") + .expect("snapshot") + .run(&handle.run_id) + .expect("run") + .status(), + RunStatus::Running + ); + + let error = service + .commit_runtime_snapshot( + &handle.runtime_id, + Some(snapshot.revision()), + &next, + std::slice::from_ref(&event), + ) + .expect_err("stale runtime revision must fail"); + assert_eq!(error.store_error_kind(), Some(StoreErrorKind::Conflict)); + } + + #[test] + fn runtime_exposes_dynamic_snapshot_store_without_adapter_handle() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("dynamic snapshot store") + .expect("prepare"); + let before = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load") + .expect("snapshot"); + let event = RuntimeEvent::status_changed( + &handle.runtime_id, + before.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .expect("event"); + let next = reduce(&before, &event).expect("reduce"); + + // The dynamic facade shares RuntimeService's connection, while the + // caller only sees the portable RuntimeStore contract. + let mut store = service.snapshot_store(); + store + .commit( + &handle.runtime_id, + Some(before.revision()), + &next, + std::slice::from_ref(&event), + ) + .expect("dynamic commit"); + + let loaded = store + .load(&handle.runtime_id) + .expect("dynamic load") + .expect("committed snapshot"); + assert_eq!(loaded, next); + assert_eq!( + service + .load_runtime_snapshot(&handle.runtime_id) + .expect("service reload") + .expect("service snapshot"), + next + ); + } + + #[test] + fn terminal_facade_keeps_run_runtime_and_session_in_one_transaction() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("terminal facade").expect("prepare"); + let lease = WorkerLease::with_identity("terminal-worker", "terminal-token").expect("lease"); + let (_, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(1)) + .expect("claim"); + let snapshot = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load") + .expect("snapshot"); + let started = RuntimeEvent::status_changed( + &handle.runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunStarted, + ) + .expect("started event"); + let running = reduce(&snapshot, &started).expect("reduce started"); + let completed = RuntimeEvent::completed( + &handle.runtime_id, + running.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + "done", + ) + .expect("completed event"); + let terminal = reduce(&running, &completed).expect("reduce completed"); + + // The facade delegates one SQLite transaction; no intermediate + // completed run or stale checkpoint can be observed after return. + let record = service + .complete_run_with_lease_and_runtime( + &handle.run_id, + &lease, + Some(json!({"text": "done"})), + &handle.runtime_id, + Some(snapshot.revision()), + &terminal, + &[started, completed], + ) + .expect("complete"); + assert_eq!(record.status, "completed"); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run") + .unwrap() + .status, + "completed" + ); + assert_eq!( + service + .load_runtime_snapshot(&handle.runtime_id) + .expect("runtime") + .unwrap() + .run(&handle.run_id) + .unwrap() + .status(), + RunStatus::Completed + ); + assert_eq!( + service + .store() + .get_session(&handle.session_id) + .expect("session") + .unwrap() + .status, + "completed" + ); + assert!( + service + .read_checkpoint(&handle.run_id) + .expect("checkpoint") + .is_none() + ); + assert_eq!(lease_record.attempt, 1); + } + + #[test] + fn lease_generation_is_local_and_non_empty() { + let first = WorkerLease::new("run-a"); + let second = WorkerLease::new("run-a"); + assert!(!first.worker_id().is_empty()); + assert!(!first.lease_token().is_empty()); + assert_ne!(first.lease_token(), second.lease_token()); + } + + fn assert_uuidv7(value: &str) { + let groups = value.split('-').collect::>(); + assert_eq!( + groups.iter().map(|group| group.len()).collect::>(), + vec![8, 4, 4, 4, 12] + ); + assert!(value.chars().all(|character| { + character == '-' || character.is_ascii_digit() || ('a'..='f').contains(&character) + })); + assert_eq!(value.as_bytes()[14], b'7', "UUID version must be 7"); + assert!(matches!( + value.as_bytes()[19], + b'8'..=b'9' | b'a'..=b'b' + )); + } + + #[test] + fn uuidv7_is_canonical_and_unique() { + let ids = (0..256).map(|_| uuidv7()).collect::>(); + let unique = ids.iter().collect::>(); + assert_eq!(unique.len(), ids.len()); + for id in ids { + assert_uuidv7(&id); + } + } + + #[test] + fn prepared_identity_keeps_prefixes_with_uuidv7_suffix() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("uuid identity").expect("prepare"); + assert_uuidv7(handle.session_id.strip_prefix("session-").unwrap()); + assert_uuidv7(handle.run_id.strip_prefix("run-").unwrap()); + assert_uuidv7(handle.runtime_id.strip_prefix("runtime-").unwrap()); + // The shared suffix preserves the historical bundle correlation. + assert_eq!( + handle.session_id.strip_prefix("session-").unwrap(), + handle.run_id.strip_prefix("run-").unwrap() + ); + assert_eq!( + handle.run_id.strip_prefix("run-").unwrap(), + handle.runtime_id.strip_prefix("runtime-").unwrap() + ); + } + + #[test] + fn expired_run_enters_core_reconciliation_without_replay() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("reconcile").expect("prepare"); + let lease = WorkerLease::with_identity("worker-test", "token-test").expect("lease"); + let (_, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_millis(30)) + .expect("claim"); + service + .store() + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: Some("request-1".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + lease.worker_id(), + lease.lease_token(), + ) + .expect("checkpoint"); + wait_until(lease_record.lease_expires_at); + + let recovered = service + .reconcile_expired_run(&handle.run_id) + .expect("reconcile"); + assert_eq!(recovered.status, "reconciling"); + let snapshot = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("runtime") + .expect("snapshot"); + assert_eq!( + snapshot.run(&handle.run_id).expect("run").status(), + RunStatus::Reconciling + ); + assert_eq!( + service + .read_checkpoint(&handle.run_id) + .expect("checkpoint") + .unwrap() + .phase, + "provider_in_flight" + ); + } + + #[test] + fn stale_recovery_probe_does_not_take_an_active_lease() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("startup probe").expect("prepare"); + let lease = WorkerLease::with_identity("active-worker", "active-token").expect("lease"); + service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(5)) + .expect("claim"); + + assert!( + service + .reconcile_expired_run_if_stale(&handle.run_id) + .expect("probe") + .is_none() + ); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run") + .unwrap() + .status, + "running" + ); + } + + #[test] + fn stale_recovery_probe_reconciles_an_expired_lease() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("restart recovery").expect("prepare"); + let lease = WorkerLease::with_identity("dead-worker", "dead-token").expect("lease"); + let (_, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_millis(20)) + .expect("claim"); + wait_until(lease_record.lease_expires_at); + + let recovered = service + .reconcile_expired_run_if_stale(&handle.run_id) + .expect("probe") + .expect("expired lease should enter reconciliation"); + assert_eq!(recovered.status, "reconciling"); + assert!( + service + .store() + .get_run_lease(&handle.run_id) + .unwrap() + .is_none() + ); + } + + #[test] + fn stale_run_scan_reconciles_only_expired_candidates_with_a_hard_limit() { + let service = RuntimeService::in_memory().expect("runtime"); + let stale = service + .prepare_run("stale candidate") + .expect("prepare stale"); + let active = service + .prepare_run("active candidate") + .expect("prepare active"); + let queued = service + .prepare_run("queued candidate") + .expect("prepare queued"); + + let stale_lease = WorkerLease::with_identity("dead-worker", "dead-token").expect("lease"); + let (_, stale_record) = service + .claim_run_with_lease(&stale.run_id, &stale_lease, Duration::from_millis(20)) + .expect("claim stale candidate"); + let active_lease = WorkerLease::with_identity("live-worker", "live-token").expect("lease"); + service + .claim_run_with_lease(&active.run_id, &active_lease, Duration::from_secs(30)) + .expect("claim active candidate"); + wait_until(stale_record.lease_expires_at); + + let recovered = service + .reconcile_stale_runs(3) + .expect("scan stale candidates"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, stale.run_id); + assert_eq!(recovered[0].status, "reconciling"); + assert_eq!( + service.get_run(&active.run_id).unwrap().unwrap().status, + "running" + ); + assert_eq!( + service.get_run(&queued.run_id).unwrap().unwrap().status, + "queued" + ); + + // 第二轮只会看到已经被其它恢复器处理过的状态;不会重复生成事件。 + assert!( + service + .reconcile_stale_runs(3) + .expect("repeat scan") + .is_empty() + ); + assert!( + service + .reconcile_stale_runs(MAX_STALE_RUN_SCAN_LIMIT + 1) + .expect_err("scan limit must be bounded") + .to_string() + .contains("不能超过") + ); + } + + #[test] + fn startup_recovery_reopens_multiple_stale_runs_in_bounded_deterministic_order() { + let database = TempRuntimeDatabase::new(); + let mut fixtures = Vec::new(); + + // This scope models the previous worker process. Each run keeps an + // in-flight checkpoint so restart recovery can prove it does not + // delete the cursor or replay an external operation. + { + let service = RuntimeService::open(database.path()).expect("open file runtime"); + for (index, duration_ms) in [(1, 40_u64), (2, 160_u64), (3, 280_u64)] { + let handle = service + .prepare_run(format!("startup stale {index}")) + .expect("prepare stale run"); + let lease = WorkerLease::with_identity( + format!("startup-worker-{index}"), + format!("startup-token-{index}"), + ) + .expect("lease"); + let (_, lease_record) = service + .claim_run_with_lease( + &handle.run_id, + &lease, + Duration::from_millis(duration_ms), + ) + .expect("claim stale run"); + service + .store() + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: i64::from(index), + next_step: i64::from(index + 1), + messages: json!([]), + provider_request_id: Some(format!("startup-request-{index}")), + tool_call_id: None, + attempt: lease_record.attempt, + }, + lease.worker_id(), + lease.lease_token(), + ) + .expect("save in-flight checkpoint"); + fixtures.push((handle, lease_record.lease_expires_at)); + } + } + + let latest_expiry = fixtures + .iter() + .map(|(_, expires_at)| *expires_at) + .max() + .expect("fixtures"); + wait_until(latest_expiry); + + let mut expected = fixtures.iter().collect::>(); + expected.sort_by_key(|(_, expires_at)| *expires_at); + + // A fresh RuntimeService models process startup. The explicit limit + // recovers only the first two stable candidates; it does not spawn a + // worker, query an external system, or consume the third candidate. + { + let service = RuntimeService::open(database.path()).expect("reopen runtime"); + let recovered = service + .recover_stale_runs_on_start(2) + .expect("startup stale recovery"); + assert_eq!(recovered.len(), 2); + for (record, (handle, _)) in recovered.iter().zip(expected.iter().take(2)) { + assert_eq!(record.id, handle.run_id); + assert_eq!(record.status, "reconciling"); + let checkpoint = service + .read_checkpoint(&handle.run_id) + .expect("read checkpoint") + .expect("checkpoint preserved"); + assert_eq!(checkpoint.phase, "provider_in_flight"); + } + assert_eq!( + service + .get_run(&expected[2].0.run_id) + .expect("read remaining run") + .expect("remaining run") + .status, + "running" + ); + } + + // Reopen once more to ensure the first batch is not emitted again and + // the remaining stale run is handled by the same bounded entry point. + let service = RuntimeService::open(database.path()).expect("reopen for second batch"); + let recovered = service + .recover_stale_runs_on_start(2) + .expect("recover remaining stale run"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, expected[2].0.run_id); + assert_eq!(recovered[0].status, "reconciling"); + assert_eq!( + service + .read_checkpoint(&expected[2].0.run_id) + .expect("read final checkpoint") + .expect("final checkpoint") + .phase, + "provider_in_flight" + ); + assert!( + service + .recover_stale_runs_on_start(2) + .expect("repeat startup recovery") + .is_empty() + ); + } + + #[test] + fn control_facade_owns_lease_and_checkpoint_fencing() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("runtime control facade") + .expect("prepare"); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run query") + .expect("run") + .status, + "queued" + ); + + let lease = WorkerLease::with_identity("facade-worker", "facade-token").expect("lease"); + let (running, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(1)) + .expect("claim through runtime"); + assert_eq!(running.status, "running"); + assert_eq!( + service + .get_run_lease(&handle.run_id) + .expect("lease query") + .expect("lease") + .lease_token, + "facade-token" + ); + + service + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([{ + "role": "user", + "content": [{"type": "text", "text": "runtime control facade"}] + }]), + provider_request_id: Some("facade-request".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + &lease, + ) + .expect("checkpoint through runtime"); + assert_eq!( + service + .read_checkpoint_with_lease(&handle.run_id, &lease) + .expect("checkpoint query") + .expect("checkpoint") + .provider_request_id + .as_deref(), + Some("facade-request") + ); + + service + .heartbeat(&handle.run_id, &lease, Duration::from_secs(1)) + .expect("heartbeat through runtime"); + let released = service + .release(&handle.run_id, &lease) + .expect("release through runtime"); + assert_eq!(released.status, "reconciling"); + // Release changes the run/lease gate only; the in-flight cursor remains + // readable through the Runtime recovery view until an explicit audit. + assert_eq!( + service + .read_checkpoint(&handle.run_id) + .expect("recovery checkpoint query") + .expect("checkpoint") + .phase, + "provider_in_flight" + ); + } + + #[test] + fn cancellable_stale_scan_stops_between_candidates_without_replaying() { + let service = RuntimeService::in_memory().expect("runtime"); + let first = service.prepare_run("first stale").expect("prepare first"); + let second = service.prepare_run("second stale").expect("prepare second"); + let first_lease = WorkerLease::with_identity("dead-first", "token-first").unwrap(); + let (_, first_record) = service + .claim_run_with_lease(&first.run_id, &first_lease, Duration::from_millis(20)) + .expect("claim first"); + let second_lease = WorkerLease::with_identity("dead-second", "token-second").unwrap(); + let (_, second_record) = service + .claim_run_with_lease(&second.run_id, &second_lease, Duration::from_millis(20)) + .expect("claim second"); + wait_until( + first_record + .lease_expires_at + .max(second_record.lease_expires_at), + ); + + let checks = Cell::new(0); + let batch = service + .reconcile_stale_runs_with_cancel(2, || { + let check = checks.get(); + checks.set(check + 1); + // Allow exactly one candidate to be processed, then cancel before + // the next one. The first check happens before the bounded query; + // the second check happens before the first candidate. + check >= 2 + }) + .expect("cancellable scan"); + assert!(batch.cancelled); + assert_eq!(batch.scanned, 1); + assert_eq!(batch.recovered.len(), 1); + let remaining = if batch.recovered[0].id == first.run_id { + second.run_id + } else { + first.run_id + }; + assert_eq!( + service.get_run(&remaining).unwrap().unwrap().status, + "running" + ); + assert!(checks.get() >= 3); + } + + #[test] + fn requeue_accepts_only_safe_checkpoint() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("safe").expect("prepare"); + let lease = WorkerLease::with_identity("worker-safe", "token-safe").expect("lease"); + let (_, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_millis(30)) + .expect("claim"); + service + .store() + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: Some("request-unsafe".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + lease.worker_id(), + lease.lease_token(), + ) + .expect("checkpoint"); + wait_until(lease_record.lease_expires_at); + service + .reconcile_expired_run(&handle.run_id) + .expect("reconcile"); + let error = service + .requeue_safe_run(&handle.run_id) + .expect_err("unknown external result must block requeue"); + assert!(error.to_string().contains("仍需外部对账")); + } + + #[test] + fn cancel_unclaimed_run_updates_core_and_storage_together() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("cancel").expect("prepare"); + let cancelled = service + .finish_unclaimed_cancelled_if_safe(&handle.run_id) + .expect("finish cancel"); + assert_eq!(cancelled.status, "cancelled"); + let snapshot = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("runtime") + .expect("snapshot"); + assert_eq!( + snapshot.run(&handle.run_id).expect("run").status(), + RunStatus::Cancelled + ); + let events = service + .store() + .list_runtime_events(&handle.runtime_id) + .expect("events"); + assert_eq!( + events.last().expect("cancel event").kind(), + RuntimeEventKind::RunCancelled + ); + assert!( + service + .read_checkpoint(&handle.run_id) + .expect("checkpoint") + .is_none() + ); + + // Repeated cancellation remains an idempotent cleanup after both + // durable projections have reached the same terminal state. + let repeated = service + .finish_unclaimed_cancelled_if_safe(&handle.run_id) + .expect("repeat finish cancel"); + assert_eq!(repeated.status, "cancelled"); + } + + #[test] + fn atomic_queued_cancel_loses_cleanly_to_worker_claim() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service.prepare_run("queued cancel race").expect("prepare"); + let lease = WorkerLease::with_identity("race-worker", "race-token").expect("lease"); + + // 先让 worker 赢得 queued CAS,再调用控制端入口,模拟控制端 + // 读取 queued 后、真正写终态前发生领取的竞态。原子 guard 应返回 + // None,而不是撤销 worker 的 lease 或写入 cancelled。 + service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(30)) + .expect("worker claim"); + assert_eq!( + service + .finish_queued_cancelled_if_unclaimed(&handle.run_id) + .expect("race is expected control flow"), + None + ); + let run = service.get_run(&handle.run_id).expect("run").unwrap(); + assert_eq!(run.status, "running"); + assert!(!run.cancel_requested); + assert_eq!( + service + .get_run_lease(&handle.run_id) + .expect("lease") + .map(|value| value.lease_token), + Some("race-token".to_owned()) + ); + } + + #[test] + fn safe_unclaimed_cancel_rejects_historical_running_without_lease() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("historical running cancel") + .expect("prepare"); + + // The legacy claim path intentionally models a row left running after + // a process exited before writing lease metadata. It is not evidence + // that no Provider/tool side effect happened. + service + .store() + .claim_run(&handle.run_id) + .expect("legacy claim"); + service + .request_cancel(&handle.run_id) + .expect("request cancel"); + let error = service + .finish_unclaimed_cancelled_if_safe(&handle.run_id) + .expect_err("unknown running boundary must stay behind the gate"); + assert!(matches!( + error, + RuntimeServiceError::InvalidInput(message) if message.contains("仍需 reconciliation") + )); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run") + .unwrap() + .status, + "cancel_requested" + ); + assert!( + service.finish_unclaimed_cancelled(&handle.run_id).is_ok(), + "the old method remains an explicitly low-level compatibility escape hatch" + ); + } + + #[test] + fn safe_unclaimed_cancel_rejects_active_lease_and_inflight_checkpoint() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("in-flight cancel gate") + .expect("prepare"); + let lease = + WorkerLease::with_identity("in-flight-worker", "in-flight-token").expect("lease"); + let (_, lease_record) = service + .claim_run_with_lease(&handle.run_id, &lease, Duration::from_secs(30)) + .expect("claim"); + service + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: handle.run_id.clone(), + phase: "provider_in_flight".to_owned(), + step: 0, + next_step: 0, + messages: json!([{"role":"user","content":[{"type":"text","text":"in-flight cancel gate"}]}]), + provider_request_id: Some("in-flight-request".to_owned()), + tool_call_id: None, + attempt: lease_record.attempt, + }, + &lease, + ) + .expect("checkpoint"); + + let active_error = service + .finish_unclaimed_cancelled_if_safe(&handle.run_id) + .expect_err("active lease must block unclaimed cancellation"); + assert!(matches!( + active_error, + RuntimeServiceError::InvalidInput(message) if message.contains("仍有 lease") + )); + + service.release(&handle.run_id, &lease).expect("release"); + let phase_error = service + .finish_unclaimed_cancelled_if_safe(&handle.run_id) + .expect_err("in-flight checkpoint must remain behind reconciliation"); + assert!(matches!( + phase_error, + RuntimeServiceError::InvalidInput(message) if message.contains("仍需 reconciliation") + )); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run") + .unwrap() + .status, + "reconciling" + ); + assert_eq!( + service + .read_checkpoint(&handle.run_id) + .expect("checkpoint") + .expect("checkpoint remains") + .phase, + "provider_in_flight" + ); + } + + #[test] + fn cancel_rejects_runtime_only_terminal_split() { + let service = RuntimeService::in_memory().expect("runtime"); + let handle = service + .prepare_run("runtime-only terminal split") + .expect("prepare"); + let snapshot = service + .load_runtime_snapshot(&handle.runtime_id) + .expect("load runtime") + .expect("runtime snapshot"); + let event = RuntimeEvent::status_changed( + &handle.runtime_id, + snapshot.revision() + 1, + SystemClock.now_millis(), + &handle.run_id, + RuntimeEventKind::RunCancelled, + ) + .expect("cancel event"); + let terminal = reduce(&snapshot, &event).expect("reduce cancel"); + + // Runtime-only CAS is deliberately a separate low-level port. If a + // caller uses it for a terminal event, the run/session projections are + // still queued; cancellation must not silently use the legacy run-only + // cleanup and create a durable split. + service + .commit_runtime_snapshot( + &handle.runtime_id, + Some(snapshot.revision()), + &terminal, + std::slice::from_ref(&event), + ) + .expect("runtime-only commit"); + let error = service + .finish_unclaimed_cancelled(&handle.run_id) + .expect_err("split terminal state must be rejected"); + assert!( + matches!(error, RuntimeServiceError::InvalidInput(message) if message.contains("不一致")) + ); + assert_eq!( + service + .get_run(&handle.run_id) + .expect("run") + .unwrap() + .status, + "queued" + ); + assert_eq!( + service + .store() + .get_session(&handle.session_id) + .expect("session") + .unwrap() + .status, + "queued" + ); + } + + #[test] + fn empty_prepare_inputs_are_rejected_before_storage() { + let service = RuntimeService::in_memory().expect("runtime"); + assert!( + service + .prepare_run_with_messages("", vec![user("x")]) + .is_err() + ); + assert!(service.prepare_run_with_messages("x", Vec::new()).is_err()); + let invalid = Message::try_new(MessageRole::User, [ContentPart::text("x").expect("text")]) + .expect("valid message"); + assert!( + service + .prepare_run_for_agent("", "x", vec![invalid]) + .is_err() + ); + } +} diff --git a/rust/crates/agent-runtime/Cargo.toml b/rust/crates/agent-runtime/Cargo.toml new file mode 100644 index 000000000..207c2bcba --- /dev/null +++ b/rust/crates/agent-runtime/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agent-runtime" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +license = "UNLICENSED" +publish = false +description = "Portable durable runtime contracts and snapshot facade for the generic Agent runtime" + +[dependencies] +agent-runtime-core.workspace = true +agent-runtime-contracts.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/rust/crates/agent-runtime/src/durable.rs b/rust/crates/agent-runtime/src/durable.rs new file mode 100644 index 000000000..fd42ec508 --- /dev/null +++ b/rust/crates/agent-runtime/src/durable.rs @@ -0,0 +1,1266 @@ +//! Portable Durable Runtime facade. +//! +//! The neutral command/view types and `DurableStore` trait live in +//! `agent-runtime-contracts`; the SQLite adapter is implemented by the +//! sibling `agent-runtime-sqlite` crate so this facade stays portable. + +pub use agent_runtime_contracts::*; +use agent_runtime_core::{RuntimeEvent, RuntimeSnapshot}; +use serde_json::Value; +use std::time::Duration; + +/// 面向任意 [`DurableStore`] 的轻量运行控制 facade。 +/// +/// 该类型只负责拥有和转发中立 command/query,不重新实现事务,也不引入 +/// SQLite、线程或 CLI 生命周期。它让后续内存、远端或其它持久化 adapter +/// 可以直接装配同一组 Runtime 控制面 API;SQLite-backed run control is +/// provided by `agent-runtime-sqlite`. +#[derive(Clone, Debug)] +pub struct DurableRuntime { + store: S, +} + +impl DurableRuntime { + /// 用调用方提供的 durable adapter 创建 facade。 + pub fn new(store: S) -> Self { + Self { store } + } + + /// 只读访问 adapter,便于调用方观察其自有的实现状态。 + pub fn store(&self) -> &S { + &self.store + } + + /// 取回 adapter 的可变引用;所有 durable 原子性仍由 adapter 保证。 + pub fn store_mut(&mut self) -> &mut S { + &mut self.store + } + + /// 结束 facade 生命周期并取回 adapter 所有权。 + pub fn into_store(self) -> S { + self.store + } +} + +impl DurableRuntime +where + S: DurableStore, +{ + pub fn create_run_bundle( + &self, + bundle: DurableRunBundle, + ) -> Result { + self.store.create_run_bundle(bundle) + } + + pub fn get_run(&self, run_id: &str) -> Result, S::Error> { + self.store.get_run(run_id) + } + + pub fn get_session(&self, session_id: &str) -> Result, S::Error> { + self.store.get_session(session_id) + } + + pub fn is_cancel_requested(&self, run_id: &str) -> Result { + self.store.is_cancel_requested(run_id) + } + + pub fn runtime_id_for_run(&self, run_id: &str) -> Result, S::Error> { + self.store.runtime_id_for_run(run_id) + } + + pub fn update_session( + &self, + session_id: &str, + status: &str, + metadata: Option, + ) -> Result { + self.store.update_session(session_id, status, metadata) + } + + pub fn claim_run_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result { + self.store + .claim_run_with_lease(run_id, worker_id, lease_token, lease_duration) + } + + pub fn get_run_lease(&self, run_id: &str) -> Result, S::Error> { + self.store.get_run_lease(run_id) + } + + pub fn heartbeat_run( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result { + self.store + .heartbeat_run(run_id, worker_id, lease_token, lease_duration) + } + + pub fn release_run_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result { + self.store.release_run_lease(run_id, worker_id, lease_token) + } + + pub fn request_cancel(&self, run_id: &str) -> Result { + self.store.request_cancel(run_id) + } + + pub fn list_stale_run_ids(&self, limit: usize, now_ms: i64) -> Result, S::Error> { + self.store.list_stale_run_ids(limit, now_ms) + } + + pub fn requeue_safe_run(&self, run_id: &str) -> Result { + self.store.requeue_safe_run(run_id) + } + + pub fn read_checkpoint(&self, run_id: &str) -> Result, S::Error> { + self.store.read_checkpoint(run_id) + } + + pub fn read_checkpoint_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result, S::Error> { + self.store + .read_checkpoint_with_lease(run_id, worker_id, lease_token) + } + + pub fn save_checkpoint_with_lease( + &self, + checkpoint: DurableCheckpointInput, + worker_id: &str, + lease_token: &str, + ) -> Result { + self.store + .save_checkpoint_with_lease(checkpoint, worker_id, lease_token) + } + + pub fn save_checkpoint_with_runtime_and_lease( + &self, + commit: DurableCheckpointRuntimeCommit, + worker_id: &str, + lease_token: &str, + ) -> Result { + self.store + .save_checkpoint_with_runtime_and_lease(commit, worker_id, lease_token) + } + + pub fn record_reconciliation_result( + &self, + run_id: &str, + phase: &str, + external_id: &str, + step: i64, + attempt: i64, + messages: Value, + ) -> Result { + self.store + .record_reconciliation_result(run_id, phase, external_id, step, attempt, messages) + } + + pub fn create_approval( + &self, + approval: DurableApprovalInput, + ) -> Result { + self.store.create_approval(approval) + } + + pub fn get_approval(&self, approval_id: &str) -> Result, S::Error> { + self.store.get_approval(approval_id) + } + + pub fn list_approvals_for_run( + &self, + run_id: &str, + ) -> Result, S::Error> { + self.store.list_approvals_for_run(run_id) + } + + pub fn get_approval_for_run_call( + &self, + run_id: &str, + tool_call_id: &str, + ) -> Result, S::Error> { + self.store.get_approval_for_run_call(run_id, tool_call_id) + } + + pub fn resolve_approval( + &self, + resolution: DurableApprovalResolution, + ) -> Result { + self.store.resolve_approval(resolution) + } + + pub fn cancel_pending_approvals(&self, run_id: &str) -> Result { + self.store.cancel_pending_approvals(run_id) + } + + pub fn create_tool_call( + &self, + call: DurableToolCallInput, + ) -> Result { + self.store.create_tool_call(call) + } + + pub fn complete_tool_call( + &self, + call_id: &str, + status: &str, + result: Value, + ) -> Result { + self.store.complete_tool_call(call_id, status, result) + } + + /// Atomically persist a tool-call row with the corresponding Core runtime + /// snapshot/events when the adapter supports the extended contract. + pub fn create_tool_call_with_runtime_and_lease( + &self, + commit: DurableToolCallRuntimeCommit, + ) -> Result + where + S::Error: From, + { + self.store.create_tool_call_with_runtime_and_lease(commit) + } + + /// Atomically finish a tool-call row and commit its Core runtime events. + /// Checkpoints remain on their existing command because they have a + /// separate fencing contract. + pub fn complete_tool_call_with_runtime_and_lease( + &self, + commit: DurableToolCallRuntimeCommit, + status: &str, + result: Value, + ) -> Result + where + S::Error: From, + { + self.store + .complete_tool_call_with_runtime_and_lease(commit, status, result) + } + + /// Atomically persist a requested tool row, its checkpoint and Core runtime + /// event batch. Adapter 负责在一个事务中执行 lease/CAS 校验。 + pub fn create_tool_call_with_checkpoint_runtime_and_lease( + &self, + commit: DurableToolCallCheckpointRuntimeCommit, + ) -> Result + where + S::Error: From, + { + self.store + .create_tool_call_with_checkpoint_runtime_and_lease(commit) + } + + /// Atomically persist a completed tool row, its checkpoint and Core runtime + /// event batch; any failed validation must roll the whole adapter transaction back. + pub fn complete_tool_call_with_checkpoint_runtime_and_lease( + &self, + commit: DurableToolCallCheckpointRuntimeCommit, + status: &str, + result: Value, + ) -> Result + where + S::Error: From, + { + self.store + .complete_tool_call_with_checkpoint_runtime_and_lease(commit, status, result) + } + + pub fn get_tool_call(&self, call_id: &str) -> Result, S::Error> { + self.store.get_tool_call(call_id) + } + + pub fn list_tool_calls_for_run( + &self, + run_id: &str, + ) -> Result, S::Error> { + self.store.list_tool_calls_for_run(run_id) + } + + pub fn queue_approved_run(&self, approval_id: &str) -> Result { + self.store.queue_approved_run(approval_id) + } + + pub fn finish_run_with_runtime( + &self, + command: DurableFinishCommand, + ) -> Result { + self.store.finish_run_with_runtime(command) + } + + pub fn mark_cancelled_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + ) -> Result { + self.store + .mark_cancelled_with_lease(run_id, worker_id, lease_token, output) + } + + pub fn mark_cancelled( + &self, + run_id: &str, + output: Option, + ) -> Result { + self.store.mark_cancelled(run_id, output) + } + + pub fn recover_expired_run(&self, run_id: &str) -> Result { + self.store.recover_expired_run(run_id) + } + + pub fn recover_expired_run_with_runtime( + &self, + commit: DurableRecoveryCommit, + ) -> Result { + self.store.recover_expired_run_with_runtime(commit) + } + + pub fn upsert_external_session( + &self, + session: DurableExternalSessionInput, + ) -> Result { + self.store.upsert_external_session(session) + } + + pub fn update_external_session( + &self, + id: &str, + external_id: &str, + status: &str, + metadata: Value, + ) -> Result { + self.store + .update_external_session(id, external_id, status, metadata) + } + + pub fn get_external_session( + &self, + id: &str, + ) -> Result, S::Error> { + self.store.get_external_session(id) + } + + pub fn list_external_sessions( + &self, + statuses: &[&str], + run_id: Option<&str>, + limit: usize, + ) -> Result, S::Error> { + self.store.list_external_sessions(statuses, run_id, limit) + } + + pub fn load_runtime_snapshot( + &self, + runtime_id: &str, + ) -> Result, S::Error> { + self.store.load_runtime_snapshot(runtime_id) + } + + pub fn commit_runtime_snapshot( + &self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), S::Error> { + self.store + .commit_runtime_snapshot(runtime_id, expected_revision, snapshot, events) + } +} + +/// A deliberately small, test-only adapter for the generic durable facade. +/// +/// This is not a second production store: the contract has many methods and a +/// fake must still implement all of them, but the test only needs the run, +/// lease, snapshot-CAS and terminal paths. Unsupported methods return an +/// explicit error instead of silently pretending to persist data. +#[cfg(test)] +mod contract_tests { + use super::*; + use agent_runtime_core::{RunSnapshot, RuntimeEventKind, RuntimeSnapshot, reduce}; + use serde_json::json; + use std::collections::BTreeMap; + use std::sync::{Mutex, MutexGuard}; + use std::time::{SystemTime, UNIX_EPOCH}; + use thiserror::Error; + + #[derive(Debug, Error)] + enum MemoryError { + #[error("in-memory durable store lock poisoned")] + LockPoisoned, + #[error("in-memory durable store conflict: {0}")] + Conflict(String), + #[error("in-memory durable store invalid input: {0}")] + Invalid(String), + #[error("in-memory durable store entity not found: {0}")] + NotFound(String), + #[error("in-memory durable store operation is intentionally unsupported: {0}")] + Unsupported(&'static str), + } + + #[derive(Default)] + struct MemoryState { + sessions: BTreeMap, + runs: BTreeMap, + runtime_ids: BTreeMap, + leases: BTreeMap, + attempts: BTreeMap, + snapshots: BTreeMap, + } + + struct InMemoryDurableStore { + state: Mutex, + } + + impl Default for InMemoryDurableStore { + fn default() -> Self { + Self { + state: Mutex::new(MemoryState::default()), + } + } + } + + impl InMemoryDurableStore { + fn lock(&self) -> Result, MemoryError> { + self.state.lock().map_err(|_| MemoryError::LockPoisoned) + } + + fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_millis().min(i64::MAX as u128) as i64 + }) + } + + fn unsupported(operation: &'static str) -> Result { + Err(MemoryError::Unsupported(operation)) + } + + fn commit_snapshot( + state: &mut MemoryState, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), MemoryError> { + let actual = state + .snapshots + .get(runtime_id) + .map(RuntimeSnapshot::revision); + if actual != expected_revision { + return Err(MemoryError::Conflict(format!( + "runtime revision expected={expected_revision:?} actual={actual:?}" + ))); + } + if snapshot.runtime_id() != runtime_id || events.is_empty() { + return Err(MemoryError::Invalid( + "snapshot/event identity 无效".to_owned(), + )); + } + snapshot + .validate() + .map_err(|error| MemoryError::Invalid(error.to_string()))?; + let previous = expected_revision.unwrap_or(0); + if snapshot.revision() <= previous + || snapshot.revision() - previous != events.len() as u64 + { + return Err(MemoryError::Invalid( + "snapshot revision 与 event 数量不一致".to_owned(), + )); + } + for (offset, event) in events.iter().enumerate() { + let expected = previous + offset as u64 + 1; + if event.runtime_id() != runtime_id + || event.revision() != expected + || event.schema_version() != agent_runtime_core::RUNTIME_EVENT_SCHEMA_VERSION + { + return Err(MemoryError::Invalid( + "runtime event identity/revision 无效".to_owned(), + )); + } + } + state + .snapshots + .insert(runtime_id.to_owned(), snapshot.clone()); + Ok(()) + } + + fn terminal_status(target: DurableFinishTarget) -> &'static str { + match target { + DurableFinishTarget::Completed => "completed", + DurableFinishTarget::Failed => "failed", + DurableFinishTarget::Cancelled => "cancelled", + } + } + } + + impl DurableStore for InMemoryDurableStore { + type Error = MemoryError; + + fn create_run_bundle( + &self, + bundle: DurableRunBundle, + ) -> Result { + let mut state = self.lock()?; + if bundle.run.session_id != bundle.session.id { + return Err(MemoryError::Invalid( + "run/session identity 不一致".to_owned(), + )); + } + if bundle.snapshot.runtime_id() != bundle.runtime_id { + return Err(MemoryError::Invalid("runtime identity 不一致".to_owned())); + } + bundle + .snapshot + .validate() + .map_err(|error| MemoryError::Invalid(error.to_string()))?; + if state.sessions.contains_key(&bundle.session.id) + || state.runs.contains_key(&bundle.run.id) + || state.snapshots.contains_key(&bundle.runtime_id) + { + return Err(MemoryError::Conflict("bundle identity 已存在".to_owned())); + } + let now = Self::now_ms(); + let session_id = bundle.session.id.clone(); + let run_id = bundle.run.id.clone(); + let runtime_id = bundle.runtime_id.clone(); + state.sessions.insert( + session_id.clone(), + DurableSessionView { + id: session_id.clone(), + agent_id: bundle.session.agent_id, + status: bundle.session.status, + metadata: bundle.session.metadata, + created_at: now, + updated_at: now, + }, + ); + state.runs.insert( + run_id.clone(), + DurableRunView { + id: run_id.clone(), + session_id, + status: bundle.run.status, + revision: 0, + input: bundle.run.input, + output: None, + cancel_requested: false, + created_at: now, + updated_at: now, + }, + ); + state.runtime_ids.insert(run_id.clone(), runtime_id.clone()); + state.snapshots.insert(runtime_id.clone(), bundle.snapshot); + Ok(DurableBundleResult { + session_id: state + .runs + .get(&run_id) + .expect("run was inserted") + .session_id + .clone(), + run_id, + runtime_id, + }) + } + + fn get_run(&self, run_id: &str) -> Result, Self::Error> { + Ok(self.lock()?.runs.get(run_id).cloned()) + } + + fn get_session(&self, session_id: &str) -> Result, Self::Error> { + Ok(self.lock()?.sessions.get(session_id).cloned()) + } + + fn is_cancel_requested(&self, run_id: &str) -> Result { + self.lock()? + .runs + .get(run_id) + .map(|run| run.cancel_requested) + .ok_or_else(|| MemoryError::NotFound(format!("run {run_id}"))) + } + + fn runtime_id_for_run(&self, run_id: &str) -> Result, Self::Error> { + Ok(self.lock()?.runtime_ids.get(run_id).cloned()) + } + + fn update_session( + &self, + session_id: &str, + status: &str, + metadata: Option, + ) -> Result { + let mut state = self.lock()?; + let session = state + .sessions + .get_mut(session_id) + .ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?; + session.status = status.to_owned(); + if let Some(metadata) = metadata { + session.metadata = metadata; + } + session.updated_at = Self::now_ms(); + Ok(session.clone()) + } + + fn claim_run_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result { + let mut state = self.lock()?; + if state.leases.contains_key(run_id) { + return Err(MemoryError::Conflict(format!("run {run_id} 已有 lease"))); + } + let attempt = { + let attempt = state.attempts.entry(run_id.to_owned()).or_insert(0); + *attempt += 1; + *attempt + }; + let now = Self::now_ms(); + let claimed_run = { + let run = state + .runs + .get_mut(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?; + if run.status != "queued" { + return Err(MemoryError::Conflict(format!( + "run {run_id} 当前状态 {} 不能 claim", + run.status + ))); + } + run.status = "running".to_owned(); + run.updated_at = now; + run.clone() + }; + let duration_ms = lease_duration.as_millis().min(i64::MAX as u128) as i64; + let lease = DurableLeaseView { + run_id: run_id.to_owned(), + worker_id: worker_id.to_owned(), + lease_token: lease_token.to_owned(), + lease_expires_at: now.saturating_add(duration_ms), + heartbeat_at: now, + attempt, + }; + state.leases.insert(run_id.to_owned(), lease.clone()); + Ok(DurableClaimResult { + run: claimed_run, + lease, + }) + } + + fn get_run_lease(&self, run_id: &str) -> Result, Self::Error> { + Ok(self.lock()?.leases.get(run_id).cloned()) + } + + fn heartbeat_run( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result { + let mut state = self.lock()?; + let lease = state + .leases + .get_mut(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("lease {run_id}")))?; + if lease.worker_id != worker_id || lease.lease_token != lease_token { + return Err(MemoryError::Conflict( + "lease fencing identity 不匹配".to_owned(), + )); + } + let now = Self::now_ms(); + lease.heartbeat_at = now; + lease.lease_expires_at = + now.saturating_add(lease_duration.as_millis().min(i64::MAX as u128) as i64); + Ok(lease.clone()) + } + + fn release_run_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result { + let mut state = self.lock()?; + let lease = state + .leases + .get(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("lease {run_id}")))?; + if lease.worker_id != worker_id || lease.lease_token != lease_token { + return Err(MemoryError::Conflict( + "lease fencing identity 不匹配".to_owned(), + )); + } + state.leases.remove(run_id); + let run = state + .runs + .get_mut(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?; + run.status = "reconciling".to_owned(); + run.updated_at = Self::now_ms(); + Ok(run.clone()) + } + + fn request_cancel(&self, run_id: &str) -> Result { + let mut state = self.lock()?; + let run = state + .runs + .get_mut(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?; + run.cancel_requested = true; + if matches!(run.status.as_str(), "queued" | "running") { + run.status = "cancel_requested".to_owned(); + } + run.updated_at = Self::now_ms(); + Ok(run.clone()) + } + + fn list_stale_run_ids( + &self, + limit: usize, + now_ms: i64, + ) -> Result, Self::Error> { + Ok(self + .lock()? + .leases + .iter() + .filter(|(_, lease)| lease.lease_expires_at <= now_ms) + .map(|(run_id, _)| run_id.clone()) + .take(limit) + .collect()) + } + + fn requeue_safe_run(&self, run_id: &str) -> Result { + let mut state = self.lock()?; + let run = state + .runs + .get_mut(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?; + if run.status != "reconciling" { + return Err(MemoryError::Conflict(format!( + "run {run_id} 不是 reconciling" + ))); + } + run.status = "queued".to_owned(); + run.updated_at = Self::now_ms(); + Ok(run.clone()) + } + + fn read_checkpoint( + &self, + _run_id: &str, + ) -> Result, Self::Error> { + Self::unsupported("read_checkpoint") + } + + fn read_checkpoint_with_lease( + &self, + _run_id: &str, + _worker_id: &str, + _lease_token: &str, + ) -> Result, Self::Error> { + Self::unsupported("read_checkpoint_with_lease") + } + + fn save_checkpoint_with_lease( + &self, + _checkpoint: DurableCheckpointInput, + _worker_id: &str, + _lease_token: &str, + ) -> Result { + Self::unsupported("save_checkpoint_with_lease") + } + + fn save_checkpoint_with_runtime_and_lease( + &self, + _commit: DurableCheckpointRuntimeCommit, + _worker_id: &str, + _lease_token: &str, + ) -> Result { + Self::unsupported("save_checkpoint_with_runtime_and_lease") + } + + fn record_reconciliation_result( + &self, + _run_id: &str, + _phase: &str, + _external_id: &str, + _step: i64, + _attempt: i64, + _messages: Value, + ) -> Result { + Self::unsupported("record_reconciliation_result") + } + + fn create_approval( + &self, + _approval: DurableApprovalInput, + ) -> Result { + Self::unsupported("create_approval") + } + + fn get_approval( + &self, + _approval_id: &str, + ) -> Result, Self::Error> { + Self::unsupported("get_approval") + } + + fn list_approvals_for_run( + &self, + _run_id: &str, + ) -> Result, Self::Error> { + Self::unsupported("list_approvals_for_run") + } + + fn get_approval_for_run_call( + &self, + _run_id: &str, + _tool_call_id: &str, + ) -> Result, Self::Error> { + Self::unsupported("get_approval_for_run_call") + } + + fn resolve_approval( + &self, + _resolution: DurableApprovalResolution, + ) -> Result { + Self::unsupported("resolve_approval") + } + + fn cancel_pending_approvals(&self, _run_id: &str) -> Result { + Self::unsupported("cancel_pending_approvals") + } + + fn queue_approved_run(&self, _approval_id: &str) -> Result { + Self::unsupported("queue_approved_run") + } + + fn finish_run_with_runtime( + &self, + command: DurableFinishCommand, + ) -> Result { + let mut state = self.lock()?; + let expected_lease = command.lease.as_ref(); + if let Some(lease) = expected_lease { + let actual = state + .leases + .get(&command.run_id) + .ok_or_else(|| MemoryError::NotFound(format!("lease {}", command.run_id)))?; + if actual.worker_id != lease.worker_id || actual.lease_token != lease.lease_token { + return Err(MemoryError::Conflict( + "finish lease fencing identity 不匹配".to_owned(), + )); + } + } else if state.leases.contains_key(&command.run_id) { + return Err(MemoryError::Conflict( + "finish 不能绕过 active lease".to_owned(), + )); + } + let runtime_id = state + .runtime_ids + .get(&command.run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {}", command.run_id)))?; + if runtime_id != &command.runtime_id { + return Err(MemoryError::Invalid( + "finish runtime identity 不一致".to_owned(), + )); + } + Self::commit_snapshot( + &mut state, + &command.runtime_id, + command.expected_runtime_revision, + &command.snapshot, + &command.events, + )?; + let status = Self::terminal_status(command.target); + let (session_id, updated_at, finished) = { + let run = state + .runs + .get_mut(&command.run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {}", command.run_id)))?; + run.status = status.to_owned(); + run.output = command.output; + run.revision = command + .snapshot + .run(&command.run_id) + .map_or(run.revision, |snapshot| { + i64::try_from(snapshot.revision).unwrap_or(i64::MAX) + }); + run.updated_at = Self::now_ms(); + (run.session_id.clone(), run.updated_at, run.clone()) + }; + state.leases.remove(&command.run_id); + if let Some(session) = state.sessions.get_mut(&session_id) { + session.status = status.to_owned(); + session.updated_at = updated_at; + } + Ok(finished) + } + + fn mark_cancelled_with_lease( + &self, + _run_id: &str, + _worker_id: &str, + _lease_token: &str, + _output: Option, + ) -> Result { + Self::unsupported("mark_cancelled_with_lease") + } + + fn mark_cancelled( + &self, + _run_id: &str, + _output: Option, + ) -> Result { + Self::unsupported("mark_cancelled") + } + + fn recover_expired_run(&self, run_id: &str) -> Result { + let mut state = self.lock()?; + if let Some(lease) = state.leases.get(run_id) + && lease.lease_expires_at > Self::now_ms() + { + return Err(MemoryError::Conflict("lease 尚未过期".to_owned())); + } + state.leases.remove(run_id); + let run = state + .runs + .get_mut(run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?; + run.status = "reconciling".to_owned(); + run.updated_at = Self::now_ms(); + Ok(run.clone()) + } + + fn recover_expired_run_with_runtime( + &self, + commit: DurableRecoveryCommit, + ) -> Result { + let mut state = self.lock()?; + if let Some(lease) = state.leases.get(&commit.run_id) + && lease.lease_expires_at > Self::now_ms() + { + return Err(MemoryError::Conflict("lease 尚未过期".to_owned())); + } + let runtime_id = state + .runtime_ids + .get(&commit.run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {}", commit.run_id)))?; + if runtime_id != &commit.runtime_id { + return Err(MemoryError::Invalid( + "recovery runtime identity 不一致".to_owned(), + )); + } + Self::commit_snapshot( + &mut state, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + state.leases.remove(&commit.run_id); + let (session_id, updated_at, recovered) = { + let run = state + .runs + .get_mut(&commit.run_id) + .ok_or_else(|| MemoryError::NotFound(format!("run {}", commit.run_id)))?; + run.status = "reconciling".to_owned(); + run.updated_at = Self::now_ms(); + (run.session_id.clone(), run.updated_at, run.clone()) + }; + if let Some(session) = state.sessions.get_mut(&session_id) { + session.status = "reconciling".to_owned(); + session.updated_at = updated_at; + } + Ok(recovered) + } + + fn upsert_external_session( + &self, + _session: DurableExternalSessionInput, + ) -> Result { + Self::unsupported("upsert_external_session") + } + + fn update_external_session( + &self, + _id: &str, + _external_id: &str, + _status: &str, + _metadata: Value, + ) -> Result { + Self::unsupported("update_external_session") + } + + fn create_tool_call( + &self, + _call: DurableToolCallInput, + ) -> Result { + Self::unsupported("create_tool_call") + } + + fn complete_tool_call( + &self, + _call_id: &str, + _status: &str, + _result: Value, + ) -> Result { + Self::unsupported("complete_tool_call") + } + + fn get_tool_call( + &self, + _call_id: &str, + ) -> Result, Self::Error> { + Self::unsupported("get_tool_call") + } + + fn list_tool_calls_for_run( + &self, + _run_id: &str, + ) -> Result, Self::Error> { + Self::unsupported("list_tool_calls_for_run") + } + + fn get_external_session( + &self, + _id: &str, + ) -> Result, Self::Error> { + Self::unsupported("get_external_session") + } + + fn list_external_sessions( + &self, + _statuses: &[&str], + _run_id: Option<&str>, + _limit: usize, + ) -> Result, Self::Error> { + Self::unsupported("list_external_sessions") + } + + fn load_runtime_snapshot( + &self, + runtime_id: &str, + ) -> Result, Self::Error> { + Ok(self.lock()?.snapshots.get(runtime_id).cloned()) + } + + fn commit_runtime_snapshot( + &self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(), Self::Error> { + let mut state = self.lock()?; + Self::commit_snapshot(&mut state, runtime_id, expected_revision, snapshot, events) + } + } + + fn fixture_bundle() -> DurableRunBundle { + let runtime_id = "memory-runtime".to_owned(); + let run_id = "memory-run".to_owned(); + let session_id = "memory-session".to_owned(); + let run = RunSnapshot::try_new(&run_id, "memory-agent", "memory task", 1) + .expect("valid run snapshot"); + let mut snapshot = RuntimeSnapshot::try_new(&runtime_id).expect("valid runtime snapshot"); + snapshot.runs.push(run); + DurableRunBundle { + session: DurableSessionInput { + id: session_id.clone(), + agent_id: Some("memory-agent".to_owned()), + status: "queued".to_owned(), + metadata: json!({"adapter":"memory"}), + }, + run: DurableRunInput { + id: run_id, + session_id, + status: "queued".to_owned(), + input: json!({"task":"memory task"}), + }, + runtime_id, + snapshot, + events: Vec::new(), + } + } + + #[test] + fn generic_durable_runtime_runs_without_sqlite_adapter() { + let facade = DurableRuntime::new(InMemoryDurableStore::default()); + let bundle = fixture_bundle(); + let expected = DurableBundleResult { + session_id: "memory-session".to_owned(), + run_id: "memory-run".to_owned(), + runtime_id: "memory-runtime".to_owned(), + }; + assert_eq!( + facade.create_run_bundle(bundle).expect("memory bundle"), + expected + ); + assert_eq!( + facade + .get_session("memory-session") + .expect("memory session") + .unwrap() + .status, + "queued" + ); + assert_eq!( + facade + .get_run("memory-run") + .expect("memory run") + .unwrap() + .status, + "queued" + ); + + let claimed = facade + .claim_run_with_lease( + "memory-run", + "memory-worker", + "memory-token", + Duration::from_secs(30), + ) + .expect("memory claim"); + assert_eq!(claimed.run.status, "running"); + assert_eq!(claimed.lease.attempt, 1); + let released = facade + .release_run_lease("memory-run", "memory-worker", "memory-token") + .expect("memory release"); + assert_eq!(released.status, "reconciling"); + facade.requeue_safe_run("memory-run").expect("safe requeue"); + let claimed = facade + .claim_run_with_lease( + "memory-run", + "memory-worker", + "memory-token-2", + Duration::from_secs(30), + ) + .expect("second memory claim"); + assert_eq!(claimed.lease.attempt, 2); + + // Build a real Core event so the fake exercises the same CAS shape as + // SQLite: one event advances one runtime revision. + let before = facade + .load_runtime_snapshot("memory-runtime") + .expect("memory snapshot") + .unwrap(); + let started = RuntimeEvent::status_changed( + "memory-runtime", + before.revision() + 1, + 2, + "memory-run", + RuntimeEventKind::RunStarted, + ) + .expect("started event"); + let running = reduce(&before, &started).expect("running snapshot"); + facade + .commit_runtime_snapshot( + "memory-runtime", + Some(before.revision()), + &running, + std::slice::from_ref(&started), + ) + .expect("memory CAS"); + let stale = facade.commit_runtime_snapshot( + "memory-runtime", + Some(before.revision()), + &running, + std::slice::from_ref(&started), + ); + assert!(matches!(stale, Err(MemoryError::Conflict(_)))); + + let completed = RuntimeEvent::completed( + "memory-runtime", + running.revision() + 1, + 3, + "memory-run", + "done", + ) + .expect("completed event"); + let terminal = reduce(&running, &completed).expect("terminal snapshot"); + let finished = facade + .finish_run_with_runtime(DurableFinishCommand { + run_id: "memory-run".to_owned(), + lease: Some(DurableLeaseIdentity { + worker_id: "memory-worker".to_owned(), + lease_token: "memory-token-2".to_owned(), + }), + target: DurableFinishTarget::Completed, + output: Some(json!({"text":"done"})), + runtime_id: "memory-runtime".to_owned(), + expected_runtime_revision: Some(running.revision()), + snapshot: terminal, + events: vec![completed], + guard: DurableFinishGuard::None, + }) + .expect("memory finish"); + assert_eq!(finished.status, "completed"); + assert!( + facade + .get_run_lease("memory-run") + .expect("lease query") + .is_none() + ); + assert_eq!( + facade + .load_runtime_snapshot("memory-runtime") + .expect("terminal snapshot") + .unwrap() + .run("memory-run") + .expect("terminal run") + .status(), + agent_runtime_core::RunStatus::Completed + ); + } + + #[test] + fn memory_adapter_recovers_an_expired_lease_without_sqlite() { + let facade = DurableRuntime::new(InMemoryDurableStore::default()); + facade + .create_run_bundle(fixture_bundle()) + .expect("memory bundle"); + facade + .claim_run_with_lease( + "memory-run", + "recovery-worker", + "recovery-token", + Duration::ZERO, + ) + .expect("expired memory claim"); + + let recovered = facade + .recover_expired_run("memory-run") + .expect("memory recovery"); + assert_eq!(recovered.status, "reconciling"); + assert!( + facade + .get_run_lease("memory-run") + .expect("lease query") + .is_none() + ); + } +} diff --git a/rust/crates/agent-runtime/src/lib.rs b/rust/crates/agent-runtime/src/lib.rs new file mode 100644 index 000000000..8501c9b6f --- /dev/null +++ b/rust/crates/agent-runtime/src/lib.rs @@ -0,0 +1,163 @@ +//! Portable Agent runtime building blocks. +//! +//! This crate deliberately contains no database, process, thread, or CLI +//! dependency. SQLite-backed durable run control lives in the sibling +//! `agent-runtime-sqlite` crate; keeping that adapter outside this crate makes +//! the generic runtime usable by in-memory and remote implementations. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agent_runtime_core::{StoreError, StoreErrorKind}; +use thiserror::Error; + +mod snapshot_store; +pub use snapshot_store::{DynRuntimeStore, RuntimeSnapshotService}; + +mod durable; +pub use durable::{ + DurableApprovalCheckpointRuntimeCommit, DurableApprovalInput, DurableApprovalResolution, + DurableApprovalView, DurableBundleResult, DurableCheckpointInput, + DurableCheckpointRuntimeCommit, DurableCheckpointView, DurableClaimResult, + DurableExternalSessionInput, DurableExternalSessionView, DurableFinishCommand, + DurableFinishGuard, DurableFinishTarget, DurableLeaseIdentity, DurableLeaseView, + DurableRecoveryCommit, DurableRunBundle, DurableRunInput, DurableRunView, DurableRuntime, + DurableSessionInput, DurableSessionView, DurableStore, DurableStoreUnsupported, + DurableToolCallCheckpointRuntimeCommit, DurableToolCallInput, DurableToolCallRuntimeCommit, + DurableToolCallView, +}; + +/// Errors returned by the portable snapshot/runtime-store facade. +/// +/// SQLite storage errors are intentionally not represented here. Consumers +/// that need durable run/lease/checkpoint control should depend on +/// `agent-runtime-sqlite` (or provide their own `DurableStore` implementation). +#[derive(Debug, Error)] +pub enum RuntimeError { + /// Core's typed store contract is preserved for portable callers. + #[error("运行时存储协议错误: {0}")] + StoreProtocol(#[from] StoreError), + /// Kept for adapters that need a short local validation message. + #[error("运行时存储协议错误: {0}")] + Store(String), + #[error("输入无效: {0}")] + InvalidInput(String), +} + +impl RuntimeError { + /// Return Core's stable error classification when the adapter supplied one. + pub fn store_error_kind(&self) -> Option { + match self { + Self::StoreProtocol(error) => Some(error.kind()), + _ => None, + } + } +} + +/// Portable result alias for [`RuntimeSnapshotService`] operations. +pub type Result = std::result::Result; + +/// Identity returned by a durable run preparation operation. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeRunHandle { + pub session_id: String, + pub run_id: String, + pub runtime_id: String, +} + +pub type PreparedRun = RuntimeRunHandle; +pub type RunHandle = RuntimeRunHandle; + +/// Opaque worker lease identity shared by durable adapters. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkerLease { + pub worker_id: String, + pub lease_token: String, +} + +impl WorkerLease { + /// Generate a process-local identity suitable for a durable adapter. + pub fn new(run_id: impl AsRef) -> Self { + let run_id = run_id.as_ref(); + let suffix = unique_suffix(); + let process = std::process::id(); + Self { + worker_id: format!("worker-{process}-{run_id}"), + lease_token: format!("lease-{process}-{suffix}"), + } + } + + /// Construct a stable identity for tests or an external scheduler. + pub fn with_identity( + worker_id: impl Into, + lease_token: impl Into, + ) -> Result { + let worker_id = worker_id.into(); + let lease_token = lease_token.into(); + if worker_id.trim().is_empty() { + return Err(RuntimeError::InvalidInput("worker_id 不能为空".to_owned())); + } + if lease_token.trim().is_empty() { + return Err(RuntimeError::InvalidInput( + "lease_token 不能为空".to_owned(), + )); + } + Ok(Self { + worker_id, + lease_token, + }) + } + + pub fn worker_id(&self) -> &str { + &self.worker_id + } + + pub fn lease_token(&self) -> &str { + &self.lease_token + } + + /// Compatibility spelling used by older host integrations. + pub fn token(&self) -> &str { + self.lease_token() + } +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +fn unique_suffix() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()); + let counter = NEXT_ID.fetch_add(1, Ordering::Relaxed); + format!("{millis}-{counter}") +} + +#[cfg(test)] +mod tests { + use super::{RuntimeRunHandle, WorkerLease}; + + #[test] + fn portable_lease_and_run_handle_round_trip() { + let lease = WorkerLease::with_identity("worker-test", "lease-test").expect("lease"); + assert!(WorkerLease::with_identity("", "lease-test").is_err()); + assert!(WorkerLease::with_identity("worker-test", "").is_err()); + let encoded = serde_json::to_value(&lease).expect("lease JSON"); + assert_eq!( + serde_json::from_value::(encoded).expect("lease"), + lease + ); + + let handle = RuntimeRunHandle { + session_id: "session-test".to_owned(), + run_id: "run-test".to_owned(), + runtime_id: "runtime-test".to_owned(), + }; + let encoded = serde_json::to_value(&handle).expect("handle JSON"); + assert_eq!( + serde_json::from_value::(encoded).expect("handle"), + handle + ); + } +} diff --git a/rust/crates/agent-runtime/src/snapshot_store.rs b/rust/crates/agent-runtime/src/snapshot_store.rs new file mode 100644 index 000000000..db8256023 --- /dev/null +++ b/rust/crates/agent-runtime/src/snapshot_store.rs @@ -0,0 +1,243 @@ +//! 可替换的 Runtime-only snapshot 存储门面。 +//! +//! `agent-runtime-sqlite::RuntimeService` 负责 SQLite 专属的 run、lease 和 +//! 跨表事务;这些职责不能仅靠一个泛型参数就安全抽象掉。本模块只覆盖 Core 的 +//! [`RuntimeStore`] 合同,让 Engine/Runtime-only 测试和未来适配器可以在不 +//! 构造 SQLite 数据库的情况下复用同一套 snapshot/event CAS 入口。 + +use agent_runtime_core::{RuntimeEvent, RuntimeSnapshot, RuntimeStore}; + +use crate::{Result, RuntimeError}; + +/// 仅协调 RuntimeSnapshot/RuntimeEvent 的可替换门面。 +/// +/// 该类型刻意使用 `&mut self` 提交,直接保留 [`RuntimeStore`] 的所有权和 +/// CAS 语义;它不会伪装成完整 durable Runtime,也不负责 run/session/lease +/// 或外部副作用。`S` 可以是 Core 自带的 `InMemoryRuntimeStore`,也可以是 +/// SQLite 或其它实现方提供的适配器。 +#[derive(Debug)] +pub struct RuntimeSnapshotService { + store: S, +} + +impl RuntimeSnapshotService { + /// 用一个已经构造好的 RuntimeStore 创建门面。 + pub fn new(store: S) -> Self { + Self { store } + } + + /// 只读访问底层适配器;调用方不需要因此暴露额外的全局状态。 + pub fn store(&self) -> &S { + &self.store + } + + /// 需要执行适配器专属可变操作时的显式窄入口。 + pub fn store_mut(&mut self) -> &mut S { + &mut self.store + } + + /// 结束门面生命周期并取回适配器所有权。 + pub fn into_store(self) -> S { + self.store + } +} + +impl RuntimeSnapshotService +where + S: RuntimeStore, +{ + /// 加载一个 Runtime 快照;存储错误保持在 Runtime 统一错误边界内。 + pub fn load(&self, runtime_id: &str) -> Result> { + self.store + .load(runtime_id) + .map_err(RuntimeError::StoreProtocol) + } + + /// 用 expected revision 原子提交一批 Runtime 事件和最终快照。 + pub fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<()> { + self.store + .commit(runtime_id, expected_revision, snapshot, events) + .map_err(RuntimeError::StoreProtocol) + } +} + +/// 允许调用方把尚未确定具体实现的 Store 作为 trait object 注入。 +/// +/// `RuntimeStore` 的实现位于 Core,Rust 的 orphan rule 不允许这个 crate +/// 为标准库 `Box` 直接增加 trait impl,因此用一个本地 newtype 承载 +/// `Box` 并转发同一 CAS 语义。它不会把 SQLite 的跨表 +/// 事务假装成通用能力,也不改变 `RuntimeStore` 合同。 +pub struct DynRuntimeStore { + inner: Box, +} + +impl std::fmt::Debug for DynRuntimeStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DynRuntimeStore") + .finish_non_exhaustive() + } +} + +impl DynRuntimeStore { + /// 从一个已经构造好的动态 Store 创建包装器。 + pub fn new(store: Box) -> Self { + Self { inner: store } + } + + /// 取回底层 trait object,供调用方更换 facade 生命周期。 + pub fn into_inner(self) -> Box { + self.inner + } +} + +impl RuntimeStore for DynRuntimeStore { + fn load( + &self, + runtime_id: &str, + ) -> std::result::Result, agent_runtime_core::StoreError> { + self.inner.load(runtime_id) + } + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> std::result::Result<(), agent_runtime_core::StoreError> { + self.inner + .commit(runtime_id, expected_revision, snapshot, events) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_runtime_core::{ + InMemoryRuntimeStore, RuntimeEventKind, SharedRuntimeStore, SystemClock, reduce, + }; + + #[test] + fn portable_facade_commits_and_loads_without_sqlite() { + let mut service = RuntimeSnapshotService::new(InMemoryRuntimeStore::new()); + let runtime_id = "runtime-portable"; + let initial = RuntimeSnapshot::try_new(runtime_id).expect("initial snapshot"); + let event = RuntimeEvent::runtime_created(runtime_id, 1, SystemClock.now_millis()) + .expect("runtime event"); + let next = reduce(&initial, &event).expect("reduced snapshot"); + + service + .commit(runtime_id, None, &next, std::slice::from_ref(&event)) + .expect("portable commit"); + + let loaded = service + .load(runtime_id) + .expect("portable load") + .expect("snapshot exists"); + assert_eq!(loaded, next); + assert_eq!(service.store().events(runtime_id).len(), 1); + assert_eq!( + service.store().events(runtime_id)[0].kind(), + RuntimeEventKind::RuntimeCreated + ); + } + + #[test] + fn portable_facade_preserves_typed_cas_conflict() { + let mut service = RuntimeSnapshotService::new(InMemoryRuntimeStore::new()); + let runtime_id = "runtime-conflict"; + let initial = RuntimeSnapshot::try_new(runtime_id).expect("initial snapshot"); + let event = RuntimeEvent::runtime_created(runtime_id, 1, SystemClock.now_millis()) + .expect("runtime event"); + let next = reduce(&initial, &event).expect("reduced snapshot"); + service + .commit(runtime_id, None, &next, std::slice::from_ref(&event)) + .expect("first commit"); + + let error = service + .commit(runtime_id, None, &next, std::slice::from_ref(&event)) + .expect_err("stale expected revision must fail"); + assert_eq!( + error.store_error_kind(), + Some(agent_runtime_core::StoreErrorKind::Conflict) + ); + assert!(matches!( + error, + RuntimeError::StoreProtocol(ref store_error) + if store_error.kind() == agent_runtime_core::StoreErrorKind::Conflict + )); + } + + #[test] + fn portable_facade_preserves_invalid_snapshot_classification() { + let mut service = RuntimeSnapshotService::new(InMemoryRuntimeStore::new()); + let runtime_id = "runtime-invalid"; + let event = RuntimeEvent::runtime_created(runtime_id, 1, SystemClock.now_millis()) + .expect("runtime event"); + // Keep the event batch valid but submit a snapshot that was not produced + // by it; the portable store must report InvalidSnapshot, not a string + // that callers need to parse. + let invalid = RuntimeSnapshot::try_new(runtime_id).expect("invalid snapshot"); + + let error = service + .commit(runtime_id, None, &invalid, std::slice::from_ref(&event)) + .expect_err("mismatched snapshot must fail"); + assert_eq!( + error.store_error_kind(), + Some(agent_runtime_core::StoreErrorKind::InvalidSnapshot) + ); + } + + #[test] + fn portable_facades_can_share_one_store_without_sqlite() { + let shared = SharedRuntimeStore::new(InMemoryRuntimeStore::new()); + let mut writer = RuntimeSnapshotService::new(shared.clone()); + let observer = RuntimeSnapshotService::new(shared); + let runtime_id = "runtime-shared-facades"; + let initial = RuntimeSnapshot::try_new(runtime_id).expect("initial snapshot"); + let event = RuntimeEvent::runtime_created(runtime_id, 1, SystemClock.now_millis()) + .expect("runtime event"); + let next = reduce(&initial, &event).expect("reduced snapshot"); + + writer + .commit(runtime_id, None, &next, std::slice::from_ref(&event)) + .expect("shared facade commit"); + let loaded = observer + .load(runtime_id) + .expect("shared facade load") + .expect("snapshot exists"); + assert_eq!(loaded, next); + } + + #[test] + fn portable_facade_accepts_dynamic_store_without_sqlite() { + // The caller can select an adapter at runtime while retaining the same + // typed CAS/error boundary. No SQLite-specific type crosses this API. + let store: Box = Box::new(InMemoryRuntimeStore::new()); + let mut service = RuntimeSnapshotService::new(DynRuntimeStore::new(store)); + let runtime_id = "runtime-dynamic-store"; + let initial = RuntimeSnapshot::try_new(runtime_id).expect("initial snapshot"); + let event = RuntimeEvent::runtime_created(runtime_id, 1, SystemClock.now_millis()) + .expect("runtime event"); + let next = reduce(&initial, &event).expect("reduced snapshot"); + + service + .commit(runtime_id, None, &next, std::slice::from_ref(&event)) + .expect("dynamic store commit"); + assert_eq!( + service + .load(runtime_id) + .expect("dynamic store load") + .expect("snapshot exists"), + next + ); + let _store: Box = service.into_store().into_inner(); + } +} diff --git a/rust/crates/agent-skills/Cargo.toml b/rust/crates/agent-skills/Cargo.toml new file mode 100644 index 000000000..9008edf4f --- /dev/null +++ b/rust/crates/agent-skills/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agent-skills" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "通用 Agent 的 SKILL.md 发现与显式激活逻辑" +license = "MIT" + +[dependencies] +agent-runtime-core.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json.workspace = true diff --git a/rust/crates/agent-skills/fixtures/invalid/unclosed-quote/SKILL.md b/rust/crates/agent-skills/fixtures/invalid/unclosed-quote/SKILL.md new file mode 100644 index 000000000..2b19fc814 --- /dev/null +++ b/rust/crates/agent-skills/fixtures/invalid/unclosed-quote/SKILL.md @@ -0,0 +1,5 @@ +--- +name: invalid-fixture +description: "未闭合 +--- +这个文件只用于验证 discovery 的 frontmatter 错误边界。 diff --git a/rust/crates/agent-skills/fixtures/skills/review/SKILL.md b/rust/crates/agent-skills/fixtures/skills/review/SKILL.md new file mode 100644 index 000000000..3c6454536 --- /dev/null +++ b/rust/crates/agent-skills/fixtures/skills/review/SKILL.md @@ -0,0 +1,11 @@ +--- +name: review +description: 一个用于测试发现和显式激活的 Skill +version: "fixture-1" +allowed-tools: [fixture_echo] +x-fixture: true +--- + +# Review fixture + +这里只是 fixture 正文。`allowed-tools` 是候选元数据,不会自动授予工具权限。 diff --git a/rust/crates/agent-skills/src/lib.rs b/rust/crates/agent-skills/src/lib.rs new file mode 100644 index 000000000..8d32f5344 --- /dev/null +++ b/rust/crates/agent-skills/src/lib.rs @@ -0,0 +1,1414 @@ +//! SKILL.md 的发现和按需加载。 +//! +//! 这里故意不包含工具执行、权限授予或脚本运行逻辑。Skill 只是可被 +//! Agent 显式激活的一段工作流知识,真正的副作用仍需经过上层工具策略。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; + +use agent_runtime_core::{ + ContextItem, ExtensionError, ExtensionErrorKind, Message, SkillActivation, SkillDefinition, + SkillSource, +}; +use serde::{Deserialize, Serialize}; + +const SKILL_FILE_NAME: &str = "SKILL.md"; +const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024; +const MAX_METADATA_BYTES: usize = 64 * 1024; +const FINGERPRINT_PREFIX: &str = "fnv1a64:"; +const FNV1A64_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3; +// 激活时允许分隔线后最多一个 Unix/Windows 换行作为总长开销,避免正文 +// 恰好卡在上限时被快速长度检查误拒绝;最终仍以 body_part 的字节数为准。 +const MAX_BODY_SEPARATOR_BYTES: usize = 2; + +/// SKILL.md frontmatter 中可用于展示和选择的元数据。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkillMetadata { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// 普通工具候选元数据;即使值指向脚本,也不代表自动执行或授予权限。 + #[serde(default, rename = "allowed-tools")] + pub allowed_tools: Vec, + /// 保留未知字段,便于未来格式扩展而不丢信息。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl SkillMetadata { + /// 返回 Skill 声明的普通工具候选;最终是否可调用仍由 Host/Engine 策略决定。 + pub fn tool_candidates(&self) -> &[String] { + &self.allowed_tools + } +} + +/// 一个已发现但尚未加载正文的 Skill。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillDescriptor { + metadata: SkillMetadata, + /// 规范化后的 SKILL.md 路径;字段私有,避免调用方伪造越界路径。 + path: PathBuf, + /// 发现时按完整文件字节计算的确定性内容指纹。 + fingerprint: String, +} + +impl SkillDescriptor { + pub fn name(&self) -> &str { + &self.metadata.name + } + + pub fn metadata(&self) -> &SkillMetadata { + &self.metadata + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// `entry_path` 是语义更明确的别名;调用方应自行决定是否向模型暴露它。 + pub fn entry_path(&self) -> &Path { + self.path() + } + + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// `allowed-tools`(包括脚本形式的工具名)只作为候选元数据返回。 + pub fn tool_candidates(&self) -> &[String] { + self.metadata.tool_candidates() + } +} + +/// 显式激活后才会产生的正文。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActivatedSkill { + pub descriptor: SkillDescriptor, + /// 已去掉 frontmatter 分隔线的 Markdown 正文。 + pub body: String, +} + +impl ActivatedSkill { + /// 以借用形式访问正文,方便上下文源注入而无需复制。 + pub fn body(&self) -> &str { + &self.body + } + + /// 返回本次激活实际校验通过的内容指纹。 + pub fn fingerprint(&self) -> &str { + self.descriptor.fingerprint() + } +} + +/// 发现和加载 Skill 时的错误。 +#[derive(Debug)] +pub enum SkillError { + Io(std::io::Error), + InvalidUtf8, + MissingFrontmatter, + InvalidFrontmatter(String), + InvalidName(String), + MetadataTooLarge, + BodyTooLarge, + /// Frontmatter 合法但没有可注入的正文;发现阶段仍可保留目录项。 + EmptyBody, + PathOutsideRoot(PathBuf), + SkillNotFound(String), + DuplicateName(String), + /// 文件在发现后发生变化;调用方应重新发现,而不是使用过期描述符。 + ContentChanged(String), +} + +impl fmt::Display for SkillError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "读取 Skill 文件失败: {error}"), + Self::InvalidUtf8 => write!(f, "SKILL.md 不是有效的 UTF-8 文件"), + Self::MissingFrontmatter => write!(f, "SKILL.md 缺少有效 frontmatter"), + Self::InvalidFrontmatter(message) => write!(f, "SKILL.md frontmatter 无效: {message}"), + Self::InvalidName(name) => write!(f, "Skill 名称无效: {name}"), + Self::MetadataTooLarge => write!(f, "Skill frontmatter 超过大小限制"), + Self::BodyTooLarge => write!(f, "Skill 正文超过大小限制"), + Self::EmptyBody => write!(f, "Skill 正文不能为空"), + Self::PathOutsideRoot(path) => { + write!(f, "Skill 路径超出配置根目录: {}", path.display()) + } + Self::SkillNotFound(name) => write!(f, "找不到 Skill: {name}"), + Self::DuplicateName(name) => write!(f, "发现重复 Skill 名称: {name}"), + Self::ContentChanged(name) => { + write!(f, "Skill 在发现后发生变化,请重新发现: {name}") + } + } + } +} + +impl std::error::Error for SkillError {} + +impl From for SkillError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +/// 解析一份完整 SKILL.md 的 frontmatter。 +pub fn parse_skill_metadata(document: &str) -> Result { + let frontmatter = frontmatter_part(document)?; + parse_frontmatter_fields(frontmatter) +} + +/// `parse_skill_metadata` 的简短别名,方便调用方按自己的命名习惯使用。 +pub fn parse_metadata(document: &str) -> Result { + parse_skill_metadata(document) +} + +/// 一组 Skill 根目录。同名 Skill 会被显式拒绝,避免根目录顺序暗中改变行为。 +#[derive(Clone, Debug)] +pub struct SkillLoader { + roots: Vec, + max_body_bytes: usize, +} + +impl SkillLoader { + pub fn new(root: impl Into) -> Self { + Self::with_roots([root.into()]) + } + + pub fn with_roots(roots: I) -> Self + where + I: IntoIterator, + { + Self { + roots: roots.into_iter().collect(), + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + } + } + + pub fn roots(&self) -> &[PathBuf] { + &self.roots + } + + pub fn max_body_bytes(&self) -> usize { + self.max_body_bytes + } + + pub fn with_max_body_bytes(mut self, max_body_bytes: usize) -> Self { + self.max_body_bytes = max_body_bytes; + self + } + + /// 解析每个子目录的 frontmatter,并流式计算完整文件指纹;正文不会留在内存。 + pub fn discover(&self) -> Result, SkillError> { + let mut result = Vec::new(); + let mut names = BTreeMap::::new(); + for root in &self.roots { + let root = canonical_root(root)?; + let mut entries = fs::read_dir(&root)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let candidate = entry.path(); + if !candidate.is_dir() { + continue; + } + let skill_file = candidate.join(SKILL_FILE_NAME); + if !skill_file.is_file() { + continue; + } + let skill_file = ensure_inside(&root, &skill_file)?; + let (metadata, fingerprint) = inspect_skill_file(&skill_file, self.max_body_bytes)?; + if names + .insert(metadata.name.clone(), skill_file.clone()) + .is_some() + { + return Err(SkillError::DuplicateName(metadata.name)); + } + result.push(SkillDescriptor { + metadata, + path: skill_file, + fingerprint, + }); + } + } + Ok(result) + } + + /// 按名称激活唯一的 Skill;正文只在这里加载进内存。 + pub fn activate(&self, name: &str) -> Result { + let descriptor = self + .discover()? + .into_iter() + .find(|descriptor| descriptor.name() == name) + .ok_or_else(|| SkillError::SkillNotFound(name.to_owned()))?; + self.activate_descriptor(&descriptor) + } + + /// 激活一个由本 loader 发现的描述符,并再次检查路径边界。 + pub fn activate_descriptor( + &self, + descriptor: &SkillDescriptor, + ) -> Result { + let mut trusted = false; + let mut outside_path = None; + for root in &self.roots { + let root = canonical_root(root)?; + match ensure_inside(&root, &descriptor.path) { + Ok(_) => { + trusted = true; + break; + } + Err(SkillError::PathOutsideRoot(path)) => outside_path = Some(path), + Err(error) => return Err(error), + } + } + if !trusted { + return Err(SkillError::PathOutsideRoot( + outside_path.unwrap_or_else(|| descriptor.path.clone()), + )); + } + + // 激活前再次流式检查完整内容,拒绝 discovery 后被替换的文件。 + // 这一步也先完成 frontmatter 错误分类,避免超大元数据被误报为正文超限。 + let (current_prefix_metadata, current_fingerprint) = + inspect_skill_file(&descriptor.path, self.max_body_bytes)?; + if current_fingerprint != descriptor.fingerprint + || current_prefix_metadata != descriptor.metadata + { + return Err(SkillError::ContentChanged(descriptor.metadata.name.clone())); + } + + let file = File::open(&descriptor.path)?; + let metadata = file.metadata()?; + // 先用 metadata 长度做有界的快速拒绝,再由 body_part 做精确正文检查。 + // frontmatter 的独立上限仍由 parse_skill_metadata 校验,不能把超大的 + // frontmatter 错报成正文超限。 + let max_document_bytes = MAX_METADATA_BYTES + .saturating_add(self.max_body_bytes) + .saturating_add(MAX_BODY_SEPARATOR_BYTES); + if metadata.len() > max_document_bytes as u64 { + return Err(SkillError::BodyTooLarge); + } + // `metadata.len()` above is only a fast path and can race with a file + // replacement/growth. Read through a bounded adapter as well; the + // second check is what keeps activation memory-bounded under TOCTOU. + let document = read_document_bounded(file, max_document_bytes)?; + // 防止文件在上面的检查和本次实际读取之间再次变化。 + if fingerprint_bytes(document.as_bytes()) != descriptor.fingerprint { + return Err(SkillError::ContentChanged(descriptor.metadata.name.clone())); + } + // 重新解析元数据,保证返回值来自这次实际读取的同一份内容。 + let current_metadata = parse_skill_metadata(&document)?; + if current_metadata != descriptor.metadata { + return Err(SkillError::ContentChanged(descriptor.metadata.name.clone())); + } + let body = body_part(&document)?.to_owned(); + if body.len() > self.max_body_bytes { + return Err(SkillError::BodyTooLarge); + } + // discover 只需要目录元数据,因此允许先发现空正文;真正激活时必须 + // 有可注入内容,避免 legacy ActivatedSkill 与 Core SkillActivation + // 对同一个文件给出不一致的成功结果。 + if body.trim().is_empty() { + return Err(SkillError::EmptyBody); + } + Ok(ActivatedSkill { + descriptor: SkillDescriptor { + metadata: current_metadata, + path: descriptor.path.clone(), + fingerprint: descriptor.fingerprint.clone(), + }, + body, + }) + } + + /// 将文件发现结果转换成内核可消费的稳定 Skill 目录。 + /// + /// 这里仅复制 frontmatter 元数据,不读取正文,也不会把 + /// `allowed-tools` 变成任何执行权限。 + pub fn list_definitions(&self) -> Result, ExtensionError> { + self.discover() + .map_err(skill_error_to_extension) + .and_then(|descriptors| { + descriptors + .iter() + .map(core_definition) + .collect::, _>>() + }) + } + + /// 按名称显式激活,并转换为内核的 SkillActivation。 + /// + /// Skill 正文同时保留在 activation.body 和一个不可信 ContextItem 中, + /// 便于 Host 按上下文策略裁剪;`tools` 刻意保持为空。 + pub fn activate_core(&self, name: &str) -> Result { + let active = self.activate(name).map_err(skill_error_to_extension)?; + core_activation(active) + } +} + +/// 将文件适配器错误归类为内核扩展错误,不把底层路径或解析错误泄漏成权限。 +fn skill_error_to_extension(error: SkillError) -> ExtensionError { + let kind = match error { + SkillError::SkillNotFound(_) => ExtensionErrorKind::NotFound, + SkillError::Io(_) => ExtensionErrorKind::Unavailable, + SkillError::InvalidUtf8 + | SkillError::MissingFrontmatter + | SkillError::InvalidFrontmatter(_) + | SkillError::InvalidName(_) + | SkillError::MetadataTooLarge + | SkillError::BodyTooLarge + | SkillError::EmptyBody + | SkillError::PathOutsideRoot(_) + | SkillError::DuplicateName(_) + | SkillError::ContentChanged(_) => ExtensionErrorKind::InvalidInput, + }; + ExtensionError::new(kind, error.to_string()) +} + +/// 构造 Core 目录定义;缺少有效描述时用名称生成最小非空描述。 +fn core_definition(metadata: &SkillDescriptor) -> Result { + let source = metadata.metadata(); + let description = source + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| format!("Skill {}", source.name)); + + let mut definition = + SkillDefinition::try_new(source.name.clone(), description).map_err(|error| { + ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()) + })?; + if let Some(version) = source.version.as_deref() { + definition = definition.with_version(version.to_owned()); + } + let mut metadata_value = serde_json::to_value(source).map_err(|error| { + ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()) + })?; + // 指纹属于适配器生成的权威元数据;同名 frontmatter 扩展字段不能覆盖它。 + metadata_value + .as_object_mut() + .expect("SkillMetadata 必定序列化为 JSON object") + .insert( + "content-fingerprint".to_owned(), + serde_json::Value::String(metadata.fingerprint().to_owned()), + ); + definition + .with_metadata(metadata_value) + .map_err(|error| ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string())) +} + +fn core_activation(active: ActivatedSkill) -> Result { + let definition = core_definition(&active.descriptor)?; + let body = active.body; + let metadata = definition.metadata().clone(); + // Skill 内容属于不可信上下文;即使 frontmatter 声明 allowed-tools, + // 也不在这里创建 ToolBinding 或授予任何审批权限。 + let message = Message::user(body.clone()).map_err(|error| { + ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()) + })?; + let context = ContextItem::try_new(format!("skill:{}", definition.name()), message, 10, false) + .map_err(|error| ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()))? + .with_metadata(metadata) + .map_err(|error| { + ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string()) + })?; + + SkillActivation::try_new(definition, body) + .map(|activation| activation.with_context([context])) + .map_err(|error| ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string())) +} + +impl SkillSource for SkillLoader { + fn list_skills(&self) -> Result, ExtensionError> { + self.list_definitions() + } + + fn activate(&self, name: &str) -> Result { + self.activate_core(name) + } +} + +/// 便捷函数:扫描一个根目录。 +pub fn discover_skills(root: impl Into) -> Result, SkillError> { + SkillLoader::new(root).discover() +} + +fn canonical_root(root: &Path) -> Result { + Ok(root.canonicalize()?) +} + +/// 规范化并检查路径是否仍位于根目录下,同时阻断 symlink 越界。 +fn ensure_inside(root: &Path, candidate: &Path) -> Result { + let canonical = candidate.canonicalize().map_err(SkillError::Io)?; + if canonical.starts_with(root) { + Ok(canonical) + } else { + Err(SkillError::PathOutsideRoot(canonical)) + } +} + +/// 单次打开文件:frontmatter 有界加载,正文只流式参与指纹计算。 +fn inspect_skill_file( + path: &Path, + max_body_bytes: usize, +) -> Result<(SkillMetadata, String), SkillError> { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut fingerprint = FNV1A64_OFFSET_BASIS; + // 用字节读取而不是 `read_line`,这样 frontmatter 中出现非法 UTF-8 + // 时可以稳定返回 InvalidUtf8,而不会被 BufRead 提前包装成 Io。 + let mut collected = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0usize; + let metadata = loop { + line.clear(); + if reader.read_until(b'\n', &mut line)? == 0 { + return Err(SkillError::MissingFrontmatter); + } + fingerprint_update(&mut fingerprint, &line); + if collected.len().saturating_add(line.len()) > MAX_METADATA_BYTES { + return Err(SkillError::MetadataTooLarge); + } + line_number += 1; + let is_end = line_number > 1 && is_delimiter_bytes(&line); + collected.extend_from_slice(&line); + if is_end { + let document = String::from_utf8(collected).map_err(|_| SkillError::InvalidUtf8)?; + break parse_skill_metadata(&document)?; + } + }; + + // Discovery must remain metadata-first and bounded even though it computes + // a fingerprint for the complete file. The body is therefore streamed + // through a hard limit; once the effective body (after the same CR/LF + // separator trimming used by `body_part`) exceeds the configured budget, + // fail before reading the remainder of an untrusted file. + let mut body_started = false; + let mut separator_bytes = 0usize; + let mut body_bytes = 0usize; + let mut probe_bytes = 0usize; + let mut buffer = [0_u8; 8 * 1024]; + loop { + // Read only as much as is needed to decide whether the configured + // body limit is exceeded. This keeps the discovery probe bounded + // (body limit + separator allowance + one byte) instead of reading a + // full buffer past the limit before noticing the overflow. + let read_limit = if body_started { + max_body_bytes + .saturating_sub(body_bytes) + .saturating_add(1) + .min(buffer.len()) + } else { + max_body_bytes + .saturating_add(MAX_BODY_SEPARATOR_BYTES) + .saturating_add(1) + .saturating_sub(probe_bytes) + .min(buffer.len()) + }; + if read_limit == 0 { + return Err(SkillError::BodyTooLarge); + } + let read = reader.read(&mut buffer[..read_limit])?; + if read == 0 { + break; + } + probe_bytes = probe_bytes.saturating_add(read); + fingerprint_update(&mut fingerprint, &buffer[..read]); + for byte in &buffer[..read] { + if !body_started + && (*byte == b'\r' || *byte == b'\n') + && separator_bytes < MAX_BODY_SEPARATOR_BYTES + { + separator_bytes += 1; + continue; + } + body_started = true; + body_bytes = body_bytes.saturating_add(1); + if body_bytes > max_body_bytes { + return Err(SkillError::BodyTooLarge); + } + } + } + + Ok((metadata, format_fingerprint(fingerprint))) +} + +fn fingerprint_bytes(bytes: &[u8]) -> String { + let mut fingerprint = FNV1A64_OFFSET_BASIS; + fingerprint_update(&mut fingerprint, bytes); + format_fingerprint(fingerprint) +} + +/// 最多读取 `max_document_bytes + 1` 个字节,并在转换为 `String` 前识别越界。 +/// +/// `activate_descriptor` 中的 metadata 长度检查不是最终真相:攻击者可能在 +/// `stat` 后替换或扩展文件。`Read::take` 让后续读取即使遇到这类竞态也保持 +/// 有界,同时保留原 `read_to_string` 路径的 UTF-8 错误分类。 +fn read_document_bounded( + reader: R, + max_document_bytes: usize, +) -> Result { + let read_limit = max_document_bytes + .checked_add(1) + .ok_or(SkillError::BodyTooLarge)?; + let mut bytes = Vec::new(); + let mut limited = reader.take(read_limit as u64); + limited.read_to_end(&mut bytes)?; + if bytes.len() > max_document_bytes { + return Err(SkillError::BodyTooLarge); + } + String::from_utf8(bytes).map_err(|_| SkillError::InvalidUtf8) +} + +fn fingerprint_update(fingerprint: &mut u64, bytes: &[u8]) { + for byte in bytes { + *fingerprint ^= u64::from(*byte); + *fingerprint = fingerprint.wrapping_mul(FNV1A64_PRIME); + } +} + +fn format_fingerprint(fingerprint: u64) -> String { + format!("{FINGERPRINT_PREFIX}{fingerprint:016x}") +} + +/// 判断一整行是否为 frontmatter 分隔线。 +/// +/// 分隔线必须是独立的 `---` 行;只去掉行尾 CR/LF,不接受前后空格, +/// 避免把正文中的 Markdown 水平线或缩进行误当成 frontmatter 边界。 +fn is_delimiter_line(line: &str) -> bool { + let line = line.strip_suffix('\n').unwrap_or(line); + let line = line.strip_suffix('\r').unwrap_or(line); + line == "---" +} + +fn is_delimiter_bytes(line: &[u8]) -> bool { + let line = line.strip_suffix(b"\n").unwrap_or(line); + let line = line.strip_suffix(b"\r").unwrap_or(line); + line == b"---" +} + +/// 返回 frontmatter 内容起止,以及闭合分隔线后的正文起点。 +fn frontmatter_bounds(document: &str) -> Result<(usize, usize, usize), SkillError> { + let first = document + .split_inclusive('\n') + .next() + .ok_or(SkillError::MissingFrontmatter)?; + if !is_delimiter_line(first) { + return Err(SkillError::MissingFrontmatter); + } + + let mut cursor = first.len(); + for line in document[first.len()..].split_inclusive('\n') { + if is_delimiter_line(line) { + let end = cursor; + return Ok((first.len(), end, end + line.len())); + } + cursor += line.len(); + } + Err(SkillError::MissingFrontmatter) +} + +fn frontmatter_part(document: &str) -> Result<&str, SkillError> { + let stripped_document = document.strip_prefix('\u{feff}').unwrap_or(document); + let bom_len = document.len() - stripped_document.len(); + let document = stripped_document; + let (start, end, body_start) = frontmatter_bounds(document)?; + if bom_len.saturating_add(body_start) > MAX_METADATA_BYTES { + return Err(SkillError::MetadataTooLarge); + } + Ok(&document[start..end]) +} + +fn body_part(document: &str) -> Result<&str, SkillError> { + let document = document.strip_prefix('\u{feff}').unwrap_or(document); + let (_start, _end, body_start) = frontmatter_bounds(document)?; + Ok(document[body_start..].trim_start_matches(['\r', '\n'])) +} + +fn parse_frontmatter_fields(frontmatter: &str) -> Result { + let mut metadata = SkillMetadata::default(); + let mut seen_keys = BTreeSet::new(); + for raw_line in frontmatter.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once(':') + .ok_or_else(|| SkillError::InvalidFrontmatter(line.to_owned()))?; + let key = key.trim(); + let raw_value = value.trim(); + if key.is_empty() { + return Err(SkillError::InvalidFrontmatter(line.to_owned())); + } + // Treat the two historical spellings of allowed-tools as one field. + // Reject every duplicate instead of silently taking the last value; + // otherwise a metadata-only discovery and a full YAML consumer could + // disagree about which tool candidates or description were declared. + let canonical_key = if key == "allowed_tools" { + "allowed-tools" + } else { + key + }; + if !seen_keys.insert(canonical_key.to_owned()) { + return Err(SkillError::InvalidFrontmatter(format!( + "重复的 {canonical_key} 字段" + ))); + } + match key { + "name" => { + let value = parse_scalar(raw_value).map_err(|message| { + SkillError::InvalidFrontmatter(format!("{line}: {message}")) + })?; + validate_name(&value)?; + metadata.name = value; + } + "description" => { + metadata.description = Some(parse_scalar(raw_value).map_err(|message| { + SkillError::InvalidFrontmatter(format!("{line}: {message}")) + })?); + } + "version" => { + metadata.version = Some(parse_scalar(raw_value).map_err(|message| { + SkillError::InvalidFrontmatter(format!("{line}: {message}")) + })?); + } + "allowed-tools" | "allowed_tools" => { + metadata.allowed_tools = parse_list(raw_value).map_err(|message| { + SkillError::InvalidFrontmatter(format!("{line}: {message}")) + })?; + } + _ => { + let value = parse_scalar(raw_value).map_err(|message| { + SkillError::InvalidFrontmatter(format!("{line}: {message}")) + })?; + metadata.extra.insert(key.to_owned(), value); + } + } + } + if metadata.name.is_empty() { + return Err(SkillError::InvalidFrontmatter("缺少 name 字段".to_owned())); + } + Ok(metadata) +} + +/// 解析本 crate 支持的有限列表语法。 +/// +/// 这不是 YAML 解析器:只支持逗号分隔的工具名,以及可选的外围方括号。 +/// 但边界必须严格:括号、引号和每个列表项都完整,否则不能把畸形元数据 +/// 静默降级成“没有工具”或截断后的工具名。 +fn parse_list(value: &str) -> Result, &'static str> { + let value = value.trim(); + let value = if value.starts_with('[') || value.ends_with(']') { + if !value.starts_with('[') || !value.ends_with(']') { + return Err("列表括号未闭合或不匹配"); + } + &value[1..value.len() - 1] + } else { + value + }; + + // Skill 元数据常见两种写法:`[read, shell]` 和 + // `["read", "shell"]`。小型扫描器足够覆盖工具名,不引入完整 + // YAML 解析器,同时保留引号内的空格和逗号。 + let mut result = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut saw_separator = false; + let mut had_item = false; + for character in value.chars() { + match quote { + Some(delimiter) if character == delimiter => quote = None, + Some(_) if character == '\'' || character == '"' => return Err("列表项引号不匹配"), + Some(_) => current.push(character), + None if character == '\'' || character == '"' => { + quote = Some(character); + had_item = true; + } + None if character == ',' => { + let item = current.trim(); + if !had_item || item.is_empty() { + return Err("列表项不能为空"); + } + result.push(parse_list_item(item)?); + current.clear(); + had_item = false; + saw_separator = true; + } + None if character.is_whitespace() => { + // Unquoted whitespace separates the historical shorthand + // `[read shell]`; quoted whitespace remains part of one item. + if had_item { + if current.trim().is_empty() { + return Err("列表项不能为空"); + } + result.push(parse_list_item(current.trim())?); + current.clear(); + had_item = false; + } + } + None => { + current.push(character); + had_item = true; + } + } + } + if quote.is_some() { + return Err("列表项引号未闭合"); + } + let item = current.trim(); + if had_item { + if item.is_empty() { + return Err("列表项不能为空"); + } + result.push(parse_list_item(item)?); + } else if saw_separator { + return Err("列表项不能为空"); + } + Ok(result) +} + +fn parse_list_item(value: &str) -> Result { + let item = parse_scalar(value)?; + if item.trim().is_empty() { + return Err("工具名不能为空"); + } + Ok(item) +} + +/// 解析一个标量;不支持转义,因而任何不成对的引号都直接拒绝。 +fn parse_scalar(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + let first = value.chars().next().expect("非空值已有首字符"); + let last = value.chars().next_back().expect("非空值已有尾字符"); + if first == '\'' || first == '"' { + if last != first || value.len() < 2 { + return Err("引号未闭合或不匹配"); + } + let inner = &value[first.len_utf8()..value.len() - last.len_utf8()]; + if inner.contains(first) { + return Err("引号不匹配(不支持转义)"); + } + return Ok(inner.to_owned()); + } + if last == '\'' || last == '"' || value.contains(['\'', '"']) { + return Err("引号未闭合或不匹配"); + } + Ok(value.to_owned()) +} + +fn validate_name(name: &str) -> Result<(), SkillError> { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + || name == "." + || name == ".." + { + return Err(SkillError::InvalidName(name.to_owned())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_root() -> PathBuf { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let sequence = NEXT_ID.fetch_add(1, Ordering::Relaxed); + // Respect an explicit TMPDIR, but default direct test runs to + // ~/data/tmp instead of the system /tmp directory. + let parent = std::env::var_os("TMPDIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("data/tmp")) + }) + .expect("TMPDIR 或 HOME 未设置,无法创建测试目录"); + fs::create_dir_all(&parent).unwrap(); + let root = parent.join(format!( + "agent-skills-{}-{id}-{sequence}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + root + } + + fn write_skill(root: &Path, directory: &str, document: impl AsRef<[u8]>) -> PathBuf { + let directory = root.join(directory); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join(SKILL_FILE_NAME); + fs::write(&path, document).unwrap(); + path + } + + fn metadata_sized_document(total_bytes: usize, body: &str) -> String { + let prefix = "---\nname: sized\n"; + let suffix = "---\n"; + let line_overhead = "x: \n".len(); + assert!(total_bytes > prefix.len() + suffix.len() + line_overhead); + let value_len = total_bytes - prefix.len() - suffix.len() - line_overhead; + let line = format!("x: {}\n", "a".repeat(value_len)); + assert_eq!(prefix.len() + line.len() + suffix.len(), total_bytes); + format!("{prefix}{line}{suffix}{body}") + } + + #[test] + fn 解析_frontmatter_并保留未知字段() { + let metadata = parse_skill_metadata( + "---\nname: review\ndescription: 检查代码\nallowed-tools: [read, shell]\nx-owner: team\n---\n正文", + ) + .unwrap(); + assert_eq!(metadata.name, "review"); + assert_eq!(metadata.allowed_tools, ["read", "shell"]); + assert_eq!(metadata.extra["x-owner"], "team"); + } + + #[test] + fn shipped_skill_fixture_supports_metadata_first_explicit_activation() { + // fixture 随 crate 一起复制/打包,验证真实目录扫描而不是只验证 + // 内存字符串;正文仍只在 activate 阶段读取。 + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("skills"); + let loader = SkillLoader::new(root); + let descriptors = loader.discover().expect("discover shipped fixture"); + assert_eq!(descriptors.len(), 1); + assert_eq!(descriptors[0].name(), "review"); + assert_eq!(descriptors[0].tool_candidates(), ["fixture_echo"]); + let active = loader.activate_core("review").expect("activate fixture"); + assert!(active.body().contains("Review fixture")); + // Skill 的候选工具不会被转换成 ToolBinding 或执行权限。 + assert!(active.tools().is_empty()); + } + + #[test] + fn shipped_invalid_skill_fixture_is_rejected_during_discovery() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("invalid"); + assert!(matches!( + SkillLoader::new(root).discover(), + Err(SkillError::InvalidFrontmatter(_)) + )); + } + + #[test] + fn 解析_frontmatter_支持_bom_crlf_和带引号列表() { + let metadata = parse_skill_metadata( + "\u{feff}---\r\nname: review\r\ndescription: \"检查: 代码\"\r\nallowed-tools: [\"read\", \"shell exec\"]\r\n---\r\n正文", + ) + .unwrap(); + assert_eq!(metadata.name, "review"); + assert_eq!(metadata.description.as_deref(), Some("检查: 代码")); + assert_eq!(metadata.allowed_tools, ["read", "shell exec"]); + } + + #[test] + fn 拒绝缺失或含空格的_frontmatter_分隔线() { + for document in [ + "name: review\n---\n正文", + "---\nname: review\n正文", + " ---\nname: review\n---\n正文", + "---\nname: review\n ---\n正文", + "---", + ] { + assert!(matches!( + parse_skill_metadata(document), + Err(SkillError::MissingFrontmatter) + )); + } + } + + #[test] + fn 拒绝缺失名称_重复名称和非法_frontmatter_字段() { + for (document, expected) in [ + ("---\ndescription: no name\n---\n正文", "missing"), + ("---\nname: one\nname: two\n---\n正文", "duplicate"), + ("---\nname: valid\nthis is not a field\n---\n正文", "field"), + ] { + let error = parse_skill_metadata(document).unwrap_err(); + assert!( + matches!(error, SkillError::InvalidFrontmatter(_)), + "{expected}: {error}" + ); + } + } + + #[test] + fn 拒绝重复的已知和扩展_frontmatter_字段() { + // Duplicate metadata used to be accepted with last-value-wins + // semantics. Refusing it keeps discovery and activation deterministic + // for callers that parse the same SKILL.md with a full YAML parser. + for document in [ + "---\nname: duplicate\ndescription: first\ndescription: second\n---\n正文", + "---\nname: duplicate\nversion: 1\nversion: 2\n---\n正文", + "---\nname: duplicate\nallowed-tools: [read]\nallowed-tools: [shell]\n---\n正文", + // The underscore spelling is a supported legacy alias, but it is + // still the same logical field as allowed-tools. + "---\nname: duplicate\nallowed-tools: [read]\nallowed_tools: [shell]\n---\n正文", + "---\nname: duplicate\nx-owner: first\nx-owner: second\n---\n正文", + ] { + assert!(matches!( + parse_skill_metadata(document), + Err(SkillError::InvalidFrontmatter(message)) if message.contains("重复") + )); + } + } + + #[test] + fn 拒绝未闭合或不匹配的_frontmatter_引号和列表括号() { + for document in [ + "---\nname: \"broken\n---\n正文", + "---\nname: 'broken\"\n---\n正文", + "---\nname: valid\ndescription: \"broken'\n---\n正文", + "---\nname: valid\nallowed-tools: [read\n---\n正文", + "---\nname: valid\nallowed-tools: read]\n---\n正文", + "---\nname: valid\nallowed-tools: [\"read]\n---\n正文", + ] { + assert!( + matches!( + parse_skill_metadata(document), + Err(SkillError::InvalidFrontmatter(_)) + ), + "应拒绝畸形 frontmatter: {document}" + ); + } + } + + #[test] + fn 拒绝空列表项和空工具名() { + for value in [ + "[read,]", + "[,read]", + "[read,,shell]", + "[\"\"]", + "[read, \"\"]", + ] { + let document = format!("---\nname: valid\nallowed-tools: {value}\n---\n正文"); + assert!( + matches!( + parse_skill_metadata(&document), + Err(SkillError::InvalidFrontmatter(_)) + ), + "应拒绝空工具名: {value}" + ); + } + } + + #[test] + fn 发现阶段不保留正文_激活才返回正文() { + let root = temp_root(); + write_skill(&root, "review", "---\nname: review\n---\n这是很长的正文。"); + + let loader = SkillLoader::new(root.clone()); + let found = loader.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].name(), "review"); + let active = loader.activate("review").unwrap(); + assert_eq!(active.body, "这是很长的正文。"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 正文边界不包含分隔线且保留正文中的水平线() { + let root = temp_root(); + write_skill(&root, "boundary", "---\nname: boundary\n---\n\n---\n正文"); + + let active = SkillLoader::new(root.clone()).activate("boundary").unwrap(); + assert_eq!(active.body(), "---\n正文"); + assert!(!active.body().contains("name: boundary")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 发现阶段不解析正文_utf8_激活才报告正文非法_utf8() { + let root = temp_root(); + write_skill(&root, "lazy", b"---\nname: lazy\n---\n\xff"); + + let loader = SkillLoader::new(root.clone()); + assert_eq!(loader.discover().unwrap()[0].name(), "lazy"); + assert!(matches!( + loader.activate("lazy"), + Err(SkillError::InvalidUtf8) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 发现阶段的非法_utf8_位于元数据时立即拒绝() { + let root = temp_root(); + write_skill(&root, "bad-metadata", b"---\nname: \xff\n---\nbody"); + + assert!(matches!( + SkillLoader::new(root.clone()).discover(), + Err(SkillError::InvalidUtf8) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 同名_skill_在单根或多根中都显式拒绝() { + let first_root = temp_root(); + let second_root = temp_root(); + write_skill(&first_root, "z-last", "---\nname: duplicate\n---\nfirst-z"); + write_skill(&first_root, "a-first", "---\nname: duplicate\n---\nfirst-a"); + write_skill(&second_root, "only", "---\nname: duplicate\n---\nsecond"); + + assert!(matches!( + SkillLoader::new(first_root.clone()).discover(), + Err(SkillError::DuplicateName(name)) if name == "duplicate" + )); + + let loader = SkillLoader::with_roots(vec![second_root.clone(), first_root.clone()]); + assert!(matches!( + loader.activate("duplicate"), + Err(SkillError::DuplicateName(name)) if name == "duplicate" + )); + fs::remove_dir_all(first_root).unwrap(); + fs::remove_dir_all(second_root).unwrap(); + } + + #[test] + fn 内容指纹确定且覆盖完整文件字节() { + let first_root = temp_root(); + let second_root = temp_root(); + let original = "---\nname: review\n---\n正文"; + write_skill(&first_root, "review", original); + write_skill(&second_root, "review", original); + + let first = SkillLoader::new(first_root.clone()) + .discover() + .unwrap() + .remove(0); + let second = SkillLoader::new(second_root.clone()) + .discover() + .unwrap() + .remove(0); + assert_eq!(first.fingerprint(), second.fingerprint()); + assert!(first.fingerprint().starts_with(FINGERPRINT_PREFIX)); + + write_skill(&second_root, "review", "---\nname: review\n---\n正文已变化"); + let changed = SkillLoader::new(second_root.clone()) + .discover() + .unwrap() + .remove(0); + assert_ne!(first.fingerprint(), changed.fingerprint()); + fs::remove_dir_all(first_root).unwrap(); + fs::remove_dir_all(second_root).unwrap(); + } + + #[test] + fn 激活拒绝发现后内容变化并保留已校验指纹() { + let root = temp_root(); + let path = write_skill(&root, "mutable", "---\nname: mutable\n---\nold"); + let loader = SkillLoader::new(root.clone()); + let descriptor = loader.discover().unwrap().remove(0); + let expected = descriptor.fingerprint().to_owned(); + + let active = loader.activate_descriptor(&descriptor).unwrap(); + assert_eq!(active.fingerprint(), expected); + + fs::write(&path, "---\nname: mutable\n---\nnew").unwrap(); + assert!(matches!( + loader.activate_descriptor(&descriptor), + Err(SkillError::ContentChanged(name)) if name == "mutable" + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 正文大小限制按字节且包含上下边界() { + let root = temp_root(); + write_skill(&root, "small", "---\nname: small\n---\n中"); + let exact = SkillLoader::new(root.clone()).with_max_body_bytes("中".len()); + assert_eq!(exact.activate("small").unwrap().body(), "中"); + + let too_small = SkillLoader::new(root.clone()).with_max_body_bytes(2); + assert!(matches!( + too_small.activate("small"), + Err(SkillError::BodyTooLarge) + )); + + write_skill(&root, "ascii", "---\nname: ascii\n---\nabcd"); + let empty = SkillLoader::new(root.clone()).with_max_body_bytes(0); + assert!(matches!( + empty.activate("ascii"), + Err(SkillError::BodyTooLarge) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn discovery_and_list_reject正文超过配置上限() { + let root = temp_root(); + write_skill(&root, "oversized", "---\nname: oversized\n---\n123456789"); + let loader = SkillLoader::new(root.clone()).with_max_body_bytes(8); + + assert!(matches!(loader.discover(), Err(SkillError::BodyTooLarge))); + let error = loader + .list_definitions() + .expect_err("超大正文不应进入 Skill 目录"); + assert_eq!(error.kind(), ExtensionErrorKind::InvalidInput); + assert!(error.message().contains("正文超过大小限制")); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 有界正文读取拒绝文件增长后的超限内容() { + let root = temp_root(); + let path = root.join("growing-SKILL.md"); + // 用比读取预算更大的实际文件模拟 stat 之后文件被替换/增长; + // `read_document_bounded` 只能读取一个额外字节来确认越界。 + fs::write(&path, "x".repeat(1024)).unwrap(); + let file = File::open(&path).unwrap(); + assert!(matches!( + read_document_bounded(file, 8), + Err(SkillError::BodyTooLarge) + )); + + let exact = read_document_bounded(std::io::Cursor::new(b"12345678"), 8).unwrap(); + assert_eq!(exact, "12345678"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn legacy_激活拒绝空正文和仅空白正文() { + let root = temp_root(); + write_skill(&root, "empty", "---\nname: empty\n---\n"); + write_skill(&root, "whitespace", "---\nname: whitespace\n---\n \t\n"); + + let loader = SkillLoader::new(root.clone()); + // 目录发现仍是惰性的:正文仅参与流式指纹计算,不会被解析或保留。 + assert_eq!(loader.discover().unwrap().len(), 2); + assert!(matches!( + loader.activate("empty"), + Err(SkillError::EmptyBody) + )); + assert!(matches!( + loader.activate("whitespace"), + Err(SkillError::EmptyBody) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn core_激活把空正文映射为_invalid_input() { + let root = temp_root(); + write_skill(&root, "empty-core", "---\nname: empty-core\n---\n\n"); + + let error = SkillLoader::new(root.clone()) + .activate_core("empty-core") + .unwrap_err(); + assert_eq!(error.kind(), ExtensionErrorKind::InvalidInput); + assert!(error.message().contains("正文不能为空")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn frontmatter_大小限制在精确边界通过_超过一字节拒绝() { + let exact = metadata_sized_document(MAX_METADATA_BYTES, "body"); + assert_eq!(parse_skill_metadata(&exact).unwrap().name, "sized"); + + let oversized = metadata_sized_document(MAX_METADATA_BYTES + 1, "body"); + assert!(matches!( + parse_skill_metadata(&oversized), + Err(SkillError::MetadataTooLarge) + )); + + let root = temp_root(); + write_skill(&root, "sized", exact); + assert_eq!(SkillLoader::new(root.clone()).discover().unwrap().len(), 1); + write_skill(&root, "too-large", oversized); + assert!(matches!( + SkillLoader::new(root.clone()).discover(), + Err(SkillError::MetadataTooLarge) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 激活时重新校验文件元数据并拒绝超大_frontmatter() { + let root = temp_root(); + let path = write_skill(&root, "mutable", "---\nname: mutable\n---\nbody"); + let loader = SkillLoader::new(root.clone()); + let descriptor = loader.discover().unwrap().remove(0); + + fs::write(&path, "---\nname: changed\n---\nbody").unwrap(); + assert!(matches!( + loader.activate_descriptor(&descriptor), + Err(SkillError::ContentChanged(name)) if name == "mutable" + )); + + fs::write( + &path, + metadata_sized_document(MAX_METADATA_BYTES + 1, "body"), + ) + .unwrap(); + assert!(matches!( + loader.activate_descriptor(&descriptor), + Err(SkillError::MetadataTooLarge) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn 拒绝非法名称和根目录外的描述符() { + assert!(matches!( + parse_skill_metadata("---\nname: ../escape\n---\nbody"), + Err(SkillError::InvalidName(_)) + )); + let loader = SkillLoader::new(env!("CARGO_MANIFEST_DIR")); + let forged = SkillDescriptor { + metadata: SkillMetadata { + name: "forged".to_owned(), + ..SkillMetadata::default() + }, + path: PathBuf::from("/etc/passwd"), + fingerprint: fingerprint_bytes(b"forged"), + }; + assert!(matches!( + loader.activate_descriptor(&forged), + Err(SkillError::PathOutsideRoot(_)) | Err(SkillError::Io(_)) + )); + + let missing_inside = SkillDescriptor { + metadata: SkillMetadata { + name: "missing".to_owned(), + ..SkillMetadata::default() + }, + // Keep this path under the loader root so the test still exercises + // the missing-file IO branch without creating a source-tree temp dir. + path: PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("skill-file-does-not-exist/SKILL.md"), + fingerprint: fingerprint_bytes(b"missing"), + }; + assert!(matches!( + loader.activate_descriptor(&missing_inside), + Err(SkillError::Io(_)) + )); + } + + #[test] + fn 错误路径返回稳定错误类型() { + let missing_root = temp_root(); + let _ = fs::remove_dir_all(&missing_root); + assert!(matches!( + SkillLoader::new(missing_root).discover(), + Err(SkillError::Io(_)) + )); + + let root = temp_root(); + let loader = SkillLoader::new(root.clone()); + assert!(matches!( + loader.activate("missing"), + Err(SkillError::SkillNotFound(name)) if name == "missing" + )); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn 拒绝指向根目录外的_skill符号链接() { + use std::os::unix::fs::symlink; + + let root = temp_root(); + let outside = temp_root(); + write_skill(&outside, "outside", "---\nname: outside\n---\n不应被加载"); + symlink(outside.join("outside"), root.join("link")).unwrap(); + + assert!(matches!( + SkillLoader::new(root.clone()).discover(), + Err(SkillError::PathOutsideRoot(_)) + )); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); + } + + #[test] + fn core_skill_source映射目录正文和不可信上下文且不授予工具() { + let root = temp_root(); + write_skill( + &root, + "review", + "---\nname: review\ndescription: 检查代码\nversion: 2.1.0\nallowed-tools: [read, shell, \"script:./check.sh\"]\nx-owner: team\n---\n请先检查变更。", + ); + let loader = SkillLoader::new(root.clone()); + let descriptor = loader.discover().unwrap().remove(0); + assert_eq!( + descriptor.tool_candidates(), + ["read", "shell", "script:./check.sh"] + ); + let fingerprint = descriptor.fingerprint().to_owned(); + + let definitions = loader.list_definitions().unwrap(); + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].name(), "review"); + assert_eq!(definitions[0].description(), "检查代码"); + assert_eq!(definitions[0].version(), Some("2.1.0")); + assert_eq!( + definitions[0].metadata()["allowed-tools"], + serde_json::json!(["read", "shell", "script:./check.sh"]) + ); + assert_eq!(definitions[0].metadata()["x-owner"], "team"); + assert_eq!( + definitions[0].metadata()["content-fingerprint"], + fingerprint + ); + + let activation = loader.activate_core("review").unwrap(); + assert_eq!(activation.body(), "请先检查变更。"); + assert_eq!(activation.definition(), &definitions[0]); + assert_eq!(activation.context().len(), 1); + let context = &activation.context()[0]; + assert_eq!(context.source_id(), "skill:review"); + assert!(!context.trusted()); + assert_eq!( + context.message().role(), + agent_runtime_core::MessageRole::User + ); + assert_eq!( + context.message().content()[0].as_text(), + Some("请先检查变更。") + ); + assert_eq!(context.metadata(), activation.definition().metadata()); + // 脚本也只是普通工具候选元数据;适配器不执行脚本、不创建绑定。 + assert!(activation.tools().is_empty()); + + let via_trait = ::activate(&loader, "review").unwrap(); + assert_eq!(via_trait.body(), activation.body()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn core_skill_source为缺失描述生成非空最小描述() { + let root = temp_root(); + write_skill(&root, "minimal", "---\nname: minimal\n---\n正文"); + let definition = SkillLoader::new(root.clone()) + .list_definitions() + .unwrap() + .remove(0); + assert_eq!(definition.description(), "Skill minimal"); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/rust/crates/agent-storage-sqlite/Cargo.toml b/rust/crates/agent-storage-sqlite/Cargo.toml new file mode 100644 index 000000000..93700797e --- /dev/null +++ b/rust/crates/agent-storage-sqlite/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "agent-storage-sqlite" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "SQLite persistence adapter for the generic agent runtime" +license = "UNLICENSED" + +[features] +default = [] +core-adapter = ["dep:agent-runtime-core", "dep:agent-runtime-contracts"] + +[dependencies] +agent-runtime-core = { workspace = true, optional = true } +agent-runtime-contracts = { workspace = true, optional = true } +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/agent-storage-sqlite/migrations/0001_initial.sql b/rust/crates/agent-storage-sqlite/migrations/0001_initial.sql new file mode 100644 index 000000000..ed33a171c --- /dev/null +++ b/rust/crates/agent-storage-sqlite/migrations/0001_initial.sql @@ -0,0 +1,104 @@ +-- 初始存储结构:只保存运行所需的中立数据,不保存 API key、token 等密钥。 +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + agent_id TEXT, + status TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + status TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + input_json TEXT NOT NULL DEFAULT '{}', + output_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_runs_session_id ON runs(session_id); + +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(run_id, revision) +); +CREATE INDEX IF NOT EXISTS idx_events_run_revision ON events(run_id, revision); + +CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + state_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(run_id, revision) +); + +CREATE TABLE IF NOT EXISTS approvals ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + tool_call_id TEXT, + status TEXT NOT NULL, + request_json TEXT NOT NULL, + decision_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_approvals_run_id ON approvals(run_id); + +CREATE TABLE IF NOT EXISTS tool_calls ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + tool_name TEXT NOT NULL, + arguments_json TEXT NOT NULL, + result_json TEXT, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tool_calls_run_id ON tool_calls(run_id); + +CREATE TABLE IF NOT EXISTS external_sessions ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + run_id TEXT REFERENCES runs(id) ON DELETE SET NULL, + backend TEXT NOT NULL, + external_id TEXT NOT NULL, + status TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(backend, external_id) +); +CREATE INDEX IF NOT EXISTS idx_external_sessions_session_id ON external_sessions(session_id); + +-- RuntimeStore 的 runtime-level 事件流。run-level 表保留给 Host 的业务查询, +-- 这里单独保存完整 RuntimeSnapshot,避免把一个 runtime 拆成多个伪 run。 +CREATE TABLE IF NOT EXISTS runtime_states ( + runtime_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS runtime_events ( + id TEXT PRIMARY KEY, + runtime_id TEXT NOT NULL REFERENCES runtime_states(runtime_id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + event_type TEXT NOT NULL, + run_id TEXT, + detail_json TEXT NOT NULL, + UNIQUE(runtime_id, revision) +); +CREATE INDEX IF NOT EXISTS idx_runtime_events_revision ON runtime_events(runtime_id, revision); diff --git a/rust/crates/agent-storage-sqlite/migrations/0002_run_control.sql b/rust/crates/agent-storage-sqlite/migrations/0002_run_control.sql new file mode 100644 index 000000000..d67273c6d --- /dev/null +++ b/rust/crates/agent-storage-sqlite/migrations/0002_run_control.sql @@ -0,0 +1,8 @@ +-- 运行控制字段:取消请求与运行状态分开保存,便于 worker 在 step 边界轮询。 +-- SQLite 没有独立 BOOLEAN 类型,0/1 分别表示 false/true。 +ALTER TABLE runs + ADD COLUMN cancel_requested INTEGER NOT NULL DEFAULT 0; + +-- runtime_events 已有 run_id,但此前没有对应索引;反查 runtime_id 是恢复/控制 +-- 路径的常用查询,单独建索引避免扫描整张事件表。 +CREATE INDEX IF NOT EXISTS idx_runtime_events_run_id ON runtime_events(run_id); diff --git a/rust/crates/agent-storage-sqlite/migrations/0003_worker_lease.sql b/rust/crates/agent-storage-sqlite/migrations/0003_worker_lease.sql new file mode 100644 index 000000000..8b09c9278 --- /dev/null +++ b/rust/crates/agent-storage-sqlite/migrations/0003_worker_lease.sql @@ -0,0 +1,19 @@ +-- worker lease 只用于防止过期 worker 写回;它不是外部副作用的完成凭证。 +-- 过期的 running run 必须先进入 reconciling,恢复器不得直接重放调用。 +ALTER TABLE runs + ADD COLUMN worker_id TEXT; + +ALTER TABLE runs + ADD COLUMN lease_token TEXT; + +ALTER TABLE runs + ADD COLUMN lease_expires_at INTEGER; + +ALTER TABLE runs + ADD COLUMN heartbeat_at INTEGER; + +ALTER TABLE runs + ADD COLUMN attempt INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_runs_lease_expiry + ON runs(status, lease_expires_at); diff --git a/rust/crates/agent-storage-sqlite/migrations/0004_run_checkpoints.sql b/rust/crates/agent-storage-sqlite/migrations/0004_run_checkpoints.sql new file mode 100644 index 000000000..105978db6 --- /dev/null +++ b/rust/crates/agent-storage-sqlite/migrations/0004_run_checkpoints.sql @@ -0,0 +1,12 @@ +-- 每个 run 只保留最新的增量 checkpoint;写入必须通过当前 worker lease fencing。 +CREATE TABLE IF NOT EXISTS run_checkpoints ( + run_id TEXT PRIMARY KEY REFERENCES runs(id) ON DELETE CASCADE, + phase TEXT NOT NULL, + step INTEGER NOT NULL, + next_step INTEGER NOT NULL, + messages_json TEXT NOT NULL, + provider_request_id TEXT, + tool_call_id TEXT, + attempt INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/rust/crates/agent-storage-sqlite/migrations/0005_runtime_event_schema.sql b/rust/crates/agent-storage-sqlite/migrations/0005_runtime_event_schema.sql new file mode 100644 index 000000000..4f193073b --- /dev/null +++ b/rust/crates/agent-storage-sqlite/migrations/0005_runtime_event_schema.sql @@ -0,0 +1,4 @@ +-- RuntimeEvent 的 wire/persistence schema 版本。 +-- 旧数据库中的事件使用当前唯一支持的版本回填;未知版本由 Core reducer 拒绝。 +ALTER TABLE runtime_events + ADD COLUMN schema_version TEXT NOT NULL DEFAULT 'agent-runtime-event.v1'; diff --git a/rust/crates/agent-storage-sqlite/migrations/0006_approval_binding.sql b/rust/crates/agent-storage-sqlite/migrations/0006_approval_binding.sql new file mode 100644 index 000000000..bd932c78f --- /dev/null +++ b/rust/crates/agent-storage-sqlite/migrations/0006_approval_binding.sql @@ -0,0 +1,10 @@ +-- 审批必须绑定精确工具参数,并有明确的令牌和过期时间。 +-- 旧记录回填为空/0,恢复入口会拒绝它们而不会隐式放行。 +ALTER TABLE approvals + ADD COLUMN arguments_hash TEXT NOT NULL DEFAULT ''; + +ALTER TABLE approvals + ADD COLUMN approval_token TEXT NOT NULL DEFAULT ''; + +ALTER TABLE approvals + ADD COLUMN expires_at_ms INTEGER NOT NULL DEFAULT 0; diff --git a/rust/crates/agent-storage-sqlite/src/lib.rs b/rust/crates/agent-storage-sqlite/src/lib.rs new file mode 100644 index 000000000..8cc42ab0e --- /dev/null +++ b/rust/crates/agent-storage-sqlite/src/lib.rs @@ -0,0 +1,6314 @@ +//! 通用 Agent Runtime 的 SQLite 持久化适配器。 +//! +//! 这个 crate 的 run-level API 只负责存储和事件一致性,不绑定 runtime core 的 +//! 具体实现。可选的 `core-adapter` feature 只增加一层 `RuntimeStore` 转换; +//! 该 feature 默认关闭,关闭时 crate 仍可作为纯 SQLite 适配器使用。 + +use std::collections::BTreeSet; +use std::io::Write; +use std::path::Path; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[cfg(feature = "core-adapter")] +use rusqlite::Transaction; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +#[cfg(feature = "core-adapter")] +use agent_runtime_contracts::{ + DurableApprovalCheckpointRuntimeCommit, DurableApprovalInput, DurableLeaseIdentity, + DurableToolCallCheckpointRuntimeCommit, DurableToolCallInput, DurableToolCallRuntimeCommit, +}; + +#[cfg(feature = "core-adapter")] +use agent_runtime_core::{ + RUNTIME_EVENT_SCHEMA_VERSION, RunStatus, RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, + RuntimeStore, StoreError, ToolCall, reduce, +}; + +const INITIAL_MIGRATION: &str = include_str!("../migrations/0001_initial.sql"); +const RUN_CONTROL_MIGRATION: &str = include_str!("../migrations/0002_run_control.sql"); +const WORKER_LEASE_MIGRATION: &str = include_str!("../migrations/0003_worker_lease.sql"); +const RUN_CHECKPOINT_MIGRATION: &str = include_str!("../migrations/0004_run_checkpoints.sql"); +const APPROVAL_BINDING_MIGRATION: &str = include_str!("../migrations/0006_approval_binding.sql"); +const RUNTIME_EVENT_SCHEMA_MIGRATION: &str = + include_str!("../migrations/0005_runtime_event_schema.sql"); + +/// 单次 stale-run 扫描允许读取的最大候选数。 +/// +/// Runtime/Host 可能在进程启动时调用扫描入口;硬上限避免一个损坏或异常大的 +/// 数据库把启动路径变成无界查询。需要处理更多 run 时由调用方在后续轮次继续扫描。 +pub const MAX_STALE_RUN_SCAN_LIMIT: usize = 256; + +/// 单次外部会话对账候选扫描允许读取的最大记录数。 +/// +/// 这个查询只发现候选,不会观察、重放或修改外部调用;调用方需要在后续 +/// 步骤显式完成协议校验和对账。和 stale-run 扫描一样,硬上限避免诊断/启动 +/// 路径因为异常大的数据库变成无界读取。 +pub const MAX_EXTERNAL_SESSION_SCAN_LIMIT: usize = 256; + +/// 外部会话状态过滤一次允许绑定的最大状态值数量。 +/// +/// 状态通常只有少量生命周期值;限制过滤器数量可以同时约束动态 SQL 的 +/// 占位符数量,避免调用方构造超大的 `IN` 表达式。 +pub const MAX_EXTERNAL_SESSION_STATUS_FILTERS: usize = 32; + +/// 存储层所有错误的统一类型。 +#[derive(Debug, Error)] +pub enum StorageError { + #[error("sqlite error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("record not found: {entity} {id}")] + NotFound { entity: &'static str, id: String }, + #[error("run {id} is already terminal: {status}")] + TerminalRun { id: String, status: String }, + #[error("run {id} lease conflict: {reason}")] + LeaseConflict { id: String, reason: String }, + /// A control-plane queued-cancel CAS lost its expected precondition. + /// + /// This is deliberately separate from `LeaseConflict`: a worker may have + /// claimed the row, but a queued row can also become ineligible because a + /// checkpoint or a cancellation marker appeared. Runtime maps this + /// branch to `Ok(None)` so Host can fall back to cooperative cancellation + /// without treating a normal race as an operational failure. + #[error("run {id} queued cancel precondition failed: {reason}")] + QueuedCancelConflict { id: String, reason: String }, + #[error("run {id} lease is missing, expired, or owned by another worker")] + LeaseLost { id: String }, + #[error("revision conflict for run {run_id}: expected {expected}, actual {actual}")] + RevisionConflict { + run_id: String, + expected: i64, + actual: i64, + }, + #[error("approval {id} status conflict: expected {expected}, actual {actual}")] + ApprovalConflict { + id: String, + expected: String, + actual: String, + }, + #[error("snapshot revision {revision} is newer than run {run_id} revision {run_revision}")] + SnapshotAhead { + run_id: String, + revision: i64, + run_revision: i64, + }, + #[error("sensitive field cannot be persisted: {field}")] + SensitiveDataRejected { field: String }, + #[error("invalid storage input: {0}")] + InvalidInput(String), + #[error("storage mutex was poisoned")] + LockPoisoned, +} + +pub type Result = std::result::Result; + +/// 会话记录。`metadata` 只允许保存非敏感业务元数据。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionRecord { + pub id: String, + pub agent_id: Option, + pub status: String, + pub metadata: Value, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewSession { + pub id: String, + pub agent_id: Option, + pub status: String, + pub metadata: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunRecord { + pub id: String, + pub session_id: String, + pub status: String, + pub revision: i64, + pub input: Value, + pub output: Option, + /// 是否已经发出取消请求;终态记录保留该标记,便于审计。 + #[serde(default)] + pub cancel_requested: bool, + pub created_at: i64, + pub updated_at: i64, +} + +/// 运行控制查询的稳定最小视图,供 worker 轮询而无需解码完整输入/输出。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunControlRecord { + pub run_id: String, + pub status: String, + pub cancel_requested: bool, +} + +/// 当前有效的 worker lease。lease token 只用于 fencing,不是外部系统的 +/// 幂等键;真正的副作用状态仍要由调用方单独记录和对账。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunLeaseRecord { + pub run_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at: i64, + pub heartbeat_at: i64, + pub attempt: i64, +} + +#[derive(Debug, Clone)] +pub struct NewRun { + pub id: String, + pub session_id: String, + pub status: String, + pub input: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EventRecord { + pub id: String, + pub session_id: String, + pub run_id: String, + pub revision: i64, + pub event_type: String, + pub payload: Value, + pub created_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewEvent { + pub id: String, + pub run_id: String, + pub event_type: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SnapshotRecord { + pub id: i64, + pub session_id: String, + pub run_id: String, + pub revision: i64, + pub state: Value, + pub created_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewSnapshot { + pub session_id: String, + pub run_id: String, + pub revision: i64, + pub state: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApprovalRecord { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_call_id: Option, + pub status: String, + pub request: Value, + pub arguments_hash: String, + pub approval_token: String, + pub expires_at_ms: i64, + pub decision: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewApproval { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_call_id: Option, + pub status: String, + pub request: Value, + pub arguments_hash: String, + pub approval_token: String, + pub expires_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolCallRecord { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_name: String, + pub arguments: Value, + pub result: Option, + pub status: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewToolCall { + pub id: String, + pub session_id: String, + pub run_id: String, + pub tool_name: String, + pub arguments: Value, + pub status: String, +} + +/// 可在 worker 中断后用于恢复的最新 step 游标。 +/// +/// `provider_in_flight`/`tool_in_flight` 只表示外部调用尚未完成对账;只有 +/// `record_reconciliation_result` 成功后,checkpoint 才会变成可安全继续的 +/// `safe` 游标。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunCheckpointRecord { + pub run_id: String, + pub phase: String, + pub step: i64, + pub next_step: i64, + pub messages: Value, + pub provider_request_id: Option, + pub tool_call_id: Option, + pub attempt: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewRunCheckpoint { + pub run_id: String, + pub phase: String, + pub step: i64, + pub next_step: i64, + pub messages: Value, + pub provider_request_id: Option, + pub tool_call_id: Option, + pub attempt: i64, +} + +/// 简短名称供 Host/Engine 接线使用。 +pub type CheckpointRecord = RunCheckpointRecord; +pub type NewCheckpoint = NewRunCheckpoint; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ExternalSessionRecord { + pub id: String, + pub session_id: String, + pub run_id: Option, + pub backend: String, + pub external_id: String, + pub status: String, + pub metadata: Value, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NewExternalSession { + pub id: String, + pub session_id: String, + pub run_id: Option, + pub backend: String, + pub external_id: String, + pub status: String, + /// 这里只保存外部 secret 的引用或非敏感元数据,绝不要放原始 token。 + pub metadata: Value, +} + +/// 一个连接包装器。Clone 后共享同一连接,避免把 rusqlite Connection 暴露给上层。 +#[derive(Clone)] +pub struct SqliteStore { + connection: Arc>, +} + +#[cfg(feature = "core-adapter")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RunFinishGuard { + None, + QueuedUnclaimed, +} + +impl std::fmt::Debug for SqliteStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SqliteStore") + .finish_non_exhaustive() + } +} + +impl SqliteStore { + /// 打开文件数据库,并启用 WAL。父目录由调用方负责创建。 + pub fn open(path: impl AsRef) -> Result { + let connection = Connection::open(path)?; + Self::from_connection(connection) + } + + /// 打开内存数据库,主要用于单元测试和短生命周期任务。 + pub fn open_in_memory() -> Result { + Self::from_connection(Connection::open_in_memory()?) + } + + fn from_connection(mut connection: Connection) -> Result { + connection.pragma_update(None, "foreign_keys", "ON")?; + connection.busy_timeout(Duration::from_secs(5))?; + // 内存数据库会返回 `memory`,文件数据库会返回 `wal`;两者都可继续使用。 + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.pragma_update(None, "synchronous", "NORMAL")?; + migrate(&mut connection)?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + /// 当前 SQLite journal mode,便于启动检查和诊断。 + pub fn journal_mode(&self) -> Result { + let connection = self.lock()?; + Ok(connection.query_row("PRAGMA journal_mode", [], |row| row.get(0))?) + } + + pub fn create_session(&self, input: NewSession) -> Result { + validate_json(&input.metadata)?; + require_id("session", &input.id)?; + let now = now_ms(); + let connection = self.lock()?; + connection.execute( + "INSERT INTO sessions (id, agent_id, status, metadata_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params![ + input.id, + input.agent_id, + input.status, + json_string(&input.metadata)?, + now + ], + )?; + Ok(SessionRecord { + id: input.id, + agent_id: input.agent_id, + status: input.status, + metadata: input.metadata, + created_at: now, + updated_at: now, + }) + } + + /// 原子创建一个 session、queued run 以及它的初始 runtime 快照/事件。 + /// + /// Host 在启动 worker 前使用这个入口,避免进程在多次独立 INSERT/CAS + /// 之间退出后留下只有 session/run 或只有 runtime 的半成品。`snapshot` + /// 必须是从空 runtime 按 `events` reducer 得到的 pending 快照;方法不会 + /// 调用外部 Provider/工具。 + #[cfg(feature = "core-adapter")] + pub fn create_run_bundle( + &self, + session: NewSession, + run: NewRun, + runtime_id: &str, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result<(SessionRecord, RunRecord)> { + validate_json(&session.metadata)?; + validate_json(&run.input)?; + require_id("session", &session.id)?; + require_id("run", &run.id)?; + require_id("runtime", runtime_id)?; + if run.session_id != session.id { + return Err(StorageError::InvalidInput( + "run session_id 与 session identity 不一致".to_owned(), + )); + } + if run.status != "queued" || snapshot.runtime_id() != runtime_id || events.is_empty() { + return Err(StorageError::InvalidInput( + "bundle 必须是 queued run 和非空 runtime 事件".to_owned(), + )); + } + if snapshot.revision() != events.len() as u64 { + return Err(StorageError::InvalidInput( + "bundle runtime revision 与事件数量不一致".to_owned(), + )); + } + snapshot.validate().map_err(|error| { + StorageError::InvalidInput(format!("bundle runtime snapshot 无效: {error}")) + })?; + let snapshot_run = snapshot + .run(&run.id) + .ok_or_else(|| StorageError::InvalidInput("bundle runtime 缺少对应 run".to_owned()))?; + if snapshot_run.status() != RunStatus::Pending { + return Err(StorageError::InvalidInput( + "bundle runtime run 必须处于 pending".to_owned(), + )); + } + let mut reduced = RuntimeSnapshot::try_new(runtime_id) + .map_err(|error| StorageError::InvalidInput(error.to_string()))?; + for (offset, event) in events.iter().enumerate() { + let expected = offset as u64 + 1; + if event.runtime_id() != runtime_id + || event.revision() != expected + || event.schema_version() != RUNTIME_EVENT_SCHEMA_VERSION + { + return Err(StorageError::InvalidInput( + "bundle runtime event schema/revision 或 identity 无效".to_owned(), + )); + } + reduced = reduce(&reduced, event).map_err(|error| { + StorageError::InvalidInput(format!( + "bundle runtime event 无法通过 reducer: {error}" + )) + })?; + } + if reduced != *snapshot { + return Err(StorageError::InvalidInput( + "bundle runtime 事件重放结果与快照不一致".to_owned(), + )); + } + let snapshot_json = serde_json::to_value(snapshot)?; + validate_json(&snapshot_json)?; + let encoded_snapshot = serde_json::to_string(&snapshot_json)?; + let snapshot_revision = i64::try_from(snapshot.revision()).map_err(|_| { + StorageError::InvalidInput( + "bundle runtime snapshot revision 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = now_ms(); + transaction.execute( + "INSERT INTO sessions (id, agent_id, status, metadata_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params![ + session.id, + session.agent_id, + session.status, + json_string(&session.metadata)?, + now + ], + )?; + transaction.execute( + "INSERT INTO runs + (id, session_id, status, revision, input_json, created_at, updated_at) + VALUES (?1, ?2, ?3, 0, ?4, ?5, ?5)", + params![ + run.id, + run.session_id, + run.status, + json_string(&run.input)?, + now + ], + )?; + transaction.execute( + "INSERT INTO runtime_states (runtime_id, revision, snapshot_json, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![runtime_id, snapshot_revision, encoded_snapshot, now], + )?; + for event in events { + let occurred_at = i64::try_from(event.occurred_at_ms()).map_err(|_| { + StorageError::InvalidInput( + "runtime event timestamp 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + let event_type = serde_json::to_value(event.kind())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + StorageError::InvalidInput("runtime event kind 不是字符串".to_owned()) + })?; + transaction.execute( + "INSERT INTO runtime_events + (id, runtime_id, revision, occurred_at, schema_version, event_type, run_id, detail_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + format!("{runtime_id}:{}", event.revision()), + runtime_id, + i64::try_from(event.revision()).map_err(|_| { + StorageError::InvalidInput( + "runtime event revision 超出 SQLite INTEGER 范围".to_owned(), + ) + })?, + occurred_at, + event.schema_version(), + event_type, + event.run_id(), + serde_json::to_string(event.detail())? + ], + )?; + } + let session_record = transaction.query_row( + "SELECT id, agent_id, status, metadata_json, created_at, updated_at + FROM sessions WHERE id = ?1", + [&session.id], + row_session, + )?; + let run_record = transaction.query_row( + "SELECT id, session_id, status, revision, input_json, output_json, + created_at, updated_at, cancel_requested + FROM runs WHERE id = ?1", + [&run.id], + row_run, + )?; + transaction.commit()?; + Ok((session_record, run_record)) + } + + pub fn get_session(&self, id: &str) -> Result> { + let connection = self.lock()?; + connection + .query_row( + "SELECT id, agent_id, status, metadata_json, created_at, updated_at + FROM sessions WHERE id = ?1", + [id], + row_session, + ) + .optional() + .map_err(StorageError::from) + } + + pub fn create_run(&self, input: NewRun) -> Result { + validate_json(&input.input)?; + require_id("run", &input.id)?; + let now = now_ms(); + let connection = self.lock()?; + connection.execute( + "INSERT INTO runs + (id, session_id, status, revision, input_json, created_at, updated_at) + VALUES (?1, ?2, ?3, 0, ?4, ?5, ?5)", + params![ + input.id, + input.session_id, + input.status, + json_string(&input.input)?, + now + ], + )?; + Ok(RunRecord { + id: input.id, + session_id: input.session_id, + status: input.status, + revision: 0, + input: input.input, + output: None, + cancel_requested: false, + created_at: now, + updated_at: now, + }) + } + + pub fn get_run(&self, id: &str) -> Result> { + let connection = self.lock()?; + connection + .query_row( + "SELECT id, session_id, status, revision, input_json, output_json, + created_at, updated_at, cancel_requested + FROM runs WHERE id = ?1", + [id], + row_run, + ) + .optional() + .map_err(StorageError::from) + } + + /// 返回一批可能因 worker 进程退出而失去 lease 的 run 身份。 + /// + /// 这是只读候选查询,不会改变任何状态;调用方必须逐项重新检查 lease, + /// 再通过原子 recovery 入口推进状态。结果按“最早 stale 时间、最后更新时间、 + /// 创建时间、run id”稳定排序,因而多次扫描可以在不依赖 SQLite 行顺序的情况下 + /// 继续处理。`limit` 受 [`MAX_STALE_RUN_SCAN_LIMIT`] 硬上限约束,传入 `0` + /// 返回空列表;`now_ms` 允许测试注入固定时间。 + pub fn list_stale_run_ids(&self, limit: usize, now_ms: i64) -> Result> { + if limit > MAX_STALE_RUN_SCAN_LIMIT { + return Err(StorageError::InvalidInput(format!( + "stale run 扫描 limit 不能超过 {}", + MAX_STALE_RUN_SCAN_LIMIT + ))); + } + if now_ms < 0 { + return Err(StorageError::InvalidInput( + "stale run 扫描时间不能为负数".to_owned(), + )); + } + if limit == 0 { + return Ok(Vec::new()); + } + + let limit_i64 = i64::try_from(limit).map_err(|_| { + StorageError::InvalidInput("stale run 扫描 limit 超出 SQLite INTEGER 范围".to_owned()) + })?; + let connection = self.lock()?; + let mut statement = connection.prepare( + "SELECT id + FROM runs + WHERE ( + status IN ('running', 'cancel_requested') + OR (status = 'reconciling' AND lease_expires_at IS NOT NULL) + ) + AND (lease_expires_at IS NULL OR lease_expires_at <= ?1) + ORDER BY COALESCE(lease_expires_at, updated_at) ASC, + updated_at ASC, + created_at ASC, + id ASC + LIMIT ?2", + )?; + let rows = + statement.query_map(params![now_ms, limit_i64], |row| row.get::<_, String>(0))?; + rows.collect::, _>>() + .map_err(StorageError::from) + } + + /// 以 CAS 抢占一个排队中的 run;只有一个 worker 能将 queued 改成 running。 + pub fn claim_run(&self, id: &str) -> Result { + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs + SET status = 'running', worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = NULL, updated_at = ?1 + WHERE id = ?2 AND status = 'queued' AND cancel_requested = 0 + AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL", + params![now_ms(), id], + )?; + let record = query_run(&transaction, id)?; + if changed == 1 { + transaction.commit()?; + return Ok(record); + } + if is_terminal_status(&record.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: record.status, + }); + } + if record.cancel_requested || record.status == "cancel_requested" { + return Err(StorageError::InvalidInput(format!( + "run {} 已请求取消,不能被 worker 抢占", + id + ))); + } + Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不能被 worker 抢占", + id, record.status + ))) + } + + /// 以带 token 的 CAS 领取排队 run。只有持有当前 lease 的 worker 才能 + /// heartbeat、收束终态或释放 lease;旧的 `claim_run` 不会覆盖带 lease + /// 的记录,因此不会绕过 fencing。 + pub fn claim_run_with_lease( + &self, + id: &str, + worker_id: impl Into, + lease_token: impl Into, + lease_duration: Duration, + ) -> Result<(RunRecord, RunLeaseRecord)> { + let worker_id = worker_id.into(); + let lease_token = lease_token.into(); + let lease_millis = lease_duration_millis(lease_duration)?; + require_non_empty("worker", &worker_id)?; + require_non_empty("lease token", &lease_token)?; + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = query_run(&transaction, id)?; + if is_terminal_status(¤t.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: current.status, + }); + } + if current.cancel_requested || current.status == "cancel_requested" { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "run 已请求取消".to_owned(), + }); + } + if current.status != "queued" { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: format!("当前状态 {} 不能被领取", current.status), + }); + } + if current.revision < 0 { + return Err(StorageError::InvalidInput( + "run revision 不能为负数".to_owned(), + )); + } + let attempt: i64 = + transaction.query_row("SELECT attempt FROM runs WHERE id = ?1", [id], |row| { + row.get(0) + })?; + let next_attempt = attempt.checked_add(1).ok_or_else(|| { + StorageError::InvalidInput("run attempt 超出 SQLite INTEGER 范围".to_owned()) + })?; + let now = now_ms(); + let expires_at = now.checked_add(lease_millis).ok_or_else(|| { + StorageError::InvalidInput("lease 到期时间超出 SQLite INTEGER 范围".to_owned()) + })?; + let changed = transaction.execute( + "UPDATE runs + SET status = 'running', worker_id = ?1, lease_token = ?2, + lease_expires_at = ?3, heartbeat_at = ?4, attempt = ?5, + updated_at = ?4 + WHERE id = ?6 AND status = 'queued' AND cancel_requested = 0 + AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL", + params![worker_id, lease_token, expires_at, now, next_attempt, id], + )?; + if changed != 1 { + let latest = query_run(&transaction, id)?; + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: format!("领取竞争失败(当前状态 {})", latest.status), + }); + } + let run = query_run(&transaction, id)?; + let lease = query_lease_required(&transaction, id)?; + transaction.commit()?; + Ok((run, lease)) + } + + /// 返回 run 当前的有效 lease;已释放或从未领取时返回 None。 + pub fn get_run_lease(&self, id: &str) -> Result> { + let connection = self.lock()?; + // 先确认 run 存在,让 missing id 与「没有 lease」可区分。 + let _: i64 = connection + .query_row("SELECT 1 FROM runs WHERE id = ?1", [id], |row| row.get(0)) + .map_err(|error| not_found_from_query(error, "run", id))?; + query_lease(&connection, id) + } + + /// 续租。过期 token 即使随后发 heartbeat 也不能复活旧 worker。 + pub fn heartbeat_run( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + lease_duration: Duration, + ) -> Result { + let lease_millis = lease_duration_millis(lease_duration)?; + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = now_ms(); + let expires_at = now.checked_add(lease_millis).ok_or_else(|| { + StorageError::InvalidInput("lease 到期时间超出 SQLite INTEGER 范围".to_owned()) + })?; + let changed = transaction.execute( + "UPDATE runs + SET lease_expires_at = ?1, heartbeat_at = ?2, updated_at = ?2 + WHERE id = ?3 AND status IN ('running', 'cancel_requested') + AND worker_id = ?4 AND lease_token = ?5 + AND lease_expires_at > ?2", + params![expires_at, now, id, worker_id, lease_token], + )?; + if changed != 1 { + return Err(lease_lost_or_conflict( + &transaction, + id, + worker_id, + lease_token, + now, + )); + } + let lease = query_lease_required(&transaction, id)?; + transaction.commit()?; + Ok(lease) + } + + /// 在同一个 IMMEDIATE 事务中完成过期 lease 的 recovery、Core runtime + /// snapshot/event CAS 以及 session 状态投影。 + /// + /// 该入口只接受调用方已经在内存中用 Core reducer 得出的 snapshot/events; + /// 它不查询、重放或猜测 Provider/工具结果。任何 runtime 事件或 revision + /// 校验失败都会让外层事务整体回滚,因而 run 仍保持 running 且旧 lease + /// 仍然可见。成功后 checkpoint 原样保留,供后续显式对账。 + #[cfg(feature = "core-adapter")] + pub fn recover_expired_run_with_runtime( + &self, + id: &str, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + require_id("run", id)?; + require_id("runtime", runtime_id)?; + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = query_run(&transaction, id)?; + let session_id: String = + transaction.query_row("SELECT session_id FROM runs WHERE id = ?1", [id], |row| { + row.get(0) + })?; + let linked_runtime: Option = transaction + .query_row( + "SELECT runtime_id FROM runtime_events + WHERE run_id = ?1 ORDER BY revision ASC LIMIT 1", + [id], + |row| row.get(0), + ) + .optional()?; + if linked_runtime.as_deref() != Some(runtime_id) { + return Err(StorageError::InvalidInput(format!( + "run {id} 的 runtime identity 不匹配" + ))); + } + + let now = now_ms(); + let lease = query_lease(&transaction, id)?; + if lease + .as_ref() + .is_some_and(|value| value.lease_expires_at > now) + { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "lease 尚未过期".to_owned(), + }); + } + if !matches!( + current.status.as_str(), + "running" | "cancel_requested" | "reconciling" + ) { + if is_terminal_status(¤t.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: current.status, + }); + } + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: format!("当前状态 {} 不能恢复", current.status), + }); + } + + let target_run = snapshot.run(id).ok_or_else(|| { + StorageError::InvalidInput(format!("recovery runtime snapshot 缺少 run: {id}")) + })?; + if target_run.status() != RunStatus::Reconciling { + return Err(StorageError::InvalidInput( + "recovery runtime snapshot 的 run 必须处于 reconciling".to_owned(), + )); + } + if events.is_empty() { + // 已经写入 Core gate 的重复 recovery 不需要伪造一个 no-op 事件; + // 仍需核对 revision 和完整 snapshot,避免把不同 runtime 状态掩盖掉。 + verify_runtime_snapshot_in_transaction( + &transaction, + runtime_id, + expected_runtime_revision, + snapshot, + )?; + } else { + let last = events.last().expect("events is non-empty"); + if last.kind() != RuntimeEventKind::ReconciliationRequired || last.run_id() != Some(id) + { + return Err(StorageError::InvalidInput( + "recovery runtime 最后事件必须是当前 run 的 ReconciliationRequired".to_owned(), + )); + } + commit_runtime_in_transaction( + &transaction, + runtime_id, + expected_runtime_revision, + snapshot, + events, + )?; + } + + // 先推进 run-level gate;若后续 session 更新失败,IMMEDIATE 事务会把 + // 这次清 lease 和上面的 runtime 写入一并回滚。 + if current.status != "reconciling" { + let changed = transaction.execute( + "UPDATE runs + SET status = 'reconciling', worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = ?1, updated_at = ?1 + WHERE id = ?2 AND status IN ('running', 'cancel_requested') + AND (lease_expires_at IS NULL OR lease_expires_at <= ?1)", + params![now, id], + )?; + if changed != 1 { + let latest = query_run(&transaction, id)?; + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: if latest.status == "running" { + "lease 尚未过期".to_owned() + } else { + format!("当前状态 {} 不能恢复", latest.status) + }, + }); + } + } else if lease.is_some() { + // 历史 reconciling 记录可能还残留一个已经过期的 fencing token; + // 同一事务内清理它,但绝不触碰仍有效的 lease(前面已拒绝)。 + let changed = transaction.execute( + "UPDATE runs + SET worker_id = NULL, lease_token = NULL, lease_expires_at = NULL, + heartbeat_at = ?1, updated_at = ?1 + WHERE id = ?2 AND status = 'reconciling' + AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?1", + params![now, id], + )?; + if changed != 1 { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "reconciling run lease 清理竞争失败".to_owned(), + }); + } + } + + let session_changed = transaction.execute( + "UPDATE sessions SET status = 'reconciling', updated_at = ?1 WHERE id = ?2", + params![now, session_id], + )?; + if session_changed != 1 { + return Err(StorageError::NotFound { + entity: "session", + id: current.session_id, + }); + } + let recovered = query_run(&transaction, id)?; + transaction.commit()?; + Ok(recovered) + } + + /// 将 lease 过期(或历史记录没有 lease)的 running/cancel_requested run 标记为 + /// reconciling;同时清理已经进入 reconciling 但残留过期 lease 的历史记录。 + /// 该操作只改变 durable 状态并清掉旧 fencing token,绝不启动 Engine 或重放 + /// 外部调用;没有 lease 也不能证明没有未知副作用。 + pub fn recover_expired_run(&self, id: &str) -> Result { + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = query_run(&transaction, id)?; + let now = now_ms(); + if current.status == "reconciling" { + // A previous recovery may have reached the Core reconciliation gate + // before its lease cleanup committed. Keep a valid lease fenced; + // only an expired residual token can be cleared by this idempotent + // recovery entry. + let lease = query_lease(&transaction, id)?; + let Some(lease) = lease else { + transaction.commit()?; + return Ok(current); + }; + if lease.lease_expires_at > now { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "reconciling run lease 尚未过期".to_owned(), + }); + } + let changed = transaction.execute( + "UPDATE runs + SET worker_id = NULL, lease_token = NULL, lease_expires_at = NULL, + heartbeat_at = ?1, updated_at = ?1 + WHERE id = ?2 AND status = 'reconciling' + AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?1", + params![now, id], + )?; + if changed != 1 { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "reconciling run lease 清理竞争失败".to_owned(), + }); + } + let recovered = query_run(&transaction, id)?; + transaction.commit()?; + return Ok(recovered); + } + if !matches!(current.status.as_str(), "running" | "cancel_requested") { + if is_terminal_status(¤t.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: current.status, + }); + } + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: format!("当前状态 {} 不能恢复", current.status), + }); + } + let changed = transaction.execute( + "UPDATE runs + SET status = 'reconciling', worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = ?1, updated_at = ?1 + WHERE id = ?2 AND status IN ('running', 'cancel_requested') + AND (lease_expires_at IS NULL OR lease_expires_at <= ?1)", + params![now, id], + )?; + if changed != 1 { + let latest = query_run(&transaction, id)?; + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: if latest.status == "running" { + "lease 尚未过期".to_owned() + } else { + format!("当前状态 {} 不能恢复", latest.status) + }, + }); + } + let recovered = query_run(&transaction, id)?; + transaction.commit()?; + Ok(recovered) + } + + /// worker 主动放弃未完成 run 时也进入 reconciling,避免产生无 lease 的 + /// running 记录。终态调用只清理遗留 lease,保持幂等。 + pub fn release_run_lease( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result { + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = query_run(&transaction, id)?; + let now = now_ms(); + if is_terminal_status(¤t.status) { + let lease = query_lease(&transaction, id)?; + if let Some(lease) = lease { + if lease.worker_id != worker_id || lease.lease_token != lease_token { + return Err(StorageError::LeaseLost { id: id.to_owned() }); + } + transaction.execute( + "UPDATE runs SET worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = ?1, updated_at = ?1 + WHERE id = ?2 AND worker_id = ?3 AND lease_token = ?4", + params![now, id, worker_id, lease_token], + )?; + } + let result = query_run(&transaction, id)?; + transaction.commit()?; + return Ok(result); + } + let changed = transaction.execute( + "UPDATE runs + SET status = 'reconciling', worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = ?1, updated_at = ?1 + WHERE id = ?2 AND status IN ('queued', 'running', 'cancel_requested') + AND worker_id = ?3 AND lease_token = ?4", + params![now, id, worker_id, lease_token], + )?; + if changed != 1 { + return Err(lease_lost_or_conflict( + &transaction, + id, + worker_id, + lease_token, + now, + )); + } + let result = query_run(&transaction, id)?; + transaction.commit()?; + Ok(result) + } + + /// 持有 lease 的 worker 才能把 run 收束为 completed;迟到或换代 + /// worker 会收到 `LeaseLost`,不会覆盖新的 owner。 + pub fn complete_run_with_lease( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + ) -> Result { + self.finish_run_with_lease(id, worker_id, lease_token, "completed", output) + } + + /// 持有 lease 的 worker 才能把 run 收束为 failed。 + pub fn fail_run_with_lease( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + ) -> Result { + self.finish_run_with_lease(id, worker_id, lease_token, "failed", output) + } + + /// 持有 lease 的 worker 才能收束取消;取消请求本身仍可由控制端通过 + /// `request_cancel` 发出,worker 只负责在自己的 lease 内完成终态写回。 + pub fn mark_cancelled_with_lease( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + ) -> Result { + self.finish_run_with_lease(id, worker_id, lease_token, "cancelled", output) + } + + fn finish_run_with_lease( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + target_status: &str, + output: Option, + ) -> Result { + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + if let Some(output) = &output { + validate_json(output)?; + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = query_run(&transaction, id)?; + if current.status == target_status { + transaction.commit()?; + return Ok(current); + } + if is_terminal_status(¤t.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: current.status, + }); + } + let now = now_ms(); + let (status_clause, cancel_assignment) = if target_status == "cancelled" { + ( + "status IN ('running', 'cancel_requested') AND cancel_requested IN (0, 1)", + "cancel_requested = 1", + ) + } else { + ( + "status = 'running' AND cancel_requested = 0", + "cancel_requested = 0", + ) + }; + // `target_status` 只由本 crate 内三个固定入口传入,不来自用户输入。 + let sql = format!( + "UPDATE runs SET status = '{target_status}', output_json = ?1, + {cancel_assignment}, worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = ?2, updated_at = ?2 + WHERE id = ?3 AND {status_clause} + AND worker_id = ?4 AND lease_token = ?5 + AND lease_expires_at > ?2" + ); + let changed = transaction.execute( + &sql, + params![ + output.as_ref().map(json_string).transpose()?, + now, + id, + worker_id, + lease_token + ], + )?; + if changed != 1 { + let latest = query_run(&transaction, id)?; + if latest.status == target_status { + transaction.commit()?; + return Ok(latest); + } + if is_terminal_status(&latest.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: latest.status, + }); + } + return Err(lease_lost_or_conflict( + &transaction, + id, + worker_id, + lease_token, + now, + )); + } + // checkpoint 与 run 终态在同一事务里收束,避免先删游标后 lease + // 失效而留下既无 owner、又无恢复证据的 running run。 + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + let result = query_run(&transaction, id)?; + transaction.commit()?; + Ok(result) + } + + /// 在一个 SQLite 事务里同时收束 run、runtime snapshot/event、session 和 + /// checkpoint。Host 的终态路径使用这个入口,避免 run 已 terminal 而 + /// RuntimeStore 还停在 Running 的跨表窗口。 + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + pub fn complete_run_with_lease_and_runtime( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.finish_run_with_runtime( + id, + Some((worker_id, lease_token)), + "completed", + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + "completed", + ) + } + + /// 与 [`Self::complete_run_with_lease_and_runtime`] 相同,但收束为 failed。 + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + pub fn fail_run_with_lease_and_runtime( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.finish_run_with_runtime( + id, + Some((worker_id, lease_token)), + "failed", + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + "failed", + ) + } + + /// 无 lease 的本地准备阶段失败收口。 + /// + /// 该入口只允许调用方在确认没有 active worker 后使用,例如后台 + /// worker 在 claim 前发现 Skill/MCP 配置无效。它与带 lease 的终态入口 + /// 共享同一事务校验,避免只把 run 标成 failed 而遗漏 RuntimeStore。 + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + pub fn fail_run_with_runtime( + &self, + id: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.finish_run_with_runtime( + id, + None, + "failed", + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + "failed", + ) + } + + /// 与 [`Self::complete_run_with_lease_and_runtime`] 相同,但收束为 cancelled。 + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + pub fn mark_cancelled_with_lease_and_runtime( + &self, + id: &str, + worker_id: &str, + lease_token: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.finish_run_with_runtime( + id, + Some((worker_id, lease_token)), + "cancelled", + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + "cancelled", + ) + } + + /// 无 lease 的控制端取消也可把两个状态流放在同一事务中收口。 + #[cfg(feature = "core-adapter")] + pub fn mark_cancelled_with_runtime( + &self, + id: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.finish_run_with_runtime( + id, + None, + "cancelled", + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + "cancelled", + ) + } + + /// Atomically cancel a run that is still queued and has not acquired an + /// Engine checkpoint or worker lease. + /// + /// The queued/checkpoint/lease predicate is evaluated inside the same + /// `IMMEDIATE` transaction as the run, runtime, session and checkpoint + /// updates. A caller may therefore prepare a snapshot before entering + /// this method without opening a read-then-write window for a worker to + /// claim the run. A normal claim/checkpoint race returns the typed + /// `QueuedCancelConflict`; Runtime converts it to `Ok(None)` and lets the + /// control plane issue a cooperative cancel instead. + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + pub fn mark_cancelled_if_queued_unclaimed_with_runtime( + &self, + id: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + self.finish_run_with_runtime_guard( + id, + None, + "cancelled", + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + "cancelled", + RunFinishGuard::QueuedUnclaimed, + ) + } + + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + fn finish_run_with_runtime( + &self, + id: &str, + lease: Option<(&str, &str)>, + target_status: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + session_status: &str, + ) -> Result { + self.finish_run_with_runtime_guard( + id, + lease, + target_status, + output, + runtime_id, + expected_runtime_revision, + snapshot, + events, + session_status, + RunFinishGuard::None, + ) + } + + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + fn finish_run_with_runtime_guard( + &self, + id: &str, + lease: Option<(&str, &str)>, + target_status: &str, + output: Option, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + session_status: &str, + guard: RunFinishGuard, + ) -> Result { + if !matches!(target_status, "completed" | "failed" | "cancelled") { + return Err(StorageError::InvalidInput( + "runtime 终态只能是 completed/failed/cancelled".to_owned(), + )); + } + if let Some(output) = &output { + validate_json(output)?; + } + if snapshot.runtime_id() != runtime_id || events.is_empty() { + return Err(StorageError::InvalidInput( + "终态 runtime snapshot/event identity 无效".to_owned(), + )); + } + let expected_run_status = match target_status { + "completed" => RunStatus::Completed, + "failed" => RunStatus::Failed, + "cancelled" => RunStatus::Cancelled, + _ => unreachable!("target status validated above"), + }; + let run_snapshot = snapshot.run(id).ok_or_else(|| { + StorageError::InvalidInput(format!("终态 runtime snapshot 缺少 run: {id}")) + })?; + if run_snapshot.status() != expected_run_status { + return Err(StorageError::InvalidInput(format!( + "终态 runtime snapshot 的 run status 与 target 不一致: expected={expected_run_status:?} actual={:?}", + run_snapshot.status() + ))); + } + let expected_event_kind = match target_status { + "completed" => RuntimeEventKind::RunCompleted, + "failed" => RuntimeEventKind::RunFailed, + "cancelled" => RuntimeEventKind::RunCancelled, + _ => unreachable!("target status validated above"), + }; + let last_event = events.last().expect("events is non-empty after validation"); + if last_event.kind() != expected_event_kind || last_event.run_id() != Some(id) { + return Err(StorageError::InvalidInput( + "终态 runtime 最后事件与 target status 不一致".to_owned(), + )); + } + let previous = expected_runtime_revision.unwrap_or(0); + let final_revision = snapshot.revision(); + if final_revision <= previous + || events.len() as u64 != final_revision.saturating_sub(previous) + { + return Err(StorageError::InvalidInput( + "终态 runtime snapshot revision 或事件数量无效".to_owned(), + )); + } + let expected_i64 = i64::try_from(previous).map_err(|_| { + StorageError::InvalidInput( + "runtime expected revision 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + let final_revision_i64 = i64::try_from(final_revision).map_err(|_| { + StorageError::InvalidInput( + "runtime snapshot revision 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + for (offset, event) in events.iter().enumerate() { + let expected = previous + offset as u64 + 1; + if event.runtime_id() != runtime_id + || event.schema_version() != RUNTIME_EVENT_SCHEMA_VERSION + || event.revision() != expected + || !event.detail().is_object() + { + return Err(StorageError::InvalidInput( + "终态 runtime event schema/revision 或 identity 无效".to_owned(), + )); + } + } + let snapshot_json = serde_json::to_value(snapshot)?; + validate_json(&snapshot_json)?; + let encoded_snapshot = serde_json::to_string(&snapshot_json)?; + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = query_run(&transaction, id)?; + if guard == RunFinishGuard::QueuedUnclaimed { + // 这是 queued cancel 的唯一竞态边界:在同一个写事务里再次 + // 确认 worker 尚未领取、Engine 尚未写 checkpoint。worker 若在 + // 此前的只读阶段抢到 run,这里只返回冲突,不会删除它的 lease + // 或把可能已发生外部副作用的 run 误收束为 cancelled。 + let eligible: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM runs AS r + WHERE r.id = ?1 AND r.status = 'queued' + AND r.cancel_requested = 0 + AND r.worker_id IS NULL + AND r.lease_token IS NULL + AND r.lease_expires_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM run_checkpoints AS c + WHERE c.run_id = r.id + ) + )", + [id], + |row| row.get(0), + )?; + if !eligible { + return Err(StorageError::QueuedCancelConflict { + id: id.to_owned(), + reason: format!("当前状态 {} 或已有 checkpoint/lease", current.status), + }); + } + } + if current.status == target_status { + // 原子事务已成功提交后的重复调用保持幂等;不要重复插入 runtime event。 + // 即使历史调用来自旧版本,也要把终态游标一并清掉。 + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + transaction.commit()?; + return Ok(current); + } + if is_terminal_status(¤t.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: current.status, + }); + } + let now = now_ms(); + let output_json = output.as_ref().map(json_string).transpose()?; + let changed = if let Some((worker_id, lease_token)) = lease { + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + let (status_clause, cancel_assignment) = if target_status == "cancelled" { + ( + "status IN ('running', 'cancel_requested') AND cancel_requested IN (0, 1)", + "cancel_requested = 1", + ) + } else { + ( + "status = 'running' AND cancel_requested = 0", + "cancel_requested = 0", + ) + }; + let sql = format!( + "UPDATE runs SET status = '{target_status}', output_json = ?1, + {cancel_assignment}, worker_id = NULL, lease_token = NULL, + lease_expires_at = NULL, heartbeat_at = ?2, updated_at = ?2 + WHERE id = ?3 AND {status_clause} + AND worker_id = ?4 AND lease_token = ?5 AND lease_expires_at > ?2" + ); + transaction.execute(&sql, params![output_json, now, id, worker_id, lease_token])? + } else { + let status_clause = if target_status == "cancelled" { + if guard == RunFinishGuard::QueuedUnclaimed { + "status = 'queued' AND cancel_requested = 0 + AND NOT EXISTS ( + SELECT 1 FROM run_checkpoints AS c + WHERE c.run_id = runs.id + )" + } else { + "status NOT IN ('completed', 'failed', 'cancelled', 'canceled')" + } + } else if target_status == "failed" { + // 只有尚未领取的本地准备失败可以无 lease 收口; + // running 必须由持有 lease 的 worker 负责,避免越权覆盖。 + "status IN ('queued', 'reconciling') AND cancel_requested = 0" + } else { + "status = 'running' AND cancel_requested = 0" + }; + let cancel_assignment = if target_status == "cancelled" { + "cancel_requested = 1" + } else { + "cancel_requested = 0" + }; + let sql = format!( + "UPDATE runs SET status = '{target_status}', output_json = COALESCE(?1, output_json), + {cancel_assignment}, updated_at = ?2 + WHERE id = ?3 AND {status_clause} + AND worker_id IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL" + ); + transaction.execute(&sql, params![output_json, now, id])? + }; + if changed != 1 { + let latest = query_run(&transaction, id)?; + if is_terminal_status(&latest.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: latest.status, + }); + } + if let Some((worker_id, lease_token)) = lease { + return Err(lease_lost_or_conflict( + &transaction, + id, + worker_id, + lease_token, + now, + )); + } + return Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许终态收束", + id, latest.status + ))); + } + + let actual_runtime: Option = transaction + .query_row( + "SELECT revision FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| row.get(0), + ) + .optional()?; + let expected_matches = match expected_runtime_revision { + Some(_) => actual_runtime == Some(expected_i64), + None => actual_runtime.is_none(), + }; + if !expected_matches { + return Err(StorageError::RevisionConflict { + run_id: id.to_owned(), + expected: expected_i64, + actual: actual_runtime.unwrap_or(0), + }); + } + // 终态 API 是 public storage contract,不能只依赖调用方声称的 + // revision。把事件从数据库中的实际快照重新经过 Core reducer, + // 再与提交快照比较,拒绝“run 已 terminal 但 runtime 仍 Running” + // 或任意伪造事件序列。 + let base_snapshot = match actual_runtime { + Some(_) => { + let (stored_revision, encoded): (i64, String) = transaction.query_row( + "SELECT revision, snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let snapshot = decode_runtime_snapshot(&encoded, "当前 runtime snapshot")?; + validate_stored_runtime_snapshot( + &snapshot, + runtime_id, + stored_revision, + "当前 runtime snapshot", + )?; + validate_runtime_history( + runtime_id, + &snapshot, + &query_runtime_events(&transaction, runtime_id)?, + "当前 runtime snapshot", + )?; + snapshot + } + None => RuntimeSnapshot::try_new(runtime_id).map_err(|error| { + StorageError::InvalidInput(format!("runtime 初始快照无效: {error}")) + })?, + }; + let mut reduced = base_snapshot; + for event in events { + reduced = reduce(&reduced, event).map_err(|error| { + StorageError::InvalidInput(format!("终态 runtime 事件不能通过 reducer: {error}")) + })?; + } + if reduced != *snapshot { + return Err(StorageError::InvalidInput( + "终态 runtime 事件重放结果与提交快照不一致".to_owned(), + )); + } + let state_changed = if actual_runtime.is_some() { + transaction.execute( + "UPDATE runtime_states SET revision = ?1, snapshot_json = ?2, updated_at = ?3 + WHERE runtime_id = ?4 AND revision = ?5", + params![ + final_revision_i64, + encoded_snapshot, + now, + runtime_id, + expected_i64 + ], + )? + } else { + transaction.execute( + "INSERT INTO runtime_states (runtime_id, revision, snapshot_json, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![runtime_id, final_revision_i64, encoded_snapshot, now], + )? + }; + if state_changed != 1 { + return Err(StorageError::RevisionConflict { + run_id: id.to_owned(), + expected: expected_i64, + actual: actual_runtime.unwrap_or(0), + }); + } + for event in events { + let event_revision = i64::try_from(event.revision()).map_err(|_| { + StorageError::InvalidInput( + "runtime event revision 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + let occurred_at = i64::try_from(event.occurred_at_ms()).map_err(|_| { + StorageError::InvalidInput( + "runtime event timestamp 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + let detail = serde_json::to_value(event.detail())?; + validate_json(&detail)?; + let event_type = serde_json::to_value(event.kind())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + StorageError::InvalidInput("runtime event kind 不是字符串".to_owned()) + })?; + transaction.execute( + "INSERT INTO runtime_events + (id, runtime_id, revision, occurred_at, schema_version, event_type, run_id, detail_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + format!("{runtime_id}:{}", event.revision()), + runtime_id, + event_revision, + occurred_at, + event.schema_version(), + event_type, + event.run_id(), + serde_json::to_string(&detail)? + ], + )?; + } + let session_id: String = + transaction.query_row("SELECT session_id FROM runs WHERE id = ?1", [id], |row| { + row.get(0) + })?; + transaction.execute( + "UPDATE sessions SET status = ?1, updated_at = ?2 WHERE id = ?3", + params![session_status, now, session_id], + )?; + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + let result = query_run(&transaction, id)?; + transaction.commit()?; + Ok(result) + } + + /// 查询 worker 需要的最小运行控制状态。 + /// + /// `status = cancel_requested` 也会被视为已请求取消,以兼容迁移前曾 + /// 直接使用状态字段表达取消意图的记录。 + pub fn get_run_control(&self, id: &str) -> Result> { + let connection = self.lock()?; + connection + .query_row( + "SELECT id, status, cancel_requested FROM runs WHERE id = ?1", + [id], + |row| { + let run_id: String = row.get(0)?; + let status: String = row.get(1)?; + let cancel_requested = bool_column(row, 2)? || status == "cancel_requested"; + Ok(RunControlRecord { + run_id, + status, + cancel_requested, + }) + }, + ) + .optional() + .map_err(StorageError::from) + } + + /// 返回指定 run 是否已收到取消请求;未知 run 视为存储层 NotFound。 + pub fn is_cancel_requested(&self, id: &str) -> Result { + self.get_run_control(id)? + .map(|control| control.cancel_requested) + .ok_or_else(|| StorageError::NotFound { + entity: "run", + id: id.to_owned(), + }) + } + + /// 以一次条件 UPDATE 发出取消请求。 + /// + /// 这是跨进程可见的轻量 CAS:只有 queued/running/paused(以及其他 + /// 已知非终态等待状态)且尚未取消的 run 才会被改成 + /// `cancel_requested`。重复调用返回当前记录,不更新时间戳;终态不会 + /// 被改写,已取消的终态记录保持幂等成功。 + pub fn request_cancel(&self, id: &str) -> Result { + let connection = self.lock()?; + let changed = connection.execute( + "UPDATE runs + SET status = 'cancel_requested', cancel_requested = 1, updated_at = ?1 + WHERE id = ?2 + AND cancel_requested = 0 + AND status IN ( + 'queued', 'running', 'paused', + 'waiting-for-tool', 'waiting-approval', 'reconciling' + )", + params![now_ms(), id], + )?; + let record = query_run(&connection, id)?; + if changed == 1 { + return Ok(record); + } + if record.cancel_requested || record.status == "cancel_requested" { + // 已有请求(包括 cancelled + 保留标记)是幂等成功。 + return Ok(record); + } + if is_terminal_status(&record.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: record.status, + }); + } + Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许请求取消", + id, record.status + ))) + } + + /// 将已请求(或仍处于可取消状态)的 run 收束为 cancelled。 + /// + /// 状态更新使用单个事务和非终态条件,避免迟到的 worker 结果覆盖 + /// completed/failed。已经 cancelled 的记录幂等返回;其它终态返回错误。 + pub fn mark_cancelled(&self, id: &str, output: Option) -> Result { + if let Some(output) = &output { + validate_json(output)?; + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs + SET status = 'cancelled', cancel_requested = 1, + output_json = COALESCE(?1, output_json), updated_at = ?2 + WHERE id = ?3 + AND status NOT IN ('completed', 'failed', 'cancelled', 'canceled') + AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL", + params![output.as_ref().map(json_string).transpose()?, now_ms(), id], + )?; + let record = query_run(&transaction, id)?; + if changed == 1 || matches!(record.status.as_str(), "cancelled" | "canceled") { + // 无 lease 的控制端收口也必须和带 lease 的终态走同一清理语义。 + // 特别是 reconciling/safe queued run 可能仍保留恢复游标;终态 + // 一旦成立,游标不能继续被 recovery/inspect 当作可恢复证据。 + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + transaction.commit()?; + return Ok(record); + } + if is_terminal_status(&record.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: record.status, + }); + } + if query_lease(&transaction, id)?.is_some() { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "run 已由带 lease 的 worker 持有,请使用 mark_cancelled_with_lease" + .to_owned(), + }); + } + Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许收束为 cancelled", + id, record.status + ))) + } + + /// 仅允许仍在运行且未收到取消请求的 run 收束为 completed。 + /// + /// 条件更新与取消请求共享同一状态条件,因此迟到的模型结果不会把 + /// `cancel_requested` 覆盖成 completed。已经 completed 的调用幂等返回。 + pub fn complete_run(&self, id: &str, output: Option) -> Result { + if let Some(output) = &output { + validate_json(output)?; + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs + SET status = 'completed', output_json = ?1, updated_at = ?2 + WHERE id = ?3 AND status = 'running' AND cancel_requested = 0 + AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL", + params![output.as_ref().map(json_string).transpose()?, now_ms(), id], + )?; + let record = query_run(&transaction, id)?; + if changed == 1 || record.status == "completed" { + // 无 lease 的兼容收口也必须和 worker 收口保持同一终态不变量: + // 一旦 run 已 completed,就不能再暴露任何可恢复 checkpoint。 + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + transaction.commit()?; + return Ok(record); + } + if is_terminal_status(&record.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: record.status, + }); + } + if query_lease(&transaction, id)?.is_some() { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "run 已由带 lease 的 worker 持有,请使用 complete_run_with_lease" + .to_owned(), + }); + } + Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许收束为 completed", + id, record.status + ))) + } + + /// 仅允许仍在运行且未收到取消请求的 run 收束为 failed。 + /// + /// 与 `complete_run` 使用同一个状态 CAS,避免错误返回与 cancel 请求 + /// 竞态时把已请求取消的 run 覆盖成 failed;已经 failed 的调用幂等返回。 + pub fn fail_run(&self, id: &str, output: Option) -> Result { + if let Some(output) = &output { + validate_json(output)?; + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs + SET status = 'failed', output_json = ?1, updated_at = ?2 + WHERE id = ?3 AND status = 'running' AND cancel_requested = 0 + AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL", + params![output.as_ref().map(json_string).transpose()?, now_ms(), id], + )?; + let record = query_run(&transaction, id)?; + if changed == 1 || record.status == "failed" { + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + transaction.commit()?; + return Ok(record); + } + if is_terminal_status(&record.status) { + return Err(StorageError::TerminalRun { + id: id.to_owned(), + status: record.status, + }); + } + if query_lease(&transaction, id)?.is_some() { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "run 已由带 lease 的 worker 持有,请使用 fail_run_with_lease".to_owned(), + }); + } + Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许收束为 failed", + id, record.status + ))) + } + + /// 根据 runtime-level 事件中的 run_id 反查所属 runtime。 + /// 没有已提交 runtime 事件时返回 None。 + pub fn runtime_id_for_run(&self, run_id: &str) -> Result> { + let connection = self.lock()?; + connection + .query_row( + "SELECT runtime_id FROM runtime_events + WHERE run_id = ?1 ORDER BY revision ASC LIMIT 1", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(StorageError::from) + } + + /// 更新运行状态和最终输出,不改变事件 revision。 + /// 输出同样经过敏感字段检查,避免把模型响应里的凭据落盘。 + pub fn update_run(&self, id: &str, status: &str, output: Option) -> Result { + if let Some(output) = &output { + validate_json(output)?; + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs SET status = ?1, output_json = ?2, updated_at = ?3 + WHERE id = ?4 AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL", + params![ + status, + output.as_ref().map(json_string).transpose()?, + now_ms(), + id + ], + )?; + if changed == 0 { + let record = transaction + .query_row( + "SELECT id, session_id, status, revision, input_json, output_json, + created_at, updated_at, cancel_requested + FROM runs WHERE id = ?1", + [id], + row_run, + ) + .map_err(|error| not_found_from_query(error, "run", id))?; + if query_lease(&transaction, id)?.is_some() { + return Err(StorageError::LeaseConflict { + id: id.to_owned(), + reason: "run 已由带 lease 的 worker 持有".to_owned(), + }); + } + return Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许更新", + id, record.status + ))); + } + if is_terminal_status(status) { + // `update_run` 是低层兼容入口;即便调用方绕过专用 complete/fail, + // 终态与恢复游标仍在同一事务内收束。 + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [id])?; + } + let record = query_run(&transaction, id)?; + transaction.commit()?; + Ok(record) + } + + /// 更新会话状态;会话元数据只有在显式传入时才会替换。 + pub fn update_session( + &self, + id: &str, + status: &str, + metadata: Option, + ) -> Result { + if let Some(metadata) = &metadata { + validate_json(metadata)?; + } + let connection = self.lock()?; + let changed = if let Some(metadata) = &metadata { + connection.execute( + "UPDATE sessions SET status = ?1, metadata_json = ?2, updated_at = ?3 + WHERE id = ?4", + params![status, json_string(metadata)?, now_ms(), id], + )? + } else { + connection.execute( + "UPDATE sessions SET status = ?1, updated_at = ?2 WHERE id = ?3", + params![status, now_ms(), id], + )? + }; + if changed == 0 { + return Err(StorageError::NotFound { + entity: "session", + id: id.to_string(), + }); + } + connection + .query_row( + "SELECT id, agent_id, status, metadata_json, created_at, updated_at + FROM sessions WHERE id = ?1", + [id], + row_session, + ) + .map_err(StorageError::from) + } + + /// 仅在 run 的 revision 等于 expected_revision 时追加事件。 + /// 递增 revision、写事件、更新时间在同一个 IMMEDIATE 事务中完成。 + pub fn append_event(&self, input: NewEvent, expected_revision: i64) -> Result { + self.append_event_checked(input, expected_revision, None) + } + + /// 与 [`Self::append_event`] 相同,但要求调用方持有当前未过期的 worker lease。 + /// Host 用它写入 Engine 观察事件,避免旧 worker 在 lease 失效后仍能把 + /// 迟到事件追加到 run-level 审计流。 + pub fn append_event_with_lease( + &self, + input: NewEvent, + expected_revision: i64, + worker_id: &str, + lease_token: &str, + ) -> Result { + self.append_event_checked(input, expected_revision, Some((worker_id, lease_token))) + } + + fn append_event_checked( + &self, + input: NewEvent, + expected_revision: i64, + lease: Option<(&str, &str)>, + ) -> Result { + validate_json(&input.payload)?; + require_id("event", &input.id)?; + if expected_revision < 0 { + return Err(StorageError::InvalidInput( + "expected_revision must be non-negative".to_string(), + )); + } + if let Some((worker_id, lease_token)) = lease { + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (session_id, actual_revision): (String, i64) = transaction + .query_row( + "SELECT session_id, revision FROM runs WHERE id = ?1", + [&input.run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| not_found_from_query(error, "run", &input.run_id))?; + let now = now_ms(); + if let Some((worker_id, lease_token)) = lease { + let valid: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM runs + WHERE id = ?1 AND worker_id = ?2 AND lease_token = ?3 + AND lease_expires_at > ?4 + )", + params![input.run_id, worker_id, lease_token, now], + |row| row.get(0), + )?; + if !valid { + return Err(lease_lost_or_conflict( + &transaction, + &input.run_id, + worker_id, + lease_token, + now, + )); + } + } + if actual_revision != expected_revision { + return Err(StorageError::RevisionConflict { + run_id: input.run_id, + expected: expected_revision, + actual: actual_revision, + }); + } + let revision = expected_revision.checked_add(1).ok_or_else(|| { + StorageError::InvalidInput("revision 超出 SQLite INTEGER 范围".to_owned()) + })?; + let changed = if let Some((worker_id, lease_token)) = lease { + transaction.execute( + "UPDATE runs SET revision = ?1, updated_at = ?2 + WHERE id = ?3 AND revision = ?4 + AND worker_id = ?5 AND lease_token = ?6 + AND lease_expires_at > ?2", + params![ + revision, + now, + input.run_id, + expected_revision, + worker_id, + lease_token + ], + )? + } else { + transaction.execute( + "UPDATE runs SET revision = ?1, updated_at = ?2 + WHERE id = ?3 AND revision = ?4", + params![revision, now, input.run_id, expected_revision], + )? + }; + if changed != 1 { + if let Some((worker_id, lease_token)) = lease { + return Err(lease_lost_or_conflict( + &transaction, + &input.run_id, + worker_id, + lease_token, + now, + )); + } + return Err(StorageError::RevisionConflict { + run_id: input.run_id, + expected: expected_revision, + actual: actual_revision, + }); + } + transaction.execute( + "INSERT INTO events + (id, session_id, run_id, revision, event_type, payload_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + input.id, + session_id, + input.run_id, + revision, + input.event_type, + json_string(&input.payload)?, + now + ], + )?; + transaction.commit()?; + Ok(EventRecord { + id: input.id, + session_id, + run_id: input.run_id, + revision, + event_type: input.event_type, + payload: input.payload, + created_at: now, + }) + } + + pub fn list_events(&self, run_id: &str, after_revision: i64) -> Result> { + let connection = self.lock()?; + let mut statement = connection.prepare( + "SELECT id, session_id, run_id, revision, event_type, payload_json, created_at + FROM events WHERE run_id = ?1 AND revision > ?2 ORDER BY revision ASC", + )?; + let rows = statement.query_map(params![run_id, after_revision], row_event)?; + rows.collect::, _>>() + .map_err(StorageError::from) + } + + /// 保存某个已存在 revision 的快照。快照不能领先于 run,避免恢复到不存在的状态。 + pub fn save_snapshot(&self, input: NewSnapshot) -> Result { + validate_json(&input.state)?; + require_id("run", &input.run_id)?; + if input.revision < 0 { + return Err(StorageError::InvalidInput( + "snapshot revision must be non-negative".to_string(), + )); + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (actual_session_id, run_revision): (String, i64) = transaction + .query_row( + "SELECT session_id, revision FROM runs WHERE id = ?1", + [&input.run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| not_found_from_query(error, "run", &input.run_id))?; + if actual_session_id != input.session_id { + return Err(StorageError::InvalidInput( + "snapshot session_id does not match run".to_string(), + )); + } + if input.revision > run_revision { + return Err(StorageError::SnapshotAhead { + run_id: input.run_id, + revision: input.revision, + run_revision, + }); + } + let now = now_ms(); + transaction.execute( + "INSERT INTO snapshots (session_id, run_id, revision, state_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(run_id, revision) DO UPDATE SET + state_json = excluded.state_json, created_at = excluded.created_at", + params![ + input.session_id, + input.run_id, + input.revision, + json_string(&input.state)?, + now + ], + )?; + let id: i64 = transaction.query_row( + "SELECT id FROM snapshots WHERE run_id = ?1 AND revision = ?2", + params![input.run_id, input.revision], + |row| row.get(0), + )?; + transaction.commit()?; + Ok(SnapshotRecord { + id, + session_id: input.session_id, + run_id: input.run_id, + revision: input.revision, + state: input.state, + created_at: now, + }) + } + + pub fn latest_snapshot(&self, run_id: &str) -> Result> { + let connection = self.lock()?; + connection + .query_row( + "SELECT id, session_id, run_id, revision, state_json, created_at + FROM snapshots WHERE run_id = ?1 ORDER BY revision DESC LIMIT 1", + [run_id], + row_snapshot, + ) + .optional() + .map_err(StorageError::from) + } + + /// 插入一个待决审批请求;同一 request id 的重复写入必须与原始身份 + /// 完全一致,不能借 upsert 悄悄覆盖已经作出的决议。 + pub fn create_approval(&self, input: NewApproval) -> Result { + validate_json(&input.request)?; + require_id("approval", &input.id)?; + require_non_empty("approval arguments_hash", &input.arguments_hash)?; + require_non_empty("approval token", &input.approval_token)?; + if input.expires_at_ms <= 0 { + return Err(StorageError::InvalidInput( + "approval expires_at_ms 必须大于 0".to_owned(), + )); + } + if input.status != "pending" { + return Err(StorageError::InvalidInput( + "新建 approval 的 status 必须为 pending".to_owned(), + )); + } + let now = now_ms(); + let connection = self.lock()?; + // Resolve an existing identity before validating the candidate + // payload. A duplicate id with different fields is a conflict (not a + // malformed-new-record error), while an exact retry is idempotent. + match self.get_approval_locked(&connection, &input.id) { + Ok(record) => { + if record.session_id != input.session_id + || record.run_id != input.run_id + || record.tool_call_id != input.tool_call_id + || record.request != input.request + || record.arguments_hash != input.arguments_hash + || record.approval_token != input.approval_token + || record.expires_at_ms != input.expires_at_ms + { + return Err(StorageError::ApprovalConflict { + id: input.id, + expected: "相同 request identity".to_owned(), + actual: "request identity 不一致".to_owned(), + }); + } + return Ok(record); + } + Err(StorageError::NotFound { .. }) => {} + Err(error) => return Err(error), + } + validate_approval_request_binding(&input)?; + connection.execute( + "INSERT INTO approvals + (id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?9) + ON CONFLICT(id) DO NOTHING", + params![ + input.id, + input.session_id, + input.run_id, + input.tool_call_id, + json_string(&input.request)?, + input.arguments_hash, + input.approval_token, + input.expires_at_ms, + now + ], + )?; + let record = self.get_approval_locked(&connection, &input.id)?; + if record.session_id != input.session_id + || record.run_id != input.run_id + || record.tool_call_id != input.tool_call_id + || record.request != input.request + || record.arguments_hash != input.arguments_hash + || record.approval_token != input.approval_token + || record.expires_at_ms != input.expires_at_ms + { + return Err(StorageError::ApprovalConflict { + id: input.id, + expected: "相同 request identity".to_owned(), + actual: "request identity 不一致".to_owned(), + }); + } + Ok(record) + } + + /// 在一个 IMMEDIATE 事务中写入 approval,并同时校验/刷新与之对应的 + /// awaiting checkpoint 和 Core runtime 快照。 + /// + /// Engine 的 checkpoint callback 发生在 `ApprovalRequest` 构造之前, + /// 所以 Host 只能在拿到完整 binding 后调用此收口入口。这里不追加 + /// runtime event(ToolRequested 已由前一条 checkpoint 事务提交),但 + /// 会在同一事务中验证 runtime event history、lease 和 checkpoint,避免 + /// approval 行单独落盘后与恢复游标分叉。 + #[cfg(feature = "core-adapter")] + pub fn create_approval_with_checkpoint_runtime_and_lease( + &self, + commit: DurableApprovalCheckpointRuntimeCommit, + ) -> Result { + let approval = approval_from_durable(&commit.approval); + validate_approval_input(&approval)?; + let checkpoint = new_checkpoint_from_durable(&commit.checkpoint); + validate_checkpoint_input( + &checkpoint, + &commit.lease.worker_id, + &commit.lease.lease_token, + )?; + if checkpoint.phase != "awaiting_approval" + || checkpoint.run_id != approval.run_id + || checkpoint.tool_call_id != approval.tool_call_id + { + return Err(StorageError::InvalidInput( + "approval 必须绑定同一 run 的 awaiting_approval checkpoint".to_owned(), + )); + } + validate_runtime_commit_identity(&commit.runtime_id, &commit.snapshot)?; + let approval_call = approval + .request + .get("call") + .cloned() + .ok_or_else(|| StorageError::InvalidInput("approval request 缺少 call".to_owned())) + .and_then(|value| { + serde_json::from_value::(value).map_err(|error| { + StorageError::InvalidInput(format!("approval call 无法解码: {error}")) + }) + })?; + let runtime_run = commit.snapshot.run(&approval.run_id).ok_or_else(|| { + StorageError::InvalidInput(format!( + "runtime snapshot 缺少 approval 对应 run: {}", + approval.run_id + )) + })?; + if !runtime_run + .tool_calls() + .iter() + .any(|call| call == &approval_call) + { + return Err(StorageError::InvalidInput( + "approval call 不存在于 runtime snapshot".to_owned(), + )); + } + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let run = query_run(&transaction, &approval.run_id)?; + if run.session_id != approval.session_id { + return Err(StorageError::InvalidInput( + "approval session identity 与 run 不一致".to_owned(), + )); + } + let now = now_ms(); + let lease_valid: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM runs + WHERE id = ?1 AND status IN ('running', 'cancel_requested') + AND worker_id = ?2 AND lease_token = ?3 + AND lease_expires_at > ?4 + )", + params![ + approval.run_id, + commit.lease.worker_id, + commit.lease.lease_token, + now + ], + |row| row.get(0), + )?; + if !lease_valid { + return Err(lease_lost_or_conflict( + &transaction, + &approval.run_id, + &commit.lease.worker_id, + &commit.lease.lease_token, + now, + )); + } + + let current_checkpoint = query_run_checkpoint(&transaction, &approval.run_id)?; + ensure_checkpoint_matches(¤t_checkpoint, &checkpoint)?; + // Keep the checkpoint write inside this transaction even though the + // values are normally identical. A stale caller therefore cannot + // commit an approval unless its exact cursor is still fenced. + upsert_checkpoint_in_transaction( + &transaction, + &checkpoint, + &commit.lease.worker_id, + &commit.lease.lease_token, + now, + )?; + verify_runtime_snapshot_in_transaction( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + )?; + let record = create_approval_in_transaction(&transaction, &approval, now)?; + transaction.commit()?; + Ok(record) + } + + /// 兼容旧的低层写入口。新的 Host 代码应使用 [`Self::create_approval`] + /// 保留“只创建 pending、不可覆盖决议”的语义。 + pub fn upsert_approval(&self, input: NewApproval) -> Result { + if input.status == "pending" { + return self.create_approval(input); + } + validate_json(&input.request)?; + require_id("approval", &input.id)?; + require_non_empty("approval arguments_hash", &input.arguments_hash)?; + require_non_empty("approval token", &input.approval_token)?; + if input.expires_at_ms <= 0 { + return Err(StorageError::InvalidInput( + "approval expires_at_ms 必须大于 0".to_owned(), + )); + } + validate_approval_request_binding(&input)?; + let now = now_ms(); + let connection = self.lock()?; + connection.execute( + "INSERT INTO approvals + (id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, request_json = excluded.request_json, + tool_call_id = excluded.tool_call_id, + arguments_hash = excluded.arguments_hash, + approval_token = excluded.approval_token, + expires_at_ms = excluded.expires_at_ms, + updated_at = excluded.updated_at", + params![ + input.id, + input.session_id, + input.run_id, + input.tool_call_id, + input.status, + json_string(&input.request)?, + input.arguments_hash, + input.approval_token, + input.expires_at_ms, + now + ], + )?; + self.get_approval_locked(&connection, &input.id) + } + + /// 用 expected status 做一次条件 UPDATE。决议一旦离开 pending 就不再 + /// 可被第二个控制端覆盖;同一目标决议重复提交保持幂等,便于 CLI 重试。 + pub fn resolve_approval( + &self, + id: &str, + expected_status: &str, + status: &str, + decision: Value, + ) -> Result { + require_id("approval", id)?; + require_non_empty("approval expected status", expected_status)?; + require_non_empty("approval status", status)?; + if !matches!(status, "allowed" | "denied" | "cancelled") { + return Err(StorageError::InvalidInput(format!( + "未知 approval status: {status}" + ))); + } + validate_json(&decision)?; + let encoded_decision = json_string(&decision)?; + let now = now_ms(); + // Serialize the expiry check and status CAS in one immediate + // transaction. A separate SELECT followed by UPDATE leaves a small + // cross-process window in which a pending approval can expire (or be + // resolved by another controller) between the two statements. + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = transaction + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE id = ?1", + [id], + row_approval, + ) + .map_err(|error| not_found_from_query(error, "approval", id))?; + if current.status == expected_status + && current.expires_at_ms > 0 + && current.expires_at_ms <= now + { + return Err(StorageError::InvalidInput(format!("approval 已过期: {id}"))); + } + let changed = transaction.execute( + "UPDATE approvals + SET status = ?1, decision_json = ?2, updated_at = ?3 + WHERE id = ?4 AND status = ?5 + AND (status <> 'pending' OR (expires_at_ms > 0 AND expires_at_ms > ?3))", + params![status, encoded_decision, now, id, expected_status], + )?; + let record = transaction + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE id = ?1", + [id], + row_approval, + ) + .map_err(|error| not_found_from_query(error, "approval", id))?; + transaction.commit()?; + if changed == 1 { + return Ok(record); + } + // SQLite 对相同值 UPDATE 可能报告 0 行;允许同一决议幂等返回, + // 但绝不把不同决议误认为成功。 + if record.status == status && record.decision.as_ref() == Some(&decision) { + return Ok(record); + } + Err(StorageError::ApprovalConflict { + id: id.to_owned(), + expected: expected_status.to_owned(), + actual: record.status, + }) + } + + /// 旧名称保留给已有适配器,但语义改为 pending-only CAS,避免调用方 + /// 继续无条件覆盖审批结果。 + pub fn decide_approval( + &self, + id: &str, + status: &str, + decision: Value, + ) -> Result { + self.resolve_approval(id, "pending", status, decision) + } + + pub fn get_approval(&self, id: &str) -> Result> { + let connection = self.lock()?; + self.get_approval_locked(&connection, id) + .map(Some) + .or_else(|error| match error { + StorageError::NotFound { .. } => Ok(None), + other => Err(other), + }) + } + + /// 按创建顺序列出一个 run 的审批,供控制端恢复/审计使用。 + pub fn list_approvals_for_run(&self, run_id: &str) -> Result> { + require_id("run", run_id)?; + let connection = self.lock()?; + let mut statement = connection.prepare( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE run_id = ?1 ORDER BY created_at ASC, id ASC", + )?; + let rows = statement.query_map([run_id], row_approval)?; + rows.collect::, _>>() + .map_err(StorageError::from) + } + + /// 找到与当前 awaiting checkpoint 对应的审批记录。调用方仍应校验 + /// request identity;这里的查询只按 durable run/call 过滤,避免把同一 + /// run 的旧审批决议套到新的工具调用上。 + pub fn get_approval_for_run_call( + &self, + run_id: &str, + tool_call_id: &str, + ) -> Result> { + require_id("run", run_id)?; + require_id("tool_call", tool_call_id)?; + let connection = self.lock()?; + connection + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals + WHERE run_id = ?1 AND tool_call_id = ?2 + ORDER BY created_at DESC, id DESC LIMIT 1", + params![run_id, tool_call_id], + row_approval, + ) + .optional() + .map_err(StorageError::from) + } + + /// 取消一个 run 时关闭尚未决议的 approvals。已完成的 allow/deny + /// 记录保留不变,便于审计;重复调用是幂等的。 + pub fn cancel_pending_approvals(&self, run_id: &str) -> Result { + require_id("run", run_id)?; + let connection = self.lock()?; + let changed = connection.execute( + "UPDATE approvals + SET status = 'cancelled', decision_json = '{\"decision\":\"cancelled\"}', + updated_at = ?1 + WHERE run_id = ?2 AND status = 'pending'", + params![now_ms(), run_id], + )?; + Ok(changed) + } + + /// 将已完成决议的 approval 对应 run 放回队列。这里只改变 run/session + /// 投影,不启动 Engine;真正执行必须再经过显式 resume API。 + #[cfg(feature = "core-adapter")] + pub fn queue_approved_run(&self, approval_id: &str) -> Result { + require_id("approval", approval_id)?; + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let approval = transaction + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE id = ?1", + [approval_id], + row_approval, + ) + .map_err(|error| not_found_from_query(error, "approval", approval_id))?; + if !matches!(approval.status.as_str(), "allowed" | "denied") { + return Err(StorageError::ApprovalConflict { + id: approval_id.to_owned(), + expected: "allowed or denied".to_owned(), + actual: approval.status, + }); + } + if approval.expires_at_ms <= 0 || approval.expires_at_ms <= now_ms() { + return Err(StorageError::InvalidInput(format!( + "approval 已过期: {approval_id}" + ))); + } + let run = query_run(&transaction, &approval.run_id)?; + if run.session_id != approval.session_id { + return Err(StorageError::InvalidInput( + "approval 与 run 的 session identity 不一致".to_owned(), + )); + } + let checkpoint = query_run_checkpoint(&transaction, &approval.run_id)?; + if checkpoint.phase != "awaiting_approval" + || checkpoint.tool_call_id != approval.tool_call_id + { + return Err(StorageError::InvalidInput(format!( + "approval checkpoint 与请求不一致: {}", + approval_id + ))); + } + if run.cancel_requested { + return Err(StorageError::InvalidInput(format!( + "run 已请求取消,不能恢复 approval: {}", + approval.run_id + ))); + } + let now = now_ms(); + let changed = transaction.execute( + "UPDATE runs SET status = 'queued', updated_at = ?1 + WHERE id = ?2 AND status = 'reconciling' + AND cancel_requested = 0 + AND worker_id IS NULL AND lease_token IS NULL AND lease_expires_at IS NULL", + params![now, approval.run_id], + )?; + let result = query_run(&transaction, &approval.run_id)?; + if changed == 0 && result.status != "queued" { + return Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {} 不允许 approval resume", + result.id, result.status + ))); + } + // A repeated resume of an already queued run is a true no-op. In + // particular, do not advance `updated_at` on the second call; callers + // can safely retry the control command and compare the returned record. + if changed == 1 { + transaction.execute( + "UPDATE sessions SET status = 'queued', updated_at = ?1 WHERE id = ?2", + params![now, approval.session_id], + )?; + } + transaction.commit()?; + Ok(result) + } + + pub fn insert_tool_call(&self, input: NewToolCall) -> Result { + validate_json(&input.arguments)?; + require_id("tool_call", &input.id)?; + let now = now_ms(); + let connection = self.lock()?; + // Engine retries and approval continuation may report the same call + // identity more than once. Treat an identical insert as idempotent; + // conflicting payloads remain a durable identity error. + match self.get_tool_call_locked(&connection, &input.id) { + Ok(existing) => { + if existing.session_id == input.session_id + && existing.run_id == input.run_id + && existing.tool_name == input.tool_name + && existing.arguments == input.arguments + { + return Ok(existing); + } + return Err(StorageError::InvalidInput(format!( + "tool_call identity 已存在但内容不一致: {}", + input.id + ))); + } + Err(StorageError::NotFound { .. }) => {} + Err(error) => return Err(error), + } + connection.execute( + "INSERT INTO tool_calls + (id, session_id, run_id, tool_name, arguments_json, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + input.id, + input.session_id, + input.run_id, + input.tool_name, + json_string(&input.arguments)?, + input.status, + now + ], + )?; + self.get_tool_call_locked(&connection, &input.id) + } + + pub fn complete_tool_call( + &self, + id: &str, + status: &str, + result: Value, + ) -> Result { + validate_json(&result)?; + let connection = self.lock()?; + let changed = connection.execute( + "UPDATE tool_calls SET status = ?1, result_json = ?2, updated_at = ?3 WHERE id = ?4", + params![status, json_string(&result)?, now_ms(), id], + )?; + if changed == 0 { + return Err(StorageError::NotFound { + entity: "tool_call", + id: id.to_string(), + }); + } + self.get_tool_call_locked(&connection, id) + } + + /// 在一个 IMMEDIATE 事务中提交 runtime snapshot/event 与工具调用行。 + /// + /// 这是 Runtime facade 使用的联合提交入口:先在事务内检查 run/session + /// 身份和可选 worker lease,再由 Core reducer/CAS 校验 runtime 事件,最后 + /// 写入 `tool_calls`。任何一步失败都会让 SQLite 自动回滚,避免出现只有 + /// runtime 事件或只有工具调用行的半提交状态。 + #[cfg(feature = "core-adapter")] + pub fn create_tool_call_with_runtime_and_lease( + &self, + commit: DurableToolCallRuntimeCommit, + ) -> Result { + validate_durable_tool_call_input(&commit.call)?; + validate_runtime_commit_identity(&commit.runtime_id, &commit.snapshot)?; + if !snapshot_contains_tool_call(&commit.snapshot, &commit.call)? { + return Err(StorageError::InvalidInput(format!( + "runtime snapshot 缺少对应的 ToolCallRequested: {}", + commit.call.id + ))); + } + if !events_contain_tool_call_requested( + &commit.events, + &commit.runtime_id, + &commit.call.run_id, + &commit.call, + )? { + return Err(StorageError::InvalidInput(format!( + "runtime event batch 缺少对应的 ToolCallRequested: {}", + commit.call.id + ))); + } + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + + // A retry after the first transaction committed must not append the + // same Core event batch a second time. Verify the already-persisted + // snapshot and event suffix before returning the existing row. + let existing = query_tool_call_optional(&transaction, &commit.call.id)?; + if let Some(existing) = existing { + ensure_tool_call_identity(&existing, &commit.call)?; + // A duplicate is read-only and may arrive after the run has already + // reached a terminal state. Still require a live matching lease + // whenever the caller supplies one; stale workers never get an + // unconditional success path. + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + commit.lease.as_ref(), + true, + )?; + verify_duplicate_runtime_commit( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + transaction.commit()?; + return Ok(existing); + } + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + commit.lease.as_ref(), + false, + )?; + + commit_runtime_in_transaction( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + let now = now_ms(); + transaction.execute( + "INSERT INTO tool_calls + (id, session_id, run_id, tool_name, arguments_json, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + commit.call.id, + commit.call.session_id, + commit.call.run_id, + commit.call.tool_name, + json_string(&commit.call.arguments)?, + commit.call.status, + now + ], + )?; + let record = query_tool_call_required(&transaction, &commit.call.id)?; + transaction.commit()?; + Ok(record) + } + + /// 在一个 IMMEDIATE 事务中提交工具调用结果与对应 runtime 事件。 + /// + /// 正常路径要求已有 `tool_calls` 行;若旧数据库缺少该行,则仅在最终 + /// runtime snapshot 已经包含完全匹配的 ToolCall 时回填一行,再写入终态。 + /// 这样旧 Host 可以安全重试 complete,而不会凭空制造一条与 Core 状态 + /// 无关的调用记录。重复提交同一终态会校验已落盘 runtime snapshot/event + /// 后幂等返回;不同终态或不同结果则 fail-closed。 + #[cfg(feature = "core-adapter")] + pub fn complete_tool_call_with_runtime_and_lease( + &self, + commit: DurableToolCallRuntimeCommit, + status: &str, + result: Value, + ) -> Result { + validate_durable_tool_call_input(&commit.call)?; + require_non_empty("tool call status", status)?; + validate_json(&result)?; + validate_runtime_commit_identity(&commit.runtime_id, &commit.snapshot)?; + if !snapshot_matches_tool_result(&commit.snapshot, &commit.call, status, &result)? { + return Err(StorageError::InvalidInput(format!( + "runtime snapshot 中的 tool result 与工具调用结果不一致: {}", + commit.call.id + ))); + } + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = query_tool_call_optional(&transaction, &commit.call.id)?; + if let Some(record) = &existing { + ensure_tool_call_identity(record, &commit.call)?; + if let Some(previous_result) = &record.result { + if record.status != status || previous_result != &result { + return Err(StorageError::InvalidInput(format!( + "tool_call {} 已经以不同终态完成", + commit.call.id + ))); + } + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + commit.lease.as_ref(), + true, + )?; + verify_duplicate_runtime_commit( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + transaction.commit()?; + return Ok(record.clone()); + } + } + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + commit.lease.as_ref(), + false, + )?; + if existing.is_none() && !snapshot_contains_tool_call(&commit.snapshot, &commit.call)? { + return Err(StorageError::NotFound { + entity: "tool_call", + id: commit.call.id, + }); + } + + // The runtime batch is committed before the tool row update, but both + // writes share this transaction so a reducer/CAS/SQL failure rolls back + // the complete operation. For a missing legacy row the final snapshot + // check above proves that the Core call identity is already present. + commit_runtime_in_transaction( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + let now = now_ms(); + if existing.is_none() { + transaction.execute( + "INSERT INTO tool_calls + (id, session_id, run_id, tool_name, arguments_json, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + commit.call.id, + commit.call.session_id, + commit.call.run_id, + commit.call.tool_name, + json_string(&commit.call.arguments)?, + commit.call.status, + now + ], + )?; + } + transaction.execute( + "UPDATE tool_calls SET status = ?1, result_json = ?2, updated_at = ?3 + WHERE id = ?4", + params![status, json_string(&result)?, now, commit.call.id], + )?; + let record = query_tool_call_required(&transaction, &commit.call.id)?; + transaction.commit()?; + Ok(record) + } + + /// 在一个 IMMEDIATE 事务中提交工具调用、checkpoint 和对应 runtime + /// snapshot/event。这个入口只在调用方已经同时拥有三者的边界使用; + /// 旧的独立 API 保持不变,避免把无法证明关联的写入伪装成原子操作。 + #[cfg(feature = "core-adapter")] + pub fn create_tool_call_with_checkpoint_runtime_and_lease( + &self, + commit: DurableToolCallCheckpointRuntimeCommit, + ) -> Result { + let checkpoint = new_checkpoint_from_durable(&commit.checkpoint); + validate_durable_tool_call_input(&commit.call)?; + validate_checkpoint_input( + &checkpoint, + &commit.lease.worker_id, + &commit.lease.lease_token, + )?; + validate_tool_checkpoint_run_identity(&commit.call, &checkpoint)?; + validate_runtime_commit_identity(&commit.runtime_id, &commit.snapshot)?; + if !snapshot_contains_tool_call(&commit.snapshot, &commit.call)? { + return Err(StorageError::InvalidInput(format!( + "runtime snapshot 缺少对应的 ToolCallRequested: {}", + commit.call.id + ))); + } + if !events_contain_tool_call_requested( + &commit.events, + &commit.runtime_id, + &commit.call.run_id, + &commit.call, + )? { + return Err(StorageError::InvalidInput(format!( + "runtime event batch 缺少对应的 ToolCallRequested: {}", + commit.call.id + ))); + } + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = query_tool_call_optional(&transaction, &commit.call.id)?; + if let Some(existing) = existing { + ensure_tool_call_identity(&existing, &commit.call)?; + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + Some(&commit.lease), + true, + )?; + verify_duplicate_runtime_commit( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + verify_duplicate_checkpoint(&transaction, &checkpoint)?; + transaction.commit()?; + return Ok(existing); + } + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + Some(&commit.lease), + false, + )?; + commit_runtime_in_transaction( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + upsert_checkpoint_in_transaction( + &transaction, + &checkpoint, + &commit.lease.worker_id, + &commit.lease.lease_token, + now_ms(), + )?; + let now = now_ms(); + transaction.execute( + "INSERT INTO tool_calls + (id, session_id, run_id, tool_name, arguments_json, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + commit.call.id, + commit.call.session_id, + commit.call.run_id, + commit.call.tool_name, + json_string(&commit.call.arguments)?, + commit.call.status, + now + ], + )?; + let record = query_tool_call_required(&transaction, &commit.call.id)?; + transaction.commit()?; + Ok(record) + } + + /// 在一个 IMMEDIATE 事务中提交工具结果、checkpoint 和对应 runtime + /// snapshot/event。任何 reducer、CAS、lease 或 SQL 失败都会回滚三类写入。 + #[cfg(feature = "core-adapter")] + pub fn complete_tool_call_with_checkpoint_runtime_and_lease( + &self, + commit: DurableToolCallCheckpointRuntimeCommit, + status: &str, + result: Value, + ) -> Result { + let checkpoint = new_checkpoint_from_durable(&commit.checkpoint); + validate_durable_tool_call_input(&commit.call)?; + require_non_empty("tool call status", status)?; + validate_json(&result)?; + validate_checkpoint_input( + &checkpoint, + &commit.lease.worker_id, + &commit.lease.lease_token, + )?; + validate_tool_checkpoint_run_identity(&commit.call, &checkpoint)?; + validate_runtime_commit_identity(&commit.runtime_id, &commit.snapshot)?; + if !snapshot_matches_tool_result(&commit.snapshot, &commit.call, status, &result)? { + return Err(StorageError::InvalidInput(format!( + "runtime snapshot 中的 tool result 与工具调用结果不一致: {}", + commit.call.id + ))); + } + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = query_tool_call_optional(&transaction, &commit.call.id)?; + if let Some(record) = &existing { + ensure_tool_call_identity(record, &commit.call)?; + if let Some(previous_result) = &record.result { + if record.status != status || previous_result != &result { + return Err(StorageError::InvalidInput(format!( + "tool_call {} 已经以不同终态完成", + commit.call.id + ))); + } + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + Some(&commit.lease), + true, + )?; + verify_duplicate_runtime_commit( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + verify_duplicate_checkpoint(&transaction, &checkpoint)?; + transaction.commit()?; + return Ok(record.clone()); + } + } + validate_tool_call_run_and_lease( + &transaction, + &commit.call, + &commit.runtime_id, + Some(&commit.lease), + false, + )?; + if existing.is_none() && !snapshot_contains_tool_call(&commit.snapshot, &commit.call)? { + return Err(StorageError::NotFound { + entity: "tool_call", + id: commit.call.id, + }); + } + commit_runtime_in_transaction( + &transaction, + &commit.runtime_id, + commit.expected_runtime_revision, + &commit.snapshot, + &commit.events, + )?; + upsert_checkpoint_in_transaction( + &transaction, + &checkpoint, + &commit.lease.worker_id, + &commit.lease.lease_token, + now_ms(), + )?; + let now = now_ms(); + if existing.is_none() { + transaction.execute( + "INSERT INTO tool_calls + (id, session_id, run_id, tool_name, arguments_json, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + commit.call.id, + commit.call.session_id, + commit.call.run_id, + commit.call.tool_name, + json_string(&commit.call.arguments)?, + commit.call.status, + now + ], + )?; + } + transaction.execute( + "UPDATE tool_calls SET status = ?1, result_json = ?2, updated_at = ?3 + WHERE id = ?4", + params![status, json_string(&result)?, now, commit.call.id], + )?; + let record = query_tool_call_required(&transaction, &commit.call.id)?; + transaction.commit()?; + Ok(record) + } + + pub fn get_tool_call(&self, id: &str) -> Result> { + let connection = self.lock()?; + self.get_tool_call_locked(&connection, id) + .map(Some) + .or_else(|error| match error { + StorageError::NotFound { .. } => Ok(None), + other => Err(other), + }) + } + + /// 按 run 读取工具调用,顺序与 JSONL 导出保持一致。 + pub fn list_tool_calls_for_run(&self, run_id: &str) -> Result> { + let connection = self.lock()?; + let mut statement = connection.prepare( + "SELECT id, session_id, run_id, tool_name, arguments_json, result_json, + status, created_at, updated_at + FROM tool_calls WHERE run_id = ?1 ORDER BY created_at ASC, id ASC", + )?; + let rows = statement.query_map([run_id], row_tool_call)?; + rows.collect::>>() + .map_err(StorageError::from) + } + + pub fn upsert_external_session( + &self, + input: NewExternalSession, + ) -> Result { + validate_json(&input.metadata)?; + require_id("external_session", &input.id)?; + require_non_empty("external session_id", &input.session_id)?; + if let Some(run_id) = &input.run_id { + require_non_empty("external run_id", run_id)?; + } + require_non_empty("external backend", &input.backend)?; + require_non_empty("external_id", &input.external_id)?; + require_non_empty("external session status", &input.status)?; + let now = now_ms(); + let connection = self.lock()?; + connection.execute( + "INSERT INTO external_sessions + (id, session_id, run_id, backend, external_id, status, metadata_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) + ON CONFLICT(backend, external_id) DO UPDATE SET + session_id = excluded.session_id, run_id = excluded.run_id, + status = excluded.status, metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at", + params![ + input.id, + input.session_id, + input.run_id, + input.backend, + input.external_id, + input.status, + json_string(&input.metadata)?, + now + ], + )?; + self.get_external_session_by_backend_locked(&connection, &input.backend, &input.external_id) + } + + /// 更新一个已经登记的外部会话生命周期。 + /// + /// dispatch 前先以 `running` 写入的记录必须在取消、异常或成功返回时 + /// 收束到同一个本地主键;不能只再插入一条新 external_id 记录,否则 + /// 重启后会留下一个看起来仍在运行的旧会话。若后端后来才给出新的 + /// external_id,本方法会在同一行更新它,并继续受数据库唯一约束保护。 + pub fn update_external_session( + &self, + id: &str, + external_id: &str, + status: &str, + metadata: Value, + ) -> Result { + require_id("external_session", id)?; + require_non_empty("external_id", external_id)?; + require_non_empty("external session status", status)?; + validate_json(&metadata)?; + let connection = self.lock()?; + let changed = connection.execute( + "UPDATE external_sessions + SET external_id = ?1, status = ?2, metadata_json = ?3, updated_at = ?4 + WHERE id = ?5", + params![external_id, status, json_string(&metadata)?, now_ms(), id], + )?; + if changed == 0 { + return Err(StorageError::NotFound { + entity: "external_session", + id: id.to_owned(), + }); + } + connection + .query_row( + "SELECT id, session_id, run_id, backend, external_id, status, + metadata_json, created_at, updated_at + FROM external_sessions WHERE id = ?1", + [id], + row_external_session, + ) + .map_err(StorageError::from) + } + + pub fn get_external_session(&self, id: &str) -> Result> { + let connection = self.lock()?; + connection + .query_row( + "SELECT id, session_id, run_id, backend, external_id, status, + metadata_json, created_at, updated_at + FROM external_sessions WHERE id = ?1", + [id], + row_external_session, + ) + .optional() + .map_err(StorageError::from) + } + + /// 按生命周期状态发现外部会话对账候选。 + /// + /// 这是只读候选查询:不会访问外部系统,也不会修改 run/checkpoint。状态 + /// 值本身始终通过 SQLite 参数绑定;SQL 中动态生成的部分只有固定数量的 + /// `?` 占位符,避免把调用方输入拼进查询文本。 + pub fn list_external_sessions( + &self, + statuses: &[&str], + run_id: Option<&str>, + limit: usize, + ) -> Result> { + if statuses.is_empty() { + return Err(StorageError::InvalidInput( + "external session statuses 不能为空".to_owned(), + )); + } + if statuses.len() > MAX_EXTERNAL_SESSION_STATUS_FILTERS { + return Err(StorageError::InvalidInput(format!( + "external session statuses 不能超过 {} 个", + MAX_EXTERNAL_SESSION_STATUS_FILTERS + ))); + } + for status in statuses { + require_non_empty("external session status", status)?; + } + if let Some(run_id) = run_id { + require_non_empty("external run_id", run_id)?; + } + if limit > MAX_EXTERNAL_SESSION_SCAN_LIMIT { + return Err(StorageError::InvalidInput(format!( + "external session 扫描 limit 不能超过 {}", + MAX_EXTERNAL_SESSION_SCAN_LIMIT + ))); + } + if limit == 0 { + return Ok(Vec::new()); + } + + // 只根据 status 数量生成占位符;所有实际值都在 bind_values 中绑定。 + let placeholders = (0..statuses.len()) + .map(|_| "?") + .collect::>() + .join(", "); + let mut sql = format!( + "SELECT id, session_id, run_id, backend, external_id, status, + metadata_json, created_at, updated_at + FROM external_sessions WHERE status IN ({placeholders})" + ); + let mut bind_values = statuses + .iter() + .map(|status| rusqlite::types::Value::Text((*status).to_owned())) + .collect::>(); + if let Some(run_id) = run_id { + sql.push_str(" AND run_id = ?"); + bind_values.push(rusqlite::types::Value::Text(run_id.to_owned())); + } + sql.push_str(" ORDER BY created_at ASC, id ASC LIMIT ?"); + bind_values.push(rusqlite::types::Value::Integer(limit as i64)); + + let connection = self.lock()?; + let mut statement = connection.prepare(&sql)?; + let rows = statement.query_map( + rusqlite::params_from_iter(bind_values), + row_external_session, + )?; + rows.collect::>>() + .map_err(StorageError::from) + } + + /// 以稳定的 JSONL 顺序导出一个 run,便于调试、迁移和离线重放。 + /// 每行都有 `kind` 字段,首行是 run,随后是事件、快照和附属记录。 + pub fn export_jsonl(&self, run_id: &str, mut writer: W) -> Result { + let connection = self.lock()?; + let run: RunRecord = connection + .query_row( + "SELECT id, session_id, status, revision, input_json, output_json, + created_at, updated_at, cancel_requested FROM runs WHERE id = ?1", + [run_id], + row_run, + ) + .map_err(|error| not_found_from_query(error, "run", run_id))?; + let mut count = 0; + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "run", "record": run }), + )?; + count += 1; + + let mut events = connection.prepare( + "SELECT id, session_id, run_id, revision, event_type, payload_json, created_at + FROM events WHERE run_id = ?1 ORDER BY revision ASC", + )?; + for event in events.query_map([run_id], row_event)? { + let event = event?; + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "event", "record": event }), + )?; + count += 1; + } + + let mut snapshots = connection.prepare( + "SELECT id, session_id, run_id, revision, state_json, created_at + FROM snapshots WHERE run_id = ?1 ORDER BY revision ASC", + )?; + for snapshot in snapshots.query_map([run_id], row_snapshot)? { + let snapshot = snapshot?; + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "snapshot", "record": snapshot }), + )?; + count += 1; + } + + let mut approvals = connection.prepare( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE run_id = ?1 ORDER BY created_at ASC, id ASC", + )?; + for approval in approvals.query_map([run_id], row_approval)? { + let approval = approval?; + write_jsonl( + &mut writer, + &serde_json::json!({ + "kind": "approval", + "record": approval_export_value(&approval)? + }), + )?; + count += 1; + } + + let mut tool_calls = connection.prepare( + "SELECT id, session_id, run_id, tool_name, arguments_json, result_json, + status, created_at, updated_at + FROM tool_calls WHERE run_id = ?1 ORDER BY created_at ASC, id ASC", + )?; + for tool_call in tool_calls.query_map([run_id], row_tool_call)? { + let tool_call = tool_call?; + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "tool_call", "record": tool_call }), + )?; + count += 1; + } + + let mut external_sessions = connection.prepare( + "SELECT id, session_id, run_id, backend, external_id, status, + metadata_json, created_at, updated_at + FROM external_sessions WHERE run_id = ?1 ORDER BY created_at ASC, id ASC", + )?; + for external_session in external_sessions.query_map([run_id], row_external_session)? { + let external_session = external_session?; + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "external_session", "record": external_session }), + )?; + count += 1; + } + Ok(count) + } + + /// 读取 runtime-level 快照。该接口与 run-level 查询分开,便于直接实现 + /// core 的 `RuntimeStore`,同时保留 SQLite 中可检索的业务表。 + #[cfg(feature = "core-adapter")] + pub fn load_runtime_snapshot(&self, runtime_id: &str) -> Result> { + let mut connection = self.lock()?; + // Snapshot 与 event log 必须在同一个 read transaction 中读取;否则 + // 两次独立查询之间的并发提交可能让校验看到跨 revision 的混合视图。 + let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?; + let row: Option<(i64, String)> = transaction + .query_row( + "SELECT revision, snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + let snapshot = if let Some((stored_revision, encoded)) = row { + let snapshot = decode_runtime_snapshot(&encoded, "runtime snapshot")?; + validate_stored_runtime_snapshot( + &snapshot, + runtime_id, + stored_revision, + "runtime snapshot", + )?; + let events = query_runtime_events(&transaction, runtime_id)?; + validate_runtime_history(runtime_id, &snapshot, &events, "runtime snapshot")?; + Some(snapshot) + } else { + None + }; + transaction.commit()?; + Ok(snapshot) + } + + /// 返回 runtime-level 事件,顺序固定按 revision 升序。 + #[cfg(feature = "core-adapter")] + pub fn list_runtime_events(&self, runtime_id: &str) -> Result> { + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?; + let events = query_runtime_events(&transaction, runtime_id)?; + let row: Option<(i64, String)> = transaction + .query_row( + "SELECT revision, snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((stored_revision, encoded)) = row { + let snapshot = decode_runtime_snapshot(&encoded, "runtime snapshot")?; + validate_stored_runtime_snapshot( + &snapshot, + runtime_id, + stored_revision, + "runtime snapshot", + )?; + validate_runtime_history(runtime_id, &snapshot, &events, "runtime event log")?; + } else if !events.is_empty() { + return Err(StorageError::InvalidInput( + "runtime event log 存在没有 snapshot 的孤儿事件".to_owned(), + )); + } + transaction.commit()?; + Ok(events) + } + + /// 导出 runtime-level 快照和事件;run-level 附属记录使用 [`Self::export_jsonl`]。 + #[cfg(feature = "core-adapter")] + pub fn export_runtime_jsonl(&self, runtime_id: &str, mut writer: W) -> Result { + let snapshot = + self.load_runtime_snapshot(runtime_id)? + .ok_or_else(|| StorageError::NotFound { + entity: "runtime", + id: runtime_id.to_owned(), + })?; + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "runtime_snapshot", "record": snapshot }), + )?; + let mut count = 1; + for event in self.list_runtime_events(runtime_id)? { + write_jsonl( + &mut writer, + &serde_json::json!({ "kind": "runtime_event", "record": event }), + )?; + count += 1; + } + Ok(count) + } + + fn lock(&self) -> Result> { + self.connection + .lock() + .map_err(|_| StorageError::LockPoisoned) + } + + /// 在当前未过期 lease 下保存(或覆盖)一个 run checkpoint。 + pub fn save_run_checkpoint( + &self, + input: NewRunCheckpoint, + worker_id: &str, + lease_token: &str, + ) -> Result { + validate_json(&input.messages)?; + require_id("run", &input.run_id)?; + require_non_empty("phase", &input.phase)?; + if !matches!( + input.phase.as_str(), + "compacting" | "provider_in_flight" | "awaiting_approval" | "tool_in_flight" | "safe" + ) { + return Err(StorageError::InvalidInput(format!( + "未知 checkpoint phase: {}", + input.phase + ))); + } + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + if input.step < 0 || input.next_step < 0 || input.attempt < 0 { + return Err(StorageError::InvalidInput( + "checkpoint step、next_step 和 attempt 不能为负数".to_owned(), + )); + } + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = now_ms(); + let changed = transaction.execute( + "INSERT INTO run_checkpoints + (run_id, phase, step, next_step, messages_json, + provider_request_id, tool_call_id, attempt, updated_at) + SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9 + WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?1 + AND status IN ('running', 'cancel_requested') + AND worker_id = ?10 AND lease_token = ?11 AND lease_expires_at > ?9) + ON CONFLICT(run_id) DO UPDATE SET + phase = excluded.phase, step = excluded.step, + next_step = excluded.next_step, messages_json = excluded.messages_json, + provider_request_id = excluded.provider_request_id, + tool_call_id = excluded.tool_call_id, attempt = excluded.attempt, + updated_at = excluded.updated_at + WHERE EXISTS (SELECT 1 FROM runs WHERE id = excluded.run_id + AND status IN ('running', 'cancel_requested') + AND worker_id = ?10 AND lease_token = ?11 AND lease_expires_at > ?9)", + params![ + input.run_id, + input.phase, + input.step, + input.next_step, + json_string(&input.messages)?, + input.provider_request_id, + input.tool_call_id, + input.attempt, + now, + worker_id, + lease_token + ], + )?; + if changed != 1 { + return Err(lease_lost_or_conflict( + &transaction, + &input.run_id, + worker_id, + lease_token, + now, + )); + } + let checkpoint = query_run_checkpoint(&transaction, &input.run_id)?; + transaction.commit()?; + Ok(checkpoint) + } + + pub fn save_checkpoint_with_lease( + &self, + input: NewCheckpoint, + worker_id: &str, + lease_token: &str, + ) -> Result { + self.save_run_checkpoint(input, worker_id, lease_token) + } + + /// 在一个 fenced IMMEDIATE 事务中提交 runtime 的 compaction 事件和 + /// 新 checkpoint。压缩会同时重写消息历史与状态;如果这两部分分开 + /// 提交,进程恰好在中间退出就会留下无法恢复的前缀不一致。 + #[cfg(feature = "core-adapter")] + #[allow(clippy::too_many_arguments)] + pub fn save_checkpoint_with_runtime_and_lease( + &self, + input: NewCheckpoint, + worker_id: &str, + lease_token: &str, + runtime_id: &str, + expected_runtime_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> Result { + validate_checkpoint_input(&input, worker_id, lease_token)?; + require_id("runtime", runtime_id)?; + if snapshot.runtime_id() != runtime_id || events.is_empty() { + return Err(StorageError::InvalidInput( + "checkpoint/runtime snapshot identity 无效".to_owned(), + )); + } + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = now_ms(); + let lease_valid: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1 + AND status IN ('running', 'cancel_requested') + AND worker_id = ?2 AND lease_token = ?3 AND lease_expires_at > ?4)", + params![input.run_id, worker_id, lease_token, now], + |row| row.get(0), + )?; + if !lease_valid { + return Err(lease_lost_or_conflict( + &transaction, + &input.run_id, + worker_id, + lease_token, + now, + )); + } + + // Both the Core event replay and the checkpoint upsert stay inside the + // same transaction. Any reducer/CAS/lease error rolls back both. + commit_runtime_in_transaction( + &transaction, + runtime_id, + expected_runtime_revision, + snapshot, + events, + )?; + let changed = transaction.execute( + "INSERT INTO run_checkpoints + (run_id, phase, step, next_step, messages_json, + provider_request_id, tool_call_id, attempt, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(run_id) DO UPDATE SET + phase = excluded.phase, step = excluded.step, + next_step = excluded.next_step, messages_json = excluded.messages_json, + provider_request_id = excluded.provider_request_id, + tool_call_id = excluded.tool_call_id, attempt = excluded.attempt, + updated_at = excluded.updated_at + WHERE EXISTS (SELECT 1 FROM runs WHERE id = excluded.run_id + AND status IN ('running', 'cancel_requested') + AND worker_id = ?10 AND lease_token = ?11 AND lease_expires_at > ?9)", + params![ + input.run_id, + input.phase, + input.step, + input.next_step, + json_string(&input.messages)?, + input.provider_request_id, + input.tool_call_id, + input.attempt, + now, + worker_id, + lease_token, + ], + )?; + if changed != 1 { + return Err(lease_lost_or_conflict( + &transaction, + &input.run_id, + worker_id, + lease_token, + now, + )); + } + let checkpoint = query_run_checkpoint(&transaction, &input.run_id)?; + transaction.commit()?; + Ok(checkpoint) + } + + /// 读取当前 lease 所属 worker 的 checkpoint。 + pub fn load_run_checkpoint( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result> { + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + let connection = self.lock()?; + let now = now_ms(); + let valid: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1 AND status IN ('running', 'cancel_requested') + AND worker_id = ?2 AND lease_token = ?3 AND lease_expires_at > ?4)", + params![run_id, worker_id, lease_token, now], |row| row.get(0))?; + if !valid { + return Err(lease_lost_or_conflict( + &connection, + run_id, + worker_id, + lease_token, + now, + )); + } + match query_run_checkpoint(&connection, run_id) { + Ok(value) => Ok(Some(value)), + Err(StorageError::NotFound { .. }) => Ok(None), + Err(error) => Err(error), + } + } + + pub fn get_checkpoint( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result> { + self.load_run_checkpoint(run_id, worker_id, lease_token) + } + + /// 恢复器读取最后一个 checkpoint。该视图不返回 worker/token,可在 lease + /// 过期并清理后继续用于判断是 safe cursor 还是未知外部副作用。 + pub fn read_checkpoint(&self, run_id: &str) -> Result> { + require_id("run", run_id)?; + let connection = self.lock()?; + let exists: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1)", + [run_id], + |row| row.get(0), + )?; + if !exists { + return Err(StorageError::NotFound { + entity: "run", + id: run_id.to_owned(), + }); + } + match query_run_checkpoint(&connection, run_id) { + Ok(value) => Ok(Some(value)), + Err(StorageError::NotFound { .. }) => Ok(None), + Err(error) => Err(error), + } + } + + /// 记录调用方已经在外部确认的 Provider/工具结果,并把对应 checkpoint + /// 原子切换为 `safe`。 + /// + /// 此方法只校验和持久化调用方提供的完整消息历史,绝不启动 Provider、工具 + /// 或任何重放动作。`external_id` 在 `provider_in_flight` 阶段对应 + /// `provider_request_id`,在 `tool_in_flight` 阶段对应 `tool_call_id`。 + /// 成功后工具调用 ID 会被清空(表示该工具已对账),Provider request ID + /// 保留用于审计;随后仍须显式调用 `resume_safe_run`。 + pub fn record_reconciliation_result( + &self, + run_id: &str, + phase: &str, + external_id: &str, + step: i64, + attempt: i64, + messages: Value, + ) -> Result { + require_id("run", run_id)?; + require_non_empty("checkpoint phase", phase)?; + require_non_empty("external call id", external_id)?; + if !matches!(phase, "provider_in_flight" | "tool_in_flight") { + return Err(StorageError::InvalidInput(format!( + "只有 provider_in_flight 或 tool_in_flight 可以对账: {phase}" + ))); + } + if step < 0 || attempt < 0 { + return Err(StorageError::InvalidInput( + "对账 step 和 attempt 不能为负数".to_owned(), + )); + } + let next_step = step.checked_add(1).ok_or_else(|| { + StorageError::InvalidInput("对账后的 next_step 超出 SQLite INTEGER 范围".to_owned()) + })?; + validate_reconciliation_messages(&messages)?; + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let run = query_run(&transaction, run_id)?; + if run.status != "reconciling" { + return Err(StorageError::InvalidInput(format!( + "run {} 当前状态 {},不是待对账状态", + run_id, run.status + ))); + } + if run.cancel_requested { + return Err(StorageError::InvalidInput(format!( + "run {} 已请求取消,不能写入 safe checkpoint", + run_id + ))); + } + + let checkpoint = + query_run_checkpoint(&transaction, run_id).map_err(|error| match error { + StorageError::NotFound { .. } => { + StorageError::InvalidInput(format!("run {} 没有可对账 checkpoint", run_id)) + } + other => other, + })?; + if checkpoint.phase != phase { + return Err(StorageError::InvalidInput(format!( + "checkpoint phase 不匹配:expected={phase} actual={}", + checkpoint.phase + ))); + } + if checkpoint.step != step || checkpoint.next_step != step { + return Err(StorageError::InvalidInput(format!( + "checkpoint 游标不匹配:expected step={step}, next_step={step}; actual step={}, next_step={}", + checkpoint.step, checkpoint.next_step + ))); + } + if checkpoint.attempt != attempt { + return Err(StorageError::InvalidInput(format!( + "checkpoint attempt 不匹配:expected={attempt} actual={}", + checkpoint.attempt + ))); + } + match phase { + "provider_in_flight" => { + if checkpoint.provider_request_id.as_deref() != Some(external_id) + || checkpoint.tool_call_id.is_some() + { + return Err(StorageError::InvalidInput( + "Provider request identity 不匹配".to_owned(), + )); + } + } + "tool_in_flight" => { + if checkpoint.tool_call_id.as_deref() != Some(external_id) { + return Err(StorageError::InvalidInput( + "tool call identity 不匹配".to_owned(), + )); + } + } + _ => unreachable!("phase was validated above"), + } + + validate_reconciliation_messages(&checkpoint.messages)?; + let existing_messages = checkpoint.messages.as_array().ok_or_else(|| { + StorageError::InvalidInput("现有 checkpoint 消息必须是 JSON array".to_owned()) + })?; + let submitted_messages = messages + .as_array() + .ok_or_else(|| StorageError::InvalidInput("对账消息必须是 JSON array".to_owned()))?; + if submitted_messages.len() <= existing_messages.len() { + return Err(StorageError::InvalidInput( + "对账消息必须包含至少一条新的完整后缀".to_owned(), + )); + } + if !existing_messages + .iter() + .zip(submitted_messages) + .all(|(existing, submitted)| existing == submitted) + { + return Err(StorageError::InvalidInput( + "对账消息必须保留 checkpoint 的完整前缀".to_owned(), + )); + } + if phase == "tool_in_flight" + && (!messages_contain_tool_call(existing_messages, external_id) + || !messages_contain_tool_result( + &submitted_messages[existing_messages.len()..], + external_id, + )) + { + return Err(StorageError::InvalidInput( + "对账消息缺少对应 tool call 或 tool result".to_owned(), + )); + } + // `safe` 表示整个已观察的工具批次都能继续消费;不能只补当前 + // call,而把同一 Provider 响应里的其它未完成调用悄悄跳过。 + if !messages_have_resolved_tool_calls(submitted_messages) { + return Err(StorageError::InvalidInput( + "对账消息仍包含未完成或顺序非法的 tool call".to_owned(), + )); + } + + let encoded_messages = json_string(&messages)?; + let now = now_ms(); + let changed = transaction.execute( + "UPDATE run_checkpoints + SET phase = 'safe', next_step = ?1, messages_json = ?2, + tool_call_id = NULL, updated_at = ?3 + WHERE run_id = ?4 AND phase = ?5 AND step = ?6 AND next_step = ?6 + AND attempt = ?7 + AND ((phase = 'provider_in_flight' + AND provider_request_id = ?8 AND tool_call_id IS NULL) + OR (phase = 'tool_in_flight' AND tool_call_id = ?8))", + params![ + next_step, + encoded_messages, + now, + run_id, + phase, + step, + attempt, + external_id + ], + )?; + if changed != 1 { + return Err(StorageError::InvalidInput( + "checkpoint 在对账期间发生变化,请重新读取后再提交".to_owned(), + )); + } + let run_changed = transaction.execute( + "UPDATE runs SET updated_at = ?1 + WHERE id = ?2 AND status = 'reconciling' AND cancel_requested = 0", + params![now, run_id], + )?; + if run_changed != 1 { + return Err(StorageError::InvalidInput( + "run 在对账期间已发生状态变化".to_owned(), + )); + } + let result = query_run_checkpoint(&transaction, run_id)?; + transaction.commit()?; + Ok(result) + } + + /// 仅将已经完成副作用对账、且保留安全游标的 run 重新放回队列。 + /// + /// 这是一个显式的 run-level CAS:接受 `reconciling`,或已经成功排队但 + /// 尚未启动 worker 的同一 `queued + safe checkpoint` 重试;两者都不能有 + /// 取消请求。checkpoint 会原样保留,调用方仍须随后通过带 lease 的领取 + /// 流程取得 worker;本方法不启动 Engine。 + pub fn requeue_reconciled_run(&self, run_id: &str) -> Result { + require_id("run", run_id)?; + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs + SET status = 'queued', updated_at = ?1 + WHERE id = ?2 + AND status = 'reconciling' + AND cancel_requested = 0 + AND worker_id IS NULL AND lease_token IS NULL + AND lease_expires_at IS NULL + AND EXISTS ( + SELECT 1 FROM run_checkpoints + WHERE run_id = runs.id AND phase = 'safe' + )", + params![now_ms(), run_id], + )?; + let record = query_run(&transaction, run_id)?; + if changed == 1 { + transaction.commit()?; + return Ok(record); + } + // SQLite 可能把“设置为同一状态”的 UPDATE 报告为 0 行变更;对已 + // 排好队且仍保留 safe 游标的重试,显式按同一条件返回当前记录。 + let queued_safe: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM runs AS r + WHERE r.id = ?1 AND r.status = 'queued' + AND r.cancel_requested = 0 + AND r.worker_id IS NULL AND r.lease_token IS NULL + AND r.lease_expires_at IS NULL + AND EXISTS ( + SELECT 1 FROM run_checkpoints AS c + WHERE c.run_id = r.id AND c.phase = 'safe' + ) + )", + [run_id], + |row| row.get(0), + )?; + if queued_safe { + transaction.commit()?; + return Ok(record); + } + if is_terminal_status(&record.status) { + return Err(StorageError::TerminalRun { + id: run_id.to_owned(), + status: record.status, + }); + } + Err(StorageError::InvalidInput(format!( + "run {} 当前状态或 checkpoint 不允许安全恢复", + run_id + ))) + } + + /// `requeue_reconciled_run` 的语义别名,供恢复器按“安全恢复”命名调用。 + pub fn resume_safe_run(&self, run_id: &str) -> Result { + self.requeue_reconciled_run(run_id) + } + + /// 在当前 lease 下删除 checkpoint。 + pub fn clear_run_checkpoint( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result<()> { + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let now = now_ms(); + let valid: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1 AND status IN ('running', 'cancel_requested') + AND worker_id = ?2 AND lease_token = ?3 AND lease_expires_at > ?4)", + params![run_id, worker_id, lease_token, now], |row| row.get(0))?; + if !valid { + return Err(lease_lost_or_conflict( + &transaction, + run_id, + worker_id, + lease_token, + now, + )); + } + transaction.execute("DELETE FROM run_checkpoints WHERE run_id = ?1", [run_id])?; + transaction.commit()?; + Ok(()) + } + + pub fn clear_checkpoint_with_lease( + &self, + run_id: &str, + worker_id: &str, + lease_token: &str, + ) -> Result<()> { + self.clear_run_checkpoint(run_id, worker_id, lease_token) + } + + fn get_approval_locked(&self, connection: &Connection, id: &str) -> Result { + connection + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE id = ?1", + [id], + row_approval, + ) + .map_err(|error| not_found_from_query(error, "approval", id)) + } + + fn get_tool_call_locked(&self, connection: &Connection, id: &str) -> Result { + connection + .query_row( + "SELECT id, session_id, run_id, tool_name, arguments_json, result_json, + status, created_at, updated_at + FROM tool_calls WHERE id = ?1", + [id], + row_tool_call, + ) + .map_err(|error| not_found_from_query(error, "tool_call", id)) + } + + fn get_external_session_by_backend_locked( + &self, + connection: &Connection, + backend: &str, + external_id: &str, + ) -> Result { + connection + .query_row( + "SELECT id, session_id, run_id, backend, external_id, status, + metadata_json, created_at, updated_at + FROM external_sessions WHERE backend = ?1 AND external_id = ?2", + params![backend, external_id], + row_external_session, + ) + .map_err(|error| not_found_from_query(error, "external_session", external_id)) + } +} + +#[cfg(feature = "core-adapter")] +fn approval_from_durable(input: &DurableApprovalInput) -> NewApproval { + NewApproval { + id: input.id.clone(), + session_id: input.session_id.clone(), + run_id: input.run_id.clone(), + tool_call_id: input.tool_call_id.clone(), + status: input.status.clone(), + request: input.request.clone(), + arguments_hash: input.arguments_hash.clone(), + approval_token: input.approval_token.clone(), + expires_at_ms: input.expires_at_ms, + } +} + +#[cfg(feature = "core-adapter")] +fn validate_approval_input(input: &NewApproval) -> Result<()> { + validate_json(&input.request)?; + require_id("approval", &input.id)?; + require_id("session", &input.session_id)?; + require_id("run", &input.run_id)?; + require_non_empty("approval arguments_hash", &input.arguments_hash)?; + require_non_empty("approval token", &input.approval_token)?; + if input.expires_at_ms <= 0 { + return Err(StorageError::InvalidInput( + "approval expires_at_ms 必须大于 0".to_owned(), + )); + } + if input.status != "pending" { + return Err(StorageError::InvalidInput( + "新建 approval 的 status 必须为 pending".to_owned(), + )); + } + validate_approval_request_binding(input) +} + +#[cfg(feature = "core-adapter")] +fn ensure_checkpoint_matches( + current: &RunCheckpointRecord, + expected: &NewRunCheckpoint, +) -> Result<()> { + let matches = current.run_id == expected.run_id + && current.phase == expected.phase + && current.step == expected.step + && current.next_step == expected.next_step + && current.messages == expected.messages + && current.provider_request_id == expected.provider_request_id + && current.tool_call_id == expected.tool_call_id + && current.attempt == expected.attempt; + if matches { + Ok(()) + } else { + Err(StorageError::InvalidInput(format!( + "awaiting approval checkpoint 与提交值不一致: {}", + expected.run_id + ))) + } +} + +#[cfg(feature = "core-adapter")] +fn create_approval_in_transaction( + transaction: &Transaction<'_>, + input: &NewApproval, + now: i64, +) -> Result { + match transaction + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE id = ?1", + [&input.id], + row_approval, + ) + .optional() + .map_err(StorageError::from)? + { + Some(record) => { + if record.session_id != input.session_id + || record.run_id != input.run_id + || record.tool_call_id != input.tool_call_id + || record.request != input.request + || record.arguments_hash != input.arguments_hash + || record.approval_token != input.approval_token + || record.expires_at_ms != input.expires_at_ms + { + return Err(StorageError::ApprovalConflict { + id: input.id.clone(), + expected: "相同 request identity".to_owned(), + actual: "request identity 不一致".to_owned(), + }); + } + Ok(record) + } + None => { + transaction.execute( + "INSERT INTO approvals + (id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?9)", + params![ + input.id, + input.session_id, + input.run_id, + input.tool_call_id, + json_string(&input.request)?, + input.arguments_hash, + input.approval_token, + input.expires_at_ms, + now + ], + )?; + let record = transaction + .query_row( + "SELECT id, session_id, run_id, tool_call_id, status, request_json, + arguments_hash, approval_token, expires_at_ms, + decision_json, created_at, updated_at + FROM approvals WHERE id = ?1", + [&input.id], + row_approval, + ) + .map_err(|error| not_found_from_query(error, "approval", &input.id))?; + if record.session_id != input.session_id + || record.run_id != input.run_id + || record.tool_call_id != input.tool_call_id + || record.request != input.request + || record.arguments_hash != input.arguments_hash + || record.approval_token != input.approval_token + || record.expires_at_ms != input.expires_at_ms + { + return Err(StorageError::ApprovalConflict { + id: input.id.clone(), + expected: "相同 request identity".to_owned(), + actual: "request identity 不一致".to_owned(), + }); + } + Ok(record) + } + } +} + +#[cfg(feature = "core-adapter")] +fn validate_durable_tool_call_input(input: &DurableToolCallInput) -> Result<()> { + require_id("tool_call", &input.id)?; + require_id("session", &input.session_id)?; + require_id("run", &input.run_id)?; + require_non_empty("tool name", &input.tool_name)?; + require_non_empty("tool call status", &input.status)?; + validate_json(&input.arguments)?; + if !input.arguments.is_object() { + return Err(StorageError::InvalidInput( + "tool call arguments 必须是 JSON object".to_owned(), + )); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn validate_runtime_commit_identity(runtime_id: &str, snapshot: &RuntimeSnapshot) -> Result<()> { + require_id("runtime", runtime_id)?; + if snapshot.runtime_id() != runtime_id { + return Err(StorageError::InvalidInput( + "runtime snapshot identity 无效".to_owned(), + )); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn validate_tool_call_run_and_lease( + transaction: &Transaction<'_>, + call: &DurableToolCallInput, + runtime_id: &str, + lease: Option<&DurableLeaseIdentity>, + allow_terminal_duplicate: bool, +) -> Result<()> { + let run = query_run(transaction, &call.run_id)?; + if run.session_id != call.session_id { + return Err(StorageError::InvalidInput(format!( + "tool_call {} 的 session identity 与 run 不一致", + call.id + ))); + } + let session_exists: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?1)", + [&call.session_id], + |row| row.get(0), + )?; + if !session_exists { + return Err(StorageError::NotFound { + entity: "session", + id: call.session_id.clone(), + }); + } + if is_terminal_status(&run.status) && !allow_terminal_duplicate { + return Err(StorageError::TerminalRun { + id: call.run_id.clone(), + status: run.status, + }); + } + + let now = now_ms(); + match lease { + Some(identity) => { + require_non_empty("worker", &identity.worker_id)?; + require_non_empty("lease token", &identity.lease_token)?; + let valid: bool = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM runs + WHERE id = ?1 AND status IN ('running', 'cancel_requested') + AND worker_id = ?2 AND lease_token = ?3 + AND lease_expires_at > ?4 + )", + params![call.run_id, identity.worker_id, identity.lease_token, now], + |row| row.get(0), + )?; + if !valid { + return Err(lease_lost_or_conflict( + transaction, + &call.run_id, + &identity.worker_id, + &identity.lease_token, + now, + )); + } + } + None => { + // The no-lease form is reserved for an unclaimed/control-plane run; + // never let it bypass an active or expired fencing row left by a + // worker. Recovery must explicitly clear that row first. + if query_lease(transaction, &call.run_id)?.is_some() { + return Err(StorageError::LeaseConflict { + id: call.run_id.clone(), + reason: "run 已由带 lease 的 worker 持有,请提供 lease identity".to_owned(), + }); + } + } + } + + // There is no foreign key from runtime events to runs. Require the first + // event mapping to agree with the command so a valid snapshot from another + // runtime cannot be attached to this run's tool row. + let linked_runtime: Option = transaction + .query_row( + "SELECT runtime_id FROM runtime_events + WHERE run_id = ?1 ORDER BY revision ASC LIMIT 1", + [&call.run_id], + |row| row.get(0), + ) + .optional()?; + if linked_runtime.as_deref() != Some(runtime_id) { + return Err(StorageError::InvalidInput(format!( + "run {} 的 runtime identity 不匹配", + call.run_id + ))); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn query_tool_call_optional(connection: &Connection, id: &str) -> Result> { + connection + .query_row( + "SELECT id, session_id, run_id, tool_name, arguments_json, result_json, + status, created_at, updated_at + FROM tool_calls WHERE id = ?1", + [id], + row_tool_call, + ) + .optional() + .map_err(StorageError::from) +} + +#[cfg(feature = "core-adapter")] +fn query_tool_call_required(connection: &Connection, id: &str) -> Result { + query_tool_call_optional(connection, id)?.ok_or_else(|| StorageError::NotFound { + entity: "tool_call", + id: id.to_owned(), + }) +} + +#[cfg(feature = "core-adapter")] +fn ensure_tool_call_identity( + existing: &ToolCallRecord, + input: &DurableToolCallInput, +) -> Result<()> { + if existing.session_id == input.session_id + && existing.run_id == input.run_id + && existing.tool_name == input.tool_name + && existing.arguments == input.arguments + { + Ok(()) + } else { + Err(StorageError::InvalidInput(format!( + "tool_call identity 已存在但内容不一致: {}", + input.id + ))) + } +} + +#[cfg(feature = "core-adapter")] +fn snapshot_contains_tool_call( + snapshot: &RuntimeSnapshot, + input: &DurableToolCallInput, +) -> Result { + let run = snapshot.run(&input.run_id).ok_or_else(|| { + StorageError::InvalidInput(format!( + "runtime snapshot 缺少 tool_call 对应 run: {}", + input.run_id + )) + })?; + Ok(run.tool_calls().iter().any(|call| { + call.id() == input.id + && call.name() == input.tool_name + && call.arguments() == &input.arguments + })) +} + +#[cfg(feature = "core-adapter")] +fn snapshot_matches_tool_result( + snapshot: &RuntimeSnapshot, + input: &DurableToolCallInput, + status: &str, + result: &Value, +) -> Result { + let run = snapshot.run(&input.run_id).ok_or_else(|| { + StorageError::InvalidInput(format!( + "runtime snapshot 缺少 tool result 对应 run: {}", + input.run_id + )) + })?; + Ok(run.tool_results().iter().any(|item| { + let status_matches = match status { + "completed" => !item.is_error(), + "error" | "failed" | "cancelled" | "canceled" => item.is_error(), + _ => true, + }; + item.call_id() == input.id && item.output() == result && status_matches + })) +} + +#[cfg(feature = "core-adapter")] +fn events_contain_tool_call_requested( + events: &[RuntimeEvent], + runtime_id: &str, + run_id: &str, + input: &DurableToolCallInput, +) -> Result { + for event in events { + if event.kind() != RuntimeEventKind::ToolCallRequested + || event.runtime_id() != runtime_id + || event.run_id() != Some(run_id) + { + continue; + } + let call: ToolCall = serde_json::from_value(event.detail().clone()).map_err(|error| { + StorageError::InvalidInput(format!("ToolCallRequested detail 无法解码: {error}")) + })?; + return Ok(call.id() == input.id + && call.name() == input.tool_name + && call.arguments() == &input.arguments); + } + Ok(false) +} + +#[cfg(feature = "core-adapter")] +fn verify_duplicate_runtime_commit( + transaction: &Transaction<'_>, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], +) -> Result<()> { + if events.is_empty() { + return Err(StorageError::InvalidInput( + "重复工具调用提交必须携带原始 runtime event 批次".to_owned(), + )); + } + if snapshot.runtime_id() != runtime_id { + return Err(StorageError::InvalidInput( + "runtime snapshot identity 无效".to_owned(), + )); + } + let expected = expected_revision.unwrap_or(0); + let current_revision: i64 = transaction + .query_row( + "SELECT revision FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| StorageError::NotFound { + entity: "runtime", + id: runtime_id.to_owned(), + })?; + let current_revision_u64 = u64::try_from(current_revision) + .map_err(|_| StorageError::InvalidInput("runtime revision 不是非负整数".to_owned()))?; + let encoded: String = transaction.query_row( + "SELECT snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| row.get(0), + )?; + let current = decode_runtime_snapshot(&encoded, "runtime snapshot")?; + let stored_events = query_runtime_events(transaction, runtime_id)?; + validate_runtime_history(runtime_id, ¤t, &stored_events, "runtime snapshot")?; + let start = usize::try_from(expected).map_err(|_| { + StorageError::InvalidInput("runtime expected revision 超出 usize 范围".to_owned()) + })?; + let end = start.checked_add(events.len()).ok_or_else(|| { + StorageError::InvalidInput("runtime event 数量超出 usize 范围".to_owned()) + })?; + if end > stored_events.len() || stored_events.get(start..end) != Some(events) { + return Err(StorageError::InvalidInput( + "重复工具调用 runtime event 批次与数据库不一致".to_owned(), + )); + } + let mut replayed = RuntimeSnapshot::try_new(runtime_id) + .map_err(|error| StorageError::InvalidInput(error.to_string()))?; + for event in stored_events.iter().take(end) { + replayed = reduce(&replayed, event).map_err(|error| { + StorageError::InvalidInput(format!("重复 runtime event 重放失败: {error}")) + })?; + } + if replayed != *snapshot || current_revision_u64 < snapshot.revision() { + return Err(StorageError::InvalidInput( + "重复工具调用 runtime snapshot 与已提交 event 前缀不一致".to_owned(), + )); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +impl RuntimeStore for SqliteStore { + fn load(&self, runtime_id: &str) -> std::result::Result, StoreError> { + self.load_runtime_snapshot(runtime_id) + .map_err(runtime_load_error) + } + + fn commit( + &mut self, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + ) -> std::result::Result<(), StoreError> { + let mut connection = self + .lock() + .map_err(|error| store_error(StoreErrorKindAdapter::Unavailable, error.to_string()))?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| store_error(StoreErrorKindAdapter::Unavailable, error.to_string()))?; + commit_runtime_in_transaction( + &transaction, + runtime_id, + expected_revision, + snapshot, + events, + ) + .map_err(runtime_commit_error)?; + transaction + .commit() + .map_err(|error| store_error(StoreErrorKindAdapter::Unavailable, error.to_string())) + } +} + +#[cfg(feature = "core-adapter")] +/// 在调用方已经开启的 IMMEDIATE 事务中提交 runtime snapshot/event。 +/// +/// 该 helper 不自行提交事务,因而可与 run、lease、session 等表的 CAS 更新 +/// 放在同一个原子边界内。所有校验都在写入 runtime_states/runtime_events 前 +/// 完成;任一错误由外层事务回滚。 +fn commit_runtime_in_transaction( + transaction: &Transaction<'_>, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], +) -> Result<()> { + if snapshot.runtime_id() != runtime_id || events.is_empty() { + return Err(StorageError::InvalidInput( + "runtime snapshot/event identity 无效".to_owned(), + )); + } + snapshot + .validate() + .map_err(|error| StorageError::InvalidInput(format!("runtime snapshot 无效: {error}")))?; + let previous = expected_revision.unwrap_or(0); + let final_revision = snapshot.revision(); + if final_revision <= previous || events.len() as u64 != final_revision.saturating_sub(previous) + { + return Err(StorageError::InvalidInput( + "runtime snapshot revision 或事件数量无效".to_owned(), + )); + } + let expected_i64 = expected_revision + .map(|value| { + i64::try_from(value).map_err(|_| { + StorageError::InvalidInput( + "runtime expected revision 超出 SQLite INTEGER 范围".to_owned(), + ) + }) + }) + .transpose()?; + let final_revision_i64 = i64::try_from(final_revision).map_err(|_| { + StorageError::InvalidInput("runtime snapshot revision 超出 SQLite INTEGER 范围".to_owned()) + })?; + for (offset, event) in events.iter().enumerate() { + let expected = previous.checked_add(offset as u64 + 1).ok_or_else(|| { + StorageError::InvalidInput("runtime event revision 超出 u64 范围".to_owned()) + })?; + if event.runtime_id() != runtime_id + || event.schema_version() != RUNTIME_EVENT_SCHEMA_VERSION + || event.revision() != expected + || !event.detail().is_object() + { + return Err(StorageError::InvalidInput( + "runtime event schema/revision 或 identity 无效".to_owned(), + )); + } + } + + let snapshot_json = serde_json::to_value(snapshot)?; + validate_json(&snapshot_json)?; + let actual: Option = transaction + .query_row( + "SELECT revision FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| row.get(0), + ) + .optional()?; + if actual != expected_i64 { + return Err(StorageError::RevisionConflict { + run_id: runtime_id.to_owned(), + expected: expected_i64.unwrap_or(0), + actual: actual.unwrap_or(0), + }); + } + + // RuntimeStore 是 public adapter boundary;不能只检查 revision/JSON 形状, + // 必须从事务内的当前快照重放事件,确保提交值确实由这批事件产生。 + let mut reduced = if actual.is_some() { + let (stored_revision, encoded): (i64, String) = transaction.query_row( + "SELECT revision, snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let current = decode_runtime_snapshot(&encoded, "当前 runtime snapshot")?; + validate_stored_runtime_snapshot( + ¤t, + runtime_id, + stored_revision, + "当前 runtime snapshot", + )?; + validate_runtime_history( + runtime_id, + ¤t, + &query_runtime_events(transaction, runtime_id)?, + "当前 runtime snapshot", + )?; + current + } else { + RuntimeSnapshot::try_new(runtime_id) + .map_err(|error| StorageError::InvalidInput(format!("runtime 初始快照无效: {error}")))? + }; + for event in events { + reduced = reduce(&reduced, event).map_err(|error| { + StorageError::InvalidInput(format!("runtime event 无法通过 reducer: {error}")) + })?; + } + if reduced != *snapshot { + return Err(StorageError::InvalidInput( + "runtime 事件重放结果与 snapshot 不一致".to_owned(), + )); + } + + let now = now_ms(); + let encoded_snapshot = serde_json::to_string(&snapshot_json)?; + let state_changed = if actual.is_some() { + transaction.execute( + "UPDATE runtime_states SET revision = ?1, snapshot_json = ?2, updated_at = ?3 + WHERE runtime_id = ?4 AND revision = ?5", + params![ + final_revision_i64, + encoded_snapshot, + now, + runtime_id, + expected_i64.unwrap_or(0) + ], + )? + } else { + transaction.execute( + "INSERT INTO runtime_states (runtime_id, revision, snapshot_json, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![runtime_id, final_revision_i64, encoded_snapshot, now], + )? + }; + if state_changed != 1 { + return Err(StorageError::RevisionConflict { + run_id: runtime_id.to_owned(), + expected: expected_i64.unwrap_or(0), + actual: actual.unwrap_or(0), + }); + } + for event in events { + let event_revision_i64 = i64::try_from(event.revision()).map_err(|_| { + StorageError::InvalidInput("runtime event revision 超出 SQLite INTEGER 范围".to_owned()) + })?; + let occurred_at_i64 = i64::try_from(event.occurred_at_ms()).map_err(|_| { + StorageError::InvalidInput( + "runtime event timestamp 超出 SQLite INTEGER 范围".to_owned(), + ) + })?; + let detail = serde_json::to_value(event.detail())?; + validate_json(&detail)?; + let event_type = serde_json::to_value(event.kind())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + StorageError::InvalidInput("runtime event kind 不是字符串".to_owned()) + })?; + // event_type 使用不带 JSON 引号的稳定文本,便于直接做 SQL 查询。 + transaction.execute( + "INSERT INTO runtime_events + (id, runtime_id, revision, occurred_at, schema_version, event_type, run_id, detail_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + format!("{runtime_id}:{}", event.revision()), + runtime_id, + event_revision_i64, + occurred_at_i64, + event.schema_version(), + event_type, + event.run_id(), + serde_json::to_string(&detail)? + ], + )?; + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn verify_runtime_snapshot_in_transaction( + transaction: &Transaction<'_>, + runtime_id: &str, + expected_revision: Option, + snapshot: &RuntimeSnapshot, +) -> Result<()> { + if snapshot.runtime_id() != runtime_id { + return Err(StorageError::InvalidInput( + "runtime snapshot identity 无效".to_owned(), + )); + } + let expected_i64 = expected_revision + .map(|value| { + i64::try_from(value).map_err(|_| { + StorageError::InvalidInput( + "runtime expected revision 超出 SQLite INTEGER 范围".to_owned(), + ) + }) + }) + .transpose()?; + let actual: Option = transaction + .query_row( + "SELECT revision FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| row.get(0), + ) + .optional()?; + if actual != expected_i64 { + return Err(StorageError::RevisionConflict { + run_id: runtime_id.to_owned(), + expected: expected_i64.unwrap_or(0), + actual: actual.unwrap_or(0), + }); + } + let (stored_revision, encoded): (i64, String) = transaction.query_row( + "SELECT revision, snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let current = decode_runtime_snapshot(&encoded, "runtime snapshot")?; + validate_stored_runtime_snapshot(¤t, runtime_id, stored_revision, "runtime snapshot")?; + validate_runtime_history( + runtime_id, + ¤t, + &query_runtime_events(transaction, runtime_id)?, + "runtime snapshot", + )?; + if current != *snapshot { + return Err(StorageError::InvalidInput( + "runtime snapshot 与数据库当前状态不一致".to_owned(), + )); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn runtime_commit_error(error: StorageError) -> StoreError { + let kind = match &error { + StorageError::RevisionConflict { .. } => StoreErrorKindAdapter::Conflict, + StorageError::InvalidInput(_) + | StorageError::Json(_) + | StorageError::SensitiveDataRejected { .. } + | StorageError::SnapshotAhead { .. } => StoreErrorKindAdapter::InvalidSnapshot, + _ => StoreErrorKindAdapter::Unavailable, + }; + store_error(kind, error.to_string()) +} + +#[cfg(feature = "core-adapter")] +// 使用一个小枚举隔离 core 的错误构造,便于 storage 在没有 core feature 时仍可复用。 +#[derive(Clone, Copy)] +enum StoreErrorKindAdapter { + Conflict, + Unavailable, + InvalidSnapshot, +} + +#[cfg(feature = "core-adapter")] +fn store_error(kind: StoreErrorKindAdapter, message: impl Into) -> StoreError { + use agent_runtime_core::StoreErrorKind; + let kind = match kind { + StoreErrorKindAdapter::Conflict => StoreErrorKind::Conflict, + StoreErrorKindAdapter::Unavailable => StoreErrorKind::Unavailable, + StoreErrorKindAdapter::InvalidSnapshot => StoreErrorKind::InvalidSnapshot, + }; + StoreError::new(kind, message) +} + +#[cfg(feature = "core-adapter")] +fn runtime_load_error(error: StorageError) -> StoreError { + let kind = match &error { + StorageError::Json(_) + | StorageError::InvalidInput(_) + | StorageError::SensitiveDataRejected { .. } => StoreErrorKindAdapter::InvalidSnapshot, + _ => StoreErrorKindAdapter::Unavailable, + }; + store_error(kind, error.to_string()) +} + +#[cfg(feature = "core-adapter")] +fn decode_runtime_snapshot(encoded: &str, context: &str) -> Result { + let snapshot: RuntimeSnapshot = serde_json::from_str(encoded) + .map_err(|error| StorageError::InvalidInput(format!("{context} 无法解码: {error}")))?; + snapshot + .validate() + .map_err(|error| StorageError::InvalidInput(format!("{context} 校验失败: {error}")))?; + Ok(snapshot) +} + +#[cfg(feature = "core-adapter")] +/// 读取并解码完整 runtime event log。 +/// +/// 行级 JSON/版本损坏属于持久化合同错误,应映射为 `InvalidInput`,而不是 +/// 让上层把它误判成暂时性的 SQLite 不可用。真正的 SQL 查询错误仍保留 +/// `StorageError::Sqlite`,由 RuntimeStore adapter 统一映射为 unavailable。 +fn query_runtime_events(connection: &Connection, runtime_id: &str) -> Result> { + let mut statement = connection.prepare( + "SELECT runtime_id, schema_version, revision, occurred_at, event_type, run_id, detail_json + FROM runtime_events WHERE runtime_id = ?1 ORDER BY revision ASC, id ASC", + )?; + let rows = statement.query_map([runtime_id], |row| { + let row_runtime_id: String = row.get(0)?; + let schema_version: String = row.get(1)?; + if schema_version != RUNTIME_EVENT_SCHEMA_VERSION { + return Err(rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unsupported runtime event schema version: {schema_version}"), + )), + )); + } + let revision: u64 = row.get::<_, i64>(2)?.try_into().map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + "runtime event revision must be non-negative".into(), + ) + })?; + let occurred_at_ms: u64 = row.get::<_, i64>(3)?.try_into().map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Integer, + "runtime event timestamp must be non-negative".into(), + ) + })?; + let event_type: String = row.get(4)?; + let kind: RuntimeEventKind = + serde_json::from_value(Value::String(event_type)).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + let detail = parse_json(row.get(6)?)?; + RuntimeEvent::new_with_schema_version( + schema_version, + row_runtime_id, + revision, + occurred_at_ms, + kind, + row.get(5)?, + detail, + ) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 6, + rusqlite::types::Type::Text, + Box::new(error), + ) + }) + })?; + rows.collect::, _>>() + .map_err(|error| StorageError::InvalidInput(format!("runtime event log 无法解码: {error}"))) +} + +#[cfg(feature = "core-adapter")] +/// 校验 event log 与当前快照之间的完整关系,而不只检查 revision/JSON 形状。 +/// +/// 从空快照重放所有持久化事件可以同时发现事件缺失、重复、乱序、非法 +/// reducer transition,以及被直接 SQL 修改后仍“看起来合法”的 snapshot。 +fn validate_runtime_history( + runtime_id: &str, + snapshot: &RuntimeSnapshot, + events: &[RuntimeEvent], + context: &str, +) -> Result<()> { + let event_count = u64::try_from(events.len()) + .map_err(|_| StorageError::InvalidInput(format!("{context} event 数量超出 u64 范围")))?; + if event_count != snapshot.revision() { + return Err(StorageError::InvalidInput(format!( + "{context} event 数量与 snapshot revision 不一致: events={event_count} revision={}", + snapshot.revision() + ))); + } + + let mut reduced = RuntimeSnapshot::try_new(runtime_id) + .map_err(|error| StorageError::InvalidInput(format!("{context} 初始快照无效: {error}")))?; + for (offset, event) in events.iter().enumerate() { + let expected_revision = u64::try_from(offset + 1).map_err(|_| { + StorageError::InvalidInput(format!("{context} event revision 超出 u64 范围")) + })?; + if event.runtime_id() != runtime_id + || event.schema_version() != RUNTIME_EVENT_SCHEMA_VERSION + || event.revision() != expected_revision + { + return Err(StorageError::InvalidInput(format!( + "{context} event schema/revision 或 identity 不连续" + ))); + } + reduced = reduce(&reduced, event).map_err(|error| { + StorageError::InvalidInput(format!("{context} event 重放失败: {error}")) + })?; + } + if reduced != *snapshot { + return Err(StorageError::InvalidInput(format!( + "{context} event 重放结果与 snapshot 不一致" + ))); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn validate_stored_runtime_snapshot( + snapshot: &RuntimeSnapshot, + runtime_id: &str, + stored_revision: i64, + context: &str, +) -> Result<()> { + if snapshot.runtime_id() != runtime_id { + return Err(StorageError::InvalidInput(format!( + "{context} identity 与 runtime_states key 不一致" + ))); + } + let stored_revision = u64::try_from(stored_revision) + .map_err(|_| StorageError::InvalidInput(format!("{context} revision 不是非负整数")))?; + if snapshot.revision() != stored_revision { + return Err(StorageError::InvalidInput(format!( + "{context} revision 与 runtime_states.revision 不一致" + ))); + } + Ok(()) +} + +fn migrate(connection: &mut Connection) -> Result<()> { + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + )", + )?; + let mut applied: i64 = connection + .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { + row.get::<_, Option>(0) + })? + .unwrap_or(0); + if applied < 1 { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(INITIAL_MIGRATION)?; + transaction.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (1, ?1)", + [now_ms()], + )?; + transaction.commit()?; + applied = 1; + } + if applied < 2 { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(RUN_CONTROL_MIGRATION)?; + transaction.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (2, ?1)", + [now_ms()], + )?; + transaction.commit()?; + applied = 2; + } + if applied < 3 { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(WORKER_LEASE_MIGRATION)?; + transaction.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (3, ?1)", + [now_ms()], + )?; + transaction.commit()?; + applied = 3; + } + if applied < 4 { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(RUN_CHECKPOINT_MIGRATION)?; + transaction.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (4, ?1)", + [now_ms()], + )?; + transaction.commit()?; + applied = 4; + } + if applied < 5 { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(RUNTIME_EVENT_SCHEMA_MIGRATION)?; + transaction.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (5, ?1)", + [now_ms()], + )?; + transaction.commit()?; + applied = 5; + } + if applied < 6 { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(APPROVAL_BINDING_MIGRATION)?; + transaction.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (6, ?1)", + [now_ms()], + )?; + transaction.commit()?; + } + Ok(()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn require_id(entity: &'static str, id: &str) -> Result<()> { + if id.trim().is_empty() { + return Err(StorageError::InvalidInput(format!( + "{entity} id must not be empty" + ))); + } + Ok(()) +} + +fn require_non_empty(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(StorageError::InvalidInput(format!("{field} 不能为空"))); + } + Ok(()) +} + +/// Keep the duplicated storage columns and the serialized approval request in +/// lockstep. The core adapter additionally verifies the cryptographic +/// argument hash; this adapter-level check catches id/run/call/expiry mix-ups +/// even when the optional core feature is disabled. +fn validate_approval_request_binding(input: &NewApproval) -> Result<()> { + let object = input.request.as_object().ok_or_else(|| { + StorageError::InvalidInput("approval request 必须是 JSON object".to_owned()) + })?; + let request_id = object + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| StorageError::InvalidInput("approval request 缺少 requestId".to_owned()))?; + if request_id != input.id { + return Err(StorageError::InvalidInput( + "approval requestId 与记录 id 不一致".to_owned(), + )); + } + let run_id = object + .get("runId") + .and_then(Value::as_str) + .ok_or_else(|| StorageError::InvalidInput("approval request 缺少 runId".to_owned()))?; + if run_id != input.run_id { + return Err(StorageError::InvalidInput( + "approval runId 与记录不一致".to_owned(), + )); + } + let call = object + .get("call") + .and_then(Value::as_object) + .ok_or_else(|| StorageError::InvalidInput("approval request 缺少 call".to_owned()))?; + let call_id = call + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| StorageError::InvalidInput("approval call 缺少 id".to_owned()))?; + if input.tool_call_id.as_deref() != Some(call_id) { + return Err(StorageError::InvalidInput( + "approval call id 与记录不一致".to_owned(), + )); + } + let arguments_hash = object + .get("argumentsHash") + .and_then(Value::as_str) + .ok_or_else(|| { + StorageError::InvalidInput("approval request 缺少 argumentsHash".to_owned()) + })?; + if arguments_hash != input.arguments_hash { + return Err(StorageError::InvalidInput( + "approval argumentsHash 与记录不一致".to_owned(), + )); + } + let approval_token = object + .get("approvalToken") + .and_then(Value::as_str) + .ok_or_else(|| { + StorageError::InvalidInput("approval request 缺少 approvalToken".to_owned()) + })?; + if approval_token != input.approval_token { + return Err(StorageError::InvalidInput( + "approval approvalToken 与记录不一致".to_owned(), + )); + } + let expires_at_ms = object + .get("expiresAtMs") + .and_then(Value::as_i64) + .ok_or_else(|| { + StorageError::InvalidInput("approval request 缺少 expiresAtMs".to_owned()) + })?; + if expires_at_ms != input.expires_at_ms { + return Err(StorageError::InvalidInput( + "approval expiresAtMs 与记录不一致".to_owned(), + )); + } + Ok(()) +} + +fn lease_duration_millis(duration: Duration) -> Result { + let millis = duration.as_millis(); + if millis == 0 { + return Err(StorageError::InvalidInput( + "lease duration 必须大于 0ms".to_owned(), + )); + } + i64::try_from(millis).map_err(|_| { + StorageError::InvalidInput("lease duration 超出 SQLite INTEGER 范围".to_owned()) + }) +} + +fn json_string(value: &Value) -> Result { + validate_json(value)?; + Ok(serde_json::to_string(value)?) +} + +/// 校验对账 API 接收的完整 Core 消息 JSON。 +/// +/// storage crate 默认不依赖 Core,因此这里只检查 Core 稳定的 wire shape; +/// Host/Engine 仍负责用 `Message` 构造消息。这样默认 feature 下也不会把 +/// runtime-core 类型泄漏进 SQLite 适配器。 +fn validate_reconciliation_messages(value: &Value) -> Result<()> { + validate_json(value)?; + let messages = value + .as_array() + .ok_or_else(|| StorageError::InvalidInput("对账消息必须是 JSON array".to_owned()))?; + if messages.is_empty() { + return Err(StorageError::InvalidInput("对账消息不能为空".to_owned())); + } + for (message_index, message) in messages.iter().enumerate() { + let object = message.as_object().ok_or_else(|| { + StorageError::InvalidInput(format!("对账消息[{message_index}] 必须是 JSON object")) + })?; + let role = object.get("role").and_then(Value::as_str).ok_or_else(|| { + StorageError::InvalidInput(format!("对账消息[{message_index}].role 必须是字符串")) + })?; + if !matches!(role, "system" | "developer" | "user" | "assistant" | "tool") { + return Err(StorageError::InvalidInput(format!( + "对账消息[{message_index}].role 不受支持: {role}" + ))); + } + let content = object + .get("content") + .and_then(Value::as_array) + .ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账消息[{message_index}].content 必须是 JSON array" + )) + })?; + if content.is_empty() { + return Err(StorageError::InvalidInput(format!( + "对账消息[{message_index}].content 不能为空" + ))); + } + for (part_index, part) in content.iter().enumerate() { + validate_reconciliation_content_part(part, message_index, part_index)?; + } + } + Ok(()) +} + +fn validate_reconciliation_content_part( + value: &Value, + message_index: usize, + part_index: usize, +) -> Result<()> { + let object = value.as_object().ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账消息[{message_index}].content[{part_index}] 必须是 JSON object" + )) + })?; + let kind = object.get("type").and_then(Value::as_str).ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账消息[{message_index}].content[{part_index}].type 必须是字符串" + )) + })?; + match kind { + "text" => { + let text = object.get("text").and_then(Value::as_str).ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账文本[{message_index}:{part_index}] 缺少 text" + )) + })?; + require_non_empty("对账文本", text)?; + } + "image" => { + if !object.get("source").is_some_and(Value::is_object) { + return Err(StorageError::InvalidInput(format!( + "对账图片[{message_index}:{part_index}] 的 source 必须是 JSON object" + ))); + } + } + "tool-call" => { + let id = object.get("id").and_then(Value::as_str).ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账 tool call[{message_index}:{part_index}] 缺少 id" + )) + })?; + let name = object.get("name").and_then(Value::as_str).ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账 tool call[{message_index}:{part_index}] 缺少 name" + )) + })?; + require_id("tool call", id)?; + require_id("tool", name)?; + if !object.get("arguments").is_some_and(Value::is_object) { + return Err(StorageError::InvalidInput(format!( + "对账 tool call[{message_index}:{part_index}] 的 arguments 必须是 JSON object" + ))); + } + } + "tool-result" => { + let call_id = object + .get("toolCallId") + .and_then(Value::as_str) + .ok_or_else(|| { + StorageError::InvalidInput(format!( + "对账 tool result[{message_index}:{part_index}] 缺少 toolCallId" + )) + })?; + require_id("tool call", call_id)?; + if !object.get("isError").is_some_and(Value::is_boolean) { + return Err(StorageError::InvalidInput(format!( + "对账 tool result[{message_index}:{part_index}] 的 isError 必须是 boolean" + ))); + } + if !object.contains_key("output") { + return Err(StorageError::InvalidInput(format!( + "对账 tool result[{message_index}:{part_index}] 缺少 output" + ))); + } + } + other => { + return Err(StorageError::InvalidInput(format!( + "对账消息 content type 不受支持: {other}" + ))); + } + } + Ok(()) +} + +fn messages_contain_tool_result(messages: &[Value], call_id: &str) -> bool { + messages.iter().any(|message| { + message + .get("content") + .and_then(Value::as_array) + .is_some_and(|content| { + content.iter().any(|part| { + part.get("type").and_then(Value::as_str) == Some("tool-result") + && part.get("toolCallId").and_then(Value::as_str) == Some(call_id) + }) + }) + }) +} + +fn messages_contain_tool_call(messages: &[Value], call_id: &str) -> bool { + messages.iter().any(|message| { + message + .get("content") + .and_then(Value::as_array) + .is_some_and(|content| { + content.iter().any(|part| { + part.get("type").and_then(Value::as_str) == Some("tool-call") + && part.get("id").and_then(Value::as_str) == Some(call_id) + }) + }) + }) +} + +fn messages_have_resolved_tool_calls(messages: &[Value]) -> bool { + let mut calls = BTreeSet::new(); + let mut results = BTreeSet::new(); + for message in messages { + let Some(content) = message.get("content").and_then(Value::as_array) else { + return false; + }; + for part in content { + match part.get("type").and_then(Value::as_str) { + Some("tool-call") => { + let Some(id) = part.get("id").and_then(Value::as_str) else { + return false; + }; + if !calls.insert(id) { + return false; + } + } + Some("tool-result") => { + let Some(id) = part.get("toolCallId").and_then(Value::as_str) else { + return false; + }; + if !calls.contains(id) || !results.insert(id) { + return false; + } + } + _ => {} + } + } + } + calls.iter().all(|id| results.contains(id)) +} + +/// 只按字段名拦截敏感数据;普通文本内容不做启发式扫描,避免误伤业务数据。 +pub fn validate_json(value: &Value) -> Result<()> { + fn visit(value: &Value, path: &str) -> Result<()> { + match value { + Value::Object(object) => { + for (key, child) in object { + let normalized = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase(); + if SENSITIVE_KEYS + .iter() + .any(|candidate| normalized == *candidate) + { + return Err(StorageError::SensitiveDataRejected { + field: if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }, + }); + } + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + visit(child, &child_path)?; + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + visit(child, &format!("{path}[{index}]"))?; + } + } + _ => {} + } + Ok(()) + } + visit(value, "") +} + +const SENSITIVE_KEYS: &[&str] = &[ + "apikey", + "accesstoken", + "refreshtoken", + "token", + "secret", + "password", + "authorization", + "cookie", + "privatekey", + "clientsecret", +]; + +fn parse_json(text: String) -> rusqlite::Result { + serde_json::from_str(&text).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + text.len(), + rusqlite::types::Type::Text, + Box::new(error), + ) + }) +} + +fn bool_column(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result { + match row.get::<_, i64>(index)? { + 0 => Ok(false), + 1 => Ok(true), + value => Err(rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Integer, + format!("boolean column must be 0 or 1, got {value}").into(), + )), + } +} + +fn is_terminal_status(status: &str) -> bool { + matches!(status, "completed" | "failed" | "cancelled" | "canceled") +} + +fn query_run(connection: &Connection, id: &str) -> Result { + connection + .query_row( + "SELECT id, session_id, status, revision, input_json, output_json, + created_at, updated_at, cancel_requested + FROM runs WHERE id = ?1", + [id], + row_run, + ) + .map_err(|error| not_found_from_query(error, "run", id)) +} + +fn query_run_checkpoint(connection: &Connection, run_id: &str) -> Result { + connection + .query_row( + "SELECT run_id, phase, step, next_step, messages_json, + provider_request_id, tool_call_id, attempt, updated_at + FROM run_checkpoints WHERE run_id = ?1", + [run_id], + |row| { + Ok(RunCheckpointRecord { + run_id: row.get(0)?, + phase: row.get(1)?, + step: row.get(2)?, + next_step: row.get(3)?, + messages: parse_json(row.get(4)?)?, + provider_request_id: row.get(5)?, + tool_call_id: row.get(6)?, + attempt: row.get(7)?, + updated_at: row.get(8)?, + }) + }, + ) + .map_err(|error| not_found_from_query(error, "run checkpoint", run_id)) +} + +/// Validate the values shared by the ordinary and atomic checkpoint writers. +/// Keeping this check before opening the transaction makes the public adapter +/// reject malformed wire values consistently in both paths. +#[cfg(feature = "core-adapter")] +fn validate_checkpoint_input( + input: &NewRunCheckpoint, + worker_id: &str, + lease_token: &str, +) -> Result<()> { + validate_json(&input.messages)?; + require_id("run", &input.run_id)?; + require_non_empty("phase", &input.phase)?; + if !matches!( + input.phase.as_str(), + "compacting" | "provider_in_flight" | "awaiting_approval" | "tool_in_flight" | "safe" + ) { + return Err(StorageError::InvalidInput(format!( + "未知 checkpoint phase: {}", + input.phase + ))); + } + require_non_empty("worker", worker_id)?; + require_non_empty("lease token", lease_token)?; + if input.step < 0 || input.next_step < 0 || input.attempt < 0 { + return Err(StorageError::InvalidInput( + "checkpoint step、next_step 和 attempt 不能为负数".to_owned(), + )); + } + Ok(()) +} + +#[cfg(feature = "core-adapter")] +fn new_checkpoint_from_durable( + input: &agent_runtime_contracts::DurableCheckpointInput, +) -> NewRunCheckpoint { + NewRunCheckpoint { + run_id: input.run_id.clone(), + phase: input.phase.clone(), + step: input.step, + next_step: input.next_step, + messages: input.messages.clone(), + provider_request_id: input.provider_request_id.clone(), + tool_call_id: input.tool_call_id.clone(), + attempt: input.attempt, + } +} + +#[cfg(feature = "core-adapter")] +fn validate_tool_checkpoint_run_identity( + call: &DurableToolCallInput, + checkpoint: &NewRunCheckpoint, +) -> Result<()> { + if call.run_id != checkpoint.run_id { + return Err(StorageError::InvalidInput(format!( + "tool call 与 checkpoint 不属于同一个 run: tool={} checkpoint={}", + call.run_id, checkpoint.run_id + ))); + } + Ok(()) +} + +/// 在已经打开的 IMMEDIATE 事务内 upsert checkpoint。调用方必须先完成 +/// runtime/run/lease 身份校验;这里不另开连接,确保失败能回滚同批工具写入。 +#[cfg(feature = "core-adapter")] +fn upsert_checkpoint_in_transaction( + transaction: &Transaction<'_>, + input: &NewRunCheckpoint, + worker_id: &str, + lease_token: &str, + now: i64, +) -> Result { + let changed = transaction.execute( + "INSERT INTO run_checkpoints + (run_id, phase, step, next_step, messages_json, + provider_request_id, tool_call_id, attempt, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(run_id) DO UPDATE SET + phase = excluded.phase, step = excluded.step, + next_step = excluded.next_step, messages_json = excluded.messages_json, + provider_request_id = excluded.provider_request_id, + tool_call_id = excluded.tool_call_id, attempt = excluded.attempt, + updated_at = excluded.updated_at + WHERE EXISTS (SELECT 1 FROM runs WHERE id = excluded.run_id + AND status IN ('running', 'cancel_requested') + AND worker_id = ?10 AND lease_token = ?11 AND lease_expires_at > ?9)", + params![ + input.run_id, + input.phase, + input.step, + input.next_step, + json_string(&input.messages)?, + input.provider_request_id, + input.tool_call_id, + input.attempt, + now, + worker_id, + lease_token, + ], + )?; + if changed != 1 { + return Err(lease_lost_or_conflict( + transaction, + &input.run_id, + worker_id, + lease_token, + now, + )); + } + query_run_checkpoint(transaction, &input.run_id) +} + +#[cfg(feature = "core-adapter")] +fn verify_duplicate_checkpoint( + transaction: &Transaction<'_>, + input: &NewRunCheckpoint, +) -> Result<()> { + let current = query_run_checkpoint(transaction, &input.run_id)?; + let same = current.phase == input.phase + && current.step == input.step + && current.next_step == input.next_step + && current.messages == input.messages + && current.provider_request_id == input.provider_request_id + && current.tool_call_id == input.tool_call_id + && current.attempt == input.attempt; + if same { + Ok(()) + } else { + Err(StorageError::InvalidInput(format!( + "重复工具事务的 checkpoint 内容不一致: {}", + input.run_id + ))) + } +} + +fn query_lease(connection: &Connection, id: &str) -> Result> { + connection + .query_row( + "SELECT id, worker_id, lease_token, lease_expires_at, heartbeat_at, attempt + FROM runs WHERE id = ?1", + [id], + row_lease, + ) + .optional() + .map(|value| value.flatten()) + .map_err(StorageError::from) +} + +fn query_lease_required(connection: &Connection, id: &str) -> Result { + query_lease(connection, id)?.ok_or_else(|| StorageError::LeaseLost { id: id.to_owned() }) +} + +fn row_lease(row: &rusqlite::Row<'_>) -> rusqlite::Result> { + let worker_id: Option = row.get(1)?; + let lease_token: Option = row.get(2)?; + let lease_expires_at: Option = row.get(3)?; + let heartbeat_at: Option = row.get(4)?; + let attempt: i64 = row.get(5)?; + match (worker_id, lease_token, lease_expires_at) { + // heartbeat_at 保留最后一次心跳时间作为审计值,lease 清理后它 + // 可以单独存在,不代表仍有 active lease。 + (None, None, None) => Ok(None), + (Some(worker_id), Some(lease_token), Some(lease_expires_at)) => { + let heartbeat_at = heartbeat_at.ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Integer, + "active run lease 缺少 heartbeat_at".into(), + ) + })?; + Ok(Some(RunLeaseRecord { + run_id: row.get(0)?, + worker_id, + lease_token, + lease_expires_at, + heartbeat_at, + attempt, + })) + } + _ => Err(rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + "run lease identity columns must be all null or all populated".into(), + )), + } +} + +fn lease_lost_or_conflict( + connection: &Connection, + id: &str, + worker_id: &str, + lease_token: &str, + now: i64, +) -> StorageError { + let current = match query_run(connection, id) { + Ok(record) => record, + Err(error) => return error, + }; + if is_terminal_status(¤t.status) { + return StorageError::TerminalRun { + id: id.to_owned(), + status: current.status, + }; + } + let lease = match query_lease(connection, id) { + Ok(lease) => lease, + Err(error) => return error, + }; + let Some(lease) = lease else { + return StorageError::LeaseLost { id: id.to_owned() }; + }; + if lease.worker_id != worker_id || lease.lease_token != lease_token { + return StorageError::LeaseConflict { + id: id.to_owned(), + reason: "lease 属于其它 worker".to_owned(), + }; + } + if lease.lease_expires_at <= now { + return StorageError::LeaseLost { id: id.to_owned() }; + } + StorageError::LeaseConflict { + id: id.to_owned(), + reason: format!("当前状态 {} 不允许该 lease 操作", current.status), + } +} + +fn row_session(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SessionRecord { + id: row.get(0)?, + agent_id: row.get(1)?, + status: row.get(2)?, + metadata: parse_json(row.get(3)?)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +fn row_run(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let output: Option = row.get(5)?; + let status: String = row.get(2)?; + let cancel_requested = bool_column(row, 8)? || status == "cancel_requested"; + Ok(RunRecord { + id: row.get(0)?, + session_id: row.get(1)?, + status, + revision: row.get(3)?, + input: parse_json(row.get(4)?)?, + output: output.map(parse_json).transpose()?, + cancel_requested, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) +} + +fn row_event(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(EventRecord { + id: row.get(0)?, + session_id: row.get(1)?, + run_id: row.get(2)?, + revision: row.get(3)?, + event_type: row.get(4)?, + payload: parse_json(row.get(5)?)?, + created_at: row.get(6)?, + }) +} + +fn row_snapshot(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SnapshotRecord { + id: row.get(0)?, + session_id: row.get(1)?, + run_id: row.get(2)?, + revision: row.get(3)?, + state: parse_json(row.get(4)?)?, + created_at: row.get(5)?, + }) +} + +fn row_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let decision: Option = row.get(9)?; + Ok(ApprovalRecord { + id: row.get(0)?, + session_id: row.get(1)?, + run_id: row.get(2)?, + tool_call_id: row.get(3)?, + status: row.get(4)?, + request: parse_json(row.get(5)?)?, + arguments_hash: row.get(6)?, + approval_token: row.get(7)?, + expires_at_ms: row.get(8)?, + decision: decision.map(parse_json).transpose()?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) +} + +fn row_tool_call(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let result: Option = row.get(5)?; + Ok(ToolCallRecord { + id: row.get(0)?, + session_id: row.get(1)?, + run_id: row.get(2)?, + tool_name: row.get(3)?, + arguments: parse_json(row.get(4)?)?, + result: result.map(parse_json).transpose()?, + status: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} + +fn row_external_session(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ExternalSessionRecord { + id: row.get(0)?, + session_id: row.get(1)?, + run_id: row.get(2)?, + backend: row.get(3)?, + external_id: row.get(4)?, + status: row.get(5)?, + metadata: parse_json(row.get(6)?)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} + +fn not_found_from_query(error: rusqlite::Error, entity: &'static str, id: &str) -> StorageError { + if matches!(error, rusqlite::Error::QueryReturnedNoRows) { + StorageError::NotFound { + entity, + id: id.to_string(), + } + } else { + StorageError::Sqlite(error) + } +} + +/// Build the export-only approval projection. +/// +/// `approval_token` is deliberately kept in the durable record: the +/// approval binding and explicit resume path use it to reject a tampered +/// request. JSONL export is a diagnostic/migration boundary, though, so it +/// must not make that bearer value portable. Remove only keys that identify +/// this storage-owned approval token (including the copy in the persisted +/// request); other approval identity and request fields remain available for +/// audit. The original record is borrowed and never mutated. +fn approval_export_value(approval: &ApprovalRecord) -> Result { + fn redact_token_keys(value: &mut Value) { + match value { + Value::Object(object) => { + let token_keys = object + .keys() + .filter(|key| { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::() + .eq_ignore_ascii_case("approvaltoken") + }) + .cloned() + .collect::>(); + for key in token_keys { + object.remove(&key); + } + for child in object.values_mut() { + redact_token_keys(child); + } + } + Value::Array(items) => { + for item in items { + redact_token_keys(item); + } + } + _ => {} + } + } + + let mut exported = serde_json::to_value(approval)?; + redact_token_keys(&mut exported); + Ok(exported) +} + +fn write_jsonl(writer: &mut W, value: &Value) -> Result<()> { + let bytes = serde_json::to_vec(value)?; + writer.write_all(&bytes)?; + writer.write_all(b"\n")?; + Ok(()) +} + +#[cfg(test)] +mod approval_tests { + use super::*; + use serde_json::json; + #[cfg(feature = "core-adapter")] + use std::time::Duration; + + fn fixture() -> (SqliteStore, String, String) { + let store = SqliteStore::open_in_memory().expect("open store"); + let session_id = "approval-session".to_owned(); + let run_id = "approval-run".to_owned(); + store + .create_session(NewSession { + id: session_id.clone(), + agent_id: Some("agent".to_owned()), + status: "queued".to_owned(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: session_id.clone(), + status: "queued".to_owned(), + input: json!({"task": "approval"}), + }) + .expect("create run"); + (store, session_id, run_id) + } + + fn new_approval(session_id: &str, run_id: &str, id: &str, call_id: &str) -> NewApproval { + NewApproval { + id: id.to_owned(), + session_id: session_id.to_owned(), + run_id: run_id.to_owned(), + tool_call_id: Some(call_id.to_owned()), + status: "pending".to_owned(), + request: json!({ + "requestId": id, + "runId": run_id, + "call": {"id": call_id, "name": "echo", "arguments": {"text": "ok"}}, + "argumentsHash": format!("hash-{call_id}"), + "approvalToken": format!("token-{id}"), + "expiresAtMs": i64::MAX + }), + arguments_hash: format!("hash-{call_id}"), + approval_token: format!("token-{id}"), + expires_at_ms: i64::MAX, + } + } + + #[test] + fn approval_create_is_idempotent_and_identity_bound() { + let (store, session_id, run_id) = fixture(); + let input = new_approval(&session_id, &run_id, "approval-1", "call-1"); + let first = store + .create_approval(input.clone()) + .expect("create approval"); + let repeated = store + .create_approval(input) + .expect("repeated create is idempotent"); + assert_eq!(first, repeated); + + let mismatched = new_approval(&session_id, &run_id, "approval-1", "call-2"); + // 保持第二条 request 自身结构有效,只改变绑定身份;这样断言的 + // 是同一 approval id 的冲突语义,而不是输入格式校验错误。 + assert!(matches!( + store.create_approval(mismatched), + Err(StorageError::ApprovalConflict { .. }) + )); + assert_eq!(store.list_approvals_for_run(&run_id).unwrap().len(), 1); + } + + #[test] + fn approval_resolution_is_pending_only_cas_and_idempotent() { + let (store, session_id, run_id) = fixture(); + store + .create_approval(new_approval(&session_id, &run_id, "approval-1", "call-1")) + .expect("create approval"); + let allow = json!({"decision": "allow"}); + let resolved = store + .resolve_approval("approval-1", "pending", "allowed", allow.clone()) + .expect("allow approval"); + assert_eq!(resolved.status, "allowed"); + assert_eq!(resolved.decision, Some(allow.clone())); + assert_eq!( + store + .resolve_approval("approval-1", "pending", "allowed", allow.clone()) + .unwrap(), + resolved + ); + assert!(matches!( + store.resolve_approval( + "approval-1", + "pending", + "denied", + json!({"decision": "deny", "reason": "no"}) + ), + Err(StorageError::ApprovalConflict { .. }) + )); + } + + #[test] + fn expired_pending_approval_cannot_be_resolved() { + let (store, session_id, run_id) = fixture(); + let mut input = new_approval(&session_id, &run_id, "approval-expired", "call-expired"); + input.expires_at_ms = 1; + input.request["expiresAtMs"] = json!(1); + store + .create_approval(input) + .expect("create expired approval"); + assert!(matches!( + store.resolve_approval( + "approval-expired", + "pending", + "allowed", + json!({"decision": "allow"}) + ), + Err(StorageError::InvalidInput(message)) if message.contains("已过期") + )); + assert_eq!( + store + .get_approval("approval-expired") + .unwrap() + .unwrap() + .status, + "pending" + ); + } + + #[test] + fn cancel_pending_approvals_closes_only_unresolved_requests() { + let (store, session_id, run_id) = fixture(); + store + .create_approval(new_approval(&session_id, &run_id, "approval-1", "call-1")) + .expect("create first approval"); + store + .create_approval(new_approval(&session_id, &run_id, "approval-2", "call-2")) + .expect("create second approval"); + store + .resolve_approval( + "approval-2", + "pending", + "allowed", + json!({"decision": "allow"}), + ) + .expect("resolve second approval"); + + assert_eq!(store.cancel_pending_approvals(&run_id).unwrap(), 1); + assert_eq!( + store.get_approval("approval-1").unwrap().unwrap().status, + "cancelled" + ); + assert_eq!( + store.get_approval("approval-2").unwrap().unwrap().status, + "allowed" + ); + assert_eq!(store.cancel_pending_approvals(&run_id).unwrap(), 0); + } + + #[cfg(feature = "core-adapter")] + #[test] + fn queue_approved_run_requires_matching_awaiting_checkpoint() { + let (store, session_id, run_id) = fixture(); + let (_, lease) = store + .claim_run_with_lease( + &run_id, + "approval-worker", + "approval-lease", + Duration::from_secs(5), + ) + .expect("claim run"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "awaiting_approval".to_owned(), + step: 0, + next_step: 0, + messages: json!([ + {"role": "user", "content": [{"type": "text", "text": "approval"}]}, + {"role": "assistant", "content": [{ + "type": "tool-call", "id": "call-1", "name": "echo", "arguments": {"text": "ok"} + }]} + ]), + provider_request_id: Some("provider-1".to_owned()), + tool_call_id: Some("call-1".to_owned()), + attempt: lease.attempt, + }, + &lease.worker_id, + &lease.lease_token, + ) + .expect("save awaiting checkpoint"); + store + .release_run_lease(&run_id, &lease.worker_id, &lease.lease_token) + .expect("release to reconciliation"); + store + .create_approval(new_approval(&session_id, &run_id, "approval-1", "call-1")) + .expect("create approval"); + store + .resolve_approval( + "approval-1", + "pending", + "allowed", + json!({"decision": "allow"}), + ) + .expect("resolve approval"); + + let queued = store + .queue_approved_run("approval-1") + .expect("queue approved run"); + assert_eq!(queued.status, "queued"); + assert_eq!( + store.get_session(&session_id).unwrap().unwrap().status, + "queued" + ); + // 同一控制命令重复执行不产生第二个状态变化。 + assert_eq!(store.queue_approved_run("approval-1").unwrap(), queued); + + let run = store.get_run(&run_id).unwrap().unwrap(); + assert_eq!(run.status, "queued"); + assert_eq!( + store.read_checkpoint(&run_id).unwrap().unwrap().phase, + "awaiting_approval" + ); + } +} + +#[cfg(test)] +mod external_session_tests { + use super::*; + use serde_json::json; + + fn fixture() -> (SqliteStore, String, String) { + let store = SqliteStore::open_in_memory().expect("open store"); + let session_a = "external-session-a".to_owned(); + let session_b = "external-session-b".to_owned(); + let run_a = "external-run-a".to_owned(); + let run_b = "external-run-b".to_owned(); + for session_id in [&session_a, &session_b] { + store + .create_session(NewSession { + id: (*session_id).clone(), + agent_id: Some("fixture-agent".to_owned()), + status: "running".to_owned(), + metadata: json!({}), + }) + .expect("create session"); + } + for (run_id, session_id) in [(&run_a, &session_a), (&run_b, &session_b)] { + store + .create_run(NewRun { + id: (*run_id).clone(), + session_id: (*session_id).clone(), + status: "running".to_owned(), + input: json!({"task": "external fixture"}), + }) + .expect("create run"); + } + for (id, session_id, run_id, status) in [ + ("external-a", &session_a, Some(&run_a), "running"), + ("external-b", &session_a, Some(&run_a), "unknown"), + ("external-c", &session_b, Some(&run_b), "completed"), + ("external-d", &session_b, None, "cancel_requested"), + ] { + store + .upsert_external_session(NewExternalSession { + id: id.to_owned(), + session_id: session_id.clone(), + run_id: run_id.cloned(), + backend: "fixture-backend".to_owned(), + external_id: format!("remote-{id}"), + status: status.to_owned(), + metadata: json!({"fixture": true}), + }) + .expect("create external session"); + } + (store, run_a, run_b) + } + + #[test] + fn list_external_sessions_filters_status_and_run_with_stable_order() { + let (store, run_a, run_b) = fixture(); + let records = store + .list_external_sessions(&["unknown", "running"], Some(&run_a), 10) + .expect("list candidates"); + assert_eq!( + records + .iter() + .map(|record| record.id.as_str()) + .collect::>(), + vec!["external-a", "external-b"] + ); + assert!(records.windows(2).all(|window| { + (window[0].created_at, &window[0].id) <= (window[1].created_at, &window[1].id) + })); + assert!( + store + .list_external_sessions(&["unknown", "running"], Some(&run_b), 10) + .expect("other run has no matching candidates") + .is_empty() + ); + + let limited = store + .list_external_sessions( + &["running", "unknown", "completed", "cancel_requested"], + None, + 2, + ) + .expect("bounded list"); + assert_eq!(limited.len(), 2); + assert_eq!( + store + .list_external_sessions(&["running"], None, 0) + .expect("zero limit is an empty read"), + Vec::::new() + ); + } + + #[test] + fn list_external_sessions_rejects_unbounded_or_empty_filters() { + let (store, _, _) = fixture(); + assert!(matches!( + store.list_external_sessions(&[], None, 1), + Err(StorageError::InvalidInput(message)) if message.contains("不能为空") + )); + assert!(matches!( + store.list_external_sessions(&[""], None, 1), + Err(StorageError::InvalidInput(message)) if message.contains("不能为空") + )); + assert!(matches!( + store.list_external_sessions(&["running"], Some(" "), 1), + Err(StorageError::InvalidInput(message)) if message.contains("不能为空") + )); + assert!(matches!( + store.list_external_sessions(&["running"], None, MAX_EXTERNAL_SESSION_SCAN_LIMIT + 1), + Err(StorageError::InvalidInput(message)) if message.contains("不能超过") + )); + let too_many_statuses = vec!["running"; MAX_EXTERNAL_SESSION_STATUS_FILTERS + 1]; + assert!(matches!( + store.list_external_sessions(&too_many_statuses, None, 1), + Err(StorageError::InvalidInput(message)) if message.contains("statuses") + )); + + // Values that look like SQL remain data, not query text. + assert!( + store + .list_external_sessions(&["running' OR 1=1 --"], None, 10) + .expect("bound status value") + .is_empty() + ); + } +} diff --git a/rust/crates/agent-storage-sqlite/tests/storage.rs b/rust/crates/agent-storage-sqlite/tests/storage.rs new file mode 100644 index 000000000..5c5a5d9b4 --- /dev/null +++ b/rust/crates/agent-storage-sqlite/tests/storage.rs @@ -0,0 +1,3430 @@ +#[cfg(feature = "core-adapter")] +use agent_runtime_contracts::{ + DurableCheckpointInput, DurableLeaseIdentity, DurableToolCallCheckpointRuntimeCommit, + DurableToolCallInput, DurableToolCallRuntimeCommit, +}; +#[cfg(feature = "core-adapter")] +use agent_runtime_core::{ + RUNTIME_EVENT_SCHEMA_VERSION, RunSnapshot, RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, + RuntimeStore, StoreErrorKind, SystemClock, ToolCall, ToolResult, reduce, +}; +use agent_storage_sqlite::{ + MAX_STALE_RUN_SCAN_LIMIT, NewApproval, NewCheckpoint, NewEvent, NewExternalSession, NewRun, + NewSession, NewSnapshot, NewToolCall, SqliteStore, StorageError, +}; +use serde_json::json; +use std::path::PathBuf; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +fn local_tempdir() -> tempfile::TempDir { + // Respect an explicit TMPDIR, but keep direct test runs out of /tmp by + // defaulting to the repository-wide ~/data/tmp location. TempDir owns the + // child directory cleanup even when a test returns early or panics. + let parent = std::env::var_os("TMPDIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("data/tmp")) + }) + .expect("TMPDIR 或 HOME 未设置,无法创建测试数据库目录"); + std::fs::create_dir_all(&parent).expect("创建测试临时目录"); + tempfile::Builder::new() + .prefix("agent-storage-test-") + .tempdir_in(parent) + .expect("tempdir") +} + +fn store_with_run() -> (SqliteStore, String) { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "session-1".into(), + agent_id: Some("agent-1".into()), + status: "active".into(), + metadata: json!({"purpose": "test"}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "run-1".into(), + session_id: "session-1".into(), + status: "running".into(), + input: json!({"message": "hello"}), + }) + .expect("create run"); + (store, "run-1".into()) +} + +/// 等待 SQLite 里记录的毫秒级到期时间,避免用固定 sleep 在慢 CI 上产生竞态。 +fn wait_until_epoch_ms(target: i64) { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_millis() as i64; + if now >= target { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for lease expiry at {target}, current time {now}" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn user_message(text: &str) -> serde_json::Value { + json!({ + "role": "user", + "content": [{"type": "text", "text": text}] + }) +} + +fn assistant_tool_call_message(call_id: &str) -> serde_json::Value { + json!({ + "role": "assistant", + "content": [{ + "type": "tool-call", + "id": call_id, + "name": "echo", + "arguments": {"text": "hello"} + }] + }) +} + +fn tool_result_message(call_id: &str) -> serde_json::Value { + json!({ + "role": "tool", + "content": [{ + "type": "tool-result", + "toolCallId": call_id, + "output": {"ok": true}, + "isError": false + }] + }) +} + +#[cfg(feature = "core-adapter")] +fn tool_call_runtime_fixture() -> ( + SqliteStore, + String, + RuntimeSnapshot, + Vec, + ToolCall, + DurableLeaseIdentity, +) { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + let session_id = "tool-transaction-session".to_owned(); + let run_id = "tool-transaction-run".to_owned(); + let runtime_id = "tool-transaction-runtime".to_owned(); + store + .create_session(NewSession { + id: session_id.clone(), + agent_id: Some("agent-1".to_owned()), + status: "active".to_owned(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: run_id.clone(), + session_id, + status: "queued".to_owned(), + input: json!({"message": "tool transaction"}), + }) + .expect("create run"); + + let run = RunSnapshot::try_new(&run_id, "agent-1", "tool transaction", 1).expect("runtime run"); + let runtime_created = RuntimeEvent::runtime_created(&runtime_id, 1, 1).expect("runtime event"); + let run_created = RuntimeEvent::run_created(&runtime_id, 2, 2, &run).expect("run event"); + let started = + RuntimeEvent::status_changed(&runtime_id, 3, 3, &run_id, RuntimeEventKind::RunStarted) + .expect("started event"); + let mut snapshot = RuntimeSnapshot::try_new(&runtime_id).expect("runtime snapshot"); + for event in [&runtime_created, &run_created, &started] { + snapshot = reduce(&snapshot, event).expect("reduce initial runtime event"); + } + let mut runtime_store = store.clone(); + runtime_store + .commit( + &runtime_id, + None, + &snapshot, + &[runtime_created, run_created, started], + ) + .expect("commit initial runtime"); + let (_, lease) = store + .claim_run_with_lease( + &run_id, + "tool-worker", + "tool-lease", + Duration::from_secs(30), + ) + .expect("claim run"); + let call = + ToolCall::try_new("tool-call-1", "echo", json!({"text": "hello"})).expect("tool call"); + let requested = + RuntimeEvent::tool_call_requested(&runtime_id, snapshot.revision() + 1, 4, &run_id, &call) + .expect("tool requested event"); + let next = reduce(&snapshot, &requested).expect("reduce tool request"); + let lease = DurableLeaseIdentity { + worker_id: lease.worker_id, + lease_token: lease.lease_token, + }; + (store, runtime_id, next, vec![requested], call, lease) +} + +fn reconciling_checkpoint( + phase: &str, + messages: serde_json::Value, + provider_request_id: Option<&str>, + tool_call_id: Option<&str>, +) -> (SqliteStore, String, i64) { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "reconcile-session".into(), + agent_id: Some("agent-1".into()), + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + let run_id = "reconcile-run".to_owned(); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: "reconcile-session".into(), + status: "queued".into(), + input: json!({"message": "reconcile"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease(&run_id, "worker-1", "lease-1", Duration::from_secs(30)) + .expect("claim run"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: phase.to_owned(), + step: 0, + next_step: 0, + messages, + provider_request_id: provider_request_id.map(str::to_owned), + tool_call_id: tool_call_id.map(str::to_owned), + attempt: lease.attempt, + }, + "worker-1", + "lease-1", + ) + .expect("save in-flight checkpoint"); + store + .release_run_lease(&run_id, "worker-1", "lease-1") + .expect("enter reconciliation"); + (store, run_id, lease.attempt) +} + +#[test] +fn migrations_and_wal_are_configured() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + // SQLite 内存库不能启用 WAL,但迁移表和业务表应已存在。 + assert_eq!(store.journal_mode().expect("journal mode"), "memory"); + let session = store + .create_session(NewSession { + id: "s".into(), + agent_id: None, + status: "new".into(), + metadata: json!({}), + }) + .expect("insert"); + assert_eq!(store.get_session(&session.id).expect("read"), Some(session)); +} + +#[test] +fn checkpoint_is_fenced_and_round_trips() { + let (store, _) = store_with_run(); + let run_id = "checkpoint-run".to_owned(); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: "session-1".into(), + status: "queued".into(), + input: json!({"message": "checkpoint"}), + }) + .expect("create checkpoint run"); + store + .claim_run_with_lease(&run_id, "worker-1", "token-1", Duration::from_secs(30)) + .expect("claim"); + let saved = store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "provider_in_flight".into(), + step: 2, + next_step: 3, + messages: json!([{"role":"user","content":"hello"}]), + provider_request_id: Some("req-1".into()), + tool_call_id: Some("tool-1".into()), + attempt: 1, + }, + "worker-1", + "token-1", + ) + .expect("save checkpoint"); + assert_eq!(saved.phase, "provider_in_flight"); + assert_eq!( + store + .get_checkpoint(&run_id, "worker-1", "token-1") + .expect("load"), + Some(saved) + ); + assert!(matches!( + store.get_checkpoint(&run_id, "worker-2", "wrong-token"), + Err(StorageError::LeaseConflict { .. }) + )); + store + .clear_checkpoint_with_lease(&run_id, "worker-1", "token-1") + .expect("clear"); + assert_eq!( + store + .get_checkpoint(&run_id, "worker-1", "token-1") + .expect("load cleared"), + None + ); +} + +#[test] +fn file_backed_restart_recovers_expired_provider_checkpoint() { + let tempdir = local_tempdir(); + let database = tempdir.path().join("restart-recovery.db"); + let run_id = "restart-provider-run".to_owned(); + let lease_expires_at; + + // This scope models the worker process that created the in-flight call. + // Dropping the store closes the SQLite connection before the recovery + // process opens the same file again. + { + let store = SqliteStore::open(&database).expect("open file-backed sqlite"); + store + .create_session(NewSession { + id: "restart-session".into(), + agent_id: Some("agent-1".into()), + status: "active".into(), + metadata: json!({"purpose": "restart-recovery"}), + }) + .expect("create restart session"); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: "restart-session".into(), + status: "queued".into(), + input: json!({"message": "restart"}), + }) + .expect("create restart run"); + + let (_, lease) = store + .claim_run_with_lease( + &run_id, + "restart-worker", + "restart-lease", + Duration::from_millis(20), + ) + .expect("claim restart run"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "provider_in_flight".into(), + step: 2, + next_step: 3, + messages: json!([user_message("restart")]), + provider_request_id: Some("provider-restart-request".into()), + tool_call_id: None, + attempt: lease.attempt, + }, + "restart-worker", + "restart-lease", + ) + .expect("save provider checkpoint"); + lease_expires_at = lease.lease_expires_at; + } + + wait_until_epoch_ms(lease_expires_at); + + // A fresh connection must be able to identify the stale lease and recover + // without deleting the checkpoint needed for explicit external reconciliation. + { + let store = SqliteStore::open(&database).expect("reopen file-backed sqlite"); + let recovered = store + .recover_expired_run(&run_id) + .expect("recover stale run after reopen"); + assert_eq!(recovered.status, "reconciling"); + assert_eq!( + store + .get_run(&run_id) + .expect("read recovered run") + .expect("recovered run exists") + .status, + "reconciling" + ); + // 低层 `recover_expired_run` 只收束 run;需要同步 session/runtime 投影时, + // 由 RuntimeService 的跨表 recovery facade 调用对应原子入口。 + assert_eq!(store.get_run_lease(&run_id).expect("read lease"), None); + + let checkpoint = store + .read_checkpoint(&run_id) + .expect("read preserved checkpoint") + .expect("checkpoint remains after recovery"); + assert_eq!(checkpoint.phase, "provider_in_flight"); + assert_eq!( + checkpoint.provider_request_id.as_deref(), + Some("provider-restart-request") + ); + assert_eq!(checkpoint.tool_call_id, None); + } +} + +#[test] +fn safe_resume_requeues_only_safe_reconciled_run_and_preserves_checkpoint() { + let (store, _) = store_with_run(); + let run_id = "safe-resume-run".to_owned(); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: "session-1".into(), + status: "queued".into(), + input: json!({"message": "resume"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease(&run_id, "worker-old", "token-old", Duration::from_secs(1)) + .expect("claim"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "safe".into(), + step: 4, + next_step: 5, + messages: json!([{"role":"user","content":"resume"}]), + provider_request_id: None, + tool_call_id: None, + attempt: 1, + }, + "worker-old", + "token-old", + ) + .expect("save safe checkpoint"); + wait_until_epoch_ms(lease.lease_expires_at); + let reconciled = store.recover_expired_run(&run_id).expect("reconcile"); + let reconciled_updated_at = reconciled.updated_at; + // The first transition still records a fresh durable update; only an + // already queued idempotent retry is allowed to preserve its timestamp. + wait_until_epoch_ms(reconciled_updated_at.saturating_add(1)); + + let queued = store.requeue_reconciled_run(&run_id).expect("safe resume"); + assert_eq!(queued.status, "queued"); + assert!(queued.updated_at > reconciled_updated_at); + // worker 尚未启动时重复触发 resume-safe 仍应保持同一 queued run, + // 让 spawn 失败或 CLI 重试不会把安全游标卡死在不可恢复状态。 + // 等待时钟前进,确保回归覆盖“不更新时间”而非仅仅碰巧落在同一毫秒。 + wait_until_epoch_ms(queued.updated_at.saturating_add(1)); + let queued_again = store + .resume_safe_run(&run_id) + .expect("idempotent safe resume"); + assert_eq!(queued_again, queued); + assert_eq!( + store + .read_checkpoint(&run_id) + .expect("read checkpoint") + .unwrap() + .phase, + "safe" + ); + + // Requeue does not start the engine; a separate lease claim is still required. + let (running, _) = store + .claim_run_with_lease(&run_id, "worker-new", "token-new", Duration::from_secs(30)) + .expect("new worker claim"); + assert_eq!(running.status, "running"); + assert!(matches!( + store.save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "safe".into(), + step: 5, + next_step: 6, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: 2, + }, + "worker-old", + "token-old", + ), + Err(StorageError::LeaseLost { .. }) | Err(StorageError::LeaseConflict { .. }) + )); +} + +#[test] +fn safe_resume_rejects_non_safe_phase_and_cancelled_run() { + let (store, _) = store_with_run(); + let run_id = "unsafe-resume-run".to_owned(); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: "session-1".into(), + status: "queued".into(), + input: json!({"message": "unsafe"}), + }) + .expect("create run"); + let (_, lease) = store + // 留出保存 checkpoint 的时间,再等待真实到期,避免测试本身先丢 lease。 + .claim_run_with_lease(&run_id, "worker", "token", Duration::from_millis(100)) + .expect("claim"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "provider_in_flight".into(), + step: 1, + next_step: 2, + messages: json!([]), + provider_request_id: Some("request".into()), + tool_call_id: None, + attempt: 1, + }, + "worker", + "token", + ) + .expect("save in-flight checkpoint"); + wait_until_epoch_ms(lease.lease_expires_at); + store.recover_expired_run(&run_id).expect("reconcile"); + assert!(matches!( + store.requeue_reconciled_run(&run_id), + Err(StorageError::InvalidInput(_)) + )); + + let (store, _) = store_with_run(); + let run_id = "cancelled-resume-run".to_owned(); + store + .create_run(NewRun { + id: run_id.clone(), + session_id: "session-1".into(), + status: "queued".into(), + input: json!({"message": "cancelled"}), + }) + .expect("create run"); + let (_, lease) = store + // 先留出保存 safe checkpoint 的时间,再等待真实到期,避免测试本身 + // 因调度抖动在写入前丢 lease。 + .claim_run_with_lease(&run_id, "worker", "token", Duration::from_millis(100)) + .expect("claim"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.clone(), + phase: "safe".into(), + step: 1, + next_step: 2, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: 1, + }, + "worker", + "token", + ) + .expect("save safe checkpoint"); + store.request_cancel(&run_id).expect("cancel request"); + wait_until_epoch_ms(lease.lease_expires_at); + let reconciled = store + .recover_expired_run(&run_id) + .expect("reconcile cancelled"); + assert!(reconciled.cancel_requested); + assert!(matches!( + store.resume_safe_run(&run_id), + Err(StorageError::InvalidInput(_)) + )); +} + +#[test] +fn terminal_transition_clears_checkpoint_atomically() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "checkpoint-session".into(), + agent_id: None, + status: "queued".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "checkpoint-terminal".into(), + session_id: "checkpoint-session".into(), + status: "queued".into(), + input: json!({"message": "finish"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease( + "checkpoint-terminal", + "worker-1", + "token-1", + Duration::from_secs(30), + ) + .expect("claim"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: "checkpoint-terminal".into(), + phase: "safe".into(), + step: 0, + next_step: 1, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: lease.attempt, + }, + "worker-1", + "token-1", + ) + .expect("save"); + let completed = store + .complete_run_with_lease( + "checkpoint-terminal", + "worker-1", + "token-1", + Some(json!({"ok": true})), + ) + .expect("complete"); + assert_eq!(completed.status, "completed"); + assert!( + store + .read_checkpoint("checkpoint-terminal") + .expect("read") + .is_none() + ); +} + +#[test] +fn unclaimed_cancel_transition_clears_checkpoint_atomically() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "unclaimed-cancel-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + + // safe 游标被重新排队后,控制端可以在没有 worker lease 的情况下直接收口。 + store + .create_run(NewRun { + id: "queued-safe-cancel".into(), + session_id: "unclaimed-cancel-session".into(), + status: "queued".into(), + input: json!({"message": "queued safe cancel"}), + }) + .expect("create queued run"); + store + .claim_run_with_lease( + "queued-safe-cancel", + "worker-queued", + "token-queued", + Duration::from_secs(30), + ) + .expect("claim queued run"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: "queued-safe-cancel".into(), + phase: "safe".into(), + step: 0, + next_step: 1, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: 1, + }, + "worker-queued", + "token-queued", + ) + .expect("save safe checkpoint"); + store + .release_run_lease("queued-safe-cancel", "worker-queued", "token-queued") + .expect("release to reconciliation"); + store + .requeue_reconciled_run("queued-safe-cancel") + .expect("requeue safe run"); + store + .request_cancel("queued-safe-cancel") + .expect("request queued cancel"); + let cancelled = store + .mark_cancelled("queued-safe-cancel", None) + .expect("cancel queued run"); + assert_eq!(cancelled.status, "cancelled"); + assert!( + store + .read_checkpoint("queued-safe-cancel") + .expect("read queued checkpoint") + .is_none() + ); + + // reconciling 中的未知外部调用也可以被控制端终止;终态不能遗留游标。 + store + .create_run(NewRun { + id: "reconciling-cancel".into(), + session_id: "unclaimed-cancel-session".into(), + status: "queued".into(), + input: json!({"message": "reconciling cancel"}), + }) + .expect("create reconciling run"); + store + .claim_run_with_lease( + "reconciling-cancel", + "worker-reconciling", + "token-reconciling", + Duration::from_secs(30), + ) + .expect("claim reconciling run"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: "reconciling-cancel".into(), + phase: "provider_in_flight".into(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: Some("provider-request".into()), + tool_call_id: None, + attempt: 1, + }, + "worker-reconciling", + "token-reconciling", + ) + .expect("save provider checkpoint"); + store + .release_run_lease( + "reconciling-cancel", + "worker-reconciling", + "token-reconciling", + ) + .expect("release provider run"); + store + .request_cancel("reconciling-cancel") + .expect("request reconciling cancel"); + let cancelled = store + .mark_cancelled("reconciling-cancel", Some(json!({"reason": "user"}))) + .expect("cancel reconciling run"); + assert_eq!(cancelled.status, "cancelled"); + assert!( + store + .read_checkpoint("reconciling-cancel") + .expect("read reconciling checkpoint") + .is_none() + ); +} + +#[test] +fn all_unleased_terminal_entrypoints_clear_checkpoint_atomically() { + for (run_id, terminal) in [ + ("unleased-complete", "complete"), + ("unleased-fail", "fail"), + ("unleased-update", "update"), + ] { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: format!("{run_id}-session"), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: run_id.into(), + session_id: format!("{run_id}-session"), + status: "queued".into(), + input: json!({"message": "terminal cleanup"}), + }) + .expect("create run"); + store + .claim_run_with_lease(run_id, "worker", "token", Duration::from_secs(30)) + .expect("claim run"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.into(), + phase: "safe".into(), + step: 0, + next_step: 1, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: 1, + }, + "worker", + "token", + ) + .expect("save checkpoint"); + store + .release_run_lease(run_id, "worker", "token") + .expect("release lease"); + + // 构造历史无 lease running 记录,验证三个公开兼容入口都维持相同 + // 终态不变量。非终态 update 不应提前删掉恢复游标。 + store + .update_run(run_id, "running", None) + .expect("restore legacy running state"); + assert!(store.read_checkpoint(run_id).unwrap().is_some()); + let record = match terminal { + "complete" => store.complete_run(run_id, None).expect("complete run"), + "fail" => store.fail_run(run_id, None).expect("fail run"), + "update" => store + .update_run(run_id, "cancelled", None) + .expect("update terminal run"), + _ => unreachable!(), + }; + assert!(matches!( + record.status.as_str(), + "completed" | "failed" | "cancelled" + )); + assert!(store.read_checkpoint(run_id).unwrap().is_none()); + } +} + +#[test] +fn external_session_lifecycle_can_update_id_and_status_in_place() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "external-lifecycle-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "external-lifecycle-run".into(), + session_id: "external-lifecycle-session".into(), + status: "running".into(), + input: json!({"message": "lifecycle"}), + }) + .expect("create run"); + store + .upsert_external_session(NewExternalSession { + id: "external-lifecycle-record".into(), + session_id: "external-lifecycle-session".into(), + run_id: Some("external-lifecycle-run".into()), + backend: "fixture".into(), + external_id: "request-1".into(), + status: "running".into(), + metadata: json!({"lifecycle": "running"}), + }) + .expect("record dispatch"); + + let updated = store + .update_external_session( + "external-lifecycle-record", + "turn-1", + "completed", + json!({"lifecycle": "completed", "externalIdKnown": true}), + ) + .expect("update lifecycle"); + assert_eq!(updated.id, "external-lifecycle-record"); + assert_eq!(updated.external_id, "turn-1"); + assert_eq!(updated.status, "completed"); + assert_eq!(updated.metadata["externalIdKnown"], true); + assert_eq!( + store + .get_external_session("external-lifecycle-record") + .expect("read lifecycle") + .unwrap() + .external_id, + "turn-1" + ); +} + +#[test] +fn checkpoint_phase_is_bounded() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + let (_, run_id) = store_with_run(); + let error = store.save_run_checkpoint( + NewCheckpoint { + run_id, + phase: "unknown".into(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: 0, + }, + "worker", + "token", + ); + assert!(matches!(error, Err(StorageError::InvalidInput(message)) if message.contains("phase"))); +} + +#[test] +fn compacting_checkpoint_is_a_durable_non_safe_boundary() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "compaction-session".into(), + agent_id: Some("agent-1".into()), + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "compaction-run".into(), + session_id: "compaction-session".into(), + status: "queued".into(), + input: json!({"message": "compact"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease( + "compaction-run", + "compaction-worker", + "compaction-token", + Duration::from_secs(5), + ) + .expect("claim run"); + let checkpoint = store + .save_run_checkpoint( + NewCheckpoint { + run_id: "compaction-run".into(), + phase: "compacting".into(), + step: 0, + next_step: 0, + messages: json!([user_message("history")]), + provider_request_id: Some("compaction-request-0".into()), + tool_call_id: None, + attempt: lease.attempt, + }, + "compaction-worker", + "compaction-token", + ) + .expect("save compacting checkpoint"); + assert_eq!(checkpoint.phase, "compacting"); + assert_eq!( + store + .read_checkpoint("compaction-run") + .unwrap() + .unwrap() + .phase, + "compacting" + ); + // A compaction cursor is intentionally not accepted by the safe-resume + // path; callers must reconcile or explicitly terminate it first. + let error = store + .resume_safe_run("compaction-run") + .expect_err("compacting must not be treated as safe"); + assert!(matches!(error, StorageError::InvalidInput(message) if message.contains("安全恢复"))); +} + +#[test] +fn reconciliation_result_marks_provider_and_tool_checkpoints_safe() { + let before = user_message("before"); + let provider_messages = json!([ + before.clone(), + {"role":"assistant","content":[{"type":"text","text":"provider result"}]} + ]); + let (store, run_id, attempt) = reconciling_checkpoint( + "provider_in_flight", + json!([before]), + Some("provider-request-1"), + None, + ); + let safe = store + .record_reconciliation_result( + &run_id, + "provider_in_flight", + "provider-request-1", + 0, + attempt, + provider_messages, + ) + .expect("record provider result"); + assert_eq!(safe.phase, "safe"); + assert_eq!(safe.step, 0); + assert_eq!(safe.next_step, 1); + assert_eq!( + safe.provider_request_id.as_deref(), + Some("provider-request-1") + ); + assert_eq!(safe.tool_call_id, None); + assert_eq!( + store.get_run(&run_id).expect("read run").unwrap().status, + "reconciling" + ); + + let call_id = "tool-call-1"; + let tool_before = json!([user_message("before"), assistant_tool_call_message(call_id)]); + let tool_messages = json!([ + user_message("before"), + assistant_tool_call_message(call_id), + tool_result_message(call_id) + ]); + let (store, run_id, attempt) = reconciling_checkpoint( + "tool_in_flight", + tool_before, + Some("provider-request-2"), + Some(call_id), + ); + let safe = store + .record_reconciliation_result( + &run_id, + "tool_in_flight", + call_id, + 0, + attempt, + tool_messages, + ) + .expect("record tool result"); + assert_eq!(safe.phase, "safe"); + assert_eq!(safe.next_step, 1); + assert_eq!( + safe.provider_request_id.as_deref(), + Some("provider-request-2") + ); + // safe checkpoint 表示 tool 已经完成对账;恢复器要求这里不再暴露 in-flight call。 + assert_eq!(safe.tool_call_id, None); +} + +#[test] +fn reconciliation_result_rejects_identity_cursor_phase_and_status_mismatch() { + let before = user_message("before"); + let after = json!([ + before.clone(), + {"role":"assistant","content":[{"type":"text","text":"result"}]} + ]); + let (store, run_id, attempt) = reconciling_checkpoint( + "provider_in_flight", + json!([before]), + Some("request-1"), + None, + ); + + for (phase, external_id, step, supplied_attempt) in [ + ("safe", "request-1", 0, attempt), + ("provider_in_flight", "wrong-request", 0, attempt), + ("provider_in_flight", "request-1", 1, attempt), + ("provider_in_flight", "request-1", 0, attempt + 1), + ] { + let error = store.record_reconciliation_result( + &run_id, + phase, + external_id, + step, + supplied_attempt, + after.clone(), + ); + assert!(matches!(error, Err(StorageError::InvalidInput(_)))); + assert_eq!( + store + .read_checkpoint(&run_id) + .expect("read checkpoint") + .expect("checkpoint") + .phase, + "provider_in_flight" + ); + } + + // 只有 reconciling run 才允许写回外部已确认结果。 + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "running-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "running-run".into(), + session_id: "running-session".into(), + status: "running".into(), + input: json!({"message": "running"}), + }) + .expect("create run"); + let error = store.record_reconciliation_result( + "running-run", + "provider_in_flight", + "request-1", + 0, + 1, + after, + ); + assert!( + matches!(error, Err(StorageError::InvalidInput(message)) if message.contains("待对账")) + ); +} + +#[test] +fn reconciliation_result_rejects_invalid_or_incomplete_messages() { + let before = user_message("before"); + let (store, run_id, attempt) = reconciling_checkpoint( + "provider_in_flight", + json!([before.clone()]), + Some("request-1"), + None, + ); + let invalid_messages = [ + json!({"role":"assistant"}), + json!([]), + json!([{"role":"user","content":"not-an-array"}]), + json!([{"role":"user","content":[{"type":"text","text":"changed"}]}]), + json!([before.clone()]), + ]; + for messages in invalid_messages { + let error = store.record_reconciliation_result( + &run_id, + "provider_in_flight", + "request-1", + 0, + attempt, + messages, + ); + assert!(matches!(error, Err(StorageError::InvalidInput(_)))); + assert_eq!( + store + .read_checkpoint(&run_id) + .expect("read checkpoint") + .expect("checkpoint") + .phase, + "provider_in_flight" + ); + } + + let call_id = "tool-call-2"; + let (store, run_id, attempt) = reconciling_checkpoint( + "tool_in_flight", + json!([assistant_tool_call_message(call_id)]), + None, + Some(call_id), + ); + let missing_tool_result = json!([ + assistant_tool_call_message(call_id), + {"role":"assistant","content":[{"type":"text","text":"not a tool result"}]} + ]); + let error = store.record_reconciliation_result( + &run_id, + "tool_in_flight", + call_id, + 0, + attempt, + missing_tool_result, + ); + assert!( + matches!(error, Err(StorageError::InvalidInput(message)) if message.contains("tool result")) + ); +} + +#[test] +fn file_database_uses_wal() { + let directory = local_tempdir(); + let path = directory.path().join("agent.db"); + let store = SqliteStore::open(&path).expect("open file db"); + assert_eq!(store.journal_mode().expect("journal mode"), "wal"); +} + +#[test] +fn append_event_enforces_expected_revision() { + let (store, run_id) = store_with_run(); + let event = store + .append_event( + NewEvent { + id: "event-1".into(), + run_id: run_id.clone(), + event_type: "message.completed".into(), + payload: json!({"text": "done"}), + }, + 0, + ) + .expect("append event"); + assert_eq!(event.revision, 1); + assert_eq!( + store.get_run(&run_id).expect("read run").unwrap().revision, + 1 + ); + let conflict = store.append_event( + NewEvent { + id: "event-2".into(), + run_id: run_id.clone(), + event_type: "message.completed".into(), + payload: json!({"text": "stale"}), + }, + 0, + ); + assert!(matches!( + conflict, + Err(StorageError::RevisionConflict { .. }) + )); + assert_eq!(store.list_events(&run_id, 0).expect("events").len(), 1); +} + +#[test] +fn snapshot_attachments_and_jsonl_export_work() { + let (store, run_id) = store_with_run(); + store + .append_event( + NewEvent { + id: "event-1".into(), + run_id: run_id.clone(), + event_type: "turn.started".into(), + payload: json!({"step": 1}), + }, + 0, + ) + .expect("event"); + let snapshot = store + .save_snapshot(NewSnapshot { + session_id: "session-1".into(), + run_id: run_id.clone(), + revision: 1, + state: json!({"status": "running"}), + }) + .expect("snapshot"); + assert_eq!( + store.latest_snapshot(&run_id).expect("snapshot").unwrap(), + snapshot + ); + store + .upsert_approval(NewApproval { + id: "approval-1".into(), + session_id: "session-1".into(), + run_id: run_id.clone(), + tool_call_id: Some("tool-1".into()), + status: "pending".into(), + request: json!({ + "requestId": "approval-1", + "runId": run_id, + "call": { + "id": "tool-1", + "name": "file.read", + "arguments": {"path": "README.md"} + }, + "argumentsHash": "hash-tool-1", + "approvalToken": "token-1", + "expiresAtMs": i64::MAX + }), + arguments_hash: "hash-tool-1".into(), + approval_token: "token-1".into(), + expires_at_ms: i64::MAX, + }) + .expect("approval"); + store + .insert_tool_call(NewToolCall { + id: "tool-1".into(), + session_id: "session-1".into(), + run_id: run_id.clone(), + tool_name: "file.read".into(), + arguments: json!({"path": "README.md"}), + status: "completed".into(), + }) + .expect("tool call"); + let duplicate = store + .insert_tool_call(NewToolCall { + id: "tool-1".into(), + session_id: "session-1".into(), + run_id: run_id.clone(), + tool_name: "file.read".into(), + arguments: json!({"path": "README.md"}), + status: "requested".into(), + }) + .expect("identical tool call insert is idempotent"); + assert_eq!(duplicate.status, "completed"); + assert_eq!(store.list_tool_calls_for_run(&run_id).unwrap().len(), 1); + store + .upsert_external_session(NewExternalSession { + id: "external-1".into(), + session_id: "session-1".into(), + run_id: Some(run_id.clone()), + backend: "codex-app-server".into(), + external_id: "turn-1".into(), + status: "active".into(), + metadata: json!({"auth_ref": "OPENAI_API_KEY"}), + }) + .expect("external session"); + + let mut output = Vec::new(); + let lines = store.export_jsonl(&run_id, &mut output).expect("export"); + assert_eq!(lines, 6); + assert_eq!(output.iter().filter(|byte| **byte == b'\n').count(), lines); + let exported = String::from_utf8(output).expect("utf8"); + assert!(exported.contains("\"kind\":\"run\"")); + assert!(exported.contains("message")); + // 只保存了引用名称,不会把引用解析成密钥值写入数据库。 + assert!(exported.contains("auth_ref")); + + // JSONL 是诊断/迁移边界,不应把可直接用于恢复审批的 bearer token + // 带出;审批自身的稳定身份仍要保留,方便离线审计定位。 + assert!(!exported.contains("token-1")); + let approval_line = exported + .lines() + .map(|line| serde_json::from_str::(line).expect("jsonl line")) + .find(|line| line.get("kind").and_then(serde_json::Value::as_str) == Some("approval")) + .expect("approval export line"); + let approval_record = approval_line.get("record").expect("approval record"); + assert_eq!( + approval_record + .get("id") + .and_then(serde_json::Value::as_str), + Some("approval-1") + ); + assert_eq!( + approval_record + .get("tool_call_id") + .and_then(serde_json::Value::as_str), + Some("tool-1") + ); + assert_eq!( + approval_record + .get("status") + .and_then(serde_json::Value::as_str), + Some("pending") + ); + assert!(approval_record.get("approval_token").is_none()); + assert!( + approval_record + .get("request") + .and_then(|request| request.get("approvalToken")) + .is_none() + ); + + // Export must not mutate the durable approval used by an explicit resume. + let persisted = store + .get_approval("approval-1") + .expect("approval after export") + .expect("persisted approval"); + assert_eq!(persisted.approval_token, "token-1"); + assert_eq!( + persisted + .request + .get("approvalToken") + .and_then(serde_json::Value::as_str), + Some("token-1") + ); +} + +#[test] +fn sensitive_json_is_rejected_before_write() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + let result = store.create_session(NewSession { + id: "sensitive".into(), + agent_id: None, + status: "new".into(), + metadata: json!({"provider": {"api_key": "do-not-save"}}), + }); + assert!(matches!( + result, + Err(StorageError::SensitiveDataRejected { .. }) + )); + assert!(store.get_session("sensitive").expect("read").is_none()); +} + +#[test] +fn snapshot_cannot_be_ahead_of_run() { + let (store, run_id) = store_with_run(); + let result = store.save_snapshot(NewSnapshot { + session_id: "session-1".into(), + run_id, + revision: 1, + state: json!({}), + }); + assert!(matches!(result, Err(StorageError::SnapshotAhead { .. }))); +} + +#[test] +fn run_and_session_status_updates_are_persisted() { + let (store, run_id) = store_with_run(); + let run = store + .update_run(&run_id, "completed", Some(json!({"answer": "ok"}))) + .expect("update run"); + assert_eq!(run.status, "completed"); + assert_eq!(run.output, Some(json!({"answer": "ok"}))); + let session = store + .update_session("session-1", "idle", None) + .expect("update session"); + assert_eq!(session.status, "idle"); +} + +#[test] +fn cancel_request_is_cas_idempotent_and_terminal_safe() { + let (store, run_id) = store_with_run(); + let initial = store.get_run(&run_id).expect("read run").unwrap(); + assert!(!initial.cancel_requested); + assert!(!store.is_cancel_requested(&run_id).expect("cancel flag")); + + let requested = store.request_cancel(&run_id).expect("request cancel"); + assert_eq!(requested.status, "cancel_requested"); + assert!(requested.cancel_requested); + assert!(store.is_cancel_requested(&run_id).expect("cancel flag")); + // 第二次请求不更新时间戳,也不产生新的状态变化。 + assert_eq!( + store.request_cancel(&run_id).expect("repeat cancel"), + requested + ); + + let cancelled = store + .mark_cancelled(&run_id, Some(json!({"reason": "user"}))) + .expect("mark cancelled"); + assert_eq!(cancelled.status, "cancelled"); + assert!(cancelled.cancel_requested); + // 已经 cancelled 的收束和取消请求都保持幂等。 + assert_eq!( + store.mark_cancelled(&run_id, None).expect("repeat mark"), + cancelled + ); + assert_eq!( + store.request_cancel(&run_id).expect("repeat request"), + cancelled + ); + + let active_store = SqliteStore::open_in_memory().expect("open active store"); + active_store + .create_session(NewSession { + id: "active-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create active session"); + active_store + .create_run(NewRun { + id: "active-run".into(), + session_id: "active-session".into(), + status: "running".into(), + input: json!({"message": "complete"}), + }) + .expect("create active run"); + let completed = active_store + .complete_run("active-run", Some(json!({"answer": "ok"}))) + .expect("complete active run"); + assert_eq!(completed.status, "completed"); + // 完成收束同样是幂等的;重复完成不会覆盖已有结果。 + assert_eq!( + active_store + .complete_run("active-run", Some(json!({"answer": "changed"}))) + .expect("repeat complete"), + completed + ); + assert!(matches!( + active_store.request_cancel("active-run"), + Err(StorageError::TerminalRun { .. }) + )); + + let failed_store = SqliteStore::open_in_memory().expect("open failed store"); + failed_store + .create_session(NewSession { + id: "failed-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create failed session"); + failed_store + .create_run(NewRun { + id: "failed-run".into(), + session_id: "failed-session".into(), + status: "running".into(), + input: json!({"message": "fail"}), + }) + .expect("create failed run"); + let failed = failed_store + .fail_run("failed-run", Some(json!({"error": "boom"}))) + .expect("fail run"); + assert_eq!(failed.status, "failed"); + assert_eq!( + failed_store + .fail_run("failed-run", Some(json!({"error": "changed"}))) + .expect("repeat fail"), + failed + ); + + let fail_cancel_race_store = SqliteStore::open_in_memory().expect("open fail race store"); + fail_cancel_race_store + .create_session(NewSession { + id: "fail-cancel-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create fail cancel session"); + fail_cancel_race_store + .create_run(NewRun { + id: "fail-cancel-run".into(), + session_id: "fail-cancel-session".into(), + status: "running".into(), + input: json!({"message": "race"}), + }) + .expect("create fail cancel run"); + fail_cancel_race_store + .request_cancel("fail-cancel-run") + .expect("request fail cancel"); + assert!( + fail_cancel_race_store + .fail_run("fail-cancel-run", Some(json!({"error": "late"}))) + .is_err() + ); + assert_eq!( + fail_cancel_race_store + .get_run("fail-cancel-run") + .expect("read fail cancel run") + .unwrap() + .status, + "cancel_requested" + ); + + let cancelled_store = SqliteStore::open_in_memory().expect("open cancelled store"); + cancelled_store + .create_session(NewSession { + id: "cancel-race-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create cancel race session"); + cancelled_store + .create_run(NewRun { + id: "cancel-race-run".into(), + session_id: "cancel-race-session".into(), + status: "running".into(), + input: json!({"message": "race"}), + }) + .expect("create cancel race run"); + cancelled_store + .request_cancel("cancel-race-run") + .expect("request race cancel"); + assert!( + cancelled_store + .complete_run("cancel-race-run", Some(json!({"answer": "late"}))) + .is_err() + ); + assert_eq!( + cancelled_store + .get_run("cancel-race-run") + .expect("read cancel race run") + .unwrap() + .status, + "cancel_requested" + ); + + let terminal_store = SqliteStore::open_in_memory().expect("open sqlite"); + terminal_store + .create_session(NewSession { + id: "terminal-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create terminal session"); + terminal_store + .create_run(NewRun { + id: "terminal-run".into(), + session_id: "terminal-session".into(), + status: "completed".into(), + input: json!({"message": "done"}), + }) + .expect("create terminal run"); + assert!(matches!( + terminal_store.request_cancel("terminal-run"), + Err(StorageError::TerminalRun { .. }) + )); + assert!(matches!( + terminal_store.mark_cancelled("terminal-run", None), + Err(StorageError::TerminalRun { .. }) + )); + assert!( + !terminal_store + .get_run("terminal-run") + .expect("read terminal run") + .unwrap() + .cancel_requested + ); +} + +#[test] +fn claim_run_allows_one_queued_worker() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "queue-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create queue session"); + store + .create_run(NewRun { + id: "queue-run".into(), + session_id: "queue-session".into(), + status: "queued".into(), + input: json!({"message": "queued"}), + }) + .expect("create queue run"); + + let claimed = store.claim_run("queue-run").expect("claim run"); + assert_eq!(claimed.status, "running"); + assert!(!claimed.cancel_requested); + assert!(store.claim_run("queue-run").is_err()); + + store.request_cancel("queue-run").expect("request cancel"); + assert!(store.claim_run("queue-run").is_err()); +} + +#[test] +fn worker_lease_fences_writes_and_tracks_attempt() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "lease-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "lease-run".into(), + session_id: "lease-session".into(), + status: "queued".into(), + input: json!({"message": "lease"}), + }) + .expect("create run"); + + let (claimed, lease) = store + .claim_run_with_lease("lease-run", "worker-a", "lease-a", Duration::from_secs(30)) + .expect("claim with lease"); + assert_eq!(claimed.status, "running"); + assert_eq!(lease.run_id, "lease-run"); + assert_eq!(lease.worker_id, "worker-a"); + assert_eq!(lease.lease_token, "lease-a"); + assert_eq!(lease.attempt, 1); + assert_eq!( + store.get_run_lease("lease-run").expect("read lease"), + Some(lease.clone()) + ); + + assert!(matches!( + store.claim_run_with_lease("lease-run", "worker-b", "lease-b", Duration::from_secs(30)), + Err(StorageError::LeaseConflict { .. }) + )); + assert!(matches!( + store.heartbeat_run("lease-run", "worker-b", "lease-a", Duration::from_secs(30)), + Err(StorageError::LeaseConflict { .. }) | Err(StorageError::LeaseLost { .. }) + )); + // 无 lease 的旧收束 API 不能绕过 fencing。 + assert!(matches!( + store.complete_run("lease-run", Some(json!({"answer": "late"}))), + Err(StorageError::LeaseConflict { .. }) + )); + + let renewed = store + .heartbeat_run("lease-run", "worker-a", "lease-a", Duration::from_secs(30)) + .expect("heartbeat"); + assert!(renewed.lease_expires_at >= lease.lease_expires_at); + let completed = store + .complete_run_with_lease( + "lease-run", + "worker-a", + "lease-a", + Some(json!({"answer": "ok"})), + ) + .expect("complete with lease"); + assert_eq!(completed.status, "completed"); + assert_eq!(store.get_run_lease("lease-run").expect("read lease"), None); + // 终态幂等,不会被迟到结果覆盖。 + assert_eq!( + store + .complete_run_with_lease( + "lease-run", + "worker-a", + "lease-a", + Some(json!({"answer": "changed"})) + ) + .expect("repeat complete"), + completed + ); +} + +#[test] +fn two_worker_handles_are_fenced_by_the_same_sqlite_lease() { + let directory = local_tempdir(); + let path = directory.path().join("two-workers.db"); + let worker_a = SqliteStore::open(&path).expect("open worker A store"); + worker_a + .create_session(NewSession { + id: "two-worker-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + worker_a + .create_run(NewRun { + id: "two-worker-run".into(), + session_id: "two-worker-session".into(), + status: "queued".into(), + input: json!({"message": "fence"}), + }) + .expect("create run"); + + // 两个独立连接代表两个进程;SQLite CAS 仍只允许一个 owner。 + let worker_b = SqliteStore::open(&path).expect("open worker B store"); + let (_, lease_a) = worker_a + .claim_run_with_lease( + "two-worker-run", + "worker-a", + "token-a", + Duration::from_secs(30), + ) + .expect("worker A claim"); + assert_eq!( + worker_b + .get_run_lease("two-worker-run") + .expect("read shared lease"), + Some(lease_a.clone()) + ); + assert!(matches!( + worker_b.claim_run_with_lease( + "two-worker-run", + "worker-b", + "token-b", + Duration::from_secs(30) + ), + Err(StorageError::LeaseConflict { .. }) + )); + + // 未持有 token 的 worker 不能通过任一收束入口写终态。 + assert!(matches!( + worker_b.complete_run_with_lease( + "two-worker-run", + "worker-b", + "token-b", + Some(json!({"answer": "stale"})) + ), + Err(StorageError::LeaseConflict { .. }) | Err(StorageError::LeaseLost { .. }) + )); + assert!(matches!( + worker_b.fail_run_with_lease( + "two-worker-run", + "worker-b", + "token-b", + Some(json!({"error": "stale"})) + ), + Err(StorageError::LeaseConflict { .. }) | Err(StorageError::LeaseLost { .. }) + )); + assert!(matches!( + worker_b.mark_cancelled_with_lease( + "two-worker-run", + "worker-b", + "token-b", + Some(json!({"reason": "stale"})) + ), + Err(StorageError::LeaseConflict { .. }) | Err(StorageError::LeaseLost { .. }) + )); + assert_eq!( + worker_b + .get_run("two-worker-run") + .expect("read fenced run") + .unwrap() + .status, + "running" + ); + + // 当前 owner 仍可续租并完成,证明拒绝旧 worker 没有破坏有效 lease。 + worker_a + .heartbeat_run( + "two-worker-run", + "worker-a", + &lease_a.lease_token, + Duration::from_secs(30), + ) + .expect("worker A heartbeat"); + let completed = worker_a + .complete_run_with_lease( + "two-worker-run", + "worker-a", + &lease_a.lease_token, + Some(json!({"answer": "owner"})), + ) + .expect("worker A complete"); + assert_eq!(completed.status, "completed"); + assert_eq!(completed.output, Some(json!({"answer": "owner"}))); +} + +#[test] +fn heartbeat_renews_lease_and_blocks_expiry_recovery() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "heartbeat-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "heartbeat-run".into(), + session_id: "heartbeat-session".into(), + status: "queued".into(), + input: json!({"message": "heartbeat"}), + }) + .expect("create run"); + + let (_, initial) = store + .claim_run_with_lease( + "heartbeat-run", + "worker-heartbeat", + "heartbeat-token", + Duration::from_secs(2), + ) + .expect("claim"); + let renewed = store + .heartbeat_run( + "heartbeat-run", + "worker-heartbeat", + "heartbeat-token", + Duration::from_secs(30), + ) + .expect("renew lease"); + assert!(renewed.heartbeat_at >= initial.heartbeat_at); + assert!(renewed.lease_expires_at > initial.lease_expires_at); + assert!(matches!( + store.recover_expired_run("heartbeat-run"), + Err(StorageError::LeaseConflict { .. }) + )); + assert!(matches!( + store.heartbeat_run( + "heartbeat-run", + "other-worker", + "heartbeat-token", + Duration::from_secs(30) + ), + Err(StorageError::LeaseConflict { .. }) | Err(StorageError::LeaseLost { .. }) + )); + + let completed = store + .complete_run_with_lease( + "heartbeat-run", + "worker-heartbeat", + "heartbeat-token", + Some(json!({"answer": "alive"})), + ) + .expect("complete after heartbeat"); + assert_eq!(completed.status, "completed"); +} + +#[test] +fn expired_token_cannot_write_terminal_state_before_reconciliation() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "expired-token-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "expired-token-run".into(), + session_id: "expired-token-session".into(), + status: "queued".into(), + input: json!({"message": "expired"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease( + "expired-token-run", + "worker-old", + "token-old", + Duration::from_millis(1), + ) + .expect("claim short lease"); + wait_until_epoch_ms(lease.lease_expires_at); + + // 到期 token 的三种终态写回都必须失败,且 run 仍保持 running,等待对账。 + assert!(matches!( + store.complete_run_with_lease( + "expired-token-run", + "worker-old", + "token-old", + Some(json!({"answer": "late"})) + ), + Err(StorageError::LeaseLost { .. }) | Err(StorageError::LeaseConflict { .. }) + )); + assert!(matches!( + store.fail_run_with_lease( + "expired-token-run", + "worker-old", + "token-old", + Some(json!({"error": "late"})) + ), + Err(StorageError::LeaseLost { .. }) | Err(StorageError::LeaseConflict { .. }) + )); + assert!(matches!( + store.mark_cancelled_with_lease( + "expired-token-run", + "worker-old", + "token-old", + Some(json!({"reason": "late"})) + ), + Err(StorageError::LeaseLost { .. }) | Err(StorageError::LeaseConflict { .. }) + )); + assert_eq!( + store + .get_run("expired-token-run") + .expect("read expired run") + .unwrap() + .status, + "running" + ); + + // 对账动作只清理 fencing 信息并进入 reconciling,不替旧 worker 重放调用。 + let reconciled = store + .recover_expired_run("expired-token-run") + .expect("reconcile expired run"); + assert_eq!(reconciled.status, "reconciling"); + assert_eq!( + store.get_run_lease("expired-token-run").expect("lease"), + None + ); + assert!(matches!( + store.complete_run_with_lease( + "expired-token-run", + "worker-old", + "token-old", + Some(json!({"answer": "after-reconcile"})) + ), + Err(StorageError::LeaseLost { .. }) | Err(StorageError::LeaseConflict { .. }) + )); +} + +#[test] +fn expired_lease_only_enters_reconciling_and_never_replays() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "recover-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "recover-run".into(), + session_id: "recover-session".into(), + status: "queued".into(), + input: json!({"message": "recover"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease( + "recover-run", + "worker-a", + "lease-a", + Duration::from_millis(1), + ) + .expect("claim short lease"); + wait_until_epoch_ms(lease.lease_expires_at); + + let recovered = store + .recover_expired_run("recover-run") + .expect("recover expired run"); + assert_eq!(recovered.status, "reconciling"); + assert_eq!( + store.get_run_lease("recover-run").expect("read lease"), + None + ); + assert!(matches!( + store.heartbeat_run( + "recover-run", + "worker-a", + "lease-a", + Duration::from_secs(30) + ), + Err(StorageError::LeaseLost { .. }) | Err(StorageError::LeaseConflict { .. }) + )); + // 重复恢复是幂等读,不会创建第二个 attempt 或重新领取。 + assert_eq!( + store + .recover_expired_run("recover-run") + .expect("repeat recovery"), + recovered + ); + assert!(matches!( + store.claim_run_with_lease( + "recover-run", + "worker-b", + "lease-b", + Duration::from_secs(30) + ), + Err(StorageError::LeaseConflict { .. }) + )); +} + +#[test] +fn cancel_requested_lease_can_heartbeat_and_finalize() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "cancel-lease-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "cancel-lease-run".into(), + session_id: "cancel-lease-session".into(), + status: "queued".into(), + input: json!({"message": "cancel"}), + }) + .expect("create run"); + store + .claim_run_with_lease( + "cancel-lease-run", + "worker-a", + "lease-a", + Duration::from_secs(30), + ) + .expect("claim"); + store + .request_cancel("cancel-lease-run") + .expect("request cancel"); + store + .heartbeat_run( + "cancel-lease-run", + "worker-a", + "lease-a", + Duration::from_secs(30), + ) + .expect("heartbeat while cancelling"); + let cancelled = store + .mark_cancelled_with_lease( + "cancel-lease-run", + "worker-a", + "lease-a", + Some(json!({"reason": "user"})), + ) + .expect("cancel with lease"); + assert_eq!(cancelled.status, "cancelled"); + assert_eq!(store.get_run_lease("cancel-lease-run").expect("read"), None); +} + +#[test] +fn expired_cancel_requested_lease_enters_reconciling() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "cancel-recover-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: "cancel-recover-run".into(), + session_id: "cancel-recover-session".into(), + status: "queued".into(), + input: json!({"message": "cancel recovery"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease( + "cancel-recover-run", + "worker-a", + "lease-a", + Duration::from_millis(1), + ) + .expect("claim"); + store + .request_cancel("cancel-recover-run") + .expect("request cancel"); + wait_until_epoch_ms(lease.lease_expires_at); + let recovered = store + .recover_expired_run("cancel-recover-run") + .expect("recover cancelled lease"); + assert_eq!(recovered.status, "reconciling"); + assert!(recovered.cancel_requested); +} + +#[test] +fn existing_v1_database_receives_run_control_migration() { + let directory = local_tempdir(); + let path = directory.path().join("v1-agent.db"); + { + let connection = rusqlite::Connection::open(&path).expect("open old database"); + connection + .execute_batch(include_str!("../migrations/0001_initial.sql")) + .expect("apply v1 schema"); + connection + .execute_batch( + "CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + INSERT INTO schema_migrations(version, applied_at) VALUES (1, 1);", + ) + .expect("record v1 migration"); + } + + let store = SqliteStore::open(&path).expect("upgrade old database"); + store + .create_session(NewSession { + id: "v1-session".into(), + agent_id: None, + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + let run = store + .create_run(NewRun { + id: "v1-run".into(), + session_id: "v1-session".into(), + status: "running".into(), + input: json!({"message": "old"}), + }) + .expect("create run"); + assert!(!run.cancel_requested); + assert_eq!( + store + .request_cancel("v1-run") + .expect("cancel old run") + .status, + "cancel_requested" + ); +} + +#[test] +fn existing_v2_database_receives_worker_lease_migration() { + let directory = local_tempdir(); + let path = directory.path().join("v2-agent.db"); + { + let connection = rusqlite::Connection::open(&path).expect("open v2 database"); + connection + .execute_batch(include_str!("../migrations/0001_initial.sql")) + .expect("apply v1 schema"); + connection + .execute_batch(include_str!("../migrations/0002_run_control.sql")) + .expect("apply v2 schema"); + connection + .execute_batch( + "CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + INSERT INTO schema_migrations(version, applied_at) VALUES (1, 1), (2, 2); + INSERT INTO sessions(id, agent_id, status, metadata_json, created_at, updated_at) + VALUES ('legacy-session', NULL, 'active', '{}', 1, 1); + INSERT INTO runs(id, session_id, status, revision, input_json, created_at, updated_at) + VALUES ('legacy-run', 'legacy-session', 'queued', 0, '{\"message\":\"legacy\"}', 1, 1);", + ) + .expect("seed v2 records"); + } + + let store = SqliteStore::open(&path).expect("upgrade v2 database"); + let legacy = store + .get_run("legacy-run") + .expect("read legacy run") + .expect("legacy run exists"); + assert_eq!(legacy.status, "queued"); + assert!(!legacy.cancel_requested); + assert_eq!(store.get_run_lease("legacy-run").expect("lease"), None); + + // v3 字段应使用默认值,不丢失 v2 的业务记录;升级后可正常领取并递增 attempt。 + let (_, lease) = store + .claim_run_with_lease( + "legacy-run", + "worker-v3", + "token-v3", + Duration::from_secs(30), + ) + .expect("claim migrated run"); + assert_eq!(lease.attempt, 1); + + let connection = rusqlite::Connection::open(&path).expect("inspect upgraded database"); + let version: i64 = connection + .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { + row.get(0) + }) + .expect("migration version"); + assert!( + version >= 5, + "runtime event schema migration was not applied: {version}" + ); + for column in [ + "worker_id", + "lease_token", + "lease_expires_at", + "heartbeat_at", + "attempt", + ] { + let present: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('runs') WHERE name = ?1", + [column], + |row| row.get(0), + ) + .expect("lease column lookup"); + assert_eq!(present, 1, "missing migrated column {column}"); + } + let checkpoint_table: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'run_checkpoints'", + [], + |row| row.get(0), + ) + .expect("checkpoint table lookup"); + assert_eq!(checkpoint_table, 1, "missing migrated checkpoint table"); + let event_schema_column: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('runtime_events') + WHERE name = 'schema_version'", + [], + |row| row.get(0), + ) + .expect("runtime event schema column lookup"); + assert_eq!( + event_schema_column, 1, + "missing runtime event schema column" + ); +} + +#[test] +fn legacy_running_without_lease_enters_reconciling() { + // v2/旧 API 可能留下没有 fencing 信息的 running 记录;不能把它当作 + // 可安全重放,显式 recovery 应先把状态送进 reconciliation gate。 + let (store, run_id) = store_with_run(); + let recovered = store + .recover_expired_run(&run_id) + .expect("recover unleased running run"); + assert_eq!(recovered.status, "reconciling"); + assert!(store.get_run_lease(&run_id).expect("read lease").is_none()); +} + +#[test] +fn stale_run_scan_is_bounded_deterministic_and_excludes_active_work() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + for (session_id, run_id, status) in [ + ("stale-session-a", "stale-run-a", "running"), + ("stale-session-b", "stale-run-b", "running"), + ("expired-session", "expired-run", "queued"), + ("active-session", "active-run", "queued"), + ("queued-session", "queued-run", "queued"), + ] { + store + .create_session(NewSession { + id: session_id.to_owned(), + agent_id: Some("agent-1".to_owned()), + status: "active".to_owned(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: run_id.to_owned(), + session_id: session_id.to_owned(), + status: status.to_owned(), + input: json!({"task": run_id}), + }) + .expect("create run"); + } + let (_, expired_lease) = store + .claim_run_with_lease( + "expired-run", + "worker-expired", + "token-expired", + Duration::from_secs(1), + ) + .expect("claim expired candidate"); + let (_, active_lease) = store + .claim_run_with_lease( + "active-run", + "worker-active", + "token-active", + Duration::from_secs(30), + ) + .expect("claim active run"); + + // 注入一个不依赖 sleep 的边界时间:expired lease 在该时刻恰好失效, + // active lease 仍在未来;无 lease 的 legacy running 记录也应被发现。 + let first = store + .list_stale_run_ids(MAX_STALE_RUN_SCAN_LIMIT, expired_lease.lease_expires_at) + .expect("list stale runs"); + let second = store + .list_stale_run_ids(MAX_STALE_RUN_SCAN_LIMIT, expired_lease.lease_expires_at) + .expect("repeat list stale runs"); + assert_eq!(first, second, "候选排序必须稳定"); + assert_eq!(first.len(), 3); + assert!(first.iter().any(|id| id == "expired-run")); + assert!(first.iter().any(|id| id == "stale-run-a")); + assert!(first.iter().any(|id| id == "stale-run-b")); + assert!(!first.iter().any(|id| id == "active-run")); + assert!(!first.iter().any(|id| id == "queued-run")); + assert!(active_lease.lease_expires_at > expired_lease.lease_expires_at); + + assert_eq!( + store + .list_stale_run_ids(0, expired_lease.lease_expires_at) + .expect("zero limit"), + Vec::::new() + ); + assert!(matches!( + store.list_stale_run_ids(MAX_STALE_RUN_SCAN_LIMIT + 1, expired_lease.lease_expires_at), + Err(StorageError::InvalidInput(message)) if message.contains("不能超过") + )); +} + +#[test] +fn stale_run_scan_finds_expired_reconciling_lease_but_not_active_one() { + let tempdir = local_tempdir(); + let database = tempdir.path().join("reconciling-stale-scan.db"); + let expired_run = "reconciling-expired-run"; + let active_run = "reconciling-active-run"; + + // A normal release clears the lease as it enters reconciliation. Keep the + // token deliberately present here to model a crash between the two durable + // recovery steps in an older worker. + { + let store = SqliteStore::open(&database).expect("open sqlite"); + for (session_id, run_id) in [ + ("reconciling-expired-session", expired_run), + ("reconciling-active-session", active_run), + ] { + store + .create_session(NewSession { + id: session_id.to_owned(), + agent_id: Some("agent-1".to_owned()), + status: "active".to_owned(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: run_id.to_owned(), + session_id: session_id.to_owned(), + status: "queued".to_owned(), + input: json!({"task": run_id}), + }) + .expect("create run"); + } + store + .claim_run_with_lease( + expired_run, + "reconciling-expired-worker", + "reconciling-expired-token", + Duration::from_millis(5), + ) + .expect("claim expired run"); + store + .claim_run_with_lease( + active_run, + "reconciling-active-worker", + "reconciling-active-token", + Duration::from_secs(30), + ) + .expect("claim active run"); + } + + // Simulate the historical split state without keeping the first SQLite + // connection open while the migration/recovery connection writes. + let connection = rusqlite::Connection::open(&database).expect("open inspection connection"); + connection + .execute( + "UPDATE runs SET status = 'reconciling' WHERE id IN (?1, ?2)", + [expired_run, active_run], + ) + .expect("inject reconciling statuses"); + drop(connection); + + let store = SqliteStore::open(&database).expect("reopen sqlite"); + let expired_at: i64 = store + .get_run_lease(expired_run) + .expect("read expired lease") + .expect("expired lease") + .lease_expires_at; + wait_until_epoch_ms(expired_at); + + let candidates = store + .list_stale_run_ids(MAX_STALE_RUN_SCAN_LIMIT, expired_at) + .expect("list stale reconciling runs"); + assert!(candidates.iter().any(|id| id == expired_run)); + assert!(!candidates.iter().any(|id| id == active_run)); +} + +#[test] +fn recovery_clears_only_expired_reconciling_lease_and_is_idempotent() { + let tempdir = local_tempdir(); + let database = tempdir.path().join("reconciling-stale-recovery.db"); + let run_id = "reconciling-residual-run"; + + let lease_expires_at; + { + let store = SqliteStore::open(&database).expect("open sqlite"); + store + .create_session(NewSession { + id: "reconciling-residual-session".to_owned(), + agent_id: Some("agent-1".to_owned()), + status: "active".to_owned(), + metadata: json!({}), + }) + .expect("create session"); + store + .create_run(NewRun { + id: run_id.to_owned(), + session_id: "reconciling-residual-session".to_owned(), + status: "queued".to_owned(), + input: json!({"task": "recover residual lease"}), + }) + .expect("create run"); + let (_, lease) = store + .claim_run_with_lease( + run_id, + "reconciling-residual-worker", + "reconciling-residual-token", + Duration::from_millis(5), + ) + .expect("claim run"); + lease_expires_at = lease.lease_expires_at; + } + let connection = rusqlite::Connection::open(&database).expect("open inspection connection"); + connection + .execute( + "UPDATE runs SET status = 'reconciling' WHERE id = ?1", + [run_id], + ) + .expect("inject reconciling status"); + drop(connection); + wait_until_epoch_ms(lease_expires_at); + + let store = SqliteStore::open(&database).expect("reopen sqlite"); + let recovered = store + .recover_expired_run(run_id) + .expect("clear expired residual lease"); + assert_eq!(recovered.status, "reconciling"); + assert!( + store + .get_run_lease(run_id) + .expect("read cleared lease") + .is_none() + ); + + // A retry after the cleanup is a no-op, while the run remains behind the + // reconciliation gate and is never silently requeued. + let repeated = store + .recover_expired_run(run_id) + .expect("repeat recovery is idempotent"); + assert_eq!(repeated.status, "reconciling"); + assert!( + store + .list_stale_run_ids(MAX_STALE_RUN_SCAN_LIMIT, lease_expires_at) + .expect("scan after cleanup") + .is_empty() + ); +} + +#[test] +fn runtime_id_for_run_returns_none_without_runtime_event() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + assert_eq!( + store.runtime_id_for_run("missing-run").expect("lookup"), + None + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn tool_call_runtime_transaction_commits_row_and_events_together() { + let (store, runtime_id, snapshot, events, call, lease) = tool_call_runtime_fixture(); + let record = store + .create_tool_call_with_runtime_and_lease(DurableToolCallRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(snapshot.revision() - 1), + snapshot: snapshot.clone(), + events: events.clone(), + lease: Some(lease), + }) + .expect("atomic tool request"); + + assert_eq!(record.id, call.id()); + assert_eq!(record.status, "requested"); + assert_eq!( + store.get_tool_call(call.id()).expect("read call").unwrap(), + record + ); + assert_eq!( + store.load(&runtime_id).expect("read runtime"), + Some(snapshot) + ); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("events") + .len(), + 4 + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn tool_call_runtime_transaction_rejects_wrong_lease_without_writing() { + let (store, runtime_id, snapshot, events, call, _) = tool_call_runtime_fixture(); + let error = store + .create_tool_call_with_runtime_and_lease(DurableToolCallRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(snapshot.revision() - 1), + snapshot: snapshot.clone(), + events, + lease: Some(DurableLeaseIdentity { + worker_id: "tool-worker".to_owned(), + lease_token: "stale-token".to_owned(), + }), + }) + .expect_err("stale lease must be fenced"); + assert!(matches!( + error, + StorageError::LeaseLost { .. } | StorageError::LeaseConflict { .. } + )); + assert!(store.get_tool_call(call.id()).expect("read call").is_none()); + assert_eq!( + store + .load(&runtime_id) + .expect("read runtime") + .unwrap() + .revision(), + 3 + ); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("events") + .len(), + 3 + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn tool_call_runtime_transaction_rolls_back_on_runtime_cas_failure() { + let (store, runtime_id, snapshot, events, call, lease) = tool_call_runtime_fixture(); + let mut stale_event = events[0].clone(); + // Keep the event structurally valid for an expected revision of 2, but + // make the persisted runtime (revision 3) win the CAS below. + stale_event.revision = 3; + let mut stale_snapshot = snapshot.clone(); + stale_snapshot.revision = 3; + let error = store + .create_tool_call_with_runtime_and_lease(DurableToolCallRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + runtime_id: runtime_id.clone(), + // The database is at revision 3, while this valid one-event suffix + // is deliberately fenced against stale expected revision 2. + expected_runtime_revision: Some(2), + snapshot: stale_snapshot, + events: vec![stale_event], + lease: Some(lease), + }) + .expect_err("runtime CAS must reject stale revision"); + assert!( + matches!(error, StorageError::RevisionConflict { .. }), + "unexpected transaction error: {error:?}" + ); + assert!(store.get_tool_call(call.id()).expect("read call").is_none()); + assert_eq!(store.load(&runtime_id).expect("read runtime"), { + let events = store.list_runtime_events(&runtime_id).expect("events"); + let mut initial = RuntimeSnapshot::try_new(&runtime_id).expect("initial snapshot"); + for event in &events { + initial = reduce(&initial, event).expect("reduce persisted event"); + } + Some(initial) + }); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("events") + .len(), + 3 + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn complete_tool_call_runtime_transaction_backfills_legacy_row() { + let (store, runtime_id, requested_snapshot, requested_events, call, lease) = + tool_call_runtime_fixture(); + let result = ToolResult::success(call.id(), json!({"echo": "hello"})).expect("result"); + let result_event = RuntimeEvent::tool_result( + &runtime_id, + requested_snapshot.revision() + 1, + 5, + "tool-transaction-run", + &result, + false, + ) + .expect("result event"); + let completed_snapshot = reduce(&requested_snapshot, &result_event).expect("reduce result"); + let record = store + .complete_tool_call_with_runtime_and_lease( + DurableToolCallRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(requested_snapshot.revision() - 1), + snapshot: completed_snapshot, + // The legacy row is missing, so this single transaction also + // replays the request event before the result event. + events: [requested_events[0].clone(), result_event].to_vec(), + lease: Some(lease), + }, + "completed", + json!({"echo": "hello"}), + ) + .expect("backfill legacy tool row"); + assert_eq!(record.status, "completed"); + assert_eq!(record.result, Some(json!({"echo": "hello"}))); + assert_eq!( + store + .list_tool_calls_for_run("tool-transaction-run") + .unwrap() + .len(), + 1 + ); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("events") + .len(), + 5 + ); + assert_eq!(requested_events.len(), 1); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn tool_call_checkpoint_runtime_transaction_commits_all_rows_together() { + let (store, runtime_id, snapshot, events, call, lease) = tool_call_runtime_fixture(); + let checkpoint = DurableCheckpointInput { + run_id: "tool-transaction-run".to_owned(), + phase: "tool_in_flight".to_owned(), + step: 0, + next_step: 1, + messages: json!([{"role": "user", "content": [{"type": "text", "text": "tool transaction"}]}]), + provider_request_id: None, + tool_call_id: Some(call.id().to_owned()), + attempt: 1, + }; + let record = store + .create_tool_call_with_checkpoint_runtime_and_lease( + DurableToolCallCheckpointRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + checkpoint, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(snapshot.revision() - 1), + snapshot: snapshot.clone(), + events, + lease, + }, + ) + .expect("atomic tool/checkpoint request"); + assert_eq!(record.status, "requested"); + let persisted_checkpoint = store + .read_checkpoint("tool-transaction-run") + .expect("read checkpoint") + .expect("checkpoint exists"); + assert_eq!(persisted_checkpoint.phase, "tool_in_flight"); + assert_eq!( + persisted_checkpoint.tool_call_id.as_deref(), + Some(call.id()) + ); + assert_eq!(store.load(&runtime_id).expect("runtime"), Some(snapshot)); + assert_eq!( + store.get_tool_call(call.id()).expect("tool call"), + Some(record) + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn tool_call_checkpoint_runtime_transaction_rolls_back_on_lease_failure() { + let (store, runtime_id, snapshot, events, call, _) = tool_call_runtime_fixture(); + let error = store + .create_tool_call_with_checkpoint_runtime_and_lease( + DurableToolCallCheckpointRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + checkpoint: DurableCheckpointInput { + run_id: "tool-transaction-run".to_owned(), + phase: "tool_in_flight".to_owned(), + step: 0, + next_step: 1, + messages: json!([]), + provider_request_id: None, + tool_call_id: Some(call.id().to_owned()), + attempt: 1, + }, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(snapshot.revision() - 1), + snapshot, + events, + lease: DurableLeaseIdentity { + worker_id: "tool-worker".to_owned(), + lease_token: "stale-token".to_owned(), + }, + }, + ) + .expect_err("stale lease must fence all writes"); + assert!(matches!( + error, + StorageError::LeaseLost { .. } | StorageError::LeaseConflict { .. } + )); + assert!(store.get_tool_call(call.id()).expect("tool call").is_none()); + assert!( + store + .read_checkpoint("tool-transaction-run") + .expect("checkpoint") + .is_none() + ); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("runtime events") + .len(), + 3 + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn tool_call_checkpoint_runtime_transaction_rolls_back_on_runtime_cas_failure() { + let (store, runtime_id, snapshot, events, call, lease) = tool_call_runtime_fixture(); + let mut stale_event = events[0].clone(); + stale_event.revision = 3; + let mut stale_snapshot = snapshot; + stale_snapshot.revision = 3; + let error = store + .create_tool_call_with_checkpoint_runtime_and_lease( + DurableToolCallCheckpointRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + checkpoint: DurableCheckpointInput { + run_id: "tool-transaction-run".to_owned(), + phase: "tool_in_flight".to_owned(), + step: 0, + next_step: 1, + messages: json!([]), + provider_request_id: None, + tool_call_id: Some(call.id().to_owned()), + attempt: 1, + }, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(2), + snapshot: stale_snapshot, + events: vec![stale_event], + lease, + }, + ) + .expect_err("stale runtime revision must roll back all writes"); + assert!(matches!(error, StorageError::RevisionConflict { .. })); + assert!(store.get_tool_call(call.id()).expect("tool call").is_none()); + assert!( + store + .read_checkpoint("tool-transaction-run") + .expect("checkpoint") + .is_none() + ); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("runtime events") + .len(), + 3 + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn complete_tool_call_checkpoint_runtime_transaction_commits_all_rows_together() { + let (store, runtime_id, requested_snapshot, requested_events, call, lease) = + tool_call_runtime_fixture(); + let result = ToolResult::success(call.id(), json!({"echo": "hello"})).expect("result"); + let result_event = RuntimeEvent::tool_result( + &runtime_id, + requested_snapshot.revision() + 1, + 5, + "tool-transaction-run", + &result, + false, + ) + .expect("result event"); + let completed_snapshot = reduce(&requested_snapshot, &result_event).expect("reduce result"); + let record = store + .complete_tool_call_with_checkpoint_runtime_and_lease( + DurableToolCallCheckpointRuntimeCommit { + call: DurableToolCallInput { + id: call.id().to_owned(), + session_id: "tool-transaction-session".to_owned(), + run_id: "tool-transaction-run".to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }, + checkpoint: DurableCheckpointInput { + run_id: "tool-transaction-run".to_owned(), + phase: "safe".to_owned(), + step: 1, + next_step: 2, + messages: json!([]), + provider_request_id: None, + tool_call_id: None, + attempt: 1, + }, + runtime_id: runtime_id.clone(), + expected_runtime_revision: Some(requested_snapshot.revision() - 1), + snapshot: completed_snapshot, + events: [requested_events[0].clone(), result_event].to_vec(), + lease, + }, + "completed", + json!({"echo": "hello"}), + ) + .expect("atomic tool/checkpoint completion"); + assert_eq!(record.status, "completed"); + assert_eq!(record.result, Some(json!({"echo": "hello"}))); + let checkpoint = store + .read_checkpoint("tool-transaction-run") + .expect("read checkpoint") + .expect("checkpoint exists"); + assert_eq!(checkpoint.phase, "safe"); + assert_eq!( + store + .list_runtime_events(&runtime_id) + .expect("events") + .len(), + 5 + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn core_runtime_store_commit_load_and_cas_work() { + let mut store = SqliteStore::open_in_memory().expect("open sqlite"); + let mut snapshot = RuntimeSnapshot::try_new("runtime-1").expect("snapshot"); + snapshot.revision = 1; + let event = RuntimeEvent::runtime_created("runtime-1", 1, 10).expect("event"); + + store + .commit("runtime-1", None, &snapshot, std::slice::from_ref(&event)) + .expect("commit runtime"); + assert_eq!( + store.load("runtime-1").expect("load"), + Some(snapshot.clone()) + ); + assert_eq!( + store.list_runtime_events("runtime-1").expect("events"), + vec![event.clone()] + ); + let mut export = Vec::new(); + assert_eq!( + store + .export_runtime_jsonl("runtime-1", &mut export) + .expect("runtime export"), + 2 + ); + assert_eq!(export.iter().filter(|byte| **byte == b'\n').count(), 2); + + let conflict = store.commit( + "runtime-1", + Some(0), + &snapshot, + std::slice::from_ref(&event), + ); + assert!(conflict.is_err()); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn runtime_event_schema_version_round_trips_through_sqlite() { + let mut store = SqliteStore::open_in_memory().expect("open sqlite"); + let runtime_id = "runtime-event-schema"; + let event = RuntimeEvent::runtime_created(runtime_id, 1, 10).expect("event"); + let mut snapshot = RuntimeSnapshot::try_new(runtime_id).expect("snapshot"); + snapshot = reduce(&snapshot, &event).expect("reduce event"); + + store + .commit(runtime_id, None, &snapshot, std::slice::from_ref(&event)) + .expect("commit event"); + + let events = store + .list_runtime_events(runtime_id) + .expect("read runtime events"); + assert_eq!(events, vec![event.clone()]); + assert_eq!(events[0].schema_version(), RUNTIME_EVENT_SCHEMA_VERSION); + let encoded = serde_json::to_value(&events[0]).expect("serialize persisted event"); + assert_eq!( + encoded + .get("schemaVersion") + .and_then(|value| value.as_str()), + Some(RUNTIME_EVENT_SCHEMA_VERSION) + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn runtime_id_for_run_uses_runtime_event_mapping() { + let mut store = SqliteStore::open_in_memory().expect("open sqlite"); + let mut snapshot = RuntimeSnapshot::try_new("runtime-with-run").expect("snapshot"); + let runtime_created = + RuntimeEvent::runtime_created("runtime-with-run", 1, SystemClock.now_millis()) + .expect("runtime event"); + let run = RunSnapshot::try_new("mapped-run", "agent-1", "resume me", 10).expect("run"); + let run_created = + RuntimeEvent::run_created("runtime-with-run", 2, 10, &run).expect("run event"); + snapshot = reduce(&snapshot, &runtime_created).expect("reduce runtime"); + snapshot = reduce(&snapshot, &run_created).expect("reduce run"); + store + .commit( + "runtime-with-run", + None, + &snapshot, + &[runtime_created, run_created], + ) + .expect("commit runtime"); + + assert_eq!( + store + .runtime_id_for_run("mapped-run") + .expect("lookup runtime"), + Some("runtime-with-run".to_owned()) + ); + assert_eq!( + store.runtime_id_for_run("unknown").expect("lookup missing"), + None + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn core_runtime_store_rejects_invalid_batch_without_writing_state() { + let mut store = SqliteStore::open_in_memory().expect("open sqlite"); + let mut snapshot = RuntimeSnapshot::try_new("runtime-2").expect("snapshot"); + snapshot.revision = 2; + let event = RuntimeEvent::runtime_created("runtime-2", 1, 10).expect("event"); + let result = store.commit("runtime-2", None, &snapshot, std::slice::from_ref(&event)); + assert!(result.is_err()); + assert!(store.load("runtime-2").expect("load").is_none()); + assert!( + store + .list_runtime_events("runtime-2") + .expect("events") + .is_empty() + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn core_runtime_store_rejects_snapshot_not_derived_from_events() { + let mut store = SqliteStore::open_in_memory().expect("open sqlite"); + let event = RuntimeEvent::runtime_created("runtime-mismatch", 1, 10).expect("event"); + let mut snapshot = RuntimeSnapshot::try_new("runtime-mismatch").expect("snapshot"); + snapshot.revision = 1; + // revision/identity 都看似合法,但 snapshot 并未由事件 reducer 产生。 + snapshot.metadata = json!({"unexpected": true}); + let result = store.commit( + "runtime-mismatch", + None, + &snapshot, + std::slice::from_ref(&event), + ); + assert!(result.is_err()); + assert!(store.load("runtime-mismatch").expect("load").is_none()); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn runtime_store_rejects_tampered_snapshot_on_load_and_commit() { + let tempdir = local_tempdir(); + let database = tempdir.path().join("tampered-runtime.db"); + let runtime_id = "runtime-tamper"; + let (snapshot, next_event, next_snapshot) = { + let mut store = SqliteStore::open(&database).expect("open sqlite"); + let initial = RuntimeSnapshot::try_new(runtime_id).expect("snapshot"); + let created = RuntimeEvent::runtime_created(runtime_id, 1, 10).expect("event"); + let snapshot = reduce(&initial, &created).expect("reduce initial event"); + store + .commit(runtime_id, None, &snapshot, std::slice::from_ref(&created)) + .expect("commit initial runtime"); + + let run = + RunSnapshot::try_new("tamper-run", "agent-1", "continue", 11).expect("run snapshot"); + let next_event = RuntimeEvent::run_created(runtime_id, 2, 11, &run).expect("run event"); + let next_snapshot = reduce(&snapshot, &next_event).expect("reduce next event"); + (snapshot, next_event, next_snapshot) + }; + + // A structurally valid JSON value can still diverge from the event log. + // Mutate it through a second connection to model a damaged database or an + // older adapter that wrote an unchecked value. + let mut tampered = serde_json::to_value(&snapshot).expect("encode snapshot"); + tampered["metadata"] = json!({"tampered": true}); + let tampered_json = serde_json::to_string(&tampered).expect("encode tampered snapshot"); + let connection = rusqlite::Connection::open(&database).expect("open inspection connection"); + connection + .execute( + "UPDATE runtime_states SET snapshot_json = ?1 WHERE runtime_id = ?2", + rusqlite::params![tampered_json, runtime_id], + ) + .expect("tamper persisted snapshot"); + drop(connection); + + let mut store = SqliteStore::open(&database).expect("reopen sqlite"); + let load_error = store + .load_runtime_snapshot(runtime_id) + .expect_err("tampered snapshot must fail the storage boundary"); + assert!( + matches!(load_error, StorageError::InvalidInput(message) if message.contains("重放结果")) + ); + + let trait_error = RuntimeStore::load(&store, runtime_id) + .expect_err("RuntimeStore must classify a tampered snapshot"); + assert_eq!(trait_error.kind(), StoreErrorKind::InvalidSnapshot); + + let commit_error = RuntimeStore::commit( + &mut store, + runtime_id, + Some(snapshot.revision()), + &next_snapshot, + std::slice::from_ref(&next_event), + ) + .expect_err("commit must revalidate the current persisted snapshot before replay"); + assert_eq!(commit_error.kind(), StoreErrorKind::InvalidSnapshot); + + let connection = rusqlite::Connection::open(&database).expect("reopen inspection connection"); + let persisted: String = connection + .query_row( + "SELECT snapshot_json FROM runtime_states WHERE runtime_id = ?1", + [runtime_id], + |row| row.get(0), + ) + .expect("read persisted snapshot"); + assert_eq!(persisted, tampered_json); + let event_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM runtime_events WHERE runtime_id = ?1 AND revision = 2", + [runtime_id], + |row| row.get(0), + ) + .expect("count uncommitted event"); + assert_eq!(event_count, 0); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn runtime_store_rejects_event_history_gap_on_load_and_commit() { + let tempdir = local_tempdir(); + let database = tempdir.path().join("broken-runtime-events.db"); + let runtime_id = "runtime-event-gap"; + let (snapshot, next_event, next_snapshot) = { + let mut store = SqliteStore::open(&database).expect("open sqlite"); + let run = + RunSnapshot::try_new("gap-run", "agent-1", "detect a gap", 10).expect("run snapshot"); + let events = [ + RuntimeEvent::runtime_created(runtime_id, 1, 10).expect("runtime event"), + RuntimeEvent::run_created(runtime_id, 2, 11, &run).expect("run event"), + RuntimeEvent::status_changed( + runtime_id, + 3, + 12, + run.run_id(), + agent_runtime_core::RuntimeEventKind::RunStarted, + ) + .expect("started event"), + ]; + let snapshot = events + .iter() + .try_fold( + RuntimeSnapshot::try_new(runtime_id).expect("initial snapshot"), + |snapshot, event| reduce(&snapshot, event), + ) + .expect("reduce history"); + store + .commit(runtime_id, None, &snapshot, &events) + .expect("commit history"); + let next_event = RuntimeEvent::status_changed( + runtime_id, + 4, + 13, + run.run_id(), + agent_runtime_core::RuntimeEventKind::RunCompleted, + ) + .expect("completion event"); + let next_snapshot = reduce(&snapshot, &next_event).expect("reduce completion"); + (snapshot, next_event, next_snapshot) + }; + + // Delete the middle revision through a second connection. The snapshot + // row still looks internally valid, but its audit stream is no longer a + // complete prefix and must not be used as a new commit base. + let connection = rusqlite::Connection::open(&database).expect("open inspection connection"); + connection + .execute( + "DELETE FROM runtime_events WHERE runtime_id = ?1 AND revision = 2", + [runtime_id], + ) + .expect("delete middle event"); + drop(connection); + + let mut store = SqliteStore::open(&database).expect("reopen sqlite"); + let load_error = store + .load_runtime_snapshot(runtime_id) + .expect_err("event history gap must fail the storage boundary"); + assert!(matches!( + load_error, + StorageError::InvalidInput(message) if message.contains("event 数量") + )); + let list_error = store + .list_runtime_events(runtime_id) + .expect_err("listing must reject a broken historical prefix"); + assert!(matches!( + list_error, + StorageError::InvalidInput(message) if message.contains("event 数量") + )); + let trait_error = RuntimeStore::load(&store, runtime_id) + .expect_err("RuntimeStore must classify a history gap"); + assert_eq!(trait_error.kind(), StoreErrorKind::InvalidSnapshot); + + let commit_error = RuntimeStore::commit( + &mut store, + runtime_id, + Some(snapshot.revision()), + &next_snapshot, + std::slice::from_ref(&next_event), + ) + .expect_err("commit must reject a broken historical prefix"); + assert_eq!(commit_error.kind(), StoreErrorKind::InvalidSnapshot); + + let connection = rusqlite::Connection::open(&database).expect("reopen inspection connection"); + let event_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM runtime_events WHERE runtime_id = ?1 AND revision = 4", + [runtime_id], + |row| row.get(0), + ) + .expect("count rejected event"); + assert_eq!(event_count, 0); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn expired_recovery_commits_all_states_and_preserves_checkpoint() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "atomic-recovery-session".into(), + agent_id: Some("agent-1".into()), + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + let run_id = "atomic-recovery-run"; + let runtime_id = "atomic-recovery-runtime"; + store + .create_run(NewRun { + id: run_id.into(), + session_id: "atomic-recovery-session".into(), + status: "queued".into(), + input: json!({"message": "recover"}), + }) + .expect("create run"); + + let mut snapshot = RuntimeSnapshot::try_new(runtime_id).expect("runtime"); + let runtime_created = RuntimeEvent::runtime_created(runtime_id, 1, 1).expect("runtime event"); + let run = RunSnapshot::try_new(run_id, "agent-1", "recover", 1).expect("run snapshot"); + let run_created = RuntimeEvent::run_created(runtime_id, 2, 2, &run).expect("run event"); + snapshot = reduce(&snapshot, &runtime_created).expect("reduce runtime"); + snapshot = reduce(&snapshot, &run_created).expect("reduce run"); + let mut store_for_commit = store.clone(); + store_for_commit + .commit( + runtime_id, + None, + &snapshot, + &[runtime_created.clone(), run_created.clone()], + ) + .expect("commit initial runtime"); + + let (_, lease) = store + .claim_run_with_lease( + run_id, + "worker-atomic", + "token-atomic", + Duration::from_millis(100), + ) + .expect("claim"); + let checkpoint = store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.into(), + phase: "provider_in_flight".into(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: Some("request-atomic".into()), + tool_call_id: None, + attempt: lease.attempt, + }, + "worker-atomic", + "token-atomic", + ) + .expect("save checkpoint"); + wait_until_epoch_ms(lease.lease_expires_at); + + let started = RuntimeEvent::status_changed( + runtime_id, + snapshot.revision() + 1, + 3, + run_id, + agent_runtime_core::RuntimeEventKind::RunStarted, + ) + .expect("started event"); + let mut next = reduce(&snapshot, &started).expect("reduce started"); + let required = RuntimeEvent::status_changed( + runtime_id, + next.revision() + 1, + 4, + run_id, + agent_runtime_core::RuntimeEventKind::ReconciliationRequired, + ) + .expect("reconciliation event"); + next = reduce(&next, &required).expect("reduce reconciliation"); + let recovered = store + .recover_expired_run_with_runtime( + run_id, + runtime_id, + Some(snapshot.revision()), + &next, + &[started, required], + ) + .expect("atomic recovery"); + + assert_eq!(recovered.status, "reconciling"); + assert!(store.get_run_lease(run_id).expect("lease").is_none()); + assert_eq!( + store + .get_session("atomic-recovery-session") + .expect("session") + .unwrap() + .status, + "reconciling" + ); + assert_eq!(store.load(runtime_id).expect("runtime"), Some(next.clone())); + assert_eq!( + store.list_runtime_events(runtime_id).expect("events").len(), + 4 + ); + assert_eq!( + store.read_checkpoint(run_id).expect("checkpoint"), + Some(checkpoint) + ); +} + +#[test] +#[cfg(feature = "core-adapter")] +fn expired_recovery_invalid_runtime_batch_rolls_back_run_and_lease() { + let store = SqliteStore::open_in_memory().expect("open sqlite"); + store + .create_session(NewSession { + id: "rollback-recovery-session".into(), + agent_id: Some("agent-1".into()), + status: "active".into(), + metadata: json!({}), + }) + .expect("create session"); + let run_id = "rollback-recovery-run"; + let runtime_id = "rollback-recovery-runtime"; + store + .create_run(NewRun { + id: run_id.into(), + session_id: "rollback-recovery-session".into(), + status: "queued".into(), + input: json!({"message": "recover"}), + }) + .expect("create run"); + let mut snapshot = RuntimeSnapshot::try_new(runtime_id).expect("runtime"); + let runtime_created = RuntimeEvent::runtime_created(runtime_id, 1, 1).expect("runtime event"); + let run = RunSnapshot::try_new(run_id, "agent-1", "recover", 1).expect("run snapshot"); + let run_created = RuntimeEvent::run_created(runtime_id, 2, 2, &run).expect("run event"); + snapshot = reduce(&snapshot, &runtime_created).expect("reduce runtime"); + snapshot = reduce(&snapshot, &run_created).expect("reduce run"); + let mut store_for_commit = store.clone(); + store_for_commit + .commit(runtime_id, None, &snapshot, &[runtime_created, run_created]) + .expect("commit initial runtime"); + let (_, lease) = store + .claim_run_with_lease( + run_id, + "worker-rollback", + "token-rollback", + Duration::from_millis(100), + ) + .expect("claim"); + store + .save_checkpoint_with_lease( + NewCheckpoint { + run_id: run_id.into(), + phase: "provider_in_flight".into(), + step: 0, + next_step: 0, + messages: json!([]), + provider_request_id: Some("request-rollback".into()), + tool_call_id: None, + attempt: lease.attempt, + }, + "worker-rollback", + "token-rollback", + ) + .expect("save checkpoint"); + wait_until_epoch_ms(lease.lease_expires_at); + let session_before = store + .get_session("rollback-recovery-session") + .expect("session") + .unwrap(); + + let invalid_event = RuntimeEvent::status_changed( + runtime_id, + snapshot.revision() + 2, + 3, + run_id, + agent_runtime_core::RuntimeEventKind::ReconciliationRequired, + ) + .expect("invalid event shape"); + let mut invalid_next = snapshot.clone(); + invalid_next.revision = snapshot.revision() + 1; + let error = store + .recover_expired_run_with_runtime( + run_id, + runtime_id, + Some(snapshot.revision()), + &invalid_next, + std::slice::from_ref(&invalid_event), + ) + .expect_err("invalid revision must roll back"); + assert!(matches!(error, StorageError::InvalidInput(_))); + assert_eq!( + store.get_run(run_id).expect("run").unwrap().status, + "running" + ); + assert!(store.get_run_lease(run_id).expect("lease").is_some()); + assert_eq!( + store.load(runtime_id).expect("runtime"), + Some(snapshot.clone()) + ); + assert_eq!( + store + .get_session("rollback-recovery-session") + .expect("session") + .unwrap(), + session_before + ); + + let started = RuntimeEvent::status_changed( + runtime_id, + snapshot.revision() + 1, + 4, + run_id, + agent_runtime_core::RuntimeEventKind::RunStarted, + ) + .expect("started event"); + let mut next = reduce(&snapshot, &started).expect("reduce started"); + let required = RuntimeEvent::status_changed( + runtime_id, + next.revision() + 1, + 5, + run_id, + agent_runtime_core::RuntimeEventKind::ReconciliationRequired, + ) + .expect("reconciliation event"); + next = reduce(&next, &required).expect("reduce reconciliation"); + let conflict = store + .recover_expired_run_with_runtime( + run_id, + runtime_id, + Some(snapshot.revision() + 1), + &next, + std::slice::from_ref(&required), + ) + .expect_err("revision conflict must roll back"); + assert!(matches!(conflict, StorageError::RevisionConflict { .. })); + assert_eq!( + store.get_run(run_id).expect("run").unwrap().status, + "running" + ); + assert!(store.get_run_lease(run_id).expect("lease").is_some()); +} diff --git a/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md b/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md new file mode 100644 index 000000000..2711aaca3 --- /dev/null +++ b/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md @@ -0,0 +1,762 @@ +# Agent 内核落地 TODO + +> 本文件只记录执行中的当前子集,不是用户最初的完整 P0–P6 计划。完整计划及真实阶段状态见 [`【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md`](./【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md)。 +> +> 除“当前交付”和文末最新复核外,带日期的增量段及其中的计数均为历史快照;不要用它们替代最新源码计数。 + +## 当前交付 + +### 当前范围与消息一致性验收(2026-09-06) + +- [x] 复现并修复正常完成仍重复保存 assistant/tool-call 的缺陷,删除按最终 phase/末条消息内容去重的推断。 +- [x] Host 私有投影游标仅在对应提交成功后推进;压缩前投影旧上下文工具/事件,压缩失败也不重复消费结果。 +- [x] 80 个 Host 单测 + 7 个新增消息持久化集成测试;验证完整历史、工具索引/结果表及从零事件重放,而不是仅验证最终文本。 +- [x] 原始范围已校正:registry 发布、公开许可证、全量 Codex schema、自动 webhook 和跨主机自动调度不是本期阻塞项。 +- [x] 已按原始 P0–P6 核对所声明协议子集、故障恢复与统一 Host 边界;本地出口和证据已收齐。进程重启采用显式 stale reconciliation→外部对账→safe resume,真实发行版 wire/session、远端 runner 和部署级自动查询仍是外部证据,不用本地 fixture 冒充。 + +下列旧增量列表保留实现线索;与本节或权威计划现状表冲突的“原计划开放项”描述不再作为验收门槛。 + +- [x] 独立 workspace 可在无 Genarrative 依赖时编译。 +- [x] Fake Provider 驱动单 Agent 完成一次工具调用闭环。 +- [x] SQLite 保存事件和快照,支持读取已提交状态与 JSONL 导出。 +- [x] MCP 工具目录转换、Skill 显式激活和统一工具/上下文接口已接通;SkillLoader 同时实现 Core SkillSource。 +- [x] PromptBuilder 保留 system/developer/user/context section 边界,ContextAssembler 提供确定性优先级裁剪、UTF-8 字节预算和选中/跳过统计。 +- [x] `ContextObservation` 记录候选 `ContextItem` selected/skipped 的 trusted/untrusted + 四类计数;新字段带 `serde(default)`,旧观测 JSON 仍可读取,Engine 定向测试当前 + 为 55 个。 +- [x] Core 的 `ContentPart`/`Message` 校验已在 PromptSection、PromptBuilder、Provider + request/response 和 ToolDefinition/Registry 边界复用;serde 解码或兼容构造产生的 + 非法嵌套内容、工具参数和工具定义会在 Provider/注册前 fail-closed。 +- [x] MCP 已提供同步 stdio 与 Streamable HTTP transport,HTTP 支持 JSON/SSE 响应;Host 有命名空间工具桥接和显式 allow list。 +- [x] CLI 可通过环境变量激活 Skill、组合提示词并连接 MCP;Skill/MCP 都不会隐式授予工具权限。 +- [x] CLI 提供运行、查看、导出、doctor 和后台 worker 入口。 +- [x] CLI `run --jsonl` 提供稳定的批量 NDJSON:前台输出 `engine_event`/ + `stream_event` 与最终 `result`,后台只输出 `queued` 身份,不冒充实时日志。 +- [x] CLI 提供 cooperative `cancel`,并能重新领取尚未启动的 `queued` run。 +- [x] SQLite/Host 提供 worker lease、心跳和 fencing;过期 `running` 只进入 + `reconciling` gate,CLI 不自动重放外部调用。 +- [x] Host 提供 `AgentBuilder`/`AgentService` 稳定命名(当前是 `AgentHost` 类型 + 别名),并提供 `AgentHost::new`/`builder` 轻量装配入口。 +- [x] Host `DurableEventListener`/`with_durable_event_callback` 在每条 run-level + 审计事件的 SQLite append 提交后通知;它不参与回滚,也不代表 runtime + trace、approval 或终态事务已完成。 +- [x] `agent-runtime` 将 portable durable command/view、snapshot/event CAS、WorkerLease + 和 RunHandle 从 Host 提取为可复用库边界;SQLite-specific 控制面位于 + `agent-runtime-sqlite`。 +- [x] Host 的 RuntimeSnapshot 只读观察通过 `agent-runtime-sqlite::RuntimeService` facade 暴露;共享 + `SqliteStore` 不再由 Host 重复持有;公开 `store()` 仅为旧的审计/导出调用 + 委托给 Runtime facade,内部控制路径不直接拼接 adapter。 +- [x] Core `ExternalObservationSource` 已通过 `agent-runtime-sqlite::RuntimeService::observe_external` 和 + `AgentHost::observe_external` 提供只读控制面入口;观察不会写 checkpoint、消息或 + reconciliation,适配器错误分类原样保留。 +- [x] approval、external session 和 run-level audit event 的常用控制面已收进 + `agent-runtime-sqlite::RuntimeService` 窄 facade;Host 内部已移除重复的 SQLite + 句柄,portable `agent-runtime` 与 SQLite adapter 的职责已物理分离。 +- [x] Host 内部 Runtime-only 事件/快照 CAS 通过 + `agent-runtime-sqlite::RuntimeService::commit_runtime_snapshot`;跨表原子收口仍走 SQLite 专用事务。 +- [x] 新增 `RuntimeSnapshotService` runtime-only 泛型 facade,可注入 + `InMemoryRuntimeStore` 做无 SQLite 的 snapshot/event CAS 合同测试;错误通过 + `RuntimeError::StoreProtocol(StoreError)` 保留 `StoreErrorKind`。 +- [x] `agent-runtime` 已完全 portable,package-only `cargo test -p agent-runtime + --no-default-features` 通过 8 个测试且不编译 SQLite;完整 run/session/lease/checkpoint + 跨表事务由独立 `agent-runtime-sqlite::RuntimeService` 负责。 +- [x] 排队 bundle 与 completed/failed/cancelled 终态使用 SQLite 原子事务同步 + run、runtime、session 和 checkpoint;worker 本地配置失败有 failed 收口。 +- [x] Engine 在 Provider/审批/工具边界写入 fenced 增量 checkpoint;终态与游标 + 原子清理,过期 run 保留游标供对账读取。 +- [x] Provider response identity、批次内重复 tool-call ID、审批 deny 的下一调用 + checkpoint 和整批工具结果完整性均有前置校验/回归测试。 +- [x] 已完成外部对账的 `safe` checkpoint 可通过显式 `resume-safe` 重排队(尚未 + 启动 worker 时可幂等重试);worker 领取后先以 RuntimeStore CAS 补齐消息/工具 + 结果,再从 `next_step` 启动 Engine。 +- [x] `provider_in_flight` / `tool_in_flight` 提供显式外部结果对账协议:调用方必须 + 传入匹配的 request/call ID、step、attempt 和完整消息历史;storage/Host 以 CAS + 校验前缀、工具结果和敏感字段后才切换为 `safe`,绝不自动重放未知调用。 +- [x] Core 提供只读 `ExternalObservationSource` 端口及有界 + `ExternalObservationRequest`/`ExternalObservation` 合同;它只查询既有调用, + 不自动推进 checkpoint、不重排队,也不替宿主完成协议校验。 +- [x] `agent-codex` 提供受限 CLI/App Server 中立 adapter,Host 提供通用 + `ExternalBackend` 工具桥和外部会话记录;未知副作用保持在 reconciliation gate。 +- [x] `agent-runtime-orchestration` 提供 DAG/Delegation/Join/Proposal、状态更新、 + 依赖阻塞、同波次并发和显式下游修复基础;`PersistentCoordinator` 另提供宿主 + 驱动的 ready 计划与 revision-CAS durable claim。 +- [x] MCP TOML `[[mcp.auth]]` 环境变量引用、`agent.toml.example` 和独立 workspace + CI 样例已落地。 +- [x] MCP transport 对不可信输入设置有界读取:stdio 单消息 1 MiB、HTTP 响应 + 4 MiB、SSE 单行 1 MiB、SSE 单事件 4 MiB;tools/resources/prompts 分页各有 + 1024 页上限。 +- [x] 已完成握手的 stdio 与 Streamable HTTP MCP client 可显式转为独占后台通知订阅; + HTTP 由私有 Tokio worker 增量读取 SSE,队列有界、支持 cooperative cancel, + `Drop`/`join` 回收订阅线程和子进程,并报告未消费的 poller/HTTP 错误。自定义 + transport 仍仅支持显式轮询。 +- [x] OpenAI Provider 支持默认 endpoint、`OPENAI_ENDPOINT`/`OPENAI_BASE_URL` + 环境变量、非秘密配置对象和手动 endpoint/base URL;CLI 同步支持 + `OPENAI_MODEL` 及 TOML endpoint 配置;已创建 Provider 还支持校验先行的 + `set_endpoint`/`set_base_url`。 +- [x] OpenAI complete/stream 请求都发送本地 request id 对应的 `Idempotency-Key`;HTTP + 408/429/502/503/504 归类为 `Unavailable` 供 Engine 做有限重试,其它非 2xx 归类为 + `Upstream`。adapter 不自动查询结果或重放;网关不承诺幂等时调用方应将重试预算设为 0。 +- [x] Engine 提供 Provider 软超时和显式工具幂等重试策略;Unknown/Cancelled 结果 + 不会被自动重放,同步 Provider 的硬 I/O 超时仍由适配器负责。 +- [x] 提供 `tests/agent-test-set.jsonl` 与 + `scripts/run-agent-test-set.sh`:默认离线验证 Fake 工具/流式闭环,显式 + `--real` 时可用用户自己的 Responses Provider 做一次 smoke。 +- [x] workspace path 依赖统一声明 `0.1.0` 版本要求;独立 CI 样例同时覆盖 + all-features 与 no-default-features 的 check、test 和 Clippy 门禁;Runtime 的 + portable no-default 验证另以 package-only 命令执行,避免 workspace feature 合并 + 把 SQLite 可选依赖重新带入。 +- [x] 独立发布前预检脚本 `scripts/check-package-manifests.sh`:离线核验 14 个 + crate 的 package 元数据、path 依赖版本约束和打包文件边界;真实 registry + 发布仍需按依赖顺序和许可证策略单独授权。 + +- [x] 已完成 Runtime/SQLite 物理拆分:`agent-runtime-sqlite` 形成 + `runtime-sqlite -> runtime + storage-sqlite` 的无环依赖,承接 RuntimeService、 + SQLite records/error、adapter 和 28 个测试;Host 已迁移且没有新增平行 API 或 + `agent_runtime::RuntimeService` 兼容 re-export。`agent-runtime` 已完全脱离 SQLite, + package-only no-default 测试覆盖 portable facade。 +- [x] 新增无状态 `agent-app` 配置/装配 crate,承接 CLI 共用的 `AgentTomlConfig`、环境/TOML + 优先级、OpenAI endpoint、effective model 和 queued metadata;CLI 只保留 dispatch、 + Host/worker 生命周期与输出逻辑,未复制 Runtime/Host 状态。agent-app 3/3、CLI 24/24。 +- [x] 新增 `agent-app` 后 workspace manifest 数量为 15;旧的“14 个 crate”描述仅属于 + 历史预检快照,新的 manifest/依赖门禁需以当前 workspace 为准。 + +## 后续扩展(不阻塞当前最小闭环) + +- [ ] Provider/工具幂等键的自动查询、长连接结果订阅和自动恢复;这些需要绑定 + 具体外部系统,当前保持显式对账。 +- [x] `ApprovalDecision::Ask` 的决议持久化与显式 resolve/resume API;pending-only + CAS、allow/deny、取消收束和 Host/CLI 端到端恢复均有回归测试。 +- [x] 原始计划要求的 UUIDv7 运行身份已用于 `agent-runtime` 新建 bundle;Host + 自身的历史兼容性局部后缀仍未迁移。 +- [x] Codex 中立 JSONL protocol fixture、CLI `[codex.cli]` 配置校验、受限外部 + adapter,以及窄 V2 `CodexAppServerClient`(thread/turn 身份、通知轮询和 + 精确 interrupt);`CodexAppServerProcess` 已把窄客户端接到显式 argv 的真实 + stdio child,并覆盖有界超时/取消、EOF/异常退出回收和 reader/writer join。 + `codex_0_152_1` 另提供严格版本核对、schema provenance/hash 清单、typed + lifecycle/通知和 server-request handler;完整 generated schema、真实发行版 + wire、审批/工具上层接线和版本特定启动参数仍由上层适配器负责。 +- [x] 有界 Coordinator 的通信去重、全局/按 agent 配额、节点隔离/修复,以及可选 + 的内存/原子 JSON 快照存储已完成;JSON 文件快照的 revision CAS 另有同目录 + sidecar advisory lock;完整持久化多 Agent 调度、Provider/工具自动幂等查询、 + webhook 订阅和未知调用自动恢复仍未实现。 + +## 2026-09-04 当前接缝与审计 + +- [x] `DynRuntimeStore` 以本地 newtype 暴露 `Box`,供 + `RuntimeSnapshotService` 在 no-default-features 下进行动态 Store 注入和 + snapshot/event CAS;`RuntimeService::snapshot_store()` 还可共享同一 SQLite 状态; + portable 6/6、默认 feature lib 28/28、check/Clippy/fmt 已通过。 +- [x] RustSec 离线扫描使用 `cargo-audit 0.22.2` 与固定 advisory-db 提交 + `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5`,扫描锁定依赖 188 个、无漏洞/warning; + 所有临时工具和数据库已清理,wrapper 缺失输入仍 fail-closed。 +- [ ] 完整 durable Runtime/Host 拆分、独立远端 CI 持续审计、正式许可证策略、真实 + Provider/Codex wire/session、自动外部对账/订阅和完整持久化多 Agent 调度仍未完成。 + +## 2026-09-04 输入、取消与依赖复核(历史快照) + +- [x] Core 当前为 28 个单测 + 16 个集成测试:PromptSection、ProviderRequest/Response + 和 ToolDefinition/Registry 的 fallible 边界都会重跑嵌套字段校验; + `section_constructor_rejects_invalid_deserialized_content`、 + `provider_constructors_reject_invalid_nested_messages_parts_and_calls` 覆盖 serde + wire/兼容入口,旧 infallible message 方法只保留为明确未校验的兼容 API。 +- [x] Engine 当前 52 个测试:压缩器拒绝 Tool 角色、结构化工具历史及 summary response + content 中的结构化工具块,默认 stream 保留 tool-call delta/usage;Host 当前 62 个 + 测试,MCP executor 在发送前和等待锁后检查 + cooperative cancel。同步 I/O 已开始后的硬取消仍由具体 adapter 负责。 +- [x] Runtime 默认 27、package-only no-default 6;Storage 5+45(直接 no-default + 4+36),并覆盖持久化 snapshot 篡改与 event-history gap 的 load/commit fail-closed + 回归;CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 28、Codex 80、Orchestration 38。 + 这些是本地命令计数,不替代远端 CI 或真实 Provider/Codex session。 +- [x] 固定 RustSec advisory DB 与 cargo-audit 0.22.2 的本地离线扫描覆盖 188 个锁定依赖, + 退出码 0、无漏洞/warning;临时工具和数据库已清理,wrapper 缺少 DB 时仍 fail-closed。 +- [ ] 独立远端仓库/CI 持续审计、正式 registry/许可证策略、真实外部 wire/session、自动 + 对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-04 RuntimeService 动态 snapshot Store 接缝(当前) + +- [x] 新增 `RuntimeService::snapshot_store()`,返回 `RuntimeSnapshotService` + 并共享当前 SQLite 状态;`runtime_exposes_dynamic_snapshot_store_without_adapter_handle` + 验证 dynamic Store 提交与 Runtime facade 的互相可见性。 +- [x] 受影响的 Runtime 测试为默认 28/28、package-only no-default 6/6;这只补齐 + runtime-only snapshot/event 适配接缝,不把 durable run/session/lease/checkpoint + 误标为已完成拆分。 +- [ ] 远端仓库/CI、正式许可证、真实 Provider/Codex session、自动外部对账/订阅、完整 + 持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成。 + +## 约束 + +- [x] Core 不引入 Tokio、HTTP、SQLite、MCP SDK 或 Codex 源码。 +- [x] 工具副作用统一经过审批策略;Skill 不直接授予权限。 +- [x] 不在本阶段加入 HTTP 服务、多 Agent 调度或 Codex 内核嵌入。 + +## 执行顺序 + +1. [x] Core 契约与 reducer +2. [x] Engine Loop、上下文预算与 Prompt 组合 +3. [x] SQLite 与 Host +4. [x] MCP/Skill 适配器对接 +5. [x] CLI、Fake/真实 Provider 和全量验证 +6. [x] Worker lease、heartbeat、过期 reconciliation gate +7. [x] 增量 checkpoint 边界与失效 lease 下的游标保留 +8. [x] safe step cursor 启动、显式外部对账后的 running resume +9. [x] in-flight Provider/工具的显式外部结果对账协议(不自动重放) +10. [x] Approval binding 重新推导 token,并校验参数哈希、持久化 token、调用方 + token 和过期时间;篡改或过期请求不能 resume + +## 2026-09-03 复核记录 + +- [x] Runtime/Host 外部观察与控制面 facade 已完成定向回归;当时 Runtime 17 个、Host + 32 个测试通过。 +- [x] Codex `0.152.1` 版本化 typed adapter、provenance/hash fixture、server-request + handler 和通知/生命周期回归已完成;Codex crate 当前 50 个测试通过。 +- [x] 变更后重新执行 all/no-default workspace 测试、`-D warnings` check/Clippy、 + rustdoc、fmt、依赖/manifest 预检、独立复制和 Fake 测试集。 +- [ ] 原始计划的独立远程仓库/CI、目标 registry 发布、正式许可证策略、自动外部 + 对账/订阅、真实 Codex 完整 wire 和最终 Host/Runtime 拆分仍未完成;这些不是 + 本地假 fixture 可以替代的验收项。 + +## 2026-09-03 Host Store ownership cleanup + +- [x] 删除 `AgentHost` 内部重复的 `SqliteStore` 字段;`AgentHost::store()` 仍保留 + 兼容签名,但只委托 `RuntimeService::store()`,不再形成第二份装配状态。 +- [x] 删除取消/错误收口路径中未使用的 Store 参数,保持所有 run/runtime/session/ + checkpoint 事务仍由 Runtime/SQLite 原子入口执行。 +- [x] 变更后重新通过 Host 定向编译和 workspace fmt;完整 all/no-default 测试与 + 静态门禁需以本轮最终命令记录为准。 +- [ ] `store()` 公开兼容 accessor、Runtime 对 SQLite 的具体依赖以及真正可替换的 + RuntimeStore 装配仍是后续拆分项;本次不把兼容接口删除或伪装成抽象已完成。 + +## 2026-09-03 Runtime lease facade cleanup + +- [x] Runtime 的带 lease 终态入口统一接收 opaque `&WorkerLease`;Host 不再拆开 + 传递 `worker_id`/`lease_token`,底层 SQLite 参数转换只发生在 Runtime facade。 +- [x] Runtime/Host all-features 与 no-default-features 定向测试通过;SQLite adapter + 的底层 API 和 fencing/事务行为保持不变。 + +## 2026-09-03 MCP subscription 与 Provider endpoint setter + +- [x] MCP stdio 与 Streamable HTTP 独占通知订阅和 poller 错误收口完成;新增回归后 + `agent-mcp` 为 41 个测试,未引入自动应答、自动重连或请求重放;自定义 transport + 仍仅支持显式轮询。 +- [x] `OpenAiProvider` 的可变 endpoint/base URL setter 完成并验证非法输入不会覆盖 + 当前地址;新增本地 fake HTTP complete/stream 回归,实际检查请求路径、鉴权、 + 幂等键和 Responses/SSE 映射;`agent-provider-openai` 当前为 23 个测试。 + +## 2026-09-03 ContextObservation trust counters 与 RuntimeStore facade + +- [x] Engine `ContextObservation` 增加 selected/skipped 的 trusted/untrusted 四类 + 计数,并用 `serde(default)` 保持旧 JSON 读取兼容;Engine 当前定向测试为 45 个。 +- [x] `RuntimeSnapshotService` 提供 runtime-only snapshot/event load + 与 revision CAS commit;Core `InMemoryRuntimeStore` 覆盖无 SQLite 合同。该 facade + 不替代完整 SQLite-backed Runtime,也不负责 run/session/lease/checkpoint 跨表事务。 +- [ ] 原始 P0–P6 仍全部保持“部分完成”;远端仓库/CI、registry 发布、正式许可证策略、 + 自动外部对账/订阅和完整 Codex wire 仍需后续外部证据。 + +## 2026-09-03 Engine hard-bound 回归 + +- [x] 新增 `deserialized_tiny_tool_output_budget_is_rejected_before_side_effects`,覆盖 + 通过 serde 绕过 Builder 最小预算的输入;Engine 在 checkpoint、Provider 调用和 + 工具副作用之前拒绝,避免截断 envelope 超过配置上限。Engine 当前定向测试为 45 个。 +- [ ] 该回归只补齐本地输入预算边界,不改变原始 P0–P6 的“部分完成”状态,也不替代 + 真实 Provider 网络或外部副作用验收。 + +## 2026-09-03 Engine 输入与压缩边界复核 + +- [x] 新增五个回归,覆盖 serde `AgentInput`、非法 `ContextItem`、非法压缩器输出、 + 压缩响应身份和压缩阶段取消;所有失败均发生在主 Provider、checkpoint 或工具 + 副作用之前。 +- [x] 默认与 no-default-features Engine 定向测试均为 45 个通过。 +- [ ] 仍不等同于真实 Provider 硬 I/O timeout、自动外部对账或原始 P2 全量出口。 + +## 2026-09-03 HTTP 订阅、Codex envelope、Provider 路由与编排快照 + +- [x] `agent-mcp` 的 Streamable HTTP client 在完成握手后可显式转成独占 GET/SSE + 后台通知订阅;私有 Tokio worker 增量解析、有界队列、cooperative cancel、 + `Drop`/`join` 和错误传播均有回归。当前 MCP 定向测试为 41 个;自定义 transport + 仍只提供显式轮询,不自动应答、重连或重放。 +- [x] `agent-codex` 拒绝混合 JSON-RPC request/response envelope,并提供中立 + `CodexServerRequestKind` 与 dispatch 前/后的错误分类;当前 Codex 定向测试为 + 53 个。完整 generated schema、真实发行版 wire 和上层审批/工具接线仍未完成。 +- [x] Host ProviderRegistry 以强类型 `ProviderInstanceId`/ + `ProviderProtocolId` 选择并校验 Provider;当前 Host 定向测试为 35 个,直接 + `with_provider` 注入路径仍保持可用。 +- [x] `PersistentCoordinator`/`OrchestrationSnapshotStore` 以 revision CAS 保存和 + 恢复图与 Coordinator 控制面,该阶段 Orchestration 定向测试为 29 个;跨进程锁、 + 自动调度、完整多 Agent Teams 和未知副作用自动恢复仍是后续项。 +- [ ] 以上仍是本地可复核增量,不改变原始 P0–P6 “部分完成”状态;独立远端仓库/CI、 + registry 发布、正式许可证策略、真实 Codex 会话和自动外部对账仍需外部证据。 + +## 2026-09-03 P1/P3/P4/P5 收尾增量 + +- [x] Core reducer 对事件 detail 的 serde 解码值重新执行构造级校验;Core 当前为 + 15 个 unit + 14 个 integration 测试,非法 payload 不改变 snapshot/revision。 +- [x] Runtime generic Store facade 通过 `StoreProtocol(StoreError)` 保留 typed + `StoreErrorKind`;Runtime 当前 20 个测试。MCP 直接构造器认证回归后为 43 个, + Skill 文件增长后的有界正文读取回归后为 23 个。 +- [x] 通用 Codex JSON-RPC channel 的显式 handler 覆盖 initialize、request/event 和 + interrupt,错误回写/传播与混合 envelope 拒绝均有 fixture;Codex 当前 57 个测试。 +- [ ] 这些是原始计划的增量,不改变 P0–P6 的部分完成状态;真实 Codex generated wire、 + 自动外部对账/订阅、完整持久化多 Agent 调度、最终 Host/Runtime 拆分和远端发布仍 + 未完成。 + +## 2026-09-03 审批导出与 CLI 展示脱敏 + +- [x] SQLite `export_jsonl` 的审批记录改为导出专用投影,递归移除顶层及嵌套 + `approval_token`/`approvalToken`,保留审计所需的身份、状态和参数字段;内部 + `ApprovalRecord` 不变,显式 resume 仍能校验原始 binding。 +- [x] CLI `approval list/get/allow/deny` 使用递归脱敏视图,回归覆盖顶层/嵌套 token + 和请求详情保留;CLI 定向测试当前为 14 个。 +- [ ] 该项不改变原始 P0–P6 的部分完成状态,真实上游、远端发布和自动外部对账等外部 + 出口仍需后续证据。 + +## 2026-09-03 最终本地边界增量 + +- [x] Engine 在 Provider response 和 ToolExecutor `ToolResult` 进入完成事件、消息历史、 + safe checkpoint 或下一轮 Provider 请求前重新执行 Core 构造校验;新增 malformed + response/result 回归,Engine 当前 47 个测试,默认与 no-default-features 均通过。 +- [x] Codex CLI 参数过滤规范化识别大小写、连字符、空格、access-token 和 + Authorization/Bearer 形式的凭据参数,同时保留 `--tokenizer`;Codex 当前 58 个测试。 +- [x] SQLite `requeue_reconciled_run` 首次只更新 `reconciling -> queued` 的时间戳;已 + `queued + safe` 的重复 `resume-safe` 返回相同记录且不改变 `updated_at`,Storage + all-features 5+39、直接 no-default 4+32 均通过。 +- [ ] 这些修正只补齐本地边界,不改变原始 P0–P6 的“部分完成”状态;远端仓库/CI、 + registry/许可证、真实 Provider/Codex 会话、自动对账/订阅、完整多 Agent 调度和 + 最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-03 PersistentCoordinator 隔离一致性 + +- [x] `PersistentCoordinator::isolate_node` 释放活动 run 后将图节点持久化为 `Waiting`, + 隔离快照重载不会留下无 active run 的 `Running` 节点。 +- [x] 快照导入补充 Running/active run 双向 agent 一致性校验;无 active run 的 Running + 节点在 Store 保存和 `from_snapshot` 入口均被拒绝。 +- [x] 新增两项回归后 Orchestration 定向测试为 31 个;all/no-default、check、Clippy + 和 fmt 均通过。自动调度、跨进程锁和完整多 Agent Teams 仍未实现。 + +## 2026-09-03 历史复核 TODO 状态 + +- [x] 对齐当前计数:Engine 48、Host 54、MCP 47、CLI 21、Codex 64、Runtime 21、 + Orchestration 38、Skill 27、OpenAI 23、Fake 4;Core 17+14,Storage 默认特性 5+40、 + 直接 no-default-features 4+33。 +- [x] MCP resources/prompts 改为显式 selection;Engine 在模型边界把 untrusted context + 的 system/developer/assistant role 统一降为 User;结构化 tool-call/tool-result 渲染 + 为普通 User 文本,避免伪造工具历史。 +- [x] Host Codex server-request handler 覆盖 `item/tool/call`,known-tool JSON Schema + 在 approval/execution 前校验,并复用 Host policy/router;记录其同步低层性质:不写 + durable approval/checkpoint/audit,Ask 返回 JSON-RPC error;另有已核对 Codex 0.152.1 + dynamic-tool typed bridge(`tool`/`callId` → `contentItems`/`success`)。Host 提供 + 显式 `NamespaceToolResolver`;默认空映射对显式非 `null` namespace 在 approval/execution + 前 fail-closed,命中调用方注册映射后才路由,缺省或 JSON `null` 才按全局工具名处理。 +- [x] Host 增加 `AgentHost::with_runtime`,允许注入已装配的 `RuntimeService` 并与调用方 + 共享同一 durable 控制面;journal mode/JSONL export 也有窄 facade,CLI 不再直接调用 + Store;namespace generic/typed 回归后 Host 当前为 54 个测试。 +- [x] MCP initialize 在发送 `notifications/initialized` 前严格校验 + `protocolVersion=2025-06-18`;缺失、非字符串或未知版本保持未初始化。stdio 同步 + client 的 pending 消息暂存队列另有 `MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限; + 溢出返回协议错误,MCP 当前为 47 + 个测试。 +- [x] Skill frontmatter 解析拒绝未闭合/不匹配引号或列表括号、空列表项和空工具名; + 保持有界行式子集和无 YAML 运行时依赖,Skill 当前为 27 个测试。 +- [x] P4 随 crate fixture 套件已补齐:stdio MCP 假服务器覆盖 initialize、tools/list、 + tools/call;Skill 有效/非法 `SKILL.md` fixture 直接参与 discovery/activation 回归, + 不授予工具权限。 +- [x] Core `SharedRuntimeStore` 为同一进程内多个 Runtime facade 共享一个 Store, + 并发 CAS 与锁中毒 `Unavailable` 映射均有回归;不改变 Core trait 或跨进程语义。 +- [x] Engine Provider response 的 `content` 只接受 Text/Image;结构化 tool call/result + 在边界拒绝并要求分别走 `tool_calls` 或工具结果回填,Engine 当前为 48 个测试。 +- [x] CLI background、approval resume、resume 和 resume-safe 共用 spawn 失败收口;子进程 + 创建失败时只原子标记仍未领取的 run 为 failed,避免遗留 queued;CLI 当前为 21 个测试。 + `doctor` 不联网/启动外部进程,但会按需初始化或迁移本地 SQLite/WAL。 +- [ ] 原始 P0–P6 不得标记完成。远程仓库/CI、registry/正式许可证策略、真实 Provider/Codex + wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、最终 Host/Runtime 拆分仍待 + 后续工作和外部证据。 + +## 2026-09-03 继续执行:编排文件快照跨进程 CAS + +- [x] `JsonFileOrchestrationSnapshotStore` 增加同目录 sidecar advisory lock,将跨进程 + 的 revision CAS 与临时文件替换串成一个写临界区;独立实例竞争回归确认只有一个 + writer 成功,另一个返回 revision conflict。Orchestration 当前 38 个测试。 +- [ ] 该锁不包含自动 scheduler、Runtime run 事务、跨主机协调或完整多 Agent Teams; + 原始 P0–P6 仍全部保持“部分完成”。 + +## 2026-09-03 继续执行增量:namespace resolver、ready claim 与诊断 facade + +- [x] Host 的 generic 与 Codex 0.152.1 typed bridge 共用显式 `NamespaceToolResolver`。 + `StaticNamespaceToolResolver` 支持同一 wire tool 在多个 namespace 映射到不同已注册 + 工具;空值、未知 namespace/工具和映射冲突在 approval/execution 前拒绝,默认 resolver + 保持 fail-closed。 +- [x] `PersistentCoordinator::plan_ready_tasks` 只读生成稳定、有界计划,应用全局/按 Agent + 配额和节点隔离;`claim_ready_tasks` 由宿主提供 run ID,校验 revision、任务归属和 + 数量后复用既有 dispatch/CAS,不启动线程或隐式 scheduler。Orchestration 当前 38 个测试。 +- [x] Runtime/Host 提供 `journal_mode` 与 `export_jsonl` 窄 facade,CLI 的 doctor/export + 常用路径不直接依赖 SQLite Store;`store()` 仍是兼容 accessor。 +- [ ] 以上增量不改变原始 P0–P6 的“部分完成”状态;自动多 Agent 调度、跨主机协调、真实 + Codex 完整 wire/session、自动外部对账/订阅、独立远端仓库/CI、registry/许可证策略和 + 最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-03 继续执行增量:OpenAI 本地 HTTP 回归 + +- [x] `agent-provider-openai` 增加本机 fake HTTP complete/stream 回归,实际验证 + `POST /v1/responses`、Authorization、Idempotency-Key、请求体及 Responses/SSE + 映射;OpenAI 当前 23 个测试,不使用真实 API key 或外网。 +- [ ] 真实网关/上游网络 smoke 仍需调用方提供授权,不能由本地 fixture 标记完成。 + +## 2026-09-03 继续执行增量:OpenAI 配置优先级与共享边界 + +- [x] CLI OpenAI endpoint 来源现在按“环境完整 endpoint → 环境 base URL → TOML 完整 + endpoint → TOML base URL”解析;环境变量无论采用哪种 URL 形式都覆盖 TOML,空环境 + 值按未设置处理。新增纯 helper 和交叉组合回归,CLI 定向测试为 21 个。 +- [x] Core `SharedRuntimeStore` 提供同一进程内多个 Runtime facade 的共享 CAS;并发 + 竞争与锁中毒 `Unavailable` 映射均有回归,跨进程/跨主机语义保持在具体 adapter。 +- [x] MCP initialize 在发送 `notifications/initialized` 前要求 + `protocolVersion=2025-06-18`;缺失、非字符串或不支持版本不会标记连接已初始化, + MCP 定向测试为 46 个。 +- [x] Skill frontmatter 严格拒绝未闭合/不匹配引号或列表括号、空列表项和空工具名, + 定向测试为 25 个,仍不引入 YAML 运行时依赖。 +- [ ] 真实网关、远端 MCP 兼容矩阵、多版本协商、独立远端 CI、正式许可证策略、真实 + Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime + 拆分仍未完成。 + +## 2026-09-03 继续执行:Codex 事件桥与外部会话重开取消 + +- [x] P5 `CodexAppServerBackend::invoke_node_with_runtime_events` 将 request/event/result + 通过 `NodeRuntimeEventMapper` 转成连续 revision 的 Core 事件,并由调用方 sink + 交给 reducer/RuntimeStore;未知事件和身份错配不会静默丢弃。 +- [x] P3/P5 外部会话重开控制已补齐:executor 在 active index 缺失时查询 durable + request-id 别名;`cancel_persisted` 与 `AgentHost::cancel_external_request` 可在 + 新 Host 中显式取消并更新 `cancel_requested`/`cancelled`/`unknown` 生命周期, + 不重新 invoke。新增回归验证原有非敏感 metadata 保留。 +- [x] P6 `AgentCatalog`/`GraphLimits` checked proposal 与 PersistentCoordinator 的 + revision-CAS 原子回归继续通过;自动 scheduler、跨主机协调和完整 Teams 仍不在本轮。 +- [x] 最新计数:Codex 64、Host 54、Runtime 21、Orchestration 38、MCP 47、Skill 27、 + CLI 21、Engine 48、OpenAI 23、Fake 4;Core 17+14,Storage all-features 5+40、 + 直接 no-default-features 4+33。 +- [ ] 原始 P0–P6 仍不得标记完成;真实 Provider/Codex session、远端 CI/registry、正式 + 许可证策略、自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分 + 仍待外部条件或后续专门实现。 + +## 2026-09-03 继续执行:Provider 生命周期与压缩接线 + +- [x] Engine 提供拥有 `Arc` 的 `OwnedProviderContextCompressor`, + 与借用式压缩适配器共用边界校验;Host 可用 `with_provider_context_compressor()` + 显式绑定当前 Provider。 +- [x] OpenAI env/config/手动 key 装配 helper 自动绑定拥有式压缩器;显式注入的专用 + `with_context_compressor()` 不被 Provider 切换覆盖。长上下文 Host 回归确认摘要 + 请求与主 Provider 请求顺序正确。 +- [x] runner 临时父目录统一使用 `TMPDIR` 或 `/var/tmp`,不再把测试产物放到 home + 目录;本轮新增的脚本/文档路径已同步修正。 +- [x] 当前定向计数:Engine 50、Host 58、Runtime 23(package-only no-default-features + 5)、CLI 23、OpenAI 29、Core 23+16、Storage all-features 5+43、直接 + no-default-features 4+36;具体全量门禁以本轮最终命令为准。 +- [ ] Provider/Codex 真实会话、远端仓库/CI、registry/许可证正式策略、自动外部 + 对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-03 继续执行:Provider 协议自描述与空配置边界 + +- [x] Core `ModelProvider::protocol_id()` 作为可选自描述端口;`ProviderRegistry` 注册 + 和解析时核对 adapter 实际协议与 descriptor,缺失 descriptor 协议时从内置自描述 + adapter 补齐;没有自描述能力的自定义 Provider 保持兼容。 +- [x] OpenAI Responses adapter 报告稳定 `openai-responses` 协议;Core/OpenAI/Host + 回归覆盖匹配、错配和实例/协议不同类型,避免把 Responses 请求路由到错误协议。 +- [x] OpenAI endpoint 凭据 query 检查覆盖连字符/空格/点号变体及 `x-api-key`、`auth`、 + `key`;CLI 空白 TOML model 按未设置处理并回退到环境/默认模型。 +- [ ] 自描述校验仍不等同于真实网关兼容矩阵;自动幂等查询/恢复、远端 CI/registry、 + 正式许可证、真实 Provider/Codex 会话和最终 Host/Runtime 拆分继续保留为未完成项。 + +## 2026-09-03 历史复核:Codex 自然退出竞态与最终门禁 + +- [x] Codex 进程 adapter 在 EOF 先于 `try_wait` 可见的窗口中先读取自然退出状态, + 再决定是否 kill process group;非零退出码保持为 `Exit(code)`。no-default-features + 回归重复 20 次通过,修复后双特性 workspace 测试均通过。 +- [x] 当前计数已按最新源码复核:Codex 73、Host 57、Engine 50、Runtime 22、MCP 47、 + Skill 27、CLI 22、Orchestration 38、OpenAI 23、Fake 4、Core 20+16,Storage + all-features 5+43、直接 no-default-features 4+36;静态门禁、rustdoc、独立复制、 + Fake 测试集、编码、diff 和 shell 检查均通过。 +- [ ] 原始 P0–P6 仍不得标记完成;真实 Provider/Codex session、远端发布与 CI、正式 + 许可证策略、自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分 + 仍待外部证据或后续专门实现。 + +## 2026-09-03 Runtime/Host 只读 facade 与编排 fixture 清理 + +- [x] Runtime 增加 `list_runtime_events`、`export_runtime_jsonl`、`get_session`;Host + 提供对应窄 facade,使控制面不必直接借出 SQLite adapter,并明确分离 run-level + 审计事件与 Core runtime 可重放事件。 +- [x] Host 测试已迁移到窄 facade;`AgentHost::store()` 仅作为 deprecated SQLite + 迁移兼容入口保留,仍委托同一个 Runtime,不形成第二份连接所有权。 +- [x] 编排文件重开 fixture 在删除快照时同步删除 sidecar advisory lock;Runtime、Host、 + Orchestration 定向测试与 Clippy 门禁通过。 +- [ ] 这一步不改变原始 P0–P6 的“部分完成”状态;远端仓库/CI、registry/许可证策略、 + 真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和 + 最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-03 Provider 路由持久化与 MCP 能力快照 + +- [x] Host 在创建带 `ProviderTarget` 的 run 时把实例/协议 ID 写入非敏感 + `session.metadata.providerTarget`;queued run claim 前要求当前 Host 选择完全相同的 + target,并再次通过 Registry/adapter 协议校验。缺少该字段的历史 run 保持兼容;错配 + 在 claim 前失败,run 仍为 queued,不会触发 Provider/工具调用。Host 回归覆盖创建、 + 重开缺失路由拒绝和重新装配同一路由后恢复。 +- [x] CLI `mcp list` 改用一次 `McpClient::capability_snapshot()` 同时生成 tools 与 + fingerprint,传播 resources/prompts 发现错误;新增离线 transport 回归确认 + `tools/list` 只请求一次且指纹描述的是同一快照。 +- [x] Provider Registry 增加 `register_checked` typed 注册入口;旧 `register` 保持 + `ContractError` 兼容,新控制面可按重复实例/协议错配类型处理。 +- [x] 当前源码计数(本次复核):Runtime 默认特性 23、package-only + `--no-default-features` 5、OpenAI 29、CLI 23、Core 23+16、Host 58;带日期的 + 旧段落计数均只作历史快照。 +- [x] Runtime `sqlite` 为默认 feature;portable no-default API 只包含 snapshot/event + facade、`RuntimeRunHandle` 和 `WorkerLease`,完整 durable `RuntimeService`/SQLite + adapter 仍由默认特性提供。OpenAI transient HTTP 408/429/502/503/504 映射 + `Unavailable`,其它非 2xx 映射 `Upstream`;complete/stream 都发送 + `Idempotency-Key`,不自动查询或重放。若网关不支持幂等,调用方需关闭重试。 +- [ ] 该增量不改变 P0–P6 的“部分完成”结论;完整 durable Runtime 仍直接依赖 SQLite, + 自动外部结果恢复、远端 CI/registry/许可证和真实协议兼容性仍需后续证据。 + +## 2026-09-03 继续执行:取消阶段门禁与 Codex 零值配置 + +- [x] 新增 `RuntimeService::finish_unclaimed_cancelled_if_safe` 并让 Host/CLI 取消流程 + 统一使用:queued 未启动 run,或无 lease 的 `safe`/`awaiting_approval` checkpoint + 才能直接收束;历史 running、cancel_requested、reconciling、缺失 checkpoint 和 + provider/tool/compacting in-flight 均拒绝直接终态化,必须先走 reconciliation。 + 过期 lease 由 Host 先做 stale probe;新增 Runtime/Host 回归覆盖无 lease 历史记录和 + 过期 in-flight checkpoint。旧 `finish_unclaimed_cancelled` 只保留为低层兼容 escape + hatch,Host/CLI 不调用;移除它需要另行处理公开 API/Storage contract。 +- [x] `CodexCliConfig` 与 `CodexAppServerProcessConfig` 在反序列化和运行时同时拒绝 + `timeout_ms=0`、输出/frame 上限为 0;子毫秒 timeout 和整数转换溢出也 fail-closed, + `doctor/validate` 与实际 child supervisor 使用同一套边界。 +- [x] 当前计数(双特性 workspace 复核):Core 23+16、Engine 50、Runtime 默认 25、 + Runtime package-only no-default 5、Storage all-features 5+43、Storage 直接 + no-default 4+36、Host 60、CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 27、Codex 77、 + Orchestration 38。 +- [x] 全量测试、定向测试、`-D warnings` check/Clippy、rustdoc、fmt、依赖/manifest/独立 + workspace、能力集正负例、编码、diff 和 shell 语法通过;能力 runner 对不存在的 + filter 返回失败而不接受零测试假绿。RustSec wrapper 因本机未提供 + `RUSTSEC_ADVISORY_DB` 保持 fail-closed 未完成状态。 +- [ ] 外部远端仓库/CI、registry/正式许可证策略、真实 Provider/Codex 会话和完整 wire、 + 自动外部对账/订阅、完整持久化多 Agent 调度、最终 Host/Runtime 拆分及低层兼容取消 + 入口的 breaking 清理仍未完成。 + +## 2026-09-03 完成性审计(历史快照) + +- [x] 本地可验证的 P0–P6 增量、双特性 workspace 门禁、能力测试集和文档证据已同步; + 当前没有把历史快照计数或本地 fixture 当成远端/真实协议验收。 +- [ ] Host queued 取消在预读与 worker claim 之间存在窄竞态;现有 Runtime 原子事务会 + 拒绝带 lease 的终态写入并保持 fail-closed,但控制端该次调用可能返回 lease conflict。 + 后续需要一个带 expected queued/no-checkpoint/no-lease 条件的单事务命令,不能放宽旧 + `finish_unclaimed_cancelled` 兼容入口。 +- [ ] 远端仓库/CI、正式 registry/许可证与 RustSec advisory DB、真实 Provider/Codex + 会话、自动外部恢复、完整多 Agent 调度和最终 Runtime/Host 拆分仍需外部条件或 + breaking API 设计;原始计划继续保持“部分完成”。 + +## 2026-09-04 继续执行:取消竞态与 Codex JSONL 空白帧(历史快照) + +- [x] queued 取消的最终条件由 SQLite 在 `BEGIN IMMEDIATE` 事务内再次校验:必须是 + `queued`、未请求取消、无 worker/lease 且没有 checkpoint;领取或写游标赢得竞争 + 时返回 `QueuedCancelConflict`,Runtime 转成 `None`,Host 随后发出 cooperative + `request_cancel`。新增 Runtime 直接竞态回归和 Host 双连接并发回归,未放宽旧的 + 低层 `finish_unclaimed_cancelled` 兼容入口。 +- [x] 安全无主取消的 queued 分支改用同一原子入口;Core runtime 非 `Pending` 时直接 + 视为投影不一致并 fail-closed,避免只写 SQLite 的孤立 `cancel_requested`。 +- [x] Codex JSONL channel 跳过空白 keep-alive 行改为循环,不再递归;8192 行空帧回归 + 已覆盖,单帧上限、版本校验和未知副作用分类未改变。 +- [x] 本轮定向计数:Runtime 26、Storage 5+43(直接 no-default 4+36)、Host 61、 + Codex 78;全 workspace 双特性测试、能力集 10/10、Fake 测试集、独立复制和根 + Vitest 3189/3189 已通过;最终 `fmt`、双特性 `check`/Clippy/rustdoc、编码、 + `git diff --check` 和 shell 语法门禁也已通过;临时构建/依赖目录和独立复制目录已清理。 +- [x] 2026-09-04 使用隔离的 `~/data/tmp/` 工具和 advisory DB 固定运行 + `run-cargo-audit.sh`:`cargo-audit 0.22.2` + RustSec commit + `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 扫描 188 个锁定依赖,退出码 0、无漏洞/ + warning;`Cargo.lock` SHA-256 为 `1a3282fe963b9a53e3d9e24a1507c75f1ee19b9a8648025d4f6da25aca5464d5`。 + 临时目录已清理,未把工具/数据库写入仓库。 +- [ ] Provider/工具自动幂等查询和结果订阅、真实 Provider/Codex 会话、独立远端 + 仓库/CI、registry/正式许可证、完整持久化多 Agent 调度以及最终 Host/Runtime 拆分 + 仍未完成;RustSec 本地扫描已通过,但远端持续审计仍需 runner 提供固定输入。 + +## 2026-09-04 Runtime event history、Skill discovery 与 Codex process bridge(当前) + +- [x] SQLite RuntimeStore 从空快照重放完整 `runtime_events` 并校验 revision 连续性和事件 + 语义;event log 缺失、断档或语义篡改均 fail-closed,load/CAS commit 不继续写入。 + 新增 `runtime_store_rejects_event_history_gap_on_load_and_commit`;Storage + all-features 5+45=50,直接 no-default 4+36=40。 +- [x] Skill discovery/list 从 discovery 起受 `max_body_bytes` 硬上限;新增 + `discovery_and_list_reject正文超过配置上限`,Skill 当前 28。 +- [x] Codex process backend runtime event bridge 覆盖 request→notification→result 顺序及 + 显式 handler 变体;新增 + `app_server_process_backend_runtime_event_bridge_maps_notification_order`,Codex 当前 81。 +- [ ] 这些是原始计划的本地增量,不改变 P0–P6 仍部分完成;真实 Provider/Codex 会话、远端 + CI/registry/许可证、自动外部对账和完整持久化多 Agent 调度仍未完成。 + +## 2026-09-04 ContextItem 嵌套消息构造校验(当前) + +- [x] `ContextItem::try_new` 在上下文值对象边界复用 `Message::validate`,拒绝兼容 + `Message::new`/serde 产生的非法嵌套消息;`context_item_constructor_rejects_invalid_nested_message` + 已通过,Core 当前为 28 个单测 + 17 个集成测试。 +- [ ] 该项只收紧 Core 输入合同,不改变真实 Provider/Codex session、远端 CI/registry/许可证、 + 自动外部对账和最终 Host/Runtime 拆分等原始计划未完成出口。 + +## 2026-09-05 当前 TODO 复核 + +- [x] Core 扩展和 backend 合同已补齐公开 serde/兼容入口校验: + `ToolBinding`、`SkillDefinition`、`SkillActivation`、`AgentDescriptor`、 + `BackendRequest`、`BackendResult`、`ToolContext`;未知副作用结果不会被转换为成功 + 工具结果。Core 当前 30 单测 + 20 个 `core_contracts` 集成 + 1 个 + `tool_context_contracts` 集成。 +- [x] Engine `ContextRequest` 在调用 ContextSource 前校验,Host/Engine 在 MCP、Skill、 + 工具和外部 backend dispatch 前校验;Runtime/Storage/Host 提供只读且有界的 + `list_external_sessions` 候选扫描(状态过滤 32、limit 256、稳定排序)。 +- [x] 最新隔离 all/no-default 测试、check、Clippy、rustdoc、fmt、Storage no-default、 + 依赖/manifest、独立复制、Fake 2/2、能力 10/10、shell、编码和 diff 门禁均通过; + Engine 55、Runtime 36/portable 8、Storage 7+53/6+36、Host 69、Codex 101;公开 + `validate_tool_arguments` 已增加 Core 调用/定义和工具名复验,压缩器公开入口也 + 会复验预算、身份和嵌套消息。 +- [x] 编排、Host、Skill 测试辅助尊重显式 `TMPDIR`,未设置时回退 `~/data/tmp`,不再 + 默认使用系统 `/tmp`。 +- [x] Runtime 第一阶段 `DurableStore` 合同已承接 `prepare_run*`、lease、checkpoint、 + approval、外部会话和 runtime snapshot/CAS,并新增 request-cancel/stale、runtime-aware + finish/recovery command;`SqliteDurableStore` contract 回归通过,当前 Runtime 36 个测试。 + command 仍由 SQLite 单事务执行,少数历史兼容入口暂未收敛。 +- [ ] 待办仍包括独立远端仓库/CI 持续运行、正式 registry/许可证策略、真实 Provider/Codex + wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、最终 Host/Runtime 拆分和 + 低层兼容 API 的 breaking 清理;不能用本地 fixture 代替。 + +## 2026-09-05 DurableStore command 扩展(当前 TODO 复核) + +- [x] `DurableStore` 已承接 run/session 查询、lease、checkpoint、approval、外部会话、 + runtime snapshot/CAS,并新增 cancel/stale、runtime-aware finish/recovery command; + `SqliteDurableStore` contract 回归通过,Runtime 当前 36 个测试(portable no-default 8)。 +- [ ] 仍需完成兼容入口收敛和最终 Runtime/SQLite 依赖反转;不能把现有 adapter command + 路由误标成完成独立存储迁移。原始远端 CI/registry/许可证、真实 Provider/Codex + wire/session、自动外部对账/订阅及完整持久化多 Agent 调度仍保持未完成。 + +## 2026-09-05 DurableStore 查询边界继续收敛(当前 TODO 复核) + +- [x] cancel marker、runtime 身份反查、session 更新和 safe requeue 已加入中立 DurableStore + 合同,并由 Runtime stale/recovery 与安全取消路径复用;session 投影保留 metadata 和时间字段。 +- [ ] 仍需收敛少数诊断/审计直连入口并完成真正的 Runtime/SQLite 依赖反转;外部 CI、真实 + Provider/Codex session、自动对账和完整持久化多 Agent 调度仍不在本地验收范围。 + +## 2026-09-05 工具调用持久化(当前 TODO 复核) + +- [x] Host/Runtime/DurableStore 已承接 ToolRequested/ToolCompleted 的 `tool_calls` 创建、 + 完成、查询和导出;相同 identity 幂等,旧 run 缺失 row 可回填,相关 Runtime/Host/ + Storage 回归通过。 +- [x] `DurableToolCallRuntimeCommit` 将工具调用行和对应 Core runtime snapshot/events + 放进同一个 SQLite IMMEDIATE 事务;lease fencing、CAS 失败回滚和旧 row 回填已有 + Storage 回归,checkpoint 仍保持独立 command。 +- [ ] 工具调用表与 Core runtime/checkpoint 的全局事务合并不另行扩张;自动外部对账、真实 + Provider/Codex session 和最终 Runtime/Host 拆分仍是原计划开放项。 + +## 2026-09-05 Codex session metadata sink(当前 TODO 复核) + +- [x] Host 新增 `CodexRuntimeSessionMetadataSink` 与 `codex_session_metadata_sink` helper, + 将 thread/turn 真实身份按 run 写入 `external_sessions`,支持原地更新和重开读取;Host + 当前 66 个测试。 +- [ ] 完整 generated Codex wire、发行版版本兼容、自动重连/对账仍需外部条件或专门适配器。 + +## 2026-09-06 Codex/MCP 边界硬化(历史快照) + +- [x] Codex cancel post-dispatch 分类、notification alias、argv prefix 安全和 Host handler + context/cancel 前置检查完成;当时 Codex 89、Host 69、MCP 49。 +- [ ] 真实 generated wire、协议级 turn interrupt、自动重连/对账和远端验收仍未完成。 + +## 2026-09-05 DurableRuntime generic facade(当前 TODO 复核) + +- [x] 新增 `DurableRuntime` generic facade,覆盖完整 command/query 转发并 + 支持 `into_store()`;Runtime 当前 36 个测试,generic 所有权和非 SQLite fake contract + 回归已通过。 + +## 2026-09-06 Codex 请求生命周期(当前 TODO 复核) + +- [x] 新增 `agent-runtime-contracts` crate,承接 DurableStore command/view/trait; + `agent-runtime` 已完成 portable 化,SQLite-specific Service/adapter 位于 + `agent-runtime-sqlite`。 +- [x] `CodexSessionMetadataSink::persist_lifecycle` 以默认方法扩展;ProcessBackend 的 + request/cancel 收束会记录 `completed/cancelled/unknown` 及 external ID/取消结果, + `failed` 作为中立状态保留给未来 pre-dispatch 观察。 +- [x] Host sink 使用 metadata merge 和 terminal 防复活;Codex stdio fixture、Host 回归和 + Codex handler durable tool-call 回归已通过,Host 当前 77 个测试。 +- [x] ProcessControl 保存自然/被终止 child 的退出码,长连接 accepted response 在 child + 存活时保持 `active`;迟到 cancel 不再覆盖已完成 lifecycle。 +- [x] Process lifecycle sink 支持自然退出/显式终止的 emit-once 观察,并可转发到 session sink; + Host process observation 使用稳定辅助 external-session 行。 +- [x] 进程级 EOF/terminate/Drop sink 已补入 ProcessControl 并支持 session sink 转发; + `app_server_process_lifecycle_sink_reports_timeout` 与 + `app_server_process_lifecycle_sink_distinguishes_reader_eof` 覆盖 timeout/EOF 观察; + reader I/O 错误细分、协议级 turn interrupt、真实发行版 wire/session 和自动外部对账 + 仍是原计划中的未完成项。 +- [x] 新增独立 `JsonRpcAppServerRouter` transport:后台 reader 按 response ID 分发, + pending/notification 队列有界,支持乱序 response 和并发 `turn/interrupt`,未知/重复 + response、非法 envelope、server request 和队列溢出 fail-closed;真实进程接线仍待完成。 + +## 2026-09-06 DurableRuntime 非 SQLite contract harness(当前 TODO 复核) + +- [x] `cfg(test)` 的 `InMemoryDurableStore` 通过 `DurableRuntime` 覆盖 bundle、lease + claim/release、snapshot CAS、safe requeue、finish 和 expired recovery;Runtime 默认 + 36/36,package-only no-default 8/8。 +- [ ] 该测试 harness 只证明 generic facade 的 adapter 形状,不替代生产 contracts crate、 + SQLite facade 迁移或最终 Runtime/Host 依赖反转。 + +## 2026-09-06 工具调用与 Checkpoint 联合事务(当前 TODO 复核) + +- [x] 新增 `DurableToolCallCheckpointRuntimeCommit` 中立 command,并从 + `DurableStore`、`DurableRuntime`、`RuntimeService` 转发;SQLite adapter 以单个 + `IMMEDIATE` 事务同时提交工具行、checkpoint 和 Core runtime snapshot/events。 +- [x] Storage 回归覆盖 create/complete 成功、错误 lease fencing 和 runtime CAS 失败整体 + 回滚;新增 4 个 core-adapter 测试,all-features Storage 为 7+53,直接 no-default 保持 + 6+36。 +- [ ] Host 仍在 checkpoint listener 与工具 trace 两个既有边界分别提交;在没有共同事件输入 + 时不强行拼接,因此全链路 run/runtime/checkpoint/tool 全局事务仍是开放项。 + +## 2026-09-06 Host 终态与后台队列 metadata(当前 TODO 复核) + +- [x] 无工具的成功纯文本终态把 assistant message 与 `RunCompleted` event 交给同一 + `complete_run_with_lease_and_runtime` 事务;工具/审批/in-flight/取消/错误路径继续 + 保留外部副作用前的安全 checkpoint。Host 当前 77 个测试。 +- [x] `AgentHost::prepare_run_with_messages_and_metadata` 为后台 CLI 提供有界非敏感 + metadata 入口;CLI 在 worker 启动前记录 effective provider/model/providerKind,递归 + 拒绝 secret 字段和超过 16 KiB 的 JSON,当前 CLI 24 个测试。 +- [ ] 普通 Engine checkpoint 尚未与所有工具 trace/终态共享一个事件输入;自动外部对账、 + 真实 Provider/Codex session、远端仓库/CI 和 registry/许可证仍是开放项。 + +## 2026-09-06 多工具与 Provider metadata 回归(当前 TODO 复核) + +- [x] `persist_runtime_trace` 只让整个事件序列的最后一个 `ToolCompleted` 复用最终 Safe + checkpoint;中间结果不会与未来工具游标组成错误联合事务。Host 当前 77 个测试。 +- [x] queue metadata 的 `providerKind` marker 现在和 persisted model 一起在 claim 前校验; + fake/openai kind 可防止同模型切换,legacy provider-only metadata 继续兼容。 + +## 2026-09-06 合并门禁(当前 TODO 复核) + +- [x] workspace 当前 15 个 crate;all/no-default all-targets、Clippy、Rustdoc、fmt、依赖/ + manifest、独立复制、编码和 diff 门禁均通过;agent-app 3/3、CLI 24/24、Host 78/78。 +- [x] 当前 `Cargo.lock` SHA-256 为 + `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`;固定 + `cargo-audit 0.22.2`/RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` + 已扫描当前锁文件 191 个依赖并通过(退出码 0、无漏洞/warning);远端持续审计仍需 CI。 + +## 2026-09-06 审批 checkpoint 联合事务(当前 TODO 复核) + +- [x] `DurableApprovalCheckpointRuntimeCommit`、Runtime facade 和 SQLite `IMMEDIATE` 事务 + 已接入 Host Ask 路径;`agent-runtime-sqlite` 31/31 覆盖成功、幂等、错 lease、CAS 与 + identity 失败回滚,Host 78/78 通过。 +- [ ] Engine callback 先于 approval binding 生成,无法安全消除该窄窗口;全链路 checkpoint/ + approval/tool/终态事务和真实 Provider/Codex/远端验收仍保持开放。 + +- [x] Host 已把 approval 提交前移到审计/trace 回放之前;首个 Ask 在联合事务中物化缺失的 + assistant/tool 消息,trace 只对 awaiting checkpoint 中完全相同的消息去重;Host 双特性 + 78/78 通过。 + +## 2026-09-06 Codex app-server 真实探测(当前 TODO 复核) + +- [x] `scripts/probe-codex-app-server.sh` 默认只执行真实 0.153.4 的 initialize→thread/start, + `--schema` 输出 v2 schema hash/大小;隔离 CODEX_HOME/TMPDIR,成功后关闭进程并清理。 +- [ ] 未执行 turn/start 或 Provider 请求;真实 0.152.1 typed adapter 与 0.153.4 generated + wire/session 的完整兼容仍需版本化适配和授权环境。 + +## 2026-09-06 有界外部会话对账(当前 TODO 复核) + +- [x] Host `ExternalSessionResolver` + `reconcile_external_sessions(limit, resolver)` 已落地, + 固定候选状态、硬 limit、稳定排序和逐条结果均有回归。 +- [x] Completed 只接受 resolver 显式提供的完整消息历史并推进 safe checkpoint;Pending/ + NotFound 保守写回 unknown,完成后不自动 requeue、不重放、不启动 Engine。 +- [ ] 具体 Provider/Codex 自动查询、webhook/订阅、身份核验和跨进程调度仍未实现;不把本地 + fake resolver 当作真实上游验收。 diff --git a/rust/docs/【审计】上下文提示词与Skill边界-2026-09-03.md b/rust/docs/【审计】上下文提示词与Skill边界-2026-09-03.md new file mode 100644 index 000000000..ca612194b --- /dev/null +++ b/rust/docs/【审计】上下文提示词与Skill边界-2026-09-03.md @@ -0,0 +1,118 @@ +# 上下文、提示词与 Skill 边界审计 + +更新时间:`2026-09-05` + +> 文末带“(当前)”的复核段是现状依据;更早的按日期追加段落保留当时证据和计数,均属历史快照。 + +## 审计范围 + +本次只检查独立 `rust/` workspace 中的 `agent-runtime-core`、 +`agent-runtime-engine` 和 `agent-skills`。目标是确认 P1/P2/P4 的上下文、提示词 +和 Skill 合同能被独立替换、按预算运行,并且不把 Skill 元数据误当成工具权限。 +不修改 Host、MCP、Codex 或现有 Genarrative crate。 + +## 源码结论 + +- Core 的 `ContextSource` 只返回候选 `ContextItem`;`ContextItem` 带来源、优先级、 + trusted 标记和对象元数据,但不携带工具授权。`PromptBuilder` 保留 + system/developer/user/context section 的顺序和边界,context 出站时才映射到旧 + `user` 消息角色。 +- Engine 的 `ContextAssembler` 先保留基础消息,再按优先级和注册顺序选择候选项; + 估算单位是 UTF-8 字节,不冒充 Provider token 计费。基础消息超预算时,没有压缩器 + 就返回 `ContextOverflow`;有压缩器时仍固定保留原始 system/developer 消息。 + 工具结果另有独立字节上限,截断结果会带 `truncated`、`reason` 和原始大小元数据。 +- Skill loader 的发现阶段只解析有界 frontmatter 并计算完整文件指纹;显式激活时 + 再校验根目录、符号链接、UTF-8、正文大小、指纹和非空正文。激活产生的不可信 + `ContextItem` 不会创建 `ToolBinding`,所以 `allowed-tools` 只能用于展示/选择, + 不会自动放行脚本或普通工具。 + +## 本轮补齐的确定性缺口 + +行式 frontmatter 扫描器此前对 `description`、`version`、`allowed-tools` 和扩展字段 +采用后值覆盖;这会让同一文件在不同 YAML 解析器或不同加载路径下得到不同的 Skill +定义。现在解析时维护 canonical key 集合: + +- `allowed-tools` 和历史拼写 `allowed_tools` 归并为同一个逻辑键; +- 所有逻辑字段(包括 `name`、已知字段和扩展字段)出现第二次都返回 + `InvalidFrontmatter`; +- 标量的引号必须成对且同类;列表的外围方括号必须成对,列表项和工具名不能为空。 + 解析器仍只实现有界行式子集,遇到这些结构错误会 fail-closed,而不是静默丢弃或截断 + 值;不引入 YAML 依赖。 +- 拒绝发生在 discovery 阶段,因此不会生成带歧义指纹的目录项,也不会进入 Engine。 + +这是一项适配器内的输入合同收紧,不改变 Core 的 `SkillSource` API,也不授予任何 +新权限。 + +## 定向验收 + +```bash +TMPDIR="$HOME/data/tmp" cargo test --locked --manifest-path rust/Cargo.toml \ + -p agent-skills --all-targets --no-fail-fast +cargo fmt --manifest-path rust/Cargo.toml --all -- --check +git diff --check +``` + +本次 Skill crate 共 `27` 个单测通过,其中新增 +`拒绝重复的已知和扩展_frontmatter_字段` 覆盖已知字段、扩展字段以及 +`allowed-tools`/`allowed_tools` 别名冲突;另有回归覆盖未闭合/不匹配引号、列表括号 +及空列表项;随 crate 分发的有效/非法 fixture 还覆盖 metadata-first、显式激活和 +非法 frontmatter discovery 拒绝。其余 P1/P2/P4 证据仍见 +[`【测试】Agent测试集与真实Provider接入-2026-09-02.md`](./【测试】Agent测试集与真实Provider接入-2026-09-02.md) +和 [`【验收】Agent内核与通用程序-2026-09-01.md`](./【验收】Agent内核与通用程序-2026-09-01.md)。 + +2026-09-03 的 Engine 边界复核新增七个回归:serde `AgentInput`、非法 `ContextItem`、 +非法 Provider response、非法 ToolResult、非法压缩器输出、压缩响应身份和压缩阶段 +取消。默认与 no-default-features 的 Engine 定向测试均为 51 个,失败不会进入完成 +事件、消息历史、Provider、checkpoint 或工具副作用。 + +## 2026-09-03 不可信上下文的出站角色边界 + +后续 Host/MCP 接线复核发现,自定义 `ContextSource` 仍可能返回带 system、developer +或 assistant 角色的 `trusted=false` 项。`ContextAssembler` 现在在出站模型消息边界统一 +降级为 User;其中结构化 `tool-call`/`tool-result` 会渲染为普通文本,避免不可信 source +伪造真实工具历史。`selected`/`skipped` 仍保留原始 `ContextItem`,因此来源和 trust +统计不会被改写。trusted 项保持原角色。该补充属于 Engine 的通用边界,不把 MCP 或 Skill +协议实现引入 Kernel。 + +## 保留的边界 + +- frontmatter 仍是有界行式子集,不承诺完整 YAML;需要完整 YAML 语义的调用方应在 + 适配器层另行实现并转换为 Core `SkillDefinition`。 +- Context 预算仍是确定性字节估算,真实 token 计费和 Provider 硬 I/O timeout 由 + 具体适配器负责。 +- Skill 正文、脚本和 MCP 资源仍是外部不可信内容;本次只消除元数据重复歧义,不把 + 任何文件声明转换为权限。 + +## 2026-09-03 fixture 复核 + +`agent-skills/fixtures/skills/review/SKILL.md` 由 discovery 先读取元数据,再由 +`activate_core` 显式加载正文;激活结果的工具集合仍为空。`fixtures/invalid/unclosed-quote/` +在 discovery 阶段被拒绝。fixture 只验证本地 Skill 边界,不代表真实目录审核或脚本执行授权。 +fixture 加入后 `agent-skills` all-features/no-default-features 测试和 `-D warnings` +check/Clippy 均通过。 + +## 2026-09-04 P2 消息、压缩与流式边界(当前) + +- Core 的 `ContentPart::validate`、`Message::validate`、`PromptSection::try_new`、 + `ProviderRequest::try_new` 和 `ProviderResponse::try_new` 会重跑嵌套字段校验; + serde 解码本身仍保持读取兼容,但 `PromptBuilder::build`/`into_messages` 及 Provider + 构造边界会在副作用前拒绝非法文本、图像、工具参数或消息内容。 +- `deserialized_invalid_content_is_rejected_before_provider_request`、 + `section_constructor_rejects_invalid_deserialized_content` 和 + `provider_constructors_reject_invalid_nested_messages_parts_and_calls` 覆盖这些 + 入口;Engine 的 `malformed_provider_response_is_rejected_before_events_or_tool_side_effects` + 仍使用未经构造器校验的 serde wire fixture,验证适配器输出不会在事件、历史或工具 + 副作用前穿透边界。 +- Engine 压缩器拒绝 `Tool` 角色及结构化 `ToolCall`/`ToolResult`,也拒绝 summary + response content 中的结构化工具块,不会静默过滤;默认 `ModelProvider::stream` + 保留 complete 响应的 tool-call delta、usage 和完成事件。 + `compressor_cannot_inject_tool_history`、`compressor_rejects_structured_content_in_summary_response` + 与 `default_stream_preserves_complete_tool_calls_and_usage` 已通过。 +- Skill discovery/list 从 discovery 阶段即执行 `max_body_bytes` 硬上限,正文超过配置上限 + 返回 `BodyTooLarge`;`discovery_and_list_reject正文超过配置上限` 已通过,Skill 当前为 28。 +- 当前定向计数为 Core 30 个单测 + 21 个集成测试、Engine 55;Skill 为 28。公开 + `CompressionRequest::validate` 和 `validate_tool_arguments` 会在直接扩展调用时复验 + 预算、身份、嵌套消息及工具定义/名称;压缩器和同步 Provider 的硬 I/O 终止仍由具体 + 适配器负责,不由上下文或 Core 伪造。 +- `ContextItem::try_new` 也会复用 `Message::validate`;`context_item_constructor_rejects_invalid_nested_message` + 覆盖兼容 `Message::new` 产生的非法嵌套消息。 diff --git a/rust/docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md b/rust/docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md new file mode 100644 index 000000000..da0d0c26b --- /dev/null +++ b/rust/docs/【审计】独立Agent依赖边界与CI验收-2026-09-02.md @@ -0,0 +1,764 @@ +# 独立 Agent 依赖边界与 CI 验收审计 + +> 本文只记录 `rust/` workspace 的可重复本地审计入口和当前限制,不把嵌套在 +> Genarrative 工作区的目录误称为已经发布的独立远程仓库,也不替代原始 P0–P6 +> 计划或功能验收记录。 + +> 本文按日期保留过程快照;涉及“当前”计数以最后一节和最新命令记录为准。拆分后的 +> Runtime/SQLite 依赖边界和最终锁文件审计以文末 2026-09-06 段落为准。 +> 除文末最新“当前”复核段外,按日期追加且含旧计数的段落均为历史快照;阶段标题中的“最新/最终”只表示当时门禁。 + +## 审计结论 + +本轮(2026-09-06)只修改 Host 私有投影与回归验证,未改 Cargo.lock、Core/Engine 端口或 SQLite schema。 +按事件位置衔接 checkpoint 消息和 trace,不依赖最终 phase 或消息内容猜测去重;压缩前投影旧工具/事件。 +80 个 Host 单测、7 个完整历史/重放集成回归及 Fake CLI 的独立只读数据库核验通过。 +workspace 双特性、Clippy、Rustdoc、15 package manifest 与依赖边界检查通过。 + +验收边界按原始附件:公开许可证、registry、跨主机自动调度、全量 Codex schema、自动 webhook 不是必做项。 +后文历史段落把这些项目写成“原计划未完成”的结论不再作为阻塞依据;本地协议/恢复能力仍须实际验证。 + +当前 workspace 可以在不读取父仓库路径的条件下完成 Cargo metadata、依赖边界 +检查和全 workspace 编译检查。`agent-runtime-core` 的依赖方向由脚本显式检查, +不会反向引入 Tokio、Reqwest、SQLite、MCP 或 Codex 适配器。仓库内的 +`.gitea/workflows/agent-runtime-ci.yml` 已包含同一组门禁,并增加了独立复制检查 +和离线 `cargo-audit` 步骤。 + +这只证明本地源码和锁文件满足下述结构性门禁。由于当前目录仍是 +`/data/dsk/Genarrative-master/rust/` 的子目录,尚没有独立远程仓库的发布记录或 +远端 CI 运行记录;这些出口保持未完成状态。依赖漏洞扫描已经在本地用临时固定 +工具和 advisory DB 完成,扫描结果只对该次锁文件和数据库快照负责。 + +## 可重复入口 + +从 `/data/dsk/Genarrative-master` 执行: + +```bash +# 检查 workspace path 依赖,以及 Core 的反向依赖黑名单 +TMPDIR="$HOME/data/tmp" rust/scripts/check-dependencies.sh rust/Cargo.toml + +# 复制到明确的临时目录后,从复制目录之外运行 metadata、依赖检查和 cargo check +AGENT_RUNTIME_VERIFY_TMPDIR="$HOME/data/tmp" rust/scripts/verify-independent-workspace.sh + +# 使用 runner 预先挂载的 RustSec advisory database 做离线漏洞审计 +RUSTSEC_ADVISORY_DB=/path/to/advisory-db \ + CARGO_AUDIT_BIN=/path/to/cargo-audit \ + rust/scripts/run-cargo-audit.sh +``` + +`check-dependencies.sh` 使用 `cargo tree --locked` 和 `cargo metadata --locked`: + +- 只接受 workspace 内部的 path dependency; +- 检查 `agent-runtime-core` 的正常依赖树不得出现 `tokio`、`reqwest`、 + `rusqlite`、`mcp` 或 `codex`; +- 检查 `agent-runtime-contracts` 的正常依赖树不得出现 SQLite、网络传输、MCP、Codex + 或 runtime facade,确保 durable command/view 合同不反向耦合适配器; +- 不把父仓库名称或路径作为依赖判断依据。 + +脚本无参数时按自身位置解析 workspace,显式传入 `Cargo.toml` 时支持从父仓库或 +任意 cwd 传入;两种调用都不会把调用者当前目录的其它 Cargo 项目误当成审计目标。 + +`verify-independent-workspace.sh` 会把 `rust/` 复制到由 +`AGENT_RUNTIME_VERIFY_TMPDIR` 指定的临时父目录(默认 `~/data/tmp`),排除 +`target/` 和复制源 `.git`,然后从复制目录之外运行: + +1. `cargo metadata --locked`; +2. 复制品内的 `scripts/check-dependencies.sh`; +3. 复制品内的 `scripts/check-package-manifests.sh`; +4. `cargo check --locked --workspace --all-targets`。 + +脚本退出时只删除自己创建的、名字带 `agent-runtime-independent.` 的复制目录, +不会清理工作区或 `/tmp` 中的其它内容。若使用自定义相对路径,应先确保该路径 +是明确且可写的临时目录。 + +`run-cargo-audit.sh` 从自身所在目录解析 `rust/` workspace 根目录,因此调用者的 +当前目录不会决定被审计的 `Cargo.lock`;若解析到的目录不是 Cargo workspace,或 +workspace manifest/锁文件缺失,脚本会直接失败。`RUSTSEC_ADVISORY_DB` 是必填的本地 +RustSec advisory-db checkout,`CARGO_AUDIT_BIN` 可选(默认使用 PATH 中的 +`cargo-audit`),二者都由 runner 或调用方预先提供。脚本调用 +`cargo-audit audit --no-fetch --db --file Cargo.lock`,不会安装工具、联网 +拉取或改写 advisory DB;直接调用 cargo-audit 可执行文件时显式传入 `audit` 子命令。 +缺少工具或数据库、或路径不可读时会给出明确错误。除 fake binary 的参数/错误路径 +自测外,2026-09-06 本机还用 `cargo-audit 0.22.2` 和 RustSec advisory-db +`5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 执行了真实离线扫描:加载 1239 条 +advisory、扫描拆分后 `Cargo.lock` 的 190 个依赖,退出码为 0,未报告漏洞或 warning。 +该数据库和工具都位于临时目录,未写入仓库;远端 runner 仍需按同一 wrapper 提供 +固定输入后重新记录自己的结果。 + +## CI 门禁 + +`.gitea/workflows/agent-runtime-ci.yml` 按独立 workspace 根目录编写,当前步骤 +依次覆盖: + +- `cargo fmt --all -- --check`; +- `RUSTFLAGS='-D warnings' cargo check --locked --workspace --all-targets --all-features`; +- `check-dependencies.sh`、`check-package-manifests.sh`、离线 `run-cargo-audit.sh` 和独立 workspace 复制检查; +- `cargo test --locked --workspace --all-features --no-fail-fast`; +- `cargo check`、`cargo test` 和 `cargo clippy` 的 `--no-default-features` 门禁; +- `RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps`; +- `cargo clippy --locked --workspace --all-features --all-targets -- -D warnings`。 + +`rust-toolchain.toml` 将独立 workspace 固定到 Rust 1.96,并声明 `rustfmt` 与 +`clippy` 组件;runner 必须预装这两个组件(workflow 保持 `RUSTUP_AUTO_INSTALL=0`, +不依赖隐式联网安装)。 + +workspace 内部 path 依赖统一声明了 `version = "0.1.0"`,因此每个 crate 的 +`cargo package --list` manifest 预检均可通过。`scripts/check-package-manifests.sh` +还会离线核验 14 个 crate 的 description、rust-version、内部 path 依赖和打包文件 +边界;完整 package/publish 仍需先把内部 crate 按依赖顺序发布到目标 registry,当前 +没有把未发布的内部包冒充为 registry 产物。 + +该文件可以在真正的独立仓库根目录直接运行;它放在当前父仓库的 `rust/` 下, +并不等于 Gitea 已经执行过该 workflow。远端仓库创建、推送、CI 结果和权限配置 +需要单独授权与验收。 + +## 当前本地证据与限制 + +- 结构性依赖脚本、发布 manifest 预检和独立复制脚本是离线可运行的;它们不访问真实 Provider、MCP + 服务器或 Codex 服务。 +- 最近一次本地 all-features workspace 回归通过:CLI 24、Host 80 单测 + 7 集成、MCP 52、Engine + 55、OpenAI 29、Fake 4、Runtime portable 8 + Runtime SQLite 31、Codex 101、Orchestration 40、Skill 28;Core + 为 30 个单元测试加 21 个集成测试;Storage 为 7 个单元测试加 53 个集成测试(共 + 60,`agent-runtime` 传递启用 `core-adapter`,含 checkpoint/approval、工具调用联合事务、篡改 snapshot + 与 event-history gap 回归)。Storage 直接关闭默认特性时为 42 个测试(6+36); + workspace `--no-default-features` 回归也通过。以上是本地命令的当前计数,不是远端 CI 记录。 +- MCP transport 的不可信输入有固定上限:stdio 单条消息 1 MiB、Streamable HTTP + 响应 4 MiB、SSE 单行 1 MiB、单个 SSE 事件的 data 4 MiB;`tools/list`、 + `resources/list` 和 `prompts/list` 各最多 1024 页。超限在解析/读取层拒绝, + 不代表业务工具结果可以绕过上层上下文预算。 +- 完成握手的 stdio/Streamable HTTP client 可显式转为独占通知订阅;HTTP 由私有 + Tokio worker 增量读取 SSE。订阅队列容量有硬上限,支持 cooperative cancel, + `Drop`/`join` 回收线程和 child,并传播尚未消费的 poller/HTTP 错误。自定义 transport + 仍不提供长连接订阅;后台订阅不会自动应答、重连或重放。 +- `agent-runtime::RuntimeSnapshotService` 只封装 runtime-only + snapshot/event load 与 revision CAS commit,可注入 Core `InMemoryRuntimeStore` 做 + 无 SQLite 合同测试;完整 run/session/lease/checkpoint 跨表事务仍由当前 + SQLite-backed `RuntimeService` 负责,不应把该泛型 facade 解读为已完成存储替换。 +- `agent-runtime-contracts` 现在承载 `DurableToolCallRuntimeCommit`;Storage 的联合事务 + 回归证明工具调用行与 Core runtime snapshot/events 可在一次 IMMEDIATE 事务中一起提交, + 但 checkpoint 仍是独立 command。 +- Engine `ContextObservation` 只统计候选 `ContextItem` 的 selected/skipped + trusted/untrusted 四类计数;字段带 `serde(default)`,旧 JSON 仍可读。这是观测元数据 + 边界,不是模型 token 计费或完整上下文审计。 + +## 2026-09-03 敏感输出边界复核 + +- SQLite JSONL approval 导出和 CLI approval list/get/allow/deny 展示均使用独立递归 + 脱敏投影,移除 binding token 字段;内部恢复记录仍保留,避免把可恢复凭据复制到 + 诊断产物或 stdout。 +- 回归分别覆盖导出值未泄漏且原始记录未变、CLI 顶层/嵌套 token 不输出;这不替代 + 真实外部审批系统或完整日志汇聚审计。 +- CLI `run --jsonl` 是同步完成后的稳定 NDJSON 批次:前台 run 按顺序输出 + `engine_event`/`stream_event` 后追加 `result`,后台 run 只输出 `queued`;它不是 + worker 的实时日志通道。Host 的 `AgentBuilder`/`AgentService` 当前是 + `AgentHost` 类型别名,`DurableEventListener` 在每条审计 `events` 行的 SQLite + append 提交后调用,不表示整次 run 事务完成,也不参与回滚。 +- `Cargo.lock` 被所有 Cargo 命令以 `--locked` 约束,能发现锁文件漂移,但不提供 + 漏洞判断。 +- 独立 workspace 的 `rust-toolchain.toml` 已声明 Rust 1.96、`rustfmt` 和 `clippy`; + 本机补齐 1.96 的 Clippy 组件后,默认 `cargo clippy`(all-features 与 + no-default-features)均以 `-D warnings` 通过。远端 runner 仍需预装同一组件, + workflow 不会自动联网安装。 +- `cargo-audit`/`cargo-deny` 需要漏洞数据库或许可证数据库;当前没有将数据库、 + 缓存或凭据写入仓库。`cargo-audit` 的本地离线扫描已按上面的固定版本/提交通过, + `cargo-deny 0.20.2` 也已在本地离线生成许可证清单;由于 BSL-1.0、LGPL-2.1-or-later、 + Unlicense 以及内部 `UNLICENSED` crate 的发布策略尚未取得确认,未新增会掩盖策略 + 决策的 `deny.toml`,所以正式许可证门禁、独立仓库远端 CI 和 runner 自己的扫描记录 + 仍未完成。 +- 通过独立复制检查不代表已完成真实远程仓库发布,也不代表完整 P0–P6 功能、 + 真实 Codex wire 或生产多 Agent 调度已经完成。 + +## 2026-09-03 变更后门禁复核 + +- Runtime/Host 的 `ExternalObservationSource` facade、approval/session/audit 窄入口 + 和 Codex `0.152.1` typed adapter 合入后,all-features/no-default-features workspace + test、`-D warnings` check/Clippy、rustdoc、fmt、依赖边界、package manifest 预检、 + 独立复制和 Fake 测试集均重新通过。 +- 该阶段测试计数为 Runtime 17、Host 32、Codex 50;本机未使用真实 API key、真实 + Codex 会话或远端 CI。版本化模块只依据 `protocol-manifest.json` 的 provenance/hash + 和本机 `codex-cli 0.152.1` 核对结果,不能替代完整 generated schema 或发行版 + wire 验收。 +- 远端仓库/CI、目标 registry 发布、正式许可证策略和自动外部对账仍需外部授权与 + 证据;本地检查不会把这些状态改写为完成。 + +## 2026-09-03 Host Store ownership cleanup + +- `AgentHost` 不再重复持有 `SqliteStore`;内部 durable 路径统一使用 + `RuntimeService`,公开 `store()` 只作为兼容性委托 accessor。 +- 该调整不改变 SQLite 事务、lease fencing 或测试数据库连接的所有权;当前 + Runtime 仍是 SQLite-backed 具体实现,不能据此宣称已完成可替换存储或远端发布。 + +## 2026-09-03 Runtime lease facade cleanup + +- Runtime 的 `complete/fail/mark_cancelled` 带 lease facade 统一接收 opaque + `&WorkerLease`;worker/token 字符串只在 Runtime 内部转换给 SQLite adapter。 +- Runtime/Host all-features 与 no-default-features 定向测试及编译通过,未改变 Core + 依赖边界,也未把底层 SQLite API 或自动恢复能力误报为已移除/已实现。 + +## 2026-09-03 MCP subscription 与 OpenAI endpoint setter + +- `agent-mcp` 新增 stdio/Streamable HTTP 独占后台通知订阅,HTTP 使用私有 Tokio + current-thread worker 增量读取 SSE;有界队列、cooperative cancellation、`Drop`/`join` + 回收及满队列/解析/HTTP 状态错误传播均有回归。新增回归后定向测试为 41 个;自定义 + transport、后台路由、自动应答、自动重连和请求重放仍未实现。 +- `OpenAiProvider` 新增可变 `set_endpoint`/`set_base_url`,复用构造阶段的 URL 安全 + 校验,并验证失败保留旧地址;新增本地 HTTP complete/stream endpoint 回归后 OpenAI 定向测试为 23 个。该能力不改变密钥只读环境 + 引用和 Responses 协议边界。 + +## 2026-09-03 ContextObservation trust counters 与 RuntimeStore facade + +- Engine 的 `ContextObservation` 新增 selected/skipped 的 trusted/untrusted 四类计数, + 并以 `serde(default)` 保持历史观测 JSON 的读取兼容;对应定向测试使 Engine 当前为 + 40 个(39 个为本段记录时的历史计数)。 +- `RuntimeSnapshotService` 以泛型注入方式复用 runtime-only + snapshot/event CAS 合同,并通过 `RuntimeServiceError::StoreProtocol(StoreError)` + 保留 Core `StoreErrorKind`;`InMemoryRuntimeStore` 测试覆盖无 SQLite 的 load/commit + 和 CAS 冲突。完整 durable Runtime 仍是 SQLite-backed,run/session/lease/checkpoint + 跨表事务没有被该 facade 替换。 +- 原始 P0–P6 仍全部为“部分完成”;本地依赖/静态门禁不能替代独立远程 CI、registry + 发布、正式许可证策略、自动外部对账/订阅或完整 Codex wire。 + +## 2026-09-03 Engine hard-bound 回归 + +- `agent-runtime-engine` 新增 `deserialized_tiny_tool_output_budget_is_rejected_before_side_effects`: + 反序列化绕过 Builder 最小预算时,在 checkpoint、Provider 调用和工具副作用之前拒绝, + 避免生成超过配置上限的截断 envelope。Engine 当前定向测试为 45 个;该回归只证明 + 本地输入边界,不扩大真实 Provider 或外部副作用结论。 + +## 2026-09-03 HTTP 订阅、Codex envelope、Provider 路由与编排快照 + +- 该阶段 workspace 定向计数为 Host 35、MCP 43、Codex 57、Orchestration 29;新增项均有 + 本地回归,不能替代远端 CI 记录。 +- Streamable HTTP 通知订阅使用 adapter 私有 Tokio current-thread worker,仅负责可取消 + 的 GET/SSE 读取和有界转发;它不自动应答、重连、重放或后台路由。自定义 transport + 仍只支持显式轮询。 +- `CodexServerRequestKind` 与 dispatch 前/后的错误分类把 malformed envelope、配置 + 错误和请求发出后的未知副作用分开;完整发行版 generated wire 和真实会话仍未验收。 +- `ProviderRegistry` 的实例/协议选择与 `PersistentCoordinator` 的 revision CAS 快照 + 只增加通用控制面合同;JSON 文件快照现在用同目录 sidecar advisory lock 保护跨进程 + 写入,自动调度和完整多 Agent Teams 仍由宿主/后续项目负责。 + +## 2026-09-03 P1/P3/P4/P5 收尾增量 + +- Core reducer 已在事件 detail serde 解码后重新执行构造级校验;Core 当前 15 个 unit + + 14 个 integration 测试,避免非法 payload 污染 snapshot。 +- Runtime `RuntimeSnapshotService` 通过 `StoreProtocol(StoreError)` 保留 + `StoreErrorKind`,并有 CAS conflict/invalid snapshot 回归;Runtime 当前 20 个测试。 +- MCP 认证环境变量在高层连接、HTTP 直接构造和 stdio 直接构造路径一致接线(43 个测试); + Skill activation 以 `Read::take(max+1)` 限制检查后增长的正文(23 个测试)。 +- Codex 通用 JSON-RPC channel 的显式 handler 覆盖 initialize、request/event、interrupt, + 并测试响应回写、错误传播和混合 envelope 拒绝(57 个测试)。 +- Engine 输入与压缩边界新增五个回归:serde `AgentInput`、`ContextItem`、压缩器输出、 + 压缩响应身份和取消传播均在 Provider/checkpoint/工具副作用前校验;默认与 + no-default-features 定向测试均为 45 个。 +- 这些证据仅来自本地;远端 CI、registry、许可证正式策略、真实 Codex 完整 wire、自动 + 外部对账/订阅和完整持久化多 Agent 调度仍未完成。 + +## 2026-09-03 Engine 输入与压缩边界复核 + +- Engine 对 serde `AgentInput`、`ContextSource` 项、压缩器输出和压缩响应身份重新执行 + 构造级校验,并在压缩阶段传播取消;新增五个回归确认非法值不会到达 Provider、 + checkpoint 或工具副作用。 +- 默认与 no-default-features 的 Engine 定向测试均为 45 个通过;这只是本地边界证据, + 不替代真实上游硬 I/O timeout 或自动外部对账。 + +## 2026-09-03 最终边界增量复核 + +- Engine 现在同时复验 Provider response 和 ToolExecutor `ToolResult` 的嵌套构造边界; + malformed serde 输出不会污染完成事件、消息历史或 safe checkpoint。Engine 默认与 + no-default-features 定向测试均为 47 个。 +- Codex CLI 参数白名单对凭据键做大小写/分隔符规范化,覆盖 access-token、 + Authorization/Bearer 等变体,并保留非凭据的 `--tokenizer`;Codex 定向测试为 58 个。 +- SQLite 安全重排队的时间戳更新仅发生在首次状态转换,重复 queued-safe resume 保持 + 记录不变;Storage all-features 5+39、直接 no-default 4+32 均通过。 +- 这些是本地依赖边界与状态合同证据,不替代远端 CI、目标 registry、正式许可证策略、 + 真实 Provider/Codex 会话或完整多 Agent 调度。 + +## 2026-09-03 继续执行边界复核 + +- Host 37 个测试覆盖多工具批次首个 Ask 的逐调用恢复;MCP 44 个测试覆盖 stdio、HTTP + JSON/SSE 与后台 SSE 共用 envelope 校验;CLI 16 个测试覆盖 doctor 的无副作用配置检查 + 与 Codex program 脱敏。 +- 该阶段 Orchestration 31 个测试覆盖隔离活动节点后的 `Waiting` 重载,以及快照中 + Running/active run 的双向 agent 一致性校验。 +- Host MCP bridge 将发送后 timeout、连接、协议、编码、HTTP 和远端错误归为 + `ToolErrorKind::Unknown`,因此 Engine 的显式 failed retry policy 不会重放未知调用。 +- 这轮仍未提供 cargo-audit/RustSec 当前扫描输入、远端 CI/registry、真实 Provider/Codex + 会话或完整多 Agent 调度证据;原始 P0–P6 继续保持“部分完成”。 + +## 关联文档 + +- [README](../README.md) +- [架构说明](./【架构】独立Agent运行时-2026-09-01.md) +- [原始 P0–P6 计划](./【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md) +- [增量功能验收](./【验收】Agent内核与通用程序-2026-09-01.md) + +## 2026-09-03 共享存储与适配器输入门禁 + +- Core `SharedRuntimeStore` 只增加同一进程内的 `Arc>` 共享装配;CAS + 竞争和锁中毒 `Unavailable` 均有单测,不引入 Tokio、SQLite 或跨主机锁依赖。 +- Skill frontmatter 的严格边界(未闭合引号/列表、空列表项、空工具名)在 adapter + 单测中覆盖,仍不引入完整 YAML 解析器或运行时依赖。 +- MCP initialize 在发送 `notifications/initialized` 前验证唯一支持的 + `protocolVersion=2025-06-18`,拒绝缺失/不支持版本;多版本协商和远端 wire 仍需外部 + 兼容矩阵。 +- CLI endpoint 来源顺序已固定为环境完整 endpoint → 环境 base URL → TOML 完整 endpoint + → TOML base URL,并由纯 helper 回归验证;这不改变没有真实 API key 的离线门禁结论。 + +## 2026-09-03 最新依赖边界复核(历史快照) + +- 当前本地测试计数:Engine 50、Host 57、MCP 47、CLI 22、Codex 73、Runtime 22、 + Orchestration 38、Skill 27、OpenAI 23、Fake 4;Core 22+16,Storage 为默认特性 5+43 + 和直接 no-default-features 4+36。 +- MCP resources/prompts 需要显式 selection 才能进入上下文;Engine 对所有 untrusted + context 重新赋予 User role,阻断不可信 system/developer/assistant 文本越权;结构化 + tool-call/tool-result 同样渲染为普通 User 文本,避免伪造工具历史。 +- `CodexHostServerRequestHandler` 属于 Host 的同步低层 server-request bridge,仅处理 + `item/tool/call`。它在 approval/execution 前完成 known-tool JSON Schema 校验,然后使用 + Host policy/router;不写 durable approval/checkpoint/audit,Ask 返回 JSON-RPC error。 + 对已核对的 0.152.1 wire,`codex_01521_server_request_handler` 另返回 + `contentItems`/`success` typed dynamic-tool response,但仍不覆盖完整 generated schema。 + Host 提供显式 `NamespaceToolResolver`;默认空映射对显式非 `null` namespace 在 + approval/execution 前 fail-closed,命中调用方注册映射后才路由,缺省或 JSON `null` 才按 + 全局工具名处理。 +- `AgentHost::with_runtime` 允许 Host 注入调用方已装配的 `RuntimeService`;Host 创建的 run + 可由外部持有的同一 facade 观察,避免重新打开 SQLite 或形成平行 durable 控制面;该回归 + 后 Host 当前为 52 个测试。journal mode/JSONL export 也通过 Runtime/Host 窄 facade + 提供给 CLI,SQLite adapter 所有权未复制。 +- CLI 的后台、审批恢复和 safe resume 路径共用 spawn 失败收口:子进程未创建成功时只将 + 仍未领取的 durable run 原子标记为 failed,不覆盖已 claim 的 worker;`doctor` 仍不联网, + 但会按需初始化/迁移本地 SQLite/WAL,当前 CLI 为 21 个测试。 +- MCP stdio 同步 client 的 pending 消息暂存使用 + `MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限,超限返回协议错误;随 crate 分发的 + stdio 假服务器还覆盖握手、tools/list 和 tools/call,当前 MCP 为 47 个测试。 +- Skill fixture 直接参与 discovery/activation 回归:有效 `SKILL.md` 验证 metadata-first + 和无工具绑定激活,非法 frontmatter fixture 在 discovery 阶段拒绝;Skill 当前为 27 个测试。 +- Engine 在 Provider response 边界只允许 `Text`/`Image` content;结构化 tool call/result + 不得进入 content,必须使用独立 `tool_calls`/工具结果回填路径,Engine 当前为 48 个测试。 +- 该边界审计不把适配器 fixture 当成完整产品能力:原始 P0–P6 仍全部“部分完成”,独立 + 远程仓库/CI、registry/正式许可证、真实 Provider/Codex wire/session、自动外部对账/订阅、 + 完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未提供证据。 + +## 2026-09-03 继续执行后的边界复核 + +- Host namespace resolver 与 `PersistentCoordinator` ready plan/claim 均保持在适配器/编排 + 层;`agent-runtime-core` 依赖图未引入 SQLite、Tokio、HTTP、MCP 或 Codex。Host 54、 + Orchestration 38 的 all/no-default 定向测试及 `-D warnings` check/Clippy、rustdoc、fmt + 已通过。 +- Runtime/Host 的 journal mode 与 JSONL export 是窄 facade,CLI 不再直接使用这两个 Store + 方法;这只改变调用边界,不改变 SQLite 的排序、脱敏、WAL 或事务语义,公开 `store()` + 兼容入口仍明确标记为后续拆分项。 +- 当前环境仍缺 `cargo-audit`/`RUSTSEC_ADVISORY_DB`;wrapper 只验证缺失输入 fail-closed。 + 本地独立复制、manifest/依赖预检和 Fake 测试通过,不能替代远端 CI、registry/许可证正式 + 审计、真实 Provider/Codex session 或完整自动多 Agent scheduler。 + +## 2026-09-03 继续执行后的计数与边界 + +- `agent-codex` 新增显式 `invoke_node_with_runtime_events` bridge、独立中断 hook 和 Core + reducer/并发回归,当前 64 个测试;`agent-host` 新增重开 Host 按 durable request-id 别名取消回归,当前 + 54 个测试。两项都只提供显式控制,不启动隐式 scheduler 或自动重放外部调用。 +- Storage 当前 all-features 为 5+40,直接 no-default-features 为 4+33;workspace + all/no-default 测试、`-D warnings` check/Clippy、rustdoc、依赖/manifest/独立复制和 + Fake 测试集在代码变更后重新通过。 +- 本地门禁仍不能证明真实 Codex 发行版 wire/session、自动外部对账/订阅、远端 CI、目标 + registry 或正式许可证策略;这些继续作为 P0–P6 的未完成外部出口。 + +## 2026-09-03 Codex 同步通道中断边界 + +- `CodexAppServerBackend` 的普通 `AppServerChannel::interrupt` 与同步 `send` 共享 + channel mutex;因此没有独立 control transport 时,阻塞中的 invoke 不会被该 fallback + 抢占。`with_interrupt_hook` 是显式 opt-in 的最小接线,hook 在不获取 channel mutex 的 + 情况下调用,且文档要求不得重入同一 channel。 +- 新增并发回归确认 out-of-band hook 在阻塞 invoke 期间可完成。该 API 不自动配置真实 + Codex control wire,不改变 Host/Runtime 取消持久化或 reconciliation 语义。 + +## 2026-09-03 最终门禁(带外中断后) + +- all-features/no-default-features workspace 测试、Storage 直接 no-default、两套 + `-D warnings` check/Clippy、双特性 rustdoc、fmt、shell 语法、编码、`git diff --check`、 + 依赖边界、manifest 预检、独立复制和 Fake 测试集均通过;`rust/target` 已清理。 +- `run-cargo-audit.sh` 在本轮没有 runner 提供的 `RUSTSEC_ADVISORY_DB` 时按设计退出 1, + 只证明缺失输入 fail-closed,不记为漏洞扫描通过。真实远端 CI、registry/许可证正式 + 审计、Provider/Codex session 和自动外部对账仍需外部证据。 + +## 2026-09-03 Provider-owned compression 与边界复核 + +- `agent-runtime-engine` 的 `OwnedProviderContextCompressor` 只增加生命周期适配, + 不改变 Core 依赖方向或压缩合同;Host 的 OpenAI 装配 helper 会绑定同一 Provider, + 专用摘要 Provider 仍可显式注入。 +- `agent-storage-sqlite` 的 no-default 构建已对仅在 `core-adapter` 使用的校验 helper + 做 feature gate;`RUSTFLAGS=-D warnings cargo check -p agent-storage-sqlite + --no-default-features` 不再产生 dead-code warning。 +- 最新本地计数为 Engine 50、Host 57、Runtime 22、Core 22+16、Storage all-features + 5+43、直接 no-default-features 4+36;这些是本地命令结果,不能替代远端 CI、registry + 发布、许可证决策或真实 Provider/Codex session。 + +## 2026-09-03 Codex 进程退出竞态复核 + +- `CodexAppServerProcess` 在 EOF 先到达而 `try_wait` 尚未可见的窗口中,先读取自然退出 + 状态再执行 EOF 收束;非零退出保留 `Exit(code)`,避免误报强制终止。 +- no-default-features 目标回归重复 20 次通过;修复后 workspace 双特性 check/Clippy、 + 测试和 rustdoc 均通过。该证据不扩大真实 Codex wire/session 的兼容声明。 + +## 2026-09-03 Runtime/Host facade 复核 + +- Runtime 新增 runtime 事件、session 和 runtime JSONL 导出的只读 facade,Host 测试已 + 使用这些入口而不再直接读取 `store()`;`store()` 标记为 deprecated 兼容 accessor, + 仍委托同一 Runtime 所有权。 +- 编排文件快照重开测试同步删除 sidecar advisory lock;Runtime/Host/Orchestration + 定向回归和 `-D warnings` Clippy 通过。该项不改变 SQLite 事务、跨进程锁或最终拆分 + 的未完成边界。 + +## 2026-09-03 Provider 协议身份审计 + +- `ModelProvider::protocol_id()` 保持可选且 object-safe;Core Registry 在注册时拒绝 + descriptor 与 adapter 自报协议不一致,未填写时补齐,并在 resolve 时再次核对。OpenAI + adapter 的稳定身份为 `openai-responses`;Core/OpenAI/Host 定向测试与 Clippy 均通过。 +- `ProviderInstanceId` 与 `ProviderProtocolId` 仍为两个独立 struct;`ProviderTarget` 的 + 旧 `id`/`providerId` 输入别名保留,序列化输出统一使用 `instanceId`。这避免旧快照无法 + 读取,同时不把实例 ID 和协议 ID 混为一谈。 +- OpenAI endpoint 凭据 query 检查统一分隔符后覆盖 `x-api-key`、`auth`、`key` 等常见 + 变体;CLI 空白 TOML model 按未设置处理。该审计仍只覆盖本地配置/路由边界,真实网关 + 兼容矩阵和外部结果恢复不在本地证据内。 + +> 依赖边界说明:`agent-runtime` 的完整 `RuntimeService` 当前仍是 SQLite-backed; +> `--no-default-features` workspace 门禁没有移除 `agent-storage-sqlite`,只验证另一组 +> feature 配置。无 SQLite 的合同验证仅覆盖 Core `RuntimeSnapshotService`, +> 不能冒充最终 Host/Runtime 拆分。 + +## 2026-09-03 当前 P5 / feature gate 复核(历史快照) + +本节是当前门禁数字和 feature 边界的唯一权威;此前按日期追加并保留旧数字的段落均为 +历史快照: + +- 当前定向计数:`agent-codex` 77、`agent-host` 60、`agent-runtime` 25(其中 + `--no-default-features` 为 5)、`agent-provider-openai` 29、`agent-cli` 23、Core + 23 个单测 + 16 个集成测试。数字来自本地 workspace 命令,不是远端 CI 记录。 +- OpenAI Responses adapter 的状态分类是 adapter-specific:408/429/502/503/504 + 映射为 `Unavailable`,其它非 2xx 保持 `Upstream`;adapter 不读取 `Retry-After`、 + 不执行自动 backoff/结果查询或重放,重试预算由 Engine 控制。 +- P5 本地门禁只证明显式 stdio process/argv、进程组回收、窄 JSON-RPC、server-request + handler、错误分类和 Core backend 接口;测试使用受控本地假进程。真实 Codex 发行版 + generated schema、启动参数/版本协商、真实 session ID 持久化/重开、自动对账/订阅和 + 请求重放仍没有本地或远端证据。 +- `agent-codex` 没有 Cargo feature gate,因此 process/typed adapter 会在 all-features + 与 no-default workspace 组合中一起编译;`cfg(unix)` 只选择 process-group 的平台 + 实现,不代表功能被关闭。 +- `agent-runtime` 的 `default = ["sqlite"]` 才启用可选 `agent-storage-sqlite`;关闭 + 默认 feature 只验证 runtime-only `RuntimeSnapshotService`/身份合同。`agent-storage-sqlite` + 默认 feature 集合为空,`core-adapter` 仅是可选的 Core 校验依赖。由于 Host/CLI 仍按 + 默认 Runtime 依赖装配,workspace `--no-default-features` 不等于 SQLite-free Host, + 也不等于最终 Host/Runtime 拆分完成。 +- 本地 feature 组合、fixture 和静态脚本不能替代独立远程仓库/CI、registry/许可证策略、 + 真实 Provider/Codex 会话或自动外部恢复;这些继续保持 P0–P6 未完成出口。 + +## 2026-09-03 取消阶段与配置门禁复核(历史快照) + +- Runtime 的安全无主取消入口在阶段检查后才调用跨表终态事务:queued 无 checkpoint,或 + 无 lease 的 safe/awaiting_approval checkpoint 可完成;历史 running、cancel_requested、 + reconciling、缺失 checkpoint 和 in-flight/compacting 游标均 fail-closed。Host 在发出 + request_cancel 前保留 queued 身份,过期 lease 先进入 stale probe。旧低层取消方法仍是 + 兼容 escape hatch,未被 Host/CLI 使用,不能作为阶段安全性证明。 +- Codex CLI/App Server 配置在 serde 与运行时两层拒绝零 timeout/output/frame limit,子毫秒 + 和整数溢出也被拒绝;本地 Codex 77 个测试覆盖这些边界。能力 runner 对不存在的 filter 返回失败,避免 + 空测试假绿。 +- 当前计数:Core 23+16、Engine 50、Runtime 25(package-only no-default 5)、Storage + all-features 5+43、直接 no-default 4+36、Host 60、CLI 23、OpenAI 29、Fake 4、MCP 47、 + Skill 27、Codex 77、Orchestration 38。双特性测试/检查、Clippy、rustdoc、fmt、依赖、 + manifest、独立复制和脚本语法均通过。 +- 本机没有 `RUSTSEC_ADVISORY_DB`,`run-cargo-audit.sh` 退出 1 是预期 fail-closed;这 + 不影响结构性依赖检查通过,但不能记作当前 advisory 扫描通过。远端 CI、registry/许可 + 证策略和真实协议兼容性仍需外部证据。 + +## 2026-09-04 取消原子性与空帧门禁复核 + +- queued 取消的最终条件由 SQLite `RunFinishGuard::QueuedUnclaimed` 在单一 + `BEGIN IMMEDIATE` 事务内复核;Runtime 直接领取竞争和 Host 双连接并发回归均通过, + 竞争只产生 typed conflict/cooperative cancel,不会留下已取消的有效 lease。 +- Codex JSONL 空白 keep-alive 行改为循环消费,8192 行 fixture 通过;未改变既有 frame + 上限、协议版本和未知副作用分类。 +- 最新双特性 workspace 测试、check/Clippy/rustdoc、fmt、manifest/依赖/独立复制、 + 能力集、编码、diff 和 shell 语法均通过。审计 wrapper 仍因本机未提供 advisory DB + fail-closed;远端 CI、真实 Provider/Codex wire/session 和最终职责拆分不由本地门禁替代。 + +## 2026-09-04 RustSec advisory DB 真实离线扫描(历史快照) + +- [x] 使用隔离的 `~/data/tmp/` 工具目录固定 `cargo-audit 0.22.2`,RustSec + advisory-db 提交为 `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5`(1239 条 advisory)。 +- [x] `run-cargo-audit.sh` 通过显式 `RUSTSEC_ADVISORY_DB` 与 `CARGO_AUDIT_BIN` 运行, + 加载 1239 条 advisory、扫描 188 个锁定依赖,退出码 0,无漏洞/warning;锁文件 + SHA-256 为 `1a3282fe963b9a53e3d9e24a1507c75f1ee19b9a8648025d4f6da25aca5464d5`。 +- [x] 临时工具、数据库和 marker 已清理,workspace 未留下审计产物;wrapper 的无 DB + fail-closed 行为仍保留,保证 runner 必须显式固定输入。 +- [ ] 这是一轮本地可复核扫描,不替代远端 CI 持续运行、正式许可证策略、真实 + Provider/Codex wire/session 或最终 Host/Runtime 拆分。 + +## 2026-09-04 RuntimeStore 动态接缝(历史快照) + +- [x] `DynRuntimeStore` 通过本地 newtype 承载 `Box`,避免 Rust orphan + rule,同时维持 Core `RuntimeStore` 的 `load`/`commit` 与 typed CAS/error 合同。 +- [x] `RuntimeSnapshotService` 的 no-default portable 测试 6/6、默认 + feature lib 测试 27/27,check/Clippy/fmt 均通过;没有引入 SQLite、Tokio 或 Host + 生命周期到 Core。 +- [ ] 完整 durable Runtime/Host 拆分、远端 CI/registry/许可证和真实外部协议仍不由该 + runtime-only 接缝替代。 + +## 2026-09-04 依赖与输入合同复核(历史快照) + +- Core 的依赖黑名单仍通过;新增的 PromptSection、ProviderRequest/Response、 + ToolDefinition/Registry 嵌套内容校验只调用标准库/已有 serde 合同,没有把 Engine、 + SQLite、Tokio 或适配器依赖带入 `agent-runtime-core`。Core 当前为 28 个单元测试 + + 16 个集成测试。 +- Engine 的压缩器工具历史拒绝、summary response 结构化 content 拒绝、默认 stream + tool-call/usage 保留和 Host MCP 发送前 cooperative cancel 门禁已由定向回归覆盖; + 当前计数为 Engine 52、Host 62、MCP 47。 +- SQLite RuntimeStore 的 load、CAS commit 和 recovery verification 现在统一复核 + `RuntimeSnapshot` 语义;篡改 JSON 的 load/commit 回归通过且失败事务不写入。当前其余 + 源码计数为 Runtime 27(package-only no-default 6)、Storage 5+44(直接 + no-default 4+36)、CLI 23、OpenAI 29、Fake 4、Skill 27、Codex 78、Orchestration 38。 + 这些是本地命令计数,不等同远端 CI 或真实上游会话。 +- 2026-09-04 的固定 RustSec 快照扫描了 188 个锁定依赖并以 0 退出、无漏洞/warning; + 该证据仍是一次性本地扫描。远端持续审计、正式许可证/registry 策略、真实 + Provider/Codex wire/session、自动外部对账和最终 Host/Runtime 拆分继续保持未完成。 + +## 2026-09-04 Runtime event history、Skill discovery 与 Codex process bridge(历史快照) + +- SQLite RuntimeStore 在 load、CAS commit 与 recovery verification 从空快照重放完整 + `runtime_events`,要求 revision 连续且事件语义可重放;event log 缺失、断档或语义篡改 + 均 fail-closed。`runtime_store_rejects_event_history_gap_on_load_and_commit` 覆盖 + load/list/trait load/commit 均拒绝断档且不写新事件;Storage all-features 为 5+45=50, + 直接 no-default 为 4+36=40。 +- Skill discovery/list 从 discovery 阶段即执行 `max_body_bytes` 硬上限,正文超限返回 + `BodyTooLarge`;`discovery_and_list_reject正文超过配置上限` 通过,Skill 当前 28 个测试。 +- Codex process backend 提供显式 request→notification→result 的 NodeEvent/runtime-event + bridge,并保留 handler 变体;`app_server_process_backend_runtime_event_bridge_maps_notification_order` + 通过,Codex 当前 80 个测试。 +- 本地 all/no-default check、Clippy、rustdoc、workspace 测试、Storage 直接 no-default、 + 依赖/manifest、独立复制、能力 10/10、Fake 2/2、fmt、编码、shell 和 diff 门禁均通过。 + 根 Vitest 本轮因工作区未安装 `vitest` 以退出码 127 结束,历史 `3189/3189` 不计入当前 + 通过项;远端 CI/registry/许可证、真实 Provider/Codex session、自动外部对账和最终 + Host/Runtime 拆分仍未完成。 + +## 2026-09-04 RuntimeService 动态 snapshot Store 接缝(当前) + +- `RuntimeService::snapshot_store()` 只把同一 SQLite 状态暴露为 + `RuntimeSnapshotService`;`runtime_exposes_dynamic_snapshot_store_without_adapter_handle` + 验证动态 CAS 与 Runtime facade 的共享可见性。Core 依赖边界没有新增 SQLite/Tokio/Host + 依赖,跨表 durable 控制面仍留在 Runtime/Storage。 +- Runtime 默认测试 28/28、package-only no-default 6/6;该接缝不替代最终 Host/Runtime + 拆分,也不改变远端 CI、真实协议和自动外部对账仍未验收的结论。 + +## 2026-09-04 manifest 预检 target 隔离(当前) + +- `check-package-manifests.sh` 在默认路径下将 `cargo package --list` 中间产物隔离到 + `~/data/tmp` 并在退出时删除;本次 13 个 crate 全部通过,workspace 没有遗留 + `rust/target`。显式 `CARGO_TARGET_DIR` 的目录不由脚本删除。 +- 这是构建产物边界修复,不改变独立远端 CI、正式 registry/许可证、真实上游协议或 + Host/Runtime 最终拆分的未验收状态。 + +## 2026-09-04 ContextItem 嵌套消息校验(当前) + +- Core 的 `ContextItem::try_new` 现在复用 `Message::validate`,在上下文源边界拒绝 + 兼容/serde 产生的非法消息;`context_item_constructor_rejects_invalid_nested_message` + 已通过,Core 当前为 28+17。该变更未引入适配器依赖。 + +## 2026-09-05 Core 输入边界与隔离门禁(当前) + +- Core 依赖黑名单和 workspace path 边界仍通过;`ToolBinding`、`SkillDefinition`、 + `SkillActivation`、`AgentDescriptor`、`BackendRequest`、`BackendResult`、`ToolContext` + 的公开 serde/兼容入口均复用验证,未引入 Tokio、SQLite、HTTP、MCP 或 Codex 依赖。 + `backend_result_as_tool` 拒绝身份错配和未知副作用结果。 +- Engine/Host 将 `ContextRequest`、`ToolContext` 和各类 dispatch 前置校验接入实际调用链; + 外部 session 候选查询只读、固定排序、硬上限和参数绑定,不执行对账或重放。 +- 最新 all/no-default workspace 测试、check、Clippy `-D warnings`、rustdoc、fmt、Storage + 直接 no-default(6+36)、13 个 manifest、独立复制、Fake 2/2、能力 10/10、编码、shell + 和 diff 门禁均退出码 0;Core 为 30+21,Engine 55,Runtime 34,Host 65,Storage + all-features 7+45,Codex 81。 +- 这些证据仍是本地源码/fixture 证据;独立远端仓库和 CI 持续运行、正式 registry/许可证、 + 真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度及最终 + Host/Runtime 拆分保持未完成。 + +- Engine 公共预检新增压缩请求合同:`CompressionRequest::validate` 在直接调用压缩适配器 + 时拒绝零预算、非法身份和非法嵌套消息;`validate_tool_arguments` 同时复验调用/定义 + 与工具名匹配。相关 Engine 回归当前为 55 个,未引入 Core 之外的依赖。 +- 测试辅助的默认临时根目录已统一为 `~/data/tmp`(显式 `TMPDIR` 可覆盖),覆盖编排、 + Host、Skill 和 SQLite Storage;最新定向回归未留下 workspace `target` 或数据库产物。 +- Runtime 第一阶段的 `DurableStore` 位于 runtime crate,`SqliteDurableStore` 只做中立 + bundle/query/lease/checkpoint/approval/external-session/runtime-CAS/cancel/stale/finish/ + recovery command 转换;没有把 SQLite、Tokio 或跨表终态事务引入 Core。Runtime 当前为 + 33 个测试,完整 trait 反转仍需兼容入口收敛和迁移设计。 + +## 2026-09-05 DurableStore command 扩展(历史快照) + +- 依赖边界复核更新:中立 `DurableStore` 现已定义 cancel/stale、runtime-aware + `finish_run_with_runtime` 和 `recover_expired_run(_with_runtime)` command;SQLite 适配器 + 负责把它们映射到现有单事务实现。Core 仍未引入 SQLite、Tokio 或具体事务句柄,Runtime + contract 回归为 33 个测试。 +- 这只是增量接缝,不等于完成 trait 反转:RuntimeService 仍以 SQLite 为 durable 具体实现, + 少数历史兼容入口仍直连 adapter;远端 CI/registry/许可证、真实 Provider/Codex + wire/session、自动外部对账/订阅和最终 Host/Runtime 拆分继续是开放项。 + +## 2026-09-05 DurableStore 查询边界复核(历史快照) + +- `is_cancel_requested`、`runtime_id_for_run`、`update_session` 和 `requeue_safe_run` 已纳入 + DurableStore,Runtime 的安全取消、stale/recovery 和 session 更新路径不再直接调用对应 + SQLite 查询/更新;session view 保留 metadata 与时间字段。 +- 该复核仍确认诊断/审计导出属于 SQLite 具体边界;无 runtime 的兼容终态已由 adapter + 路由。Runtime 当前 33 个测试,完整 trait 反转和最终 Host/Runtime 拆分未完成。 + +## 2026-09-05 DurableRuntime generic facade 复核(历史快照) + +- `DurableRuntime` 以拥有式方式转发完整控制面合同;它的公开 API 不暴露 + SQLite,`into_store()` 可取回调用方 adapter。RuntimeService 只在便利构造时使用 + `SqliteDurableStore`,没有新增 Core/Engine 的具体依赖。 +- generic facade 回归使 Runtime 当前测试数为 36;新增测试内 `InMemoryDurableStore` 实际覆盖 + bundle、lease、snapshot CAS、safe requeue、finish 和 expired recovery,不依赖 SQLite;最终 trait 反转仍需 + 和迁移合同,不能由 SQLite wrapper 自证。 + +## 2026-09-05 工具调用持久化边界复核(当前) + +- `tool_calls` 已从“只有 Storage API”变为 Host 生产路径:请求时创建、结果时完成,Runtime/ + DurableStore 提供中立 view 和按 run 查询;JSONL 导出包含该记录,重复 identity 不新增行。 +- 该记录仍与 Core runtime event 分属两条流,未把工具表、runtime snapshot 和 checkpoint + 强行拼成新的多表事务;未知外部副作用继续由 checkpoint/reconciliation 管理。 + +## 2026-09-05 Codex session sink 边界复核(当前) + +- `agent-codex` 仍只依赖中立 `CodexSessionMetadataSink`;Host 的实现通过 Runtime facade 将 + thread/turn 身份按 run 持久化为 `external_sessions`,不让 Codex 直接依赖 SQLite。 +- thread/turn metadata 落盘回归通过,但真实发行版 generated wire、自动重连和外部对账仍未 + 验收;该 sink 只解决本地 session identity wiring。 + +## 2026-09-06 Codex/MCP 边界复核(历史快照) + +- Codex 的 post-dispatch cancel 分类、已核对 notification alias、空/控制 argv prefix 和 + Host handler context/cancel 门禁均已通过本地回归;当时 Codex 86、Host 69、MCP 49。 +- 本地证据仍不覆盖真实发行版 generated schema、协议级 turn interrupt、自动对账或远端 CI; + sampling/elicitation 等未实现扩展继续显式拒绝。 +- 通用 `JsonRpcAppServerRouter` 已在本地 transport fixture 中验证有界 pending/notification + 队列、乱序 response 与并发 interrupt;未接入 ProcessControl,不替代真实发行版 wire/session。 + +## 2026-09-06 Codex 请求生命周期复核(历史快照) + +- `agent-runtime-contracts` 新 crate 承接 DurableStore command/view/trait;`agent-runtime` + 仍保留 SQLite adapter 过渡实现,最终 Runtime/SQLite 依赖反转尚未完成。 +- `agent-codex` 仍只依赖中立 `CodexSessionMetadataSink`;其默认生命周期扩展不会破坏旧 sink, + ProcessBackend 在 request/cancel 收束后才调用回调,Host 不被反向编译进 Codex。 +- Host 生命周期记录先合并现有 JSON 再更新 SQLite external session,并保持 terminal 状态,避免 + 覆盖 thread/turn/custom audit 字段或把 completed/failed/cancelled 复活;from_host Codex + handler 的 tool call 也写入 durable `tool_calls`,重复 call_id 返回缓存结果。 +- 该边界现在还提供 ProcessControl emit-once sink、退出码和 session sink 转发;其中 + `app_server_process_lifecycle_sink_reports_timeout` 与 + `app_server_process_lifecycle_sink_distinguishes_reader_eof` 覆盖 timeout/EOF lifecycle + 观察;仍只承诺 request/process observation,不提供协议级 turn interrupt。当前 + Codex/Host/MCP 为 101/69/51, + reader I/O 错误细分、真实 generated wire/session 和远端 CI 仍是 P5 外部缺口。 + +## 2026-09-06 MCP 协议版本协商复核(当前) + +- `McpClientOptions` 的协议候选列表有数量和重复校验;配置型连接在明确的版本拒绝后才会 + 重建 transport 尝试下一候选,注入的自定义 transport 不被假定可重放。 +- Streamable HTTP 在协商成功后更新 `mcp-protocol-version` header;52 个 MCP 定向测试和 + all/no-default workspace 回归通过。后台通知自动重连、server request 自动应答和工具调用 + 自动重放仍未实现。 + +## 2026-09-06 RustSec 当前锁文件审计(当前) + +- 使用临时目录构建固定 `cargo-audit 0.22.2`,并以 RustSec advisory-db 提交 + `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 运行 `run-cargo-audit.sh`;当前锁文件 + SHA-256 为 `e4dccbc5848294df920676fbcf8be1fd6fef2c543647c4ea5db63cdecc7a0bdc`,加载 + 1239 条 advisory、扫描 190 个 crate 依赖,退出码 0 且无漏洞/warning。 +- 工具、数据库和构建产物均位于 `~/data/tmp` 并在验证后清理;结果不等同远端 CI 持续扫描、 + registry 发布或正式许可证策略。 + +## 2026-09-06 工具调用、Checkpoint 与 Runtime 联合事务复核(历史快照) + +- `DurableToolCallCheckpointRuntimeCommit` 位于中立 `agent-runtime-contracts`,由 + `DurableStore`、`DurableRuntime` 与 `RuntimeService` 以拥有值 command 转发;不向 Core + 引入 SQLite、连接或事务句柄。 +- SQLite adapter 在单一 `IMMEDIATE` 事务内校验 run/tool identity、opaque lease、runtime + CAS/事件重放,随后 upsert checkpoint 和工具行;Storage all-features 7+53、直接 + no-default 6+36,成功/完成/错 lease/CAS 回滚回归均通过。 +- Host 首次 `awaiting_approval` checkpoint 与 `ToolRequested` 已共享联合事务,已有游标的 + `ToolCompleted` 也复用该合同;普通 checkpoint/终态仍未全部共享同一事件输入,因此不能 + 作为全链路全局事务证据。Runtime/SQLite 物理拆分则已由 `agent-runtime-sqlite` 完成。 + +## 2026-09-06 Runtime/SQLite 物理拆分复核(当前) + +- workspace 当前包含 14 个 crate;`agent-runtime-sqlite` 依赖 portable `agent-runtime` + 和 `agent-storage-sqlite`,Host 已切换到新 Service/records/error 路径。 +- `agent-runtime` 的依赖树在 `--no-default-features` 下不包含 `rusqlite`、SQLite 或 + `agent-storage-sqlite`;不再提供 `agent_runtime::RuntimeService` 兼容 re-export,避免 + 形成平行 API。 +- breaking migration 后 all/no-default workspace test、Clippy、rustdoc、fmt、manifest 和 + independent-copy 门禁均已通过。该复核只证明本地依赖边界,不替代远端仓库/CI、registry + 发布或许可证策略验收。 +- Host manifest 还移除了对 portable `agent-runtime` 的直接 path 依赖;Host → + `agent-runtime-sqlite` → `agent-runtime`/`agent-storage-sqlite` 的依赖方向已由 metadata 复核。 + +## 2026-09-06 Host 队列与多工具边界复核(当前) + +- Host 纯文本终态、首次审批 checkpoint 和最终工具结果分别使用已验证的联合事务;多工具 + trace 中间结果不复用未来 Safe checkpoint,避免 Core snapshot/cursor 前缀错配。 +- 后台 queue metadata 只保留 provider/effective model/providerKind;带 marker 的队列在 + claim 前校验模型一致性,错配不取 lease、不触发 Provider,legacy metadata 保持兼容。 +- 最新本地证据为 Host 80/80 单测 + 7 个消息持久化集成回归、CLI 24/24、能力集 10/10;这些边界仍不替代自动外部对账、 + 真实 Provider/Codex session 或远端持续 CI。 + +## 2026-09-06 agent-app 依赖边界复核(当前) + +- `agent-app` 只依赖配置所需的 `agent-codex`、`agent-mcp`、`agent-provider-openai` 及 + serde/toml;不依赖 `agent-host`、`agent-runtime`、SQLite、线程或 CLI 可执行入口。 +- CLI 已移除本地 `AgentTomlConfig` 定义和 `effective_model`/queued metadata 实现,改为 + 通过 `agent-app` 导入,因而没有两套配置优先级或运行状态。 +- 新增 `agent-app` 后 workspace 当前为 15 个 crate;历史段落中的 13/14 个 crate 仅代表 + 当时的门禁快照,不能作为本轮 manifest 数量证据。 +- 当前 `agent-app` 3 个单测、CLI 24 个单测已通过;workspace manifest/依赖门禁应按新增 + workspace package 复跑。该边界不替代远端 CI、真实 Provider/Codex session 或 registry。 + +## 2026-09-06 外部会话对账边界复核(当前) + +- Host 的 `ExternalSessionResolver` 是显式、可替换的查询/消息转换端口;批处理硬上限为 + `MAX_EXTERNAL_SESSION_SCAN_LIMIT`,候选列表不启动 Engine、不自动调用 invoke。 +- Completed 先走既有 checkpoint identity、消息前缀和 CAS 校验,再标记外部记录完成;Pending/ + NotFound 保守写回 unknown,单条失败返回结果而不吞掉其它候选。Host 80/80 单测 + 7 个消息持久化集成回归通过。 +- 该增量不构成真实 Provider/Codex 自动对账、webhook 订阅、跨主机 scheduler 或远端 CI 证明; + 这些仍是原始 P0–P6 的开放项。 + +## 2026-09-06 合并后门禁状态(当前) + +- workspace 15 个 crate 的 all/no-default all-targets、Clippy、Rustdoc、fmt、manifest/依赖 + 边界、独立复制、编码和 diff 检查已通过;agent-app 不反向依赖 Host/Runtime/SQLite。 +- 当前 lock SHA-256 为 `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`; + 固定 `cargo-audit 0.22.2`/RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` + 已扫描当前锁文件 191 个依赖,退出码 0、无漏洞/warning;远端持续审计仍需 CI 记录。 + +## 2026-09-06 审批事务边界复核(当前) + +- `DurableApprovalCheckpointRuntimeCommit` 保持 contracts/Runtime/SQLite 单向依赖;SQLite + 事务内重新校验 live lease、awaiting checkpoint、runtime revision/history 和 approval + binding,失败整体回滚,未向 Core 添加数据库依赖。 +- Runtime 31/31 与 Host 80/80 单测 + 7 个消息持久化集成回归通过,覆盖成功、幂等和 fencing/CAS/identity 失败;该 + 事务不掩盖 Engine callback→binding 生成窗口,也不构成所有 checkpoint/工具/终态全局事务。 + +- Host 在审计/trace 回放前提交 approval 联合事务,并在同一 checkpoint/runtime 事务中物化 + 缺失消息前缀;后续 trace 仅对当前 awaiting checkpoint 中已存在的完全相同消息去重,避免 + 恢复前缀重复。该修复不改变普通运行的重复消息语义。 + +## 2026-09-06 当前最终事实(权威) + +- 当前 workspace 为 15 个 crate;all/no-default all-targets、两套 Clippy、Rustdoc、fmt、 + manifest/依赖边界、独立复制、编码和 diff 检查均通过。当前锁文件 + `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711` 已用固定 + `cargo-audit 0.22.2` + advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 扫描 + 191 个依赖并通过。 +- `agent-app` 不反向依赖 Host/Runtime/SQLite;`agent-runtime` 仍为 portable,SQLite service + 位于 `agent-runtime-sqlite`;审批联合事务和外部 resolver 都是显式 Host/adapter API, + 不改变 Kernel 的中立依赖边界。 + +- `scripts/probe-codex-app-server.sh` 只作为可选人工证据脚本,不纳入默认 CI;它隔离 + `CODEX_HOME`/TMPDIR、校验版本并在 thread/start 后主动关闭 child,避免探测触发 turn 或 + 未知 Provider 副作用。 +- `--schema` 模式已在本机生成 v2 schema 摘要(0.153.4、706653 bytes、SHA-256 + `e5f798fd1343c539f01fedea0e8a84a43c080fcca4615c80eb04a5edab4f7d0a`),文件仅存在于临时目录; + 该 hash 用于版本漂移审计,不是仓库 typed adapter 的兼容承诺。 diff --git a/rust/docs/【方案】多Agent通信配额与故障隔离边界-2026-09-02.md b/rust/docs/【方案】多Agent通信配额与故障隔离边界-2026-09-02.md new file mode 100644 index 000000000..6330d722b --- /dev/null +++ b/rust/docs/【方案】多Agent通信配额与故障隔离边界-2026-09-02.md @@ -0,0 +1,157 @@ +# 多 Agent 通信、配额与故障隔离边界 + +> 本文是原始 P6「多 Agent 扩展准备」的当前控制面方案。它只约束 +> `agent-runtime-orchestration` 已提供的 DAG 和 Coordinator 合同,不把当前 +> workspace 宣称为完整的多 Agent 调度系统。代码和测试是最终依据;本文用于 +> 说明 Host 接线时哪些状态由谁拥有,以及哪些行为必须显式发生。 + +## 1. 范围与所有权 + +`agent-runtime-orchestration` 只依赖 `agent-runtime-core` 的 workspace 合同,持有 +任务 ID、Agent ID、依赖边和协调控制面索引。它不启动线程、不创建 Runtime run、 +不调用 Provider/Tool、不写 SQLite,也不保存业务完成真相。 + +一次最小编排由宿主按下列顺序驱动: + +```text +TaskGraph.ready/dependency_waves + -> Coordinator.dispatch_wave + -> Host 为每个 CoordinatorRun 创建/领取独立 Runtime run + -> Host 回写完成、失败或取消 + -> PersistentCoordinator.complete_run / fail_run / cancel_run + -> 必要时显式 repair_downstream,再开始新的 epoch +``` + +`Delegation` 只描述父 run 到子 Agent 任务的关系,`Join` 只描述一组 +delegation 及其汇总策略;两者不隐含执行、等待或自动重试。`GraphProposal` 只能 +在新 epoch 增加节点和边,不能原地改写已有任务的依赖;最终的环和未知依赖校验 +由 `TaskGraph::try_new` 完成。 + +## 2. 通信合同 + +Coordinator 的节点间通信使用 `CoordinatorMessage`: + +| 操作 | 合同 | +| --- | --- | +| `CoordinatorMessage::try_new/try_text` | 校验 message/source/target ID;payload 必须可编码,编码后的 JSON 不超过 16,384 个字符(ASCII 内容约 16 KiB)。 | +| `Coordinator::deliver_message` | 以 `message_id` 建立去重账本并投递到目标 mailbox;首次返回 `Delivered`。 | +| 重复投递 | 相同 ID 且完整内容相同返回 `Duplicate`,不会再次入队。相同 ID 但内容不同返回 `MessageConflict`。 | +| `Coordinator::receive_messages` | 按 mailbox 顺序取出并清空当前待收件项;已取出的 ID 仍保留在去重账本中。 | + +发送方或接收方处于隔离状态时,投递会被拒绝。消息只代表控制面信封,payload +不由该 crate 解释,也不会绕过 Core/Engine 的权限、上下文和事件链路。 + +当前语义是进程内、显式轮询的 mailbox:没有确认(ack)、TTL、跨进程传输、 +后台订阅、自动重试或消息顺序的跨实例保证。去重账本随 Coordinator 生命周期 +保留;高吞吐或长期运行宿主必须自行决定持久化/压缩策略后再实现相应适配器, +不能把当前 JSON 快照当作消息队列。 + +## 3. 配额与 admission control + +`CoordinatorQuota` 提供两级有界 admission control: + +- `max_active_runs`:Coordinator 允许的活动 run 总数,默认 16; +- `max_active_runs_per_agent`:单个 Agent 的活动 run 数,默认 4。 + +`try_start_run` 和 `dispatch_wave` 在修改图、活动 run 索引或配额计数前一次性 +完成以下校验:run/task 不重复、任务处于 ready wave、task 与 agent 匹配、节点未 +隔离,以及两级配额仍有余量。任一请求失败都不会留下半个波次或半个配额。 + +`finish_run` 只释放 Coordinator 的活动计数;持久协调器的 `complete_run`、`fail_run` +和 `cancel_run` 会在同一 revision-CAS 中更新图状态并释放活动计数。`cancel_run` 只 +记录编排节点的 `Cancelled` 状态,不发送 Runtime 的 cooperative cancel;Runtime 的 +终态、lease 和 checkpoint 仍由 Host/Runtime 负责。这里的配额是并发数量上限,不是 +token、金额、CPU 时间或跨主机资源预算;这些预算在产品确定口径前不进入通用内核。 + +## 4. 故障隔离与显式修复 + +`isolate_node(node_id, reason)` 将任务节点标记为隔离,并释放该节点当前占用的 +活动 run;同一节点重复隔离保持第一次原因并且幂等。`fail_node` 额外把图节点设为 +`Failed`。隔离只按任务节点生效,兄弟节点的活动 run 不受影响;它不会终止外部 +进程,也不会替宿主重放 Provider/Tool 调用。 + +修复必须由控制端显式调用 `repair_downstream(graph, seeds)`: + +1. 计算种子及全部下游的稳定闭包; +2. 释放受影响节点的活动 run; +3. 将受影响图节点重置为 `Pending`; +4. 清除这些节点的隔离标记,等待宿主在下一 epoch 重新 dispatch。 + +因此,未知外部副作用仍遵循 Core/Runtime 的 reconciliation 合同;Coordinator +不会因为节点失败自动创建新 run,也不会把失败下游直接当作可执行任务。 + +## 5. 快照与 Host/Runtime 接线 + +`Coordinator::snapshot/from_snapshot` 和 `CoordinatorStore` 只覆盖协调控制面: + +- `schema_version` 固定为 `agent-runtime-coordinator.v1`; +- 快照包含活动 run、隔离原因、消息去重账本和 mailbox;导入时重建活动任务与 + Agent 计数索引,并重新校验配额、消息目标和 mailbox 引用; +- `InMemoryCoordinatorStore` 用于测试/短生命周期;`JsonFileCoordinatorStore` + 采用同目录临时文件 + `rename`,限制快照最大 8 MiB,并拒绝截断或损坏 JSON; +- `TaskGraph`、RuntimeSnapshot、事件、lease、checkpoint 和业务产物不在该快照内, + 由宿主选择自己的持久化事务和恢复顺序。 + +推荐的宿主边界是:先恢复并校验图 epoch,再显式加载 Coordinator 快照,最后依据 +`active_runs` 与 Runtime 的真实 lease 做对账。快照恢复成功不等于 worker 已启动, +也不等于任何外部调用可以自动重放。 + +## 6. 宿主驱动的 ready 计划与 durable claim + +`PersistentCoordinator::plan_ready_tasks(max_tasks)` 提供一个只读、稳定排序的 +ready-task 计划。计划记录生成时的 `expected_revision` 以及任务/Agent 对,按全局 +活动 run 空间、每 Agent 配额和节点隔离状态筛选;不会生成 Runtime run ID,也不会 +写入快照。宿主为每个候选任务生成自己的 run ID 后,调用 +`PersistentCoordinator::claim_ready_tasks(plan, run_ids)` 完成一次原子领取: + +- plan revision 必须仍与当前 revision 相同;不一致直接返回 `RevisionConflict`,不 + 读取或修改图/配额; +- run ID 数量必须与候选数一致,超出输入会在有界读取内拒绝;任务/Agent 归属、 + ready 状态、隔离和两级配额继续由既有 `dispatch_wave` 重复校验; +- 领取成功时图节点、活动 run 索引和持久快照在同一 mutation/CAS 中更新;任何 + 校验或 Store 冲突都不留下半个 claim;空计划是无写入的幂等结果。 + +这不是后台 scheduler:不会启动线程、创建 Runtime、生成 run ID 或自动重试。对于 +`JsonFileOrchestrationSnapshotStore` 会用同目录 sidecar advisory lock 保护本机跨进程 +CAS 写入;旧 `JsonFileCoordinatorStore` 仍只有进程内锁,这不是分布式锁服务。多个宿主可 +基于同一 revision 生成相同计划,只有首个成功 claim 的宿主 +获得任务,其他宿主应 reload 后重新规划。 + +## 7. 当前证据与明确不做项 + +`agent-runtime-orchestration` 当前有 38 个单元测试,覆盖环/未知依赖、确定性 +ready/wave、同波次 dispatch、全局和按 Agent 配额(包括按 Agent 配额拒绝时的无 +部分状态保证)、message_id 幂等与冲突、隔离节点收发拒绝、下游 repair 的错误 +原子性、快照索引重建/配额/隔离/去重恢复、版本/邮箱校验、公开合同反序列化校验, +以及内存/原子 JSON 文件存储的共享合同和有界读取;还覆盖隔离后的 `Waiting` 重载 +与 Running/active run 双向一致性拒绝,以及 ready 计划的稳定排序、配额/隔离筛选、 +revision-CAS 领取、run ID 数量边界、无状态泄漏和两个文件实例跨进程 CAS 竞争回归。 + +本阶段明确不做: + +- 完整多 Agent Teams、自动调度器、远程执行或跨主机协调; +- SQLite 内置 Coordinator 存储、分布式锁、消息 broker、ack/重试/TTL; +- 自动外部查询、webhook 订阅、未知副作用重放; +- token/金额/时间等产品资源计费模型; +- 复制单 Agent reducer 或在 orchestration 中实现 Provider/Tool 生命周期。 + +这些事项需要产品语义、部署拓扑或外部系统授权后单独立项。当前 P6 出口应理解 +为“提供不复制单 Agent Loop 的稳定编排控制面和扩展端口”,而不是“多 Agent 已经 +可以自动运行”。 + +关联: + +- [原始 P0–P6 计划](./【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md) +- [Agent Runtime 架构](./【架构】独立Agent运行时-2026-09-01.md) +- [Agent 测试与验收证据](./【测试】Agent测试集与真实Provider接入-2026-09-02.md) +- [`agent-runtime-orchestration` 源码](../crates/agent-runtime-orchestration/src/lib.rs) + +## 2026-09-03 隔离状态与快照双向一致性 + +- 持久协调器释放节点的 active run 后,将该节点写成 `Waiting`,由显式 + `repair_downstream` 清除隔离并重新进入 ready;不会留下没有运行句柄的 `Running`。 +- durable 快照保存/导入同时校验两个方向:活动 run 必须对应同 agent 的 `Running` 节点, + 每个 `Running` 节点也必须存在唯一匹配的 active run。无效快照在写入前和恢复入口均 +拒绝;`JsonFileOrchestrationSnapshotStore` 现在提供同目录 sidecar advisory lock 来保护 +本机跨进程 revision CAS 写入,但旧 `JsonFileCoordinatorStore` 不包含该锁,且这仍不提供 +自动调度或跨主机协调。 diff --git a/rust/docs/【架构】独立Agent运行时-2026-09-01.md b/rust/docs/【架构】独立Agent运行时-2026-09-01.md new file mode 100644 index 000000000..ceb822f50 --- /dev/null +++ b/rust/docs/【架构】独立Agent运行时-2026-09-01.md @@ -0,0 +1,779 @@ +# 独立 Agent 运行时 + +> 本文按日期追加的过程段落均为历史快照;当前消息持久化合同以「按事件位置投影消息」为准。 + +## 一句话交付 + +在不依赖 Genarrative 或 Codex 源码的前提下,提供可嵌入的单 Agent Loop、 +durable Runtime 协调层和带 cooperative 后台控制的 CLI;MCP、Skill、OpenAI +与 Codex 都通过适配器接入。 + +## 边界 + +内核拥有中立消息、工具、事件、状态,以及 MCP/Skill 的生命周期、能力和上下文 +契约(通过 `ToolSource`、`SkillSource`、`SkillActivation`、`ContextSource` 等 +端口表达)。MCP 传输、Skill 文件发现与格式解析、SQLite、模型 HTTP 与 Codex +进程都位于适配层;适配层通过稳定端口接入,不把外部协议字段泄漏进 Core。 + +## 持久化合同:按事件位置投影消息 + +Host 在每次执行尝试内维护私有投影游标:已消费的事件位置,以及 checkpoint 联合事务已投影消息的位置。 +checkpoint 写入成功才推进消息游标;最终 trace 根据事件位置跳过已投影的消息,不使用最终 checkpoint +phase、消息是否相等或最后一条消息判断。正常完成、串行多工具、审批恢复适用同一规则。 +压缩前先完成旧上下文中已观察的工具结果/事件投影,再提交 ContextCompacted;已消费事件不会在压缩后 +回流。数据库中的 checkpoint 和事件历史仍是重启依据,游标不构成第二套持久化状态,也不改变公开 API。 + +验收必须逐条比较 Engine 消息、Runtime 消息与事件从零重放结果;仅检查最终文本或“包含一个工具消息”不足以验收。 + +## 当前实现顺序 + +1. Core 契约和纯 reducer; +2. Engine 单 Agent Loop; +3. durable Runtime、SQLite 与 Host; +4. MCP/Skill/Provider 适配器; +5. Codex 外部 backend 与 DAG 编排基础; +6. CLI 和回归测试。 + +当前已落地 `agent-runtime-core`、`agent-runtime-contracts`、`agent-runtime-engine`、`agent-runtime`、 +`agent-runtime-sqlite`、`agent-host`、`agent-storage-sqlite`、`agent-mcp`、`agent-skills`、 +`agent-provider-openai`、`agent-provider-fake`、`agent-codex` 和 +`agent-runtime-orchestration`、`agent-app`。其中 `agent-app` 只承接 CLI/嵌入程序共用的 +非秘密配置解析和装配输入,不拥有 Host/Runtime 状态。Host 会把 Engine 观察事件写入 run-level 审计表, +并把可重放的生命周期事件写入 Core RuntimeStore;两条记录流共享同一 run +ID,但不互相替代。 + +Host 对外的装配名称目前保持最小实现:`AgentBuilder` 和 `AgentService` 是 +`AgentHost` 的类型别名,`AgentHost::new()`/`AgentHost::builder()` 返回同一个 +内存 Host,`open`/`with_store` 用于 durable SQLite。别名不会复制 Runtime 状态或 +线程生命周期;后续若拆出真正的 Builder/Service,仍需保持这些调用合同。 + +`prepare_run_with_messages` 由 `agent-runtime-sqlite::RuntimeService` 使用 SQLite 的 `create_run_bundle` 一次事务写入 +session、queued run、runtime 快照和初始事件,并在写入前用 Core reducer 重放 +校验快照。正常 completed/failed/cancelled 也通过同一类原子终态 API 同时更新 +run、runtime、session 并清理 checkpoint;因此任一状态流校验失败都会回滚, +不会把半成品当作成功。后台 worker 在 claim 之前构造 Provider、Skill 或 MCP +失败时,使用无 lease 的 `fail_unclaimed_run` 原子记录 failed;claim 之后的 +本地准备失败则先把 runtime 推入 reconciling,再释放 lease。 + +CLI 的 `run --background` 先写入 `queued` run,再启动同一可执行文件的隐藏 +worker;worker 领取时写入带 token 的 lease,并由独立 watcher 发送 heartbeat。 +`cancel` 通过 SQLite CAS 写入跨进程可见的取消请求,worker 在 step 边界收口为 +`cancelled`。`resume` 目前只重新领取尚未启动的 `queued` run,不对已经执行过的 +running run 做隐式重放;`reconcile` 只把 lease 已过期或历史上没有 lease 的 +running run 转成 `reconciling`,不会启动 Engine。Host 尝试启动已在 +`running`/`cancel_requested` 状态的 run 时,会先调用一次 +`RuntimeService::reconcile_expired_run_if_stale`:有效 lease 仍拒绝第二个 worker, +过期 lease 才进入同一 reconciliation gate。Runtime 另外提供 +`reconcile_stale_runs(limit)`,用固定排序和硬上限做一次性候选扫描;每个候选仍会 +重新检查 lease 并走同一个原子 gate。这是显式调用的有界扫描,不是后台线程或自动 +恢复调度。完成外部副作用对账后,只有 +`resume-safe` 才能把带 `safe` checkpoint 的 run 显式重新排队。 + +Engine 事件在当前 worker 收口时批量写入审计流,同时在 Provider、审批和工具 +边界写入当前 run 的 fenced 增量 checkpoint。`provider_in_flight`、 +`awaiting_approval` 和 `tool_in_flight` 表示仍可能存在未确认的外部动作;整个 +工具批次完成后才写 `safe` 游标。Provider 响应会先校验 request/model identity +和批次内 tool-call ID 唯一性;审批 deny 会写失败 ToolResult,并在仍有下一调用 +时把 checkpoint 前移到下一调用的 `awaiting_approval`,不会沿用已处理调用的 +身份。没有工具调用的最终 Provider 响应在 run 终态 +提交前仍保留 in-flight 游标,避免崩溃后重复请求 Provider。completed/failed/ +cancelled 与 checkpoint 在同一个 SQLite 事务里清理;lease 过期转入 +`reconciling` 时保留最后游标供对账读取。 + +Engine 的 `EventListener`/`StreamEventListener` 只观察已经接受到 Engine 结果中的 +事件,`CheckpointListener` 则在边界检查点写入时返回成功/失败。Host 额外提供 +`DurableEventListener`(以及 `with_durable_event_callback` 闭包入口):它在每条 +run-level `events` 行的 `append_event_with_lease` 事务提交后同步回调,并带上 +`(run_id, revision, event)`。这个回调只证明该审计行已落盘,后续 runtime trace、 +approval 或终态事务仍可能失败;回调不参与事务、不能回滚或直接改变状态,外部通知 +应自行按 `(run_id, revision)` 去重。 + +这仍不是自动 resume:`reconcile` 和启动边界探测都不启动 Engine,也不重放任何调用。 +当前已提供 +显式的外部结果对账入口:宿主先用 `provider_request_id` 或 `tool_call_id` 在外部 +系统核对结果,再调用 `AgentHost::reconcile_provider_result` / +`AgentHost::reconcile_tool_result`;CLI 对应 `reconcile-provider` 和 +`reconcile-tool`。提交的消息必须是完整历史(保留 checkpoint 前缀并追加已观察的 +assistant/tool 结果),且匹配 phase、调用 ID、step、attempt;Host 与 SQLite +分别做 Core 结构校验和条件 CAS。CAS 成功只把游标切到 `safe`,不会再次调用 +Provider/工具,随后仍须显式 `resume-safe`。恢复器会在领取 queued run 后、启动 +Engine 前用一次 RuntimeStore CAS 补齐消息、调用和结果,再发出 `RunReconciled`, +之后才允许从 `next_step` 启动 Engine。`resume-safe` 在 queued 尚未启动 worker +时可幂等重试。run-level 重排队和这次 runtime CAS 有明确的可重试边界,不能先把 +runtime 提前标成 Running。Core 另提供只读的 `ExternalObservationSource` 端口: +适配器可按 provider request id、tool call id 或 external id 查询既有调用,并返回 +有界的不透明状态/结果;该端口不推进 checkpoint、不重排队,也不自动把结果转成 +消息,宿主仍须完成协议校验后显式调用 reconciliation API。自动查询编排、webhook +订阅和结果重放仍是后续外部系统适配项,不属于本期协议。 + +Runtime 快照只在宿主的边界批次中提交;Provider/工具调用进行期间,带当前 lease 的 +checkpoint 是判断未知外部副作用的唯一观察依据,不能只看快照猜测是否可以重放。 + +PromptBuilder 将 system/developer/user/context 组织为独立 section;由于当前 +旧 MessageRole 合同没有单独的 context role,context section 出站时映射到 +user 通道,section 类型只在组合阶段保留。ContextAssembler 按优先级和稳定 +注册顺序裁剪候选项,并返回预算统计,不冒充模型真实 token 计费。每个 Provider +step 的 `ContextObservation` 还会按候选 `ContextItem.trusted` 统计选中/跳过的 +trusted 与 untrusted 数量;它只记录计数,不把正文或凭据写入观察值,旧 JSON 缺少 +这些可选字段时按 0 读取。 + +agent-skills 只在显式 activate 时读取正文,执行路径和大小/UTF-8/根目录 +边界会再次校验;它实现 Core 的 SkillSource,激活结果以不可信 +ContextItem 提供给 Host,frontmatter 的 allowed-tools 只是元数据。当前解析器是 +有界的行式 frontmatter 子集(name/description/version/allowed-tools 与字符串 +扩展字段),不承诺完整 YAML;每个逻辑字段只允许出现一次,`allowed-tools` 与 +`allowed_tools` 是同一字段,重复会拒绝,避免不同解析器的后值覆盖差异。缺少 +正文的文件只能被发现,激活会拒绝。Host 不会因为 Skill 被激活而放行工具。 + +agent-mcp 提供同步 StdioClient 和 StreamableHttpClient(JSON 或 SSE +响应),McpClient 负责 initialize、tools/list 分页和 tools/call。它还提供显式 +`poll_notification`、有界 `McpReconnectScheduler`(可输出调度审计)以及调用前 +权限审计 gate。Host 把 +目录项映射为 mcp:: 并通过 McpToolExecutor 转发调用;发现 +工具与授予权限分离,默认仍拒绝 MCP 工具,调用方必须注入 allow list。 +stdio 子进程只继承 `PATH` 与显式配置的环境变量;配置、请求和错误的 Debug/ +Display 不回显 URL、header、argv、body 或远端错误正文。tools/list 有页数上限 +并拒绝循环游标;默认同步 transport 由调用方显式轮询消费通知,完成握手的 stdio +client 另可显式转成独占后台订阅。 +transport 对不可信输入设置硬上限:单条 stdio JSON-RPC 消息 1 MiB、Streamable +HTTP 响应 4 MiB、SSE 单行 1 MiB、单个 SSE 事件累计 data 4 MiB;tools/list、 +resources/list 和 prompts/list 各最多 1024 页。超限会在读取/解析层返回协议错误, +不会静默截断。 +重连调度器只包裹握手,不能强杀已经阻塞的同步 I/O,也不会自动重放 +`tools/call`;每次调度尝试携带的 `attempt_timeout` 会传入 +`initialize` 请求和 `notifications/initialized` 写入确认。HTTP transport 用它 +限制响应等待,stdio 通知仍是同步写入。已完成握手的 stdio client 可显式消费 +`into_notification_subscription` 创建的独占后台订阅;订阅使用有界队列和 +cooperative cancel,`Drop`/`join` 回收后台线程及子进程。订阅期间不允许并发 request, +也不会自动应答、重连或重放;Streamable HTTP 与注入的自定义 transport 仍没有长连接 +订阅能力。 + +Host 的 `McpToolExecutor` 在发送前、以及等待 client 锁后再次检查 +`ToolContext` 的 cooperative cancel;已取消调用在这些门禁处返回 +`ToolErrorKind::Cancelled`。第二次检查与实际发送之间仍是 best-effort 窗口,已经进入 +同步 transport I/O 后的硬中断不能由通用 Host 强杀,必须由具体 adapter 自己提供。 + +CLI 的后台 worker 只从继承环境重新构造 SkillLoader 和 MCP client,不把扩展 +正文、认证头或进程句柄写入 SQLite;自行编排 worker 的宿主必须在每个进程 +重建同样的适配器。扩展配置变更不会追溯改写已经落盘的初始消息。 + +CLI 的 `run --jsonl` 只改变呈现格式:前台 run 在 Host 完成后把已收集的事件按 +NDJSON 批量输出,记录类型依次可为 `engine_event`、`stream_event`,最后是带完整 +`HostRunOutput` 的 `result`;`run --background --jsonl` 只返回 `queued` 身份, +worker 的最终结果需另用 `inspect`/`export` 查询。该格式不是实时 worker 日志或 +durable 事务订阅。CLI 的 `reconcile --stale [limit]` 则直接调用 Host 的一次性 +有界扫描(省略 limit 时最多 256 项),只输出本次实际进入 reconciliation 的 run, +不会启动线程、Engine 或外部调用。 + +默认 Host 只批准内置 `echo`;真实工具必须由宿主注入审批策略。OpenAI 适配器 +把 Core 的结构化 `ToolCall/ToolResult` 转换为 Responses 的 +`function_call/function_call_output` input item,并保留 Fake Provider 作为离线回归路径。 +OpenAI Provider 默认请求 `https://api.openai.com/v1/responses`,也可通过 +`OPENAI_ENDPOINT` 指定完整地址、通过 `OPENAI_BASE_URL` 自动补 `/responses`,或 +由 `OpenAiProviderConfig`/手动构造注入;已创建的 Provider 还可通过 +`set_endpoint`/`set_base_url` 在校验后切换地址,CLI 的环境变量优先于 TOML。 +OpenAI 请求使用 Core 本地 request id 作为 `Idempotency-Key`,并把厂商 response +`id` 保存在 `ProviderResponse.external_id`;通用内核只保留该线索,不自动查询 +厂商状态。 + + +`ApprovalDecision::Ask` 是显式的人机协同挂起:Host 会保留 +`awaiting_approval` checkpoint 并进入 reconciliation gate,不伪造 deny、也不 +自动执行。控制端通过 Host/CLI 的 pending-only allow/deny CAS 决议,再显式 resume; +取消会收束 pending 记录,迟到决议会被拒绝。SQLite、Host 和 CLI 均有对应回归测试。 + +## Durable Runtime 与外部 backend + +`agent-runtime` 只提供 portable Core 状态与 `DurableStore` command/view facade: +`DurableRuntime`、`RuntimeSnapshotService`、`WorkerLease` 和 `RunHandle` 不依赖 +SQLite、线程或 CLI。SQLite durable 操作由 `agent-runtime-sqlite` 的 +`RuntimeService` 协调,包括 queued run、claim/heartbeat/release lease、checkpoint、 +取消、过期 reconciliation、safe requeue、审计和跨表终态 CAS;CLI/Host 负责这些装配 +与 worker 生命周期。Host 的 run 查询、lease 领取/续租/释放、stale 检查和带 lease 的 +checkpoint 读写统一通过 `agent-runtime-sqlite::RuntimeService`,不直接拼接 SQLite +参数,也不在 portable runtime 保留第二份 Service API。 + +Runtime 快照的只读观察也经过同一 facade:`AgentHost::load_runtime_snapshot` +仅返回已持久化的 `RuntimeSnapshot`,不领取 lease、不改变状态。Host 的运行路径 +只持有 Runtime facade;恢复重试中为 CAS 准备的本地快照读同样通过该 facade。 +Runtime-only 的事件/快照 CAS 通过 `agent-runtime-sqlite::RuntimeService::commit_runtime_snapshot`, +避免 Host 直接调用 `RuntimeStore` adapter trait;该入口明确不承担跨表事务。 + +需要在不装配 SQLite 的场景复用同一合同时,可使用 +`agent-runtime::RuntimeSnapshotService`。它只拥有注入的 +`RuntimeStore`,提供 runtime-only snapshot/event 的 load 与 revision CAS commit, +也可把 store ownership 交还调用方;它不负责 run/session/lease/checkpoint 的跨表 +事务;SQLite-specific `RuntimeService` 由 `agent-runtime-sqlite` 提供。 +当调用方需要运行时动态装配时,`agent-runtime::DynRuntimeStore` 这个本地 newtype +承载 `Box` 并转发同一组方法;它只是对象安全的装配接缝,不自带锁, +也不扩大 Core 的依赖边界。需要同进程共享和互斥时仍使用 Core 的 +`SharedRuntimeStore`。 + +外部既有调用的查询也经过 `agent-runtime-sqlite::RuntimeService::observe_external`,Host 通过 +`AgentHost::observe_external` 暴露给控制端。该入口只把请求交给 +`ExternalObservationSource`,不会写 checkpoint、消息、requeue 或 reconciliation; +适配器返回的 `ExternalError` 分类不会被 Host 改写。approval、external session 和 +run-level audit event 的常用读写使用同一窄 facade,避免把控制面观察误当成提交 +动作。`store()` 仍可能被旧调用方用于导出或诊断,但不再表示 Host 自己拥有一份 +独立 adapter 状态。 + +`agent-codex` 暴露受限 CLI 一次性调用、可注入的 App Server channel 和带版本字段的 +有界 JSONL protocol fixture,均转换为 Core `ExternalBackend`;另提供窄 V2 +`CodexAppServerClient` 以及把它接到真实 `std::process::Command` 的 +`CodexAppServerProcess`。后者只接受显式 `program + args`,以有界 reader/writer +supervisor 排空 stdin/stdout JSONL,并在 deadline、cooperative cancel、EOF 或 Drop +时完成 Unix process-group 终止、wait/reap 和线程 join;每个 argv 还必须通过调用方 +提供的 `allowed_arg_prefixes`,不会把整段字符串交给 shell 解析;不会自动重连或重放请求。 +这只负责外部进程的生命周期,不接管 Host 的 durable 真相。窄客户端提供可选的 +`CodexServerRequestHandler` 中立回调,负责带 id 的 server request 响应;它不内置 +审批策略、工具执行或厂商参数解码。Host 的 +`ExternalBackendToolExecutor` 将 ToolCall +映射成中立请求、校验 request identity、记录 `external_sessions`,并把未知 +副作用保留在 `tool_in_flight`/reconciliation gate;它不依赖 Codex crate, +因此其它远端执行器可以复用同一桥。App Server channel 的 `protocolVersion=1` +和 `initialize/ready` 握手只属于本仓库的中立 fixture;窄 V2 客户端的 V2 只是当前 +请求 DTO 形状,不是具体 Codex 发行版兼容承诺。真实 Codex 版本的 generated schema、 +wire 转换、启动参数、服务端审批/工具请求的具体语义和会话语义仍由上层适配器负责。CLI 的 +`[codex.cli]` 仍只做配置白名单校验;真实 App Server 进程由调用方显式构造 +`CodexAppServerProcessConfig`,不把发行版启动参数写入 Kernel。 + +在需要锁定具体发行版时,`agent-codex::codex_0_152_1` 要求调用方先核对 +`codex-cli 0.152.1`,再使用固定 `app-server --stdio` 参数。模块只提交生成 schema +的来源/hash 清单和当前实际消费的 typed 子集(生命周期、通知、审批/工具请求), +复杂对象仍是不透明 JSON;它不是“所有 v2 发行版都兼容”的标签。升级发行版必须 +重新生成并审计清单、类型和 fixture,不能仅修改常量绕过版本检查。 + +`agent-runtime-orchestration` 只持有通用任务图和状态:DAG 校验、ready/wave、 +Delegation/Join/Graph Proposal、依赖阻塞、同波次并发和显式下游修复。其内存 +`Coordinator` 额外提供全局/按 agent 的活动 run 配额、message_id 去重投递、节点 +隔离和受影响下游释放/修复;这些是有界控制面,不创建 Runtime run、不调度线程、 +也不携带 Genarrative 业务任务。它默认只在内存中工作;`CoordinatorStore` 端口提供 +可选的内存或原子 JSON 文件快照保存/恢复,文件实现限制大小并校验 schema/派生索引, +不冒充 SQLite 事务或完整多 Agent 调度。`PersistentCoordinator` 将 `TaskGraph` 与 +Coordinator 控制面包装为同一带 schema/revision 的快照;内存和 JSON 文件实现提供 +CAS、原子替换、重开恢复及活动 run/图状态一致性校验,其中 +`JsonFileOrchestrationSnapshotStore` 通过同目录 sidecar advisory lock 串行化跨进程的 +revision CAS/rename;旧的 `JsonFileCoordinatorStore` 仍只保证进程内串行和原子替换, +自动调度仍由宿主负责。 + +相关文档: + +- [README](../README.md) +- [原始 P0–P6 计划](./【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md) +- [增量验收记录](./【验收】Agent内核与通用程序-2026-09-01.md) +- [依赖边界与 CI 审计](./【审计】独立Agent依赖边界与CI验收-2026-09-02.md) + +## 当前非目标与已知缺口 + +当前仍不实现 HTTP 服务、持久化的完整多 Agent 调度、远程执行编排或游戏业务规则;也不把进程中断后的 running +run 伪装成可自动恢复。当前只允许外部系统确认并通过显式协议写入的 `safe` +checkpoint resume;未知 in-flight 调用仍必须先完成对账,自动幂等查询/重放不在 +本期范围。MCP transport 目前以同步请求为主;完成握手的 stdio/Streamable HTTP +client 可由调用方显式转为独占、有界、可取消的后台通知订阅,HTTP 由 adapter 私有 +Tokio worker 增量读取 GET/SSE。自定义 transport 仍不提供长连接订阅、后台通知路由 +或异步并发调度;重连不会自动重放调用。 +Host/CLI 已提供显式 approval resolve/resume,但不会自动替用户决议。真实 Codex +协议转换、Provider/工具自动状态查询、Coordinator 的自动调度,以及独立仓库 +远端 CI/许可证数据库审计仍需后续补齐;本地 `cargo-audit` 漏洞扫描已用固定工具和 +advisory 快照通过。`agent-runtime` 新建 bundle 的运行身份已经使用 +UUIDv7,但 Host 兼容性局部 ID 生成器尚未统一迁移。`AgentBuilder`/`AgentService` +目前只是 `AgentHost` 别名,Host 的 durable callback 也只确认单条审计行提交, +不等同于完整事务订阅或独立 Service 生命周期。 + +## 2026-09-03 Runtime lease facade cleanup + +RuntimeService 的带 lease 终态操作现在统一接收 opaque `&WorkerLease`,在 facade +内部才转换为 SQLite 所需的 worker/token 参数。Host 因而不能把不匹配的两个字符串 +拼接到同一 fencing 操作;这只是类型边界收敛,不改变 SQLite 事务、lease 或自动 +恢复语义。底层 `SqliteStore` API 仍保留给 adapter/兼容 accessor,最终可替换存储 +装配仍未完成。 + +## 2026-09-03 MCP notification subscription 与 OpenAI endpoint setter + +- MCP 新增可选的 stdio/Streamable HTTP 独占通知订阅:`McpClient` 完成握手后通过 + `into_notification_subscription` 转移所有权,后台 worker 使用有界队列;HTTP worker + 在私有 Tokio current-thread runtime 中增量读取 GET/SSE。调用方可通过 + `recv`/`recv_timeout`/`try_recv` 读取,或用 cooperative cancellation 停止。`Drop`/`join` + 会等待 worker 退出并释放连接/stdio child,也会报告尚未消费的 poller、解析、HTTP + 状态或队列错误。自定义 transport 仍只支持显式 `poll_notification`,不自动应答、 + 重连、重放或路由到 Host。 +- OpenAI Provider 在既有 `from_env`、`from_config`、`with_endpoint` 和 `with_base_url` + 之外增加可变 `set_endpoint`/`set_base_url`;校验先行,非法地址不会覆盖当前 endpoint。 + +## 2026-09-03 ContextObservation trust counters 与 RuntimeStore facade + +- Engine 的每个 Provider step 观察值现在额外记录候选上下文的 + `selected_trusted_items`、`selected_untrusted_items`、`skipped_trusted_items` 和 + `skipped_untrusted_items` 四类计数。计数只针对 `ContextItem`,不把基础消息或 + Prompt section 当作候选;字段带 `serde(default)`,历史 JSON 缺字段时仍按 0 读取。 +- `RuntimeSnapshotService` 是 runtime-only 的泛型快照/事件 CAS + facade,可注入 `InMemoryRuntimeStore` 做无 SQLite 的合同测试;完整 durable run、 + session、lease、checkpoint 及跨表原子事务仍由 SQLite-backed `RuntimeService` 负责。 + +## 2026-09-03 HTTP 订阅、Codex envelope、Provider 路由与编排快照 + +- Streamable HTTP 的后台通知订阅现在与 stdio 具有相同的显式所有权转移边界:只在 + 完成握手后建立独占 GET/SSE worker,队列有界、可 cooperative cancel,并在 `Drop`/ + `join` 时回收;它不是通用异步调度器,也不自动应答、重连或重放。 +- Codex 通用 JSON-RPC channel 在 handler 前拒绝混合 request/response envelope,并以 + `CodexServerRequestKind` 和 dispatch 边界错误分类保留未知副作用语义;完整发行版 wire + 仍由版本化 adapter 单独审计。 +- Host 的 ProviderRegistry 仅负责实例/协议 descriptor 选择和匹配校验;Provider 的 + 实际调用仍由 Engine/Host 既有路径负责。`PersistentCoordinator` 仅持有带 revision + CAS 的图/控制面快照,不接 SQLite、不启动线程;只有完整快照的 JSON 文件 adapter + 自带 sidecar 跨进程写锁,旧 Coordinator 快照 adapter 不提供该锁,自动调度和 + Runtime run 事务仍属于宿主职责。 + +## 2026-09-03 P1/P3/P4/P5 收尾增量 + +- Core reducer 在从事件 `detail` 反序列化后重新执行消息、工具调用、工具结果和 + RunSnapshot 的构造级校验;非法值返回 `InvalidEvent`,不改变已接受的快照。 +- Runtime 的泛型 `RuntimeSnapshotService` 通过 + `RuntimeServiceError::StoreProtocol(StoreError)` 保留 CAS/快照错误分类;它仍只覆盖 + runtime-only snapshot/event,完整 SQLite 跨表事务未被伪装成可替换实现。 +- MCP 认证环境变量在高层 connect、HTTP 直接构造和 stdio 直接构造路径一致解析;Skill + 激活正文在读取阶段使用 `Read::take(max+1)`,避免检查后文件增长绕过大小限制。 +- 通用 Codex JSON-RPC channel 增加显式 server-request handler 的握手、请求/事件和 + interrupt 入口,默认仍 fail-closed;版本化 generated wire 仍留在 adapter。 +- Engine 输入与压缩边界新增五个回归:serde `AgentInput`、`ContextItem`、压缩器输出、 + 压缩响应身份和取消传播均在 Provider/checkpoint/工具副作用前校验;Engine 当前 + 定向测试为 45 个(默认与 no-default-features 均通过)。 + +## 2026-09-03 最终本地边界增量 + +- Engine 进一步在 Provider response 和 ToolExecutor `ToolResult` 进入事件、历史或 + 下一轮请求前复用 Core 构造器校验;非法 serde 输出保留 in-flight checkpoint,不伪造 + 完成或 safe 状态。Engine 当前 47 个测试,默认与 no-default-features 均通过。 +- Codex CLI 参数白名单规范化识别大小写、连字符、空格、access-token 和 + Authorization/Bearer 形式的凭据参数,同时保留 `--tokenizer` 等正常参数;Codex + 当前 58 个测试。 +- `requeue_reconciled_run` 首次状态转换才更新时间,重复 queued-safe resume 返回同一 + 记录且不改变 `updated_at`;该幂等修正不改变 SQLite 原子事务边界。 + +## 2026-09-03 编排隔离与快照一致性增量 + +`PersistentCoordinator::isolate_node` 在释放控制面中的活动 run 后同步把图节点写为 +`Waiting`,表示仍可由控制端显式 repair 的隔离态;低层 `Coordinator` 继续只维护 +控制面索引。编排快照导入采用双向不变量:活动 run 必须指向匹配的 `Running` 节点, +图中每个 `Running` 节点也必须有且仅有匹配的 active run/agent。这样重载不会产生 +永远卡住的 Running 节点,也不会把不完整快照当成可调度状态;该边界不引入自动调度、 +Runtime run 事务或跨主机锁服务(完整快照 adapter 的本地跨进程文件锁除外)。 + +## 2026-09-03 最新架构复核(历史快照) + +当前实现计数为 Engine 50、Host 57、MCP 47、CLI 22、Codex 73、Runtime 22、Orchestration 38、 +Skill 27、OpenAI 23、Fake 4;Core 为 22 个单元测试 + 16 个集成测试,Storage 为默认特性 +5+43(48)和直接 no-default-features 4+36(40)。 + +本文件更早按日期追加的计数保留为历史快照;当前计数以上述本节及文档末尾最新复核为准。 + +MCP resources/prompts 是显式选择的 adapter 输入,不属于 Kernel 的隐式上下文;进入 Engine +后所有 untrusted context 的高权限角色(system/developer/assistant)降级为 User,trusted +context 才保留原角色;结构化 tool-call/tool-result 会渲染为普通 User 文本,避免伪造工具 +历史。Host 侧 `CodexHostServerRequestHandler` 只负责同步桥接 +`item/tool/call`,先做已知工具 JSON Schema 校验,再交给 Host approval policy/router;它 +不承担 durable approval/checkpoint/audit,`Ask` 以 JSON-RPC error 返回,故不能替代 Runtime +持久化执行与恢复。对已核对的 Codex 0.152.1,Host 另提供 +`codex_01521_server_request_handler`,将 dynamic-tool 的 `tool`/`callId` wire 返回为 +`contentItems`/`success`;该 typed bridge 仍不代表完整发行版适配。Host 提供显式 +`NamespaceToolResolver`:默认空映射对显式非 `null` namespace 在 approval/execution +前 fail-closed,命中调用方注册的 `(namespace, tool) -> registered_tool` 映射后才路由, +缺省或 JSON `null` 才按全局工具名处理。 + +Host 的 `AgentHost::with_runtime` 是 Runtime 注入边界:调用方可以先装配并持有 +`RuntimeService`,再把同一 facade 交给 Host;Host 不重新打开 SQLite,也不维护平行控制面。 +MCP initialize 在发送 `notifications/initialized` 前严格要求当前唯一支持的 +`protocolVersion=2025-06-18`;缺失或不支持版本保持未初始化。MCP stdio 同步 client 等待响应时暂存的 pending 消息队列使用 +`MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限,超限直接返回协议错误。Engine 对 +Provider response 的 `content` 只接受 Text/Image,结构化 tool call/result 必须走独立字段和 +工具结果回填路径。 + +CLI 的后台、审批恢复和 safe resume 路径共用 spawn 失败收口;子进程未创建成功时只原子 +收束仍未领取的 run,避免遗留 queued 状态,当前 CLI 定向测试为 21 个。`doctor` 不联网或 +启动外部进程,但会按需初始化/迁移本地 SQLite/WAL。 + +原始 P0–P6 仍为“部分完成”。独立远程仓库/CI、registry 与正式许可证策略、真实 +Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime +拆分仍是明确的后续边界。 + +## 2026-09-03 继续执行:显式 namespace 与宿主驱动编排 + +Codex dynamic-tool 的 namespace 解析位于 Host adapter,而不是 Core。调用方可向 +`AgentHost` 或两个 server-request handler 注入 `NamespaceToolResolver`;默认的静态 +resolver 不含任何映射。解析命中后仍按“工具定义 → Schema → ApprovalPolicy → Router” +顺序执行,resolver 本身不拥有执行器或权限。 + +`PersistentCoordinator` 的 `plan_ready_tasks`/`claim_ready_tasks` 是 Runtime 与编排之间 +的窄控制面:前者只读产生带 revision 的有界计划,后者由宿主提供 run ID 并通过既有 +dispatch/CAS 一次性领取。该接口不启动线程、不创建 Runtime run;文件快照的 sidecar +锁只保护其自身的跨进程写入,不负责自动调度,因此不会把编排层重新变成单 Agent 应用 +大杂烩。 + +Runtime/Host 的 `journal_mode`/`export_jsonl` 以及 runtime 事件/session 查询是 SQLite +诊断与只读控制 facade;排序、脱敏和事务语义仍由 storage adapter 保持。`store()` 已标记 +为 deprecated,仅作为兼容 accessor 委托 Runtime;最终可替换存储与 Host/Runtime 完整 +拆分仍是后续工作。 + +## 2026-09-03 继续执行:共享 RuntimeStore 与适配器输入校验 + +Core 的 `SharedRuntimeStore` 为同一进程内的多个 `RuntimeSnapshotService` 提供一个 +共享 Store 实例。它以标准库 `Arc>` 串行化 `RuntimeStore` 的 CAS,并把锁中毒 +映射为 `StoreErrorKind::Unavailable`;不改变 reducer、trait 合同或 SQLite 的事务边界, +也不声称提供跨进程/跨主机 fencing。 + +Skill frontmatter 仍是有界行式子集,解析器现在拒绝未闭合/不匹配引号或列表括号、空列表项 +和空工具名。MCP initialize 则在发送 `notifications/initialized` 前要求 +`protocolVersion=2025-06-18`,未知或缺失版本保持连接未初始化。随 crate 分发的有效/非法 +Skill fixture 和 stdio MCP 假服务器也参与回归;新增边界的定向计数为 Core 17+14、Runtime +21、Skill 27、MCP 47;真实远端协议兼容性仍不由本地 fixture 代替。 + +## 2026-09-03 继续执行:外部事件与重开控制边界 + +`CodexAppServerBackend::invoke_node_with_runtime_events` 是 Codex 适配器到 Core 的显式 +事件桥。它按 request → channel event → result 顺序调用 `NodeRuntimeEventMapper`,将 +中立 DTO 转为连续 revision 的 `RuntimeEvent`,再由调用方 sink 应用 reducer 或 durable +store;backend 不自行创建第二套 Host 会话,也不把 mapper 游标当作持久化真相。 + +`ExternalBackendToolExecutor` 的 active map 仍只是进程内快速索引。索引缺失时,executor +先按 request-id 别名读取 SQLite 的 external session,再由 `cancel_persisted` 调用 +backend cancel 并更新同一行生命周期;Host 通过 `cancel_external_request` 暴露这个显式 +控制面。没有 durable row 时才回退到 backend 的兼容 cancel,不伪造 session/run 归属。 +取消成功或失败都不会自动把未知副作用当成完成,后续仍需显式 reconciliation。 + +## 2026-09-03 继续执行:Codex 通道中断边界 + +通用 `CodexAppServerBackend` 的同步 `AppServerChannel` 仍由一个 mutex 串行保护。 +为避免把“能调用 interrupt”误写成“能抢占阻塞 I/O”,backend 增加显式 +`with_interrupt_hook`:调用方提供独立 control transport 后,hook 可在 `send` 持有 +channel 锁时直接执行;没有 hook 时继续使用 channel 自带的 interrupt。hook 不得重入同一 +channel,也不自动写 Runtime/Host 状态。阻塞 channel 并发回归验证了该差异;真实发行版 +control wire 和 Host 自动取消 fan-out 仍属上层未完成边界。 + +## 2026-09-03 最终门禁(带外中断后) + +带外中断回归后,all-features/no-default-features workspace 测试、`-D warnings` +check/Clippy、双特性 rustdoc、fmt、依赖/manifest/独立复制、Fake 测试集、编码、diff +和 shell 语法均通过;`rust/target` 已清理。当前计数为 Codex 73、Host 57、Orchestration +38、Storage 48(直接 no-default-features 40)。本轮没有 `RUSTSEC_ADVISORY_DB`,离线 +audit wrapper 仅验证缺失输入 fail-closed;真实会话、远端 CI、registry/许可证和自动 +恢复仍不在本地证据内。 + +## 2026-09-03 Provider 生命周期与压缩接线 + +Host 的 OpenAI env/config/手动 key 装配现在使用 Engine 的 +`OwnedProviderContextCompressor`,因此 Host 可以在自己的生命周期内保存压缩器而不 +借用临时 Provider。该适配器只拥有 `Arc`,不改变 Core/Engine 端口; +专用摘要模型仍可通过 `with_context_compressor` 注入。直接切换 Provider 时仅清理由 +自动绑定产生的旧压缩器,避免误把摘要发给旧实例。压缩仍只在上下文超预算时触发, +失败继续保持 fail-closed 和 durable reconciliation 边界。 + +## 2026-09-03 Codex 进程退出状态边界 + +进程 adapter 将 stdout EOF 与 child wait 状态视为两个独立事件:在 EOF 收束前先用短暂 +宽限窗口读取自然退出状态,只有确认仍存活时才终止 process group。这样自然的非零退出 +码继续映射为 `Exit(code)`,不会因调度竞态变成 `ProcessTerminated`;超时、取消和真正 +的 EOF 孤儿进程仍按既有 supervisor 规则收束。该修复的 no-default-features 回归重复 +20 次通过,未改变 Core/Host 的未知副作用和 reconciliation 边界。 + +## 2026-09-03 Runtime/Host 只读 facade 与编排 fixture 清理 + +Runtime 现在提供 `list_runtime_events`、`export_runtime_jsonl` 和 `get_session`;Host +转发这些入口并保留 run-level `list_events`/`export_jsonl` 的独立记录流。这样控制面可以 +观察 Runtime 生命周期、session 和导出结果,而无需依赖 SQLite 表布局。Host 内部测试已 +不再调用 deprecated `store()`;该方法只为旧 SQLite 迁移调用方保留,且委托同一个 Runtime +实例。编排文件重开 fixture 同步清理 sidecar advisory lock,避免测试残留;这些变化不 +提供跨主机锁、自动 scheduler 或最终可替换 RuntimeStore。 + +Provider 注册边界还支持适配器自描述协议:`ModelProvider::protocol_id()` 是可选的 +Core 端口,内置 OpenAI Responses adapter 返回 `openai-responses`。Registry 注册和 +解析时会核对该值与 `ProviderDescriptor.protocol_id`;descriptor 未填写时只对自描述 +adapter 自动补齐,旧/自定义 adapter 返回 `None` 时仍沿用调用方声明。Provider 实例 +身份使用 `ProviderInstanceId`,协议身份使用独立的 `ProviderProtocolId`,两者不可互换。 +这只是本地路由防错,不替代真实协议兼容矩阵。 + +## 2026-09-03 当前 P5 与 feature gate 边界(历史快照) + +当前数字以本节为准;本文件此前按日期追加、含旧数字的段落均为历史快照: + +- 定向测试计数为:`agent-codex` 77、`agent-host` 60、`agent-runtime` 25(其中 + `--no-default-features` 为 5)、`agent-provider-openai` 29、`agent-cli` 23、Core + 23 个单测 + 16 个集成测试。 +- P5 的本地边界是一个可替换的外部 backend:显式 `program + args` 的 stdio process、 + 有界 reader/writer、argv 白名单、deadline/取消、process-group wait/reap、窄 V2 + JSON-RPC 和显式 server-request handler;Host 只通过 Core `ExternalBackend`/外部会话 + 端口接入,不把 Codex 状态机复制进 Kernel。当前本地进程回归使用受控假进程。 +- 真实 Codex 发行版 generated schema、启动参数/版本协商、完整 server-request 语义、 + `threadId`/`turnId` 的 durable session 重开、自动外部对账/订阅及请求重放仍未实现; + 未知副作用必须由宿主显式 reconciliation。 +- feature gate 只位于 Runtime/SQLite 边界:`agent-runtime` 的 `default = ["sqlite"]` + 启用可选 `agent-storage-sqlite`;关闭默认 feature 仅编译 runtime-only 的 + `RuntimeSnapshotService`/身份合同,不代表完整 durable Runtime 或 Host 无 SQLite。 + `agent-storage-sqlite` 的 `core-adapter` 也是可选依赖;Host/CLI 当前仍按默认 Runtime + 依赖使用 SQLite。`agent-codex` 没有 Cargo feature gate,process/typed API 在 all 和 + no-default workspace 组合中都可编译。 +- 因此本地 feature 组合门禁只能证明编译与合同边界,不能把本地 fixture 记为真实 + Provider/Codex session 或远端兼容性验收。 +- OpenAI HTTP 状态分类只属于 Responses adapter:408/429/502/503/504 为 + `Unavailable`,其它非 2xx 为 `Upstream`;它不读取 `Retry-After`,也不执行自动 + backoff、结果查询或重放。 + +## 2026-09-05 输入合同与外部观察边界(当前) + +- Core 的扩展/后端/工具上下文值(`ToolBinding`、`SkillDefinition`、`SkillActivation`、 + `AgentDescriptor`、`BackendRequest`、`BackendResult`、`ToolContext`)在公开 serde 和 + 兼容构造入口重新校验;未知副作用结果只能停留在 reconciliation,不会变成成功工具结果。 +- Engine 在调用 `ContextSource` 前验证 `ContextRequest`;Host 在 Tool/MCP/Skill/backend + dispatch 前复用 Core 合同。这样具体适配器只负责协议和 I/O,不能绕过内核身份、预算和 + 生命周期边界。 +- `list_external_sessions` 是 Runtime/Storage/Host 的只读候选查询,不是自动恢复器:查询 + 受状态过滤 32、limit 256、稳定排序和参数绑定约束,调用方仍须显式执行外部观察和 + reconciliation。 +- 最新 all/no-default workspace 测试、静态门禁、独立复制和离线能力集均通过;这些本地 + 证据不改变真实 Provider/Codex session、远端 CI/registry/许可证、自动外部对账、完整 + 持久化多 Agent 调度及最终 Host/Runtime 拆分仍未完成的边界。 + +## 2026-09-03 取消阶段门禁与配置边界(历史快照) + +无主取消的控制面策略位于 Runtime/Host,而不是 SQLite 的宽泛低层更新接口: +`finish_unclaimed_cancelled_if_safe` 只接受 queued 未启动 run 或无 lease 的 +safe/awaiting_approval checkpoint;历史 running、cancel_requested、reconciling、缺失 +checkpoint 和 provider/tool/compacting in-flight 统一留在 reconciliation。Host 在 +`request_cancel` 前保留 queued 判别,过期 lease 先 stale probe。旧 +`finish_unclaimed_cancelled` 为公开兼容入口,明确不承担该策略,后续若删除需要 breaking +API/Storage contract 变更。 + +Codex CLI/App Server 的 timeout/output/frame limit 在 serde 和运行时两层 fail-closed,避免 +配置检查与 supervisor 执行口径不一致。当前本地计数为 Core 23+16、Engine 50、Runtime 25(package-only +no-default 5)、Storage 5+43/4+36、Host 60、CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 27、 +Codex 77、Orchestration 38;这些数字来自当前源码的双特性门禁,不表示真实外部会话或 +远端 CI 已验收。 + +## 2026-09-04 取消原子性与 Codex 通道边界(历史快照) + +- queued 取消的控制面现在调用 Runtime 的 `finish_queued_cancelled_if_unclaimed`,由 + SQLite 在单个 `BEGIN IMMEDIATE` 事务内再次核验 queued、无取消标记、无 worker/lease + 和无 checkpoint。条件失效只返回 typed conflict/`None`,Host 再走 cooperative + `request_cancel`;因此取消与领取不会产生“已取消但仍持有 lease”的双重所有权。 +- `finish_unclaimed_cancelled_if_safe` 的 queued 分支复用同一原子命令;若 Core snapshot + 不是 `Pending` 直接报告投影裂缝。低层 `finish_unclaimed_cancelled` 继续保留为兼容 + 入口,不被安全控制面使用,后续删除仍需要 breaking contract 设计。 +- Codex JSONL 通道对空白 keep-alive 行采用循环跳过而非递归;8192 行空帧 fixture 已 + 覆盖,未改变帧大小、协议版本和未知副作用分类边界。 +- 当时计数为 Core 23+16、Engine 50、Runtime 26、Storage 5+43(直接 no-default + 4+36)、Host 61、CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 27、Codex 78、 + Orchestration 38;双特性测试和静态门禁通过,但不代表真实外部 wire/session 或远端 + CI 已验收。 + +## 2026-09-04 P2/P4 输入与取消边界(当前) + +- Core 的 `ContentPart::validate`、`Message::validate` 和三个 fallible 构造入口会 + 对嵌套内容执行同一组构造校验;`PromptBuilder::build`/`into_messages`、Provider + request/response 边界不会让非法 serde 数据直接进入 Provider。serde 解码仍可用于 + 读取旧数据,但提交前必须重新构造校验。 +- Engine 压缩器拒绝 `Tool` 角色、任意角色中的结构化 `ToolCall`/`ToolResult`,以及 + summary Provider response content 中的结构化工具块,默认 + `ModelProvider::stream` 在 Provider 只实现 `complete` 时仍保留 tool-call delta、 + usage 和完成事件,不静默丢弃结构化结果。 +- Host MCP executor 在发送前、等待 client 锁后再次检查取消;这是 cooperative + best-effort 门禁,不承诺覆盖第二次检查与发送之间的竞态或已进入同步 I/O 的硬中断。 +- SQLite RuntimeStore 在 load、CAS commit 与 recovery verification 复核 + `RuntimeSnapshot::validate`/`RunSnapshot::validate`;持久化快照被篡改时 fail-closed, + 不会在事务中继续写入后续事件。 +- 当前定向计数为 Core 30+21、Engine 55、Runtime 36(package-only no-default 8)、 + Storage 7+53(直接 no-default 6+36)、Host 69、CLI 23、OpenAI 29、Fake 4、MCP 52、 + Skill 28、Codex 101、Orchestration 40。DurableStore 现已承接 run/session 查询、checkpoint + fencing、approval/external-session、request-cancel/stale 及 runtime-aware finish/recovery + command;真实 Provider/Codex session、远端 CI/registry + 与正式许可证策略、自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime + 拆分仍是未完成出口。 + +- SQLite RuntimeStore 现从空快照重放完整 `runtime_events`,要求 revision 连续且事件语义 + 可重放;event log 缺失、断档或语义篡改在 load/CAS commit/recovery verification + 均 fail-closed。新增 `runtime_store_rejects_event_history_gap_on_load_and_commit`; + Storage all-features 5+45=50,直接 no-default 4+36=40。 +- Skill discovery/list 从 discovery 起受 `max_body_bytes` 硬上限,新增 + `discovery_and_list_reject正文超过配置上限`,Skill 当前 28。 +- Codex process backend runtime event bridge 显式映射 request→notification→result, + 并保留显式 handler 变体;新增 + `app_server_process_backend_runtime_event_bridge_maps_notification_order`,Codex 当前 80。 + +## 2026-09-04 RuntimeService 动态 snapshot Store 接缝(历史快照) + +- `RuntimeService::snapshot_store()` 通过 `DynRuntimeStore` 返回 runtime-only + `RuntimeSnapshotService`,让调用方使用同一 SQLite 状态完成 typed snapshot/event CAS; + `runtime_exposes_dynamic_snapshot_store_without_adapter_handle` 覆盖提交后 facade + 立即可见。 +- 该接缝明确不抽象跨表 durable run/session/lease/checkpoint;Runtime 默认 28/28、 + package-only no-default 6/6 已复核,最终 Host/Runtime 拆分仍是开放项。 + +## 2026-09-04 ContextItem 嵌套消息构造校验(当前) + +`ContextItem::try_new` 在保存消息前复用 `Message::validate`,使上下文值对象不会 +接受兼容入口产生的空内容或非法内容;Core conformance 当前为 28 个单测 + 17 个 +集成测试。该校验仍属于 Core,不引入具体上下文适配器依赖。 + +## 2026-09-05 Engine 公共预检合同(当前) + +公开 `validate_tool_arguments` 在执行 JSON Schema 子集校验前复验 `ToolCall` 和 +`ToolDefinition`,并要求两者的工具名一致;`CompressionRequest::validate` 也会在 +公开压缩适配器调用 Provider 前复验预算、身份和嵌套消息。这样 approval UI、入队方 +和压缩器直调用者不会把 serde/兼容构造出的非法值当成可执行数据;Engine 内部执行 +路径仍会在审批、checkpoint 和工具副作用前再次校验。相关 Engine 定向测试当前为 +55 个,不改变真实 Provider/Codex 会话、自动外部对账或最终 Host/Runtime 拆分等未完成边界。 + +## 2026-09-05 DurableStore 第一阶段接缝(历史快照) + +Runtime 新增中立的 `DurableStore` bundle、run/session 查询、lease、checkpoint、approval、 +外部会话和 runtime snapshot/CAS 命令合同,并由 `SqliteDurableStore` 做当前 SQLite 适配。 +`prepare_run*`、claim/heartbeat/release、checkpoint/approval/external-session 操作和 Runtime +snapshot load/commit 已经经过该接缝,命令参数均为拥有值,不暴露 SQL 或事务句柄;相关 contract +回归验证共享可见性、fencing、pending-only CAS 和 typed 错误。request-cancel/stale 扫描及 +runtime-aware finish/recovery command 也已加入该接缝;SQLite 仍在专用单事务内执行跨表 +原子校验,少数历史兼容 facade 尚未收敛。后续需完成 trait 反转和迁移合同再继续拆分。 + +## 2026-09-05 Engine 公共预检合同(当前) + +公开 `validate_tool_arguments` 在执行 JSON Schema 子集校验前复验 `ToolCall` 和 +`ToolDefinition`,并要求两者的工具名一致。这样 approval UI、入队方等提前调用者 +不会把 serde/兼容构造出的非法 ID、schema 或名称错配当成可执行参数;Engine 内部 +执行路径仍在审批和工具副作用前再次校验。该回归将 Engine 当前测试数更新为 54, +不改变真实 Provider/Codex 会话、自动外部对账或最终 Host/Runtime 拆分等未完成边界。 + +## 2026-09-05 DurableStore command 扩展(历史快照) + +Runtime 的中立 DurableStore 现在覆盖 run/session 查询、lease、checkpoint fencing、approval +CAS、external-session 候选、cooperative cancel/stale 扫描,以及 runtime-aware finish/recovery +command。`RuntimeService` 将这些 facade 路由到 `SqliteDurableStore`;终态 command 的目标和 +queued-unclaimed guard、recovery command 的 runtime snapshot/CAS 都在 SQLite 现有单事务中 +复核,避免把跨表原子操作拆成多次调用。 + +该接缝仍是渐进式迁移:RuntimeService 的具体 durable 实现仍为 SQLite,少数历史兼容取消/终态 +入口保留,尚未完成完整 trait 反转或最终 Host/Runtime 拆分;外部系统自动对账、真实 Provider/ +Codex session 和远端发布/CI 仍需后续证据。 + +## 2026-09-05 DurableStore 控制面查询收敛(历史快照) + +在上述 command 扩展后,cooperative cancel 查询、run→runtime 身份反查、session 投影更新和 +safe requeue 也经 `DurableStore` 路由;Runtime 的 stale/recovery 与安全取消预检不再为这些 +读写直接拼接 `SqliteStore`。session view 保留 metadata、created_at 和 updated_at,兼容公开 +记录语义。诊断导出和 run-level 审计仍直接由 SQLite 提供,旧兼容终态已经经 adapter +路由;这一步继续是渐进接缝,不宣称已完成最终依赖反转。 +`RuntimeService::store()` 与 `AgentHost::store()` 均标记为 deprecated,调用方应迁移到窄 +facade。 + +`DurableRuntime` 是不绑定 SQLite 的拥有式控制面 facade,完整转发 bundle、 +查询、lease、checkpoint、approval、外部会话、终态和 recovery command,并支持 `into_store()` +取回 adapter;`RuntimeService::durable_runtime()` 只是当前 SQLite 便利装配。 + +Runtime 的 `#[cfg(test)]` contract harness 还提供一个仅测试用的 `InMemoryDurableStore`, +实际验证 generic facade 的 bundle、lease、snapshot CAS、safe requeue、finish 和 expired +recovery,不把 SQLite wrapper 误当成非 SQLite adapter;它不进入生产构建或 Host API。 + +## 2026-09-06 Codex 进程观察边界(当前) + +`agent-runtime-contracts` 新 crate 已承接 DurableStore command/view/trait;`agent-runtime` +已完成 portable 化,SQLite-specific Service/adapter 位于 `agent-runtime-sqlite`。 + +Codex 的通用 `JsonRpcAppServerRouter` 作为独立 transport 接缝提供后台 reader、有界 +pending/notification 队列、乱序 response 分发和并发 `turn/interrupt`;它不接入 +`ProcessControl`,因此不替代真实发行版 wire/session 验收。 + +`agent-codex` 的 `CodexProcessLifecycleSink` 是一个可选、emit-once 的进程级观察端口; +ProcessControl 在自然退出、显式终止、cancel、timeout 或 Drop 收束并 join reader/writer +后报告 reason 与可观察 exit code。`CodexAppServerProcessBackend::with_session_metadata_sink` +会把该事件转发给已有 session sink,Host 可将其写入稳定的 +`external-session::process:run:` auxiliary row。该 row 只记录进程观察, +不创建第二个 Core run/session;长连接的 `thread/start`/`turn/start` accepted response 在 +child 仍存活时保持 `active`;`app_server_process_lifecycle_sink_reports_timeout` 与 +`app_server_process_lifecycle_sink_distinguishes_reader_eof` 覆盖 timeout/EOF lifecycle +观察,reader EOF 已有独立 reason;更细的 reader I/O 错误分类、协议级并发 `turn/interrupt` +需要独立的多路 response demux,真实 Codex generated wire/session 仍需匹配 0.152.1 发行版。 + +## 2026-09-06 工具调用联合事务(历史快照) + +`DurableToolCallRuntimeCommit` 位于 `agent-runtime-contracts`,把工具调用行和对应 +Core runtime snapshot/events 作为一个拥有值 command 交给 SQLite adapter。Host 的 +`ToolRequested`/`ToolCompleted` 现在以 lease fencing 和 runtime revision CAS 复用这个 +入口;重复 identity 可幂等,旧数据库缺少 row 时只在 Core call 已核验的情况下回填, +事务失败会同时回滚工具行和 runtime 事件。Engine checkpoint 仍由独立 command 提交, +因此完整 run/runtime/checkpoint 全局事务以及最终 Runtime/SQLite 依赖反转仍未完成。 + +## 2026-09-06 工具调用、Checkpoint 与 Runtime 联合事务(历史快照) + +`DurableToolCallCheckpointRuntimeCommit` 进一步把工具调用行、Engine checkpoint 与 Core +runtime snapshot/events 及 worker lease 作为一个中立拥有值 command。SQLite adapter 在同一 +`IMMEDIATE` 事务中完成身份/lease fencing、runtime CAS/事件重放、checkpoint upsert 和工具 +行写入;create/complete 成功、错 lease 与 runtime CAS 失败回滚均有 Storage 回归。该合同 +没有改动 Host 现有 listener/trace 调度:两者尚未共享同一次事件输入,因此 Host 仍不能把 +该 adapter 能力宣称为全链路全局事务;自动外部对账和最终 Runtime/Host 拆分继续保持开放。 + +## 2026-09-06 编排取消控制面(当前) + +`Coordinator::cancel_run(graph, run_id)` 和 `PersistentCoordinator::cancel_run(run_id)` +是 P6 控制面内的原子取消入口:先在图副本中校验任务状态,再移除活动 run;持久入口 +随后以同一 revision-CAS 提交 `Cancelled` 节点、配额释放和索引变化。该操作不发送 +Runtime cooperative cancel、不终止 worker,也不自动修复或重跑下游;宿主仍负责将 +Runtime 的取消结果与编排状态对齐。 + +## 2026-09-06 Runtime/SQLite 物理拆分完成(当前) + +最终物理结构已落地为 `agent-runtime-sqlite -> agent-runtime + agent-storage-sqlite`。 +`agent-runtime-sqlite` 承接 SQLite-specific `RuntimeService`、记录/错误转换和跨表事务; +`agent-runtime` 已删除 SQLite feature/依赖,只保留 portable facade、值对象和 +`DurableStore` 合同。Host 已迁移到新 crate,未保留 `agent_runtime::RuntimeService` 的 +平行 re-export;breaking API 迁移后的 workspace 双特性测试、portable Runtime 8、SQLite + Runtime 31 和 Host 78 均通过。 + +## 2026-09-06 Host 终态与后台队列边界(当前) + +纯文本成功终态没有外部工具副作用,Host 会把 assistant message event 与 completed event +一起交给现有 SQLite 终态事务;工具、审批和 in-flight 路径仍先持久化安全 checkpoint, +不把未知副作用伪装成可重放状态。后台 CLI 使用 Host 的 +`prepare_run_with_messages_and_metadata` 写入非敏感 provider/model/providerKind 观察信息, +worker 稍后再装配真实 Provider/MCP/Skill;metadata 有对象、大小和 secret-key 边界,不创建 +第二套运行状态。 + +CLI queue 写入的 `providerKind` 是新 metadata marker;内置 fake/openai Host 会在取 lease +前同时核对 kind 和 model,错配只留在 `queued`,而没有 marker 的历史队列继续按兼容语义执行。 + +多工具结果回放遵循 checkpoint 游标边界:中间 `ToolCompleted` 不读取包含后续调用结果的 +最终 Safe checkpoint,只有整个事件序列的最后一个结果才使用 checkpoint/runtime/tool 联合 +事务;这样不会把未来工具结果写入当前 Core snapshot。 + +## 2026-09-06 agent-app 配置边界(当前) + +`agent-app` 是 CLI 之外可复用的无状态装配输入层。它承接 `AgentTomlConfig` 及其 +Skill/MCP/Codex 配置结构、`AGENT_CONFIG`/`AGENT_DB`/Provider/model/streaming 环境优先级、 +OpenAI endpoint/base URL 解析和 queued run 的非秘密 provider metadata。该 crate 不依赖 +Host、Runtime、线程或数据库;密钥只保留环境变量名,实际适配器仍由 CLI/宿主显式装配。 + +CLI 保留 command dispatch、Host 生命周期、worker 和输出逻辑,只通过 `agent-app` 使用上述 +配置 API,避免复制第二套运行状态或把单 Agent 程序入口反向塞进 Runtime。 + +## 2026-09-06 外部会话对账边界(当前) + +Host 在 Runtime facade 之上提供有界 `reconcile_external_sessions(limit, resolver)`。resolver +负责查询已存在的外部调用并显式转换为完整消息历史;Host 只做 checkpoint identity/消息顺序 +校验和 durable CAS。Completed 不自动重排队或重放,Pending/NotFound 保守保留 unknown;真实 +Provider/Codex 查询和 webhook 订阅仍属于适配器/部署层,不进入 Kernel 或 Runtime。 + +## 2026-09-06 审批 checkpoint 联合事务(当前) + +Host 在拿到 Engine 生成的审批 binding 后,通过 `DurableApprovalCheckpointRuntimeCommit` +把 pending approval 与当前 `awaiting_approval` checkpoint/runtime revision 交给 SQLite +adapter 的单一 `IMMEDIATE` 事务校验和写入。对应 ToolRequested Core event 已在前一条 +checkpoint 事务提交,因此该命令不追加事件;Engine callback 先于 binding 生成的窗口仍由 +Host 的 reconciliation/failure gate 负责,不能伪装成全链路原子操作。 + +审批联合事务在 Engine 审计/trace 回放之前执行;首个 awaiting checkpoint 会把缺失的 +assistant/tool 消息前缀与 ToolRequested 一起放入 runtime snapshot,trace 回放只在该 +checkpoint 仍存在且消息完全相同时跳过重复 MessageAppended。普通运行不会按内容全局去重。 + +## 2026-09-06 最终实现口径(当前) + +当前 workspace 的配置层为无状态 `agent-app`,durable SQLite 控制面为 +`agent-runtime-sqlite`,portable 合同为 `agent-runtime`。Host 通过显式 +`ExternalSessionResolver` 提供有界外部会话对账,并通过 +`DurableApprovalCheckpointRuntimeCommit` 收口审批 binding 生成后的 checkpoint/runtime/approval +事务。两者都不启动隐式 scheduler、不重放未知副作用;真实 Provider/Codex 查询、远端 CI/registry/ +许可证和跨主机调度仍属于原计划开放项。 diff --git a/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md b/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md new file mode 100644 index 000000000..fec40a084 --- /dev/null +++ b/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md @@ -0,0 +1,949 @@ +# Agent 测试集与真实 Provider 接入 + +> 本文末尾的 2026-09-06「queued Provider metadata 回归(当前)」和「多工具 checkpoint +> 游标回归(当前)」是当前计数、门禁与路径依据;其余带日期段落保留当时的测试命令和计数, +> 作为历史追溯,不覆盖最新结论。 + +本测试集的目标是新加的 Rust Agent 单程序(`rust/crates/agent-cli` 及其 Host/Engine), +不是 Skill/Plugin 静态评测。 + +## 当前消息持久化回归(2026-09-06) + +`agent-host/tests/message_persistence.rs` 的 7 个集成测试核对:Engine 输出与 Runtime 消息逐条相等; +派生工具索引相等;从空快照重放全部事件等于存储快照;工具表无重复且已完成结果不留 requested。 +场景包括同步、流式、自动多工具/连续轮次、前两调用允许而第三 Ask 后恢复、Provider 错误、工具后的两次压缩和压缩失败。 + +Host 单测另外验证 checkpoint 错 lease 时不推进投影游标、同一 trace 重入不重复提交、相同内容的不同事件不被误吞。 +当前 Host 为 80 单测 + 7 集成;workspace 双特性测试、Clippy、Rustdoc 通过。 + +`run-agent-test-set.sh --quick` 现在为每个成功用例另开只读 SQLite 连接,比较 +`runtime_states.snapshot_json` 中当前 run 的消息与 CLI 返回的 Engine 消息;Fake 两个用例通过。 +它不再仅凭 completed、最终文本或最少事件数判定持久化正确;真实 Provider 仍仅在显式 opt-in 时调用。 + +这份测试集用于先验证运行时闭环,再接入自己的真实 Provider。测试数据在 +[`../tests/agent-test-set.jsonl`](../tests/agent-test-set.jsonl),执行器是 +[`../scripts/run-agent-test-set.sh`](../scripts/run-agent-test-set.sh)。 + +## 运行离线回归 + +在独立 workspace 根目录执行: + +```bash +cd /data/dsk/Genarrative-master/rust +./scripts/run-agent-test-set.sh +``` + +执行器按自身路径定位 `rust/Cargo.toml`,所以从父仓库或任意当前目录调用同一 +脚本也不会误用调用方的 Cargo workspace;`--quick` 仍只运行下面的离线测试集。 + +默认会先运行 workspace 的两套 Cargo 测试,再运行测试集中的两个 Fake 用例: + +| 用例 | 覆盖内容 | 通过条件 | +| --- | --- | --- | +| `fake-tool-loop` | 非流式 Provider → 工具调用 → 工具结果回填 → 最终回答 | run 为 `completed`、至少 2 个 step、至少 1 个工具完成、JSONL 导出可读 | +| `fake-stream-loop` | 同一工具闭环的流式路径 | 有流事件、工具完成、最终回答和 durable 记录 | +| `real-text-smoke` | 自定义真实 Responses Provider 的文本/流式连通性 | 真实 run 完成且返回非空文本;默认跳过 | + +只跑集成用例、跳过 Cargo 测试: + +```bash +./scripts/run-agent-test-set.sh --quick +``` + +从仓库根目录也可使用同一套入口:`npm run agent-runtime:test` 运行确定性测试集, +`npm run agent-runtime:test:full` 先执行独立 workspace 全量 Cargo 回归,再执行测试集。 + +能力评测另外由 `tests/agent-capability-set.jsonl` 和 +`scripts/run-agent-capability-set.sh` 提供。它不调用模型,而是逐项运行现有 Rust +行为测试,覆盖工具参数校验、取消、幂等重试、审批恢复、Skill 显式激活、SQLite 重启 +恢复、过期 run reconciliation、编排节点隔离和重复工具调用防护: + +```bash +npm run agent-runtime:test:capability +# 或查看能力用例清单 +bash rust/scripts/run-agent-capability-set.sh --list +``` + +因此 `agent-runtime:test` 现在同时包含 smoke 和能力回归;真实 Provider 只由 +`run-agent-test-set.sh --real` 显式触发,能力回归不会读取 API key。 + +测试脚本默认在 `~/data/tmp` 下创建 `agent-test-set.*` 目录,数据库、Cargo target +和中间文件都放在其中;显式设置 `TMPDIR` 或 `AGENT_TEST_TMPDIR` 时尊重调用方指定的 +临时父目录。退出时只清理本轮自己创建的目录,不会把 target 留在 workspace,也不会读取 +或打印 API key。 + +测试集调用前台 CLI 的 pretty JSON 接口;如需脚本逐行消费,可给 `run` 加 +`--jsonl`。前台 JSONL 会在运行完成后按顺序输出 `engine_event`/`stream_event`, +最后一行是 `type=result`;后台 `run --background --jsonl` 只输出 `type=queued`, +不会把 worker 的最终结果伪装成实时事件流。 + +本轮本地实际执行(不读取密钥)为: + +```text +AGENT_TEST_TMPDIR="$HOME/data/tmp" bash rust/scripts/run-agent-test-set.sh --quick +PASS fake-tool-loop: steps=2, tools=1, stream_events=0 +PASS fake-stream-loop: steps=2, tools=1, stream_events=4 +SKIP real-text-smoke(需 --real) +``` + +脚本在退出时已清理自己创建的 `agent-test-set.*` 子目录;未触碰 `~/data/tmp` 中的 +其它目录。 + +## 原始 P0–P6 测试清单对应证据 + +`agent-test-set.jsonl` 只做两个离线 CLI 冒烟闭环和一个可选真实 Provider 冒烟, +并不重复所有单元/集成场景。完整 Cargo 回归中的关键证据如下;括号内是测试函数名, +便于在源码中定位: + +| 计划阶段 | 本地证据 | 当前边界 | +| --- | --- | --- | +| P0 独立性与依赖边界 | `scripts/check-dependencies.sh`、`scripts/check-package-manifests.sh`、`scripts/verify-independent-workspace.sh`、`scripts/run-cargo-audit.sh`;审计脚本会从复制目录外运行 metadata/check/package 预检;2026-09-06 用固定 `cargo-audit 0.22.2`/RustSec advisory-db(1239 条 advisory)扫描 190 个锁定依赖并以 0 退出 | 只能证明本地源码结构、复制验收、manifest 文件边界和本次 advisory 快照(lock SHA-256 `e4dccbc5848294df920676fbcf8be1fd6fef2c543647c4ea5db63cdecc7a0bdc`);独立远程仓库、远端 CI、许可证数据库审计及 runner 自己的持续扫描记录仍未提供证据 | +| P1 Core 状态、CAS、版本化事件 | `agent-runtime-core/tests/core_contracts.rs`:`reducer_applies_basic_single_agent_lifecycle`、`reducer_rejects_invalid_transition_and_supports_reconciliation`、`in_memory_store_enforces_revision_cas`、`runtime_event_schema_is_serialized_and_checked_by_reducer`;Core unit:`shared_runtime_store_serializes_concurrent_cas`、`shared_runtime_store_maps_poisoned_lock_to_unavailable`;SQLite:`append_event_enforces_expected_revision`、`runtime_event_schema_version_round_trips_through_sqlite` | `SharedRuntimeStore` 只证明同一进程内共享和 typed lock error;证据仍不是跨进程/跨主机一致性证明 | +| P1 审批绑定与恢复 gate | Core `approval_binding_is_argument_and_expiry_bound`、`approval::tests::verify_binding_rejects_tampered_persisted_hash`、`verify_binding_rejects_tampered_persisted_token`;Host `approval_allow_is_durable_and_resumes_without_replaying_provider_call`、`approval_deny_is_a_durable_failed_tool_result_and_resumes` | 决议仍由控制端显式发起,不自动替用户批准 | +| P2 Engine 文本、工具、流式、取消、压缩 | Engine `synchronous_run_keeps_complete_compatibility_and_no_stream_trace`、`fake_provider_can_finish_after_tool_call`、`streaming_run_consumes_provider_events_without_using_complete`、`provider_cancellation_is_checked_when_call_returns`、`injected_cancellation_is_observed_at_step_boundary`、`context_overflow_uses_explicit_compressor_before_provider`、`compression_preserves_system_and_developer_instructions`、`compressor_cannot_inject_tool_history`、`compressor_rejects_structured_content_in_summary_response`、`oversized_tool_result_is_bounded_with_a_reason`、`deserialized_tiny_tool_output_budget_is_rejected_before_side_effects`、`context_observation_reports_trust_for_selected_and_skipped_items`、`context_observation_accepts_legacy_payload_without_trust_counts`、`malformed_provider_response_is_rejected_before_events_or_tool_side_effects`、`malformed_tool_result_is_rejected_before_completion_event_or_history`;Core `section_constructor_rejects_invalid_deserialized_content`、`provider_constructors_reject_invalid_nested_messages_parts_and_calls`、`serde_invalid_tool_definition_is_rejected_by_request_and_registry`、`deserialized_invalid_content_is_rejected_before_provider_request`、`default_stream_preserves_complete_tool_calls_and_usage` | Provider 硬 I/O 终止仍由具体 adapter 负责;Engine 不自动重放未知副作用;压缩器返回的 `Tool` 角色或结构化 `ToolCall`/`ToolResult` 会在 Provider/checkpoint 前拒绝,遇到摘要响应中的结构化 `content` 直接报错而不静默过滤;Core 的 `PromptSection`、`ProviderRequest`、`ProviderResponse` 和 `ToolDefinition::validate` 构造边界会重新校验 serde/兼容入口产生的嵌套值,Registry 与 `with_tools` 也会在适配器前复核;PromptBuilder 也会在 Provider 前复核;默认 `stream` 不丢失 complete 响应中的 tool-call/usage;观察计数只针对候选 `ContextItem`,不等同模型 token 计费;非法 Provider/ToolExecutor 输出在事件和历史边界 fail-closed | +| P2 工具参数、重试、checkpoint | Engine `invalid_tool_arguments_are_rejected_before_approval_or_execution`、`duplicate_tool_call_ids_are_rejected_before_any_tool_side_effect`、`declared_idempotent_tool_can_retry_selected_timeout`、`unknown_tool_outcome_is_never_retried_even_when_declared_idempotent`、`checkpoints_mark_external_boundaries_and_safe_cursor`、`checkpoint_listener_failure_stops_before_provider`;Host `external_timeout_is_unknown_and_persisted_without_retryable_kind` | 仅声明幂等且由策略放行的明确 Timeout 可重试;外部 backend 的同步 Timeout 结果未知,统一映射为 Unknown 并持久化,不能自动重放;仍没有通用外部幂等查询 | +| P3 Runtime/SQLite/Host durable 恢复 | Storage `expired_recovery_commits_all_states_and_preserves_checkpoint`、`expired_recovery_invalid_runtime_batch_rolls_back_run_and_lease`、`file_backed_restart_recovers_expired_provider_checkpoint`、`safe_resume_requeues_only_safe_reconciled_run_and_preserves_checkpoint`、`two_worker_handles_are_fenced_by_the_same_sqlite_lease`、`stale_run_scan_is_bounded_deterministic_and_excludes_active_work`;Runtime `control_facade_owns_lease_and_checkpoint_fencing`、`runtime_facade_commits_runtime_only_snapshot_with_cas`、`external_observation_is_read_only_and_preserves_adapter_error`、`stale_recovery_probe_does_not_take_an_active_lease`、`stale_recovery_probe_reconciles_an_expired_lease`、`stale_run_scan_reconciles_only_expired_candidates_with_a_hard_limit`、`portable_facade_commits_and_loads_without_sqlite`、`portable_facade_accepts_dynamic_store_without_sqlite`、`portable_facade_preserves_cas_conflict`;Host `expired_worker_moves_to_reconciling_without_replay`、`explicit_provider_and_tool_reconciliation_produce_safe_resumable_cursors`、`host_observes_runtime_snapshot_through_runtime_facade`、`host_observes_external_reference_without_mutating_runtime` | 启动边界和显式扫描只把过期候选送入 reconciliation gate,不会实际杀进程后拉起 worker 或自动外部对账;文件 reopen 回归只证明 durable 状态可在新连接中恢复;Host 的 run/lease/checkpoint/reconciliation、RuntimeSnapshot/ExternalObservation、Runtime-only CAS 和内部终态收口均经 Runtime facade,`store()` 仅保留兼容访问;`RuntimeSnapshotService` 与 `DynRuntimeStore` 只覆盖 runtime-only CAS,完整自动崩溃恢复和可替换 durable Runtime 仍未实现 | +| P3 Provider 与 CLI | OpenAI fixture:`response_parser_reads_calls_and_usage`、`sse_payloads_emit_text_tool_usage_and_build_response`、`stream_terminal_failures_are_rejected_and_include_nested_reason`;CLI `jsonl_records_are_one_line_and_end_with_complete_result`、`run_options_allow_jsonl_anywhere_and_reject_conflicting_stream_flags`;另有本地假 HTTP endpoint smoke | 未使用真实 API key;真实网关可用性不计入离线验收 | +| P4 MCP | `agent-mcp`:`stdio_client_完成握手列工具并调用`、`streamable_http_client_完成_json_握手和工具调用`、`stdio_client_超时不会无限等待`、`单次连接恢复只重握手且不会自动重放工具调用`、`握手会使用调用方传入的_timeout预算`、`重连策略的_attempt_timeout会限制实际握手等待`、`initialize_拒绝缺失或不支持的协议版本`、`权限拒绝会审计且不会发送_tools_call`、`http和_sse解码拒绝超大正文或单行`、`stdio_client_拒绝超大单行消息`、`stdio_reader_超限错误在满队列时也不会阻塞`、`tools_list_拒绝循环游标而不是无限请求`、`后台通知订阅有界转发并支持跨线程取消`、`stdio_client可转成独占后台通知订阅`、`streamable_http_client可转成独占后台通知订阅`、`http后台订阅在空闲间隔也可协作取消`、`后台通知订阅队列溢出传播错误`、`后台通知订阅_join不会吞掉队列中的轮询错误`;Host `cancelled_mcp_tool_context_blocks_transport_before_tools_call` | 默认仍是同步 transport;Host executor 在发送前及取得 client 锁后拒绝已取消调用,因此不会发送 `tools/call`;已经进入同步 I/O 后的硬取消仍由具体 MCP adapter 负责,通用 Host 不强杀线程或伪造副作用结果;initialize 支持调用方配置的有界协议版本候选,版本拒绝时仅对可重建连接重试;完成握手的 stdio/Streamable HTTP client 可显式创建独占、有界、可取消的后台订阅,注入的自定义 transport 仍仅支持轮询;重连不重放调用,后台订阅不自动应答/重连/重放或路由;reader/HTTP worker 的终止错误在有界队列满时也不阻塞回收 | +| P4 Skill | `agent-skills`:`发现阶段不保留正文_激活才返回正文`、`解析_frontmatter_并保留未知字段`、`拒绝重复的已知和扩展_frontmatter_字段`、`拒绝未闭合或不匹配的_frontmatter_引号和列表括号`、`同名_skill_在单根或多根中都显式拒绝`、`拒绝指向根目录外的_skill符号链接`、`激活拒绝发现后内容变化并保留已校验指纹`、`正文大小限制按字节且包含上下边界`;Host `skill_file_core_activation_reaches_host_provider_end_to_end` | 解析器是有界 frontmatter 子集,不承诺完整 YAML;逻辑字段重复(含 `allowed-tools`/`allowed_tools` 别名)、未闭合引号/列表、空列表项和空工具名都会拒绝;Skill 不自动授予工具权限 | +| P5 Codex 外部适配器 | `agent-codex`:中立 JSONL/JSON-RPC 边界测试、`codex_v2_client_*` 生命周期测试、`cli_supervisor_*`、`CodexAppServerProcessRouter` 进程接线回归;新增 `app_server_process_performs_real_stdio_handshake_and_thread_start`、`app_server_process_reports_malformed_output_without_reusing_child`、`app_server_process_reaps_nonzero_exit_and_rejects_later_use`、`app_server_process_reaps_successful_eof_before_next_operation`、`app_server_process_timeout_kills_process_group_and_joins_reader`、`app_server_process_cancel_from_another_thread_is_bounded`、`app_server_maps_post_dispatch_protocol_errors_to_unknown_side_effect`、`app_server_preserves_pre_dispatch_configuration_errors`、`codex_v2_client_dispatches_server_request_to_handler_and_writes_result`、`codex_v2_client_poll_handler_consumes_server_request_before_notification`;版本化模块还覆盖 `manifest_records_the_audited_binary_and_schema_provenance`、`process_config_requires_the_exact_observed_cli_version`、`typed_client_runs_lifecycle_and_routes_command_approval`、`every_typed_server_request_variant_decodes_from_the_audited_wire`、`typed_responses_match_each_request_and_keep_wire_shapes`、`typed_notifications_follow_required_fields_and_preserve_unknown_methods`、`user_input_variants_serialize_with_schema_discriminators`;Host `external_backend_bridge_uses_host_run_and_persists_session`、stale cancel/reconciliation 回归 | 这些是中立 fixture、窄 V2 JSON-RPC 边界、本地 `sh` 假进程和已核对 `codex-cli 0.152.1` 的离线 provenance fixture;真实 process adapter 覆盖显式 argv、stdin/stdout JSONL、EOF/异常退出回收、取消/超时和线程收口;版本化模块只消费 typed 子集并严格检查版本,不是任意 v2 兼容层;请求发出后协议/响应契约错误统一进入 UnknownSideEffect,避免通用重试重放未知副作用;完整 generated schema、真实 Codex 会话、版本特定上层 wire/启动参数、服务端审批/工具请求处理和上层会话接线仍未完成 | +| P6 编排扩展准备 | `agent-runtime-orchestration` 当前 40 个定向测试:除 `cycles_and_unknown_dependencies_fail`、`ready_and_waves_are_deterministic`、`coordinator_dispatches_independent_tasks_in_one_wave`、`coordinator_message_delivery_is_idempotent`、`coordinator_repair_is_explicit_and_releases_affected_runs`、`coordinator_snapshot_round_trip_rebuilds_indexes_and_message_ledger`、`coordinator_snapshot_rejects_unknown_version_and_mailbox_mismatch` 外,还覆盖配额/隔离/修复错误原子性、公开合同反序列化校验、`PersistentCoordinator` 图与控制面联合恢复、revision CAS 冲突、隔离后 `Waiting` 重载和 Running/active run 双向一致性;`plan_ready_tasks`/`claim_ready_tasks` 覆盖稳定候选排序、全局/按 Agent 配额和隔离筛选、过期计划 CAS 拒绝、run ID 数量边界及失败无状态泄漏;新增两个独立文件实例的跨进程 CAS 竞争及 Coordinator/PersistentCoordinator cancel_run 回归。 | Coordinator 提供可由宿主选择保存的版本化 JSON 控制面快照;ready 计划和 durable claim 只由宿主显式驱动,复用同一 revision CAS 与 dispatch 校验;Store 保存/恢复拒绝无效快照,文件读取有硬字节上限;仅 `JsonFileOrchestrationSnapshotStore` 以 sidecar advisory lock 保护遵守该适配器的本机跨进程写入,旧 `JsonFileCoordinatorStore` 仍只有进程内锁;不直接接 SQLite、不调度线程、不实现完整多 Agent Teams | + +因此当前没有必要为了“补齐数字”新增重复 fixture:本地可完成的合同已有对应 +Cargo 测试和两个可重复 CLI 用例;仍缺的项目属于原计划明确的外部出口或非目标, +应保持未完成标记而不是用本地假测试替代。 + +## 接入自己的真实 Provider + +当前适配器是 OpenAI Responses 协议。网关需要兼容 `POST /responses` 的请求体、 +响应字段和 SSE 事件;只兼容 Chat Completions 的地址不能直接使用。 + +### 官方地址 + +```bash +cd /data/dsk/Genarrative-master/rust +export AGENT_PROVIDER=openai +export OPENAI_API_KEY='替换成你的 key' +export OPENAI_MODEL='gpt-4.1-mini' # 按账号可用模型修改 +export AGENT_STREAM=1 + +# 先做一次真实 smoke(会产生一次模型请求) +./scripts/run-agent-test-set.sh --quick --real +``` + +### OpenAI-compatible 网关 + +`OPENAI_BASE_URL` 填网关 base,适配器会自动追加 `/responses`: + +```bash +export OPENAI_BASE_URL='https://gateway.example/v1' +export OPENAI_API_KEY='你的网关 key' +export OPENAI_MODEL='你的模型名' +./scripts/run-agent-test-set.sh --quick --real +``` + +如果网关要求完整路径,也可以直接设置: + +```bash +export OPENAI_ENDPOINT='https://gateway.example/v1/responses' +``` + +`OPENAI_ENDPOINT` 优先于 `OPENAI_BASE_URL`。如果 URL 带 query 参数,适配器会拒绝 +明显的 credential 参数,并在错误/Debug 输出中隐藏安全 query。 + +### 使用自定义 key 环境变量名 + +配置文件和命令行只保存环境变量名,不保存 key 原文: + +```bash +export TEAM_OPENAI_KEY='你的 key' +export OPENAI_API_KEY_ENV=TEAM_OPENAI_KEY +export OPENAI_MODEL='你的模型名' +./scripts/run-agent-test-set.sh --quick --real +``` + +也可使用 `AGENT_OPENAI_API_KEY_ENV`,它优先级更高。不要把 key 写进 +`agent.toml`、JSONL、SQLite 或 shell 脚本。 + +### 使用 `agent.toml` + +```bash +cd /data/dsk/Genarrative-master/rust +cp agent.toml.example agent.toml +``` + +在 `agent.toml` 中设置普通配置(例如 `provider = "openai"`、`model`、 +`openai_base_url` 或 `openai_endpoint`、`openai_api_key_env`),然后通过环境变量 +提供对应 key: + +```bash +export OPENAI_API_KEY='你的 key' +export AGENT_CONFIG=./agent.toml +./scripts/run-agent-test-set.sh --quick --real +``` + +测试执行器为了隔离回归,会故意使用一个不存在的临时配置路径;因此真实测试时请 +优先使用上面的环境变量方式。需要验证 TOML 装配时,可直接运行: + +```bash +AGENT_CONFIG=./agent.toml AGENT_DB=./agent.db \ + cargo run --locked -p agent-cli -- run --stream '只回复 CONFIG_OK' +``` + +环境变量覆盖 TOML:Provider 使用 `AGENT_PROVIDER`,模型使用 +`AGENT_MODEL`(其次 `OPENAI_MODEL`),完整 endpoint 使用 `OPENAI_ENDPOINT`, +再其次是 `OPENAI_BASE_URL`。 + +## 真实 LLM 能力评测 + +仅做 smoke 只能证明请求能发出,不能证明模型按要求输出。本仓库另有 +[`../tests/agent-llm-eval.jsonl`](../tests/agent-llm-eval.jsonl) 和 +[`../scripts/run-agent-llm-eval.sh`](../scripts/run-agent-llm-eval.sh):3 条固定输入的 +真实 Provider 用例,分别检查文本 sentinel、固定双词输出、流式输出和 `echo` 工具闭环。每条用例都会实际 +调用一次 LLM,解析 CLI 的最终 `result`,校验 `finished`、最终文本、步骤数以及流事件数, +并输出加权分数;不会把完整响应写入仓库或日志。 + +默认拒绝联网;使用当前 Codex 桌面配置运行: + +```bash +npm run agent-runtime:test:real-eval +``` + +该命令等价于 `run-agent-llm-eval.sh --real --from-codex-config`,从 +`$CODEX_HOME/config.toml`(默认 `~/.codex/config.toml`)读取模型和 Responses 网关, +从同目录 `auth.json` 读取认证,仅放入子进程环境;密钥不会打印、写入测试集或持久化记录。 +也可以不用 Codex 配置,显式提供 `OPENAI_API_KEY` 后执行: + +```bash +bash rust/scripts/run-agent-llm-eval.sh --real +``` + +真实评测是手工/nightly 门禁,不包含在默认 `npm run agent-runtime:test` 中,以避免每次 +本地或 CI 提交产生网络请求和模型费用。模型回复具有非确定性,当前断言是短 sentinel 和 +协议级轨迹,不代表开放式回答质量;当前工具调用依赖模型遵循提示词,未调用会直接判失败, +不自动重试收费请求。 + +### 新增能力集 + +为避免把基础 smoke 当成能力验收,另有 +[`../tests/agent-llm-capability-eval.jsonl`](../tests/agent-llm-capability-eval.jsonl),覆盖 +非流式 complete 路径、system prompt、developer prompt 和 context prompt。使用当前 Codex +配置执行: + +```bash +npm run agent-runtime:test:real-capabilities +``` + +这组用例的失败也是有效评测结果:当前网关若只接受 `stream=true`,非流式用例应报告 +`Stream must be set to true`;若网关拒绝 Responses 的 system message,应报告 +`System messages are not allowed`,不能为了变绿而放宽断言。当前实跑结果为 2 通过、2 失败: +developer/context prompt 通过,complete/system prompt 暴露上述网关限制。 + +## 如何解读结果 + +- Fake 用例失败:先不要接真实 Provider,说明 Engine、工具或 SQLite 回归需要先修复。 +- `real-text-smoke` 失败:优先检查 key、模型名、网关是否支持 Responses `/responses` 和 + SSE;脚本只显示脱敏错误摘要。 +- 真实 smoke 只验证“请求发出、响应映射、流式终止、run 持久化和最终收口”。它不对 + 模型回答质量、事实准确性或是否主动选择工具打分;这些属于后续业务评测集。 + +真实请求会写入你指定的 `AGENT_DB`(测试脚本使用临时 DB)。完成检查后可删除该 +测试 DB,勿将它或包含响应内容的导出文件提交到仓库。 + +## 2026-09-03 变更后复核 + +本轮 Runtime/Host facade 和 Codex `0.152.1` typed adapter 合入后,以下命令在 +`/data/dsk/Genarrative-master` 重新执行并通过: + +```text +cargo test --locked --manifest-path rust/Cargo.toml --workspace --all-features --no-fail-fast +cargo test --locked --manifest-path rust/Cargo.toml --workspace --no-default-features --no-fail-fast +cargo test --locked --manifest-path rust/Cargo.toml -p agent-storage-sqlite --no-default-features --no-fail-fast +RUSTFLAGS='-D warnings' cargo check --locked --manifest-path rust/Cargo.toml --workspace --all-targets --all-features +RUSTFLAGS='-D warnings' cargo check --locked --manifest-path rust/Cargo.toml --workspace --all-targets --no-default-features +RUSTFLAGS='-D warnings' cargo clippy --locked --manifest-path rust/Cargo.toml --workspace --all-targets --all-features -- -D warnings +RUSTFLAGS='-D warnings' cargo clippy --locked --manifest-path rust/Cargo.toml --workspace --all-targets --no-default-features -- -D warnings +RUSTDOCFLAGS='-D warnings' cargo doc --locked --manifest-path rust/Cargo.toml --workspace --all-features --no-deps +cargo fmt --manifest-path rust/Cargo.toml --all -- --check +``` + +当前计数为:Runtime 22、Host 57、MCP 47、Engine 50、OpenAI 23、Skill 27、Codex 73、 +Orchestration 38; +Storage all-features 为 48(5 个单测 + 43 个集成测试),直接 no-default-features 为 40 +(4 个单测 + 36 个集成测试); +其余 workspace 计数与验收记录一致。 +`verify-independent-workspace.sh`、`check-package-manifests.sh`、依赖边界脚本和 +Fake 测试集也通过。没有使用真实 API key 或真实 Codex 会话;远端 CI、registry +发布、许可证正式门禁和真实发行版完整 wire 仍不在本地证据内。 + +## 2026-09-03 Host Store ownership cleanup + +- `AgentHost` 内部已移除重复 `SqliteStore` 字段;`store()` 兼容 accessor 与原先 + 使用同一个 Runtime-owned 连接,取消/错误收口路径也不再携带无效 Store 参数。 +- 变更后执行 `cargo check --locked --manifest-path rust/Cargo.toml --workspace + --all-targets --all-features` 通过;公开 accessor、SQLite-backed Runtime 和自动 + 外部恢复仍是明确边界。 + +## 2026-09-03 Context/Prompt/Skill metadata determinism + +- `agent-skills` 增加 `拒绝重复的已知和扩展_frontmatter_字段`,覆盖已知字段、 + 扩展字段以及 `allowed-tools`/`allowed_tools` 别名冲突。 +- 定向命令 `TMPDIR=/var/tmp cargo test --locked --manifest-path rust/Cargo.toml + -p agent-skills --all-targets --no-fail-fast`:22 个测试通过;`cargo fmt --check` + 与 `git diff --check` 同时通过。 +- frontmatter 仍是有界行式子集,真实 YAML 语义和 Context token 计费不在本次变更 + 范围;请以专项审计文档的边界说明为准。 + +## 2026-09-03 Runtime lease facade cleanup + +本轮将 Runtime 带 lease 终态 facade 改为接收 `&WorkerLease`,Host 不再拆分传递 +worker/token。以下定向命令通过: + +```text +cargo check --locked --manifest-path rust/Cargo.toml -p agent-runtime -p agent-host --all-targets --all-features +TMPDIR=/var/tmp cargo test --locked --manifest-path rust/Cargo.toml -p agent-runtime -p agent-host --all-features --all-targets --no-fail-fast +TMPDIR=/var/tmp cargo test --locked --manifest-path rust/Cargo.toml -p agent-runtime -p agent-host --no-default-features --all-targets --no-fail-fast +``` + +两种 feature 组合当时均为 Runtime 17、Host 32;该历史计数不涉及真实 Provider、外部恢复 +自动编排或 registry 发布。 + +## 2026-09-03 MCP subscription 与 OpenAI endpoint setter + +- MCP 定向测试现为 38 个:新增 stdio 独占通知订阅、有界队列溢出、跨线程取消、 + poller 错误经 `join` 传播、满队列超限错误不阻塞 reader 和真实 `sh` stdio fixture;订阅 `Drop`/`join` 会回收 + 后台线程与 child。Streamable HTTP 仍无长连接订阅,且没有自动应答、重连或重放。 +- OpenAI 定向测试现为 23 个:除已创建 Provider 的 `set_endpoint`/`set_base_url` + 校验先行回归外,还由本机 fake HTTP server 实际检查 endpoint、请求方法、鉴权、 + 幂等键、请求体和 complete/stream Responses 映射。真实网关兼容性仍需 `--real` + 和调用方自己的 key/endpoint 外部验收。 + +## 2026-09-03 ContextObservation trust counters 与 RuntimeStore facade + +- Engine 新增 `context_observation_reports_trust_for_selected_and_skipped_items`, + 验证 selected/skipped 的 trusted/untrusted 四类计数; + `context_observation_accepts_legacy_payload_without_trust_counts` 验证旧 JSON 缺少 + 新字段时仍可读取。Engine 当前定向测试为 45 个。 +- Runtime 新增 `RuntimeSnapshotService` 的 + `portable_facade_commits_and_loads_without_sqlite` 与 + `portable_facade_preserves_cas_conflict`,证明 runtime-only snapshot/event CAS 可 + 注入 Core `InMemoryRuntimeStore`,不需要 SQLite。该 facade 不覆盖 run/session/lease/ + checkpoint 跨表事务;完整 durable Runtime 仍由 SQLite-backed `RuntimeService` 实现。 +- 本地测试证据不改变原始 P0–P6 的“部分完成”状态;远端仓库/CI、registry 发布、正式 + 许可证策略、自动外部对账/订阅和完整 Codex wire 仍未由这些单测替代。 + +## 2026-09-03 MCP reader 非阻塞终止复核 + +- 新增满有界中间队列的超限 stdio reader 回归,确认终止错误使用 `try_send` 后, + `StdioClient::Drop` 能在有界时间内终止 child 并 join reader;MCP 当前 38 个测试 + 全部通过。 +- 变更后重新执行 all/no-default workspace tests、Storage no-default、两套 + `-D warnings` check/Clippy、`RUSTDOCFLAGS='-D warnings'` rustdoc、fmt、依赖/manifest、 + 独立复制、Fake 测试集、编码与 diff 检查,均以 0 退出。cargo-audit wrapper 仍要求 + runner 注入 `RUSTSEC_ADVISORY_DB`,本地缺少该输入时只报告环境缺口,不把它算作新的 + 漏洞扫描证据。 + +## 2026-09-03 Provider stream interruption 复核 + +- Engine 新增 `streaming_provider_error_does_not_emit_completion`:Provider 发出 partial + 增量后返回 `ProviderErrorKind::Stream`,测试确认错误、增量和完成事件边界均按约定 + 传播,且同步 `complete` 未被调用。 +- `TMPDIR=/var/tmp cargo test --locked --manifest-path rust/Cargo.toml + -p agent-runtime-engine --all-targets --no-fail-fast`:39 个测试通过(前一轮历史记录; +当前 hard-bound 回归后为 45 个);随后 workspace +all-features/no-default-features 回归也以 0 退出。真实 Provider smoke 仍需显式 +`--real`,本次未使用真实 key。 + +## 2026-09-03 HTTP 订阅、Codex envelope 与编排快照复核 + +- `agent-mcp` 当前 41 个测试:Streamable HTTP 完成握手后可显式转移为独占 GET/SSE + 通知订阅;私有 worker 增量解析 SSE,队列有界,支持 cooperative cancel、 + `Drop`/`join` 回收和 HTTP 状态/解析/队列错误传播。空闲间隔取消和队列溢出均有回归。 +- `agent-codex` 当前 53 个测试:混合 JSON-RPC request/response envelope 在 handler + 前拒绝,服务端请求可按中立类别识别,dispatch 前后的错误分别映射为 + `InvalidInput`、`Unavailable` 或 `UnknownSideEffect`。 +- 该阶段 Host 为 35 个测试,ProviderRegistry 可按强类型实例/协议选择 Provider; + Orchestration 该阶段为 29 个测试,`PersistentCoordinator` 以 revision CAS 保存并恢复 + 图与 Coordinator 控制面,拒绝活动 run 与图节点状态不一致的快照。 +- 本轮所有证据仍为本地 fixture;真实 Provider/Codex 会话、远端 CI、registry 发布、 + 正式许可证策略、自动外部对账和完整多 Agent 调度不在本地测试替代范围。 + +## 2026-09-03 P1/P3/P4/P5 收尾复核 + +- Core 新增 `reducer_revalidates_serde_payloads_before_accepting_them`,验证事件 + `detail` 反序列化后仍会重新执行 Message/ToolCall/ToolResult/RunSnapshot 的构造级 + 校验;当前 Core 为 16 个 unit + 14 个 integration 测试。 +- Runtime 新增 `RuntimeServiceError::StoreProtocol(StoreError)` 路径和 CAS/非法快照 + 回归,调用方可按 `StoreErrorKind` 分支处理;Runtime 当前 20 个测试。 +- MCP 新增直接 `StreamableHttpClient::from_config` bearer 和 `StdioClient::spawn` + 环境认证回归,当前 43 个测试;Skill 新增文件增长后的有界正文读取回归,当前 23 个 + 测试。 +- Codex 通用 JSON-RPC channel 新增显式 handler 的 initialize、request/event、interrupt + fixture,当前 57 个测试;默认 handler 仍 fail-closed。 +- 这些命令仍只使用本地 fixture/假服务;真实上游、远端 CI、registry 和许可证数据库 + 不由本地计数替代。 + +## 2026-09-03 文件数据库重开恢复复核 + +- 新增 `file_backed_restart_recovers_expired_provider_checkpoint`:创建作用域结束后 + 关闭 SQLite 连接,再从同一路径重新打开并执行过期 recovery;run 进入 + `reconciling`、lease 被清除,`provider_in_flight` checkpoint 与 + `provider_request_id` 仍在。 +- Storage 定向测试为 all-features 5+39(44)、no-default 4+32(36);测试只验证 + durable 文件状态和显式恢复,不启动 Provider、不自动重放外部调用。 + +## 2026-09-03 Engine hard-bound 回归 + +- 新增 `deserialized_tiny_tool_output_budget_is_rejected_before_side_effects`,覆盖通过 + serde 绕过 Builder 最小预算的输入;Engine 在 checkpoint、Provider 调用和工具副作用 + 之前拒绝,避免截断 envelope 超过配置上限。Engine 当前定向测试为 45 个。 +- 该回归只覆盖本地输入预算边界,不扩大真实 Provider、上游重试或外部副作用结论; + 原始 P0–P6 仍保持“部分完成”。 + +## 2026-09-03 Engine 输入与压缩边界复核 + +- 新增五个回归:serde `AgentInput` 的消息/工具边界、非法 `ContextItem`、非法压缩器 + 输出、压缩响应 `request_id`/`model` 身份和压缩阶段取消传播。 +- 默认与 no-default-features 的 Engine 定向测试均为 45 个通过;失败均发生在主 + Provider、checkpoint 或工具副作用之前。该证据不替代真实上游硬 I/O timeout 或 + 自动外部对账。 + +## 2026-09-03 审批导出与 CLI 展示脱敏复核 + +- Storage JSONL 导出的 approval 行会移除顶层及嵌套 `approval_token`/ + `approvalToken`,但保留审批身份、状态和审计字段;数据库中的原始 binding 仍供 + 显式 resume 使用。 +- CLI `approval list/get/allow/deny` 的 stdout 使用递归脱敏视图,新增回归覆盖顶层、 + 嵌套 token 和请求详情保留;CLI 定向测试为 14 个。 +- 该边界不代表真实 Provider 或外部审批系统已验收;测试集默认仍不读取密钥、不访问 + 网络。 + +## 2026-09-03 最终边界增量回归 + +- Engine 新增 `malformed_provider_response_is_rejected_before_events_or_tool_side_effects` + 和 `malformed_tool_result_is_rejected_before_completion_event_or_history`,覆盖通过 + serde 绕过 Core 构造器的 Provider/ToolExecutor 输出;两种特性配置下 Engine 均为 + 47 个测试通过,非法值不会进入完成事件、消息历史、safe checkpoint 或工具副作用。 +- Codex 新增 `cli_config_rejects_common_secret_argument_spellings`,覆盖大小写、连字符、 + 空格、access-token、Authorization/Bearer 参数,并确认 `--tokenizer` 不被误拦;Codex + 当前为 58 个测试通过。 +- Storage 的 `safe_resume_requeues_only_safe_reconciled_run_and_preserves_checkpoint` 现在 + 额外断言重复 `resume_safe_run` 不改变 `updated_at`;首次 reconciling→queued 仍正常 + 更新时间,all-features 5+39、直接 no-default 4+32 均通过。 +- 上述回归属于本地合同边界,不替代真实 Provider/Codex 会话、远端 CI、许可证审计、 + 自动外部对账或完整持久化多 Agent 调度。 + +## 2026-09-03 继续执行:恢复、MCP envelope 与 doctor + +- Host 新增多工具批次首个 Ask 的恢复回归;当前 Host 定向测试为 37 个。恢复只将 + pending call 前缀写入 Core,后续调用留在 checkpoint,连续显式审批后可完成。 +- MCP 新增共享 JSON-RPC 入站 envelope 校验回归;stdio、HTTP JSON/SSE、后台 SSE 均拒绝 + 非 2.0、method/result/error 混合或缺少类型字段,当前 MCP 定向测试为 44 个。 +- Host MCP 工具桥对发送后 timeout、连接、协议、编码、HTTP 和远端错误统一返回 + ToolErrorKind::Unknown,防止显式 failed 重试策略重放未知副作用。 +- CLI 新增 doctor 配置边界回归;当前 CLI 定向测试为 16 个。doctor 不连接外部服务, + 但会分别报告 SQLite、Provider、Skill、MCP、Codex 检查结果,且不回显 Codex 路径。 +- Orchestration 新增隔离后 `Waiting` 的重载回归,以及无 active run 的 `Running` 快照 + 在 Store 保存和 `from_snapshot` 入口的拒绝回归;双向一致性定向测试当前为 31 个。 +- 该修正后重新通过 all/no-default workspace 测试、双特性 rustdoc、`-D warnings` + check/Clippy、依赖/manifest/独立复制、Fake 测试集、编码和 diff 门禁;当前环境仍 + 没有 cargo-audit/RustSec DB,因此只记录 wrapper 的 fail-closed 缺失输入结果。 + +## 2026-09-03 最新测试边界复核 + +- 组件计数为 Engine 48、Host 54、MCP 47、CLI 21、Codex 64、Runtime 21、Orchestration 38、 + Skill 27、OpenAI 23、Fake 4;Core 为 17+14(单元/集成),Storage 为 all-features 5+40 + (45)和直接 no-default-features 4+33(37)。 +- MCP context 回归要求 resources/prompts 使用显式 selection;选中的内容保持 untrusted, + Engine 边界回归确认不可信的 system/developer/assistant context role 会降为 User,不能 + 通过外部文本提升消息权限;结构化 tool-call/tool-result 也会渲染为普通 User 文本,不能 + 伪造工具历史。 +- Codex Host bridge 回归覆盖 `item/tool/call`:未知工具或 JSON Schema 参数错误在 approval + 和执行前拒绝;合法请求才经过 Host policy/router。该同步低层 handler 不落 durable + approval/checkpoint/audit,Ask 以 JSON-RPC error 返回,因此不等同于完整 Codex session。 + 另有 0.152.1 dynamic-tool typed bridge 回归,验证 `tool`/`callId` 到 + `contentItems`/`success` 响应形状及别名冲突拒绝。Host 提供显式 + `NamespaceToolResolver`;默认空映射对显式非 `null` namespace 在 approval/execution + 前拒绝,命中调用方注册映射后才路由,缺省或 JSON `null` 才接受。 +- Host 新增 `host_uses_an_injected_runtime_facade_without_reopening_storage`:通过 + `AgentHost::with_runtime` 注入既有 `RuntimeService`,由外部 facade 观察 Host 创建的 run, + 证明不会重新打开 SQLite;namespace generic/typed 回归后 Host 当前为 54 个测试。 +- MCP 新增 `stdio_pending消息队列有界`:同步 client 等待响应时的 pending 消息暂存队列 + 受 `MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限约束,溢出返回协议错误;MCP 当前为 + 47 个测试。 +- Engine 的 Provider response 边界回归明确 `content` 只允许 Text/Image;结构化 + tool call/result 不进入 content,必须分别走 `tool_calls` 或工具结果回填;Engine 当前 + 为 48 个测试。 +- CLI 的 background、approval resume、resume 和 resume-safe 启动路径共用 + `spawn_worker_or_fail_unclaimed`;spawn 失败时原子收束仍未领取的 run,CLI 当前为 21 个 + 测试。`doctor` 不联网或启动外部进程,但会按需初始化/迁移本地 SQLite/WAL。 +- 本地测试不替代真实 Provider/Codex wire/session、远端 CI、registry/正式许可证审计、自动 + 外部对账/订阅或完整持久化多 Agent 调度;原始 P0–P6 仍全部为“部分完成”。 + +## 2026-09-03 继续执行增量验证 + +- Host generic 与 0.152.1 typed bridge 的 namespace 回归覆盖显式映射命中、多个 namespace + 复用同一 wire tool、未知 namespace/工具、空值和映射冲突;命中后仍先过 Schema 和 + ApprovalPolicy,再执行 ToolRouter。Host all/no-default 定向测试均为 52 个。 +- `PersistentCoordinator` ready 计划/claim 回归覆盖稳定图顺序、全局/按 Agent 配额、节点 + 隔离、revision 冲突、run ID 数量边界和无状态泄漏;Orchestration all/no-default 定向测试 + 均为 38 个。 +- Runtime/Host 的 journal mode 与 JSONL export facade 由 CLI `doctor`/`export` smoke + 间接覆盖;CLI 不再直接调用 Store 的这两个 SQLite 专属方法。该 facade 只收敛装配边界, + 不改变导出排序、脱敏或事务语义。 +- 以上仍是本地 fixture 与静态门禁证据;真实 Provider/Codex 会话、远端 CI、registry/许可证 + 正式审计、自动外部对账/订阅和完整持久化多 Agent scheduler 仍未验收。 + +## 2026-09-03 编排文件快照跨进程 CAS 回归 + +- 新增 `persistent_file_store_serializes_cross_instance_revision_cas`:两个独立的 + `JsonFileOrchestrationSnapshotStore` 实例同时以同一 expected revision 写入时,sidecar + advisory lock 让一个写入成功,另一个在临界区重新读取 revision 后返回冲突;最终文件 + 仍可完整解码。 +- Orchestration 定向测试当前为 38 个(all/no-default-features 均通过)。该锁只覆盖 + 文件快照写入,不提供自动 scheduler、Runtime run 事务或跨主机协调。 + +## 2026-09-03 MCP initialize 协议版本边界 + +- [x] `agent-mcp` 在发送 `notifications/initialized` 前严格校验 initialize result 的 + `protocolVersion`:当前适配器只接受 `2025-06-18`;缺失、非字符串或未知版本均返回 + `McpError::Protocol`,不会把未完成协商的连接标记为 initialized。新增 + `initialize_拒绝缺失或不支持的协议版本` 回归测试。 +- [ ] 这不是多版本协商实现;若以后支持其它 MCP 版本,仍需显式增加版本化的 transport、 + HTTP header 与能力合同。真实远端 MCP server、远端 CI 和正式兼容性矩阵仍未在本地验收。 + +## 2026-09-03 最终本地门禁复核 + +- 本轮变更收齐后,all/no-default workspace 测试、Storage 直接 no-default、两套 + `-D warnings` check/Clippy、rustdoc、fmt、依赖/manifest/独立复制、Fake 测试集、编码、 + diff、shell 语法和临时目录卫生均已重跑并通过。 +- 当前组件计数为 CLI 21、MCP 47、OpenAI 23、Runtime 22、Core 20+16、Host 57、Engine + 50、Skill 27、Codex 73、Orchestration 38;真实 Provider smoke 仍仅在显式 `--real` + 且由调用方提供凭据时执行。 + +## 2026-09-03 CLI endpoint 集成 smoke + +- 用显式 `--manifest-path rust/Cargo.toml` 运行 CLI,`OPENAI_BASE_URL` 指向回环 fake + Responses 服务;服务端实际断言 `POST /v1/responses`、Authorization、模型字段和最终 + 文本,结果为 `cli endpoint ok`。临时 SQLite 已清理。 +- 该回归不读取真实密钥、不访问外网,真实网关兼容性仍需调用方另行授权验收。 + +## 2026-09-03 CLI OpenAI endpoint 优先级复核 + +- [x] `agent-cli` 的 endpoint 来源现在按“环境完整 endpoint → 环境 base URL → TOML + 完整 endpoint → TOML base URL”解析;同一来源内完整 endpoint 仍优先于 base URL,空白 + 环境值视为未设置。这样 `OPENAI_BASE_URL` 不会被 TOML 中遗留的完整 endpoint 遮住,且 + base URL 仍会由 `agent-provider-openai` 自动补齐 `/responses`。 +- [x] `openai_endpoint_precedence_is_environment_first_across_endpoint_forms` 覆盖环境两种 + 形式、TOML 两种形式、交叉覆盖和空值回退;`agent-cli` 定向测试当前 21 个通过,未修改 + 进程环境,也未使用真实 API key。 +- Host 的 `with_openai_from_env()` 继续只负责默认模型 `gpt-4.1-mini` 的便捷装配;需要 + 环境或调用方指定模型时使用已有的 `with_openai_from_env_with_model`/`with_openai_config`。 + `OPENAI_MODEL` 是 CLI 配置合同,不让库 helper 隐式读取全局环境,避免装配边界漂移。 + +## 2026-09-03 共享 RuntimeStore、严格 Skill frontmatter 与 MCP 握手复核 + +- Core `SharedRuntimeStore` 的两个回归覆盖同一进程内 clone 共享时的 revision CAS + 竞争,以及共享锁中毒映射为 `StoreErrorKind::Unavailable`;当前 Core 为 17 个单测 + + 14 个集成测试,Runtime facade 为 21 个测试。该 wrapper 不提供跨进程/跨主机锁。 +- Skill frontmatter 回归覆盖未闭合/不匹配引号和列表括号、空列表项、空工具名,以及 + 已知/扩展字段重复;随 crate 分发的有效/非法 fixture 也直接参与 discovery/activation, + 当前 Skill 为 27 个测试,仍是无 YAML 依赖的有界行式子集。 +- MCP initialize 回归覆盖缺失、非字符串和不支持的 `protocolVersion`;在发送 + `notifications/initialized` 前只接受 `2025-06-18`;随 crate 分发的 stdio 假服务器还 + 覆盖握手、tools/list 和 tools/call,当前 MCP 为 47 个测试。该项不等于多版本协商或 + 真实远端兼容矩阵。 + +## 2026-09-03 P4 随 crate fixture 回归 + +- [x] `agent-mcp/fixtures/stdio-jsonrpc-server.sh` 是无外部依赖的固定 stdio JSON-RPC + server;回归实际通过 `McpClient` 完成 initialize、tools/list 和 tools/call,并保留 + response-id 校验。 +- [x] `agent-skills/fixtures/skills/review/SKILL.md` 覆盖 metadata-first 与显式 + `activate_core`,`fixtures/invalid/unclosed-quote/SKILL.md` 覆盖 discovery 失败;激活 + 结果的工具列表保持为空,证明 `allowed-tools` 只是候选元数据。 +- [ ] fixture 只证明本地适配器边界,不替代真实远端 MCP 兼容矩阵或真实 Skill 目录审核。 + +## 2026-09-03 继续执行:Codex bridge 与 durable external cancel 回归 + +- 新增 `app_server_runtime_event_bridge_feeds_core_reducer`:本地 App Server channel + 的 request、delta 和 result 通过 `NodeRuntimeEventMapper` 进入 Core reducer,验证 + 连续 revision、request ID 和最终 `RunCompleted`。 +- 新增 `reopened_host_can_cancel_durable_external_request_without_active_index`:新建 + Host 没有旧进程的 active map 时,按 durable request-id 别名调用 backend cancel, + 记录收束为 `cancelled` 并保留既有 metadata。 +- Codex 当前 64 个测试,Host 当前 54 个测试;两套 feature 组合均通过。真实 Codex + 进程、远端网关和自动 reconciliation 仍需单独授权/实现,不能由本地 fixture 代替。 + +## 2026-09-03 Codex 带外中断并发回归 + +- `app_server_out_of_band_interrupt_hook_runs_while_invoke_is_blocked` 使用阻塞的中立 + channel 验证 `with_interrupt_hook` 不获取 channel mutex,独立 control transport 可在 + invoke 等待响应时完成中断;测试对 interrupt 结果使用有界等待,回退路径未被调用。 +- 该 hook 是 opt-in 的通用接线,不内置强杀、超时或真实 Codex control wire;未配置独立 + transport 时,channel interrupt 仍可能与同步 invoke 串行等待。 + +## 2026-09-03 带外中断后最终计数 + +- 最后一轮 all/no-default workspace 测试和直接 Storage no-default 测试均通过;当前 + Codex 73、Host 57、Orchestration 38、Storage all-features 5+43、直接 no-default + 4+36。`run-agent-test-set.sh --quick` 的两个 Fake 用例通过,真实用例按设计跳过。 +- 格式、编码、diff、shell、依赖/manifest、独立复制、check/Clippy 和 rustdoc 门禁均通过; + `run-cargo-audit.sh` 因缺少 `RUSTSEC_ADVISORY_DB` 保持 fail-closed,不能写成当前扫描 + 通过。 + +## 2026-09-03 Provider-owned compression 回归 + +- Engine 新增拥有式 `OwnedProviderContextCompressor` 测试;Host 新增长上下文回归, + 确认摘要调用和主 Provider 调用使用同一实例且顺序稳定。该回归只使用本地 Provider + fixture,不读取 API key 或访问网络。 +- OpenAI 的 env/config/手动 key 装配路径现在自动绑定该压缩器;需要独立摘要模型时, + 仍可在最后调用 `with_context_compressor()` 覆盖。Provider 切换不会误用由旧实例 + 自动绑定的压缩器。 +- runner 默认临时目录为 `TMPDIR` 或 `/var/tmp`;已清理本轮生成的测试目录,避免 + 把测试产物写入用户 home。 + +## 2026-09-03 Codex 进程退出状态回归 + +- reader EOF 与 child `try_wait` 的竞态已修复:adapter 先检查短暂自然退出宽限窗口, + 再决定是否 kill process group;自然非零退出码保持为精确 `Exit(code)`。 +- no-default-features 下目标回归重复 20 次通过;all/no-default workspace 测试和静态 + 门禁均重新通过。该项只收紧本地进程适配器诊断,不代表真实 Codex wire/session 已验收。 + +## 2026-09-03 本地回归与适配器边界(历史快照) + +本节记录 2026-09-03 当日脚本和定向测试数字;最新计数以文末 2026-09-04 当前门禁段为准。 + +- 当前源码计数为:Core 23 个单元测试 + 16 个集成测试,Engine 50、Runtime 25 + (package-only `--no-default-features` 为 5)、Storage all-features 5+43、直接 + `agent-storage-sqlite --no-default-features` 4+36、Host 60、CLI 23、OpenAI 29、 + Fake 4、MCP 47、Skill 27、Codex 77、Orchestration 38。 +- OpenAI Responses 的 HTTP 状态分类是该 adapter 的窄策略:408、429、502、503、504 + 映射为 `Unavailable`,400、401、500 等其它非 2xx 保持 `Upstream`;complete 和 + stream 都保留状态码并脱敏错误正文。adapter 不读取 `Retry-After`,也不在自身执行 + backoff、结果查询或重放;是否重试由 Engine 的 `max_provider_retries` 决定,不支持 + `Idempotency-Key` 的网关应将预算设为 0。该 allowlist 不向其它 Provider 扩散。 +- Runtime package-only no-default 门禁只证明 portable facade 的依赖边界;workspace + no-default 仍是兼容矩阵,不等同于 SQLite-free Host 或最终 Runtime/Host 拆分。 +- 本轮 Fake 测试集默认使用 `~/data/tmp`(显式设置 `TMPDIR` 或 + `AGENT_TEST_TMPDIR` 时尊重调用方覆盖),真实 Provider 只在显式 `--real` 且调用方 + 提供凭据时执行;本地 fixture 不替代真实上游兼容性证据。 + +## 2026-09-03 门禁补充:取消阶段与 Codex 配置(历史快照) + +- Runtime 25 个测试中,`safe_unclaimed_cancel_rejects_historical_running_without_lease` + 和 `safe_unclaimed_cancel_rejects_active_lease_and_inflight_checkpoint` 验证无主取消 + 不会绕过 reconciliation;Host 对应的两条取消回归也在 all/no-default workspace 中通过。 +- Codex 77 个测试新增 CLI 与 App Server 配置的零值、子毫秒和整数转换溢出回归,覆盖 + 配置解析、运行时校验和 Duration 转换边界。 +- 能力 runner 正例 10/10 通过;把一个 filter 改成不存在的名字时返回非零并报告未实际 + 通过任何匹配测试,证明不会把 `0 tests` 当作绿灯。临时目录均在脚本退出时清理。 +- 当前完整计数为 Core 23+16、Engine 50、Runtime 25(package-only no-default 5)、 + Storage all-features 5+43/直接 no-default 4+36、Host 60、CLI 23、OpenAI 29、Fake 4、 + MCP 47、Skill 27、Codex 77、Orchestration 38。RustSec wrapper 因未提供 + `RUSTSEC_ADVISORY_DB` 退出 1,属于预期 fail-closed,未冒充审计通过。 + +## 2026-09-04 取消竞态与 Codex 空帧当前门禁 + +- Runtime/Host 的 queued 取消领取竞态回归已使用两个独立 SQLite 连接和直接 Runtime + 领取竞争验证;Storage 的 `RunFinishGuard::QueuedUnclaimed` 在同一 + `BEGIN IMMEDIATE` 事务内复核 queued、取消标记、worker/lease 和 checkpoint,竞争时 + 只返回 `QueuedCancelConflict`,Host 再发 cooperative cancel。 +- Codex JSONL channel 的空白 keep-alive 行改为循环跳过;8192 行空帧 fixture 通过,且 + 单帧大小、版本和未知副作用测试保持通过。 +- Core 的 `PromptSection::try_new`、`ProviderRequest::try_new` 和 `ProviderResponse::try_new` + 会在构造边界重新校验嵌套内容与工具调用;`section_constructor_rejects_invalid_deserialized_content` + 与 `provider_constructors_reject_invalid_nested_messages_parts_and_calls` 覆盖 serde/兼容入口。 + `PromptBuilder::build`/`into_messages` 也会对 serde 解码消息重新执行 `ContentPart::validate`, + `deserialized_invalid_content_is_rejected_before_provider_request` 验证空文本等非法内容不会进入 Provider。 + Engine 的压缩器拒绝 `Tool` 角色和任意 + 结构化 `ToolCall`/`ToolResult`,`compressor_cannot_inject_tool_history` 覆盖伪造工具 + 历史;Core 默认 `ModelProvider::stream` 在只有 `complete` 实现时仍发出 + `ToolCallDelta` 和 `Usage`,`default_stream_preserves_complete_tool_calls_and_usage` + 验证事件顺序。`ToolDefinition::validate` 会在 Registry 注册和 + `ProviderRequest::with_tools` 边界复核 serde 工具定义,`serde_invalid_tool_definition_is_rejected_by_request_and_registry` + 覆盖非法 schema 的 fail-closed。`compressor_rejects_structured_content_in_summary_response` + 验证 `ProviderContextCompressor` 遇到摘要响应中的结构化 content 会报错而不静默过滤。 +- Host MCP executor 在发送前及取得 client 锁后检查取消, + `cancelled_mcp_tool_context_blocks_transport_before_tools_call` 验证已取消调用不会 + 触碰 transport;已经进入同步 I/O 后的硬取消仍由具体 MCP adapter 负责,通用 Host + 不强杀线程或伪造副作用结果。 +- 最新计数为 Core 28+17、Engine 52、Runtime 28(package-only no-default 6)、Storage + 5+45(直接 no-default 4+36)、Host 62、CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 28、 + Codex 80、Orchestration 38;双特性 workspace 测试、`-D warnings` check/Clippy/ + rustdoc、fmt、依赖/manifest/独立复制、能力集 10/10、Fake 集、编码、diff 和 shell + 语法均通过。根 Vitest 的 `3189/3189` 为历史记录,本轮工作区未安装 `vitest`,尝试以 + 退出码 127 结束,未计入当前通过项。 +- 2026-09-04 在隔离的 `~/data/tmp/` 临时目录使用 `cargo-audit 0.22.2` 与 RustSec + advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 执行 `run-cargo-audit.sh`, + 加载 1239 条 advisory,扫描 188 个锁定依赖并以退出码 0 通过,无漏洞/warning; + `Cargo.lock` SHA-256 为 `1a3282fe963b9a53e3d9e24a1507c75f1ee19b9a8648025d4f6da25aca5464d5`。 + 工具和数据库随后清理,未写入仓库;wrapper 在缺失 DB 时仍 fail-closed。 +- 清理 `rust/target` 后以临时 `AGENT_DB` 直接运行 `cargo run --locked -p agent-cli -- run` + 完成 Fake Provider 工具调用闭环;该黑盒 smoke 的临时数据库已删除。 +- SQLite 运行时快照回归 `runtime_store_rejects_tampered_snapshot_on_load_and_commit` + 通过第二连接篡改已持久化 JSON,验证 load/replay 前的语义校验、typed + `InvalidSnapshot` 映射和失败 CAS 的原子不写入;因此当前 Storage all-features 为 + 5 个单测 + 45 个集成测试(50),直接 no-default 仍为 4+36(40)。 + +## 2026-09-04 RuntimeService 动态 snapshot Store 接缝(当前) + +- `RuntimeService::snapshot_store()` 的动态 `RuntimeSnapshotService` + 回归 `runtime_exposes_dynamic_snapshot_store_without_adapter_handle` 已通过;它共享 + 当前 SQLite 状态,只覆盖 runtime-only snapshot/event CAS。 +- 受影响的 Runtime 默认测试为 28/28,package-only no-default 为 6/6;这项窄接缝不把 + SQLite-backed durable run/session/lease/checkpoint 宣称成已经可替换。 + +## 2026-09-04 manifest 预检 target 隔离(当前) + +- 以 `AGENT_PACKAGE_TMPDIR="$HOME/data/tmp/package-manifest-check-0904"` + 运行 `scripts/check-package-manifests.sh rust/Cargo.toml`,13 个 crate 全部通过; + 脚本语法检查通过,退出后临时 target 和 workspace `rust/target` 均不存在。 +- 显式 `CARGO_TARGET_DIR` 仍由调用方控制;该验证只覆盖本地 manifest 预检,不等同于 + registry 发布或远端 CI 验收。 + +## 2026-09-04 ContextItem 嵌套消息构造校验(当前) + +- `agent-runtime-core` 集成回归 `context_item_constructor_rejects_invalid_nested_message` + 验证 `ContextItem::try_new` 会拒绝由兼容 `Message::new` 构造的非法消息;Core 当前 + 28 个单元测试 + 17 个集成测试,Engine 的既有 ContextSource 边界复核保持通过。 + +## 2026-09-04 临时目录策略(当前) + +- `run-agent-test-set.sh`、`run-agent-capability-set.sh` 和 + `run-agent-llm-eval.sh` 在没有专用覆盖变量或显式 `TMPDIR` 时,统一回退到 + `~/data/tmp`;`verify-independent-workspace.sh` 的默认复制目录父级也使用该路径。 + 每个脚本只删除自己创建的精确子目录。 +- 无外部服务的回归会先清空 `TMPDIR`,用 `bash -x ... --list` 检查三个 runner 的展开值, + 再把 `AGENT_TEST_TMPDIR` 指到 `~/data/tmp` 的隔离子目录运行 `--quick`,并确认退出后 + 没有遗留 `agent-test-set.*` 目录;`bash -n rust/scripts/*.sh` 同时作为语法门禁。 + +## 2026-09-04 Runtime event history、Skill discovery 与 Codex process bridge(当前) + +- SQLite RuntimeStore 新增完整 event history continuity 校验:load、CAS commit 和 recovery + verification 都从空快照重放 `runtime_events`,event log 缺失、断档或语义篡改时 + fail-closed;`runtime_store_rejects_event_history_gap_on_load_and_commit` 已通过。 + Storage all-features 5+45=50,直接 no-default 4+36=40。 +- Skill discovery/list 从 discovery 起受 `max_body_bytes` 硬上限,正文超限不再无界成功; + `discovery_and_list_reject正文超过配置上限` 已通过,Skill 当前 28。 +- Codex process backend runtime event bridge 显式映射 request→notification→result, + 同时保留显式 handler 变体;`app_server_process_backend_runtime_event_bridge_maps_notification_order` + 已通过,Codex 当前 80。 +- 根 Vitest 的 `3189/3189` 仍为历史记录,本轮工作区未安装 `vitest`,尝试以退出码 127 + 结束,未计入当前通过项;原始 P0–P6 仍部分完成。 + +## 2026-09-05 Core 合同与全量门禁复核(当前) + +- Core 的公开扩展/后端/工具上下文值在 serde/兼容入口统一调用 `validate`;Engine 在 + `ContextSource` 调用前校验 `ContextRequest`,Host 在工具、Skill、MCP、外部 backend + dispatch 前复用同一合同。非法身份、预算、元数据、工具定义或未知副作用结果不会进入 + Provider、checkpoint 或外部 transport。 +- Runtime/Storage/Host 的 `list_external_sessions` 只读候选查询限制状态过滤 32 项、 + limit 256,固定 `created_at ASC, id ASC` 排序并参数绑定;它不执行 observe、replay、 + requeue 或 reconciliation。 +- 隔离 `~/data/tmp` 的 all/no-default workspace 测试、check、Clippy、rustdoc、Storage + 直接 no-default、依赖/manifest、独立复制、Fake 2/2、能力 10/10、fmt、shell、编码和 + `git diff --check` 均通过。当前计数:Core 30+21(另 1 doctest)、Engine 55、Runtime + 36/portable 8、Storage 7+53/直接 no-default 6+36、Host 69、Codex 101、MCP 52、Skill + 28、OpenAI 29、CLI 23、Fake 4、Orchestration 40。 +- `DurableToolCallRuntimeCommit` 的 Storage 回归覆盖同一 IMMEDIATE 事务中的工具行与 + runtime event/CAS、错误回滚、lease fencing 和旧 row 回填;Host Fake loop 已走该路径。 +- 真实 Provider smoke 仍需显式 `--real` 和调用方凭据;本轮未读取 key,也未把根仓库缺少 + `vitest`(退出码 127)的历史记录计入 Rust workspace 通过项。 + +- 公开压缩入口新增 `compression_request_revalidates_public_budget_and_identity`:零预算、 + 非法 session/run 身份或非法嵌套消息会在压缩 Provider 调用前失败;公开工具参数预检 + 同样复验 Core 调用/定义和工具名匹配。Engine 当前定向测试为 55 个。 +- 编排、Host、Skill 和 SQLite Storage 的测试临时目录均尊重显式 `TMPDIR`,未设置时 + 回退到 `~/data/tmp`,避免默认落入系统 `/tmp`。 +- Runtime 第一阶段 `DurableStore` 合同回归覆盖 `prepare_run*` 通过 + `SqliteDurableStore` 创建 bundle、run/session 查询、runtime snapshot CAS、lease claim/ + heartbeat/release、checkpoint fencing、approval pending-only CAS、外部会话候选以及 stale + revision 的 typed conflict;当前还覆盖 request-cancel/stale、runtime-aware + finish/recovery command 及 queued-unclaimed guard;Runtime 当前 33 个测试,仍由 + SQLite 单事务实现跨表原子性。 + +## 2026-09-05 DurableStore command 扩展(当前) + +- `DurableStore` contract 回归现在还覆盖 request-cancel/stale 扫描,以及 runtime-aware + finish/recovery command;终态目标、queued-unclaimed guard、runtime revision/CAS 和 + checkpoint/lease fencing 均以拥有值 command 传入 `SqliteDurableStore`,Runtime 当前仍为 + 33 个测试。 +- 这些测试验证适配器路由和 SQLite 单事务冲突语义,不声称外部 Provider 结果已自动查询、 + 对账或重放;历史兼容取消/终态入口仍是待迁移边界。 + +## 2026-09-05 DurableStore 查询边界回归(当前) + +- Runtime contract 额外覆盖 runtime identity、cancel marker 和 session update;safe requeue、 + stale/recovery 与安全取消预检均经过 DurableStore,Runtime 当前仍为 33 个测试。 +- 本轮只扩大本地中立 adapter 接缝,不改变真实 Provider/Codex session、自动对账或外部发布 + 验收结论;诊断/审计导出仍保留 SQLite 具体实现。 + +## 2026-09-05 DurableRuntime generic facade 回归(当前) + +- `generic_durable_runtime_facade_owns_and_returns_the_adapter` 验证 generic facade 可以在 + 不暴露 SQLite 的情况下查询 run/session、反查 runtime identity,并通过 `into_store()` 取回 + adapter;`generic_durable_runtime_runs_without_sqlite_adapter` 和 + `memory_adapter_recovers_an_expired_lease_without_sqlite` 进一步使用测试内存 adapter + 覆盖 bundle/lease/CAS/finish/recovery;Runtime 当前 36 个测试,package-only no-default 8 个。 + +## 2026-09-05 工具调用持久化回归(当前) + +- Host Fake loop 断言 `tool_calls` 行包含 `echo`、`completed` 和结果;Storage 回归断言相同 + identity 的重复 insert 幂等,按 run 查询保持稳定顺序。CLI export smoke 输出 1 条工具调用行。 + +## 2026-09-05 Codex session metadata sink 回归(当前) + +- `codex_session_metadata_sink` 回归验证 thread/turn ID 写入同一 run 的 `external_sessions`, + 第二次 turn 更新 external_id 而不新增孤立记录;Host 当前 66 个测试。 + +## 2026-09-06 Codex/MCP 边界回归(当前) + +- Codex 101 个测试覆盖 cancel post-dispatch 分类、argv prefix 安全和真实 wire notification + alias;Host 80 个单测 + 7 个消息持久化集成回归覆盖非法/取消 ToolContext 不触发审批或执行;MCP 52 个测试覆盖 + server request 不空转且后台订阅传播错误。 + +## 2026-09-06 Codex 请求生命周期回归(历史快照) + +- `agent-runtime-contracts` 新 crate 承接 DurableStore command/view/trait;`agent-runtime` + 已完成 portable 化,SQLite-specific Service/adapter 位于 `agent-runtime-sqlite`。 +- `app_server_process_backend_reports_lifecycle_to_sink` 使用真实 stdio child fixture 验证 + request result、生成的 external ID 和长连接仍存活时的 `active` 生命周期会送达 sink; + `app_server_process_reaps_nonzero_exit_and_rejects_later_use` 另验证自然退出码可观察。 +- 迟到 cancel 的竞态回归确认 ProcessControl 已空闲时不再追加 `cancelled` lifecycle,避免 + 覆盖已经完成的 request observation。 +- `codex_session_metadata_sink_persists_thread_and_latest_turn` 额外验证生命周期字段与已有 + `customAudit` 合并、退出码持久化,以及 terminal 状态保持不变。 +- `codex_host_from_host_persists_and_caches_duplicate_tool_call` 与 + `codex_01521_from_host_closes_failed_tool_call_durably` 验证 Host 两种 handler 的 durable + tool-call 接线、重复调用缓存和失败收束。 +- `app_server_process_lifecycle_sink_emits_natural_exit_once`、 + `app_server_process_lifecycle_sink_emits_explicit_terminate_once` 和 + `app_server_backend_forwards_process_lifecycle_to_session_sink`、 + `app_server_process_lifecycle_sink_emits_drop_once`、 + `app_server_process_lifecycle_sink_reports_timeout`、 + `app_server_process_lifecycle_sink_distinguishes_reader_eof` 验证进程 lifecycle sink 的 + emit-once、退出码、Drop、timeout/EOF 和 session sink 转发。 +- 定向结果:Codex 101/101、Host 80/80 单测 + 7 个消息持久化集成回归;reader EOF/I/O reason 已分开实现,协议级 turn interrupt 和真实 + 发行版 wire 仍未验收。 +- `JsonRpcAppServerRouter` 与 `CodexAppServerProcessRouter` 回归覆盖乱序 response、通知混合、 + timeout 释放 pending 槽位、独立 interrupt 参数、cancel 唤醒 pending 和 scoped remote error + 复用;真实发行版 wire/session 仍需专用适配器或外部证据。 + +## 2026-09-06 MCP 协议版本候选协商回归(当前) + +- `stdio_initialize会按候选版本重建连接并完成协商` 使用两个独立 stdio 进程验证:首个 + `protocolVersion` 被明确拒绝后,client 只重建连接并尝试下一个候选,成功后暴露协商版本。 +- `streamable_http_initialize按候选版本重建连接并更新协议头` 使用本地 TCP HTTP 服务验证 + 首次拒绝、第二次重建和后续 `notifications/initialized` 的协议头均符合协商结果。 +- `protocol_version候选配置有界且_http头可更新` 验证候选列表有界,以及 Streamable HTTP + `mcp-protocol-version` header 随协商结果更新。 +- 当前 `agent-mcp` 定向测试为 52/52;该回归不覆盖 server request 自动应答、通知订阅自动 + 重连或 `tools/call` 自动重放,原始 P4 仍保持部分完成。 + +## 2026-09-06 编排取消控制面回归(当前) + +- `coordinator_cancel_run_updates_graph_and_releases_quota_atomically` 验证低层 Coordinator + 在同一次操作内更新图节点与活动索引;`persistent_cancel_run_commits_cancelled_state_and_releases_quota` + 验证持久协调器的 revision-CAS、配额释放、依赖阻塞和重开恢复。 +- `agent-runtime-orchestration` 当前定向测试为 40/40。该能力只更新编排控制面,不发送 + Runtime 取消信号、终止 worker 或自动重跑下游任务。 + +## 2026-09-06 Host ToolCompleted checkpoint 联合事务回归(历史快照) + +- Host Fake tool loop 现在覆盖 `ToolCompleted` trace 读取当前 checkpoint 后使用联合事务的 + 路径;Storage 的 create/complete、lease fencing 和 CAS 回滚回归确保错误不会留下半个提交。 +- 首次 `awaiting_approval` checkpoint 已由 Host 在同一事务中提交 `ToolRequested`、工具行、 + checkpoint 和 Core runtime event;旧 run 缺少 checkpoint 时仍保持兼容 tool/runtime 路径。 + 普通 checkpoint 和终态尚未全部共享同一事件输入,因此仍不等同全链路全局事务。 + +## 2026-09-06 Runtime/SQLite 物理拆分回归(当前) + +- `agent-runtime` 已变为 portable crate,package-only `--no-default-features` 通过 8 个 + 测试;`cargo tree -p agent-runtime --no-default-features` 不含 `rusqlite`、 + `agent-storage-sqlite` 或 SQLite feature。 +- `agent-runtime-sqlite` 承接 SQLite-specific `RuntimeService`、adapter/records/error 和 + 31 个测试;Host 80/80 单测 + 7 个消息持久化集成回归已迁移到新 crate,未保留旧 `agent_runtime::RuntimeService` re-export。 +- workspace all-features/no-default-features 测试、Clippy `-D warnings`、rustdoc、fmt、依赖 + 边界和独立复制检查均在该 breaking API 迁移后通过;workspace 当前为 15 个 crate。 +- 能力集过滤器同步迁移 `runtime-stale-recovery` 到 `agent-runtime-sqlite`,重新执行后 + `SUMMARY total=10 passed=10 failed=0`;不存在测试过滤器漂移的假绿。 +- `cargo test --workspace --all-targets` 的 all-features 与 no-default-features 两次回归均 + 通过,包含 CLI 24、Codex 101、Host 80 单测 + 7 集成、MCP 52、OpenAI 29、Fake 4、portable Runtime + 8、SQLite Runtime 31、Core 30+20+1、Engine 55、Orchestration 40、Skill 28 和 + Storage 7+53。 + +## 2026-09-06 多工具 checkpoint 游标回归(当前) + +- `multi_tool_trace_only_reuses_safe_checkpoint_for_final_result` 验证正常多工具批次中, + 中间 `ToolCompleted` 不复用包含后续调用结果的 Safe checkpoint,最终结果才使用三方联合 + 事务;Host 定向测试 78/78 通过。 + +## 2026-09-06 queued Provider metadata 回归(当前) + +- `queued_provider_metadata_matching_model_can_claim`、`queued_provider_metadata_model_mismatch_is_rejected_before_claim` + 和 legacy metadata 回归验证:后台队列记录的 provider/model 不会在 worker claim 前静默漂移, + 错配保持 queued 且不触发 Provider,旧 provider-only 记录仍可执行。 + +## 2026-09-06 agent-app 配置边界回归(当前) + +- `agent-app` 的 3 个纯配置测试覆盖缺失配置默认值、OpenAI endpoint 环境优先级,以及 + provider/effective model 与非秘密 queued metadata;不触碰 Host/Runtime 状态。 +- CLI 改用 `agent-app` 配置 API 后定向测试保持 24/24;这些本地测试不宣称真实 Provider、 + Codex wire/session 或远端持续 CI 已验收。 + +## 2026-09-06 Host 外部会话对账回归(当前) + +- `bounded_external_reconciler_commits_completed_and_keeps_unknown_conservative` 使用 fake + resolver 验证 Completed 消息前缀校验与 safe checkpoint、Pending/NotFound 的 unknown 收口, + 并确认第二次扫描不会重新处理已完成记录。 +- Host 定向测试为 78/78;该测试只验证 resolver 注入和本地 durable CAS,不替代真实 Provider/ + Codex 查询、webhook、网络超时或自动重放验收。 + +## 2026-09-06 真实 Codex app-server 探测(当前证据) + +- 在隔离 `CODEX_HOME`、无 API key 的本机环境运行 `codex-cli 0.153.4 app-server --stdio`, + 真实进程成功返回 `initialize`,随后接受 `initialized` 并返回 `thread/start`;响应包含 + string `thread.id` 和版本化 user-agent。 +- 没有发送 `turn/start` 或触发网络/Provider/工具副作用;该证据不替代仓库 + `codex_0_152_1` typed adapter 的版本兼容验收,也不关闭真实 generated wire/session 缺口。 + +- 证据已固化为 `scripts/probe-codex-app-server.sh`,可通过 + `AGENT_CODEX_PROBE_TMPDIR=~/data/tmp/codex-probe scripts/probe-codex-app-server.sh` 手动 + 重跑;脚本只输出通过/失败摘要,自动清理自身临时目录,不进入默认 workspace 测试或 CI。 +- `--schema` 模式额外核对本机 0.153.4 v2 schema:706653 bytes,SHA-256 + `e5f798fd1343c539f01fedea0e8a84a43c080fcca4615c80eb04a5edab4f7d0a`;不提交生成文件,且不 + 将该版本漂移证据当作 0.152.1 typed adapter 兼容证明。 + +## 2026-09-06 合并门禁(当前) + +- all-features 与 no-default-features workspace all-targets 均通过;当前 workspace 为 15 + 个 crate,agent-app 3/3、CLI 24/24、Host 80/80 单测 + 7 个消息持久化集成回归,其他能力集与既有计数保持通过。 +- `cargo clippy -D warnings`(两套特性)、`cargo doc -D warnings`、fmt、依赖边界、manifest、 + 独立复制、`npm run check:encoding` 和 `git diff --check` 均通过。 +- 当前 lock SHA-256 为 `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`; + 固定 `cargo-audit 0.22.2`/RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` + 已扫描当前锁文件 191 个依赖,退出码 0、无漏洞/warning;远端持续审计仍需 CI 记录。 + +## 2026-09-06 审批 checkpoint 联合事务回归(当前) + +- `approval_checkpoint_runtime_transaction_is_idempotent` 验证 approval、awaiting checkpoint + 和 runtime snapshot 的同 identity 重试不重复写入;`...rolls_back_on_fencing_or_cas_failure` + 与 `...rejects_mismatched_checkpoint_identity` 验证错 lease、revision 和调用身份失败时不 + 留下 approval 行。 +- `agent-runtime-sqlite` 定向测试为 31/31,Host Ask 生产路径仍为 78/78;Engine callback + 到 binding 生成之间的非原子窗口仍作为明确边界,不以 fixture 伪造全链路事务。 + +- Host Ask 顺序回归确认 approval 在审计/trace 写入前落盘;首个多工具 checkpoint 的 assistant + 消息与后续 tool result 消息不会被 trace 重复追加,`approval_` 与完整 Host 双特性测试均通过。 + +## 2026-09-06 当前最终事实(权威) + +- workspace 当前为 15 个 crate;all/no-default all-targets 测试均通过。当前计数为: + agent-app 3、CLI 24、Host 80 单测 + 7 集成、Runtime portable 8、Runtime SQLite 31、Codex 101、 + MCP 52、OpenAI 29、Fake 4、Engine 55、Orchestration 40、Skill 28、Core 30+20+1、 + Storage 7+53。 +- 当前 `Cargo.lock` SHA-256 为 + `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`;固定 + `cargo-audit 0.22.2`/RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` + 扫描 191 个依赖通过,退出码 0、无漏洞/warning。 diff --git a/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md b/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md new file mode 100644 index 000000000..d1c96bf6f --- /dev/null +++ b/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md @@ -0,0 +1,1382 @@ +# 独立通用 Agent 内核与单 Agent 程序建设计划 + +> 本文件是本项目当前执行依据。内容按用户最初给出的 P0–P6 计划完整落档;后续执行中产生的缩减版 TODO、阶段验收或收口记录不得替代本文件。当前代码位于现有工作区的 `rust/` 子目录,这是用户后续明确允许的落地方式;它不改变本计划要求的运行时依赖边界。 +> +> 现状与范围以「当前执行状态」中的原始范围复核及现状表为准;按日期追加的过程段落保留当时证据,不覆盖这一口径。 + +## 目标与交付 + +新建独立 Rust 仓库 agent-runtime,不依赖 Genarrative、Tauri、SpacetimeDB 或 Codex 源码,交付: +- 一个可嵌入的通用 Agent 内核; +- 一个可直接运行的 CLI 单 Agent 程序; +- SQLite 会话和事件持久化; +- OpenAI Responses 与 Fake Provider; +- MCP、Skill、Codex 的可插拔适配器; +- 后续接入多 Agent/DAG 时不需要重写单 Agent 内核。 + +核心原则是:MCP 和 Skill 的生命周期、能力与上下文契约属于内核,具体以中立的 +`ToolSource`、`SkillSource`、`SkillActivation`、`ContextSource` 等端口表达;MCP +协议传输、Skill 文件格式及其发现/加载实现、以及 Codex 进程通信由外部适配器或 +Host 管理。协议或文件内容进入 Engine 前,必须转换为 Core 的中立端口或 +`ContextSource`,协议实现本身不进入 Kernel。 + +原计划假设不修改当前 Genarrative 工作区;执行时用户明确允许在 +`/data/dsk/Genarrative-master/rust/` 建立独立 workspace。本次代码因此只落在 +该子目录,不接入现有 Genarrative crate,也不改变其运行时、后端和前端代码。 + +## 架构与公共接口 + +### Workspace crate + +```text +agent-cli + ├── agent-app + └── agent-host + ├── agent-runtime + ├── agent-runtime-sqlite + ├── agent-runtime-contracts + ├── agent-runtime-engine + │ └── agent-runtime-core + ├── agent-storage-sqlite + ├── agent-provider-openai + ├── agent-provider-fake + ├── agent-mcp + ├── agent-skills + └── agent-codex +agent-runtime-orchestration + └── agent-runtime-core +``` + +- agent-runtime-core + - 中立消息、内容块、工具定义、工具调用、工具结果、Provider 事件; + - SessionId、RunId、CallId、EventId; + - RuntimeSnapshot、RunStatus、RuntimeEvent、纯函数状态 reducer; + - RuntimeStore、EventSink、ModelProvider、ToolSource、ToolExecutor、ContextSource、ApprovalPolicy、ExternalBackend 等端口; + - `SharedRuntimeStore` 仅为同一进程内的多个 facade 提供标准库共享同步,不改变 RuntimeStore trait 或跨进程语义; + - 不依赖 Tokio、HTTP、SQLite、MCP SDK、厂商 SDK 或操作系统 API。 +- agent-runtime-engine + - 完整 Agent Loop:模型流式输出 → 工具调用解析 → 参数校验 → 权限审批 → 工具执行 → 结果回填 → 下一轮模型请求; + - 上下文组装、预算、截断、压缩、取消、超时、重试和恢复; + - 默认工具调用按声明顺序串行执行,避免首期引入并发竞态。 +- agent-runtime + - portable durable command/view facade、RuntimeSnapshotService、WorkerLease 和 RunHandle; + - 不启动 Engine,不读取 Provider/MCP/Skill 配置,也不拥有线程生命周期; + - 依赖 `agent-runtime-contracts` 的中立 command/view/`DurableStore` 合同,不依赖 SQLite。 +- agent-runtime-sqlite + - SQLite-specific `RuntimeService`、记录/错误转换、worker lease、checkpoint、取消和 + reconciliation 协调; + - 依赖 portable `agent-runtime` 与 `agent-storage-sqlite`,不反向污染 Core/Engine; + - 是 Host/CLI 的 SQLite 装配入口,不启动 Engine 或管理线程生命周期。 +- agent-runtime-contracts + - durable run/session/lease/checkpoint/approval/external-session command 和 view; + - `DurableStore` trait 只依赖 Core/JSON,不暴露 SQLite、连接或事务句柄; + - 只定义跨 crate 合同,不负责 worker、线程或具体持久化。 +- agent-storage-sqlite + - SQLite WAL、迁移、单写入者、revision CAS; + - 保存会话、运行、事件、快照、审批、工具调用和外部后端会话; + - 提供确定性 JSONL 导出; + - 事件写入和快照更新必须在同一事务内完成。 +- agent-provider-openai + - OpenAI Responses API 的 HTTP/SSE 适配; + - 将厂商响应映射为中立 Provider 事件; + - API Key 仅从环境变量或外部 secret provider 读取,不进入日志、事件和 SQLite。 +- agent-provider-fake + - 可脚本化的确定性 Provider; + - 支持文本、工具调用、错误、取消和压缩场景; + - 用于所有核心和端到端测试,不依赖真实模型。 +- agent-mcp + - 支持 MCP stdio 和 Streamable HTTP; + - 首期实现 initialize、tools/list、tools/call; + - 将工具命名空间化为 mcp::; + - MCP 资源和 Prompt 作为不可信上下文源接入; + - 支持超时、重连、认证配置、服务器进程退出和能力指纹; + - initialize 在发送 `notifications/initialized` 前校验调用方配置的协议版本候选; + 版本拒绝时仅对可重建连接有界重试,不自动重放工具调用; + - 不实现 sampling、elicitation 等扩展能力,留出接口。 +- agent-skills + - 发现 Codex 兼容的 SKILL.md; + - 首次只读取元数据,显式激活后再加载正文; + - 支持项目 .codex/skills、.agents/skills 和 CLI 指定目录; + - 校验名称、路径、frontmatter、大小和指纹; + - frontmatter 解析拒绝未闭合引号/列表、空列表项和空工具名; + - Skill 不能直接授予权限,脚本必须经普通工具和审批策略执行。 +- agent-codex + - 外部 Codex CLI 一次性节点模式; + - 外部 Codex App Server 长连接模式; + - 不嵌入或 fork codex-core; + - Host 保持外层会话、权限、审计和恢复真相; + - Codex 的外部会话 ID、进程状态和取消结果必须持久化; + - Codex 发出的可识别工具请求重新进入 Host 的工具策略;无法拦截的内部副作用必须显式标记为外部 opaque 操作。 +- agent-runtime-orchestration + - 只提供通用 DAG、Delegation、Join、Graph Proposal、状态更新和下游修复; + - 不调度线程、不创建 Runtime run、不保存业务完成真相。 +- agent-app + - 只承接通用程序的 `agent.toml`/环境变量解析、Provider/model/streaming 优先级、 + OpenAI endpoint、MCP 认证引用和 queued metadata; + - 不依赖 Host、Runtime、线程或数据库,不持有运行状态;CLI/其它嵌入入口可复用同一配置 API。 +- agent-host + - 组合 Engine、Store、Provider、MCP、Skill、Codex 和 Policy; + - 暴露稳定的 AgentBuilder、AgentService、run、resume、cancel、inspect API;当前 + 两个名称是 `AgentHost` 的轻量类型别名,不创建第二套 Runtime 或生命周期对象; + - 配置格式使用 agent.toml,密钥使用环境变量引用; + - 非交互环境下遇到 ask 默认拒绝,不自动放行。 +- agent-cli + - 首期命令: + - run + - resume + - cancel + - inspect + - skills list + - mcp list + - doctor + - export + - 支持人类可读输出和稳定 JSONL 事件输出; + - 不实现 HTTP 服务,HTTP 作为后续 Host 层扩展。 + +## 内核状态与上下文规则 + +- 运行状态: + queued → running → waiting_approval / waiting_tool / compacting → running → completed / failed / cancelled +- 重启时发现未完成外部调用,进入 reconciling; +- 不自动重放未知副作用,必须由适配器查询或标记为 unknown; +- 所有状态变化先写事件,再由 reducer 生成快照; +- 事件包含 schema_version、run_id、revision、时间戳、事件类型和结构化 payload; +- Engine 的 Event/Stream listener 只能观察已接受的内存事件;Host 的 + `DurableEventListener` 在每条 run-level 审计 `events` 行的 append 事务提交后通知, + 但不代表整次 run 的 runtime trace、approval 或终态事务已提交。所有 callback + 都不能直接修改运行状态,状态变更必须通过 Runtime Command。 + +上下文引擎采用分层预算: +1. system; +2. developer; +3. 用户消息; +4. 已显式激活的 Skill; +5. 工具结果和 MCP 资源; +6. 历史对话。 + +system/developer 内容固定保留,外部内容标记为不可信。先做确定性淘汰,仍超预算时调用压缩 Provider;压缩失败返回 ContextOverflow,不静默丢弃高优先级指令。 + +默认限制: +- 单次运行最多 32 个 Loop step,可配置; +- Provider 瞬时错误最多重试 2 次; +- 工具不自动重试,只有声明幂等时才允许由策略放行; +- 每个工具调用都绑定参数哈希、审批令牌和过期时间; +- 工具输出超过预算时截断并记录原因,不直接无限写入上下文。 + +## 分阶段实施与 TODO + +### P0:独立仓库与抽取 + +- 创建 agent-runtime 独立仓库和 Rust 2024 workspace。 +- 从现有 agent-runtime-core 抽取通用代码和 conformance tests,保留历史来源但去除 Genarrative 路径依赖。 +- 明确 crate 依赖方向,确保 core 不反向依赖任何 adapter。 +- 添加基础文档:架构、状态机、扩展端口、版本策略。 +- 建立统一 rustfmt、Clippy、测试和依赖审计 CI。 + +出口:新仓库可独立 cargo check --workspace,不需要 Genarrative 工作区。 + +### P1:Runtime Core + +- 整理消息、工具、Provider、事件和错误类型。 +- 实现 RuntimeSnapshot、事件 reducer 和 revision CAS 契约。 +- 实现审批、取消、超时、重启 reconciliation 状态。 +- 实现 ToolSource、ContextSource、SkillActivation、ExternalBackend 端口。 +- 为所有状态转移编写纯函数单元测试和序列化契约测试。 + +出口:Fake Store 可以完整驱动状态机,且事件重放结果确定。 + +### P2:单 Agent Engine + +- 实现模型流式事件消费。 +- 实现工具调用解析、JSON Schema 参数校验和结构化错误。 +- 实现默认审批流程、串行工具执行和取消传播。 +- 实现上下文源排序、预算、截断、压缩和上下文追踪。 +- 实现事件流、Callback 和运行结果 API。 +- 用 Fake Provider + Fake Tool 完成完整 Loop。 + +出口:无外部服务时,单元测试可覆盖“文本回答”和“工具调用后继续回答”两条完整链路。 + +### P3:SQLite、Host、CLI、OpenAI + +- 添加 SQLite 迁移、WAL、单写入者和事务 CAS。 +- 保存运行事件、快照、审批、工具调用和外部会话。 +- 实现崩溃后恢复、JSONL 导出和敏感字段脱敏。 +- 实现 AgentBuilder、TOML 配置和 doctor 检查。 +- 接入 OpenAI Responses/SSE Provider。 +- OpenAI endpoint 支持默认地址、`OPENAI_BASE_URL` 自动补 `/responses`、完整 + `OPENAI_ENDPOINT`、配置对象和手动构造;CLI 支持 `OPENAI_MODEL`。 +- 完成 CLI 的运行、恢复、取消、查看和导出命令。 +- 让 Fake Provider 可通过 CLI 直接运行,作为安装后的自检示例。 + +出口:全新环境执行 Fake Agent;配置 OpenAI Key 后可运行真实流式工具调用;进程重启后可恢复未完成运行。 + +### P4:MCP 与 Skill + +- 实现 MCP stdio 生命周期、握手、工具发现和调用。 +- 实现 MCP Streamable HTTP、超时、重连和认证引用。 +- 将 MCP 工具统一接入审批、取消、审计和错误分类。 +- 实现 Skill 元数据扫描、显式激活、正文懒加载和指纹记录。 +- 防止 Skill 路径穿越、重复名称和脚本绕过权限。 +- 增加 MCP 假服务器和 Skill fixture 套件。 + +出口:替换 MCP 服务器或 Skill 目录时不需要改 Engine;所有外部能力仍经过同一权限和事件链路。 + +### P5:Codex 外部适配器 + +- 实现 Codex CLI 进程启动、参数白名单、超时和退出码映射。 +- 实现 App Server 长连接、请求 ID、事件流和中断。 +- 建立 NodeRequest、NodeEvent、NodeResult 到内核事件的映射。 +- 持久化外部会话 ID 和不可恢复状态。 +- 验证 Codex 模式不会创建第二套 Host 会话、工具权限或持久化真相。 +- 用本地假进程和协议 fixture 做适配器测试。 + +出口:Codex 作为可替换 backend 工作,关闭 Codex 适配器时核心和 CLI 仍可独立运行。 + +### P6:多 Agent 扩展准备 + +- 将现有通用 DAG/Proposal 能力移植为 agent-runtime-orchestration。 +- 只增加 Delegation/Join/Graph Proposal 扩展,不改变单 Agent reducer。 +- 增加多运行并发、任务依赖和下游修复测试。 +- 单独制定多 Agent 的通信、配额和故障隔离方案(见 + [`【方案】多Agent通信配额与故障隔离边界-2026-09-02.md`](./【方案】多Agent通信配额与故障隔离边界-2026-09-02.md))。 + +出口:多 Agent 只依赖稳定的 Core/Engine 端口,不需要复制单 Agent Loop。 + +## 测试与验收 + +必须通过以下验证: +- cargo fmt --check +- cargo clippy --workspace --all-features -- -D warnings +- cargo test --workspace +- 编码检查、依赖审计和 git diff --check +- Core 依赖检查:不得出现 Tokio、Reqwest、SQLite、MCP 或 Codex 依赖 +- Fake Provider 端到端测试: + - 纯文本完成; + - 工具调用、审批通过、工具结果回填; + - 审批拒绝; + - Provider 流中断; + - 工具执行取消; + - 上下文压缩; + - 进程重启后恢复; + - CAS 冲突; + - 未知外部副作用不被重复执行。 +- MCP 测试: + - stdio/HTTP 握手; + - 工具发现和调用; + - 超时、断线、重连; + - 权限拒绝和审计。 +- Skill 测试: + - 元数据优先加载; + - 显式激活; + - 重名、非法 frontmatter、路径穿越和超预算; + - 脚本必须经过工具策略。 +- OpenAI/Codex 测试全部使用录制 fixture 或本地假服务,不在 CI 使用真实密钥。 + +完成标准是:新仓库从零安装后可以运行 Fake Agent;真实 Provider、MCP、Skill 和 Codex 都通过同一套中立事件、权限、持久化和恢复接口;Genarrative 后续只需切换依赖,不需要把项目业务代码搬入内核。 + +## 默认假设与明确不做项 + +- 默认仓库名为 agent-runtime,首期作为内部/private 仓库,暂不决定公开许可证。 +- 使用 Rust 2024;Tokio 只出现在 Engine、Host 和适配器,Core 保持运行时无关。 +- SQLite 使用 rusqlite、WAL 和单写入者;事件 payload 使用版本化 JSON。 +- ID 使用 UUIDv7;CLI 配置使用 TOML;密钥只允许环境变量或外部 secret 引用。 +- 首期只做 CLI + 库 API,不做 HTTP 服务、Web UI、浏览器工具、图像工具或游戏业务。 +- 首期不嵌入 codex-core,不把 Codex 的内部上下文或工具系统复制进本仓库。 +- 首期不实现多 Agent Teams、自动调度、远程执行和跨主机协调;仅保留稳定扩展端口。 +- 当前 Genarrative 的本地 crate 不在本计划中删除,迁移到独立仓库作为后续单独变更。 + +## 当前执行状态 + +### 原始范围复核(2026-09-06) + +按用户原始附件及后续明确的 `rust/` workspace / Runtime 分层 / endpoint 配置变更执行。 +下方历史增量曾把后续扩展写成“原计划未完成”,这些表述不再作为阻塞依据: + +- registry 发布、正式公开许可证、全量 Codex generated schema 不是本期验收项;公开许可证原文明确暂缓。 +- 多 Agent Teams、自动调度、远程执行和跨主机协调是明确不做项;P6 验收稳定扩展端口、DAG、并发与依赖/修复。 +- 外部调用允许查询或保留 unknown;不强制自动 webhook、订阅或自动重放。 +- OpenAI/Codex 按原文用录制 fixture 或本地假服务验证所支持协议,不在 CI 使用真实密钥。 +- 事件与快照须同事务、崩溃须可恢复;不要求一次事务覆盖整个运行的所有外部调用。 + +已修复本轮复现的 Host 消息重复:普通 Fake Loop 原先输出 4 条消息,Runtime 留下 5 条。 +按消息/事件投影游标修复后,完整历史、工具索引和从零重放回归通过;仍不据此推断所有故障路径已验收。 + +本节只记录与原始 P0–P6 逐项对照的事实,不改变上文计划。依赖边界和独立 +workspace 复制验收的可复核证据见 [`【审计】独立Agent依赖边界与CI验收-2026-09-02.md`](./【审计】独立Agent依赖边界与CI验收-2026-09-02.md)。 + +| 阶段 | 当前状态 | 说明 | +| --- | --- | --- | +| P0 | 本地独立构建门禁通过 | 已有独立 `rust/` workspace、架构/扩展文档、Gitea workflow、依赖边界及独立复制验证脚本。保持用户允许的项目内落点,不要求 registry 或公开许可证。当前 lock 的固定 RustSec 快照审计见审计文档;远端 CI 是否实际部署不能由本地脚本证明。 | +| P1 | 本地契约与状态机出口通过 | Core 30 单测、21 集成测试覆盖 reducer、CAS、序列化、审批、取消、reconciliation 及中立扩展端口;Runtime portable 8 个回归。Fake Store 可驱动状态机,事件重放确定。 | +| P2 | 本地执行循环出口通过 | Engine 55 个测试覆盖文本/工具/流式、参数校验、审批恢复、上下文预算/压缩、取消与重试;Host 用完整消息历史、工具索引和从零重放进行对照。 | +| P3 | durable 与显式恢复出口通过 | SQLite Runtime 31、Storage 60 个测试;Host 80 单测 + 7 个消息持久化集成测试。checkpoint 与 trace 按事件位置投影,正常/流式/连续工具/审批/失败/多次压缩下历史一致。重启语义是新连接读取 durable 状态后显式 stale reconciliation → 外部对账 → safe resume;不自动重放未知调用,`compacting` 失败保留为不可安全继续的恢复门。 | +| P4 | 本地适配器出口通过 | MCP 52、Skill 28 个测试与 Host 桥接测试覆盖发现/显式激活、stdio/HTTP、权限/错误/取消及上下文;sampling、elicitation、自动 webhook 等明确排除。 | +| P5 | 声明协议子集与统一宿主边界通过 | CLI/App Server 进程适配器、ID/事件/中断、外部会话与工具策略均有 101 个 Codex 测试和本地假进程证据;真实发行版完整 wire/session、协议级兼容和自动对账仍未证明,不复制全量 generated schema。 | +| P6 | 扩展准备出口通过 | 40 个编排测试与通信/配额/故障隔离方案覆盖 DAG、Delegation/Join/Proposal、并发、依赖及修复;首期不实现 Teams、自动调度、远程执行或跨主机协调。 | + +计划内本地交付已完成:P0–P6 的可在当前 workspace 实现和验证的出口均已逐项通过。真实外部发行版/凭据、远端 runner 或后续部署语义只作为部署前外部证据,不把它们或明确非目标重新列为本地欠账。 + +> 计数说明:上表及下方历史条目中的 29/30/31 是前序增量记录;当前 +> `Orchestration` 定向测试为 40 个,新增 ready 计划/claim、取消控制面与跨进程 CAS 证据见本文件末尾的最新继续执行记录。 + +### 2026-09-02 增量实现清单 + +本轮新增或补齐的代码入口如下,状态以测试和源码为准: + +- `agent-runtime`:把 durable run/lease/checkpoint/reconciliation 从 Host 提取为独立协调 API;新建 bundle 使用 UUIDv7 身份;过期 run 的 run/runtime/session 原子 recovery API 用同一 IMMEDIATE 事务校验并提交(失败整体回滚);提供 `reconcile_expired_run_if_stale` 供 Host 在启动边界探测失效 lease,并提供 `reconcile_stale_runs(limit)` 做固定排序、硬上限的一次性扫描。它不启动后台线程、不查询外部系统,也不自动重放调用;Host 的 run 查询、lease 控制、stale 探测、RuntimeSnapshot 只读观察、Runtime-only 快照/事件 CAS 和带 lease checkpoint 读写已统一走 RuntimeService facade,但 Engine 审计、approval/session 投影及跨表终态 CAS 仍保留兼容性 Store 句柄,拆分尚未完全收口。 +- `agent-codex` + `agent-host::ExternalBackendToolExecutor`:Codex/其它外部 backend 可通过 Core 端口接入同一工具审批、checkpoint 和 `external_sessions` 记录;CLI supervisor 负责有界 child timeout/cancel、输出上限和 Unix process-group 终止;未知副作用不会生成 safe 结果。`CodexAppServerClient` 提供当前窄 V2 请求形状的 initialize、thread/start、turn/start、通知轮询和精确 thread/turn interrupt,并提供中立 `CodexServerRequestHandler` 回调响应审批/动态工具等带 id 请求;`CodexAppServerProcess` 将该 client 接到显式 `program + args` 的真实 stdio 子进程,并在 timeout/cancel/EOF/Drop 时收束 process group、wait/reap 和 reader/writer 线程。CLI/App Server 请求发出后的协议、进程和输出错误均进入 UnknownSideEffect,避免未知副作用被通用重试重放;完整 generated schema、具体审批/工具参数语义、自动重连或请求重放仍由上层适配器负责。 +- `agent-runtime-orchestration`:通用 DAG 状态和提案基础,不复制单 Agent reducer,也不自动执行下游任务;Coordinator 快照可显式导入/导出并在导入时重建派生索引。新增 `PersistentCoordinator` 与 `OrchestrationSnapshotStore`,把 `TaskGraph` 和 Coordinator 控制面作为同一版本化快照保存,内存/JSON 文件实现提供 revision CAS、原子替换和重开恢复校验;该阶段 29 个定向测试覆盖配额拒绝、隔离、去重、图与控制面联合恢复、CAS 冲突和活动 run/图状态不一致拒绝。通信、配额和故障隔离的当前控制面合同见 [`【方案】多Agent通信配额与故障隔离边界-2026-09-02.md`](./【方案】多Agent通信配额与故障隔离边界-2026-09-02.md)。 +- Core 外部观察端口:`ExternalObservationSource` 只按既有引用查询并返回有界不透明的 + `ExternalObservation`;它不推进 checkpoint、不重排队、不自动生成消息,宿主完成 + 协议校验后仍须显式调用 reconciliation API。 +- `[[mcp.auth]]`:TOML 只接受环境变量引用,支持 HTTP bearer/header 和 stdio 环境注入;旧 `AGENT_MCP_HTTP_HEADERS` 仅为运行时兼容入口。 +- MCP:同步 client 新增显式 `poll_notification`、有界可取消 `McpReconnectScheduler` 及调度审计;每次调度尝试的 `attempt_timeout` 会传递到 initialize 请求和 initialized 通知(HTTP 实际限制响应等待,stdio 通知为同步写入);`call_tool_with_permission` 在 transport 前记录权限决定;完成握手的 stdio/Streamable HTTP client 可显式转移为独占后台通知订阅,HTTP 由私有 Tokio worker 增量读取 SSE,并通过有界队列、cooperative cancel、Drop/join 和错误传播收口;不自动应答、重连、重放或后台路由,自定义 transport 仍只支持显式轮询。 +- Approval:`Ask` 请求会写入 pending approval;控制端通过 Host/CLI 的 pending-only + allow/deny CAS 后显式 resume,取消会收束 pending 记录并拒绝迟到决议。 +- Approval binding:请求持久化 `arguments_hash`、`approval_token` 和 + `expires_at_ms`;Core resume 前会重新推导 token 并校验调用参数、持久化 token、 + 调用方 token 和过期时间,篡改或过期都会拒绝。 +- Host API/Callback:`AgentBuilder`/`AgentService` 作为 `AgentHost` 类型别名提供 + 稳定命名;`with_durable_event_listener`/`with_durable_event_callback` 在每条 + run-level 审计事件 append 提交后同步通知,不参与事务回滚,也不覆盖 Engine + 提交前观察 listener。 +- CLI JSONL:`run --jsonl` 将已收集的 Engine/Provider 流事件按行编码为 + `engine_event`/`stream_event`,最后追加 `result`;`run --background --jsonl` + 只输出 `queued`,worker 的最终结果需另行 inspect/export。 +- MCP 输入边界:stdio 单消息 1 MiB、HTTP 响应 4 MiB、SSE 单行 1 MiB、SSE 单事件 + 4 MiB,tools/resources/prompts 各自最多跟随 1024 页;超限在 transport 层拒绝。 +- Codex:`agent-codex` 的有界 JSONL channel 是带 `protocolVersion=1` 的本地中立协议 fixture;另有不绑定发行版的最小 JSON-RPC/JSONL channel,消费匹配响应并支持通过 `CodexServerRequestHandler` 回应带 id 的服务端请求,默认仍拒绝未注册请求;混合 request/response envelope 会在 handler 前拒绝,`CodexServerRequestKind` 提供中立类别,错误分类方法按 dispatch 前后区分 InvalidInput、Unavailable 与 UnknownSideEffect;`[codex.cli]` 负责受限启动配置校验,CLI backend 与 `CodexAppServerProcess` 均提供本地 child supervisor。App Server process adapter 已用本地假进程覆盖真实 stdio 握手、非法输出/退出、EOF 回收、取消和超时,但不声称已经实现具体 Codex 版本的完整 wire/App Server 启动协议。 +- Orchestration:Coordinator 增加全局/按 agent 配额、message_id 去重投递、节点隔离、显式下游修复,以及 `CoordinatorSnapshot`/JSON 导入导出;快照是宿主可选择持久化的控制面边界,`CoordinatorStore` 提供内存和原子 JSON 文件实现,但不直接写 SQLite、不调度线程、不创建 Runtime run。 +- `agent.toml.example` 与 `.gitea/workflows/agent-runtime-ci.yml`:提供独立 workspace 的配置样例和 CI 文件;嵌套在当前父仓库时不等于已经完成远端独立仓库 CI。 +- `rust-toolchain.toml`:独立 workspace 固定 Rust 1.96,并声明 `rustfmt` 与 + `clippy`;CI runner 需预装同一工具链组件,避免依赖父仓库的 minimal toolchain + 或隐式联网安装。 +- `scripts/check-package-manifests.sh`:离线检查 14 个 crate 的 package 元数据、 + workspace path 依赖版本约束和待发布文件边界;它是 registry 发布前预检,不替代 + 目标 registry 中的真实 package/publish 结果。 +- `tests/agent-test-set.jsonl` 与 `scripts/run-agent-test-set.sh`:提供可重复的 Fake + 工具/流式闭环测试集,并以显式 `--real` 追加一次自有 Responses Provider smoke; + 真实网络请求默认跳过,测试临时数据默认写入 `~/data/tmp`(显式 `TMPDIR` 或 + `AGENT_TEST_TMPDIR` 可覆盖);脚本只清理本轮创建的 + 目录。 +- 依赖审计:历史固定 RustSec 快照曾由 `cargo-audit 0.22.2` 扫描通过;本轮环境没有 + advisory DB,`run-cargo-audit.sh` 按设计 fail-closed。`cargo-deny 0.20.2` + 的离线许可证检查可运行但按默认策略失败,原因是内部 crate 的 `UNLICENSED`/发布 + 属性和少数第三方许可证的正式政策尚未确认,因此没有提交未经批准的 allowlist。 + +### 2026-09-03 manifest 与 CI 门禁补充 + +- workspace 内部 path 依赖统一补充 `version = "0.1.0"`;14 个 crate 的 + `cargo package --list` manifest 预检通过。完整 package/publish 仍须先将内部 crate + 按依赖顺序发布到目标 registry,这一步没有在本地伪造成功。 +- 独立 Gitea workflow 增加 no-default-features 的 check、test 和 Clippy 步骤,与本地 + 最终门禁保持一致;本次 manifest 变更后的 all-features/no-default-features workspace + 测试、check、Clippy、rustdoc、fmt、依赖边界和独立复制检查均重新通过;远端 runner + 仍需预装 Rust 1.96、rustfmt 与 clippy。 + +### 2026-09-03 Runtime/Host facade 与 Codex 版本化适配补充 + +- `agent-runtime::RuntimeService::observe_external` 已把 Core 的只读 + `ExternalObservationSource` 接到 durable 控制面;Host 暴露同名入口,并保留 + `ExternalError` 分类。观察只查询既有引用,不写 checkpoint、消息、requeue 或 + reconciliation 状态。 +- approval、external session 和 run-level audit event 的常用查询/写入也有 Runtime + 窄 facade;Host 内部已移除重复的 SQLite adapter 字段,`store()` 仅作为兼容性 + 访问器委托给 Runtime。Runtime 仍是当前 SQLite-backed 具体实现,公开 adapter 访问 + 和最终可替换存储抽象仍未收口,因此不宣称最终 Runtime/应用装配拆分完成。 +- `agent-codex::codex_0_152_1` 固定本机核对的 `codex-cli 0.152.1`:内置 schema + provenance/hash 清单、精确版本检查、`initialize`/`thread/start`/`turn/start`/ + `turn/interrupt` typed DTO、通知子集和 server-request handler。它不是任意 v2 + 发行版兼容层;完整 generated schema、版本特定 wire 迁移和上层会话接线仍待完成。 +- 该阶段变更后本地 all/no-default workspace 测试均通过:Runtime 17、Host 32、Codex 50; + `-D warnings` check/Clippy、rustdoc、fmt、依赖/manifest 预检、独立复制和 Fake + 测试集均通过。远端 CI、registry 发布、正式许可证策略和真实 Codex wire 仍是 + 原计划的未完成出口。 + +### 2026-09-03 Runtime lease facade cleanup + +- [x] `RuntimeService` 的 `complete_run_with_lease_and_runtime`、 + `fail_run_with_lease_and_runtime`、`mark_cancelled_with_lease_and_runtime` 和 + `mark_cancelled_with_lease` 统一接收 `&WorkerLease`。Host 不再把 + `worker_id`/`lease_token` 拆成独立字符串传入;底层 SQLite 参数转换只发生在 + Runtime facade 内部,fencing 与事务行为保持不变。 +- [x] Runtime/Host 定向测试覆盖新的 opaque lease 接口;该调整只收敛控制面类型, + 不改变 SQLite adapter 的公开底层 API 或自动恢复范围。 + +### 2026-09-03 Context/Prompt/Skill metadata determinism + +- [x] `agent-skills` 的有界 frontmatter 扫描器现在把 `allowed-tools` 与 + `allowed_tools` 归并为同一逻辑字段,并拒绝所有已知/扩展字段的重复键,避免 + 后值覆盖导致 discovery 与 activation 语义漂移。 +- [x] 新增重复字段回归测试和专项审计文档;不改变 Core `SkillSource` 接口,也不 + 把 `allowed-tools` 转换为工具权限。 +- [x] Skill 定向测试由 21 个增至 22 个并通过;P1/P2 Context/Prompt 现有预算、 + 压缩和不可信上下文边界保持不变。 + +### 2026-09-03 MCP notification subscription 与 OpenAI endpoint setter + +- [x] `agent-mcp` 为完成握手的 stdio client 增加独占的 + `into_notification_subscription`;通知队列容量有硬上限,支持 + `recv`/`recv_timeout`/`try_recv`、跨线程 cooperative cancel,以及 `Drop`/`join` + 的线程和子进程回收。`join` 会检查队列中尚未消费的 poller 错误,不把错误误报为成功。 +- [x] Streamable HTTP 和注入的自定义 transport 继续保持显式轮询边界,不伪装成长连接 + 订阅;订阅线程不自动应答、重连或重放任何请求。新增 MCP 定向测试后当前为 38 个, + 并覆盖满中间队列时超限终止错误不会阻塞 reader 回收。 +- [x] `OpenAiProvider` 在既有 env/config/构造入口之外增加可变 + `set_endpoint`/`set_base_url`,校验失败时保留原地址;新增回归测试后当前 OpenAI + 定向测试为 21 个。真实网关兼容性仍需调用方在外部验收。 + +### 2026-09-03 ContextObservation trust counters 与 RuntimeStore facade + +- [x] Engine 的 `ContextObservation` 增加 selected/skipped 的 trusted/untrusted 四类 + 计数;只统计候选 `ContextItem`,不改变 base message 或 Prompt section 的出站角色。 + 新字段带 `serde(default)`,旧观测 JSON 缺字段时按 0 读取;Engine 定向测试当前为 + 40 个。 +- [x] `agent-runtime` 新增 `RuntimeSnapshotService`,只封装 + runtime-only snapshot/event CAS、load 和 store ownership;Core `InMemoryRuntimeStore` + 测试证明无 SQLite 时可复用同一合同,并保留 Core `StoreErrorKind` 的稳定 CAS 分类。 + 完整 run/session/lease/跨表事务仍由当前 + SQLite-backed `RuntimeService` 负责,不能把该窄 facade 写成完整存储替换完成。 + +### 2026-09-03 MCP reader 非阻塞终止与最新门禁复核 + +- [x] stdio reader 在消息超限、EOF 或 I/O 终止时统一使用非阻塞 `try_send`;即使 + 有界中间队列已经填满,`StdioClient::Drop` 仍能终止子进程并 join reader,不会被 + 终止错误发送卡住。新增满队列超限回归,`agent-mcp` 当前 38 个测试通过。 +- [x] 最新源码已通过 all-features 与 no-default-features workspace 测试、 + `agent-storage-sqlite --no-default-features`、两套 `-D warnings` check/Clippy、 + `RUSTDOCFLAGS='-D warnings'` rustdoc、fmt、依赖/manifest/独立复制脚本、Fake + 测试集、编码和 diff 门禁;本地未使用真实 API key、真实 Codex 会话或远端写操作。 +- [ ] 这次只补齐本地 MCP 终止语义,不改变原始 P0–P6 的“部分完成”状态。独立远端 + 仓库/CI、registry 发布、正式许可证策略、自动外部对账/订阅、真实 Codex 完整 + generated wire、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成。 + +### 2026-09-03 Provider stream interruption regression + +- [x] Engine 新增真实 streaming provider-error 回归:Provider 先发出 partial + `TextDelta` 后返回 `ProviderErrorKind::Stream`,Engine 返回原始错误并保留已观测增量, + 不发出 `Completed`/`ModelCompleted`/`Finished`,也不回退调用同步 `complete`。 +- [x] `agent-runtime-engine` 定向测试由 38 个增至 39 个并通过(前一轮历史计数;当前 + hard-bound 回归后为 40 个);该测试只覆盖本地 Engine/Provider 契约,不把真实上游 + 网络可用性或自动重试宣称为已完成。 + +### 2026-09-03 文件数据库重开恢复回归 + +- [x] 新增 `file_backed_restart_recovers_expired_provider_checkpoint`:首个作用域关闭 + worker 的 SQLite 连接,第二个作用域从同一路径重新打开数据库并执行显式过期 recovery; + run 进入 `reconciling`、lease/fencing 清除,`provider_in_flight` checkpoint 和 + `provider_request_id` 保留。 +- [x] Storage 集成测试由 38 个增至 39 个;all-features 为 5+39(44),直接 + `--no-default-features` 为 4+32(36)。 +- [ ] 该测试只证明文件持久化后的显式恢复,不代表自动 crash supervisor、外部对账或 + 未知 Provider 调用重放已经完成。 + +### 2026-09-03 Engine hard-bound 回归 + +- [x] 新增 `deserialized_tiny_tool_output_budget_is_rejected_before_side_effects`,覆盖 + 通过 serde 绕过 Builder 最小预算的输入:Engine 在 checkpoint、Provider 调用和工具 + 副作用之前拒绝,避免截断 envelope 超过配置上限。Engine 当前定向测试为 45 个。 +- [ ] 该回归只补齐本地输入预算边界,不改变原始 P0–P6 的“部分完成”状态,也不替代 + 真实 Provider 网络或外部副作用验收。 + +### 2026-09-03 MCP HTTP 订阅、Codex envelope 分类、Provider 路由与编排快照 + +- [x] `agent-mcp` 的 Streamable HTTP client 现在可在完成配置后显式调用 + `into_notification_subscription`/`subscribe_notifications`,由私有 Tokio + current-thread worker 建立独占 GET/SSE 长连接并增量解析通知;订阅队列有界,支持 + cooperative cancel、`Drop`/`join` 回收和未消费错误传播。该 worker 不自动应答、 + 重连或重放请求,也不把通知路由到 Host;自定义 transport 仍只提供显式轮询。新增 + 回归后 `agent-mcp` 当前为 43 个测试;认证环境变量在高层 connect、 + `StreamableHttpClient::from_config` 和 `StdioClient::spawn` 直接构造路径一致生效。 +- [x] `agent-codex` 的 JSON-RPC 轮询和响应等待均拒绝同时带 `method` 与 + `result/error` 的混合 envelope,避免在 handler 前产生副作用;新增 + `CodexServerRequestKind` 中立分类和 dispatch 前/后的错误分类方法,明确区分 + `InvalidInput`、`Unavailable` 与 `UnknownSideEffect`。新增回归后 Codex 当前为 + 57 个测试;通用 channel 额外提供显式 handler 的 initialize、request/event 和 + interrupt 入口,完整 generated schema、真实发行版 wire 和上层审批/工具接线仍未完成。 +- [x] Host 使用 Core 强类型 `ProviderInstanceId`/`ProviderProtocolId` 的 + `ProviderDescriptor`/`ProviderTarget` 注册表,在执行前校验实例与协议匹配;已有 + `with_provider` 直接注入路径保持兼容。新增路由/协议回归后 Host 当前为 35 个测试。 +- [x] `agent-runtime-orchestration` 新增 `PersistentCoordinator` 和 + `OrchestrationSnapshotStore`,以 revision CAS 将 `TaskGraph` 与 Coordinator + 控制面写入同一版本化快照;内存和 JSON 文件适配器覆盖原子替换、重开恢复、活动 + run/图状态一致性校验以及 CAS 冲突不泄漏未提交状态。该阶段定向测试为 29 个。 + 该控制器不接 SQLite、不启动线程、不创建 Runtime run;跨进程锁、自动调度、 + 完整多 Agent Teams 和未知副作用自动恢复仍是原计划未完成项。 +- [ ] 以上均是本地 fixture/定向测试增量,不改变原始 P0–P6 全部“部分完成”的状态; + 独立远端仓库与 CI、目标 registry 发布、正式许可证策略、真实 Codex 会话、自动 + 外部对账/订阅和最终 Host/Runtime 拆分仍需独立证据。 + +### 2026-09-03 P1/P3/P4/P5 收尾增量 + +- [x] Core reducer 在事件 detail serde 解码后重新执行 Message、ToolCall、ToolResult + 和 RunSnapshot 的构造级校验;非法 payload 统一返回 `InvalidEvent`,不改变已接受 + snapshot/revision。当前 Core 为 16 个 unit + 14 个 integration 测试。 +- [x] `RuntimeSnapshotService` 和 Runtime generic Store 提交路径使用 + `RuntimeServiceError::StoreProtocol(StoreError)` 保留 `StoreErrorKind`,新增 CAS + conflict/invalid snapshot 回归;Runtime 当前 20 个测试。 +- [x] MCP 直接 transport 构造器认证回归、Skill 检查后文件增长的有界正文读取回归和 + Codex 通用 channel handler fixture 已通过;该阶段计数分别为 MCP 43、Skill 23、 + Codex 57。 +- [ ] 以上仍是本地增量,不改变 P0–P6 的“部分完成”状态;远端仓库/CI、registry 与 + 许可证正式策略、真实 Codex 完整 wire、自动外部对账/订阅、完整持久化多 Agent + 调度和最终 Host/Runtime 拆分仍未完成。 + +### 2026-09-03 Engine 输入与压缩边界复核 + +- [x] `run` 入口对 serde 解码后的 `AgentInput` 重新校验消息、工具定义、请求字段和 + 重复工具名;非法输入在 ContextSource、Provider、checkpoint 与工具副作用之前 + 返回错误。 +- [x] `ContextAssembler` 对 base/source 消息、`ContextItem` 和压缩器输出重新执行 + Core 构造级校验;压缩 Provider 的响应必须保留当前 `request_id`/`model` 身份。 +- [x] 压缩阶段观察取消信号,取消后不再发起主 Provider 请求;新增五个回归后 + `agent-runtime-engine` 定向测试为 45 个(默认和 no-default-features 均通过)。 +- [ ] 该边界复核仍只覆盖本地 Engine/Provider 合同,不替代真实上游硬 I/O timeout、 + 自动外部对账或原始 P2 全量出口。 + +### 2026-09-03 当前源码最终串行门禁(阶段快照) + +- [x] 该阶段的 all-features 与 no-default-features workspace 测试通过(阶段快照计数:CLI 20、Host 52、 + MCP 45、Engine 48、OpenAI 23、Fake 4、Runtime 20、Codex 58、Orchestration 36、 + Skill 23、Core 16+14、Storage 5+39(44);Storage 直接 no-default 为 4+32(36))。 +- [x] all/no-default `-D warnings` check/Clippy、rustdoc、fmt、bash 语法、依赖边界、 + 13 个 package manifest、独立 workspace 复制、Fake 2/2、编码、diff、尾随空白、 + 冲突标记与临时目录卫生均已串行复核通过。 +- [ ] 当前环境没有 `cargo-audit` 或 `RUSTSEC_ADVISORY_DB`;wrapper 的缺失输入路径已 + fail-closed,但历史固定快照扫描不等同于本次重跑。原始 P0–P6 仍逐项为“部分完成”。 + +### 2026-09-03 审批导出与 CLI 展示脱敏复核 + +- [x] `agent-storage-sqlite::export_jsonl` 使用仅用于导出的审批投影,递归移除顶层 + `approval_token` 以及 request 中的 `approvalToken`/`approval_token`;导出仍保留 + 审批 ID、调用 ID、状态、参数哈希、过期时间和决定等审计字段,且不改变数据库中 + 用于显式 resume 的完整 `ApprovalRecord`。 +- [x] CLI `approval list/get/allow/deny` 统一使用递归脱敏视图;回归确认顶层、嵌套 + token 不出现在 stdout,同时保留请求中的工具名称等决策字段。`approval resume` + 继续只在 Host 内部读取完整 binding。 +- [ ] 该项只收紧本地导出/展示边界,不改变原始 P0–P6 的“部分完成”状态;真实上游、 + 远端 CI、registry、许可证策略、自动外部对账和完整多 Agent 调度仍需外部证据。 + +### 2026-09-03 最终边界增量:Provider/ToolResult、Codex argv 与安全重排队 + +- [x] Engine 在发布 `ModelCompleted`、写入消息历史或进入工具审批前,重新校验 + Provider response 的每个 `ContentPart`、`ToolCall` 和批次 ID;Provider 通过 + serde 绕过 Core 构造器的空文本、非 object 参数等值会 fail-closed,不产生完成 + 事件或工具副作用。当前 Engine 定向测试为 47 个,默认与 no-default-features + 均通过。 +- [x] Engine 在 `ToolCompleted`/历史/下一次 Provider 请求前重新校验 ToolExecutor + 返回的 `ToolResult`(包括 call ID、summary、metadata);非法 serde 结果不会被 + 当作完成结果写入,保留 `tool_in_flight` 供宿主对账。截断后的 runtime 结果也 + 重新执行同一构造级校验。 +- [x] Codex CLI 参数白名单对 key 名做规范化匹配,覆盖大小写、连字符、空格、 + `Authorization`/`Bearer` 和 access-token 变体,同时保留 `--tokenizer` 等非凭据 + 参数;新增回归后 Codex 当前为 58 个测试。 +- [x] SQLite `requeue_reconciled_run` 仅在 `reconciling -> queued` 首次转换时更新 + `updated_at`;已是 `queued + safe checkpoint` 的重复 `resume-safe` 只返回同一记录, + 不制造虚假更新时间,且保留原有取消/lease/CAS 条件。 +- [ ] 以上均为本地边界修正,不改变原始 P0–P6 全部“部分完成”状态;独立远端仓库/CI、 + registry 与正式许可证策略、真实 Provider/Codex 会话、完整发行版 wire、自动 + 外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成。 + +### 2026-09-03 最终 CLI 程序 smoke + +- [x] 独立 CLI 在 `/var/tmp` 临时数据库完成 `doctor`、前台/后台 Fake run、`inspect`、 + `export` 和有界 `reconcile --stale 8`;后台 run 到 `completed`,前台 JSONL 为 8 + 条记录,临时目录已清理。该证据不包含真实 Provider/Codex 会话。 + +### 2026-09-03 继续执行:审批恢复、MCP envelope 与配置自检 + +- [x] P3/P2:Host 的 approval checkpoint 恢复按当前 pending tool call 截断可物化前缀。 + 多工具响应在首个 Ask 后恢复时,后续未审批调用继续留在 checkpoint,不会提前进入 + Core;新增首个 Ask → Allow → 第二个 Ask → Allow 的全链路回归,Host 当前 37 个测试。 +- [x] P4:stdio、HTTP JSON/SSE、后台 SSE 的入站 JSON-RPC 消息共用 envelope 校验, + 拒绝非 2.0、method 与 result/error 混合、result 与 error 同时出现或缺少类型字段; + MCP 当前 44 个测试。Host 的 MCP 工具桥把发送后 timeout、断线、协议、编码、HTTP + 和远端错误映射为 Unknown,避免显式 failed 重试策略重放未知副作用。 +- [x] P3 CLI:doctor 保持无网络、无进程副作用,但分别校验 SQLite、Provider key/endpoint、 + Skill 根与显式名称、MCP transport/auth 引用和 Codex CLI 配置;Codex program 只输出 + 已配置标记,不回显路径。CLI 当前 16 个测试。 +- [x] P6 编排状态:`PersistentCoordinator::isolate_node` 释放活动 run 后将图节点保存为 + 可恢复的 `Waiting`;快照导入同时要求每个 `Running` 节点都有且仅有匹配的 active + run/agent,新增隔离重载与反向不变量回归,Orchestration 当前 31 个测试。 +- [ ] 这些本地增量不改变 P0–P6 的“部分完成”状态;独立远端仓库/CI、registry 与许可证 + 策略、真实 Provider/Codex 会话、自动外部对账/订阅、完整持久化多 Agent 调度和最终 + Host/Runtime 拆分仍需外部证据或后续实现。 + +### 2026-09-03 最新复核(当前边界) + +- [x] Engine 当前 48 个测试,Host 当前 54 个测试;MCP 47、CLI 21、Codex 64、Runtime 21、 + Orchestration 38、Skill 27、OpenAI 23、Fake 4。Core 为 17+14(单元/集成),Storage + 为 all-features 5+40(45)和直接 no-default-features 4+33(37)。 +- [x] MCP resources/prompts 只能通过显式 selection 请求并注入为不可信上下文;Engine 在 + 输入模型边界将不可信 context 的 system/developer/assistant 角色统一降级为 User, + trusted context 的角色保持不变;不可信 ContextItem 中的结构化 tool-call/tool-result + 会渲染成普通 User 文本,避免伪造工具历史。 +- [x] Host 的 `CodexHostServerRequestHandler` 处理 `item/tool/call`,对已知工具先做 + JSON Schema 参数校验,再经过 Host approval policy 和 ToolRouter;该 handler 是同步 + 低层 bridge,不写 durable approval/checkpoint/audit,`Ask` 返回 JSON-RPC error,不能 + 代替 Engine/Runtime 的持久化执行路径。另有只覆盖已核对 Codex 0.152.1 dynamic-tool + 子集的 `codex_01521_server_request_handler`,将 `tool`/`callId` 请求返回为 + `contentItems`/`success` typed response。 +- [x] Host 新增 `AgentHost::with_runtime` 注入入口;它复用调用方已经装配的 + `RuntimeService`,不重新打开 SQLite 或保留平行控制面;journal mode/JSONL export + 也有窄 facade,CLI 不再直接调用 Store。Host 新增显式 `NamespaceToolResolver`: + 默认空映射对显式非 `null` namespace 仍在 approval/execution 前 fail-closed,只有 + 调用方注册并命中 `(namespace, tool) -> registered_tool` 后才路由;Host 当前为 52 + 个测试。 +- [x] MCP initialize 在发送 `notifications/initialized` 前严格校验服务端返回的 + `protocolVersion=2025-06-18`;缺失、非字符串或未知版本保持连接未初始化。 +- [x] MCP stdio 同步 client 的 pending 消息暂存队列使用 + `MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限;超限返回协议错误,随 crate 分发的 + stdio 假服务器还覆盖握手、tools/list 和 tools/call;新增回归后 MCP 当前为 47 个测试。 +- [x] Skill frontmatter 解析拒绝未闭合/不匹配的引号和列表括号、空列表项及空工具名;随 + crate 分发的有效/非法 `SKILL.md` fixture 直接参与 discovery/activation 回归;Skill + 当前为 27 个测试,仍保持无 YAML 运行时依赖。 +- [x] Core `SharedRuntimeStore` 为同一进程内的多个 Runtime facade 共享一个 + `RuntimeStore` 实例;并发 CAS 与锁中毒映射为 `StoreErrorKind::Unavailable` 均有回归, + 不改变跨进程/跨主机语义。 +- [x] Engine Provider response 边界明确规定 `content` 只允许 Text/Image;结构化 + tool call/result 被拒绝并要求分别使用 `tool_calls` 或工具结果回填,Engine 当前仍为 + 48 个测试。 +- [ ] 原始 P0–P6 仍全部标记为“部分完成”。独立远程仓库/CI、registry/正式许可证策略、 + 真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和最终 + Host/Runtime 拆分仍是外部缺口。 + +### 2026-09-03 继续执行:namespace 显式路由与 ready durable claim + +- [x] Host 增加 `NamespaceToolResolver`/`StaticNamespaceToolResolver`。它只把调用方 + 显式注册的 `(namespace, wire_tool) -> registered_tool` 映射为 Host 工具名;命中后 + 仍按顺序执行已知工具查找、JSON Schema、ApprovalPolicy 和 ToolRouter。默认空映射、 + 未知 namespace/工具、空 namespace 和映射冲突均 fail-closed,不猜测分隔符或授予权限。 +- [x] `PersistentCoordinator` 增加只读 `plan_ready_tasks(max_tasks)` 与 revision-CAS + `claim_ready_tasks(plan, run_ids)`。计划按稳定图顺序应用全局/按 Agent 配额和隔离 + 状态;claim 由宿主提供 run ID,并复用既有 dispatch 校验和一次持久化 CAS,不创建 + 线程、Runtime 或自动 scheduler。Orchestration 当前 38 个定向测试。 +- [x] `RuntimeService`/`AgentHost` 增加 journal mode 与 JSONL export 窄 facade,CLI + 的常用 doctor/export 路径不再直接调用 Store;SQLite 仍是当前具体 adapter,公开 + `store()` 仅保留兼容性。 +- [ ] 这些增量不改变原始 P0–P6 的“部分完成”状态;真实 Codex wire/session、自动 + 外部对账/订阅、完整持久化多 Agent 调度、独立远端仓库/CI、registry/许可证策略和 + 最终 Host/Runtime 拆分仍未完成。 + +### 2026-09-03 继续执行:编排文件快照跨进程 CAS + +- [x] `JsonFileOrchestrationSnapshotStore` 使用同目录 sidecar advisory lock 保护 + “读取 revision → CAS → 临时文件 rename”临界区;两个独立文件实例竞争同一 + revision 时只有一个写入成功,另一个返回 `RevisionConflict`。新增回归后 + Orchestration 定向测试为 38 个。 +- [ ] 该锁只覆盖文件快照的跨进程写入,不启动自动 scheduler、不创建 Runtime run、 + 不提供跨主机锁或完整持久化多 Agent 调度;原始 P0–P6 仍保持“部分完成”。 + +### 2026-09-03 继续执行:共享存储、Skill 解析与 MCP 协议版本 + +- [x] Core 新增 `SharedRuntimeStore`,让同一进程内的多个 Runtime facade 共享一个 + `RuntimeStore` 实例;标准库 `Arc>` 串行化 CAS,锁中毒映射为 + `StoreErrorKind::Unavailable`。它不改变 Core trait,也不提供跨进程或跨主机 fencing。 +- [x] Skill frontmatter 解析继续使用有界行式子集,并拒绝未闭合/不匹配引号或列表括号、 + 空列表项和空工具名;新增回归后 Skill 定向测试为 27 个。 +- [x] MCP initialize 在发送 `notifications/initialized` 前严格要求 + `protocolVersion=2025-06-18`;缺失、非字符串或未知版本返回协议错误并保持未初始化, + MCP 定向测试为 47 个。 +- [x] P4 fixture 套件补齐:`agent-mcp/fixtures/stdio-jsonrpc-server.sh` 提供无外部依赖的 + initialize/tools/list/tools/call stdio 假服务器,`agent-skills/fixtures/` 提供有效和 + 非法 `SKILL.md`;回归测试直接扫描/激活随 crate 分发的文件,验证 fixture 不会绕过 + Core 的工具权限边界。 +- [ ] 以上是本地边界增量;多版本 MCP 协商、真实远端兼容矩阵、独立远端 CI、正式许可证 + 策略、真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度 + 和最终 Host/Runtime 拆分仍未完成,原始 P0–P6 不改标为“部分完成”。 + +### 2026-09-03 继续执行:CLI → OpenAI endpoint 集成 smoke + +- [x] 使用 `cargo run --locked --manifest-path rust/Cargo.toml -p agent-cli` 启动 CLI, + 由本机回环 fake Responses 服务接收请求;设置 `OPENAI_BASE_URL` 后实际收到 + `POST /v1/responses`,Authorization、`AGENT_MODEL` 和最终文本均符合预期。临时 + SQLite 目录在 `/var/tmp` 作用域内创建并已清理。 +- [ ] 该 smoke 仍不代表真实网关、真实 API key 或模型质量验收;这些继续保留为外部项。 + +### 2026-09-03 继续执行:Codex 事件桥与外部会话重开取消 + +- [x] `NodeRuntimeEventMapper` 现在把中立 `NodeRequest`、白名单 `NodeEvent` 和 + `NodeResult` 映射为连续 revision 的 Core `RuntimeEvent`;保留 request ID、完整 + output 和 reconciliation gate,未知事件或身份不匹配不会消耗 revision。新增 + `CodexAppServerBackend::invoke_node_with_runtime_events`,显式把 App Server 的 + request/event/result 流交给 mapper 和调用方 reducer sink;它不隐式修改 Host 或 + 创建第二套会话。 +- [x] `ExternalBackendToolExecutor` 的 cancel 在当前进程没有 active index 时先查 + durable request-id 别名;新增 `cancel_persisted` 和 `AgentHost::cancel_external_request`, + 重开 Host 后可以调用 backend cancel 并把已有 external session 收束为 + `cancel_requested`/`cancelled`/`unknown`,不重新 invoke。原有未知副作用仍必须由 + 调用方显式 reconciliation。 +- [x] `CodexAppServerBackend` 增加显式 `with_interrupt_hook`;调用方提供独立 control + transport 时可在同步 `send` 持有 channel mutex 期间执行中断,未配置时保留原有 + channel interrupt。hook 不得重入同一 channel,也不自动修改 Host/Runtime 状态;阻塞 + channel 并发回归已通过。 +- [x] P6 的 `GraphLimits`/`AgentCatalog` checked proposal 与 `PersistentCoordinator` + 的 revision-CAS 原子提案回归已落地;提案应用前校验完整候选图的任务数、边数、层深、 + 扇出和 Agent 注册,失败不污染快照。它不改变自动 scheduler、跨主机协调等非目标边界。 +- [x] 本轮新增回归后 Codex 为 64 个测试、Host 为 54 个测试;Storage 直接 + no-default-features 为 4+33,all-features workspace 为 5+40。Codex/Host 的 + all/no-default 定向测试和 `-D warnings` Clippy 均通过。 +- [ ] 以上是可在本地闭环的窄接线;完整 generated Codex wire、真实发行版 session、 + 自动外部对账/订阅、独立远端仓库/CI、registry/许可证策略、完整持久化多 Agent + 调度和最终 Host/Runtime 拆分仍未完成,原始 P0–P6 继续保持“部分完成”。 + +### 2026-09-03 继续执行:最终门禁复核(带外中断后) + +- [x] Codex `with_interrupt_hook` 合入后,all-features 与 no-default-features workspace + 测试均通过;`agent-codex` 各为 64 个测试,Host 54、Runtime 21、Orchestration 38、 + MCP 47、Skill 27、Engine 48、OpenAI 23、CLI 21、Fake 4,Core 17+14,Storage + all-features 5+40、直接 no-default-features 4+33。 +- [x] 两套 `-D warnings` check/Clippy、双特性 rustdoc、fmt、shell 语法、编码、 + `git diff --check`、依赖边界、manifest 预检、独立复制和 `run-agent-test-set.sh + --quick` 均通过;workspace `rust/target` 已在验证后清理。 +- [ ] 本轮 `run-cargo-audit.sh` 按设计因没有 runner 提供的 `RUSTSEC_ADVISORY_DB` 失败, + 只证明缺失输入会 fail-closed;不能把它记为当前漏洞扫描通过。独立远端仓库/CI、 + registry/许可证正式策略、真实 Provider/Codex session、自动外部对账/订阅、完整 + 持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成,原始 P0–P6 继续保持“部分完成”。 + +### 2026-09-03 继续执行:压缩、工具取消、启动恢复与进程桥 + +- [x] Core 新增可序列化、可重放的 `Compacting` 状态,以及严格的 + `CompactionStarted`/`CompactionCompleted` 状态事件;只接受 + `Running -> Compacting -> Running`,非法事件不会消耗 revision 或改变快照。 +- [x] Engine 在调用压缩器前写入 `compacting` checkpoint,并发出对应观察事件;Host + 在 durable checkpoint 边界同步投影 Core 压缩事件。压缩失败或取消会保留该游标并 + 进入 reconciliation;成功后再回到 `provider_in_flight`。Storage 可读取 + `compacting`,但拒绝 `resume-safe`。 +- [x] 工具上下文提供 runtime-only、只读 cooperative cancellation probe;Engine 的 + 普通工具路径和 approval continuation 都注入该探针,工具可在自身检查点返回 + `ToolErrorKind::Cancelled`,旧 `ToolExecutor` 签名保持兼容。 +- [x] `RuntimeService::recover_stale_runs_on_start(limit)` 提供启动边界固定排序、有界 + stale 扫描;文件数据库重开回归覆盖多候选、checkpoint 保留和重复扫描幂等。它不 + 启动线程、Engine、外部查询或自动重放。 +- [x] `CodexAppServerProcessBackend` 将已完成 initialize 的真实 stdio app-server 接到 + Core `ExternalBackend`;默认拒绝 server request,显式 handler 才能处理,活动 + request ID 串行保护,匹配取消会终止 child,dispatch 后错误统一归为 + `UnknownSideEffect`。它不猜测发行版 schema,也不接管 Host 持久化。 +- [ ] 本增量仍不等同于真实 Codex generated wire/session、自动外部对账/订阅、独立 + 远端仓库/CI、registry/许可证审计或完整多 Agent 调度;原始 P0–P6 继续保持“部分完成”。 + +### 2026-09-03 继续执行:Provider 生命周期与压缩接线 + +- [x] `agent-runtime-engine` 增加 `OwnedProviderContextCompressor`,在不改变 + `ContextCompressor`/Core 端口的前提下拥有 `Arc`;它复用借用式 + 压缩适配器的输入校验、响应 identity 校验和摘要边界,适合由 Host 长期保存。 +- [x] Host 增加 `with_provider_context_compressor()`,并在 OpenAI 的 env/config/手动 + key 装配 helper 中自动绑定同一 Provider。直接注入专用压缩器仍可使用 + `with_context_compressor()`;切换 Provider 只清理由旧 Provider 自动绑定的压缩器, + 不覆盖调用方明确提供的独立摘要模型。 +- [x] 新增长上下文回归:Host 使用拥有式压缩器时会先完成摘要请求,再发送主 Provider + 请求;无真实 API key、无网络依赖。当前 Engine 50、Host 57、Runtime 22,Core + 20+16,Storage all-features 5+43、直接 no-default-features 4+36。 +- [x] 测试 runner 默认临时父目录改为系统 `TMPDIR`(未设置时 `/var/tmp`),不再把 + 测试产物写入用户 home;脚本只清理本轮创建的精确子目录。 +- [ ] 这一步仍不改变原始 P0–P6 的“部分完成”结论:真实 Provider/Codex 会话、独立 + 远端仓库/CI、registry/许可证策略、自动外部对账/订阅、完整持久化多 Agent 调度和 + 最终 Host/Runtime 拆分仍需要外部条件或后续专门实现。 + +### 2026-09-03 继续执行:Codex 自然退出竞态修复与最终复核 + +- [x] `CodexAppServerProcess` 在 reader EOF 与 child `try_wait` 的调度竞态下,先检查 + 短暂退出宽限窗口中的自然状态,再执行 EOF 收束;自然非零退出保留精确退出码, + 不误报为 supervisor 强制终止。no-default-features 下同一回归重复 20 次均通过。 +- [x] 修复后 all/no-default workspace 测试、Storage 直接 no-default、`-D warnings` + check/Clippy、双特性 rustdoc、fmt、依赖/manifest/独立复制、Fake 测试集、编码、 + diff 和 shell 语法均通过;当前计数 Codex 73、Host 57、Engine 50、Runtime 22、 + MCP 47、Skill 27、CLI 21、Orchestration 38、OpenAI 23、Fake 4、Core 20+16, + Storage 5+43 / 4+36。 +- [ ] 真实 Provider/Codex 会话、远端仓库/CI、registry/许可证正式策略、自动外部对账/ + 订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未完成。 + +### 2026-09-03 继续执行:Provider 适配器协议自描述与配置边界 + +- [x] Core `ModelProvider` 增加可选的 `protocol_id()` 自描述端口;注册表在注册时 + 校验适配器报告的协议与 `ProviderDescriptor.protocol_id`,descriptor 省略协议时 + 从自描述适配器补齐,并在解析时再次校验动态适配器身份。没有自描述能力的旧/自定义 + Provider 仍可沿用调用方 descriptor,不被强制改写。 +- [x] `OpenAiProvider` 明确报告 `openai-responses` 协议常量,并有回归覆盖匹配、错配和 + descriptor 自动补齐;Provider 实例 ID 与协议 ID 继续保持不同的强类型。 +- [x] OpenAI endpoint query 的凭据字段检测规范化连字符、空格和点号变体,并拒绝常见 + `x-api-key`、`auth`、`key` 参数;CLI 将空白 TOML model 视为未设置,回退到环境或 + 默认模型。新增 Core/OpenAI/CLI 回归已通过;当前计数为 Core 22+16、OpenAI 23、 + CLI 22、Host 57。 +- [ ] 本地自描述校验仍不替代真实网关协议兼容矩阵、自动结果查询/恢复、远端 CI、registry + 与正式许可证策略;原始 P0–P6 继续保持“部分完成”。 + +> 说明:`agent-runtime` 的完整 durable facade 当前仍直接依赖 SQLite adapter;workspace +> 的 `--no-default-features` 只是验证 feature 组合,不应解读为 Runtime 已脱离 SQLite。 +> 只有 runtime-only `RuntimeSnapshotService` 可在 Core 内存 Store 上做 +> 无 SQLite 的 snapshot/event CAS 合同验证。 + +### 2026-09-03 继续执行:Runtime/Host 只读 facade 与编排 fixture 清理 + +- [x] `RuntimeService` 增加 `list_runtime_events`、`export_runtime_jsonl` 和 + `get_session` 窄 facade;`AgentHost` 提供对应的 run-level/runtime-level 事件、 + session 查询和 runtime JSONL 导出入口。控制面常用只读路径不需要借出 SQLite + adapter,run-level 审计流与可由 Core reducer 重放的 runtime 事件流保持分离。 +- [x] Host 现有测试已迁移到上述 facade;`AgentHost::store()` 标记为 deprecated, + 仅保留 SQLite 迁移兼容,不再作为新代码的推荐入口。它仍委托同一个 Runtime + 所有权,不会形成第二份持久化控制面。 +- [x] `JsonFileOrchestrationSnapshotStore` 的重开回归会同时清理同目录 sidecar + advisory lock,避免测试 fixture 遗留控制面文件;锁的跨进程 CAS 语义不变。 +- [x] Runtime、Host、Orchestration 定向测试和 `-D warnings` Clippy 均通过;最终全量 + 门禁以本文件后续最新记录为准。 +- [ ] 原始 P0–P6 仍逐项为“部分完成”。独立远程仓库/CI、registry/正式许可证策略、 + 真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和 + 最终 Host/Runtime 拆分仍未完成。 + +### 2026-09-03 继续执行:portable Runtime、HTTP 状态分类与脚本临时目录(历史快照) + +- [x] `agent-runtime` 增加 `sqlite` 可选特性:默认特性继续提供当前 SQLite durable + 控制面;`--no-default-features` 的 package-only check/test/rustdoc/Clippy 和依赖树 + 门禁只暴露 `RuntimeSnapshotService`、`WorkerLease` 等 portable surface,当前 + 5 个测试通过且不包含 `agent-storage-sqlite`、`rusqlite` 或 `libsqlite3-sys`。 + workspace 级 no-default 仍是兼容矩阵(Host 的正常依赖会重新启用 SQLite),不能 + 误读为完整 durable Runtime 已经可替换。 +- [x] OpenAI Responses adapter 对已收到的非 2xx 状态使用窄 transient allowlist + (408/429/502/503/504),返回 `Unavailable` 并保留 status code;400/401/500 等 + 仍为 `Upstream`。错误正文和截断/读取失败路径都脱敏,重试是否发生由 Engine 的 + `max_provider_retries` 决定;不支持 `Idempotency-Key` 的网关应将该预算设为 0。 + 当前 OpenAI 定向测试为 29 个。 +- [x] 能力测试与真实 eval runner 的默认临时父目录统一为 `TMPDIR`(未设置时 + `/var/tmp`),只删除本轮精确子目录;不再因默认配置把构建、SQLite 或日志写入 + 用户 home。`--from-codex-config` 仍只有显式调用才读取调用方已有配置。 +- [x] 最新本地源码计数:Core 23 个单元 + 16 个集成测试,Engine 50、Runtime 23 + (其中 no-default portable 5)、Storage all-features 5+43、直接 no-default + 4+36、Host 58、CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 27、Codex 73、 + Orchestration 38;完整门禁结果以本轮末尾验收记录为准。 +- [ ] 以上仍不替代独立远端仓库/CI、RustSec advisory DB、registry/正式许可证策略、 + 真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和 + 最终 Host/Runtime 拆分;原始 P0–P6 继续保持“部分完成”。 + +### 2026-09-03 继续执行:取消阶段门禁与 Codex 配置 fail-closed(历史复核) + +- [x] `RuntimeService::finish_unclaimed_cancelled_if_safe` 现在是 Host/CLI 的无主取消 + 入口:只有 `queued` 且没有 checkpoint,或没有 lease 且 checkpoint 为 `safe` / + `awaiting_approval` 时才允许跨表原子终态;残留 lease、历史 `running` / + `cancel_requested` / `reconciling`、缺失 checkpoint 和 `provider_in_flight` / + `tool_in_flight` / `compacting` 都保留 reconciliation gate。Host 在 + `request_cancel` 前保留 queued 判别,过期 lease 先 stale probe;Runtime 与 Host + 新增回归覆盖历史无 lease 和过期 in-flight 两条路径。旧 + `finish_unclaimed_cancelled` 仍是明确标注的低层兼容入口,不被 Host/CLI 使用,未来 + 若要完全禁止绕过需要 breaking API/Storage contract 变更。 +- [x] `CodexCliConfig` 与 `CodexAppServerProcessConfig` 的 timeout/output 或 frame limit + 在 serde 与运行时两层拒绝零值;`with_timeout` 拒绝小于一毫秒的 Duration,并对 + 整数转换溢出 fail-closed。新增零值、子毫秒和溢出回归,避免 `doctor/validate` 与 + 实际 supervisor 执行的配置口径分叉。 +- [x] 当前源码计数已由双特性 workspace 串行复核:Core 23 个单元 + 16 个集成测试, + Engine 50、Runtime 默认特性 25(package-only no-default 5)、Storage + all-features 5+43、直接 no-default 4+36、Host 60、CLI 23、OpenAI 29、Fake 4、 + MCP 47、Skill 27、Codex 77、Orchestration 38。 +- [x] all/no-default workspace 测试、Runtime/Host/Codex/Storage 定向测试、`-D warnings` + check/Clippy、双特性 rustdoc、fmt、依赖/manifest/独立复制、能力集正例与负例、编码、 + diff 和 shell 语法均已复核;能力 runner 的不存在 filter 会明确失败而不再出现 + `0 tests` 假绿。`run-cargo-audit.sh` 在本机因没有 `RUSTSEC_ADVISORY_DB` 按设计退出 + 1,不能记作当前漏洞扫描通过。 +- [ ] 这次复核不改变 P0–P6 的“部分完成”结论:独立远端仓库/CI、registry/正式许可证 + 策略、真实 Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、 + 最终 Host/Runtime 拆分,以及移除低层兼容取消入口仍需外部条件或后续 breaking 设计。 + +## 2026-09-03 完成性审计(历史边界快照) + +- [x] 本地可闭环的 Core、Engine、Runtime、Storage、Host、CLI、Provider、MCP、Skill、 + Codex 和 Orchestration 实现及其双特性门禁已经完成;当前源码计数和命令证据以上一节及 + 验收记录为准。 +- [ ] `AgentHost::cancel` 的 queued 快速路径先读取状态再调用 Runtime 安全终态入口, + 与 worker 在极窄窗口内领取之间仍可能发生竞态。Runtime 的跨表事务会再次检查 lease, + 因而只会 fail-closed 返回 lease conflict,不会越权删除 checkpoint 或伪造 cancelled; + 但本次控制端取消可能需要重试或等待 worker cooperative 收口。彻底消除需要新增 + “预期 queued + 无 checkpoint/lease”的单事务 Storage/Runtime 命令及其合同测试,不能 + 通过放宽现有低层取消入口解决。 +- [ ] 独立远端仓库/CI、registry/正式许可证策略、真实 Provider/Codex wire/session、 + 自动外部对账/订阅、完整持久化多 Agent 调度、最终 Host/Runtime 拆分,以及低层兼容 + 取消入口的 breaking 清理仍未完成;这些分别需要外部授权/系统证据或后续 API 设计。 + +## 2026-09-04 继续执行:取消竞态与 Codex 空白帧(历史复核) + +- [x] queued 取消现在经过 `RuntimeService::finish_queued_cancelled_if_unclaimed` 和 + SQLite `RunFinishGuard::QueuedUnclaimed`。Storage 在同一个 `BEGIN IMMEDIATE` + 事务内重新检查 queued、无 cancel marker、无 worker/lease、无 checkpoint;条件 + 失效返回 typed `QueuedCancelConflict`,不删除 lease、不写孤立终态。Host 在 + `None` 时退回 cooperative `request_cancel`,由已领取 worker 在自己的 fencing + 边界收口。 +- [x] `finish_unclaimed_cancelled_if_safe` 的 queued 分支也复用上述原子入口;Core + runtime 投影若不是 `Pending` 会直接报告数据裂缝,不再把不一致伪装成普通竞态。 + 旧的 `finish_unclaimed_cancelled` 仍只作为明确的低层兼容入口,未放宽其语义。 +- [x] Runtime 直接领取后竞态回归,以及两个独立 SQLite 连接的 Host 取消/领取并发 + 回归通过;两种结果都只允许 `cancelled` 或 `running + cancel_requested`,不会出现 + 已取消但仍持有 worker lease 的裂缝。 +- [x] Codex JSONL channel 的空白 keep-alive 行由递归改为循环跳过,连续 8192 行 fixture + 回归通过,避免不可信对端用空帧增长调用栈;单帧大小限制和协议版本校验保持不变。 +- [x] 当时源码定向计数:Core 23 个单测 + 16 个集成测试、Engine 50、Runtime 26 + (package-only no-default 5)、Storage 5+43(直接 no-default 4+36)、Host 61、 + CLI 23、OpenAI 29、Fake 4、MCP 47、Skill 27、Codex 78、Orchestration 38。 + all/no-default workspace 测试、依赖/manifest/独立复制、能力集和根仓库 3189 个 + Vitest 测试均已通过;最终静态、格式、编码、diff 和 shell 语法门禁也已通过, + 临时构建/依赖目录已清理。 +- [x] 清理构建目录后,使用临时 `AGENT_DB` 直接启动 `agent-cli run` 完成 Fake Provider + 工具调用闭环;CLI 黑盒输出为 completed,临时数据库已删除。 +- [ ] 独立远端仓库/CI、registry/正式许可证策略、真实 + Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、最终 + Host/Runtime 拆分和删除低层兼容 API 仍未完成;本地 fixture 与竞态回归不能替代 + 这些外部证据或 breaking 迁移设计。 + +## 2026-09-04 RustSec 离线审计复核(历史快照) + +- [x] 在 `~/data/tmp/` 的隔离工具/数据库目录中固定 `cargo-audit 0.22.2`,使用 RustSec + advisory-db 提交 `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5`(1239 条 advisory)。 +- [x] 通过显式 `RUSTSEC_ADVISORY_DB` 与 `CARGO_AUDIT_BIN` 调用 + `bash rust/scripts/run-cargo-audit.sh`,脚本加载 1239 条 advisory,扫描 188 个锁定 + 依赖,退出码 0 且无漏洞/warning。`Cargo.lock` SHA-256 为 + `1a3282fe963b9a53e3d9e24a1507c75f1ee19b9a8648025d4f6da25aca5464d5`。 +- [x] 工具、advisory DB、marker 与扫描产物均已从 `~/data/tmp/` 清理;仓库未新增 + RustSec 文件。wrapper 仍保持缺失 `RUSTSEC_ADVISORY_DB` 时 fail-closed,避免 CI 偷偷 + 联网或使用漂移数据库。 +- [ ] 本地一次性扫描不等同于独立远端 runner 的持续审计;远端 CI、正式许可证策略、真实 + Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、最终 + Host/Runtime 拆分和低层兼容 API 的 breaking 清理仍未完成。 + +## 2026-09-04 RuntimeStore 可替换接缝(历史快照) + +- [x] `agent-runtime` 公开 `DynRuntimeStore` 本地 newtype,承载 + `Box` 并转发 `load`/`commit`;`RuntimeSnapshotService` 因此可在 + 不构造 SQLite 的情况下接收动态 Store,同时保留 Core typed `StoreErrorKind` 和 + revision CAS 语义。 +- [x] no-default-features portable facade 测试 6/6,默认 feature lib 测试 27/27; + `-D warnings` check/Clippy 与 fmt 通过。该接缝只覆盖 runtime-only + snapshot/event,不改变 SQLite-backed durable run/session/lease/checkpoint 的现状。 +- [ ] 这不是最终 Host/Runtime 拆分:完整 RuntimeService 仍依赖 SQLite,`store()` 兼容 + accessor 和跨表事务仍需后续 breaking 设计;远端仓库/CI、正式许可证、真实 + Provider/Codex session、自动外部恢复和完整多 Agent 调度继续保持未完成。 + +## 2026-09-04 P2/P4 输入与取消边界(当前) + +- [x] Core `ContentPart::validate`/`Message::validate` 复用构造级规则; + `PromptSection::try_new`、`ProviderRequest::try_new` 和 `ProviderResponse::try_new` + 也会校验嵌套内容/工具调用;`PromptBuilder::build` 与 `into_messages` 改走 fallible + `Message::try_new`。serde 解码本身仍保持数据读取兼容,但非法文本、图像或工具参数 + 会在生成 Provider 请求前被拒绝; + `deserialized_invalid_content_is_rejected_before_provider_request` 覆盖该边界。 +- [x] Engine 的压缩器输出不再允许 `Tool` 角色或任何角色中的结构化 + `ToolCall`/`ToolResult`,summary Provider response content 中的结构化工具块也会 + 直接拒绝,避免不可信摘要伪造可重放的工具历史或静默丢失; + `compressor_cannot_inject_tool_history` 和 + `compressor_rejects_structured_content_in_summary_response` 已通过。 +- [x] Core 默认 `ModelProvider::stream` 在 Provider 只实现 `complete` 时仍发出完整文本、 + 每个 tool-call 的 `ToolCallDelta`、可选 `Usage` 和 `Completed`; + `default_stream_preserves_complete_tool_calls_and_usage` 已通过,避免结构化结果静默丢失。 +- [x] Host `McpToolExecutor` 在发送前、以及等待 client 锁后再次检查取消;已取消调用返回 + `ToolErrorKind::Cancelled` 且不触碰 `tools/call` transport,回归为 + `cancelled_mcp_tool_context_blocks_transport_before_tools_call`。同步 transport 已进入 + 阻塞 I/O 后的硬中断仍由具体 MCP adapter 提供,通用层不强杀线程。 +- [x] Durable `RuntimeSnapshot`/`RunSnapshot` 在 SQLite load、CAS commit 和 recovery + verification 边界统一执行 `validate()`;文件库被外部篡改为结构合法但语义非法的 + snapshot 时,load 映射为 `StoreErrorKind::InvalidSnapshot`,后续 commit 不写入事件或 + 新快照。`runtime_store_rejects_tampered_snapshot_on_load_and_commit` 覆盖该回归。 +- [x] 本轮定向计数:Core 28 个单测 + 17 个集成测试、Engine 52、Runtime 默认 28 + (package-only no-default 6)、Storage 5 个单测 + 45 个集成测试(50,直接 + no-default 4+36=40)、Host 62;其余 + crate 数字以最新验收记录为准。 +- [ ] 以上是本地输入/取消合同收紧,不改变 P0–P6 的“部分完成”结论;真实 Provider/Codex + session、远端 CI/registry/许可证、自动外部对账和最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-04 Runtime event history、Skill discovery 与 Codex process bridge(当前) + +- [x] SQLite RuntimeStore 在 load、CAS commit 与 recovery verification 从空快照重放完整 + `runtime_events`,要求 revision 连续且事件语义可重放;event log 缺失、断档或语义篡改 + 均 fail-closed,不接受当前 snapshot,也不写入后续事件。新增 + `runtime_store_rejects_event_history_gap_on_load_and_commit`;Storage all-features + 为 5+45=50,直接 no-default 仍为 4+36=40。 +- [x] Skill discovery/list 从 discovery 阶段即受 `max_body_bytes` 硬上限约束,正文超过 + 配置上限直接返回 `BodyTooLarge`;新增 + `discovery_and_list_reject正文超过配置上限`,Skill 当前 28 个测试。 +- [x] Codex process backend runtime event bridge 显式映射 request→notification→result + 顺序,并保留显式 handler 变体;新增 + `app_server_process_backend_runtime_event_bridge_maps_notification_order`,Codex + 当前 81 个测试。 +- [ ] 以上是本地可验证增量,不改变原始 P0–P6 仍部分完成的结论;真实 Provider/Codex + session、远端 CI/registry/许可证、自动外部对账和最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-04 RuntimeService 动态 snapshot Store 接缝(当前) + +- [x] `RuntimeService::snapshot_store()` 返回 `RuntimeSnapshotService`, + 让调用方通过 portable `RuntimeStore` 合同访问同一 SQLite 状态;新增 + `runtime_exposes_dynamic_snapshot_store_without_adapter_handle` 验证动态 CAS 提交 + 会被 Runtime facade 立即观察到。该入口只覆盖 snapshot/event,不改变跨表 + run/session/lease/checkpoint 事务仍由 SQLite-backed RuntimeService 负责的边界。 +- [x] 受影响的 Runtime package 测试为默认 feature 28/28、package-only + no-default-features 6/6;其余计数和全量门禁以最新验收记录为准。 +- [ ] 该接缝不等同最终 Host/Runtime 拆分;远端仓库/CI、正式 registry/许可证策略、真实 + Provider/Codex wire/session、自动外部对账/订阅和完整持久化多 Agent 调度仍未完成。 + +## 2026-09-04 manifest 预检 target 隔离(当前) + +- [x] `scripts/check-package-manifests.sh` 在未显式设置 `CARGO_TARGET_DIR` 时,将 + `cargo package --list` 的中间产物放入 `~/data/tmp`(可由 + `AGENT_PACKAGE_TMPDIR` 覆盖),并在正常/失败退出时清理;显式 target 目录仍由调用方 + 管理。13 个 crate 的 manifest 预检已复跑通过,workspace 内没有留下 `rust/target`。 +- [ ] 该脚本修复只收紧本地构建边界,不改变 P0–P6 的部分完成状态;独立远端仓库/CI、正式 + registry/许可证策略、真实 Provider/Codex wire/session、自动外部对账/订阅和最终 + Host/Runtime 拆分仍未完成。 + +## 2026-09-04 ContextItem 嵌套消息构造校验(当前) + +- [x] `ContextItem::try_new` 在接收消息时重跑 `Message::validate`,不会把兼容 + `Message::new`/serde 构造的非法内容交给上下文源;新增 Core conformance 回归后 + 当前 Core 为 28 个单测 + 17 个集成测试。 + +## 2026-09-05 Core 合同、外部会话候选与全量门禁(当前) + +- [x] Core 扩展值对象和外部端口现在在公开 serde/兼容入口复验: + `ToolBinding`、`SkillDefinition`、`SkillActivation`、`AgentDescriptor`、 + `BackendRequest`、`BackendResult` 与 `ToolContext` 均有明确 `validate` 边界; + `backend_result_as_tool` 拒绝 request/call 身份错配及 `side_effect_unknown` 结果, + 不把未知副作用伪装成成功工具结果。新增 Core conformance 后为 30 个单测、 + 20 个 `core_contracts` 集成测试和 1 个 `tool_context_contracts` 集成测试。 +- [x] Engine 在调用任意 `ContextSource` 前校验 `ContextRequest`,Host/Engine 在 + ToolContext、ToolCall、Skill 激活、MCP 和外部 backend dispatch 前复用 Core 校验; + 非法 session/run 身份、预算、元数据或工具合同会在副作用前 fail-closed,来源不会 + 被调用。相关 Host 65、Engine 55 定向测试均通过;公开 `validate_tool_arguments` + 还会复验 Core 调用/定义并检查工具名匹配。 +- [x] Runtime/Storage/Host 增加只读 `list_external_sessions` 候选查询:状态过滤最多 + 32 项,limit 最多 256,固定 `created_at ASC, id ASC` 排序,所有值参数绑定; + 该入口只观察已持久化记录,不自动对账、重放、重排队或修改 checkpoint。Storage + all-features 为 7+49,直接 no-default 为 6+36;Runtime 为 36(package-only + no-default portable facade 为 8),Host 为 69,Codex 为 92 个测试。 +- [x] Runtime 第一阶段新增中立 `DurableStore` command/查询/lease 合同与 + `SqliteDurableStore` 适配器;`prepare_run*`、claim/heartbeat/release、runtime + snapshot load/CAS、run/session 查询、checkpoint fencing、approval pending-only CAS、 + 外部会话候选和 request-cancel/stale 扫描已通过该接缝;runtime-aware finish/recovery + command 也已接入,直接 contract 回归覆盖 bundle、lease view、跨 facade 可见性和 + typed CAS conflict。少数历史兼容入口仍保留专用原子实现,最终依赖反转尚未完成。 +- [x] 最新隔离门禁全部通过:all-features/no-default workspace 测试、默认/全特性/no-default + check、Clippy `-D warnings`、rustdoc、fmt、Storage 直接 no-default、依赖边界、13 + 个 manifest、独立 workspace 复制、Fake 测试集 2/2、能力集 10/10、shell、编码和 + `git diff --check` 均退出码 0;编排、Host、Skill、Storage 测试默认回退 `~/data/tmp`,临时 + target/复制品只在该目录,退出后已清理。 +- [ ] 这次仍只是原始 P0–P6 的本地增量,阶段状态保持“部分完成”。独立远端仓库/CI 持续 + 运行、正式 registry/许可证策略、真实 Provider/Codex wire/session、自动外部对账/订阅、 + 完整持久化多 Agent 调度、最终 Host/Runtime 拆分和低层兼容 API 的 breaking 清理仍未完成。 + +## 2026-09-05 Engine 公共工具预检合同(当前) + +- [x] 公开 `validate_tool_arguments` 在 JSON Schema 校验前复验 `ToolCall`、 + `ToolDefinition`,并要求调用名与定义名一致;serde/兼容构造出的空 ID、非法 + arguments、非法 schema 或名称错配会在 approval/入队预检阶段返回结构化 + `ToolArgumentError`。新增 `public_tool_argument_validator_rechecks_core_contracts_and_name` + 回归,Engine 定向测试当前为 55 个;`CompressionRequest::validate` 同时拒绝零预算、 + 非法身份和非法嵌套消息,`ProviderContextCompressor` 在 Provider 调用前复用该合同。 +- [ ] 本地公共边界现已收紧,但真实 Provider/Codex 会话、自动外部对账、远端 CI/registry/ + 许可证、完整持久化多 Agent 调度以及最终 Host/Runtime 拆分仍需外部证据或后续设计, + 不因本地回归而标记原始 P0–P6 完成。 + +## 2026-09-05 DurableStore command 扩展(当前) + +- [x] `DurableStore` 现在同时承接 run/session 查询、lease claim/heartbeat/release、 + cooperative cancel/stale 扫描、checkpoint(含 runtime+lease fencing)、approval + pending-only CAS、external-session 查询,以及 runtime-aware finish/recovery command; + `RuntimeService` 的对应 facade 通过 `SqliteDurableStore` 路由,contract 回归保持 + Runtime 36 个测试(package-only no-default 8)。 +- [x] finish command 覆盖 completed/failed/cancelled 目标和 queued-unclaimed guard, + recovery command 覆盖带 runtime snapshot/CAS 的跨表恢复;SQLite 仍负责一次事务内 + 校验 run/session/lease/checkpoint/runtime 一致性,避免由多次 trait 调用拼出假事务。 +- [ ] 该增量仍不是最终依赖反转:少数历史兼容取消/终态入口和 RuntimeService 的 SQLite + 具体实现仍保留;独立远端仓库/CI、正式 registry/许可证、真实 Provider/Codex + wire/session、自动外部对账/订阅、完整持久化多 Agent 调度及最终 Host/Runtime 拆分 + 继续保持未完成。 + +## 2026-09-05 DurableStore 控制面查询收敛(当前) + +- [x] DurableStore 继续承接 cooperative cancel 查询、run→runtime 身份反查、session 投影更新 + 和 safe requeue;Runtime 的 stale/recovery、安全取消、session 更新等路径复用同一中立 + adapter 合同,session view 保留 metadata 与创建/更新时间字段,不丢失现有公开记录语义。 +- [x] 新增 contract 回归覆盖 runtime identity、cancel marker 和 session update;诊断/审计导出 + 仍保留 SQLite 具体实现;无 runtime 的兼容终态也经 adapter 路由,避免 + 把非原子旧 API 伪装成可替换事务。 +- [x] `RuntimeService::store()` 与 `AgentHost::store()` 均标记为 deprecated;新调用方使用窄 + facade,旧 accessor 只作为 SQLite 迁移兼容视图。 +- [x] 新增通用 `DurableRuntime` 拥有式 facade,完整转发 durable command/query, + 可注入并取回任意 adapter;`RuntimeService::durable_runtime()` 仅提供当前 SQLite 便利装配。 +- [x] Runtime contract 回归更新为 36 个测试(package-only no-default 8),验证 generic facade + 的查询、身份反查、所有权取回以及非 SQLite fake adapter 的 bundle/lease/CAS/finish/recovery。 +- [ ] 这仍不是最终 Runtime/SQLite 依赖反转;历史兼容 API、独立远端 CI/registry/许可证、真实 + Provider/Codex session、自动外部对账和完整持久化多 Agent 调度继续保持未完成。 + +## 2026-09-05 工具调用 durable 记录(当前) + +- [x] `ToolRequested` 现在通过 Runtime/DurableStore 创建 `tool_calls` 记录,`ToolCompleted` + 更新结果为 `completed` 或 `error`;重复 identity 幂等,旧 run 缺少记录时由完成事件回填。 +- [x] SQLite `list_tool_calls_for_run`、`AgentHost::list_tool_calls` 与 JSONL 导出包含工具调用记录;Host Fake loop 和 Storage + idempotency 回归通过,兑现 P3 的工具调用持久化要求。 +- [x] 工具调用请求/结果与对应 Core runtime snapshot/events 已通过 + `DurableToolCallRuntimeCommit` 在 SQLite 的单个 IMMEDIATE 事务中提交;Host + `ToolRequested/ToolCompleted` 路径已复用该合同,覆盖 lease fencing、重复 identity + 幂等、旧 row 回填和 CAS 失败回滚。 +- [x] 在调用方同时拥有 checkpoint 游标时,新增 `DurableToolCallCheckpointRuntimeCommit`, + 由 SQLite adapter 在一个 IMMEDIATE 事务中合并工具行、checkpoint 与 runtime snapshot/events; + 独立 tool/runtime command 仍保持兼容。 +- [ ] Host 已将首次 `awaiting_approval` checkpoint 与 `ToolRequested` 联合提交,并在 + `ToolCompleted` 已有游标时复用联合合同;普通 checkpoint、终态与工具 trace 尚未共享 + 全部事件输入,因此全链路全局事务和自动外部对账仍未完成。 + +## 2026-09-06 MCP server-request 边界收敛(当前) + +- [x] stdio `poll_notification` 遇到 server request 时直接返回 `McpError::Unsupported`,不再 + 放回 pending 队列造成后台订阅空转;Host 对已 dispatch 的 MCP `Unsupported` 映射为 + `ToolErrorKind::Unknown`,不把未知副作用当作可安全重试的 InvalidInput。 +- [x] 新增 stdio 轮询、后台订阅、Host 错误分类和协议版本协商回归;MCP 当前 52 个测试。sampling/ + elicitation 等双向 handler 仍由上层适配器实现,不进入 Kernel。 + +## 2026-09-06 Codex/MCP 边界硬化(历史快照) + +- [x] `CodexAppServerBackend::cancel` 的已 dispatch 错误改用 + `external_error_kind_after_dispatch`;`NodeRuntimeEventMapper` 增加已核对的 + `item/agentMessage/delta`、`turn/completed`、`turn/failed`、`turn/cancelled` 和 + `item/tool/result` 别名,未知 vendor event 仍 fail-closed。 +- [x] Codex argv 白名单拒绝空/控制字符前缀;Host Codex 中立/typed handler 在审批前校验 + `ToolContext` 并检查 cooperative cancel,取消或非法上下文不会触发 policy/tool。 +- [x] Host sink 保持 metadata merge、terminal 不复活和 run/session 归属校验;Codex 当前 + 当时 86 个测试、Host 当前 69 个测试、MCP 当前 49 个测试。 +- [ ] 真实 Codex 发行版 generated wire、协议级 turn interrupt、进程生命周期全量 sink 和 + 自动外部对账仍需要外部发行版或后续专用适配器。 + +## 2026-09-05 Codex session metadata sink(当前) + +- [x] `CodexRuntimeSessionMetadataSink` 将版本化 app-server 的 thread/turn 身份接入同一 + Runtime `external_sessions` 记录;Host 提供 `codex_session_metadata_sink(run_id, backend)` + helper,thread/start 后创建记录,turn/start 后原地更新 external_id,不创建第二套会话真相。 +- [x] Host 回归覆盖 session/run 归属、最新 turn identity、active 状态和 metadata 合并;P5 + 的真实发行版 generated wire 及自动重连/对账仍需外部适配器。 + +## 2026-09-06 Codex 请求生命周期 sink(当前) + +- [x] `CodexSessionMetadataSink` 增加向后兼容的 `persist_lifecycle` 扩展;真实 + `CodexAppServerProcessBackend` 在请求成功、post-dispatch 失败和匹配的 cancel + 收束时发送中立生命周期记录,不把 sink 失败误报成可安全重放。 +- [x] 新增 `agent-runtime-contracts` crate,承接 DurableStore command/view/trait; + `agent-runtime` 已完成 portable 化,SQLite-specific Service/adapter 位于 + `agent-runtime-sqlite`。 +- [x] `CodexRuntimeSessionMetadataSink` 对 `external_sessions.metadata` 做读-合并-写,保留 + 既有审计字段,并在已有 terminal 状态下拒绝 late lifecycle/metadata 复活;生命周期 + 可记录 `completed/failed/cancelled/unknown`、生成的 external ID、退出码和取消结果。 +- [x] 新增 Codex 真实 stdio fixture、Host merge/terminal 回归和 Codex handler durable + tool-call 回归(含 `app_server_process_lifecycle_sink_reports_timeout`、 + `app_server_process_lifecycle_sink_distinguishes_reader_eof`);当前 Codex 101 个测试、 + Host 77 个测试、MCP 52 个测试。 +- [x] 对长连接 app-server,`thread/start`/`turn/start` 的 accepted response 在 child 仍存活 + 时记录为 `active`;`ProcessControl` 现可观察已回收 child 的真实退出码。 +- [x] 取消路径使用 ProcessControl 的 pending/active outcome;请求已收束后的迟到 cancel + 保持幂等 no-op,不覆盖已有 lifecycle 状态。 +- [x] `CodexProcessLifecycleSink` 可注入真实 stdio process,按 emit-once 规则报告自然退出、 + 显式终止、cancel/timeout/drop,并由 session metadata sink 转发;Host 为 process observation + 使用稳定辅助 external-session 行。 +- [x] ProcessControl 将 reader EOF 与 reader I/O 错误区分为不同 lifecycle reason;两者都在 + process 收束后 emit-once。仍未实现协议级 turn interrupt;完整外部发行版验证仍需专用 + 进程级合同,不能把本地 fixture 计为完整 Codex 生命周期。当前机器可用的 + `codex-cli` 是 0.153.4,而本适配器固定核对 0.152.1,版本不一致时不做 wire 兼容推断。 +- [x] 新增独立 `JsonRpcAppServerRouter` transport 接缝及接入 `ProcessControl` 的 + `CodexAppServerProcessRouter`:后台 reader 按 response ID 分发,pending/notification + 队列有界,支持乱序 response、并发 `turn/interrupt` 和真实 stdio child 收束;未知/重复 + response、带 ID server request 和队列溢出 fail-closed,不等同真实 Codex 发行版 wire/session + 已验收。 + +## 2026-09-06 DurableRuntime SQLite wrapper 隔离(历史快照) + +- [x] `agent-runtime/src/durable.rs` 将 `SqliteDurableStore` 和 SQLite 记录转换函数移到同目录 + `durable_sqlite.rs`(由 `cfg(feature = "sqlite")` 的 `sqlite_adapter` 模块加载);通用 + `DurableRuntime` 文件只依赖 contracts/Core。`cargo test -p agent-runtime + --no-default-features` 8/8 通过,`cargo tree --no-default-features` 未出现 + `agent-storage-sqlite`;默认 SQLite API/re-export 保持不变,方便 Host/CLI 渐进迁移。 +- [ ] 该项只完成源码/feature 边界隔离,不等同最终 Runtime/SQLite 独立 crate 拆分;历史 + 兼容 API、完整跨表全局事务、非 SQLite 生产 adapter、远端 CI/registry/许可证和其余 + P0–P6 外部验收仍保持未完成。 + +## 2026-09-06 MCP 协议版本候选协商(当前) + +- [x] `McpClientOptions::with_protocol_versions` 提供有界、去重且按优先级排列的协议版本候选; + initialize 会校验服务端选择必须属于候选集合,遇到明确的版本拒绝时,仅对带可重建配置的 + stdio/HTTP 连接创建新 transport 再尝试下一个版本。注入的自定义 transport 不会被假定可重放, + 多候选配置会明确返回 `Unsupported`。 +- [x] Streamable HTTP transport 在协商成功后更新 `mcp-protocol-version` 请求头;新增 3 个 MCP + 回归覆盖 stdio/HTTP 候选版本重建、协商结果观察、有界配置和 HTTP 头更新。MCP 当前 52 个测试。 +- [ ] 该协商仍不自动应答 server request、重连通知订阅或重放 `tools/call`;完整多版本能力合同和 + 上层自动恢复仍需具体协议/宿主设计,原始 P4 继续保持部分完成。 + +## 2026-09-06 工具调用、Checkpoint 与 Runtime 联合事务(历史快照) + +- [x] `agent-runtime-contracts` 新增 `DurableToolCallCheckpointRuntimeCommit`,要求 worker + 同时提供工具调用、checkpoint、Core runtime snapshot/events 和 opaque lease; + `DurableStore`/`DurableRuntime`/`RuntimeService` 均提供对应 create/complete 转发,旧的 + 仅 tool/runtime 合同保持兼容。 +- [x] SQLite adapter 在单个 `IMMEDIATE` 事务内执行 run/tool identity、lease fencing、runtime + CAS/事件重放、checkpoint upsert 与工具行 insert/update;任一校验失败都会整体回滚。新增 + 成功、完成、错 lease 和 runtime CAS 回滚回归;Storage 当前 all-features 为 7+53,直接 + no-default 仍为 6+36。 +- [x] Host 的 `ToolCompleted` trace 在已有当前 checkpoint 时复用该联合合同;没有 checkpoint + 的旧 run 仍走兼容的 tool/runtime 事务。首次 `awaiting_approval` checkpoint 也会把 + `ToolRequested`、工具行、checkpoint 和 Core runtime event 一起提交;普通 checkpoint + 与终态仍保留各自边界,避免把未来的多工具游标提前写入。 +- [ ] 因此 Host 仍未形成覆盖所有 Engine checkpoint、工具请求/结果和终态的全链路全局事务;自动 + 外部对账和最终 Runtime/Host 应用编排拆分仍保持未完成,Runtime/SQLite 物理拆分已完成。 + +## 2026-09-06 Codex router 进程接线(当前) + +- [x] 新增 CodexAppServerProcessRouter,用现有 ProcessControl 管理同一真实 stdio + child,并把持久化 reader/writer 接入通用 JsonRpcAppServerRouter。wrapper 暴露有界 + request、notification、turn/interrupt、cancel、terminate 和 exit-code 观察;router + 请求超时或协议错误会收束 child,Drop 也会回收 reader/writer,避免通用 router 只 + 停留在内存 transport fixture。 +- [x] 新增本地 sh 长连接 fixture,覆盖真实进程上的 initialize-like request、notification、 + turn interrupt 和 ProcessControl terminate;Codex 定向测试为 all-features 101/101、 + no-default-features 101/101。 +- [ ] 该入口仍不猜测具体 Codex 发行版 schema,也不自动完成 initialize/重连/请求重放; + generated wire、协议级外部对账和真实发行版 session 仍需专用适配器或外部证据。 + +## 2026-09-06 RuntimeService 泛型 facade 所有权接缝(历史快照) + +- [x] `RuntimeService` 现在内部拥有 `DurableRuntime`,SQLite + convenience API 统一从该 facade 取回 adapter;不再同时维护平行的裸 store 字段。 + 这保持既有 `RuntimeService`/SQLite 兼容方法,同时让通用 facade 成为实际控制面实现。 +- [x] 新增 `RuntimeService::into_durable_runtime()`,允许 Host/调度器消费 SQLite + convenience shell 后只拥有通用 `DurableRuntime`,不需要再暴露或复制 SQLite 连接; + `DurableRuntime` 增加 `Clone` 以支持同一 adapter 句柄的显式共享。 +- [x] Runtime 定向测试 36/36 通过,并增加 facade 所有权交接回归;该改动不改变跨表事务 + 语义,也没有新增第二套 Runtime API。 +- [ ] 这只是内部 ownership seam,尚未完成独立 Runtime/SQLite crate、非 SQLite 生产 + adapter、Host 全量 generic 化或历史兼容 API 的 breaking 清理。 + +## 2026-09-06 P6 编排取消控制面(当前) + +- [x] `Coordinator::cancel_run` 在更新图节点为 `Cancelled` 前先构造校验副本,再释放 + active run 配额;`PersistentCoordinator::cancel_run` 通过同一 mutation/revision-CAS + 持久化图状态和 Coordinator 索引,避免调用方先释放配额后写状态的半完成窗口。 +- [x] 新增低层与持久协调器回归:取消结果、配额释放、依赖任务不误报 ready,以及快照重开 + 后的 `Cancelled` 状态均已验证;Orchestration 当前为 40 个测试。 +- [ ] 该入口只记录编排控制面取消,不发送 Runtime cooperative cancel、不终止 worker 或外部 + 进程;完整持久化多 Agent scheduler、跨主机协调和自动外部对账仍需宿主/部署语义。 + +## 2026-09-06 RustSec 当前锁文件审计(当前) + +- [x] 在 `~/data/tmp` 临时构建固定 `cargo-audit 0.22.2`,使用 RustSec advisory-db + 提交 `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 对当前 `Cargo.lock`(SHA-256 + `e4dccbc5848294df920676fbcf8be1fd6fef2c543647c4ea5db63cdecc7a0bdc`)执行离线扫描; + 加载 1239 条 advisory,扫描 190 个 crate 依赖,退出码 0、无漏洞或 warning。 +- [ ] 该证据只覆盖本次锁文件和 advisory 快照;独立远端仓库/CI 持续扫描、正式许可证策略 + 和 registry 发布仍未完成,临时工具与数据库不进入仓库。 + +## 2026-09-06 Runtime/SQLite 物理拆分完成(当前) + +- [x] 新增 `agent-runtime-sqlite` 承接 SQLite-specific `RuntimeService`、记录/错误转换、 + `SqliteDurableStore` 和 28 个 SQLite runtime 测试;Host 已迁移到该 crate,未保留 + `agent_runtime::RuntimeService` 的平行 re-export。 +- [x] `agent-runtime` 已删除 SQLite feature、`agent-storage-sqlite` 和 `rusqlite` 依赖, + 只保留 portable `DurableRuntime`、`RuntimeSnapshotService`、`WorkerLease`、 + `RunHandle` 与 `RuntimeError`。验证 `cargo tree -p agent-runtime --no-default-features` + 不含 SQLite 相关依赖,workspace package 数量更新为 14。 +- [x] breaking API 迁移后的 all/no-default workspace 测试、Runtime portable 8、SQLite + runtime 28、Host 77、Clippy、rustdoc、fmt、依赖边界和独立复制检查均通过。 +- [x] 能力集中的 `runtime-stale-recovery` filter 已跟随 RuntimeService 迁移到 + `agent-runtime-sqlite`,重新执行能力集 10/10 通过。 +- [x] Host 的直接 path 依赖已进一步收敛为 `agent-runtime-sqlite`(不再直接依赖 + portable `agent-runtime`);portable identity 由 SQLite facade 重新导出,避免 + 装配层绕过职责边界。 + +## 2026-09-06 拆分后锁文件安全复核(当前) + +- [x] Runtime/SQLite 拆分更新 `Cargo.lock` 后,重新使用固定 `cargo-audit 0.22.2` 和 + advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 扫描;加载 1239 条 advisory, + 扫描 190 个依赖,退出码 0、无漏洞或 warning。新锁文件 SHA-256 为 + `e4dccbc5848294df920676fbcf8be1fd6fef2c543647c4ea5db63cdecc7a0bdc`。 + +## 2026-09-06 Host 终态与后台队列 metadata 收敛(当前) + +- [x] 对成功且没有工具调用的纯文本运行,Host 将 assistant `MessageAppended` 与 + `RunCompleted` Core event 在现有 `complete_run_with_lease_and_runtime` 单一事务中 + 提交;Provider/Tool in-flight、审批、多工具、取消和错误路径继续保留各自安全游标。 + 新增终态批次顺序和实际 Host 分支回归,Host 当前 80 个单测 + 7 个消息持久化集成回归。 +- [x] 新增 `AgentHost::prepare_run_with_messages_and_metadata`,后台 CLI 在尚未装配 + Provider/MCP/Skill 时也能持久化实际 provider/model/providerKind 观察信息;metadata + 要求 JSON object、16 KiB 上限并递归拒绝 secret 字段;claim 前对带 `providerKind` + marker 的新 metadata 强制核对模型,legacy provider-only metadata 保持兼容。CLI 当前 + 24 个测试,Host 当前 80 个单测 + 7 个消息持久化集成回归,OpenAI queued metadata 的 effective model 有回归覆盖。 +- [ ] 普通 Engine checkpoint 与所有工具/终态仍未共享同一事件输入;自动外部对账、真实 + Provider/Codex session、远端 CI/registry/许可证等原始 P0–P6 外部验收继续未完成。 + +## 2026-09-06 多工具 checkpoint 游标错配修复(当前) + +- [x] 修复 Host 回放多个 `ToolCompleted` 时重复读取最终 `safe` checkpoint 的问题:中间 + 工具结果继续使用 tool/runtime 联合事务,只有整个事件序列的最后一个工具结果才复用 + checkpoint/runtime/tool 三方事务,避免把包含后续结果的游标与不完整 Core snapshot 配对。 +- [x] 新增 `multi_tool_trace_only_reuses_safe_checkpoint_for_final_result` 回归;Host 当前 + 80/80 单测 + 7 个消息持久化集成回归,all/no-default workspace all-targets 测试、Clippy 和 fmt 通过。 + +## 2026-09-06 agent-app 配置与装配边界(当前) + +- [x] 新增无状态 `agent-app` crate,承接 `AgentTomlConfig`、Skill/MCP/Codex 配置结构、 + `AGENT_CONFIG`/`AGENT_DB` 加载、provider/model/streaming 环境优先级、OpenAI + endpoint/base URL 解析、MCP 认证环境变量引用转换以及非秘密 queued metadata。 +- [x] `agent-app` 不依赖 Host、Runtime、线程或数据库;CLI 仅导入其配置/装配 API,command + dispatch、worker 生命周期和 Host 业务逻辑保持原位,未引入平行 Runtime/Host。 +- [x] `agent-app` 自有 3 个配置回归,CLI 定向 24 个测试通过;workspace manifest、格式和 + 依赖方向检查需按新增 15 个 package 的当前门禁复跑。 +- [ ] 该项只完成通用程序配置边界;完整 Host 装配、自动外部对账、真实 Provider/Codex + session、远端 CI/registry/许可证仍属于原始 P0–P6 未完成范围。 + +## 2026-09-06 有界外部会话对账(当前) + +- [x] Host 新增 `ExternalSessionResolver` 与 `reconcile_external_sessions(limit, resolver)`; + 候选只取 `active`/`running`/`unknown`/`cancel_requested`,limit 受 + `MAX_EXTERNAL_SESSION_SCAN_LIMIT` 硬上限约束,按 SQLite 稳定顺序处理。 +- [x] resolver 必须显式返回已核验的完整 `Message` 历史;Completed 通过现有 CAS 对账入口推进 + safe checkpoint,Pending/NotFound 只写保守 `unknown` 生命周期;单条错误不会中断同批 + 其它候选,Completed 不自动 requeue、重放或启动 Engine。Host 80/80 单测 + 7 个消息持久化集成回归通过。 +- [ ] 这不是具体 Provider/Codex 的自动查询或 webhook 订阅;真实 resolver、消息转换、远端 + 身份核验和跨进程调度仍由宿主/适配器提供,原始 P0–P6 外部验收继续未完成。 + +## 2026-09-06 agent-app + 外部对账合并门禁(当前) + +- [x] 新增 `agent-app` 后 workspace 为 15 个 crate;all/no-default workspace all-targets + 测试通过,`agent-app` 3、CLI 24、Host 80 单测 + 7 个消息持久化集成回归(含有界外部 resolver 回归)。Clippy + `-D warnings`、Rustdoc、fmt、manifest/依赖边界、独立复制、编码和 diff 检查均通过。 +- [x] 当前 `rust/Cargo.lock` SHA-256 为 + `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`;新增 app 只引入 + workspace 内本地 package,运行时依赖版本仍按 `--locked` 复用。 +- [x] 当前 `rust/Cargo.lock` SHA-256 为 + `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`;固定 + `cargo-audit 0.22.2` + RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` + 已扫描当前锁文件 191 个依赖并以 0 退出、无漏洞/warning。远端持续审计仍需 runner 自行 + 提供固定输入和运行记录。 + +## 2026-09-06 审批 checkpoint 联合事务(当前) + +- [x] 新增中立 `DurableApprovalCheckpointRuntimeCommit`,由 SQLite adapter 在单个 + `IMMEDIATE` 事务中校验 live lease、run/session/call identity、`awaiting_approval` + checkpoint 和 runtime snapshot/history,再幂等写入 pending approval;Host Ask 路径已接入。 +- [x] 新增 Runtime facade 与 SQLite 回归:成功/同 identity 重试、错 lease、revision CAS + 冲突和 checkpoint identity 错配均不会留下 approval 半行;`agent-runtime-sqlite` 当前 + 31/31,Host 当前 80/80 单测 + 7 个消息持久化集成回归。 +- [ ] Engine 的 checkpoint callback 发生在 `ApprovalRequest` 生成 binding 之前,该时间窗 + 无法安全提前写入 token;因此本增量不宣称所有 Engine checkpoint、approval、工具 trace + 和终态的全链路单事务,真实外部与远端验收仍未完成。 + +- [x] Host 已将 approval 联合事务前移到 Engine 审计/trace 回放之前;首个 Ask 会从 checkpoint + 消息前缀一并物化缺失的 assistant/tool 消息,再写入 ToolRequested、checkpoint、runtime + 和 approval。trace 回放对 awaiting checkpoint 中已物化的 assistant/tool 消息做精确去重, + 避免恢复前缀重复;审批/多工具双特性 Host 80/80 单测 + 7 个消息持久化集成回归通过。 + +## 2026-09-06 本机 Codex app-server wire 探测(当前证据) + +- [x] 使用本机 `codex-cli 0.153.4`,在隔离 `CODEX_HOME` 且不提供 API key 的条件下,真实 + `codex app-server --stdio` 已完成 `initialize`、`initialized` 和 `thread/start`; + `initialize` 返回真实 user-agent,`thread/start` 返回带 string `thread.id` 的完整响应。 +- [ ] 该探测只证明当前机器上 app-server 的握手和线程创建 wire 可用;未执行 `turn/start`、 + Provider 请求、真实工具/审批 handler,也不能证明仓库固定的 `codex_0_152_1` typed + adapter 兼容 0.153.4。真实 generated schema/session 仍需版本化适配和授权环境。 + +- [x] 将该探测固化为可选手动脚本 `scripts/probe-codex-app-server.sh`:脚本校验期望版本, + 使用隔离 `CODEX_HOME`/临时目录,进程只执行 initialize→initialized→thread/start, + 输出中不包含原始 JSON、路径或凭据;本机 0.153.4 运行通过。脚本不纳入默认 CI,避免 + 把开发机上的 Codex 安装误当成仓库依赖。 +- [x] 脚本 `--schema` 模式在同一隔离环境生成本机 v2 schema 摘要:0.153.4、706653 bytes、 + SHA-256 `e5f798fd1343c539f01fedea0e8a84a43c080fcca4615c80eb04a5edab4f7d0a`;生成物已清理, + 只保留 hash 作为版本漂移证据。 diff --git a/rust/docs/【设计】RuntimeStore共享装配边界-2026-09-03.md b/rust/docs/【设计】RuntimeStore共享装配边界-2026-09-03.md new file mode 100644 index 000000000..5a3afd2f0 --- /dev/null +++ b/rust/docs/【设计】RuntimeStore共享装配边界-2026-09-03.md @@ -0,0 +1,150 @@ +# RuntimeStore 共享装配边界 + +## 目的 + +`agent-runtime-core::RuntimeStore` 仍是最小的 snapshot/event 接口: +`load` 只读,`commit` 以 `&mut self` 和 `expected_revision` 执行 CAS。这个合同 +不绑定 SQLite,也不负责线程或进程生命周期。 + +当一个宿主需要把同一份 Store 交给多个 Runtime-only facade 时,直接复制底层 +adapter 可能形成多份状态,直接把 facade 放进共享容器又会让每个调用方重复发明 +锁。为此 Core 提供 `SharedRuntimeStore`:它只用标准库 +`Arc>` 串行访问同一份 Store,并继续实现原有 `RuntimeStore` trait。 + +## 使用边界 + +```rust +use agent_runtime_core::{InMemoryRuntimeStore, RuntimeStore, SharedRuntimeStore}; + +let shared = SharedRuntimeStore::new(InMemoryRuntimeStore::new()); +let worker_store = shared.clone(); +let observer_store = shared.clone(); + +// 两个 facade 可以各自持有一个 clone,但底层 snapshot/revision 只有一份。 +let _ = RuntimeStore::load(&worker_store, "runtime-id"); +let _ = RuntimeStore::load(&observer_store, "runtime-id"); +``` + +`commit` 仍按底层 Store 的原子合同执行;共享 wrapper 只保证同一进程内的 +互斥访问。因此两个 clone 同时提交相同的旧 revision 时,一个提交成功,另一个 +仍得到 `StoreErrorKind::Conflict`,不会通过锁把 CAS 冲突隐藏掉。 + +适配器确有额外查询需求时,可以用 `with_store`/`with_store_mut` 在同一把锁内执行 +窄操作。锁中毒返回 `StoreErrorKind::Unavailable`,不会让控制面因 mutex panic +而误报持久化成功。 + +## 非目标 + +- 不改变 `RuntimeStore` trait 的方法或错误分类。 +- 不把完整的 run/session/lease/checkpoint 跨表事务抽象成泛型接口;这些仍由当前 + `agent-runtime::RuntimeService` 和 SQLite adapter 负责。 +- 不提供跨进程或跨主机锁。需要文件快照跨进程 CAS 的场景使用编排层明确提供的 + sidecar lock;`SharedRuntimeStore` 只解决同一进程内共享一个 adapter 实例。 +- 不让 Core 依赖 Tokio、SQLite、HTTP、MCP 或 Codex。 + +## 验收证据 + +- `agent-runtime-core` 单元测试新增 + `shared_runtime_store_serializes_concurrent_cas`:两个独立 clone 在同一旧 revision + 竞争时恰有一个成功、另一个返回 typed conflict,最终 revision 和事件数量保持一致。 +- `shared_runtime_store_maps_poisoned_lock_to_unavailable` 覆盖共享锁中毒: + `with_store` 以及公开 `RuntimeStore::load` 都返回 `StoreErrorKind::Unavailable`, + 不把 mutex panic 泄漏成控制面成功或二次 panic。 +- 当前 Core 定向测试为 28 个单元测试、17 个集成测试;该数字只描述本地回归, + 不代表完整 SQLite 跨表 Runtime 已经可替换。 +- all-features 与 no-default-features 下的 Core 测试、`-D warnings` Clippy 均通过。 + +## 2026-09-04 ContextItem 构造边界 + +`ContextItem::try_new` 会先调用 `Message::validate`,因此共享 Store 接缝之外的 +上下文值对象也不会接受兼容入口产生的非法消息;Core conformance 当前为 28 个单测 +加 17 个集成测试。 + +Core 的其它值对象边界也会复用构造校验:`ToolDefinition::validate()` 在 +`ToolRegistry::register` 和 `ProviderRequest::with_tools` 前拒绝 serde/兼容入口产生的 +非法定义;该行为属于 Core 合同,与本文件的 Store wrapper 解耦。 + +## 2026-09-03 feature gate 复核 + +`agent-runtime` 的 `sqlite` 是默认特性;关闭默认特性时只提供本文件所描述的 +runtime-only snapshot/event CAS facade、`RuntimeRunHandle` 和 `WorkerLease` 合同。 +完整 run/session/lease/checkpoint 的 durable `RuntimeService` 仍由可选的 +`agent-storage-sqlite` 提供,workspace 级 `--no-default-features` 可能因 Host 的 +正常依赖重新启用 SQLite。因此该 wrapper 不宣称已经完成最终可替换 RuntimeStore +装配,也不改变 Core 不依赖 SQLite 的边界。 + +`SharedRuntimeStore` 与 `agent-runtime::DynRuntimeStore` 解决的是不同装配问题:前者 +在 Core 中用 `Arc>` 让同一进程的多个 facade 共享并互斥访问一个具体 Store; +后者是 runtime crate 的本地 newtype,只为承载 `Box` 提供对象安全的 +动态注入,本身不加锁、不提供跨线程或跨进程同步。两者都只覆盖 runtime-only +snapshot/event CAS,不把 SQLite durable run/session/lease/checkpoint 事务变成泛型接口。 + +## 2026-09-04 RuntimeService 动态入口 + +`RuntimeService::snapshot_store()` 现在返回 `RuntimeSnapshotService`, +并把同一 SQLite 状态以 Core 的 `RuntimeStore` 合同暴露给 runtime-only 调用方。该入口 +只补齐动态注入接缝;跨表的 run/session/lease/checkpoint 操作仍必须经 +`RuntimeService` 的专用 facade。默认 feature 测试 28/28、package-only no-default +测试 6/6 已通过。 + +## 2026-09-05 Core 合同复核(当前) + +Store wrapper 之外,Core 的 `ToolContext`、扩展定义和 backend request/result 也有公开 +serde/兼容入口校验;这保证 runtime-only CAS 注入不会成为绕过工具身份或未知副作用分类的 +旁路。`backend_result_as_tool` 会先校验 request/call 身份并拒绝 `side_effect_unknown`。 +Runtime/Storage/Host 的外部 session 候选查询仍是只读、有界观察,不自动修改 snapshot 或 +执行 reconciliation。Core 当前为 30 个单测、21 个集成测试,相关 workspace 双 feature +门禁和 Clippy 已通过。 + +## 2026-09-05 DurableStore 第一阶段(当前) + +`agent-runtime` 现在提供中立 `DurableStore` trait、拥有值的 `DurableRunBundle` 命令、 +最小 run/session/lease/checkpoint/approval/external-session view;`SqliteDurableStore` 负责把它们 +转换到现有 SQLite adapter。 +`RuntimeService::prepare_run*` 与 runtime snapshot load/CAS 已使用该接缝,并保留 +`SqliteDurableStoreError` 的 Storage/Runtime typed 分类。 + +本阶段已将取消、终态和跨表 recovery 的原子 command 形状纳入合同;这些操作仍由 +SQLite adapter 在单事务内完成,Runtime facade 不把多次 trait 调用拼成假事务。下一步 +仍需反转 Runtime/SQLite 依赖并收敛历史兼容入口。 + +## 2026-09-05 DurableStore command 扩展(当前) + +上一阶段的“未抽象”边界已有小幅推进:`DurableStore` 现提供 request-cancel/stale、 +`finish_run_with_runtime` 与 `recover_expired_run(_with_runtime)` 原子 command,覆盖 +runtime-aware completed/failed/cancelled 和 queued-unclaimed guard;checkpoint、approval、 +external-session 和 run/session 查询也统一由同一适配器承接。Runtime contract 回归当前 +为 33 个测试。 + +这不等于已经完成可替换 Runtime。SQLite 仍在 adapter 内执行跨表事务,RuntimeService 仍保留 +少数兼容直连入口;后续仍需在不破坏公开 API 的前提下完成 trait 反转、迁移合同和最终 +Host/Runtime 职责收敛。 + +## 2026-09-05 通用 DurableRuntime facade(当前) + +`DurableRuntime` 现在承接完整 durable command/query 的拥有式转发,可由任意 +adapter 构造、借用和取回;它不复制连接、不引入 SQLite,也不重写事务。SQLite-backed +`RuntimeService::durable_runtime()` 只是便利入口,后续替换持久化实现时上层控制面无需重写。 + +## 2026-09-05 DurableStore 控制面查询收敛(当前) + +`RuntimeService` 现在持有 `SqliteDurableStore`,不再单独保存第二个 `SqliteStore` 字段;旧 +`store()`、导出和审计入口通过适配器提供兼容视图。cancel marker、runtime identity、session +update、safe requeue 以及旧 cancelled 终态入口均通过 DurableStore 路由,SQLite 具体实现只 +保留在 adapter 和明确的诊断/审计接口内。完整可替换持久化仍需后续 trait 反转与迁移合同。 + +## 2026-09-06 DurableRuntime SQLite wrapper 隔离(当前) + +`agent-runtime/src/durable.rs` 现将 `SqliteDurableStore` 及其记录转换函数移到同目录的 +`durable_sqlite.rs`(由 `sqlite` feature 保护的 `sqlite_adapter` 模块);通用 +`DurableRuntime` 文件只依赖 `agent-runtime-contracts` 与 Core。这样 package-only 的 +`cargo test -p agent-runtime --no-default-features` 会直接编译并运行通用 facade,而不会 +加载 `agent-storage-sqlite`。默认 SQLite API 和旧 re-export 暂时保持不变,便于 Host/CLI +渐进迁移;这一步是边界隔离,不宣称已经完成最终 Runtime/SQLite crate 拆分。 + +## 2026-09-06 Runtime/SQLite 物理拆分完成(当前) + +上一节记录的渐进 wrapper 已完成 breaking migration:`agent-runtime` 现为无 SQLite +feature 的 portable facade;`agent-runtime-sqlite` 承接 `RuntimeService`、SQLite records/error、 +`SqliteDurableStore` 和跨表事务。Host 已统一依赖新 crate,`agent_runtime::RuntimeService` +不再作为兼容入口存在。workspace all/no-default 与依赖树门禁已验证该边界。 diff --git a/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md b/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md new file mode 100644 index 000000000..19e4bb7ac --- /dev/null +++ b/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md @@ -0,0 +1,990 @@ +# Agent 内核与通用程序验收记录 + +> 本记录截至 2026-09-06,覆盖原始 P0–P6 在当前独立 workspace 内可实现和验证的交付出口。真实外部发行版/凭据、远端 runner 和部署级自动查询另列为外部证据,不用本地 fixture 冒充。原始计划和逐项状态见 [`【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md`](./【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md);依赖边界和独立复制验收见 [`【审计】独立Agent依赖边界与CI验收-2026-09-02.md`](./【审计】独立Agent依赖边界与CI验收-2026-09-02.md)。下方按日期追加的旧计数是历史快照,当前以功能表和文末最新复核记录为准。 +> 除功能验收表和文末最新“当前”复核记录外,所有按日期追加的段落均为历史快照;其中“最新/最终”只表示当时阶段,不代表当前计数。当前 Host/CLI 计数以文末 2026-09-06 段落为准。 + +## 本轮消息一致性验收(2026-09-06,当前口径) + +- 默认工具闭环此前 Engine 输出 4 条消息、Runtime 保存 5 条;旧检查只验证最终状态与包含性,未证明消息恰好一次。 +- Host 已改为按本次观察事件的位置衔接 checkpoint 与 trace;成功联合提交才标记消息已投影,压缩前完成旧上下文的工具结果投影。 +- Host 80 个单测 + 7 个 `tests/message_persistence.rs` 集成回归通过,覆盖同步/流式、自动三工具及后续轮次、Ask/Allow 恢复、Provider 错误、连续两次工具后压缩和压缩失败。 +- 回归逐条核对 Engine/Runtime 消息、派生工具索引、工具表结果及从零事件重放;Fake CLI smoke 2/2 另开只读 SQLite 连接核验完整消息。 +- workspace all-features(含 doctest)、no-default all-targets、两套 Clippy `-D warnings`、Rustdoc、fmt、依赖边界及 15 package manifest 门禁通过。未改公开 API、数据库 schema 或 Cargo.lock。 + +原始范围已纠正:registry、公开许可证、全量 schema、自动 webhook 和跨主机自动调度不作为本期阻塞项。 +总计划仍须按权威计划现状表逐项验收所声明的协议行为与恢复路径;下方历史计数/“未完成”扩张描述不覆盖本节。 + +## 结论 + +截至 2026-09-06,独立 `rust/` workspace 的当前增量闭环通过本地验收: +Core、Engine、durable Runtime、Prompt/Context、Skill、MCP、Provider、SQLite、 +Host、Codex adapter、DAG orchestration 和 CLI 均可编译,单 Agent 工具循环可以 +离线运行,外部 backend、扩展接线和恢复边界有自动回归测试。 + +这里的“通过”指原始计划在当前 workspace 约定的本地验收合同;不代表完整 Codex +发行版兼容或生产级多 Agent 调度,这两项本来就需要外部/后续部署语义。 + +## 功能验收 + +> 当前计数(2026-09-06)以文末最新复核为准:Core 30 单测 + 20 个 +> `core_contracts` 集成 + 1 个 `tool_context_contracts` 集成,Engine 55、Host 80 单测 + 7 集成、 +> Runtime portable 8 + Runtime SQLite 31、Storage 7+53(直接 no-default 6+36)、 +> Codex 101、MCP 52、Skill 28、OpenAI 29、CLI 24、Fake 4、Orchestration 40。 + +本轮新增 `agent-runtime-contracts` crate,承接数据库无关的 DurableStore command/view/trait; +SQLite-specific `RuntimeService` 已迁移至独立的 `agent-runtime-sqlite`,`agent-runtime` +本身不再依赖 SQLite。 + +| 能力 | 当前结果 | 证据 | +| --- | --- | --- | +| Prompt | system/developer/user/context 保持独立 section 和稳定顺序;`PromptSection::try_new`、`PromptBuilder::build`/`into_messages`、`ProviderRequest::try_new` 和 `ProviderResponse::try_new` 都会对 serde/兼容入口产生的嵌套 `Message`/`ContentPart`/工具调用重新执行构造校验,空文本或非法结构在进入 Provider 前 fail-closed | `agent-runtime-core/src/prompt.rs`:`section_constructor_rejects_invalid_deserialized_content`、`deserialized_invalid_content_is_rejected_before_provider_request`;`provider.rs`:`provider_constructors_reject_invalid_nested_messages_parts_and_calls`;`message.rs` 的 `ContentPart::validate`/`Message::try_new`;OpenAI 请求映射单测 | +| Context | 可插拔 `ContextSource`;按优先级和注册顺序裁剪;UTF-8 字节预算;返回 selected/skipped/overflow 统计,并为每个 Provider step 输出不含正文的 `ContextObservation`(含压缩前后大小及 selected/skipped 的 trusted/untrusted 四类计数) | `agent-runtime-engine` 上下文组装/压缩、信任计数和旧 JSON 兼容单测;Host 身份透传单测 | +| Skill | `SKILL.md` 有界 frontmatter;发现阶段只读元数据;显式激活才读正文;路径、UTF-8、大小、指纹和空正文校验;逻辑字段重复(含 `allowed-tools`/`allowed_tools` 别名)直接拒绝;未闭合引号/列表括号、空列表项和空工具名直接拒绝;发现/list 阶段和激活正文均采用有界读取,文件检查后增长也不能绕过硬上限;不自动授予工具 | `agent-skills` 28 个单测(含 `discovery_and_list_reject正文超过配置上限`);Host SkillSource 端到端单测 | +| MCP | 同步 stdio、Streamable HTTP JSON/SSE、initialize、tools/list/resources/prompts 分页、tools/call、命名空间、认证引用和 allow list 桥接;initialize 支持调用方配置的有界协议版本候选,候选失败时仅对配置型连接重建 transport 并重试,成功后校验服务端版本并更新 HTTP 协议头;显式通知轮询、有界重连调度/审计和调用前权限审计;Host MCP executor 在发送前及取得 client 锁后再次检查取消,已取消调用不会发送 `tools/call`;完成握手的 stdio client 与 Streamable HTTP client 均可转成独占的有界后台通知订阅(HTTP 由私有 Tokio worker 增量读取 SSE,支持 cooperative cancel、Drop/join 回收和错误传播);认证引用在高层连接及两个直接 transport 构造器一致生效;自定义 transport 仍无后台订阅;transport 对单条 stdio 消息(1 MiB)、HTTP 响应(4 MiB)、SSE 单行(1 MiB)和单事件 data(4 MiB)设置硬上限;同步 stdio client 暂存 pending 消息也受 `MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限约束,超限返回协议错误;reader 终止错误在满队列时也不阻塞;stdio、HTTP JSON/SSE 和后台 SSE 共用 JSON-RPC envelope 校验;订阅不自动应答、重连、重放或路由;已经进入同步 I/O 后的硬取消仍由具体 adapter 负责 | `agent-mcp` 52 个单测(含候选版本重建、stdio/HTTP 重建和 HTTP 头回归);Host `cancelled_mcp_tool_context_blocks_transport_before_tools_call` 及真实调用桥接单测 | +| Function call / Tool call | Core 结构化 `ToolCall`/`ToolResult`;OpenAI Responses `function_call` / `function_call_output` 映射;Engine 在 Provider response 边界只接受 Text/Image content,结构化 tool call/result 不得混入 content;压缩器不得返回 `Tool` 角色或结构化工具历史,`ProviderContextCompressor` 遇到摘要响应中的结构化块会拒绝而不静默过滤;仅实现 `complete` 的 Provider 也会由默认 `stream` 保留 tool-call delta 和 usage;`ToolDefinition::validate` 在 Registry 注册和 `ProviderRequest::with_tools` 边界复核 serde 工具定义,批次重复 ID 和工具结果 ID 前置校验;公开工具参数预检会复验调用/定义合同并检查工具名 | Core 30 个单测 + 21 个集成测试;Engine 55 个单测;OpenAI 29 个单测;`provider_constructors_reject_invalid_nested_messages_parts_and_calls`、`serde_invalid_tool_definition_is_rejected_by_request_and_registry`、`compressor_cannot_inject_tool_history`、`compressor_rejects_structured_content_in_summary_response`、`default_stream_preserves_complete_tool_calls_and_usage`、`public_tool_argument_validator_rechecks_core_contracts_and_name` | +| OpenAI endpoint 配置 | 默认官方地址;`OPENAI_ENDPOINT` 完整地址;`OPENAI_BASE_URL` 自动补 `/responses`;配置对象、手动构造、已创建 Provider 的校验先行 setter 和 CLI/TOML 接线;Responses adapter 仅将 408/429/502/503/504 归为 `Unavailable`,其它非 2xx 保持 `Upstream`,不读取 `Retry-After` 或自动 backoff | OpenAI 29 个单测(含本地 HTTP endpoint/请求头/Responses 与 SSE 映射、状态分类和截断正文);CLI endpoint 配置单测 | +| Provider 实例/协议路由 | Core 使用强类型 `ProviderInstanceId` 与 `ProviderProtocolId` 描述实例和协议;可自描述的 `ModelProvider` adapter 在 Registry 注册/解析时与 `ProviderDescriptor` 核对协议,`ProviderTarget` 选择实例并在执行前校验;旧 `id`/`providerId` 输入别名保持可读,既有 `with_provider` 直接注入路径保持可用 | Core `registry_uses_adapter_protocol_identity_when_available`、`registry_derives_protocol_from_self_describing_adapter`;Host `host_selects_registered_provider_by_instance_and_protocol`、`host_rejects_protocol_mismatch_before_creating_selection_or_calling_provider`、`direct_provider_injection_remains_usable_after_registry_selection`、`host_uses_an_injected_runtime_facade_without_reopening_storage`;Host 当前 80 个单测 + 7 个消息持久化集成回归 | +| Callback | Core `EventSink` / `ProviderStreamSink`、Engine `EventListener` / `StreamEventListener` / `CheckpointListener` 可注入;Host 另提供 `DurableEventListener`/闭包入口,在每条 run-level 审计事件的 SQLite append 事务提交后通知;checkpoint 写入失败会停止继续执行 | Core/Engine/Host 回归测试;Host durable callback 回归测试 | +| Durable Runtime | queued bundle、lease/heartbeat/release、checkpoint、取消、过期 reconciliation、safe requeue 作为独立 `agent-runtime-sqlite` API;新建身份使用 UUIDv7;过期 recovery 在同一 IMMEDIATE 事务中同步 run/runtime/session/lease,非法 runtime 批次会整体回滚;Host 启动边界会探测失效 lease;Runtime 还提供固定排序、硬上限的一次性 stale-run 扫描,逐项重新检查并原子恢复,不重放外部调用;Host 的 run/lease/checkpoint/reconciliation 控制动作、RuntimeSnapshot 只读观察、Runtime-only CAS 和外部观察入口均经 `agent-runtime-sqlite::RuntimeService` facade;另有可注入 `RuntimeSnapshotService` 做 runtime-only CAS,且 `StoreProtocol` 保留 Core `StoreErrorKind`;`Core SharedRuntimeStore` 可让同一进程内多个 facade 共享一个 Store,`agent-runtime::DynRuntimeStore` 还可承载 `Box` 做动态 runtime-only 注入;`agent-runtime-sqlite::RuntimeService::snapshot_store()` 可把同一 SQLite 状态以该窄合同暴露给调用方;SQLite load/CAS/recovery verification 会复核 `RuntimeSnapshot::validate`、数据库 revision/identity 及完整 `runtime_events` 历史连续性,篡改或断档快照不会继续写入;文件数据库 reopen 回归覆盖过期 provider checkpoint 的持久化恢复;`requeue_reconciled_run` 对已 queued+safe 的重复 resume 保持时间戳幂等;`DurableStore` 还提供 run/session 查询、checkpoint fencing、approval pending-only CAS、external-session 候选、request-cancel/stale 及 runtime-aware finish/recovery command,并由 SQLite 适配器保持跨表单事务原子性;工具调用请求/结果与 Core runtime snapshot/events 另由 `DurableToolCallRuntimeCommit` 在单个 IMMEDIATE 事务中提交,支持 lease fencing、幂等和旧 row 回填;当调用方同时拥有 checkpoint 时,`DurableToolCallCheckpointRuntimeCommit` 还会在单个 IMMEDIATE 事务中合并工具行、checkpoint 与 runtime CAS;Host 首次 `awaiting_approval` checkpoint 也与 `ToolRequested`、工具行和 Core runtime event 共享该调用边界,普通 checkpoint/终态仍未全部共享;工具调用由 Runtime/DurableStore 按 run 持久化并纳入 JSONL 导出;`cfg(test)` 的 `InMemoryDurableStore` harness 另验证非 SQLite generic facade 的 bundle/lease/CAS/finish/recovery | portable Runtime 8 个 + SQLite Runtime 28 个单测;Storage workspace 60 个测试(7 个单测 + 53 个集成测试,含工具事务 lease/CAS/回填回归、`runtime_store_rejects_tampered_snapshot_on_load_and_commit`、`runtime_store_rejects_event_history_gap_on_load_and_commit`),直接关闭 storage `core-adapter` 为 42 个测试(6 个单测 + 36 个集成测试) | +| 外部 backend / Codex | Core `ExternalBackend`、只读 `ExternalObservationSource`、受限 Codex CLI、可注入 App Server channel、带 `protocolVersion` 的有界 JSONL fixture,以及独立的窄 V2 JSON-RPC/JSONL 客户端(initialize/initialized、thread/start、turn/start 接受、通知轮询、精确 thread/turn interrupt);另有通用 `JsonRpcAppServerRouter` transport 接缝及接入 `ProcessControl` 的 `CodexAppServerProcessRouter`,提供有界 pending map、乱序 response 分发、并发 `turn/interrupt` 和真实 stdio child 收束;`CodexAppServerProcess` 将窄客户端接到显式 `program + args` 的真实 stdin/stdout JSONL,并由有界 supervisor 负责 deadline、取消、process-group 终止、EOF/异常退出 wait/reap 与 reader/writer join;Host 工具桥、外部会话记录;子进程已启动后的非法输出按未知副作用进入 reconciliation;请求已发出后的协议/响应契约错误也按未知副作用映射,只有 dispatch 前配置错误保留 InvalidInput;JSON-RPC 混合 request/response envelope 会在 handler 前拒绝,`CodexServerRequestKind` 提供审批、工具、用户输入、elicitation/未知的中立分类,错误分类方法按 dispatch 边界区分 `InvalidInput`、`Unavailable` 与 `UnknownSideEffect`;窄客户端和通用 channel 都提供可选的中立 server-request handler;CLI 参数白名单会规范化拒绝常见凭据参数;另有只接受已核对 `codex-cli 0.152.1` 的版本化 typed adapter、provenance/hash 清单和审批/工具/通知子集;Host 另提供 dynamic-tool typed bridge(`tool`/`callId` 到 `contentItems`/`success`)并复用 schema/approval/router 前置校验;Host 提供显式 `NamespaceToolResolver`;`invoke_node_with_runtime_events` 与 process backend 的 `invoke_with_runtime_events` 可将 request/notification/result 显式桥接到 Core reducer sink;重开 Host 后可按 durable request-id 显式取消外部会话,不重新 invoke;Codex session metadata sink 可将 thread/turn 身份按 run 持久化到 external_sessions;`from_host` 创建的中立/0.152.1 typed handler 会把 tool call 写入同一 run 的 durable `tool_calls`,重复 terminal call_id 返回缓存;ProcessControl 提供 emit-once 的自然退出/显式终止生命周期 sink、exit code 和 session sink 转发 | Codex 101 个单测;Host 80 个单测 + 7 个消息持久化集成回归;Core 集成测试覆盖只读观察端口;本地 `sh` fixture 覆盖握手、非法输出/退出、正常 EOF 回收、跨线程取消、超时、process event bridge、metadata/lifecycle sink、process lifecycle sink(含 `app_server_process_lifecycle_sink_reports_timeout`、`app_server_process_lifecycle_sink_distinguishes_reader_eof`)和 Host handler durable tool-call 回归 | +| DAG 编排 | DAG/环校验、Delegation/Join/Graph Proposal、状态解锁、依赖阻塞、同波次并发、显式下游修复;Coordinator 提供全局/按 agent 配额、消息去重、节点隔离、受影响下游释放,以及带版本校验的显式 JSON 快照导入/导出和可选 `CoordinatorStore`(内存/原子 JSON 文件);`PersistentCoordinator` + `OrchestrationSnapshotStore` 进一步把 `TaskGraph` 与 Coordinator 控制面作为同一版本化快照,以 revision CAS 原子保存和恢复,并校验活动 run 与图节点状态一致;隔离活动节点后会持久化为可恢复的 `Waiting`,避免 reload 留下无 active run 的 `Running`;快照导入同时拒绝无 active run 的 `Running` 节点;新增宿主驱动的 ready 计划稳定排序、隔离/配额筛选和 revision-CAS durable claim、`cancel_run` 原子取消;公开合同反序列化复用构造校验,文件读取有硬字节上限;仅 `JsonFileOrchestrationSnapshotStore` 以同目录 sidecar advisory lock 保护遵守该适配器的本机跨进程写入,旧 `JsonFileCoordinatorStore` 仍只有进程内锁 | Orchestration 40 个单测 | +| 通用程序 | `run`、`run --background`、`worker`、`cancel`、`approval list/get/allow/deny/resume`、`resume-safe`、`reconcile`、`reconcile --stale [limit]`、`inspect`、`export`、`doctor`、`skills list`、`mcp list`、`codex validate` 可用;默认 Fake Provider 离线闭环;`run --jsonl` 输出稳定 NDJSON(前台包含 `engine_event`/`stream_event`/`result`,后台只输出 `queued`);`doctor` 分项检查 SQLite、Provider、Skill、MCP 与 Codex 且不连接外部服务;审批 list/get/allow/deny 展示不回显 binding token;后台 worker 启动失败会通过 unclaimed-run 原子失败收口 | CLI 24 个单测及本地 smoke | + +> Runtime 物理边界补充:portable `agent-runtime` 仅包含 generic facade/value objects;SQLite +> `RuntimeService`、records/error 和跨表事务位于 `agent-runtime-sqlite`,Host 已迁移到该 crate。 + +> 计数校准:上方功能表中早于审批联合事务的 28/77 等数字属于当时快照;当前权威计数以 +> 文末 2026-09-06「当前最终事实」段为准(Runtime SQLite 31、Host 80 单测 + 7 集成、workspace 15)。 + +> 额外边界:Codex dynamic-tool 的显式 namespace 只有在调用方注册 +> `(namespace, tool) -> registered_tool` 映射并命中后才路由;默认空 resolver 对显式 +> namespace fail-closed,缺省或 JSON `null` namespace 才按全局工具名处理。 +| 可重复测试集 | `tests/agent-test-set.jsonl` + `scripts/run-agent-test-set.sh`;Fake 工具/流式闭环默认离线执行,真实 Provider smoke 需显式 `--real` | 本地测试集 2 个 Fake 用例通过;真实用例默认跳过,避免无授权网络请求 | + +Skill 和 MCP 的具体文件/传输实现位于 adapter/Host 层,Core 保留它们所需的中立 +生命周期、能力与上下文端口;因此不会把 SKILL.md 格式、JSON-RPC 或具体网络客户端 +耦合进内核。 + +## 验证命令 + +以下命令在 `/data/dsk/Genarrative-master` 执行并作为本地验收门禁(最终代码变更后应重新运行): + +```text +TMPDIR="$HOME/data/tmp" cargo test --locked --manifest-path rust/Cargo.toml --workspace --all-features --no-fail-fast +TMPDIR="$HOME/data/tmp" cargo test --locked --manifest-path rust/Cargo.toml --workspace --no-default-features --no-fail-fast +TMPDIR="$HOME/data/tmp" cargo test --locked --manifest-path rust/Cargo.toml -p agent-storage-sqlite --no-default-features --no-fail-fast +RUSTFLAGS='-D warnings' TMPDIR="$HOME/data/tmp" cargo check --locked --manifest-path rust/Cargo.toml --workspace --all-targets +RUSTFLAGS='-D warnings' TMPDIR="$HOME/data/tmp" cargo check --locked --manifest-path rust/Cargo.toml --workspace --all-targets --all-features +RUSTFLAGS='-D warnings' TMPDIR="$HOME/data/tmp" cargo check --locked --manifest-path rust/Cargo.toml --workspace --all-targets --no-default-features +RUSTFLAGS='-D warnings' TMPDIR="$HOME/data/tmp" cargo clippy --locked --manifest-path rust/Cargo.toml --workspace --all-targets --all-features -- -D warnings +RUSTFLAGS='-D warnings' TMPDIR="$HOME/data/tmp" cargo clippy --locked --manifest-path rust/Cargo.toml --workspace --all-targets --no-default-features -- -D warnings +RUSTDOCFLAGS='-D warnings' TMPDIR="$HOME/data/tmp" cargo doc --locked --manifest-path rust/Cargo.toml --workspace --all-features --no-deps +cargo fmt --manifest-path rust/Cargo.toml --all -- --check +git diff --check +npm run check:encoding +TMPDIR="$HOME/data/tmp" rust/scripts/check-dependencies.sh rust/Cargo.toml +TMPDIR="$HOME/data/tmp" rust/scripts/check-package-manifests.sh rust/Cargo.toml +AGENT_RUNTIME_VERIFY_TMPDIR="$HOME/data/tmp" ./rust/scripts/verify-independent-workspace.sh +``` + +当前最近一次 workspace 回归计数:Host 80 单测 + 7 集成、Engine 55、MCP 52、Skill 28、 + OpenAI 29、Fake 4、CLI 24、Runtime portable 8 + Runtime SQLite 31、Codex 101、Orchestration 40、Core 单测 30 + 加集成测试 21、Storage workspace 60(7 个单测 + 53 个集成测试;`agent-runtime` +传递启用 `core-adapter`),直接 `agent-storage-sqlite --no-default-features` 为 42 +(6 个单测 + 36 个集成测试)。 + +独立 workspace 使用 `rust/rust-toolchain.toml` 固定的 Rust 1.96,并声明 +`rustfmt`/`clippy`;本机已补齐 Clippy 组件并以默认工具链完成 all-features 与 +no-default-features 的 `cargo clippy -D warnings`。远端 runner 需预装同一组件。 + +CLI smoke 还验证了 `doctor`、离线 foreground run、独立 session 中的 background +成功,以及 Skill/MCP 配置初始化失败时将 queued run 原子收束为 failed。当前 smoke +约定使用 `~/data/tmp` 下的隔离临时数据库(显式 `TMPDIR` 或 `AGENT_TEST_TMPDIR` +可覆盖),后台 run 已观察到 `completed`,临时数据库和 `.tmp-test` 目录已清理。 +独立测试集执行器 `scripts/run-agent-test-set.sh` 还会检查 Fake 工具循环、Fake 流式 +循环、`inspect` 的 durable completed 状态和 JSONL 导出;默认在 `~/data/tmp` 下创建并 +只清理本轮临时目录,显式设置 `TMPDIR` 或 `AGENT_TEST_TMPDIR` 时尊重调用方的临时父目录。接入 +自己的 Responses Provider 时可追加 `--quick --real`,具体 +环境变量和网关要求见 [`【测试】Agent测试集与真实Provider接入-2026-09-02.md`](./【测试】Agent测试集与真实Provider接入-2026-09-02.md)。 +本轮额外用本地假 HTTP Responses 服务验证 CLI 的 `OPENAI_BASE_URL` 自动拼接为 +`/v1/responses`,并核对请求中的 `model` 值;没有使用真实 API key。 + +## 已知边界 + +- `ApprovalDecision::Ask` 已持久化为 pending approval;Host/CLI 提供 pending-only + allow/deny CAS 和显式 resume。它不会自动替用户决议;取消会把 pending 记录收束为 + cancelled,迟到决议被拒绝。 +- `provider_in_flight` / `tool_in_flight` 只支持调用方完成外部核对后显式写入 + `safe`;Core 的 `ExternalObservationSource` 仅提供有界、只读、不透明的查询结果 + 端口,不推进 checkpoint 或自动转成消息;仍没有自动查询编排、webhook 订阅或未知 + 调用自动重放。 +- 外部 Codex 子进程一旦启动,正常退出但输出无法解析、超限、被中断或退出码异常, + 都不能证明副作用不存在;这些路径统一保留为未知副作用,不生成 safe 结果。真实 + App Server process adapter 只接受显式 `program + args`,在 timeout/cancel/EOF/Drop + 时终止 process group、wait/reap 并 join reader/writer;它不自动重连或重放已经发出的 + 请求。 +- MCP 默认仍是同步 request transport;调用方可对完成握手的 stdio/Streamable HTTP + client 显式调用 `into_notification_subscription` 建立独占、有界、可取消的后台通知 + 订阅,或继续使用 `poll_notification`。HTTP 订阅由私有 Tokio worker 增量读取 GET/SSE; + 自定义 transport 仍无长连接订阅。 + 已提供有界、可取消、可审计的重连调度;每次尝试的 + `attempt_timeout` 会传入握手请求和 initialized 通知;HTTP transport 会用它限制响应等待,stdio 通知仍是不可抢占的同步写入。它只重做连接/握手, + 不能强杀阻塞的同步 I/O,也不缓存或自动重放 `tools/call`。传输层拒绝超过 + 1 MiB 的 stdio 消息、4 MiB 的 HTTP 响应、1 MiB 的 SSE 单行或 4 MiB 的 SSE + 单事件;分页还受每类 1024 页上限约束。 +- OpenAI adapter 已完成离线请求/响应映射和脱敏测试;本轮未用真实 API key 做网络 + 请求,因此真实上游可用性不计入本地验收。 +- Engine 已提供可配置的 Provider 软超时边界和显式工具幂等重试策略;同步 Provider + 无法被通用 crate 强杀,硬 I/O timeout 仍由具体 adapter 负责。 +- 独立 workspace 的 `rust-toolchain.toml` 固定 Rust 1.96 并声明 `rustfmt` 与 + `clippy`;本机默认 `cargo clippy --locked --workspace --all-targets` 的 + all-features 与 no-default-features 检查均以 `-D warnings` 通过。远端 runner + 需要预装同一组件,CI 不隐式联网安装。 +- Codex CLI 的本地 supervisor 已覆盖有界 child 生命周期、超时、取消、输出上限和 + process-group 终止;App Server 有不绑定发行版的中立 JSON-RPC/JSONL channel,另有 + 窄 V2 客户端和真实 stdio `CodexAppServerProcess`,可完成 initialize/initialized、 + thread/start、turn/start 接受、显式通知轮询和带 `threadId`/`turnId` 的 interrupt。 + V2 只是当前请求 DTO 形状,不是具体 + Codex 发行版兼容承诺;完整 generated schema、发行版启动参数、服务端审批/工具 + 请求处理和上层会话适配仍未完成。DAG Coordinator 的配额、消息去重和节点隔离/修复 + 可与任务图一起显式保存为内存或 JSON 文件 revision-CAS 快照;其中 + `JsonFileOrchestrationSnapshotStore` 通过同目录 sidecar advisory lock 保护跨进程写入, + 旧的 `JsonFileCoordinatorStore` 仍只保证进程内串行和原子替换。两者都不接 SQLite、 + 不调度线程,也不是完整多 Agent Teams。 +- 原计划要求的 Provider/工具自动幂等查询/webhook、独立仓库远端 CI 和许可证数据库 + 审计尚未完成;历史环境中 `cargo-audit 0.22.2` 配合固定 RustSec 快照的本地漏洞扫描曾通过, + `cargo-deny 0.20.2` 的离线许可证清单也已生成,但由于 BSL/LGPL/Unlicense 及内部 + `UNLICENSED` crate 的发布策略尚未确认,没有把临时 allowlist 写入仓库。新增的独立 + 复制脚本、依赖边界脚本和本地 CI 文件不能替代远端运行记录。Host 的 + 历史兼容性局部后缀也尚未统一迁移。 +- `AgentBuilder` 和 `AgentService` 当前是 `AgentHost` 的稳定类型别名, + `AgentHost::new`/`builder` 提供轻量装配入口;它们不是第二套 Runtime 或独立 + 生命周期对象。Host 的 run 查询、lease 控制、stale 探测和带 lease 的 + checkpoint 读写已经经由 `RuntimeService` facade;Host 内部不再重复持有 + `SqliteStore`,公开 `store()` 仅是旧 Engine 审计/导出调用的兼容 accessor, + Runtime 仍是 SQLite-backed 具体实现,最终可替换存储装配尚未收口。 +- Host 的 `DurableEventListener` 只确认单条 run-level `events` 行已经提交, + 不代表 runtime trace、approval 或终态事务也已提交;回调不参与回滚,消费者应按 + `(run_id, revision)` 去重。Engine 的普通 Event/Stream listener 仍是提交前的 + 观察接口。 + +相关文档:[README](../README.md)、[测试集与真实 Provider 接入](./【测试】Agent测试集与真实Provider接入-2026-09-02.md)、[架构说明](./【架构】独立Agent运行时-2026-09-01.md)、 +[原始计划](./【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md)、 +[依赖边界与 CI 审计](./【审计】独立Agent依赖边界与CI验收-2026-09-02.md)。 + +## 2026-09-03 复核补充 + +- workspace 内部 path 依赖已补充 `0.1.0` 版本要求;13 个 crate 的 + `cargo package --list` manifest 预检通过。完整 package/publish 仍依赖目标 + registry 中按顺序存在内部 crate,未作为本地验收结论。 +- `scripts/check-package-manifests.sh` 以离线模式核验 13 个 crate 的 description、 + `rust-version = 1.96`、内部 path 依赖版本和待发布文件边界;它不联网、不上传 + crate,也不替代 registry 发布验证。 +- CI 样例现在同时覆盖 all-features 与 no-default-features 的 check、test 和 Clippy; + 本地最终计数与上一节一致,原始 P0–P6 仍保持“部分完成”。 + +## 2026-09-03 Runtime/Host 与 Codex 版本化适配复核 + +- `RuntimeService::observe_external` 和 `AgentHost::observe_external` 只读转发 + `ExternalObservationSource`;成功观察不会改变 RuntimeSnapshot/checkpoint,外部 + timeout/error 分类原样返回。approval、external session、run-level audit event + 的常用控制面也经 Runtime 窄 facade,跨表终态事务仍由 SQLite adapter 负责。 +- `agent-codex` 的 `codex_0_152_1` 模块依据本机 `codex-cli 0.152.1` 的生成 schema + provenance/hash 清单,严格校验版本后提供 typed lifecycle、通知子集和 + server-request handler;未知字段仍保留中立 JSON,不能把该模块当作任意 Codex + v2 版本的兼容承诺。 +- 变更后重新执行并通过:all/no-default workspace test、Storage 直接 + no-default test、all/no-default `-D warnings` check/Clippy、rustdoc、fmt、依赖边界、 + package manifest 预检、独立 workspace 复制和 Fake 测试集。当时计数为 Runtime 17、 + Host 32、Codex 50;本地没有使用真实 API key 或真实 Codex 会话。 +- 因此本次是本地可复核增量,不改变原始 P0–P6 的“部分完成”状态;独立远端仓库/CI、 + registry 发布、正式许可证策略、自动外部对账和真实 Codex 完整 wire 仍未验收。 + +## 2026-09-03 Host Store ownership cleanup + +- `AgentHost` 已删除重复的 `SqliteStore` 成员;`AgentHost::store()` 保留旧签名, + 但只委托 `RuntimeService::store()`,不会产生第二份连接或持久化状态。 +- 取消/错误收口的私有路径不再携带未使用的 Store 参数;原子 run/runtime/session/ + checkpoint 事务和 fencing 语义由 Runtime/SQLite 入口保持不变。 +- 这一步只收敛 Host 内部依赖,不删除公开兼容 accessor,也不把当前 SQLite-backed + Runtime 伪装成已经完成的通用存储抽象。 + +## 2026-09-03 Context/Prompt/Skill metadata determinism + +- `agent-skills` frontmatter 现在按 canonical key 检查重复;`allowed-tools` 和 + `allowed_tools` 别名冲突也会直接返回 `InvalidFrontmatter`。 +- 新增 `拒绝重复的已知和扩展_frontmatter_字段`,验证后值覆盖、扩展字段重复和 + 别名重复均不会进入 Skill 目录或 Engine 上下文;Skill 定向测试共 22 个通过。 +- 该收紧只影响 Skill adapter 的输入解析,不改变 Core/Engine 的上下文预算、Prompt + section 或权限端口;原始 P0–P6 仍按计划标记为“部分完成”。 + +## 2026-09-03 Runtime lease facade cleanup + +- `RuntimeService` 的四个带 lease 终态入口统一使用 `&WorkerLease`;Host 不再直接 + 传递拆开的 worker/token 字符串,fencing 参数只在 Runtime 内部落到 SQLite。 +- `cargo check --locked --manifest-path rust/Cargo.toml -p agent-runtime -p agent-host + --all-targets --all-features` 通过;两 crate 的 all-features 与 no-default-features + 当时测试共 Runtime 17、Host 32,均通过。 +- 该项只验证类型/装配边界,未扩大原计划的自动外部对账、可替换存储或远端发布结论。 + +## 2026-09-03 MCP subscription 与 OpenAI endpoint setter + +- `agent-mcp` 的 stdio client 在完成 initialize 后可通过 + `into_notification_subscription(queue_capacity, poll_interval)` 转移所有权给 + 后台订阅。订阅队列固定有界,跨线程取消后 `Drop`/`join` 会回收 poller、reader + 和子进程;`join` 会报告队列中尚未消费的 poller 错误。HTTP 与自定义 transport + 明确返回 unsupported,不提供伪异步订阅,也不自动应答、重连或重放。 +- `OpenAiProvider::set_endpoint` 和 `set_base_url` 在替换当前地址前复用同一套 + endpoint/base URL 校验;非法 URL、凭据 query 或其它不安全形状不会覆盖已有地址。 +- 定向证据:MCP 38 个测试、OpenAI 21 个测试通过;原始 P0–P6 仍按计划保持部分 + 完成,真实网关、远端 CI 和 registry 发布不在本地证据内。 + +## 2026-09-03 ContextObservation trust counters 与 RuntimeStore facade + +- `ContextObservation` 现在记录候选上下文 selected/skipped 的 trusted/untrusted + 四类计数;只统计 `ContextItem`,不把基础消息或 Prompt section 混入计数。字段带 + `serde(default)`,缺少新字段的历史 JSON 仍可读取。Engine 当前定向测试为 45 个。 +- 新增 `RuntimeSnapshotService` runtime-only 泛型 facade,覆盖注入 + Store 的 snapshot/event load 与 revision CAS commit;`InMemoryRuntimeStore` 回归证明 + 该窄合同可脱离 SQLite 使用。完整 run/session/lease/checkpoint 跨表事务仍由当前 + SQLite-backed `RuntimeService` 负责,不能据此宣称可替换 Runtime 存储已经完成。 +- 这次增量只补强上下文观察和 Runtime Store 注入边界;原始 P0–P6 仍全部保持“部分 + 完成”,远端仓库/CI、registry、正式许可证策略、自动外部对账/订阅和完整 Codex + wire 仍需外部证据。 + +## 2026-09-03 MCP reader 终止回归与最终复核 + +- `read_stdio_messages` 的超限消息分支现在与 EOF/IO 分支一样使用非阻塞 + `try_send`;有界队列填满时不会阻塞 `StdioClient::Drop` 的 child/reader 回收。 + 新增 `stdio_reader_超限错误在满队列时也不会阻塞`,MCP 共 38 个测试通过。 +- 最新源码对应的 all/no-default workspace 测试、Storage no-default、两套 + `-D warnings` check/Clippy、`RUSTDOCFLAGS='-D warnings'` rustdoc、fmt、依赖与 + manifest 预检、独立复制、Fake 测试集、编码和 diff 门禁均通过。 +- 本地没有真实 API key、真实 Codex 会话或远端写操作;原始 P0–P6 仍按计划标为 + “部分完成”,外部发布、正式许可证、自动外部对账/订阅和完整 Codex wire 不由 + 本地 fixture 代替。 + +## 2026-09-03 Provider stream interruption 回归 + +- 新增 `streaming_provider_error_does_not_emit_completion` Engine 回归:假 Provider + 先发送一个 partial `TextDelta`,随后返回 `ProviderErrorKind::Stream`;断言 Engine + 返回 Provider 错误、保留已收到的增量、没有 `ProviderStreamEvent::Completed`,且不 + 产生 `ModelCompleted`/`Finished`,也没有回退调用同步 `complete`。 +- `agent-runtime-engine` 定向测试由 38 个增至 39 个并通过(前一轮历史计数;当前 + hard-bound 回归后为 40 个);该证据只覆盖本地流式终止合同,不替代真实 Provider + 网络或上游副作用验收。 + +## 2026-09-03 文件数据库重开恢复回归 + +- 新增 `file_backed_restart_recovers_expired_provider_checkpoint`:测试在首个作用域 + 关闭写入 in-flight checkpoint 的 SQLite 连接,再由新作用域重新打开同一路径;过期 + lease 被收束为 `reconciling`,fencing 信息清除,`provider_in_flight` checkpoint 和 + `provider_request_id` 保留。 +- Storage all-features 为 5 个单测 + 39 个集成测试(44),直接 no-default-features + 为 4 个单测 + 32 个集成测试(36);该证据覆盖文件持久化后的显式 recovery,不 + 宣称自动 crash supervisor 或外部调用重放。 + +## 2026-09-03 Engine hard-bound 回归 + +- 新增 `deserialized_tiny_tool_output_budget_is_rejected_before_side_effects`,覆盖 + 通过 serde 绕过 Builder 最小预算的输入;Engine 会在 checkpoint、Provider 调用和 + 工具副作用之前拒绝,避免截断 envelope 超过配置上限。Engine 当前定向测试为 45 个。 +- 该回归只补齐本地输入预算边界,不扩大真实 Provider、上游重试或外部副作用结论; + 原始 P0–P6 仍保持“部分完成”。 + +## 2026-09-03 Engine 输入与压缩边界复核 + +- 新增五个边界回归:serde 解码后的 `AgentInput` 重新校验、非法 `ContextItem` 在 + Provider 前拒绝、非法压缩器输出在 Provider 前拒绝、压缩响应的 `request_id`/`model` + 身份校验,以及压缩阶段取消后不再发起主 Provider 请求。 +- 默认与 no-default-features 下 `agent-runtime-engine` 均为 45 个测试通过;这些测试 + 只证明本地 Engine/Provider 合同,不替代真实上游硬 I/O timeout、自动外部对账或 + 原始 P2 全量出口。 + +## 2026-09-03 HTTP 订阅、Codex envelope、Provider 路由与编排快照 + +- MCP 当前 41 个测试:Streamable HTTP 完成握手后可显式建立独占 GET/SSE 后台订阅, + 私有 worker 增量解析并以有界队列转发;空闲取消、队列溢出、HTTP 状态和解析错误 + 均有回归。自定义 transport 仍只支持显式轮询。 +- Codex 当前 53 个测试:混合 JSON-RPC envelope 在 handler 前拒绝,服务端请求提供 + 中立分类,dispatch 前/后的错误分别保留配置错误、不可用和未知副作用语义。 +- Host 当前 52 个测试:ProviderRegistry 按强类型实例/协议选择 Provider 并在执行前 + 校验匹配;Orchestration 该阶段为 29 个测试:PersistentCoordinator 对图和控制面做 + revision CAS 快照恢复及一致性校验。 +- 以上是本地增量验收,不代表原始 P0–P6 已全部完成;远端仓库/CI、registry、正式 + 许可证策略、真实 Codex wire/会话、自动外部对账和完整多 Agent 调度仍未验收。 + +> 上述 41/53 是该阶段的中间计数;当前权威计数见本文件开头和末尾的最新复核(MCP 47、Host 52、Codex 58、Orchestration 36)。 + +## 2026-09-03 P1/P3/P4/P5 收尾增量 + +- Core reducer 在从事件 `detail` 反序列化后重新执行 Message、ToolCall、ToolResult + 和 RunSnapshot 的构造级校验;空文本、非法参数、调用 ID 或 metadata 会以 + `InvalidEvent` 拒绝,snapshot/revision 保持不变。Core 当前为 16 个 unit + 14 个 + integration 测试。 +- Runtime generic `RuntimeStore` facade 通过 `RuntimeServiceError::StoreProtocol` 保留 + `StoreErrorKind`(`Conflict`、`InvalidSnapshot`、`Unavailable`),不会把 CAS/协议错误 + 降成不可判别字符串;Runtime 当前 20 个测试。 +- MCP 认证引用已覆盖高层 connect、`StreamableHttpClient::from_config` 和 + `StdioClient::spawn` 直接构造路径;Skill activation 使用 `Read::take(max+1)`,文件 + 在 metadata 检查后增长也会拒绝超限正文。MCP 当前 43 个测试,Skill 当前 23 个。 +- 通用 Codex JSON-RPC channel 增加显式 handler 的 initialize、request/event、interrupt + API;handler 响应、错误回写和混合 envelope 拒绝均有 fixture。Codex 当前 57 个测试。 +- 以上是本地增量证据;原始 P0–P6 仍逐项为“部分完成”,远端仓库/CI、registry 与正式 + 许可证策略、真实 Codex 完整 wire、自动外部对账/订阅、完整持久化多 Agent 调度和 + 最终 Host/Runtime 拆分不由这些测试替代。 + +## 2026-09-03 最终串行门禁复核(历史快照) + +- 最新源码已单独重跑 all/no-default workspace tests、Storage 直接 + no-default、两套 `-D warnings` check/Clippy、rustdoc、fmt、依赖/manifest/独立复制、 + Fake 测试集、编码、diff 和临时目录卫生检查,均通过。 +- 最新计数为 CLI 21、Host 52、MCP 47、Engine 48、OpenAI 23、Fake 4、Runtime 21、 + Codex 64、Orchestration 38、Skill 27、Core 17+14、Storage 5+40(45);Storage 直接 + no-default 为 4+32(36)。Fake 测试集为 2/2,真实 Provider 按设计跳过。 +- 当前环境没有 `cargo-audit` 二进制或 `RUSTSEC_ADVISORY_DB`,离线审计 wrapper 按设计 + 明确提示缺少 runner 输入;历史固定快照扫描证据仍不等同于本次环境重跑。原始 P0–P6 + 仍全部为“部分完成”。 + +## 2026-09-03 审批敏感字段边界复核 + +- SQLite JSONL 导出采用审批专用脱敏投影,移除顶层和嵌套 `approval_token`/ + `approvalToken`,保留审批身份、状态和审计字段;数据库内部记录未被修改,显式 + `approval resume` 仍可使用原始 binding。 +- CLI `approval list/get/allow/deny` 在 stdout 侧使用递归脱敏视图;回归测试覆盖顶层 + 和嵌套 token,并确认工具请求详情仍可用于决策。 +- 这项复核只覆盖导出/展示边界,不把内部 binding token 从恢复流程中删除,也不扩大 + 原始 P0–P6 的完成结论。 + +## 2026-09-03 最终本地边界增量复核 + +- Engine 新增 Provider response 与 ToolExecutor `ToolResult` 的构造级边界校验。通过 + serde 绕过 Core Builder 的空文本、非 object tool 参数、空 summary 或非法 metadata + 不会进入 `ModelCompleted`/`ToolCompleted`、消息历史、`safe` checkpoint 或下一轮 + Provider 请求;异常路径保留对应的 in-flight checkpoint,交给宿主对账。Engine 当前 + 47 个测试,默认与 no-default-features 均通过。 +- Codex CLI 参数白名单新增规范化凭据识别,覆盖大小写、连字符、空格、access-token、 + Authorization/Bearer 等常见写法,同时不误拦 `--tokenizer`;Codex 当前 58 个测试。 +- SQLite `requeue_reconciled_run` 仅在 `reconciling -> queued` 时更新时间;已排队且 + 拥有 `safe` 游标的重复 `resume-safe` 返回相同记录且不改变 `updated_at`。 +- 上述修正后重新通过 all/no-default workspace 测试、Storage 直接 no-default、两套 + `-D warnings` check/Clippy、rustdoc、fmt、依赖/manifest/独立复制、Fake、编码、diff + 和临时目录卫生门禁。当前环境仍没有 `cargo-audit`/RustSec DB,缺失输入路径按设计 + fail-closed;原始 P0–P6 仍逐项为“部分完成”。 + +## 2026-09-03 最终 CLI 程序 smoke + +- 在独立 workspace `/data/dsk/Genarrative-master/rust` 使用 `/var/tmp` 临时数据库, + 实际运行 `doctor`、前台 JSONL `run`、后台 `run --background`,并通过 `inspect` 观察 + 后台 run 到 `completed`;随后 `export` 输出 8 条记录,`reconcile --stale 8` 返回空列表。 +- 本次 smoke 使用 Fake Provider,不读取真实 API key;前台 JSONL 有 8 条记录且结果文本 + 非空,临时数据库和目录已清理。 + +## 2026-09-03 继续执行:恢复、配置与编排边界 + +- Host 新增多工具批次首个 Ask 的恢复回归:恢复只将当前 pending call 及其前缀物化到 + Core,后续调用保留在 checkpoint;连续两次显式 Allow 后可完成,Host 当前 37 个测试。 +- MCP 的 stdio、HTTP JSON/SSE 和后台 SSE 入站消息统一校验 JSON-RPC envelope;非 2.0、 + 混合 method/result/error、缺失类型字段均拒绝,MCP 当前 44 个测试。MCP 工具桥对发送后 + 的 timeout/连接/协议/编码/HTTP/远端错误返回 Unknown,显式 failed 重试不会重放未知调用。 +- CLI `doctor` 现在在不连接外部服务、不启动进程的前提下分项校验 Provider key/endpoint、 + Skill、MCP、Codex 和 SQLite;Codex program 仅输出配置标记。CLI 当前 16 个测试。 +- Orchestration 的 `PersistentCoordinator::isolate_node` 在释放活动 run 后将图节点持久化为 + `Waiting`;快照导入同时校验“每个 Running 节点必须有且仅有匹配的 active run”,新增 + 隔离重载和反向不变量回归;该阶段 Orchestration 为 31 个测试,后续 ready + 计划/claim 增量后的当前计数见末尾最新复核。 +- 该修正后重新通过 all/no-default workspace 测试、双特性 rustdoc、`-D warnings` + check/Clippy、依赖/manifest/独立复制、Fake 测试集、编码、diff 和临时目录卫生; + cargo-audit 当前因缺少 RustSec DB 只验证 fail-closed。 +- 以上是本地边界证据;原始 P0–P6 仍保持“部分完成”,真实 Provider/Codex 会话、远端 + CI/registry/许可证策略、自动外部对账和完整多 Agent 调度仍未验收。 + +## 2026-09-03 最新复核(历史快照) + +- 当前计数:Engine 48、Host 54、MCP 47、CLI 21、Codex 64、Runtime 21、Orchestration 38、 + Skill 27、OpenAI 23、Fake 4;Core 17 个单测 + 14 个集成测试;Storage all-features + 5 个单测 + 40 个集成测试(45),直接 no-default-features 4 个单测 + 33 个集成测试(37)。 +- MCP resources/prompts 没有隐式发现或注入,必须由调用方显式 selection 指定;注入项带有 + untrusted 边界,Engine 最终生成模型消息时把不可信 context 的 system/developer/assistant + 角色降为 User,并把结构化 tool-call/tool-result 内容渲染成普通用户文本。 +- Host `CodexHostServerRequestHandler` 仅桥接 `item/tool/call`:先确认已注册工具并执行 + JSON Schema 参数校验,再调用 Host approval policy 和 ToolRouter。它是同步低层接口, + 不创建 durable approval/checkpoint/audit;`Ask` 通过 JSON-RPC error 返回,持久化恢复仍须 + 使用 Engine/Runtime 路径。另有 `codex_01521_server_request_handler` 只覆盖已核对版本 + 的 dynamic-tool typed wire(`tool`/`callId` 请求与 `contentItems`/`success` 响应)。Host + 现在提供显式 `NamespaceToolResolver`:默认空映射对显式非 `null` namespace 都在 + approval/execution 前 fail-closed,调用方注册并命中 `(namespace, tool) -> registered_tool` + 后才路由,缺省或 JSON `null` 仍按全局工具名处理。 +- Host 的 `AgentHost::with_runtime` 允许注入已装配的 `RuntimeService`;Host 创建的 run + 可由外部持有的同一 facade 直接观察,证明不会重新打开 SQLite 或形成平行控制面;该回归 + 使 Host 当前计数为 54。 +- MCP stdio client 等待响应期间的 pending 消息队列有 + `MAX_STDIO_PENDING_MESSAGES`(当前 4096)硬上限,溢出按协议错误返回;随 crate 分发的 + stdio 假服务器还覆盖握手、tools/list 和 tools/call,MCP 当前计数为 47。 +- Engine 的 Provider response `content` 只接受 Text/Image;结构化 tool call/result 会在 + 边界拒绝,必须分别使用 `tool_calls` 或工具结果回填,Engine 当前计数保持 48。 +- CLI 后台启动统一通过 `spawn_worker_or_fail_unclaimed`:子进程未创建成功时只原子收束 + 仍未被领取的 run,不覆盖已由其它 worker claim 的状态;该边界覆盖 background、approval + resume、resume 和 resume-safe,CLI 当前计数为 21。`doctor` 不联网或启动外部进程,但会 + 打开并按需初始化/迁移本地 SQLite/WAL。 +- 原始 P0–P6 仍逐项为“部分完成”。远端仓库/CI、registry 与正式许可证策略、真实 + Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度、最终 + Host/Runtime 拆分仍未验收。 + +## 2026-09-03 P4 fixture 回归 + +- `agent-mcp/fixtures/stdio-jsonrpc-server.sh` 由 MCP client 实际调用,覆盖固定的 + initialize、tools/list 和 tools/call;不读取密钥或依赖 jq/node。 +- `agent-skills/fixtures/skills/review/SKILL.md` 与非法 frontmatter fixture 参与真实目录 + discovery/activation 测试;激活结果不包含工具绑定,`allowed-tools` 仍只是候选元数据。 +- 本地 fixture 只证明适配器边界,不替代真实远端 MCP 兼容矩阵或真实 Skill 目录审核。 + +## 2026-09-03 编排文件快照跨进程 CAS + +- `JsonFileOrchestrationSnapshotStore` 新增同目录 sidecar advisory lock;写入时在 + 同一临界区内读取当前 revision、执行 CAS、写临时文件并 rename。两个独立 store + 实例竞争同一 revision 时,只有一个写者成功,另一个返回 `RevisionConflict`。 +- 回归覆盖两个实例的并发竞争;该锁只保护文件快照写入,不启动 scheduler、不创建 + Runtime run,也不替代完整多 Agent 调度或跨主机锁服务。 + +## 2026-09-03 继续执行后的最终门禁复核 + +- all-features 与 no-default-features workspace 测试、Storage 直接 no-default 测试、两套 + `-D warnings` check/Clippy、双特性 rustdoc、fmt、依赖/manifest/独立复制、Fake 测试集、 + 编码、`git diff --check`、shell 语法和临时目录卫生均已重跑并通过。 +- 该段记录的是带外中断阶段的历史快照;当前计数以本文件开头和末尾最新复核为准。 +- 本机没有 `cargo-audit`/`RUSTSEC_ADVISORY_DB`,因此仅验证 wrapper 的缺失输入 + fail-closed;真实 Provider/MCP/Codex 会话、远端 CI、registry/许可证正式审计、自动 + 外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未验收。 + +## 2026-09-03 CLI OpenAI endpoint 集成 smoke + +- 使用显式 `--manifest-path rust/Cargo.toml` 启动 CLI,并将 `OPENAI_BASE_URL` 指向仅监听 + `127.0.0.1` 的 fake Responses 服务;服务端实际收到 `POST /v1/responses`,校验了 + Authorization、`AGENT_MODEL=smoke-model` 和响应文本 `cli endpoint ok`。临时数据库位于 + `/var/tmp` 并已清理。 +- 该证据只覆盖本地 CLI/Host/Provider 接线,不覆盖真实网关、真实凭据或上游模型质量。 + +## 2026-09-03 继续执行:共享 RuntimeStore、严格 Skill frontmatter 与 MCP 握手 + +- `agent-runtime-core::SharedRuntimeStore` 为同一进程内的多个 + `RuntimeSnapshotService` 提供一个共享、串行化的 `RuntimeStore` 实例;并发 CAS + 仍只允许一个旧 revision 写入,锁中毒会映射为 typed `StoreErrorKind::Unavailable`。 + 该 wrapper 只使用标准库,不改变 Core trait,也不提供跨进程或跨主机 fencing。 +- `agent-skills` frontmatter 解析继续保持轻量行式子集,但现在拒绝未闭合/不匹配的引号 + 和列表括号、空列表项及空工具名;Skill 目录发现和显式激活都沿用同一解析器。定向 + 测试为 25 个,未引入 YAML 运行时依赖。 +- `agent-mcp` 在发送 `notifications/initialized` 前要求 initialize result 携带当前唯一 + 支持的 `protocolVersion=2025-06-18`;缺失、非字符串或未知版本不会把连接标记为已初始化。 + 随 crate 分发的 stdio 假服务器还覆盖握手、tools/list 和 tools/call,MCP 定向测试为 47 + 个;这不是多版本协商或远端兼容性证明。 +- `agent-skills` 随 crate 分发有效/非法 `SKILL.md` fixture,直接参与 metadata-first + discovery/显式 activation 回归;激活结果不携带工具绑定,Skill 定向测试为 27 个。 +- 本轮仍不改变原始 P0–P6 的“部分完成”结论;独立远端仓库/CI、正式许可证策略、真实 + Provider/Codex wire/session、自动外部对账/订阅、完整持久化多 Agent 调度和最终 + Host/Runtime 拆分仍未验收。 + +## 2026-09-03 继续执行:Codex 事件桥与外部会话重开取消 + +- `CodexAppServerBackend::invoke_node_with_runtime_events` 已提供显式的实际 backend + 接线:request、channel event、result 依次经 `NodeRuntimeEventMapper` 生成 Core + `RuntimeEvent`,调用方 sink 可直接应用 reducer;回归覆盖三段事件和连续 revision, + 未引入隐式 Host 状态修改。 +- 外部会话桥在 active index 不存在时先读取 durable request-id 别名;重开 Host 通过 + `cancel_external_request` 可调用 backend cancel 并更新原记录,成功为 `cancelled`, + 失败为保守的 `unknown`。回归确认取消不重新 invoke 且保留自定义审计 metadata。 +- 本轮后 Codex 64、Host 54;Storage all-features 5+40、直接 no-default-features + 4+33,Codex/Host all/no-default 定向测试和 `-D warnings` Clippy 均通过。 +- `CodexAppServerBackend::with_interrupt_hook` 的阻塞 channel 并发回归确认:配置独立 + control transport 时,invoke 持有 channel mutex 期间仍可完成 interrupt;未配置 hook + 时保持原有 fallback。该 API 只提供显式接线,不代表真实 Codex control wire 或 Host + 自动取消 fan-out。 +- 这仍是本地适配器验收。完整 generated/version-specific Codex wire、真实 session、 + 自动外部对账/订阅、远端 CI/registry/许可证和完整持久化多 Agent 调度未验收;原始 + P0–P6 继续保持“部分完成”。 + +## 2026-09-03 Provider 生命周期与压缩接线复核 + +- `OwnedProviderContextCompressor` 让 Host 可以安全保存一个绑定 `Arc` Provider 的 + 压缩器;它与借用式实现共用消息/响应 identity 校验。OpenAI 的 env、配置对象和手动 + key 装配 helper 已自动启用该适配器,专用摘要模型仍可通过 `with_context_compressor` + 覆盖。 +- Host 长上下文回归实际观察到“摘要请求 → 主 Provider 请求”两次调用,未使用真实 key + 或网络;Provider 切换只清理由自动绑定产生的压缩器,不会覆盖调用方明确注入的摘要器。 +- 本轮当前计数:Engine 50、Host 57、Runtime 22、Core 20 个单测 + 16 个集成测试; + Storage all-features 5+43(48),直接 no-default-features 4+36(40)。 +- 测试 runner 与相关文档默认使用 `TMPDIR`/`/var/tmp`,不再引用 home 下的临时路径; + 本轮生成的临时目录已在验收后清理。 +- 以上只补齐本地可复核接线;真实 Provider/Codex 会话、远端发布与 CI、自动外部 + 对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未验收。 + +## 2026-09-03 Codex 进程自然退出竞态复核 + +- `CodexAppServerProcess` 在 reader 先观察到 EOF、child 的 `try_wait` 尚未立即可见时, + 先经过短暂退出宽限窗口检查自然退出状态,再决定是否终止 process group;非零退出码 + 不会被竞态降级成 `ProcessTerminated`。 +- no-default-features 下重复运行该回归 20 次均稳定返回 `CodexError::Exit(7)`;修复后 + all/no-default workspace 测试、双特性 Clippy 和 rustdoc 重新通过。当前 Codex 计数为 + 73,原始 P0–P6 的外部未完成项仍不变。 + +## 2026-09-03 Runtime/Host facade 与编排 fixture 复核 + +- Runtime 的 `list_runtime_events`、`export_runtime_jsonl`、`get_session` 已通过 + `AgentHost` 暴露;run-level 审计事件和 Core runtime 事件保持两条只读记录流,CLI/ + 控制面不需要直接访问 SQLite adapter。 +- Host 测试中的旧 `host.store()` 读取已迁移到窄 facade;`AgentHost::store()` 现在只 + 作为 deprecated 兼容入口委托 Runtime,不能据此推断 Host 仍拥有平行 Store。 +- 编排文件重开测试同步删除 sidecar advisory lock;Runtime/Host/Orchestration 定向 + 测试和 `-D warnings` Clippy 通过。最终 all/no-default 门禁以本轮末尾命令为准。 +- 原始 P0–P6 仍保持“部分完成”;远端发布/CI、正式许可证、真实 Provider/Codex + session、自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分未验收。 + +## 2026-09-03 Provider 协议自描述与空配置复核 + +- Core `ModelProvider` 新增可选 `protocol_id()`;Registry 在注册和解析两个边界核对 + adapter 自报协议,descriptor 未填写时补齐,错配在 Provider 执行前拒绝。OpenAI + adapter 固定报告 `openai-responses`,Core/OpenAI/Host 回归均通过。 +- OpenAI endpoint 凭据字段检查统一规范化分隔符并覆盖 `x-api-key`、`auth`、`key` 等 + 常见变体;CLI 空白 TOML model 按未设置处理。相关定向测试:Core 22、OpenAI 23、 + CLI 22、Host 57。 +- 该证据只证明本地协议身份和配置边界,不代表真实网关兼容、自动外部结果恢复、远端 + CI/registry/许可证或原始 P0–P6 全量完成。 + +## 2026-09-03 P5 与 feature gate 复核(历史快照) + +以下计数记录 2026-09-03 当日 workspace 源码;最新数字以文末 2026-09-04 当前复核为准: + +- 当前定向计数为:`agent-codex` 73、`agent-host` 58、`agent-runtime` 23(其中 + `--no-default-features` 为 5)、`agent-provider-openai` 29、`agent-cli` 23、Core + 23 个单测 + 16 个集成测试。其它 crate 的历史计数仍按各自最新门禁记录解释。 +- OpenAI HTTP 状态分类只属于该 Responses adapter:408/429/502/503/504 映射为 + `Unavailable`,其余非 2xx 映射为 `Upstream`;adapter 保留状态码并脱敏正文,但不 + 读取 `Retry-After`、不执行自动 backoff/结果查询或重放,重试预算仍由 Engine 控制。 +- P5 在本地已验证的范围是:显式 `program + args` 的真实 stdio 子进程、argv 白名单、 + deadline/取消、process-group 终止、EOF/退出回收、窄 V2 JSON-RPC、server-request + handler 和 Core `ExternalBackend` 接线;fixture 使用本地假进程,不等于真实 Codex + 发行版 wire/session。 +- P5 仍未完成的范围是:完整 generated schema、发行版启动/版本协商、Host 对 + server-request handler 的自动装配、真实 `threadId`/`turnId` 会话持久化与重开、自动 + 外部对账/订阅和请求重放。未知副作用仍只能进入 reconciliation,不生成 safe 结果。 +- feature gate 边界:`agent-runtime` 默认启用 `sqlite`,该 feature 才引入可选的 + `agent-storage-sqlite`;关闭默认 feature 只暴露 runtime-only、可移植的 + `RuntimeSnapshotService`/身份合同。`agent-storage-sqlite` 自身默认无 feature, + `core-adapter` 仅为可选 Core 校验依赖。`agent-host`/`agent-cli` 当前仍通过默认依赖 + 使用 SQLite,因此 workspace 的 `--no-default-features` 是 feature 组合编译/测试, + 不能宣称 Host 已脱离 SQLite。`agent-codex` 当前没有 Cargo feature gate,其 process + 与 typed API 在两套 workspace feature 组合中都编译。 +- 本节只更新本地证据口径;独立远程仓库/CI、registry/正式许可证、真实 Provider/Codex + 会话和自动恢复仍不在本地验收范围。 + +## 2026-09-03 取消阶段门禁与 Codex 配置复核(历史快照) + +- `RuntimeService::finish_unclaimed_cancelled_if_safe` 只允许 queued 未启动 run,或无 + lease 且处于 `safe`/`awaiting_approval` checkpoint 的 run 直接原子取消。历史 + running/cancel_requested/reconciling、缺失 checkpoint 和 in-flight/compacting 游标 + 都必须停在 reconciliation;Host 在 `request_cancel` 前保留 queued 判别,过期 lease + 先 stale probe。Runtime/Host 的历史无 lease 与过期 in-flight 回归均通过。旧 + `finish_unclaimed_cancelled` 是为旧调用方保留的低层兼容入口,Host/CLI 不调用,不能 + 作为安全阶段策略的证据。 +- `CodexCliConfig` 与 `CodexAppServerProcessConfig` 的 timeout/output/frame limit 在 + serde 和运行时均拒绝零值,子毫秒 timeout 及整数转换溢出也拒绝;相关 Codex crate + 测试为 77 个。这样 `doctor/validate` 与实际 supervisor 不会出现“校验成功、执行时 + 才失败”的分叉。 +- 当前本地计数:Core 23 单测 + 16 集成,Engine 50,Runtime 默认 25(package-only + no-default 5),Storage all-features 5+43、直接 no-default 4+36,Host 60,CLI 23, + OpenAI 29,Fake 4,MCP 47,Skill 27,Codex 77,Orchestration 38。all/no-default + workspace 测试和静态、文档、格式、依赖、manifest、独立复制、能力集正负例、编码、 + diff、shell 门禁均通过。 +- `run-cargo-audit.sh` 在当前执行环境因缺少 `RUSTSEC_ADVISORY_DB` 按设计退出 1;这 + 是未提供审计输入的 fail-closed,不记作漏洞扫描通过。独立远端 CI、真实 Provider/ + Codex wire/session、自动外部恢复、registry/许可证策略、多 Agent 完整调度和最终 + Host/Runtime 拆分仍未验收。 + +## 2026-09-03 完成性审计边界 + +- Host 的 queued 取消快速路径与 worker claim 之间存在极窄的预读竞态。底层 Runtime + 事务会重新检查 lease 并 fail-closed,不会把可能已启动的 run 直接写成 cancelled; + 竞态窗口内控制端可能得到 lease conflict,需重试或等待 worker 收口。要消除该现象, + 后续应增加带 expected 状态条件的单事务取消命令并补 Storage 合同测试。 +- 该边界不影响当前“不会越权终态化”的安全证据,但意味着原始 P0–P6 不能因此标成 + 全部完成;远端发布/CI、正式许可证、真实 Provider/Codex wire/session、自动外部恢复、 + 完整持久化多 Agent 调度和最终 Runtime/Host 拆分仍未验收。 + +## 2026-09-04 取消竞态与 Codex 空白帧复核(当前) + +- queued 取消的最终 predicate 已下沉到 Storage 的同一 `BEGIN IMMEDIATE` 事务:只有 + `status=queued`、未设置 `cancel_requested`、没有 worker/lease、没有 checkpoint 时 + 才能同步更新 run、runtime、session 和事件;竞争失败返回 + `QueuedCancelConflict`,不写半截状态。Host/Runtime 回归覆盖直接领取竞争和两个 + 独立 SQLite 连接并发,结果只允许“已取消”或“已领取且 cancel_requested”。 +- `finish_unclaimed_cancelled_if_safe` 的 queued 路径复用原子命令;Core snapshot 非 + `Pending` 直接报告投影不一致。旧低层 `finish_unclaimed_cancelled` 仍是兼容 API,未 + 被安全控制面调用。 +- Codex JSONL channel 对空白 keep-alive 行使用循环跳过;8192 行空帧 fixture 通过, + 不改变既有单帧大小、版本和未知副作用边界。 +- Core 的 `PromptSection::try_new`、`ProviderRequest::try_new` 和 `ProviderResponse::try_new` + 会在构造边界重新校验嵌套内容与工具调用;`section_constructor_rejects_invalid_deserialized_content` + 与 `provider_constructors_reject_invalid_nested_messages_parts_and_calls` 覆盖 serde/兼容入口。 + `PromptBuilder::build`/`into_messages` 也会对 serde 解码消息重新执行 `ContentPart::validate`, + `deserialized_invalid_content_is_rejected_before_provider_request` 验证空文本等非法内容不会进入 Provider。 + Engine 的压缩器拒绝 `Tool` 角色和任意 + 结构化 `ToolCall`/`ToolResult`,`compressor_cannot_inject_tool_history` 覆盖伪造 + 工具历史;Core 默认 `ModelProvider::stream` 在只有 `complete` 实现时仍发出 + `ToolCallDelta` 和 `Usage`,`default_stream_preserves_complete_tool_calls_and_usage` + 验证事件顺序。`ToolDefinition::validate` 会在 Registry 注册和 + `ProviderRequest::with_tools` 边界复核 serde 工具定义,`serde_invalid_tool_definition_is_rejected_by_request_and_registry` + 覆盖非法 schema 的 fail-closed。`ProviderContextCompressor` 对摘要响应中的结构化 + content 直接报错,不静默过滤,`compressor_rejects_structured_content_in_summary_response` + 覆盖该边界。 +- Host MCP executor 在发送前及取得 client 锁后检查取消, + `cancelled_mcp_tool_context_blocks_transport_before_tools_call` 验证已取消调用不会 + 触碰 transport;已经进入同步 I/O 后的硬取消仍由具体 MCP adapter 负责,通用 Host + 不强杀线程或伪造副作用结果。 +- SQLite RuntimeStore 在 load、CAS commit 和 recovery verification 统一复核 + `RuntimeSnapshot::validate`;`runtime_store_rejects_tampered_snapshot_on_load_and_commit` + 证明语义被篡改的持久化快照会映射为 `InvalidSnapshot`,且失败提交不会写入新事件。 +- 当前本地计数:Core 28+17、Engine 52、Runtime 28(package-only no-default 6)、 + Storage 5+45(直接 no-default 4+36)、Host 62、CLI 23、OpenAI 29、Fake 4、MCP 47、 + Skill 28、Codex 81、Orchestration 38。双 feature workspace 测试、静态检查、文档、 + 依赖/manifest/独立复制、能力集 10/10 和 Fake 集已通过;根 Vitest 的 `3189/3189` 为 + 历史记录,本轮工作区未安装 `vitest`,尝试以退出码 127 结束,未计入当前通过项。 +- 清理构建目录后执行 `AGENT_DB=<临时路径> cargo run --locked -p agent-cli -- run + "从零启动 smoke"`,CLI 实际完成 Fake Provider 的工具调用闭环并输出 completed + 结果;临时数据库随后已删除。 +- 2026-09-04 在隔离的 `~/data/tmp/` 临时目录使用 `cargo-audit 0.22.2` 与 + RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` 执行 + `run-cargo-audit.sh`,扫描 188 个锁定依赖并以退出码 0 通过,无漏洞/warning; + `Cargo.lock` SHA-256 为 `1a3282fe963b9a53e3d9e24a1507c75f1ee19b9a8648025d4f6da25aca5464d5`。 + 工具和数据库随后清理,未写入仓库。缺失 DB 时 wrapper 仍 fail-closed。真实 + Provider/Codex 会话、远端 CI/registry/许可证、自动外部对账/订阅、完整持久化多 Agent + 调度和最终 Host/Runtime 拆分不由上述本地证据替代。 + +## 2026-09-04 RuntimeStore 可替换接缝(当前) + +- `DynRuntimeStore` 是承载 `Box` 的本地 newtype,供 + `RuntimeSnapshotService` 动态注入 runtime-only Store;它只转发 snapshot/event 的 + typed CAS,不承诺 SQLite durable run 控制面可替换。 +- no-default-features portable facade 6/6、默认 feature lib 28/28,`-D warnings` + check/Clippy 和 fmt 通过。该证据补齐的是适配器接缝,不是最终 Host/Runtime 拆分或 + 远端发布/真实协议验收。 + +## 2026-09-04 Runtime event history、Skill discovery 与 Codex process bridge(当前) + +- SQLite RuntimeStore 从空快照重放完整 `runtime_events`,校验 revision 连续性与事件语义; + event log 缺失、断档或语义篡改均 fail-closed,load 和 CAS commit 都拒绝继续写入。 + `runtime_store_rejects_event_history_gap_on_load_and_commit` 已通过;Storage + all-features 5+45=50,直接 no-default 4+36=40。 +- Skill discovery/list 在 discovery 阶段即执行 `max_body_bytes` 硬上限,正文超限返回 + `BodyTooLarge`;`discovery_and_list_reject正文超过配置上限` 已通过,Skill 当前 28。 +- Codex process backend runtime event bridge 覆盖 request→notification→result 顺序及 + 显式 handler 变体;`app_server_process_backend_runtime_event_bridge_maps_notification_order` + 已通过,Codex 当前 81。 +- 根 Vitest 的 `3189/3189` 仍为历史记录;本轮工作区未安装 `vitest`,尝试以退出码 127 + 结束,未计入当前通过项。原始 P0–P6 仍部分完成,真实 Provider/Codex session、远端 + CI/registry/许可证和最终 Host/Runtime 拆分仍未完成。 + +## 2026-09-04 RuntimeService 动态 snapshot Store 接缝(当前) + +- `RuntimeService::snapshot_store()` 提供 `RuntimeSnapshotService`, + 通过同一 SQLite 连接共享 runtime snapshot/event 状态;回归 + `runtime_exposes_dynamic_snapshot_store_without_adapter_handle` 已验证动态提交与 + Runtime facade 互相可见,不暴露 SQLite adapter 句柄给调用方。 +- 该 API 仅是 runtime-only CAS 接缝,不能替代跨表 durable run/session/lease/checkpoint + 控制面;默认 Runtime 28/28、portable no-default 6/6 已通过,原始 P0–P6 仍部分完成。 + +## 2026-09-04 manifest 预检 target 隔离(当前) + +- `scripts/check-package-manifests.sh` 的 `cargo package --list` 中间产物现在默认写入 + `~/data/tmp` 的精确临时目录(可用 `AGENT_PACKAGE_TMPDIR` 覆盖),退出时自动清理;显式 + `CARGO_TARGET_DIR` 不被接管。13 个 crate 的 manifest 预检通过,workspace 未残留 + `rust/target`。 +- 该项只修复本地预检的产物边界,不替代远端 CI、registry/许可证策略或其它原始 P0–P6 + 未完成出口。 + +## 2026-09-04 ContextItem 嵌套消息构造校验(当前) + +- `ContextItem::try_new` 现在在保存消息前调用 `Message::validate`,即使调用方使用 + 兼容 `Message::new` 或 serde,也不能把空内容/非法内容交给 `ContextSource`。 + `context_item_constructor_rejects_invalid_nested_message` 已通过;Core 当前为 + 28 个单元测试 + 17 个集成测试。 + +## 2026-09-05 Core 扩展合同、外部候选扫描与全量门禁(当前) + +- [x] Core 对公开 serde/兼容入口统一执行嵌套校验:`ToolBinding`、`SkillDefinition`、 + `SkillActivation`、`AgentDescriptor`、`BackendRequest`、`BackendResult` 和 + `ToolContext` 均有 `validate`;`backend_result_as_tool` 拒绝 request/call 身份 + 错配和未知副作用结果。Core 当前为 30 个单测、20 个 `core_contracts` 集成测试、 + 1 个 `tool_context_contracts` 集成测试和 1 个 doctest。 +- [x] Engine 在调用 `ContextSource` 前验证 `ContextRequest`;Host/Engine 在工具、Skill、 + MCP 和外部 backend dispatch 前验证身份、元数据和调用合同。非法输入在 Provider、 + checkpoint 或外部副作用前 fail-closed,Host/Engine 当前分别为 65/55 个测试;公开 + `validate_tool_arguments` 也会复验 Core 调用/定义并检查工具名匹配。 +- [x] Runtime/Storage/Host 提供只读 `list_external_sessions` 候选查询,状态过滤上限 32、 + limit 上限 256,固定 `created_at ASC, id ASC` 排序并全部参数绑定;它不会自动 + observe、replay、requeue 或 reconciliation。Storage all-features 7+53、直接 + no-default 6+36,Runtime 36(package-only no-default 8)。 +- [x] Runtime 第一阶段提供中立 `DurableStore` bundle/query/lease/runtime-CAS 合同,并由 + `SqliteDurableStore` 适配;`prepare_run*`、run/session 查询、claim/heartbeat/release、 + snapshot load/CAS 已经过该接缝,`durable_store_contract_covers_bundle_queries_and_runtime_cas` + 覆盖查询、共享可见性和 typed CAS conflict;checkpoint fencing、审批和外部会话查询、 + request-cancel/stale 及 runtime-aware finish/recovery command 也经同一接缝。当前 + Runtime 为 36 个测试;其中新增非 SQLite InMemoryDurableStore contract harness;跨表原子性 + 仍由 SQLite 专用事务实现,少数历史兼容入口尚未收敛。 +- [x] 隔离 `~/data/tmp` 门禁全部通过:all/no-default workspace 测试、check、Clippy + `-D warnings`、rustdoc、fmt、Storage 直接 no-default、依赖/manifest、独立复制、 + Fake 2/2、能力 10/10、shell、编码和 `git diff --check`;退出后 workspace 没有 + `rust/target`、数据库或日志残留。 +- [x] 编排、Host、Skill 和 SQLite Storage 测试辅助在显式 `TMPDIR` 下尊重调用方目录, + 未设置时回退到 `~/data/tmp`,不再默认把测试快照、fixture 或数据库写入系统 `/tmp`。 +- [ ] 远端独立仓库/CI 持续运行、正式 registry/许可证策略、真实 Provider/Codex wire/session、 + 自动外部对账/订阅、完整持久化多 Agent 调度和最终 Host/Runtime 拆分仍未验收;因此 + 原始 P0–P6 继续保持“部分完成”。 + +## 2026-09-05 DurableStore command 扩展(历史快照) + +- [x] DurableStore/SqliteDurableStore 现已覆盖 run/session 查询、lease、cooperative + cancel/stale 扫描、checkpoint fencing、approval pending-only CAS、external-session + 查询,以及 runtime-aware finish/recovery command;Runtime facade 对应路径均经过 + 该适配器,Runtime 当前 33 个测试(package-only no-default 6 个)。 +- [x] 终态 command 支持 completed/failed/cancelled 和 queued-unclaimed guard;recovery + command 携带 runtime snapshot/CAS 与事件批次,在 SQLite 单事务中保持 run、session、 + lease、checkpoint、runtime 一致性。新增 contract 仅扩大可替换边界,不把多次调用拼成 + 假事务。 +- [ ] 少数旧兼容入口仍直接暴露 SQLite-backed 实现,完整 Runtime/Host 依赖反转尚未完成; + 远端 CI/registry/许可证、真实 Provider/Codex wire/session、自动外部对账/订阅和完整 + 持久化多 Agent 调度仍不在本地验收范围。 + +## 2026-09-05 DurableStore 控制面查询收敛(历史快照) + +- [x] cancel marker 查询、runtime 身份反查、session 更新和 safe requeue 已加入 DurableStore; + Runtime stale/recovery 与安全取消路径复用该 adapter,session metadata/时间字段保持完整。 +- [x] Runtime 36 个测试、workspace 双特性测试和 `-D warnings` 门禁继续通过;剩余少数旧兼容 + 诊断/审计导出仍是 SQLite 具体实现;兼容终态已通过 adapter,不把本地路由误报为最终 + 可替换 Runtime。 +- [x] `DurableRuntime` generic facade 已通过 + `generic_durable_runtime_facade_owns_and_returns_the_adapter`,可在不依赖 SQLite 具体类型 + 的情况下持有、查询并取回 durable adapter;Runtime 当前 36 个测试,另有 package-only + no-default 8 个测试。 +- [x] 直接运行 `AGENT_PROVIDER=fake cargo run --locked -p agent-cli -- run --no-stream --jsonl`, + 随后用同一数据库执行 `inspect` 和 `export`;Fake 工具调用、第二轮文本响应、完成状态、 + 事件 JSONL 和 checkpoint 清理均实际可观察,临时数据库已清理。 + +## 2026-09-05 工具调用持久化(当前) + +- [x] Host Fake loop 的 `ToolRequested`/`ToolCompleted` 已写入 `tool_calls` durable 表, + `RuntimeService` 和 `AgentHost::list_tool_calls` 提供 create/complete/get/list facade;重复 identity 幂等,旧 run 缺失 + row 时可回填,SQLite JSONL 导出实际包含 `tool_call` 行。 +- [x] Host、Runtime、Storage 定向回归与 CLI export smoke 通过;Runtime 当前 36 个测试。 + +## 2026-09-06 MCP server-request 边界(当前) + +- [x] stdio 同步轮询和后台通知订阅遇到 server request 时都返回 `Unsupported`,不再静默 + pending 空转;Host 对 tools/call 已 dispatch 的 `Unsupported` 映射为 `Unknown`。 +- [x] MCP 当前 52 个测试,覆盖同步 poll、后台订阅传播、post-dispatch 错误分类和有界协议 + 版本候选协商(含真实本地 HTTP 重建);sampling/elicitation 自动 handler 仍明确留在 + 上层适配器边界。 + +## 2026-09-06 Codex/MCP 边界硬化(历史快照) + +- [x] Codex post-dispatch cancel 错误分类、Codex wire notification alias、argv 空/控制前缀 + 拒绝和 Host handler 的 context/cancel 前置门禁均已回归;当时 Codex 86、Host 69、MCP 49。 +- [x] 这些修改只扩大本地协议/安全合同,不声明真实 Codex 版本兼容或协议级 interrupt 已 + 完成;未知方法和未实现双向扩展仍 fail-closed。 + +## 2026-09-06 Codex 请求生命周期(当前) + +- [x] Backend request 成功、post-dispatch 失败和匹配 cancel 都通过中立 + `CodexSessionMetadataSink::persist_lifecycle` 观察;默认旧 sink 仍可只实现 thread/turn + metadata。 +- [x] 新增 `agent-runtime-contracts` crate,承接 DurableStore command/view/trait; + `agent-runtime` 已完成 portable 化,SQLite-specific Service/adapter 位于 + `agent-runtime-sqlite`。 +- [x] 长连接 accepted response 不再无条件写 terminal `completed`:child 仍存活时为 `active`; + 自然回收后的 child exit code 可通过 Process API 读取,非零退出继续保留原错误分类。 +- [x] ProcessControl 的 cancel outcome 参与 lifecycle 记录;迟到的 idle cancel 不会覆盖 + 已完成 observation。 +- [x] Host sink 读-合并-写 `external_sessions.metadata`,保留已有自定义审计字段,并验证 + terminal 状态不会被 late metadata/lifecycle 复活;回归覆盖完成状态、external ID、退出码 + 和取消结果字段。 +- [x] `from_host` 创建的中立/0.152.1 typed Codex handler 将工具调用写入同一 run 的 + `tool_calls`;相同 call_id 的已完成结果返回缓存,in-flight 重复请求拒绝,执行失败收束 + 为 `error`,而 `new` 构造器保持无 Runtime 兼容。 +- [x] Codex 101/101、Host 80/80 单测 + 7 个消息持久化集成回归通过,Clippy `-D warnings` 通过;其中 + `app_server_process_lifecycle_sink_reports_timeout` 与 + `app_server_process_lifecycle_sink_distinguishes_reader_eof` 覆盖 timeout/EOF lifecycle sink。 +- [x] ProcessControl 进程 sink 覆盖自然退出/显式终止的 emit-once、退出码和 session sink 转发。 +- [x] reader EOF 与 reader I/O 错误在 ProcessControl lifecycle reason 中分开记录。 +- [ ] 协议级 turn interrupt 和真实 Codex generated wire/session 仍未验收。 + +- [x] 通用 `JsonRpcAppServerRouter` 及其 `CodexAppServerProcessRouter` 进程接线回归覆盖 + 乱序 response、通知/response 混合、timeout 释放 pending 槽位、独立 `turn/interrupt` + 参数、cancel 唤醒 pending 和 scoped remote error 复用;不替代真实发行版 wire/session 验收。 + +## 2026-09-06 工具调用联合事务(历史快照) + +- [x] `DurableToolCallRuntimeCommit` 将工具调用行与 Core runtime snapshot/events 放入 + 单个 SQLite IMMEDIATE 事务;Storage 覆盖成功、错 lease、CAS 回滚、重复 identity + 幂等和 legacy row 回填,Host Fake loop 已使用该路径。 +- [x] `DurableToolCallCheckpointRuntimeCommit` 进一步支持在调用方同时拥有 checkpoint 时, + 于单个 IMMEDIATE 事务合并工具行、checkpoint 和 runtime snapshot/events;新增成功、完成、 + 错 lease 与 CAS 回滚回归。 +- [x] Host `ToolCompleted` trace 在读取到当前 checkpoint 时已使用该联合事务;无 checkpoint 的 + 旧 run 保留兼容 tool/runtime 路径;首次 `awaiting_approval` checkpoint 会和 + `ToolRequested`、工具行及 Core runtime event 一起提交。 +- [ ] Host 尚未把所有 Engine checkpoint、工具请求/结果和终态统一到一个全链路事务;最终 + Runtime/SQLite 依赖反转仍未完成。 + +## 2026-09-06 当前增量复核(历史快照) + +- `agent-runtime` 的通用 `DurableRuntime` 在 `--no-default-features` 下不再编译 SQLite + adapter;SQLite wrapper 仅在 `sqlite` feature 下导出,Runtime 8/8 portable 测试通过。 +- `agent-mcp` 的 52/52 测试覆盖有界协议版本候选、配置型 stdio/HTTP 重建和 Streamable HTTP + 协商头更新;仍不自动应答 server request、重连通知订阅或重放工具调用。 +- 以上是本地增量,不改变原始 P0–P6 的部分完成结论:远端独立仓库/CI、真实 Provider/Codex + wire/session、自动外部对账、完整持久化多 Agent 调度和最终 Runtime/SQLite crate 拆分仍未验收。 + +## 2026-09-06 Codex router 进程接线(当前) + +- [x] CodexAppServerProcessRouter 将通用 JsonRpcAppServerRouter 的并发 request、 + 有界 notification、turn/interrupt 接入现有 ProcessControl 管理的真实 stdio child; + timeout、协议错误、cancel、terminate 和 Drop 均有界收束并回收 I/O worker。 +- [x] 本地 sh 长连接 fixture 覆盖 request、notification、turn interrupt 和退出回收; + Codex 定向测试 all-features 101/101、no-default-features 101/101。 +- [ ] 仍不声明具体 Codex 发行版 generated wire/session 兼容,也不自动重连或重放; + 这些能力继续需要专用版本适配器及外部证据。 + +## 2026-09-06 工具调用、Checkpoint 与 Runtime 联合事务(历史快照) + +- [x] 中立 `DurableToolCallCheckpointRuntimeCommit` 将工具调用、checkpoint 与 Core + runtime snapshot/events 及 worker lease 作为一个拥有值 command; + `DurableStore`、`DurableRuntime` 和 `RuntimeService` 均提供 create/complete facade。 +- [x] SQLite adapter 在一个 `IMMEDIATE` 事务内执行 lease fencing、runtime CAS/事件重放、 + checkpoint upsert 和工具行写入;Storage 回归覆盖成功提交、完成提交、错 lease 和 CAS + 失败整体回滚(all-features 7+53,直接 no-default 6+36)。 +- [x] Host `ToolCompleted` trace 在已有当前 checkpoint 时复用该联合合同;没有 checkpoint + 的旧 run 保留兼容 tool/runtime 路径;首次 `awaiting_approval` checkpoint 会和 + `ToolRequested`、工具行及 Core runtime event 一起提交。 +- [ ] 现有 Host 仍未把所有 Engine checkpoint、工具请求/结果和终态统一到一个全链路事务;自动 + 外部对账、非 SQLite 生产 adapter 及最终 Runtime/Host 拆分仍未验收。 + +## 2026-09-06 P6 编排取消控制面(当前) + +- [x] `Coordinator::cancel_run` 与 `PersistentCoordinator::cancel_run` 已将活动任务原子 + 标记为 `Cancelled` 并释放配额;持久入口使用 revision-CAS,失败不泄漏图或索引状态。 +- [x] 回归覆盖低层取消、持久快照重开、依赖任务 ready 阻塞和配额释放;Orchestration + 定向测试为 40/40。 +- [ ] 该操作不替代 Runtime/worker 的 cooperative cancel,也不实现自动多 Agent 调度或 + 跨主机恢复;这些仍是 P6 的开放范围。 + +## 2026-09-06 Runtime facade ownership 接缝(历史快照) + +- [x] `RuntimeService` 内部实际拥有 `DurableRuntime`;SQLite + convenience 方法统一经 generic facade 访问同一 adapter,不再维护平行裸 store 字段。 +- [x] `RuntimeService::into_durable_runtime()` 提供 Host/调度器的显式所有权交接;回归覆盖 + 消费 convenience shell 后继续查询同一 run,Runtime all-features 36/36 通过。 +- [ ] 该接缝不等同最终 Runtime/SQLite crate 拆分或 Host 全量 generic 化;非 SQLite 生产 + adapter、历史兼容 API 清理和远端验收仍未完成。 + +## 2026-09-06 Runtime/SQLite 物理拆分完成(当前) + +- [x] 新增 `agent-runtime-sqlite` 承接 SQLite-specific `RuntimeService`、记录/错误转换、 + `SqliteDurableStore` 和 28 个 SQLite runtime 测试;Host 已迁移到该 crate,未保留 + `agent_runtime::RuntimeService` 的平行 re-export。 +- [x] `agent-runtime` 已删除 SQLite feature、`agent-storage-sqlite` 和 `rusqlite` 依赖, + 只保留 portable `DurableRuntime`、`RuntimeSnapshotService`、`WorkerLease`、 + `RunHandle` 与 `RuntimeError`;`cargo tree -p agent-runtime --no-default-features` + 不含 SQLite 相关依赖。 +- [x] breaking API 迁移后的 all/no-default workspace 测试、portable Runtime 8、SQLite + Runtime 31、Host 80 单测 + 7 集成、Clippy、rustdoc、fmt、依赖边界和独立复制检查均通过。 +- [x] Host manifest 已移除对 portable `agent-runtime` 的直接依赖,仅经 + `agent-runtime-sqlite` 装配 SQLite Service 和 opaque lease;metadata 依赖方向复核通过。 + +## 2026-09-06 Host 终态与后台队列 metadata 回归(当前) + +- [x] 纯文本成功终态的 assistant message 与 completed event 通过同一终态事务提交; + 带工具、审批、in-flight、取消或错误的路径不提前合并外部边界。Host 当前 80/80 单测 + 7 集成。 +- [x] `prepare_run_with_messages_and_metadata` 只接受有界非敏感 JSON object;CLI 后台 + 队列记录实际 provider、effective model 和 providerKind,不再把 OpenAI/custom model + 标成默认 fake;带 marker 的队列在 claim 前拒绝模型错配且保持 queued,legacy metadata + 继续兼容。CLI 当前 24/24,Host 当前 80/80 单测 + 7 集成。 +- [ ] 这仍不等于所有 Engine checkpoint、工具 trace 和终态的全链路单事务;未知外部副作用 + 仍必须显式对账,真实 Provider/Codex 和远端验收不在本地 fixture 范围内。 + +## 2026-09-06 多工具 checkpoint 游标回归(当前) + +- [x] `ToolCompleted` trace 只有在整个事件序列的最后一个工具结果处复用最终 Safe + checkpoint;中间结果不会将未来工具结果的游标写入当前 Core snapshot。 +- [x] `multi_tool_trace_only_reuses_safe_checkpoint_for_final_result`、Host 80/80 单测 + 7 集成、双特性 + workspace all-targets、Clippy、Rustdoc、fmt、依赖边界和独立复制检查均通过。 + +## 2026-09-06 agent-app 配置边界(当前) + +- [x] `agent-app` 独立承接 `AgentTomlConfig` 及嵌套 Skill/MCP/Codex 配置、配置文件与环境 + 优先级、OpenAI endpoint/base URL、effective model 和 queued metadata;它不依赖 + Host、Runtime、线程或数据库。 +- [x] CLI 保留 command dispatch、Host/worker 生命周期和输出协议,只导入 `agent-app` 的 + 配置 API,未引入平行运行状态。`agent-app` 配置回归 3/3,CLI 定向测试 24/24。 +- [x] 新增 package 后当前 workspace 为 15 个 crate;新增 manifest/依赖边界门禁需按该 + 当前数量执行,历史段落中的 13/14 个 crate 计数仅保留原始验收快照。 +- [ ] `agent-app` 只完成配置/装配输入边界,不宣称 Host 全量装配或原始计划中的真实 + Provider/Codex、自动外部对账、远端 CI/registry/许可证验收。 + +## 2026-09-06 有界外部会话对账(当前) + +- [x] `AgentHost::reconcile_external_sessions` 提供固定候选状态、硬 limit 和稳定顺序; + resolver 只能通过显式接口返回 Pending/NotFound/Completed,避免 Host 隐式 invoke。 +- [x] Completed 仅接受包含 checkpoint 完整前缀的消息历史并推进 safe 游标,随后仍需调用方 + 显式 requeue;Pending/NotFound 保守保留 `unknown`。新增完成、挂起、不存在和重复扫描 + 回归,Host 80/80 单测 + 7 集成通过。 +- [ ] 未接入具体 Provider/Codex 自动查询、webhook 或跨进程 scheduler;这些仍需要外部适配器 + 和部署语义,不能据此宣称原始计划整体验收通过。 + +## 2026-09-06 Codex app-server 真实握手证据(当前) + +- [x] 隔离 `CODEX_HOME`、无 API key 的本机 `codex-cli 0.153.4` 真实 stdio 进程已完成 + `initialize`、`initialized`、`thread/start`;拿到版本化 user-agent 与 string thread ID。 +- [x] 证据固化为可选 `scripts/probe-codex-app-server.sh`,脚本只输出通过/失败摘要并自动 + 清理临时目录,不进入默认 workspace 测试或 CI。 +- [ ] 未执行 `turn/start`、真实 Provider 请求或工具/审批副作用;仓库 `codex_0_152_1` + typed adapter 对 0.153.4 的完整 generated wire/session 兼容仍未证明。 + +## 2026-09-06 合并门禁(当前) + +- [x] workspace 当前 15 个 crate;all/no-default all-targets、两套 Clippy、Rustdoc、fmt、 + 依赖/manifest、独立复制、编码和 diff 检查通过。当前计数包含 agent-app 3、CLI 24、 + Host 80 单测 + 7 集成,以及既有 Codex 101、MCP 52、OpenAI 29、Runtime portable 8/SQLite 31。 +- [x] 当前 `Cargo.lock` SHA-256 为 + `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`;固定 + `cargo-audit 0.22.2`/RustSec advisory-db `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5` + 扫描当前锁文件 191 个依赖,退出码 0、无漏洞/warning;远端持续审计仍需 CI 记录。 + +## 2026-09-06 审批 checkpoint 联合事务(当前) + +- [x] `DurableApprovalCheckpointRuntimeCommit` 将 pending approval 与已存在的 + `awaiting_approval` checkpoint/runtime 快照放进同一 SQLite `IMMEDIATE` 事务校验和落盘; + Host Ask 路径已接入,不追加重复 Core event。 +- [x] `agent-runtime-sqlite` 回归 31/31 覆盖成功、同 identity 幂等、错 lease、revision CAS + 冲突和 checkpoint identity 错配;Host 80/80 单测 + 7 个消息持久化集成回归通过。 +- [ ] Engine 在 checkpoint callback 后才生成 approval binding,callback→token 的窄窗口仍 + 不能原子化;该增量不等于所有 checkpoint/工具/终态的全链路事务。 + +## 2026-09-06 当前最终事实(权威) + +- workspace 当前为 15 个 crate;all/no-default all-targets、两套 Clippy、Rustdoc、fmt、 + 依赖/manifest、独立复制、编码和 diff 检查通过。 +- 当前计数:agent-app 3、CLI 24、Host 80 单测 + 7 集成、Runtime portable 8、Runtime SQLite 31、 + Codex 101、MCP 52、OpenAI 29、Fake 4、Engine 55、Orchestration 40、Skill 28、 + Core 30+20+1、Storage 7+53。 +- 当前 lock SHA-256 为 `c9b14957f7e0edda46073f1b2a0ff06e084b0d747af118a29654ca1dc1022711`; + 固定 cargo-audit/RustSec 快照扫描 191 个依赖通过。原始计划的远端仓库/CI、正式许可证、 + 真实上游会话和完整跨主机多 Agent 调度仍未验收。 + +## 2026-09-06 有界外部会话对账(当前) + +- [x] `AgentHost::reconcile_external_sessions` 提供固定候选状态、硬 limit 和稳定顺序; + resolver 只能通过显式接口返回 Pending/NotFound/Completed,避免 Host 隐式 invoke。 +- [x] Completed 仅接受包含 checkpoint 完整前缀的消息历史并推进 safe 游标,随后仍需调用方 + 显式 requeue;Pending/NotFound 保守保留 `unknown`。新增完成、挂起、不存在和重复扫描 + 回归,Host 80/80 单测 + 7 个消息持久化集成回归通过。 +- [ ] 未接入具体 Provider/Codex 自动查询、webhook 或跨进程 scheduler;这些仍需要外部适配器 + 和部署语义,不能据此宣称原始计划整体验收通过。 diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 000000000..c2294d8fb --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.96.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/rust/scripts/check-dependencies.sh b/rust/scripts/check-dependencies.sh new file mode 100755 index 000000000..091735a70 --- /dev/null +++ b/rust/scripts/check-dependencies.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 这是一个不依赖额外 cargo 插件的轻量依赖审计:它锁定 workspace 的 +# 可解析依赖,并把最敏感的 kernel 反向依赖检查放在独立脚本中,供本地和 +# Gitea CI 复用。它不替代带漏洞数据库的 cargo-audit/cargo-deny。 +if (($# > 1)); then + echo "用法:$0 [workspace/Cargo.toml]" >&2 + exit 2 +fi + +# 默认始终检查脚本所属 workspace,而不是把调用者的 cwd 当成 workspace。 +# 这样从仓库外执行 `.../check-dependencies.sh` 也会得到同一份 Cargo.lock。 +caller_pwd="$(pwd -P)" +script_path="${BASH_SOURCE[0]}" +case "$script_path" in + /*) ;; + *) script_path="$caller_pwd/$script_path" ;; +esac +script_dir="$(cd -- "$(dirname -- "$script_path")" && pwd -P)" +script_workspace_root="$(cd -- "$script_dir/.." && pwd -P)" + +manifest_input="${1:-}" +if [[ -z "$manifest_input" ]]; then + workspace_manifest="$script_workspace_root/Cargo.toml" +elif [[ "$manifest_input" == /* ]]; then + workspace_manifest="$manifest_input" +elif [[ -f "$caller_pwd/$manifest_input" ]]; then + # 保留显式相对路径相对于调用者 cwd 的直觉语义(例如从父仓库传 rust/Cargo.toml)。 + workspace_manifest="$caller_pwd/$manifest_input" +else + # 若调用者 cwd 没有该文件,再把相对路径解释为脚本 workspace 根目录下的路径。 + workspace_manifest="$script_workspace_root/$manifest_input" +fi + +manifest_dir="$(cd -- "$(dirname -- "$workspace_manifest")" && pwd -P)" +workspace_manifest="$manifest_dir/$(basename -- "$workspace_manifest")" +# 显式传入其它 workspace 时,边界检查应以该 manifest 所在目录为根,而不是 +# 脚本所属 workspace;这也让复制验收和独立调用保持同一语义。 +workspace_root="$manifest_dir" + +[[ -f "$workspace_manifest" && -r "$workspace_manifest" ]] || { + echo "workspace manifest 不存在或不可读:$workspace_manifest" >&2 + exit 1 +} + +core_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" --edges normal -p agent-runtime-core)" + +for forbidden in tokio reqwest rusqlite mcp codex agent-mcp agent-codex; do + # 既匹配精确 crate 名,也匹配同前缀的实现 crate(例如 tokio-util)。 + if grep -Eiq "(^|[[:space:]])${forbidden}(-[^[:space:]]*)?([[:space:]]|$)" <<<"$core_tree"; then + echo "agent-runtime-core unexpectedly depends on ${forbidden} family" >&2 + exit 1 + fi +done + +# Durable command/view contracts must remain database and transport neutral too. +# The portable runtime facade now shares that boundary; SQLite-specific service +# assembly lives in the sibling agent-runtime-sqlite crate. +contracts_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" --edges normal -p agent-runtime-contracts)" +for forbidden in tokio reqwest rusqlite agent-mcp agent-codex agent-runtime; do + if grep -Eq "(^|[[:space:]])${forbidden}([[:space:]]|$)" <<<"$contracts_tree"; then + echo "agent-runtime-contracts unexpectedly depends on ${forbidden}" >&2 + exit 1 + fi +done + +# The portable runtime must stay usable without SQLite. The SQLite-specific +# service is intentionally a sibling crate; checking the package in isolation +# prevents workspace feature unification from hiding an accidental dependency. +portable_runtime_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" \ + --no-default-features --edges normal -p agent-runtime)" +for forbidden in rusqlite agent-storage-sqlite; do + if grep -Eq "(^|[[:space:]])${forbidden}([[:space:]]|$)" <<<"$portable_runtime_tree"; then + echo "agent-runtime portable facade unexpectedly depends on ${forbidden}" >&2 + exit 1 + fi +done + +# Conversely, the SQLite service must visibly depend on both portable runtime +# contracts and the concrete storage adapter. This guards the intended +# one-way dependency direction while keeping Core/Engine independent. +sqlite_runtime_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" \ + --edges normal -p agent-runtime-sqlite)" +for required in agent-runtime agent-storage-sqlite; do + if ! grep -Eq "(^|[[:space:]])${required}([[:space:]]|$)" <<<"$sqlite_runtime_tree"; then + echo "agent-runtime-sqlite is missing required dependency ${required}" >&2 + exit 1 + fi +done + +# The generic program configuration layer must remain below Host/Runtime. It +# may depend on concrete protocol configuration types, but it must not acquire +# durable state, worker lifecycle or the CLI's application backend by accident. +app_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" \ + --edges normal -p agent-app)" +for forbidden in agent-host agent-runtime agent-runtime-sqlite agent-storage-sqlite rusqlite; do + if grep -Eq "(^|[[:space:]])${forbidden}([[:space:]]|$)" <<<"$app_tree"; then + echo "agent-app unexpectedly depends on ${forbidden}" >&2 + exit 1 + fi +done + +# 只允许 workspace 内部的 path crate 参与组装。用 cargo metadata 检查 +# manifest 路径和无 source 的 path 依赖,避免把当前父仓库路径误当成 +# 依赖(cargo tree 的根节点本身会显示这个路径)。 +cargo metadata --locked --manifest-path "$workspace_manifest" --format-version 1 \ + | WORKSPACE_ROOT="$workspace_root" python3 -c ' +import json +import os +import pathlib +import sys + +root = pathlib.Path(os.environ["WORKSPACE_ROOT"]).resolve() +metadata = json.load(sys.stdin) +packages = metadata["packages"] +workspace_ids = set(metadata["workspace_members"]) +names = {package["name"] for package in packages if package["id"] in workspace_ids} +for package in packages: + if package["id"] not in workspace_ids: + continue + manifest = pathlib.Path(package["manifest_path"]).resolve() + if root not in manifest.parents and manifest != root / "Cargo.toml": + raise SystemExit(f"package manifest escapes workspace: {manifest}") + for dependency in package["dependencies"]: + if dependency["source"] is None and dependency["name"] not in names: + package_name = package["name"] + dependency_name = dependency["name"] + raise SystemExit( + f"path dependency is outside workspace: {package_name} -> {dependency_name}" + ) +' + +echo "dependency boundary check passed" diff --git a/rust/scripts/check-package-manifests.sh b/rust/scripts/check-package-manifests.sh new file mode 100755 index 000000000..5411bb967 --- /dev/null +++ b/rust/scripts/check-package-manifests.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 只做本地发布前预检:验证 package 元数据、workspace 内部依赖版本和 +# `cargo package --list` 文件边界。它不访问 registry、不上传 crate,也不把 +# 尚未发布的内部依赖伪装成已经可安装的包。 + +usage() { + cat <<'EOF' +用法: + ./scripts/check-package-manifests.sh [workspace/Cargo.toml] + +检查内容: + - 所有 workspace package 都有版本、Rust edition、最低 Rust 版本、license 和 description; + - path dependency 只能指向 workspace 内部,并且带明确版本要求; + - 每个 package 的 cargo package 文件清单不包含 target、.git、数据库或环境文件。 + +该检查使用 `cargo package --offline --list --no-verify`,只验证本地 manifest +和打包边界;未显式设置 `CARGO_TARGET_DIR` 时,中间产物会放在 +`~/data/tmp`(可用 `AGENT_PACKAGE_TMPDIR` 覆盖)并在退出时清理。依赖其它内部 +crate 的完整 package 校验仍需目标 registry 先按依赖顺序发布这些 crate。 +EOF +} + +if (($# == 1)) && [[ "$1" == "-h" || "$1" == "--help" ]]; then + usage + exit 0 +fi + +if (($# > 1)); then + usage >&2 + exit 2 +fi + +caller_pwd="$(pwd -P)" +script_path="${BASH_SOURCE[0]}" +case "$script_path" in + /*) ;; + *) script_path="$caller_pwd/$script_path" ;; +esac +script_dir="$(cd -- "$(dirname -- "$script_path")" && pwd -P)" +script_workspace_root="$(cd -- "$script_dir/.." && pwd -P)" + +manifest_input="${1:-}" +if [[ -z "$manifest_input" ]]; then + workspace_manifest="$script_workspace_root/Cargo.toml" +elif [[ "$manifest_input" == /* ]]; then + workspace_manifest="$manifest_input" +elif [[ -f "$caller_pwd/$manifest_input" ]]; then + workspace_manifest="$caller_pwd/$manifest_input" +else + workspace_manifest="$script_workspace_root/$manifest_input" +fi + +manifest_dir="$(cd -- "$(dirname -- "$workspace_manifest")" && pwd -P)" +workspace_manifest="$manifest_dir/$(basename -- "$workspace_manifest")" +workspace_root="$manifest_dir" + +# `cargo package --list` 仍可能为 workspace 生成 target/package 中间文件。 +# 未显式指定 CARGO_TARGET_DIR 时,把它放到本轮临时目录并在退出时清理, +# 避免 manifest 预检污染仓库;调用方显式指定时则保留其生命周期和位置。 +package_target_dir_owned=0 +if [[ -z "${CARGO_TARGET_DIR:-}" ]]; then + package_tmp_parent="${AGENT_PACKAGE_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}" + mkdir -p -- "$package_tmp_parent" + package_target_dir="$(mktemp -d "${package_tmp_parent%/}/agent-package-manifests.XXXXXX")" + export CARGO_TARGET_DIR="$package_target_dir" + package_target_dir_owned=1 +fi + +cleanup_package_target() { + if (( package_target_dir_owned == 1 )) && [[ -n "${package_target_dir:-}" ]] && + [[ -d "$package_target_dir" ]]; then + rm -r -- "$package_target_dir" + fi +} +trap cleanup_package_target EXIT + +[[ -r "$workspace_manifest" ]] || { + echo "workspace manifest 不存在或不可读:$workspace_manifest" >&2 + exit 1 +} + +grep -Eq '^[[:space:]]*\[workspace\][[:space:]]*$' "$workspace_manifest" || { + echo "Cargo.toml 不是 workspace manifest:$workspace_manifest" >&2 + exit 1 +} + +metadata="$(cargo metadata --offline --locked --manifest-path "$workspace_manifest" \ + --format-version 1 --no-deps)" + +# Python 负责解析 Cargo metadata,避免用 grep 解析 TOML/JSON 时误判依赖。 +# 使用命令替换而不是 process substitution,确保 Python 校验失败时不会被 +# `mapfile` 吞掉退出码,也不会拿着不完整的名称列表继续打包检查。 +package_names_text="$(printf '%s\n' "$metadata" | \ + WORKSPACE_ROOT="$workspace_root" python3 -c ' +import json +import os +import pathlib +import sys + +root = pathlib.Path(os.environ["WORKSPACE_ROOT"]).resolve() +metadata = json.load(sys.stdin) +packages = metadata.get("packages", []) +workspace_ids = set(metadata.get("workspace_members", [])) +workspace_packages = [p for p in packages if p.get("id") in workspace_ids] +if not workspace_packages: + raise SystemExit("workspace 没有可检查的 package") +if len(workspace_packages) != len(workspace_ids): + raise SystemExit("workspace_members 与 package metadata 数量不一致") + +names = {p.get("name") for p in workspace_packages} +if len(names) != len(workspace_packages): + raise SystemExit("workspace package 名称重复") + +for package in sorted(workspace_packages, key=lambda item: item["name"]): + name = package.get("name", "") + if not name.strip(): + raise SystemExit("workspace package 缺少名称") + manifest = pathlib.Path(package["manifest_path"]).resolve() + if root not in manifest.parents or manifest == root / "Cargo.toml": + raise SystemExit(f"package manifest 不在 crates/ 下:{manifest}") + for field in ("version", "edition", "rust_version", "license", "description"): + if not str(package.get(field) or "").strip(): + raise SystemExit(f"{name} 缺少 package.{field}") + if package["edition"] != "2024": + raise SystemExit(f"{name} 使用了非 Rust 2024 edition:{package['edition']}") + if package["rust_version"] != "1.96": + raise SystemExit(f"{name} 的 rust-version 不是 1.96:{package['rust_version']}") + for dependency in package.get("dependencies", []): + if dependency.get("source") is not None: + continue + dependency_name = dependency.get("name", "") + dependency_path = dependency.get("path") + if dependency_name not in names: + raise SystemExit( + f"path dependency 指向 workspace 外部:{name} -> {dependency_name}" + ) + if not dependency_path or root not in pathlib.Path(dependency_path).resolve().parents: + raise SystemExit( + f"path dependency 路径越过 workspace 边界:{name} -> {dependency_name}" + ) + requirement = str(dependency.get("req") or "").strip() + if requirement in ("", "*", "latest"): + raise SystemExit( + f"path dependency 缺少明确版本要求:{name} -> {dependency_name}" + ) + print(name) +')" || exit 1 + +[[ -n "$package_names_text" ]] || { + echo "没有找到 workspace package" >&2 + exit 1 +} +mapfile -t package_names <<<"$package_names_text" + +for package in "${package_names[@]}"; do + package_files="$(cargo package --offline --locked --manifest-path "$workspace_manifest" \ + -p "$package" --allow-dirty --no-verify --list 2>/dev/null)" || { + echo "cargo package 文件清单失败:$package" >&2 + exit 1 + } + grep -Fxq 'Cargo.toml' <<<"$package_files" || { + echo "package 缺少 Cargo.toml:$package" >&2 + exit 1 + } + grep -Eq '(^|/)src/' <<<"$package_files" || { + echo "package 缺少 src 文件:$package" >&2 + exit 1 + } + if grep -Eiq '(^|/)(\.git|target|\.env|.*\.db([.-]|$)|.*\.sqlite([.-]|$))' \ + <<<"$package_files"; then + echo "package 清单包含不应发布的文件:$package" >&2 + exit 1 + fi + echo "package manifest passed: $package" +done + +echo "package manifest check passed: ${#package_names[@]} packages" diff --git a/rust/scripts/probe-codex-app-server.sh b/rust/scripts/probe-codex-app-server.sh new file mode 100755 index 000000000..c745e4788 --- /dev/null +++ b/rust/scripts/probe-codex-app-server.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 可选的 P5 真实 wire 探测:只验证本机 Codex app-server 的 initialize、 +# initialized 和 thread/start。它不发送 turn/start,不需要 API key,也不 +# 进入默认 CI;真实 Provider/工具副作用仍由调用方显式承担。 +codex_bin="${CODEX_BIN:-codex}" +expected_version="${CODEX_EXPECTED_VERSION:-0.153.4}" +tmp_parent="${AGENT_CODEX_PROBE_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}" +emit_schema=0 + +while (($# > 0)); do + case "$1" in + --schema) + emit_schema=1 + ;; + -h|--help) + echo "用法:$0 [--schema]" + echo " --schema 额外生成并摘要真实 app-server v2 schema(仍不发送 turn/start)" + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + exit 2 + ;; + esac + shift +done + +if ! command -v python3 >/dev/null 2>&1; then + echo "需要 python3 才能运行 Codex wire probe" >&2 + exit 1 +fi +if ! command -v "$codex_bin" >/dev/null 2>&1 && [[ "$codex_bin" != */* ]]; then + echo "找不到 Codex 可执行文件:$codex_bin" >&2 + exit 1 +fi + +version_output="$($codex_bin --version 2>/dev/null || true)" +if [[ "$version_output" != *"$expected_version"* ]]; then + echo "Codex 版本不匹配:期望包含 $expected_version" >&2 + exit 1 +fi + +mkdir -p -- "$tmp_parent" +tmp_parent="$(cd -- "$tmp_parent" && pwd -P)" +probe_dir="$(mktemp -d "$tmp_parent/codex-app-server-probe.XXXXXX")" +cleanup() { + # probe_dir 是本次脚本刚创建的精确目录,不触碰 tmp_parent 中其它文件。 + rm -rf -- "$probe_dir" +} +trap cleanup EXIT +mkdir -p -- "$probe_dir/home" "$probe_dir/workspace" "$probe_dir/tmp" + +if ((emit_schema == 1)); then + mkdir -p -- "$probe_dir/schema" + CODEX_HOME="$probe_dir/home" TMPDIR="$probe_dir/tmp" "$codex_bin" \ + app-server generate-json-schema --out "$probe_dir/schema" --experimental \ + >/dev/null + schema_file="$probe_dir/schema/codex_app_server_protocol.v2.schemas.json" + [[ -s "$schema_file" ]] || { + echo "Codex schema probe 未生成 v2 schema" >&2 + exit 1 + } + schema_bytes="$(wc -c < "$schema_file")" + schema_sha="$(sha256sum "$schema_file" | awk '{print $1}')" + echo "Codex app-server schema: version=$expected_version bytes=$schema_bytes sha256=$schema_sha" +fi + +CODEX_HOME="$probe_dir/home" TMPDIR="$probe_dir/tmp" python3 - "$codex_bin" \ + "$probe_dir/home" "$probe_dir/workspace" <<'PY' +import json +import os +import selectors +import signal +import subprocess +import sys +import time + +codex_bin, codex_home, workspace = sys.argv[1:] +env = dict(os.environ) +env["CODEX_HOME"] = codex_home +env["TMPDIR"] = os.environ.get("TMPDIR", os.path.join(codex_home, "tmp")) + +process = subprocess.Popen( + [codex_bin, "app-server", "--stdio"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + env=env, + cwd=workspace, + start_new_session=True, +) +selector = selectors.DefaultSelector() +selector.register(process.stdout, selectors.EVENT_READ) + + +def send(frame): + process.stdin.write(json.dumps(frame, separators=(",", ":")) + "\n") + process.stdin.flush() + + +def read_until(frame_id, timeout=8.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ready = selector.select(max(0.0, deadline - time.monotonic())) + if not ready: + continue + line = process.stdout.readline() + if not line: + break + try: + frame = json.loads(line) + except json.JSONDecodeError as error: + raise RuntimeError(f"返回了非法 JSON({error.msg})") from error + if frame.get("id") != frame_id: + continue + if "error" in frame: + error = frame["error"] + raise RuntimeError(f"request {frame_id} 失败(code={error.get('code', 'unknown')})") + return frame.get("result") + raise RuntimeError(f"等待 request {frame_id} 响应超时") + + +try: + send( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": {"name": "agent-runtime-probe", "version": "0.1.0"}, + "capabilities": {}, + }, + } + ) + initialize = read_until(1) + if not isinstance(initialize, dict) or not isinstance(initialize.get("userAgent"), str): + raise RuntimeError("initialize result 缺少 userAgent") + + send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + send( + { + "jsonrpc": "2.0", + "id": 2, + "method": "thread/start", + "params": {"cwd": workspace, "approvalPolicy": "never"}, + } + ) + thread_start = read_until(2) + thread = thread_start.get("thread") if isinstance(thread_start, dict) else None + if not isinstance(thread, dict) or not isinstance(thread.get("id"), str): + raise RuntimeError("thread/start result 缺少 string thread.id") + print("Codex app-server probe passed: initialize + thread/start") +finally: + try: + process.stdin.close() + except Exception: + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=2) +PY + +echo "Codex app-server probe finished (version=$expected_version)" diff --git a/rust/scripts/run-agent-capability-set.sh b/rust/scripts/run-agent-capability-set.sh new file mode 100755 index 000000000..6eb7db260 --- /dev/null +++ b/rust/scripts/run-agent-capability-set.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 逐项运行 Agent 能力回归;测试定义只引用 Rust workspace 中已经存在的测试。 +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +workspace_root="$(cd -- "$script_dir/.." && pwd)" +dataset="$workspace_root/tests/agent-capability-set.jsonl" +# 生成的数据库、日志和 Cargo 输出默认放在 `~/data/tmp`;调用方仍可通过 +# `TMPDIR` 或 AGENT_TEST_TMPDIR 显式选择临时父目录。 +tmp_parent="${AGENT_TEST_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}" + +usage() { + cat <<'EOF' +用法: + ./scripts/run-agent-capability-set.sh 运行全部 Agent 能力用例 + ./scripts/run-agent-capability-set.sh --list 只列出用例,不运行 Cargo + +临时目录默认使用 ~/data/tmp(可由 TMPDIR 或 AGENT_TEST_TMPDIR 覆盖)。 +EOF +} + +list_only=0 +while (($# > 0)); do + case "$1" in + --list) + list_only=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +python3 - "$dataset" "$workspace_root" "$tmp_parent" "$list_only" <<'PY' +import json +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile +import re + +dataset = pathlib.Path(sys.argv[1]) +workspace = pathlib.Path(sys.argv[2]) +tmp_parent = pathlib.Path(sys.argv[3]).expanduser() +list_only = sys.argv[4] == "1" + +try: + cases = [ + json.loads(line) + for line in dataset.read_text(encoding="utf-8").splitlines() + if line.strip() + ] +except (OSError, json.JSONDecodeError) as error: + print(f"能力测试集读取失败:{error}", file=sys.stderr) + raise SystemExit(2) + +required = {"id", "package", "target", "filter", "capability"} +seen = set() +for case in cases: + missing = required - case.keys() + if missing or case["id"] in seen: + detail = f"缺少字段 {sorted(missing)}" if missing else "重复 id" + print(f"能力测试集格式错误:{case.get('id', '')}({detail})", file=sys.stderr) + raise SystemExit(2) + if case["target"] != "lib" and not case["target"]: + print(f"能力测试集格式错误:{case['id']} 的 target 为空", file=sys.stderr) + raise SystemExit(2) + seen.add(case["id"]) + +if list_only: + for case in cases: + print(f"{case['id']}: {case['package']} / {case['target']} / {case['filter']} · {case['capability']}") + raise SystemExit(0) + +tmp_parent.mkdir(parents=True, exist_ok=True) +suite_dir = pathlib.Path(tempfile.mkdtemp(prefix="agent-capability-set.", dir=tmp_parent)) +try: + (suite_dir / "tmp").mkdir() + env = os.environ.copy() + env["TMPDIR"] = str(suite_dir / "tmp") + env["CARGO_TARGET_DIR"] = str(suite_dir / "target") + env["CARGO_INCREMENTAL"] = "0" + # Harness output is parsed below; disable ANSI color so a successful test + # cannot be hidden behind escape sequences in the count check. + env["CARGO_TERM_COLOR"] = "never" + + passed = 0 + failed = 0 + for index, case in enumerate(cases, start=1): + target_args = ["--lib"] if case["target"] == "lib" else ["--test", case["target"]] + command = [ + "cargo", + "test", + "--locked", + "--manifest-path", + str(workspace / "Cargo.toml"), + "-p", + case["package"], + *target_args, + case["filter"], + ] + log_path = suite_dir / f"{index:02d}-{case['id']}.log" + with log_path.open("wb") as log: + result = subprocess.run( + command, + cwd=workspace, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + check=False, + ) + + # `cargo test ` exits zero even when the filter matches no + # tests. Treat that case as a failed capability check: a renamed or + # deleted test must not silently turn this gate into a no-op. The + # harness keeps its result summary in English regardless of the + # caller locale, and CARGO_TERM_COLOR=never above makes the patterns + # deterministic. + output = log_path.read_text(encoding="utf-8", errors="replace") + passed_tests = sum( + int(match.group(1)) + for match in re.finditer( + r"^test result:\s+ok\.\s+(\d+) passed;", + output, + flags=re.MULTILINE, + ) + ) + ran_tests = sum( + int(match.group(1)) + for match in re.finditer( + r"^running\s+(\d+) tests?$", output, flags=re.MULTILINE + ) + ) + if result.returncode == 0 and ran_tests > 0 and passed_tests > 0: + passed += 1 + print(f"PASS {case['id']}: {case['capability']}") + else: + failed += 1 + # 不打印 Cargo 输出,避免意外回显环境变量或 Provider 诊断内容。 + if result.returncode != 0: + reason = f"exit={result.returncode}" + elif ran_tests == 0 or passed_tests == 0: + reason = "未实际通过任何匹配测试(可能是 filter 漂移)" + else: + reason = "测试结果无法确认" + print(f"FAIL {case['id']}: {case['capability']} ({reason})") + + print(f"SUMMARY total={len(cases)} passed={passed} failed={failed}") + raise SystemExit(1 if failed else 0) +finally: + shutil.rmtree(suite_dir, ignore_errors=True) +PY diff --git a/rust/scripts/run-agent-llm-eval.sh b/rust/scripts/run-agent-llm-eval.sh new file mode 100755 index 000000000..36564003b --- /dev/null +++ b/rust/scripts/run-agent-llm-eval.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 最小真实 Provider eval。默认只列出用例;显式 --real 才会产生网络请求。 +# 临时状态默认写入 ~/data/tmp(优先尊重显式 TMPDIR;可用 AGENT_EVAL_TMPDIR 覆盖), +# 退出时清理本轮目录,不把构建或测试产物写到用户 home 的其它位置。 +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +workspace_root="$(cd -- "$script_dir/.." && pwd)" +dataset="$workspace_root/tests/agent-llm-eval.jsonl" +tmp_parent="${AGENT_EVAL_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}" +run_real=0 +list_only=0 +from_codex_config=0 + +usage() { + cat <<'EOF' +用法: + ./scripts/run-agent-llm-eval.sh --real # 调用 OpenAI-compatible Provider + ./scripts/run-agent-llm-eval.sh --real --from-codex-config # 使用当前 Codex 配置 + ./scripts/run-agent-llm-eval.sh --dataset tests/agent-llm-capability-eval.jsonl --real --from-codex-config + ./scripts/run-agent-llm-eval.sh --list # 列出用例,不联网 + +真实运行需要 OPENAI_API_KEY(或 OPENAI_API_KEY_ENV/AGENT_OPENAI_API_KEY_ENV +指向的变量)。脚本不会读取或打印密钥值;可用 AGENT_MODEL/OPENAI_MODEL 选模型。 +`--from-codex-config` 从 `$CODEX_HOME/config.toml`(默认 `~/.codex/config.toml`) +和同目录 `auth.json` 读取当前模型、Responses 网关和密钥,仅注入本轮子进程。 +EOF +} + +while (($# > 0)); do + case "$1" in + --dataset) + [[ $# -ge 2 ]] || { echo "--dataset 需要路径" >&2; exit 2; } + dataset="$2" + [[ "$dataset" = /* ]] || dataset="$workspace_root/$dataset" + shift + ;; + --real) run_real=1 ;; + --from-codex-config) from_codex_config=1 ;; + --list) list_only=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "未知参数:$1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if [[ "$list_only" == 1 ]]; then + python3 - "$dataset" <<'PY' +import json, sys +for line in open(sys.argv[1], encoding="utf-8"): + if line.strip(): + case = json.loads(line) + print(f"{case['id']}: {case['input']['stream'] and 'stream' or 'complete'}") +PY + exit 0 +fi + +if [[ "$run_real" != 1 ]]; then + echo "未执行:真实 eval 需要显式 --real(使用 --list 查看用例)" >&2 + exit 2 +fi + +if [[ "$from_codex_config" == 1 ]]; then + codex_home="${CODEX_HOME:-${HOME:?HOME 未设置}/.codex}" + codex_config="$codex_home/config.toml" + codex_auth="$codex_home/auth.json" + if [[ ! -r "$codex_config" || ! -r "$codex_auth" ]]; then + echo "当前 Codex 配置不完整:需要 config.toml 和 auth.json(不会显示内容)" >&2 + exit 2 + fi + # 只在进程内搬运配置;stdout 为 base64 字段,不会直接回显凭据。 + config_values="$(python3 - "$codex_config" "$codex_auth" <<'PY' +import base64, json, pathlib, sys, tomllib +config = tomllib.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +auth = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +provider_name = config.get("model_provider") +provider = (config.get("model_providers") or {}).get(provider_name or "", {}) +model = str(config.get("model") or "") +url = str(provider.get("base_url") or "") +key = str(auth.get("OPENAI_API_KEY") or "") +if not model or not url or not key: + raise SystemExit("Codex 配置缺少 model、provider base_url 或 OPENAI_API_KEY") +enc = lambda value: base64.b64encode(value.encode()).decode() +print("\t".join((model, enc(url), enc(key)))) +PY +)" + IFS=$'\t' read -r codex_model codex_base64_url codex_base64_key <<<"$config_values" + export AGENT_MODEL="$codex_model" + export OPENAI_BASE_URL="$(printf '%s' "$codex_base64_url" | base64 -d)" + export OPENAI_API_KEY="$(printf '%s' "$codex_base64_key" | base64 -d)" +fi + +key_name="${AGENT_OPENAI_API_KEY_ENV:-${OPENAI_API_KEY_ENV:-OPENAI_API_KEY}}" +if [[ -z "${!key_name:-}" ]]; then + echo "缺少真实 Provider 凭据环境变量:$key_name(不会显示其值)" >&2 + exit 2 +fi + +mkdir -p -- "$tmp_parent" +tmp_parent="$(cd -- "$tmp_parent" && pwd)" +suite_dir="$(mktemp -d "$tmp_parent/agent-llm-eval.XXXXXX")" +cleanup() { rm -rf -- "$suite_dir"; } +trap cleanup EXIT +export TMPDIR="$suite_dir/tmp" +export CARGO_TARGET_DIR="$suite_dir/target" +mkdir -p -- "$TMPDIR" + +python3 - "$dataset" "$workspace_root" "$suite_dir" <<'PY' +import json, os, pathlib, re, subprocess, sys + +dataset, workspace, suite_dir = map(pathlib.Path, sys.argv[1:]) + +def scrub_error(raw: bytes) -> str: + text = raw.decode("utf-8", errors="replace") + names = {"OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY_ENV", ""), os.environ.get("AGENT_OPENAI_API_KEY_ENV", "")} + for name in names: + if name: + value = os.environ.get(name, "") + if value: + text = text.replace(value, "[redacted]") + text = re.sub(r"(?i)(authorization|api[-_ ]?key|token|password)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text) + return next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "无错误详情") + +def run_case(case, case_dir): + env = os.environ.copy() + for name in ("AGENT_SKILL_ROOT", "AGENT_SKILL_ROOTS", "AGENT_SKILLS", "AGENT_MCP_STDIO_COMMAND", "AGENT_MCP_STDIO_ARGS", "AGENT_MCP_HTTP_URL", "AGENT_MCP_HTTP_HEADERS", "AGENT_MCP_ALLOW", "AGENT_SYSTEM_PROMPT", "AGENT_DEVELOPER_PROMPT", "AGENT_CONTEXT_PROMPT"): + env.pop(name, None) + env.update({"AGENT_PROVIDER": "openai", "AGENT_CONFIG": str(case_dir / "missing.toml"), "AGENT_DB": str(case_dir / "agent.db"), "AGENT_STREAM": "1" if case["input"].get("stream") else "0"}) + if case["input"].get("model"): + env["AGENT_MODEL"] = case["input"]["model"] + for field, env_name in (("system_prompt", "AGENT_SYSTEM_PROMPT"), ("developer_prompt", "AGENT_DEVELOPER_PROMPT"), ("context_prompt", "AGENT_CONTEXT_PROMPT")): + value = case["input"].get(field) + if value: + env[env_name] = value + args = ["cargo", "run", "--locked", "-q", "-p", "agent-cli", "--", "run", "--jsonl", "--stream" if case["input"].get("stream") else "--no-stream", case["input"]["prompt"]] + proc = subprocess.run(args, cwd=workspace, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False) + if proc.returncode: + raise RuntimeError(f"退出码 {proc.returncode}:{scrub_error(proc.stderr)}") + result = None + for line in proc.stdout.decode("utf-8", errors="replace").splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if record.get("type") == "result": + result = record.get("result") + if result is None: + raise RuntimeError("Provider 输出缺少 JSONL result 记录") + return result + +def check(case, result): + out = result.get("output") or {} + events = out.get("events") or [] + types = [e.get("type") for e in events] + assertion = case.get("assert", {}) + if assertion.get("status"): + # completed 是 Engine 的 Finished 语义;CLI 结果中不重复返回状态字段。 + if "finished" not in types: + raise AssertionError("缺少 finished 事件") + text = str(out.get("text") or "") + expected = assertion.get("final_text", {}) + if expected.get("mode") == "contains_all" and any(v not in text for v in expected.get("values", [])): + raise AssertionError("最终文本未满足 contains_all") + steps = int(out.get("steps") or 0) + bounds = assertion.get("steps", {}) + if steps < bounds.get("min", 0) or (bounds.get("max") is not None and steps > bounds["max"]): + raise AssertionError(f"steps={steps} 超出范围") + if len(out.get("stream_events") or []) < assertion.get("stream_events", {}).get("min", 0): + raise AssertionError("stream_events 不足") + tool_assert = assertion.get("tool_calls", {}) + completed_tools = [ + event.get("call") or {} + for event in events + if event.get("type") == "tool_requested" + ] + min_tools = int(tool_assert.get("min_completed", 0)) + max_tools = tool_assert.get("max_completed") + if len(completed_tools) < min_tools or (max_tools is not None and len(completed_tools) > int(max_tools)): + raise AssertionError(f"tool_requested 数量={len(completed_tools)} 超出范围") + for expected_tool in tool_assert.get("required", []): + matches = [call for call in completed_tools if call.get("name") == expected_tool.get("name")] + if not matches: + raise AssertionError(f"未调用工具 {expected_tool.get('name')}") + actual_args = matches[0].get("arguments") or {} + expected_args = expected_tool.get("arguments") or {} + if actual_args != expected_args: + raise AssertionError(f"工具 {expected_tool.get('name')} 参数不匹配") + if assertion.get("secrets_absent"): + for name in ("OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY_ENV", ""), os.environ.get("AGENT_OPENAI_API_KEY_ENV", "")): + value = os.environ.get(name, "") + if value and value in text: + raise AssertionError("最终文本疑似包含 Provider 凭据") + weights = case.get("score", {}).get("weights", {}) + observed = {"status": 1.0, "final_text": 1.0, "streaming": 1.0, "efficiency": 1.0, "tool_calls": 1.0} + total = sum(float(value) for value in weights.values()) + score = (sum(float(weights.get(name, 0)) * observed.get(name, 0.0) for name in weights) / total) if total else 1.0 + threshold = float(case.get("score", {}).get("pass_threshold", 1.0)) + if score < threshold: + raise AssertionError(f"score={score:.2f} 低于阈值 {threshold:.2f}") + return score + +cases = [json.loads(line) for line in dataset.read_text(encoding="utf-8").splitlines() if line.strip()] +passed = 0 +for case in cases: + case_dir = suite_dir / case["id"] + case_dir.mkdir(parents=True) + try: + score = check(case, run_case(case, case_dir)) + passed += 1 + print(f"PASS {case['id']} score={score:.2f}") + except Exception as exc: + print(f"FAIL {case['id']}: {exc}") +print(f"SUMMARY total={len(cases)} passed={passed} failed={len(cases)-passed}") +raise SystemExit(0 if passed == len(cases) else 1) +PY diff --git a/rust/scripts/run-agent-test-set.sh b/rust/scripts/run-agent-test-set.sh new file mode 100755 index 000000000..06d5052ff --- /dev/null +++ b/rust/scripts/run-agent-test-set.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 运行确定性的 Agent 回归集;真实 Provider 只在显式 --real 时调用。 +# 所有数据库、Cargo 临时文件和中间输出都放在明确的临时父目录下,退出时 +# 只清理本轮创建的子目录;默认使用 `~/data/tmp`(优先尊重调用方显式的 +# `TMPDIR`),也可通过 AGENT_TEST_TMPDIR 指定专用 runner 目录。 +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +workspace_root="$(cd -- "$script_dir/.." && pwd)" +dataset="$workspace_root/tests/agent-test-set.jsonl" +tmp_parent="${AGENT_TEST_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}" +run_real="${AGENT_TEST_REAL_PROVIDER:-0}" +run_cargo=1 +list_only=0 + +usage() { + cat <<'EOF' +用法: + ./scripts/run-agent-test-set.sh # Rust 全量测试 + 离线测试集 + ./scripts/run-agent-test-set.sh --quick # 只运行测试集,跳过 cargo test + ./scripts/run-agent-test-set.sh --real # 额外运行自有 OpenAI-compatible Provider + ./scripts/run-agent-test-set.sh --list # 列出测试用例 + +真实 Provider 需要在当前 shell 提供 OPENAI_API_KEY(或 +OPENAI_API_KEY_ENV/AGENT_OPENAI_API_KEY_ENV 指向的变量)。 +EOF +} + +while (($# > 0)); do + case "$1" in + --quick) + run_cargo=0 + ;; + --real) + run_real=1 + ;; + --list) + list_only=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [[ "$list_only" == 1 ]]; then + python3 - "$dataset" <<'PY' +import json +import sys + +for line in open(sys.argv[1], encoding="utf-8"): + if line.strip(): + case = json.loads(line) + suffix = "(可选真实 Provider)" if case.get("optional") else "" + print(f"{case['id']}: {case['provider']} / {'stream' if case['stream'] else 'complete'}{suffix}") +PY + exit 0 +fi + +mkdir -p -- "$tmp_parent" +tmp_parent="$(cd -- "$tmp_parent" && pwd)" +suite_dir="$(mktemp -d "$tmp_parent/agent-test-set.XXXXXX")" +mkdir -p -- "$suite_dir/tmp" +cleanup() { + # suite_dir 是本脚本刚创建的精确目录,不触碰 tmp_parent 中的其它内容。 + rm -rf -- "$suite_dir" +} +trap cleanup EXIT + +export TMPDIR="$suite_dir/tmp" +export CARGO_TARGET_DIR="$suite_dir/target" + +if [[ "$run_cargo" == 1 ]]; then + echo "[1/2] 运行 workspace 单元/集成测试" + # 脚本通常从父仓库调用;显式传 manifest,避免把调用者 cwd 当成 + # workspace 根目录(父仓库本身没有 Cargo.toml)。 + cargo test --locked --manifest-path "$workspace_root/Cargo.toml" \ + --workspace --all-features --no-fail-fast + cargo test --locked --manifest-path "$workspace_root/Cargo.toml" \ + --workspace --no-default-features --no-fail-fast +else + echo "[1/2] 已跳过 cargo workspace 测试(--quick)" +fi + +echo "[2/2] 运行 Agent 测试集" +python3 - "$dataset" "$workspace_root" "$suite_dir" "$run_real" <<'PY' +import json +import os +import pathlib +import re +import sqlite3 +import subprocess +import sys + +dataset = pathlib.Path(sys.argv[1]) +workspace = pathlib.Path(sys.argv[2]) +suite_dir = pathlib.Path(sys.argv[3]) +run_real = sys.argv[4] == "1" + + +def key_env_name(env): + return ( + env.get("AGENT_OPENAI_API_KEY_ENV") + or env.get("OPENAI_API_KEY_ENV") + or "OPENAI_API_KEY" + ) + + +def safe_stderr(raw, env): + """只给出脱敏诊断,避免 Provider 错误回显凭据或 URL 查询参数。""" + text = raw.decode("utf-8", errors="replace") + values = { + env.get("OPENAI_API_KEY", ""), + env.get(key_env_name(env), ""), + env.get("CODEX_API_KEY", ""), + } + for value in values: + if value: + text = text.replace(value, "[redacted]") + text = re.sub(r"(?i)(authorization|api[-_ ]?key|token|password)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text) + text = re.sub(r"https?://[^\s]+", "[endpoint redacted]", text) + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[-1] if lines else "无错误详情" + + +def invoke(args, env, timeout=180): + command = ["cargo", "run", "--locked", "-q", "-p", "agent-cli", "--", *args] + completed = subprocess.run( + command, + cwd=workspace, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + if completed.returncode != 0: + detail = safe_stderr(completed.stderr, env) + raise RuntimeError(f"退出码 {completed.returncode}:{detail}") + return completed.stdout + + +def isolated_env(case, case_dir): + env = os.environ.copy() + # 测试集只验证 Provider/Engine/SQLite,不继承调用 shell 的 Skill/MCP。 + for name in ( + "AGENT_SKILL_ROOT", + "AGENT_SKILL_ROOTS", + "AGENT_SKILLS", + "AGENT_MCP_STDIO_COMMAND", + "AGENT_MCP_STDIO_ARGS", + "AGENT_MCP_HTTP_URL", + "AGENT_MCP_HTTP_HEADERS", + "AGENT_MCP_ALLOW", + ): + env.pop(name, None) + env["AGENT_CONFIG"] = str(case_dir / "does-not-exist.toml") + env["AGENT_DB"] = str(case_dir / "agent.db") + env["AGENT_STREAM"] = "1" if case.get("stream") else "0" + if case["provider"] == "fake": + env["AGENT_PROVIDER"] = "fake" + env["AGENT_MODEL"] = "fake" + else: + env["AGENT_PROVIDER"] = "openai" + # 真实用例允许通过 AGENT_MODEL 或 OPENAI_MODEL 选择任意兼容模型。 + if case.get("model"): + env["AGENT_MODEL"] = case["model"] + return env + + +cases = [json.loads(line) for line in dataset.read_text(encoding="utf-8").splitlines() if line.strip()] +if run_real: + key_name = key_env_name(os.environ) + if not os.environ.get(key_name, "").strip(): + raise SystemExit( + f"真实 Provider 测试需要环境变量 {key_name};脚本不会读取或打印 key 值" + ) +passed = 0 +skipped = 0 +for index, case in enumerate(cases, 1): + if case["provider"] == "openai" and not run_real: + print(f" SKIP {case['id']}(需 --real,不会读取或显示密钥)") + skipped += 1 + continue + case_dir = suite_dir / case["id"] + case_dir.mkdir(parents=True, exist_ok=True) + env = isolated_env(case, case_dir) + args = ["run", "--stream" if case.get("stream") else "--no-stream", case["prompt"]] + try: + result = json.loads(invoke(args, env, timeout=240 if case["provider"] == "openai" else 60)) + output = result.get("output") or {} + events = output.get("events") or [] + event_types = [event.get("type") for event in events] + tool_completed = sum(event_type == "tool_completed" for event_type in event_types) + stream_events = len(output.get("stream_events") or []) + steps = int(output.get("steps") or 0) + text = str(output.get("text") or "") + run_id = result.get("run_id") + if not run_id: + raise AssertionError("结果缺少 run_id") + + # 用新连接读取落盘结果,防止只验证最终文本而漏掉 Runtime 消息重复。 + # 参数绑定且连接只读,不创建第二套业务状态或改动 CLI 的数据库。 + database_uri = pathlib.Path(env["AGENT_DB"]).resolve().as_uri() + "?mode=ro" + connection = sqlite3.connect(database_uri, uri=True) + try: + row = connection.execute( + "SELECT snapshot_json FROM runtime_states WHERE runtime_id = ?", + (result.get("runtime_id"),), + ).fetchone() + finally: + connection.close() + if row is None: + raise AssertionError("缺少已提交的 RuntimeSnapshot") + snapshot = json.loads(row[0]) + matching_runs = [run for run in snapshot["runs"] if run["runId"] == run_id] + if len(matching_runs) != 1 or matching_runs[0]["messages"] != output.get("messages"): + raise AssertionError("Runtime 消息与 Engine 输出不一致(顺序、缺失或重复)") + + expected = case.get("expect") or {} + if expected.get("status"): + inspected = json.loads(invoke(["inspect", run_id], env, timeout=30)) + if inspected.get("status") != expected["status"]: + raise AssertionError(f"run status={inspected.get('status')!r}") + if expected.get("exact_text") is not None and text != expected["exact_text"]: + raise AssertionError(f"最终文本不匹配:{text!r}") + if expected.get("non_empty_text") and not text.strip(): + raise AssertionError("最终文本为空") + if steps < expected.get("min_steps", 0): + raise AssertionError(f"steps={steps} 小于 {expected['min_steps']}") + if tool_completed < expected.get("min_tool_completed", 0): + raise AssertionError(f"tool_completed={tool_completed} 小于 {expected['min_tool_completed']}") + if stream_events < expected.get("min_stream_events", 0): + raise AssertionError(f"stream_events={stream_events} 小于 {expected['min_stream_events']}") + if expected.get("min_export_records"): + exported = invoke(["export", run_id], env, timeout=30) + records = [line for line in exported.decode("utf-8").splitlines() if line.strip()] + if len(records) < expected["min_export_records"]: + raise AssertionError(f"JSONL 记录数={len(records)} 小于 {expected['min_export_records']}") + print( + f" PASS {case['id']}: steps={steps}, tools={tool_completed}, " + f"stream_events={stream_events}, text_bytes={len(text.encode('utf-8'))}" + ) + passed += 1 + except (AssertionError, RuntimeError, json.JSONDecodeError, subprocess.TimeoutExpired) as error: + print(f" FAIL {case['id']}: {error}", file=sys.stderr) + raise SystemExit(1) + +print(f"测试集完成:通过 {passed},跳过 {skipped}") +if run_real: + print("真实 Provider smoke 已执行;它验证协议/持久化闭环,不替代模型质量评测。") +PY + +echo "Agent 测试集通过" diff --git a/rust/scripts/run-cargo-audit.sh b/rust/scripts/run-cargo-audit.sh new file mode 100755 index 000000000..a44d4f978 --- /dev/null +++ b/rust/scripts/run-cargo-audit.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 运行固定本地 advisory database 的离线漏洞审计。 +# +# RUSTSEC_ADVISORY_DB 必须由 runner/调用方预先提供;脚本不会联网、clone、 +# 更新或写入 advisory database。runner 也应预装并固定 cargo-audit 版本,必要 +# 时通过 CARGO_AUDIT_BIN 指向该可执行文件。这样 CI 不会因为自动 fetch DB +# 或工具版本漂移而产生不可复核的审计结果。 + +usage() { + cat <<'EOF' +用法: + RUSTSEC_ADVISORY_DB=/绝对路径/advisory-db ./scripts/run-cargo-audit.sh + +环境变量: + RUSTSEC_ADVISORY_DB runner 预先提供的本地 RustSec advisory database 目录,必填。 + 脚本使用 --no-fetch,不联网、不初始化、不更新此目录。 + CARGO_AUDIT_BIN 可选的 cargo-audit 可执行文件路径或 PATH 中的命令名;默认 + 为 cargo-audit。脚本会向该二进制传入 `audit` 子命令;runner + 应预装并固定其版本。 + +脚本从自身所在 workspace 的上一级解析 Cargo.toml/Cargo.lock,并在该目录运行; +调用者当前目录不会决定审计哪一个 Cargo.lock。 +EOF +} + +fail() { + echo "cargo-audit gate failed: $*" >&2 + exit 1 +} + +if (($# > 0)); then + case "$1" in + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + fail "未知参数:$1" + ;; + esac +fi + +caller_pwd="$(pwd -P)" || fail "无法解析调用目录" +script_path="${BASH_SOURCE[0]}" +case "$script_path" in + /*) ;; + *) script_path="$caller_pwd/$script_path" ;; +esac +script_dir="$(cd -- "$(dirname -- "$script_path")" && pwd -P)" \ + || fail "无法解析脚本目录" +workspace_root="$(cd -- "$script_dir/.." && pwd -P)" \ + || fail "无法解析 workspace 根目录" + +# 既检查文件存在,也检查 manifest 确实是 workspace manifest,避免把脚本复制 +# 到其它 Cargo 项目后静默审计错误的锁文件。 +[[ -f "$workspace_root/Cargo.toml" && -r "$workspace_root/Cargo.toml" ]] \ + || fail "workspace 根目录缺少可读 Cargo.toml:$workspace_root" +[[ -f "$workspace_root/Cargo.lock" && -r "$workspace_root/Cargo.lock" ]] \ + || fail "workspace 根目录缺少可读 Cargo.lock:$workspace_root" +grep -Eq '^[[:space:]]*\[workspace\][[:space:]]*$' "$workspace_root/Cargo.toml" \ + || fail "Cargo.toml 不是 workspace manifest:$workspace_root/Cargo.toml" + +db_input="${RUSTSEC_ADVISORY_DB:-}" +[[ -n "$db_input" ]] || fail "必须设置 RUSTSEC_ADVISORY_DB(runner 提供本地 advisory DB)" +case "$db_input" in + *$'\n'*|*$'\r'*) fail "RUSTSEC_ADVISORY_DB 不能包含换行" ;; +esac +if [[ "$db_input" == /* ]]; then + advisory_db="$db_input" +else + # 相对路径也固定解释为 workspace 根目录下的路径,不随调用 cwd 漂移。 + advisory_db="$workspace_root/$db_input" +fi +[[ -d "$advisory_db" ]] \ + || fail "advisory DB 不存在或不是目录:$advisory_db" +[[ -r "$advisory_db" ]] \ + || fail "advisory DB 不可读:$advisory_db" +advisory_db="$(cd -- "$advisory_db" && pwd -P)" \ + || fail "无法解析 advisory DB 路径:$advisory_db" + +audit_bin_input="${CARGO_AUDIT_BIN:-cargo-audit}" +[[ -n "$audit_bin_input" ]] || fail "CARGO_AUDIT_BIN 不能为空" +case "$audit_bin_input" in + *$'\n'*|*$'\r'*) fail "CARGO_AUDIT_BIN 不能包含换行" ;; +esac +if [[ "$audit_bin_input" == */* ]]; then + if [[ "$audit_bin_input" == /* ]]; then + audit_bin="$audit_bin_input" + else + # 在 cd 到 workspace 前解析相对可执行文件,避免 cwd 改变后找不到 fake/tool。 + audit_bin="$caller_pwd/$audit_bin_input" + fi + [[ -f "$audit_bin" && -x "$audit_bin" ]] \ + || fail "CARGO_AUDIT_BIN 不存在或不可执行:$audit_bin" +else + audit_bin="$(command -v "$audit_bin_input" 2>/dev/null || true)" + [[ -n "$audit_bin" ]] \ + || fail "找不到 cargo-audit 工具:$audit_bin_input(请由 runner 预装,或设置 CARGO_AUDIT_BIN)" + # PATH 里若使用相对目录(例如 PATH=.:$PATH),command -v 可能返回 + # 相对路径;在切换到 workspace 后先固定成调用时的绝对位置。 + case "$audit_bin" in + /*) ;; + *) audit_bin="$caller_pwd/$audit_bin" ;; + esac + [[ -f "$audit_bin" && -x "$audit_bin" ]] \ + || fail "找到的 cargo-audit 不可执行:$audit_bin" +fi + +cd -- "$workspace_root" || fail "无法进入 workspace 根目录:$workspace_root" +[[ "$(pwd -P)" == "$workspace_root" ]] \ + || fail "workspace 根目录解析漂移:期望 $workspace_root,实际 $(pwd -P)" + +echo "运行离线 cargo-audit:workspace=$workspace_root advisory_db=$advisory_db" +# cargo-audit 的可执行文件本身仍使用 cargo 子命令解析器,因此必须显式传入 +# `audit`;省略它会把 --no-fetch 当成顶层参数而失败。 +"$audit_bin" audit --no-fetch --db "$advisory_db" --file Cargo.lock diff --git a/rust/scripts/verify-independent-workspace.sh b/rust/scripts/verify-independent-workspace.sh new file mode 100755 index 000000000..d5c96f3c2 --- /dev/null +++ b/rust/scripts/verify-independent-workspace.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 在一个明确的临时目录复制 workspace(排除构建产物),再从复制品运行 +# metadata、依赖边界和 cargo check。这样可以发现误用父仓库相对路径的 +# crate,而不需要把当前目录初始化为第二个 Git 仓库或修改用户 refs。 +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +workspace_root="$(cd -- "$script_dir/.." && pwd)" +workspace_manifest="$workspace_root/Cargo.toml" +temp_parent="${AGENT_RUNTIME_VERIFY_TMPDIR:-${HOME:?HOME 未设置}/data/tmp}" +mkdir -p -- "$temp_parent" +# Resolve an optional relative override before using it as a cargo working +# directory. This keeps the copy check independent of the caller's cwd. +temp_parent="$(cd -- "$temp_parent" && pwd)" +copy_root="$(mktemp -d "$temp_parent/agent-runtime-independent.XXXXXX")" +cleanup() { + rm -rf -- "$copy_root" +} +trap cleanup EXIT + +if ! command -v tar >/dev/null 2>&1; then + echo "tar is required to create the isolated workspace copy" >&2 + exit 1 +fi + +# tar 的输入和输出目录都由上面的绝对路径确定,不会触碰 workspace 外的 +# 用户文件。除 target/.git 外,复制边界还明确过滤任意层级的环境文件、真实 +# agent.toml、SQLite 数据/WAL/SHM、日志、缓存和编辑器临时文件;精确的 +# agent.toml.example 不匹配这些规则,会保留为安全配置模板。 +tar -C "$workspace_root" \ + --exclude='./target' \ + --exclude='target' \ + --exclude='*/target' \ + --exclude='./.git' \ + --exclude='.git' \ + --exclude='*/.git' \ + --exclude='.env*' \ + --exclude='*/.env*' \ + --exclude='agent.toml' \ + --exclude='*/agent.toml' \ + --exclude='*.db' \ + --exclude='*.db-*' \ + --exclude='*.sqlite' \ + --exclude='*.sqlite-*' \ + --exclude='*.sqlite3' \ + --exclude='*.sqlite3-*' \ + --exclude='*.wal' \ + --exclude='*.shm' \ + --exclude='*-wal' \ + --exclude='*-shm' \ + --exclude='*-journal' \ + --exclude='*.log' \ + --exclude='*.log.*' \ + --exclude='logs' \ + --exclude='*/logs' \ + --exclude='cache' \ + --exclude='*/cache' \ + --exclude='*.cache' \ + --exclude='.cache' \ + --exclude='*/.cache' \ + --exclude='.pytest_cache' \ + --exclude='*/.pytest_cache' \ + --exclude='__pycache__' \ + --exclude='*/__pycache__' \ + --exclude='.tmp-test' \ + --exclude='*/.tmp-test' \ + --exclude='.tmp-*' \ + --exclude='*/.tmp-*' \ + --exclude='*.tmp' \ + --exclude='*.temp' \ + --exclude='*.swp' \ + -cf - . | tar -C "$copy_root" -xf - + +# Cargo/TLS 等工具会尊重 TMPDIR;显式创建复制品内的临时目录,避免在某些 +# runner 上因为目录不存在而回退到宿主机临时目录。 +mkdir -p -- "$copy_root/tmp" + +copied_manifest="$copy_root/Cargo.toml" +cargo metadata --locked --manifest-path "$copied_manifest" --format-version 1 \ + >/dev/null +# 故意不传 manifest,并从复制目录之外调用,回归检查依赖脚本不会把 caller +# cwd 当成 workspace 根目录。 +( + cd "$temp_parent" + "$copy_root/scripts/check-dependencies.sh" +) + +# 同一份复制品还要通过发布前 manifest/文件边界预检;这一步仍然只读本地 +# Cargo metadata 和 package 清单,不连接 registry,也不上传 crate。 +( + cd "$temp_parent" + TMPDIR="$copy_root/tmp" "$copy_root/scripts/check-package-manifests.sh" +) + +# 从复制目录之外调用 cargo,确保构建不依赖当前父仓库的工作目录; +# 使用本轮明确的临时父目录,避免把用户的 HOME 或仓库路径当作构建根。 +cd "$temp_parent" +TMPDIR="$copy_root/tmp" cargo check --locked --manifest-path "$copied_manifest" \ + --workspace --all-targets + +echo "independent workspace check passed (temporary copy will be cleaned: $copy_root)" diff --git a/rust/tests/agent-capability-set.jsonl b/rust/tests/agent-capability-set.jsonl new file mode 100644 index 000000000..cd07b766d --- /dev/null +++ b/rust/tests/agent-capability-set.jsonl @@ -0,0 +1,10 @@ +{"id":"tool-argument-validation","package":"agent-runtime-engine","target":"lib","filter":"invalid_tool_arguments_are_rejected_before_approval_or_execution","capability":"工具参数校验"} +{"id":"cooperative-cancellation","package":"agent-runtime-engine","target":"lib","filter":"injected_cancellation_is_observed_at_step_boundary","capability":"取消"} +{"id":"idempotent-tool-retry","package":"agent-runtime-engine","target":"lib","filter":"declared_idempotent_tool_can_retry_selected_timeout","capability":"幂等工具重试"} +{"id":"approval-durable-resume","package":"agent-host","target":"lib","filter":"approval_allow_is_durable_and_resumes_without_replaying_provider_call","capability":"审批恢复"} +{"id":"skill-explicit-activation","package":"agent-skills","target":"lib","filter":"shipped_skill_fixture_supports_metadata_first_explicit_activation","capability":"Skill 激活"} +{"id":"sqlite-restart-recovery","package":"agent-storage-sqlite","target":"storage","filter":"file_backed_restart_recovers_expired_provider_checkpoint","capability":"运行时持久化恢复"} +{"id":"runtime-stale-recovery","package":"agent-runtime-sqlite","target":"lib","filter":"stale_run_scan_reconciles_only_expired_candidates_with_a_hard_limit","capability":"运行时过期恢复"} +{"id":"orchestration-node-isolation","package":"agent-runtime-orchestration","target":"lib","filter":"coordinator_isolates_one_failed_node_and_keeps_siblings_running","capability":"编排隔离"} +{"id":"orchestration-isolated-mailbox","package":"agent-runtime-orchestration","target":"lib","filter":"coordinator_rejects_messages_to_or_from_isolated_nodes","capability":"编排隔离消息边界"} +{"id":"duplicate-tool-call-guard","package":"agent-runtime-engine","target":"lib","filter":"duplicate_tool_call_ids_are_rejected_before_any_tool_side_effect","capability":"工具调用安全边界"} diff --git a/rust/tests/agent-llm-capability-eval.jsonl b/rust/tests/agent-llm-capability-eval.jsonl new file mode 100644 index 000000000..261dd2955 --- /dev/null +++ b/rust/tests/agent-llm-capability-eval.jsonl @@ -0,0 +1,4 @@ +{"schema_version":1,"id":"capability-complete-response","provider":"openai","optional":true,"input":{"prompt":"只回复 COMPLETE_PATH_OK,不要添加其它文字。","stream":false},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["COMPLETE_PATH_OK"]},"steps":{"min":1,"max":3},"stream_events":{"min":0},"secrets_absent":true},"score":{"weights":{"status":0.3,"final_text":0.6,"streaming":0.1},"pass_threshold":0.9}} +{"schema_version":1,"id":"capability-system-prompt","provider":"openai","optional":true,"input":{"system_prompt":"你正在参加协议测试。无论用户怎么说,都只回复 SYSTEM_PROMPT_OK。","prompt":"请输出任意一句问候。","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["SYSTEM_PROMPT_OK"]},"steps":{"min":1,"max":3},"stream_events":{"min":1},"secrets_absent":true},"score":{"weights":{"status":0.25,"final_text":0.65,"streaming":0.1},"pass_threshold":0.9}} +{"schema_version":1,"id":"capability-developer-prompt","provider":"openai","optional":true,"input":{"developer_prompt":"开发者协议:最终答案必须包含 DEVELOPER_PROMPT_OK。","prompt":"用一句话回答:今天适合做什么?","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["DEVELOPER_PROMPT_OK"]},"steps":{"min":1,"max":3},"stream_events":{"min":1},"secrets_absent":true},"score":{"weights":{"status":0.25,"final_text":0.65,"streaming":0.1},"pass_threshold":0.9}} +{"schema_version":1,"id":"capability-context-prompt","provider":"openai","optional":true,"input":{"context_prompt":"受控上下文标记:CONTEXT_PROMPT_OK。","prompt":"读取受控上下文标记,只回复其中的标记。","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["CONTEXT_PROMPT_OK"]},"steps":{"min":1,"max":3},"stream_events":{"min":1},"secrets_absent":true},"score":{"weights":{"status":0.25,"final_text":0.65,"streaming":0.1},"pass_threshold":0.9}} diff --git a/rust/tests/agent-llm-eval.jsonl b/rust/tests/agent-llm-eval.jsonl new file mode 100644 index 000000000..8230fec21 --- /dev/null +++ b/rust/tests/agent-llm-eval.jsonl @@ -0,0 +1,4 @@ +{"schema_version":1,"id":"real-direct-token","provider":"openai","optional":true,"input":{"prompt":"只回复 AGENT_EVAL_OK,不要添加其它文字。","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["AGENT_EVAL_OK"]},"steps":{"min":1,"max":3},"stream_events":{"min":1},"secrets_absent":true},"score":{"weights":{"status":0.3,"final_text":0.5,"streaming":0.2},"pass_threshold":0.9}} +{"schema_version":1,"id":"real-structured-answer","provider":"openai","optional":true,"input":{"prompt":"请只回复两个词:France 42。不要添加其它文字。","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["France","42"]},"steps":{"min":1,"max":3},"stream_events":{"min":1},"secrets_absent":true},"score":{"weights":{"status":0.3,"final_text":0.5,"streaming":0.2},"pass_threshold":0.9}} +{"schema_version":1,"id":"real-stream-token","provider":"openai","optional":true,"input":{"prompt":"使用流式输出,只回复 STREAM_OK。","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["STREAM_OK"]},"steps":{"min":1,"max":3},"stream_events":{"min":1},"secrets_absent":true},"score":{"weights":{"status":0.25,"final_text":0.55,"streaming":0.2},"pass_threshold":0.9}} +{"schema_version":1,"id":"real-echo-tool","provider":"openai","optional":true,"input":{"prompt":"必须调用 echo 工具一次,参数 text 必须是 AGENT_TOOL_INPUT。工具返回后只回复 AGENT_TOOL_DONE。不要猜测或跳过工具调用。","stream":true},"assert":{"status":"completed","final_text":{"mode":"contains_all","values":["AGENT_TOOL_DONE"]},"steps":{"min":2,"max":4},"stream_events":{"min":1},"tool_calls":{"required":[{"name":"echo","arguments":{"text":"AGENT_TOOL_INPUT"}}],"min_completed":1,"max_completed":1},"secrets_absent":true},"score":{"weights":{"status":0.25,"final_text":0.35,"streaming":0.1,"tool_calls":0.3},"pass_threshold":0.9}} diff --git a/rust/tests/agent-test-set.jsonl b/rust/tests/agent-test-set.jsonl new file mode 100644 index 000000000..0b65bcb18 --- /dev/null +++ b/rust/tests/agent-test-set.jsonl @@ -0,0 +1,3 @@ +{"id":"fake-tool-loop","provider":"fake","stream":false,"prompt":"完成一次工具调用后给出最终回答。","expect":{"status":"completed","exact_text":"fake provider complete","min_steps":2,"min_tool_completed":1,"min_stream_events":0,"min_export_records":7}} +{"id":"fake-stream-loop","provider":"fake","stream":true,"prompt":"用流式模式完成一次工具调用和最终回答。","expect":{"status":"completed","non_empty_text":true,"min_steps":2,"min_tool_completed":1,"min_stream_events":1,"min_export_records":7}} +{"id":"real-text-smoke","provider":"openai","stream":true,"prompt":"请只回复 REAL_PROVIDER_OK,不调用工具。","optional":true,"expect":{"status":"completed","non_empty_text":true,"min_steps":1,"min_stream_events":1}}