Add per-item retention policies for injected history (#61344)

## What changed

Allow `thread/inject_items` to accept wrapped items with `retention: "default"` or `retention: "always"`, while preserving support for raw response items. Persist explicit policies separately from legacy client-authored metadata.

Carry explicitly retained items through local and remote compaction, context-window changes, and child-agent creation, subject to retention budgets. Preserve history order and keep retained tool exchanges together; require matching calls and outputs to be adjacent in the injection request and both marked `always`.

Update Quinn dependencies and record exceptions for additional Hickory DNSSEC advisories where DNSSEC is disabled.

## Testing

Update injection tests for explicit retention metadata and extend the compaction scenario to cover a retained tool exchange across repeated compaction and restart.

GitOrigin-RevId: 1195a4ed62dc162a62e58bc2ad733262fbabea27
This commit is contained in:
Vivian Fang
2026-11-13 18:46:13 +00:00
committed by copyberry
parent d17887a8d1
commit c33002259e
29 changed files with 578 additions and 300 deletions
+3 -3
View File
@@ -1845,9 +1845,9 @@
"quick-xml_0.39.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.9\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}",
"quick-xml_0.41.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.9\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}",
"quickcheck_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"sys_rng\"],\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"regex\",\"use_logging\"],\"regex\":[\"env_logger/regex\"],\"use_logging\":[\"log\",\"env_logger\"]}}",
"quinn-proto_0.11.18": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"name\":\"rand_pcg\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"__rustls-post-quantum-test\":[],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}",
"quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}",
"quinn-udp_0.6.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"tracing-log\"],\"fast-apple-datapath\":[],\"log\":[\"dep:log\"],\"tracing-log\":[\"tracing/log\"]}}",
"quinn-proto_0.11.19": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"name\":\"rand_pcg\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"__rustls-post-quantum-test\":[],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}",
"quinn-udp_0.5.16": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}",
"quinn-udp_0.6.3": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"tracing-log\"],\"fast-apple-datapath\":[],\"log\":[\"dep:log\"],\"tracing-log\":[\"tracing/log\"]}}",
"quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}",
"quote_1.0.45": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\"]}}",
"r-efi_5.3.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"efiapi\":[],\"examples\":[\"native\"],\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}",
+4
View File
@@ -24,6 +24,10 @@ ignore = [
"RUSTSEC-2026-0511", # hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2
"RUSTSEC-2026-0512", # hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2
"RUSTSEC-2026-0514", # hickory-resolver 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2
# Additional DNSSEC-only advisories reviewed 2026-11-13.
"RUSTSEC-2026-0530", # hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2
"RUSTSEC-2026-0531", # hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2
"RUSTSEC-2026-0532", # hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2
"RUSTSEC-2026-0194", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
"RUSTSEC-2026-0195", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
"RUSTSEC-2026-0370", # zvariant 4.2.0 via zbus 4.4.0 in Linux TUI and keyring/secret-service; remove when those paths migrate off the retired standalone crates
+10 -10
View File
@@ -12571,7 +12571,7 @@ dependencies = [
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp 0.5.14",
"quinn-udp 0.5.16",
"rustc-hash",
"rustls",
"socket2 0.6.3",
@@ -12583,9 +12583,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.18"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.2",
@@ -12605,29 +12605,29 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.14"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
name = "quinn-udp"
version = "0.6.1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42"
checksum = "47091c86514c2e11452cb228dee77037334b99d92a23e4c69aa5982261f39247"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"log",
"socket2 0.6.3",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -17315,7 +17315,7 @@ dependencies = [
"event-listener",
"futures",
"log",
"quinn-udp 0.6.1",
"quinn-udp 0.6.3",
"rtc",
"tokio",
]
@@ -18,6 +18,7 @@ use codex_protocol::protocol::TurnEnvironmentRequests;
use codex_protocol::protocol::TurnSettingsUpdate;
use codex_protocol::protocol::TurnSettingsUpdateOutcome;
use codex_protocol::turn_input::AnnotatedResponseItem;
use codex_protocol::turn_input::InjectedResponseItem;
use codex_protocol::turn_input::ResponseItemAnnotations;
use codex_utils_path_uri::PathUri;
@@ -1081,15 +1082,17 @@ impl TurnRequestProcessor {
.into_iter()
.enumerate()
.map(|(index, value)| {
serde_json::from_value::<ResponseItem>(value)
serde_json::from_value::<InjectedResponseItem>(value)
.map_err(|err| format!("items[{index}] is not a valid response item: {err}"))
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(invalid_request)?;
validate_response_item_image_urls(&items)?;
for item in &items {
validate_response_item_image_urls(std::slice::from_ref(&item.item))?;
}
thread
.inject_response_items(items)
.inject_response_items_with_retention(items)
.await
.map_err(|err| match err.details() {
CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()),
@@ -127,9 +127,9 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
thread_id: thread.id.clone(),
items: vec![
serde_json::to_value(&injected_item)?,
serde_json::to_value(&injected_developer_item)?,
serde_json::to_value(&marker_shaped_developer_item)?,
named_tool_output.clone(),
json!({"item": injected_developer_item, "retention": "default"}),
json!({"item": marker_shaped_developer_item, "retention": "always"}),
json!({"item": named_tool_output, "retention": "always"}),
],
})
.await?;
@@ -160,10 +160,14 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
.metadata
.as_ref()
.is_some_and(|metadata| metadata.client_authored),
envelope
.metadata
.as_ref()
.and_then(|metadata| metadata.retain_across_windows),
)),
_ => None,
})
.filter(|(item, _)| {
.filter(|(item, _, _)| {
item == &injected_item
|| item == &injected_developer_item
|| item == &marker_shaped_developer_item
@@ -173,10 +177,10 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
assert_eq!(
persisted_injected_items,
vec![
(injected_item.clone(), false),
(injected_developer_item.clone(), true),
(marker_shaped_developer_item.clone(), true),
(named_tool_item, false),
(injected_item.clone(), false, None),
(injected_developer_item.clone(), false, Some(false)),
(marker_shaped_developer_item.clone(), false, Some(true)),
(named_tool_item, false, Some(true)),
]
);
@@ -257,7 +261,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
envelope
.metadata
.as_ref()
.map(|metadata| metadata.client_authored),
.and_then(|metadata| metadata.retain_across_windows),
)
})
}
@@ -266,10 +270,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
.collect::<Vec<_>>();
assert_eq!(
persisted_additional_context,
vec![
("developer", Some(true)),
("function_call_output", Some(false))
]
vec![("developer", Some(true)), ("function_call_output", None)]
);
let injected_value = serde_json::to_value(&injected_item)?;
@@ -480,6 +481,7 @@ async fn thread_inject_items_cannot_forge_configuration_update_before_or_after_r
items: vec![
injected_item.clone(),
forged_update.clone(),
json!({"item": forged_update, "retention": "always"}),
forged_system.clone(),
],
})
@@ -770,7 +772,7 @@ async fn turn_tool_output_retention_preserves_sender_metadata() -> Result<()> {
})
.context("delegation output should be saved")?;
let metadata = envelope.metadata.as_ref().context("output metadata")?;
assert!(metadata.client_authored);
assert!(envelope.is_always_retained());
let sender_messages = metadata
.sender_user_messages
.as_ref()
+52 -4
View File
@@ -1,6 +1,8 @@
//! Loads stored agent context and prepares the history a child inherits when it forks.
use crate::agent::control::LocalAgentRuntime;
use crate::agent::types::SpawnAgentOptions;
use crate::compact_remote_v2::retained_client_items;
use crate::config::Config;
use crate::context::BaseInstructionsFragment;
use crate::context::ContextualUserFragment;
@@ -22,6 +24,7 @@ use crate::thread_manager::ThreadManagerState;
use crate::thread_manager::fork_history_from_snapshot;
use codex_context_fragments::set_annotated_content;
use codex_context_fragments::to_annotated_content;
use codex_history::CodexHarnessMetadata;
use codex_history::InitialHistory;
use codex_history::ResponseItemEnvelope;
use codex_history::RolloutItem;
@@ -39,8 +42,51 @@ use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_thread_store::LoadThreadHistoryParams;
impl LocalAgentRuntime {
pub(crate) async fn retained_items_for_fresh_child(
&self,
initial_history: &InitialHistory,
session_source: &SessionSource,
) -> Vec<ResponseItemEnvelope> {
// Full-history forks already carry their retained items.
let (InitialHistory::New, Some(parent_thread_id)) =
(initial_history, session_source.parent_thread_id())
else {
return Vec::new();
};
let Ok(manager) = self.upgrade() else {
return Vec::new();
};
let Ok(parent) = manager.get_thread(parent_thread_id).await else {
return Vec::new();
};
let history = parent.session.clone_history().await;
let mut items: Vec<_> = retained_client_items(
history.annotated_items(),
&history.file_audio_durations,
ResponseItemEnvelope::is_always_retained,
)
.into_values()
.collect();
for item in &mut items {
let parent_metadata = item.metadata.take().unwrap_or_default();
// Keep item metadata needed for retention and replay. Checkpoints belong to the child.
item.metadata = Some(CodexHarnessMetadata {
retain_across_windows: parent_metadata.retain_across_windows,
retained_source: parent_metadata.retained_source,
history_truncation_token_limit: parent_metadata.history_truncation_token_limit,
image_replay_sources: parent_metadata.image_replay_sources,
inherited_user_message: true,
..Default::default()
});
}
items
}
}
fn keep_forked_rollout_item(item: &RolloutItem, preserve_context_baselines: bool) -> bool {
match item {
RolloutItem::ResponseItem(envelope) if envelope.is_always_retained() => true,
RolloutItem::ResponseItem(envelope) => match &envelope.item {
ResponseItem::Message { role, phase, .. } => match role.as_str() {
"system" | "developer" | "user" => true,
@@ -256,9 +302,11 @@ pub(super) async fn prepare_forked_history(
forked_rollout_items = items;
}
let retain_forked_item = |envelope: &mut ResponseItemEnvelope, replaced: &mut bool| {
if multi_agent_version == MultiAgentVersion::V2
&& (preserve_fork_prefix
|| matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "user" || role == "assistant"))
let explicitly_retained = envelope.is_always_retained();
if explicitly_retained
|| (multi_agent_version == MultiAgentVersion::V2
&& (preserve_fork_prefix
|| matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "user" || role == "assistant")))
{
// Resume must not recapture copied parent messages as local authorization.
envelope
@@ -273,7 +321,7 @@ pub(super) async fn prepare_forked_history(
// Assistant and tool positions belong to the parent counter, not the child.
metadata.user_input_order = None;
}
if preserve_fork_prefix {
if preserve_fork_prefix || explicitly_retained {
return true;
}
let response_item = &mut envelope.item;
+39 -23
View File
@@ -62,6 +62,8 @@ use codex_protocol::protocol::TurnEnvironmentRequests;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::turn_input::InjectedResponseItem;
use codex_protocol::turn_input::ItemRetention;
use codex_protocol::turn_input::RecoverTurnRequest;
use codex_protocol::turn_input::StartIfIdleSubmission;
use codex_protocol::turn_input::SteerSubmission;
@@ -1032,7 +1034,15 @@ impl CodexThread {
/// Record raw Responses API items without starting a new turn.
pub async fn inject_response_items(&self, items: Vec<ResponseItem>) -> CodexResult<()> {
self.inject_response_items_for_turn(items).await?;
self.inject_response_items_with_retention(items).await
}
/// Inject Responses items with explicit, persisted retention policies.
pub async fn inject_response_items_with_retention(
&self,
items: Vec<impl Into<InjectedResponseItem>>,
) -> CodexResult<()> {
self.record_injected_items(items).await?;
self.checkpoint_preparation().await?;
Ok(())
}
@@ -1058,37 +1068,43 @@ impl CodexThread {
pub async fn inject_response_items_for_turn(
&self,
items: Vec<ResponseItem>,
) -> CodexResult<()> {
self.record_injected_items(items).await
}
async fn record_injected_items(
&self,
items: Vec<impl Into<InjectedResponseItem>>,
) -> CodexResult<()> {
if items.is_empty() {
return Err(CodexErr::InvalidRequest(
"items must not be empty".to_string(),
));
}
items.iter().try_for_each(validate_injected_response_item)?;
let had_reference_context = self.session.reference_context_item().await.is_some();
let mut turn_context = if had_reference_context {
self.session.new_inject_items_context().await
} else {
self.session.new_default_turn().await
};
if self.session.reference_context_item().await.is_none() {
// Compaction can clear the reference while the recording context is built.
// Initial context must capture a step with a complete skills snapshot.
if had_reference_context {
turn_context = self.session.new_default_turn().await;
let items: Vec<InjectedResponseItem> = items.into_iter().map(Into::into).collect();
let mut indexed_items = items.iter().enumerate();
while let Some((index, item)) = indexed_items.next() {
validate_injected_response_item(&item.item)?;
if item.retention != ItemRetention::Always {
continue;
}
let needs_call = crate::tools::output_call_id(&item.item).is_some()
&& !matches!(&item.item, ResponseItem::ToolSearchOutput { execution, .. } if execution == "server");
if (crate::tools::input_call_id(&item.item).is_some() || needs_call)
&& !indexed_items.next().is_some_and(|(_, output)| {
output.retention == ItemRetention::Always
&& crate::tools::is_matching_tool_output(&item.item, &output.item)
})
{
return Err(CodexErr::InvalidRequest(format!(
"items[{index}]: retained tool calls and outputs must be adjacent in the same request, call first, with both marked always"
)));
}
// This history-only API runs without run_turn, so it owns its initial step.
let step_context = self
.session
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
.await?;
self.session
.record_context_updates_and_set_reference_context_item(step_context.as_ref())
.await?;
}
let turn_context = self.session.history_injection_context().await?;
self.session
.inject_client_response_items(items, turn_context.as_ref())
.inject_client_response_items(items, &turn_context)
.await;
Ok(())
}
+38 -26
View File
@@ -7,12 +7,14 @@ use crate::Prompt;
use crate::client::ModelClientSession;
use crate::client_common::ResponseEvent;
use crate::compact_pending_input::is_retained_pending_mail;
use crate::compact_remote_v2::retained_client_items;
use crate::context::BaseInstructionsFragment;
use crate::context::CompactionSummary;
use crate::context::ContextualUserFragment;
use crate::context::world_state::WorldState;
use crate::context::world_state::WorldStateSnapshot;
use crate::context::world_state::split_prefix_updates;
use crate::context_manager::ContextManager;
use crate::context_manager::updates::merge_world_state_updates;
use crate::hook_runtime::PostCompactHookOutcome;
use crate::hook_runtime::PreCompactHookOutcome;
@@ -20,6 +22,7 @@ use crate::hook_runtime::run_post_compact_hooks;
use crate::hook_runtime::run_pre_compact_hooks;
use crate::responses_metadata::CodexResponsesMetadata;
use crate::responses_metadata::CompactionTurnMetadata;
use crate::session::FileAudioDurations;
use crate::session::RequestEffortUsage;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
@@ -379,7 +382,6 @@ async fn run_compact_task_inner_impl(
};
let history_snapshot = sess.clone_history().await;
let history_items = history_snapshot.annotated_items();
let summary_suffix = if matches!(compaction_metadata.phase(), CompactionPhase::PostTurn) {
get_last_assistant_message_from_turn(compaction_response.output.iter())
.filter(|summary| !summary.trim().is_empty())
@@ -392,9 +394,8 @@ async fn run_compact_task_inner_impl(
get_last_assistant_message_from_turn(history_snapshot.raw_items()).unwrap_or_default()
};
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_annotated_user_messages(history_items);
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
let mut new_history = build_compacted_history(&history_snapshot, &summary_text);
if let Some(summary_item) = new_history.last_mut() {
// This replacement history skips `record_conversation_items`; only the appended summary
// belongs to this compaction turn.
@@ -407,6 +408,9 @@ async fn run_compact_task_inner_impl(
let summary = new_history
.pop()
.ok_or_else(|| CodexErr::Fatal("local compaction summary is missing".to_string()))?;
// A client may also have explicitly retained this mail. Use the final
// compactor input for pending mail so each delivery appears once.
new_history.retain(|item| !is_retained_pending_mail(item, first));
new_history.extend(
history
.annotated_items()
@@ -606,13 +610,13 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<CompactedUser
.collect()
}
pub(crate) fn collect_annotated_user_messages(
fn annotated_user_messages(
items: &[ResponseItemEnvelope],
) -> Vec<CompactedUserMessage<'_>> {
items
.iter()
.filter_map(|envelope| compacted_user_message(&envelope.item, envelope.metadata.as_ref()))
.collect()
) -> impl DoubleEndedIterator<Item = (usize, CompactedUserMessage<'_>)> {
items.iter().enumerate().filter_map(|(position, envelope)| {
let message = compacted_user_message(&envelope.item, envelope.metadata.as_ref())?;
Some((position, message))
})
}
fn compacted_user_message<'a>(
@@ -714,28 +718,33 @@ pub(crate) fn insert_initial_context_before_last_real_user_or_summary(
}
pub(crate) fn build_compacted_history(
initial_context: Vec<ResponseItemEnvelope>,
user_messages: &[CompactedUserMessage<'_>],
history: &ContextManager,
summary_text: &str,
) -> Vec<ResponseItemEnvelope> {
build_compacted_history_with_limit(
initial_context,
user_messages,
history.annotated_items(),
summary_text,
COMPACT_USER_MESSAGE_MAX_TOKENS,
&history.file_audio_durations,
)
}
fn build_compacted_history_with_limit(
mut history: Vec<ResponseItemEnvelope>,
user_messages: &[CompactedUserMessage<'_>],
items: &[ResponseItemEnvelope],
summary_text: &str,
max_tokens: usize,
audio_durations: &FileAudioDurations,
) -> Vec<ResponseItemEnvelope> {
let mut selected_messages = Vec::new();
// An injected user message can qualify under both budgets. Its position
// identifies the occurrence even if another message has the same client ID.
let mut selected = retained_client_items(
items,
audio_durations,
ResponseItemEnvelope::is_always_retained,
);
if max_tokens > 0 {
let mut remaining = max_tokens;
for message in user_messages.iter().rev() {
for (position, message) in annotated_user_messages(items).rev() {
if remaining == 0 {
break;
}
@@ -749,6 +758,10 @@ fn build_compacted_history_with_limit(
else {
continue;
};
if selected.contains_key(&position) {
remaining = remaining.saturating_sub(tokens);
continue;
}
let mut harness_metadata = message.harness_metadata.cloned();
let preserve_content = tokens <= remaining
&& content
@@ -806,18 +819,17 @@ fn build_compacted_history_with_limit(
{
metadata.mark_retained_sources_incomplete();
}
selected_messages.push(ResponseItemEnvelope {
item,
metadata: harness_metadata,
});
if tokens > remaining {
break;
}
selected.insert(
position,
ResponseItemEnvelope {
item,
metadata: harness_metadata,
},
);
remaining = remaining.saturating_sub(tokens);
}
selected_messages.reverse();
}
history.extend(selected_messages);
let mut history: Vec<_> = selected.into_values().collect();
let summary_text = if summary_text.is_empty() {
"(no summary available)".to_string()
+78 -59
View File
@@ -1,13 +1,13 @@
use std::borrow::Borrow;
use std::collections::BTreeMap;
use crate::compact_retained_tool_outputs::is_retained_tool_output;
use crate::compact_remote_v2::should_retain_client_item;
use crate::context::ContextualUserFragment;
use crate::context::ImageResizeNotice;
use crate::context_manager::ContextManager;
use crate::context_manager::estimate_item_token_count_with_audio;
use crate::session::FileAudioDurations;
use crate::session::turn_context::TurnContext;
use codex_features::Feature;
use crate::tools::is_matching_tool_output;
use codex_history::ResponseItemEnvelope;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
@@ -19,51 +19,85 @@ use codex_utils_output_truncation::approx_token_count;
const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str =
"Output exceeded the available model context and was truncated";
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct HistoryItemGroup<T> {
pub(crate) source: T,
pub(crate) attached_notice: Option<T>,
pub(crate) struct HistoryItemGroup<'a> {
pub(crate) source_position: usize,
pub(crate) source: &'a ResponseItemEnvelope,
pub(crate) attached_notice: Option<&'a ResponseItemEnvelope>,
pub(crate) tool_call: Option<&'a ResponseItemEnvelope>,
}
impl<T: Borrow<ResponseItem>> HistoryItemGroup<T> {
pub(crate) fn into_items(self) -> impl Iterator<Item = T> {
std::iter::once(self.source).chain(self.attached_notice)
impl HistoryItemGroup<'_> {
pub(crate) fn keep(
&self,
selected: &mut BTreeMap<usize, ResponseItemEnvelope>,
source: ResponseItemEnvelope,
) {
if let Some(call) = self.tool_call {
selected.insert(self.source_position - 1, call.clone());
}
selected.insert(self.source_position, source);
if let Some(notice) = self.attached_notice {
selected.insert(self.source_position + 1, notice.clone());
}
}
pub(crate) fn estimated_token_count(&self, audio_durations: &FileAudioDurations) -> i128 {
let source_tokens = i128::from(estimate_item_token_count_with_audio(
self.source.borrow(),
audio_durations,
));
let notice_tokens = self.attached_notice.as_ref().map_or(0, |notice| {
i128::from(estimate_item_token_count_with_audio(
notice.borrow(),
audio_durations,
))
});
source_tokens.saturating_add(notice_tokens)
self.tool_call
.into_iter()
.chain(std::iter::once(self.source))
.chain(self.attached_notice)
.map(|item| i128::from(estimate_item_token_count_with_audio(item, audio_durations)))
.fold(0, i128::saturating_add)
}
}
pub(crate) fn history_item_groups<I>(items: I) -> impl Iterator<Item = HistoryItemGroup<I::Item>>
where
I: IntoIterator,
I::Item: Borrow<ResponseItem>,
{
let mut items = items.into_iter().peekable();
pub(crate) fn newest_history_item_groups(
mut items: &[ResponseItemEnvelope],
) -> impl Iterator<Item = HistoryItemGroup<'_>> {
std::iter::from_fn(move || {
let source = items.next()?;
let attached_notice = items.next_if(|notice| is_attached_notice(notice.borrow()));
let (last, preceding) = items.split_last()?;
let (source, attached_notice, rest) = match preceding.split_last() {
Some((source, rest)) if is_attached_notice(source, last) => (source, Some(last), rest),
_ => (last, None, preceding),
};
let source_position = rest.len();
// Injection requires explicitly retained exchanges to be adjacent in the request.
let (tool_call, rest) = match rest.split_last() {
Some((call, rest))
if source.is_always_retained()
&& call.is_always_retained()
&& is_matching_tool_output(&call.item, &source.item) =>
{
(Some(call), rest)
}
_ => (None, rest),
};
items = rest;
Some(HistoryItemGroup {
source_position,
source,
attached_notice,
tool_call,
})
})
}
fn is_attached_notice(notice: &ResponseItem) -> bool {
fn is_attached_notice(source: &ResponseItemEnvelope, notice: &ResponseItemEnvelope) -> bool {
let can_have_resize_notice = match &source.item {
ResponseItem::Message { role, .. } => role == "user",
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => true,
_ => false,
};
// A client may send the same text as a resize notice with its own policy.
if !can_have_resize_notice
|| notice.metadata.as_ref().is_some_and(|metadata| {
metadata.client_authored || metadata.retain_across_windows.is_some()
})
{
return false;
}
matches!(
notice,
&notice.item,
ResponseItem::Message { role, content, .. }
if role == "developer"
&& matches!(
@@ -89,38 +123,24 @@ pub(crate) fn trim_function_call_history_to_fit_context_window(
i128::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i128::MAX);
let audio_durations = &history.file_audio_durations;
let original_items = history.annotated_items();
let mut estimated_tokens = history_item_groups(original_items.iter().map(|item| &item.item))
.map(|group| group.estimated_token_count(audio_durations))
let mut estimated_tokens = original_items
.iter()
.map(|item| i128::from(estimate_item_token_count_with_audio(item, audio_durations)))
.fold(base_tokens, i128::saturating_add);
let initial_estimated_tokens = i64::try_from(estimated_tokens).unwrap_or(i64::MAX);
if initial_estimated_tokens <= context_window {
return (0, 0);
}
let mut rewritten_items = Vec::new();
let mut consumed_items: usize = 0;
for group in history_item_groups(original_items.iter().map(|item| &item.item))
.collect::<Vec<_>>()
.into_iter()
.rev()
{
let mut retained_len = original_items.len();
for group in newest_history_item_groups(original_items) {
if i64::try_from(estimated_tokens).unwrap_or(i64::MAX) <= context_window {
break;
}
let group_item_count = 1 + usize::from(group.attached_notice.is_some());
let source_index = original_items
.len()
.saturating_sub(consumed_items.saturating_add(group_item_count));
let Some(rewritten_item) = original_items.get(source_index).and_then(|envelope| {
if turn_context
.config
.features
.enabled(Feature::RetainClientToolOutputs)
&& is_retained_tool_output(envelope)
{
// Enabled retained outputs form the same boundary as user/developer messages.
None
} else {
rewritten_output_for_context_window(envelope)
}
}) else {
if should_retain_client_item(group.source, &turn_context.config.features) {
break;
}
let Some(rewritten_item) = rewritten_output_for_context_window(group.source) else {
break;
};
estimated_tokens = estimated_tokens
@@ -129,13 +149,12 @@ pub(crate) fn trim_function_call_history_to_fit_context_window(
&rewritten_item.item,
audio_durations,
)));
consumed_items += group_item_count;
retained_len = group.source_position;
rewritten_items.push(rewritten_item);
}
let rewritten_outputs = rewritten_items.len();
if rewritten_outputs > 0 {
let retained_len = original_items.len() - consumed_items;
let mut items = original_items[..retained_len].to_vec();
items.extend(rewritten_items.into_iter().rev());
history.replace_annotated(items);
+80 -63
View File
@@ -1,4 +1,5 @@
use codex_login::AuthOwner;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::OnceLock;
@@ -18,8 +19,7 @@ use crate::compact_model_fallback::record_model_fallback;
use crate::compact_model_fallback::should_retry_with_current_model;
use crate::compact_pending_input::MAX_RETAINED_AGENT_MESSAGE_TOKENS;
use crate::compact_pending_input::is_retained_pending_mail;
use crate::compact_remote_history::HistoryItemGroup;
use crate::compact_remote_history::history_item_groups;
use crate::compact_remote_history::newest_history_item_groups;
use crate::context::world_state::WorldState;
use crate::context_manager::estimate_item_token_count;
use crate::context_manager::estimate_item_token_count_with_audio;
@@ -510,20 +510,18 @@ fn build_v2_compacted_history(
&prompt_input,
);
let pending_sequence = crate::compact_pending_input::pending_mail_sequence(&prompt_input);
let retained = v2_history_item_groups(prompt_input)
.filter(|group| {
is_retained_for_remote_compaction_v2(&group.source, features)
|| pending_sequence
.is_some_and(|first| is_retained_pending_mail(&group.source, first))
})
.flat_map(HistoryItemGroup::into_items)
.collect::<Vec<_>>();
let retained = truncate_retained_messages(
retained,
let retained = select_retained_items(
&prompt_input,
|item| {
is_retained_for_remote_compaction_v2(item, features)
|| pending_sequence.is_some_and(|first| is_retained_pending_mail(item, first))
},
RETAINED_MESSAGE_TOKEN_BUDGET,
image_budget,
audio_durations,
);
)
.into_values()
.collect::<Vec<_>>();
let retained_image_count = retained
.iter()
.map(|envelope| retained_input_image_count(&envelope.item))
@@ -540,6 +538,20 @@ fn build_v2_compacted_history(
(retained, retained_image_count)
}
pub(crate) fn retained_client_items(
items: &[ResponseItemEnvelope],
audio_durations: &FileAudioDurations,
should_retain: impl Fn(&ResponseItemEnvelope) -> bool,
) -> BTreeMap<usize, ResponseItemEnvelope> {
select_retained_items(
items,
should_retain,
RETAINED_MESSAGE_TOKEN_BUDGET,
RetainedImageBudget::Disabled,
audio_durations,
)
}
pub(crate) fn is_client_authored_developer_message(item: &ResponseItemEnvelope) -> bool {
item.metadata
.as_ref()
@@ -547,26 +559,20 @@ pub(crate) fn is_client_authored_developer_message(item: &ResponseItemEnvelope)
&& matches!(&item.item, ResponseItem::Message { role, .. } if role == "developer")
}
fn v2_history_item_groups(
items: Vec<ResponseItemEnvelope>,
) -> impl Iterator<Item = HistoryItemGroup<ResponseItemEnvelope>> {
history_item_groups(items).flat_map(|mut group| {
let client_message = group
.attached_notice
.take_if(|item| is_client_authored_developer_message(item))
.map(|source| HistoryItemGroup {
source,
attached_notice: None,
});
std::iter::once(group).chain(client_message)
})
pub(crate) fn should_retain_client_item(item: &ResponseItemEnvelope, features: &Features) -> bool {
if let Some(retain) = item.metadata.as_ref().and_then(|m| m.retain_across_windows) {
return retain;
}
(features.enabled(Feature::RetainClientDeveloperMessages)
&& is_client_authored_developer_message(item))
|| (features.enabled(Feature::RetainClientToolOutputs) && is_retained_tool_output(item))
}
fn is_retained_for_remote_compaction_v2(
envelope: &ResponseItemEnvelope,
features: &Features,
) -> bool {
if features.enabled(Feature::RetainClientToolOutputs) && is_retained_tool_output(envelope) {
if should_retain_client_item(envelope, features) {
return true;
}
let item = &envelope.item;
@@ -605,10 +611,6 @@ fn is_retained_for_remote_compaction_v2(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_))
),
"developer" => {
features.enabled(Feature::RetainClientDeveloperMessages)
&& is_client_authored_developer_message(envelope)
}
_ => false,
}
}
@@ -651,19 +653,32 @@ fn truncate_retained_messages(
image_budget: RetainedImageBudget,
audio_durations: &FileAudioDurations,
) -> Vec<ResponseItemEnvelope> {
select_retained_items(&items, |_| true, max_tokens, image_budget, audio_durations)
.into_values()
.collect()
}
/// Select newest first, then return the items at their original positions.
fn select_retained_items(
items: &[ResponseItemEnvelope],
should_retain: impl Fn(&ResponseItemEnvelope) -> bool,
max_tokens: usize,
image_budget: RetainedImageBudget,
audio_durations: &FileAudioDurations,
) -> BTreeMap<usize, ResponseItemEnvelope> {
let mut remaining = max_tokens;
let mut truncated_reversed = Vec::with_capacity(items.len());
for group in v2_history_item_groups(items)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
let mut selected = BTreeMap::new();
for group in newest_history_item_groups(items) {
if remaining == 0 {
break;
}
if !should_retain(group.source) {
continue;
}
let client_developer = is_client_authored_developer_message(&group.source);
let image_budget = if client_developer {
let charge_full_item =
group.source.is_always_retained() || is_client_authored_developer_message(group.source);
let image_budget = if charge_full_item {
RetainedImageBudget::Disabled
} else {
image_budget
@@ -677,7 +692,7 @@ fn truncate_retained_messages(
// Charged media is truncated newest-first as a message boundary. Each
// modality still defines its own grouping and token cost in `media`.
let requires_media_boundary_truncation = has_budgeted_image || has_file_audio;
let notice_tokens = group.attached_notice.as_ref().map_or(0, |notice| {
let notice_tokens = group.attached_notice.map_or(0, |notice| {
retained_message_token_count(
&notice.item,
RetainedImageBudget::Disabled,
@@ -685,11 +700,10 @@ fn truncate_retained_messages(
)
.max(1)
});
// Client-authored developer messages already charge non-text content via
// the serialized estimate. Preserve their text-only boundary correction.
// Explicitly retained items and client developer messages also pay for their media.
let content_tokens =
retained_message_token_count(&group.source.item, image_budget, audio_durations);
let source_tokens = if client_developer {
let source_tokens = if charge_full_item {
usize::try_from(estimate_item_token_count_with_audio(
&group.source.item,
audio_durations,
@@ -698,19 +712,23 @@ fn truncate_retained_messages(
} else {
content_tokens.max(1)
};
let token_count = source_tokens.saturating_add(notice_tokens);
if token_count <= remaining {
if let Some(notice) = group.attached_notice {
truncated_reversed.push(notice);
}
truncated_reversed.push(group.source);
let token_count = if group.tool_call.is_some() {
usize::try_from(group.estimated_token_count(audio_durations)).unwrap_or(usize::MAX)
} else {
source_tokens.saturating_add(notice_tokens)
};
let source = if token_count <= remaining {
remaining = remaining.saturating_sub(token_count);
} else if is_retained_tool_output(&group.source) {
// Keep outputs whole and stop before older items to preserve newest-first ordering.
group.source.clone()
} else if is_retained_tool_output(group.source)
|| (group.source.is_always_retained()
&& !matches!(group.source.item, ResponseItem::Message { .. }))
{
// Structured items such as tool results must stay whole.
break;
} else if remaining > notice_tokens {
let available_tokens = remaining - notice_tokens;
let content_budget = if client_developer {
let content_budget = if charge_full_item {
available_tokens.saturating_sub(source_tokens.saturating_sub(content_tokens))
} else {
available_tokens
@@ -722,18 +740,18 @@ fn truncate_retained_messages(
}
let truncated_item = if requires_media_boundary_truncation {
media::truncate_message_to_token_budget(
group.source,
group.source.clone(),
content_budget,
image_budget,
audio_durations,
)
} else {
truncate_message_text_to_token_budget(group.source, content_budget)
truncate_message_text_to_token_budget(group.source.clone(), content_budget)
};
let Some(mut truncated_item) = truncated_item else {
continue;
};
if client_developer {
if charge_full_item {
let item_tokens = usize::try_from(estimate_item_token_count_with_audio(
&truncated_item.item,
audio_durations,
@@ -768,17 +786,16 @@ fn truncate_retained_messages(
truncated_item = adjusted;
}
}
if let Some(notice) = group.attached_notice {
truncated_reversed.push(notice);
}
truncated_reversed.push(truncated_item);
remaining = 0;
truncated_item
} else if requires_media_boundary_truncation {
remaining = 0;
}
break;
} else {
continue;
};
group.keep(&mut selected, source);
}
truncated_reversed.reverse();
truncated_reversed
selected
}
fn retained_message_token_count(
@@ -1,5 +1,4 @@
//! Retains explicitly marked, named function outputs that have no matching call.
//! Outputs share the normal history token budget and are retained whole or dropped.
//! Identifies standalone retained tool outputs in model history.
use codex_history::ResponseItemEnvelope;
use codex_protocol::models::ResponseItem;
@@ -8,7 +7,7 @@ pub(crate) fn is_retained_tool_output(envelope: &ResponseItemEnvelope) -> bool {
envelope
.metadata
.as_ref()
.is_some_and(|metadata| metadata.client_authored)
.is_some_and(|m| m.retain_across_windows.unwrap_or(m.client_authored))
&& matches!(
envelope.item,
ResponseItem::FunctionCallOutput {
+18 -13
View File
@@ -272,6 +272,15 @@ fn compacted_user_message<'a>(text: &str, original: &'a ResponseItem) -> Compact
}
}
fn compact_items(items: &[ResponseItemEnvelope], summary: &str) -> Vec<ResponseItemEnvelope> {
build_compacted_history_with_limit(
items,
summary,
COMPACT_USER_MESSAGE_MAX_TOKENS,
&FileAudioDurations::default(),
)
}
#[tokio::test]
async fn extension_window_prefix_precedes_retained_turns_and_is_not_repeated() {
use codex_extension_api::PreviousWorldStateSection;
@@ -429,7 +438,9 @@ fn collect_annotated_user_messages_extracts_user_text_only(
ResponseItemEnvelope::new(ResponseItem::Other),
];
let collected = collect_annotated_user_messages(&items);
let collected: Vec<_> = annotated_user_messages(&items)
.map(|(_, message)| message)
.collect();
if !preserve_content {
item = user_message(expected_text);
@@ -458,7 +469,7 @@ fn collect_annotated_user_messages_extracts_user_text_only(
}]
);
assert_eq!(
build_compacted_history(Vec::new(), &collected, "summary"),
compact_items(&items, "summary"),
vec![
expected,
ResponseItemEnvelope::new(ContextualUserFragment::into(CompactionSummary::new(
@@ -576,10 +587,10 @@ fn build_token_limited_compacted_history_truncates_overlong_user_messages(
.set_id(Some(ResponseItemId::with_suffix("msg", "long-user")));
original.metadata = Some(CodexHarnessMetadata::default());
let history = super::build_compacted_history_with_limit(
Vec::new(),
&collect_annotated_user_messages(std::slice::from_ref(&original)),
std::slice::from_ref(&original),
"SUMMARY",
max_tokens,
&FileAudioDurations::default(),
);
assert_eq!(history.len(), 2);
@@ -653,12 +664,10 @@ fn build_token_limited_compacted_history_truncates_overlong_user_messages(
#[test]
fn build_token_limited_compacted_history_appends_summary_message() {
let initial_context: Vec<ResponseItemEnvelope> = Vec::new();
let original = user_message("first user message");
let user_messages = collect_user_messages(std::slice::from_ref(&original));
let items = annotated(vec![user_message("first user message")]);
let summary_text = "summary text";
let history = build_compacted_history(initial_context, &user_messages, summary_text);
let history = compact_items(&items, summary_text);
assert!(
!history.is_empty(),
"expected compacted history to include summary"
@@ -704,11 +713,7 @@ fn build_compacted_history_preserves_user_message_passthrough_metadata(attribute
item: original,
metadata: Some(CodexHarnessMetadata::default()),
};
let history = build_compacted_history(
Vec::new(),
&collect_annotated_user_messages(std::slice::from_ref(&original)),
"summary text",
);
let history = compact_items(std::slice::from_ref(&original), "summary text");
assert_eq!(
history,
+49 -4
View File
@@ -7,6 +7,8 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
/// Uploaded files need the admission checks on typed turn input before entering history.
pub(crate) fn validate_injected_response_item(item: &ResponseItem) -> CodexResult<()> {
@@ -19,6 +21,45 @@ pub(crate) fn validate_injected_response_item(item: &ResponseItem) -> CodexResul
}
impl Session {
/// History injection can precede the first turn. Capture full initial context
/// only when the thread has no reference context, including after compaction.
pub(crate) async fn history_injection_context(
self: &Arc<Self>,
) -> CodexResult<Arc<TurnContext>> {
if self.reference_context_item().await.is_some() {
let turn = self.new_inject_items_context().await;
// Compaction can clear the baseline while we build this context.
// Rebuilding initial context requires a full skills snapshot.
if self.reference_context_item().await.is_some() {
return Ok(turn);
}
}
let turn = self.new_default_turn().await;
if self.reference_context_item().await.is_none() {
let step = self
.capture_step_context(Arc::clone(&turn), &CancellationToken::new())
.await?;
self.record_context_updates_and_set_reference_context_item(step.as_ref())
.await?;
}
Ok(turn)
}
pub(crate) async fn record_inherited_items(
self: &Arc<Self>,
items: Vec<ResponseItemEnvelope>,
) -> CodexResult<()> {
if items.is_empty() {
return Ok(());
}
let turn = self.history_injection_context().await?;
// The parent already prepared the images and their dimension notices.
self.record_prepared_conversation_items(&turn, turn.model_info(), items, Vec::new())
.await;
self.flush_rollout().await?;
Ok(())
}
/// Returns the input if there is no active turn to inject into.
#[expect(
clippy::await_holding_invalid_type,
@@ -76,19 +117,19 @@ impl Session {
Ok(())
}
/// Preserves trusted client provenance while items wait for an active turn.
/// Applies client developer-message retention before queueing or recording items.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub(crate) async fn inject_client_response_items(
&self,
items: Vec<ResponseItem>,
items: Vec<impl Into<ResponseItemEnvelope>>,
turn_context: &TurnContext,
) {
let items = items
.into_iter()
.map(ResponseItemEnvelope::new)
.map(Into::into)
.map(|item| self.annotate_client_response_item(item))
.collect::<Vec<_>>();
let mut active = self.active_turn.lock().await;
@@ -120,7 +161,11 @@ impl Session {
if self.enabled(Feature::RetainClientDeveloperMessages)
&& matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "developer")
{
envelope.metadata.get_or_insert_default().client_authored = true;
envelope
.metadata
.get_or_insert_default()
.retain_across_windows
.get_or_insert(true);
}
envelope
}
+20 -22
View File
@@ -225,8 +225,6 @@ use uuid::Uuid;
use crate::client::ModelClient;
use crate::codex_thread::CodexThreadSettingsOverrides;
use crate::codex_thread::ThreadConfigSnapshot;
#[cfg(test)]
use crate::compact::collect_user_messages;
use crate::config::Config;
use crate::config::ConstraintResult;
use crate::config::PermissionProfileSnapshot;
@@ -689,7 +687,15 @@ impl Session {
})
}
async fn spawn_internal(args: SessionSpawnArgs) -> CodexResult<(Arc<Self>, SessionIo)> {
// Construct the startup future outside the caller's poll frame.
#[inline(never)]
fn spawn_internal(
args: SessionSpawnArgs,
) -> BoxFuture<'static, CodexResult<(Arc<Self>, SessionIo)>> {
Box::pin(Self::initialize(args))
}
async fn initialize(args: SessionSpawnArgs) -> CodexResult<(Arc<Self>, SessionIo)> {
args.config
.config_layer_stack
.ensure_config_server_applied()?;
@@ -5472,24 +5478,16 @@ impl Session {
let turn_context = step_context.turn.as_ref();
let history = self.clone_history().await;
let input_goal_ids = crate::context::UserGoalUpdate::message_ids(history.raw_items());
let retained_client_items =
crate::compact_remote_v2::truncate_retained_messages_for_remote_compaction(
history
.annotated_items()
.iter()
.filter(|item| {
(self.enabled(Feature::RetainClientToolOutputs)
&& crate::compact_retained_tool_outputs::is_retained_tool_output(item))
|| (self.enabled(Feature::RetainClientDeveloperMessages)
&& crate::compact_remote_v2::is_client_authored_developer_message(
item,
))
})
.cloned()
.collect(),
crate::compact_remote_v2::RETAINED_MESSAGE_TOKEN_BUDGET,
&history.file_audio_durations,
);
let retained_client_items = crate::compact_remote_v2::retained_client_items(
history.annotated_items(),
&history.file_audio_durations,
|item| {
crate::compact_remote_v2::should_retain_client_item(
item,
&turn_context.config.features,
)
},
);
// Resolve the roster before allocating a new context window.
let rendered = world_state.render_full_with_host_roster().await;
let (window_number, window_ids) = self.state.lock().await.start_new_context_window();
@@ -5499,7 +5497,7 @@ impl Session {
crate::context_manager::updates::merge_world_state_updates(context_updates)
.into_iter()
.map(ResponseItemEnvelope::new)
.chain(retained_client_items)
.chain(retained_client_items.into_values())
.collect();
let turn_context_item = step_context.to_turn_context_item();
self.replace_compacted_history(
@@ -561,13 +561,8 @@ impl ContextManager {
// prompt shape.
// TODO(ccunningham): if we drop support for None replacement_history compaction items,
// we can get rid of this second loop entirely and just build `history` directly in the first loop.
let user_messages =
compact::collect_annotated_user_messages(history.annotated_items());
let rebuilt = compact::build_compacted_history(
Vec::new(),
&user_messages,
&compacted.message,
);
let rebuilt =
compact::build_compacted_history(&history, &compacted.message);
let retained_context = history.retained_context().clone();
history.replace_annotated(rebuilt);
history.restore_retained_context(Some(&retained_context));
+5
View File
@@ -1017,6 +1017,10 @@ impl Session {
SessionId::from(thread_id)
}
});
let inherited_items = agent_control
.runtime()
.retained_items_for_fresh_child(&initial_history, &session_source)
.await;
let has_provided_agent_control =
matches!(&agent_control, AgentControlInit::Provided { .. });
let (agent_control, local_agent_runtime): (Arc<dyn AgentControl>, _) = match agent_control {
@@ -2129,6 +2133,7 @@ impl Session {
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
Box::pin(sess.record_initial_history(initial_history)).await;
Box::pin(sess.record_inherited_items(inherited_items)).await?;
if restore_child_window {
sess.state.lock().await.restore_auto_compact_window(
/*window_number*/ 0,
+2 -6
View File
@@ -13433,9 +13433,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::ResponseItem(assistant1.clone().into()));
let summary1 = "summary one";
let snapshot1 = raw_history_items(&live_history);
let user_messages1 = collect_user_messages(&snapshot1);
let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1);
let rebuilt1 = compact::build_compacted_history(&live_history, summary1);
live_history.replace_annotated(rebuilt1);
let (window_number, window_ids) = session.advance_auto_compact_window().await;
rollout_items.push(RolloutItem::Compacted(Box::new(CompactedItem {
@@ -13468,9 +13466,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::ResponseItem(assistant2.clone().into()));
let summary2 = "summary two";
let snapshot2 = raw_history_items(&live_history);
let user_messages2 = collect_user_messages(&snapshot2);
let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2);
let rebuilt2 = compact::build_compacted_history(&live_history, summary2);
live_history.replace_annotated(rebuilt2);
let (window_number, window_ids) = session.advance_auto_compact_window().await;
rollout_items.push(RolloutItem::Compacted(Box::new(CompactedItem {
+8 -10
View File
@@ -1127,7 +1127,7 @@ async fn pending_turn_input(
SubmittedTurnInput::AnnotatedResponseItem(input) => input.annotations.retain,
_ => false,
};
let mut input = match input {
match input {
SubmittedTurnInput::UserInput { content, client_id } => TurnInput::UserInput {
content,
client_id,
@@ -1143,7 +1143,7 @@ async fn pending_turn_input(
ResponseItem::FunctionCallOutput { call_id: None, .. }
) =>
{
let metadata = if let Some(fragment) = sender_messages {
let mut metadata = if let Some(fragment) = sender_messages {
let mut messages = SenderUserMessages {
receiver_turn_id: turn_id.to_owned(),
receiver_message_id: fragment.delivery.clone(),
@@ -1158,6 +1158,12 @@ async fn pending_turn_input(
} else {
None
};
if retain
&& session.enabled(Feature::RetainClientToolOutputs)
&& matches!(item, ResponseItem::FunctionCallOutput { name: Some(_), .. })
{
metadata.get_or_insert_default().retain_across_windows = Some(true);
}
TurnInput::FunctionCallOutput(ResponseItemEnvelope { item, metadata })
}
SubmittedTurnInput::ResponseItem(item)
@@ -1167,13 +1173,5 @@ async fn pending_turn_input(
SubmittedTurnInput::InterAgentCommunication(communication) => {
TurnInput::InterAgentCommunication(communication.into())
}
};
if retain
&& let TurnInput::ResponseItem(envelope) | TurnInput::FunctionCallOutput(envelope) =
&mut input
&& matches!(envelope.item, ResponseItem::FunctionCallOutput { .. })
{
envelope.metadata.get_or_insert_default().client_authored = true;
}
input
}
+22 -2
View File
@@ -766,7 +766,7 @@ fn recorded_call(call: &ToolCall) -> (ExecutedToolCall, usize) {
(ExecutedToolCall::new(name, arguments), original_bytes)
}
fn input_call_id(item: &ResponseItem) -> Option<&str> {
pub(crate) fn input_call_id(item: &ResponseItem) -> Option<&str> {
match item {
ResponseItem::FunctionCall { call_id, .. }
| ResponseItem::CustomToolCall { call_id, .. } => Some(call_id),
@@ -776,7 +776,7 @@ fn input_call_id(item: &ResponseItem) -> Option<&str> {
}
}
fn output_call_id(item: &ResponseItem) -> Option<&str> {
pub(crate) fn output_call_id(item: &ResponseItem) -> Option<&str> {
match item {
ResponseItem::FunctionCallOutput { call_id, .. }
| ResponseItem::ToolSearchOutput { call_id, .. } => call_id.as_deref(),
@@ -784,3 +784,23 @@ fn output_call_id(item: &ResponseItem) -> Option<&str> {
_ => None,
}
}
pub(crate) fn is_matching_tool_output(call: &ResponseItem, output: &ResponseItem) -> bool {
let Some(call_id) = input_call_id(call) else {
return false;
};
output_call_id(output) == Some(call_id)
&& matches!(
(call, output),
(
ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. },
ResponseItem::FunctionCallOutput { .. }
) | (
ResponseItem::CustomToolCall { .. },
ResponseItem::CustomToolCallOutput { .. }
) | (
ResponseItem::ToolSearchCall { .. },
ResponseItem::ToolSearchOutput { .. }
)
)
}
+3
View File
@@ -41,6 +41,9 @@ use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text;
use codex_utils_output_truncation::truncate_text;
pub(crate) use executed_tool_calls::ExecutedToolCalls;
pub(crate) use executed_tool_calls::input_call_id;
pub(crate) use executed_tool_calls::is_matching_tool_output;
pub(crate) use executed_tool_calls::output_call_id;
pub use multi_agent_tool::MULTI_AGENT_V2_NAMESPACE_DESCRIPTION;
pub use router::ToolRouter;
+1 -5
View File
@@ -439,11 +439,7 @@ async fn remote_compact_v2_retains_only_client_developer_messages_when_enabled(
.context("remote compaction should persist a checkpoint")?;
let retained_client_developers = replacement_history
.iter()
.filter(|item| {
item.metadata
.as_ref()
.is_some_and(|metadata| metadata.client_authored)
})
.filter(|item| item.is_always_retained())
.count();
assert_eq!(retained_client_developers, usize::from(enabled));
Ok(())
+3 -3
View File
@@ -877,11 +877,11 @@ async fn resumed_history_only_emits_resize_notices_for_new_images() -> anyhow::R
else {
return None;
};
matches!(envelope.item, ResponseItem::Message { role, .. } if role == "developer")
.then(|| envelope.metadata.map(|metadata| metadata.client_authored))
matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "developer")
.then(|| envelope.is_always_retained())
})
.collect::<Vec<_>>();
assert_eq!(persisted_developer_metadata, vec![Some(false), Some(true)]);
assert_eq!(persisted_developer_metadata, vec![false, true]);
Ok(())
}
@@ -1,6 +1,7 @@
//! Snapshots pending mail across remote compaction, restart, and normal inference.
use super::*;
use codex_protocol::turn_input::InjectedResponseItem;
use core_test_support::responses::strip_metadata_from_json;
use pretty_assertions::assert_eq;
@@ -52,8 +53,20 @@ async fn remote_slash_compact_preserves_context_across_windows() -> Result<()> {
"author": "/root/worker", "recipient": "/root",
"content": [{"type": "input_text", "text": "Message Type: FINAL_ANSWER\nThe worker found a release blocker."}],
});
let call: InjectedResponseItem = serde_json::from_value(json!({
"item": {"type": "function_call", "call_id": "release-check", "name": "check_release", "arguments": "{}"},
"retention": "always"
}))?;
let result: InjectedResponseItem = serde_json::from_value(json!({
"item": {"type": "function_call_output", "call_id": "release-check", "output": "The release candidate passed."},
"retention": "always"
}))?;
test.codex
.inject_response_items(vec![serde_json::from_value(mail.clone())?])
.inject_response_items_with_retention(vec![
call,
result,
serde_json::from_value::<ResponseItem>(mail.clone())?.into(),
])
.await?;
for compaction in 0..2 {
test.codex.submit(Op::Compact).await?;
@@ -104,8 +104,10 @@ Settings:
-- request 2 (compaction; Remote /compact request) --
08:message/assistant:
The release plan has three steps.
09:agent_message
10:compaction_trigger
09:function_call/check_release:{}
10:function_call_output:The release candidate passed.
11:agent_message
12:compaction_trigger
## Window 2 (after request 2: input diverged at item 07)
Settings: same as window 1
@@ -194,9 +196,11 @@ Settings: same as window 1
</environment_context>
06:message/user:
Plan the release.
07:compaction:encrypted=true; chars=31; hash=8255caf138edede8
08:agent_message
09:compaction_trigger
07:function_call/check_release:{}
08:function_call_output:The release candidate passed.
09:compaction:encrypted=true; chars=31; hash=8255caf138edede8
10:agent_message
11:compaction_trigger
## Window 3 (after request 3: input diverged at item 02)
Settings: same as window 1
@@ -298,22 +302,24 @@ Settings: same as window 1
</environment_context>
06:message/user:
Plan the release.
07:compaction:encrypted=true; chars=22; hash=5180312a53382d23
08:agent_message
09:message/developer:
07:function_call/check_release:{}
08:function_call_output:The release candidate passed.
09:compaction:encrypted=true; chars=22; hash=5180312a53382d23
10:agent_message
11:message/developer:
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b]
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>
10:message/user:
12:message/user:
<environment_context>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 2></root></workspace_roots><permission_profile type="disabled"...e="unrestricted" /></permission_profile></filesystem> [hash=46e2a5c52f0891de]
</environment_context>
11:message/user:
13:message/user:
Continue with the first step.
-- request 5 (compaction; Compaction after mail was sampled) --
12:message/assistant:
14:message/assistant:
Start by validating the release candidate.
13:compaction_trigger
15:compaction_trigger
@@ -1807,13 +1807,7 @@ async fn tool_output_retention_is_submission_metadata() -> anyhow::Result<()> {
else {
return None;
};
Some((
name.as_deref().unwrap(),
envelope
.metadata
.as_ref()
.is_some_and(|metadata| metadata.client_authored),
))
Some((name.as_deref().unwrap(), envelope.is_always_retained()))
})
.collect::<Vec<_>>();
assert_eq!(
+4
View File
@@ -95,6 +95,10 @@ ignore = [
{ id = "RUSTSEC-2026-0511", reason = "hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2" },
{ id = "RUSTSEC-2026-0512", reason = "hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2" },
{ id = "RUSTSEC-2026-0514", reason = "hickory-resolver 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2" },
# Additional DNSSEC-only advisories reviewed 2026-11-13.
{ id = "RUSTSEC-2026-0530", reason = "hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2" },
{ id = "RUSTSEC-2026-0531", reason = "hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2" },
{ id = "RUSTSEC-2026-0532", reason = "hickory-proto 0.25.2 via rama-dns/rama-tcp in codex-network-proxy; DNSSEC is disabled even with all workspace features; reassess before enabling DNSSEC; remove after the Rust/Rama upgrade uses Hickory >=0.26.2" },
{ id = "RUSTSEC-2026-0194", reason = "quick-xml v0.39.4 remains via plist/syntect and wayland-scanner; plist does not exercise the affected APIs and wayland-scanner parses trusted build-time XML; remove when rust-plist#191 and wayland-rs#938 are released" },
{ id = "RUSTSEC-2026-0195", reason = "quick-xml v0.39.4 remains via plist/syntect and wayland-scanner; plist does not exercise the affected APIs and wayland-scanner parses trusted build-time XML; remove when rust-plist#191 and wayland-rs#938 are released" },
{ id = "RUSTSEC-2026-0370", reason = "zvariant 4.2.0 is unmaintained; pulled in via zbus 4.4.0 by Linux TUI accessibility integration and keyring/secret-service; remove when those paths migrate off the retired standalone crates" },
+30
View File
@@ -46,6 +46,8 @@ use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::WorldStateItem;
use codex_protocol::realtime::RealtimeItem;
use codex_protocol::security_risk::SecurityRiskScore;
use codex_protocol::turn_input::InjectedResponseItem;
use codex_protocol::turn_input::ItemRetention;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
@@ -104,9 +106,17 @@ pub struct CodexHarnessMetadata {
pub additional_context_tool_output: bool,
/// Whether a developer message was supplied by a client or a client requested tool-output retention.
///
/// Use this field only to interpret older rollouts.
/// Store new retention decisions in `retain_across_windows`.
#[serde(default)]
pub client_authored: bool,
/// Per-item override of the thread's context-window retention policy.
/// Never sent to the model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retain_across_windows: Option<bool>,
/// The originating history budget, including any tool-specific allowance.
/// Measured in tokens and reused when replaying persisted history.
#[serde(
@@ -211,6 +221,13 @@ impl CodexHarnessMetadata {
}
impl ResponseItemEnvelope {
/// Whether the client requested that this item survive context-window changes and forks.
pub fn is_always_retained(&self) -> bool {
self.metadata
.as_ref()
.is_some_and(|metadata| metadata.retain_across_windows == Some(true))
}
/// Wraps a raw Responses API item for persisted history.
pub fn new(item: ResponseItem) -> Self {
Self {
@@ -231,6 +248,19 @@ impl From<ResponseItem> for ResponseItemEnvelope {
}
}
impl From<InjectedResponseItem> for ResponseItemEnvelope {
fn from(injected: InjectedResponseItem) -> Self {
let mut envelope = Self::new(injected.item);
if injected.retention != ItemRetention::Unspecified {
envelope
.metadata
.get_or_insert_default()
.retain_across_windows = Some(injected.retention == ItemRetention::Always);
}
envelope
}
}
impl Deref for ResponseItemEnvelope {
type Target = ResponseItem;
+50
View File
@@ -10,6 +10,7 @@ use crate::protocol::W3cTraceContext;
use crate::user_input::UserInput;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -52,6 +53,55 @@ pub struct ResponseItemAnnotations {
pub retain: bool,
}
/// A Responses item for history injection. Wrapped items must specify a policy.
#[derive(Clone, Debug)]
pub struct InjectedResponseItem {
pub item: ResponseItem,
pub retention: ItemRetention,
}
impl<'de> Deserialize<'de> for InjectedResponseItem {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct WrappedItem {
item: ResponseItem,
retention: ItemRetention,
}
let value = Value::deserialize(deserializer)?;
if value.get("type").is_some() {
return ResponseItem::deserialize(value)
.map(Self::from)
.map_err(serde::de::Error::custom);
}
let WrappedItem { item, retention } =
WrappedItem::deserialize(value).map_err(serde::de::Error::custom)?;
Ok(Self { item, retention })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ItemRetention {
/// Raw items use the thread's configured retention rules.
#[serde(skip)]
Unspecified,
/// Apply ordinary history rules even when the thread retains client developer messages.
Default,
/// Carry the item into later context windows and newly spawned children.
Always,
}
impl From<ResponseItem> for InjectedResponseItem {
fn from(item: ResponseItem) -> Self {
Self {
item,
retention: ItemRetention::Unspecified,
}
}
}
/// One turn input and the context that follows it through submission.
///
/// Callers choose start-or-steer, idle-start, or steer-only behavior through