Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions agy-acp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -31,23 +32,41 @@ struct JsonRpcNotification {
}

struct Session {
has_history: bool,
/// agy conversation ID (from conversations directory)
conversation_id: Option<String>,
/// cumulative stdout length from previous turns
prev_output_len: usize,
}

struct Adapter {
sessions: HashMap<String, Session>,
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<String> {
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",
Expand All @@ -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,
Expand All @@ -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("<sender_context>"))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let clean_prompt = prompt_text.trim();

// Build args: use --conversation <ID> for subsequent turns
let mut args: Vec<String> = 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());
Expand All @@ -109,18 +139,43 @@ 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(),
params: json!({
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": text },
"content": { "type": "text", "text": new_text },
},
}),
}).unwrap();
Expand All @@ -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::<String>();
std::thread::spawn(move || {
let stdin = io::stdin();
Expand Down
36 changes: 18 additions & 18 deletions docs/antigravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ID> -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 <ID> -p "text"` (resumes specific conversation)
- Only the **delta** (new response) is sent back — previous turns are not repeated
- Full `<sender_context>` metadata is passed through to agy

## Configuration

Expand All @@ -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 <working_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

Expand All @@ -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/`.
Expand All @@ -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"
Expand All @@ -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.
Loading