From 5f985ac5e50af9eed0fe7bc1795875a1518b7370 Mon Sep 17 00:00:00 2001 From: Pahud Hsieh Date: Fri, 22 May 2026 12:05:57 -0400 Subject: [PATCH] fix(agy-acp): use --conversation ID + delta extraction for multi-turn Replace --continue with --conversation to fix two bugs: 1. Full conversation history repeated on every turn (#905) 2. Concurrent sessions unsafe (--continue targets most recent globally) Now tracks per-session: agy conversation ID (from conversations dir) and cumulative output length. Only emits the delta on each turn. Fixes #905 --- agy-acp/src/main.rs | 74 +++++++++++++++++++++++++++++++++++++++------ docs/antigravity.md | 36 +++++++++++----------- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/agy-acp/src/main.rs b/agy-acp/src/main.rs index 8cb2d92b..51ede1e4 100644 --- a/agy-acp/src/main.rs +++ b/agy-acp/src/main.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::io::{self, BufRead, Write}; +use std::path::PathBuf; use tokio::process::Command; use tokio::sync::mpsc; use uuid::Uuid; @@ -31,23 +32,41 @@ struct JsonRpcNotification { } struct Session { - has_history: bool, + /// agy conversation ID (from conversations directory) + conversation_id: Option, + /// cumulative stdout length from previous turns + prev_output_len: usize, } struct Adapter { sessions: HashMap, working_dir: String, + conversations_dir: PathBuf, } impl Adapter { fn new() -> Self { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); Self { sessions: HashMap::new(), - working_dir: std::env::var("AGY_WORKING_DIR") + working_dir: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| "/tmp".to_string()), + conversations_dir: PathBuf::from(&home) + .join(".gemini/antigravity-cli/conversations"), } } + /// Find the most recently modified conversation ID from agy's data dir. + fn latest_conversation_id(&self) -> Option { + let entries = std::fs::read_dir(&self.conversations_dir).ok()?; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "pb").unwrap_or(false)) + .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok())) + .and_then(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string())) + } + fn handle_initialize(&self, id: u64) -> JsonRpcResponse { JsonRpcResponse { jsonrpc: "2.0", @@ -63,7 +82,10 @@ impl Adapter { fn handle_session_new(&mut self, id: u64) -> JsonRpcResponse { let session_id = Uuid::new_v4().to_string(); - self.sessions.insert(session_id.clone(), Session { has_history: false }); + self.sessions.insert(session_id.clone(), Session { + conversation_id: None, + prev_output_len: 0, + }); JsonRpcResponse { jsonrpc: "2.0", id, @@ -80,17 +102,25 @@ impl Adapter { .map(|arr| { arr.iter() .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .filter(|t| !t.starts_with("")) .collect::>() .join("\n") }) .unwrap_or_default(); let clean_prompt = prompt_text.trim(); + // Build args: use --conversation for subsequent turns let mut args: Vec = Vec::new(); + // Always add working dir as workspace so agy reads AGENTS.md/GEMINI.md + args.push("--add-dir".to_string()); + args.push(self.working_dir.clone()); + // Add extra args from AGY_EXTRA_ARGS env var if set + if let Ok(extra) = std::env::var("AGY_EXTRA_ARGS") { + args.extend(extra.split_whitespace().map(String::from)); + } if let Some(session) = self.sessions.get(session_id) { - if session.has_history { - args.push("--continue".to_string()); + if let Some(conv_id) = &session.conversation_id { + args.push("--conversation".to_string()); + args.push(conv_id.clone()); } } args.push("-p".to_string()); @@ -109,10 +139,35 @@ impl Adapter { match result { Ok(output) => { - let text = String::from_utf8_lossy(&output.stdout).to_string(); + let full_text = String::from_utf8_lossy(&output.stdout).to_string(); + + // Extract only the new content (delta) + let prev_len = self.sessions.get(session_id) + .map(|s| s.prev_output_len) + .unwrap_or(0); + let new_text = if prev_len < full_text.len() { + full_text[prev_len..].trim_start().to_string() + } else { + full_text.clone() + }; + + // Update session state + let conv_id = if self.sessions.get(session_id) + .map(|s| s.conversation_id.is_none()) + .unwrap_or(false) + { + self.latest_conversation_id() + } else { + None + }; + if let Some(session) = self.sessions.get_mut(session_id) { - session.has_history = true; + session.prev_output_len = full_text.len(); + if session.conversation_id.is_none() { + session.conversation_id = conv_id; + } } + let notification = serde_json::to_string(&JsonRpcNotification { jsonrpc: "2.0", method: "session/update".to_string(), @@ -120,7 +175,7 @@ impl Adapter { "sessionId": session_id, "update": { "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": text }, + "content": { "type": "text", "text": new_text }, }, }), }).unwrap(); @@ -141,7 +196,6 @@ impl Adapter { async fn main() { let mut adapter = Adapter::new(); - // Read stdin lines in a blocking thread, send to async handler let (tx, mut rx) = mpsc::unbounded_channel::(); std::thread::spawn(move || { let stdin = io::stdin(); diff --git a/docs/antigravity.md b/docs/antigravity.md index 14d3d833..6022edeb 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -5,13 +5,14 @@ OpenAB supports [Google Antigravity CLI](https://antigravity.google/) via the `a ## How It Works ``` -openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --dangerously-skip-permissions -p "prompt" - agy --continue -p "follow-up" +openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --add-dir /home/agent -p "prompt" + agy --add-dir /home/agent --conversation -p "follow-up" ``` -- First prompt in a session: `agy -p "text"` -- Subsequent prompts: `agy --continue -p "text"` (resumes most recent conversation) -- Tool permissions are auto-approved via `--dangerously-skip-permissions` +- First prompt in a session: `agy -p "text"`, then discovers the conversation ID +- Subsequent prompts: `agy --conversation -p "text"` (resumes specific conversation) +- Only the **delta** (new response) is sent back — previous turns are not repeated +- Full `` metadata is passed through to agy ## Configuration @@ -22,20 +23,22 @@ args = [] working_dir = "/home/agent" ``` -Or with the Docker image: - -```toml -[agent] -command = "/usr/local/bin/agy-acp" -args = [] -working_dir = "/home/agent" -``` - ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `AGY_WORKING_DIR` | Working directory for agy invocations | `/tmp` | +| `AGY_EXTRA_ARGS` | Extra arguments prepended to every `agy` invocation (optional) | (none) | + +## Steering Files + +agy reads `AGENTS.md` and `GEMINI.md` when it considers a directory a workspace: + +1. `AGENTS.md` and `GEMINI.md` are loaded first and injected into the system prompt +2. agy does not disclose how it determines HOME as a workspace, but `--add-dir` explicitly adds a directory +3. agy-acp **automatically** passes `--add-dir ` on every invocation — no configuration needed + +Place your steering instructions in `/home/agent/AGENTS.md` or `/home/agent/GEMINI.md` — they will be read on every prompt as long as `working_dir` points to that directory. ## Docker @@ -48,7 +51,7 @@ docker build -f Dockerfile.antigravity -t openab-antigravity . Antigravity CLI uses Google Sign-In (OAuth). Authenticate inside the container: ```bash -kubectl exec -it deployment/openab-antigravity -- agy auth +kubectl exec -it deployment/openab-antigravity -- /lib64/ld-linux-x86-64.so.2 /usr/local/bin/agy auth ``` Complete the device flow in your browser. Auth tokens persist in the PVC at `~/.gemini/`. @@ -65,8 +68,6 @@ agents: command: "agy-acp" args: [] workingDir: "/home/agent" - env: - AGY_WORKING_DIR: "/home/agent" image: repository: ghcr.io/openabdev/openab-antigravity tag: "latest" @@ -76,4 +77,3 @@ agents: - **No streaming**: `agy -p` returns the full response at once; the adapter sends it as a single `agent_message_chunk` notification. - **Cancel is a no-op**: `agy -p` runs to completion; `session/cancel` acknowledges but cannot interrupt. -- **Session continuity uses `--continue`**: This resumes the *most recent* agy conversation, which works for single-user-per-pod deployments but may conflict if multiple sessions run concurrently in the same container.