From ec5807ba81c037b0f7601ab6d2af34b14830ac43 Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 10:06:29 +0200 Subject: [PATCH 1/7] feat: agent deny card --- lua/pair/card.lua | 9 ++ rust/crates/pair_backends/src/codex_app.rs | 12 ++ rust/crates/pair_backends/src/generic.rs | 49 ++++++-- rust/crates/pair_backends/src/lib.rs | 2 +- rust/crates/pair_backends/src/stdio_agent.rs | 2 +- rust/crates/pair_harness/src/engine.rs | 18 ++- rust/crates/pair_protocol/src/agent.rs | 112 ++++++++++++++++++- rust/crates/pair_protocol/src/card.rs | 13 +++ schemas/pair-agent-op.schema.json | 52 +++++---- 9 files changed, 230 insertions(+), 39 deletions(-) diff --git a/lua/pair/card.lua b/lua/pair/card.lua index 716e1fa..c42a926 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) @@ -327,6 +335,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/rust/crates/pair_backends/src/codex_app.rs b/rust/crates/pair_backends/src/codex_app.rs index c72aa7a..234d307 100644 --- a/rust/crates/pair_backends/src/codex_app.rs +++ b/rust/crates/pair_backends/src/codex_app.rs @@ -880,6 +880,7 @@ 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(), @@ -1052,6 +1053,17 @@ fn summary_schema() -> Value { ) } +fn deny_schema() -> Value { + object_schema( + &["op", "title", "reason"], + json!({ + "op": {"type": "string", "enum": ["deny"]}, + "title": {"type": "string"}, + "reason": {"type": "string"} + }), + ) +} + 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..02d1cf7 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -34,7 +34,7 @@ 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.", + "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), 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. error is only for technical failures. 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"}, @@ -245,11 +245,7 @@ fn backend_name(command: &str) -> 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()) { + if let Ok(card) = parse_json_card(output.trim()) { return Ok(card); } @@ -257,11 +253,20 @@ 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)?) + Ok(serde_json::from_value(value)?) } fn excerpt(output: &str) -> String { @@ -340,6 +345,34 @@ 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_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..65e4b88 100644 --- a/rust/crates/pair_backends/src/lib.rs +++ b/rust/crates/pair_backends/src/lib.rs @@ -125,7 +125,7 @@ pub fn enforce_card_contract( return card; }; - if matches!(card, Card::Error(_)) + if matches!(card, Card::Error(_) | Card::Deny(_)) || card.kind() == expected_kind || (contract.allow_goal_completion && matches!(card, Card::Summary(_))) { diff --git a/rust/crates/pair_backends/src/stdio_agent.rs b/rust/crates/pair_backends/src/stdio_agent.rs index d8c3036..97d2ca4 100644 --- a/rust/crates/pair_backends/src/stdio_agent.rs +++ b/rust/crates/pair_backends/src/stdio_agent.rs @@ -239,7 +239,7 @@ fn action_value(action: &BackendAction) -> serde_json::Value { 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/src/engine.rs b/rust/crates/pair_harness/src/engine.rs index 2cfc2ed..6525574 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -755,6 +755,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 +852,10 @@ 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)?; + } Card::Summary(card) => { require_text("card title", &card.title)?; require_text("summary", &card.summary)?; @@ -1042,7 +1052,7 @@ fn expected_card_kind( .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, @@ -1051,10 +1061,11 @@ fn expected_card_kind( } 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 +1078,7 @@ 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::Summary(card) => format!("summary: {}", card.summary), Card::Error(card) => format!("error: {}", card.message), } diff --git a/rust/crates/pair_protocol/src/agent.rs b/rust/crates/pair_protocol/src/agent.rs index 29f4a2b..de3a199 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, PatchCard, SummaryCard, }; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -32,6 +32,10 @@ pub enum AgentOp { question: String, options: Vec, }, + Deny { + title: String, + reason: String, + }, Summary { title: String, summary: String, @@ -63,12 +67,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 +179,17 @@ 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 } => Card::Deny(DenyCard { + id, + title, + reason, + actions: vec![Action::Retry, Action::EditPrompt, Action::Stop], }), Self::Summary { title, @@ -196,9 +243,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 +264,57 @@ fn one() -> usize { mod tests { use super::*; + #[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_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..d835ede 100644 --- a/rust/crates/pair_protocol/src/card.rs +++ b/rust/crates/pair_protocol/src/card.rs @@ -13,6 +13,7 @@ pub enum CardKind { Finding, Patch, Choice, + Deny, Summary, Error, } @@ -41,6 +42,7 @@ pub enum Card { Finding(FindingCard), Patch(PatchCard), Choice(ChoiceCard), + Deny(DenyCard), Summary(SummaryCard), Error(ErrorCard), } @@ -52,6 +54,7 @@ impl Card { Card::Finding(_) => CardKind::Finding, Card::Patch(_) => CardKind::Patch, Card::Choice(_) => CardKind::Choice, + Card::Deny(_) => CardKind::Deny, Card::Summary(_) => CardKind::Summary, Card::Error(_) => CardKind::Error, } @@ -63,6 +66,7 @@ impl Card { Card::Finding(card) => &card.id, Card::Patch(card) => &card.id, Card::Choice(card) => &card.id, + Card::Deny(card) => &card.id, Card::Summary(card) => &card.id, Card::Error(card) => &card.id, } @@ -74,6 +78,7 @@ impl Card { Card::Finding(card) => &card.actions, Card::Patch(card) => &card.actions, Card::Choice(_) => &[], + Card::Deny(card) => &card.actions, Card::Summary(card) => &card.next_actions, Card::Error(card) => &card.actions, } @@ -133,6 +138,14 @@ 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, + pub actions: Vec, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct SummaryCard { pub id: CardId, diff --git a/schemas/pair-agent-op.schema.json b/schemas/pair-agent-op.schema.json index 1a4a52f..7030e9b 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", "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"] }, From 673e92d2cb8d33f99c18cc11ead0d896c0e5ecac Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 10:35:23 +0200 Subject: [PATCH 2/7] create long running processes for ollama and claude --- Cargo.lock | 568 ++++++++++++++++++++ Cargo.toml | 1 + README.md | 8 +- lua/pair/config.lua | 29 +- rust/crates/pair_backends/Cargo.toml | 1 + rust/crates/pair_backends/src/claude_app.rs | 534 ++++++++++++++++++ rust/crates/pair_backends/src/generic.rs | 71 ++- rust/crates/pair_backends/src/lib.rs | 34 ++ rust/crates/pair_backends/src/ollama.rs | 169 ++++++ rust/crates/pair_protocol/src/agent.rs | 100 ++++ rust/crates/pair_protocol/src/rpc.rs | 9 + rust/crates/paird/src/main.rs | 6 +- 12 files changed, 1504 insertions(+), 26 deletions(-) create mode 100644 rust/crates/pair_backends/src/claude_app.rs create mode 100644 rust/crates/pair_backends/src/ollama.rs diff --git a/Cargo.lock b/Cargo.lock index 779d894..2d10743 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", @@ -192,12 +442,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 +487,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 +574,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 +602,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 +635,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 +675,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 +696,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys", ] @@ -344,12 +712,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 +817,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 +845,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 +887,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 +912,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..6921127 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 diff --git a/lua/pair/config.lua b/lua/pair/config.lua index 33eced9..91e6522 100644 --- a/lua/pair/config.lua +++ b/lua/pair/config.lua @@ -36,7 +36,7 @@ M.values = { args = { "dev", "stdio-agent" }, }, claude = { - kind = "generic", + kind = "claude_app", command = "claude", args = {}, }, @@ -46,9 +46,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 = { @@ -316,6 +316,27 @@ 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 "", + } + 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/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..a394e49 --- /dev/null +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -0,0 +1,534 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::process::Stdio; + +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} +- {"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. +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 one `claude` CLI process alive per Pair session using its +/// stream-json stdin/stdout mode, so follow-up cards skip the CLI cold start +/// and reuse the conversation instead of resending the whole session. +pub struct ClaudeAppBackend { + command: String, + args: Vec, + model: Option, + state: Mutex, +} + +#[derive(Default)] +struct ClaudeAppState { + process: Option, + session_key: Option, + context_fingerprint: 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, +} + +enum StreamEvent { + Working(String), + Result { + text: String, + token_usage: Option, + }, + Failed(String), + Other, +} + +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 = std::env::var("PAIR_CLAUDE_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()); + + Ok(Self::new(command, args, model)) + } + + pub fn new(command: impl Into, args: Vec, model: Option) -> Self { + Self { + command: command.into(), + args, + model, + state: Mutex::new(ClaudeAppState::default()), + } + } + + fn spawn_args(&self) -> Vec { + let mut args = vec![ + "-p".into(), + "--input-format".into(), + "stream-json".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--disallowedTools".into(), + "Edit,Write,NotebookEdit,Bash".into(), + "--append-system-prompt".into(), + SYSTEM_PROMPT.into(), + ]; + + if let Some(model) = &self.model { + args.push("--model".into()); + args.push(model.clone()); + } + + args.extend(self.args.iter().cloned()); + + args + } + + async fn ensure(&self, state: &mut ClaudeAppState, session_key: &str) -> Result<()> { + // One Claude process holds one conversation; a new Pair session must + // not inherit the previous session's context. + if state.session_key.as_deref() != Some(session_key) { + state.process = None; + state.context_fingerprint = None; + state.session_key = Some(session_key.to_string()); + } + + if state.process.is_some() { + return Ok(()); + } + + let mut child = Command::new(&self.command) + .args(self.spawn_args()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .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"))?; + + state.process = Some(ClaudeAppProcess { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + }); + + Ok(()) + } + + async fn ask( + &self, + req: &BackendRequest, + progress: Option<&ProgressReporter>, + ) -> Result { + let mut state = self.state.lock().await; + let fresh = state.session_key.as_deref() != Some(req.session.id.as_str()) + || state.process.is_none(); + + report_progress( + progress, + &req.session.id, + "starting", + if fresh { + "Starting Claude" + } else { + "Reusing the Claude session" + }, + ); + self.ensure(&mut state, &req.session.id).await?; + + let fingerprint = context_fingerprint(req); + let include_context = state.context_fingerprint != Some(fingerprint); + state.context_fingerprint = Some(fingerprint); + + let prompt = turn_prompt(req, include_context); + let message = json!({ + "type": "user", + "message": { + "role": "user", + "content": [{"type": "text", "text": prompt}] + } + }); + let line = serde_json::to_string(&message)?; + + if let Err(error) = Self::send(&mut state, &line).await { + // The previous process may have died between turns; retry once on + // a fresh process before giving up. + state.process = None; + state.context_fingerprint = None; + self.ensure(&mut state, &req.session.id).await?; + let prompt = turn_prompt(req, true); + let message = json!({ + "type": "user", + "message": { + "role": "user", + "content": [{"type": "text", "text": prompt}] + } + }); + Self::send(&mut state, &serde_json::to_string(&message)?) + .await + .map_err(|retry_error| { + anyhow!("could not reach claude: {error}; retry failed: {retry_error}") + })?; + } + + report_progress( + progress, + &req.session.id, + "working", + "Claude is processing the request", + ); + + loop { + let process = state + .process + .as_mut() + .ok_or_else(|| anyhow!("claude process unavailable"))?; + let Some(line) = process.stdout.next_line().await? else { + state.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::Working(activity) => { + report_progress(progress, &req.session.id, "working", &activity); + } + StreamEvent::Result { text, token_usage } => { + return Ok(TurnOutput { text, token_usage }); + } + StreamEvent::Failed(message) => { + return Err(anyhow!("claude turn failed: {message}")); + } + StreamEvent::Other => {} + } + } + } + + async fn send(state: &mut ClaudeAppState, line: &str) -> Result<()> { + let process = state + .process + .as_mut() + .ok_or_else(|| anyhow!("claude process unavailable"))?; + + process.stdin.write_all(line.as_bytes()).await?; + process.stdin.write_all(b"\n").await?; + process.stdin.flush().await?; + + Ok(()) + } + + 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_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(), + token_usage: Some(token_usage), + activities: vec![], + attempts: vec![], + }, + }) + } + + 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_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 + } + }); + + 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}) + } + } +} + +fn parse_stream_event(value: &Value) -> StreamEvent { + match value.get("type").and_then(Value::as_str) { + 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::Working("Claude is drafting the next Pair card".into()), + } + } + _ => 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 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 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 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/generic.rs b/rust/crates/pair_backends/src/generic.rs index 02d1cf7..093ff7f 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -33,7 +33,21 @@ impl GenericCliBackend { } fn prompt(&self, req: &BackendRequest) -> String { - let value = json!({ + 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), 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. error is only for technical failures. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", "stream": { "protocol": "ndjson", @@ -71,21 +85,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 { @@ -244,7 +248,7 @@ fn backend_name(command: &str) -> String { .to_string() } -fn parse_card(output: &str) -> Result { +pub(crate) fn parse_card(output: &str) -> Result { if let Ok(card) = parse_json_card(output.trim()) { return Ok(card); } @@ -266,7 +270,26 @@ fn parse_json_card(json: &str) -> Result { return Ok(op.into_card("c_agent")); } - Ok(serde_json::from_value(value)?) + 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 { @@ -356,9 +379,21 @@ mod tests { 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 output = + r#"{"op":"deny","title":"Ambiguous prompt","reason":"Say which spec to write."}"#; let card = parse_card(output).unwrap(); assert!(matches!(card, Card::Deny(_))); diff --git a/rust/crates/pair_backends/src/lib.rs b/rust/crates/pair_backends/src/lib.rs index 65e4b88..b8f5d8a 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::*; @@ -197,6 +201,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::*; diff --git a/rust/crates/pair_backends/src/ollama.rs b/rust/crates/pair_backends/src/ollama.rs new file mode 100644 index 0000000..70cfcdc --- /dev/null +++ b/rust/crates/pair_backends/src/ollama.rs @@ -0,0 +1,169 @@ +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(), + 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_protocol/src/agent.rs b/rust/crates/pair_protocol/src/agent.rs index de3a199..803b10f 100644 --- a/rust/crates/pair_protocol/src/agent.rs +++ b/rust/crates/pair_protocol/src/agent.rs @@ -48,6 +48,7 @@ pub enum AgentOp { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(from = "AgentLocationInput")] pub struct AgentLocation { pub file: PathBuf, pub line: usize, @@ -57,6 +58,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)] @@ -264,6 +329,41 @@ 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( diff --git a/rust/crates/pair_protocol/src/rpc.rs b/rust/crates/pair_protocol/src/rpc.rs index ffaf397..8139a38 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; diff --git a/rust/crates/paird/src/main.rs b/rust/crates/paird/src/main.rs index 057077d..4036f32 100644 --- a/rust/crates/paird/src/main.rs +++ b/rust/crates/paird/src/main.rs @@ -3,8 +3,8 @@ use std::sync::{Arc, Mutex}; 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_protocol::{ @@ -53,6 +53,8 @@ async fn serve_stdio() -> Result<()> { 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)), From 3a20a003a49c106a4bbaf0a5120d4cf80accfd72 Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 10:46:05 +0200 Subject: [PATCH 3/7] display model name --- doc/tags | 22 ++++++++ lua/pair/init.lua | 4 ++ lua/pair/prompt.lua | 5 ++ rust/crates/pair_backends/src/claude_app.rs | 59 ++++++++++++++++---- rust/crates/pair_backends/src/codex_app.rs | 1 + rust/crates/pair_backends/src/generic.rs | 1 + rust/crates/pair_backends/src/lib.rs | 1 + rust/crates/pair_backends/src/mock.rs | 1 + rust/crates/pair_backends/src/ollama.rs | 1 + rust/crates/pair_backends/src/stdio_agent.rs | 1 + rust/crates/pair_harness/src/engine.rs | 15 +++++ rust/crates/pair_protocol/src/rpc.rs | 4 ++ 12 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 doc/tags 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/init.lua b/lua/pair/init.lua index c11b928..825ea3a 100644 --- a/lua/pair/init.lua +++ b/lua/pair/init.lua @@ -81,6 +81,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 +149,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 +214,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 {}) @@ -313,6 +316,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..8e818af 100644 --- a/lua/pair/prompt.lua +++ b/lua/pair/prompt.lua @@ -29,6 +29,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/rust/crates/pair_backends/src/claude_app.rs b/rust/crates/pair_backends/src/claude_app.rs index a394e49..892e28d 100644 --- a/rust/crates/pair_backends/src/claude_app.rs +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -46,6 +46,7 @@ struct ClaudeAppState { process: Option, session_key: Option, context_fingerprint: Option, + model: Option, } struct ClaudeAppProcess { @@ -63,9 +64,11 @@ impl Drop for ClaudeAppProcess { struct TurnOutput { text: String, token_usage: Option, + model: Option, } enum StreamEvent { + Init(Option), Working(String), Result { text: String, @@ -222,15 +225,20 @@ impl ClaudeAppBackend { ); loop { - let process = state - .process - .as_mut() - .ok_or_else(|| anyhow!("claude process unavailable"))?; - let Some(line) = process.stdout.next_line().await? else { - state.process = None; - return Err(anyhow!( - "claude exited before finishing the turn; check that the claude CLI is logged in" - )); + let line = { + let process = state + .process + .as_mut() + .ok_or_else(|| anyhow!("claude process unavailable"))?; + match process.stdout.next_line().await? { + Some(line) => line, + None => { + state.process = None; + return Err(anyhow!( + "claude exited before finishing the turn; check that the claude CLI is logged in" + )); + } + } }; if line.trim().is_empty() { @@ -242,11 +250,18 @@ impl ClaudeAppBackend { }; match parse_stream_event(&value) { + StreamEvent::Init(model) => { + state.model = self.model.clone().or(model); + } StreamEvent::Working(activity) => { report_progress(progress, &req.session.id, "working", &activity); } StreamEvent::Result { text, token_usage } => { - return Ok(TurnOutput { text, token_usage }); + return Ok(TurnOutput { + text, + token_usage, + model: state.model.clone().or_else(|| self.model.clone()), + }); } StreamEvent::Failed(message) => { return Err(anyhow!("claude turn failed: {message}")); @@ -307,6 +322,7 @@ impl BackendAdapter for ClaudeAppBackend { raw_output: Some(output.text), metadata: BackendMetadata { backend: "claude_app".into(), + model: output.model, token_usage: Some(token_usage), activities: vec![], attempts: vec![], @@ -392,6 +408,14 @@ fn action_value(action: &BackendAction) -> Value { 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("result") => { let text = value .get("result") @@ -497,6 +521,21 @@ mod tests { 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 detects_failed_turns() { let value = json!({ diff --git a/rust/crates/pair_backends/src/codex_app.rs b/rust/crates/pair_backends/src/codex_app.rs index 234d307..9807435 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)), diff --git a/rust/crates/pair_backends/src/generic.rs b/rust/crates/pair_backends/src/generic.rs index 093ff7f..4b98d81 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -202,6 +202,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), diff --git a/rust/crates/pair_backends/src/lib.rs b/rust/crates/pair_backends/src/lib.rs index b8f5d8a..22e5030 100644 --- a/rust/crates/pair_backends/src/lib.rs +++ b/rust/crates/pair_backends/src/lib.rs @@ -176,6 +176,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, diff --git a/rust/crates/pair_backends/src/mock.rs b/rust/crates/pair_backends/src/mock.rs index 0220156..00f9115 100644 --- a/rust/crates/pair_backends/src/mock.rs +++ b/rust/crates/pair_backends/src/mock.rs @@ -35,6 +35,7 @@ impl BackendAdapter for MockBackend { 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 index 70cfcdc..2fcd4c2 100644 --- a/rust/crates/pair_backends/src/ollama.rs +++ b/rust/crates/pair_backends/src/ollama.rs @@ -134,6 +134,7 @@ impl BackendAdapter for OllamaBackend { raw_output: Some(text), metadata: BackendMetadata { backend: "ollama".into(), + model: Some(self.model.clone()), token_usage: Some(token_usage), activities: vec![], attempts: vec![], diff --git a/rust/crates/pair_backends/src/stdio_agent.rs b/rust/crates/pair_backends/src/stdio_agent.rs index 97d2ca4..9994193 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![], diff --git a/rust/crates/pair_harness/src/engine.rs b/rust/crates/pair_harness/src/engine.rs index 6525574..c47aebb 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -60,6 +60,7 @@ impl Engine { .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)?; @@ -77,6 +78,7 @@ impl Engine { token_usage, turn_token_usage, context_report, + model, attempts, }) } @@ -143,6 +145,7 @@ impl Engine { token_usage, turn_token_usage: Default::default(), context_report: session.context.report.clone(), + model: None, attempts: vec![], }); } @@ -163,6 +166,7 @@ impl Engine { 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 +179,7 @@ impl Engine { token_usage, turn_token_usage, context_report: session.context.report.clone(), + model, attempts, }) } @@ -207,6 +212,7 @@ impl Engine { 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 +225,7 @@ impl Engine { token_usage, turn_token_usage, context_report: session.context.report.clone(), + model, attempts, }) } @@ -295,6 +302,7 @@ impl Engine { token_usage: session.token_usage.clone(), turn_token_usage: TokenUsage::default(), context_report: session.context.report.clone(), + model: None, attempts: vec![], }); } @@ -987,6 +995,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![], @@ -1007,6 +1016,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![], @@ -1598,6 +1608,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![], @@ -1668,6 +1679,7 @@ mod tests { raw_output: None, metadata: BackendMetadata { backend: "flaky".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -1719,6 +1731,7 @@ mod tests { raw_output: None, metadata: BackendMetadata { backend: "bad_patch".into(), + model: None, token_usage: None, activities: vec![], attempts: vec![], @@ -1791,6 +1804,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![], @@ -1837,6 +1851,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![], diff --git a/rust/crates/pair_protocol/src/rpc.rs b/rust/crates/pair_protocol/src/rpc.rs index 8139a38..6ca3adf 100644 --- a/rust/crates/pair_protocol/src/rpc.rs +++ b/rust/crates/pair_protocol/src/rpc.rs @@ -101,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, } @@ -129,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, } From 8341b8d3e506678b955ffcd2699076477143ca95 Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 11:34:24 +0200 Subject: [PATCH 4/7] feat: prefetch, warmup, model routing and streaming previews --- Cargo.lock | 1 + lua/pair/config.lua | 18 + lua/pair/prompt.lua | 4 + rust/crates/pair_backends/src/claude_app.rs | 484 ++++++++++++++++---- rust/crates/pair_backends/src/lib.rs | 10 +- rust/crates/pair_harness/Cargo.toml | 1 + rust/crates/pair_harness/src/engine.rs | 310 ++++++++++++- rust/crates/paird/src/main.rs | 22 +- 8 files changed, 752 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d10743..7decb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,7 @@ dependencies = [ "pair_context", "pair_patch", "pair_protocol", + "serde_json", "thiserror", "tokio", "uuid", diff --git a/lua/pair/config.lua b/lua/pair/config.lua index 91e6522..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", @@ -39,6 +42,11 @@ M.values = { 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", @@ -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", @@ -325,6 +339,10 @@ function M.backend_env() 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 diff --git a/lua/pair/prompt.lua b/lua/pair/prompt.lua index 8e818af..6f585a0 100644 --- a/lua/pair/prompt.lua +++ b/lua/pair/prompt.lua @@ -7,6 +7,10 @@ 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 ", diff --git a/rust/crates/pair_backends/src/claude_app.rs b/rust/crates/pair_backends/src/claude_app.rs index 892e28d..e522b16 100644 --- a/rust/crates/pair_backends/src/claude_app.rs +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -1,5 +1,7 @@ +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; @@ -31,22 +33,43 @@ Patch only for fix actions. patch.diff must be unified diff hunks starting with 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 one `claude` CLI process alive per Pair session using its -/// stream-json stdin/stdout mode, so follow-up cards skip the CLI cold start -/// and reuse the conversation instead of resending the whole session. +/// 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 { - process: Option, 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 { @@ -70,6 +93,7 @@ struct TurnOutput { enum StreamEvent { Init(Option), Working(String), + Delta(String), Result { text: String, token_usage: Option, @@ -78,27 +102,152 @@ enum StreamEvent { 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 = std::env::var("PAIR_CLAUDE_MODEL") - .ok() - .filter(|value| !value.trim().is_empty()); + 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)) + Ok(Self::new( + command, + args, + model, + discovery_model, + discovery_thinking, + )) } - pub fn new(command: impl Into, args: Vec, model: Option) -> Self { + 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 spawn_args(&self) -> Vec { + 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(), @@ -106,13 +255,14 @@ impl ClaudeAppBackend { "--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) = &self.model { + if let Some(model) = model { args.push("--model".into()); args.push(model.clone()); } @@ -122,27 +272,24 @@ impl ClaudeAppBackend { args } - async fn ensure(&self, state: &mut ClaudeAppState, session_key: &str) -> Result<()> { - // One Claude process holds one conversation; a new Pair session must - // not inherit the previous session's context. - if state.session_key.as_deref() != Some(session_key) { - state.process = None; - state.context_fingerprint = None; - state.session_key = Some(session_key.to_string()); - } - - if state.process.is_some() { - return Ok(()); - } - - let mut child = Command::new(&self.command) - .args(self.spawn_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) - .spawn()?; + .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() @@ -152,69 +299,126 @@ impl ClaudeAppBackend { .take() .ok_or_else(|| anyhow!("claude stdout unavailable"))?; - state.process = Some(ClaudeAppProcess { + 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 mut state = self.state.lock().await; - let fresh = state.session_key.as_deref() != Some(req.session.id.as_str()) - || state.process.is_none(); + 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 fresh { - "Starting Claude" - } else { + if slot.process.is_some() { "Reusing the Claude session" + } else { + "Starting Claude" }, ); - self.ensure(&mut state, &req.session.id).await?; + + 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 = state.context_fingerprint != Some(fingerprint); - state.context_fingerprint = Some(fingerprint); - - let prompt = turn_prompt(req, include_context); - let message = json!({ - "type": "user", - "message": { - "role": "user", - "content": [{"type": "text", "text": prompt}] - } - }); - let line = serde_json::to_string(&message)?; - - if let Err(error) = Self::send(&mut state, &line).await { - // The previous process may have died between turns; retry once on - // a fresh process before giving up. - state.process = None; - state.context_fingerprint = None; - self.ensure(&mut state, &req.session.id).await?; - let prompt = turn_prompt(req, true); - let message = json!({ - "type": "user", - "message": { - "role": "user", - "content": [{"type": "text", "text": prompt}] - } - }); - Self::send(&mut state, &serde_json::to_string(&message)?) + 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_error| { - anyhow!("could not reach claude: {error}; retry failed: {retry_error}") - })?; + .map_err(|retry| anyhow!("could not reach claude: {error}; retry: {retry}"))?; } report_progress( @@ -224,16 +428,18 @@ impl ClaudeAppBackend { "Claude is processing the request", ); + let mut preview = StreamPreview::default(); + loop { let line = { - let process = state + let process = slot .process .as_mut() .ok_or_else(|| anyhow!("claude process unavailable"))?; match process.stdout.next_line().await? { Some(line) => line, None => { - state.process = None; + slot.process = None; return Err(anyhow!( "claude exited before finishing the turn; check that the claude CLI is logged in" )); @@ -251,16 +457,21 @@ impl ClaudeAppBackend { match parse_stream_event(&value) { StreamEvent::Init(model) => { - state.model = self.model.clone().or(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: state.model.clone().or_else(|| self.model.clone()), + model: slot.reported_model.clone().or_else(|| slot.model.clone()), }); } StreamEvent::Failed(message) => { @@ -271,19 +482,6 @@ impl ClaudeAppBackend { } } - async fn send(state: &mut ClaudeAppState, line: &str) -> Result<()> { - let process = state - .process - .as_mut() - .ok_or_else(|| anyhow!("claude process unavailable"))?; - - process.stdin.write_all(line.as_bytes()).await?; - process.stdin.write_all(b"\n").await?; - process.stdin.flush().await?; - - Ok(()) - } - fn error_card(message: impl Into) -> Card { Card::Error(ErrorCard { id: "c_claude_error".into(), @@ -294,6 +492,27 @@ impl ClaudeAppBackend { } } +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 { @@ -330,6 +549,10 @@ impl BackendAdapter for ClaudeAppBackend { }) } + async fn warmup(&self) -> Result<()> { + self.warm_up().await + } + fn capabilities(&self) -> BackendInfo { BackendInfo { name: "claude_app".into(), @@ -342,6 +565,16 @@ impl BackendAdapter for ClaudeAppBackend { } } +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": { @@ -416,6 +649,18 @@ fn parse_stream_event(value: &Value) -> StreamEvent { .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") @@ -454,7 +699,7 @@ fn parse_stream_event(value: &Value) -> StreamEvent { match tool { Some(name) => StreamEvent::Working(format!("Claude is using {name}")), - None => StreamEvent::Working("Claude is drafting the next Pair card".into()), + None => StreamEvent::Other, } } _ => StreamEvent::Other, @@ -469,6 +714,12 @@ fn parse_usage(value: Option<&Value>) -> Option { 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() @@ -536,6 +787,24 @@ mod tests { 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!({ @@ -560,6 +829,49 @@ mod tests { 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(); diff --git a/rust/crates/pair_backends/src/lib.rs b/rust/crates/pair_backends/src/lib.rs index 22e5030..f560949 100644 --- a/rust/crates/pair_backends/src/lib.rs +++ b/rust/crates/pair_backends/src/lib.rs @@ -37,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; } @@ -49,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, @@ -85,7 +91,7 @@ pub fn backend_context(context: &ContextBundle) -> serde_json::Value { }) } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub enum BackendAction { Start, User(Action), 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 c47aebb..0e83912 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -21,6 +21,23 @@ pub struct Engine { backend: Arc, sessions: HashMap, context_optimizer: ContextOptimizer, + prefetch_mode: PrefetchMode, + prefetches: HashMap, +} + +/// 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 +46,15 @@ impl Engine { backend, sessions: HashMap::new(), context_optimizer: ContextOptimizer::default(), + prefetch_mode: PrefetchMode::Off, + prefetches: HashMap::new(), } } + pub fn set_prefetch_mode(&mut self, mode: PrefetchMode) { + self.prefetch_mode = mode; + } + pub async fn start(&mut self, params: StartSessionParams) -> Result { self.start_with_progress(params, None).await } @@ -56,6 +79,7 @@ impl Engine { context, &expected, progress, + None, ) .await; let turn_token_usage = response.metadata.token_usage.clone().unwrap_or_default(); @@ -70,6 +94,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, @@ -94,11 +119,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 } @@ -119,6 +148,9 @@ impl Engine { .await; self.sessions.insert(session_id.into(), session); + if result.is_ok() { + self.schedule_prefetch(session_id).await; + } result } @@ -129,6 +161,7 @@ impl Engine { session: &mut Session, action: Action, progress: Option, + prefetched: Option, ) -> Result { let state = session.state.next(&action)?; if action == Action::Stop { @@ -161,6 +194,7 @@ impl Engine { context, &state, progress, + prefetched, ) .await; @@ -207,6 +241,7 @@ impl Engine { context, &expected, progress, + None, ) .await; @@ -310,7 +345,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 } @@ -379,18 +414,26 @@ impl Engine { context: ContextBundle, expected: &NextState, progress: Option, + mut prefetched: Option, ) -> BackendResponse { let mut action = action; 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 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:#}"); @@ -545,6 +588,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( @@ -1870,4 +2044,124 @@ 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)); + } } diff --git a/rust/crates/paird/src/main.rs b/rust/crates/paird/src/main.rs index 4036f32..1e8a77f 100644 --- a/rust/crates/paird/src/main.rs +++ b/rust/crates/paird/src/main.rs @@ -6,7 +6,7 @@ use pair_backends::{ BackendAdapter, ClaudeAppBackend, CodexAppBackend, GenericCliBackend, MockBackend, OllamaBackend, ProgressReporter, StdioAgentBackend, }; -use pair_harness::Engine; +use pair_harness::{Engine, PrefetchMode}; use pair_protocol::{ ActionParams, BackendInfo, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, PatchApplyResult, ReplyParams, StartSessionParams, @@ -69,8 +69,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, } @@ -173,6 +176,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}"))), }; @@ -181,6 +192,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, From d4c1fd550f9abea5bad7e035f073c6d04eaf2056 Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 11:52:09 +0200 Subject: [PATCH 5/7] feat: flexible card contract with /{kind} prompt override --- README.md | 8 ++ lua/pair/prompt.lua | 2 +- rust/crates/pair_backends/src/claude_app.rs | 4 +- rust/crates/pair_backends/src/codex_app.rs | 70 ++++++++++++++++- rust/crates/pair_backends/src/generic.rs | 5 +- rust/crates/pair_backends/src/lib.rs | 43 +++++++++++ rust/crates/pair_harness/src/engine.rs | 86 +++++++++++++++++---- rust/crates/pair_harness/src/session.rs | 73 ++++++++++++++++- 8 files changed, 266 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6921127..97dcf4c 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,14 @@ 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. +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/lua/pair/prompt.lua b/lua/pair/prompt.lua index 6f585a0..97963e6 100644 --- a/lua/pair/prompt.lua +++ b/lua/pair/prompt.lua @@ -13,7 +13,7 @@ function M.open(mode) 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, diff --git a/rust/crates/pair_backends/src/claude_app.rs b/rust/crates/pair_backends/src/claude_app.rs index e522b16..7e59c75 100644 --- a/rust/crates/pair_backends/src/claude_app.rs +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -29,6 +29,7 @@ The discriminator field is named "op". Allowed ops, with exact shapes: 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. +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."#; @@ -593,7 +594,8 @@ fn turn_prompt(req: &BackendRequest, include_context: bool) -> String { "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 } }); diff --git a/rust/crates/pair_backends/src/codex_app.rs b/rust/crates/pair_backends/src/codex_app.rs index 9807435..73492d2 100644 --- a/rust/crates/pair_backends/src/codex_app.rs +++ b/rust/crates/pair_backends/src/codex_app.rs @@ -884,10 +884,78 @@ fn output_schema(req: &BackendRequest) -> Value { Some(pair_protocol::CardKind::Deny) => deny_schema(), Some(pair_protocol::CardKind::Summary) => summary_schema(), Some(pair_protocol::CardKind::Error) => error_schema(), - None => error_schema(), + 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", "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); diff --git a/rust/crates/pair_backends/src/generic.rs b/rust/crates/pair_backends/src/generic.rs index 4b98d81..8532501 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -48,7 +48,7 @@ impl GenericCliBackend { 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), 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. error is only for technical failures. Patch only for fix. patch.diff must be unified diff hunks starting with @@. Unused schema fields null.", + "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), 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. 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"}, @@ -74,7 +74,8 @@ pub(crate) fn generic_prompt(req: &BackendRequest) -> String { "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, diff --git a/rust/crates/pair_backends/src/lib.rs b/rust/crates/pair_backends/src/lib.rs index f560949..5b7db31 100644 --- a/rust/crates/pair_backends/src/lib.rs +++ b/rust/crates/pair_backends/src/lib.rs @@ -135,8 +135,13 @@ pub fn enforce_card_contract( return card; }; + // 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.kind() == expected_kind + || (discovery_expected && matches!(card, Card::Choice(_))) || (contract.allow_goal_completion && matches!(card, Card::Summary(_))) { return card; @@ -285,6 +290,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_harness/src/engine.rs b/rust/crates/pair_harness/src/engine.rs index 0e83912..a84d86a 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -400,7 +400,7 @@ impl Engine { action, context, card_contract: CardContract { - expected_kind: Some(expected_kind), + expected_kind, allow_goal_completion: matches!(expected, NextState::Continuation), ..CardContract::default() }, @@ -1206,31 +1206,36 @@ 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::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 @@ -1238,8 +1243,8 @@ fn expected_card_kind( .rev() .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), }, } } @@ -2164,4 +2169,51 @@ mod tests { 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"); + } } 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()) + ); + } +} From 17a86c5b9757c304c3f6c00456ae143733dd54ed Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 12:19:42 +0200 Subject: [PATCH 6/7] feat: deny with location and one-key open-and-retry --- lua/pair/card.lua | 10 +++++++++ lua/pair/init.lua | 19 ++++++++++++++++ rust/crates/pair_backends/src/claude_app.rs | 3 ++- rust/crates/pair_backends/src/codex_app.rs | 5 +++-- rust/crates/pair_backends/src/generic.rs | 2 +- rust/crates/pair_harness/src/engine.rs | 10 ++++++++- rust/crates/pair_protocol/src/agent.rs | 25 ++++++++++++++++++++- rust/crates/pair_protocol/src/card.rs | 4 ++++ 8 files changed, 72 insertions(+), 6 deletions(-) diff --git a/lua/pair/card.lua b/lua/pair/card.lua index c42a926..88bd498 100644 --- a/lua/pair/card.lua +++ b/lua/pair/card.lua @@ -255,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] @@ -301,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] diff --git a/lua/pair/init.lua b/lua/pair/init.lua index 825ea3a..a1e920d 100644 --- a/lua/pair/init.lua +++ b/lua/pair/init.lua @@ -249,6 +249,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 diff --git a/rust/crates/pair_backends/src/claude_app.rs b/rust/crates/pair_backends/src/claude_app.rs index 7e59c75..fd01fbb 100644 --- a/rust/crates/pair_backends/src/claude_app.rs +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -23,12 +23,13 @@ The discriminator field is named "op". Allowed ops, with exact shapes: - {"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} +- {"op":"deny","title":string,"reason":string,"location":LOC|null} - {"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 — deny IMMEDIATELY with location set to that exact place instead of attempting a patch; the editor offers a one-key jump there and retries. Never draft a patch against a file that is not the supplied buffer. 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. diff --git a/rust/crates/pair_backends/src/codex_app.rs b/rust/crates/pair_backends/src/codex_app.rs index 73492d2..653534c 100644 --- a/rust/crates/pair_backends/src/codex_app.rs +++ b/rust/crates/pair_backends/src/codex_app.rs @@ -1124,11 +1124,12 @@ fn summary_schema() -> Value { fn deny_schema() -> Value { object_schema( - &["op", "title", "reason"], + &["op", "title", "reason", "location"], json!({ "op": {"type": "string", "enum": ["deny"]}, "title": {"type": "string"}, - "reason": {"type": "string"} + "reason": {"type": "string"}, + "location": nullable_location_schema() }), ) } diff --git a/rust/crates/pair_backends/src/generic.rs b/rust/crates/pair_backends/src/generic.rs index 8532501..232b62c 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -48,7 +48,7 @@ impl GenericCliBackend { 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), 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. 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.", + "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), 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, deny immediately with location set to that place instead of attempting a patch. 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"}, diff --git a/rust/crates/pair_harness/src/engine.rs b/rust/crates/pair_harness/src/engine.rs index a84d86a..2bfb895 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -521,7 +521,7 @@ 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 a deny op with location set to that place instead of another patch." )); continue; } @@ -1037,6 +1037,14 @@ fn validate_one_card(card: &Card) -> Result<()> { 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::Summary(card) => { require_text("card title", &card.title)?; diff --git a/rust/crates/pair_protocol/src/agent.rs b/rust/crates/pair_protocol/src/agent.rs index 803b10f..bcccf59 100644 --- a/rust/crates/pair_protocol/src/agent.rs +++ b/rust/crates/pair_protocol/src/agent.rs @@ -35,6 +35,8 @@ pub enum AgentOp { Deny { title: String, reason: String, + #[serde(default)] + location: Option, }, Summary { title: String, @@ -250,10 +252,15 @@ impl AgentOp { .map(|(index, option)| option.choice(index + 1)) .collect(), }), - Self::Deny { title, reason } => Card::Deny(DenyCard { + 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::Summary { @@ -415,6 +422,22 @@ mod tests { ); } + #[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 d835ede..799d996 100644 --- a/rust/crates/pair_protocol/src/card.rs +++ b/rust/crates/pair_protocol/src/card.rs @@ -143,6 +143,10 @@ 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, } From 8d73bd72b79202e7bf8fae9b67be316ad877a96e Mon Sep 17 00:00:00 2001 From: Dorian Donoch Date: Mon, 13 Jul 2026 14:29:00 +0200 Subject: [PATCH 7/7] feat: mid-turn open_location permission flow --- README.md | 7 + lua/pair/init.lua | 20 ++ lua/pair/rpc.lua | 43 ++++ lua/pair/version.lua | 2 +- rust/crates/pair_backends/src/claude_app.rs | 4 +- rust/crates/pair_backends/src/codex_app.rs | 5 +- rust/crates/pair_backends/src/generic.rs | 3 +- rust/crates/pair_backends/src/lib.rs | 5 +- rust/crates/pair_backends/src/mock.rs | 1 + rust/crates/pair_backends/src/stdio_agent.rs | 1 + rust/crates/pair_harness/src/engine.rs | 255 ++++++++++++++++++- rust/crates/pair_protocol/src/agent.rs | 11 +- rust/crates/pair_protocol/src/card.rs | 15 ++ rust/crates/pair_protocol/src/lib.rs | 2 +- rust/crates/paird/src/main.rs | 141 +++++++++- schemas/pair-agent-op.schema.json | 2 +- 16 files changed, 500 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 97dcf4c..eb84e46 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,13 @@ 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`, diff --git a/lua/pair/init.lua b/lua/pair/init.lua index a1e920d..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() 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/src/claude_app.rs b/rust/crates/pair_backends/src/claude_app.rs index fd01fbb..448ec06 100644 --- a/rust/crates/pair_backends/src/claude_app.rs +++ b/rust/crates/pair_backends/src/claude_app.rs @@ -24,12 +24,13 @@ The discriminator field is named "op". Allowed ops, with exact shapes: - {"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 — deny IMMEDIATELY with location set to that exact place instead of attempting a patch; the editor offers a one-key jump there and retries. Never draft a patch against a file that is not the supplied buffer. +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. @@ -639,6 +640,7 @@ fn action_value(action: &BackendAction) -> Value { BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), } } diff --git a/rust/crates/pair_backends/src/codex_app.rs b/rust/crates/pair_backends/src/codex_app.rs index 653534c..e0c612f 100644 --- a/rust/crates/pair_backends/src/codex_app.rs +++ b/rust/crates/pair_backends/src/codex_app.rs @@ -706,6 +706,7 @@ fn action_value(action: &BackendAction) -> Value { BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), } } @@ -884,7 +885,7 @@ fn output_schema(req: &BackendRequest) -> Value { Some(pair_protocol::CardKind::Deny) => deny_schema(), Some(pair_protocol::CardKind::Summary) => summary_schema(), Some(pair_protocol::CardKind::Error) => error_schema(), - None => any_op_schema(), + Some(pair_protocol::CardKind::OpenLocation) | None => any_op_schema(), } } @@ -912,7 +913,7 @@ fn any_op_schema() -> Value { "message", ], json!({ - "op": {"type": "string", "enum": ["hypothesis", "finding", "patch", "choice", "deny", "summary", "error"]}, + "op": {"type": "string", "enum": ["hypothesis", "finding", "patch", "choice", "deny", "open_location", "summary", "error"]}, "title": {"type": "string"}, "claim": {"type": ["string", "null"]}, "evidence": nullable_location_schema(), diff --git a/rust/crates/pair_backends/src/generic.rs b/rust/crates/pair_backends/src/generic.rs index 232b62c..486cca0 100644 --- a/rust/crates/pair_backends/src/generic.rs +++ b/rust/crates/pair_backends/src/generic.rs @@ -48,7 +48,7 @@ impl GenericCliBackend { 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), 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, deny immediately with location set to that place instead of attempting a patch. 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.", + "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"}, @@ -103,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"}), } } diff --git a/rust/crates/pair_backends/src/lib.rs b/rust/crates/pair_backends/src/lib.rs index 5b7db31..d2dcc31 100644 --- a/rust/crates/pair_backends/src/lib.rs +++ b/rust/crates/pair_backends/src/lib.rs @@ -97,6 +97,9 @@ pub enum BackendAction { 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)] @@ -139,7 +142,7 @@ pub fn enforce_card_contract( // 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(_)) + 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(_))) diff --git a/rust/crates/pair_backends/src/mock.rs b/rust/crates/pair_backends/src/mock.rs index 00f9115..bca89b2 100644 --- a/rust/crates/pair_backends/src/mock.rs +++ b/rust/crates/pair_backends/src/mock.rs @@ -30,6 +30,7 @@ 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 { diff --git a/rust/crates/pair_backends/src/stdio_agent.rs b/rust/crates/pair_backends/src/stdio_agent.rs index 9994193..2b5d9ad 100644 --- a/rust/crates/pair_backends/src/stdio_agent.rs +++ b/rust/crates/pair_backends/src/stdio_agent.rs @@ -235,6 +235,7 @@ fn action_value(action: &BackendAction) -> serde_json::Value { BackendAction::ContractRetry(reason) => { json!({"kind": "contract_retry", "reason": reason}) } + BackendAction::LocationGranted => json!({"kind": "location_granted"}), } } diff --git a/rust/crates/pair_harness/src/engine.rs b/rust/crates/pair_harness/src/engine.rs index 2bfb895..ea84ed5 100644 --- a/rust/crates/pair_harness/src/engine.rs +++ b/rust/crates/pair_harness/src/engine.rs @@ -23,8 +23,25 @@ pub struct Engine { 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. @@ -48,6 +65,7 @@ impl Engine { context_optimizer: ContextOptimizer::default(), prefetch_mode: PrefetchMode::Off, prefetches: HashMap::new(), + location_granter: None, } } @@ -55,6 +73,10 @@ impl Engine { 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 } @@ -417,10 +439,13 @@ impl Engine { mut prefetched: Option, ) -> BackendResponse { let mut action = action; + let mut context = context; let mut token_usage = None; let mut attempts = Vec::new(); + let mut attempt = 0; + let mut grants = 0; - for attempt in 0..3 { + 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 @@ -456,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( @@ -482,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; } @@ -521,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. If the change belongs in a different file than the supplied buffer, return a deny op with location set to that place instead of another patch." + "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; } @@ -1046,6 +1131,15 @@ fn validate_one_card(card: &Card) -> Result<()> { )?; } } + 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)?; @@ -1239,6 +1333,7 @@ fn expected_card_kind( }), BackendAction::Reply(_) => None, BackendAction::ContractRetry(_) => None, + BackendAction::LocationGranted => None, BackendAction::User(action) => match action { Action::Fix => Some(CardKind::Patch), Action::OtherLead => Some(CardKind::Hypothesis), @@ -1276,6 +1371,7 @@ fn card_summary(card: &Card) -> String { 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), } @@ -2224,4 +2320,159 @@ mod tests { 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_protocol/src/agent.rs b/rust/crates/pair_protocol/src/agent.rs index bcccf59..b3d93e3 100644 --- a/rust/crates/pair_protocol/src/agent.rs +++ b/rust/crates/pair_protocol/src/agent.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Action, Card, ChoiceCard, ChoiceOption, DenyCard, ErrorCard, FilePatch, FindingCard, - HypothesisCard, Location, LocationEvidence, NextMove, PatchCard, SummaryCard, + HypothesisCard, Location, LocationEvidence, NextMove, OpenLocationCard, PatchCard, SummaryCard, }; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -38,6 +38,10 @@ pub enum AgentOp { #[serde(default)] location: Option, }, + OpenLocation { + reason: String, + location: AgentLocation, + }, Summary { title: String, summary: String, @@ -263,6 +267,11 @@ impl AgentOp { 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, summary, diff --git a/rust/crates/pair_protocol/src/card.rs b/rust/crates/pair_protocol/src/card.rs index 799d996..87075e6 100644 --- a/rust/crates/pair_protocol/src/card.rs +++ b/rust/crates/pair_protocol/src/card.rs @@ -14,6 +14,7 @@ pub enum CardKind { Patch, Choice, Deny, + OpenLocation, Summary, Error, } @@ -43,6 +44,7 @@ pub enum Card { Patch(PatchCard), Choice(ChoiceCard), Deny(DenyCard), + OpenLocation(OpenLocationCard), Summary(SummaryCard), Error(ErrorCard), } @@ -55,6 +57,7 @@ impl Card { Card::Patch(_) => CardKind::Patch, Card::Choice(_) => CardKind::Choice, Card::Deny(_) => CardKind::Deny, + Card::OpenLocation(_) => CardKind::OpenLocation, Card::Summary(_) => CardKind::Summary, Card::Error(_) => CardKind::Error, } @@ -67,6 +70,7 @@ impl Card { 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, } @@ -79,6 +83,7 @@ impl Card { 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, } @@ -150,6 +155,16 @@ pub struct DenyCard { 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/paird/src/main.rs b/rust/crates/paird/src/main.rs index 1e8a77f..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, ClaudeAppBackend, CodexAppBackend, GenericCliBackend, MockBackend, OllamaBackend, ProgressReporter, StdioAgentBackend, }; -use pair_harness::{Engine, PrefetchMode}; +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,6 +95,90 @@ 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()?)), diff --git a/schemas/pair-agent-op.schema.json b/schemas/pair-agent-op.schema.json index 7030e9b..9b339b9 100644 --- a/schemas/pair-agent-op.schema.json +++ b/schemas/pair-agent-op.schema.json @@ -22,7 +22,7 @@ ], "properties": { "op": { - "enum": ["hypothesis", "finding", "patch", "choice", "deny", "summary", "error"] + "enum": ["hypothesis", "finding", "patch", "choice", "deny", "open_location", "summary", "error"] }, "title": { "type": "string"