Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532c833875 | ||
|
|
23289f6656 | ||
|
|
c71d5f05f0 | ||
|
|
d8aac9748d | ||
|
|
ce084714dc | ||
|
|
ca6d0746b1 | ||
|
|
9147ac303b |
@@ -14,7 +14,7 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
|
|||||||
|
|
||||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||||
|
|
||||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
|
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / `<system-reminder>` 系はこの原則で扱う。
|
||||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+45
-580
@@ -26,16 +26,6 @@ dependencies = [
|
|||||||
"pom",
|
"pom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aead"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
|
||||||
dependencies = [
|
|
||||||
"crypto-common 0.1.7",
|
|
||||||
"generic-array",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -47,56 +37,6 @@ dependencies = [
|
|||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aes-gcm"
|
|
||||||
version = "0.10.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
|
||||||
dependencies = [
|
|
||||||
"aead",
|
|
||||||
"aes",
|
|
||||||
"cipher",
|
|
||||||
"ctr",
|
|
||||||
"ghash",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "agen"
|
|
||||||
version = "0.2.1"
|
|
||||||
dependencies = [
|
|
||||||
"agen-macros",
|
|
||||||
"async-trait",
|
|
||||||
"base64 0.22.1",
|
|
||||||
"chrono",
|
|
||||||
"clap",
|
|
||||||
"dotenv",
|
|
||||||
"eventsource-stream",
|
|
||||||
"futures",
|
|
||||||
"reqwest",
|
|
||||||
"schemars",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"trybuild",
|
|
||||||
"wiremock",
|
|
||||||
"zstd",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "agen-macros"
|
|
||||||
version = "0.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -350,12 +290,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base16ct"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -368,12 +302,6 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64ct"
|
|
||||||
version = "1.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64urlsafedata"
|
name = "base64urlsafedata"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -385,17 +313,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bcrypt-pbkdf"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
|
|
||||||
dependencies = [
|
|
||||||
"blowfish",
|
|
||||||
"pbkdf2",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -450,16 +367,6 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "blowfish"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"cipher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -492,12 +399,6 @@ version = "1.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -558,17 +459,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chacha20"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cipher",
|
|
||||||
"cpufeatures 0.2.17",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -650,7 +540,6 @@ dependencies = [
|
|||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"workdir",
|
"workdir",
|
||||||
"workspace-api",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -728,12 +617,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "const-oid"
|
|
||||||
version = "0.9.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -1017,18 +900,6 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crypto-bigint"
|
|
||||||
version = "0.5.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -1058,41 +929,6 @@ dependencies = [
|
|||||||
"phf 0.11.3",
|
"phf 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ctr"
|
|
||||||
version = "0.9.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
|
||||||
dependencies = [
|
|
||||||
"cipher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "curve25519-dalek"
|
|
||||||
version = "4.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures 0.2.17",
|
|
||||||
"curve25519-dalek-derive",
|
|
||||||
"digest 0.10.7",
|
|
||||||
"fiat-crypto",
|
|
||||||
"rustc_version",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "curve25519-dalek-derive"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -1183,16 +1019,6 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "der"
|
|
||||||
version = "0.7.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
|
||||||
dependencies = [
|
|
||||||
"const-oid 0.9.6",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der-parser"
|
name = "der-parser"
|
||||||
version = "9.0.0"
|
version = "9.0.0"
|
||||||
@@ -1251,9 +1077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"const-oid 0.9.6",
|
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1263,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.0",
|
"block-buffer 0.12.0",
|
||||||
"const-oid 0.10.2",
|
"const-oid",
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1314,66 +1138,12 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ecdsa"
|
|
||||||
version = "0.16.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
|
||||||
dependencies = [
|
|
||||||
"der",
|
|
||||||
"digest 0.10.7",
|
|
||||||
"elliptic-curve",
|
|
||||||
"rfc6979",
|
|
||||||
"signature",
|
|
||||||
"spki",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ed25519"
|
|
||||||
version = "2.2.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
|
||||||
dependencies = [
|
|
||||||
"signature",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ed25519-dalek"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
|
||||||
dependencies = [
|
|
||||||
"curve25519-dalek",
|
|
||||||
"ed25519",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "elliptic-curve"
|
|
||||||
version = "0.13.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
|
||||||
dependencies = [
|
|
||||||
"base16ct",
|
|
||||||
"crypto-bigint",
|
|
||||||
"digest 0.10.7",
|
|
||||||
"ff",
|
|
||||||
"generic-array",
|
|
||||||
"group",
|
|
||||||
"pkcs8",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"sec1",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-io"
|
name = "embedded-io"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -1477,22 +1247,6 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ff"
|
|
||||||
version = "0.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fiat-crypto"
|
|
||||||
version = "0.2.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1735,7 +1489,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1778,16 +1531,6 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ghash"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
|
||||||
dependencies = [
|
|
||||||
"opaque-debug",
|
|
||||||
"polyval",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.33.0"
|
version = "0.33.0"
|
||||||
@@ -1856,17 +1599,6 @@ dependencies = [
|
|||||||
"memmap2",
|
"memmap2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "group"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
|
||||||
dependencies = [
|
|
||||||
"ff",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -1955,15 +1687,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
|
||||||
dependencies = [
|
|
||||||
"digest 0.10.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.26.0"
|
version = "0.26.0"
|
||||||
@@ -2452,9 +2175,6 @@ name = "lazy_static"
|
|||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
dependencies = [
|
|
||||||
"spin",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
@@ -2540,6 +2260,42 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "llm-engine"
|
||||||
|
version = "0.2.1"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"dotenv",
|
||||||
|
"eventsource-stream",
|
||||||
|
"futures",
|
||||||
|
"llm-engine-macros",
|
||||||
|
"reqwest",
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"trybuild",
|
||||||
|
"wiremock",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "llm-engine-macros"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2627,8 +2383,8 @@ dependencies = [
|
|||||||
name = "manifest"
|
name = "manifest"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
"llm-engine",
|
||||||
"protocol",
|
"protocol",
|
||||||
"secrets",
|
"secrets",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2755,11 +2511,11 @@ dependencies = [
|
|||||||
name = "memory"
|
name = "memory"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"libc",
|
"libc",
|
||||||
"lint-common",
|
"lint-common",
|
||||||
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2915,22 +2671,6 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-bigint-dig"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
|
||||||
dependencies = [
|
|
||||||
"lazy_static",
|
|
||||||
"libm",
|
|
||||||
"num-integer",
|
|
||||||
"num-iter",
|
|
||||||
"num-traits",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"smallvec",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2957,16 +2697,6 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-iter"
|
|
||||||
version = "0.1.46"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
|
||||||
dependencies = [
|
|
||||||
"num-integer",
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2974,7 +2704,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
"libm",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3035,12 +2764,6 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opaque-debug"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.76"
|
version = "0.10.76"
|
||||||
@@ -3094,44 +2817,6 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "p256"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
|
||||||
dependencies = [
|
|
||||||
"ecdsa",
|
|
||||||
"elliptic-curve",
|
|
||||||
"primeorder",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "p384"
|
|
||||||
version = "0.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
|
||||||
dependencies = [
|
|
||||||
"ecdsa",
|
|
||||||
"elliptic-curve",
|
|
||||||
"primeorder",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "p521"
|
|
||||||
version = "0.13.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
|
|
||||||
dependencies = [
|
|
||||||
"base16ct",
|
|
||||||
"ecdsa",
|
|
||||||
"elliptic-curve",
|
|
||||||
"primeorder",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -3161,15 +2846,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pbkdf2"
|
|
||||||
version = "0.12.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
|
||||||
dependencies = [
|
|
||||||
"digest 0.10.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdf-extract"
|
name = "pdf-extract"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -3187,15 +2863,6 @@ dependencies = [
|
|||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pem-rfc7468"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -3341,27 +3008,6 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -3374,29 +3020,6 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "poly1305"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
|
||||||
dependencies = [
|
|
||||||
"cpufeatures 0.2.17",
|
|
||||||
"opaque-debug",
|
|
||||||
"universal-hash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "polyval"
|
|
||||||
version = "0.6.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures 0.2.17",
|
|
||||||
"opaque-debug",
|
|
||||||
"universal-hash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pom"
|
name = "pom"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -3477,15 +3100,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "primeorder"
|
|
||||||
version = "0.13.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
|
||||||
dependencies = [
|
|
||||||
"elliptic-curve",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -3910,16 +3524,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rfc6979"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
|
||||||
dependencies = [
|
|
||||||
"hmac",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3934,27 +3538,6 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rsa"
|
|
||||||
version = "0.9.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
|
||||||
dependencies = [
|
|
||||||
"const-oid 0.9.6",
|
|
||||||
"digest 0.10.7",
|
|
||||||
"num-bigint-dig",
|
|
||||||
"num-integer",
|
|
||||||
"num-traits",
|
|
||||||
"pkcs1",
|
|
||||||
"pkcs8",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"signature",
|
|
||||||
"spki",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -4161,20 +3744,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sec1"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
|
||||||
dependencies = [
|
|
||||||
"base16ct",
|
|
||||||
"der",
|
|
||||||
"generic-array",
|
|
||||||
"pkcs8",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4397,10 +3966,10 @@ dependencies = [
|
|||||||
name = "session-store"
|
name = "session-store"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"futures",
|
"futures",
|
||||||
|
"llm-engine",
|
||||||
"protocol",
|
"protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4490,16 +4059,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signature"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
|
||||||
dependencies = [
|
|
||||||
"digest 0.10.7",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -4543,72 +4102,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spki"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"der",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ssh-cipher"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
|
|
||||||
dependencies = [
|
|
||||||
"aes",
|
|
||||||
"aes-gcm",
|
|
||||||
"cbc",
|
|
||||||
"chacha20",
|
|
||||||
"cipher",
|
|
||||||
"ctr",
|
|
||||||
"poly1305",
|
|
||||||
"ssh-encoding",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ssh-encoding"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"pem-rfc7468",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ssh-key"
|
|
||||||
version = "0.6.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
|
|
||||||
dependencies = [
|
|
||||||
"bcrypt-pbkdf",
|
|
||||||
"ed25519-dalek",
|
|
||||||
"p256",
|
|
||||||
"p384",
|
|
||||||
"p521",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"rsa",
|
|
||||||
"sec1",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"signature",
|
|
||||||
"ssh-cipher",
|
|
||||||
"ssh-encoding",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4914,10 +4407,10 @@ dependencies = [
|
|||||||
name = "ticket"
|
name = "ticket"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"fs4",
|
"fs4",
|
||||||
|
"llm-engine",
|
||||||
"project-record",
|
"project-record",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"schemars",
|
"schemars",
|
||||||
@@ -5118,11 +4611,11 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
|||||||
name = "tools"
|
name = "tools"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"filetime",
|
"filetime",
|
||||||
"fs-operation",
|
"fs-operation",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"markup5ever_rcdom",
|
"markup5ever_rcdom",
|
||||||
"pdf-extract",
|
"pdf-extract",
|
||||||
@@ -5300,11 +4793,11 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
|||||||
name = "tui"
|
name = "tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"client",
|
"client",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"fs4",
|
"fs4",
|
||||||
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
@@ -5440,16 +4933,6 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "universal-hash"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
|
||||||
dependencies = [
|
|
||||||
"crypto-common 0.1.7",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
@@ -6576,7 +6059,6 @@ dependencies = [
|
|||||||
name = "worker"
|
name = "worker"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6591,6 +6073,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"libc",
|
"libc",
|
||||||
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"mcp",
|
"mcp",
|
||||||
"memory",
|
"memory",
|
||||||
@@ -6624,13 +6107,13 @@ dependencies = [
|
|||||||
name = "worker-runtime"
|
name = "worker-runtime"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"decodal",
|
"decodal",
|
||||||
"flow",
|
"flow",
|
||||||
"futures",
|
"futures",
|
||||||
|
"llm-engine",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -6647,22 +6130,9 @@ dependencies = [
|
|||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"url",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"workdir",
|
"workdir",
|
||||||
"worker",
|
"worker",
|
||||||
"workspace-api",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "workspace-api"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"ts-rs",
|
|
||||||
"workdir",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6735,7 +6205,6 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tui",
|
"tui",
|
||||||
"worker",
|
"worker",
|
||||||
"workspace-api",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6776,13 +6245,11 @@ dependencies = [
|
|||||||
"project-record",
|
"project-record",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"ssh-key",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"ticket",
|
"ticket",
|
||||||
@@ -6798,8 +6265,6 @@ dependencies = [
|
|||||||
"workdir",
|
"workdir",
|
||||||
"worker",
|
"worker",
|
||||||
"worker-runtime",
|
"worker-runtime",
|
||||||
"workspace-api",
|
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+6
-11
@@ -2,8 +2,8 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/agen",
|
"crates/llm-engine",
|
||||||
"crates/agen-macros",
|
"crates/llm-engine-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
@@ -27,14 +27,13 @@ members = [
|
|||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/merge-request",
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-api",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
default-members = [
|
default-members = [
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/agen",
|
"crates/llm-engine",
|
||||||
"crates/agen-macros",
|
"crates/llm-engine-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
@@ -58,7 +57,6 @@ default-members = [
|
|||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/merge-request",
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-api",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,8 +67,8 @@ license = "MIT"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal crates
|
# Internal crates
|
||||||
client = { path = "crates/client" }
|
client = { path = "crates/client" }
|
||||||
agen = { path = "crates/agen", version = "0.2" }
|
llm-engine = { path = "crates/llm-engine", version = "0.2" }
|
||||||
agen-macros = { path = "crates/agen-macros", version = "0.2" }
|
llm-engine-macros = { path = "crates/llm-engine-macros", version = "0.2" }
|
||||||
manifest = { path = "crates/manifest" }
|
manifest = { path = "crates/manifest" }
|
||||||
mcp = { path = "crates/mcp" }
|
mcp = { path = "crates/mcp" }
|
||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
@@ -80,7 +78,6 @@ ticket = { path = "crates/ticket" }
|
|||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
worker = { path = "crates/worker" }
|
worker = { path = "crates/worker" }
|
||||||
worker-runtime = { path = "crates/worker-runtime" }
|
worker-runtime = { path = "crates/worker-runtime" }
|
||||||
workspace-api = { path = "crates/workspace-api" }
|
|
||||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||||
yoi = { path = "crates/yoi" }
|
yoi = { path = "crates/yoi" }
|
||||||
protocol = { path = "crates/protocol" }
|
protocol = { path = "crates/protocol" }
|
||||||
@@ -115,7 +112,6 @@ tar = "0.4"
|
|||||||
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = "1.52"
|
tokio = "1.52"
|
||||||
@@ -125,5 +121,4 @@ toml = "1.1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
uuid = "1.23"
|
uuid = "1.23"
|
||||||
zeroize = "1"
|
|
||||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
- "8787"
|
- "8787"
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/server-data
|
- server-data:/server-data
|
||||||
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
- ./docker/workspace:/workspace:ro
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: yoi-webui:latest
|
image: yoi-webui:latest
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "agen-macros"
|
|
||||||
description = "Procedural macros for declaring agen tools"
|
|
||||||
version = "0.2.0"
|
|
||||||
edition.workspace = true
|
|
||||||
rust-version = "1.85"
|
|
||||||
license.workspace = true
|
|
||||||
readme = "README.md"
|
|
||||||
repository = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
documentation = "https://docs.rs/agen-macros"
|
|
||||||
keywords = ["llm", "agent", "tools", "macros"]
|
|
||||||
categories = ["development-tools::procedural-macro-helpers"]
|
|
||||||
include = ["src/**", "README.md", "LICENSE"]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
proc-macro = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
proc-macro2 = "1"
|
|
||||||
quote = "1"
|
|
||||||
syn = { version = "2", features = ["full"] }
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Copyright 2026 Hare
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# agen-macros
|
|
||||||
|
|
||||||
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
|
|
||||||
|
|
||||||
Applications should normally depend only on `agen` and import its re-exports:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use agen::tool_registry;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Tools;
|
|
||||||
|
|
||||||
#[tool_registry]
|
|
||||||
impl Tools {
|
|
||||||
/// Returns the supplied text.
|
|
||||||
#[tool]
|
|
||||||
async fn echo(
|
|
||||||
&self,
|
|
||||||
#[description = "Text to return"] text: String,
|
|
||||||
) -> Result<String, std::io::Error> {
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`#[tool_registry]` generates the argument schema, a `Tool` implementation, and an `<method>_definition` constructor. It rejects arguments of its own, duplicate `#[tool]` markers, malformed or duplicate `#[description = "..."]` attributes, and non-identifier argument patterns.
|
|
||||||
|
|
||||||
Generated code targets the canonical `::agen` path and uses implementation dependencies re-exported by `agen`; consumers do not need direct `serde`, `schemars`, `serde_json`, or `async-trait` dependencies. Renaming the `agen` dependency in `Cargo.toml` is not currently supported.
|
|
||||||
|
|
||||||
This companion package is published before the matching `agen` release. Its public contract is the generated API consumed by `agen`, and its minor version compatibility follows the `agen` 0.2 series.
|
|
||||||
|
|
||||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
//! Procedural macros for declaring [`agen`](https://docs.rs/agen) tools.
|
|
||||||
//!
|
|
||||||
//! [`tool_registry`] expands methods marked with `#[tool]` into `agen::tool::Tool`
|
|
||||||
//! implementations and tool definitions. Applications normally use the re-exports from
|
|
||||||
//! `agen`; this companion crate exists so those macros can be published and versioned
|
|
||||||
//! independently.
|
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
|
||||||
use quote::{format_ident, quote};
|
|
||||||
use syn::{
|
|
||||||
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
|
||||||
spanned::Spanned,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
|
|
||||||
///
|
|
||||||
/// Method doc comments become the tool description. An argument can use
|
|
||||||
/// `#[description = "..."]` to supply its JSON Schema description.
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// #[derive(Clone)]
|
|
||||||
/// struct MyApp;
|
|
||||||
///
|
|
||||||
/// #[agen::tool_registry]
|
|
||||||
/// impl MyApp {
|
|
||||||
/// /// Retrieves a user by ID.
|
|
||||||
/// #[tool]
|
|
||||||
/// async fn get_user(
|
|
||||||
/// &self,
|
|
||||||
/// #[description = "The user ID"] user_id: String,
|
|
||||||
/// ) -> Result<String, std::io::Error> {
|
|
||||||
/// todo!()
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// This generates a `ToolGetUser` wrapper, a `GetUserArgs` schema type, and
|
|
||||||
/// `MyApp::get_user_definition()`.
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
let attr = proc_macro2::TokenStream::from(attr);
|
|
||||||
let impl_block = parse_macro_input!(item as ItemImpl);
|
|
||||||
|
|
||||||
expand_tool_registry(attr, impl_block)
|
|
||||||
.unwrap_or_else(syn::Error::into_compile_error)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_tool_registry(
|
|
||||||
attr: proc_macro2::TokenStream,
|
|
||||||
mut impl_block: ItemImpl,
|
|
||||||
) -> syn::Result<proc_macro2::TokenStream> {
|
|
||||||
if !attr.is_empty() {
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
attr.span(),
|
|
||||||
"tool_registry does not accept arguments",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let self_ty = impl_block.self_ty.as_ref().clone();
|
|
||||||
let mut generated_items = Vec::new();
|
|
||||||
|
|
||||||
for item in &mut impl_block.items {
|
|
||||||
let ImplItem::Fn(method) = item else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let tool_attrs: Vec<_> = method
|
|
||||||
.attrs
|
|
||||||
.iter()
|
|
||||||
.filter(|attr| attr.path().is_ident("tool"))
|
|
||||||
.collect();
|
|
||||||
if tool_attrs.len() > 1 {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
tool_attrs[1],
|
|
||||||
"duplicate #[tool] attribute",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let Some(tool_attr) = tool_attrs.first() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !matches!(tool_attr.meta, Meta::Path(_)) {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
tool_attr,
|
|
||||||
"#[tool] does not accept arguments",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
method.attrs.retain(|attr| !attr.path().is_ident("tool"));
|
|
||||||
generated_items.push(generate_tool_impl(&self_ty, method)?);
|
|
||||||
|
|
||||||
for input in &mut method.sig.inputs {
|
|
||||||
if let FnArg::Typed(pat_type) = input {
|
|
||||||
pat_type
|
|
||||||
.attrs
|
|
||||||
.retain(|attr| !attr.path().is_ident("description"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#impl_block
|
|
||||||
|
|
||||||
#(#generated_items)*
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
|
|
||||||
for attr in attrs {
|
|
||||||
if attr.path().is_ident("doc")
|
|
||||||
&& let Meta::NameValue(meta) = &attr.meta
|
|
||||||
&& let syn::Expr::Lit(expr_lit) = &meta.value
|
|
||||||
&& let Lit::Str(lit_str) = &expr_lit.lit
|
|
||||||
{
|
|
||||||
let line = lit_str.value();
|
|
||||||
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
|
||||||
lines.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_description_attr(attrs: &[Attribute]) -> syn::Result<Option<String>> {
|
|
||||||
let mut description = None;
|
|
||||||
|
|
||||||
for attr in attrs
|
|
||||||
.iter()
|
|
||||||
.filter(|attr| attr.path().is_ident("description"))
|
|
||||||
{
|
|
||||||
let value = match &attr.meta {
|
|
||||||
Meta::NameValue(meta) => match &meta.value {
|
|
||||||
syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
|
|
||||||
Lit::Str(value) => value.value(),
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"description must be a string literal",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"description must be a string literal",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"expected #[description = \"...\"]",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if description.replace(value).is_some() {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"duplicate #[description] attribute",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(description)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn argument_ident(pat: &Pat) -> syn::Result<&syn::Ident> {
|
|
||||||
match pat {
|
|
||||||
Pat::Ident(pat_ident) => Ok(&pat_ident.ident),
|
|
||||||
_ => Err(syn::Error::new_spanned(
|
|
||||||
pat,
|
|
||||||
"tool arguments must use simple identifier patterns",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_tool_execution_context_type(ty: &Type) -> bool {
|
|
||||||
let Type::Path(path) = ty else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
path.path
|
|
||||||
.segments
|
|
||||||
.last()
|
|
||||||
.is_some_and(|segment| segment.ident == "ToolExecutionContext")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_tool_impl(
|
|
||||||
self_ty: &Type,
|
|
||||||
method: &syn::ImplItemFn,
|
|
||||||
) -> syn::Result<proc_macro2::TokenStream> {
|
|
||||||
let sig = &method.sig;
|
|
||||||
let method_name = &sig.ident;
|
|
||||||
let tool_name = method_name.to_string();
|
|
||||||
|
|
||||||
let pascal_name = to_pascal_case(&method_name.to_string());
|
|
||||||
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
|
||||||
let args_struct_name = format_ident!("{}Args", pascal_name);
|
|
||||||
let definition_name = format_ident!("{}_definition", method_name);
|
|
||||||
|
|
||||||
let description = extract_doc_comment(&method.attrs);
|
|
||||||
let description = if description.is_empty() {
|
|
||||||
format!("Tool: {}", tool_name)
|
|
||||||
} else {
|
|
||||||
description
|
|
||||||
};
|
|
||||||
|
|
||||||
let method_args: Vec<_> = sig
|
|
||||||
.inputs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|arg| match arg {
|
|
||||||
FnArg::Typed(pat_type) => Some(pat_type),
|
|
||||||
FnArg::Receiver(_) => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let json_args: Vec<_> = method_args
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|pat_type| !is_tool_execution_context_type(pat_type.ty.as_ref()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let arg_fields: Vec<_> = json_args
|
|
||||||
.iter()
|
|
||||||
.map(|pat_type| {
|
|
||||||
let field_name = argument_ident(pat_type.pat.as_ref())?;
|
|
||||||
let ty = &pat_type.ty;
|
|
||||||
let description = extract_description_attr(&pat_type.attrs)?;
|
|
||||||
|
|
||||||
Ok(if let Some(description) = description {
|
|
||||||
quote! {
|
|
||||||
#[schemars(description = #description)]
|
|
||||||
pub #field_name: #ty
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
pub #field_name: #ty
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<syn::Result<_>>()?;
|
|
||||||
|
|
||||||
let call_args: Vec<_> = method_args
|
|
||||||
.iter()
|
|
||||||
.map(|pat_type| {
|
|
||||||
if is_tool_execution_context_type(pat_type.ty.as_ref()) {
|
|
||||||
Ok(quote! { ctx.clone() })
|
|
||||||
} else {
|
|
||||||
let ident = argument_ident(pat_type.pat.as_ref())?;
|
|
||||||
Ok(quote! { args.#ident })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<syn::Result<_>>()?;
|
|
||||||
let method_call = if call_args.is_empty() {
|
|
||||||
quote! { self.ctx.#method_name() }
|
|
||||||
} else {
|
|
||||||
quote! { self.ctx.#method_name(#(#call_args),*) }
|
|
||||||
};
|
|
||||||
|
|
||||||
let awaiter = if sig.asyncness.is_some() {
|
|
||||||
quote! { .await }
|
|
||||||
} else {
|
|
||||||
quote! {}
|
|
||||||
};
|
|
||||||
|
|
||||||
let result_handling = if is_result_type(&sig.output) {
|
|
||||||
quote! {
|
|
||||||
match result {
|
|
||||||
Ok(val) => Ok(format!("{:?}", val).into()),
|
|
||||||
Err(error) => Err(::agen::tool::ToolError::ExecutionFailed(format!("{}", error))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
Ok(format!("{:?}", result).into())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let args_struct_def = quote! {
|
|
||||||
#[derive(
|
|
||||||
::agen::__private::serde::Deserialize,
|
|
||||||
::agen::__private::schemars::JsonSchema,
|
|
||||||
)]
|
|
||||||
#[serde(crate = "::agen::__private::serde")]
|
|
||||||
#[schemars(crate = "::agen::__private::schemars")]
|
|
||||||
struct #args_struct_name {
|
|
||||||
#(#arg_fields),*
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let execute_body = if json_args.is_empty() {
|
|
||||||
quote! {
|
|
||||||
let _: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
|
||||||
.unwrap_or(#args_struct_name {});
|
|
||||||
|
|
||||||
let result = #method_call #awaiter;
|
|
||||||
#result_handling
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
let args: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
|
||||||
.map_err(|error| ::agen::tool::ToolError::InvalidArgument(error.to_string()))?;
|
|
||||||
|
|
||||||
let result = #method_call #awaiter;
|
|
||||||
#result_handling
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#args_struct_def
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct #tool_struct_name {
|
|
||||||
ctx: #self_ty,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[::agen::__private::async_trait::async_trait]
|
|
||||||
impl ::agen::tool::Tool for #tool_struct_name {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
ctx: ::agen::tool::ToolExecutionContext,
|
|
||||||
) -> Result<::agen::tool::ToolOutput, ::agen::tool::ToolError> {
|
|
||||||
let _ = &ctx;
|
|
||||||
#execute_body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #self_ty {
|
|
||||||
/// Returns a tool definition for registration with an `agen::Engine`.
|
|
||||||
pub fn #definition_name(&self) -> ::agen::tool::ToolDefinition {
|
|
||||||
let ctx = self.clone();
|
|
||||||
::std::sync::Arc::new(move || {
|
|
||||||
let schema = ::agen::__private::schemars::schema_for!(#args_struct_name);
|
|
||||||
let meta = ::agen::tool::ToolMeta::new(#tool_name)
|
|
||||||
.description(#description)
|
|
||||||
.input_schema(
|
|
||||||
::agen::__private::serde_json::to_value(schema)
|
|
||||||
.unwrap_or_else(|_| ::agen::__private::serde_json::json!({})),
|
|
||||||
);
|
|
||||||
let tool: ::std::sync::Arc<dyn ::agen::tool::Tool> =
|
|
||||||
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
|
||||||
(meta, tool)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_result_type(return_type: &ReturnType) -> bool {
|
|
||||||
match return_type {
|
|
||||||
ReturnType::Default => false,
|
|
||||||
ReturnType::Type(_, ty) => {
|
|
||||||
if let Type::Path(type_path) = ty.as_ref()
|
|
||||||
&& let Some(segment) = type_path.path.segments.last()
|
|
||||||
{
|
|
||||||
return segment.ident == "Result";
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_pascal_case(s: &str) -> String {
|
|
||||||
s.split('_')
|
|
||||||
.map(|part| {
|
|
||||||
let mut chars = part.chars();
|
|
||||||
match chars.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker attribute interpreted by [`tool_registry`].
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
marker_attribute("tool", attr, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Argument description marker interpreted by [`tool_registry`].
|
|
||||||
///
|
|
||||||
/// Use it as `#[description = "The argument description"]` on a tool method argument.
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
marker_attribute("description", attr, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn marker_attribute(name: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
if attr.is_empty() {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
syn::Error::new(
|
|
||||||
proc_macro2::Span::call_site(),
|
|
||||||
format!("{name} is a marker interpreted by #[tool_registry]"),
|
|
||||||
)
|
|
||||||
.into_compile_error()
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use quote::quote;
|
|
||||||
use syn::parse_quote;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_tool_registry_arguments() {
|
|
||||||
let implementation: ItemImpl = parse_quote!(impl Registry {});
|
|
||||||
let error = expand_tool_registry(quote!(unexpected), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("does not accept arguments"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_duplicate_tool_markers() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
#[tool]
|
|
||||||
fn inspect(&self) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("duplicate #[tool]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_description_attributes() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
fn inspect(&self, #[description] input: String) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("expected #[description"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_duplicate_description_attributes() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
fn inspect(
|
|
||||||
&self,
|
|
||||||
#[description = "first"]
|
|
||||||
#[description = "second"]
|
|
||||||
input: String,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("duplicate #[description]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_code_uses_only_agen_runtime_paths() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
fn inspect(&self, input: String) -> Result<String, Error> {
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let expanded = expand_tool_registry(quote!(), implementation)
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
assert!(expanded.contains(":: agen :: tool :: Tool"));
|
|
||||||
assert!(expanded.contains(":: agen :: __private :: serde_json"));
|
|
||||||
assert!(expanded.contains(":: agen :: __private :: serde"));
|
|
||||||
assert!(expanded.contains(":: agen :: __private :: schemars"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Copyright 2026 Hare
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# agen
|
|
||||||
|
|
||||||
`agen` is a provider-neutral Rust engine for streaming LLM applications that use tools. It owns the turn loop, typed conversation history, provider wire-format adapters, tool execution, interceptors, usage accounting, and cache-aware state transitions.
|
|
||||||
|
|
||||||
> `agen` is pre-1.0. Public APIs may change between minor releases.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
agen = "0.2.1"
|
|
||||||
```
|
|
||||||
|
|
||||||
The default feature set is intentionally empty. Enable the experimental Codex/ChatGPT authentication adapter when needed:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
agen = { version = "0.2.1", features = ["codex"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
`agen` requires Rust 1.86 or newer. The companion `agen-macros` package requires Rust 1.85 or newer.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
|
||||||
|
|
||||||
```no_run
|
|
||||||
use agen::{Engine, EngineError, History};
|
|
||||||
use agen::llm_client::LlmClient;
|
|
||||||
|
|
||||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
|
||||||
let mut history = History::new();
|
|
||||||
let output = Engine::new(client)
|
|
||||||
.system_prompt("You are a concise assistant.")
|
|
||||||
.run(&mut history, "Explain typed state in one sentence.")
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut engine = output.engine;
|
|
||||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Declaring tools
|
|
||||||
|
|
||||||
The tool macros are re-exported by `agen`; applications do not need direct dependencies on `serde`, `schemars`, `serde_json`, or `async-trait` for generated code.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use agen::tool_registry;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Tools;
|
|
||||||
|
|
||||||
#[tool_registry]
|
|
||||||
impl Tools {
|
|
||||||
/// Returns the supplied text.
|
|
||||||
#[tool]
|
|
||||||
async fn echo(
|
|
||||||
&self,
|
|
||||||
#[description = "Text to return"] text: String,
|
|
||||||
) -> Result<String, std::io::Error> {
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let definition = Tools.echo_definition();
|
|
||||||
assert_eq!(definition().0.name, "echo");
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated API uses the canonical crate name `agen`. Renaming the `agen` dependency in `Cargo.toml` is not currently supported by these macros.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
| Feature | Default | Adds |
|
|
||||||
|---|---:|---|
|
|
||||||
| `codex` | No | Experimental Codex/ChatGPT auth-file loading and token refresh support |
|
|
||||||
|
|
||||||
The base crate includes provider-neutral transport and Anthropic, OpenAI-compatible, Gemini, and Ollama wire-format schemes. See [`llm_client`](https://docs.rs/agen/latest/agen/llm_client/) for the client boundary.
|
|
||||||
|
|
||||||
## Architecture and API scope
|
|
||||||
|
|
||||||
The current public modules cover the engine, typed history, client transport/schemes, timeline events, tools, interceptors, pruning, token estimation, and usage records. Their relationships are described in [Architecture](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/architecture.md); behavioral requirements are summarized in [Requirements](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/requirements.md).
|
|
||||||
|
|
||||||
Low-level modules remain public in the 0.2 series because downstream Yoi components implement custom clients, event handlers, pruning policies, and tool registries against them. This surface is versioned as pre-1.0 API rather than declared stable.
|
|
||||||
|
|
||||||
## Packaging and security
|
|
||||||
|
|
||||||
The published package contains source, public documentation, curated examples, and deterministic tests/fixtures. Credentialed fixture-recording utilities are intentionally excluded. Examples that contact a provider read credentials from environment variables and never embed production credentials.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# agen architecture
|
|
||||||
|
|
||||||
`agen` separates orchestration, event projection, and provider transport so applications can replace an LLM client without changing the turn loop or tool model.
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌────────────────────────────────────────────┐
|
|
||||||
│ Engine │
|
|
||||||
│ turn loop · interceptors · tool execution │
|
|
||||||
│ typed state: Mutable → Locked → Mutable │
|
|
||||||
└─────────────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────▼──────────────────────┐
|
|
||||||
│ Timeline │
|
|
||||||
│ event dispatch · block collectors │
|
|
||||||
└─────────────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────▼──────────────────────┐
|
|
||||||
│ LlmClient │
|
|
||||||
│ transport · provider wire-format schemes │
|
|
||||||
└────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Main modules
|
|
||||||
|
|
||||||
| Module | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `engine` | Turn execution, pause/resume, retries, tool integration, and callbacks |
|
|
||||||
| `state` | Sealed `Mutable` and `Locked` type-state markers |
|
|
||||||
| `interceptor` | Application-owned control decisions at orchestration boundaries |
|
|
||||||
| `tool` / `tool_server` | Tool metadata, registration, execution, and bounded output |
|
|
||||||
| `timeline` | Streaming event dispatch, handlers, and block assembly |
|
|
||||||
| `llm_client` | Provider-neutral request, response, auth, transport, and scheme contracts |
|
|
||||||
| `providers` | Optional higher-level provider adapters such as the `codex` feature |
|
|
||||||
| `prune` / `token_counter` | Cache-aware history reduction and token estimation |
|
|
||||||
| `usage_record` | Request and token usage accounting |
|
|
||||||
|
|
||||||
## Request flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Engine history
|
|
||||||
→ provider-neutral Request
|
|
||||||
→ Scheme::build_request
|
|
||||||
→ Provider transport
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
streaming response bytes
|
|
||||||
→ Scheme event parsing
|
|
||||||
→ unified Event values
|
|
||||||
→ Timeline handlers and collectors
|
|
||||||
→ Engine history/tool decisions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type state and cache protection
|
|
||||||
|
|
||||||
`Engine<C, Mutable>` permits configuration and history editing. `Engine::run` or `Engine::lock` commits the current prefix and produces `Engine<C, Locked>`. The locked engine may append turns without mutating the committed prefix. `Engine::unlock` explicitly returns to mutable state when an application accepts losing that cache guarantee.
|
|
||||||
|
|
||||||
## Public surface
|
|
||||||
|
|
||||||
The 0.2 series exposes the low-level client, timeline, tool, pruning, and usage modules because custom clients and orchestration hosts build directly on them. These APIs are intentionally provider-neutral but remain pre-1.0 and may change in later minor releases.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# agen requirements
|
|
||||||
|
|
||||||
## R1: Turn execution and continuation
|
|
||||||
|
|
||||||
- `Engine::run` starts a turn and loops through provider output and tool calls.
|
|
||||||
- An `Interceptor` may continue, cancel, or pause work at defined orchestration boundaries.
|
|
||||||
- `Engine::resume` continues paused generation without fabricating another user message.
|
|
||||||
- Cancellation and provider errors are represented as typed `EngineError` values.
|
|
||||||
|
|
||||||
## R2: Explicit cache-preserving state
|
|
||||||
|
|
||||||
- `Engine<C, Mutable>` permits configuration and history edits.
|
|
||||||
- `Engine::run` or `Engine::lock` transitions to `Engine<C, Locked>` and records the committed prefix.
|
|
||||||
- A locked engine appends turns but cannot mutate that prefix through mutable-only APIs.
|
|
||||||
- `Engine::unlock` explicitly abandons the lock before configuration or history changes.
|
|
||||||
|
|
||||||
## R3: Tool declarations and execution
|
|
||||||
|
|
||||||
- `#[tool_registry]` generates a schema and `Tool` implementation for methods marked `#[tool]`.
|
|
||||||
- `#[description = "..."]` supplies argument descriptions in generated JSON Schema.
|
|
||||||
- Generated code resolves its runtime and helper dependencies through `::agen`.
|
|
||||||
- Invalid and duplicate marker attributes produce compile errors rather than panics.
|
|
||||||
- Tools execute through `ToolServer` with typed context, errors, and output limits.
|
|
||||||
|
|
||||||
## R4: Provider-neutral orchestration
|
|
||||||
|
|
||||||
- `LlmClient` is the boundary between the engine and provider-specific transport.
|
|
||||||
- Request/response schemes translate provider wire formats into shared request and event types.
|
|
||||||
- Interceptors, tool execution, timeline collection, and pruning stay above the provider transport.
|
|
||||||
- Provider-specific capabilities are optional features when they require additional policy or dependencies.
|
|
||||||
|
|
||||||
## R5: Publication quality
|
|
||||||
|
|
||||||
- crates.io metadata includes license, repository, documentation, README, categories, keywords, and MSRV.
|
|
||||||
- The default feature set and each optional feature compile and test independently.
|
|
||||||
- Macro expansion compiles in a downstream-style integration test without direct helper dependencies.
|
|
||||||
- rustdoc builds without dependency documentation.
|
|
||||||
- Package contents are explicitly bounded and exclude credentialed fixture-recording utilities.
|
|
||||||
- `cargo package` and `cargo publish --dry-run` are run for `agen-macros` before `agen` because the main package depends on its companion package.
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
//! Typed conversation history containers.
|
|
||||||
//!
|
|
||||||
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
|
|
||||||
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
|
|
||||||
//! preserves each item and annotation as one entry for clone/truncate/restore
|
|
||||||
//! style history operations.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::Item;
|
|
||||||
|
|
||||||
/// One conversation-history entry with host-owned annotation.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct HistoryEntry<A = ()> {
|
|
||||||
/// Provider/model-visible conversation item.
|
|
||||||
pub item: Item,
|
|
||||||
/// Host-domain metadata kept with the item and never projected to providers.
|
|
||||||
pub annotation: A,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A> HistoryEntry<A> {
|
|
||||||
/// Build an entry from an item and its annotation.
|
|
||||||
pub fn new(item: Item, annotation: A) -> Self {
|
|
||||||
Self { item, annotation }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split the entry into its item and annotation.
|
|
||||||
pub fn into_parts(self) -> (Item, A) {
|
|
||||||
(self.item, self.annotation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HistoryEntry<()> {
|
|
||||||
/// Build a unit-annotated entry.
|
|
||||||
pub fn from_item(item: Item) -> Self {
|
|
||||||
Self {
|
|
||||||
item,
|
|
||||||
annotation: (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Conversation history with one annotation per item.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
||||||
pub struct History<A = ()> {
|
|
||||||
entries: Vec<HistoryEntry<A>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A> History<A> {
|
|
||||||
/// Create an empty history.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
entries: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build history from already annotated entries, preserving order.
|
|
||||||
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
|
|
||||||
Self { entries }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace all entries as one restore/rebuild operation and return the old entries.
|
|
||||||
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
|
|
||||||
std::mem::replace(&mut self.entries, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Borrow annotated entries.
|
|
||||||
pub fn entries(&self) -> &[HistoryEntry<A>] {
|
|
||||||
&self.entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutably borrow annotated entries for host-owned rebuild operations.
|
|
||||||
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
|
|
||||||
&mut self.entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consume the history into annotated entries.
|
|
||||||
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
|
|
||||||
self.entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of entries.
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.entries.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the history is empty.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.entries.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Iterate over annotated entries.
|
|
||||||
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
|
|
||||||
self.entries.iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Iterate over provider-visible items only.
|
|
||||||
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
|
|
||||||
self.entries.iter().map(|entry| &entry.item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clone provider-visible items into a request-local projection.
|
|
||||||
pub fn items_cloned(&self) -> Vec<Item> {
|
|
||||||
self.items().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append an already annotated entry.
|
|
||||||
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
|
|
||||||
self.entries.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append many already annotated entries.
|
|
||||||
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
|
|
||||||
self.entries.extend(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Commit one item through a trusted annotation callback before it becomes live.
|
|
||||||
///
|
|
||||||
/// The callback may durably persist the item and returns the annotation that
|
|
||||||
/// must be stored with it. If the callback fails, the history is left unchanged.
|
|
||||||
pub fn append_with(
|
|
||||||
&mut self,
|
|
||||||
item: Item,
|
|
||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let annotation = annotate(&item)?;
|
|
||||||
self.entries.push(HistoryEntry { item, annotation });
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Commit items through a trusted annotation callback before they become live.
|
|
||||||
///
|
|
||||||
/// Items before a failure remain appended; the failing item and later items do
|
|
||||||
/// not enter history. This mirrors append-only durable logs where each accepted
|
|
||||||
/// item is already committed before the next item is attempted.
|
|
||||||
pub fn extend_with(
|
|
||||||
&mut self,
|
|
||||||
items: impl IntoIterator<Item = Item>,
|
|
||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
for item in items {
|
|
||||||
self.append_with(item, annotate)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truncate entries, preserving item+annotation pairing for retained entries.
|
|
||||||
pub fn truncate(&mut self, len: usize) {
|
|
||||||
self.entries.truncate(len);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all entries.
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.entries.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl History<()> {
|
|
||||||
/// Build unit-annotated history from provider-visible items.
|
|
||||||
pub fn from_items(items: Vec<Item>) -> Self {
|
|
||||||
Self {
|
|
||||||
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace history from provider-visible items using unit annotations.
|
|
||||||
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
|
|
||||||
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append one item with unit annotation.
|
|
||||||
pub fn push(&mut self, item: Item) {
|
|
||||||
self.entries.push(HistoryEntry::from_item(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append items with unit annotations.
|
|
||||||
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
|
|
||||||
self.entries
|
|
||||||
.extend(items.into_iter().map(HistoryEntry::from_item));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A> IntoIterator for History<A> {
|
|
||||||
type Item = HistoryEntry<A>;
|
|
||||||
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
|
|
||||||
|
|
||||||
fn into_iter(self) -> Self::IntoIter {
|
|
||||||
self.entries.into_iter()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, A> IntoIterator for &'a History<A> {
|
|
||||||
type Item = &'a HistoryEntry<A>;
|
|
||||||
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
|
|
||||||
|
|
||||||
fn into_iter(self) -> Self::IntoIter {
|
|
||||||
self.entries.iter()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#![doc = include_str!("../README.md")]
|
|
||||||
|
|
||||||
mod engine;
|
|
||||||
mod handler;
|
|
||||||
mod history;
|
|
||||||
mod message;
|
|
||||||
|
|
||||||
pub(crate) mod callback;
|
|
||||||
pub mod event;
|
|
||||||
pub mod interceptor;
|
|
||||||
pub mod llm_client;
|
|
||||||
pub mod providers;
|
|
||||||
pub mod prune;
|
|
||||||
pub mod state;
|
|
||||||
pub mod timeline;
|
|
||||||
pub mod token_counter;
|
|
||||||
pub mod tool;
|
|
||||||
pub mod tool_server;
|
|
||||||
pub mod usage_record;
|
|
||||||
|
|
||||||
pub use agen_macros::{description, tool, tool_registry};
|
|
||||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
|
||||||
pub use engine::{
|
|
||||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
|
||||||
LlmRetryNotice, StopReason, ToolRegistryError,
|
|
||||||
};
|
|
||||||
pub use handler::ToolUseBlockStart;
|
|
||||||
pub use history::{History, HistoryEntry};
|
|
||||||
pub use interceptor::Interceptor;
|
|
||||||
pub use message::{ContentPart, Item, Message, Role};
|
|
||||||
pub use tool::{
|
|
||||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
|
||||||
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
|
|
||||||
ToolResultDisposition,
|
|
||||||
};
|
|
||||||
pub use usage_record::UsageRecord;
|
|
||||||
|
|
||||||
/// Implementation dependencies used by code generated from `agen` macros.
|
|
||||||
///
|
|
||||||
/// This module is not a stable user-facing API. It is public only because macro expansion
|
|
||||||
/// happens in the downstream crate.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub mod __private {
|
|
||||||
pub use async_trait;
|
|
||||||
pub use schemars;
|
|
||||||
pub use serde;
|
|
||||||
pub use serde_json;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
mod common;
|
|
||||||
|
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
|
||||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
|
||||||
use common::MockLlmClient;
|
|
||||||
|
|
||||||
fn completed_text_events(text: &str) -> Vec<Event> {
|
|
||||||
vec![
|
|
||||||
Event::text_block_start(0),
|
|
||||||
Event::text_delta(0, text),
|
|
||||||
Event::text_block_stop(0, None),
|
|
||||||
Event::Status(StatusEvent {
|
|
||||||
status: ResponseStatus::Completed,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn run_preserves_item_annotations_without_projecting_them() {
|
|
||||||
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
|
||||||
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
|
||||||
let mut history = History::<String>::new();
|
|
||||||
let mut next = 0usize;
|
|
||||||
let mut annotate = |item: &Item| {
|
|
||||||
next += 1;
|
|
||||||
let kind = match item {
|
|
||||||
Item::Message { role, .. } => match role {
|
|
||||||
Role::User => "user",
|
|
||||||
Role::Assistant => "assistant",
|
|
||||||
Role::System => "system",
|
|
||||||
},
|
|
||||||
Item::ToolCall { .. } => "tool_call",
|
|
||||||
Item::ToolResult { .. } => "tool_result",
|
|
||||||
Item::Reasoning { .. } => "reasoning",
|
|
||||||
};
|
|
||||||
Ok(format!("{next}:{kind}"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let output = engine
|
|
||||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
|
||||||
assert_eq!(history.len(), 2);
|
|
||||||
assert_eq!(history.entries()[0].annotation, "1:user");
|
|
||||||
assert_eq!(history.entries()[1].annotation, "2:assistant");
|
|
||||||
assert_eq!(history.items_cloned().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_failure_does_not_make_item_live() {
|
|
||||||
let client = MockLlmClient::new(vec![]);
|
|
||||||
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
|
|
||||||
let mut history = History::<usize>::new();
|
|
||||||
let mut fail = |_item: &Item| Err("commit failed".to_string());
|
|
||||||
|
|
||||||
let err = engine
|
|
||||||
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
|
|
||||||
assert!(history.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replacement_keeps_items_and_annotations_together() {
|
|
||||||
let mut history = History::from_entries(vec![
|
|
||||||
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
|
|
||||||
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
history.truncate(1);
|
|
||||||
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
|
|
||||||
assert_eq!(history.entries()[0].annotation, "old-ann");
|
|
||||||
|
|
||||||
let previous = history.replace_entries(vec![HistoryEntry::new(
|
|
||||||
Item::user_message("restored"),
|
|
||||||
"restored-ann".to_string(),
|
|
||||||
)]);
|
|
||||||
|
|
||||||
assert_eq!(previous.len(), 1);
|
|
||||||
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
|
|
||||||
assert_eq!(history.entries()[0].annotation, "restored-ann");
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,14 +9,13 @@ protocol = { workspace = true }
|
|||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||||
tokio-tungstenite = { workspace = true }
|
tokio-tungstenite = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
workspace-api.workspace = true
|
|
||||||
workdir = { workspace = true }
|
workdir = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Does not own:
|
|||||||
- product command names (`yoi`)
|
- product command names (`yoi`)
|
||||||
- Worker state authority (`worker`, `session-store` worker metadata)
|
- Worker state authority (`worker`, `session-store` worker metadata)
|
||||||
- UI rendering (`tui`)
|
- UI rendering (`tui`)
|
||||||
- Engine turn semantics (`agen`)
|
- Engine turn semantics (`llm-engine`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use protocol::stream::{decode_event, encode_method};
|
use protocol::stream::{decode_event, encode_method};
|
||||||
use protocol::{ErrorCode, Event, Method};
|
use protocol::{ErrorCode, Event, Method};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||||
pub use workspace_api::{
|
|
||||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
|
||||||
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
|
||||||
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
|
|
||||||
WorkerImplementationSummary as BackendWorkerImplementationSummary,
|
|
||||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
|
||||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
|
||||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendRuntimeTarget {
|
pub struct BackendRuntimeTarget {
|
||||||
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
|
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
|
||||||
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// Workspace identity used for every Worker lifecycle and protocol operation.
|
|
||||||
pub workspace_id: String,
|
|
||||||
/// Backend-owned Runtime identity used as path authority.
|
/// Backend-owned Runtime identity used as path authority.
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
/// Backend-owned Worker identity used as path authority.
|
/// Backend-owned Worker identity used as path authority.
|
||||||
@@ -33,13 +23,11 @@ pub struct BackendRuntimeTarget {
|
|||||||
impl BackendRuntimeTarget {
|
impl BackendRuntimeTarget {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
workspace_id: impl Into<String>,
|
|
||||||
runtime_id: impl Into<String>,
|
runtime_id: impl Into<String>,
|
||||||
worker_id: impl Into<String>,
|
worker_id: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
workspace_id: workspace_id.into(),
|
|
||||||
runtime_id: runtime_id.into(),
|
runtime_id: runtime_id.into(),
|
||||||
worker_id: worker_id.into(),
|
worker_id: worker_id.into(),
|
||||||
}
|
}
|
||||||
@@ -69,36 +57,95 @@ impl BackendRuntimeListTarget {
|
|||||||
runtime_id,
|
runtime_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
self.workspace_id = Some(workspace_id.into());
|
pub struct BackendRuntimeListResponse<T> {
|
||||||
}
|
pub workspace_id: String,
|
||||||
|
pub limit: usize,
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<BackendDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_workspace(&mut self) {
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
self.workspace_id = None;
|
pub struct BackendRuntimeSummary {
|
||||||
}
|
pub runtime_id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub host_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<BackendDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> Option<&str> {
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
self.workspace_id.as_deref()
|
pub struct BackendWorkerWorkspaceSummary {
|
||||||
}
|
pub visibility: String,
|
||||||
|
pub identity: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn runtime_target(
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
&self,
|
pub struct BackendWorkerImplementationSummary {
|
||||||
runtime_id: impl Into<String>,
|
pub kind: String,
|
||||||
worker_id: impl Into<String>,
|
pub display_hint: String,
|
||||||
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
|
}
|
||||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
|
||||||
BackendRuntimeClientError::InvalidTarget(
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
"workspace_id is required before selecting a Backend worker".to_string(),
|
pub struct BackendWorkerCapabilitySummary {
|
||||||
)
|
pub can_stop: bool,
|
||||||
})?;
|
pub can_spawn_followup: bool,
|
||||||
Ok(BackendRuntimeTarget::new(
|
}
|
||||||
self.base_url.clone(),
|
|
||||||
workspace_id,
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
runtime_id,
|
pub struct BackendWorkerSummary {
|
||||||
worker_id,
|
pub runtime_id: String,
|
||||||
))
|
pub worker_id: String,
|
||||||
}
|
#[serde(default)]
|
||||||
|
pub human_key: Option<String>,
|
||||||
|
pub host_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub display_name: String,
|
||||||
|
pub label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub singleton_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub workspace: BackendWorkerWorkspaceSummary,
|
||||||
|
pub state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retention_state: String,
|
||||||
|
pub implementation: BackendWorkerImplementationSummary,
|
||||||
|
pub capabilities: BackendWorkerCapabilitySummary,
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_directory: Option<BackendWorkingDirectorySummary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<BackendDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct BackendWorkerRestoreResult {
|
||||||
|
pub state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub worker: Option<BackendWorkerSummary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<BackendDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct BackendWorkerRestoreResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub result: BackendWorkerRestoreResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -139,13 +186,7 @@ pub async fn list_backend_workers(
|
|||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
|
||||||
target
|
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated Backend Workspace scope"),
|
|
||||||
runtime_id,
|
|
||||||
);
|
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
return Ok(http
|
return Ok(http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -156,12 +197,7 @@ pub async fn list_backend_workers(
|
|||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtime_path = backend_runtimes_path(
|
let runtime_path = backend_runtimes_path(target.workspace_id.as_deref());
|
||||||
target
|
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated Backend Workspace scope"),
|
|
||||||
);
|
|
||||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
||||||
let runtimes = http
|
let runtimes = http
|
||||||
.get(runtime_url)
|
.get(runtime_url)
|
||||||
@@ -174,13 +210,8 @@ pub async fn list_backend_workers(
|
|||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut diagnostics = runtimes.diagnostics;
|
let mut diagnostics = runtimes.diagnostics;
|
||||||
for runtime in runtimes.items {
|
for runtime in runtimes.items {
|
||||||
let path = backend_runtime_workers_path(
|
let path =
|
||||||
target
|
backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id);
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated Backend Workspace scope"),
|
|
||||||
&runtime.runtime_id,
|
|
||||||
);
|
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
match http
|
match http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -197,7 +228,7 @@ pub async fn list_backend_workers(
|
|||||||
}
|
}
|
||||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
severity: BackendDiagnosticSeverity::Error,
|
severity: Some("error".to_string()),
|
||||||
message: format!(
|
message: format!(
|
||||||
"failed to list workers for runtime {}: {error}",
|
"failed to list workers for runtime {}: {error}",
|
||||||
runtime.runtime_id
|
runtime.runtime_id
|
||||||
@@ -225,13 +256,7 @@ pub async fn list_backend_stopped_workers(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
|
||||||
target
|
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated Backend Workspace scope"),
|
|
||||||
runtime_id,
|
|
||||||
);
|
|
||||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
||||||
Ok(http
|
Ok(http
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -247,11 +272,7 @@ pub async fn restore_backend_worker(
|
|||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let path = backend_runtime_worker_restore_path(
|
let path = backend_runtime_worker_restore_path(None, &target.runtime_id, &target.worker_id);
|
||||||
&target.workspace_id,
|
|
||||||
&target.runtime_id,
|
|
||||||
&target.worker_id,
|
|
||||||
);
|
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
Ok(http
|
Ok(http
|
||||||
.post(url)
|
.post(url)
|
||||||
@@ -419,11 +440,6 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
|
|||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if target.workspace_id.is_empty() {
|
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"workspace_id is required".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if target.runtime_id.is_empty() {
|
if target.runtime_id.is_empty() {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
"runtime_id is required".to_string(),
|
"runtime_id is required".to_string(),
|
||||||
@@ -450,19 +466,11 @@ fn validate_list_target(
|
|||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
match target.workspace_id.as_deref() {
|
if target.workspace_id.as_deref().is_some_and(str::is_empty) {
|
||||||
Some("") => {
|
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
"workspace_id must not be empty".to_string(),
|
"workspace_id must not be empty when provided".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"workspace selection is required before listing Backend workers".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some(_) => {}
|
|
||||||
}
|
|
||||||
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
|
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
"runtime_id must not be empty when provided".to_string(),
|
"runtime_id must not be empty when provided".to_string(),
|
||||||
@@ -471,35 +479,47 @@ fn validate_list_target(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtimes_path(workspace_id: &str) -> String {
|
fn backend_runtimes_path(workspace_id: Option<&str>) -> String {
|
||||||
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
|
match workspace_id {
|
||||||
|
Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)),
|
||||||
|
None => "/api/runtimes".to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String {
|
fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String {
|
||||||
format!(
|
match workspace_id {
|
||||||
|
Some(workspace_id) => format!(
|
||||||
"/api/w/{}/runtimes/{}/workers",
|
"/api/w/{}/runtimes/{}/workers",
|
||||||
path_segment_encode(workspace_id),
|
path_segment_encode(workspace_id),
|
||||||
path_segment_encode(runtime_id)
|
path_segment_encode(runtime_id)
|
||||||
)
|
),
|
||||||
|
None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_runtime_worker_restore_path(
|
fn backend_runtime_worker_restore_path(
|
||||||
workspace_id: &str,
|
workspace_id: Option<&str>,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
match workspace_id {
|
||||||
|
Some(workspace_id) => format!(
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/restore",
|
"/api/w/{}/runtimes/{}/workers/{}/restore",
|
||||||
path_segment_encode(workspace_id),
|
path_segment_encode(workspace_id),
|
||||||
path_segment_encode(runtime_id),
|
path_segment_encode(runtime_id),
|
||||||
path_segment_encode(worker_id)
|
path_segment_encode(worker_id)
|
||||||
)
|
),
|
||||||
|
None => format!(
|
||||||
|
"/api/runtimes/{}/workers/{}/restore",
|
||||||
|
path_segment_encode(runtime_id),
|
||||||
|
path_segment_encode(worker_id)
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
"/api/runtimes/{}/workers/{}/protocol/ws",
|
||||||
path_segment_encode(&target.workspace_id),
|
|
||||||
path_segment_encode(&target.runtime_id),
|
path_segment_encode(&target.runtime_id),
|
||||||
path_segment_encode(&target.worker_id)
|
path_segment_encode(&target.worker_id)
|
||||||
);
|
);
|
||||||
@@ -539,21 +559,25 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct BackendDiagnostic {
|
||||||
|
pub code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_url_uses_backend_runtime_worker_identity() {
|
fn protocol_url_uses_backend_runtime_worker_identity() {
|
||||||
let target = BackendRuntimeTarget::new(
|
let target =
|
||||||
"http://127.0.0.1:8787/",
|
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
|
||||||
"workspace alpha",
|
|
||||||
"runtime/one",
|
|
||||||
"worker one",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
protocol_ws_url(&target),
|
protocol_ws_url(&target),
|
||||||
"ws://127.0.0.1:8787/api/w/workspace%20alpha/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws"
|
"ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +586,6 @@ mod tests {
|
|||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"runtime_id": "arcadia",
|
"runtime_id": "arcadia",
|
||||||
"worker_id": "worker-opaque-64",
|
"worker_id": "worker-opaque-64",
|
||||||
"resource_key": "W-64",
|
|
||||||
"host_id": "host",
|
"host_id": "host",
|
||||||
"display_name": "Coder",
|
"display_name": "Coder",
|
||||||
"label": "Coder",
|
"label": "Coder",
|
||||||
@@ -599,8 +622,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workers_path_requires_workspace_scope_for_status_queries() {
|
fn workers_path_can_be_workspace_scoped_for_status_queries() {
|
||||||
let path = backend_runtime_workers_path("team main", "runtime/one");
|
let path = backend_runtime_workers_path(Some("team main"), "runtime/one");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format!("{path}?status=stopped"),
|
format!("{path}?status=stopped"),
|
||||||
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
|
"/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped"
|
||||||
@@ -608,10 +631,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_worker_path_requires_workspace_scope() {
|
fn restore_worker_path_uses_backend_runtime_worker_identity() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"),
|
backend_runtime_worker_restore_path(None, "runtime/one", "worker one"),
|
||||||
"/api/w/team%20main/runtimes/runtime%2Fone/workers/worker%20one/restore"
|
"/api/runtimes/runtime%2Fone/workers/worker%20one/restore"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
|
||||||
|
|
||||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkspace {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub owner_account_id: Option<String>,
|
|
||||||
pub display_name: String,
|
|
||||||
pub state: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct CreateBackendWorkspaceRequest {
|
|
||||||
pub operation_key: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub repository: CreateBackendWorkspaceRepository,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct CreateBackendWorkspaceRepository {
|
|
||||||
pub uri: String,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub default_ref: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CreateBackendWorkspaceResponse {
|
|
||||||
pub workspace: BackendWorkspace,
|
|
||||||
pub repository: CreateBackendWorkspaceRepositoryRecord,
|
|
||||||
pub config_revision: u64,
|
|
||||||
pub request_fingerprint: String,
|
|
||||||
pub replayed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct CreateBackendWorkspaceRepositoryRecord {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub repository_id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub provider: Option<String>,
|
|
||||||
pub source: RepositorySource,
|
|
||||||
pub default_ref: Option<String>,
|
|
||||||
pub source_revision: u64,
|
|
||||||
pub source_fingerprint: String,
|
|
||||||
pub observed_status: RepositoryObservedStatus,
|
|
||||||
pub observed_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkspaceCatalogTarget {
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendWorkspaceCatalogTarget {
|
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: base_url.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum BackendWorkspaceClientError {
|
|
||||||
InvalidTarget(String),
|
|
||||||
RequestFailed { status: u16, message: String },
|
|
||||||
Http(reqwest::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for BackendWorkspaceClientError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
|
||||||
Self::RequestFailed { status, message } => {
|
|
||||||
write!(f, "Backend request failed with HTTP {status}: {message}")
|
|
||||||
}
|
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for BackendWorkspaceClientError {}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
|
||||||
fn from(error: reqwest::Error) -> Self {
|
|
||||||
Self::Http(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_backend_workspaces(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
|
||||||
validate_target(target)?;
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
|
||||||
target.base_url.trim_end_matches('/')
|
|
||||||
);
|
|
||||||
let response = reqwest::Client::new().get(url).send().await?;
|
|
||||||
let response = require_success(response).await?;
|
|
||||||
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_backend_workspace(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
request: &CreateBackendWorkspaceRequest,
|
|
||||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
|
||||||
validate_target(target)?;
|
|
||||||
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post(url)
|
|
||||||
.json(request)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let response = require_success(response).await?;
|
|
||||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn require_success(
|
|
||||||
response: reqwest::Response,
|
|
||||||
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response.text().await.unwrap_or_default();
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_target(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
|
||||||
let request = CreateBackendWorkspaceRequest {
|
|
||||||
operation_key: "workspace-create-1".to_string(),
|
|
||||||
display_name: "Alpha".to_string(),
|
|
||||||
repository: CreateBackendWorkspaceRepository {
|
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
|
||||||
display_name: Some("Main".to_string()),
|
|
||||||
default_ref: Some("develop".to_string()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let retry = request.clone();
|
|
||||||
assert_eq!(retry.operation_key, "workspace-create-1");
|
|
||||||
assert_eq!(retry, request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,36 +10,29 @@
|
|||||||
|
|
||||||
pub mod backend_auth;
|
pub mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod backend_workspace;
|
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
pub mod ticket_role;
|
pub mod ticket_role;
|
||||||
mod worker_client;
|
mod worker_client;
|
||||||
mod workspace_product;
|
|
||||||
|
|
||||||
pub use backend_auth::{
|
pub use backend_auth::{
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
};
|
};
|
||||||
pub use backend_runtime::{
|
pub use backend_runtime::{
|
||||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError,
|
BackendDiagnostic, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
||||||
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||||
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||||
list_backend_workers, restore_backend_worker,
|
list_backend_workers, restore_backend_worker,
|
||||||
};
|
};
|
||||||
pub use backend_workspace::{
|
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
|
||||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
|
||||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
|
||||||
};
|
|
||||||
pub use runtime_command::WorkerRuntimeCommand;
|
pub use runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
||||||
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
|
||||||
WorkerResume, WorkerSpawn,
|
WorkerSpawn,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use spawn::{
|
pub use spawn::{
|
||||||
@@ -53,5 +46,3 @@ pub use ticket_role::{
|
|||||||
plan_ticket_role_launch_with_config,
|
plan_ticket_role_launch_with_config,
|
||||||
};
|
};
|
||||||
pub use worker_client::WorkerClient;
|
pub use worker_client::WorkerClient;
|
||||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
|
||||||
pub use workspace_product::BackendWorkspaceProductClient;
|
|
||||||
|
|||||||
+4
-148
@@ -8,24 +8,6 @@ pub enum TargetKind {
|
|||||||
Backend,
|
Backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ResolvedTarget {
|
|
||||||
Local,
|
|
||||||
Backend {
|
|
||||||
base_url: String,
|
|
||||||
workspace_id: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResolvedTarget {
|
|
||||||
pub fn kind(&self) -> TargetKind {
|
|
||||||
match self {
|
|
||||||
Self::Local => TargetKind::Local,
|
|
||||||
Self::Backend { .. } => TargetKind::Backend,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for TargetKind {
|
impl fmt::Display for TargetKind {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -122,14 +104,8 @@ pub struct WorkerResume {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Dashboard {
|
pub struct Dashboard {
|
||||||
Local {
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
runtime_command: WorkerRuntimeCommand,
|
|
||||||
},
|
|
||||||
Backend {
|
|
||||||
base_url: String,
|
|
||||||
workspace_id: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -156,12 +132,6 @@ impl TargetError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalid(target: TargetKind, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
message: format!("invalid {target} target: {}", message.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn local_runtime_command(error: std::io::Error) -> Self {
|
fn local_runtime_command(error: std::io::Error) -> Self {
|
||||||
Self {
|
Self {
|
||||||
message: format!("failed to resolve local Worker runtime command: {error}"),
|
message: format!("failed to resolve local Worker runtime command: {error}"),
|
||||||
@@ -180,13 +150,6 @@ impl std::error::Error for TargetError {}
|
|||||||
pub trait Target: fmt::Debug + Send + Sync {
|
pub trait Target: fmt::Debug + Send + Sync {
|
||||||
fn kind(&self) -> TargetKind;
|
fn kind(&self) -> TargetKind;
|
||||||
|
|
||||||
/// Resolve the target once for Workspace product-state operations.
|
|
||||||
///
|
|
||||||
/// Backend targets must carry an explicit Workspace identity. Callers use
|
|
||||||
/// this value instead of rediscovering Backend/local authority from cwd or
|
|
||||||
/// process configuration after command dispatch.
|
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
|
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
||||||
|
|
||||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
||||||
@@ -208,10 +171,6 @@ impl Target for LocalTarget {
|
|||||||
TargetKind::Local
|
TargetKind::Local
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
|
||||||
Ok(ResolvedTarget::Local)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
Ok(WorkerSpawn {
|
Ok(WorkerSpawn {
|
||||||
runtime_command: self.runtime_command()?,
|
runtime_command: self.runtime_command()?,
|
||||||
@@ -231,7 +190,7 @@ impl Target for LocalTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
Ok(Dashboard::Local {
|
Ok(Dashboard {
|
||||||
runtime_command: self.runtime_command()?,
|
runtime_command: self.runtime_command()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -266,19 +225,6 @@ impl Target for BackendTarget {
|
|||||||
TargetKind::Backend
|
TargetKind::Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
|
||||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
|
||||||
TargetError::invalid(
|
|
||||||
self.kind(),
|
|
||||||
"workspace selection is required for Backend product-state operations",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(ResolvedTarget::Backend {
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
workspace_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||||
}
|
}
|
||||||
@@ -295,16 +241,7 @@ impl Target for BackendTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
match self.resolve()? {
|
Err(TargetError::unsupported("Dashboard", self.kind()))
|
||||||
ResolvedTarget::Backend {
|
|
||||||
base_url,
|
|
||||||
workspace_id,
|
|
||||||
} => Ok(Dashboard::Backend {
|
|
||||||
base_url,
|
|
||||||
workspace_id,
|
|
||||||
}),
|
|
||||||
ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||||
@@ -323,16 +260,9 @@ impl Target for BackendTarget {
|
|||||||
&self,
|
&self,
|
||||||
selector: WorkerConnectionSelector,
|
selector: WorkerConnectionSelector,
|
||||||
) -> Result<WorkerConnection, TargetError> {
|
) -> Result<WorkerConnection, TargetError> {
|
||||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
|
||||||
TargetError::invalid(
|
|
||||||
self.kind(),
|
|
||||||
"workspace selection is required before connecting to a Backend Worker",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(WorkerConnection {
|
Ok(WorkerConnection {
|
||||||
target: BackendRuntimeTarget::new(
|
target: BackendRuntimeTarget::new(
|
||||||
self.base_url.clone(),
|
self.base_url.clone(),
|
||||||
workspace_id,
|
|
||||||
selector.runtime_id,
|
selector.runtime_id,
|
||||||
selector.worker_id,
|
selector.worker_id,
|
||||||
),
|
),
|
||||||
@@ -344,63 +274,6 @@ impl Target for BackendTarget {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_resolves_workspace_scoped_product_state_authority() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
target.resolve().unwrap(),
|
|
||||||
ResolvedTarget::Backend {
|
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
|
||||||
workspace_id: "workspace-a".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_rejects_product_state_resolution_without_workspace() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
target
|
|
||||||
.resolve()
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.contains("workspace selection is required")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_target_resolves_local_product_state_authority() {
|
|
||||||
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_builds_workspace_scoped_dashboard() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
target.dashboard().unwrap(),
|
|
||||||
Dashboard::Backend {
|
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
|
||||||
workspace_id: "workspace-a".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_rejects_dashboard_without_workspace_selection() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
target
|
|
||||||
.dashboard()
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.contains("workspace selection is required")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_target_builds_worker_list() {
|
fn backend_target_builds_worker_list() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||||
@@ -440,27 +313,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
||||||
assert_eq!(connection.target.workspace_id, "workspace-a");
|
|
||||||
assert_eq!(connection.target.runtime_id, "runtime-a");
|
assert_eq!(connection.target.runtime_id, "runtime-a");
|
||||||
assert_eq!(connection.target.worker_id, "worker-b");
|
assert_eq!(connection.target.worker_id, "worker-b");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_rejects_worker_connection_before_workspace_selection() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
|
||||||
let error =
|
|
||||||
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) {
|
|
||||||
Ok(_) => panic!("unscoped connection must fail"),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("workspace selection is required")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_target_rejects_local_worker_operations() {
|
fn backend_target_rejects_local_worker_operations() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
|
|||||||
@@ -743,7 +743,6 @@ mod tests {
|
|||||||
},
|
},
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: protocol::InFlightSnapshot::default(),
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
internal_workers: vec![],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,852 +0,0 @@
|
|||||||
use reqwest::Method;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use ticket::{
|
|
||||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
|
||||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
|
||||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit,
|
|
||||||
TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
|
||||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
|
||||||
};
|
|
||||||
use workspace_api::{
|
|
||||||
ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest,
|
|
||||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::BackendWorkspaceClientError;
|
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendWorkerLaunchOptions {
|
|
||||||
runtimes: Vec<BackendWorkerLaunchRuntime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendWorkerLaunchRuntime {
|
|
||||||
runtime_id: String,
|
|
||||||
worker_creation_available: bool,
|
|
||||||
working_directory_required: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendCreateWorkerResponse {
|
|
||||||
runtime_id: String,
|
|
||||||
worker_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BackendWorkspaceOrchestratorResponse {
|
|
||||||
disposition: String,
|
|
||||||
worker: Option<BackendCreateWorkerResponse>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
|
||||||
///
|
|
||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
|
||||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
|
||||||
/// failed requests against repository-local state.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkspaceProductClient {
|
|
||||||
base_url: String,
|
|
||||||
workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendWorkspaceProductClient {
|
|
||||||
pub fn new(
|
|
||||||
base_url: impl Into<String>,
|
|
||||||
workspace_id: impl Into<String>,
|
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
|
||||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
|
||||||
if base_url.is_empty() {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend base URL must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let workspace_id = workspace_id.into();
|
|
||||||
if workspace_id.trim().is_empty() {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend Workspace identity must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
base_url,
|
|
||||||
workspace_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> &str {
|
|
||||||
&self.workspace_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_tickets(
|
|
||||||
&self,
|
|
||||||
query: &TicketListQuery,
|
|
||||||
) -> Result<Vec<TicketSummary>, BackendWorkspaceClientError> {
|
|
||||||
let state = ticket_list_state_query(query);
|
|
||||||
self.get_json(&format!("/tickets/search?state={state}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_ticket(&self, id: &TicketIdOrSlug) -> Result<Ticket, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/tickets/{}/record",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_ticket(
|
|
||||||
&self,
|
|
||||||
input: &NewTicket,
|
|
||||||
) -> Result<TicketRef, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(Method::POST, "/tickets", Some(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ticket_event(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
event: &NewTicketEvent,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/thread-events",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(event),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_ticket_workflow_state(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
change: &TicketStateChange,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow-state",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(change),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close_ticket(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
resolution: &MarkdownText,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow/close",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(resolution),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ticket_relation(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
relation: &NewTicketRelation,
|
|
||||||
) -> Result<TicketRelation, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/relations",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(relation),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn query_ticket_relations(
|
|
||||||
&self,
|
|
||||||
ticket: Option<&TicketIdOrSlug>,
|
|
||||||
kind: Option<TicketRelationKind>,
|
|
||||||
) -> Result<Vec<TicketRelation>, BackendWorkspaceClientError> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Query<'a> {
|
|
||||||
ticket: Option<&'a TicketIdOrSlug>,
|
|
||||||
kind: Option<TicketRelationKind>,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
TICKET_RELATIONS_QUERY_PATH,
|
|
||||||
Some(&Query { ticket, kind }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ticket_doctor(&self) -> Result<TicketDoctorReport, BackendWorkspaceClientError> {
|
|
||||||
self.get_json("/tickets/doctor")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_objectives(
|
|
||||||
&self,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<ListResponse<ObjectiveSummary>, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!("/objectives?limit={limit}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_objective(&self, id: &str) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!("/objectives/{}", encode_path_segment(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_objective(
|
|
||||||
&self,
|
|
||||||
input: &ObjectiveCreateRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(Method::POST, "/objectives", Some(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn edit_objective(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
input: &ObjectiveEditRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!("/objectives/{}", encode_path_segment(id)),
|
|
||||||
Some(input),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_objective_state(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
input: &ObjectiveStateRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("/objectives/{}/state", encode_path_segment(id)),
|
|
||||||
Some(input),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn link_objective_ticket(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
input: &ObjectiveLinkTicketRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("/objectives/{}/ticket-links", encode_path_segment(id)),
|
|
||||||
Some(input),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unlink_objective_ticket(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
ticket_id: &str,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json::<(), _>(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!(
|
|
||||||
"/objectives/{}/ticket-links/{}",
|
|
||||||
encode_path_segment(id),
|
|
||||||
encode_path_segment(ticket_id)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn launch_ticket_intake(
|
|
||||||
&self,
|
|
||||||
ticket_id: &str,
|
|
||||||
) -> Result<String, BackendWorkspaceClientError> {
|
|
||||||
let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?;
|
|
||||||
let runtime = options
|
|
||||||
.runtimes
|
|
||||||
.iter()
|
|
||||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let response: BackendCreateWorkerResponse = self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
"/workers",
|
|
||||||
Some(&serde_json::json!({
|
|
||||||
"runtime_id": runtime.runtime_id,
|
|
||||||
"display_name": format!("intake-{ticket_id}"),
|
|
||||||
"profile": "builtin:intake",
|
|
||||||
"initial_submit": [{
|
|
||||||
"kind": "text",
|
|
||||||
"content": format!("Please handle intake for Ticket {ticket_id}.")
|
|
||||||
}]
|
|
||||||
})),
|
|
||||||
)?;
|
|
||||||
Ok(format!(
|
|
||||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
|
||||||
response.runtime_id, response.worker_id
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
|
||||||
let response: BackendWorkspaceOrchestratorResponse =
|
|
||||||
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
|
||||||
let worker = response.worker.ok_or_else(|| {
|
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend accepted the Orchestrator request without returning a Worker".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(format!(
|
|
||||||
"Workspace Orchestrator {} at {}/{}",
|
|
||||||
response.disposition, worker.runtime_id, worker.worker_id
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_product_list_limit() -> usize {
|
|
||||||
DEFAULT_PRODUCT_LIST_LIMIT
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_json<R: DeserializeOwned>(&self, path: &str) -> Result<R, BackendWorkspaceClientError> {
|
|
||||||
self.send_json::<(), R>(Method::GET, path, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_json<B: Serialize + ?Sized, R: DeserializeOwned>(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&B>,
|
|
||||||
) -> Result<R, BackendWorkspaceClientError> {
|
|
||||||
let response = self.request(method, path, body)?.send()?;
|
|
||||||
let response = ensure_success(response)?;
|
|
||||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_unit<B: Serialize + ?Sized>(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&B>,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
ensure_success(self.request(method, path, body)?.send()?)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request<B: Serialize + ?Sized>(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&B>,
|
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
|
||||||
let client = reqwest::blocking::Client::builder().build()?;
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/w/{}/{}",
|
|
||||||
self.base_url,
|
|
||||||
encode_path_segment(&self.workspace_id),
|
|
||||||
path.trim_start_matches('/')
|
|
||||||
);
|
|
||||||
let request = client.request(method, url);
|
|
||||||
Ok(match body {
|
|
||||||
Some(body) => request.json(body),
|
|
||||||
None => request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketBackend for BackendWorkspaceProductClient {
|
|
||||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Request<'a> {
|
|
||||||
from: &'a str,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
"/tickets/default-intake-ready-body",
|
|
||||||
Some(&Request { from }),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> {
|
|
||||||
self.list_tickets(&filter).map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show(&self, id: TicketIdOrSlug) -> ticket::Result<Ticket> {
|
|
||||||
self.show_ticket(&id).map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&self, input: NewTicket) -> ticket::Result<TicketRef> {
|
|
||||||
self.create_ticket(&input).map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> ticket::Result<Ticket> {
|
|
||||||
self.send_json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/item",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&edit),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result<TicketDependencyCheck> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/tickets/{}/dependency-check",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
))
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> ticket::Result<()> {
|
|
||||||
self.add_ticket_event(&id, &event)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_state_changed(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/state-changes",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&change),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_intake_summary(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
summary: TicketIntakeSummary,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/intake-summaries",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&summary),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_state_field(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
field: &str,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/state-fields/{}",
|
|
||||||
encode_path_segment(&ticket_reference(&id)),
|
|
||||||
encode_path_segment(field)
|
|
||||||
),
|
|
||||||
Some(&change),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_workflow_state(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.set_ticket_workflow_state(&id, &change)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow/mark-ready",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&request),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn queue_ready(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
_queued_by: &str,
|
|
||||||
) -> ticket::Result<ticket::TicketQueueOutcome> {
|
|
||||||
self.send_json::<(), _>(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow/queue",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
|
||||||
self.close_ticket(&id, &resolution)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_ticket_relation(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
relation: NewTicketRelation,
|
|
||||||
) -> ticket::Result<TicketRelation> {
|
|
||||||
BackendWorkspaceProductClient::add_ticket_relation(self, &id, &relation)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_ticket_relation(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
kind: TicketRelationKind,
|
|
||||||
target: TicketIdOrSlug,
|
|
||||||
) -> ticket::Result<TicketRelation> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Request {
|
|
||||||
kind: TicketRelationKind,
|
|
||||||
target: String,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/relations",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&Request {
|
|
||||||
kind,
|
|
||||||
target: ticket_reference(&target),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_ticket_relations(
|
|
||||||
&self,
|
|
||||||
ticket: Option<TicketIdOrSlug>,
|
|
||||||
kind: Option<TicketRelationKind>,
|
|
||||||
) -> ticket::Result<Vec<TicketRelation>> {
|
|
||||||
BackendWorkspaceProductClient::query_ticket_relations(self, ticket.as_ref(), kind)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result<TicketRelationView> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/tickets/{}/relation-view",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
))
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_orchestration_plan_record(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
record: NewOrchestrationPlanRecord,
|
|
||||||
) -> ticket::Result<OrchestrationPlanRecord> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/orchestration-plans",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&record),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_orchestration_plan_records(
|
|
||||||
&self,
|
|
||||||
ticket: Option<TicketIdOrSlug>,
|
|
||||||
kind: Option<OrchestrationPlanKind>,
|
|
||||||
) -> ticket::Result<Vec<OrchestrationPlanRecord>> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Query {
|
|
||||||
ticket: Option<TicketIdOrSlug>,
|
|
||||||
kind: Option<OrchestrationPlanKind>,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
|
||||||
Some(&Query { ticket, kind }),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn doctor(&self) -> ticket::Result<TicketDoctorReport> {
|
|
||||||
self.ticket_doctor().map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
|
||||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_success(
|
|
||||||
response: reqwest::blocking::Response,
|
|
||||||
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response
|
|
||||||
.text()
|
|
||||||
.unwrap_or_else(|_| "Backend request failed".to_string());
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
|
||||||
match id {
|
|
||||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
|
||||||
TicketIdOrSlug::Slug(slug) | TicketIdOrSlug::Query(slug) => slug.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_list_state_query(query: &TicketListQuery) -> String {
|
|
||||||
match &query.state {
|
|
||||||
TicketStateSelector::Active => "active".to_string(),
|
|
||||||
TicketStateSelector::All => "all".to_string(),
|
|
||||||
TicketStateSelector::States(states) => states
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.map(TicketListState::as_str)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_path_segment(value: &str) -> String {
|
|
||||||
let mut encoded = String::with_capacity(value.len());
|
|
||||||
for byte in value.bytes() {
|
|
||||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
|
||||||
encoded.push(char::from(byte));
|
|
||||||
} else {
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
write!(&mut encoded, "%{byte:02X}").expect("writing to String cannot fail");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::TcpListener;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn one_response_server(
|
|
||||||
status: &str,
|
|
||||||
body: &str,
|
|
||||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let status = status.to_string();
|
|
||||||
let body = body.to_string();
|
|
||||||
let (sender, receiver) = mpsc::channel();
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut request = vec![0_u8; 8_192];
|
|
||||||
let bytes = stream.read(&mut request).unwrap();
|
|
||||||
sender
|
|
||||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
|
||||||
.unwrap();
|
|
||||||
write!(
|
|
||||||
stream,
|
|
||||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
||||||
body.len()
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
(format!("http://{address}"), receiver, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn response_sequence_server(
|
|
||||||
responses: Vec<(&'static str, &'static str)>,
|
|
||||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let (sender, receiver) = mpsc::channel();
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
for (status, body) in responses {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut request = vec![0_u8; 16_384];
|
|
||||||
let bytes = stream.read(&mut request).unwrap();
|
|
||||||
sender
|
|
||||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
|
||||||
.unwrap();
|
|
||||||
write!(
|
|
||||||
stream,
|
|
||||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
||||||
body.len()
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(format!("http://{address}"), receiver, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
|
||||||
|
|
||||||
let response = client.list_objectives(1_000).unwrap();
|
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
|
||||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
|
||||||
|
|
||||||
let error = client
|
|
||||||
.create_objective(&ObjectiveCreateRequest {
|
|
||||||
title: "Objective".to_string(),
|
|
||||||
body_md: "body".to_string(),
|
|
||||||
state: "active".to_string(),
|
|
||||||
linked_tickets: Vec::new(),
|
|
||||||
})
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("403"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("POST /api/w/workspace-a/objectives ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
|
||||||
|
|
||||||
let relations = client
|
|
||||||
.query_ticket_relations(
|
|
||||||
Some(&TicketIdOrSlug::Query("T-1".to_string())),
|
|
||||||
Some(TicketRelationKind::Related),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(relations.is_empty());
|
|
||||||
let request = request.recv().unwrap();
|
|
||||||
assert!(request.starts_with("POST /api/w/workspace-a/tickets/relations/search "));
|
|
||||||
assert!(request.contains("\"ticket\":{\"Query\":\"T-1\"}"));
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
|
||||||
|
|
||||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
|
||||||
|
|
||||||
assert!(records.is_empty());
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("POST /api/w/workspace-a/tickets/orchestration-plans/search ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_intake_launch_uses_backend_options_and_workspace_worker_route() {
|
|
||||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
|
||||||
(
|
|
||||||
"200 OK",
|
|
||||||
r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"200 OK",
|
|
||||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
|
||||||
|
|
||||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
|
||||||
|
|
||||||
assert!(status.contains("embedded/worker-1"));
|
|
||||||
assert!(
|
|
||||||
requests
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/workers/launch-options ")
|
|
||||||
);
|
|
||||||
let create_request = requests.recv().unwrap();
|
|
||||||
assert!(create_request.starts_with("POST /api/w/workspace-a/workers "));
|
|
||||||
assert!(create_request.contains("\"profile\":\"builtin:intake\""));
|
|
||||||
assert!(create_request.contains("Ticket T-1"));
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
|
||||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
|
||||||
|
|
||||||
let status = client.start_workspace_orchestrator().unwrap();
|
|
||||||
|
|
||||||
assert!(status.contains("created at embedded/worker-2"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("POST /api/w/workspace-a/orchestrator ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn product_client_requires_workspace_identity() {
|
|
||||||
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
|
||||||
assert!(error.to_string().contains("Workspace identity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_state_query_preserves_local_filter_semantics() {
|
|
||||||
assert_eq!(
|
|
||||||
ticket_list_state_query(&TicketListQuery::active()),
|
|
||||||
"active"
|
|
||||||
);
|
|
||||||
assert_eq!(ticket_list_state_query(&TicketListQuery::all()), "all");
|
|
||||||
assert_eq!(
|
|
||||||
ticket_list_state_query(&TicketListQuery {
|
|
||||||
state: TicketStateSelector::States(
|
|
||||||
[TicketListState::Ready, TicketListState::InProgress]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
"ready,inprogress"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_and_objective_references_are_path_encoded() {
|
|
||||||
assert_eq!(encode_path_segment("T-1/a"), "T-1%2Fa");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
use client::{
|
|
||||||
BackendTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, Target,
|
|
||||||
WorkerConnectionSelector,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_creation_request_preserves_operation_key_for_retry() {
|
|
||||||
let request = CreateBackendWorkspaceRequest {
|
|
||||||
operation_key: "workspace-create-1".to_string(),
|
|
||||||
display_name: "Alpha".to_string(),
|
|
||||||
repository: CreateBackendWorkspaceRepository {
|
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
|
||||||
display_name: Some("Main".to_string()),
|
|
||||||
default_ref: Some("develop".to_string()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(request.clone(), request);
|
|
||||||
assert_eq!(request.operation_key, "workspace-create-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_worker_connection_requires_explicit_workspace_scope() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
|
||||||
let error = match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-a"))
|
|
||||||
{
|
|
||||||
Ok(_) => panic!("unscoped Backend worker connection must fail"),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("workspace selection is required")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
|
|||||||
pub const MAX_PATH_BYTES: usize = 512;
|
pub const MAX_PATH_BYTES: usize = 512;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
||||||
|
#[serde(transparent)]
|
||||||
pub struct VirtualPath(String);
|
pub struct VirtualPath(String);
|
||||||
|
|
||||||
impl VirtualPath {
|
impl VirtualPath {
|
||||||
@@ -1790,19 +1791,6 @@ mod tests {
|
|||||||
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
|
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn virtual_path_serde_shape_is_a_string() {
|
|
||||||
let path = path("profiles/main.dcdl");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(&path).unwrap(),
|
|
||||||
serde_json::json!(path.as_str())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_value::<VirtualPath>(serde_json::json!(path.as_str())).unwrap(),
|
|
||||||
path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
||||||
let base = ConfigTreeSnapshot::from_entries(
|
let base = ConfigTreeSnapshot::from_entries(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
|||||||
match slug {
|
match slug {
|
||||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||||
slug: CODER_REVIEW_FLOW_SLUG,
|
slug: CODER_REVIEW_FLOW_SLUG,
|
||||||
revision: 4,
|
revision: 3,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
content: CODER_REVIEW_FLOW_SOURCE,
|
||||||
}),
|
}),
|
||||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
|||||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||||
slug: CODER_REVIEW_FLOW_SLUG,
|
slug: CODER_REVIEW_FLOW_SLUG,
|
||||||
revision: 4,
|
revision: 3,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
content: CODER_REVIEW_FLOW_SOURCE,
|
||||||
}];
|
}];
|
||||||
@@ -46,30 +46,6 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coder_review_flow_uses_current_selector_ref_review_contract() {
|
|
||||||
let source = builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder review Flow");
|
|
||||||
for required in [
|
|
||||||
"OpenMergeRequest",
|
|
||||||
"ShowMergeRequest",
|
|
||||||
"ReviewMergeRequest",
|
|
||||||
"CompleteMergeRequest",
|
|
||||||
"existing Merge Request `selector_from`",
|
|
||||||
"Target-only movement does not invalidate",
|
|
||||||
] {
|
|
||||||
assert!(source.content.contains(required), "missing {required}");
|
|
||||||
}
|
|
||||||
for stale in [
|
|
||||||
"MergeRequestOpen",
|
|
||||||
"MergeRequestShow",
|
|
||||||
"MergeRequestReview",
|
|
||||||
"MergeRequestComplete",
|
|
||||||
"new immutable revision",
|
|
||||||
] {
|
|
||||||
assert!(!source.content.contains(stale), "stale contract {stale}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||||
assert!(!builtin_flow_sources().is_empty());
|
assert!(!builtin_flow_sources().is_empty());
|
||||||
|
|||||||
@@ -279,58 +279,4 @@ mod tests {
|
|||||||
assert_eq!(grep.matched_files, 2);
|
assert_eq!(grep.matched_files, 2);
|
||||||
assert!(!grep.output.contains("c.txt"));
|
assert!(!grep.output.contains("c.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
temp.path().join("first.txt"),
|
|
||||||
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
let grep = run_grep(
|
|
||||||
&root,
|
|
||||||
root.clone(),
|
|
||||||
GrepRequest {
|
|
||||||
pattern: "needle".to_string(),
|
|
||||||
path: FsPath::root(),
|
|
||||||
glob: Some("*.txt".to_string()),
|
|
||||||
output_mode: GrepOutputMode::Content,
|
|
||||||
case_insensitive: false,
|
|
||||||
before_context: 1,
|
|
||||||
after_context: 1,
|
|
||||||
multiline: false,
|
|
||||||
file_type: None,
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(grep.match_count, 3);
|
|
||||||
assert_eq!(grep.matched_files, 2);
|
|
||||||
assert_eq!(
|
|
||||||
grep.output,
|
|
||||||
concat!(
|
|
||||||
"first.txt\n",
|
|
||||||
" 1 │ before\n",
|
|
||||||
" > 2 │ needle one\n",
|
|
||||||
" 3 │ after\n",
|
|
||||||
" …\n",
|
|
||||||
" 6 │ before distant\n",
|
|
||||||
" > 7 │ needle distant\n",
|
|
||||||
" 8 │ after distant\n",
|
|
||||||
"\n",
|
|
||||||
"second.txt\n",
|
|
||||||
" > 1 │ needle two\n",
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert_eq!(grep.output.matches("first.txt").count(), 1);
|
|
||||||
assert_eq!(grep.output.matches("second.txt").count(), 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -59,11 +57,20 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepOutputMode::Content => {
|
GrepOutputMode::Content => {
|
||||||
output.push_str(&render_content_lines(
|
for line in &self.lines {
|
||||||
root,
|
let separator = if line.is_match { ':' } else { '-' };
|
||||||
&self.lines,
|
let path = logical_display(root, &line.path);
|
||||||
self.show_line_numbers,
|
if self.show_line_numbers
|
||||||
|
&& let Some(number) = line.line_number
|
||||||
|
{
|
||||||
|
output.push_str(&format!(
|
||||||
|
"{path}{separator}{number}{separator}{}\n",
|
||||||
|
line.text
|
||||||
));
|
));
|
||||||
|
} else {
|
||||||
|
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepResult {
|
GrepResult {
|
||||||
@@ -75,48 +82,6 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
|
|
||||||
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
|
|
||||||
for line in lines {
|
|
||||||
grouped.entry(&line.path).or_default().push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output = String::new();
|
|
||||||
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
|
|
||||||
if file_index > 0 {
|
|
||||||
output.push('\n');
|
|
||||||
}
|
|
||||||
let _ = writeln!(output, "{}", logical_display(root, path));
|
|
||||||
|
|
||||||
let number_width = file_lines
|
|
||||||
.iter()
|
|
||||||
.filter_map(|line| line.line_number)
|
|
||||||
.map(|number| number.to_string().len())
|
|
||||||
.max()
|
|
||||||
.unwrap_or(1);
|
|
||||||
let mut previous_line_end = None;
|
|
||||||
for line in file_lines {
|
|
||||||
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
|
|
||||||
&& number > previous_end
|
|
||||||
{
|
|
||||||
let _ = writeln!(output, " …");
|
|
||||||
}
|
|
||||||
|
|
||||||
let marker = if line.is_match { '>' } else { ' ' };
|
|
||||||
if show_line_numbers && let Some(number) = line.line_number {
|
|
||||||
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
|
|
||||||
} else {
|
|
||||||
let _ = writeln!(output, " {marker} │ {}", line.text);
|
|
||||||
}
|
|
||||||
previous_line_end = line
|
|
||||||
.line_number
|
|
||||||
.map(|number| number + line.text.split('\n').count() as u64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
fn logical_display(root: &Path, path: &Path) -> String {
|
fn logical_display(root: &Path, path: &Path) -> String {
|
||||||
path.strip_prefix(root)
|
path.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "llm-engine-macros"
|
||||||
|
description = "llm-engine's proc macros"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proc-macro2 = "1"
|
||||||
|
quote = "1"
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# llm-engine-macros
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
`llm-engine-macros` provides procedural macros for declaring Rust methods as LLM-callable tools.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- compile-time generation of tool argument structures and definitions
|
||||||
|
- small macro conveniences around tool descriptions and schemas
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- runtime permission decisions
|
||||||
|
- filesystem scope checks
|
||||||
|
- tool execution policy
|
||||||
|
- model/tool-loop orchestration
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
Macros reduce boilerplate, but they must not imply capability. A generated tool definition is still subject to host permissions, application scope, and runtime policy.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
//! llm-engine-macros - Procedural macros for Tool generation
|
||||||
|
//!
|
||||||
|
//! Provides `#[tool_registry]` and `#[tool]` macros to
|
||||||
|
//! automatically generate `Tool` trait implementations from user-defined methods.
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::{format_ident, quote};
|
||||||
|
use syn::{
|
||||||
|
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Macro applied to an `impl` block that generates tools from methods marked with `#[tool]`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// #[tool_registry]
|
||||||
|
/// impl MyApp {
|
||||||
|
/// /// Get user information
|
||||||
|
/// /// Retrieves a user from the database by their ID.
|
||||||
|
/// #[tool]
|
||||||
|
/// async fn get_user(&self, user_id: String) -> Result<User, Error> { ... }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// This generates:
|
||||||
|
/// - `GetUserArgs` struct (for arguments)
|
||||||
|
/// - `Tool_get_user` struct (Tool wrapper)
|
||||||
|
/// - `impl Tool for Tool_get_user`
|
||||||
|
/// - `impl MyApp { fn get_user_tool(&self) -> Tool_get_user }`
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn tool_registry(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let mut impl_block = parse_macro_input!(item as ItemImpl);
|
||||||
|
let self_ty = &impl_block.self_ty;
|
||||||
|
|
||||||
|
let mut generated_items = Vec::new();
|
||||||
|
|
||||||
|
for item in &mut impl_block.items {
|
||||||
|
if let ImplItem::Fn(method) = item {
|
||||||
|
// Look for #[tool] attribute
|
||||||
|
let mut is_tool = false;
|
||||||
|
|
||||||
|
// Iterate through attributes to check for tool and remove it
|
||||||
|
method.attrs.retain(|attr| {
|
||||||
|
if attr.path().is_ident("tool") {
|
||||||
|
is_tool = true;
|
||||||
|
false // Remove the attribute
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_tool {
|
||||||
|
let tool_impl = generate_tool_impl(self_ty, method);
|
||||||
|
generated_items.push(tool_impl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expanded = quote! {
|
||||||
|
#impl_block
|
||||||
|
|
||||||
|
#(#generated_items)*
|
||||||
|
};
|
||||||
|
|
||||||
|
TokenStream::from(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract description from doc comments
|
||||||
|
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for attr in attrs {
|
||||||
|
if attr.path().is_ident("doc") {
|
||||||
|
if let Meta::NameValue(meta) = &attr.meta {
|
||||||
|
if let syn::Expr::Lit(expr_lit) = &meta.value {
|
||||||
|
if let Lit::Str(lit_str) = &expr_lit.lit {
|
||||||
|
let line = lit_str.value();
|
||||||
|
// Remove only the leading space (after ///)
|
||||||
|
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
||||||
|
lines.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract description from #[description = "..."] attribute
|
||||||
|
fn extract_description_attr(attrs: &[syn::Attribute]) -> Option<String> {
|
||||||
|
for attr in attrs {
|
||||||
|
if attr.path().is_ident("description")
|
||||||
|
&& let Meta::NameValue(meta) = &attr.meta
|
||||||
|
&& let syn::Expr::Lit(expr_lit) = &meta.value
|
||||||
|
&& let Lit::Str(lit_str) = &expr_lit.lit
|
||||||
|
{
|
||||||
|
return Some(lit_str.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tool_execution_context_type(ty: &Type) -> bool {
|
||||||
|
let Type::Path(path) = ty else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
path.path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.is_some_and(|segment| segment.ident == "ToolExecutionContext")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate Tool implementation from a method
|
||||||
|
fn generate_tool_impl(self_ty: &Type, method: &syn::ImplItemFn) -> proc_macro2::TokenStream {
|
||||||
|
let sig = &method.sig;
|
||||||
|
let method_name = &sig.ident;
|
||||||
|
let tool_name = method_name.to_string();
|
||||||
|
|
||||||
|
// Generate struct names (convert to PascalCase)
|
||||||
|
let pascal_name = to_pascal_case(&method_name.to_string());
|
||||||
|
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
||||||
|
let args_struct_name = format_ident!("{}Args", pascal_name);
|
||||||
|
let definition_name = format_ident!("{}_definition", method_name);
|
||||||
|
|
||||||
|
// Get description from doc comments
|
||||||
|
let description = extract_doc_comment(&method.attrs);
|
||||||
|
let description = if description.is_empty() {
|
||||||
|
format!("Tool: {}", tool_name)
|
||||||
|
} else {
|
||||||
|
description
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse method arguments (excluding self). A parameter typed as
|
||||||
|
// ToolExecutionContext is supplied from the execution context and is not
|
||||||
|
// exposed in the JSON input schema.
|
||||||
|
let method_args: Vec<_> = sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|arg| {
|
||||||
|
if let FnArg::Typed(pat_type) = arg {
|
||||||
|
Some(pat_type)
|
||||||
|
} else {
|
||||||
|
None // Exclude self
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let json_args: Vec<_> = method_args
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|pat_type| !is_tool_execution_context_type(pat_type.ty.as_ref()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Generate argument struct fields
|
||||||
|
let arg_fields: Vec<_> = json_args
|
||||||
|
.iter()
|
||||||
|
.map(|pat_type| {
|
||||||
|
let pat = &pat_type.pat;
|
||||||
|
let ty = &pat_type.ty;
|
||||||
|
let desc = extract_description_attr(&pat_type.attrs);
|
||||||
|
|
||||||
|
// Extract identifier from pattern
|
||||||
|
let field_name = if let Pat::Ident(pat_ident) = pat.as_ref() {
|
||||||
|
&pat_ident.ident
|
||||||
|
} else {
|
||||||
|
panic!("Only simple identifiers are supported for tool arguments");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert #[description] to schemars doc if present
|
||||||
|
if let Some(desc_str) = desc {
|
||||||
|
quote! {
|
||||||
|
#[schemars(description = #desc_str)]
|
||||||
|
pub #field_name: #ty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
pub #field_name: #ty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Code to expand method arguments in execute
|
||||||
|
let call_args: Vec<_> = method_args
|
||||||
|
.iter()
|
||||||
|
.map(|pat_type| {
|
||||||
|
if is_tool_execution_context_type(pat_type.ty.as_ref()) {
|
||||||
|
quote! { ctx.clone() }
|
||||||
|
} else if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
|
||||||
|
let ident = &pat_ident.ident;
|
||||||
|
quote! { args.#ident }
|
||||||
|
} else {
|
||||||
|
panic!("Only simple identifiers are supported");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let method_call = if call_args.is_empty() {
|
||||||
|
quote! { self.ctx.#method_name() }
|
||||||
|
} else {
|
||||||
|
quote! { self.ctx.#method_name(#(#call_args),*) }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if method is async
|
||||||
|
let is_async = sig.asyncness.is_some();
|
||||||
|
|
||||||
|
// Parse return type and determine if Result
|
||||||
|
let awaiter = if is_async {
|
||||||
|
quote! { .await }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if return type is Result
|
||||||
|
let result_handling = if is_result_type(&sig.output) {
|
||||||
|
quote! {
|
||||||
|
match result {
|
||||||
|
Ok(val) => Ok(format!("{:?}", val).into()),
|
||||||
|
Err(e) => Err(::llm_engine::tool::ToolError::ExecutionFailed(format!("{}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
Ok(format!("{:?}", result).into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create empty Args struct if no arguments
|
||||||
|
let args_struct_def = if arg_fields.is_empty() {
|
||||||
|
quote! {
|
||||||
|
#[derive(serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
struct #args_struct_name {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
#[derive(serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
struct #args_struct_name {
|
||||||
|
#(#arg_fields),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute body handling for no arguments case
|
||||||
|
let execute_body = if json_args.is_empty() {
|
||||||
|
quote! {
|
||||||
|
// Allow empty JSON object even with no JSON arguments
|
||||||
|
let _: #args_struct_name = serde_json::from_str(input_json)
|
||||||
|
.unwrap_or(#args_struct_name {});
|
||||||
|
|
||||||
|
let result = #method_call #awaiter;
|
||||||
|
#result_handling
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
let args: #args_struct_name = serde_json::from_str(input_json)
|
||||||
|
.map_err(|e| ::llm_engine::tool::ToolError::InvalidArgument(e.to_string()))?;
|
||||||
|
|
||||||
|
let result = #method_call #awaiter;
|
||||||
|
#result_handling
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#args_struct_def
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct #tool_struct_name {
|
||||||
|
ctx: #self_ty,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ::llm_engine::tool::Tool for #tool_struct_name {
|
||||||
|
async fn execute(&self, input_json: &str, ctx: ::llm_engine::tool::ToolExecutionContext) -> Result<::llm_engine::tool::ToolOutput, ::llm_engine::tool::ToolError> {
|
||||||
|
let _ = &ctx;
|
||||||
|
#execute_body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #self_ty {
|
||||||
|
/// Get ToolDefinition (for registering with Engine)
|
||||||
|
pub fn #definition_name(&self) -> ::llm_engine::tool::ToolDefinition {
|
||||||
|
let ctx = self.clone();
|
||||||
|
::std::sync::Arc::new(move || {
|
||||||
|
let schema = schemars::schema_for!(#args_struct_name);
|
||||||
|
let meta = ::llm_engine::tool::ToolMeta::new(#tool_name)
|
||||||
|
.description(#description)
|
||||||
|
.input_schema(serde_json::to_value(schema).unwrap_or(serde_json::json!({})));
|
||||||
|
let tool: ::std::sync::Arc<dyn ::llm_engine::tool::Tool> =
|
||||||
|
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine if return type is Result
|
||||||
|
fn is_result_type(return_type: &ReturnType) -> bool {
|
||||||
|
match return_type {
|
||||||
|
ReturnType::Default => false,
|
||||||
|
ReturnType::Type(_, ty) => {
|
||||||
|
// For Type::Path, check if last segment is "Result"
|
||||||
|
if let Type::Path(type_path) = ty.as_ref() {
|
||||||
|
if let Some(segment) = type_path.path.segments.last() {
|
||||||
|
return segment.ident == "Result";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert snake_case to PascalCase
|
||||||
|
fn to_pascal_case(s: &str) -> String {
|
||||||
|
s.split('_')
|
||||||
|
.map(|part| {
|
||||||
|
let mut chars = part.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker attribute. Does nothing here as it's processed by `tool_registry`.
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn tool(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker for argument attributes. Interpreted by `tool_registry` during parsing.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// #[tool]
|
||||||
|
/// async fn get_user(
|
||||||
|
/// &self,
|
||||||
|
/// #[description = "The ID of the user to retrieve"] user_id: String
|
||||||
|
/// ) -> Result<User, Error> { ... }
|
||||||
|
/// ```
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn description(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
item
|
||||||
|
}
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agen"
|
name = "llm-engine"
|
||||||
description = "Provider-neutral orchestration for tool-using LLM applications"
|
description = "A library for building autonomous LLM-powered systems"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version = "1.86"
|
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
readme = "README.md"
|
|
||||||
repository = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
documentation = "https://docs.rs/agen"
|
|
||||||
keywords = ["llm", "agent", "tools", "streaming", "orchestration"]
|
|
||||||
categories = ["api-bindings", "asynchronous"]
|
|
||||||
include = ["src/**", "tests/**", "examples/*.rs", "docs/**", "README.md", "LICENSE"]
|
|
||||||
autoexamples = false
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
@@ -21,7 +12,6 @@ codex = ["dep:chrono"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
schemars = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
@@ -33,23 +23,13 @@ eventsource-stream = "0.2"
|
|||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||||
agen-macros = { workspace = true }
|
llm-engine-macros = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
schemars = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
trybuild = "1.0.116"
|
trybuild = "1.0.116"
|
||||||
wiremock = "0.6.5"
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "engine_cancel_demo"
|
|
||||||
path = "examples/engine_cancel_demo.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "engine_cli"
|
|
||||||
path = "examples/engine_cli.rs"
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# llm-engine
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
`llm-engine` owns provider-independent model turn orchestration over committed history, tools, callbacks, retries, continuation, pruning, and compaction boundaries.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- Engine history mutation and append contracts
|
||||||
|
- tool-call loop semantics
|
||||||
|
- pre-stream retry and stream-started continuation policy
|
||||||
|
- pruning/compaction coordination from the Engine perspective
|
||||||
|
- provider-neutral events/callbacks/interceptors
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- Host application names, sockets, process lifecycle, or scope delegation
|
||||||
|
- Product CLI shape
|
||||||
|
- Provider catalog and secret resolution
|
||||||
|
- Durable application state outside engine history
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
The Engine is where turn lifecycle belongs because it sees history, in-flight usage, partial output, and tool-call state. It should not receive context-only volatile facts; model-affecting inputs must first be appended to history.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||||
|
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
|
||||||
|
- [`../../docs/design/provider-model-boundary.md`](../../docs/design/provider-model-boundary.md)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# llm-engine アーキテクチャ
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
llm-engineは3層構成でLLMとのインタラクションを管理する。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Engine (オーケストレーション) │
|
||||||
|
│ ターンループ / フック / ツール実行 │
|
||||||
|
│ Type-state: Mutable ↔ CacheLocked │
|
||||||
|
└───────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────▼─────────────────────────────┐
|
||||||
|
│ Timeline (イベント処理) │
|
||||||
|
│ Handler dispatch / Block collectors │
|
||||||
|
└───────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────▼─────────────────────────────┐
|
||||||
|
│ LLM Client (プロトコル) │
|
||||||
|
│ Provider (HTTP) / Scheme (変換) │
|
||||||
|
│ Anthropic / OpenAI / Gemini / Ollama │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## モジュール構成
|
||||||
|
|
||||||
|
| モジュール | 責務 | 要件 |
|
||||||
|
|---|---|---|
|
||||||
|
| `engine` | ターンループ、フック統合、ツール実行、Pause/Resume | R1, R4 |
|
||||||
|
| `state` | Type-state (Mutable/CacheLocked) | R2 |
|
||||||
|
| `hook` | Hook trait、10フックポイント | R3, R4 |
|
||||||
|
| `tool` / `tool_server` | ツール定義・登録・実行 | R3 |
|
||||||
|
| `timeline` | イベントストリーム処理、Handler dispatch | — |
|
||||||
|
| `handler` | Handler/Kind trait、ブロック別ハンドラ | — |
|
||||||
|
| `callback` | クロージャベースイベント購読(`on_text_block`, `on_usage` 等) | — |
|
||||||
|
| `llm_client` | LLMプロバイダへのHTTPリクエスト/ストリーミング | — |
|
||||||
|
| `llm_client/scheme` | プロバイダ固有ワイヤーフォーマット変換 | — |
|
||||||
|
| `llm_client/providers` | Anthropic, OpenAI, Gemini, Ollama実装 | — |
|
||||||
|
|
||||||
|
## データフロー
|
||||||
|
|
||||||
|
### リクエスト(送信)
|
||||||
|
```
|
||||||
|
Engine.history (Vec<Item>)
|
||||||
|
→ build_request() → Request { items, tools, config }
|
||||||
|
→ Scheme.build_request() → プロバイダ固有JSON
|
||||||
|
→ Provider.stream() → HTTP POST
|
||||||
|
```
|
||||||
|
|
||||||
|
### レスポンス(受信)
|
||||||
|
```
|
||||||
|
HTTP SSE bytes
|
||||||
|
→ Provider → SSE events
|
||||||
|
→ Scheme.parse_event() → Event (統一型)
|
||||||
|
→ Timeline.dispatch() → Handler.on_event()
|
||||||
|
→ TextBlockCollector / ToolCallCollector
|
||||||
|
→ Engine: 履歴に追加、ツール実行判定
|
||||||
|
```
|
||||||
|
|
||||||
|
## 内部型
|
||||||
|
|
||||||
|
### Item (会話履歴の単位)
|
||||||
|
- `Item::Message` — テキストメッセージ (user/assistant)
|
||||||
|
- `Item::ToolCall` — ツール呼び出し
|
||||||
|
- `Item::ToolResult` — ツール実行結果
|
||||||
|
- `Item::Reasoning` — 思考 (Extended Thinking)
|
||||||
|
|
||||||
|
### Event (ストリーミングイベント)
|
||||||
|
- Meta: `Ping`, `Usage`, `Status`, `Error`
|
||||||
|
- Block: `BlockStart` → `BlockDelta`* → `BlockStop` / `BlockAbort`
|
||||||
|
|
||||||
|
単一の `Event` 型が全層で共有される(`llm_client::event` で定義、他層はre-export)。
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# llm-engine 要件
|
||||||
|
|
||||||
|
## 前提
|
||||||
|
|
||||||
|
a. userメッセージを追加しなくてもagentの途中ママ投げれば、AIはそれを自身の生成途中と認識して普通に継続する
|
||||||
|
b. KVキャッシュは速度・効率の面で有利で、コンテキストの事後改変はキャッシュヒット率を大幅に下げる
|
||||||
|
c. ツール・フックの基本的なスキーマ自動化を提供する
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
### R1: Resume/Pause
|
||||||
|
|
||||||
|
メッセージの送信と生成のResume、一時停止/再開。
|
||||||
|
|
||||||
|
- `Engine::run()` でターンを開始
|
||||||
|
- フックから `Pause` を返してターンを一時停止
|
||||||
|
- `Engine::resume()` でユーザーメッセージを追加せず継続
|
||||||
|
- AIは中断を認識せず、継続として処理する
|
||||||
|
|
||||||
|
**実装**: `engine.rs` — `resume()`, `get_pending_tool_calls()`, `EngineResult::Paused`
|
||||||
|
|
||||||
|
### R2: 暗黙的KVキャッシュ保証
|
||||||
|
|
||||||
|
キャッシュを破壊しうる操作を明示的にブロックせずとも、いつの間にかキャッシュ破壊してた状態にはしたくない。
|
||||||
|
|
||||||
|
- Type-stateパターン(`Mutable` / `CacheLocked`)でコンパイル時に保証
|
||||||
|
- `Engine::lock()` でCacheLocked状態に遷移
|
||||||
|
- CacheLocked状態ではシステムプロンプトや履歴の変更APIが型レベルで利用不可
|
||||||
|
- `locked_prefix_len` でプレフィックスの不変性を追跡
|
||||||
|
|
||||||
|
**実装**: `state.rs` (sealed trait), `engine.rs` (state-specific impl blocks)
|
||||||
|
|
||||||
|
### R3: ツール・フックスキーマ自動化
|
||||||
|
|
||||||
|
- `#[tool]` マクロでツール定義を自動生成
|
||||||
|
- `#[tool_registry]` マクロでツールサーバーを自動構成
|
||||||
|
- `Hook` traitで10種のフックポイント
|
||||||
|
|
||||||
|
**実装**: `llm-engine-macros/`, `tool.rs`, `tool_server.rs`, `hook.rs`
|
||||||
|
|
||||||
|
### R4: フックは上層の関心事
|
||||||
|
|
||||||
|
フックはLLMクライアント層ではなく、Engine(オーケストレーション)層に配置する。
|
||||||
|
|
||||||
|
- LLMクライアント (`llm_client/`) はストリーミングとプロトコルのみ
|
||||||
|
- Engine層でフック実行、ツール統合、Pause/Resume制御
|
||||||
|
|
||||||
|
**実装**: `engine.rs` (hook integration), `hook.rs` (trait definitions)
|
||||||
+12
-12
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Example of cancelling from another thread during streaming
|
//! Example of cancelling from another thread during streaming
|
||||||
|
|
||||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
use llm_engine::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use llm_engine::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use agen::{Engine, EngineRunExit, StopReason};
|
use llm_engine::{Engine, EngineResult};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -29,7 +29,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let base_url = scheme.default_base_url().to_string();
|
let base_url = scheme.default_base_url().to_string();
|
||||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
println!("🚀 Starting Engine...");
|
println!("🚀 Starting Engine...");
|
||||||
println!("💡 Will cancel after 2 seconds\n");
|
println!("💡 Will cancel after 2 seconds\n");
|
||||||
@@ -46,15 +45,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
println!("📡 Sending request to LLM...");
|
println!("📡 Sending request to LLM...");
|
||||||
|
|
||||||
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
|
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||||
match output.result {
|
Ok(out) => match out.result {
|
||||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
EngineResult::Yielded => println!("↩️ Task yielded"),
|
||||||
println!("🔒 Turn limit reached")
|
},
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Task error: {}", e);
|
||||||
}
|
}
|
||||||
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\n✨ Demo complete!");
|
println!("\n✨ Demo complete!");
|
||||||
@@ -38,8 +38,9 @@ use async_trait::async_trait;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use agen::{
|
use clap::{Parser, ValueEnum};
|
||||||
Engine, EngineRunExit, StopReason,
|
use llm_engine::{
|
||||||
|
Engine,
|
||||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
@@ -50,9 +51,12 @@ use agen::{
|
|||||||
transport::{HttpTransport, ResolvedAuth},
|
transport::{HttpTransport, ResolvedAuth},
|
||||||
},
|
},
|
||||||
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
||||||
tool_registry,
|
|
||||||
};
|
};
|
||||||
use clap::{Parser, ValueEnum};
|
use llm_engine_macros::tool_registry;
|
||||||
|
|
||||||
|
// Required imports for macro expansion
|
||||||
|
use schemars;
|
||||||
|
use serde;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Provider Definition
|
// Provider Definition
|
||||||
@@ -451,7 +455,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Create Engine
|
// Create Engine
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
@@ -477,9 +480,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// One-shot mode
|
// One-shot mode
|
||||||
if let Some(prompt) = args.prompt {
|
if let Some(prompt) = args.prompt {
|
||||||
let output = engine.run(&mut history, &prompt).await;
|
match engine.run(&prompt).await {
|
||||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
|
Ok(_) => {}
|
||||||
eprintln!("\n❌ Error: {error}");
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -498,8 +504,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = engine.run(&mut history, first_input).await;
|
let mut locked = match engine.run(first_input).await {
|
||||||
let mut locked = output.engine;
|
Ok(out) => out.engine,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
print!("\n👤 You: ");
|
print!("\n👤 You: ");
|
||||||
@@ -518,10 +529,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
|
match locked.run(input).await {
|
||||||
locked.run(&mut history, input).await
|
Ok(_) => {}
|
||||||
{
|
Err(e) => {
|
||||||
eprintln!("\n❌ Error: {error}");
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+3
-3
@@ -19,11 +19,11 @@
|
|||||||
mod recorder;
|
mod recorder;
|
||||||
mod scenarios;
|
mod scenarios;
|
||||||
|
|
||||||
use agen::llm_client::scheme::{
|
use clap::{Parser, ValueEnum};
|
||||||
|
use llm_engine::llm_client::scheme::{
|
||||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||||
};
|
};
|
||||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use llm_engine::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use clap::{Parser, ValueEnum};
|
|
||||||
|
|
||||||
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
||||||
let cap = scheme.default_capability();
|
let cap = scheme.default_capability();
|
||||||
+1
-1
@@ -7,8 +7,8 @@ use std::io::{BufWriter, Write};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use agen::llm_client::{LlmClient, Request};
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use llm_engine::llm_client::{LlmClient, Request};
|
||||||
|
|
||||||
/// Recorded event
|
/// Recorded event
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Defines requests and output file names for each scenario
|
//! Defines requests and output file names for each scenario
|
||||||
|
|
||||||
use agen::llm_client::{Request, ToolDefinition};
|
use llm_engine::llm_client::{Request, ToolDefinition};
|
||||||
|
|
||||||
/// Test scenario
|
/// Test scenario
|
||||||
pub struct TestScenario {
|
pub struct TestScenario {
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ pub trait Kind {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
/// use llm_engine::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
||||||
///
|
///
|
||||||
/// struct TextCollector {
|
/// struct TextCollector {
|
||||||
/// texts: Vec<String>,
|
/// texts: Vec<String>,
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! llm-engine - LLM Engine Library
|
||||||
|
//!
|
||||||
|
//! Provides components for managing interactions with LLMs.
|
||||||
|
//!
|
||||||
|
//! # Main Components
|
||||||
|
//!
|
||||||
|
//! - [`Engine`] - Central component for managing LLM interactions
|
||||||
|
//! - [`tool::Tool`] - Tools that can be invoked by the LLM
|
||||||
|
//! - [`interceptor::Interceptor`] - Control-flow delegation for the execution loop
|
||||||
|
//! - Closure-based event callbacks via `Engine::on_text_block()`, `on_tool_use_block()`, etc.
|
||||||
|
//!
|
||||||
|
//! # Quick Start
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use llm_engine::{Engine, Item};
|
||||||
|
//!
|
||||||
|
//! // Create a Engine
|
||||||
|
//! let mut engine = Engine::new(client)
|
||||||
|
//! .system_prompt("You are a helpful assistant.");
|
||||||
|
//!
|
||||||
|
//! // Register tools (optional)
|
||||||
|
//! // engine.register_tool(my_tool_definition)?;
|
||||||
|
//!
|
||||||
|
//! // Run the interaction
|
||||||
|
//! let history = engine.run("Hello!").await?;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Cache Protection
|
||||||
|
//!
|
||||||
|
//! `run()` automatically locks the cache. To edit state between turns,
|
||||||
|
//! call `unlock_cache()` first; the next `run()` re-locks automatically.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! engine.run("user input").await?;
|
||||||
|
//! engine.unlock_cache();
|
||||||
|
//! engine.set_system_prompt("new prompt");
|
||||||
|
//! engine.run("next input").await?;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
mod handler;
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
pub(crate) mod callback;
|
||||||
|
pub mod event;
|
||||||
|
pub mod interceptor;
|
||||||
|
pub mod llm_client;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod prune;
|
||||||
|
pub mod state;
|
||||||
|
pub mod timeline;
|
||||||
|
pub mod token_counter;
|
||||||
|
pub mod tool;
|
||||||
|
pub mod tool_server;
|
||||||
|
pub mod usage_record;
|
||||||
|
|
||||||
|
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||||
|
pub use engine::{
|
||||||
|
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||||
|
ToolRegistryError,
|
||||||
|
};
|
||||||
|
pub use handler::ToolUseBlockStart;
|
||||||
|
pub use interceptor::Interceptor;
|
||||||
|
pub use message::{ContentPart, Item, Message, Role};
|
||||||
|
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||||
|
pub use usage_record::UsageRecord;
|
||||||
@@ -30,7 +30,7 @@ pub enum AuthRequirement {
|
|||||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||||
///
|
///
|
||||||
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
||||||
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
|
/// 必要があるケースで使う。実体は呼び出し側に置き、llm-engine は
|
||||||
/// trait を知るだけ。
|
/// trait を知るだけ。
|
||||||
///
|
///
|
||||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||||
@@ -18,9 +18,6 @@ pub enum ClientError {
|
|||||||
message: String,
|
message: String,
|
||||||
retry_after: Option<Duration>,
|
retry_after: Option<Duration>,
|
||||||
},
|
},
|
||||||
/// The provider rejected the request because it exceeded the model context window.
|
|
||||||
/// Classified only from a structured provider error code, never message text.
|
|
||||||
ContextWindowExceeded,
|
|
||||||
/// A request lifecycle phase exceeded its hard timeout.
|
/// A request lifecycle phase exceeded its hard timeout.
|
||||||
Timeout {
|
Timeout {
|
||||||
phase: &'static str,
|
phase: &'static str,
|
||||||
@@ -51,7 +48,6 @@ impl fmt::Display for ClientError {
|
|||||||
}
|
}
|
||||||
write!(f, ": {}", message)
|
write!(f, ": {}", message)
|
||||||
}
|
}
|
||||||
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
|
|
||||||
ClientError::Timeout { phase, timeout } => {
|
ClientError::Timeout { phase, timeout } => {
|
||||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||||
}
|
}
|
||||||
@@ -116,10 +112,7 @@ pub fn is_retryable(error: &ClientError) -> bool {
|
|||||||
ClientError::Api { status: None, .. } => false,
|
ClientError::Api { status: None, .. } => false,
|
||||||
ClientError::Timeout { .. } => true,
|
ClientError::Timeout { .. } => true,
|
||||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||||
ClientError::ContextWindowExceeded
|
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||||
| ClientError::Json(_)
|
|
||||||
| ClientError::Sse(_)
|
|
||||||
| ClientError::Config(_) => false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||||
///
|
///
|
||||||
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
|
/// `Default` は llm-engine 全体の固定値を返す。呼び出し側からの上書きが
|
||||||
/// 必要になったら拡張する。
|
/// 必要になったら拡張する。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RetryPolicy {
|
pub struct RetryPolicy {
|
||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||||
//! 流れる。
|
//! 流れる。
|
||||||
//!
|
//!
|
||||||
//! - リクエスト JSON 生成: `request`
|
//! - リクエスト JSON 生成: [`request`]
|
||||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
|
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: [`events`]
|
||||||
|
|
||||||
mod capability;
|
mod capability;
|
||||||
mod events;
|
mod events;
|
||||||
+7
-4
@@ -431,7 +431,13 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||||
matches!(error, ClientError::ContextWindowExceeded)
|
match error {
|
||||||
|
ClientError::Api { code, message, .. } => {
|
||||||
|
code.as_deref() == Some("context_length_exceeded")
|
||||||
|
|| message.contains("context_length_exceeded")
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn response_with_timeout(
|
async fn response_with_timeout(
|
||||||
@@ -481,9 +487,6 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&text)
|
.unwrap_or(&text)
|
||||||
.to_string();
|
.to_string();
|
||||||
if code.as_deref() == Some("context_length_exceeded") {
|
|
||||||
return ClientError::ContextWindowExceeded;
|
|
||||||
}
|
|
||||||
ClientError::Api {
|
ClientError::Api {
|
||||||
status: Some(status),
|
status: Some(status),
|
||||||
code,
|
code,
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
use std::{fmt, sync::Arc};
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
use crate::tool::{Attachment, ToolResultDisposition};
|
use crate::tool::Attachment;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ impl fmt::Debug for RequestTrace {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::Item;
|
/// use llm_engine::Item;
|
||||||
///
|
///
|
||||||
/// let user = Item::user_message("Hello!");
|
/// let user = Item::user_message("Hello!");
|
||||||
/// let assistant = Item::assistant_message("Hi there!");
|
/// let assistant = Item::assistant_message("Hi there!");
|
||||||
@@ -121,9 +121,6 @@ pub enum Item {
|
|||||||
/// Detailed output (removed by pruning when old enough)
|
/// Detailed output (removed by pruning when old enough)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
/// Typed terminal state used for replay and recovery.
|
|
||||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
|
||||||
disposition: ToolResultDisposition,
|
|
||||||
/// Whether the tool result represents an execution error.
|
/// Whether the tool result represents an execution error.
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
@@ -264,17 +261,7 @@ impl Item {
|
|||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::tool_result_item_with_disposition_and_attachments(
|
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
||||||
call_id,
|
|
||||||
summary,
|
|
||||||
content,
|
|
||||||
if is_error {
|
|
||||||
ToolResultDisposition::Error
|
|
||||||
} else {
|
|
||||||
ToolResultDisposition::Success
|
|
||||||
},
|
|
||||||
Vec::new(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a tool result item with durable, prunable structured attachments.
|
/// Create a tool result item with durable, prunable structured attachments.
|
||||||
@@ -285,33 +272,11 @@ impl Item {
|
|||||||
is_error: bool,
|
is_error: bool,
|
||||||
attachments: Vec<Attachment>,
|
attachments: Vec<Attachment>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::tool_result_item_with_disposition_and_attachments(
|
|
||||||
call_id,
|
|
||||||
summary,
|
|
||||||
content,
|
|
||||||
if is_error {
|
|
||||||
ToolResultDisposition::Error
|
|
||||||
} else {
|
|
||||||
ToolResultDisposition::Success
|
|
||||||
},
|
|
||||||
attachments,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tool_result_item_with_disposition_and_attachments(
|
|
||||||
call_id: impl Into<String>,
|
|
||||||
summary: impl Into<String>,
|
|
||||||
content: Option<String>,
|
|
||||||
disposition: ToolResultDisposition,
|
|
||||||
attachments: Vec<Attachment>,
|
|
||||||
) -> Self {
|
|
||||||
let is_error = !disposition.is_success();
|
|
||||||
Self::ToolResult {
|
Self::ToolResult {
|
||||||
id: None,
|
id: None,
|
||||||
call_id: call_id.into(),
|
call_id: call_id.into(),
|
||||||
summary: summary.into(),
|
summary: summary.into(),
|
||||||
content,
|
content,
|
||||||
disposition,
|
|
||||||
is_error,
|
is_error,
|
||||||
attachments,
|
attachments,
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,12 @@ mod private {
|
|||||||
/// - Editing message history (add, delete, clear)
|
/// - Editing message history (add, delete, clear)
|
||||||
/// - Registering tools and hooks
|
/// - Registering tools and hooks
|
||||||
///
|
///
|
||||||
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
|
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::Engine;
|
/// use llm_engine::Engine;
|
||||||
///
|
///
|
||||||
/// let mut engine = Engine::new(client)
|
/// let mut engine = Engine::new(client)
|
||||||
/// .system_prompt("You are helpful.");
|
/// .system_prompt("You are helpful.");
|
||||||
@@ -348,7 +348,7 @@ where
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::{Timeline, Handler, TextBlockKind, TextBlockEvent};
|
/// use llm_engine::{Timeline, Handler, TextBlockKind, TextBlockEvent};
|
||||||
///
|
///
|
||||||
/// struct MyHandler;
|
/// struct MyHandler;
|
||||||
/// impl Handler<TextBlockKind> for MyHandler {
|
/// impl Handler<TextBlockKind> for MyHandler {
|
||||||
@@ -3,14 +3,7 @@
|
|||||||
//! Traits for defining tools callable by LLM.
|
//! Traits for defining tools callable by LLM.
|
||||||
//! Usually auto-implemented using the `#[tool]` macro.
|
//! Usually auto-implemented using the `#[tool]` macro.
|
||||||
|
|
||||||
use std::{
|
use std::{collections::HashMap, fmt, sync::Arc};
|
||||||
collections::HashMap,
|
|
||||||
fmt,
|
|
||||||
future::Future,
|
|
||||||
pin::Pin,
|
|
||||||
sync::Arc,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
@@ -30,12 +23,6 @@ pub enum ToolError {
|
|||||||
/// Internal error
|
/// Internal error
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
/// Cooperative cancellation completed with bounded terminal output.
|
|
||||||
#[error("Tool execution cancelled")]
|
|
||||||
Cancelled(ToolOutput),
|
|
||||||
/// Execution was interrupted with a confirmed bounded terminal output.
|
|
||||||
#[error("Tool execution interrupted")]
|
|
||||||
Interrupted(ToolOutput),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -171,28 +158,6 @@ pub enum Attachment {
|
|||||||
Image(ImageAttachment),
|
Image(ImageAttachment),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal disposition of one started tool call.
|
|
||||||
///
|
|
||||||
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
|
|
||||||
/// execution stopped without confirmation, so neither completion nor side
|
|
||||||
/// effects may be inferred.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ToolResultDisposition {
|
|
||||||
#[default]
|
|
||||||
Success,
|
|
||||||
Error,
|
|
||||||
Interrupted,
|
|
||||||
Cancelled,
|
|
||||||
OutcomeUnknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToolResultDisposition {
|
|
||||||
pub const fn is_success(&self) -> bool {
|
|
||||||
matches!(self, Self::Success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tool execution result.
|
/// Tool execution result.
|
||||||
///
|
///
|
||||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||||
@@ -357,12 +322,6 @@ impl ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifies one live execution attempt without making the batch id a durable
|
|
||||||
/// replay or idempotency authority.
|
|
||||||
pub fn execution_id(&self) -> String {
|
|
||||||
format!("{}:{}", self.batch_id, self.call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
||||||
pub fn direct() -> Self {
|
pub fn direct() -> Self {
|
||||||
Self::new("direct", "direct", 0)
|
Self::new("direct", "direct", 0)
|
||||||
@@ -375,142 +334,6 @@ impl Default for ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The provider-confirmed terminal result of one started tool execution.
|
|
||||||
///
|
|
||||||
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
|
|
||||||
/// or failed before the provider could confirm its terminal result.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ToolExecutionTerminal {
|
|
||||||
Confirmed(Result<ToolOutput, ToolError>),
|
|
||||||
OutcomeUnknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
|
|
||||||
/// future does not drop the provider execution: the spawned execution remains
|
|
||||||
/// owned by its handle until it completes or is explicitly force-closed.
|
|
||||||
pub struct ToolExecutionTerminalFuture {
|
|
||||||
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Future for ToolExecutionTerminalFuture {
|
|
||||||
type Output = ToolExecutionTerminal;
|
|
||||||
|
|
||||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
|
||||||
match Pin::new(&mut self.task).poll(cx) {
|
|
||||||
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
|
|
||||||
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
|
|
||||||
Poll::Pending => Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live ownership and control for one started tool execution.
|
|
||||||
///
|
|
||||||
/// Execution, cancellation, and terminal confirmation remain provider-owned:
|
|
||||||
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
|
|
||||||
/// `Tool::cancel_execution`, and treats execution-future completion as the
|
|
||||||
/// provider's terminal confirmation. Agen may force-close only after its caller's
|
|
||||||
/// deadline expires, at which point the outcome is necessarily unknown.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ToolExecutionHandle {
|
|
||||||
inner: Arc<ToolExecutionHandleInner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ToolExecutionHandleInner {
|
|
||||||
tool: Arc<dyn Tool>,
|
|
||||||
context: ToolExecutionContext,
|
|
||||||
abort: tokio::task::AbortHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for ToolExecutionHandleInner {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Losing the final live owner is an explicit forced close, never a
|
|
||||||
// best-effort detached provider future.
|
|
||||||
self.abort.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for ToolExecutionHandle {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("ToolExecutionHandle")
|
|
||||||
.field("call_id", &self.inner.context.call_id)
|
|
||||||
.field("batch_id", &self.inner.context.batch_id)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToolExecutionHandle {
|
|
||||||
pub fn start(
|
|
||||||
tool: Arc<dyn Tool>,
|
|
||||||
input_json: String,
|
|
||||||
context: ToolExecutionContext,
|
|
||||||
) -> (Self, ToolExecutionTerminalFuture) {
|
|
||||||
let execution_tool = Arc::clone(&tool);
|
|
||||||
let execution_context = context.clone();
|
|
||||||
let task =
|
|
||||||
tokio::spawn(
|
|
||||||
async move { execution_tool.execute(&input_json, execution_context).await },
|
|
||||||
);
|
|
||||||
let abort = task.abort_handle();
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(ToolExecutionHandleInner {
|
|
||||||
tool,
|
|
||||||
context,
|
|
||||||
abort,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
ToolExecutionTerminalFuture { task },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn context(&self) -> &ToolExecutionContext {
|
|
||||||
&self.inner.context
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
|
|
||||||
match tokio::time::timeout_at(
|
|
||||||
deadline,
|
|
||||||
self.inner.tool.cancel_execution(&self.inner.context),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(ToolError::Internal(format!(
|
|
||||||
"tool cancellation request exceeded its deadline for call {}",
|
|
||||||
self.inner.context.call_id
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn force_close(&self) {
|
|
||||||
self.inner.abort.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct ToolExecutionPolicy {
|
|
||||||
/// Time a pause waits for already-started providers to reach a natural safe
|
|
||||||
/// boundary before escalating to explicit cooperative cancellation.
|
|
||||||
pub pause_safe_boundary_timeout: std::time::Duration,
|
|
||||||
/// Maximum time allowed for a provider to accept one cooperative
|
|
||||||
/// cancellation request.
|
|
||||||
pub cancellation_request_timeout: std::time::Duration,
|
|
||||||
/// Maximum time allowed for all providers to confirm terminal results after
|
|
||||||
/// cancellation has been requested.
|
|
||||||
pub terminal_confirmation_timeout: std::time::Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ToolExecutionPolicy {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
|
|
||||||
cancellation_request_timeout: std::time::Duration::from_millis(100),
|
|
||||||
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tool trait
|
// Tool trait
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -541,7 +364,7 @@ impl Default for ToolExecutionPolicy {
|
|||||||
/// # Manual Implementation
|
/// # Manual Implementation
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolDefinition, ToolOutput};
|
/// use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolDefinition, ToolOutput};
|
||||||
/// use std::sync::Arc;
|
/// use std::sync::Arc;
|
||||||
///
|
///
|
||||||
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
|
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
|
||||||
@@ -579,26 +402,6 @@ pub trait Tool: Send + Sync {
|
|||||||
input_json: &str,
|
input_json: &str,
|
||||||
ctx: ToolExecutionContext,
|
ctx: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError>;
|
) -> Result<ToolOutput, ToolError>;
|
||||||
|
|
||||||
/// Request cooperative cancellation for one started call.
|
|
||||||
///
|
|
||||||
/// Implementations that own cancellable provider operations should signal
|
|
||||||
/// every live execution identified by `call_id`, then let `execute` return
|
|
||||||
/// the confirmed bounded terminal output. Direct callers may use this
|
|
||||||
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
|
|
||||||
/// can bind cancellation to one exact live attempt.
|
|
||||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request cooperative cancellation for one exact started execution.
|
|
||||||
///
|
|
||||||
/// The default preserves existing tools by delegating to `cancel(call_id)`.
|
|
||||||
/// Providers with their own execution registry should override this method
|
|
||||||
/// and key cancellation by [`ToolExecutionContext::execution_id`].
|
|
||||||
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
|
|
||||||
self.cancel(&ctx.call_id).await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -626,9 +429,6 @@ pub struct ToolCall {
|
|||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
/// Corresponding tool call ID
|
/// Corresponding tool call ID
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
/// Typed terminal state.
|
|
||||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
|
||||||
pub disposition: ToolResultDisposition,
|
|
||||||
/// Short summary (always kept in history)
|
/// Short summary (always kept in history)
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
/// Detailed output (prunable)
|
/// Detailed output (prunable)
|
||||||
@@ -645,20 +445,11 @@ pub struct ToolResult {
|
|||||||
impl ToolResult {
|
impl ToolResult {
|
||||||
/// Create a success result from a [`ToolOutput`].
|
/// Create a success result from a [`ToolOutput`].
|
||||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||||
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_output_with_disposition(
|
|
||||||
tool_use_id: impl Into<String>,
|
|
||||||
output: ToolOutput,
|
|
||||||
disposition: ToolResultDisposition,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
tool_use_id: tool_use_id.into(),
|
tool_use_id: tool_use_id.into(),
|
||||||
disposition,
|
|
||||||
summary: output.summary,
|
summary: output.summary,
|
||||||
content: output.content,
|
content: output.content,
|
||||||
is_error: !disposition.is_success(),
|
is_error: false,
|
||||||
attachments: output.attachments,
|
attachments: output.attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,28 +458,12 @@ impl ToolResult {
|
|||||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tool_use_id: tool_use_id.into(),
|
tool_use_id: tool_use_id.into(),
|
||||||
disposition: ToolResultDisposition::Error,
|
|
||||||
summary: message.into(),
|
summary: message.into(),
|
||||||
content: None,
|
content: None,
|
||||||
is_error: true,
|
is_error: true,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close an execution whose completion and side effects cannot be confirmed.
|
|
||||||
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
tool_use_id: tool_use_id.into(),
|
|
||||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
|
||||||
summary: "Tool execution outcome unknown".to_string(),
|
|
||||||
content: Some(
|
|
||||||
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
is_error: true,
|
|
||||||
attachments: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -8,13 +8,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use agen::Engine;
|
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
|
||||||
use agen::llm_client::retry::RetryPolicy;
|
|
||||||
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
use llm_engine::Engine;
|
||||||
|
use llm_engine::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||||
|
use llm_engine::llm_client::retry::RetryPolicy;
|
||||||
|
use llm_engine::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||||
|
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FailOnceClient {
|
struct FailOnceClient {
|
||||||
@@ -58,7 +58,6 @@ async fn test_callback_llm_retry_event() {
|
|||||||
max_attempts: 2,
|
max_attempts: 2,
|
||||||
total_timeout: Duration::from_secs(1),
|
total_timeout: Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||||
let sink = notices.clone();
|
let sink = notices.clone();
|
||||||
@@ -66,11 +65,8 @@ async fn test_callback_llm_retry_event() {
|
|||||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = engine.run(&mut history, "retry once").await;
|
let result = engine.run("retry once").await;
|
||||||
assert!(
|
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||||
matches!(result.result, agen::EngineRunExit::Finished),
|
|
||||||
"engine should succeed after one retry"
|
|
||||||
);
|
|
||||||
|
|
||||||
let notices = notices.lock().unwrap();
|
let notices = notices.lock().unwrap();
|
||||||
assert_eq!(notices.len(), 1);
|
assert_eq!(notices.len(), 1);
|
||||||
@@ -95,7 +91,6 @@ async fn test_callback_text_block_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -113,12 +108,9 @@ async fn test_callback_text_block_events() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run(&mut history, "Greet me").await;
|
let result = engine.run("Greet me").await;
|
||||||
assert!(
|
assert!(result.is_ok(), "Engine should complete");
|
||||||
matches!(result.result, agen::EngineRunExit::Finished),
|
|
||||||
"Engine should complete"
|
|
||||||
);
|
|
||||||
|
|
||||||
let deltas = text_deltas.lock().unwrap();
|
let deltas = text_deltas.lock().unwrap();
|
||||||
assert_eq!(deltas.len(), 2);
|
assert_eq!(deltas.len(), 2);
|
||||||
@@ -145,7 +137,6 @@ async fn test_callback_tool_call_complete() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -163,8 +154,8 @@ async fn test_callback_tool_call_complete() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let _ = engine.run(&mut history, "Weather please").await;
|
let _ = engine.run("Weather please").await;
|
||||||
|
|
||||||
let starts = tool_starts.lock().unwrap();
|
let starts = tool_starts.lock().unwrap();
|
||||||
assert_eq!(starts.len(), 1);
|
assert_eq!(starts.len(), 1);
|
||||||
@@ -192,7 +183,6 @@ async fn test_callback_turn_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -207,9 +197,9 @@ async fn test_callback_turn_events() {
|
|||||||
ends.lock().unwrap().push(turn);
|
ends.lock().unwrap().push(turn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run(&mut history, "Do something").await;
|
let result = engine.run("Do something").await;
|
||||||
assert!(matches!(result.result, agen::EngineRunExit::Finished));
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let starts = turn_starts.lock().unwrap();
|
let starts = turn_starts.lock().unwrap();
|
||||||
let ends = turn_ends.lock().unwrap();
|
let ends = turn_ends.lock().unwrap();
|
||||||
@@ -231,7 +221,7 @@ impl Tool for FixedOutputTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
_input_json: &str,
|
_input_json: &str,
|
||||||
_ctx: agen::tool::ToolExecutionContext,
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
Ok(self.output.clone())
|
Ok(self.output.clone())
|
||||||
}
|
}
|
||||||
@@ -264,14 +254,12 @@ async fn test_callback_tool_result_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
engine.register_tool(fixed_tool(
|
engine.register_tool(fixed_tool(
|
||||||
"fixed",
|
"fixed",
|
||||||
ToolOutput {
|
ToolOutput {
|
||||||
summary: "did the thing".into(),
|
summary: "did the thing".into(),
|
||||||
content: Some("full detail body".into()),
|
content: Some("full detail body".into()),
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -287,7 +275,7 @@ async fn test_callback_tool_result_events() {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = engine.run(&mut history, "call it").await;
|
let _ = engine.run("call it").await;
|
||||||
|
|
||||||
let observed = captured.lock().unwrap();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -308,7 +296,7 @@ impl Tool for ErroringTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
_input_json: &str,
|
_input_json: &str,
|
||||||
_ctx: agen::tool::ToolExecutionContext,
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
Err(ToolError::ExecutionFailed(self.message.clone()))
|
Err(ToolError::ExecutionFailed(self.message.clone()))
|
||||||
}
|
}
|
||||||
@@ -341,7 +329,6 @@ async fn test_callback_tool_result_error_path() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||||
|
|
||||||
@@ -357,7 +344,7 @@ async fn test_callback_tool_result_error_path() {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = engine.run(&mut history, "fail it").await;
|
let _ = engine.run("fail it").await;
|
||||||
|
|
||||||
let observed = captured.lock().unwrap();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -386,7 +373,6 @@ async fn test_callback_usage_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
let mut history = agen::History::new();
|
|
||||||
|
|
||||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
@@ -395,8 +381,8 @@ async fn test_callback_usage_events() {
|
|||||||
usages.lock().unwrap().push(event.clone());
|
usages.lock().unwrap().push(event.clone());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let _ = engine.run(&mut history, "Hello").await;
|
let _ = engine.run("Hello").await;
|
||||||
|
|
||||||
let usages = usage_events.lock().unwrap();
|
let usages = usage_events.lock().unwrap();
|
||||||
assert_eq!(usages.len(), 1);
|
assert_eq!(usages.len(), 1);
|
||||||
@@ -6,11 +6,11 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use agen::llm_client::event::{BlockType, DeltaContent, Event};
|
|
||||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
|
||||||
use agen::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
|
use llm_engine::llm_client::event::{BlockType, DeltaContent, Event};
|
||||||
|
use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
||||||
|
use llm_engine::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
pub struct MockLlmClient {
|
pub struct MockLlmClient {
|
||||||
responses: Arc<Vec<Vec<Event>>>,
|
responses: Arc<Vec<Vec<Event>>>,
|
||||||
call_count: Arc<AtomicUsize>,
|
call_count: Arc<AtomicUsize>,
|
||||||
requests: Arc<Mutex<Vec<Request>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockLlmClient {
|
impl MockLlmClient {
|
||||||
@@ -31,7 +30,6 @@ impl MockLlmClient {
|
|||||||
Self {
|
Self {
|
||||||
responses: Arc::new(responses),
|
responses: Arc::new(responses),
|
||||||
call_count: Arc::new(AtomicUsize::new(0)),
|
call_count: Arc::new(AtomicUsize::new(0)),
|
||||||
requests: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +41,6 @@ impl MockLlmClient {
|
|||||||
pub fn event_count(&self) -> usize {
|
pub fn event_count(&self) -> usize {
|
||||||
self.responses.iter().map(|v| v.len()).sum()
|
self.responses.iter().map(|v| v.len()).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn requests(&self) -> Vec<Request> {
|
|
||||||
self.requests.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -57,9 +51,8 @@ impl LlmClient for MockLlmClient {
|
|||||||
|
|
||||||
async fn stream(
|
async fn stream(
|
||||||
&self,
|
&self,
|
||||||
request: Request,
|
_request: Request,
|
||||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||||
self.requests.lock().unwrap().push(request);
|
|
||||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||||
if count >= self.responses.len() {
|
if count >= self.responses.len() {
|
||||||
return Err(ClientError::Api {
|
return Err(ClientError::Api {
|
||||||
@@ -279,7 +272,7 @@ pub fn assert_timeline_integration(subdir: &str) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for event in &events {
|
for event in &events {
|
||||||
let timeline_event: agen::timeline::event::Event = event.clone().into();
|
let timeline_event: llm_engine::timeline::event::Event = event.clone().into();
|
||||||
timeline.dispatch(&timeline_event);
|
timeline.dispatch(&timeline_event);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user