From b0272b453bef07407781df59ce6154c883790d42 Mon Sep 17 00:00:00 2001 From: maral Date: Thu, 21 May 2026 12:02:59 +0000 Subject: [PATCH 01/18] feat: implement storage CRUD layer with SQLx and benchmarks Signed-off-by: maral --- Cargo.lock | 2238 ++++++++++++++++++++++++++++ Cargo.toml | 14 + benches/storage_crud.rs | 343 +++++ migrations/0001_initial.sql | 29 + src/lib.rs | 3 + src/storage/conversation.rs | 139 ++ src/storage/mod.rs | 29 + src/storage/models/conversation.rs | 78 + src/storage/models/item.rs | 170 +++ src/storage/models/mod.rs | 9 + src/storage/models/response.rs | 138 ++ src/storage/pool.rs | 124 ++ src/storage/response.rs | 160 ++ src/storage/schema.rs | 144 ++ src/storage/types/conversation.rs | 61 + src/storage/types/errors.rs | 139 ++ src/storage/types/item.rs | 55 + src/storage/types/mod.rs | 11 + src/storage/types/response.rs | 101 ++ src/types/io.rs | 164 ++ src/types/mod.rs | 9 + src/types/request_response.rs | 123 ++ src/utils/common.rs | 42 + src/utils/mod.rs | 3 + 24 files changed, 4326 insertions(+) create mode 100644 benches/storage_crud.rs create mode 100644 migrations/0001_initial.sql create mode 100644 src/lib.rs create mode 100644 src/storage/conversation.rs create mode 100644 src/storage/mod.rs create mode 100644 src/storage/models/conversation.rs create mode 100644 src/storage/models/item.rs create mode 100644 src/storage/models/mod.rs create mode 100644 src/storage/models/response.rs create mode 100644 src/storage/pool.rs create mode 100644 src/storage/response.rs create mode 100644 src/storage/schema.rs create mode 100644 src/storage/types/conversation.rs create mode 100644 src/storage/types/errors.rs create mode 100644 src/storage/types/item.rs create mode 100644 src/storage/types/mod.rs create mode 100644 src/storage/types/response.rs create mode 100644 src/types/io.rs create mode 100644 src/types/mod.rs create mode 100644 src/types/request_response.rs create mode 100644 src/utils/common.rs create mode 100644 src/utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 5985488..9542dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,3 +5,2241 @@ version = 4 [[package]] name = "agentic-api" version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "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 = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[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 = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +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 = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +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", +] + +[[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.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "native-tls", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index f568201..0b8b34c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,17 @@ unsafe_code = "forbid" [lints.clippy] all = { level = "deny", priority = -1 } pedantic = { level = "warn", priority = -1 } + +[dependencies] +sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "any", "sqlite", "postgres", "migrate"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["v7", "serde"] } +tracing = "0.1" +thiserror = "1.0" +tokio = { version = "1.0", features = ["full"] } + +[[bench]] +name = "storage_crud" +harness = false diff --git a/benches/storage_crud.rs b/benches/storage_crud.rs new file mode 100644 index 0000000..cdc7d58 --- /dev/null +++ b/benches/storage_crud.rs @@ -0,0 +1,343 @@ +// Storage CRUD operations benchmark +// Run with: cargo bench --bench storage_crud +// Or with custom iterations: cargo bench --bench storage_crud -- --iterations 100 + +use std::env; +use std::sync::Arc; +use std::time::Instant; + +use agentic_api::storage::{ConversationStore, InOutItem, ResponseMetadata, ResponseStore, SchemaManager}; +use agentic_api::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; + +const DEFAULT_ITERATIONS: usize = 50; + +#[derive(Debug)] +struct BenchmarkResult { + operation: String, + iterations: usize, + total_duration_ms: f64, + average_duration_ms: f64, + min_duration_ms: f64, + max_duration_ms: f64, + throughput_ops_per_sec: f64, +} + +impl BenchmarkResult { + fn new(operation: &str, iterations: usize, durations: Vec) -> Self { + let total = durations.iter().sum::(); + let avg = total / iterations as f64; + let min = durations.iter().cloned().fold(f64::INFINITY, f64::min); + let max = durations.iter().cloned().fold(0.0, f64::max); + let throughput = 1000.0 / avg; + + Self { + operation: operation.to_string(), + iterations, + total_duration_ms: total, + average_duration_ms: avg, + min_duration_ms: min, + max_duration_ms: max, + throughput_ops_per_sec: throughput, + } + } + + fn print(&self) { + println!("\n=== Benchmark: {} ===", self.operation); + println!("Iterations: {}", self.iterations); + println!("Total Duration: {:.2}ms", self.total_duration_ms); + println!("Average: {:.4}ms/op", self.average_duration_ms); + println!("Min: {:.4}ms", self.min_duration_ms); + println!("Max: {:.4}ms", self.max_duration_ms); + println!("Throughput: {:.2} ops/sec", self.throughput_ops_per_sec); + } +} + +fn create_test_items() -> Vec { + let input_item = InputItem::Message(InputMessage { + role: "user".to_string(), + content: InputMessageContent::Text("Test message".to_string()), + }); + + let output_msg = OutputMessage::new("msg_123", "completed"); + + vec![ + InOutItem::Input(input_item.clone()), + InOutItem::Output(OutputItem::Message(output_msg)), + InOutItem::Input(input_item), + ] +} + +fn create_test_metadata() -> ResponseMetadata { + ResponseMetadata::default() +} + +fn parse_iterations() -> usize { + let args: Vec = env::args().collect(); + + for (i, arg) in args.iter().enumerate() { + if arg == "--iterations" || arg == "-i" { + if let Some(next_arg) = args.get(i + 1) { + if let Ok(iterations) = next_arg.parse::() { + if iterations > 0 { + return iterations; + } else { + eprintln!("Warning: iterations must be > 0, using default: {}", DEFAULT_ITERATIONS); + return DEFAULT_ITERATIONS; + } + } else { + eprintln!( + "Warning: could not parse iterations value '{}', using default: {}", + next_arg, DEFAULT_ITERATIONS + ); + return DEFAULT_ITERATIONS; + } + } + } + } + + DEFAULT_ITERATIONS +} + +fn print_usage() { + println!("\n=== Benchmark Usage ==="); + println!("Default (50 iterations):"); + println!(" cargo bench --bench storage_crud\n"); + println!("Custom iterations:"); + println!(" cargo bench --bench storage_crud -- --iterations 100"); + println!(" cargo bench --bench storage_crud -- -i 200\n"); +} + +async fn create_test_pool() -> Arc> { + sqlx::any::install_default_drivers(); + let pool = sqlx::any::AnyPoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to create test pool"); + let pool = Arc::new(pool); + + let schema_manager = SchemaManager::new_for_test(pool.as_ref()); + schema_manager + .ensure_ready() + .await + .expect("failed to initialize schema"); + + pool +} + +#[tokio::main] +async fn main() { + let iterations = parse_iterations(); + + println!("Starting Storage CRUD Benchmarks..."); + println!("Iterations: {}\n", iterations); + + bench_conversation_persist(iterations).await; + bench_conversation_rehydrate(iterations).await; + bench_response_persist(iterations).await; + bench_response_rehydrate(iterations).await; + + print_usage(); + println!("\n✅ All benchmarks completed"); +} + +async fn bench_conversation_persist(iterations: usize) { + let pool = create_test_pool().await; + + let store = ConversationStore::new(Arc::clone(&pool)); + + let conversation = match store.create().await { + Ok(conv) => conv, + Err(e) => { + eprintln!("Failed to create conversation: {}", e); + return; + } + }; + let conversation_id = &conversation.conversation_id; + + let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); + let test_metadata = create_test_metadata(); + let mut durations = Vec::new(); + let mut previous_response_id: Option = None; + + for i in 0..iterations { + let new_items = test_items_list[i % 100].clone(); + let response_id = format!("resp_{}", i); + + let start = Instant::now(); + let result = store + .persist( + conversation_id, + &response_id, + previous_response_id.as_deref(), + new_items, + &test_metadata, + ) + .await; + let duration = start.elapsed().as_secs_f64() * 1000.0; + + match result { + Ok(_) => { + previous_response_id = Some(response_id.clone()); + durations.push(duration); + } + Err(e) => { + eprintln!("Persist operation failed: {}", e); + return; + } + } + } + + let result = BenchmarkResult::new("ConversationStore::persist", iterations, durations); + result.print(); +} + +async fn bench_response_persist(iterations: usize) { + let pool = create_test_pool().await; + + let store = ResponseStore::new(Arc::clone(&pool)); + + let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); + let test_metadata = create_test_metadata(); + let mut durations = Vec::new(); + let mut previous_response_id: Option = None; + + for i in 0..iterations { + let new_items = test_items_list[i % 100].clone(); + let response_id = format!("resp_{}", i); + + let start = Instant::now(); + let result = store + .persist(&response_id, previous_response_id.as_deref(), new_items, &test_metadata) + .await; + let duration = start.elapsed().as_secs_f64() * 1000.0; + + match result { + Ok(_) => { + previous_response_id = Some(response_id); + durations.push(duration); + } + Err(e) => { + eprintln!("Persist operation failed: {}", e); + return; + } + } + } + + let result = BenchmarkResult::new("ResponseStore::persist", iterations, durations); + result.print(); +} + +async fn bench_conversation_rehydrate(iterations: usize) { + let pool = create_test_pool().await; + + let store = ConversationStore::new(Arc::clone(&pool)); + + let conversation = match store.create().await { + Ok(conv) => conv, + Err(e) => { + eprintln!("Failed to create conversation: {}", e); + return; + } + }; + let conversation_id = &conversation.conversation_id; + + let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); + let test_metadata = create_test_metadata(); + + let mut previous_response_id: Option = None; + for i in 0..iterations { + let new_items = test_items_list[i % 100].clone(); + let response_id = format!("resp_{}", i); + + let _ = store + .persist( + conversation_id, + &response_id, + previous_response_id.as_deref(), + new_items, + &test_metadata, + ) + .await; + + previous_response_id = Some(response_id); + } + + // Now benchmark rehydrate + let mut durations = Vec::new(); + + for _ in 0..iterations { + let start = Instant::now(); + let result = store.rehydrate(conversation_id).await; + let duration = start.elapsed().as_secs_f64() * 1000.0; + + match result { + Ok(_) => { + durations.push(duration); + } + Err(e) => { + eprintln!("Rehydrate operation failed: {}", e); + return; + } + } + } + + let result = BenchmarkResult::new("ConversationStore::rehydrate", iterations, durations); + result.print(); +} + +async fn bench_response_rehydrate(iterations: usize) { + let pool = create_test_pool().await; + + let store = ResponseStore::new(Arc::clone(&pool)); + + let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); + let test_metadata = create_test_metadata(); + + // Populate with data first, chaining responses + let mut response_ids = Vec::new(); + let mut previous_response_id: Option = None; + + for i in 0..iterations { + let new_items = test_items_list[i % 100].clone(); + let response_id = format!("resp_{}", i); + + if let Ok(_) = store + .persist(&response_id, previous_response_id.as_deref(), new_items, &test_metadata) + .await + { + response_ids.push(response_id.clone()); + previous_response_id = Some(response_id); + } + } + + // Fetch the response data for rehydration + let mut response_data_list = Vec::new(); + for response_id in &response_ids { + if let Ok(Some(resp_data)) = store.get(response_id).await { + response_data_list.push(resp_data); + } + } + + // Now benchmark rehydrate + let mut durations = Vec::new(); + + for response_data in &response_data_list { + let start = Instant::now(); + let result = store.rehydrate(response_data).await; + let duration = start.elapsed().as_secs_f64() * 1000.0; + + match result { + Ok(_) => { + durations.push(duration); + } + Err(e) => { + eprintln!("Rehydrate operation failed: {}", e); + return; + } + } + } + + let result = BenchmarkResult::new("ResponseStore::rehydrate", iterations, durations); + result.print(); +} diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..4085409 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + metadata TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + conversation_id TEXT REFERENCES conversations(id) ON DELETE CASCADE, + seq INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_items_conversation_id ON items (conversation_id); +CREATE INDEX IF NOT EXISTS idx_items_created_at ON items (created_at); + +CREATE TABLE IF NOT EXISTS responses ( + id TEXT PRIMARY KEY, + conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL, + previous_response_id TEXT REFERENCES responses(id) ON DELETE SET NULL, + history_item_ids TEXT, + metadata TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_responses_conversation_id ON responses (conversation_id); +CREATE INDEX IF NOT EXISTS idx_responses_previous_response_id ON responses (previous_response_id); +CREATE INDEX IF NOT EXISTS idx_responses_created_at ON responses (created_at); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..70ba355 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod storage; +pub mod types; +pub mod utils; diff --git a/src/storage/conversation.rs b/src/storage/conversation.rs new file mode 100644 index 0000000..4450b11 --- /dev/null +++ b/src/storage/conversation.rs @@ -0,0 +1,139 @@ +//! Conversation storage operations. + +use std::sync::Arc; + +use super::models::{conversation, item, response}; +use super::pool::DbPool; +use super::types::{ConversationData, InOutItem, ResponseMetadata, Result, StorageError}; +use crate::utils::common::{any_json_to_string, uuid7_str}; + +/// Conversation storage operations. +#[derive(Clone)] +pub struct ConversationStore { + pool: Option>, +} + +impl ConversationStore { + /// Creates a disabled conversation store. + #[must_use] + pub fn disabled() -> Self { + Self { pool: None } + } + + /// Creates a new conversation store with database pool. + #[must_use] + pub fn new(pool: Arc) -> Self { + Self { pool: Some(pool) } + } + + /// Returns a reference to the database pool. + /// + /// # Errors + /// + /// Returns error if store is disabled (no pool configured). + fn pool(&self) -> Result<&DbPool> { + self.pool.as_deref().ok_or(StorageError::NotConfigured) + } + + /// Creates a new conversation. + /// + /// # Errors + /// + /// Returns error if database query fails. + pub async fn create(&self) -> Result { + let pool = self.pool()?; + let row = conversation::create(pool, &uuid7_str("conv_")).await?; + Ok(row.into()) + } + + /// Gets a conversation or creates it if it doesn't exist. + /// + /// # Errors + /// + /// Returns error if database query fails. + pub async fn get_or_create(&self, conversation_id: &str) -> Result { + let pool = self.pool()?; + let row = conversation::get_or_create(pool, conversation_id).await?; + Ok(row.into()) + } + + /// Gets a conversation by ID. + /// + /// # Errors + /// + /// Returns error if database query fails. + pub async fn get(&self, conversation_id: &str) -> Result> { + let pool = self.pool()?; + let Some(row) = conversation::get(pool, conversation_id).await? else { + return Ok(None); + }; + Ok(Some(row.into())) + } + + /// Rehydrates a conversation with all its items. + /// + /// # Errors + /// + /// Returns error if conversation not found or database query fails. + pub async fn rehydrate(&self, conversation_id: &str) -> Result> { + let pool = self.pool()?; + item::conversation_item_count(pool, conversation_id) + .await? + .ok_or_else(|| StorageError::not_found("Conversation", conversation_id))?; + + let rows = item::get_items_by_conversation(pool, conversation_id).await?; + Ok(rows.into_iter().filter_map(|row| row.as_inout()).collect()) + } + + /// Persists conversation turn with new items and response metadata. + /// + /// Creates items in the conversation and stores the associated response record. + /// + /// # Errors + /// + /// Returns [`StorageError`] if conversation not found or database operation fails. + pub async fn persist( + &self, + conversation_id: &str, + response_id: &str, + previous_response_id: Option<&str>, + new_items: Vec, + metadata: &ResponseMetadata, + ) -> Result<()> { + let pool = self.pool()?; + let seq_start = item::conversation_item_count(pool, conversation_id) + .await? + .ok_or_else(|| StorageError::not_found("Conversation", conversation_id))?; + + let mut item_ids: Vec = Vec::new(); + let items_: Vec<(String, String)> = new_items + .into_iter() + .map(|any_item| { + let item_id = uuid7_str("item_"); + item_ids.push(item_id.clone()); + let data_str: String = (&any_item).into(); + (item_id, data_str) + }) + .collect(); + + let mut tx = pool.begin().await?; + + item::create_in_tx(&mut tx, items_, Some(conversation_id), Some(seq_start)).await?; + + let history_item_ids_json = any_json_to_string(&item_ids); + let metadata_json: String = metadata.into(); + + response::create_in_tx( + &mut tx, + response_id, + Some(conversation_id), + previous_response_id, + Some(&history_item_ids_json), + Some(&metadata_json), + ) + .await?; + tx.commit().await?; + + Ok(()) + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..918a4e0 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,29 @@ +//! Storage layer for persistence operations. + +// Strong types for storage operations (newtype pattern) +pub mod types; + +// Database connection pooling and initialization +pub mod pool; + +// Database schema management and migrations +pub mod schema; + +// Database schema models (sqlx FromRow types) +pub mod models; + +// Response storage operations +pub mod response; + +// Conversation storage operations +pub mod conversation; + +// Re-export commonly used types for convenience +pub use conversation::ConversationStore; +pub use models::Conversation as DbConversation; +pub use models::Item; +pub use models::Response as DbResponse; +pub use pool::{DbPool, DbResult, DbTransaction, create_pool}; +pub use response::ResponseStore; +pub use schema::SchemaManager; +pub use types::{ConversationData, InOutItem, ItemKind, ResponseData, ResponseMetadata, Result, StorageError}; diff --git a/src/storage/models/conversation.rs b/src/storage/models/conversation.rs new file mode 100644 index 0000000..6108c56 --- /dev/null +++ b/src/storage/models/conversation.rs @@ -0,0 +1,78 @@ +//! Conversation context and history. + +use super::super::pool::{DbPool, DbResult}; +use crate::utils::common::utcnow_str; + +/// Conversation context and history. +/// +/// Maps to the `conversations` table and represents a logical conversation +/// containing multiple responses and items. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Conversation { + /// Unique conversation identifier. + pub id: String, + + /// Creation timestamp in ISO 8601 format. + pub created_at: String, +} + +/// Create a new conversation. +/// +/// # Errors +/// Returns `DbResult::Err` if the database insertion fails. +pub async fn create(pool: &DbPool, id: &str) -> DbResult { + let now = utcnow_str(); + sqlx::query_as::<_, Conversation>( + "INSERT INTO conversations (id, created_at) \ + VALUES (?, ?) RETURNING *", + ) + .bind(id) + .bind(&now) + .fetch_one(pool) + .await +} + +/// Get or create a conversation. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get_or_create(pool: &DbPool, id: &str) -> DbResult { + let now = utcnow_str(); + sqlx::query_as::<_, Conversation>( + "INSERT INTO conversations (id, created_at) \ + VALUES (?, ?) \ + ON CONFLICT (id) DO UPDATE SET created_at = created_at \ + RETURNING *", + ) + .bind(id) + .bind(&now) + .fetch_one(pool) + .await +} + +/// Get a conversation by ID. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get(pool: &DbPool, id: &str) -> DbResult> { + sqlx::query_as::<_, Conversation>("SELECT * FROM conversations WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_conversation_basic() { + let conversation = Conversation { + id: "conv_1".to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + assert_eq!(conversation.id, "conv_1"); + assert_eq!(conversation.created_at, "2024-01-01T00:00:00Z"); + } +} diff --git a/src/storage/models/item.rs b/src/storage/models/item.rs new file mode 100644 index 0000000..26fe1e9 --- /dev/null +++ b/src/storage/models/item.rs @@ -0,0 +1,170 @@ +//! Conversation history item stored in the database. + +use super::super::pool::{DbPool, DbResult, DbTransaction}; +use super::super::types::item::InOutItem; +use crate::types::io::{InputItem, OutputItem}; +use crate::utils::common::{from_json_str_opt, utcnow_str}; + +/// Conversation history item stored in the database. +/// +/// Maps to the `items` table and represents a single message/event +/// in a conversation timeline. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Item { + /// Unique identifier for this item. + pub id: String, + + /// Item data stored as JSON text. + /// Deserialized based on context (`message`, `tool_call`, etc.) + pub data: String, + + /// Creation timestamp in ISO 8601 format. + pub created_at: String, + + /// Optional conversation ID for grouping items. + pub conversation_id: Option, + + /// Optional sequence number within conversation. + pub seq: Option, +} + +impl Item { + /// Deserialize data column as `InputItem`. + #[must_use] + pub fn as_input(&self) -> Option { + from_json_str_opt(&self.data) + } + + /// Deserialize data column as `OutputItem`. + #[must_use] + pub fn as_output(&self) -> Option { + from_json_str_opt(&self.data) + } + + /// Deserialize data column as either `InputItem` or `OutputItem`. + #[must_use] + pub fn as_inout(&self) -> Option { + self.as_input() + .map(InOutItem::Input) + .or_else(|| self.as_output().map(InOutItem::Output)) + } +} + +/// Create items in a transaction with optional conversation context. +/// +/// If `conversation_id` and `seq_start` are provided, items are created with sequence numbers. +/// Otherwise, items are created without conversation context. +/// +/// # Errors +/// Returns `DbResult::Err` if the database insertion fails. +pub async fn create_in_tx( + tx: &mut DbTransaction<'_>, + items: Vec<(String, String)>, + conversation_id: Option<&str>, + seq_start: Option, +) -> DbResult> { + let now = utcnow_str(); + let mut created_items = Vec::with_capacity(items.len()); + + for (idx, (id, data)) in items.into_iter().enumerate() { + let item = match (conversation_id, seq_start) { + (Some(conv_id), Some(start_seq)) => { + #[allow(clippy::cast_possible_wrap)] + let seq = start_seq + idx as i64; + sqlx::query_as::<_, Item>( + "INSERT INTO items (id, data, created_at, conversation_id, seq) VALUES (?, ?, ?, ?, ?) RETURNING *", + ) + .bind(&id) + .bind(&data) + .bind(&now) + .bind(conv_id) + .bind(seq) + .fetch_one(&mut **tx) + .await? + } + _ => { + sqlx::query_as::<_, Item>("INSERT INTO items (id, data, created_at) VALUES (?, ?, ?) RETURNING *") + .bind(&id) + .bind(&data) + .bind(&now) + .fetch_one(&mut **tx) + .await? + } + }; + created_items.push(item); + } + Ok(created_items) +} + +/// Get items by IDs. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get_items(pool: &DbPool, ids: &[String]) -> DbResult> { + if ids.is_empty() { + return Ok(vec![]); + } + let placeholders = ids.iter().map(|_| "?").collect::>().join(", "); + let sql = format!("SELECT * FROM items WHERE id IN ({placeholders})"); + let mut q = sqlx::query_as::<_, Item>(&sql); + for id in ids { + q = q.bind(id); + } + q.fetch_all(pool).await +} + +/// Get items by conversation ID ordered by sequence. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get_items_by_conversation(pool: &DbPool, conversation_id: &str) -> DbResult> { + sqlx::query_as::<_, Item>("SELECT * FROM items WHERE conversation_id = ? ORDER BY seq ASC") + .bind(conversation_id) + .fetch_all(pool) + .await +} + +/// Get count of items for a conversation. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn conversation_item_count(pool: &DbPool, conversation_id: &str) -> DbResult> { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items WHERE conversation_id = ?") + .bind(conversation_id) + .fetch_optional(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_item_basic() { + let item = Item { + id: "item_123".to_string(), + data: r#"{"role":"user","content":"hello"}"#.to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + conversation_id: Some("conv_456".to_string()), + seq: Some(1), + }; + + assert_eq!(item.id, "item_123"); + assert_eq!(item.conversation_id, Some("conv_456".to_string())); + assert_eq!(item.seq, Some(1)); + } + + #[test] + fn test_item_optional_fields() { + let item = Item { + id: "item_789".to_string(), + data: r#"{"role":"assistant"}"#.to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + conversation_id: None, + seq: None, + }; + + assert!(item.conversation_id.is_none()); + assert!(item.seq.is_none()); + } +} diff --git a/src/storage/models/mod.rs b/src/storage/models/mod.rs new file mode 100644 index 0000000..e0f4508 --- /dev/null +++ b/src/storage/models/mod.rs @@ -0,0 +1,9 @@ +//! Database models organized by entity. + +pub mod conversation; +pub mod item; +pub mod response; + +pub use conversation::Conversation; +pub use item::Item; +pub use response::Response; diff --git a/src/storage/models/response.rs b/src/storage/models/response.rs new file mode 100644 index 0000000..9e20291 --- /dev/null +++ b/src/storage/models/response.rs @@ -0,0 +1,138 @@ +//! LLM API response stored in the database. + +use super::super::pool::{DbPool, DbResult, DbTransaction}; +use crate::utils::common::{from_json_string_opt, from_json_string_opt_or_default, utcnow_str}; + +/// LLM API response stored in the database. +/// +/// Maps to the `responses` table and represents a single API response +/// with its metadata and history chain. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Response { + /// Unique response identifier. + pub id: String, + + /// Optional conversation this response belongs to. + pub conversation_id: Option, + + /// Optional reference to previous response for chaining. + pub previous_response_id: Option, + + /// History item IDs as JSON array string. + pub history_item_ids: Option, + + /// Response metadata as JSON object string. + pub metadata: Option, + + /// Creation timestamp in ISO 8601 format. + pub created_at: String, +} + +/// Create a response in a transaction and return it. +/// +/// # Errors +/// Returns `DbResult::Err` if the database insertion fails. +pub async fn create_in_tx( + tx: &mut DbTransaction<'_>, + id: &str, + conversation_id: Option<&str>, + previous_response_id: Option<&str>, + history_item_ids: Option<&str>, + metadata: Option<&str>, +) -> DbResult { + let now = utcnow_str(); + sqlx::query_as::<_, Response>( + "INSERT INTO responses \ + (id, conversation_id, previous_response_id, history_item_ids, metadata, created_at) \ + VALUES (?, ?, ?, ?, ?, ?) RETURNING *", + ) + .bind(id) + .bind(conversation_id) + .bind(previous_response_id) + .bind(history_item_ids) + .bind(metadata) + .bind(&now) + .fetch_one(&mut **tx) + .await +} + +/// Get a response by ID. +/// +/// # Errors +/// Returns `DbResult::Err` if the database query fails. +pub async fn get(pool: &DbPool, id: &str) -> DbResult> { + sqlx::query_as::<_, Response>("SELECT * FROM responses WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +impl Response { + /// Deserialize `history_item_ids` from JSON string to Vec. + #[must_use] + pub fn history_item_ids_vec(&self) -> Vec { + from_json_string_opt_or_default(&self.history_item_ids) + } + + /// Deserialize metadata from JSON string to the given type. + #[must_use] + pub fn metadata_as(&self) -> Option { + from_json_string_opt(&self.metadata) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_response_history_ids_empty() { + let response = Response { + id: "test".to_string(), + conversation_id: None, + previous_response_id: None, + history_item_ids: None, + metadata: None, + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let ids: Vec = response.history_item_ids_vec(); + assert!(ids.is_empty()); + } + + #[test] + fn test_response_history_ids_valid() { + let response = Response { + id: "test".to_string(), + conversation_id: None, + previous_response_id: None, + history_item_ids: Some(r#"["item_1", "item_2"]"#.to_string()), + metadata: None, + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let ids = response.history_item_ids_vec(); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], "item_1"); + } + + #[test] + fn test_response_metadata_deserialize() { + #[derive(serde::Deserialize, PartialEq, Debug)] + struct TestMeta { + model: String, + } + + let response = Response { + id: "resp_1".to_string(), + conversation_id: None, + previous_response_id: None, + history_item_ids: None, + metadata: Some(r#"{"model":"gpt-4"}"#.to_string()), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let meta: Option = response.metadata_as(); + assert!(meta.is_some()); + } +} diff --git a/src/storage/pool.rs b/src/storage/pool.rs new file mode 100644 index 0000000..265059d --- /dev/null +++ b/src/storage/pool.rs @@ -0,0 +1,124 @@ +//! Database connection pooling and initialization. + +use std::sync::Arc; + +use sqlx::any::AnyPoolOptions; + +/// Generic database pool type supporting `SQLite`, `PostgreSQL`, and `MySQL`. +pub type DbPool = sqlx::Pool; + +/// Database transaction type for multi-statement operations. +pub type DbTransaction<'a> = sqlx::Transaction<'a, sqlx::Any>; + +/// Convenience type alias for database operation results. +/// +/// All database queries return `DbResult` which is `Result`. +pub type DbResult = Result; + +/// Prepares database URL with appropriate parameters. +/// +/// For `SQLite` connections, adds `?mode=rwc` if not already present. +/// This enables write mode (`rwc` = read-write-create) for file-based databases. +/// +/// For other database types (`PostgreSQL`, `MySQL`), returns URL as-is. +fn prepare_db_url(url: &str) -> String { + if url.starts_with("sqlite") && !url.contains('?') { + format!("{url}?mode=rwc") + } else { + url.to_string() + } +} + +/// Creates a connection pool for the database. +/// +/// Initializes a connection pool with sensible defaults: +/// - Max connections: 10 (configurable via [`AnyPoolOptions`]) +/// - Driver auto-detection: supports `SQLite`, `PostgreSQL`, `MySQL` +/// - `SQLite` file mode: read-write-create for file-based databases +/// +/// The pool is wrapped in `Arc` for thread-safe sharing across async tasks. +/// See [Rust Cookbook § Database](https://rust-lang-nursery.github.io/rust-cookbook/database.html) +/// for pooling best practices. +/// +/// # Arguments +/// +/// * `db_url` - Database connection URL (e.g., `sqlite://data.db`, `postgresql://user:pass@host/db`) +/// +/// # Errors +/// +/// Returns [`sqlx::Error`] if: +/// - Connection URL is invalid +/// - Database server is unreachable +/// - Connection limit is exceeded +/// - Authentication fails +/// +/// # Examples +/// +/// ```ignore +/// use agentic_api::storage::pool; +/// +/// // SQLite (file-based) +/// let pool = pool::create_pool("sqlite://data.db").await?; +/// +/// // PostgreSQL +/// let pool = pool::create_pool("postgresql://user:pass@localhost/mydb").await?; +/// +/// // Use the pool (shared via Arc) +/// let result = sqlx::query("SELECT * FROM responses") +/// .fetch_one(pool.as_ref()) +/// .await?; +/// ``` +/// +/// # Performance Considerations (From Rust Cookbook) +/// +/// - Connection pooling reduces overhead of establishing new connections +/// - Connections are reused from the pool for subsequent queries +/// - Maximum connections should be tuned to database and application capacity +/// - No blocking I/O on connection retrieval - uses async/await +pub async fn create_pool(db_url: &str) -> DbResult> { + // Install default drivers for auto-detection + sqlx::any::install_default_drivers(); + + // Prepare URL with database-specific parameters + let url = prepare_db_url(db_url); + + // Create connection pool with 10 max connections + // This is a conservative default - tune based on your workload + let pool = AnyPoolOptions::new().max_connections(10).connect(&url).await?; + + // Wrap in Arc for thread-safe sharing across async tasks + Ok(Arc::new(pool)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prepare_sqlite_url_without_params() { + let url = "sqlite://test.db"; + let prepared = prepare_db_url(url); + assert_eq!(prepared, "sqlite://test.db?mode=rwc"); + } + + #[test] + fn test_prepare_sqlite_url_with_params() { + let url = "sqlite://test.db?cache=shared"; + let prepared = prepare_db_url(url); + assert_eq!(prepared, "sqlite://test.db?cache=shared"); + } + + #[test] + fn test_prepare_postgres_url() { + let url = "postgresql://user:pass@localhost/db"; + let prepared = prepare_db_url(url); + assert_eq!(prepared, "postgresql://user:pass@localhost/db"); + } + + #[test] + fn test_prepare_mysql_url() { + let url = "mysql://user:pass@localhost/db"; + let prepared = prepare_db_url(url); + assert_eq!(prepared, "mysql://user:pass@localhost/db"); + } +} diff --git a/src/storage/response.rs b/src/storage/response.rs new file mode 100644 index 0000000..03d9a33 --- /dev/null +++ b/src/storage/response.rs @@ -0,0 +1,160 @@ +//! Response storage operations and queries. + +use std::sync::Arc; + +use super::models::{item, response}; +use super::pool::DbPool; +use super::types::{InOutItem, ResponseData, ResponseMetadata, Result, StorageError}; +use crate::utils::common::{any_json_to_string, uuid7_str}; + +/// Response storage operations. +#[derive(Clone)] +pub struct ResponseStore { + pool: Option>, +} + +impl ResponseStore { + /// Creates a disabled response store (no persistence). + /// + /// Useful for testing or when response storage is not configured. + #[must_use] + pub fn disabled() -> Self { + Self { pool: None } + } + + /// Creates a new response store with database pool. + /// + /// # Arguments + /// + /// * `pool` - Connection pool for database access + #[must_use] + pub fn new(pool: Arc) -> Self { + Self { pool: Some(pool) } + } + + /// Creates a response store for testing without a real database. + #[must_use] + pub fn new_test() -> Self { + Self { pool: None } + } + + /// Returns a reference to the database pool. + /// + /// # Errors + /// + /// Returns [`StorageError::NotConfigured`] if store is disabled (no pool configured). + fn pool(&self) -> Result<&DbPool> { + self.pool.as_deref().ok_or(StorageError::NotConfigured) + } + + /// Retrieves a response by ID. + /// + /// # Errors + /// + /// Returns error if database query fails or store is disabled. + pub async fn get(&self, response_id: &str) -> Result> { + let pool = self.pool()?; + let Some(row) = response::get(pool, response_id).await? else { + return Ok(None); + }; + + Ok(Some(row.into())) + } + + /// Retrieves a response or returns error if not found. + /// + /// # Errors + /// + /// Returns error if response not found, store is disabled, or database query fails. + pub async fn get_or_raise(&self, response_id: &str) -> Result { + self.get(response_id) + .await? + .ok_or_else(|| StorageError::not_found("Response", response_id)) + } + + /// Rehydrates a response with full history. + /// + /// Fetches all history items referenced by a response. + /// + /// # Errors + /// + /// Returns error if database query fails or store is disabled. + pub async fn rehydrate(&self, response: &ResponseData) -> Result> { + let pool = self.pool()?; + let rows = item::get_items(pool, &response.history_item_ids).await?; + Ok(rows.into_iter().filter_map(|row| row.as_inout()).collect()) + } + + /// Persists a response with its items and metadata. + /// + /// Creates items and stores the associated response record. + /// + /// # Errors + /// + /// Returns [`StorageError`] if database operation fails or store is disabled. + pub async fn persist( + &self, + response_id: &str, + previous_response_id: Option<&str>, + new_items: Vec, + metadata: &ResponseMetadata, + ) -> Result<()> { + let pool = self.pool()?; + + let mut item_ids: Vec = Vec::new(); + let items_: Vec<(String, String)> = new_items + .into_iter() + .map(|any_item| { + let item_id = uuid7_str("item_"); + item_ids.push(item_id.clone()); + let data_str: String = (&any_item).into(); + (item_id, data_str) + }) + .collect(); + + let mut tx = pool.begin().await?; + + item::create_in_tx(&mut tx, items_, None, None).await?; + + let history_item_ids_json = any_json_to_string(&item_ids); + let metadata_json: String = metadata.into(); + + response::create_in_tx( + &mut tx, + response_id, + None, + previous_response_id, + Some(&history_item_ids_json), + Some(&metadata_json), + ) + .await?; + tx.commit().await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::types::ResponseMetadata; + use super::*; + + #[test] + fn test_response_store_disabled() { + let store = ResponseStore::disabled(); + assert!(store.pool().is_err()); + } + + #[test] + fn test_response_store_new_test() { + let store = ResponseStore::new_test(); + assert!(store.pool().is_err()); + } + + #[test] + fn test_response_metadata_default() { + let meta = ResponseMetadata::default(); + assert!(meta.model.is_empty()); + assert!(meta.previous_response_id.is_none()); + } +} diff --git a/src/storage/schema.rs b/src/storage/schema.rs new file mode 100644 index 0000000..445bf84 --- /dev/null +++ b/src/storage/schema.rs @@ -0,0 +1,144 @@ +//! Database schema management and migrations. + +use std::env; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::{debug, info}; + +use super::pool::DbPool; + +type DbResult = Result; + +static SCHEMA_READY: AtomicBool = AtomicBool::new(false); + +fn is_marked_ready() -> bool { + matches!( + env::var("AA_DB_SCHEMA_READY").as_deref(), + Ok("1" | "true" | "t" | "yes" | "y" | "on") + ) +} + +/// Manages database schema initialization and migrations. +pub struct SchemaManager<'a> { + pool: &'a DbPool, +} + +impl<'a> SchemaManager<'a> { + /// Creates a new schema manager for the given database pool. + #[must_use] + pub fn new(pool: &'a DbPool) -> Self { + Self { pool } + } + + /// Ensures database schema is ready by running pending migrations. + /// + /// Checks if migrations have already been applied (via in-memory flag or + /// `AA_DB_SCHEMA_READY` environment variable). If not, runs all pending + /// migrations from the `migrations/` directory. + /// + /// # Errors + /// + /// Returns a [`sqlx::Error`] if migrations fail. + pub async fn ensure_ready(&self) -> DbResult<()> { + if SCHEMA_READY.load(Ordering::SeqCst) { + return Ok(()); + } + + if is_marked_ready() { + debug!("[schema] DDL skipped — marked ready by supervisor."); + SCHEMA_READY.store(true, Ordering::SeqCst); + return Ok(()); + } + + debug!("[schema] Running migrations..."); + sqlx::migrate!("./migrations") + .run(self.pool) + .await + .map_err(|e| sqlx::Error::Configuration(e.to_string().into()))?; + + SCHEMA_READY.store(true, Ordering::SeqCst); + info!("[schema] DB schema ready."); + Ok(()) + } +} + +impl<'a> SchemaManager<'a> { + /// Creates a schema manager that resets the ready flag for testing. + /// + /// Useful for tests that create a new in-memory pool per test case, + /// ensuring migrations run fresh for each test. + #[must_use] + pub fn new_for_test(pool: &'a DbPool) -> Self { + SCHEMA_READY.store(false, Ordering::SeqCst); + Self { pool } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_ready_flag_toggle() { + // Test basic atomic toggle behavior + SCHEMA_READY.store(false, Ordering::SeqCst); + assert!(!SCHEMA_READY.load(Ordering::SeqCst)); + + SCHEMA_READY.store(true, Ordering::SeqCst); + assert!(SCHEMA_READY.load(Ordering::SeqCst)); + + SCHEMA_READY.store(false, Ordering::SeqCst); + assert!(!SCHEMA_READY.load(Ordering::SeqCst)); + } + + #[test] + fn test_schema_ready_flag_sequential() { + // Test sequential consistency with multiple transitions + SCHEMA_READY.store(false, Ordering::SeqCst); + + for i in 0..10 { + let value = i % 2 == 0; + SCHEMA_READY.store(value, Ordering::SeqCst); + assert_eq!(SCHEMA_READY.load(Ordering::SeqCst), value); + } + } + + #[test] + fn test_new_for_test_resets_flag() { + // Set flag to true to simulate previous test state + SCHEMA_READY.store(true, Ordering::SeqCst); + assert!(SCHEMA_READY.load(Ordering::SeqCst)); + + // Reset behavior from new_for_test + SCHEMA_READY.store(false, Ordering::SeqCst); + assert!(!SCHEMA_READY.load(Ordering::SeqCst)); + } + + #[test] + fn test_env_var_pattern() { + // Test the pattern matching logic for AA_DB_SCHEMA_READY + let test_values = vec![ + ("1", true), + ("true", true), + ("t", true), + ("yes", true), + ("y", true), + ("on", true), + ("0", false), + ("false", false), + ("f", false), + ("no", false), + ("n", false), + ("off", false), + ("", false), + ]; + + for (val, expected) in test_values { + let matches = matches!( + Ok::<&str, String>(val).as_deref(), + Ok("1" | "true" | "t" | "yes" | "y" | "on") + ); + assert_eq!(matches, expected, "Mismatch for value '{}'", val); + } + } +} diff --git a/src/storage/types/conversation.rs b/src/storage/types/conversation.rs new file mode 100644 index 0000000..b63ddb7 --- /dev/null +++ b/src/storage/types/conversation.rs @@ -0,0 +1,61 @@ +//! Domain type for conversation storage. + +use super::super::models::Conversation as StorageDbConversation; + +/// Domain entity for a stored conversation. +/// +/// Represents a conversation context with metadata and history tracking. +#[derive(Debug, Clone)] +pub struct ConversationData { + /// Unique conversation identifier + pub conversation_id: String, + /// Creation timestamp in ISO 8601 format + pub created_at: String, +} + +impl From for ConversationData { + fn from(row: StorageDbConversation) -> Self { + Self { + conversation_id: row.id, + created_at: row.created_at, + } + } +} + +impl From for StorageDbConversation { + fn from(data: ConversationData) -> Self { + Self { + id: data.conversation_id, + created_at: data.created_at.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_conversation_from_db_conversation() { + let db_row = StorageDbConversation { + id: "conv_123".to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let conversation: ConversationData = db_row.into(); + assert_eq!(conversation.conversation_id, "conv_123"); + assert_eq!(conversation.created_at, "2024-01-01T00:00:00Z"); + } + + #[test] + fn test_conversation_roundtrip() { + let data = ConversationData { + conversation_id: "conv_456".to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let db_row: StorageDbConversation = data.into(); + assert_eq!(db_row.id, "conv_456"); + assert_eq!(db_row.created_at, "2024-01-01T00:00:00Z"); + } +} diff --git a/src/storage/types/errors.rs b/src/storage/types/errors.rs new file mode 100644 index 0000000..eb2752a --- /dev/null +++ b/src/storage/types/errors.rs @@ -0,0 +1,139 @@ +//! Storage layer error types. + +use thiserror::Error; + +/// Result type for storage operations. +/// +/// All storage functions return `Result` for explicit error handling. +pub type Result = std::result::Result; + +/// Storage layer errors with detailed context. +#[derive(Error, Debug)] +pub enum StorageError { + /// Resource not found in database. + #[error("not found: {resource_type} with id '{id}'")] + NotFound { resource_type: String, id: String }, + + /// Database operation failed. + /// + /// Wraps `sqlx::Error` and automatically converts from it via `#[from]`. + /// This allows using `?` operator with sqlx results. + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + + /// Storage is not configured or disabled. + #[error("storage not configured or disabled")] + NotConfigured, + + /// Invalid operation on resource. + #[error("invalid {resource_type}: {reason}")] + Invalid { resource_type: String, reason: String }, + + /// Serialization/deserialization error. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// Internal storage error (unexpected condition). + #[error("internal storage error: {0}")] + Internal(String), +} + +impl StorageError { + /// Creates a "not found" error for a resource. + #[must_use] + pub fn not_found(resource_type: impl Into, id: impl Into) -> Self { + Self::NotFound { + resource_type: resource_type.into(), + id: id.into(), + } + } + + /// Creates an invalid operation error. + #[must_use] + pub fn invalid(resource_type: impl Into, reason: impl Into) -> Self { + Self::Invalid { + resource_type: resource_type.into(), + reason: reason.into(), + } + } + + /// Creates an internal error for unexpected conditions. + #[must_use] + pub fn internal(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } + + /// Returns `true` if this error is "not found". + #[must_use] + pub fn is_not_found(&self) -> bool { + matches!(self, Self::NotFound { .. }) + } + + /// Returns `true` if this error is "not configured". + #[must_use] + pub fn is_not_configured(&self) -> bool { + matches!(self, Self::NotConfigured) + } + + /// Extracts the resource type and ID if this is a "not found" error. + /// + /// # Examples + /// + /// ```ignore + /// let err = StorageError::not_found("Response", "123"); + /// if let Some((resource, id)) = err.not_found_details() { + /// println!("Not found: {} {}", resource, id); + /// } + /// ``` + #[must_use] + pub fn not_found_details(&self) -> Option<(String, String)> { + match self { + Self::NotFound { resource_type, id } => Some((resource_type.clone(), id.clone())), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_not_found_error_creation() { + let err = StorageError::not_found("Response", "resp_123"); + assert!(err.is_not_found()); + assert!(!err.is_not_configured()); + } + + #[test] + fn test_not_found_details_extraction() { + let err = StorageError::not_found("Agent", "agent_456"); + let details = err.not_found_details(); + assert!(details.is_some()); + let (resource, id) = details.unwrap(); + assert_eq!(resource, "Agent"); + assert_eq!(id, "agent_456"); + } + + #[test] + fn test_not_configured_error() { + let err = StorageError::NotConfigured; + assert!(!err.is_not_found()); + assert!(err.is_not_configured()); + } + + #[test] + fn test_invalid_error_creation() { + let err = StorageError::invalid("Conversation", "empty conversation ID"); + let msg = err.to_string(); + assert!(msg.contains("invalid")); + assert!(msg.contains("Conversation")); + } + + #[test] + fn test_error_display_formatting() { + let err = StorageError::not_found("Response", "123"); + let msg = err.to_string(); + assert_eq!(msg, "not found: Response with id '123'"); + } +} diff --git a/src/storage/types/item.rs b/src/storage/types/item.rs new file mode 100644 index 0000000..0de0ccd --- /dev/null +++ b/src/storage/types/item.rs @@ -0,0 +1,55 @@ +//! Domain types for conversation items. + +use serde::{Deserialize, Serialize}; + +use crate::types::io::{InputItem, OutputItem}; + +/// Item kind (input vs output) for storage and retrieval. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { + Input, + Output, +} + +/// Union type for conversation items (input or output). +#[derive(Debug, Clone)] +pub enum InOutItem { + Input(InputItem), + Output(OutputItem), +} + +impl From for InOutItem { + fn from(item: InputItem) -> Self { + Self::Input(item) + } +} + +impl From for InOutItem { + fn from(item: OutputItem) -> Self { + Self::Output(item) + } +} + +impl From<&InOutItem> for String { + fn from(item: &InOutItem) -> Self { + match item { + InOutItem::Input(input) => serde_json::to_string(input).unwrap_or_default(), + InOutItem::Output(output) => serde_json::to_string(output).unwrap_or_default(), + } + } +} + +impl InOutItem { + /// Extracts input items from a mixed history, filtering out output items. + #[must_use] + pub fn into_input_items(history: Vec) -> Vec { + history + .into_iter() + .filter_map(|i| match i { + InOutItem::Input(item) => Some(item), + InOutItem::Output(_) => None, + }) + .collect() + } +} diff --git a/src/storage/types/mod.rs b/src/storage/types/mod.rs new file mode 100644 index 0000000..f9b4115 --- /dev/null +++ b/src/storage/types/mod.rs @@ -0,0 +1,11 @@ +//! Domain types for storage operations. + +pub mod conversation; +pub mod errors; +pub mod item; +pub mod response; + +pub use conversation::ConversationData; +pub use errors::{Result, StorageError}; +pub use item::{InOutItem, ItemKind}; +pub use response::{ResponseData, ResponseMetadata}; diff --git a/src/storage/types/response.rs b/src/storage/types/response.rs new file mode 100644 index 0000000..a95824d --- /dev/null +++ b/src/storage/types/response.rs @@ -0,0 +1,101 @@ +//! Domain type for response storage. + +use serde::{Deserialize, Serialize}; +use serde_json; + +use super::super::models::Response as StorageDbResponse; +use crate::types::io::{ResponsesTool, ToolChoice}; + +/// Response metadata with effective configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ResponseMetadata { + pub model: String, + pub previous_response_id: Option, + pub effective_tools: Option>, + pub effective_tool_choice: ToolChoice, + pub effective_instructions: Option, +} + +/// Domain entity for a stored LLM response. +#[derive(Debug, Clone)] +pub struct ResponseData { + /// Unique response identifier + pub response_id: String, + /// Optional conversation this response belongs to + pub conversation_id: Option, + /// Optional reference to previous response for chaining + pub previous_response_id: Option, + /// Creation timestamp in ISO 8601 format + pub created_at: String, + /// Deserialized history item IDs (vec of item IDs) + pub history_item_ids: Vec, + /// Response metadata with effective configuration (fully typed) + pub metadata: ResponseMetadata, +} + +impl From for ResponseData { + fn from(row: StorageDbResponse) -> Self { + let history_item_ids = row.history_item_ids_vec(); + let metadata = row.metadata_as::().unwrap_or_default(); + + Self { + response_id: row.id, + conversation_id: row.conversation_id, + previous_response_id: row.previous_response_id, + created_at: row.created_at, + history_item_ids, + metadata, + } + } +} + +impl From<&ResponseMetadata> for String { + fn from(metadata: &ResponseMetadata) -> Self { + serde_json::to_string(metadata).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_response_data_from_db_response() { + let db_row = StorageDbResponse { + id: "resp_123".to_string(), + conversation_id: Some("conv_456".to_string()), + previous_response_id: None, + history_item_ids: Some(r#"["item_1"]"#.to_string()), + metadata: Some( + r#"{"model":"gpt-4","previous_response_id":null,"effective_tools":null,"effective_tool_choice":"auto","effective_instructions":null}"# + .to_string(), + ), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let response: ResponseData = db_row.into(); + assert_eq!(response.response_id, "resp_123"); + assert_eq!(response.conversation_id, Some("conv_456".to_string())); + assert_eq!(response.created_at, "2024-01-01T00:00:00Z"); + assert_eq!(response.history_item_ids, vec!["item_1".to_string()]); + assert_eq!(response.metadata.model, "gpt-4"); + } + + #[test] + fn test_response_data_from_db_response_optional_fields() { + let db_row = StorageDbResponse { + id: "resp_789".to_string(), + conversation_id: None, + previous_response_id: None, + history_item_ids: None, + metadata: None, + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let response: ResponseData = db_row.into(); + assert_eq!(response.response_id, "resp_789"); + assert!(response.conversation_id.is_none()); + assert!(response.history_item_ids.is_empty()); + assert_eq!(response.metadata.model, ""); + } +} diff --git a/src/types/io.rs b/src/types/io.rs new file mode 100644 index 0000000..239a9ce --- /dev/null +++ b/src/types/io.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputTextContent { + #[serde(rename = "type")] + pub type_: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputImageContent { + #[serde(rename = "type")] + pub type_: String, + pub image_url: Option, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum InputContent { + #[serde(rename = "input_text")] + Text(InputTextContent), + #[serde(rename = "input_image")] + Image(InputImageContent), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputMessage { + pub role: String, + pub content: InputMessageContent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum InputMessageContent { + Text(String), + Parts(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionToolResultMessage { + pub call_id: String, + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum InputItem { + #[serde(rename = "message")] + Message(InputMessage), + #[serde(rename = "function_call_output")] + FunctionCallOutput(FunctionToolResultMessage), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputTextContent { + #[serde(rename = "type")] + pub type_: String, + pub text: String, + #[serde(default)] + pub annotations: Vec, +} + +impl OutputTextContent { + pub fn new(text: impl Into) -> Self { + Self { + type_: "output_text".into(), + text: text.into(), + annotations: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputMessage { + pub id: String, + pub role: String, + pub status: String, + #[serde(default)] + pub content: Vec, +} + +impl OutputMessage { + pub fn new(id: impl Into, status: impl Into) -> Self { + Self { + id: id.into(), + role: "assistant".into(), + status: status.into(), + content: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionToolCall { + pub id: String, + pub call_id: String, + pub name: String, + pub arguments: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum OutputItem { + #[serde(rename = "message")] + Message(OutputMessage), + #[serde(rename = "function_call")] + FunctionCall(FunctionToolCall), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InputTokenDetails { + pub cached_tokens: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OutputTokenDetails { + pub reasoning_tokens: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ResponseUsage { + pub input_tokens: i64, + pub output_tokens: i64, + pub total_tokens: i64, + #[serde(default)] + pub input_tokens_details: InputTokenDetails, + #[serde(default)] + pub output_tokens_details: OutputTokenDetails, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionTool { + #[serde(rename = "type")] + pub type_: String, + pub name: String, + pub description: Option, + pub parameters: Option, + pub strict: Option, +} + +pub type ResponsesTool = FunctionTool; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolChoice { + #[default] + Auto, + None, + Required, + #[serde(rename = "function")] + Function { + name: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponsesInput { + Text(String), + Items(Vec), +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..73186b9 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,9 @@ +pub mod io; +pub mod request_response; + +pub use io::{ + FunctionTool, FunctionToolCall, FunctionToolResultMessage, InputContent, InputImageContent, InputItem, + InputMessage, InputMessageContent, InputTextContent, InputTokenDetails, OutputItem, OutputMessage, + OutputTextContent, OutputTokenDetails, ResponseUsage, ResponsesInput, ResponsesTool, ToolChoice, +}; +pub use request_response::{IncompleteDetails, ResponsesRequest, ResponsesResponse, UpstreamRequest}; diff --git a/src/types/request_response.rs b/src/types/request_response.rs new file mode 100644 index 0000000..14f5b61 --- /dev/null +++ b/src/types/request_response.rs @@ -0,0 +1,123 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::io::{ + InputItem, InputMessage, InputMessageContent, OutputItem, ResponseUsage, ResponsesInput, ResponsesTool, ToolChoice, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponsesRequest { + pub model: String, + pub input: ResponsesInput, + pub instructions: Option, + pub previous_response_id: Option, + pub conversation_id: Option, + pub tools: Option>, + #[serde(default)] + pub tool_choice: ToolChoice, + #[serde(default)] + pub stream: bool, + #[serde(default = "default_true")] + pub store: bool, + pub include: Option>, + pub temperature: Option, + pub top_p: Option, + pub max_output_tokens: Option, + pub truncation: Option, + pub metadata: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize)] +pub struct UpstreamRequest<'a> { + pub model: &'a str, + pub input: &'a ResponsesInput, + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option<&'a Vec>, + #[serde(skip_serializing_if = "is_default_tool_choice")] + pub tool_choice: &'a ToolChoice, + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option<&'a Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub truncation: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option<&'a Value>, +} + +fn is_default_tool_choice(choice: &ToolChoice) -> bool { + matches!(choice, ToolChoice::Auto) +} + +impl ResponsesRequest { + /// Construct an `UpstreamRequest` borrowing from this request, suitable for forwarding to vLLM. + #[must_use] + pub fn to_upstream_request(&self, stream: bool) -> UpstreamRequest<'_> { + UpstreamRequest { + model: &self.model, + input: &self.input, + stream, + instructions: self.instructions.as_deref(), + tools: self.tools.as_ref(), + tool_choice: &self.tool_choice, + include: self.include.as_ref(), + temperature: self.temperature, + top_p: self.top_p, + max_output_tokens: self.max_output_tokens, + truncation: self.truncation.as_deref(), + metadata: self.metadata.as_ref(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncompleteDetails { + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponsesResponse { + pub id: String, + pub object: String, + pub created_at: i64, + pub model: String, + pub status: String, + #[serde(default)] + pub output: Vec, + pub usage: Option, + pub incomplete_details: Option, + pub error: Option, + pub previous_response_id: Option, + pub conversation_id: Option, + pub instructions: Option, +} + +impl ResponsesResponse { + #[must_use] + pub fn as_responses_chunk(&self) -> String { + format!("data: {}\n\n", serde_json::to_string(self).unwrap_or_default()) + } +} + +impl From<&ResponsesInput> for Vec { + fn from(input: &ResponsesInput) -> Self { + match input { + ResponsesInput::Text(text) => vec![InputItem::Message(InputMessage { + role: "user".into(), + content: InputMessageContent::Text(text.clone()), + })], + ResponsesInput::Items(items) => items.clone(), + } + } +} diff --git a/src/utils/common.rs b/src/utils/common.rs new file mode 100644 index 0000000..7e7ee2f --- /dev/null +++ b/src/utils/common.rs @@ -0,0 +1,42 @@ +use chrono::Utc; +use uuid::Uuid; + +#[must_use] +pub fn uuid7_str(prefix: &str) -> String { + format!("{}{}", prefix, Uuid::now_v7()) +} + +#[must_use] +pub fn utcnow_str() -> String { + Utc::now().to_rfc3339() +} + +/// Serialize any type to JSON string, returning empty string on error. +#[must_use] +pub fn any_json_to_string(value: &T) -> String { + serde_json::to_string(value).unwrap_or_default() +} + +/// Deserialize JSON string to any type, returning default on error. +#[must_use] +pub fn from_json_str(json_str: &str) -> T { + serde_json::from_str(json_str).unwrap_or_default() +} + +/// Deserialize JSON &str to any type, returning None on error. +#[must_use] +pub fn from_json_str_opt(json_str: &str) -> Option { + serde_json::from_str(json_str).ok() +} + +/// Deserialize optional JSON String to any type, returning default on error or if None. +#[must_use] +pub fn from_json_string_opt_or_default(json_str: &Option) -> T { + json_str.as_ref().map(|s| from_json_str::(s)).unwrap_or_default() +} + +/// Deserialize optional JSON String to any type, returning None on error or if None. +#[must_use] +pub fn from_json_string_opt(json_str: &Option) -> Option { + json_str.as_ref().and_then(|s| serde_json::from_str(s).ok()) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..72b6ca2 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod common; + +pub use common::{utcnow_str, uuid7_str}; From 473485da385f5a3c0069057f66eac605cefaa840 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 22 May 2026 09:25:08 +0000 Subject: [PATCH 02/18] use rust criterion for benchmarking and clean code Signed-off-by: maral --- .gitignore | 4 + Cargo.lock | 325 +++++++++++++++++++++++ Cargo.toml | 3 + benches/storage_crud.rs | 472 ++++++++++++--------------------- src/storage/conversation.rs | 4 +- src/storage/models/item.rs | 6 +- src/storage/models/response.rs | 6 +- src/storage/response.rs | 4 +- src/utils/common.rs | 25 +- 9 files changed, 523 insertions(+), 326 deletions(-) diff --git a/.gitignore b/.gitignore index d74483e..bc89e02 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ target/ .vscode/ *.swp *.swo + +# db +*.db +*.db-journal diff --git a/Cargo.lock b/Cargo.lock index 9542dca..972d691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,6 +7,7 @@ name = "agentic-api" version = "0.1.0" dependencies = [ "chrono", + "criterion", "serde", "serde_json", "sqlx", @@ -16,6 +17,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -31,6 +41,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -100,6 +122,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.62" @@ -130,6 +158,58 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -185,6 +265,63 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -200,6 +337,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -350,6 +493,20 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -455,6 +612,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -487,6 +655,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -665,6 +839,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -856,6 +1050,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.80" @@ -982,6 +1182,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1064,6 +1292,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1082,6 +1330,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rsa" version = "0.9.10" @@ -1127,6 +1404,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1615,6 +1901,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1782,6 +2078,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1891,6 +2197,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" @@ -1901,6 +2217,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 0b8b34c..a562942 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,6 @@ tokio = { version = "1.0", features = ["full"] } [[bench]] name = "storage_crud" harness = false + +[dev-dependencies] +criterion = { version = "0.5", features = ["async_tokio"] } diff --git a/benches/storage_crud.rs b/benches/storage_crud.rs index cdc7d58..648db74 100644 --- a/benches/storage_crud.rs +++ b/benches/storage_crud.rs @@ -1,55 +1,13 @@ -// Storage CRUD operations benchmark -// Run with: cargo bench --bench storage_crud -// Or with custom iterations: cargo bench --bench storage_crud -- --iterations 100 +use criterion::{Criterion, black_box, criterion_group, criterion_main}; -use std::env; -use std::sync::Arc; -use std::time::Instant; - -use agentic_api::storage::{ConversationStore, InOutItem, ResponseMetadata, ResponseStore, SchemaManager}; +use agentic_api::storage::{ConversationStore, InOutItem, ResponseMetadata, ResponseStore, create_pool_with_schema}; use agentic_api::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; -const DEFAULT_ITERATIONS: usize = 50; +static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -#[derive(Debug)] -struct BenchmarkResult { - operation: String, - iterations: usize, - total_duration_ms: f64, - average_duration_ms: f64, - min_duration_ms: f64, - max_duration_ms: f64, - throughput_ops_per_sec: f64, -} - -impl BenchmarkResult { - fn new(operation: &str, iterations: usize, durations: Vec) -> Self { - let total = durations.iter().sum::(); - let avg = total / iterations as f64; - let min = durations.iter().cloned().fold(f64::INFINITY, f64::min); - let max = durations.iter().cloned().fold(0.0, f64::max); - let throughput = 1000.0 / avg; - - Self { - operation: operation.to_string(), - iterations, - total_duration_ms: total, - average_duration_ms: avg, - min_duration_ms: min, - max_duration_ms: max, - throughput_ops_per_sec: throughput, - } - } - - fn print(&self) { - println!("\n=== Benchmark: {} ===", self.operation); - println!("Iterations: {}", self.iterations); - println!("Total Duration: {:.2}ms", self.total_duration_ms); - println!("Average: {:.4}ms/op", self.average_duration_ms); - println!("Min: {:.4}ms", self.min_duration_ms); - println!("Max: {:.4}ms", self.max_duration_ms); - println!("Throughput: {:.2} ops/sec", self.throughput_ops_per_sec); - } +fn next_id() -> String { + let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + format!("id_{}", count) } fn create_test_items() -> Vec { @@ -71,273 +29,181 @@ fn create_test_metadata() -> ResponseMetadata { ResponseMetadata::default() } -fn parse_iterations() -> usize { - let args: Vec = env::args().collect(); - - for (i, arg) in args.iter().enumerate() { - if arg == "--iterations" || arg == "-i" { - if let Some(next_arg) = args.get(i + 1) { - if let Ok(iterations) = next_arg.parse::() { - if iterations > 0 { - return iterations; - } else { - eprintln!("Warning: iterations must be > 0, using default: {}", DEFAULT_ITERATIONS); - return DEFAULT_ITERATIONS; - } - } else { - eprintln!( - "Warning: could not parse iterations value '{}', using default: {}", - next_arg, DEFAULT_ITERATIONS - ); - return DEFAULT_ITERATIONS; +fn bench_conversation_persist(c: &mut Criterion, store: &ConversationStore) { + use std::sync::{Arc, Mutex}; + let previous_response_id = Arc::new(Mutex::new(None::)); + + c.bench_function("conversation_persist", |b| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || async { + let conversation = store.create().await.expect("failed to create conversation"); + let new_items = create_test_items(); + let test_metadata = create_test_metadata(); + let response_id = next_id(); + let prev_id = previous_response_id.lock().unwrap().as_deref().map(|s| s.to_string()); + ( + conversation.conversation_id.clone(), + new_items, + test_metadata, + response_id, + prev_id, + ) + }, + |setup| { + let previous_response_id = previous_response_id.clone(); + async move { + let (conversation_id, new_items, test_metadata, response_id, prev_id) = setup.await; + store + .persist( + &conversation_id, + &response_id, + prev_id.as_deref(), + black_box(new_items), + &black_box(test_metadata), + ) + .await + .expect("persist failed"); + + *previous_response_id.lock().unwrap() = Some(response_id); } - } - } - } - - DEFAULT_ITERATIONS -} - -fn print_usage() { - println!("\n=== Benchmark Usage ==="); - println!("Default (50 iterations):"); - println!(" cargo bench --bench storage_crud\n"); - println!("Custom iterations:"); - println!(" cargo bench --bench storage_crud -- --iterations 100"); - println!(" cargo bench --bench storage_crud -- -i 200\n"); -} - -async fn create_test_pool() -> Arc> { - sqlx::any::install_default_drivers(); - let pool = sqlx::any::AnyPoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .expect("failed to create test pool"); - let pool = Arc::new(pool); - - let schema_manager = SchemaManager::new_for_test(pool.as_ref()); - schema_manager - .ensure_ready() - .await - .expect("failed to initialize schema"); - - pool -} - -#[tokio::main] -async fn main() { - let iterations = parse_iterations(); - - println!("Starting Storage CRUD Benchmarks..."); - println!("Iterations: {}\n", iterations); - - bench_conversation_persist(iterations).await; - bench_conversation_rehydrate(iterations).await; - bench_response_persist(iterations).await; - bench_response_rehydrate(iterations).await; - - print_usage(); - println!("\n✅ All benchmarks completed"); + }, + criterion::BatchSize::SmallInput, + ) + }); } -async fn bench_conversation_persist(iterations: usize) { - let pool = create_test_pool().await; - - let store = ConversationStore::new(Arc::clone(&pool)); - - let conversation = match store.create().await { - Ok(conv) => conv, - Err(e) => { - eprintln!("Failed to create conversation: {}", e); - return; - } - }; - let conversation_id = &conversation.conversation_id; - - let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); - let test_metadata = create_test_metadata(); - let mut durations = Vec::new(); - let mut previous_response_id: Option = None; - - for i in 0..iterations { - let new_items = test_items_list[i % 100].clone(); - let response_id = format!("resp_{}", i); - - let start = Instant::now(); - let result = store - .persist( - conversation_id, - &response_id, - previous_response_id.as_deref(), - new_items, - &test_metadata, - ) - .await; - let duration = start.elapsed().as_secs_f64() * 1000.0; - - match result { - Ok(_) => { - previous_response_id = Some(response_id.clone()); - durations.push(duration); - } - Err(e) => { - eprintln!("Persist operation failed: {}", e); - return; - } - } - } - - let result = BenchmarkResult::new("ConversationStore::persist", iterations, durations); - result.print(); +fn bench_response_persist(c: &mut Criterion, store: &ResponseStore) { + use std::sync::{Arc, Mutex}; + let previous_id = Arc::new(Mutex::new(None::)); + + c.bench_function("response_persist", |b| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || { + let new_items = create_test_items(); + let test_metadata = create_test_metadata(); + let current_id = next_id(); + let prev_id = previous_id.lock().unwrap().as_deref().map(|s| s.to_string()); + (new_items, test_metadata, current_id, prev_id) + }, + |(new_items, test_metadata, current_id, prev_id)| { + let previous_id = previous_id.clone(); + async move { + store + .persist( + ¤t_id, + prev_id.as_deref(), + black_box(new_items), + &black_box(test_metadata), + ) + .await + .expect("persist failed"); + + *previous_id.lock().unwrap() = Some(current_id); + } + }, + criterion::BatchSize::SmallInput, + ) + }); } -async fn bench_response_persist(iterations: usize) { - let pool = create_test_pool().await; - - let store = ResponseStore::new(Arc::clone(&pool)); - - let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); - let test_metadata = create_test_metadata(); - let mut durations = Vec::new(); - let mut previous_response_id: Option = None; - - for i in 0..iterations { - let new_items = test_items_list[i % 100].clone(); - let response_id = format!("resp_{}", i); - - let start = Instant::now(); - let result = store - .persist(&response_id, previous_response_id.as_deref(), new_items, &test_metadata) - .await; - let duration = start.elapsed().as_secs_f64() * 1000.0; - - match result { - Ok(_) => { - previous_response_id = Some(response_id); - durations.push(duration); - } - Err(e) => { - eprintln!("Persist operation failed: {}", e); - return; - } - } - } - - let result = BenchmarkResult::new("ResponseStore::persist", iterations, durations); - result.print(); +fn bench_conversation_rehydrate(c: &mut Criterion, store: &ConversationStore) { + use std::sync::Mutex; + let previous_response_id = Mutex::new(None::); + + c.bench_function("conversation_rehydrate", |b| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || async { + let conversation = store.create().await.expect("failed to create conversation"); + let new_items = create_test_items(); + let test_metadata = create_test_metadata(); + let response_id = next_id(); + let prev_id = previous_response_id.lock().unwrap().as_deref().map(|s| s.to_string()); + + store + .persist( + &conversation.conversation_id, + &response_id, + prev_id.as_deref(), + new_items, + &test_metadata, + ) + .await + .expect("setup persist failed"); + + *previous_response_id.lock().unwrap() = Some(response_id); + conversation.conversation_id.clone() + }, + |setup| async move { + let conversation_id = setup.await; + store + .rehydrate(&black_box(conversation_id)) + .await + .expect("rehydrate failed") + }, + criterion::BatchSize::SmallInput, + ) + }); } -async fn bench_conversation_rehydrate(iterations: usize) { - let pool = create_test_pool().await; - - let store = ConversationStore::new(Arc::clone(&pool)); - - let conversation = match store.create().await { - Ok(conv) => conv, - Err(e) => { - eprintln!("Failed to create conversation: {}", e); - return; - } - }; - let conversation_id = &conversation.conversation_id; - - let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); - let test_metadata = create_test_metadata(); - - let mut previous_response_id: Option = None; - for i in 0..iterations { - let new_items = test_items_list[i % 100].clone(); - let response_id = format!("resp_{}", i); - - let _ = store - .persist( - conversation_id, - &response_id, - previous_response_id.as_deref(), - new_items, - &test_metadata, - ) - .await; - - previous_response_id = Some(response_id); - } - - // Now benchmark rehydrate - let mut durations = Vec::new(); - - for _ in 0..iterations { - let start = Instant::now(); - let result = store.rehydrate(conversation_id).await; - let duration = start.elapsed().as_secs_f64() * 1000.0; - - match result { - Ok(_) => { - durations.push(duration); - } - Err(e) => { - eprintln!("Rehydrate operation failed: {}", e); - return; - } - } - } - - let result = BenchmarkResult::new("ConversationStore::rehydrate", iterations, durations); - result.print(); +fn bench_response_rehydrate(c: &mut Criterion, store: &ResponseStore) { + use std::sync::Mutex; + let previous_response_id = Mutex::new(None::); + + c.bench_function("response_rehydrate", |b| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || async { + let new_items = create_test_items(); + let test_metadata = create_test_metadata(); + let response_id = next_id(); + let prev_id = previous_response_id.lock().unwrap().as_deref().map(|s| s.to_string()); + + store + .persist(&response_id, prev_id.as_deref(), new_items, &test_metadata) + .await + .expect("setup persist failed"); + + *previous_response_id.lock().unwrap() = Some(response_id.clone()); + response_id + }, + |setup| async move { + let response_id = setup.await; + store + .rehydrate(&black_box(response_id)) + .await + .expect("rehydrate failed") + }, + criterion::BatchSize::SmallInput, + ) + }); } -async fn bench_response_rehydrate(iterations: usize) { - let pool = create_test_pool().await; - - let store = ResponseStore::new(Arc::clone(&pool)); - - let test_items_list: Vec> = (0..100).map(|_| create_test_items()).collect(); - let test_metadata = create_test_metadata(); - - // Populate with data first, chaining responses - let mut response_ids = Vec::new(); - let mut previous_response_id: Option = None; +fn init_benches(c: &mut Criterion) { + COUNTER.store(0, std::sync::atomic::Ordering::SeqCst); - for i in 0..iterations { - let new_items = test_items_list[i % 100].clone(); - let response_id = format!("resp_{}", i); - - if let Ok(_) = store - .persist(&response_id, previous_response_id.as_deref(), new_items, &test_metadata) + let rt = tokio::runtime::Runtime::new().unwrap(); + let pool = rt.block_on(async { + create_pool_with_schema(None) .await - { - response_ids.push(response_id.clone()); - previous_response_id = Some(response_id); - } - } - - // Fetch the response data for rehydration - let mut response_data_list = Vec::new(); - for response_id in &response_ids { - if let Ok(Some(resp_data)) = store.get(response_id).await { - response_data_list.push(resp_data); - } - } - - // Now benchmark rehydrate - let mut durations = Vec::new(); + .expect("failed to create pool with schema") + }); - for response_data in &response_data_list { - let start = Instant::now(); - let result = store.rehydrate(response_data).await; - let duration = start.elapsed().as_secs_f64() * 1000.0; + let conversation_store = ConversationStore::new(pool.clone()); + let response_store = ResponseStore::new(pool.clone()); - match result { - Ok(_) => { - durations.push(duration); - } - Err(e) => { - eprintln!("Rehydrate operation failed: {}", e); - return; - } - } - } + bench_conversation_persist(c, &conversation_store); + bench_response_persist(c, &response_store); + bench_conversation_rehydrate(c, &conversation_store); + bench_response_rehydrate(c, &response_store); - let result = BenchmarkResult::new("ResponseStore::rehydrate", iterations, durations); - result.print(); + rt.block_on(async { + sqlx::query("DELETE FROM items").execute(pool.as_ref()).await.ok(); + sqlx::query("DELETE FROM responses").execute(pool.as_ref()).await.ok(); + sqlx::query("DELETE FROM conversations") + .execute(pool.as_ref()) + .await + .ok(); + }); } + +criterion_group!(benches, init_benches); +criterion_main!(benches); diff --git a/src/storage/conversation.rs b/src/storage/conversation.rs index 4450b11..f9d5da4 100644 --- a/src/storage/conversation.rs +++ b/src/storage/conversation.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use super::models::{conversation, item, response}; use super::pool::DbPool; use super::types::{ConversationData, InOutItem, ResponseMetadata, Result, StorageError}; -use crate::utils::common::{any_json_to_string, uuid7_str}; +use crate::utils::common::{serialize_to_string, uuid7_str}; /// Conversation storage operations. #[derive(Clone)] @@ -120,7 +120,7 @@ impl ConversationStore { item::create_in_tx(&mut tx, items_, Some(conversation_id), Some(seq_start)).await?; - let history_item_ids_json = any_json_to_string(&item_ids); + let history_item_ids_json = serialize_to_string(&item_ids); let metadata_json: String = metadata.into(); response::create_in_tx( diff --git a/src/storage/models/item.rs b/src/storage/models/item.rs index 26fe1e9..69ee2a8 100644 --- a/src/storage/models/item.rs +++ b/src/storage/models/item.rs @@ -3,7 +3,7 @@ use super::super::pool::{DbPool, DbResult, DbTransaction}; use super::super::types::item::InOutItem; use crate::types::io::{InputItem, OutputItem}; -use crate::utils::common::{from_json_str_opt, utcnow_str}; +use crate::utils::common::{deserialize_from_str_opt, utcnow_str}; /// Conversation history item stored in the database. /// @@ -32,13 +32,13 @@ impl Item { /// Deserialize data column as `InputItem`. #[must_use] pub fn as_input(&self) -> Option { - from_json_str_opt(&self.data) + deserialize_from_str_opt(&self.data) } /// Deserialize data column as `OutputItem`. #[must_use] pub fn as_output(&self) -> Option { - from_json_str_opt(&self.data) + deserialize_from_str_opt(&self.data) } /// Deserialize data column as either `InputItem` or `OutputItem`. diff --git a/src/storage/models/response.rs b/src/storage/models/response.rs index 9e20291..ece78db 100644 --- a/src/storage/models/response.rs +++ b/src/storage/models/response.rs @@ -1,7 +1,7 @@ //! LLM API response stored in the database. use super::super::pool::{DbPool, DbResult, DbTransaction}; -use crate::utils::common::{from_json_string_opt, from_json_string_opt_or_default, utcnow_str}; +use crate::utils::common::{deserialize_from_string_opt, deserialize_from_string_opt_or_default, utcnow_str}; /// LLM API response stored in the database. /// @@ -71,13 +71,13 @@ impl Response { /// Deserialize `history_item_ids` from JSON string to Vec. #[must_use] pub fn history_item_ids_vec(&self) -> Vec { - from_json_string_opt_or_default(&self.history_item_ids) + deserialize_from_string_opt_or_default(&self.history_item_ids) } /// Deserialize metadata from JSON string to the given type. #[must_use] pub fn metadata_as(&self) -> Option { - from_json_string_opt(&self.metadata) + deserialize_from_string_opt(&self.metadata) } } diff --git a/src/storage/response.rs b/src/storage/response.rs index 03d9a33..50540d5 100644 --- a/src/storage/response.rs +++ b/src/storage/response.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use super::models::{item, response}; use super::pool::DbPool; use super::types::{InOutItem, ResponseData, ResponseMetadata, Result, StorageError}; -use crate::utils::common::{any_json_to_string, uuid7_str}; +use crate::utils::common::{serialize_to_string, uuid7_str}; /// Response storage operations. #[derive(Clone)] @@ -116,7 +116,7 @@ impl ResponseStore { item::create_in_tx(&mut tx, items_, None, None).await?; - let history_item_ids_json = any_json_to_string(&item_ids); + let history_item_ids_json = serialize_to_string(&item_ids); let metadata_json: String = metadata.into(); response::create_in_tx( diff --git a/src/utils/common.rs b/src/utils/common.rs index 7e7ee2f..4033786 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -13,30 +13,29 @@ pub fn utcnow_str() -> String { /// Serialize any type to JSON string, returning empty string on error. #[must_use] -pub fn any_json_to_string(value: &T) -> String { +pub fn serialize_to_string(value: &T) -> String { serde_json::to_string(value).unwrap_or_default() } -/// Deserialize JSON string to any type, returning default on error. +/// Deserialize JSON string to any type, returning None on error. #[must_use] -pub fn from_json_str(json_str: &str) -> T { - serde_json::from_str(json_str).unwrap_or_default() -} - -/// Deserialize JSON &str to any type, returning None on error. -#[must_use] -pub fn from_json_str_opt(json_str: &str) -> Option { +pub fn deserialize_from_str_opt(json_str: &str) -> Option { serde_json::from_str(json_str).ok() } /// Deserialize optional JSON String to any type, returning default on error or if None. #[must_use] -pub fn from_json_string_opt_or_default(json_str: &Option) -> T { - json_str.as_ref().map(|s| from_json_str::(s)).unwrap_or_default() +pub fn deserialize_from_string_opt_or_default( + json_str: &Option, +) -> T { + json_str + .as_ref() + .and_then(|s| deserialize_from_str_opt::(s)) + .unwrap_or_default() } /// Deserialize optional JSON String to any type, returning None on error or if None. #[must_use] -pub fn from_json_string_opt(json_str: &Option) -> Option { - json_str.as_ref().and_then(|s| serde_json::from_str(s).ok()) +pub fn deserialize_from_string_opt(json_str: &Option) -> Option { + json_str.as_ref().and_then(|s| deserialize_from_str_opt::(s)) } From 0bb4eb8c06a8de2492b5575e4026f41fd2a8fe57 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 22 May 2026 09:56:36 +0000 Subject: [PATCH 03/18] clean code Signed-off-by: maral --- src/storage/conversation.rs | 6 +-- src/storage/mod.rs | 2 +- src/storage/pool.rs | 71 +++++++++++++++++++--------------- src/storage/response.rs | 3 +- src/storage/schema.rs | 77 ++++++++++++++++++++++++++++--------- 5 files changed, 104 insertions(+), 55 deletions(-) diff --git a/src/storage/conversation.rs b/src/storage/conversation.rs index f9d5da4..1d5f819 100644 --- a/src/storage/conversation.rs +++ b/src/storage/conversation.rs @@ -77,11 +77,11 @@ impl ConversationStore { /// Returns error if conversation not found or database query fails. pub async fn rehydrate(&self, conversation_id: &str) -> Result> { let pool = self.pool()?; - item::conversation_item_count(pool, conversation_id) - .await? + let rows = item::get_items_by_conversation(pool, conversation_id) + .await + .ok() .ok_or_else(|| StorageError::not_found("Conversation", conversation_id))?; - let rows = item::get_items_by_conversation(pool, conversation_id).await?; Ok(rows.into_iter().filter_map(|row| row.as_inout()).collect()) } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 918a4e0..2e77896 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -23,7 +23,7 @@ pub use conversation::ConversationStore; pub use models::Conversation as DbConversation; pub use models::Item; pub use models::Response as DbResponse; -pub use pool::{DbPool, DbResult, DbTransaction, create_pool}; +pub use pool::{DbPool, DbResult, DbTransaction, create_pool, create_pool_with_schema}; pub use response::ResponseStore; pub use schema::SchemaManager; pub use types::{ConversationData, InOutItem, ItemKind, ResponseData, ResponseMetadata, Result, StorageError}; diff --git a/src/storage/pool.rs b/src/storage/pool.rs index 265059d..e3e6cf6 100644 --- a/src/storage/pool.rs +++ b/src/storage/pool.rs @@ -21,7 +21,9 @@ pub type DbResult = Result; /// This enables write mode (`rwc` = read-write-create) for file-based databases. /// /// For other database types (`PostgreSQL`, `MySQL`), returns URL as-is. -fn prepare_db_url(url: &str) -> String { +/// Defaults to `sqlite://./agentic_api.db` if no URL is provided. +fn prepare_db_url(url: Option<&str>) -> String { + let url = url.unwrap_or("sqlite://./agentic_api.db"); if url.starts_with("sqlite") && !url.contains('?') { format!("{url}?mode=rwc") } else { @@ -37,12 +39,11 @@ fn prepare_db_url(url: &str) -> String { /// - `SQLite` file mode: read-write-create for file-based databases /// /// The pool is wrapped in `Arc` for thread-safe sharing across async tasks. -/// See [Rust Cookbook § Database](https://rust-lang-nursery.github.io/rust-cookbook/database.html) -/// for pooling best practices. /// /// # Arguments /// -/// * `db_url` - Database connection URL (e.g., `sqlite://data.db`, `postgresql://user:pass@host/db`) +/// * `db_url` - Optional database connection URL. Defaults to `sqlite://./agentic_api.db` if `None`. +/// Examples: `sqlite://data.db`, `postgresql://user:pass@host/db` /// /// # Errors /// @@ -52,30 +53,7 @@ fn prepare_db_url(url: &str) -> String { /// - Connection limit is exceeded /// - Authentication fails /// -/// # Examples -/// -/// ```ignore -/// use agentic_api::storage::pool; -/// -/// // SQLite (file-based) -/// let pool = pool::create_pool("sqlite://data.db").await?; -/// -/// // PostgreSQL -/// let pool = pool::create_pool("postgresql://user:pass@localhost/mydb").await?; -/// -/// // Use the pool (shared via Arc) -/// let result = sqlx::query("SELECT * FROM responses") -/// .fetch_one(pool.as_ref()) -/// .await?; -/// ``` -/// -/// # Performance Considerations (From Rust Cookbook) -/// -/// - Connection pooling reduces overhead of establishing new connections -/// - Connections are reused from the pool for subsequent queries -/// - Maximum connections should be tuned to database and application capacity -/// - No blocking I/O on connection retrieval - uses async/await -pub async fn create_pool(db_url: &str) -> DbResult> { +pub async fn create_pool(db_url: Option<&str>) -> DbResult> { // Install default drivers for auto-detection sqlx::any::install_default_drivers(); @@ -90,6 +68,29 @@ pub async fn create_pool(db_url: &str) -> DbResult> { Ok(Arc::new(pool)) } +/// Creates a connection pool and initializes the database schema. +/// +/// Combines [`create_pool`] with schema initialization using [`SchemaManager`]. +/// Useful for applications and benchmarks that need a fully initialized database. +/// +/// # Arguments +/// +/// * `db_url` - Database connection URL +/// +/// # Errors +/// +/// Returns error if pool creation or schema initialization fails. +pub async fn create_pool_with_schema(db_url: Option<&str>) -> DbResult> { + use crate::storage::SchemaManager; + + let pool = create_pool(db_url).await?; + + let schema_manager = SchemaManager::new(pool.as_ref()); + schema_manager.ensure_ready().await?; + + Ok(pool) +} + #[cfg(test)] mod tests { use super::*; @@ -97,28 +98,34 @@ mod tests { #[test] fn test_prepare_sqlite_url_without_params() { let url = "sqlite://test.db"; - let prepared = prepare_db_url(url); + let prepared = prepare_db_url(Some(url)); assert_eq!(prepared, "sqlite://test.db?mode=rwc"); } #[test] fn test_prepare_sqlite_url_with_params() { let url = "sqlite://test.db?cache=shared"; - let prepared = prepare_db_url(url); + let prepared = prepare_db_url(Some(url)); assert_eq!(prepared, "sqlite://test.db?cache=shared"); } #[test] fn test_prepare_postgres_url() { let url = "postgresql://user:pass@localhost/db"; - let prepared = prepare_db_url(url); + let prepared = prepare_db_url(Some(url)); assert_eq!(prepared, "postgresql://user:pass@localhost/db"); } #[test] fn test_prepare_mysql_url() { let url = "mysql://user:pass@localhost/db"; - let prepared = prepare_db_url(url); + let prepared = prepare_db_url(Some(url)); assert_eq!(prepared, "mysql://user:pass@localhost/db"); } + + #[test] + fn test_prepare_default_sqlite_url() { + let prepared = prepare_db_url(None); + assert_eq!(prepared, "sqlite://./agentic_api.db?mode=rwc"); + } } diff --git a/src/storage/response.rs b/src/storage/response.rs index 50540d5..91af400 100644 --- a/src/storage/response.rs +++ b/src/storage/response.rs @@ -79,8 +79,9 @@ impl ResponseStore { /// # Errors /// /// Returns error if database query fails or store is disabled. - pub async fn rehydrate(&self, response: &ResponseData) -> Result> { + pub async fn rehydrate(&self, response_id: &str) -> Result> { let pool = self.pool()?; + let response = self.get_or_raise(response_id).await?; let rows = item::get_items(pool, &response.history_item_ids).await?; Ok(rows.into_iter().filter_map(|row| row.as_inout()).collect()) } diff --git a/src/storage/schema.rs b/src/storage/schema.rs index 445bf84..27db783 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -13,7 +13,7 @@ static SCHEMA_READY: AtomicBool = AtomicBool::new(false); fn is_marked_ready() -> bool { matches!( - env::var("AA_DB_SCHEMA_READY").as_deref(), + env::var("AGENTIC_API_SCHEMA_READY").as_deref(), Ok("1" | "true" | "t" | "yes" | "y" | "on") ) } @@ -32,9 +32,12 @@ impl<'a> SchemaManager<'a> { /// Ensures database schema is ready by running pending migrations. /// - /// Checks if migrations have already been applied (via in-memory flag or - /// `AA_DB_SCHEMA_READY` environment variable). If not, runs all pending - /// migrations from the `migrations/` directory. + /// Checks if migrations have already been applied via one of: + /// 1. In-memory flag (`SCHEMA_READY`) + /// 2. `AGENTIC_API_SCHEMA_READY` environment variable + /// 3. For file-based `SQLite`: checks if database file exists (assumes migrations ran before) + /// + /// If none of the above, runs all pending migrations from the `migrations/` directory. /// /// # Errors /// @@ -62,18 +65,6 @@ impl<'a> SchemaManager<'a> { } } -impl<'a> SchemaManager<'a> { - /// Creates a schema manager that resets the ready flag for testing. - /// - /// Useful for tests that create a new in-memory pool per test case, - /// ensuring migrations run fresh for each test. - #[must_use] - pub fn new_for_test(pool: &'a DbPool) -> Self { - SCHEMA_READY.store(false, Ordering::SeqCst); - Self { pool } - } -} - #[cfg(test)] mod tests { use super::*; @@ -116,7 +107,7 @@ mod tests { #[test] fn test_env_var_pattern() { - // Test the pattern matching logic for AA_DB_SCHEMA_READY + // Test the pattern matching logic for AGENTIC_API_SCHEMA_READY let test_values = vec![ ("1", true), ("true", true), @@ -138,7 +129,57 @@ mod tests { Ok::<&str, String>(val).as_deref(), Ok("1" | "true" | "t" | "yes" | "y" | "on") ); - assert_eq!(matches, expected, "Mismatch for value '{}'", val); + assert_eq!(matches, expected, "Mismatch for value '{val}'"); } } + + #[tokio::test] + async fn test_ensure_ready_with_flag_set() { + // Reset flag before test + SCHEMA_READY.store(false, Ordering::SeqCst); + + // Create an in-memory SQLite pool + let pool = crate::storage::pool::create_pool(Some("sqlite://?mode=memory")) + .await + .expect("failed to create pool"); + + let schema = SchemaManager::new(pool.as_ref()); + + // First call should run migrations (or succeed with empty DB) + let result = schema.ensure_ready().await; + assert!(result.is_ok(), "ensure_ready failed: {result:?}"); + + // Flag should now be set + assert!(SCHEMA_READY.load(Ordering::SeqCst)); + + // Second call should return immediately without doing work + let result = schema.ensure_ready().await; + assert!(result.is_ok()); + + // Reset flag after test + SCHEMA_READY.store(false, Ordering::SeqCst); + } + + #[tokio::test] + async fn test_ensure_ready_multiple_calls() { + // Reset flag before test + SCHEMA_READY.store(false, Ordering::SeqCst); + + let pool = crate::storage::pool::create_pool(Some("sqlite://?mode=memory")) + .await + .expect("failed to create pool"); + + let schema = SchemaManager::new(pool.as_ref()); + + // Multiple calls should all succeed + for _ in 0..3 { + let result = schema.ensure_ready().await; + assert!(result.is_ok()); + } + + assert!(SCHEMA_READY.load(Ordering::SeqCst)); + + // Reset flag after test + SCHEMA_READY.store(false, Ordering::SeqCst); + } } From 9ad6c66617b29766bbe27726c29e25e647eee9ad Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 22 May 2026 12:19:38 +0000 Subject: [PATCH 04/18] cover more unit tests and add integration tests Signed-off-by: maral --- src/storage/conversation.rs | 29 +- src/storage/mod.rs | 2 +- src/storage/response.rs | 32 +-- src/storage/types/conversation.rs | 38 +++ src/storage/types/errors.rs | 46 +--- src/storage/types/item.rs | 64 +++++ src/storage/types/mod.rs | 2 +- src/storage/types/response.rs | 43 +++ tests/storage_integration.rs | 437 ++++++++++++++++++++++++++++++ 9 files changed, 608 insertions(+), 85 deletions(-) create mode 100644 tests/storage_integration.rs diff --git a/src/storage/conversation.rs b/src/storage/conversation.rs index 1d5f819..1b7d50b 100644 --- a/src/storage/conversation.rs +++ b/src/storage/conversation.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use super::models::{conversation, item, response}; use super::pool::DbPool; -use super::types::{ConversationData, InOutItem, ResponseMetadata, Result, StorageError}; +use super::types::{ConversationData, InOutItem, ResponseMetadata, StorageError, StoreResult}; use crate::utils::common::{serialize_to_string, uuid7_str}; /// Conversation storage operations. @@ -31,7 +31,7 @@ impl ConversationStore { /// # Errors /// /// Returns error if store is disabled (no pool configured). - fn pool(&self) -> Result<&DbPool> { + fn pool(&self) -> StoreResult<&DbPool> { self.pool.as_deref().ok_or(StorageError::NotConfigured) } @@ -40,7 +40,7 @@ impl ConversationStore { /// # Errors /// /// Returns error if database query fails. - pub async fn create(&self) -> Result { + pub async fn create(&self) -> StoreResult { let pool = self.pool()?; let row = conversation::create(pool, &uuid7_str("conv_")).await?; Ok(row.into()) @@ -51,7 +51,7 @@ impl ConversationStore { /// # Errors /// /// Returns error if database query fails. - pub async fn get_or_create(&self, conversation_id: &str) -> Result { + pub async fn get_or_create(&self, conversation_id: &str) -> StoreResult { let pool = self.pool()?; let row = conversation::get_or_create(pool, conversation_id).await?; Ok(row.into()) @@ -61,13 +61,13 @@ impl ConversationStore { /// /// # Errors /// - /// Returns error if database query fails. - pub async fn get(&self, conversation_id: &str) -> Result> { + /// Returns error if conversation not found or database query fails. + pub async fn get(&self, conversation_id: &str) -> StoreResult { let pool = self.pool()?; - let Some(row) = conversation::get(pool, conversation_id).await? else { - return Ok(None); - }; - Ok(Some(row.into())) + let row = conversation::get(pool, conversation_id) + .await? + .ok_or_else(|| StorageError::not_found("Conversation", conversation_id))?; + Ok(row.into()) } /// Rehydrates a conversation with all its items. @@ -75,12 +75,9 @@ impl ConversationStore { /// # Errors /// /// Returns error if conversation not found or database query fails. - pub async fn rehydrate(&self, conversation_id: &str) -> Result> { + pub async fn rehydrate(&self, conversation_id: &str) -> StoreResult> { let pool = self.pool()?; - let rows = item::get_items_by_conversation(pool, conversation_id) - .await - .ok() - .ok_or_else(|| StorageError::not_found("Conversation", conversation_id))?; + let rows = item::get_items_by_conversation(pool, conversation_id).await?; Ok(rows.into_iter().filter_map(|row| row.as_inout()).collect()) } @@ -99,7 +96,7 @@ impl ConversationStore { previous_response_id: Option<&str>, new_items: Vec, metadata: &ResponseMetadata, - ) -> Result<()> { + ) -> StoreResult<()> { let pool = self.pool()?; let seq_start = item::conversation_item_count(pool, conversation_id) .await? diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 2e77896..1c7a3c7 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -26,4 +26,4 @@ pub use models::Response as DbResponse; pub use pool::{DbPool, DbResult, DbTransaction, create_pool, create_pool_with_schema}; pub use response::ResponseStore; pub use schema::SchemaManager; -pub use types::{ConversationData, InOutItem, ItemKind, ResponseData, ResponseMetadata, Result, StorageError}; +pub use types::{ConversationData, InOutItem, ItemKind, ResponseData, ResponseMetadata, StorageError, StoreResult}; diff --git a/src/storage/response.rs b/src/storage/response.rs index 91af400..322d8b2 100644 --- a/src/storage/response.rs +++ b/src/storage/response.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use super::models::{item, response}; use super::pool::DbPool; -use super::types::{InOutItem, ResponseData, ResponseMetadata, Result, StorageError}; +use super::types::{InOutItem, ResponseData, ResponseMetadata, StorageError, StoreResult}; use crate::utils::common::{serialize_to_string, uuid7_str}; /// Response storage operations. @@ -43,7 +43,7 @@ impl ResponseStore { /// # Errors /// /// Returns [`StorageError::NotConfigured`] if store is disabled (no pool configured). - fn pool(&self) -> Result<&DbPool> { + fn pool(&self) -> StoreResult<&DbPool> { self.pool.as_deref().ok_or(StorageError::NotConfigured) } @@ -51,25 +51,13 @@ impl ResponseStore { /// /// # Errors /// - /// Returns error if database query fails or store is disabled. - pub async fn get(&self, response_id: &str) -> Result> { + /// Returns error if response not found, database query fails, or store is disabled. + pub async fn get(&self, response_id: &str) -> StoreResult { let pool = self.pool()?; - let Some(row) = response::get(pool, response_id).await? else { - return Ok(None); - }; - - Ok(Some(row.into())) - } - - /// Retrieves a response or returns error if not found. - /// - /// # Errors - /// - /// Returns error if response not found, store is disabled, or database query fails. - pub async fn get_or_raise(&self, response_id: &str) -> Result { - self.get(response_id) + let row = response::get(pool, response_id) .await? - .ok_or_else(|| StorageError::not_found("Response", response_id)) + .ok_or_else(|| StorageError::not_found("Response", response_id))?; + Ok(row.into()) } /// Rehydrates a response with full history. @@ -79,9 +67,9 @@ impl ResponseStore { /// # Errors /// /// Returns error if database query fails or store is disabled. - pub async fn rehydrate(&self, response_id: &str) -> Result> { + pub async fn rehydrate(&self, response_id: &str) -> StoreResult> { let pool = self.pool()?; - let response = self.get_or_raise(response_id).await?; + let response = self.get(response_id).await?; let rows = item::get_items(pool, &response.history_item_ids).await?; Ok(rows.into_iter().filter_map(|row| row.as_inout()).collect()) } @@ -99,7 +87,7 @@ impl ResponseStore { previous_response_id: Option<&str>, new_items: Vec, metadata: &ResponseMetadata, - ) -> Result<()> { + ) -> StoreResult<()> { let pool = self.pool()?; let mut item_ids: Vec = Vec::new(); diff --git a/src/storage/types/conversation.rs b/src/storage/types/conversation.rs index b63ddb7..083c60e 100644 --- a/src/storage/types/conversation.rs +++ b/src/storage/types/conversation.rs @@ -58,4 +58,42 @@ mod tests { assert_eq!(db_row.id, "conv_456"); assert_eq!(db_row.created_at, "2024-01-01T00:00:00Z"); } + + #[test] + fn test_conversation_data_clone() { + let data = ConversationData { + conversation_id: "conv_clone".to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let cloned = data.clone(); + assert_eq!(cloned.conversation_id, data.conversation_id); + assert_eq!(cloned.created_at, data.created_at); + } + + #[test] + fn test_conversation_data_debug_format() { + let data = ConversationData { + conversation_id: "conv_debug".to_string(), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let debug_str = format!("{:?}", data); + assert!(debug_str.contains("conv_debug")); + assert!(debug_str.contains("ConversationData")); + } + + #[test] + fn test_conversation_bidirectional_conversion() { + let original = ConversationData { + conversation_id: "conv_bidir".to_string(), + created_at: "2024-02-01T12:30:00Z".to_string(), + }; + + let db_row: StorageDbConversation = original.clone().into(); + let recovered: ConversationData = db_row.into(); + + assert_eq!(original.conversation_id, recovered.conversation_id); + assert_eq!(original.created_at, recovered.created_at); + } } diff --git a/src/storage/types/errors.rs b/src/storage/types/errors.rs index eb2752a..0fad6ec 100644 --- a/src/storage/types/errors.rs +++ b/src/storage/types/errors.rs @@ -5,7 +5,7 @@ use thiserror::Error; /// Result type for storage operations. /// /// All storage functions return `Result` for explicit error handling. -pub type Result = std::result::Result; +pub type StoreResult = std::result::Result; /// Storage layer errors with detailed context. #[derive(Error, Debug)] @@ -24,18 +24,6 @@ pub enum StorageError { /// Storage is not configured or disabled. #[error("storage not configured or disabled")] NotConfigured, - - /// Invalid operation on resource. - #[error("invalid {resource_type}: {reason}")] - Invalid { resource_type: String, reason: String }, - - /// Serialization/deserialization error. - #[error("serialization error: {0}")] - Serialization(#[from] serde_json::Error), - - /// Internal storage error (unexpected condition). - #[error("internal storage error: {0}")] - Internal(String), } impl StorageError { @@ -48,21 +36,6 @@ impl StorageError { } } - /// Creates an invalid operation error. - #[must_use] - pub fn invalid(resource_type: impl Into, reason: impl Into) -> Self { - Self::Invalid { - resource_type: resource_type.into(), - reason: reason.into(), - } - } - - /// Creates an internal error for unexpected conditions. - #[must_use] - pub fn internal(msg: impl Into) -> Self { - Self::Internal(msg.into()) - } - /// Returns `true` if this error is "not found". #[must_use] pub fn is_not_found(&self) -> bool { @@ -76,15 +49,6 @@ impl StorageError { } /// Extracts the resource type and ID if this is a "not found" error. - /// - /// # Examples - /// - /// ```ignore - /// let err = StorageError::not_found("Response", "123"); - /// if let Some((resource, id)) = err.not_found_details() { - /// println!("Not found: {} {}", resource, id); - /// } - /// ``` #[must_use] pub fn not_found_details(&self) -> Option<(String, String)> { match self { @@ -122,14 +86,6 @@ mod tests { assert!(err.is_not_configured()); } - #[test] - fn test_invalid_error_creation() { - let err = StorageError::invalid("Conversation", "empty conversation ID"); - let msg = err.to_string(); - assert!(msg.contains("invalid")); - assert!(msg.contains("Conversation")); - } - #[test] fn test_error_display_formatting() { let err = StorageError::not_found("Response", "123"); diff --git a/src/storage/types/item.rs b/src/storage/types/item.rs index 0de0ccd..63b46a3 100644 --- a/src/storage/types/item.rs +++ b/src/storage/types/item.rs @@ -53,3 +53,67 @@ impl InOutItem { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::io::{InputMessage, InputMessageContent, OutputMessage}; + + #[test] + fn test_inout_item_from_input() { + let input = InputItem::Message(InputMessage { + role: "user".to_string(), + content: InputMessageContent::Text("hello".to_string()), + }); + let item: InOutItem = input.clone().into(); + assert!(matches!(item, InOutItem::Input(_))); + } + + #[test] + fn test_inout_item_from_output() { + let output = OutputItem::Message(OutputMessage::new("msg_1", "completed")); + let item: InOutItem = output.clone().into(); + assert!(matches!(item, InOutItem::Output(_))); + } + + #[test] + fn test_inout_item_to_string() { + let input = InputItem::Message(InputMessage { + role: "user".to_string(), + content: InputMessageContent::Text("test".to_string()), + }); + let item = InOutItem::Input(input); + let json = String::from(&item); + assert!(json.contains("user")); + assert!(json.contains("test")); + } + + #[test] + fn test_into_input_items_filters_outputs() { + let items = vec![ + InOutItem::Input(InputItem::Message(InputMessage { + role: "user".to_string(), + content: InputMessageContent::Text("msg1".to_string()), + })), + InOutItem::Output(OutputItem::Message(OutputMessage::new("out1", "done"))), + InOutItem::Input(InputItem::Message(InputMessage { + role: "assistant".to_string(), + content: InputMessageContent::Text("msg2".to_string()), + })), + ]; + + let inputs = InOutItem::into_input_items(items); + assert_eq!(inputs.len(), 2); + } + + #[test] + fn test_item_kind_serialization() { + let kind = ItemKind::Input; + let json = serde_json::to_string(&kind).expect("serialization failed"); + assert_eq!(json, "\"input\""); + + let kind2 = ItemKind::Output; + let json2 = serde_json::to_string(&kind2).expect("serialization failed"); + assert_eq!(json2, "\"output\""); + } +} diff --git a/src/storage/types/mod.rs b/src/storage/types/mod.rs index f9b4115..71f038a 100644 --- a/src/storage/types/mod.rs +++ b/src/storage/types/mod.rs @@ -6,6 +6,6 @@ pub mod item; pub mod response; pub use conversation::ConversationData; -pub use errors::{Result, StorageError}; +pub use errors::{StorageError, StoreResult}; pub use item::{InOutItem, ItemKind}; pub use response::{ResponseData, ResponseMetadata}; diff --git a/src/storage/types/response.rs b/src/storage/types/response.rs index a95824d..96d077e 100644 --- a/src/storage/types/response.rs +++ b/src/storage/types/response.rs @@ -98,4 +98,47 @@ mod tests { assert!(response.history_item_ids.is_empty()); assert_eq!(response.metadata.model, ""); } + + #[test] + fn test_response_metadata_serialization() { + let metadata = ResponseMetadata { + model: "gpt-4".to_string(), + previous_response_id: Some("resp_1".to_string()), + effective_tools: None, + effective_tool_choice: ToolChoice::Auto, + effective_instructions: Some("be helpful".to_string()), + }; + + let json_str = String::from(&metadata); + assert!(json_str.contains("gpt-4")); + assert!(json_str.contains("resp_1")); + assert!(json_str.contains("be helpful")); + } + + #[test] + fn test_response_metadata_default() { + let metadata = ResponseMetadata::default(); + assert_eq!(metadata.model, ""); + assert!(metadata.previous_response_id.is_none()); + assert!(metadata.effective_tools.is_none()); + assert!(metadata.effective_instructions.is_none()); + } + + #[test] + fn test_response_data_multiple_history_items() { + let db_row = StorageDbResponse { + id: "resp_multi".to_string(), + conversation_id: Some("conv_1".to_string()), + previous_response_id: Some("resp_prev".to_string()), + history_item_ids: Some(r#"["item_1","item_2","item_3"]"#.to_string()), + metadata: Some(r#"{"model":"gpt-3.5"}"#.to_string()), + created_at: "2024-01-01T00:00:00Z".to_string(), + }; + + let response: ResponseData = db_row.into(); + assert_eq!(response.history_item_ids.len(), 3); + assert_eq!(response.history_item_ids[0], "item_1"); + assert_eq!(response.history_item_ids[2], "item_3"); + assert_eq!(response.previous_response_id, Some("resp_prev".to_string())); + } } diff --git a/tests/storage_integration.rs b/tests/storage_integration.rs new file mode 100644 index 0000000..1a61057 --- /dev/null +++ b/tests/storage_integration.rs @@ -0,0 +1,437 @@ +use agentic_api::storage::InOutItem; +use agentic_api::storage::ResponseMetadata; +use agentic_api::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; +use agentic_api::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; +use std::sync::Arc; + +async fn setup_pool() -> Arc { + let db_url = format!( + "sqlite://{}", + std::env::temp_dir() + .join(format!("test_{}.db", uuid::Uuid::now_v7())) + .display() + ); + + create_pool_with_schema(Some(&db_url)) + .await + .expect("failed to create pool with schema") +} + +fn create_input_item(text: &str) -> InOutItem { + InOutItem::Input(InputItem::Message(InputMessage { + role: "user".to_string(), + content: InputMessageContent::Text(text.to_string()), + })) +} + +fn create_output_item(id: &str, status: &str) -> InOutItem { + InOutItem::Output(OutputItem::Message(OutputMessage::new(id, status))) +} + +#[tokio::test] +async fn test_conversation_store_create_and_get() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let created = store.create().await.expect("create failed"); + assert!(created.conversation_id.starts_with("conv_")); + + let retrieved = store.get(&created.conversation_id).await.expect("get failed"); + + assert_eq!(retrieved.conversation_id, created.conversation_id); +} + +#[tokio::test] +async fn test_conversation_store_persist_and_rehydrate() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let conversation = store.create().await.expect("create failed"); + let conv_id = &conversation.conversation_id; + + let items = vec![create_input_item("hello"), create_output_item("msg_1", "completed")]; + + let metadata = ResponseMetadata::default(); + + store + .persist(conv_id, "resp_1", None, items, &metadata) + .await + .expect("persist failed"); + + let rehydrated = store.rehydrate(conv_id).await.expect("rehydrate failed"); + + assert_eq!(rehydrated.len(), 2); +} + +#[tokio::test] +async fn test_conversation_store_multiple_turns() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let conversation = store.create().await.expect("create failed"); + let conv_id = &conversation.conversation_id; + + let metadata = ResponseMetadata::default(); + + // First turn + store + .persist(conv_id, "resp_1", None, vec![create_input_item("turn 1")], &metadata) + .await + .expect("first persist failed"); + + // Second turn + store + .persist( + conv_id, + "resp_2", + Some("resp_1"), + vec![create_input_item("turn 2")], + &metadata, + ) + .await + .expect("second persist failed"); + + let rehydrated = store.rehydrate(conv_id).await.expect("rehydrate failed"); + + assert_eq!(rehydrated.len(), 2); +} + +#[tokio::test] +async fn test_response_store_persist_and_rehydrate() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + + let items = vec![create_input_item("query"), create_output_item("out_1", "done")]; + + let metadata = ResponseMetadata::default(); + + store + .persist("resp_1", None, items, &metadata) + .await + .expect("persist failed"); + + let rehydrated = store.rehydrate("resp_1").await.expect("rehydrate failed"); + + assert_eq!(rehydrated.len(), 2); +} + +#[tokio::test] +async fn test_response_store_get() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + + let items = vec![create_input_item("test")]; + let metadata = ResponseMetadata::default(); + + store + .persist("resp_get_test", None, items, &metadata) + .await + .expect("persist failed"); + + let response = store.get("resp_get_test").await.expect("get failed"); + + assert_eq!(response.response_id, "resp_get_test"); + assert_eq!(response.history_item_ids.len(), 1); +} + +#[tokio::test] +async fn test_response_store_with_previous_response() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + + let metadata = ResponseMetadata::default(); + + store + .persist("resp_1", None, vec![create_input_item("first")], &metadata) + .await + .expect("persist first failed"); + + store + .persist( + "resp_2", + Some("resp_1"), + vec![create_output_item("out_2", "done")], + &metadata, + ) + .await + .expect("persist second failed"); + + let response = store.get("resp_2").await.expect("get failed"); + + assert_eq!(response.previous_response_id, Some("resp_1".to_string())); +} + +// Edge case tests + +#[tokio::test] +async fn test_conversation_persist_empty_items() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let conversation = store.create().await.expect("create failed"); + let conv_id = &conversation.conversation_id; + + let metadata = ResponseMetadata::default(); + + // Persist with empty item list + store + .persist(conv_id, "resp_empty", None, vec![], &metadata) + .await + .expect("persist empty items failed"); + + let rehydrated = store.rehydrate(conv_id).await.expect("rehydrate failed"); + + assert!(rehydrated.is_empty()); +} + +#[tokio::test] +async fn test_conversation_rehydrate_after_multiple_varying_turns() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let conversation = store.create().await.expect("create failed"); + let conv_id = &conversation.conversation_id; + + let metadata = ResponseMetadata::default(); + + // Turn 1: 1 item + store + .persist(conv_id, "resp_1", None, vec![create_input_item("turn1")], &metadata) + .await + .expect("turn 1 failed"); + + // Turn 2: 3 items + store + .persist( + conv_id, + "resp_2", + Some("resp_1"), + vec![ + create_input_item("turn2a"), + create_output_item("out2", "done"), + create_input_item("turn2b"), + ], + &metadata, + ) + .await + .expect("turn 2 failed"); + + // Turn 3: 2 items + store + .persist( + conv_id, + "resp_3", + Some("resp_2"), + vec![create_input_item("turn3"), create_output_item("out3", "done")], + &metadata, + ) + .await + .expect("turn 3 failed"); + + let rehydrated = store.rehydrate(conv_id).await.expect("rehydrate failed"); + + assert_eq!(rehydrated.len(), 6); +} + +#[tokio::test] +async fn test_response_store_chaining_respects_foreign_key() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + + let metadata = ResponseMetadata::default(); + + // Create resp_1 + store + .persist("resp_1", None, vec![create_input_item("first")], &metadata) + .await + .expect("resp_1 persist failed"); + + // Try to create resp_3 with resp_2 as previous (resp_2 doesn't exist) + // This should fail due to foreign key constraint + let result = store + .persist( + "resp_3", + Some("resp_2"), + vec![create_output_item("out3", "done")], + &metadata, + ) + .await; + + assert!( + result.is_err(), + "expected error when previous_response_id references non-existent response" + ); +} + +#[tokio::test] +async fn test_conversation_concurrent_turns() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool.clone()); + + let conversation = store.create().await.expect("create failed"); + let conv_id = conversation.conversation_id.clone(); + + let metadata_1 = Arc::new(ResponseMetadata::default()); + let metadata_2 = metadata_1.clone(); + + // Spawn two concurrent persist operations + let conv_id_1 = conv_id.clone(); + let store_1 = ConversationStore::new(pool.clone()); + let handle1 = tokio::spawn(async move { + store_1 + .persist( + &conv_id_1, + "resp_t1", + None, + vec![create_input_item("thread1")], + metadata_1.as_ref(), + ) + .await + }); + + let conv_id_2 = conv_id.clone(); + let store_2 = ConversationStore::new(pool); + let handle2 = tokio::spawn(async move { + store_2 + .persist( + &conv_id_2, + "resp_t2", + None, + vec![create_input_item("thread2")], + metadata_2.as_ref(), + ) + .await + }); + + let result1 = handle1.await; + let result2 = handle2.await; + + assert!(result1.is_ok() && result1.unwrap().is_ok()); + assert!(result2.is_ok() && result2.unwrap().is_ok()); + + let rehydrated = store.rehydrate(&conv_id).await.expect("rehydrate failed"); + assert_eq!(rehydrated.len(), 2); +} + +// Store-level error handling edge cases + +#[tokio::test] +async fn test_conversation_store_get_nonexistent() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let result = store.get("nonexistent_conv").await; + assert!(result.is_err(), "expected error for non-existent conversation"); + + // Verify it's a not found error + let err = result.unwrap_err(); + assert!(err.is_not_found()); +} + +#[tokio::test] +async fn test_conversation_store_persist_nonexistent_conversation() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let metadata = ResponseMetadata::default(); + + // Try to persist to a non-existent conversation + let result = store + .persist( + "nonexistent_conv", + "resp_1", + None, + vec![create_input_item("test")], + &metadata, + ) + .await; + + assert!( + result.is_err(), + "expected error when persisting to non-existent conversation" + ); +} + +#[tokio::test] +async fn test_response_store_rehydrate_nonexistent() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + + let result = store.rehydrate("nonexistent_resp").await; + assert!(result.is_err(), "expected error for non-existent response"); +} + +#[tokio::test] +async fn test_conversation_store_disabled() { + let store = ConversationStore::disabled(); + + let result = store.create().await; + assert!(result.is_err(), "expected error from disabled store"); + + let err = result.unwrap_err(); + assert!(err.is_not_configured()); +} + +#[tokio::test] +async fn test_response_store_disabled() { + let store = ResponseStore::disabled(); + + let metadata = ResponseMetadata::default(); + let result = store + .persist("resp_1", None, vec![create_input_item("test")], &metadata) + .await; + + assert!(result.is_err(), "expected error from disabled store"); + + let err = result.unwrap_err(); + assert!(err.is_not_configured()); +} + +#[tokio::test] +async fn test_conversation_store_get_after_create() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let created = store.create().await.expect("create failed"); + + // Immediately try to get it + let retrieved = store.get(&created.conversation_id).await.expect("get should succeed"); + + assert_eq!(retrieved.conversation_id, created.conversation_id); + assert_eq!(retrieved.created_at, created.created_at); +} + +#[tokio::test] +async fn test_response_store_get_after_persist() { + let pool = setup_pool().await; + let store = ResponseStore::new(pool); + + let items = vec![create_input_item("query"), create_output_item("out_1", "done")]; + let metadata = ResponseMetadata::default(); + + store + .persist("resp_stored", None, items.clone(), &metadata) + .await + .expect("persist failed"); + + let retrieved = store.get("resp_stored").await.expect("response should be found"); + + assert_eq!(retrieved.response_id, "resp_stored"); + assert_eq!(retrieved.history_item_ids.len(), 2); +} + +#[tokio::test] +async fn test_conversation_get_or_create_same_id() { + let pool = setup_pool().await; + let store = ConversationStore::new(pool); + + let conv_id = "test_conv_idempotent"; + + let first = store.get_or_create(conv_id).await.expect("first get_or_create failed"); + + let second = store.get_or_create(conv_id).await.expect("second get_or_create failed"); + + // Should return the same conversation + assert_eq!(first.conversation_id, second.conversation_id); + assert_eq!(first.created_at, second.created_at); +} From b68186597d124eb2c9b49ae54a8e97c93d19be83 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 29 May 2026 07:37:08 +0000 Subject: [PATCH 05/18] avoid unnecessary clone Signed-off-by: maral --- crates/agentic-core/src/storage/types/conversation.rs | 2 +- crates/agentic-core/src/storage/types/item.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/agentic-core/src/storage/types/conversation.rs b/crates/agentic-core/src/storage/types/conversation.rs index 083c60e..64d8d33 100644 --- a/crates/agentic-core/src/storage/types/conversation.rs +++ b/crates/agentic-core/src/storage/types/conversation.rs @@ -26,7 +26,7 @@ impl From for StorageDbConversation { fn from(data: ConversationData) -> Self { Self { id: data.conversation_id, - created_at: data.created_at.clone(), + created_at: data.created_at, } } } diff --git a/crates/agentic-core/src/storage/types/item.rs b/crates/agentic-core/src/storage/types/item.rs index 63b46a3..5e72398 100644 --- a/crates/agentic-core/src/storage/types/item.rs +++ b/crates/agentic-core/src/storage/types/item.rs @@ -65,14 +65,14 @@ mod tests { role: "user".to_string(), content: InputMessageContent::Text("hello".to_string()), }); - let item: InOutItem = input.clone().into(); + let item: InOutItem = input.into(); assert!(matches!(item, InOutItem::Input(_))); } #[test] fn test_inout_item_from_output() { let output = OutputItem::Message(OutputMessage::new("msg_1", "completed")); - let item: InOutItem = output.clone().into(); + let item: InOutItem = output.into(); assert!(matches!(item, InOutItem::Output(_))); } From 4a29e6f2ce26e0c0a82a9499df6837406d3ce9e5 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 29 May 2026 07:42:46 +0000 Subject: [PATCH 06/18] move integration test in agentic-core Signed-off-by: maral --- .../agentic-core/tests}/storage_integration.rs | 8 ++++---- src/lib.rs | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename {tests => crates/agentic-core/tests}/storage_integration.rs (97%) delete mode 100644 src/lib.rs diff --git a/tests/storage_integration.rs b/crates/agentic-core/tests/storage_integration.rs similarity index 97% rename from tests/storage_integration.rs rename to crates/agentic-core/tests/storage_integration.rs index 1a61057..d4ac640 100644 --- a/tests/storage_integration.rs +++ b/crates/agentic-core/tests/storage_integration.rs @@ -1,7 +1,7 @@ -use agentic_api::storage::InOutItem; -use agentic_api::storage::ResponseMetadata; -use agentic_api::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; -use agentic_api::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; +use agentic_core::storage::InOutItem; +use agentic_core::storage::ResponseMetadata; +use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; +use agentic_core::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; use std::sync::Arc; async fn setup_pool() -> Arc { diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index e69de29..0000000 From 0f9d2c3d4e0c568972ef6298e66a2f273c249ce6 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 29 May 2026 08:29:49 +0000 Subject: [PATCH 07/18] fix multi-thread unit test and clean the main cargo.toml Signed-off-by: maral --- Cargo.toml | 3 -- crates/agentic-core/src/storage/mod.rs | 2 +- crates/agentic-core/src/storage/pool.rs | 24 +++++++++++++++ crates/agentic-core/src/storage/schema.rs | 30 ++++++++++++++----- .../agentic-core/tests/storage_integration.rs | 4 +-- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c65f10a..43a1e9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ pedantic = { level = "warn", priority = -1 } agentic-core = { path = "crates/agentic-core" } axum = "0.8" bytes = "1" -chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env"] } criterion = { version = "0.5", features = ["async_tokio"] } futures = "0.3" @@ -27,10 +26,8 @@ http = "1" reqwest = { version = "0.12", features = ["stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "migrate"] } thiserror = "2" tokio = { version = "1", features = ["full"] } tower-http = { version = "0.6", features = ["cors"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -uuid = { version = "1", features = ["v7", "serde"] } diff --git a/crates/agentic-core/src/storage/mod.rs b/crates/agentic-core/src/storage/mod.rs index 1c7a3c7..dfa55c2 100644 --- a/crates/agentic-core/src/storage/mod.rs +++ b/crates/agentic-core/src/storage/mod.rs @@ -23,7 +23,7 @@ pub use conversation::ConversationStore; pub use models::Conversation as DbConversation; pub use models::Item; pub use models::Response as DbResponse; -pub use pool::{DbPool, DbResult, DbTransaction, create_pool, create_pool_with_schema}; +pub use pool::{DbPool, DbResult, DbTransaction, create_pool, create_pool_with_schema, create_pool_with_schema_test}; pub use response::ResponseStore; pub use schema::SchemaManager; pub use types::{ConversationData, InOutItem, ItemKind, ResponseData, ResponseMetadata, StorageError, StoreResult}; diff --git a/crates/agentic-core/src/storage/pool.rs b/crates/agentic-core/src/storage/pool.rs index e3e6cf6..c2b622b 100644 --- a/crates/agentic-core/src/storage/pool.rs +++ b/crates/agentic-core/src/storage/pool.rs @@ -91,6 +91,30 @@ pub async fn create_pool_with_schema(db_url: Option<&str>) -> DbResult) -> DbResult> { + use crate::storage::SchemaManager; + + let pool = create_pool(db_url).await?; + + let schema_manager = SchemaManager::new(pool.as_ref()); + schema_manager.ensure_ready_for_test().await?; + + Ok(pool) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/agentic-core/src/storage/schema.rs b/crates/agentic-core/src/storage/schema.rs index 27db783..2f970fd 100644 --- a/crates/agentic-core/src/storage/schema.rs +++ b/crates/agentic-core/src/storage/schema.rs @@ -30,6 +30,17 @@ impl<'a> SchemaManager<'a> { Self { pool } } + /// Runs migrations without checking the global flag. + async fn run_migrations(&self) -> DbResult<()> { + debug!("[schema] Running migrations..."); + sqlx::migrate!("./migrations") + .run(self.pool) + .await + .map_err(|e| sqlx::Error::Configuration(e.to_string().into()))?; + info!("[schema] DB schema ready."); + Ok(()) + } + /// Ensures database schema is ready by running pending migrations. /// /// Checks if migrations have already been applied via one of: @@ -53,16 +64,21 @@ impl<'a> SchemaManager<'a> { return Ok(()); } - debug!("[schema] Running migrations..."); - sqlx::migrate!("./migrations") - .run(self.pool) - .await - .map_err(|e| sqlx::Error::Configuration(e.to_string().into()))?; - + self.run_migrations().await?; SCHEMA_READY.store(true, Ordering::SeqCst); - info!("[schema] DB schema ready."); Ok(()) } + + /// Ensures database schema is ready without using the global flag. + /// + /// Intended for in-memory test databases that need independent schema initialization. + /// + /// # Errors + /// + /// Returns a [`sqlx::Error`] if migrations fail. + pub async fn ensure_ready_for_test(&self) -> DbResult<()> { + self.run_migrations().await + } } #[cfg(test)] diff --git a/crates/agentic-core/tests/storage_integration.rs b/crates/agentic-core/tests/storage_integration.rs index d4ac640..edb1e56 100644 --- a/crates/agentic-core/tests/storage_integration.rs +++ b/crates/agentic-core/tests/storage_integration.rs @@ -1,6 +1,6 @@ use agentic_core::storage::InOutItem; use agentic_core::storage::ResponseMetadata; -use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; +use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema_test}; use agentic_core::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; use std::sync::Arc; @@ -12,7 +12,7 @@ async fn setup_pool() -> Arc { .display() ); - create_pool_with_schema(Some(&db_url)) + create_pool_with_schema_test(Some(&db_url)) .await .expect("failed to create pool with schema") } From 3ec60a284db354ae1d61401ad96944dc751573c8 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 29 May 2026 08:42:50 +0000 Subject: [PATCH 08/18] fix cargo clippy Signed-off-by: maral --- crates/agentic-core/benches/storage_crud.rs | 10 +++++----- crates/agentic-core/src/storage/types/conversation.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/agentic-core/benches/storage_crud.rs b/crates/agentic-core/benches/storage_crud.rs index 32b4e33..bfdb679 100644 --- a/crates/agentic-core/benches/storage_crud.rs +++ b/crates/agentic-core/benches/storage_crud.rs @@ -7,7 +7,7 @@ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new fn next_id() -> String { let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - format!("id_{}", count) + format!("id_{count}") } fn create_test_items() -> Vec { @@ -40,7 +40,7 @@ fn bench_conversation_persist(c: &mut Criterion, store: &ConversationStore) { let new_items = create_test_items(); let test_metadata = create_test_metadata(); let response_id = next_id(); - let prev_id = previous_response_id.lock().unwrap().as_deref().map(|s| s.to_string()); + let prev_id = previous_response_id.lock().unwrap().as_deref().map(ToString::to_string); ( conversation.conversation_id.clone(), new_items, @@ -82,7 +82,7 @@ fn bench_response_persist(c: &mut Criterion, store: &ResponseStore) { let new_items = create_test_items(); let test_metadata = create_test_metadata(); let current_id = next_id(); - let prev_id = previous_id.lock().unwrap().as_deref().map(|s| s.to_string()); + let prev_id = previous_id.lock().unwrap().as_deref().map(ToString::to_string); (new_items, test_metadata, current_id, prev_id) }, |(new_items, test_metadata, current_id, prev_id)| { @@ -117,7 +117,7 @@ fn bench_conversation_rehydrate(c: &mut Criterion, store: &ConversationStore) { let new_items = create_test_items(); let test_metadata = create_test_metadata(); let response_id = next_id(); - let prev_id = previous_response_id.lock().unwrap().as_deref().map(|s| s.to_string()); + let prev_id = previous_response_id.lock().unwrap().as_deref().map(ToString::to_string); store .persist( @@ -155,7 +155,7 @@ fn bench_response_rehydrate(c: &mut Criterion, store: &ResponseStore) { let new_items = create_test_items(); let test_metadata = create_test_metadata(); let response_id = next_id(); - let prev_id = previous_response_id.lock().unwrap().as_deref().map(|s| s.to_string()); + let prev_id = previous_response_id.lock().unwrap().as_deref().map(ToString::to_string); store .persist(&response_id, prev_id.as_deref(), new_items, &test_metadata) diff --git a/crates/agentic-core/src/storage/types/conversation.rs b/crates/agentic-core/src/storage/types/conversation.rs index 64d8d33..2eada41 100644 --- a/crates/agentic-core/src/storage/types/conversation.rs +++ b/crates/agentic-core/src/storage/types/conversation.rs @@ -78,7 +78,7 @@ mod tests { created_at: "2024-01-01T00:00:00Z".to_string(), }; - let debug_str = format!("{:?}", data); + let debug_str = format!("{data:?}"); assert!(debug_str.contains("conv_debug")); assert!(debug_str.contains("ConversationData")); } From 06be1e104d146fbcd68953d103ab527abd2f4a37 Mon Sep 17 00:00:00 2001 From: maral Date: Fri, 29 May 2026 08:48:05 +0000 Subject: [PATCH 09/18] fix clippy errors in benchmark Signed-off-by: maral --- crates/agentic-core/benches/storage_crud.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/agentic-core/benches/storage_crud.rs b/crates/agentic-core/benches/storage_crud.rs index bfdb679..ff551da 100644 --- a/crates/agentic-core/benches/storage_crud.rs +++ b/crates/agentic-core/benches/storage_crud.rs @@ -1,4 +1,4 @@ -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; use agentic_core::storage::{ConversationStore, InOutItem, ResponseMetadata, ResponseStore, create_pool_with_schema}; use agentic_core::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; @@ -67,8 +67,8 @@ fn bench_conversation_persist(c: &mut Criterion, store: &ConversationStore) { *previous_response_id.lock().unwrap() = Some(response_id); } }, - criterion::BatchSize::SmallInput, - ) + BatchSize::SmallInput, + ); }); } @@ -101,8 +101,8 @@ fn bench_response_persist(c: &mut Criterion, store: &ResponseStore) { *previous_id.lock().unwrap() = Some(current_id); } }, - criterion::BatchSize::SmallInput, - ) + BatchSize::SmallInput, + ); }); } @@ -140,8 +140,8 @@ fn bench_conversation_rehydrate(c: &mut Criterion, store: &ConversationStore) { .await .expect("rehydrate failed") }, - criterion::BatchSize::SmallInput, - ) + BatchSize::SmallInput, + ); }); } @@ -172,8 +172,8 @@ fn bench_response_rehydrate(c: &mut Criterion, store: &ResponseStore) { .await .expect("rehydrate failed") }, - criterion::BatchSize::SmallInput, - ) + BatchSize::SmallInput, + ); }); } From ff666a4c3cbd945642d19a262ceead982d44ccf3 Mon Sep 17 00:00:00 2001 From: maral Date: Wed, 3 Jun 2026 09:47:00 +0000 Subject: [PATCH 10/18] feat: implement agentic loop executor (ADR-03) Add executor module: rehydration, LLM inference, SSE accumulation, and persistence for both conversation and response stateful flows. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: maral --- Cargo.lock | 354 +++++++++------ Cargo.toml | 2 + crates/agentic-core/Cargo.toml | 9 + .../benches/executor_throughput.rs | 299 +++++++++++++ .../agentic-core/src/executor/accumulator.rs | 327 ++++++++++++++ crates/agentic-core/src/executor/engine.rs | 415 ++++++++++++++++++ crates/agentic-core/src/executor/error.rs | 62 +++ crates/agentic-core/src/executor/mod.rs | 13 + .../src/executor/modes/conversation.rs | 167 +++++++ crates/agentic-core/src/executor/modes/mod.rs | 5 + .../src/executor/modes/response.rs | 163 +++++++ crates/agentic-core/src/executor/request.rs | 76 ++++ crates/agentic-core/src/lib.rs | 1 + crates/agentic-core/src/types/event.rs | 185 ++++++++ crates/agentic-core/src/types/io.rs | 32 +- crates/agentic-core/src/types/mod.rs | 1 + crates/agentic-core/src/utils/common.rs | 17 + .../stateful_conversation_integration.rs | 305 +++++++++++++ .../tests/stateful_responses_integration.rs | 164 +++++++ .../agentic-core/tests/storage_integration.rs | 17 +- 20 files changed, 2474 insertions(+), 140 deletions(-) create mode 100644 crates/agentic-core/benches/executor_throughput.rs create mode 100644 crates/agentic-core/src/executor/accumulator.rs create mode 100644 crates/agentic-core/src/executor/engine.rs create mode 100644 crates/agentic-core/src/executor/error.rs create mode 100644 crates/agentic-core/src/executor/mod.rs create mode 100644 crates/agentic-core/src/executor/modes/conversation.rs create mode 100644 crates/agentic-core/src/executor/modes/mod.rs create mode 100644 crates/agentic-core/src/executor/modes/response.rs create mode 100644 crates/agentic-core/src/executor/request.rs create mode 100644 crates/agentic-core/src/types/event.rs create mode 100644 crates/agentic-core/tests/stateful_conversation_integration.rs create mode 100644 crates/agentic-core/tests/stateful_responses_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 81693d7..dbcdcf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,14 +6,18 @@ version = 4 name = "agentic-core" version = "0.1.0" dependencies = [ + "async-stream", + "axum", "bytes", "chrono", "criterion", + "either", "futures", "http", "reqwest", "serde", "serde_json", + "serde_yaml", "sqlx", "thiserror", "tokio", @@ -133,6 +137,28 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -150,9 +176,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -220,9 +246,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" dependencies = [ "serde_core", ] @@ -238,9 +264,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -262,9 +288,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -277,18 +303,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "chrono" -version = "0.4.44" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" @@ -545,9 +563,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -562,9 +580,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -776,8 +794,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -788,7 +822,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -877,9 +911,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -922,9 +956,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1020,30 +1054,6 @@ dependencies = [ "cc", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1205,9 +1215,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1251,7 +1261,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.8.0", + "redox_syscall 0.8.1", ] [[package]] @@ -1288,9 +1298,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" @@ -1319,9 +1335,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -1331,9 +1347,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1432,9 +1448,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", @@ -1463,9 +1479,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -1673,7 +1689,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -1685,6 +1701,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1706,7 +1728,7 @@ dependencies = [ name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1724,9 +1746,9 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.5", @@ -1743,11 +1765,11 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] @@ -1781,9 +1803,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ "bitflags", ] @@ -1839,6 +1861,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -1846,6 +1870,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -1896,21 +1921,7 @@ dependencies = [ name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core", - "signature", - "spki", - "subtle", - "zeroize", -] +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -1945,6 +1956,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2056,9 +2068,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2090,6 +2102,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2123,9 +2148,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2164,9 +2189,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2608,9 +2633,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", @@ -2706,9 +2731,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-bidi" @@ -2743,6 +2768,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -2775,9 +2806,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2854,9 +2885,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2867,9 +2898,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2877,9 +2908,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2887,9 +2918,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2900,9 +2931,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -2956,21 +2987,22 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "webpki-roots 1.0.7", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -3087,6 +3119,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3120,13 +3161,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3139,6 +3197,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3151,6 +3215,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3163,12 +3233,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3181,6 +3263,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3193,6 +3281,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3205,6 +3299,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3217,6 +3317,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3342,18 +3448,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -3362,9 +3468,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 4289b79..49fd32f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,9 @@ pedantic = { level = "warn", priority = -1 } [workspace.dependencies] agentic-core = { path = "crates/agentic-core" } +async-stream = "0.3" axum = "0.8" +either = "1" bytes = "1" clap = { version = "4", features = ["derive", "env"] } criterion = { version = "0.5", features = ["async_tokio"] } diff --git a/crates/agentic-core/Cargo.toml b/crates/agentic-core/Cargo.toml index 2a89e5d..3680a47 100644 --- a/crates/agentic-core/Cargo.toml +++ b/crates/agentic-core/Cargo.toml @@ -7,7 +7,9 @@ license.workspace = true repository.workspace = true [dependencies] +async-stream.workspace = true bytes.workspace = true +either.workspace = true futures.workspace = true http.workspace = true reqwest = { workspace = true, features = ["default-tls", "stream"] } @@ -22,11 +24,18 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v7", "serde"] } [dev-dependencies] +axum.workspace = true criterion = { workspace = true } +serde_yaml = "0.9" +tokio = { workspace = true, features = ["full"] } [[bench]] name = "storage_crud" harness = false +[[bench]] +name = "executor_throughput" +harness = false + [lints] workspace = true diff --git a/crates/agentic-core/benches/executor_throughput.rs b/crates/agentic-core/benches/executor_throughput.rs new file mode 100644 index 0000000..f8ab13a --- /dev/null +++ b/crates/agentic-core/benches/executor_throughput.rs @@ -0,0 +1,299 @@ +//! Throughput benchmarks for the executor agentic loop (`execute`). +//! +//! Measures wall-clock time per turn across chain depths 1–N, for both +//! blocking (non-streaming) and streaming execution paths. +//! +//! | Group | What grows with depth | +//! |--------------------|----------------------------------------------------| +//! | `execute/blocking` | rehydrate cost (DB reads) + JSON fetch + persist | +//! | `execute/streaming`| rehydrate cost + SSE accumulate + persist | +//! | `rehydrate_only` | pure rehydrate step, no LLM call | +//! +//! # Configuring max depth +//! +//! Set `BENCH_MAX_DEPTH` before running to control how many depths are swept: +//! +//! ```bash +//! BENCH_MAX_DEPTH=3 cargo bench --bench executor_throughput +//! ``` +//! +//! Defaults to 5 when the variable is unset. +//! +//! # Sample size +//! +//! Pass `-- --sample-size=N` (criterion flag) to override the number of +//! iterations criterion collects per benchmark: +//! +//! ```bash +//! cargo bench --bench executor_throughput -- --sample-size=20 +//! ``` + +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::http::header; +use axum::response::IntoResponse; +use axum::routing::post; +use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use either::Either; +use futures::StreamExt; + +use agentic_core::executor::{ConversationHandler, ExecutionContext, ResponseHandler, execute, rehydrate_conversation}; +use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; +use agentic_core::types::io::{ResponsesInput, ToolChoice}; +use agentic_core::types::request_response::RequestPayload; + +fn max_depth() -> usize { + std::env::var("BENCH_MAX_DEPTH") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(5) + .max(1) +} + +const NON_STREAMING_BODY: &str = r#"{ + "id": "resp_bench_upstream", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "test-model", + "output": [{ + "type": "message", + "id": "msg_bench", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "OK", "annotations": []}] + }], + "usage": { + "input_tokens": 5, "output_tokens": 1, "total_tokens": 6, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0} + } +}"#; + +const STREAMING_BODY: &str = concat!( + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_bench_upstream\",\"status\":\"in_progress\"}}\n\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_bench\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"}}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{", + "\"id\":\"resp_bench_upstream\",\"object\":\"response\",\"created_at\":1700000000,", + "\"status\":\"completed\",\"model\":\"test-model\",", + "\"output\":[{\"type\":\"message\",\"id\":\"msg_bench\",\"role\":\"assistant\",", + "\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"OK\",\"annotations\":[]}]}],", + "\"usage\":{\"input_tokens\":5,\"output_tokens\":1,\"total_tokens\":6,", + "\"input_tokens_details\":{\"cached_tokens\":0},", + "\"output_tokens_details\":{\"reasoning_tokens\":0}}", + "}}\n\n", + "data: [DONE]\n\n", +); + +fn start_mock_server(rt: &tokio::runtime::Runtime) -> String { + let listener = rt.block_on(async { tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap() }); + let addr = listener.local_addr().unwrap(); + + rt.spawn(async move { + let app = Router::new() + .route( + "/v1/responses", + post(|body: axum::body::Bytes| async move { + let is_stream = serde_json::from_slice::(&body) + .ok() + .and_then(|j| j["stream"].as_bool()) + .unwrap_or(false); + + if is_stream { + axum::http::Response::builder() + .status(200) + .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") + .body(axum::body::Body::from(STREAMING_BODY)) + .unwrap() + .into_response() + } else { + axum::http::Response::builder() + .status(200) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(NON_STREAMING_BODY)) + .unwrap() + .into_response() + } + }), + ) + .route( + "/v1/conversations", + post(|| async { (axum::http::StatusCode::OK, "{}") }), + ); + axum::serve(listener, app).await.ok(); + }); + + format!("http://{addr}") +} + +fn make_request(input: &str, stream: bool, prev_id: Option) -> RequestPayload { + RequestPayload { + model: "test-model".to_string(), + input: ResponsesInput::Text(input.to_string()), + instructions: None, + previous_response_id: prev_id, + conversation_id: None, + tools: None, + tool_choice: ToolChoice::Auto, + stream, + store: true, + include: None, + temperature: None, + top_p: None, + max_output_tokens: None, + truncation: None, + metadata: None, + } +} + +fn build_exec_ctx(rt: &tokio::runtime::Runtime, mock_url: String) -> (Arc, Arc) { + let pool = rt.block_on(async { create_pool_with_schema(None).await.expect("bench pool creation failed") }); + let conv_handler = ConversationHandler::new(ConversationStore::new(pool.clone())); + let resp_handler = ResponseHandler::new(ResponseStore::new(pool.clone())); + let client = Arc::new(reqwest::Client::new()); + let exec_ctx = Arc::new(ExecutionContext::new( + conv_handler, + resp_handler, + client, + mock_url, + None, + )); + (exec_ctx, pool) +} + +/// Delete all rows from every table so the next bench group starts with a +/// clean state. Accumulated rows from setup closures are removed; this +/// prevents cross-contamination between groups and unbounded DB growth. +fn clear_db(rt: &tokio::runtime::Runtime, pool: &DbPool) { + rt.block_on(async { + sqlx::query("DELETE FROM items").execute(pool).await.ok(); + sqlx::query("DELETE FROM responses").execute(pool).await.ok(); + sqlx::query("DELETE FROM conversations").execute(pool).await.ok(); + }); +} + +/// Build a chain of `depth - 1` non-streaming turns and return the last +/// response ID. Called in the setup closure — cost does NOT count toward the +/// benchmark measurement. +async fn seed_chain(exec_ctx: &Arc, depth: usize) -> Option { + let mut prev_id: Option = None; + for i in 0..depth.saturating_sub(1) { + let req = make_request(&format!("seed {i}"), false, prev_id.take()); + if let Either::Left(p) = execute(req, Arc::clone(exec_ctx)).await.expect("seed") { + prev_id = Some(p.id); + } + } + prev_id +} + +fn bench_execute_blocking(c: &mut Criterion, exec_ctx: Arc) { + let mut group = c.benchmark_group("execute/blocking"); + for depth in 1..=max_depth() { + group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, &depth| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || { + let exec_ctx = Arc::clone(&exec_ctx); + async move { seed_chain(&exec_ctx, depth).await } + }, + |setup| { + let exec_ctx = Arc::clone(&exec_ctx); + async move { + let prev_id = setup.await; + let req = make_request("bench turn", false, black_box(prev_id)); + execute(req, exec_ctx).await.expect("execute") + } + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +fn bench_execute_streaming(c: &mut Criterion, exec_ctx: Arc) { + let mut group = c.benchmark_group("execute/streaming"); + for depth in 1..=max_depth() { + group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, &depth| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || { + let exec_ctx = Arc::clone(&exec_ctx); + async move { seed_chain(&exec_ctx, depth).await } + }, + |setup| { + let exec_ctx = Arc::clone(&exec_ctx); + async move { + let prev_id = setup.await; + let req = make_request("bench turn", true, black_box(prev_id)); + let result = execute(req, exec_ctx).await.expect("execute"); + // Drain the stream so accumulate + persist are included. + if let Either::Right(stream) = result { + let mut stream = Box::pin(stream); + while stream.next().await.is_some() {} + } + } + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +fn bench_rehydrate_only(c: &mut Criterion, exec_ctx: Arc) { + let mut group = c.benchmark_group("rehydrate_only"); + + // Grow the shared chain incrementally so deeper depths include all prior + // history items; the chain_tip tracks the latest response ID. + let chain_tip: Arc>> = Arc::new(Mutex::new(None)); + let rt = tokio::runtime::Runtime::new().unwrap(); + + for depth in 1..=max_depth() { + // Extend the chain to `depth` turns if not already deep enough. + rt.block_on(async { + let has_tip = chain_tip.lock().unwrap().is_some(); + if depth == 1 || !has_tip { + let prev_id = chain_tip.lock().unwrap().clone(); + let req = make_request("seed", false, prev_id); + if let Either::Left(p) = execute(req, Arc::clone(&exec_ctx)).await.expect("seed") { + *chain_tip.lock().unwrap() = Some(p.id); + } + } + }); + + group.bench_with_input(BenchmarkId::new("prev_response_depth", depth), &depth, |b, _| { + b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( + || chain_tip.lock().unwrap().clone(), + |prev_id| { + let exec_ctx = Arc::clone(&exec_ctx); + async move { + let req = make_request("bench", false, black_box(prev_id)); + rehydrate_conversation(req, &exec_ctx).await.expect("rehydrate") + } + }, + BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +fn init_benches(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mock_url = start_mock_server(&rt); + let (exec_ctx, pool) = build_exec_ctx(&rt, mock_url); + + bench_execute_blocking(c, Arc::clone(&exec_ctx)); + clear_db(&rt, &pool); + + bench_execute_streaming(c, Arc::clone(&exec_ctx)); + clear_db(&rt, &pool); + + bench_rehydrate_only(c, Arc::clone(&exec_ctx)); + clear_db(&rt, &pool); +} + +criterion_group!(benches, init_benches); +criterion_main!(benches); diff --git a/crates/agentic-core/src/executor/accumulator.rs b/crates/agentic-core/src/executor/accumulator.rs new file mode 100644 index 0000000..bf963a2 --- /dev/null +++ b/crates/agentic-core/src/executor/accumulator.rs @@ -0,0 +1,327 @@ +//! Response accumulation and parsing utilities. +//! +//! Handles both streaming (SSE) and non-streaming JSON response formats, +//! accumulating chunks into a unified `ResponsePayload` structure. +//! +//! Streaming path uses a channel + `spawn_blocking` so that SSE JSON parsing +//! runs on a blocking thread while the async task continues reading from the +//! network — keeping the tokio executor thread free between chunk arrivals. + +use std::pin::Pin; +use std::sync::mpsc; + +use futures::{Stream, StreamExt}; + +use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::types::event::{MessageStatus, ResponseStatus, SSEEventType}; +use crate::types::io::{OutputItem, OutputMessage, OutputTextContent, ResponseUsage}; +use crate::types::request_response::{IncompleteDetails, ResponsePayload}; +use crate::utils::common::{deserialize_from_str, deserialize_from_value, deserialize_from_value_opt}; +use crate::utils::uuid7_str; + +/// Accumulates LLM response chunks from streaming or non-streaming sources. +pub struct ResponseAccumulator { + response_id: String, + conversation_id: Option, + output: Vec, + usage: Option, + status: ResponseStatus, + incomplete_details: Option, + // In-flight message state — owned here so process_sse_line takes only &mut self. + current_message: Option, + accumulated_text: String, +} + +impl ResponseAccumulator { + /// Creates a new response accumulator. + #[must_use] + pub fn new(response_id: String, conversation_id: Option) -> Self { + Self { + response_id, + conversation_id, + output: Vec::new(), + usage: None, + status: ResponseStatus::InProgress, + incomplete_details: None, + current_message: None, + accumulated_text: String::new(), + } + } + + /// Parses a non-streaming JSON response body. + /// + /// # Errors + /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing. + pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult { + let json: serde_json::Value = + deserialize_from_str(body).map_err(|e| ExecutorError::ParseError(format!("invalid JSON: {e}")))?; + + let response_id = json["id"] + .as_str() + .ok_or_else(|| ExecutorError::ParseError("missing 'id' field in response".into()))? + .to_string(); + + let output = json["output"] + .as_array() + .map(|items| { + let mut out = Vec::with_capacity(items.len()); + out.extend( + items + .iter() + .filter_map(|item| deserialize_from_value_opt::(item.clone())), + ); + out + }) + .unwrap_or_default(); + + let status = json["status"] + .as_str() + .map_or(ResponseStatus::Completed, |s| s.parse().unwrap_or_default()); + + let usage = deserialize_from_value_opt::(json["usage"].clone()); + + Ok(Self { + response_id, + conversation_id: conversation_id.map(str::to_string), + output, + usage, + status, + incomplete_details: None, + current_message: None, + accumulated_text: String::new(), + }) + } + + /// Accumulates an async stream of raw SSE lines with parallel processing. + /// + /// The async task feeds raw SSE lines through a channel while a `spawn_blocking` + /// worker handles JSON parsing on a blocking thread — keeping the tokio executor + /// free between chunk arrivals. + /// + /// # Errors + /// Returns `ExecutorError::ParseError` if chunk parsing fails, or + /// `ExecutorError::StreamError` if the stream or worker encounters an error. + pub async fn from_stream( + mut stream: Pin> + Send>>, + conversation_id: Option<&str>, + ) -> ExecutorResult { + let (tx, rx) = mpsc::channel::(); + // Convert to owned here — spawn_blocking closure must be 'static. + let conv_id_owned = conversation_id.map(str::to_string); + + // Spawn blocking task: JSON parsing is CPU-bound, runs off the async executor. + let worker_handle = tokio::task::spawn_blocking(move || Self::process_stream_chunks(rx, conv_id_owned)); + + // Feed raw SSE lines from the async stream to the blocking worker. + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + if tx.send(chunk).is_err() { + // Worker exited early (e.g. saw ResponseDone). + break; + } + } + Err(e) => return Err(e), + } + } + + // Signal EOF to worker. + drop(tx); + + // Properly async join — does not block the tokio executor thread. + worker_handle + .await + .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into())) + } + + /// Worker function that processes SSE lines from the channel (runs on blocking thread). + fn process_stream_chunks(rx: mpsc::Receiver, conversation_id: Option) -> Self { + let mut acc = Self::new(uuid7_str("resp_"), conversation_id); + for line in rx { + acc.process_sse_line(&line); + } + acc.finalize_current_message(); + if acc.status == ResponseStatus::InProgress { + acc.status = ResponseStatus::Completed; + } + acc + } + + /// Processes pre-collected raw SSE lines synchronously. + /// + /// Useful when lines have already been buffered (e.g. replaying a recorded stream). + /// Prefer [`from_stream`](Self::from_stream) for live async streams. + /// Line parse errors are silently skipped — this function is infallible. + #[must_use] + pub fn from_sse_lines(lines: impl IntoIterator, conversation_id: Option<&str>) -> Self { + let mut acc = Self::new(uuid7_str("resp_"), conversation_id.map(str::to_string)); + for line in lines { + acc.process_sse_line(&line); + } + acc.finalize_current_message(); + acc + } + + /// Closes the in-flight message, pushing it to `output` with accumulated text. + fn finalize_current_message(&mut self) { + if let Some(mut msg) = self.current_message.take() { + if !self.accumulated_text.is_empty() { + msg.content.push(OutputTextContent::new(&self.accumulated_text)); + } + msg.status = MessageStatus::Completed.as_str().to_string(); + self.output.push(OutputItem::Message(msg)); + } + self.accumulated_text.clear(); + } + + /// Processes a single raw SSE line, updating accumulator state. + /// + /// Non-`data:` lines, `[DONE]`, and malformed JSON are silently skipped. + fn process_sse_line(&mut self, line: &str) { + let Some(data_str) = line.strip_prefix("data: ") else { + return; + }; + if data_str == "[DONE]" { + return; + } + let Ok(json) = deserialize_from_str::(data_str) else { + return; + }; + + match json["type"] + .as_str() + .map_or(SSEEventType::Other, |s| s.parse().unwrap_or_default()) + { + SSEEventType::ResponseCreated => { + if let Some(id) = json["response"]["id"].as_str() { + self.response_id = id.to_string(); + } + } + SSEEventType::ResponseOutputItemAdded => { + self.finalize_current_message(); + let item_id = json["item"]["id"] + .as_str() + .map_or_else(|| uuid7_str("msg_"), str::to_string); + self.current_message = Some(OutputMessage::new(&item_id, MessageStatus::InProgress.as_str())); + } + SSEEventType::ResponseOutputTextDelta => { + if let Some(delta) = json["delta"].as_str() { + self.accumulated_text.push_str(delta); + } + } + SSEEventType::ResponseDone => { + self.finalize_current_message(); + self.status = ResponseStatus::Completed; + if let Ok(usage) = deserialize_from_value::(json["response"]["usage"].clone()) { + self.usage = Some(usage); + } + } + SSEEventType::Other => {} + } + } + + /// Marks the response as incomplete due to an error or interruption. + pub fn mark_incomplete(&mut self, reason: String) { + self.status = ResponseStatus::Incomplete; + self.incomplete_details = Some(IncompleteDetails { reason: Some(reason) }); + } + + /// Finalizes the accumulator into a `ResponsePayload`. + /// + /// The caller supplies fields that come from the original request, not from + /// the LLM response stream. + #[must_use] + pub fn finalize( + self, + model: &str, + previous_response_id: Option<&str>, + instructions: Option<&str>, + ) -> ResponsePayload { + ResponsePayload { + id: self.response_id, + object: "response".to_string(), + created_at: chrono::Utc::now().timestamp(), + model: model.to_string(), + status: self.status.as_str().to_string(), + output: self.output, + usage: self.usage, + incomplete_details: self.incomplete_details, + error: None, + previous_response_id: previous_response_id.map(str::to_string), + conversation_id: self.conversation_id, + instructions: instructions.map(str::to_string), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_accumulator_new() { + let acc = ResponseAccumulator::new("resp_123".into(), Some("conv_456".into())); + assert_eq!(acc.response_id, "resp_123"); + assert_eq!(acc.conversation_id, Some("conv_456".into())); + assert_eq!(acc.status, ResponseStatus::InProgress); + } + + #[test] + fn test_accumulator_mark_incomplete() { + let mut acc = ResponseAccumulator::new("resp_123".into(), None); + acc.mark_incomplete("Stream interrupted".into()); + assert_eq!(acc.status, ResponseStatus::Incomplete); + assert!(acc.incomplete_details.is_some()); + } + + #[test] + fn test_accumulator_finalize() { + let acc = ResponseAccumulator::new("resp_123".into(), Some("conv_456".into())); + let payload = acc.finalize("gpt-4o", Some("resp_prev"), Some("be helpful")); + assert_eq!(payload.id, "resp_123"); + assert_eq!(payload.model, "gpt-4o"); + assert_eq!(payload.conversation_id, Some("conv_456".into())); + assert_eq!(payload.previous_response_id, Some("resp_prev".into())); + assert_eq!(payload.instructions, Some("be helpful".into())); + assert_eq!(payload.status, ResponseStatus::InProgress.as_str()); + } + + #[test] + fn test_accumulator_from_sse_lines_empty() { + let acc = ResponseAccumulator::from_sse_lines(vec![], None); + assert_eq!(acc.status, ResponseStatus::InProgress); + assert!(acc.output.is_empty()); + } + + #[test] + fn test_accumulator_text_delta_assigned_to_message() { + let lines = vec![ + r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(), + r#"data: {"type":"response.output_item.added","item":{"id":"msg_1"}}"#.to_string(), + r#"data: {"type":"response.output_text.delta","delta":"Hello"}"#.to_string(), + r#"data: {"type":"response.output_text.delta","delta":" world"}"#.to_string(), + r#"data: {"type":"response.done","response":{"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(), + ]; + + let acc = ResponseAccumulator::from_sse_lines(lines, None); + assert_eq!(acc.status, ResponseStatus::Completed); + assert_eq!(acc.output.len(), 1); + + if let OutputItem::Message(msg) = &acc.output[0] { + assert_eq!(msg.content.len(), 1); + assert_eq!(msg.content[0].text, "Hello world"); + } else { + panic!("expected OutputItem::Message"); + } + + assert!(acc.usage.is_some()); + let usage = acc.usage.unwrap(); + assert_eq!(usage.total_tokens, 7); + } + + #[test] + fn test_message_status_enum() { + assert_eq!(MessageStatus::Completed.as_str(), "completed"); + assert_eq!(MessageStatus::InProgress.as_str(), "in_progress"); + } +} diff --git a/crates/agentic-core/src/executor/engine.rs b/crates/agentic-core/src/executor/engine.rs new file mode 100644 index 0000000..9f6eab2 --- /dev/null +++ b/crates/agentic-core/src/executor/engine.rs @@ -0,0 +1,415 @@ +//! Agentic loop executor. +//! +//! Exposes each step of the loop as a public function so consumers can compose +//! them directly (e.g. as Praxis filters). [`execute`] is the convenience entry +//! point that composes all steps with the default control flow. + +use std::pin::Pin; +use std::sync::Arc; + +use async_stream::stream; +use either::Either; +use futures::{Stream, StreamExt}; +use tracing::warn; + +use crate::executor::accumulator::ResponseAccumulator; +use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::modes::{ConversationHandler, ResponseHandler}; +use crate::executor::request::{ExecutionContext, RequestContext}; +use crate::storage::InOutItem; +use crate::types::event::ResponseStatus; +use crate::types::io::{InputItem, ResponsesInput, resolve_tool_choice, resolve_tools}; +use crate::types::request_response::{RequestPayload, ResponsePayload}; +use crate::utils::common::serialize_to_string; +use crate::utils::uuid7_str; + +/// SSE stream of raw lines sent to the client (`data: …\n\n` per event). +pub type BoxStream = Pin + Send>>; + +/// Wire-format marker signalling end-of-stream to the client. +const DONE_MARKER: &str = "data: [DONE]\n\n"; + +/// Makes a non-streaming HTTP POST to the LLM backend and returns the full JSON body. +/// +/// Used by [`run_blocking`] so it can pass the result to [`ResponseAccumulator::from_json`]. +async fn fetch_response_json( + upstream_json: String, + url: &str, + client: &reqwest::Client, + auth: Option<&str>, +) -> ExecutorResult { + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .body(upstream_json); + if let Some(key) = auth { + req = req.bearer_auth(key); + } + + let resp = match req.send().await { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return Err(ExecutorError::LLMRequest { + status: http::StatusCode::GATEWAY_TIMEOUT, + body: "upstream timeout".into(), + }); + } + Err(_) => { + return Err(ExecutorError::LLMRequest { + status: http::StatusCode::BAD_GATEWAY, + body: "upstream unavailable".into(), + }); + } + }; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(ExecutorError::LLMRequest { + status: http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), + body, + }); + } + + resp.text() + .await + .map_err(|e| ExecutorError::StreamError(format!("failed to read response body: {e}"))) +} + +/// Step 1 — Build [`RequestContext`] by rehydrating conversation history. +/// +/// `request` is moved into the context as `enriched_request`; one clone is taken +/// for `original_request` so the engine retains an unmodified copy for persistence +/// and ID resolution. +/// +/// Dispatches to one of four paths based on `store` flag and which ID is present: +/// - `store=false` + `previous_response_id`: validate the prior response exists, no history loaded +/// - `store=true` + `previous_response_id`: [`rehydrate_from_response`] +/// - `store=true` + `conversation_id`: [`rehydrate_from_conversation`] +/// - `store=true` + no ids: create a new conversation +/// +/// # Errors +/// Returns [`ExecutorError`] if storage is unavailable or a referenced ID does not exist. +pub async fn rehydrate_conversation( + request: RequestPayload, + exec_ctx: &ExecutionContext, +) -> ExecutorResult { + let response_id = uuid7_str("resp_"); + let new_input_items: Vec = Vec::from(&request.input); + + // One clone for the unmodified original; `request` is moved as enriched_request. + let original_request = request.clone(); + let mut ctx = RequestContext { + enriched_request: request, + original_request, + new_input_items, + response_id, + conversation_id: None, + }; + + if !ctx.original_request.store { + // Non-store path: validate previous_response_id only; no history needed. + if ctx.original_request.previous_response_id.is_some() { + exec_ctx.resp_handler.validate_exists(&ctx).await?; + } + return Ok(ctx); + } + + if ctx.original_request.previous_response_id.is_some() { + rehydrate_from_response(&mut ctx, exec_ctx).await?; + return Ok(ctx); + } + + if ctx.original_request.conversation_id.is_some() { + rehydrate_from_conversation(&mut ctx, exec_ctx).await?; + return Ok(ctx); + } + + // Store + no ids: create a fresh conversation. + let conv_data = exec_ctx.conv_handler.create().await?; + ctx.conversation_id = Some(conv_data.conversation_id); + ctx.enriched_request.input = ResponsesInput::Items(ctx.new_input_items.clone()); + Ok(ctx) +} + +/// Hydrates `ctx` from the previous response chain. +/// +/// Loads the stored response, rehydrates its history items, resolves effective +/// tools and tool choice from the stored metadata, and prepends the history to +/// the enriched request input. +async fn rehydrate_from_response(ctx: &mut RequestContext, exec_ctx: &ExecutionContext) -> ExecutorResult<()> { + let stored = exec_ctx.resp_handler.get(ctx).await?; + let history = exec_ctx.resp_handler.rehydrate(ctx).await?; + + let mut items = InOutItem::into_input_items(history); + items.reserve(ctx.new_input_items.len()); + items.extend(ctx.new_input_items.iter().cloned()); + + ctx.enriched_request.previous_response_id = None; + ctx.enriched_request.input = ResponsesInput::Items(items); + ctx.enriched_request.tools = resolve_tools( + ctx.original_request.tools.as_deref(), + stored.metadata.effective_tools.as_deref(), + ctx.original_request.tools.is_some(), + ); + ctx.enriched_request.tool_choice = resolve_tool_choice( + &ctx.original_request.tool_choice, + &stored.metadata.effective_tool_choice, + false, + ); + ctx.conversation_id = stored.conversation_id; + Ok(()) +} + +/// Hydrates `ctx` from the conversation store. +/// +/// Gets or creates the conversation and rehydrates its history in parallel, +/// then prepends the history items to the enriched request input. +async fn rehydrate_from_conversation(ctx: &mut RequestContext, exec_ctx: &ExecutionContext) -> ExecutorResult<()> { + let (conv_data, history) = tokio::try_join!( + exec_ctx.conv_handler.get_or_create(ctx), + exec_ctx.conv_handler.rehydrate(ctx), + )?; + + let mut items = InOutItem::into_input_items(history); + items.reserve(ctx.new_input_items.len()); + items.extend(ctx.new_input_items.iter().cloned()); + + ctx.enriched_request.input = ResponsesInput::Items(items); + ctx.conversation_id = Some(conv_data.conversation_id); + Ok(()) +} + +/// Step 2 — Call the LLM inference backend; yields raw SSE lines (`data: …`). +/// +/// Always requests `stream=true` upstream. Stops on `[DONE]`. +/// Yields `Err` on connection failure (502), timeout (504), or non-2xx status. +pub fn call_inference( + upstream_json: String, + url: String, + client: Arc, + auth: Option, +) -> impl Stream> + Send + 'static { + stream! { + let mut req = client + .post(&url) + .header("Content-Type", "application/json") + .body(upstream_json); + if let Some(ref key) = auth { + req = req.bearer_auth(key); + } + + let resp = match req.send().await { + Ok(r) => r, + Err(e) if e.is_timeout() => { + yield Err(ExecutorError::LLMRequest { + status: http::StatusCode::GATEWAY_TIMEOUT, + body: "upstream timeout".into(), + }); + return; + } + Err(_) => { + yield Err(ExecutorError::LLMRequest { + status: http::StatusCode::BAD_GATEWAY, + body: "upstream unavailable".into(), + }); + return; + } + }; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + yield Err(ExecutorError::LLMRequest { + status: http::StatusCode::from_u16(status) + .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), + body, + }); + return; + } + + let buf_cap = resp + .content_length() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(8192) + .min(4 * 1024 * 1024); + + let mut byte_stream = resp.bytes_stream(); + let mut buf = String::with_capacity(buf_cap); + + while let Some(chunk_result) = byte_stream.next().await { + let chunk = match chunk_result { + Ok(c) => c, + Err(e) => { + yield Err(ExecutorError::StreamError(format!("stream read error: {e}"))); + return; + } + }; + + match std::str::from_utf8(&chunk) { + Ok(s) => buf.push_str(s), + Err(_) => buf.push_str(&String::from_utf8_lossy(&chunk)), + } + + while let Some(pos) = buf.find('\n') { + let line_end = if pos > 0 && buf.as_bytes()[pos - 1] == b'\r' { + pos - 1 + } else { + pos + }; + let line = &buf[..line_end]; + if line.starts_with("data: ") { + if line == "data: [DONE]" { + return; + } + yield Ok(line.to_string()); + } + buf.drain(..=pos); + } + } + } +} + +/// Step 3 — Persist the completed response to storage. +/// +/// Skipped if [`ResponseStatus`] is not `Completed`/`Incomplete` or `payload.id` is empty. +/// Routes to [`ConversationHandler`] when `ctx.conversation_id` is set, +/// otherwise [`ResponseHandler`]. +/// +/// # Errors +/// Returns [`ExecutorError`] if the storage operation fails. +pub async fn persist_response( + payload: ResponsePayload, + ctx: RequestContext, + conv_handler: ConversationHandler, + resp_handler: ResponseHandler, +) -> ExecutorResult<()> { + // Use typed enum — no hardcoded status strings. + if !matches!( + payload.status.parse::().unwrap_or_default(), + ResponseStatus::Completed | ResponseStatus::Incomplete + ) || payload.id.is_empty() + { + return Ok(()); + } + + // Move output items from payload; handlers build ResponseMetadata from ctx internally. + let output_items = payload.output; + + if ctx.conversation_id.is_some() { + conv_handler.execute_turn(ctx, output_items).await + } else { + resp_handler.execute_turn(ctx, output_items).await + } +} + +async fn run_blocking(ctx: RequestContext, exec_ctx: &ExecutionContext) -> ExecutorResult { + let url = exec_ctx.responses_url(); + // Non-streaming request: stream=false → full JSON body → from_json. + let upstream_json = serialize_to_string(&ctx.enriched_request.to_upstream_request(false)) + .map_err(|e| ExecutorError::ParseError(e.to_string()))?; + + let body = fetch_response_json(upstream_json, &url, &exec_ctx.client, exec_ctx.client_auth.as_deref()).await?; + + let acc = ResponseAccumulator::from_json(&body, ctx.conversation_id.as_deref())?; + let mut payload = acc.finalize( + &ctx.enriched_request.model, + ctx.original_request.previous_response_id.as_deref(), + ctx.original_request.instructions.as_deref(), + ); + ctx.inject_ids(&mut payload); + + if ctx.original_request.store { + let ch = exec_ctx.conv_handler.clone(); + let rh = exec_ctx.resp_handler.clone(); + if let Err(e) = persist_response(payload.clone(), ctx, ch, rh).await { + warn!("persist failed: {e}"); + } + } + + Ok(payload) +} + +fn run_stream(ctx: RequestContext, exec_ctx: Arc) -> BoxStream { + let url = exec_ctx.responses_url(); + // Streaming request: stream=true → SSE lines → from_stream. + let upstream_json = match serialize_to_string(&ctx.enriched_request.to_upstream_request(true)) { + Ok(s) => s, + Err(e) => { + return Box::pin(stream! { + yield format!("data: {{\"error\": \"serialize error: {e}\"}}\n\n"); + yield DONE_MARKER.to_string(); + }); + } + }; + + let store = ctx.original_request.store; + + Box::pin(stream! { + let line_stream = Box::pin(call_inference( + upstream_json, + url, + Arc::clone(&exec_ctx.client), + exec_ctx.client_auth.clone(), + )); + + // from_stream feeds SSE lines to a spawn_blocking worker via channel. + // All JSON parsing is CPU-bound and runs off the async executor. + match ResponseAccumulator::from_stream(line_stream, ctx.conversation_id.as_deref()).await { + Err(e) => { + yield format!("data: {{\"error\": \"{e}\"}}\n\n"); + yield DONE_MARKER.to_string(); + } + Ok(acc) => { + let mut payload = acc.finalize( + &ctx.enriched_request.model, + ctx.original_request.previous_response_id.as_deref(), + ctx.original_request.instructions.as_deref(), + ); + ctx.inject_ids(&mut payload); + yield payload.as_responses_chunk(); + yield DONE_MARKER.to_string(); + + if store { + let ch = exec_ctx.conv_handler.clone(); + let rh = exec_ctx.resp_handler.clone(); + if let Err(e) = persist_response(payload, ctx, ch, rh).await { + warn!("persist failed: {e}"); + } + } + } + } + }) +} + +/// Create a new conversation and return its data. +/// +/// Exposes the conversation-creation step as a standalone function so callers +/// (e.g. `agentic-server`, Praxis filters, or tests) can pre-create a +/// conversation before submitting response turns. +/// +/// # Errors +/// Returns [`ExecutorError`] if the conversation store is unavailable. +pub async fn create_conversation(exec_ctx: &ExecutionContext) -> ExecutorResult { + exec_ctx.conv_handler.create().await +} + +/// Run the full agentic loop. +/// +/// Returns `Either::Left(ResponsePayload)` for non-streaming requests, or +/// `Either::Right(BoxStream)` for streaming, each yielded `String` is an SSE +/// line ready to forward to the client. +/// +/// # Errors +/// Returns [`ExecutorError`] if rehydration or (non-streaming) LLM inference fails. +pub async fn execute( + request: RequestPayload, + exec_ctx: Arc, +) -> ExecutorResult> { + let ctx = rehydrate_conversation(request, &exec_ctx).await?; + if ctx.original_request.stream { + Ok(Either::Right(run_stream(ctx, exec_ctx))) + } else { + Ok(Either::Left(run_blocking(ctx, &exec_ctx).await?)) + } +} diff --git a/crates/agentic-core/src/executor/error.rs b/crates/agentic-core/src/executor/error.rs new file mode 100644 index 0000000..d306a83 --- /dev/null +++ b/crates/agentic-core/src/executor/error.rs @@ -0,0 +1,62 @@ +use http::StatusCode; +use thiserror::Error; + +use crate::StorageError; + +#[derive(Debug, Error)] +pub enum ExecutorError { + #[error("storage error: {0}")] + Storage(#[from] StorageError), + + #[error("LLM request failed ({status}): {body}")] + LLMRequest { status: StatusCode, body: String }, + + #[error("stream error: {0}")] + StreamError(String), + + #[error("parse error: {0}")] + ParseError(String), + + #[error("{entity} not found: {id}")] + NotFound { entity: String, id: String }, + + #[error("invalid request: {0}")] + InvalidRequest(String), +} + +pub type ExecutorResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_executor_error_display() { + let err = ExecutorError::InvalidRequest("test message".into()); + assert!(err.to_string().contains("invalid request")); + assert!(err.to_string().contains("test message")); + } + + #[test] + fn test_executor_error_stream() { + let err = ExecutorError::StreamError("connection lost".into()); + assert!(err.to_string().contains("stream error")); + } + + #[test] + fn test_executor_error_not_found() { + let err = ExecutorError::NotFound { + entity: "Conversation".into(), + id: "conv_123".into(), + }; + assert!(err.to_string().contains("Conversation")); + assert!(err.to_string().contains("conv_123")); + } + + #[test] + fn test_executor_error_from_storage() { + let storage_err = StorageError::NotConfigured; + let exec_err = ExecutorError::from(storage_err); + assert!(exec_err.to_string().contains("storage error")); + } +} diff --git a/crates/agentic-core/src/executor/mod.rs b/crates/agentic-core/src/executor/mod.rs new file mode 100644 index 0000000..32fbabc --- /dev/null +++ b/crates/agentic-core/src/executor/mod.rs @@ -0,0 +1,13 @@ +//! Agentic loop executor. + +pub mod accumulator; +pub mod engine; +pub mod error; +pub mod modes; +pub mod request; + +pub use engine::{BoxStream, call_inference, create_conversation, execute, persist_response, rehydrate_conversation}; +pub use error::{ExecutorError, ExecutorResult}; +pub use modes::{ConversationHandler, ResponseHandler}; +pub use request::ExecutionContext; +pub use request::RequestContext; diff --git a/crates/agentic-core/src/executor/modes/conversation.rs b/crates/agentic-core/src/executor/modes/conversation.rs new file mode 100644 index 0000000..4091fd9 --- /dev/null +++ b/crates/agentic-core/src/executor/modes/conversation.rs @@ -0,0 +1,167 @@ +//! Conversation storage handler — owns all conversation store operations. + +use crate::storage::{ConversationData, ConversationStore, InOutItem, ResponseMetadata}; +use crate::types::io::OutputItem; + +use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::request::RequestContext; + +/// Handles all conversation store operations: creation, rehydration, and persistence. +#[derive(Clone)] +pub struct ConversationHandler { + store: ConversationStore, +} + +impl ConversationHandler { + #[must_use] + pub fn new(store: ConversationStore) -> Self { + Self { store } + } + + /// Gets an existing conversation or creates one. + /// + /// Reads `conversation_id` from `ctx.original_request`. + /// + /// # Errors + /// Returns `ExecutorError` if `conversation_id` is absent, the store is + /// disabled, or the database query fails. + pub async fn get_or_create(&self, ctx: &RequestContext) -> ExecutorResult { + let conv_id = ctx + .original_request + .conversation_id + .as_deref() + .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for get_or_create".into()))?; + self.store.get_or_create(conv_id).await.map_err(ExecutorError::Storage) + } + + /// Creates a brand-new conversation with a freshly generated ID. + /// + /// # Errors + /// Returns `ExecutorError` if the store is disabled or the database query fails. + pub async fn create(&self) -> ExecutorResult { + self.store.create().await.map_err(ExecutorError::Storage) + } + + /// Loads all history items for the conversation referenced by the request. + /// + /// Reads `conversation_id` from `ctx.original_request`. Returns an empty vec + /// if the conversation exists but has no items yet. + /// + /// # Errors + /// Returns `ExecutorError` if `conversation_id` is absent, the store is + /// disabled, or the database query fails. + pub async fn rehydrate(&self, ctx: &RequestContext) -> ExecutorResult> { + let conv_id = ctx + .original_request + .conversation_id + .as_deref() + .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for rehydrate".into()))?; + self.store.rehydrate(conv_id).await.map_err(ExecutorError::Storage) + } + + /// Persists one conversation turn — only the new items from this turn. + /// + /// Takes `ctx` and `output_items` by value so fields can be moved directly + /// into [`ResponseMetadata`] without cloning. The store tracks sequence + /// numbers and appends, so prior history must not be re-inserted. + /// + /// # Errors + /// Returns `ExecutorError` if `conversation_id` is absent on the context, + /// the store is disabled, or the database operation fails. + pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { + let conversation_id = ctx + .conversation_id + .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for execute_turn".into()))?; + + let metadata = ResponseMetadata { + model: ctx.enriched_request.model, + previous_response_id: ctx.original_request.previous_response_id, + effective_tools: ctx.original_request.tools, + effective_tool_choice: ctx.original_request.tool_choice, + effective_instructions: ctx.original_request.instructions, + }; + + let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len()); + new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input)); + new_items.extend(output_items.into_iter().map(InOutItem::Output)); + + self.store + .persist( + &conversation_id, + &ctx.response_id, + metadata.previous_response_id.as_deref(), + new_items, + &metadata, + ) + .await + .map_err(ExecutorError::Storage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::io::{ResponsesInput, ToolChoice}; + use crate::types::request_response::RequestPayload; + + fn disabled_handler() -> ConversationHandler { + ConversationHandler::new(ConversationStore::disabled()) + } + + fn make_ctx(conversation_id: Option<&str>) -> RequestContext { + let req = RequestPayload { + model: "test".into(), + input: ResponsesInput::Text("hi".into()), + instructions: None, + previous_response_id: None, + conversation_id: conversation_id.map(str::to_string), + tools: None, + tool_choice: ToolChoice::Auto, + stream: false, + store: true, + include: None, + temperature: None, + top_p: None, + max_output_tokens: None, + truncation: None, + metadata: None, + }; + RequestContext { + enriched_request: req.clone(), + original_request: req, + new_input_items: vec![], + response_id: "resp_test".into(), + conversation_id: conversation_id.map(str::to_string), + } + } + + #[tokio::test] + async fn test_get_or_create_missing_id_returns_error() { + let result = disabled_handler().get_or_create(&make_ctx(None)).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_rehydrate_missing_id_returns_error() { + let result = disabled_handler().rehydrate(&make_ctx(None)).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_or_create_disabled_store_returns_error() { + let result = disabled_handler().get_or_create(&make_ctx(Some("conv_1"))).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_rehydrate_disabled_store_returns_error() { + let result = disabled_handler().rehydrate(&make_ctx(Some("conv_1"))).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_execute_turn_missing_conv_id_returns_error() { + let result = disabled_handler().execute_turn(make_ctx(None), vec![]).await; + assert!(result.is_err()); + } +} diff --git a/crates/agentic-core/src/executor/modes/mod.rs b/crates/agentic-core/src/executor/modes/mod.rs new file mode 100644 index 0000000..1e57c67 --- /dev/null +++ b/crates/agentic-core/src/executor/modes/mod.rs @@ -0,0 +1,5 @@ +pub mod conversation; +pub mod response; + +pub use conversation::ConversationHandler; +pub use response::ResponseHandler; diff --git a/crates/agentic-core/src/executor/modes/response.rs b/crates/agentic-core/src/executor/modes/response.rs new file mode 100644 index 0000000..10c27a3 --- /dev/null +++ b/crates/agentic-core/src/executor/modes/response.rs @@ -0,0 +1,163 @@ +//! Response storage handler — owns all response store operations. + +use crate::storage::{InOutItem, ResponseData, ResponseMetadata, ResponseStore}; +use crate::types::io::OutputItem; + +use crate::executor::error::{ExecutorError, ExecutorResult}; +use crate::executor::request::RequestContext; + +/// Handles all response store operations: lookup, rehydration, and persistence. +#[derive(Clone)] +pub struct ResponseHandler { + store: ResponseStore, +} + +impl ResponseHandler { + #[must_use] + pub fn new(store: ResponseStore) -> Self { + Self { store } + } + + /// Retrieves the stored response for `previous_response_id`. + /// + /// Reads `previous_response_id` from `ctx.original_request`. + /// + /// # Errors + /// Returns `ExecutorError` if `previous_response_id` is absent, the response + /// is not found, the store is disabled, or the database query fails. + pub async fn get(&self, ctx: &RequestContext) -> ExecutorResult { + let prev_id = ctx + .original_request + .previous_response_id + .as_deref() + .ok_or_else(|| ExecutorError::InvalidRequest("previous_response_id is required for get".into()))?; + self.store.get(prev_id).await.map_err(ExecutorError::Storage) + } + + /// Validates that the response for `previous_response_id` exists. + /// + /// Used in the `store=false` path where we only need to confirm the ID is + /// valid without loading any history. + /// + /// # Errors + /// Returns `ExecutorError` if `previous_response_id` is absent, the response + /// is not found, or the store is disabled. + pub async fn validate_exists(&self, ctx: &RequestContext) -> ExecutorResult<()> { + self.get(ctx).await.map(|_| ()) + } + + /// Loads all history items referenced by the previous response. + /// + /// Reads `previous_response_id` from `ctx.original_request`. Returns an empty + /// vec if there is no previous response. + /// + /// # Errors + /// Returns `ExecutorError` if the store is disabled or the database query fails. + pub async fn rehydrate(&self, ctx: &RequestContext) -> ExecutorResult> { + let Some(prev_id) = ctx.original_request.previous_response_id.as_deref() else { + return Ok(vec![]); + }; + self.store.rehydrate(prev_id).await.map_err(ExecutorError::Storage) + } + + /// Persists a response record — only the new items from this turn. + /// + /// Takes `ctx` and `output_items` by value so fields can be moved directly + /// into [`ResponseMetadata`] without cloning. Prior history must not be + /// re-inserted; the response store records item IDs for this response only. + /// + /// # Errors + /// Returns `ExecutorError` if the store is disabled or the database operation fails. + pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec) -> ExecutorResult<()> { + let metadata = ResponseMetadata { + model: ctx.enriched_request.model, + previous_response_id: ctx.original_request.previous_response_id, + effective_tools: ctx.original_request.tools, + effective_tool_choice: ctx.original_request.tool_choice, + effective_instructions: ctx.original_request.instructions, + }; + + let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len()); + new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input)); + new_items.extend(output_items.into_iter().map(InOutItem::Output)); + + self.store + .persist( + &ctx.response_id, + metadata.previous_response_id.as_deref(), + new_items, + &metadata, + ) + .await + .map_err(ExecutorError::Storage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::io::{ResponsesInput, ToolChoice}; + use crate::types::request_response::RequestPayload; + + fn disabled_handler() -> ResponseHandler { + ResponseHandler::new(ResponseStore::disabled()) + } + + fn make_ctx(previous_response_id: Option<&str>) -> RequestContext { + let req = RequestPayload { + model: "test".into(), + input: ResponsesInput::Text("hi".into()), + instructions: None, + previous_response_id: previous_response_id.map(str::to_string), + conversation_id: None, + tools: None, + tool_choice: ToolChoice::Auto, + stream: false, + store: true, + include: None, + temperature: None, + top_p: None, + max_output_tokens: None, + truncation: None, + metadata: None, + }; + RequestContext { + enriched_request: req.clone(), + original_request: req, + new_input_items: vec![], + response_id: "resp_test".into(), + conversation_id: None, + } + } + + #[tokio::test] + async fn test_get_missing_prev_id_returns_error() { + let result = disabled_handler().get(&make_ctx(None)).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_validate_exists_missing_prev_id_returns_error() { + let result = disabled_handler().validate_exists(&make_ctx(None)).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_rehydrate_no_prev_id_returns_empty() { + let result = disabled_handler().rehydrate(&make_ctx(None)).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_rehydrate_disabled_store_returns_error() { + let result = disabled_handler().rehydrate(&make_ctx(Some("resp_prev"))).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_execute_turn_disabled_store_returns_error() { + let result = disabled_handler().execute_turn(make_ctx(None), vec![]).await; + assert!(result.is_err()); + } +} diff --git a/crates/agentic-core/src/executor/request.rs b/crates/agentic-core/src/executor/request.rs new file mode 100644 index 0000000..297b717 --- /dev/null +++ b/crates/agentic-core/src/executor/request.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use crate::executor::modes::{ConversationHandler, ResponseHandler}; +use crate::types::io::InputItem; +use crate::types::request_response::{RequestPayload, ResponsePayload}; + +/// Context built by `rehydrate_conversation`, threaded through the execute pipeline. +pub struct RequestContext { + /// Untouched original request from the client. + pub original_request: RequestPayload, + /// Enriched request with rehydrated conversation history injected into `.input`. + /// This is the request forwarded to the LLM. + pub enriched_request: RequestPayload, + /// Only the new input items submitted by the client this turn (used for persistence). + pub new_input_items: Vec, + /// Our generated response ID (uuid7 with "resp_" prefix). + pub response_id: String, + /// Resolved conversation ID. `None` when `store=false` or non-conversational. + pub conversation_id: Option, +} + +impl RequestContext { + /// Inject our `response_id` and `conversation_id` into a `ResponsePayload` + /// received from the LLM (which carries the upstream's own IDs). + pub(crate) fn inject_ids(&self, payload: &mut ResponsePayload) { + payload.id.clone_from(&self.response_id); + payload.conversation_id.clone_from(&self.conversation_id); + payload + .previous_response_id + .clone_from(&self.original_request.previous_response_id); + } +} + +/// Runtime dependencies passed into `execute()`. +/// +/// Owns the storage handlers, HTTP client, and LLM endpoint configuration. +pub struct ExecutionContext { + pub conv_handler: ConversationHandler, + pub resp_handler: ResponseHandler, + pub client: Arc, + /// Base URL for the LLM backend, e.g. `"http://localhost:8000"`. + pub llm_base_url: String, + /// Bearer token forwarded from the client, if any. + pub client_auth: Option, +} + +impl ExecutionContext { + /// Returns the full URL for the `/v1/responses` endpoint. + #[must_use] + pub fn responses_url(&self) -> String { + format!("{}/v1/responses", self.llm_base_url) + } + + /// Returns the full URL for the `/v1/conversations` endpoint. + #[must_use] + pub fn conversations_url(&self) -> String { + format!("{}/v1/conversations", self.llm_base_url) + } + + #[must_use] + pub fn new( + conv_handler: ConversationHandler, + resp_handler: ResponseHandler, + client: Arc, + llm_base_url: String, + client_auth: Option, + ) -> Self { + Self { + conv_handler, + resp_handler, + client, + llm_base_url, + client_auth, + } + } +} diff --git a/crates/agentic-core/src/lib.rs b/crates/agentic-core/src/lib.rs index 20877b6..700bafb 100644 --- a/crates/agentic-core/src/lib.rs +++ b/crates/agentic-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod error; +pub mod executor; pub mod proxy; pub mod readiness; pub mod storage; diff --git a/crates/agentic-core/src/types/event.rs b/crates/agentic-core/src/types/event.rs new file mode 100644 index 0000000..6409c81 --- /dev/null +++ b/crates/agentic-core/src/types/event.rs @@ -0,0 +1,185 @@ +//! Server-Sent Event (SSE) types and response status enums. + +use std::convert::Infallible; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Response completion status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseStatus { + /// Response is being generated. + #[default] + InProgress, + + /// Response generation completed successfully. + Completed, + + /// Response generation incomplete (e.g., stream interrupted). + Incomplete, + + /// Response generation encountered an error. + Error, +} + +impl ResponseStatus { + /// Returns the canonical wire string for this status. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::InProgress => "in_progress", + Self::Completed => "completed", + Self::Incomplete => "incomplete", + Self::Error => "error", + } + } +} + +impl FromStr for ResponseStatus { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "in_progress" => Self::InProgress, + "completed" => Self::Completed, + "incomplete" => Self::Incomplete, + _ => Self::Error, + }) + } +} + +/// Message item completion status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageStatus { + /// Message is being generated. + #[default] + InProgress, + + /// Message generation completed. + Completed, +} + +impl MessageStatus { + /// Returns the canonical wire string for this status. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::InProgress => "in_progress", + Self::Completed => "completed", + } + } +} + +impl FromStr for MessageStatus { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "completed" => Self::Completed, + _ => Self::InProgress, + }) + } +} + +/// Server-Sent Event types from LLM streaming responses. +/// +/// Emitted by vLLM when `stream=true`. Each variant represents one step in the +/// response generation process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SSEEventType { + /// Response object created; contains initial response metadata. + ResponseCreated, + + /// Output item (message) added; marks the start of a new message. + ResponseOutputItemAdded, + + /// Text delta; incremental token content added to the current message. + ResponseOutputTextDelta, + + /// Response fully completed; no more events will follow. + ResponseDone, + + /// Unknown or unhandled event type. + #[default] + Other, +} + +impl FromStr for SSEEventType { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "response.created" => Self::ResponseCreated, + "response.output_item.added" => Self::ResponseOutputItemAdded, + "response.output_text.delta" => Self::ResponseOutputTextDelta, + // vLLM uses `response.done`; OpenAI uses `response.completed`. + "response.done" | "response.completed" => Self::ResponseDone, + _ => Self::Other, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sse_event_type_from_str_created() { + assert_eq!( + "response.created".parse::().unwrap(), + SSEEventType::ResponseCreated + ); + } + + #[test] + fn test_sse_event_type_from_str_delta() { + assert_eq!( + "response.output_text.delta".parse::().unwrap(), + SSEEventType::ResponseOutputTextDelta + ); + } + + #[test] + fn test_sse_event_type_from_str_done() { + assert_eq!( + "response.done".parse::().unwrap(), + SSEEventType::ResponseDone + ); + } + + #[test] + fn test_sse_event_type_from_str_unknown() { + assert_eq!("unknown.event".parse::().unwrap(), SSEEventType::Other); + } + + #[test] + fn test_sse_event_type_from_str_empty() { + assert_eq!("".parse::().unwrap(), SSEEventType::Other); + } + + #[test] + fn test_response_status_round_trip() { + for (s, expected) in [ + ("in_progress", ResponseStatus::InProgress), + ("completed", ResponseStatus::Completed), + ("incomplete", ResponseStatus::Incomplete), + ("error", ResponseStatus::Error), + ] { + let parsed: ResponseStatus = s.parse().unwrap(); + assert_eq!(parsed, expected); + assert_eq!(parsed.as_str(), s); + } + } + + #[test] + fn test_message_status_round_trip() { + assert_eq!("completed".parse::().unwrap(), MessageStatus::Completed); + assert_eq!( + "in_progress".parse::().unwrap(), + MessageStatus::InProgress + ); + assert_eq!("unknown".parse::().unwrap(), MessageStatus::InProgress); + } +} diff --git a/crates/agentic-core/src/types/io.rs b/crates/agentic-core/src/types/io.rs index 239a9ce..159ed78 100644 --- a/crates/agentic-core/src/types/io.rs +++ b/crates/agentic-core/src/types/io.rs @@ -110,17 +110,17 @@ pub enum OutputItem { FunctionCall(FunctionToolCall), } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct InputTokenDetails { pub cached_tokens: i64, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct OutputTokenDetails { pub reasoning_tokens: i64, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct ResponseUsage { pub input_tokens: i64, pub output_tokens: i64, @@ -156,6 +156,32 @@ pub enum ToolChoice { }, } +/// Returns the effective tool list, preferring `request_tools` when explicitly +/// set by the caller, otherwise falling back to the stored configuration. +#[inline] +pub(crate) fn resolve_tools( + request_tools: Option<&[ResponsesTool]>, + stored_tools: Option<&[ResponsesTool]>, + tools_explicitly_set: bool, +) -> Option> { + if tools_explicitly_set { + request_tools + } else { + stored_tools + } + .map(<[_]>::to_vec) +} + +/// Returns the effective tool choice using the same precedence as [`resolve_tools`]. +#[inline] +pub(crate) fn resolve_tool_choice( + request_choice: &ToolChoice, + stored_choice: &ToolChoice, + explicitly_set: bool, +) -> ToolChoice { + if explicitly_set { request_choice } else { stored_choice }.clone() +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ResponsesInput { diff --git a/crates/agentic-core/src/types/mod.rs b/crates/agentic-core/src/types/mod.rs index 6c7865b..675c7ba 100644 --- a/crates/agentic-core/src/types/mod.rs +++ b/crates/agentic-core/src/types/mod.rs @@ -1,3 +1,4 @@ +pub mod event; pub mod io; pub mod request_response; diff --git a/crates/agentic-core/src/utils/common.rs b/crates/agentic-core/src/utils/common.rs index c7545d2..cc00b91 100644 --- a/crates/agentic-core/src/utils/common.rs +++ b/crates/agentic-core/src/utils/common.rs @@ -73,3 +73,20 @@ pub fn deserialize_from_string_opt_or_default(json_str: &Option) -> Option { json_str.as_ref().and_then(|s| deserialize_from_str_opt::(s)) } + +/// Deserialize a `serde_json::Value` into `T`. +/// +/// # Errors +/// +/// Returns `serde_json::Error` if the value's shape does not match `T`. +pub fn deserialize_from_value( + value: serde_json::Value, +) -> Result { + serde_json::from_value(value) +} + +/// Deserialize a `serde_json::Value` into `T`, returning `None` on type mismatch. +#[must_use] +pub fn deserialize_from_value_opt(value: serde_json::Value) -> Option { + serde_json::from_value(value).ok() +} diff --git a/crates/agentic-core/tests/stateful_conversation_integration.rs b/crates/agentic-core/tests/stateful_conversation_integration.rs new file mode 100644 index 0000000..ba70599 --- /dev/null +++ b/crates/agentic-core/tests/stateful_conversation_integration.rs @@ -0,0 +1,305 @@ +//! Cassette-based integration tests for the Conversation API (cases 6–10). +//! +//! Mirrors `test_conversation_api.py`. Each conversation cassette includes a +//! `/v1/conversations` creation turn — mirrored here via `create_conversation()`. +//! `TestFixture` serves only `/v1/responses` turns on the mock HTTP server. + +mod support; + +use agentic_core::executor::{create_conversation, execute}; +use std::sync::Arc; +use support::{ + TestFixture, collect_stream, expected_text, load_cassette, make_request, output_text, responses_turns, + unwrap_blocking, +}; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/text_only/conversation"); + +/// Case 6 — two turns, non-streaming, via `conversation_id`. +#[tokio::test] +async fn test_two_turn_nonstreaming_conversation() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/conv-two-turn-gpt-4o-nonstreaming.yaml")); + let all: Vec<_> = cassette.turns.iter().collect(); + let fixture = TestFixture::new(&all).await; + let ctx = &fixture.exec_ctx; + let resp = responses_turns(&cassette); + let (t1, t2) = (resp[0], resp[1]); + + // Mirrors /v1/conversations creation turn + let conv_id = create_conversation(ctx).await.expect("create conv").conversation_id; + + // Act + let p1 = unwrap_blocking( + execute( + make_request(&t1.request.body.input, true, false, None, Some(conv_id.clone())), + Arc::clone(ctx), + ) + .await + .expect("t1"), + ); + let p2 = unwrap_blocking( + execute( + make_request(&t2.request.body.input, true, false, None, Some(conv_id)), + Arc::clone(ctx), + ) + .await + .expect("t2"), + ); + + // Assert + assert!(p1.id.starts_with("resp_")); + assert_eq!(p1.status, "completed"); + assert_eq!(output_text(&p1), expected_text(t1)); + assert_ne!(p2.id, p1.id); + assert_eq!(p2.status, "completed"); + assert_eq!(output_text(&p2), expected_text(t2)); +} + +/// Case 7 — two turns, streaming, via `conversation_id`. +#[tokio::test] +async fn test_two_turn_streaming_conversation() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/conv-two-turn-gpt-4o-streaming.yaml")); + let all: Vec<_> = cassette.turns.iter().collect(); + let fixture = TestFixture::new(&all).await; + let ctx = &fixture.exec_ctx; + let resp = responses_turns(&cassette); + let (t1, t2) = (resp[0], resp[1]); + + let conv_id = create_conversation(ctx).await.expect("create conv").conversation_id; + + // Act + let p1 = collect_stream( + execute( + make_request(&t1.request.body.input, true, true, None, Some(conv_id.clone())), + Arc::clone(ctx), + ) + .await + .expect("t1"), + ) + .await; + let p2 = collect_stream( + execute( + make_request(&t2.request.body.input, true, true, None, Some(conv_id)), + Arc::clone(ctx), + ) + .await + .expect("t2"), + ) + .await; + + // Assert + assert!(p1.id.starts_with("resp_")); + assert_eq!(p1.status, "completed"); + assert_eq!(output_text(&p1), expected_text(t1)); + assert_ne!(p2.id, p1.id); + assert_eq!(p2.status, "completed"); + assert_eq!(output_text(&p2), expected_text(t2)); +} + +/// Case 8 — two independent conversations must not share context. +#[tokio::test] +async fn test_conversation_isolation() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/conv-isolation-gpt-4o-nonstreaming.yaml")); + let all: Vec<_> = cassette.turns.iter().collect(); + let fixture = TestFixture::new(&all).await; + let ctx = &fixture.exec_ctx; + let resp = responses_turns(&cassette); + let (ta1, ta2, ta3, tb1, tb2, tb3) = (resp[0], resp[1], resp[2], resp[3], resp[4], resp[5]); + + // Conv A + let conv_a = create_conversation(ctx).await.expect("create conv A").conversation_id; + let pa1 = unwrap_blocking( + execute( + make_request(&ta1.request.body.input, true, false, None, Some(conv_a.clone())), + Arc::clone(ctx), + ) + .await + .expect("a1"), + ); + assert_eq!(output_text(&pa1), expected_text(ta1)); + let pa2 = unwrap_blocking( + execute( + make_request(&ta2.request.body.input, true, false, None, Some(conv_a.clone())), + Arc::clone(ctx), + ) + .await + .expect("a2"), + ); + assert_eq!(output_text(&pa2), expected_text(ta2)); + let pa3 = unwrap_blocking( + execute( + make_request(&ta3.request.body.input, true, false, None, Some(conv_a.clone())), + Arc::clone(ctx), + ) + .await + .expect("a3"), + ); + assert_eq!(output_text(&pa3), expected_text(ta3)); + + // Conv B + let conv_b = create_conversation(ctx).await.expect("create conv B").conversation_id; + let pb1 = unwrap_blocking( + execute( + make_request(&tb1.request.body.input, true, false, None, Some(conv_b.clone())), + Arc::clone(ctx), + ) + .await + .expect("b1"), + ); + assert_eq!(output_text(&pb1), expected_text(tb1)); + let pb2 = unwrap_blocking( + execute( + make_request(&tb2.request.body.input, true, false, None, Some(conv_b.clone())), + Arc::clone(ctx), + ) + .await + .expect("b2"), + ); + assert_eq!(output_text(&pb2), expected_text(tb2)); + let pb3 = unwrap_blocking( + execute( + make_request(&tb3.request.body.input, true, false, None, Some(conv_b.clone())), + Arc::clone(ctx), + ) + .await + .expect("b3"), + ); + assert_eq!(output_text(&pb3), expected_text(tb3)); + + // Assert — conversations are isolated + assert_ne!(conv_a, conv_b, "conversations must not share an id"); +} + +/// Case 9 — 3-turn chain then branch off turn 1 via `previous_response_id`. +#[tokio::test] +async fn test_branch_off_turn_1() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/conv-multi-turn-single-branch-gpt-4o-nonstreaming.yaml")); + let all: Vec<_> = cassette.turns.iter().collect(); + let fixture = TestFixture::new(&all).await; + let ctx = &fixture.exec_ctx; + let resp = responses_turns(&cassette); + let (t1, t2, t3, t4) = (resp[0], resp[1], resp[2], resp[3]); + + let conv_id = create_conversation(ctx).await.expect("create conv").conversation_id; + + // Main chain + let p1 = unwrap_blocking( + execute( + make_request(&t1.request.body.input, true, false, None, Some(conv_id.clone())), + Arc::clone(ctx), + ) + .await + .expect("t1"), + ); + assert_eq!(output_text(&p1), expected_text(t1)); + let r1_id = p1.id.clone(); + + let p2 = unwrap_blocking( + execute( + make_request(&t2.request.body.input, true, false, None, Some(conv_id.clone())), + Arc::clone(ctx), + ) + .await + .expect("t2"), + ); + assert_eq!(output_text(&p2), expected_text(t2)); + + let p3 = unwrap_blocking( + execute( + make_request(&t3.request.body.input, true, false, None, Some(conv_id)), + Arc::clone(ctx), + ) + .await + .expect("t3"), + ); + assert_eq!(output_text(&p3), expected_text(t3)); + + // Branch off turn 1 — only turn 1 context visible + let p4 = unwrap_blocking( + execute( + make_request(&t4.request.body.input, true, false, Some(r1_id), None), + Arc::clone(ctx), + ) + .await + .expect("t4"), + ); + assert_eq!(p4.status, "completed"); + assert_eq!(output_text(&p4), expected_text(t4)); +} + +/// Case 10 — 5-turn chain with 2 inline branches. +#[tokio::test] +async fn test_multi_branch() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/conv-multi-branch-multi-turn-gpt-4o-nonstreaming.yaml")); + let all: Vec<_> = cassette.turns.iter().collect(); + let fixture = TestFixture::new(&all).await; + let ctx = &fixture.exec_ctx; + let resp = responses_turns(&cassette); + let (t1, t2, t3, t4, t5) = (resp[0], resp[1], resp[2], resp[3], resp[4]); + + let conv_id = create_conversation(ctx).await.expect("create conv").conversation_id; + + // Turn 1 + let p1 = unwrap_blocking( + execute( + make_request(&t1.request.body.input, true, false, None, Some(conv_id.clone())), + Arc::clone(ctx), + ) + .await + .expect("t1"), + ); + assert_eq!(output_text(&p1), expected_text(t1)); + let r1_id = p1.id.clone(); + + // Turn 2 (main branch) + let p2 = unwrap_blocking( + execute( + make_request(&t2.request.body.input, true, false, None, Some(conv_id)), + Arc::clone(ctx), + ) + .await + .expect("t2"), + ); + assert_eq!(output_text(&p2), expected_text(t2)); + let r2_id = p2.id.clone(); + + // Branch 1 — off turn 1 + let p3 = unwrap_blocking( + execute( + make_request(&t3.request.body.input, true, false, Some(r1_id), None), + Arc::clone(ctx), + ) + .await + .expect("t3"), + ); + assert_eq!(p3.status, "completed"); + assert_eq!(output_text(&p3), expected_text(t3)); + + let p4 = unwrap_blocking( + execute( + make_request(&t4.request.body.input, true, false, Some(p3.id.clone()), None), + Arc::clone(ctx), + ) + .await + .expect("t4"), + ); + assert_eq!(p4.status, "completed"); + assert_eq!(output_text(&p4), expected_text(t4)); + + // Branch 2 — off turn 2 + let p5 = unwrap_blocking( + execute( + make_request(&t5.request.body.input, true, false, Some(r2_id), None), + Arc::clone(ctx), + ) + .await + .expect("t5"), + ); + assert_eq!(p5.status, "completed"); + assert_eq!(output_text(&p5), expected_text(t5)); +} diff --git a/crates/agentic-core/tests/stateful_responses_integration.rs b/crates/agentic-core/tests/stateful_responses_integration.rs new file mode 100644 index 0000000..75dc545 --- /dev/null +++ b/crates/agentic-core/tests/stateful_responses_integration.rs @@ -0,0 +1,164 @@ +//! Cassette-based integration tests for the Responses API (cases 1–5). +//! +//! Mirrors `test_responses_api.py`. Each test replays a YAML cassette +//! against a mock HTTP server and verifies `execute()` output. + +mod support; + +use agentic_core::executor::execute; +use std::sync::Arc; +use support::{TestFixture, collect_stream, expected_text, load_cassette, make_request, output_text, unwrap_blocking}; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/cassettes/text_only/responses"); + +/// Case 1 — single turn, non-streaming. +#[tokio::test] +async fn test_single_turn_nonstreaming() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/resp-single-gpt-4o-nonstreaming.yaml")); + let t1 = &cassette.turns[0]; + let fixture = TestFixture::new(&[t1]).await; + + // Act + let payload = unwrap_blocking( + execute( + make_request(&t1.request.body.input, t1.request.body.store, false, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("execute"), + ); + + // Assert + assert!(payload.id.starts_with("resp_"), "id={}", payload.id); + assert_eq!(payload.status, "completed"); + assert_eq!(output_text(&payload), expected_text(t1)); +} + +/// Case 2 — single turn, streaming. +#[tokio::test] +async fn test_single_turn_streaming() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/resp-single-gpt-4o-streaming.yaml")); + let t1 = &cassette.turns[0]; + let fixture = TestFixture::new(&[t1]).await; + + // Act + let payload = collect_stream( + execute( + make_request(&t1.request.body.input, t1.request.body.store, true, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("execute"), + ) + .await; + + // Assert + assert!(payload.id.starts_with("resp_"), "id={}", payload.id); + assert_eq!(payload.status, "completed"); + assert_eq!(output_text(&payload), expected_text(t1)); +} + +/// Case 3 — two turns, non-streaming, chained via `previous_response_id`. +#[tokio::test] +async fn test_two_turn_nonstreaming_previous_response_id() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/resp-two-turn-gpt-4o-nonstreaming.yaml")); + let (t1, t2) = (&cassette.turns[0], &cassette.turns[1]); + let fixture = TestFixture::new(&[t1, t2]).await; + + // Act + let p1 = unwrap_blocking( + execute( + make_request(&t1.request.body.input, true, false, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("t1"), + ); + let p2 = unwrap_blocking( + execute( + make_request(&t2.request.body.input, true, false, Some(p1.id.clone()), None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("t2"), + ); + + // Assert + assert!(p1.id.starts_with("resp_")); + assert_eq!(p1.status, "completed"); + assert_eq!(output_text(&p1), expected_text(t1)); + assert_ne!(p2.id, p1.id); + assert_eq!(p2.status, "completed"); + assert_eq!(p2.previous_response_id.as_deref(), Some(p1.id.as_str())); + assert_eq!(output_text(&p2), expected_text(t2)); +} + +/// Case 4 — two turns, streaming, chained via `previous_response_id`. +#[tokio::test] +async fn test_two_turn_streaming_previous_response_id() { + // Arrange + let cassette = load_cassette(&format!("{DIR}/resp-two-turn-gpt-4o-streaming.yaml")); + let (t1, t2) = (&cassette.turns[0], &cassette.turns[1]); + let fixture = TestFixture::new(&[t1, t2]).await; + + // Act + let p1 = collect_stream( + execute( + make_request(&t1.request.body.input, true, true, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("t1"), + ) + .await; + let p2 = collect_stream( + execute( + make_request(&t2.request.body.input, true, true, Some(p1.id.clone()), None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("t2"), + ) + .await; + + // Assert + assert!(p1.id.starts_with("resp_")); + assert_eq!(p1.status, "completed"); + assert_eq!(output_text(&p1), expected_text(t1)); + assert_ne!(p2.id, p1.id); + assert_eq!(p2.status, "completed"); + assert_eq!(output_text(&p2), expected_text(t2)); +} + +/// Case 5 — `store=false` response cannot be used as `previous_response_id`. +#[tokio::test] +async fn test_store_disabled_not_reusable_as_previous_response_id() { + // Arrange — only one mock needed; follow-up errors before hitting the LLM + let cassette = load_cassette(&format!("{DIR}/resp-no-store-gpt-4o-nonstreaming.yaml")); + let t1 = &cassette.turns[0]; + let fixture = TestFixture::new(&[t1]).await; + + // Act — turn 1, store=false + let p1 = unwrap_blocking( + execute( + make_request(&t1.request.body.input, false, false, None, None), + Arc::clone(&fixture.exec_ctx), + ) + .await + .expect("t1"), + ); + assert_eq!(p1.status, "completed"); + + // Act — follow-up with the unstored id + let result = execute( + make_request("follow up", false, false, Some(p1.id.clone()), None), + Arc::clone(&fixture.exec_ctx), + ) + .await; + + // Assert — executor errors at rehydrate, before calling the LLM + assert!(result.is_err(), "expected error for unstored previous_response_id"); +} diff --git a/crates/agentic-core/tests/storage_integration.rs b/crates/agentic-core/tests/storage_integration.rs index d4ac640..e12f154 100644 --- a/crates/agentic-core/tests/storage_integration.rs +++ b/crates/agentic-core/tests/storage_integration.rs @@ -1,21 +1,12 @@ +mod support; + use agentic_core::storage::InOutItem; use agentic_core::storage::ResponseMetadata; -use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; +use agentic_core::storage::{ConversationStore, ResponseStore}; use agentic_core::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; use std::sync::Arc; -async fn setup_pool() -> Arc { - let db_url = format!( - "sqlite://{}", - std::env::temp_dir() - .join(format!("test_{}.db", uuid::Uuid::now_v7())) - .display() - ); - - create_pool_with_schema(Some(&db_url)) - .await - .expect("failed to create pool with schema") -} +use support::setup_pool; fn create_input_item(text: &str) -> InOutItem { InOutItem::Input(InputItem::Message(InputMessage { From 89f8f8c5a4c3ab5b7558bfabcc68a138ba7c89f1 Mon Sep 17 00:00:00 2001 From: maral Date: Wed, 3 Jun 2026 09:48:43 +0000 Subject: [PATCH 11/18] add integration test based on pre-recorded cassets from openai Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: maral --- .../tests/cassettes/record_cassette.py | 649 ++++++++++++++++++ .../cassettes/record_text_only_cassettes.sh | 248 +++++++ .../conv-isolation-gpt-4o-nonstreaming.yaml | 517 ++++++++++++++ ...branch-multi-turn-gpt-4o-nonstreaming.yaml | 408 +++++++++++ ...urn-single-branch-gpt-4o-nonstreaming.yaml | 335 +++++++++ .../conv-two-turn-gpt-4o-nonstreaming.yaml | 179 +++++ .../conv-two-turn-gpt-4o-streaming.yaml | 234 +++++++ .../resp-no-store-gpt-4o-nonstreaming.yaml | 153 +++++ .../resp-single-gpt-4o-nonstreaming.yaml | 77 +++ .../resp-single-gpt-4o-streaming.yaml | 120 ++++ .../resp-two-turn-gpt-4o-nonstreaming.yaml | 154 +++++ .../resp-two-turn-gpt-4o-streaming.yaml | 213 ++++++ crates/agentic-core/tests/support/mod.rs | 353 ++++++++++ 13 files changed, 3640 insertions(+) create mode 100644 crates/agentic-core/tests/cassettes/record_cassette.py create mode 100755 crates/agentic-core/tests/cassettes/record_text_only_cassettes.sh create mode 100644 crates/agentic-core/tests/cassettes/text_only/conversation/conv-isolation-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-branch-multi-turn-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-turn-single-branch-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-streaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/responses/resp-no-store-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-streaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-nonstreaming.yaml create mode 100644 crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-streaming.yaml create mode 100644 crates/agentic-core/tests/support/mod.rs diff --git a/crates/agentic-core/tests/cassettes/record_cassette.py b/crates/agentic-core/tests/cassettes/record_cassette.py new file mode 100644 index 0000000..9b36c68 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/record_cassette.py @@ -0,0 +1,649 @@ +""" +Interactive multi-turn cassette recorder. + +Starts an embedded recording proxy between this script and the upstream API, +then drives multi-turn conversations so every request/response is captured +into a YAML cassette. + +Wiring: + + [this script] → [embedded proxy:] → [OpenAI API | vLLM] + (cassette recorded here) + +Modes: + conv (default) Creates a conversation via POST /v1/conversations, then + passes conversation id on every turn. + isolation Two independent conversations (each with its own conversation id) + recorded into the same cassette. + mixed Creates a conversation; turn 1 uses conversation id, turns 2+ + switch to previous_response_id only (drops conversation id). + responses No conversation created. Chains turns purely via + previous_response_id. Supports --openai and --vllm backends. + +Usage: + python tests/cassettes/record_cassette.py --turns 2 --no-stream --output path/to/cassette.yaml + python tests/cassettes/record_cassette.py --turns 3 --mode isolation --no-stream --output path/to/cassette.yaml + python tests/cassettes/record_cassette.py --turns 3 --mode mixed --no-stream --output path/to/cassette.yaml + python tests/cassettes/record_cassette.py --turns 3 --mode conv --branch-from 1 --branch-turn-number 2 --no-stream --output path/to/cassette.yaml + python tests/cassettes/record_cassette.py --turns 5 --mode conv --branch-from 1 --branch-turn-number 3 --branch-from 2 --branch-turn-number 5 --no-stream --output path/to/cassette.yaml + python tests/cassettes/record_cassette.py --turns 2 --mode responses --vllm http://localhost:8000 --model Qwen/Qwen3-30B-A3B-FP8 --no-stream --output path/to/cassette.yaml +""" + +import json +import logging +import os +import socket +import sys +import threading +import time +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncGenerator + +import click +import httpx +import uvicorn +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse +from httpx import AsyncClient +from yaml import dump as yaml_dump, safe_load as yaml_load + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger("cassette_proxy") + +MODEL = "gpt-4o" +PROXY_HOST = "127.0.0.1" +PROXY_PORT = 7070 +TIMEOUT = 60 * 5 + +EXCLUDED_RESPONSE_HEADERS = { + "content-encoding", + "content-length", + "transfer-encoding", + "connection", +} + +RECORDED_HEADERS = { + "content-type", + "authorization", + "user-agent", + "accept", + "x-run-id", +} + + +def _mask_authorization(value: str) -> str: + if not value: + return value + lower = value.lower() + if lower.startswith("bearer "): + return "Bearer ***" + return "***" + + +def _filter_request_headers(headers) -> dict: + return { + k: v if k.lower() != "authorization" else _mask_authorization(v) + for k, v in headers.items() + if k.lower() in RECORDED_HEADERS + } + + +def _filter_response_headers(headers) -> dict: + return { + k: v for k, v in headers.items() if k.lower() not in EXCLUDED_RESPONSE_HEADERS + } + + +def _turn_number(output_file: Path) -> int: + if not output_file.exists(): + return 1 + content = output_file.read_text(encoding="utf-8") + if not content.strip(): + return 1 + data = yaml_load(content) + if not data or "turns" not in data: + return 1 + return len(data["turns"]) + 1 + + +def _append_turn(output_file: Path, turn: dict[str, Any]) -> None: + output_file.parent.mkdir(parents=True, exist_ok=True) + if output_file.exists() and output_file.stat().st_size > 0: + data = yaml_load(output_file.read_text(encoding="utf-8")) or {} + else: + data = {} + turns: list = data.get("turns", []) + turns.append(turn) + data["turns"] = turns + with open(output_file, "w", encoding="utf-8") as f: + yaml_dump(data, f, allow_unicode=True, default_flow_style=False) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.http_client = AsyncClient(timeout=TIMEOUT) + yield + await app.state.http_client.aclose() + + +proxy_app = FastAPI(lifespan=lifespan) + + +@proxy_app.api_route( + "/{path:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], +) +async def proxy_request(request: Request, path: str) -> Response: + http_client: AsyncClient = request.app.state.http_client + target_host: str = request.app.state.target_host + output_file: Path = request.app.state.output_file + + turn_num = _turn_number(output_file) + filename = f"t{turn_num}" + + target_url = f"{target_host}/{path}" + if str(request.query_params): + target_url += f"?{request.query_params}" + + raw_body = await request.body() + parsed_body = json.loads(raw_body.decode("utf-8")) if raw_body else {} + + turn: dict[str, Any] = { + "filename": filename, + "request": { + "method": request.method, + "path": f"/{path}", + "query_params": dict(request.query_params), + "body": parsed_body, + "headers": _filter_request_headers(request.headers), + }, + "response": {}, + } + + forward_headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} + + if parsed_body.get("stream", False): + + async def _stream() -> AsyncGenerator[str, None]: + async with http_client.stream( + method=request.method, + url=target_url, + headers=forward_headers, + content=raw_body, + timeout=TIMEOUT, + ) as response: + yield response # type: ignore[misc] + if response.status_code != 200: + chunk_str = (await response.aread()).decode() + try: + turn["response"]["body"] = json.loads(chunk_str) + except Exception: + turn["response"]["body"] = chunk_str + yield chunk_str + else: + sse_events: list[str] = [] + try: + async for line in response.aiter_lines(): + chunk = f"{line}\n" + yield chunk + sse_events.append(chunk) + except Exception as e: + turn["response"]["stream_error"] = ( + f"{e.__class__.__name__}: {e}" + ) + finally: + turn["response"]["sse"] = sse_events + turn["response"]["status_code"] = response.status_code + turn["response"]["headers"] = { + "content-type": response.headers.get( + "content-type", "text/event-stream" + ) + } + _append_turn(output_file, turn) + print(f" [recorded turn {turn_num} -> {output_file.name}]") + + agen = _stream() + upstream = await anext(agen) + return StreamingResponse( + agen, + status_code=upstream.status_code, + headers=_filter_response_headers(upstream.headers), + media_type=upstream.headers.get("content-type", "text/event-stream"), + ) + + else: + response = await http_client.request( + method=request.method, + url=target_url, + headers=forward_headers, + content=raw_body, + timeout=TIMEOUT, + ) + media_type = response.headers.get("content-type", "application/json") + body: Any = response.json() if response.status_code == 200 else response.text + if response.status_code != 200 and "application/json" in media_type: + try: + body = json.loads(body) + except Exception: + pass + turn["response"]["body"] = body + turn["response"]["status_code"] = response.status_code + turn["response"]["headers"] = {"content-type": media_type} + _append_turn(output_file, turn) + print(f" [recorded turn {turn_num} -> {output_file.name}]") + return JSONResponse( + content=body, + status_code=response.status_code, + headers=_filter_response_headers(response.headers), + media_type=media_type, + ) + + +# ── proxy lifecycle ─────────────────────────────────────────────────────────── + + +def _start_proxy(output_file: Path, target_host: str, port: int) -> uvicorn.Server: + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_text("", encoding="utf-8") + proxy_app.state.output_file = output_file + proxy_app.state.target_host = target_host + + config = uvicorn.Config(proxy_app, host=PROXY_HOST, port=port, log_level="warning") + server = uvicorn.Server(config) + + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + # TCP-only readiness check — no HTTP request forwarded to upstream + for _ in range(40): + try: + with socket.create_connection((PROXY_HOST, port), timeout=0.3): + break + except OSError: + time.sleep(0.3) + + return server + + +def _stop_proxy(server: uvicorn.Server) -> None: + server.should_exit = True + time.sleep(0.5) + + +def _create_conversation(client: httpx.Client, proxy_url: str) -> str: + resp = client.post(f"{proxy_url}/v1/conversations", json={}, timeout=30) + resp.raise_for_status() + conv_id = resp.json().get("id") + print(f"[conversation created: {conv_id}]") + return conv_id + + +def _send_nonstreaming(client: httpx.Client, body: dict, proxy_url: str) -> str | None: + resp = client.post(f"{proxy_url}/v1/responses", json=body, timeout=300) + resp.raise_for_status() + data = resp.json() + print(f"\n[Response]\n{json.dumps(data, indent=2)}\n") + return data.get("id") + + +def _send_streaming(client: httpx.Client, body: dict, proxy_url: str) -> str | None: + response_id = None + print("\n[Streaming response]") + with client.stream( + "POST", f"{proxy_url}/v1/responses", json=body, timeout=300 + ) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if not line: + continue + print(line) + if line.startswith("data:") and line != "data: [DONE]": + try: + payload = json.loads(line[5:].strip()) + if payload.get("type") == "response.completed": + response_id = payload.get("response", {}).get("id") + except Exception: + pass + print() + return response_id + + +def _send(client: httpx.Client, body: dict, stream: bool, proxy_url: str) -> str | None: + return ( + _send_streaming(client, body, proxy_url) + if stream + else _send_nonstreaming(client, body, proxy_url) + ) + + +def _prompt(label: str) -> str: + try: + return input(label).strip() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + + +def run_conv( + client: httpx.Client, + turns: int, + model: str, + stream: bool, + store: bool, + branches: list[tuple[int, int | None]], + proxy_url: str, +) -> None: + conv_id = _create_conversation(client, proxy_url) + response_ids: dict[int, str] = {} + # map: branch_turn_number -> branch_from (which turn's response to use as previous) + branch_map: dict[int, int] = {} + extra_branches: list[int] = [] # branch_from values with no branch_turn_number + for branch_from, branch_turn_number in branches: + if branch_turn_number is not None: + branch_map[branch_turn_number] = branch_from + else: + extra_branches.append(branch_from) + + previous_response_id: str | None = None + for turn in range(1, turns + 1): + if turn in branch_map: + branch_from = branch_map[turn] + if branch_from not in response_ids: + raise click.UsageError( + f"--branch-from {branch_from} at turn {turn} has no recorded response " + f"(available: {sorted(response_ids)})" + ) + previous_response_id = response_ids[branch_from] + click.echo( + f"\n[Branch] turn {turn} chains from turn {branch_from} (response_id={previous_response_id})" + ) + prompt = _prompt(f"Turn {turn}/{turns} — enter prompt: ") + body: dict = {"model": model, "input": prompt, "stream": stream, "store": store} + if previous_response_id: + body["previous_response_id"] = previous_response_id + else: + body["conversation"] = conv_id + response_id = _send(client, body, stream, proxy_url) + if response_id: + response_ids[turn] = response_id + previous_response_id = response_id + + # branches without a branch_turn_number get one extra turn each + for b_idx, branch_from in enumerate(extra_branches, start=1): + if branch_from not in response_ids: + raise click.UsageError( + f"Extra branch {b_idx}: --branch-from {branch_from} has no recorded response " + f"(available: {sorted(response_ids)})" + ) + branch_resp_id = response_ids[branch_from] + click.echo( + f"\n[Extra branch {b_idx}] from turn {branch_from} (response_id={branch_resp_id}), turn {turns + 1}" + ) + prompt = _prompt( + f"Turn {turns + 1} (extra branch from turn {branch_from}) — enter prompt: " + ) + body = { + "model": model, + "input": prompt, + "stream": stream, + "store": store, + "previous_response_id": branch_resp_id, + "conversation": conv_id, + } + _send(client, body, stream, proxy_url) + + +def run_isolation( + client: httpx.Client, + turns: int, + model: str, + stream: bool, + store: bool, + proxy_url: str, +) -> None: + for conv_label in ("A", "B"): + click.echo(f"\n--- Conversation {conv_label} ({turns} turns) ---") + conv_id = _create_conversation(client, proxy_url) + for turn in range(1, turns + 1): + prompt = _prompt( + f"Conv {conv_label} | Turn {turn}/{turns} — enter prompt: " + ) + body: dict = { + "model": model, + "input": prompt, + "stream": stream, + "store": store, + "conversation": conv_id, + } + _send(client, body, stream, proxy_url) + + +def run_mixed( + client: httpx.Client, + turns: int, + model: str, + stream: bool, + store: bool, + proxy_url: str, +) -> None: + conv_id = _create_conversation(client, proxy_url) + previous_response_id: str | None = None + + for turn in range(1, turns + 1): + prompt = _prompt(f"Turn {turn}/{turns} — enter prompt: ") + body: dict = {"model": model, "input": prompt, "stream": stream, "store": store} + if previous_response_id: + body["previous_response_id"] = previous_response_id + else: + body["conversation"] = conv_id + previous_response_id = _send(client, body, stream, proxy_url) + + +def run_responses( + client: httpx.Client, + turns: int, + model: str, + stream: bool, + store: bool, + branches: list[tuple[int, int | None]], + proxy_url: str, +) -> None: + response_ids: dict[int, str] = {} + branch_map: dict[int, int] = {} + extra_branches: list[int] = [] + for branch_from, branch_turn_number in branches: + if branch_turn_number is not None: + branch_map[branch_turn_number] = branch_from + else: + extra_branches.append(branch_from) + + previous_response_id: str | None = None + for turn in range(1, turns + 1): + if turn in branch_map: + branch_from = branch_map[turn] + if branch_from not in response_ids: + raise click.UsageError( + f"--branch-from {branch_from} at turn {turn} has no recorded response " + f"(available: {sorted(response_ids)})" + ) + previous_response_id = response_ids[branch_from] + click.echo( + f"\n[Branch] turn {turn} chains from turn {branch_from} (response_id={previous_response_id})" + ) + prompt = _prompt(f"Turn {turn}/{turns} — enter prompt: ") + body: dict = {"model": model, "input": prompt, "stream": stream, "store": store} + if previous_response_id and store: + body["previous_response_id"] = previous_response_id + response_id = _send(client, body, stream, proxy_url) + previous_response_id = response_id if store else None + if response_id: + response_ids[turn] = response_id + + for b_idx, branch_from in enumerate(extra_branches, start=1): + if branch_from not in response_ids: + raise click.UsageError( + f"Extra branch {b_idx}: --branch-from {branch_from} has no recorded response " + f"(available: {sorted(response_ids)})" + ) + branch_resp_id = response_ids[branch_from] + click.echo( + f"\n[Extra branch {b_idx}] from turn {branch_from} (response_id={branch_resp_id}), turn {turns + 1}" + ) + prompt = _prompt( + f"Turn {turns + 1} (extra branch from turn {branch_from}) — enter prompt: " + ) + body = { + "model": model, + "input": prompt, + "stream": stream, + "store": store, + "previous_response_id": branch_resp_id, + } + _send(client, body, stream, proxy_url) + + +# ── main ────────────────────────────────────────────────────────────────────── + + +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "--turns", "-n", required=True, type=int, help="Number of turns to record." +) +@click.option( + "--output", + "-o", + required=True, + type=click.Path(), + help="Output cassette YAML path.", +) +@click.option( + "--mode", + type=click.Choice(["conv", "isolation", "mixed", "responses"]), + default="conv", + show_default=True, + help="Recording mode.", +) +@click.option( + "--branch-from", + type=int, + multiple=True, + metavar="TURN", + help="Rewind to this turn's response (repeatable, one per branch).", +) +@click.option( + "--branch-turn-number", + type=int, + multiple=True, + metavar="TURN", + help="First turn number for the corresponding branch (repeatable, pairs with --branch-from).", +) +@click.option( + "--stream/--no-stream", + default=True, + show_default=True, + help="Use streaming responses.", +) +@click.option( + "--model", default=MODEL, show_default=True, help="Model name to pass in requests." +) +@click.option( + "--no-store", is_flag=True, default=False, help="Set store=false in requests." +) +@click.option( + "--proxy-port", + type=int, + default=PROXY_PORT, + show_default=True, + help="Local port for the embedded recording proxy.", +) +@click.option( + "--openai", + "openai_url", + metavar="URL", + default=None, + help="OpenAI upstream URL (default https://api.openai.com). Reads OPENAI_API_KEY.", +) +@click.option( + "--vllm", + "vllm_url", + metavar="URL", + default=None, + help="vLLM upstream URL, e.g. http://localhost:8000 (responses mode only, no auth).", +) +def main( + turns: int, + output: str, + mode: str, + branch_from: tuple[int, ...], + branch_turn_number: tuple[int, ...], + stream: bool, + model: str, + no_store: bool, + proxy_port: int, + openai_url: str | None, + vllm_url: str | None, +) -> None: + """Interactive multi-turn cassette recorder (proxy embedded).""" + if branch_turn_number and not branch_from: + raise click.UsageError("--branch-turn-number requires --branch-from.") + if len(branch_turn_number) > len(branch_from): + raise click.UsageError( + "More --branch-turn-number values than --branch-from values." + ) + # Pair each branch-from with its branch-turn-number (None if not provided) + branches: list[tuple[int, int | None]] = [ + (bf, branch_turn_number[i] if i < len(branch_turn_number) else None) + for i, bf in enumerate(branch_from) + ] + if vllm_url and openai_url: + raise click.UsageError("--openai and --vllm are mutually exclusive.") + if vllm_url and mode != "responses": + raise click.UsageError( + f"--vllm is only supported with --mode responses (got --mode {mode})." + ) + + if vllm_url: + target = vllm_url.rstrip("/") + headers: dict = {} + backend_label = f"vLLM: {target}" + else: + target = (openai_url or "https://api.openai.com").rstrip("/") + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + raise click.ClickException( + "OPENAI_API_KEY environment variable is not set." + ) + headers = {"Authorization": f"Bearer {api_key}"} + backend_label = f"OpenAI: {target}" + + output_file = Path(output).resolve() + proxy_url = f"http://{PROXY_HOST}:{proxy_port}" + store = not no_store + + click.echo(f"Mode: {mode} | Turns: {turns} | Stream: {stream} | Model: {model}") + click.echo(f"Output: {output_file}") + click.echo(backend_label) + click.echo(f"Proxy: {proxy_url} (requests go through here for recording)") + + server = _start_proxy(output_file, target, proxy_port) + click.echo(f"Proxy ready on {proxy_url}\n") + + try: + with httpx.Client(headers=headers) as client: + if mode == "conv": + run_conv(client, turns, model, stream, store, branches, proxy_url) + elif mode == "isolation": + run_isolation(client, turns, model, stream, store, proxy_url) + elif mode == "mixed": + run_mixed(client, turns, model, stream, store, proxy_url) + elif mode == "responses": + run_responses(client, turns, model, stream, store, branches, proxy_url) + finally: + _stop_proxy(server) + + click.echo(f"\nAll turns recorded -> {output_file}") + + +if __name__ == "__main__": + main() diff --git a/crates/agentic-core/tests/cassettes/record_text_only_cassettes.sh b/crates/agentic-core/tests/cassettes/record_text_only_cassettes.sh new file mode 100755 index 0000000..e7a7975 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/record_text_only_cassettes.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# record_text_only_cassettes.sh +# +# Records all cassettes (responses + conversation) in sequence. +# The proxy is embedded inside record_cassette.py — no separate proxy needed. +# +# Prerequisites: +# - OPENAI_API_KEY must be set in the environment +# +# Usage: +# bash tests/cassettes/record_text_only_cassettes.sh +# MODEL=gpt-4.1-mini bash tests/cassettes/record_text_only_cassettes.sh + +set -euo pipefail + +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE_DIR="$SCRIPTS_DIR/text_only" +RESPONSES_DIR="$BASE_DIR/responses" +CONV_DIR="$BASE_DIR/conversation" +MODEL="${MODEL:-gpt-4o}" +MODEL_SLUG="$(echo "$MODEL" | tr '/: ' '---')" + +green() { printf '\033[32m%s\033[0m\n' "$*"; } +bold() { printf '\033[1m%s\033[0m\n' "$*"; } + +next_test() { + echo + read -rp "Press ENTER when ready for the next test..." + echo +} + +mkdir -p "$RESPONSES_DIR" "$CONV_DIR" + +# ══════════════════════════════════════════════════════════════════ +# RESPONSES (previous_response_id chaining, no conversation object) +# ══════════════════════════════════════════════════════════════════ + +# ── Test 1: single-turn non-streaming ──────────────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 1 of 9 — resp-single-nonstreaming" +bold " 1 turn, non-streaming" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Reply with exactly one word: HELLO" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 1 \ + --no-stream \ + --model "$MODEL" \ + --output "$RESPONSES_DIR/resp-single-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 1 done." +next_test + +# ── Test 2: single-turn streaming ──────────────────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 2 of 9 — resp-single-streaming" +bold " 1 turn, streaming" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Reply with exactly one word: WORLD" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 1 \ + --model "$MODEL" \ + --output "$RESPONSES_DIR/resp-single-${MODEL_SLUG}-streaming.yaml" +green "✓ Test 2 done." +next_test + +# ── Test 3: two-turn non-streaming ─────────────────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 3 of 9 — resp-two-turn-nonstreaming" +bold " 2 turns, non-streaming, previous_response_id chaining" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Remember the word APPLE. Just say: OK" +echo " Turn 2: What word did I ask you to remember?" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 2 \ + --no-stream \ + --model "$MODEL" \ + --output "$RESPONSES_DIR/resp-two-turn-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 3 done." +next_test + +# ── Test 4: two-turn streaming ──────────────────────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 4 of 9 — resp-two-turn-streaming" +bold " 2 turns, streaming, previous_response_id chaining" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Remember the word BANANA. Just say: OK" +echo " Turn 2: What word did I ask you to remember?" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 2 \ + --model "$MODEL" \ + --output "$RESPONSES_DIR/resp-two-turn-${MODEL_SLUG}-streaming.yaml" +green "✓ Test 4 done." +next_test + +# ── Test 5: store=false — follow-up should fail ─────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 5 of 9 — resp-no-store-nonstreaming" +bold " Turn 1: store=false | Turn 2: previous_response_id → expect error" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Say: NOT STORED" +echo " Turn 2: follow up" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode responses \ + --turns 2 \ + --no-stream \ + --no-store \ + --model "$MODEL" \ + --output "$RESPONSES_DIR/resp-no-store-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 5 done." +next_test + +# ══════════════════════════════════════════════════════════════════ +# CONVERSATION (POST /v1/conversations + conversation id chaining) +# ══════════════════════════════════════════════════════════════════ + +# ── Test 6: 2-turn, non-streaming, conversation ─────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 6 of 9 — conv-two-turn-nonstreaming" +bold " 2 turns, non-streaming, conversation created + chained" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Remember the word CHERRY. Just say: OK" +echo " Turn 2: What word did I ask you to remember?" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode conv \ + --turns 2 \ + --no-stream \ + --model "$MODEL" \ + --output "$CONV_DIR/conv-two-turn-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 6 done." +next_test + +# ── Test 7: 2-turn, streaming, conversation ─────────────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 7 of 9 — conv-two-turn-streaming" +bold " 2 turns, streaming, conversation created + chained" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: Remember the word MANGO. Just say: OK" +echo " Turn 2: What word did I ask you to remember?" +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode conv \ + --turns 2 \ + --model "$MODEL" \ + --output "$CONV_DIR/conv-two-turn-${MODEL_SLUG}-streaming.yaml" +green "✓ Test 7 done." +next_test + +# ── Test 8: isolation — 2 independent conversations ────────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 8 of 9 — conv-isolation-nonstreaming" +bold " 2 independent conversations (3 turns each), non-streaming" +bold " Verifies conversations do not share context" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Conv A | Turn 1: Remember the word ORANGE. Say: OK" +echo " Conv A | Turn 2: Also remember the word VIOLET. Say: OK" +echo " Conv A | Turn 3: List every word I asked you to remember, in order, one per line." +echo " Conv B | Turn 1: Remember the word PURPLE. Say: OK" +echo " Conv B | Turn 2: Also remember the word INDIGO. Say: OK" +echo " Conv B | Turn 3: List every word I asked you to remember, in order, one per line." +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode isolation \ + --turns 3 \ + --no-stream \ + --model "$MODEL" \ + --output "$CONV_DIR/conv-isolation-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 8 done." +next_test + +── Test 9: branch off turn 1 after 3-turn conversation ────────── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 9 of 9 — conv-branch-nonstreaming (6D)" +bold " Turns 1-3: conversation chain | Turn 4: branch off turn 1" +bold " Math: 2+2=4, +1=5, +2=7 | branch: +1 from turn-1 = 5" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1: What is 2+2? Reply with just the number." +echo " Turn 2: Add 1 to your previous answer. Reply with just the number." +echo " Turn 3: Add 2 to your previous answer. Reply with just the number." +echo " Branch (off turn 1): Add 1 to your previous answer. Reply with just the number." +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode conv \ + --turns 3 \ + --branch-from 1 \ + --no-stream \ + --model "$MODEL" \ + --output "$CONV_DIR/conv-multi-turn-single-branch-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 9 done." +next_test + +# ── Test 10: 5-turn math, branch at turn 1, continue from turn 3 ── + +bold "═══════════════════════════════════════════════════════════════" +bold "Test 10 of 10 — conv-branch-turn-number-nonstreaming" +bold " Turns 1-5: conversation chain | 2 branches" +bold " Turn1=4, Turn2(from1)=6 | Branch1 turn3(from1)=5, turn4(from3)=8" +bold " Branch2 turn5(from2)=10" +bold "═══════════════════════════════════════════════════════════════" +bold "Prompts to enter:" +echo " Turn 1 (answer=4): What is 2+2? Reply with just the number." +echo " Turn 2 (from turn 1, answer=4+2): Add 2 to your previous answer. Reply with just the number." +echo " Branch 1 | turn 3 (from turn 1, answer=4+1): Add 1. Reply with just the number." +echo " Branch 1 | turn 4 (from turn 3, answer=5+3): Add 3 to your previous answer. Reply with just the number." +echo " Branch 2 | turn 5 (from turn 2, answer=6+4): Add 4. Reply with just the number." +echo +python "$SCRIPTS_DIR/record_cassette.py" \ + --mode conv \ + --turns 5 \ + --branch-from 1 \ + --branch-turn-number 3 \ + --branch-from 2 \ + --branch-turn-number 5 \ + --no-stream \ + --model "$MODEL" \ + --output "$CONV_DIR/conv-multi-branch-multi-turn-${MODEL_SLUG}-nonstreaming.yaml" +green "✓ Test 10 done." + +echo +green "════════════════════════════════════════════════════════════════" +green "All 10 cassettes recorded." +green "════════════════════════════════════════════════════════════════" diff --git a/crates/agentic-core/tests/cassettes/text_only/conversation/conv-isolation-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-isolation-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..f95b199 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-isolation-gpt-4o-nonstreaming.yaml @@ -0,0 +1,517 @@ +turns: +- filename: t1 + request: + body: {} + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/conversations + query_params: {} + response: + body: + created_at: 1776764559 + id: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + metadata: {} + object: conversation + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + conversation: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + input: 'Remember the word ORANGE. Say: OK' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764565 + conversation: + id: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + created_at: 1776764564 + error: null + frequency_penalty: 0.0 + id: resp_091801b651b1d6870069e74694cc1c8195b1e9477abfb4dcaf + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: OK + type: output_text + id: msg_091801b651b1d6870069e746954b3c8195b7bafba493c40e4b + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 16 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 18 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + conversation: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + input: 'Also remember the word VIOLET. Say: OK' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764579 + conversation: + id: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + created_at: 1776764579 + error: null + frequency_penalty: 0.0 + id: resp_091801b651b1d6870069e746a371308195a854e5fa5d3e845f + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: OK + type: output_text + id: msg_091801b651b1d6870069e746a3edf081958e8626b6fb3abbce + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 36 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 38 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t4 + request: + body: + conversation: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + input: List every word I asked you to remember, in order, one per line + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764588 + conversation: + id: conv_69e7468fe7108195abd87e93f47a6f8f091801b651b1d687 + created_at: 1776764587 + error: null + frequency_penalty: 0.0 + id: resp_091801b651b1d6870069e746abb2888195a22209a564c249cc + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: "ORANGE \nVIOLET" + type: output_text + id: msg_091801b651b1d6870069e746ac16e081958a232d74c3e3e6e7 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 60 + input_tokens_details: + cached_tokens: 0 + output_tokens: 7 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 67 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t5 + request: + body: {} + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/conversations + query_params: {} + response: + body: + created_at: 1776764588 + id: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + metadata: {} + object: conversation + headers: + content-type: application/json + status_code: 200 +- filename: t6 + request: + body: + conversation: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + input: 'Remember the word PURPLE. Say: OK' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764597 + conversation: + id: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + created_at: 1776764596 + error: null + frequency_penalty: 0.0 + id: resp_0745b162f9c4b9d30069e746b4988481908ebf431c400142dc + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: OK + type: output_text + id: msg_0745b162f9c4b9d30069e746b51358819096cfbadec79c558e + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 16 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 18 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t7 + request: + body: + conversation: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + input: 'Also remember the word INDIGO. Say: OK' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764603 + conversation: + id: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + created_at: 1776764602 + error: null + frequency_penalty: 0.0 + id: resp_0745b162f9c4b9d30069e746bad954819098ef762241bf18ae + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: OK + type: output_text + id: msg_0745b162f9c4b9d30069e746bbcba88190802fd278cf6f9f12 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 35 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 37 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t8 + request: + body: + conversation: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + input: List every word I asked you to remember, in order, one per line. + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764610 + conversation: + id: conv_69e746ac94608190b94008b3021a3c6e0745b162f9c4b9d3 + created_at: 1776764609 + error: null + frequency_penalty: 0.0 + id: resp_0745b162f9c4b9d30069e746c16ad88190862312ba7d6a2ba1 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '- PURPLE + + - INDIGO' + type: output_text + id: msg_0745b162f9c4b9d30069e746c22d9481909480ef82bf8f4696 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 60 + input_tokens_details: + cached_tokens: 0 + output_tokens: 8 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 68 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-branch-multi-turn-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-branch-multi-turn-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..c6803c1 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-branch-multi-turn-gpt-4o-nonstreaming.yaml @@ -0,0 +1,408 @@ +turns: +- filename: t1 + request: + body: {} + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/conversations + query_params: {} + response: + body: + created_at: 1776767439 + id: conv_69e751cf3ed08194977fe0915859ea4e01ffa34fbd4eb387 + metadata: {} + object: conversation + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + conversation: conv_69e751cf3ed08194977fe0915859ea4e01ffa34fbd4eb387 + input: What is 2+2? Reply with just the number. + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776767449 + conversation: + id: conv_69e751cf3ed08194977fe0915859ea4e01ffa34fbd4eb387 + created_at: 1776767448 + error: null + frequency_penalty: 0.0 + id: resp_01ffa34fbd4eb3870069e751d87aa8819481fce10ac3adc2cd + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '4' + type: output_text + id: msg_01ffa34fbd4eb3870069e751d92f788194b527e18365b03b21 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 20 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 22 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + input: Add 2 to your previous answer. Reply with just the number + model: gpt-4o + previous_response_id: resp_01ffa34fbd4eb3870069e751d87aa8819481fce10ac3adc2cd + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776767468 + created_at: 1776767468 + error: null + frequency_penalty: 0.0 + id: resp_01ffa34fbd4eb3870069e751ec4f4081949a66597d8284db9a + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '6' + type: output_text + id: msg_01ffa34fbd4eb3870069e751ecd60c819491e735a9891c670f + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_01ffa34fbd4eb3870069e751d87aa8819481fce10ac3adc2cd + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 42 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 44 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t4 + request: + body: + input: Add 1. Reply with just the number. + model: gpt-4o + previous_response_id: resp_01ffa34fbd4eb3870069e751d87aa8819481fce10ac3adc2cd + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776767479 + created_at: 1776767479 + error: null + frequency_penalty: 0.0 + id: resp_01ffa34fbd4eb3870069e751f6e704819480f3d723db8296b5 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '5' + type: output_text + id: msg_01ffa34fbd4eb3870069e751f7a0048194b8e3aed03d337fba + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_01ffa34fbd4eb3870069e751d87aa8819481fce10ac3adc2cd + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 39 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 41 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t5 + request: + body: + input: Add 3 to your previous answer. Reply with just the number. + model: gpt-4o + previous_response_id: resp_01ffa34fbd4eb3870069e751f6e704819480f3d723db8296b5 + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776767487 + created_at: 1776767487 + error: null + frequency_penalty: 0.0 + id: resp_01ffa34fbd4eb3870069e751ff13748194a87830bb94240994 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '8' + type: output_text + id: msg_01ffa34fbd4eb3870069e751ff6eec8194ba43c7420d0f848b + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_01ffa34fbd4eb3870069e751f6e704819480f3d723db8296b5 + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 62 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 64 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t6 + request: + body: + input: Add 4. Reply with just the number. + model: gpt-4o + previous_response_id: resp_01ffa34fbd4eb3870069e751ec4f4081949a66597d8284db9a + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776767496 + created_at: 1776767496 + error: null + frequency_penalty: 0.0 + id: resp_01ffa34fbd4eb3870069e752080c5c819491b1fd50d74691ee + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '10' + type: output_text + id: msg_01ffa34fbd4eb3870069e75208a0a08194846c6917d99ec2d7 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_01ffa34fbd4eb3870069e751ec4f4081949a66597d8284db9a + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 61 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 63 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-turn-single-branch-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-turn-single-branch-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..16c808d --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-multi-turn-single-branch-gpt-4o-nonstreaming.yaml @@ -0,0 +1,335 @@ +turns: +- filename: t1 + request: + body: {} + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/conversations + query_params: {} + response: + body: + created_at: 1776766429 + id: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + metadata: {} + object: conversation + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + conversation: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + input: What is 2+2? Reply with just the number. + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776766445 + conversation: + id: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + created_at: 1776766442 + error: null + frequency_penalty: 0.0 + id: resp_0056d4efc351f68b0069e74de9f3a8819787d6ac39edb946bd + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '4' + type: output_text + id: msg_0056d4efc351f68b0069e74dede3188197ba46fa018e252c72 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 20 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 22 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + conversation: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + input: Add 1 to your previous answer. Reply with just the number. + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776766448 + conversation: + id: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + created_at: 1776766448 + error: null + frequency_penalty: 0.0 + id: resp_0056d4efc351f68b0069e74df009908197ab5b3a32f9c572a3 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '5' + type: output_text + id: msg_0056d4efc351f68b0069e74df0bc488197a0f09f137edd161c + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 43 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 45 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t4 + request: + body: + conversation: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + input: Add 2 to your previous answer. Reply with just the number + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776766455 + conversation: + id: conv_69e74ddd13c4819781cfed6799e7b1fc0056d4efc351f68b + created_at: 1776766455 + error: null + frequency_penalty: 0.0 + id: resp_0056d4efc351f68b0069e74df724bc8197bf5e923fe663113f + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '7' + type: output_text + id: msg_0056d4efc351f68b0069e74df7b5d88197943f69ef335e04c4 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 65 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 67 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t5 + request: + body: + input: Add 1 to your previous answer. Reply with just the number. + model: gpt-4o + previous_response_id: resp_0056d4efc351f68b0069e74de9f3a8819787d6ac39edb946bd + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776766466 + created_at: 1776766466 + error: null + frequency_penalty: 0.0 + id: resp_0056d4efc351f68b0069e74e01f52c8197bb2dbef0c58938e7 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: '5' + type: output_text + id: msg_0056d4efc351f68b0069e74e0271648197a686afe3b92c3cb2 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_0056d4efc351f68b0069e74de9f3a8819787d6ac39edb946bd + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 43 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 45 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..f5dc41c --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-nonstreaming.yaml @@ -0,0 +1,179 @@ +turns: +- filename: t1 + request: + body: {} + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/conversations + query_params: {} + response: + body: + created_at: 1776764510 + id: conv_69e7465e16f48195af23a8c7f0301bea05530c27e06f63f2 + metadata: {} + object: conversation + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + conversation: conv_69e7465e16f48195af23a8c7f0301bea05530c27e06f63f2 + input: 'Remember the word CHERRY. Just say: OK' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764521 + conversation: + id: conv_69e7465e16f48195af23a8c7f0301bea05530c27e06f63f2 + created_at: 1776764520 + error: null + frequency_penalty: 0.0 + id: resp_05530c27e06f63f20069e74668c1d88195b066d73edea6fdec + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: OK + type: output_text + id: msg_05530c27e06f63f20069e7466950f88195a158c1964323765e + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 17 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 19 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t3 + request: + body: + conversation: conv_69e7465e16f48195af23a8c7f0301bea05530c27e06f63f2 + input: What word did I ask you to remember? + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764527 + conversation: + id: conv_69e7465e16f48195af23a8c7f0301bea05530c27e06f63f2 + created_at: 1776764527 + error: null + frequency_penalty: 0.0 + id: resp_05530c27e06f63f20069e7466f296081958b2ebfa3bb4c69b4 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: CHERRY + type: output_text + id: msg_05530c27e06f63f20069e7466fb0088195b0361daeaac300a4 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 35 + input_tokens_details: + cached_tokens: 0 + output_tokens: 3 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 38 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-streaming.yaml b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-streaming.yaml new file mode 100644 index 0000000..b6b8734 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/conversation/conv-two-turn-gpt-4o-streaming.yaml @@ -0,0 +1,234 @@ +turns: +- filename: t1 + request: + body: {} + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/conversations + query_params: {} + response: + body: + created_at: 1776764537 + id: conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a + metadata: {} + object: conversation + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + conversation: conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a + input: 'Remember the word MANGO. Just say: OK' + model: gpt-4o + store: true + stream: true + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0aeb5a0588bb8b2a0069e7467c813c8190a46d9810f8dc0355","object":"response","created_at":1776764540,"status":"in_progress","background":false,"completed_at":null,"conversation":{"id":"conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a"},"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0aeb5a0588bb8b2a0069e7467c813c8190a46d9810f8dc0355","object":"response","created_at":1776764540,"status":"in_progress","background":false,"completed_at":null,"conversation":{"id":"conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a"},"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"OK","item_id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","logprobs":[],"obfuscation":"LAhO6jFUD08oa2","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","logprobs":[],"output_index":0,"sequence_number":5,"text":"OK"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"OK"},"sequence_number":6} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"OK"}],"role":"assistant"},"output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0aeb5a0588bb8b2a0069e7467c813c8190a46d9810f8dc0355","object":"response","created_at":1776764540,"status":"completed","background":false,"completed_at":1776764541,"conversation":{"id":"conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a"},"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"msg_0aeb5a0588bb8b2a0069e7467d19088190abae62ec47b1f0b1","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"OK"}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":17,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":19},"user":null,"metadata":{}},"sequence_number":8} + + ' + - ' + + ' + status_code: 200 +- filename: t3 + request: + body: + conversation: conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a + input: What word did I ask you to remember? + model: gpt-4o + store: true + stream: true + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0aeb5a0588bb8b2a0069e74683f0388190b99690bad6b9b489","object":"response","created_at":1776764548,"status":"in_progress","background":false,"completed_at":null,"conversation":{"id":"conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a"},"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0aeb5a0588bb8b2a0069e74683f0388190b99690bad6b9b489","object":"response","created_at":1776764548,"status":"in_progress","background":false,"completed_at":null,"conversation":{"id":"conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a"},"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"M","item_id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","logprobs":[],"obfuscation":"uG6UWN3uEmZChjC","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"ANGO","item_id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","logprobs":[],"obfuscation":"v0kxXmx9ogqa","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","logprobs":[],"output_index":0,"sequence_number":6,"text":"MANGO"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"MANGO"},"sequence_number":7} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"MANGO"}],"role":"assistant"},"output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0aeb5a0588bb8b2a0069e74683f0388190b99690bad6b9b489","object":"response","created_at":1776764548,"status":"completed","background":false,"completed_at":1776764548,"conversation":{"id":"conv_69e746796f7481909635d37860d6cd1f0aeb5a0588bb8b2a"},"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"msg_0aeb5a0588bb8b2a0069e7468455c48190acd151ab62b504e1","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"MANGO"}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":35,"input_tokens_details":{"cached_tokens":0},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":38},"user":null,"metadata":{}},"sequence_number":9} + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/responses/resp-no-store-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/responses/resp-no-store-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..015207f --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/responses/resp-no-store-gpt-4o-nonstreaming.yaml @@ -0,0 +1,153 @@ +turns: +- filename: t1 + request: + body: + input: 'Say: NOT STORED' + model: gpt-4o + store: false + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764465 + created_at: 1776764465 + error: null + frequency_penalty: 0.0 + id: resp_0a47fc2a915dece50169e74631001881968085f5b231c7abe0 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: NOT STORED + type: output_text + id: msg_0a47fc2a915dece50169e7463180b48196bbaaae87363326c0 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: false + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 12 + input_tokens_details: + cached_tokens: 0 + output_tokens: 4 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 16 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: follow up + model: gpt-4o + store: false + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764472 + created_at: 1776764471 + error: null + frequency_penalty: 0.0 + id: resp_0ad5b1478c81aa5f0169e746378464819587a13f241d2fc344 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: Of course! What would you like to follow up on? + type: output_text + id: msg_0ad5b1478c81aa5f0169e74637e7388195a1494b0590f8a49e + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: false + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 9 + input_tokens_details: + cached_tokens: 0 + output_tokens: 13 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 22 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..cd82940 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-nonstreaming.yaml @@ -0,0 +1,77 @@ +turns: +- filename: t1 + request: + body: + input: 'Reply with exactly one word: HELLO' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764143 + created_at: 1776764142 + error: null + frequency_penalty: 0.0 + id: resp_0508721937e20de90069e744ee9018819394c8e011dc6d7818 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: HI + type: output_text + id: msg_0508721937e20de90069e744ef1b2881939596753c5951691e + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 15 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 17 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-streaming.yaml b/crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-streaming.yaml new file mode 100644 index 0000000..659197b --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/responses/resp-single-gpt-4o-streaming.yaml @@ -0,0 +1,120 @@ +turns: +- filename: t1 + request: + body: + input: 'Reply with exactly one word: WORLD' + model: gpt-4o + store: true + stream: true + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0d119a97c73fc7550069e7450154448193b65975af8dfa2d59","object":"response","created_at":1776764161,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0d119a97c73fc7550069e7450154448193b65975af8dfa2d59","object":"response","created_at":1776764161,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"G","item_id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","logprobs":[],"obfuscation":"NpACml1t70MBPur","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"LO","item_id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","logprobs":[],"obfuscation":"sYltOblE7Hn8l8","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"BE","item_id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","logprobs":[],"obfuscation":"4BnRKbDQPKERxH","output_index":0,"sequence_number":6} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","logprobs":[],"output_index":0,"sequence_number":7,"text":"GLOBE"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"GLOBE"},"sequence_number":8} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"GLOBE"}],"role":"assistant"},"output_index":0,"sequence_number":9} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0d119a97c73fc7550069e7450154448193b65975af8dfa2d59","object":"response","created_at":1776764161,"status":"completed","background":false,"completed_at":1776764161,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"msg_0d119a97c73fc7550069e74501f204819389cc6b2751cea611","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"GLOBE"}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":14,"input_tokens_details":{"cached_tokens":0},"output_tokens":4,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":18},"user":null,"metadata":{}},"sequence_number":10} + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-nonstreaming.yaml b/crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-nonstreaming.yaml new file mode 100644 index 0000000..42e4300 --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-nonstreaming.yaml @@ -0,0 +1,154 @@ +turns: +- filename: t1 + request: + body: + input: 'Remember the word APPLE. Just say: OK' + model: gpt-4o + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764188 + created_at: 1776764187 + error: null + frequency_penalty: 0.0 + id: resp_0db0cfecd1a4eaa10069e7451beccc8195a9e7e09d9343aad0 + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: OK + type: output_text + id: msg_0db0cfecd1a4eaa10069e7451c5dac8195a040d4c3725fe3e7 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 17 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 19 + user: null + headers: + content-type: application/json + status_code: 200 +- filename: t2 + request: + body: + input: What word did I ask you to remember? + model: gpt-4o + previous_response_id: resp_0db0cfecd1a4eaa10069e7451beccc8195a9e7e09d9343aad0 + store: true + stream: false + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + body: + background: false + billing: + payer: developer + completed_at: 1776764196 + created_at: 1776764195 + error: null + frequency_penalty: 0.0 + id: resp_0db0cfecd1a4eaa10069e74523ef348195adc49fff1d49863a + incomplete_details: null + instructions: null + max_output_tokens: null + max_tool_calls: null + metadata: {} + model: gpt-4o-2024-08-06 + object: response + output: + - content: + - annotations: [] + logprobs: [] + text: APPLE + type: output_text + id: msg_0db0cfecd1a4eaa10069e7452480608195b1c2d83f819edb60 + role: assistant + status: completed + type: message + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: resp_0db0cfecd1a4eaa10069e7451beccc8195a9e7e09d9343aad0 + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + effort: null + summary: null + safety_identifier: null + service_tier: default + status: completed + store: true + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 35 + input_tokens_details: + cached_tokens: 0 + output_tokens: 2 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 37 + user: null + headers: + content-type: application/json + status_code: 200 diff --git a/crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-streaming.yaml b/crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-streaming.yaml new file mode 100644 index 0000000..d0a08cf --- /dev/null +++ b/crates/agentic-core/tests/cassettes/text_only/responses/resp-two-turn-gpt-4o-streaming.yaml @@ -0,0 +1,213 @@ +turns: +- filename: t1 + request: + body: + input: 'Remember the word BANANA. Just say: OK' + model: gpt-4o + store: true + stream: true + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387","object":"response","created_at":1776764210,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387","object":"response","created_at":1776764210,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"OK","item_id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","logprobs":[],"obfuscation":"fFzOZt2wTSxfuW","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","logprobs":[],"output_index":0,"sequence_number":5,"text":"OK"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"OK"},"sequence_number":6} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"OK"}],"role":"assistant"},"output_index":0,"sequence_number":7} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387","object":"response","created_at":1776764210,"status":"completed","background":false,"completed_at":1776764210,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"msg_0f3cfbadf7c5eca80069e74532ab188193a649f754404be233","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"OK"}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":17,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":19},"user":null,"metadata":{}},"sequence_number":8} + + ' + - ' + + ' + status_code: 200 +- filename: t2 + request: + body: + input: What word did I ask you to remember? + model: gpt-4o + previous_response_id: resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387 + store: true + stream: true + headers: + accept: '*/*' + authorization: Bearer *** + content-type: application/json + user-agent: python-httpx/0.28.1 + method: POST + path: /v1/responses + query_params: {} + response: + headers: + content-type: text/event-stream; charset=utf-8 + sse: + - 'event: response.created + + ' + - 'data: {"type":"response.created","response":{"id":"resp_0f3cfbadf7c5eca80069e7453a2be4819383b068fd4663b6c8","object":"response","created_at":1776764218,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387","prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + ' + - ' + + ' + - 'event: response.in_progress + + ' + - 'data: {"type":"response.in_progress","response":{"id":"resp_0f3cfbadf7c5eca80069e7453a2be4819383b068fd4663b6c8","object":"response","created_at":1776764218,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387","prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + ' + - ' + + ' + - 'event: response.output_item.added + + ' + - 'data: {"type":"response.output_item.added","item":{"id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + + ' + - ' + + ' + - 'event: response.content_part.added + + ' + - 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"BAN","item_id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","logprobs":[],"obfuscation":"ndpMUu4dcc0wQ","output_index":0,"sequence_number":4} + + ' + - ' + + ' + - 'event: response.output_text.delta + + ' + - 'data: {"type":"response.output_text.delta","content_index":0,"delta":"ANA","item_id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","logprobs":[],"obfuscation":"rzt7Jc0RMv0V1","output_index":0,"sequence_number":5} + + ' + - ' + + ' + - 'event: response.output_text.done + + ' + - 'data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","logprobs":[],"output_index":0,"sequence_number":6,"text":"BANANA"} + + ' + - ' + + ' + - 'event: response.content_part.done + + ' + - 'data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"BANANA"},"sequence_number":7} + + ' + - ' + + ' + - 'event: response.output_item.done + + ' + - 'data: {"type":"response.output_item.done","item":{"id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"BANANA"}],"role":"assistant"},"output_index":0,"sequence_number":8} + + ' + - ' + + ' + - 'event: response.completed + + ' + - 'data: {"type":"response.completed","response":{"id":"resp_0f3cfbadf7c5eca80069e7453a2be4819383b068fd4663b6c8","object":"response","created_at":1776764218,"status":"completed","background":false,"completed_at":1776764218,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"msg_0f3cfbadf7c5eca80069e7453ab4108193987ef7e3267e91c1","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"BANANA"}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":"resp_0f3cfbadf7c5eca80069e7453219a881939da5d5e61bae3387","prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":35,"input_tokens_details":{"cached_tokens":0},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":38},"user":null,"metadata":{}},"sequence_number":9} + + ' + - ' + + ' + status_code: 200 diff --git a/crates/agentic-core/tests/support/mod.rs b/crates/agentic-core/tests/support/mod.rs new file mode 100644 index 0000000..769eacf --- /dev/null +++ b/crates/agentic-core/tests/support/mod.rs @@ -0,0 +1,353 @@ +//! Shared test infrastructure for executor integration tests. +//! +//! - [`MockServer`] — axum-based HTTP mock with RAII shutdown (`Drop`). +//! - [`TestFixture`] — bundles mock server + `ExecutionContext` for one test. +//! - Cassette loading utilities. +//! - Response helpers. + +#![allow(dead_code)] + +use std::sync::Arc; + +use axum::Router; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use either::Either; +use futures::StreamExt; +use serde::Deserialize; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; + +use agentic_core::executor::{BoxStream, ConversationHandler, ExecutionContext, ResponseHandler}; +use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_pool_with_schema}; +use agentic_core::types::io::{OutputItem, ResponsesInput, ToolChoice}; +use agentic_core::types::request_response::{RequestPayload, ResponsePayload}; + +// --------------------------------------------------------------------------- +// Cassette types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct Cassette { + pub turns: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Turn { + pub request: TurnRequest, + pub response: TurnResponse, +} + +#[derive(Debug, Deserialize)] +pub struct TurnRequest { + pub path: String, + pub body: TurnBody, +} + +#[derive(Debug, Deserialize, Default)] +pub struct TurnBody { + #[serde(default)] + pub input: String, + #[serde(default = "default_true")] + pub store: bool, + #[serde(default)] + pub stream: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Deserialize)] +pub struct TurnResponse { + /// Non-streaming: full JSON response body. + pub body: Option, + /// Streaming: list of raw SSE strings from the recording. + pub sse: Option>, +} + +/// Load and parse a cassette YAML file (all turns preserved). +pub fn load_cassette(path: &str) -> Cassette { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read cassette {path}: {e}")); + serde_yaml::from_str(&text).unwrap_or_else(|e| panic!("failed to parse cassette {path}: {e}")) +} + +/// Filter to only `/v1/responses` turns — the LLM inference turns that need a +/// mock HTTP response. Conversation cassettes interleave `/v1/conversations` +/// management turns; the Rust executor handles those internally via +/// [`ConversationHandler`] without any HTTP call. +pub fn responses_turns(cassette: &Cassette) -> Vec<&Turn> { + cassette + .turns + .iter() + .filter(|t| t.request.path == "/v1/responses") + .collect() +} + +/// Extract the expected output text from a cassette turn. +/// +/// - Non-streaming: `body.output[0].content[0].text` +/// - Streaming: concatenate all `response.output_text.delta` values +pub fn expected_text(turn: &Turn) -> String { + if let Some(body) = &turn.response.body { + return body["output"][0]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); + } + if let Some(sse) = &turn.response.sse { + let mut out = String::new(); + for raw in sse { + for line in raw.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(json) = serde_json::from_str::(data) { + if json["type"].as_str() == Some("response.output_text.delta") { + if let Some(delta) = json["delta"].as_str() { + out.push_str(delta); + } + } + } + } + } + } + return out; + } + String::new() +} + +// --------------------------------------------------------------------------- +// Mock HTTP server (RAII — Drop aborts the server task) +// --------------------------------------------------------------------------- + +/// A per-test HTTP mock server. The server task is aborted when this struct +/// is dropped, ensuring clean teardown even if a test panics. +pub struct MockServer { + url: String, + handle: JoinHandle<()>, +} + +impl MockServer { + pub fn url(&self) -> &str { + &self.url + } +} + +impl Drop for MockServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +fn build_response(resp: MockResponse) -> Response { + match resp { + MockResponse::Json(body) => Response::builder() + .status(200) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(body)) + .unwrap() + .into_response(), + MockResponse::Sse(body) => Response::builder() + .status(200) + .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") + .body(axum::body::Body::from(body)) + .unwrap() + .into_response(), + } +} + +/// A single queued mock response. +pub enum MockResponse { + Json(String), + Sse(String), +} + +impl MockResponse { + /// Build a `MockResponse` from a cassette turn. + pub fn from_turn(turn: &Turn) -> Self { + if let Some(body) = &turn.response.body { + return Self::Json(serde_json::to_string(body).expect("cassette body is valid JSON")); + } + if let Some(sse) = &turn.response.sse { + let mut body = sse.join(""); + // Ensure the stream is terminated. + if !body.contains("data: [DONE]") { + body.push_str("data: [DONE]\n\n"); + } + return Self::Sse(body); + } + panic!("cassette turn has neither body nor sse"); + } +} + +// Use a VecDeque so pop_front is O(1). +impl MockServer { + pub async fn start_deque(responses: Vec) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock server"); + let addr = listener.local_addr().expect("local addr"); + let url = format!("http://{addr}"); + // Store as VecDeque for O(1) pop_front. + use std::collections::VecDeque; + let queue: Arc>> = Arc::new(Mutex::new(VecDeque::from(responses))); + + let handle = tokio::spawn(async move { + let app = Router::new() + .route( + "/v1/responses", + post(move |_body: axum::body::Bytes| { + let queue = Arc::clone(&queue); + async move { + let mut q = queue.lock().await; + let resp = q.pop_front().expect("mock queue exhausted — check test setup"); + build_response(resp) + } + }), + ) + // Conversation management calls don't go through the mock — + // the executor handles them via ConversationHandler (DB-only). + // This route is here so the server doesn't return 404 if called. + .route( + "/v1/conversations", + post(|| async { (axum::http::StatusCode::OK, "{}") }), + ); + axum::serve(listener, app).await.ok(); + }); + + Self { url, handle } + } +} + +// --------------------------------------------------------------------------- +// Shared database pool setup +// --------------------------------------------------------------------------- + +/// Create a fresh SQLite pool with schema applied. +/// +/// Uses a unique temp-file per call so concurrent tests don't conflict. +pub async fn setup_pool() -> Arc { + let db_path = std::env::temp_dir().join(format!("test_{}.db", uuid::Uuid::now_v7())); + let db_url = format!("sqlite://{}", db_path.display()); + create_pool_with_schema(Some(&db_url)) + .await + .expect("failed to create test pool") +} + +// --------------------------------------------------------------------------- +// TestFixture — owns MockServer + ExecutionContext for one test +// --------------------------------------------------------------------------- + +/// Bundles everything a test needs. Dropped at end of test scope. +pub struct TestFixture { + pub exec_ctx: Arc, + // Kept for its Drop impl — aborts the mock server when the test ends. + _server: MockServer, +} + +impl TestFixture { + /// Build a fixture from a full cassette turn slice. + /// + /// The mock server queues only `/v1/responses` turns (LLM inference). + /// `/v1/conversations` turns are handled by the executor via + /// [`ConversationHandler`] (DB-only, no outbound HTTP). + pub async fn new(turns: &[&Turn]) -> Self { + let responses = turns + .iter() + .filter(|t| t.request.path == "/v1/responses") + .map(|t| MockResponse::from_turn(t)) + .collect(); + let server = MockServer::start_deque(responses).await; + + let pool = setup_pool().await; + let conv_handler = ConversationHandler::new(ConversationStore::new(Arc::clone(&pool))); + let resp_handler = ResponseHandler::new(ResponseStore::new(Arc::clone(&pool))); + let client = Arc::new(reqwest::Client::new()); + let exec_ctx = Arc::new(ExecutionContext::new( + conv_handler, + resp_handler, + client, + server.url().to_string(), + None, + )); + + Self { + exec_ctx, + _server: server, + } + } +} + +// --------------------------------------------------------------------------- +// Request builder +// --------------------------------------------------------------------------- + +pub fn make_request( + input: &str, + store: bool, + stream: bool, + previous_response_id: Option, + conversation_id: Option, +) -> RequestPayload { + RequestPayload { + model: "test-model".to_string(), + input: ResponsesInput::Text(input.to_string()), + instructions: None, + previous_response_id, + conversation_id, + tools: None, + tool_choice: ToolChoice::Auto, + stream, + store, + include: None, + temperature: None, + top_p: None, + max_output_tokens: None, + truncation: None, + metadata: None, + } +} + +// --------------------------------------------------------------------------- +// Response helpers +// --------------------------------------------------------------------------- + +pub fn unwrap_blocking(result: Either) -> ResponsePayload { + match result { + Either::Left(p) => p, + Either::Right(_) => panic!("expected non-streaming response, got stream"), + } +} + +/// Collect a streaming response to its final `ResponsePayload`. +pub async fn collect_stream(result: Either) -> ResponsePayload { + let stream = match result { + Either::Right(s) => s, + Either::Left(_) => panic!("expected streaming response, got blocking"), + }; + let mut stream = Box::pin(stream); + while let Some(chunk) = stream.next().await { + if let Some(data) = chunk.trim_end_matches('\n').strip_prefix("data: ") { + if data != "[DONE]" { + if let Ok(payload) = serde_json::from_str::(data) { + while stream.next().await.is_some() {} + return payload; + } + } + } + } + panic!("stream ended without a ResponsePayload chunk"); +} + +/// Extract concatenated text content from a `ResponsePayload`. +pub fn output_text(payload: &ResponsePayload) -> String { + payload + .output + .iter() + .filter_map(|item| match item { + OutputItem::Message(msg) => Some(msg.content.iter().map(|c| c.text.as_str()).collect::>().join("")), + OutputItem::FunctionCall(_) => None, + }) + .collect::>() + .join("") +} From b024ef281d2b3e84ed726fb8e5c1de365993b507 Mon Sep 17 00:00:00 2001 From: maral Date: Wed, 3 Jun 2026 10:15:05 +0000 Subject: [PATCH 12/18] clean code and fix cargo clippy Signed-off-by: maral --- .../benches/executor_throughput.rs | 24 +++++++------- crates/agentic-core/tests/support/mod.rs | 33 +++---------------- 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/crates/agentic-core/benches/executor_throughput.rs b/crates/agentic-core/benches/executor_throughput.rs index f8ab13a..d1c38f7 100644 --- a/crates/agentic-core/benches/executor_throughput.rs +++ b/crates/agentic-core/benches/executor_throughput.rs @@ -188,17 +188,17 @@ async fn seed_chain(exec_ctx: &Arc, depth: usize) -> Option) { +fn bench_execute_blocking(c: &mut Criterion, exec_ctx: &Arc) { let mut group = c.benchmark_group("execute/blocking"); for depth in 1..=max_depth() { group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, &depth| { b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( || { - let exec_ctx = Arc::clone(&exec_ctx); + let exec_ctx = Arc::clone(exec_ctx); async move { seed_chain(&exec_ctx, depth).await } }, |setup| { - let exec_ctx = Arc::clone(&exec_ctx); + let exec_ctx = Arc::clone(exec_ctx); async move { let prev_id = setup.await; let req = make_request("bench turn", false, black_box(prev_id)); @@ -212,17 +212,17 @@ fn bench_execute_blocking(c: &mut Criterion, exec_ctx: Arc) { group.finish(); } -fn bench_execute_streaming(c: &mut Criterion, exec_ctx: Arc) { +fn bench_execute_streaming(c: &mut Criterion, exec_ctx: &Arc) { let mut group = c.benchmark_group("execute/streaming"); for depth in 1..=max_depth() { group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, &depth| { b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( || { - let exec_ctx = Arc::clone(&exec_ctx); + let exec_ctx = Arc::clone(exec_ctx); async move { seed_chain(&exec_ctx, depth).await } }, |setup| { - let exec_ctx = Arc::clone(&exec_ctx); + let exec_ctx = Arc::clone(exec_ctx); async move { let prev_id = setup.await; let req = make_request("bench turn", true, black_box(prev_id)); @@ -241,7 +241,7 @@ fn bench_execute_streaming(c: &mut Criterion, exec_ctx: Arc) { group.finish(); } -fn bench_rehydrate_only(c: &mut Criterion, exec_ctx: Arc) { +fn bench_rehydrate_only(c: &mut Criterion, exec_ctx: &Arc) { let mut group = c.benchmark_group("rehydrate_only"); // Grow the shared chain incrementally so deeper depths include all prior @@ -256,7 +256,7 @@ fn bench_rehydrate_only(c: &mut Criterion, exec_ctx: Arc) { if depth == 1 || !has_tip { let prev_id = chain_tip.lock().unwrap().clone(); let req = make_request("seed", false, prev_id); - if let Either::Left(p) = execute(req, Arc::clone(&exec_ctx)).await.expect("seed") { + if let Either::Left(p) = execute(req, Arc::clone(exec_ctx)).await.expect("seed") { *chain_tip.lock().unwrap() = Some(p.id); } } @@ -266,7 +266,7 @@ fn bench_rehydrate_only(c: &mut Criterion, exec_ctx: Arc) { b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( || chain_tip.lock().unwrap().clone(), |prev_id| { - let exec_ctx = Arc::clone(&exec_ctx); + let exec_ctx = Arc::clone(exec_ctx); async move { let req = make_request("bench", false, black_box(prev_id)); rehydrate_conversation(req, &exec_ctx).await.expect("rehydrate") @@ -285,13 +285,13 @@ fn init_benches(c: &mut Criterion) { let mock_url = start_mock_server(&rt); let (exec_ctx, pool) = build_exec_ctx(&rt, mock_url); - bench_execute_blocking(c, Arc::clone(&exec_ctx)); + bench_execute_blocking(c, &exec_ctx); clear_db(&rt, &pool); - bench_execute_streaming(c, Arc::clone(&exec_ctx)); + bench_execute_streaming(c, &exec_ctx); clear_db(&rt, &pool); - bench_rehydrate_only(c, Arc::clone(&exec_ctx)); + bench_rehydrate_only(c, &exec_ctx); clear_db(&rt, &pool); } diff --git a/crates/agentic-core/tests/support/mod.rs b/crates/agentic-core/tests/support/mod.rs index 769eacf..26dfaea 100644 --- a/crates/agentic-core/tests/support/mod.rs +++ b/crates/agentic-core/tests/support/mod.rs @@ -24,10 +24,6 @@ use agentic_core::storage::{ConversationStore, DbPool, ResponseStore, create_poo use agentic_core::types::io::{OutputItem, ResponsesInput, ToolChoice}; use agentic_core::types::request_response::{RequestPayload, ResponsePayload}; -// --------------------------------------------------------------------------- -// Cassette types -// --------------------------------------------------------------------------- - #[derive(Debug, Deserialize)] pub struct Cassette { pub turns: Vec, @@ -116,10 +112,6 @@ pub fn expected_text(turn: &Turn) -> String { String::new() } -// --------------------------------------------------------------------------- -// Mock HTTP server (RAII — Drop aborts the server task) -// --------------------------------------------------------------------------- - /// A per-test HTTP mock server. The server task is aborted when this struct /// is dropped, ensuring clean teardown even if a test panics. pub struct MockServer { @@ -183,13 +175,13 @@ impl MockResponse { // Use a VecDeque so pop_front is O(1). impl MockServer { pub async fn start_deque(responses: Vec) -> Self { + use std::collections::VecDeque; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind mock server"); let addr = listener.local_addr().expect("local addr"); let url = format!("http://{addr}"); // Store as VecDeque for O(1) pop_front. - use std::collections::VecDeque; let queue: Arc>> = Arc::new(Mutex::new(VecDeque::from(responses))); let handle = tokio::spawn(async move { @@ -219,11 +211,7 @@ impl MockServer { } } -// --------------------------------------------------------------------------- -// Shared database pool setup -// --------------------------------------------------------------------------- - -/// Create a fresh SQLite pool with schema applied. +/// Create a fresh `SQLite` pool with schema applied. /// /// Uses a unique temp-file per call so concurrent tests don't conflict. pub async fn setup_pool() -> Arc { @@ -234,10 +222,6 @@ pub async fn setup_pool() -> Arc { .expect("failed to create test pool") } -// --------------------------------------------------------------------------- -// TestFixture — owns MockServer + ExecutionContext for one test -// --------------------------------------------------------------------------- - /// Bundles everything a test needs. Dropped at end of test scope. pub struct TestFixture { pub exec_ctx: Arc, @@ -278,10 +262,6 @@ impl TestFixture { } } -// --------------------------------------------------------------------------- -// Request builder -// --------------------------------------------------------------------------- - pub fn make_request( input: &str, store: bool, @@ -308,10 +288,6 @@ pub fn make_request( } } -// --------------------------------------------------------------------------- -// Response helpers -// --------------------------------------------------------------------------- - pub fn unwrap_blocking(result: Either) -> ResponsePayload { match result { Either::Left(p) => p, @@ -345,9 +321,8 @@ pub fn output_text(payload: &ResponsePayload) -> String { .output .iter() .filter_map(|item| match item { - OutputItem::Message(msg) => Some(msg.content.iter().map(|c| c.text.as_str()).collect::>().join("")), + OutputItem::Message(msg) => Some(msg.content.iter().map(|c| c.text.as_str()).collect::()), OutputItem::FunctionCall(_) => None, }) - .collect::>() - .join("") + .collect::() } From da38e55a19142a5174ca53c7f1250bdae8ebe308 Mon Sep 17 00:00:00 2001 From: maral Date: Wed, 3 Jun 2026 10:42:20 +0000 Subject: [PATCH 13/18] improve error handling Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: maral --- .../agentic-core/src/executor/accumulator.rs | 3 +- crates/agentic-core/src/executor/engine.rs | 32 ++++++++++----- crates/agentic-core/src/executor/error.rs | 39 +++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/agentic-core/src/executor/accumulator.rs b/crates/agentic-core/src/executor/accumulator.rs index bf963a2..a598ea4 100644 --- a/crates/agentic-core/src/executor/accumulator.rs +++ b/crates/agentic-core/src/executor/accumulator.rs @@ -53,8 +53,7 @@ impl ResponseAccumulator { /// # Errors /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing. pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult { - let json: serde_json::Value = - deserialize_from_str(body).map_err(|e| ExecutorError::ParseError(format!("invalid JSON: {e}")))?; + let json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?; let response_id = json["id"] .as_str() diff --git a/crates/agentic-core/src/executor/engine.rs b/crates/agentic-core/src/executor/engine.rs index 9f6eab2..62fe580 100644 --- a/crates/agentic-core/src/executor/engine.rs +++ b/crates/agentic-core/src/executor/engine.rs @@ -64,16 +64,21 @@ async fn fetch_response_json( if !resp.status().is_success() { let status = resp.status().as_u16(); - let body = resp.text().await.unwrap_or_default(); + // Log and discard any error reading the error body — the status code + // is the primary signal; an empty body is acceptable here. + let body = resp + .text() + .await + .inspect_err(|e| tracing::debug!("failed to read error response body: {e}")) + .unwrap_or_default(); return Err(ExecutorError::LLMRequest { status: http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), body, }); } - resp.text() - .await - .map_err(|e| ExecutorError::StreamError(format!("failed to read response body: {e}"))) + // Preserve the reqwest::Error as the typed source (NetworkError). + resp.text().await.map_err(ExecutorError::NetworkError) } /// Step 1 — Build [`RequestContext`] by rehydrating conversation history. @@ -183,7 +188,12 @@ async fn rehydrate_from_conversation(ctx: &mut RequestContext, exec_ctx: &Execut /// Step 2 — Call the LLM inference backend; yields raw SSE lines (`data: …`). /// /// Always requests `stream=true` upstream. Stops on `[DONE]`. -/// Yields `Err` on connection failure (502), timeout (504), or non-2xx status. +/// +/// # Errors +/// Each stream item is `Result`. The stream yields `Err` on: +/// - [`ExecutorError::LLMRequest`] — connect timeout (504), connection failure (502), +/// or non-2xx HTTP status from the backend +/// - [`ExecutorError::NetworkError`] — network failure while reading the response body pub fn call_inference( upstream_json: String, url: String, @@ -219,7 +229,11 @@ pub fn call_inference( if !resp.status().is_success() { let status = resp.status().as_u16(); - let body = resp.text().await.unwrap_or_default(); + let body = resp + .text() + .await + .inspect_err(|e| tracing::debug!("failed to read error response body: {e}")) + .unwrap_or_default(); yield Err(ExecutorError::LLMRequest { status: http::StatusCode::from_u16(status) .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), @@ -241,7 +255,7 @@ pub fn call_inference( let chunk = match chunk_result { Ok(c) => c, Err(e) => { - yield Err(ExecutorError::StreamError(format!("stream read error: {e}"))); + yield Err(ExecutorError::NetworkError(e)); return; } }; @@ -306,8 +320,8 @@ pub async fn persist_response( async fn run_blocking(ctx: RequestContext, exec_ctx: &ExecutionContext) -> ExecutorResult { let url = exec_ctx.responses_url(); // Non-streaming request: stream=false → full JSON body → from_json. - let upstream_json = serialize_to_string(&ctx.enriched_request.to_upstream_request(false)) - .map_err(|e| ExecutorError::ParseError(e.to_string()))?; + let upstream_json = + serialize_to_string(&ctx.enriched_request.to_upstream_request(false)).map_err(ExecutorError::JsonError)?; let body = fetch_response_json(upstream_json, &url, &exec_ctx.client, exec_ctx.client_auth.as_deref()).await?; diff --git a/crates/agentic-core/src/executor/error.rs b/crates/agentic-core/src/executor/error.rs index d306a83..ba927a0 100644 --- a/crates/agentic-core/src/executor/error.rs +++ b/crates/agentic-core/src/executor/error.rs @@ -5,15 +5,45 @@ use crate::StorageError; #[derive(Debug, Error)] pub enum ExecutorError { + /// A storage layer operation failed. #[error("storage error: {0}")] Storage(#[from] StorageError), + /// The LLM backend returned a non-2xx status or was unreachable. #[error("LLM request failed ({status}): {body}")] LLMRequest { status: StatusCode, body: String }, + /// A network error occurred reading from the LLM response stream. + /// + /// The original `reqwest::Error` is preserved as the error source so + /// callers can inspect the underlying network failure. + #[error("network error: {0}")] + NetworkError( + #[from] + #[source] + reqwest::Error, + ), + + /// JSON deserialisation failed. + /// + /// The original `serde_json::Error` is preserved as the error source so + /// callers can inspect the exact parse failure location and kind. + #[error("json error: {0}")] + JsonError( + #[from] + #[source] + serde_json::Error, + ), + + /// A general stream processing error with a human-readable message. + /// + /// Used for non-network stream failures (e.g. worker thread panic). #[error("stream error: {0}")] StreamError(String), + /// A validation error on the request payload with a human-readable message. + /// + /// Used when required fields are missing or structurally invalid. #[error("parse error: {0}")] ParseError(String), @@ -59,4 +89,13 @@ mod tests { let exec_err = ExecutorError::from(storage_err); assert!(exec_err.to_string().contains("storage error")); } + + #[test] + fn test_executor_error_json_preserves_source() { + use std::error::Error; + let json_err: serde_json::Error = serde_json::from_str::("{bad}").unwrap_err(); + let exec_err = ExecutorError::from(json_err); + assert!(exec_err.source().is_some(), "source should be chained"); + assert!(exec_err.to_string().contains("json error")); + } } From 8d2d843ab2e9e9808e0bca0607477c4316236d86 Mon Sep 17 00:00:00 2001 From: maral Date: Wed, 3 Jun 2026 11:19:18 +0000 Subject: [PATCH 14/18] improve apis Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: maral --- .../agentic-core/src/executor/accumulator.rs | 9 ++++-- crates/agentic-core/src/executor/engine.rs | 25 ++++++++++++++--- crates/agentic-core/src/executor/error.rs | 1 + .../src/executor/modes/conversation.rs | 2 +- .../src/executor/modes/response.rs | 2 +- crates/agentic-core/src/executor/request.rs | 28 +++++++++++++++++++ .../agentic-core/src/storage/conversation.rs | 2 +- crates/agentic-core/src/storage/response.rs | 2 +- 8 files changed, 60 insertions(+), 11 deletions(-) diff --git a/crates/agentic-core/src/executor/accumulator.rs b/crates/agentic-core/src/executor/accumulator.rs index a598ea4..5b94e8e 100644 --- a/crates/agentic-core/src/executor/accumulator.rs +++ b/crates/agentic-core/src/executor/accumulator.rs @@ -20,6 +20,7 @@ use crate::utils::common::{deserialize_from_str, deserialize_from_value, deseria use crate::utils::uuid7_str; /// Accumulates LLM response chunks from streaming or non-streaming sources. +#[derive(Debug)] pub struct ResponseAccumulator { response_id: String, conversation_id: Option, @@ -220,9 +221,11 @@ impl ResponseAccumulator { } /// Marks the response as incomplete due to an error or interruption. - pub fn mark_incomplete(&mut self, reason: String) { + pub fn mark_incomplete(&mut self, reason: impl Into) { self.status = ResponseStatus::Incomplete; - self.incomplete_details = Some(IncompleteDetails { reason: Some(reason) }); + self.incomplete_details = Some(IncompleteDetails { + reason: Some(reason.into()), + }); } /// Finalizes the accumulator into a `ResponsePayload`. @@ -268,7 +271,7 @@ mod tests { #[test] fn test_accumulator_mark_incomplete() { let mut acc = ResponseAccumulator::new("resp_123".into(), None); - acc.mark_incomplete("Stream interrupted".into()); + acc.mark_incomplete("Stream interrupted"); assert_eq!(acc.status, ResponseStatus::Incomplete); assert!(acc.incomplete_details.is_some()); } diff --git a/crates/agentic-core/src/executor/engine.rs b/crates/agentic-core/src/executor/engine.rs index 62fe580..93832ec 100644 --- a/crates/agentic-core/src/executor/engine.rs +++ b/crates/agentic-core/src/executor/engine.rs @@ -23,6 +23,8 @@ use crate::types::request_response::{RequestPayload, ResponsePayload}; use crate::utils::common::serialize_to_string; use crate::utils::uuid7_str; +use std::time::Duration; + /// SSE stream of raw lines sent to the client (`data: …\n\n` per event). pub type BoxStream = Pin + Send>>; @@ -199,6 +201,7 @@ pub fn call_inference( url: String, client: Arc, auth: Option, + chunk_timeout: Duration, ) -> impl Stream> + Send + 'static { stream! { let mut req = client @@ -251,10 +254,23 @@ pub fn call_inference( let mut byte_stream = resp.bytes_stream(); let mut buf = String::with_capacity(buf_cap); - while let Some(chunk_result) = byte_stream.next().await { - let chunk = match chunk_result { - Ok(c) => c, - Err(e) => { + loop { + // Apply a per-chunk timeout when configured; zero means no timeout. + let next = if chunk_timeout.is_zero() { + byte_stream.next().await + } else if let Ok(opt) = tokio::time::timeout(chunk_timeout, byte_stream.next()).await { + opt + } else { + yield Err(ExecutorError::StreamError( + "chunk timeout: no data received within the configured window".into(), + )); + return; + }; + + let chunk = match next { + None => break, + Some(Ok(c)) => c, + Some(Err(e)) => { yield Err(ExecutorError::NetworkError(e)); return; } @@ -365,6 +381,7 @@ fn run_stream(ctx: RequestContext, exec_ctx: Arc) -> BoxStream url, Arc::clone(&exec_ctx.client), exec_ctx.client_auth.clone(), + exec_ctx.streaming_timeout, )); // from_stream feeds SSE lines to a spawn_blocking worker via channel. diff --git a/crates/agentic-core/src/executor/error.rs b/crates/agentic-core/src/executor/error.rs index ba927a0..6c6e41b 100644 --- a/crates/agentic-core/src/executor/error.rs +++ b/crates/agentic-core/src/executor/error.rs @@ -3,6 +3,7 @@ use thiserror::Error; use crate::StorageError; +#[non_exhaustive] #[derive(Debug, Error)] pub enum ExecutorError { /// A storage layer operation failed. diff --git a/crates/agentic-core/src/executor/modes/conversation.rs b/crates/agentic-core/src/executor/modes/conversation.rs index 4091fd9..bc89476 100644 --- a/crates/agentic-core/src/executor/modes/conversation.rs +++ b/crates/agentic-core/src/executor/modes/conversation.rs @@ -7,7 +7,7 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::request::RequestContext; /// Handles all conversation store operations: creation, rehydration, and persistence. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ConversationHandler { store: ConversationStore, } diff --git a/crates/agentic-core/src/executor/modes/response.rs b/crates/agentic-core/src/executor/modes/response.rs index 10c27a3..a747776 100644 --- a/crates/agentic-core/src/executor/modes/response.rs +++ b/crates/agentic-core/src/executor/modes/response.rs @@ -7,7 +7,7 @@ use crate::executor::error::{ExecutorError, ExecutorResult}; use crate::executor::request::RequestContext; /// Handles all response store operations: lookup, rehydration, and persistence. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ResponseHandler { store: ResponseStore, } diff --git a/crates/agentic-core/src/executor/request.rs b/crates/agentic-core/src/executor/request.rs index 297b717..17a21b7 100644 --- a/crates/agentic-core/src/executor/request.rs +++ b/crates/agentic-core/src/executor/request.rs @@ -1,10 +1,12 @@ use std::sync::Arc; +use std::time::Duration; use crate::executor::modes::{ConversationHandler, ResponseHandler}; use crate::types::io::InputItem; use crate::types::request_response::{RequestPayload, ResponsePayload}; /// Context built by `rehydrate_conversation`, threaded through the execute pipeline. +#[derive(Debug)] pub struct RequestContext { /// Untouched original request from the client. pub original_request: RequestPayload, @@ -34,6 +36,7 @@ impl RequestContext { /// Runtime dependencies passed into `execute()`. /// /// Owns the storage handlers, HTTP client, and LLM endpoint configuration. +#[derive(Debug)] pub struct ExecutionContext { pub conv_handler: ConversationHandler, pub resp_handler: ResponseHandler, @@ -42,6 +45,9 @@ pub struct ExecutionContext { pub llm_base_url: String, /// Bearer token forwarded from the client, if any. pub client_auth: Option, + /// Maximum wait time for the next SSE chunk. `Duration::ZERO` disables the timeout. + /// Sourced from [`Config::streaming_chunk_timeout_s`](crate::config::Config::streaming_chunk_timeout_s). + pub streaming_timeout: Duration, } impl ExecutionContext { @@ -71,6 +77,28 @@ impl ExecutionContext { client, llm_base_url, client_auth, + streaming_timeout: Duration::from_secs(30), + } + } + + #[must_use] + pub fn from_config( + conv_handler: ConversationHandler, + resp_handler: ResponseHandler, + client: Arc, + cfg: &crate::config::Config, + client_auth: Option, + ) -> Self { + // TODO: expose `streaming_chunk_timeout_s: Option` in `Config` and read it here + // once all `Config` struct literals in agentic-server use `..Config::default()`. + let streaming_timeout = Duration::from_secs(30); + Self { + conv_handler, + resp_handler, + client, + llm_base_url: cfg.llm_api_base.clone(), + client_auth, + streaming_timeout, } } } diff --git a/crates/agentic-core/src/storage/conversation.rs b/crates/agentic-core/src/storage/conversation.rs index 621e181..5f18d7e 100644 --- a/crates/agentic-core/src/storage/conversation.rs +++ b/crates/agentic-core/src/storage/conversation.rs @@ -9,7 +9,7 @@ use super::types::{ConversationData, InOutItem, ResponseMetadata, StorageError, use crate::utils::common::{serialize_to_string, uuid7_str}; /// Conversation storage operations. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ConversationStore { pool: Option>, } diff --git a/crates/agentic-core/src/storage/response.rs b/crates/agentic-core/src/storage/response.rs index ddb4c2e..a41b49c 100644 --- a/crates/agentic-core/src/storage/response.rs +++ b/crates/agentic-core/src/storage/response.rs @@ -10,7 +10,7 @@ use super::types::{InOutItem, ResponseData, ResponseMetadata, StorageError, Stor use crate::utils::common::{serialize_to_string, uuid7_str}; /// Response storage operations. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ResponseStore { pool: Option>, } From 080aabeb871f8782d4f49368a93f8156072056b4 Mon Sep 17 00:00:00 2001 From: maral Date: Wed, 3 Jun 2026 11:32:45 +0000 Subject: [PATCH 15/18] simplify call_inference Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: maral --- crates/agentic-core/src/executor/engine.rs | 151 ++++++++++----------- 1 file changed, 75 insertions(+), 76 deletions(-) diff --git a/crates/agentic-core/src/executor/engine.rs b/crates/agentic-core/src/executor/engine.rs index 93832ec..5d2908d 100644 --- a/crates/agentic-core/src/executor/engine.rs +++ b/crates/agentic-core/src/executor/engine.rs @@ -31,6 +31,24 @@ pub type BoxStream = Pin + Send>>; /// Wire-format marker signalling end-of-stream to the client. const DONE_MARKER: &str = "data: [DONE]\n\n"; +/// Fetch the next raw bytes chunk from a streaming response. +/// +/// Returns `Ok(Some(bytes))` on data, `Ok(None)` when the stream ends cleanly, +/// and `Err` on a network failure or chunk timeout. +async fn next_chunk(stream: &mut S, timeout: Duration) -> ExecutorResult> +where + S: futures::Stream> + Unpin, +{ + let item = if timeout.is_zero() { + stream.next().await + } else { + tokio::time::timeout(timeout, stream.next()).await.map_err(|_| { + ExecutorError::StreamError("chunk timeout: no data received within the configured window".into()) + })? + }; + item.transpose().map_err(ExecutorError::NetworkError) +} + /// Makes a non-streaming HTTP POST to the LLM backend and returns the full JSON body. /// /// Used by [`run_blocking`] so it can pass the result to [`ResponseAccumulator::from_json`]. @@ -187,6 +205,50 @@ async fn rehydrate_from_conversation(ctx: &mut RequestContext, exec_ctx: &Execut Ok(()) } +/// Send a single inference POST and validate the HTTP response. +/// +/// Handles connect/timeout errors and non-2xx status codes so that +/// [`call_inference`] only deals with streaming the body. +async fn send_inference_request( + client: &reqwest::Client, + url: &str, + body: String, + auth: Option<&str>, +) -> ExecutorResult { + let mut req = client.post(url).header("Content-Type", "application/json").body(body); + if let Some(key) = auth { + req = req.bearer_auth(key); + } + + let resp = req.send().await.map_err(|e| ExecutorError::LLMRequest { + status: if e.is_timeout() { + http::StatusCode::GATEWAY_TIMEOUT + } else { + http::StatusCode::BAD_GATEWAY + }, + body: if e.is_timeout() { + "upstream timeout".into() + } else { + "upstream unavailable".into() + }, + })?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .inspect_err(|e| tracing::debug!("failed to read error response body: {e}")) + .unwrap_or_default(); + return Err(ExecutorError::LLMRequest { + status: http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), + body, + }); + } + + Ok(resp) +} + /// Step 2 — Call the LLM inference backend; yields raw SSE lines (`data: …`). /// /// Always requests `stream=true` upstream. Stops on `[DONE]`. @@ -204,76 +266,19 @@ pub fn call_inference( chunk_timeout: Duration, ) -> impl Stream> + Send + 'static { stream! { - let mut req = client - .post(&url) - .header("Content-Type", "application/json") - .body(upstream_json); - if let Some(ref key) = auth { - req = req.bearer_auth(key); - } - - let resp = match req.send().await { + let resp = match send_inference_request(&client, &url, upstream_json, auth.as_deref()).await { Ok(r) => r, - Err(e) if e.is_timeout() => { - yield Err(ExecutorError::LLMRequest { - status: http::StatusCode::GATEWAY_TIMEOUT, - body: "upstream timeout".into(), - }); - return; - } - Err(_) => { - yield Err(ExecutorError::LLMRequest { - status: http::StatusCode::BAD_GATEWAY, - body: "upstream unavailable".into(), - }); - return; - } + Err(e) => { yield Err(e); return; } }; - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body = resp - .text() - .await - .inspect_err(|e| tracing::debug!("failed to read error response body: {e}")) - .unwrap_or_default(); - yield Err(ExecutorError::LLMRequest { - status: http::StatusCode::from_u16(status) - .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), - body, - }); - return; - } - - let buf_cap = resp - .content_length() - .and_then(|n| usize::try_from(n).ok()) - .unwrap_or(8192) - .min(4 * 1024 * 1024); - - let mut byte_stream = resp.bytes_stream(); - let mut buf = String::with_capacity(buf_cap); + let mut bytes = resp.bytes_stream(); + let mut buf = String::with_capacity(8192); loop { - // Apply a per-chunk timeout when configured; zero means no timeout. - let next = if chunk_timeout.is_zero() { - byte_stream.next().await - } else if let Ok(opt) = tokio::time::timeout(chunk_timeout, byte_stream.next()).await { - opt - } else { - yield Err(ExecutorError::StreamError( - "chunk timeout: no data received within the configured window".into(), - )); - return; - }; - - let chunk = match next { - None => break, - Some(Ok(c)) => c, - Some(Err(e)) => { - yield Err(ExecutorError::NetworkError(e)); - return; - } + let chunk = match next_chunk(&mut bytes, chunk_timeout).await { + Ok(Some(c)) => c, + Ok(None) => break, + Err(e) => { yield Err(e); return; } }; match std::str::from_utf8(&chunk) { @@ -282,17 +287,11 @@ pub fn call_inference( } while let Some(pos) = buf.find('\n') { - let line_end = if pos > 0 && buf.as_bytes()[pos - 1] == b'\r' { - pos - 1 - } else { - pos - }; - let line = &buf[..line_end]; - if line.starts_with("data: ") { - if line == "data: [DONE]" { - return; - } - yield Ok(line.to_string()); + let line = buf[..pos].trim_end_matches('\r'); + match line { + "data: [DONE]" => return, + l if l.starts_with("data: ") => yield Ok(l.to_string()), + _ => {} } buf.drain(..=pos); } From 04a0271f719c252dafb4e663bc24d59a8d295981 Mon Sep 17 00:00:00 2001 From: maral Date: Thu, 4 Jun 2026 05:52:53 +0000 Subject: [PATCH 16/18] fix benchmarking to record per turn pref and merge all benches into same entry Signed-off-by: maral --- crates/agentic-core/Cargo.toml | 6 +-- crates/agentic-core/benches/benches.rs | 6 +++ .../benches/executor_throughput.rs | 41 +++++++++++-------- crates/agentic-core/benches/storage_crud.rs | 5 +-- 4 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 crates/agentic-core/benches/benches.rs diff --git a/crates/agentic-core/Cargo.toml b/crates/agentic-core/Cargo.toml index 3680a47..612e0fe 100644 --- a/crates/agentic-core/Cargo.toml +++ b/crates/agentic-core/Cargo.toml @@ -30,11 +30,7 @@ serde_yaml = "0.9" tokio = { workspace = true, features = ["full"] } [[bench]] -name = "storage_crud" -harness = false - -[[bench]] -name = "executor_throughput" +name = "benches" harness = false [lints] diff --git a/crates/agentic-core/benches/benches.rs b/crates/agentic-core/benches/benches.rs new file mode 100644 index 0000000..b49511f --- /dev/null +++ b/crates/agentic-core/benches/benches.rs @@ -0,0 +1,6 @@ +mod executor_throughput; +mod storage_crud; + +use criterion::criterion_main; + +criterion_main!(storage_crud::storage_benches, executor_throughput::executor_benches); diff --git a/crates/agentic-core/benches/executor_throughput.rs b/crates/agentic-core/benches/executor_throughput.rs index d1c38f7..8774e5d 100644 --- a/crates/agentic-core/benches/executor_throughput.rs +++ b/crates/agentic-core/benches/executor_throughput.rs @@ -34,7 +34,7 @@ use axum::Router; use axum::http::header; use axum::response::IntoResponse; use axum::routing::post; -use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group}; use either::Either; use futures::StreamExt; @@ -188,19 +188,25 @@ async fn seed_chain(exec_ctx: &Arc, depth: usize) -> Option) { let mut group = c.benchmark_group("execute/blocking"); + let rt = tokio::runtime::Runtime::new().unwrap(); + for depth in 1..=max_depth() { - group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, &depth| { + // Pre-seed N-1 turns outside criterion — their cost is NOT measured. + let prev_id = rt.block_on(seed_chain(exec_ctx, depth)); + + group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, _| { b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( - || { - let exec_ctx = Arc::clone(exec_ctx); - async move { seed_chain(&exec_ctx, depth).await } - }, - |setup| { + // Synchronous setup: just hand the pre-seeded prev_id to each sample. + || prev_id.clone(), + |prev_id| { let exec_ctx = Arc::clone(exec_ctx); async move { - let prev_id = setup.await; let req = make_request("bench turn", false, black_box(prev_id)); execute(req, exec_ctx).await.expect("execute") } @@ -212,22 +218,22 @@ fn bench_execute_blocking(c: &mut Criterion, exec_ctx: &Arc) { group.finish(); } +// Bench: streaming path, depths 1–max_depth (same pre-seed approach). fn bench_execute_streaming(c: &mut Criterion, exec_ctx: &Arc) { let mut group = c.benchmark_group("execute/streaming"); + let rt = tokio::runtime::Runtime::new().unwrap(); + for depth in 1..=max_depth() { - group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, &depth| { + let prev_id = rt.block_on(seed_chain(exec_ctx, depth)); + + group.bench_with_input(BenchmarkId::new("turns", depth), &depth, |b, _| { b.to_async(tokio::runtime::Runtime::new().unwrap()).iter_batched( - || { - let exec_ctx = Arc::clone(exec_ctx); - async move { seed_chain(&exec_ctx, depth).await } - }, - |setup| { + || prev_id.clone(), + |prev_id| { let exec_ctx = Arc::clone(exec_ctx); async move { - let prev_id = setup.await; let req = make_request("bench turn", true, black_box(prev_id)); let result = execute(req, exec_ctx).await.expect("execute"); - // Drain the stream so accumulate + persist are included. if let Either::Right(stream) = result { let mut stream = Box::pin(stream); while stream.next().await.is_some() {} @@ -295,5 +301,4 @@ fn init_benches(c: &mut Criterion) { clear_db(&rt, &pool); } -criterion_group!(benches, init_benches); -criterion_main!(benches); +criterion_group!(executor_benches, init_benches); diff --git a/crates/agentic-core/benches/storage_crud.rs b/crates/agentic-core/benches/storage_crud.rs index ff551da..221903d 100644 --- a/crates/agentic-core/benches/storage_crud.rs +++ b/crates/agentic-core/benches/storage_crud.rs @@ -1,4 +1,4 @@ -use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, Criterion, black_box, criterion_group}; use agentic_core::storage::{ConversationStore, InOutItem, ResponseMetadata, ResponseStore, create_pool_with_schema}; use agentic_core::types::io::{InputItem, InputMessage, InputMessageContent, OutputItem, OutputMessage}; @@ -205,5 +205,4 @@ fn init_benches(c: &mut Criterion) { }); } -criterion_group!(benches, init_benches); -criterion_main!(benches); +criterion_group!(storage_benches, init_benches); From fc25c960c14226cef0e271eae1128f0a8d410d75 Mon Sep 17 00:00:00 2001 From: maral Date: Thu, 4 Jun 2026 06:04:03 +0000 Subject: [PATCH 17/18] fix cargo clippy Signed-off-by: maral --- crates/agentic-core/tests/support/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agentic-core/tests/support/mod.rs b/crates/agentic-core/tests/support/mod.rs index 26dfaea..bba6b3f 100644 --- a/crates/agentic-core/tests/support/mod.rs +++ b/crates/agentic-core/tests/support/mod.rs @@ -322,7 +322,7 @@ pub fn output_text(payload: &ResponsePayload) -> String { .iter() .filter_map(|item| match item { OutputItem::Message(msg) => Some(msg.content.iter().map(|c| c.text.as_str()).collect::()), - OutputItem::FunctionCall(_) => None, + OutputItem::FunctionCall(_) | OutputItem::Unknown => None, }) .collect::() } From 734cae1c0d318c6fc4f1230c64c9dc5eefacf77e Mon Sep 17 00:00:00 2001 From: maral Date: Thu, 4 Jun 2026 07:05:52 +0000 Subject: [PATCH 18/18] clean code Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: maral --- crates/agentic-core/src/executor/engine.rs | 105 +++++++-------------- 1 file changed, 35 insertions(+), 70 deletions(-) diff --git a/crates/agentic-core/src/executor/engine.rs b/crates/agentic-core/src/executor/engine.rs index 5d2908d..f888501 100644 --- a/crates/agentic-core/src/executor/engine.rs +++ b/crates/agentic-core/src/executor/engine.rs @@ -49,38 +49,34 @@ where item.transpose().map_err(ExecutorError::NetworkError) } -/// Makes a non-streaming HTTP POST to the LLM backend and returns the full JSON body. +/// Build, send, and validate an HTTP POST to the LLM backend. /// -/// Used by [`run_blocking`] so it can pass the result to [`ResponseAccumulator::from_json`]. -async fn fetch_response_json( - upstream_json: String, - url: &str, +/// Shared by both the blocking path (caller reads `.text()`) and the streaming +/// path (caller reads `.bytes_stream()`). Maps connect/timeout failures and +/// non-2xx status codes to [`ExecutorError::LLMRequest`]. +async fn send_request( client: &reqwest::Client, + url: &str, + body: String, auth: Option<&str>, -) -> ExecutorResult { - let mut req = client - .post(url) - .header("Content-Type", "application/json") - .body(upstream_json); +) -> ExecutorResult { + let mut req = client.post(url).header("Content-Type", "application/json").body(body); if let Some(key) = auth { req = req.bearer_auth(key); } - let resp = match req.send().await { - Ok(r) => r, - Err(e) if e.is_timeout() => { - return Err(ExecutorError::LLMRequest { - status: http::StatusCode::GATEWAY_TIMEOUT, - body: "upstream timeout".into(), - }); - } - Err(_) => { - return Err(ExecutorError::LLMRequest { - status: http::StatusCode::BAD_GATEWAY, - body: "upstream unavailable".into(), - }); - } - }; + let resp = req.send().await.map_err(|e| ExecutorError::LLMRequest { + status: if e.is_timeout() { + http::StatusCode::GATEWAY_TIMEOUT + } else { + http::StatusCode::BAD_GATEWAY + }, + body: if e.is_timeout() { + "upstream timeout".into() + } else { + "upstream unavailable".into() + }, + })?; if !resp.status().is_success() { let status = resp.status().as_u16(); @@ -97,6 +93,19 @@ async fn fetch_response_json( }); } + Ok(resp) +} + +/// Makes a non-streaming HTTP POST to the LLM backend and returns the full JSON body. +/// +/// Used by [`run_blocking`] so it can pass the result to [`ResponseAccumulator::from_json`]. +async fn fetch_response_json( + upstream_json: String, + url: &str, + client: &reqwest::Client, + auth: Option<&str>, +) -> ExecutorResult { + let resp = send_request(client, url, upstream_json, auth).await?; // Preserve the reqwest::Error as the typed source (NetworkError). resp.text().await.map_err(ExecutorError::NetworkError) } @@ -205,50 +214,6 @@ async fn rehydrate_from_conversation(ctx: &mut RequestContext, exec_ctx: &Execut Ok(()) } -/// Send a single inference POST and validate the HTTP response. -/// -/// Handles connect/timeout errors and non-2xx status codes so that -/// [`call_inference`] only deals with streaming the body. -async fn send_inference_request( - client: &reqwest::Client, - url: &str, - body: String, - auth: Option<&str>, -) -> ExecutorResult { - let mut req = client.post(url).header("Content-Type", "application/json").body(body); - if let Some(key) = auth { - req = req.bearer_auth(key); - } - - let resp = req.send().await.map_err(|e| ExecutorError::LLMRequest { - status: if e.is_timeout() { - http::StatusCode::GATEWAY_TIMEOUT - } else { - http::StatusCode::BAD_GATEWAY - }, - body: if e.is_timeout() { - "upstream timeout".into() - } else { - "upstream unavailable".into() - }, - })?; - - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body = resp - .text() - .await - .inspect_err(|e| tracing::debug!("failed to read error response body: {e}")) - .unwrap_or_default(); - return Err(ExecutorError::LLMRequest { - status: http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), - body, - }); - } - - Ok(resp) -} - /// Step 2 — Call the LLM inference backend; yields raw SSE lines (`data: …`). /// /// Always requests `stream=true` upstream. Stops on `[DONE]`. @@ -266,7 +231,7 @@ pub fn call_inference( chunk_timeout: Duration, ) -> impl Stream> + Send + 'static { stream! { - let resp = match send_inference_request(&client, &url, upstream_json, auth.as_deref()).await { + let resp = match send_request(&client, &url, upstream_json, auth.as_deref()).await { Ok(r) => r, Err(e) => { yield Err(e); return; } };