diff --git a/Cargo.lock b/Cargo.lock index 779d894..7decb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,24 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "bumpalo" version = "3.20.3" @@ -37,6 +55,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "errno" version = "0.3.14" @@ -47,6 +76,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -82,6 +129,196 @@ dependencies = [ "r-efi", ] +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -105,6 +342,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -135,6 +384,7 @@ dependencies = [ "anyhow", "async-trait", "pair_protocol", + "reqwest", "serde", "serde_json", "tokio", @@ -157,6 +407,7 @@ dependencies = [ "pair_context", "pair_patch", "pair_protocol", + "serde_json", "thiserror", "tokio", "uuid", @@ -192,12 +443,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -222,12 +488,50 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -271,6 +575,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -287,6 +603,28 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.118" @@ -298,6 +636,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -318,6 +676,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -329,6 +697,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys", ] @@ -344,12 +713,100 @@ dependencies = [ "syn", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.4" @@ -361,6 +818,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -380,6 +846,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -412,6 +888,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -427,6 +913,89 @@ dependencies = [ "windows-link", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 7a111ee..698b916 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ rust-version = "1.85" [workspace.dependencies] anyhow = "1" async-trait = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" diff --git a/README.md b/README.md index 738566a..eb84e46 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ The editor experience stays the same. ## Status and compatibility Pairagen is beta software. It has been developed and tested primarily with the -Codex CLI app-server backend. Generic CLI, stdio agent, and local model adapters -are available, but currently receive less real-world testing than Codex. +Codex CLI app-server backend. Persistent Claude CLI (stream-json), Ollama HTTP, +generic CLI, and stdio agent adapters are available, but currently receive less +real-world testing than Codex. Requirements: @@ -36,6 +37,9 @@ Implemented capabilities include: - patch gate - mock backend - generic CLI backend +- persistent Claude CLI backend (one stream-json process per session) +- Ollama HTTP backend for local models (model stays loaded, JSON-forced output) +- structured agent denial (`deny` op) rendered as a distinct card - deterministic token-budgeted project context with LSP hints and dependency ranking ## Installation @@ -170,6 +174,21 @@ Cards stay anchored beside the source line and do not take focus. Use `p to jump to a finding or the first line of an inline draft, and `pr` to focus the current Pair card. +When the agent can only proceed from a different file — say the fix belongs in +a component class while a template is open — it does not fail or deny. It asks +mid-turn: Pair shows a permission prompt ("Pair agent wants to open …"), and on +approval the file opens and the same agent turn continues with the new buffer. +One request, no retry. Declining (or ignoring for two minutes) surfaces a deny +card with the location and an `[o] Open & retry` shortcut instead. + +By default the first card is whatever fits the prompt best: a hypothesis, a +finding, or a clarifying choice when the prompt is ambiguous. Start the prompt +with `/{kind}` to demand a specific card instead — `/hypothesis`, `/finding`, +`/patch` (alias `/fix`), `/choice`, or `/summary`. For example +`/patch guard the payload here` skips discovery and drafts a patch directly. +Unknown words after `/` are treated as normal prompt text, so paths like +`/tmp/project` are safe. + The goal and accepted-step count stay visible on cards and editable drafts. After accepting a patch, Pair shows a local receipt without calling the agent again. `Next` explicitly continues the same goal and must return either one local patch diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..c6be047 --- /dev/null +++ b/doc/tags @@ -0,0 +1,22 @@ +:Pair pairagen.txt /*:Pair* +:PairAgent pairagen.txt /*:PairAgent* +:PairFix pairagen.txt /*:PairFix* +:PairFollow pairagen.txt /*:PairFollow* +:PairHide pairagen.txt /*:PairHide* +:PairLog pairagen.txt /*:PairLog* +:PairLogClear pairagen.txt /*:PairLogClear* +:PairModel pairagen.txt /*:PairModel* +:PairNext pairagen.txt /*:PairNext* +:PairOther pairagen.txt /*:PairOther* +:PairReply pairagen.txt /*:PairReply* +:PairReset pairagen.txt /*:PairReset* +:PairResume pairagen.txt /*:PairResume* +:PairStop pairagen.txt /*:PairStop* +:PairWhy pairagen.txt /*:PairWhy* +pairagen pairagen.txt /*pairagen* +pairagen-commands pairagen.txt /*pairagen-commands* +pairagen-contents pairagen.txt /*pairagen-contents* +pairagen-health pairagen.txt /*pairagen-health* +pairagen-privacy pairagen.txt /*pairagen-privacy* +pairagen-setup pairagen.txt /*pairagen-setup* +pairagen.txt pairagen.txt /*pairagen.txt* diff --git a/lua/pair/card.lua b/lua/pair/card.lua index 716e1fa..88bd498 100644 --- a/lua/pair/card.lua +++ b/lua/pair/card.lua @@ -96,8 +96,16 @@ function M.lines(card) M.add(lines, card.summary or card.title) elseif card.kind == "error" then M.add(lines, card.message or card.title) + elseif card.kind == "deny" then + table.insert(lines, "Agent could not proceed") + table.insert(lines, "") + M.add(lines, card.reason or card.title) elseif card.kind == "choice" then M.add(lines, card.question or card.title) + for index, option in ipairs(card.options or {}) do + table.insert(lines, "") + M.add(lines, string.format("%d. %s", index, option.label or option.id or "")) + end end local location = M.location(card) @@ -247,6 +255,10 @@ function M.actions(card) table.insert(parts, M.hint(config.values.keymaps.go_to, "Go to line")) end + if card.kind == "deny" and type(card.location) == "table" then + table.insert(parts, M.hint("o", "Open & retry")) + end + for _, action in ipairs(actions) do local name = type(action) == "table" and "apply_patch" or action local label = labels[name] @@ -293,6 +305,12 @@ function M.bind(buf, card) require("pair").go_to() end, { buffer = buf, nowait = true, silent = true }) + if card.kind == "deny" and type(card.location) == "table" then + vim.keymap.set("n", "o", function() + require("pair").open_and_retry() + end, { buffer = buf, nowait = true, silent = true }) + end + for _, action in ipairs(actions) do local name = type(action) == "table" and "apply" or action local label = labels[name] @@ -327,6 +345,7 @@ function M.highlight(buf, lines, card) or line:match("^At ") and "PairMuted" or line:match("tokens$") and "PairMuted" or card.kind == "error" and "DiagnosticError" + or card.kind == "deny" and line == "Agent could not proceed" and "DiagnosticError" if group then vim.api.nvim_buf_add_highlight(buf, -1, group, index - 1, 0, -1) end diff --git a/lua/pair/config.lua b/lua/pair/config.lua index 33eced9..bfa786e 100644 --- a/lua/pair/config.lua +++ b/lua/pair/config.lua @@ -6,6 +6,9 @@ M.values = { args = {}, mode = "auto", agent = "mock", + -- Speculative prefetch of the likely next card: "fix" requests the patch + -- in the background while you read a discovery card; "off" disables it. + prefetch = "fix", }, distribution = { repository = "DorianDevp/pairagen", @@ -36,9 +39,14 @@ M.values = { args = { "dev", "stdio-agent" }, }, claude = { - kind = "generic", + kind = "claude_app", command = "claude", args = {}, + -- Discovery cards (hypothesis/finding/choice) run on a faster model + -- with a capped thinking budget; patch drafting keeps the main model + -- with adaptive thinking. Set either to nil to use the CLI default. + discovery_model = "haiku", + discovery_thinking = 1024, }, aider = { kind = "generic", @@ -46,9 +54,9 @@ M.values = { args = {}, }, ["local"] = { - kind = "generic", - command = "ollama", - args = { "run", "qwen2.5-coder:7b" }, + kind = "ollama", + model = "qwen2.5-coder:7b", + host = "http://127.0.0.1:11434", }, }, keymaps = { @@ -285,7 +293,13 @@ end function M.backend_env() local _, agent = M.agent_config() + local env = M.agent_env(agent) + env.PAIR_PREFETCH = M.values.backend.prefetch or "fix" + + return env +end +function M.agent_env(agent) if agent.kind == "mock" then return { PAIR_BACKEND = "mock", @@ -316,6 +330,31 @@ function M.backend_env() } end + if agent.kind == "claude_app" then + local args = vim.deepcopy(agent.args or {}) + + return { + PAIR_BACKEND = "claude_app", + PAIR_CLAUDE_COMMAND = agent.command, + PAIR_CLAUDE_ARGS = table.concat(args, " "), + PAIR_CLAUDE_ARGS_JSON = vim.json.encode(args), + PAIR_CLAUDE_MODEL = agent.model or "", + PAIR_CLAUDE_DISCOVERY_MODEL = agent.discovery_model or "", + PAIR_CLAUDE_DISCOVERY_THINKING = agent.discovery_thinking + and tostring(agent.discovery_thinking) + or "", + } + end + + if agent.kind == "ollama" then + return { + PAIR_BACKEND = "ollama", + PAIR_OLLAMA_MODEL = agent.model or "", + PAIR_OLLAMA_HOST = agent.host or "", + PAIR_OLLAMA_KEEP_ALIVE = agent.keep_alive or "", + } + end + local args = M.agent_args(agent) return { diff --git a/lua/pair/init.lua b/lua/pair/init.lua index c11b928..aec9a0b 100644 --- a/lua/pair/init.lua +++ b/lua/pair/init.lua @@ -16,6 +16,26 @@ rpc.on("agent/progress", function(progress) thinking.progress(progress) end) +-- Mid-turn permission request: the agent can only continue once a different +-- file is open. On approval the same agent turn resumes with fresh context — +-- no card is shown and no extra request is spent. +rpc.on_request("editor/open_location", function(params, respond) + local location = params.location or {} + local file = location.file or "?" + local question = string.format( + "Pair agent wants to open %s:%s\n%s", + vim.fn.fnamemodify(file, ":~:."), + location.line or 1, + params.reason or "" + ) + + if vim.fn.confirm(question, "&Open\n&Deny", 1, "Question") == 1 and navigation.open_location(location) then + respond({ granted = true, context = context.session() }) + else + respond({ granted = false }) + end +end) + function M.setup(opts) config.setup(opts) require("pair.commands").setup() @@ -81,6 +101,7 @@ function M.start(text, mode, source) state.goal = message.result.goal or state.goal state.token_usage = message.result.token_usage state.turn_token_usage = message.result.turn_token_usage + state.backend_model = message.result.model or state.backend_model state.context_report = message.result.context_report log.event("context_optimization", message.result.context_report or {}) log.event("agent_attempts", message.result.attempts or {}) @@ -148,6 +169,7 @@ function M.action(action) state.token_usage = message.result.token_usage state.turn_token_usage = message.result.turn_token_usage + state.backend_model = message.result.model or state.backend_model state.context_report = message.result.context_report log.event("context_optimization", message.result.context_report or {}) log.event("agent_attempts", message.result.attempts or {}) @@ -212,6 +234,7 @@ function M.reply(text) state.token_usage = message.result.token_usage state.turn_token_usage = message.result.turn_token_usage + state.backend_model = message.result.model or state.backend_model state.context_report = message.result.context_report log.event("context_optimization", message.result.context_report or {}) log.event("agent_attempts", message.result.attempts or {}) @@ -246,6 +269,25 @@ function M.resume() ui.notify("No Pair card to restore", vim.log.levels.WARN) end +-- One-key continuation for a deny card that names a location: jump there, so +-- the next context capture sees that buffer, then retry the denied step. +function M.open_and_retry() + local active_card = state.card + if not (active_card and active_card.kind == "deny" and type(active_card.location) == "table") then + ui.notify("No location on this card", vim.log.levels.WARN) + return + end + + if not navigation.open_location(active_card.location) then + ui.notify("Could not open " .. tostring(active_card.location.file), vim.log.levels.ERROR) + return + end + + ui.close(state.card_win) + state.card_win = nil + M.action("retry") +end + function M.go_to() if state.card and state.card.kind == "patch" and require("pair.diff").focus_change() then return @@ -313,6 +355,7 @@ function M.reset() state.diff_first_row = nil state.token_usage = nil state.turn_token_usage = nil + state.backend_model = nil state.context_report = nil state.workspace_hints = nil state.completion_notified_card = nil diff --git a/lua/pair/prompt.lua b/lua/pair/prompt.lua index c3b9049..97963e6 100644 --- a/lua/pair/prompt.lua +++ b/lua/pair/prompt.lua @@ -7,9 +7,13 @@ local M = {} function M.open(mode) local source = require("pair.context").capture() + -- Let the backend pay its startup cost (CLI boot, process spawn) while the + -- user is still typing the prompt. + require("pair.rpc").request("backend/warmup", {}, function() end) + M.open_for({ title = M.title("Prompt"), - footer = " Ctrl-s submit Esc normal q close ", + footer = " /kind forces card type Ctrl-s submit Esc normal q close ", submit = function(text) require("pair").start(text, mode, source) end, @@ -29,6 +33,11 @@ end function M.title(kind) local agent = config.agent() local model = config.model() + if not model or model == "" then + -- No model configured: show what the backend reported it is actually + -- using, once a turn has completed. + model = state.backend_model + end local active = model and model ~= "" and (agent .. " / " .. model) or (agent .. " / default") return string.format(" Pair %s · %s ", kind, active) diff --git a/lua/pair/rpc.lua b/lua/pair/rpc.lua index 45969bd..8aa252e 100644 --- a/lua/pair/rpc.lua +++ b/lua/pair/rpc.lua @@ -211,6 +211,24 @@ function M.on(method, callback) M.notifications[method] = callback end +-- Handlers for requests initiated by paird (method + id). The handler +-- receives (params, respond); it must call respond(result) exactly once. +function M.on_request(method, callback) + M.requests = M.requests or {} + M.requests[method] = callback +end + +function M.respond(id, result) + local payload = vim.json.encode({ + jsonrpc = "2.0", + id = id, + result = result, + }) + + log.event("rpc_server_response", { id = id }) + vim.fn.chansend(M.job, payload .. "\n") +end + function M.on_data(data) if not data or #data == 0 then return @@ -249,6 +267,31 @@ function M.handle(line) return end + if message.method and message.id ~= nil then + local handler = M.requests and M.requests[message.method] + local id = message.id + + if not handler then + vim.fn.chansend( + M.job, + vim.json.encode({ + jsonrpc = "2.0", + id = id, + error = { code = -32601, message = "unknown editor request " .. message.method }, + }) .. "\n" + ) + return + end + + vim.schedule(function() + handler(message.params or {}, function(result) + M.respond(id, result) + end) + end) + + return + end + if message.id then local callback = M.pending[message.id] M.pending[message.id] = nil diff --git a/lua/pair/version.lua b/lua/pair/version.lua index b01e4f5..c702491 100644 --- a/lua/pair/version.lua +++ b/lua/pair/version.lua @@ -1,4 +1,4 @@ return { plugin = "0.1.0", - protocol = 5, + protocol = 6, } diff --git a/rust/crates/pair_backends/Cargo.toml b/rust/crates/pair_backends/Cargo.toml index 1438344..08dad64 100644 --- a/rust/crates/pair_backends/Cargo.toml +++ b/rust/crates/pair_backends/Cargo.toml @@ -12,6 +12,7 @@ publish = false anyhow.workspace = true async-trait.workspace = true pair_protocol = { path = "../pair_protocol" } +reqwest.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/rust/crates/pair_backends/src/claude_app.rs b/rust/crates/pair_backends/src/claude_app.rs new file mode 100644 index 0000000..448ec06 --- /dev/null +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -0,0 +1,890 @@ +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::process::Stdio; +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use pair_protocol::{Action, BackendInfo, Card, ErrorCard, TokenUsage}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; + +use crate::{ + BackendAction, BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, + BackendResponse, ProgressReporter, enforce_card_contract, estimate_tokens, +}; + +const SYSTEM_PROMPT: &str = r#"You are a local Pairagen pair-programming partner inside the user's editor. +Every user message is a JSON Pair request. Reply with exactly one JSON Pair op and nothing else: no prose, no markdown fences. +The discriminator field is named "op". Allowed ops, with exact shapes: +- {"op":"hypothesis","title":string,"claim":string,"evidence":LOC|null,"next":LOC|null} +- {"op":"finding","title":string,"finding":string,"location":LOC|null,"annotation":string|null} +- {"op":"patch","title":string,"explanation":string,"patches":[{"id":string|null,"file":string,"diff":string,"explanation":string}]} +- {"op":"choice","title":string,"question":string,"options":[{"id":string,"label":string,"action":string}]} +- {"op":"deny","title":string,"reason":string,"location":LOC|null} +- {"op":"open_location","reason":string,"location":LOC} +- {"op":"summary","title":string,"summary":string,"changed_files":[string]} +- {"op":"error","title":string,"message":string} +LOC is an object {"file":string,"line":int,"column":int,"annotation":string|null} with 1-based line and column; never a plain string. +choice option action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. +Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. error is only for technical failures. +If you can only proceed from a different file or location — for example the change belongs in another file than the supplied buffer — return open_location IMMEDIATELY with that exact place instead of attempting a patch. The editor asks the user for permission, opens the file, and the next message continues this same turn with a.kind "location_granted" and fresh ctx for that buffer; then produce the real op. Never draft a patch against a file that is not the supplied buffer. Use deny only for refusals that navigation cannot solve. +limits.expected, when set, names the op you must return (deny is always allowed instead; a clarifying choice is also accepted for hypothesis and finding). When limits.expected is null, choose whichever op fits best and ask via choice when the request is ambiguous. +Patch only for fix actions. patch.diff must be unified diff hunks starting with @@ against the supplied buffer. +A patch is one small local pair-programming step: one file, one hunk, no more changed lines than the supplied limit. Never plan or complete a whole refactor in one response. +Prefer the supplied context; you may use at most two targeted read-only searches when it is insufficient. Never edit files or run commands."#; + +/// Keeps `claude` CLI processes alive across turns using its stream-json +/// stdin/stdout mode. Each Pair session gets up to two processes: a discovery +/// process (hypothesis/finding/choice turns, optionally on a faster model +/// with a capped thinking budget) and a patch process (full model). Separate +/// processes let a speculative patch prefetch run while the user keeps +/// navigating discovery cards, and are required anyway because the CLI cannot +/// switch models within one process. +pub struct ClaudeAppBackend { + command: String, + args: Vec, + model: Option, + discovery_model: Option, + discovery_thinking: Option, + state: Mutex, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum Phase { + Discovery, + Patch, +} + +#[derive(Default)] +struct ClaudeAppState { + session_key: Option, + slots: HashMap>>, + // Pre-spawned discovery process created by warmup() before a session + // exists, adopted by the next session's first discovery turn. + warm: Option>>, +} + +#[derive(Default)] +struct ClaudeSlot { + process: Option, + context_fingerprint: Option, + model: Option, + reported_model: Option, +} + +struct ClaudeAppProcess { + child: Child, + stdin: ChildStdin, + stdout: Lines>, +} + +impl Drop for ClaudeAppProcess { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +struct TurnOutput { + text: String, + token_usage: Option, + model: Option, +} + +enum StreamEvent { + Init(Option), + Working(String), + Delta(String), + Result { + text: String, + token_usage: Option, + }, + Failed(String), + Other, +} + +/// Extracts card fields from the partially streamed op JSON so the editor can +/// show what is being drafted before the turn completes. +#[derive(Default)] +struct StreamPreview { + buffer: String, + title_reported: bool, + body_reported: bool, +} + +const PREVIEW_BODY_FIELDS: &[&str] = &[ + "claim", + "finding", + "question", + "explanation", + "reason", + "summary", + "message", +]; +const PREVIEW_BUFFER_LIMIT: usize = 4096; +const PREVIEW_BODY_MIN_CHARS: usize = 40; +const PREVIEW_BODY_MAX_CHARS: usize = 72; + +impl StreamPreview { + fn push(&mut self, delta: &str) -> Option { + if self.body_reported || self.buffer.len() > PREVIEW_BUFFER_LIMIT { + return None; + } + + self.buffer.push_str(delta); + + if !self.title_reported { + let (title, complete) = extract_string_field(&self.buffer, "title")?; + if !complete || title.trim().is_empty() { + return None; + } + self.title_reported = true; + return Some(format!("Drafting: {title}")); + } + + let (title, _) = extract_string_field(&self.buffer, "title")?; + let (body, complete) = PREVIEW_BODY_FIELDS + .iter() + .find_map(|field| extract_string_field(&self.buffer, field))?; + if !complete && body.chars().count() < PREVIEW_BODY_MIN_CHARS { + return None; + } + self.body_reported = true; + let snippet = body + .chars() + .take(PREVIEW_BODY_MAX_CHARS) + .collect::(); + let ellipsis = if body.chars().count() > PREVIEW_BODY_MAX_CHARS || !complete { + "…" + } else { + "" + }; + + Some(format!("{title}: {snippet}{ellipsis}")) + } +} + +/// Returns the (possibly still streaming) value of `"field":"..."` in `json`, +/// plus whether its closing quote has arrived. +fn extract_string_field(json: &str, field: &str) -> Option<(String, bool)> { + let needle = format!("\"{field}\""); + let start = json.find(&needle)? + needle.len(); + let rest = json[start..].trim_start(); + let rest = rest.strip_prefix(':')?.trim_start(); + let rest = rest.strip_prefix('"')?; + + let mut value = String::new(); + let mut chars = rest.chars(); + while let Some(next) = chars.next() { + match next { + '"' => return Some((value, true)), + '\\' => match chars.next() { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('u') => { + // Good enough for a preview: skip the escape digits. + for _ in 0..4 { + chars.next(); + } + value.push('?'); + } + Some(escaped) => value.push(escaped), + None => return Some((value, false)), + }, + _ => value.push(next), + } + } + + Some((value, false)) +} + +impl ClaudeAppBackend { + pub fn from_env() -> Result { + let command = std::env::var("PAIR_CLAUDE_COMMAND").unwrap_or_else(|_| "claude".into()); + let args = args_from_env("PAIR_CLAUDE_ARGS_JSON", "PAIR_CLAUDE_ARGS")?; + let model = optional_env("PAIR_CLAUDE_MODEL"); + let discovery_model = optional_env("PAIR_CLAUDE_DISCOVERY_MODEL"); + let discovery_thinking = optional_env("PAIR_CLAUDE_DISCOVERY_THINKING"); + + Ok(Self::new( + command, + args, + model, + discovery_model, + discovery_thinking, + )) + } + + pub fn new( + command: impl Into, + args: Vec, + model: Option, + discovery_model: Option, + discovery_thinking: Option, + ) -> Self { + Self { + command: command.into(), + args, + model, + discovery_model, + discovery_thinking, + state: Mutex::new(ClaudeAppState::default()), + } + } + + fn phase_model(&self, phase: Phase) -> Option { + match phase { + Phase::Patch => self.model.clone(), + Phase::Discovery => self.discovery_model.clone().or_else(|| self.model.clone()), + } + } + + fn phase_thinking(&self, phase: Phase) -> Option { + match phase { + // Patch turns keep the CLI's adaptive thinking: diff correctness + // is where reasoning pays for itself. + Phase::Patch => None, + Phase::Discovery => self.discovery_thinking.clone(), + } + } + + fn spawn_args(&self, model: &Option) -> Vec { + let mut args = vec![ + "-p".into(), + "--input-format".into(), + "stream-json".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--disallowedTools".into(), + "Edit,Write,NotebookEdit,Bash".into(), + "--append-system-prompt".into(), + SYSTEM_PROMPT.into(), + ]; + + if let Some(model) = model { + args.push("--model".into()); + args.push(model.clone()); + } + + args.extend(self.args.iter().cloned()); + + args + } + + fn spawn_process( + &self, + model: &Option, + thinking: &Option, + ) -> Result { + let mut command = Command::new(&self.command); + command + .args(self.spawn_args(model)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + + if let Some(thinking) = thinking { + command.env("MAX_THINKING_TOKENS", thinking); + } + + let mut child = command.spawn()?; + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("claude stdin unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("claude stdout unavailable"))?; + + Ok(ClaudeAppProcess { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + }) + } + + /// Pre-spawns a discovery process before any session exists so the first + /// card skips the CLI cold start. Called while the user is still typing + /// the prompt; idempotent and cheap when something is already running. + async fn warm_up(&self) -> Result<()> { + let slot = { + let mut state = self.state.lock().await; + if !state.slots.is_empty() || state.warm.is_some() { + return Ok(()); + } + let slot = Arc::new(Mutex::new(ClaudeSlot { + model: self.phase_model(Phase::Discovery), + ..ClaudeSlot::default() + })); + state.warm = Some(slot.clone()); + slot + }; + + let mut slot = slot.lock().await; + if slot.process.is_none() { + slot.process = Some(self.spawn_process( + &self.phase_model(Phase::Discovery), + &self.phase_thinking(Phase::Discovery), + )?); + } + + Ok(()) + } + + /// Returns the slot for this turn's phase, creating it (or adopting the + /// warm process) as needed. The outer state lock is held only for the + /// bookkeeping; the returned slot's own lock serializes the actual turn, + /// so discovery and patch turns can run concurrently. + async fn slot(&self, session_key: &str, phase: Phase) -> Arc> { + let mut state = self.state.lock().await; + + if state.session_key.as_deref() != Some(session_key) { + // One process holds one conversation; a new Pair session must not + // inherit the previous session's context. + state.slots.clear(); + state.session_key = Some(session_key.to_string()); + } + + if let Some(slot) = state.slots.get(&phase) { + return slot.clone(); + } + + let wanted_model = self.phase_model(phase); + let slot = match (phase, state.warm.take()) { + (Phase::Discovery, Some(warm)) => { + let adoptable = warm + .try_lock() + .map(|slot| slot.model == wanted_model) + .unwrap_or(false); + if adoptable { + warm + } else { + Arc::new(Mutex::new(ClaudeSlot { + model: wanted_model, + ..ClaudeSlot::default() + })) + } + } + (_, warm) => { + state.warm = warm; + Arc::new(Mutex::new(ClaudeSlot { + model: wanted_model, + ..ClaudeSlot::default() + })) + } + }; + + state.slots.insert(phase, slot.clone()); + + slot + } + + async fn ask( + &self, + req: &BackendRequest, + progress: Option<&ProgressReporter>, + ) -> Result { + let phase = turn_phase(req); + let slot = self.slot(&req.session.id, phase).await; + let mut slot = slot.lock().await; + + report_progress( + progress, + &req.session.id, + "starting", + if slot.process.is_some() { + "Reusing the Claude session" + } else { + "Starting Claude" + }, + ); + + if slot.process.is_none() { + slot.process = Some(self.spawn_process(&slot.model, &self.phase_thinking(phase))?); + slot.context_fingerprint = None; + } + + let fingerprint = context_fingerprint(req); + let include_context = slot.context_fingerprint != Some(fingerprint); + slot.context_fingerprint = Some(fingerprint); + + if let Err(error) = send_turn(&mut slot, &turn_prompt(req, include_context)).await { + // The process may have died between turns; retry once on a fresh + // process with full context before giving up. + slot.process = Some(self.spawn_process(&slot.model, &self.phase_thinking(phase))?); + slot.context_fingerprint = Some(fingerprint); + send_turn(&mut slot, &turn_prompt(req, true)) + .await + .map_err(|retry| anyhow!("could not reach claude: {error}; retry: {retry}"))?; + } + + report_progress( + progress, + &req.session.id, + "working", + "Claude is processing the request", + ); + + let mut preview = StreamPreview::default(); + + loop { + let line = { + let process = slot + .process + .as_mut() + .ok_or_else(|| anyhow!("claude process unavailable"))?; + match process.stdout.next_line().await? { + Some(line) => line, + None => { + slot.process = None; + return Err(anyhow!( + "claude exited before finishing the turn; check that the claude CLI is logged in" + )); + } + } + }; + + if line.trim().is_empty() { + continue; + } + + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + + match parse_stream_event(&value) { + StreamEvent::Init(model) => { + slot.reported_model = model; + } + StreamEvent::Working(activity) => { + report_progress(progress, &req.session.id, "working", &activity); + } + StreamEvent::Delta(text) => { + if let Some(message) = preview.push(&text) { + report_progress(progress, &req.session.id, "drafting", &message); + } + } + StreamEvent::Result { text, token_usage } => { + return Ok(TurnOutput { + text, + token_usage, + model: slot.reported_model.clone().or_else(|| slot.model.clone()), + }); + } + StreamEvent::Failed(message) => { + return Err(anyhow!("claude turn failed: {message}")); + } + StreamEvent::Other => {} + } + } + } + + fn error_card(message: impl Into) -> Card { + Card::Error(ErrorCard { + id: "c_claude_error".into(), + title: "Claude error".into(), + message: message.into(), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }) + } +} + +async fn send_turn(slot: &mut ClaudeSlot, prompt: &str) -> Result<()> { + let message = json!({ + "type": "user", + "message": { + "role": "user", + "content": [{"type": "text", "text": prompt}] + } + }); + let mut line = serde_json::to_vec(&message)?; + line.push(b'\n'); + let process = slot + .process + .as_mut() + .ok_or_else(|| anyhow!("claude process unavailable"))?; + + process.stdin.write_all(&line).await?; + process.stdin.flush().await?; + + Ok(()) +} + +#[async_trait] +impl BackendAdapter for ClaudeAppBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.next_card_with_progress(req, None).await + } + + async fn next_card_with_progress( + &self, + req: BackendRequest, + progress: Option, + ) -> Result { + let output = self.ask(&req, progress.as_ref()).await?; + let card = crate::parse_card(&output.text).unwrap_or_else(|error| { + Self::error_card(format!("{error}\n\nRaw output:\n{}", output.text)) + }); + let card = enforce_card_contract(card, &req.card_contract, "Claude", &output.text); + let token_usage = output.token_usage.unwrap_or_else(|| { + TokenUsage::estimated( + estimate_tokens(&turn_prompt(&req, true)), + estimate_tokens(&output.text), + ) + }); + + Ok(BackendResponse { + card, + raw_output: Some(output.text), + metadata: BackendMetadata { + backend: "claude_app".into(), + model: output.model, + token_usage: Some(token_usage), + activities: vec![], + attempts: vec![], + }, + }) + } + + async fn warmup(&self) -> Result<()> { + self.warm_up().await + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "claude_app".into(), + streaming: true, + patches: true, + reasoning: true, + can_read_project: true, + can_use_tools: true, + } + } +} + +fn turn_phase(req: &BackendRequest) -> Phase { + if req.card_contract.expected_kind == Some(pair_protocol::CardKind::Patch) + || req.card_contract.allow_goal_completion + { + Phase::Patch + } else { + Phase::Discovery + } +} + +fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { + let mut value = json!({ + "s": { + "id": req.session.id, + "p": req.session.prompt, + "completed_steps": req.session.completed_steps, + "known_observations": req.session.known_observations, + "mode": req.session.mode, + "n": req.session.card_count, + "last": req.session.last_summary + }, + "a": action_value(&req.action), + "limits": { + "one": req.card_contract.one_card_only, + "max": req.card_contract.max_body_chars, + "patch_files": req.card_contract.max_patch_files, + "hunks_per_patch": req.card_contract.max_hunks_per_patch, + "changed_lines": req.card_contract.max_changed_lines, + "goal_completion": req.card_contract.allow_goal_completion, + "expected": req.card_contract.expected_kind + } + }); + + if include_context { + value["ctx"] = crate::backend_context(&req.context); + } else { + value["ctx"] = json!("unchanged; reuse the context from the previous message"); + } + + serde_json::to_string(&value).unwrap_or_default() +} + +fn context_fingerprint(req: &BackendRequest) -> u64 { + let mut hasher = DefaultHasher::new(); + req.context.file.hash(&mut hasher); + req.context.cursor.line.hash(&mut hasher); + req.context.cursor.column.hash(&mut hasher); + req.context.buffer_start_line.hash(&mut hasher); + req.context.buffer_text.hash(&mut hasher); + for diagnostic in &req.context.diagnostics { + diagnostic.file.hash(&mut hasher); + diagnostic.line.hash(&mut hasher); + diagnostic.message.hash(&mut hasher); + } + for artifact in &req.context.artifacts { + artifact.file.hash(&mut hasher); + artifact.start_line.hash(&mut hasher); + artifact.text.hash(&mut hasher); + } + hasher.finish() +} + +fn action_value(action: &BackendAction) -> Value { + match action { + BackendAction::Start => json!({"kind": "start"}), + BackendAction::User(action) => { + json!({"kind": "user", "action": serde_json::to_value(action).unwrap_or_default()}) + } + BackendAction::Reply(text) => json!({"kind": "reply", "text": text}), + BackendAction::ContractRetry(reason) => { + json!({"kind": "contract_retry", "reason": reason}) + } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), + } +} + +fn parse_stream_event(value: &Value) -> StreamEvent { + match value.get("type").and_then(Value::as_str) { + Some("system") if value.get("subtype").and_then(Value::as_str) == Some("init") => { + StreamEvent::Init( + value + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + ) + } + Some("stream_event") => { + let delta = value.get("event").and_then(|event| event.get("delta")); + let text = delta + .filter(|delta| delta.get("type").and_then(Value::as_str) == Some("text_delta")) + .and_then(|delta| delta.get("text")) + .and_then(Value::as_str); + + match text { + Some(text) => StreamEvent::Delta(text.to_string()), + None => StreamEvent::Other, + } + } + Some("result") => { + let text = value + .get("result") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let failed = value.get("is_error").and_then(Value::as_bool) == Some(true) + || value.get("error").is_some_and(|error| !error.is_null()); + + if failed { + let message = value + .get("error") + .filter(|error| !error.is_null()) + .map(Value::to_string) + .unwrap_or_else(|| text.clone()); + return StreamEvent::Failed(message); + } + + StreamEvent::Result { + text, + token_usage: parse_usage(value.get("usage")), + } + } + Some("assistant") => { + let tool = value + .get("message") + .and_then(|message| message.get("content")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .find_map(|block| { + (block.get("type").and_then(Value::as_str) == Some("tool_use")) + .then(|| block.get("name").and_then(Value::as_str)) + .flatten() + }); + + match tool { + Some(name) => StreamEvent::Working(format!("Claude is using {name}")), + None => StreamEvent::Other, + } + } + _ => StreamEvent::Other, + } +} + +fn parse_usage(value: Option<&Value>) -> Option { + let usage = value?; + let input = usage.get("input_tokens")?.as_u64()? as usize; + let output = usage.get("output_tokens")?.as_u64()? as usize; + + Some(TokenUsage::reported(input, output)) +} + +fn optional_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn args_from_env(json_name: &str, plain_name: &str) -> Result> { + if let Ok(value) = std::env::var(json_name) + && !value.trim().is_empty() + { + return Ok(serde_json::from_str(&value)?); + } + + Ok(std::env::var(plain_name) + .unwrap_or_default() + .split_whitespace() + .map(str::to_string) + .collect()) +} + +fn report_progress( + progress: Option<&ProgressReporter>, + session_id: &str, + phase: &str, + message: &str, +) { + if let Some(progress) = progress { + progress(BackendProgress { + session_id: session_id.into(), + phase: phase.into(), + message: message.into(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_result_text_and_usage() { + let value = json!({ + "type": "result", + "result": "{\"op\":\"finding\",\"title\":\"T\",\"finding\":\"F\"}", + "usage": {"input_tokens": 120, "output_tokens": 30}, + "error": null + }); + + let StreamEvent::Result { text, token_usage } = parse_stream_event(&value) else { + panic!("expected result event"); + }; + assert!(text.contains("\"op\":\"finding\"")); + let usage = token_usage.unwrap(); + assert_eq!(usage.input_tokens, 120); + assert_eq!(usage.output_tokens, 30); + assert!(!usage.estimated); + } + + #[test] + fn extracts_model_from_init_event() { + let value = json!({ + "type": "system", + "subtype": "init", + "session_id": "abc", + "model": "claude-opus-4-8" + }); + + let StreamEvent::Init(model) = parse_stream_event(&value) else { + panic!("expected init event"); + }; + assert_eq!(model.as_deref(), Some("claude-opus-4-8")); + } + + #[test] + fn extracts_text_deltas_and_skips_thinking() { + let text = json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "abc"}} + }); + let thinking = json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "delta": {"type": "thinking_delta", "thinking": "hmm"}} + }); + + assert!(matches!( + parse_stream_event(&text), + StreamEvent::Delta(delta) if delta == "abc" + )); + assert!(matches!(parse_stream_event(&thinking), StreamEvent::Other)); + } + + #[test] + fn detects_failed_turns() { + let value = json!({ + "type": "result", + "result": "credit balance too low", + "is_error": true + }); + + assert!(matches!(parse_stream_event(&value), StreamEvent::Failed(_))); + } + + #[test] + fn reports_tool_use_as_activity() { + let value = json!({ + "type": "assistant", + "message": {"content": [{"type": "tool_use", "name": "Grep", "input": {}}]} + }); + + let StreamEvent::Working(activity) = parse_stream_event(&value) else { + panic!("expected working event"); + }; + assert!(activity.contains("Grep")); + } + + #[test] + fn preview_reports_title_then_body_once() { + let mut preview = StreamPreview::default(); + + assert_eq!(preview.push("{\"op\":\"hypothesis\",\"ti"), None); + assert_eq!( + preview.push("tle\":\"Falsy guard\","), + Some("Drafting: Falsy guard".into()) + ); + assert_eq!(preview.push("\"claim\":\"The guard rejects"), None); + let body = preview + .push(" 0, empty strings and false, so callers lose data\"") + .expect("body preview"); + assert!(body.starts_with("Falsy guard: The guard rejects 0")); + assert_eq!(preview.push("\"more\":\"noise\""), None); + } + + #[test] + fn extract_string_field_handles_escapes_and_partials() { + assert_eq!( + extract_string_field(r#"{"title":"a \"quoted\" step""#, "title"), + Some(("a \"quoted\" step".into(), true)) + ); + assert_eq!( + extract_string_field(r#"{"title":"still stream"#, "title"), + Some(("still stream".into(), false)) + ); + assert_eq!(extract_string_field(r#"{"titl"#, "title"), None); + } + + #[test] + fn routes_patch_turns_to_the_patch_phase() { + let mut req = crate::test_request(); + assert!(matches!(turn_phase(&req), Phase::Discovery)); + + req.card_contract.expected_kind = Some(pair_protocol::CardKind::Patch); + assert!(matches!(turn_phase(&req), Phase::Patch)); + + req.card_contract.expected_kind = None; + req.card_contract.allow_goal_completion = true; + assert!(matches!(turn_phase(&req), Phase::Patch)); + } + + #[test] + fn turn_prompt_omits_unchanged_context() { + let req = crate::test_request(); + let with_context = turn_prompt(&req, true); + let without_context = turn_prompt(&req, false); + + assert!(with_context.contains("buffer_text")); + assert!(!without_context.contains("buffer_text")); + assert!(without_context.contains("unchanged")); + } +} diff --git a/rust/crates/pair_backends/src/codex_app.rs b/rust/crates/pair_backends/src/codex_app.rs index c72aa7a..e0c612f 100644 --- a/rust/crates/pair_backends/src/codex_app.rs +++ b/rust/crates/pair_backends/src/codex_app.rs @@ -535,6 +535,7 @@ impl BackendAdapter for CodexAppBackend { raw_output: Some(output.text.clone()), metadata: BackendMetadata { backend: "codex_app".into(), + model: self.model.clone(), token_usage: output.token_usage.or_else(|| { Some(TokenUsage::estimated( estimate_tokens(&prompt(&req, true)), @@ -705,6 +706,7 @@ fn action_value(action: &BackendAction) -> Value { BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), } } @@ -880,12 +882,81 @@ fn output_schema(req: &BackendRequest) -> Value { Some(pair_protocol::CardKind::Hypothesis) => hypothesis_schema(), Some(pair_protocol::CardKind::Finding) => finding_schema(), Some(pair_protocol::CardKind::Choice) => choice_schema(), + Some(pair_protocol::CardKind::Deny) => deny_schema(), Some(pair_protocol::CardKind::Summary) => summary_schema(), Some(pair_protocol::CardKind::Error) => error_schema(), - None => error_schema(), + Some(pair_protocol::CardKind::OpenLocation) | None => any_op_schema(), } } +/// Schema for turns without a demanded kind: the agent picks whichever op +/// fits, including a clarifying choice or a deny. Mirrors +/// schemas/pair-agent-op.schema.json (every field present, unused ones null). +fn any_op_schema() -> Value { + object_schema( + &[ + "op", + "title", + "claim", + "evidence", + "next", + "finding", + "location", + "annotation", + "explanation", + "patches", + "question", + "options", + "reason", + "summary", + "changed_files", + "message", + ], + json!({ + "op": {"type": "string", "enum": ["hypothesis", "finding", "patch", "choice", "deny", "open_location", "summary", "error"]}, + "title": {"type": "string"}, + "claim": {"type": ["string", "null"]}, + "evidence": nullable_location_schema(), + "next": nullable_location_schema(), + "finding": {"type": ["string", "null"]}, + "location": nullable_location_schema(), + "annotation": {"type": ["string", "null"]}, + "explanation": {"type": ["string", "null"]}, + "patches": { + "type": ["array", "null"], + "items": object_schema( + &["id", "file", "diff", "explanation"], + json!({ + "id": {"type": ["string", "null"]}, + "file": {"type": "string"}, + "diff": {"type": "string"}, + "explanation": {"type": "string"} + }) + ) + }, + "question": {"type": ["string", "null"]}, + "options": { + "type": ["array", "null"], + "items": object_schema( + &["id", "label", "action"], + json!({ + "id": {"type": "string"}, + "label": {"type": "string"}, + "action": { + "type": "string", + "enum": ["follow", "why", "fix", "other_lead", "retry", "edit_prompt", "open", "run_check", "next", "stop"] + } + }) + ) + }, + "reason": {"type": ["string", "null"]}, + "summary": {"type": ["string", "null"]}, + "changed_files": {"type": ["array", "null"], "items": {"type": "string"}}, + "message": {"type": ["string", "null"]} + }), + ) +} + fn goal_step_schema(contract: &crate::CardContract) -> Value { let mut patches = patch_schema(contract)["properties"]["patches"].clone(); patches["minItems"] = json!(0); @@ -1052,6 +1123,18 @@ fn summary_schema() -> Value { ) } +fn deny_schema() -> Value { + object_schema( + &["op", "title", "reason", "location"], + json!({ + "op": {"type": "string", "enum": ["deny"]}, + "title": {"type": "string"}, + "reason": {"type": "string"}, + "location": nullable_location_schema() + }), + ) +} + fn error_schema() -> Value { object_schema( &["op", "title", "message"], diff --git a/rust/crates/pair_backends/src/generic.rs b/rust/crates/pair_backends/src/generic.rs index 30ea503..486cca0 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -33,8 +33,22 @@ impl GenericCliBackend { } fn prompt(&self, req: &BackendRequest) -> String { - let value = json!({ - "api": "Return one JSON Pair op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,patches), choice(title,question,options), summary(title,summary,changed_files), error(title,message). Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", + generic_prompt(req) + } + + fn error_card(message: impl Into) -> Card { + Card::Error(ErrorCard { + id: "c_backend_error".into(), + title: "Backend error".into(), + message: message.into(), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }) + } +} + +pub(crate) fn generic_prompt(req: &BackendRequest) -> String { + let value = json!({ + "api": "Return one JSON Pair op only. No prose. Ops: hypothesis(title,claim,evidence,next), finding(title,finding,location,annotation), patch(title,explanation,patches), choice(title,question,options), deny(title,reason,location), open_location(reason,location), summary(title,summary,changed_files), error(title,message). choice.options items are {id,label,action} objects; action is one of follow|why|fix|other_lead|retry|edit_prompt|open|run_check|next|stop. Use deny when you cannot or should not proceed (ambiguous prompt, missing information, out-of-scope request); reason is shown to the user. When the change belongs in a different file than the supplied buffer, return open_location immediately with that place instead of attempting a patch; the editor opens it with the user's permission and the same turn continues with a.kind location_granted and fresh ctx. error is only for technical failures. limits.expected, when set, is the required op (deny always allowed; choice also allowed for hypothesis/finding); when null, choose the best fitting op and ask via choice when ambiguous. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", "stream": { "protocol": "ndjson", "progress": {"t": "pair_progress", "phase": "short phase", "message": "short user-visible activity summary"}, @@ -60,7 +74,8 @@ impl GenericCliBackend { "patch_files": req.card_contract.max_patch_files, "hunks_per_patch": req.card_contract.max_hunks_per_patch, "changed_lines": req.card_contract.max_changed_lines, - "goal_completion": req.card_contract.allow_goal_completion + "goal_completion": req.card_contract.allow_goal_completion, + "expected": req.card_contract.expected_kind }, "s": { "id": req.session.id, @@ -71,21 +86,11 @@ impl GenericCliBackend { "n": req.session.card_count, "last": req.session.last_summary }, - "a": action_value(&req.action), - "ctx": crate::backend_context(&req.context) - }); + "a": action_value(&req.action), + "ctx": crate::backend_context(&req.context) + }); - serde_json::to_string(&value).unwrap_or_default() - } - - fn error_card(message: impl Into) -> Card { - Card::Error(ErrorCard { - id: "c_backend_error".into(), - title: "Backend error".into(), - message: message.into(), - actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], - }) - } + serde_json::to_string(&value).unwrap_or_default() } fn action_value(action: &crate::BackendAction) -> serde_json::Value { @@ -98,6 +103,7 @@ fn action_value(action: &crate::BackendAction) -> serde_json::Value { crate::BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } + crate::BackendAction::LocationGranted => json!({"kind": "location_granted"}), } } @@ -198,6 +204,7 @@ impl BackendAdapter for GenericCliBackend { raw_output: Some(raw_output), metadata: BackendMetadata { backend: "generic_cli".into(), + model: None, token_usage: Some(TokenUsage::estimated( estimate_tokens(&prompt), estimate_tokens(&stdout), @@ -244,12 +251,8 @@ fn backend_name(command: &str) -> String { .to_string() } -fn parse_card(output: &str) -> Result { - if let Ok(op) = serde_json::from_str::(output.trim()) { - return Ok(op.into_card("c_agent")); - } - - if let Ok(card) = serde_json::from_str(output.trim()) { +pub(crate) fn parse_card(output: &str) -> Result { + if let Ok(card) = parse_json_card(output.trim()) { return Ok(card); } @@ -257,11 +260,39 @@ fn parse_card(output: &str) -> Result { return Err(anyhow!("backend returned no Pair op")); }; - if let Ok(op) = serde_json::from_str::(json) { + parse_json_card(json) +} + +fn parse_json_card(json: &str) -> Result { + let value = serde_json::from_str::(json)?; + + // Dispatch on the discriminator so a malformed op reports what is wrong with + // the op itself instead of the misleading Card error ("missing field kind"). + if value.get("op").is_some() { + let op = serde_json::from_value::(value)?; return Ok(op.into_card("c_agent")); } - Ok(serde_json::from_str(json)?) + match serde_json::from_value::(value.clone()) { + Ok(card) => Ok(card), + // Agents sometimes name the discriminator "kind" while otherwise + // emitting an op payload; retry the op parse under that reading. + Err(card_error) => match value.get("kind").cloned() { + Some(kind) => { + let mut value = value; + value["op"] = kind; + if let Some(object) = value.as_object_mut() { + object.remove("kind"); + } + + match serde_json::from_value::(value) { + Ok(op) => Ok(op.into_card("c_agent")), + Err(op_error) => Err(op_error.into()), + } + } + None => Err(card_error.into()), + }, + } } fn excerpt(output: &str) -> String { @@ -340,6 +371,46 @@ mod tests { assert!(matches!(card, Card::Error(_))); } + #[test] + fn parses_choice_op_with_string_options_and_null_fields() { + let output = r#"{"op":"choice","title":"Clarify what to test","question":"What should we test?","options":["Add a spec","Extend the directive spec","Just a smoke test"],"claim":null,"evidence":null,"next":null,"finding":null,"location":null,"annotation":null,"explanation":null,"patches":null,"summary":null,"changed_files":null,"message":null}"#; + let card = parse_card(output).unwrap(); + + let Card::Choice(card) = card else { + panic!("expected choice card"); + }; + assert_eq!(card.options.len(), 3); + } + + #[test] + fn parses_op_payload_with_kind_discriminator() { + let output = r#"{"kind":"hypothesis","title":"T","claim":"C","evidence":"src/work.ts:2 — no value returned"}"#; + let card = parse_card(output).unwrap(); + + let Card::Hypothesis(card) = card else { + panic!("expected hypothesis card"); + }; + assert_eq!(card.evidence.as_ref().unwrap().line, 2); + } + + #[test] + fn parses_deny_op() { + let output = + r#"{"op":"deny","title":"Ambiguous prompt","reason":"Say which spec to write."}"#; + let card = parse_card(output).unwrap(); + + assert!(matches!(card, Card::Deny(_))); + } + + #[test] + fn reports_op_error_instead_of_card_error() { + let output = r#"{"op":"finding","title":"T"}"#; + let error = parse_card(output).unwrap_err().to_string(); + + assert!(error.contains("finding"), "unexpected error: {error}"); + assert!(!error.contains("kind"), "unexpected error: {error}"); + } + #[test] fn extracts_agent_op() { let output = diff --git a/rust/crates/pair_backends/src/lib.rs b/rust/crates/pair_backends/src/lib.rs index 505cead..d2dcc31 100644 --- a/rust/crates/pair_backends/src/lib.rs +++ b/rust/crates/pair_backends/src/lib.rs @@ -1,6 +1,8 @@ +pub mod claude_app; pub mod codex_app; pub mod generic; pub mod mock; +pub mod ollama; pub mod stdio_agent; pub mod stream; @@ -15,9 +17,11 @@ use pair_protocol::{ use serde::Serialize; use serde_json::json; +pub use claude_app::*; pub use codex_app::*; pub use generic::*; pub use mock::*; +pub use ollama::*; pub use stdio_agent::*; pub use stream::*; @@ -33,6 +37,12 @@ pub trait BackendAdapter: Send + Sync { self.next_card(req).await } + /// Called when the editor opens the prompt window, before any session + /// exists, so backends can pay their startup cost while the user types. + async fn warmup(&self) -> Result<()> { + Ok(()) + } + fn capabilities(&self) -> BackendInfo; } @@ -45,7 +55,7 @@ pub struct BackendProgress { pub message: String, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub struct BackendRequest { pub session: SessionSnapshot, pub action: BackendAction, @@ -81,12 +91,15 @@ pub fn backend_context(context: &ContextBundle) -> serde_json::Value { }) } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub enum BackendAction { Start, User(Action), Reply(String), ContractRetry(String), + // The editor granted an open_location request mid-turn; the request's + // context carries the freshly opened buffer. + LocationGranted, } #[derive(Clone, Debug, Serialize)] @@ -125,8 +138,13 @@ pub fn enforce_card_contract( return card; }; - if matches!(card, Card::Error(_)) + // A clarifying question is a legitimate alternative to guessing wherever a + // discovery card is expected; patch and summary requests stay strict. + let discovery_expected = matches!(expected_kind, CardKind::Hypothesis | CardKind::Finding); + + if matches!(card, Card::Error(_) | Card::Deny(_) | Card::OpenLocation(_)) || card.kind() == expected_kind + || (discovery_expected && matches!(card, Card::Choice(_))) || (contract.allow_goal_completion && matches!(card, Card::Summary(_))) { return card; @@ -172,6 +190,7 @@ pub struct BackendResponse { #[derive(Clone, Debug)] pub struct BackendMetadata { pub backend: String, + pub model: Option, pub token_usage: Option, pub activities: Vec, pub attempts: Vec, @@ -197,6 +216,36 @@ pub struct SessionSnapshot { pub last_summary: Option, } +#[cfg(test)] +pub(crate) fn test_request() -> BackendRequest { + BackendRequest { + session: SessionSnapshot { + id: "s_1".into(), + prompt: "payload is empty".into(), + completed_steps: vec![], + known_observations: vec![], + mode: Mode::Auto, + card_count: 0, + last_card: None, + last_summary: None, + }, + action: BackendAction::Start, + context: ContextBundle { + cwd: "/tmp/project".into(), + file: "src/main.rs".into(), + cursor: pair_protocol::Cursor { line: 1, column: 1 }, + selection: None, + buffer_text: "fn main() {}".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + }, + card_contract: CardContract::default(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -244,6 +293,44 @@ mod tests { )); } + #[test] + fn allows_choice_when_discovery_kind_is_expected() { + let contract = CardContract { + expected_kind: Some(CardKind::Hypothesis), + ..CardContract::default() + }; + let choice = Card::Choice(pair_protocol::ChoiceCard { + id: "c_choice".into(), + title: "Clarify".into(), + question: "Which behavior do you want?".into(), + options: vec![], + }); + + assert!(matches!( + enforce_card_contract(choice, &contract, "Claude", "{}"), + Card::Choice(_) + )); + } + + #[test] + fn rejects_choice_when_patch_is_required() { + let contract = CardContract { + expected_kind: Some(CardKind::Patch), + ..CardContract::default() + }; + let choice = Card::Choice(pair_protocol::ChoiceCard { + id: "c_choice".into(), + title: "Clarify".into(), + question: "Which behavior do you want?".into(), + options: vec![], + }); + + assert!(matches!( + enforce_card_contract(choice, &contract, "Claude", "{}"), + Card::Error(_) + )); + } + #[test] fn allows_summary_for_goal_completion_contract() { let contract = CardContract { diff --git a/rust/crates/pair_backends/src/mock.rs b/rust/crates/pair_backends/src/mock.rs index 0220156..bca89b2 100644 --- a/rust/crates/pair_backends/src/mock.rs +++ b/rust/crates/pair_backends/src/mock.rs @@ -30,11 +30,13 @@ impl BackendAdapter for MockBackend { BackendAction::User(action) => unsupported_card(action), BackendAction::Reply(text) => reply_card(text), BackendAction::ContractRetry(_) => finding_card(), + BackendAction::LocationGranted => patch_card(&req), }; Ok(BackendResponse { metadata: BackendMetadata { backend: "mock".into(), + model: None, token_usage: Some(TokenUsage::estimated( estimate_tokens(&req.session.prompt), estimate_tokens(&to_string(&card).unwrap_or_default()), diff --git a/rust/crates/pair_backends/src/ollama.rs b/rust/crates/pair_backends/src/ollama.rs new file mode 100644 index 0000000..2fcd4c2 --- /dev/null +++ b/rust/crates/pair_backends/src/ollama.rs @@ -0,0 +1,170 @@ +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use pair_protocol::{Action, BackendInfo, Card, ErrorCard, TokenUsage}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + BackendAdapter, BackendMetadata, BackendProgress, BackendRequest, BackendResponse, + ProgressReporter, enforce_card_contract, estimate_tokens, +}; + +/// Talks to a local Ollama server over its HTTP API instead of spawning +/// `ollama run` per card. The server keeps the model loaded between turns +/// (`keep_alive`), and `format: json` forces the model to emit parseable ops. +pub struct OllamaBackend { + host: String, + model: String, + keep_alive: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct ChatResponse { + message: ChatMessage, + #[serde(default)] + prompt_eval_count: Option, + #[serde(default)] + eval_count: Option, +} + +#[derive(Deserialize)] +struct ChatMessage { + content: String, +} + +impl OllamaBackend { + pub fn from_env() -> Result { + let model = std::env::var("PAIR_OLLAMA_MODEL") + .map_err(|_| anyhow!("PAIR_OLLAMA_MODEL is required"))?; + let host = std::env::var("PAIR_OLLAMA_HOST") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:11434".into()); + let keep_alive = std::env::var("PAIR_OLLAMA_KEEP_ALIVE") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "30m".into()); + + Ok(Self::new(host, model, keep_alive)) + } + + pub fn new( + host: impl Into, + model: impl Into, + keep_alive: impl Into, + ) -> Self { + Self { + host: host.into().trim_end_matches('/').to_string(), + model: model.into(), + keep_alive: keep_alive.into(), + client: reqwest::Client::new(), + } + } + + async fn ask(&self, prompt: &str) -> Result { + let response = self + .client + .post(format!("{}/api/chat", self.host)) + .json(&json!({ + "model": self.model, + "stream": false, + "format": "json", + "keep_alive": self.keep_alive, + "messages": [ + {"role": "user", "content": prompt} + ] + })) + .send() + .await + .map_err(|error| anyhow!("could not reach ollama at {}: {error}", self.host))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("ollama returned {status}: {}", body.trim())); + } + + Ok(response.json().await?) + } + + fn error_card(message: impl Into) -> Card { + Card::Error(ErrorCard { + id: "c_ollama_error".into(), + title: "Ollama error".into(), + message: message.into(), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }) + } +} + +#[async_trait] +impl BackendAdapter for OllamaBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.next_card_with_progress(req, None).await + } + + async fn next_card_with_progress( + &self, + req: BackendRequest, + progress: Option, + ) -> Result { + let prompt = crate::generic_prompt(&req); + + report_progress( + progress.as_ref(), + &req.session.id, + "requesting", + &format!("Sending the task to {}", self.model), + ); + + let response = self.ask(&prompt).await?; + + let text = response.message.content; + let card = crate::parse_card(&text) + .unwrap_or_else(|error| Self::error_card(format!("{error}\n\nRaw output:\n{text}"))); + let card = enforce_card_contract(card, &req.card_contract, &self.model, &text); + let token_usage = match (response.prompt_eval_count, response.eval_count) { + (Some(input), Some(output)) => TokenUsage::reported(input, output), + _ => TokenUsage::estimated(estimate_tokens(&prompt), estimate_tokens(&text)), + }; + + Ok(BackendResponse { + card, + raw_output: Some(text), + metadata: BackendMetadata { + backend: "ollama".into(), + model: Some(self.model.clone()), + token_usage: Some(token_usage), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "ollama".into(), + streaming: false, + patches: true, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } +} + +fn report_progress( + progress: Option<&ProgressReporter>, + session_id: &str, + phase: &str, + message: &str, +) { + if let Some(progress) = progress { + progress(BackendProgress { + session_id: session_id.into(), + phase: phase.into(), + message: message.into(), + }); + } +} diff --git a/rust/crates/pair_backends/src/stdio_agent.rs b/rust/crates/pair_backends/src/stdio_agent.rs index d8c3036..2b5d9ad 100644 --- a/rust/crates/pair_backends/src/stdio_agent.rs +++ b/rust/crates/pair_backends/src/stdio_agent.rs @@ -166,6 +166,7 @@ impl BackendAdapter for StdioAgentBackend { raw_output: Some(raw_output), metadata: BackendMetadata { backend: "agent_stdio".into(), + model: None, token_usage: Some(TokenUsage::estimated(answer.input_tokens, output_tokens)), activities: vec![], attempts: vec![], @@ -234,12 +235,13 @@ fn action_value(action: &BackendAction) -> serde_json::Value { BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), } } fn agent_api() -> serde_json::Value { json!( - "Return one JSON Pair op only. Ops: hypothesis, finding, patch, choice, summary, error. Behave as an equal pair-programming partner: explain what you noticed and why the next coherent block matters, then return control to the user. Never plan or complete a whole refactor in one response. Return patch for user action fix or start mode fix unless impossible. When limits.allow_goal_completion is true, return a patch if the original goal is unresolved or a summary if it is complete; never restart discovery. A patch is one local step: exactly one file and one hunk within the supplied changed-line limit. patch.diff must be a unified diff hunk starting with @@. You may first emit newline-delimited {\"t\":\"pair_progress\",\"phase\":string,\"message\":string} records with concise user-visible activity summaries. Never emit hidden reasoning or private chain-of-thought. End with either a raw Pair op or {\"t\":\"pair_result\",\"result\":}." + "Return one JSON Pair op only. Ops: hypothesis, finding, patch, choice, deny, summary, error. Use deny(title,reason) when you cannot or should not proceed, such as an ambiguous prompt or missing information; the reason is shown to the user. error is only for technical failures. Behave as an equal pair-programming partner: explain what you noticed and why the next coherent block matters, then return control to the user. Never plan or complete a whole refactor in one response. Return patch for user action fix or start mode fix unless impossible. When limits.allow_goal_completion is true, return a patch if the original goal is unresolved or a summary if it is complete; never restart discovery. A patch is one local step: exactly one file and one hunk within the supplied changed-line limit. patch.diff must be a unified diff hunk starting with @@. You may first emit newline-delimited {\"t\":\"pair_progress\",\"phase\":string,\"message\":string} records with concise user-visible activity summaries. Never emit hidden reasoning or private chain-of-thought. End with either a raw Pair op or {\"t\":\"pair_result\",\"result\":}." ) } diff --git a/rust/crates/pair_harness/Cargo.toml b/rust/crates/pair_harness/Cargo.toml index f4a7b70..eee8da1 100644 --- a/rust/crates/pair_harness/Cargo.toml +++ b/rust/crates/pair_harness/Cargo.toml @@ -15,6 +15,7 @@ pair_backends = { path = "../pair_backends" } pair_context = { path = "../pair_context" } pair_patch = { path = "../pair_patch" } pair_protocol = { path = "../pair_protocol" } +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/rust/crates/pair_harness/src/engine.rs b/rust/crates/pair_harness/src/engine.rs index 2cfc2ed..ea84ed5 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -21,6 +21,40 @@ pub struct Engine { backend: Arc, sessions: HashMap, context_optimizer: ContextOptimizer, + prefetch_mode: PrefetchMode, + prefetches: HashMap, + location_granter: Option, +} + +/// Most open_location grants honored within a single turn before the request +/// is surfaced as a deny card instead. +pub const MAX_LOCATION_GRANTS: usize = 2; + +/// Editor callback that asks the user to open a location mid-turn and, when +/// granted, returns freshly captured context for that buffer. +pub type LocationGranter = Arc< + dyn Fn( + pair_protocol::OpenLocationCard, + String, + ) + -> std::pin::Pin> + Send>> + + Send + + Sync, +>; + +/// Speculative prefetch of the likely next card. `Fix` requests the patch +/// card in the background while the user is still reading a discovery card, +/// so pressing Fix returns (near-)instantly on a hit. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrefetchMode { + Off, + Fix, +} + +struct Prefetch { + action: Action, + fingerprint: u64, + handle: tokio::task::JoinHandle>, } impl Engine { @@ -29,9 +63,20 @@ impl Engine { backend, sessions: HashMap::new(), context_optimizer: ContextOptimizer::default(), + prefetch_mode: PrefetchMode::Off, + prefetches: HashMap::new(), + location_granter: None, } } + pub fn set_prefetch_mode(&mut self, mode: PrefetchMode) { + self.prefetch_mode = mode; + } + + pub fn set_location_granter(&mut self, granter: LocationGranter) { + self.location_granter = Some(granter); + } + pub async fn start(&mut self, params: StartSessionParams) -> Result { self.start_with_progress(params, None).await } @@ -56,10 +101,12 @@ impl Engine { context, &expected, progress, + None, ) .await; let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); self.add_usage(&mut session, &response.metadata.token_usage); let card = self.accept_response(&mut session, response, expected)?; @@ -69,6 +116,7 @@ impl Engine { let context_report = session.context.report.clone(); self.sessions.insert(session_id.clone(), session); + self.schedule_prefetch(&session_id).await; Ok(StartSessionResult { session_id, @@ -77,6 +125,7 @@ impl Engine { token_usage, turn_token_usage, context_report, + model, attempts, }) } @@ -92,11 +141,15 @@ impl Engine { progress: Option, ) -> Result { let mut session = self.take_session(session_id)?; + let prefetched = self.take_prefetch(&mut session, &action).await; let result = self - .action_taken(session_id, &mut session, action, progress) + .action_taken(session_id, &mut session, action, progress, prefetched) .await; self.sessions.insert(session_id.into(), session); + if result.is_ok() { + self.schedule_prefetch(session_id).await; + } result } @@ -117,6 +170,9 @@ impl Engine { .await; self.sessions.insert(session_id.into(), session); + if result.is_ok() { + self.schedule_prefetch(session_id).await; + } result } @@ -127,6 +183,7 @@ impl Engine { session: &mut Session, action: Action, progress: Option, + prefetched: Option, ) -> Result { let state = session.state.next(&action)?; if action == Action::Stop { @@ -143,6 +200,7 @@ impl Engine { token_usage, turn_token_usage: Default::default(), context_report: session.context.report.clone(), + model: None, attempts: vec![], }); } @@ -158,11 +216,13 @@ impl Engine { context, &state, progress, + prefetched, ) .await; let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); self.add_usage(session, &response.metadata.token_usage); let card = self.accept_response(session, response, state)?; @@ -175,6 +235,7 @@ impl Engine { token_usage, turn_token_usage, context_report: session.context.report.clone(), + model, attempts, }) } @@ -202,11 +263,13 @@ impl Engine { context, &expected, progress, + None, ) .await; let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); let attempts = response.metadata.attempts.clone(); + let model = response.metadata.model.clone(); self.add_usage(session, &response.metadata.token_usage); let card = self.accept_response(session, response, expected)?; @@ -219,6 +282,7 @@ impl Engine { token_usage, turn_token_usage, context_report: session.context.report.clone(), + model, attempts, }) } @@ -295,6 +359,7 @@ impl Engine { token_usage: session.token_usage.clone(), turn_token_usage: TokenUsage::default(), context_report: session.context.report.clone(), + model: None, attempts: vec![], }); } @@ -302,7 +367,7 @@ impl Engine { session.rejected_patches.extend(result.patch_ids.clone()); session.state = SessionState::PatchShown; - self.action_taken(&session_id, session, Action::Retry, progress) + self.action_taken(&session_id, session, Action::Retry, progress, None) .await } @@ -357,7 +422,7 @@ impl Engine { action, context, card_contract: CardContract { - expected_kind: Some(expected_kind), + expected_kind, allow_goal_completion: matches!(expected, NextState::Continuation), ..CardContract::default() }, @@ -371,18 +436,29 @@ impl Engine { context: ContextBundle, expected: &NextState, progress: Option, + mut prefetched: Option, ) -> BackendResponse { let mut action = action; + let mut context = context; let mut token_usage = None; let mut attempts = Vec::new(); - - for attempt in 0..3 { - let request = self.request(session, action, context.clone(), expected); - let mut response = match self - .backend - .next_card_with_progress(request, progress.clone()) - .await - { + let mut attempt = 0; + let mut grants = 0; + + while attempt < 3 { + let attempt_response = match prefetched.take() { + // A matching speculative response was computed for this exact + // request while the user was reading the previous card; it + // still goes through every dedup/validation gate below. + Some(response) => Ok(response), + None => { + let request = self.request(session, action, context.clone(), expected); + self.backend + .next_card_with_progress(request, progress.clone()) + .await + } + }; + let mut response = match attempt_response { Ok(response) => response, Err(error) => { let detail = format!("{error:#}"); @@ -405,6 +481,64 @@ impl Engine { let attempt_usage = response.metadata.token_usage.clone().unwrap_or_default(); merge_usage(&mut token_usage, &response.metadata.token_usage); + // A mid-turn permission request: the agent needs another file open + // before it can produce the real card. Ask the editor; on grant the + // same turn continues with the freshly captured context. Grants do + // not consume retry attempts. + if let Card::OpenLocation(request) = &response.card { + let request = request.clone(); + + if grants < MAX_LOCATION_GRANTS + && let Some(granter) = &self.location_granter + { + if let Some(progress) = &progress { + progress(BackendProgress { + session_id: session.id.clone(), + phase: "permission".into(), + message: format!( + "Agent asks to open {}", + request.location.file.display() + ), + }); + } + + if let Some(granted) = granter(request.clone(), session.id.clone()).await { + attempts.push(agent_attempt( + attempt + 1, + &response, + "location_granted", + Some(request.location.file.display().to_string()), + attempt_usage, + false, + )); + session.context = granted.clone(); + context = granted; + action = BackendAction::LocationGranted; + grants += 1; + continue; + } + } + + attempts.push(agent_attempt( + attempt + 1, + &response, + "location_declined", + Some(request.location.file.display().to_string()), + attempt_usage, + false, + )); + response.card = Card::Deny(pair_protocol::DenyCard { + id: session.next_card_id("deny"), + title: "Agent needs another file".into(), + reason: request.reason, + location: Some(request.location), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }); + response.metadata.token_usage = token_usage; + response.metadata.attempts = attempts; + return response; + } + if let Some((key, reason)) = duplicate_observation(session, &response.card) { activate_observation(session, &key); attempts.push(agent_attempt( @@ -431,6 +565,7 @@ impl Engine { action = BackendAction::ContractRetry(format!( "{reason}. Return a distinct next observation; do not repeat known findings or signals." )); + attempt += 1; continue; } @@ -470,8 +605,9 @@ impl Engine { }); } action = BackendAction::ContractRetry(format!( - "The previous card failed the local patch contract: {detail}. Rebuild the same step. Source context/remove lines must be exact and contiguous in the supplied buffer; added lines do not replace omitted source context. The resulting local step must remain type-correct without work deferred to a later card." + "The previous card failed the local patch contract: {detail}. Rebuild the same step. Source context/remove lines must be exact and contiguous in the supplied buffer; added lines do not replace omitted source context. The resulting local step must remain type-correct without work deferred to a later card. If the change belongs in a different file than the supplied buffer, return an open_location op with that place instead of another patch." )); + attempt += 1; continue; } @@ -537,6 +673,137 @@ impl Engine { session.token_usage.add(usage); } } + + /// Requests the likely next card in the background while the user reads + /// the one just shown. Only Fix is predicted: it is the most common and + /// slowest follow-up, and on backends with a separate patch process a + /// misprediction never blocks the user's real next request. + async fn schedule_prefetch(&mut self, session_id: &str) { + if self.prefetch_mode != PrefetchMode::Fix { + return; + } + + if let Some(existing) = self.prefetches.get(session_id) { + if !existing.handle.is_finished() { + // An earlier speculation is still running on the backend; + // queueing another would only pile up turns. + return; + } + // Fold the finished-but-unconsumed speculation into the session's + // token totals so wasted turns stay visible to the user. + if let Some(stale) = self.prefetches.remove(session_id) + && let Ok(Ok(response)) = stale.handle.await + && let Some(session) = self.sessions.get_mut(session_id) + { + fold_usage(session, &response.metadata.token_usage); + } + } + + let Some(session) = self.sessions.get(session_id) else { + return; + }; + if session.state != SessionState::CardShown { + return; + } + let Some(card) = session.cards.last() else { + return; + }; + if !card.actions().contains(&Action::Fix) { + return; + } + let Ok(expected) = session.state.next(&Action::Fix) else { + return; + }; + + let request = self.request( + session, + BackendAction::User(Action::Fix), + session.context.clone(), + &expected, + ); + let fingerprint = request_fingerprint(&request); + let backend = self.backend.clone(); + let handle = tokio::spawn(async move { backend.next_card(request).await }); + + self.prefetches.insert( + session_id.to_string(), + Prefetch { + action: Action::Fix, + fingerprint, + handle, + }, + ); + } + + /// Consumes a pending speculation if it was computed for exactly the + /// request this action would produce; otherwise leaves the real path + /// untouched and keeps the wasted tokens accounted for. + async fn take_prefetch( + &mut self, + session: &mut Session, + action: &Action, + ) -> Option { + let entry = self.prefetches.remove(&session.id)?; + + if entry.action == *action + && let Ok(expected) = session.state.next(action) + { + let request = self.request( + session, + BackendAction::User(action.clone()), + session.context.clone(), + &expected, + ); + if request_fingerprint(&request) == entry.fingerprint { + return match entry.handle.await { + Ok(Ok(response)) => Some(response), + _ => None, + }; + } + if entry.handle.is_finished() { + if let Ok(Ok(response)) = entry.handle.await { + fold_usage(session, &response.metadata.token_usage); + } + return None; + } + self.prefetches.insert(session.id.clone(), entry); + return None; + } + + if entry.handle.is_finished() { + if let Ok(Ok(response)) = entry.handle.await { + fold_usage(session, &response.metadata.token_usage); + } + } else { + self.prefetches.insert(session.id.clone(), entry); + } + + None + } +} + +fn fold_usage(session: &mut Session, usage: &Option) { + if let Some(usage) = usage { + session.token_usage.add(usage); + } +} + +fn request_fingerprint(request: &BackendRequest) -> u64 { + use std::hash::{DefaultHasher, Hash, Hasher}; + + // Only model-visible data may decide whether a speculative response + // matches: the optimizer report (cache counters vary run to run) and raw + // LSP hints are telemetry that backend_context strips before the model + // ever sees them. + let mut request = request.clone(); + request.context.report = None; + request.context.hints = vec![]; + + let mut hasher = DefaultHasher::new(); + serde_json::to_string(&request) + .unwrap_or_default() + .hash(&mut hasher); + hasher.finish() } fn agent_attempt( @@ -755,6 +1022,12 @@ fn validate_backend_card( return Ok(()); } + // A denial is valid in any state: the agent is telling the user it cannot + // produce the expected card, so only the card text itself is checked. + if matches!(card, Card::Deny(_)) { + return validate_one_card(card); + } + validate_one_card(card)?; PatchValidator::validate_card(card)?; validate_patch_target(card, context)?; @@ -846,6 +1119,27 @@ fn validate_one_card(card: &Card) -> Result<()> { require_text("choice option label", &option.label)?; } } + Card::Deny(card) => { + require_text("card title", &card.title)?; + require_text("deny reason", &card.reason)?; + if let Some(location) = &card.location { + validate_location( + &location.file, + location.line, + location.column, + "deny location", + )?; + } + } + Card::OpenLocation(card) => { + require_text("open_location reason", &card.reason)?; + validate_location( + &card.location.file, + card.location.line, + card.location.column, + "open_location", + )?; + } Card::Summary(card) => { require_text("card title", &card.title)?; require_text("summary", &card.summary)?; @@ -977,6 +1271,7 @@ fn backend_failure_response(session: &Session, error: anyhow::Error) -> BackendR raw_output: None, metadata: pair_backends::BackendMetadata { backend: "harness".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -997,6 +1292,7 @@ fn duplicate_failure_response(session: &Session, reason: String) -> BackendRespo raw_output: None, metadata: pair_backends::BackendMetadata { backend: "harness".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -1012,49 +1308,56 @@ fn expected_start_state(mode: &Mode) -> NextState { } } +/// None means the agent may answer with whichever card kind fits, including a +/// clarifying choice or a deny. A kind is only demanded when the user asked +/// for one (a "/{kind}" prompt prefix, an explicit mode, or a concrete action +/// such as Fix) or when the state machine requires it. fn expected_card_kind( session: &Session, action: &BackendAction, next_state: &NextState, -) -> CardKind { +) -> Option { match next_state { - NextState::Patch => return CardKind::Patch, - NextState::Continuation => return CardKind::Patch, - NextState::Summary | NextState::Finished => return CardKind::Summary, + NextState::Patch => return Some(CardKind::Patch), + NextState::Continuation => return Some(CardKind::Patch), + NextState::Summary | NextState::Finished => return Some(CardKind::Summary), NextState::Any | NextState::Card => {} } match action { - BackendAction::Start => match session.mode { - Mode::Fix | Mode::Propose => CardKind::Patch, - Mode::Explain | Mode::Review => CardKind::Finding, - Mode::Auto | Mode::Investigate => CardKind::Hypothesis, - }, - BackendAction::Reply(_) => CardKind::Finding, - BackendAction::ContractRetry(_) => CardKind::Finding, + BackendAction::Start => session.forced_kind.or(match session.mode { + Mode::Fix | Mode::Propose => Some(CardKind::Patch), + Mode::Explain | Mode::Review => Some(CardKind::Finding), + Mode::Investigate => Some(CardKind::Hypothesis), + Mode::Auto => None, + }), + BackendAction::Reply(_) => None, + BackendAction::ContractRetry(_) => None, + BackendAction::LocationGranted => None, BackendAction::User(action) => match action { - Action::Fix => CardKind::Patch, - Action::OtherLead => CardKind::Hypothesis, + Action::Fix => Some(CardKind::Patch), + Action::OtherLead => Some(CardKind::Hypothesis), Action::Follow | Action::Why | Action::Open | Action::RunCheck | Action::Next => { - CardKind::Finding + Some(CardKind::Finding) } Action::Retry | Action::EditPrompt => session .cards .iter() .rev() - .find(|card| !matches!(card, Card::Error(_))) + .find(|card| !matches!(card, Card::Error(_) | Card::Deny(_))) .map(Card::kind) - .unwrap_or(CardKind::Hypothesis), - Action::Apply | Action::ApplyPatch { .. } | Action::Stop => CardKind::Summary, + .or(session.forced_kind), + Action::Apply | Action::ApplyPatch { .. } | Action::Stop => Some(CardKind::Summary), }, } } fn state_after_card(card: &Card, next_state: &NextState) -> SessionState { - if matches!(card, Card::Error(_)) && matches!(next_state, NextState::Patch) { + let refused = matches!(card, Card::Error(_) | Card::Deny(_)); + if refused && matches!(next_state, NextState::Patch) { return SessionState::PatchFailed; } - if matches!(card, Card::Error(_)) && matches!(next_state, NextState::Continuation) { + if refused && matches!(next_state, NextState::Continuation) { return SessionState::ContinuationFailed; } @@ -1067,6 +1370,8 @@ fn card_summary(card: &Card) -> String { Card::Finding(card) => format!("finding: {}", card.finding), Card::Patch(card) => format!("patch: {}", card.explanation), Card::Choice(card) => format!("choice: {}", card.question), + Card::Deny(card) => format!("deny: {}", card.reason), + Card::OpenLocation(card) => format!("open_location: {}", card.reason), Card::Summary(card) => format!("summary: {}", card.summary), Card::Error(card) => format!("error: {}", card.message), } @@ -1586,6 +1891,7 @@ mod tests { raw_output: None, metadata: BackendMetadata { backend: "repeating_observation".into(), + model: None, token_usage: Some(pair_protocol::TokenUsage::estimated(10, 5)), activities: vec![], attempts: vec![], @@ -1656,6 +1962,7 @@ mod tests { raw_output: None, metadata: BackendMetadata { backend: "flaky".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -1707,6 +2014,7 @@ mod tests { raw_output: None, metadata: BackendMetadata { backend: "bad_patch".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -1779,6 +2087,7 @@ mod tests { raw_output: None, metadata: BackendMetadata { backend: "repairing_patch".into(), + model: None, token_usage: Some(pair_protocol::TokenUsage::estimated(10, 5)), activities: vec![], attempts: vec![], @@ -1825,6 +2134,7 @@ mod tests { raw_output: Some("raw finding from backend".into()), metadata: BackendMetadata { backend: "wrong_type".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -1843,4 +2153,326 @@ mod tests { } } } + + #[derive(Default)] + struct CountingBackend { + inner: MockBackend, + calls: std::sync::Mutex>, + } + + impl CountingBackend { + fn record(&self, path: &str, action: &BackendAction) { + self.calls + .lock() + .unwrap() + .push(format!("{path}:{action:?}")); + } + } + + #[async_trait] + impl BackendAdapter for CountingBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.record("plain", &req.action); + self.inner.next_card(req).await + } + + async fn next_card_with_progress( + &self, + req: BackendRequest, + _progress: Option, + ) -> Result { + self.record("progress", &req.action); + self.inner.next_card(req).await + } + + fn capabilities(&self) -> BackendInfo { + self.inner.capabilities() + } + } + + #[tokio::test] + async fn prefetched_fix_is_consumed_without_a_second_backend_call() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_prefetch_mode(PrefetchMode::Fix); + + let start = engine.start(params()).await.unwrap(); + assert!(matches!(start.card, Card::Hypothesis(_))); + + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + assert!(matches!(result.card, Card::Patch(_))); + + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 2, "unexpected backend calls: {calls:?}"); + assert!(calls[0].starts_with("progress:Start")); + assert!(calls[1].starts_with("plain:User(Fix)")); + } + + #[tokio::test] + async fn stale_prefetch_is_discarded_after_context_change() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend.clone()); + engine.set_prefetch_mode(PrefetchMode::Fix); + + let start = engine.start(params()).await.unwrap(); + let context = ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("src/work.ts"), + cursor: Cursor { line: 1, column: 1 }, + selection: None, + buffer_text: "const edited = true".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + }; + engine.update_context(&start.session_id, context).unwrap(); + + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + let Card::Patch(card) = result.card else { + panic!("expected patch card"); + }; + + // The mock builds the diff from the buffer's first line, so a patch + // produced from the fresh request must reference the edited buffer. + assert!( + card.patches[0].diff.contains("const edited = true"), + "patch was built from stale context: {}", + card.patches[0].diff + ); + let calls = backend.calls.lock().unwrap().clone(); + assert!( + calls + .iter() + .any(|call| call.starts_with("progress:User(Fix)")), + "real fix call missing: {calls:?}" + ); + } + + #[tokio::test] + async fn fingerprint_ignores_optimizer_telemetry() { + let backend = Arc::new(CountingBackend::default()); + let mut engine = Engine::new(backend); + engine.set_prefetch_mode(PrefetchMode::Fix); + let start = engine.start(params()).await.unwrap(); + let session = engine.get(&start.session_id).unwrap(); + + let request = engine.request( + session, + BackendAction::User(Action::Fix), + session.context.clone(), + &NextState::Patch, + ); + let mut noisy = request.clone(); + noisy.context.report = Some(pair_protocol::ContextReport { + enabled: true, + cache_hits: 42, + ..Default::default() + }); + + assert_eq!(request_fingerprint(&request), request_fingerprint(&noisy)); + } + + #[derive(Default)] + struct ExpectationRecorder { + inner: MockBackend, + requests: std::sync::Mutex, String)>>, + } + + #[async_trait] + impl BackendAdapter for ExpectationRecorder { + async fn next_card(&self, req: BackendRequest) -> Result { + self.requests + .lock() + .unwrap() + .push((req.card_contract.expected_kind, req.session.prompt.clone())); + self.inner.next_card(req).await + } + + fn capabilities(&self) -> BackendInfo { + self.inner.capabilities() + } + } + + #[tokio::test] + async fn plain_auto_prompt_expects_any_kind() { + let backend = Arc::new(ExpectationRecorder::default()); + let mut engine = Engine::new(backend.clone()); + + engine.start(params()).await.unwrap(); + + let requests = backend.requests.lock().unwrap().clone(); + assert_eq!(requests[0].0, None); + assert_eq!(requests[0].1, "payload is empty"); + } + + #[tokio::test] + async fn kind_prefix_forces_the_expected_card() { + let backend = Arc::new(ExpectationRecorder::default()); + let mut engine = Engine::new(backend.clone()); + let mut start_params = params(); + start_params.prompt = "/patch guard the payload".into(); + + engine.start(start_params).await.unwrap(); + + let requests = backend.requests.lock().unwrap().clone(); + assert_eq!(requests[0].0, Some(CardKind::Patch)); + assert_eq!(requests[0].1, "guard the payload"); + } + + #[derive(Default)] + struct NavigatingBackend { + calls: std::sync::Mutex>, + } + + #[async_trait] + impl BackendAdapter for NavigatingBackend { + async fn next_card(&self, req: BackendRequest) -> Result { + self.calls.lock().unwrap().push(format!("{:?}", req.action)); + + let card = match req.action { + BackendAction::Start => Card::Hypothesis(HypothesisCard { + id: "c_1".into(), + title: "Wrong buffer suspected".into(), + claim: "The sizing lives in the component file.".into(), + evidence: None, + next_move: None, + actions: vec![Action::Fix, Action::Stop], + }), + BackendAction::User(Action::Fix) => { + Card::OpenLocation(pair_protocol::OpenLocationCard { + id: "c_nav".into(), + reason: "The change belongs in the component file.".into(), + location: pair_protocol::Location { + file: "src/component.ts".into(), + line: 3, + column: 1, + }, + }) + } + BackendAction::LocationGranted => { + let old_line = req.context.buffer_text.lines().next().unwrap_or_default(); + Card::Patch(PatchCard { + id: "c_patch".into(), + title: "Adjust icon size".into(), + explanation: "Sets the icon size on the component.".into(), + warnings: vec![], + patches: vec![FilePatch { + id: "p_1".into(), + file: req.context.file.clone(), + diff: format!("@@ -1,1 +1,1 @@\n-{old_line}\n+const size = 16\n"), + explanation: "Shrinks the icon.".into(), + }], + actions: vec![ + Action::Apply, + Action::Retry, + Action::EditPrompt, + Action::Stop, + ], + }) + } + other => panic!("unexpected action {other:?}"), + }; + + Ok(BackendResponse { + card, + raw_output: None, + metadata: BackendMetadata { + backend: "navigating".into(), + model: None, + token_usage: Some(pair_protocol::TokenUsage::estimated(10, 5)), + activities: vec![], + attempts: vec![], + }, + }) + } + + fn capabilities(&self) -> BackendInfo { + BackendInfo { + name: "navigating".into(), + streaming: false, + patches: true, + reasoning: false, + can_read_project: false, + can_use_tools: false, + } + } + } + + fn granted_context() -> ContextBundle { + ContextBundle { + cwd: PathBuf::from("/tmp/project"), + file: PathBuf::from("src/component.ts"), + cursor: Cursor { line: 3, column: 1 }, + selection: None, + buffer_text: "const size = 24".into(), + buffer_start_line: 1, + diagnostics: vec![], + hints: vec![], + artifacts: vec![], + report: None, + } + } + + #[tokio::test] + async fn open_location_grant_continues_the_same_turn() { + let backend = Arc::new(NavigatingBackend::default()); + let granted: Arc>> = Arc::default(); + let granted_log = granted.clone(); + let mut engine = Engine::new(backend.clone()); + engine.set_location_granter(Arc::new(move |request, _session| { + granted_log + .lock() + .unwrap() + .push(request.location.file.display().to_string()); + Box::pin(async move { Some(granted_context()) }) + })); + + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Patch(card) = result.card else { + panic!("expected patch card, got {:?}", result.card); + }; + assert_eq!(card.patches[0].file, PathBuf::from("src/component.ts")); + assert!(card.patches[0].diff.contains("const size = 24")); + assert_eq!(granted.lock().unwrap().as_slice(), ["src/component.ts"]); + assert_eq!( + engine.get(&start.session_id).unwrap().context.file, + PathBuf::from("src/component.ts") + ); + + let calls = backend.calls.lock().unwrap().clone(); + assert!(calls.iter().any(|call| call.contains("LocationGranted"))); + } + + #[tokio::test] + async fn declined_open_location_surfaces_a_deny_card() { + let backend = Arc::new(NavigatingBackend::default()); + let mut engine = Engine::new(backend); + engine.set_location_granter(Arc::new(|_, _| Box::pin(async { None }))); + + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + let Card::Deny(card) = result.card else { + panic!("expected deny card, got {:?}", result.card); + }; + assert_eq!( + card.location.as_ref().map(|l| l.file.clone()), + Some(PathBuf::from("src/component.ts")) + ); + } + + #[tokio::test] + async fn open_location_without_granter_becomes_a_deny_card() { + let backend = Arc::new(NavigatingBackend::default()); + let mut engine = Engine::new(backend); + + let start = engine.start(params()).await.unwrap(); + let result = engine.action(&start.session_id, Action::Fix).await.unwrap(); + + assert!(matches!(result.card, Card::Deny(_))); + } } diff --git a/rust/crates/pair_harness/src/session.rs b/rust/crates/pair_harness/src/session.rs index 987411e..2228ace 100644 --- a/rust/crates/pair_harness/src/session.rs +++ b/rust/crates/pair_harness/src/session.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::path::PathBuf; use pair_protocol::{ - Card, ContextBundle, ContextPolicy, Cursor, Location, Mode, ObservationProgress, PatchId, - Selection, StartSessionParams, TokenUsage, + Card, CardKind, ContextBundle, ContextPolicy, Cursor, Location, Mode, ObservationProgress, + PatchId, Selection, StartSessionParams, TokenUsage, }; use uuid::Uuid; @@ -19,6 +19,9 @@ pub struct Session { pub initial_cursor: Cursor, pub initial_selection: Option, pub original_prompt: String, + // Card kind demanded by a "/{kind}" prefix on the prompt; None means the + // agent may answer with whatever kind fits, including a clarifying choice. + pub forced_kind: Option, pub mode: Mode, pub cards: Vec, pub accepted_patches: Vec, @@ -37,6 +40,7 @@ pub struct Session { impl Session { pub fn new(params: StartSessionParams) -> Self { let context = ContextBundle::from_start(params.clone()); + let (forced_kind, prompt) = parse_kind_prefix(¶ms.prompt); Self { id: format!("s_{}", Uuid::new_v4().simple()), @@ -44,7 +48,8 @@ impl Session { initial_file: params.file, initial_cursor: params.cursor, initial_selection: params.selection, - original_prompt: params.prompt, + original_prompt: prompt, + forced_kind, mode: params.mode, cards: vec![], accepted_patches: vec![], @@ -65,3 +70,65 @@ impl Session { format!("c_{}_{}", label, self.cards.len() + 1) } } + +/// Splits a "/{kind}" prefix off the prompt. Unknown words after "/" are left +/// untouched so prompts may legitimately start with paths like "/tmp/x". +pub fn parse_kind_prefix(prompt: &str) -> (Option, String) { + let trimmed = prompt.trim_start(); + let Some(rest) = trimmed.strip_prefix('/') else { + return (None, prompt.trim().to_string()); + }; + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let (word, remainder) = rest.split_at(end); + let kind = match word.to_ascii_lowercase().as_str() { + "hypothesis" => CardKind::Hypothesis, + "finding" => CardKind::Finding, + "patch" | "fix" => CardKind::Patch, + "choice" => CardKind::Choice, + "summary" => CardKind::Summary, + _ => return (None, prompt.trim().to_string()), + }; + + (Some(kind), remainder.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_kind_prefixes() { + assert_eq!( + parse_kind_prefix("/patch guard the payload"), + (Some(CardKind::Patch), "guard the payload".into()) + ); + assert_eq!( + parse_kind_prefix("/fix guard the payload"), + (Some(CardKind::Patch), "guard the payload".into()) + ); + assert_eq!( + parse_kind_prefix("/Choice how should icons scale?"), + (Some(CardKind::Choice), "how should icons scale?".into()) + ); + assert_eq!( + parse_kind_prefix("/patch"), + (Some(CardKind::Patch), "".into()) + ); + } + + #[test] + fn leaves_plain_and_unknown_prompts_untouched() { + assert_eq!( + parse_kind_prefix("why is payload empty"), + (None, "why is payload empty".into()) + ); + assert_eq!( + parse_kind_prefix("/tmp/project has a broken build"), + (None, "/tmp/project has a broken build".into()) + ); + assert_eq!( + parse_kind_prefix("/patchy naming is odd"), + (None, "/patchy naming is odd".into()) + ); + } +} diff --git a/rust/crates/pair_protocol/src/agent.rs b/rust/crates/pair_protocol/src/agent.rs index 29f4a2b..b3d93e3 100644 --- a/rust/crates/pair_protocol/src/agent.rs +++ b/rust/crates/pair_protocol/src/agent.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::{ - Action, Card, ChoiceCard, ChoiceOption, ErrorCard, FilePatch, FindingCard, HypothesisCard, - Location, LocationEvidence, NextMove, PatchCard, SummaryCard, + Action, Card, ChoiceCard, ChoiceOption, DenyCard, ErrorCard, FilePatch, FindingCard, + HypothesisCard, Location, LocationEvidence, NextMove, OpenLocationCard, PatchCard, SummaryCard, }; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -32,6 +32,16 @@ pub enum AgentOp { question: String, options: Vec, }, + Deny { + title: String, + reason: String, + #[serde(default)] + location: Option, + }, + OpenLocation { + reason: String, + location: AgentLocation, + }, Summary { title: String, summary: String, @@ -44,6 +54,7 @@ pub enum AgentOp { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(from = "AgentLocationInput")] pub struct AgentLocation { pub file: PathBuf, pub line: usize, @@ -53,6 +64,70 @@ pub struct AgentLocation { pub annotation: Option, } +// Agents occasionally emit locations as "file:line[:column][ — note]" strings +// instead of the location object; accept both so the op still parses. +#[derive(Deserialize)] +#[serde(untagged)] +enum AgentLocationInput { + Object { + file: PathBuf, + line: usize, + #[serde(default = "one")] + column: usize, + #[serde(default)] + annotation: Option, + }, + Text(String), +} + +impl From for AgentLocation { + fn from(input: AgentLocationInput) -> Self { + match input { + AgentLocationInput::Object { + file, + line, + column, + annotation, + } => Self { + file, + line, + column, + annotation, + }, + AgentLocationInput::Text(text) => parse_location_text(&text), + } + } +} + +fn parse_location_text(text: &str) -> AgentLocation { + let (place, annotation) = [" — ", " – ", " - "] + .iter() + .find_map(|separator| text.split_once(separator)) + .map(|(place, annotation)| (place.trim(), Some(annotation.trim().to_string()))) + .unwrap_or((text.trim(), None)); + + let mut file = place; + let mut numbers = Vec::new(); + while let Some((head, tail)) = file.rsplit_once(':') { + let Ok(number) = tail.parse::() else { + break; + }; + if numbers.len() == 2 { + break; + } + numbers.push(number); + file = head; + } + numbers.reverse(); + + AgentLocation { + file: PathBuf::from(file), + line: numbers.first().copied().unwrap_or(1).max(1), + column: numbers.get(1).copied().unwrap_or(1).max(1), + annotation, + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct AgentPatch { #[serde(default)] @@ -63,12 +138,45 @@ pub struct AgentPatch { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(from = "AgentChoiceInput")] pub struct AgentChoice { pub id: String, pub label: String, pub action: Action, } +// Agents frequently emit choice options as plain strings instead of the full +// {id,label,action} object; accept both so a valid choice op never fails to parse. +#[derive(Deserialize)] +#[serde(untagged)] +enum AgentChoiceInput { + Object { + #[serde(default)] + id: Option, + label: String, + #[serde(default)] + action: Option, + }, + Label(String), +} + +impl From for AgentChoice { + fn from(input: AgentChoiceInput) -> Self { + match input { + AgentChoiceInput::Object { id, label, action } => Self { + id: id.unwrap_or_default(), + label, + action: action.unwrap_or(Action::EditPrompt), + }, + AgentChoiceInput::Label(label) => Self { + id: String::new(), + label, + action: Action::EditPrompt, + }, + } + } +} + impl AgentOp { pub fn into_card(self, id: impl Into) -> Card { let id = id.into(); @@ -142,7 +250,27 @@ impl AgentOp { id, title, question, - options: options.into_iter().map(AgentChoice::choice).collect(), + options: options + .into_iter() + .enumerate() + .map(|(index, option)| option.choice(index + 1)) + .collect(), + }), + Self::Deny { + title, + reason, + location, + } => Card::Deny(DenyCard { + id, + title, + reason, + location: location.map(AgentLocation::location), + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], + }), + Self::OpenLocation { reason, location } => Card::OpenLocation(OpenLocationCard { + id, + reason, + location: location.location(), }), Self::Summary { title, @@ -196,9 +324,13 @@ impl AgentPatch { } impl AgentChoice { - fn choice(self) -> ChoiceOption { + fn choice(self, index: usize) -> ChoiceOption { ChoiceOption { - id: self.id, + id: if self.id.trim().is_empty() { + format!("o_{index}") + } else { + self.id + }, label: self.label, action: self.action, } @@ -213,6 +345,108 @@ fn one() -> usize { mod tests { use super::*; + #[test] + fn parses_location_given_as_string() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"hypothesis","title":"T","claim":"C","evidence":"src/work.ts:2:5 — the return has no value"}"#, + ) + .unwrap(); + + let AgentOp::Hypothesis { evidence, .. } = op else { + panic!("expected hypothesis"); + }; + let evidence = evidence.unwrap(); + assert_eq!(evidence.file, PathBuf::from("src/work.ts")); + assert_eq!(evidence.line, 2); + assert_eq!(evidence.column, 5); + assert_eq!( + evidence.annotation.as_deref(), + Some("the return has no value") + ); + } + + #[test] + fn parses_location_string_without_line() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"finding","title":"T","finding":"F","location":"src/work.ts"}"#, + ) + .unwrap(); + + let AgentOp::Finding { location, .. } = op else { + panic!("expected finding"); + }; + let location = location.unwrap(); + assert_eq!(location.file, PathBuf::from("src/work.ts")); + assert_eq!(location.line, 1); + } + + #[test] + fn parses_choice_options_given_as_plain_strings() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"choice","title":"T","question":"Q","options":["First","Second"]}"#, + ) + .unwrap(); + let card = op.into_card("c_1"); + + let Card::Choice(card) = card else { + panic!("expected choice card"); + }; + assert_eq!(card.options.len(), 2); + assert_eq!(card.options[0].id, "o_1"); + assert_eq!(card.options[0].label, "First"); + assert_eq!(card.options[1].id, "o_2"); + } + + #[test] + fn parses_choice_options_missing_id_and_action() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"choice","title":"T","question":"Q","options":[{"label":"Only label"},{"id":"custom","label":"Full","action":"fix"}]}"#, + ) + .unwrap(); + let card = op.into_card("c_1"); + + let Card::Choice(card) = card else { + panic!("expected choice card"); + }; + assert_eq!(card.options[0].id, "o_1"); + assert_eq!(card.options[1].id, "custom"); + assert_eq!(card.options[1].action, Action::Fix); + } + + #[test] + fn maps_deny_op_to_deny_card() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"deny","title":"Ambiguous prompt","reason":"The prompt does not say what to test."}"#, + ) + .unwrap(); + let card = op.into_card("c_1"); + + let Card::Deny(card) = card else { + panic!("expected deny card"); + }; + assert_eq!(card.title, "Ambiguous prompt"); + assert_eq!( + card.actions, + vec![Action::Retry, Action::EditPrompt, Action::Stop] + ); + } + + #[test] + fn maps_deny_location_for_editor_navigation() { + let op: AgentOp = serde_json::from_str( + r#"{"op":"deny","title":"Wrong buffer","reason":"Open the component file first.","location":{"file":"libs/app/util/src/lib/vw-icon-button.component.ts","line":12,"column":1}}"#, + ) + .unwrap(); + let card = op.into_card("c_1"); + + let Card::Deny(card) = card else { + panic!("expected deny card"); + }; + let location = card.location.expect("deny location"); + assert_eq!(location.line, 12); + assert!(location.file.ends_with("vw-icon-button.component.ts")); + } + #[test] fn maps_op_to_card() { let op = AgentOp::Hypothesis { diff --git a/rust/crates/pair_protocol/src/card.rs b/rust/crates/pair_protocol/src/card.rs index 1549bc4..87075e6 100644 --- a/rust/crates/pair_protocol/src/card.rs +++ b/rust/crates/pair_protocol/src/card.rs @@ -13,6 +13,8 @@ pub enum CardKind { Finding, Patch, Choice, + Deny, + OpenLocation, Summary, Error, } @@ -41,6 +43,8 @@ pub enum Card { Finding(FindingCard), Patch(PatchCard), Choice(ChoiceCard), + Deny(DenyCard), + OpenLocation(OpenLocationCard), Summary(SummaryCard), Error(ErrorCard), } @@ -52,6 +56,8 @@ impl Card { Card::Finding(_) => CardKind::Finding, Card::Patch(_) => CardKind::Patch, Card::Choice(_) => CardKind::Choice, + Card::Deny(_) => CardKind::Deny, + Card::OpenLocation(_) => CardKind::OpenLocation, Card::Summary(_) => CardKind::Summary, Card::Error(_) => CardKind::Error, } @@ -63,6 +69,8 @@ impl Card { Card::Finding(card) => &card.id, Card::Patch(card) => &card.id, Card::Choice(card) => &card.id, + Card::Deny(card) => &card.id, + Card::OpenLocation(card) => &card.id, Card::Summary(card) => &card.id, Card::Error(card) => &card.id, } @@ -74,6 +82,8 @@ impl Card { Card::Finding(card) => &card.actions, Card::Patch(card) => &card.actions, Card::Choice(_) => &[], + Card::Deny(card) => &card.actions, + Card::OpenLocation(_) => &[], Card::Summary(card) => &card.next_actions, Card::Error(card) => &card.actions, } @@ -133,6 +143,28 @@ pub struct ChoiceOption { pub action: Action, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DenyCard { + pub id: CardId, + pub title: String, + pub reason: String, + // Where the agent needs the editor to be before it can proceed; the + // editor offers to jump there and retry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub location: Option, + pub actions: Vec, +} + +/// A mid-turn permission request: the agent can only proceed once the editor +/// has this location open. The harness intercepts it, asks the user, and +/// resumes the same turn with fresh context — it is never shown as a card. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OpenLocationCard { + pub id: CardId, + pub reason: String, + pub location: Location, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct SummaryCard { pub id: CardId, diff --git a/rust/crates/pair_protocol/src/lib.rs b/rust/crates/pair_protocol/src/lib.rs index 0369c0f..6711e55 100644 --- a/rust/crates/pair_protocol/src/lib.rs +++ b/rust/crates/pair_protocol/src/lib.rs @@ -4,7 +4,7 @@ pub mod context; pub mod patch; pub mod rpc; -pub const PROTOCOL_VERSION: u32 = 5; +pub const PROTOCOL_VERSION: u32 = 6; pub use agent::*; pub use card::*; diff --git a/rust/crates/pair_protocol/src/rpc.rs b/rust/crates/pair_protocol/src/rpc.rs index ffaf397..6ca3adf 100644 --- a/rust/crates/pair_protocol/src/rpc.rs +++ b/rust/crates/pair_protocol/src/rpc.rs @@ -21,6 +21,15 @@ impl TokenUsage { } } + pub fn reported(input: usize, output: usize) -> Self { + Self { + input_tokens: input, + output_tokens: output, + total_tokens: input + output, + estimated: false, + } + } + pub fn add(&mut self, other: &Self) { self.input_tokens += other.input_tokens; self.output_tokens += other.output_tokens; @@ -92,6 +101,8 @@ pub struct StartSessionResult { pub token_usage: TokenUsage, pub turn_token_usage: TokenUsage, pub context_report: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, #[serde(default)] pub attempts: Vec, } @@ -120,6 +131,8 @@ pub struct ActionResult { pub token_usage: TokenUsage, pub turn_token_usage: TokenUsage, pub context_report: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, #[serde(default)] pub attempts: Vec, } diff --git a/rust/crates/paird/src/main.rs b/rust/crates/paird/src/main.rs index 057077d..a67ef8e 100644 --- a/rust/crates/paird/src/main.rs +++ b/rust/crates/paird/src/main.rs @@ -1,19 +1,24 @@ +use std::collections::VecDeque; use std::io::{self, BufRead, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::Result; use pair_backends::{ - BackendAdapter, CodexAppBackend, GenericCliBackend, MockBackend, ProgressReporter, - StdioAgentBackend, + BackendAdapter, ClaudeAppBackend, CodexAppBackend, GenericCliBackend, MockBackend, + OllamaBackend, ProgressReporter, StdioAgentBackend, }; -use pair_harness::Engine; +use pair_harness::{Engine, LocationGranter, PrefetchMode}; use pair_protocol::{ - ActionParams, BackendInfo, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + ActionParams, BackendInfo, ContextBundle, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, PatchApplyResult, ReplyParams, StartSessionParams, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; +const OPEN_LOCATION_TIMEOUT: Duration = Duration::from_secs(120); + #[tokio::main] async fn main() -> Result<()> { let args = std::env::args().skip(1).collect::>(); @@ -33,16 +38,56 @@ async fn main() -> Result<()> { async fn serve_stdio() -> Result<()> { let backend = backend_from_env()?; let stdout = Arc::new(Mutex::new(io::stdout())); - let mut server = Server::new(backend, progress_reporter(stdout.clone())); - let stdin = io::stdin(); - for line in stdin.lock().lines() { - let line = line?; + // Editor lines flow through a channel so that a mid-turn server request + // (editor/open_location) can await its response while the main loop is + // blocked inside the engine call that produced it. + let (line_tx, line_rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { + break; + }; + if line_tx.send(line).is_err() { + break; + } + } + }); + let lines = Arc::new(tokio::sync::Mutex::new(line_rx)); + // Client requests that arrived while a granter was waiting for its + // response; the main loop drains them before reading new lines. + let deferred = Arc::new(Mutex::new(VecDeque::::new())); + + let mut server = Server::new(backend, progress_reporter(stdout.clone())); + server.engine.set_location_granter(location_granter( + stdout.clone(), + lines.clone(), + deferred.clone(), + )); + + loop { + let line = { + let queued = deferred.lock().expect("deferred lock").pop_front(); + match queued { + Some(line) => line, + None => match lines.lock().await.recv().await { + Some(line) => line, + None => break, + }, + } + }; if line.trim().is_empty() { continue; } + // A response to a paird-initiated request that arrived after its + // granter timed out; there is nothing left to deliver it to. + if is_stale_server_response(&line) { + continue; + } + let response = server.handle_line(&line).await; write_json(&stdout, &response)?; } @@ -50,9 +95,95 @@ async fn serve_stdio() -> Result<()> { Ok(()) } +fn is_stale_server_response(line: &str) -> bool { + serde_json::from_str::(line) + .ok() + .and_then(|value| { + let id = value.get("id")?.as_str()?.to_string(); + let is_response = value.get("method").is_none(); + Some(id.starts_with("paird_") && is_response) + }) + .unwrap_or(false) +} + +/// Asks the editor to open a location mid-turn: sends an editor/open_location +/// request and pumps incoming lines until its response arrives (deferring +/// unrelated client requests for the main loop) or the timeout expires. +fn location_granter( + stdout: Arc>, + lines: Arc>>, + deferred: Arc>>, +) -> LocationGranter { + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + Arc::new(move |request, session_id| { + let stdout = stdout.clone(); + let lines = lines.clone(); + let deferred = deferred.clone(); + + Box::pin(async move { + let id = format!("paird_{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)); + let message = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "editor/open_location", + "params": { + "session_id": session_id, + "reason": request.reason, + "location": request.location, + }, + }); + + if write_json(&stdout, &message).is_err() { + return None; + } + + let deadline = tokio::time::Instant::now() + OPEN_LOCATION_TIMEOUT; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return None; + } + + let line = { + let mut lines = lines.lock().await; + match tokio::time::timeout(remaining, lines.recv()).await { + Err(_) => return None, + Ok(None) => return None, + Ok(Some(line)) => line, + } + }; + + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + + if value.get("id").and_then(Value::as_str) == Some(id.as_str()) + && value.get("method").is_none() + { + let result = value.get("result")?; + if result.get("granted").and_then(Value::as_bool) != Some(true) { + return None; + } + + return result + .get("context") + .cloned() + .and_then(|context| serde_json::from_value::(context).ok()); + } + + deferred.lock().expect("deferred lock").push_back(line); + } + }) + }) +} + fn backend_from_env() -> Result> { match std::env::var("PAIR_BACKEND").as_deref() { Ok("codex_app") | Ok("codex") => Ok(Arc::new(CodexAppBackend::from_env()?)), + Ok("claude_app") | Ok("claude") => Ok(Arc::new(ClaudeAppBackend::from_env()?)), + Ok("ollama") => Ok(Arc::new(OllamaBackend::from_env()?)), Ok("agent") | Ok("agent_stdio") => Ok(Arc::new(StdioAgentBackend::from_env()?)), Ok("generic") | Ok("generic_cli") => Ok(Arc::new(GenericCliBackend::from_env()?)), _ => Ok(Arc::new(MockBackend)), @@ -67,8 +198,11 @@ struct Server { impl Server { fn new(backend: Arc, progress: ProgressReporter) -> Self { + let mut engine = Engine::new(backend.clone()); + engine.set_prefetch_mode(prefetch_mode_from_env()); + Self { - engine: Engine::new(backend.clone()), + engine, backend, progress, } @@ -171,6 +305,14 @@ impl Server { json!(result) } + "backend/warmup" => { + self.backend + .warmup() + .await + .map_err(|error| (id.clone(), error.to_string()))?; + + json!({"ok": true}) + } "shutdown" => json!({"ok": true}), method => return Err((id, format!("unknown method {method}"))), }; @@ -179,6 +321,13 @@ impl Server { } } +fn prefetch_mode_from_env() -> PrefetchMode { + match std::env::var("PAIR_PREFETCH").as_deref() { + Ok("off") => PrefetchMode::Off, + _ => PrefetchMode::Fix, + } +} + fn parse(id: &Value, value: Value) -> Result where T: DeserializeOwned, diff --git a/schemas/pair-agent-op.schema.json b/schemas/pair-agent-op.schema.json index 1a4a52f..9b339b9 100644 --- a/schemas/pair-agent-op.schema.json +++ b/schemas/pair-agent-op.schema.json @@ -15,13 +15,14 @@ "patches", "question", "options", + "reason", "summary", "changed_files", "message" ], "properties": { "op": { - "enum": ["hypothesis", "finding", "patch", "choice", "summary", "error"] + "enum": ["hypothesis", "finding", "patch", "choice", "deny", "open_location", "summary", "error"] }, "title": { "type": "string" @@ -91,29 +92,38 @@ "options": { "type": ["array", "null"], "items": { - "type": "object", - "required": ["id", "label", "action"], - "properties": { - "id": { "type": "string" }, - "label": { "type": "string" }, - "action": { - "enum": [ - "follow", - "why", - "fix", - "other_lead", - "retry", - "edit_prompt", - "open", - "run_check", - "next", - "stop" - ] + "anyOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["label"], + "properties": { + "id": { "type": ["string", "null"] }, + "label": { "type": "string" }, + "action": { + "enum": [ + "follow", + "why", + "fix", + "other_lead", + "retry", + "edit_prompt", + "open", + "run_check", + "next", + "stop", + null + ] + } + }, + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, + "reason": { + "type": ["string", "null"] + }, "summary": { "type": ["string", "null"] },