From 9abf831a4975a695433b2967d88a8004773efd55 Mon Sep 17 00:00:00 2001 From: Vaughan Date: Sat, 2 May 2026 21:48:58 -0700 Subject: [PATCH] Add self-contained AI agent harness with any OpenAI-compatible provider Adds a new WarpAi harness and warp-ai CLI that runs an autonomous sysadmin agent using any OpenAI-compatible API (DeepSeek, LiteLLM, Ollama, etc.) without requiring a Warp account or backend server. Features: - warp-ai CLI with tool-use agent loop (run_command, read_file, write_file, list_directory) for autonomous task execution - WarpAiHarness ThirdPartyHarness integration that bypasses server-side prompt resolution via new requires_server_prompt_resolution() trait method - Default harness dropdown in Settings > Features for auto-launching warp-ai on new tabs/sessions - Config via env vars (WARP_AI_API_KEY, WARP_AI_BASE_URL, WARP_AI_MODEL) or ~/.config/warp-ai/config.json - Customizable system prompt via ~/.config/warp-ai/system-prompt.txt Co-Authored-By: Claude Opus 4.6 --- README.md | 276 +++++++++++++----- app/src/ai/agent/conversation.rs | 1 + app/src/ai/agent_sdk/driver.rs | 48 +-- app/src/ai/agent_sdk/driver/harness/mod.rs | 10 + .../ai/agent_sdk/driver/harness/warp_ai.rs | 226 ++++++++++++++ app/src/ai/agent_sdk/mod.rs | 1 + .../history_model/conversation_loader.rs | 2 +- app/src/ai/harness_display.rs | 4 + app/src/pane_group/mod.rs | 24 +- .../pane_group/pane/local_harness_launch.rs | 3 +- app/src/server/telemetry/events.rs | 1 + app/src/settings/ai.rs | 73 +++++ app/src/settings_view/features_page.rs | 122 +++++++- app/src/terminal/cli_agent.rs | 8 + .../cli_agent_sessions/listener/mod.rs | 1 + .../cli_agent_sessions/plugin_manager/mod.rs | 1 + .../view/ambient_agent/harness_selector.rs | 1 + .../terminal/view/ambient_agent/view_impl.rs | 1 + app/src/terminal/view/use_agent_footer/mod.rs | 2 +- app/src/workspace/view.rs | 21 +- crates/warp_ai_cli/Cargo.toml | 20 ++ crates/warp_ai_cli/src/client.rs | 239 +++++++++++++++ crates/warp_ai_cli/src/config.rs | 75 +++++ crates/warp_ai_cli/src/main.rs | 145 +++++++++ crates/warp_ai_cli/src/tools.rs | 268 +++++++++++++++++ crates/warp_cli/src/agent.rs | 8 +- 26 files changed, 1486 insertions(+), 95 deletions(-) create mode 100644 app/src/ai/agent_sdk/driver/harness/warp_ai.rs create mode 100644 crates/warp_ai_cli/Cargo.toml create mode 100644 crates/warp_ai_cli/src/client.rs create mode 100644 crates/warp_ai_cli/src/config.rs create mode 100644 crates/warp_ai_cli/src/main.rs create mode 100644 crates/warp_ai_cli/src/tools.rs diff --git a/README.md b/README.md index ab2bec0a25c..2e3cf0c0192 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,242 @@ - - Warp Agentic Development Environment product preview - +# Warp AI — Self-Contained AI Terminal Agent -

- Website - · - Code - · - Agents - · - Terminal - · - Drive - · - Docs - · - How Warp Works -

+> This is a fork of [warpdotdev/warp](https://github.com/warpdotdev/warp), modified to add a self-contained AI agent that works with any OpenAI-compatible API — no Warp account or backend server required. -> [!NOTE] -> OpenAI is the founding sponsor of the new, open-source Warp repository, and the new agentic management workflows are powered by GPT models. +**Author:** [goshitsarch](https://github.com/goshitsarch) (goshitsarch@gmail.com) -

+> AI tools (OpenClaude with GLM-5.1) were used to accelerate the development of these modifications. All code was designed, reviewed, and validated by a human developer. -## About +--- -[Warp](https://www.warp.dev) is an agentic development environment, born out of the terminal. Use Warp's built-in coding agent, or bring your own CLI agent (Claude Code, Codex, Gemini CLI, and others). +## What This Fork Adds -## Installation +### 1. `warp-ai` CLI Agent (`crates/warp_ai_cli/`) -You can [download Warp](https://www.warp.dev/download) and [read our docs](https://docs.warp.dev/) for platform-specific instructions. +A Rust CLI that acts as a fully autonomous sysadmin agent. It: -## Warp Contributions Overview Dashboard +- Accepts a prompt and system prompt from files +- Calls any **OpenAI-compatible API** (DeepSeek, LiteLLM, Ollama, Together, local models, etc.) +- Uses **function calling / tool use** to autonomously execute tasks +- Streams responses in real-time to the terminal -Explore [build.warp.dev](https://build.warp.dev) to: -- Watch thousands of Oz agents triage issues, write specs, implement changes, and review PRs -- View top contributors and in-flight features -- Track your own issues with GitHub sign-in -- Click into active agent sessions in a web-compiled Warp terminal +**Available tools:** -## Licensing +| Tool | Description | +|------|-------------| +| `run_command` | Execute any shell command via `sh -c` — get stdout, stderr, exit code | +| `read_file` | Read file contents | +| `write_file` | Write to a file (creates parent dirs if needed) | +| `list_directory` | List files with `ls -la` | -Warp's UI framework (the `warpui_core` and `warpui` crates) are licensed under the [MIT license](LICENSE-MIT). +The agent loop runs until the task is complete: LLM calls tools, results are sent back, LLM decides next step, repeat. Maximum 50 turns by default. -The rest of the code in this repository is licensed under the [AGPL v3](LICENSE-AGPL). +### 2. WarpAi Harness (`app/src/ai/agent_sdk/driver/harness/warp_ai.rs`) -## Open Source & Contributing +A [ThirdPartyHarness](https://github.com/warpdotdev/warp/blob/master/app/src/ai/agent_sdk/driver/harness/mod.rs) implementation that integrates the `warp-ai` CLI into Warp's agent mode: -Warp's client codebase is open source and lives in this repository. We welcome community contributions and have designed a lightweight workflow to help new contributors get started. For the full contribution flow, read our [CONTRIBUTING.md](CONTRIBUTING.md) guide. +- Spawns `warp-ai` in the terminal +- Writes system prompt to a temp file (customizable via `~/.config/warp-ai/system-prompt.txt`) +- **Bypasses Warp's server** — no `resolve_prompt()` call, no account needed +- Conversations are local-only (no server save) -> [!TIP] -> **Chat with contributors and the Warp team** in the [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) Slack channel — a good place for ad-hoc questions, design discussion, and pairing with maintainers. New here? [Join the Warp Slack community](https://go.warp.dev/join-preview) first, then jump into `#oss-contributors`. +### 3. Default Harness Setting (Settings UI) -### Issue to PR +A new "Default AI harness" dropdown in **Settings > Features**: -Before filing, [search existing issues](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) for your bug or feature request. If nothing exists, [file an issue](https://github.com/warpdotdev/warp/issues/new/choose) using our templates. Security vulnerabilities should be reported privately as described in [CONTRIBUTING.md](CONTRIBUTING.md#reporting-security-issues). +- **None** — default, uses Warp's built-in Oz agent +- **Warp AI** — new tabs/sessions auto-launch the `warp-ai` CLI agent -Once filed, a Warp maintainer reviews the issue and may apply a readiness label: [`ready-to-spec`](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+label%3Aready-to-spec) signals the design is open for contributors to spec out, and [`ready-to-implement`](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+label%3Aready-to-implement) signals the design is settled and code PRs are welcome. Anyone can pick up a labeled issue — mention **@oss-maintainers** on an issue if you'd like it considered for a readiness label. +Stored in `~/.warp/settings.toml` at `general.default_harness`. -### Building the Repo Locally +### 4. Server Bypass -To build and run Warp from source: +Added `requires_server_prompt_resolution()` to the `ThirdPartyHarness` trait. Harnesses that return `false` skip the server-side prompt resolution call. This is the mechanism that makes the whole system work without Warp's backend. + +--- + +## Quick Start + +### 1. Build + +```bash +# Build Warp with the warp-ai binary included +./script/bootstrap +./script/run +``` + +The `warp-ai` binary is built automatically as part of the workspace. + +### 2. Configure Your AI Provider + +Set environment variables (or create a config file): + +```bash +# Required: your API key +export WARP_AI_API_KEY="sk-your-key-here" + +# Optional: defaults to OpenAI +export WARP_AI_BASE_URL="https://api.deepseek.com/v1" +export WARP_AI_MODEL="deepseek-chat" +``` + +Or create `~/.config/warp-ai/config.json`: + +```json +{ + "api_key": "sk-your-key-here", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat" +} +``` + +### 3. Use It + +**Option A: Auto-launch (recommended)** + +1. Open Warp +2. Go to **Settings > Features** +3. Set "Default AI harness" to **Warp AI** +4. New tabs automatically start the AI agent + +**Option B: Manual launch** + +```bash +warp --harness warp-ai -p "check disk usage and clean up temp files over 1GB" +``` + +**Option C: Standalone CLI** ```bash -./script/bootstrap # platform-specific setup -./script/run # build and run Warp -./script/presubmit # fmt, clippy, and tests +echo "show me running services on this machine" > /tmp/prompt.txt +echo "You are a sysadmin assistant." > /tmp/sys.txt +WARP_AI_API_KEY=sk-xxx warp-ai --prompt-file /tmp/prompt.txt --system-prompt-file /tmp/sys.txt +``` + +--- + +## Example Usage + +``` +You: set up nginx with a reverse proxy to localhost:3000 + +[run_command] sudo apt install -y nginx +[OK] Reading package lists... Setting up nginx... + +[run_command] sudo tee /etc/nginx/sites-available/myapp +[OK] Wrote 247 bytes to /etc/nginx/sites-available/myapp + +[run_command] sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ +[OK] + +[run_command] sudo nginx -t && sudo systemctl reload nginx +[OK] nginx: configuration syntax is ok... + +Nginx is configured and running. Your app at localhost:3000 is now +reverse-proxied through nginx on port 80. ``` -See [WARP.md](WARP.md) for the full engineering guide, including coding style, testing, and platform-specific notes. +--- + +## Supported Providers + +Any API that implements the OpenAI chat completions interface with streaming and function calling: -## Joining the Team +| Provider | Base URL | Notes | +|----------|----------|-------| +| OpenAI | `https://api.openai.com/v1` | Default | +| DeepSeek | `https://api.deepseek.com/v1` | Good value | +| Together AI | `https://api.together.xyz/v1` | | +| Groq | `https://api.groq.com/openai/v1` | Fast inference | +| LiteLLM Proxy | `http://localhost:4000/v1` | Local proxy for any provider | +| Ollama | `http://localhost:11434/v1` | Local models | +| Any OpenAI-compatible | Custom | | -Interested in joining the team? See our [open roles](https://www.warp.dev/careers). +--- -## Support and Questions +## Custom System Prompt -1. See our [docs](https://docs.warp.dev/) for a comprehensive guide to Warp's features. -2. Join our [Slack Community](https://go.warp.dev/join-preview) to connect with other users and get help from the Warp team — contributors hang out in [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB). -3. Try our [Preview build](https://www.warp.dev/download-preview) to test the latest experimental features. -4. Mention **@oss-maintainers** on any issue to escalate to the team — for example, if you encounter problems with the automated agents. +Override the default sysadmin-focused system prompt by creating: -## Code of Conduct +``` +~/.config/warp-ai/system-prompt.txt +``` -We ask everyone to be respectful and empathetic. Warp follows the [Code of Conduct](CODE_OF_CONDUCT.md). To report violations, email warp-coc at warp.dev. +--- -## Open Source Dependencies +## Architecture -We'd like to call out a few of the [open source dependencies](https://docs.warp.dev/help/licenses) that have helped Warp to get off the ground: +``` +User → Warp Terminal → WarpAiHarness → spawns `warp-ai` CLI → OpenAI-compatible API + ↕ + run_command / read_file / write_file / list_directory +``` + +### Request Flow + +``` +1. User types prompt in Warp +2. WarpAiHarness writes prompt + system prompt to temp files +3. Spawns: warp-ai --prompt-file --system-prompt-file +4. CLI sends prompt + tools to LLM API +5. LLM responds with tool calls (e.g., run_command("mkdir deploy")) +6. CLI executes tools locally, sends results back to LLM +7. Repeat steps 5-6 until LLM says done +8. Response streams to Warp terminal +``` -* [Tokio](https://github.com/tokio-rs/tokio) -* [NuShell](https://github.com/nushell/nushell) -* [Fig Completion Specs](https://github.com/withfig/autocomplete) -* [Warp Server Framework](https://github.com/seanmonstar/warp) -* [Alacritty](https://github.com/alacritty/alacritty) -* [Hyper HTTP library](https://github.com/hyperium/hyper) -* [FontKit](https://github.com/servo/font-kit) -* [Core-foundation](https://github.com/servo/core-foundation-rs) -* [Smol](https://github.com/smol-rs/smol) +--- + +## Files Added + +| File | Purpose | +|------|---------| +| `crates/warp_ai_cli/Cargo.toml` | CLI crate definition | +| `crates/warp_ai_cli/src/main.rs` | Agent loop entry point | +| `crates/warp_ai_cli/src/client.rs` | OpenAI-compatible streaming client with tool call parsing | +| `crates/warp_ai_cli/src/config.rs` | Config resolution (env > CLI > config file) | +| `crates/warp_ai_cli/src/tools.rs` | Tool definitions and execution | +| `app/src/ai/agent_sdk/driver/harness/warp_ai.rs` | WarpAiHarness + WarpAiHarnessRunner | + +## Files Modified + +| File | Change | +|------|--------| +| `crates/warp_cli/src/agent.rs` | Added `Harness::WarpAi` enum variant | +| `app/src/terminal/cli_agent.rs` | Added `CLIAgent::WarpAi` + all match arms | +| `app/src/ai/agent_sdk/driver/harness/mod.rs` | Registered harness, added `requires_server_prompt_resolution()` | +| `app/src/ai/agent_sdk/driver.rs` | Server bypass guard in `prepare_harness()` | +| `app/src/ai/agent/conversation.rs` | Added `AIAgentHarness::WarpAi` | +| `app/src/ai/harness_display.rs` | Display name, icon, conversions | +| `app/src/ai/agent_sdk/mod.rs` | Telemetry label | +| `app/src/settings/ai.rs` | `DefaultHarness` enum + setting registration | +| `app/src/settings_view/features_page.rs` | Dropdown widget + auto-launch UI | +| `app/src/pane_group/mod.rs` | Auto-launch on new pane | +| `app/src/workspace/view.rs` | Auto-launch on new tab | +| `app/src/server/telemetry/events.rs` | `CLIAgentType::WarpAi` | +| `app/src/terminal/view/ambient_agent/*.rs` | UI updates | +| `app/src/pane_group/pane/local_harness_launch.rs` | Local launch support | +| `app/src/ai/blocklist/history_model/conversation_loader.rs` | Conversation loading | +| Various other files | Exhaustive match updates for new enum variants | + +--- + +## Limitations + +- **Agent mode only** — passive suggestions, autosuggestions, and AI command search still require Warp's backend +- **No conversation resume** — conversations are not persisted between sessions +- **No MCP tools** — the CLI uses its own built-in tools, not Warp's MCP tool system +- **Function calling required** — the LLM must support OpenAI-style function calling for the agent loop to work + +--- + +## License + +Same as upstream Warp: +- `warpui_core` and `warpui` crates: [MIT license](LICENSE-MIT) +- Everything else (including new code): [AGPL v3](LICENSE-AGPL) + +## Credits + +- [Warp](https://github.com/warpdotdev/warp) by the Warp team — the incredible terminal this is built on +- AI development assistance: [OpenClaude](https://github.com/anthropics/claude-code) with GLM-5.1 +- Maintained by [goshitsarch](https://github.com/goshitsarch) diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 6fc03805ed5..ac8b3ee130b 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -3525,6 +3525,7 @@ pub enum AIAgentHarness { ClaudeCode, Gemini, Codex, + WarpAi, Unknown, } diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 0f0439b0644..77840192281 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -1560,26 +1560,34 @@ impl AgentDriver { skill, attachments_dir, } => { - let skill = skill - .as_ref() - .map(|parsed_skill| ResolvePromptAttachedSkill { - name: parsed_skill.name.clone(), - content: parsed_skill.content.clone(), - path: Some(parsed_skill.path.to_string_lossy().to_string()), - }); - let request = ResolvePromptRequest { - skill, - attachments_dir: attachments_dir.clone(), - }; - let resolved = server_api - .resolve_prompt(request) - .await - .map_err(AgentDriverError::PromptResolutionFailed)?; - ( - Cow::Owned(resolved.prompt), - resolved.system_prompt, - resolved.resumption_prompt, - ) + if !harness.requires_server_prompt_resolution() { + let prompt_text = skill + .as_ref() + .map(|s| s.content.clone()) + .unwrap_or_default(); + (Cow::Owned(prompt_text), None, None) + } else { + let skill = skill + .as_ref() + .map(|parsed_skill| ResolvePromptAttachedSkill { + name: parsed_skill.name.clone(), + content: parsed_skill.content.clone(), + path: Some(parsed_skill.path.to_string_lossy().to_string()), + }); + let request = ResolvePromptRequest { + skill, + attachments_dir: attachments_dir.clone(), + }; + let resolved = server_api + .resolve_prompt(request) + .await + .map_err(AgentDriverError::PromptResolutionFailed)?; + ( + Cow::Owned(resolved.prompt), + resolved.system_prompt, + resolved.resumption_prompt, + ) + } } }; diff --git a/app/src/ai/agent_sdk/driver/harness/mod.rs b/app/src/ai/agent_sdk/driver/harness/mod.rs index 511f80ae7a1..ba123b3cdc4 100644 --- a/app/src/ai/agent_sdk/driver/harness/mod.rs +++ b/app/src/ai/agent_sdk/driver/harness/mod.rs @@ -40,12 +40,14 @@ mod codex; pub(crate) mod codex_transcript; mod gemini; mod json_utils; +mod warp_ai; pub(crate) use claude_code::ClaudeHarness; use claude_transcript::ClaudeResumeInfo; use codex::CodexHarness; use codex_transcript::CodexResumeInfo; use gemini::GeminiHarness; +use warp_ai::WarpAiHarness; /// Harness-agnostic payload describing how to resume an existing conversation. /// @@ -138,6 +140,13 @@ pub(crate) trait ThirdPartyHarness: Send + Sync { validate_cli_installed(self.cli_agent().command_prefix(), self.install_docs_url()) } + /// Whether this harness requires server-side prompt resolution. + /// Self-contained harnesses that operate independently of Warp's backend + /// should return `false` to skip the `resolve_prompt()` server call. + fn requires_server_prompt_resolution(&self) -> bool { + true + } + /// Prepare CLI-specific config files before launching the harness command. fn prepare_environment_config( &self, @@ -229,6 +238,7 @@ pub(crate) fn harness_kind(harness: Harness) -> Result Ok(HarnessKind::Unsupported(Harness::OpenCode)), Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))), Harness::Codex => Ok(HarnessKind::ThirdParty(Box::new(CodexHarness))), + Harness::WarpAi => Ok(HarnessKind::ThirdParty(Box::new(WarpAiHarness))), Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState), } } diff --git a/app/src/ai/agent_sdk/driver/harness/warp_ai.rs b/app/src/ai/agent_sdk/driver/harness/warp_ai.rs new file mode 100644 index 00000000000..df3618039ad --- /dev/null +++ b/app/src/ai/agent_sdk/driver/harness/warp_ai.rs @@ -0,0 +1,226 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use parking_lot::Mutex; +use tempfile::NamedTempFile; +use warp_cli::agent::Harness; +use warp_managed_secrets::ManagedSecretValue; +use warpui::{ModelHandle, ModelSpawner}; + +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::server::server_api::ServerApi; +use crate::terminal::model::block::BlockId; +use crate::terminal::CLIAgent; + +use super::super::terminal::{CommandHandle, TerminalDriver}; +use super::super::{AgentDriver, AgentDriverError}; +use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness}; + +pub(crate) struct WarpAiHarness; + +const WARP_AI_CLI_NAME: &str = "warp-ai"; + +const DEFAULT_SYSTEM_PROMPT: &str = "\ +You are a system administration AI agent running inside a terminal. You execute tasks \ +on Mac and Linux machines by running shell commands, reading and writing files, and \ +inspecting system state. + +## Available Tools + +You have access to these tools: +- **run_command**: Execute any shell command via `sh -c`. You get stdout, stderr, and exit code. +- **read_file**: Read a file's contents. +- **write_file**: Write content to a file (creates parent dirs if needed). +- **list_directory**: List files with details (`ls -la`). + +## Guidelines + +1. **Execute, don't suggest.** When asked to do something, use the tools to actually do it. \ + Don't just print commands for the user to run. +2. **Verify your work.** After making changes, run commands to confirm the result \ + (e.g., check a service is running, verify a file was written correctly). +3. **Be safe.** Before destructive operations (rm, format, drop), confirm what will be affected. \ + Use dry-run flags when available. +4. **Handle errors.** If a command fails, read the error, diagnose the issue, and try to fix it. +5. **Know the OS.** Detect the OS early (`uname -a`, `sw_vers`) and use appropriate commands. \ + macOS and Linux differ in package managers, service management, and file locations. +6. **One step at a time.** For complex tasks, break them into steps. Run a command, check the \ + result, then proceed. +7. **Prefer idempotent commands.** Use `mkdir -p`, `install -d`, `>>` for append, etc. +8. **Current directory.** Assume you're in the user's current working directory unless they specify otherwise. + +## Common Tasks + +- Package management: apt, yum, brew, pacman +- Service management: systemctl, launchctl, service +- File management: create, edit, move, copy, permissions +- User management: useradd, usermod, groups +- Network: curl, wget, netstat, ss, ping, dig +- Process management: ps, top, kill, lsof +- Disk: df, du, mount, fdisk/parted +- Logs: journalctl, tail, grep in /var/log"; + +#[cfg_attr(not(target_family = "wasm"), async_trait)] +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +impl ThirdPartyHarness for WarpAiHarness { + fn harness(&self) -> Harness { + Harness::WarpAi + } + + fn cli_agent(&self) -> CLIAgent { + CLIAgent::WarpAi + } + + fn requires_server_prompt_resolution(&self) -> bool { + false + } + + fn validate(&self) -> Result<(), AgentDriverError> { + // Check for warp-ai next to the Warp binary first (bundled build) + if let Ok(exe_path) = std::env::current_exe() { + if let Some(dir) = exe_path.parent() { + let bundled = dir.join(WARP_AI_CLI_NAME); + if bundled.exists() { + return Ok(()); + } + } + } + // Fall back to PATH check + validate_cli_installed(self.cli_agent().command_prefix(), self.install_docs_url()) + } + + fn prepare_environment_config( + &self, + _working_dir: &Path, + _system_prompt: Option<&str>, + _secrets: &HashMap, + ) -> Result<(), AgentDriverError> { + // No special environment setup needed beyond the system prompt file + // which is handled in build_runner. + Ok(()) + } + + fn build_runner( + &self, + prompt: &str, + system_prompt: Option<&str>, + _resumption_prompt: Option<&str>, + _working_dir: &Path, + _task_id: Option, + _server_api: Arc, + terminal_driver: ModelHandle, + _resume: Option, + ) -> Result, AgentDriverError> { + Ok(Box::new(WarpAiHarnessRunner::new( + prompt, + system_prompt, + terminal_driver, + )?)) + } +} + +fn warp_ai_command(cli_name: &str, prompt_path: &str, system_prompt_path: &str) -> String { + format!( + "{cli_name} --prompt-file '{prompt_path}' --system-prompt-file '{system_prompt_path}'" + ) +} + +enum WarpAiRunnerState { + Preexec, + Running { block_id: BlockId }, +} + +struct WarpAiHarnessRunner { + command: String, + /// Held so temp files are cleaned up when the runner is dropped. + _temp_prompt_file: NamedTempFile, + _temp_system_prompt_file: NamedTempFile, + terminal_driver: ModelHandle, + state: Mutex, +} + +impl WarpAiHarnessRunner { + fn new( + prompt: &str, + system_prompt: Option<&str>, + terminal_driver: ModelHandle, + ) -> Result { + let temp_prompt_file = write_temp_file("warp_ai_prompt_", prompt)?; + + let resolved_system_prompt = system_prompt + .map(|s| s.to_owned()) + .or_else(|| load_custom_system_prompt()) + .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_owned()); + + let temp_system_prompt_file = + write_temp_file("warp_ai_sys_", &resolved_system_prompt)?; + + let prompt_path = temp_prompt_file.path().display().to_string(); + let sys_path = temp_system_prompt_file.path().display().to_string(); + + Ok(Self { + command: warp_ai_command(WARP_AI_CLI_NAME, &prompt_path, &sys_path), + _temp_prompt_file: temp_prompt_file, + _temp_system_prompt_file: temp_system_prompt_file, + terminal_driver, + state: Mutex::new(WarpAiRunnerState::Preexec), + }) + } +} + +/// Load a custom system prompt from `~/.config/warp-ai/system-prompt.txt` if it exists. +fn load_custom_system_prompt() -> Option { + dirs::home_dir() + .map(|p| p.join(".config/warp-ai/system-prompt.txt")) + .filter(|p| p.exists()) + .and_then(|p| std::fs::read_to_string(p).ok()) +} + +#[cfg_attr(not(target_family = "wasm"), async_trait)] +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +impl HarnessRunner for WarpAiHarnessRunner { + async fn start( + &self, + foreground: &ModelSpawner, + ) -> Result { + let command = self.command.clone(); + let terminal_driver = self.terminal_driver.clone(); + let command_handle = foreground + .spawn(move |_, ctx| { + terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx)) + }) + .await?? + .await?; + + *self.state.lock() = WarpAiRunnerState::Running { + block_id: command_handle.block_id().clone(), + }; + + Ok(command_handle) + } + + async fn exit(&self, foreground: &ModelSpawner) -> Result<()> { + log::info!("Sending Ctrl-C to warp-ai CLI"); + let terminal_driver = self.terminal_driver.clone(); + foreground + .spawn(move |_, ctx| { + terminal_driver.update(ctx, |driver, ctx| { + driver.send_text_to_cli("\x03".to_string(), ctx); + }); + }) + .await + .map_err(|_| anyhow::anyhow!("Agent driver dropped while sending exit")) + } + + async fn save_conversation( + &self, + _save_point: SavePoint, + _foreground: &ModelSpawner, + ) -> Result<()> { + // No server to save to — conversations are local only. + Ok(()) + } +} diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 54e693cbe02..2194d746833 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -1390,6 +1390,7 @@ fn resolve_orchestration_harness_label() -> &'static str { Some(Harness::OpenCode) => "opencode", Some(Harness::Gemini) => "gemini", Some(Harness::Codex) => "codex", + Some(Harness::WarpAi) => "warp-ai", Some(Harness::Unknown) | None => "unknown", } } diff --git a/app/src/ai/blocklist/history_model/conversation_loader.rs b/app/src/ai/blocklist/history_model/conversation_loader.rs index b029c88c977..123b5b86570 100644 --- a/app/src/ai/blocklist/history_model/conversation_loader.rs +++ b/app/src/ai/blocklist/history_model/conversation_loader.rs @@ -135,7 +135,7 @@ pub async fn load_conversation_from_server( } } } - AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini | AIAgentHarness::Codex => { + AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini | AIAgentHarness::Codex | AIAgentHarness::WarpAi => { if !FeatureFlag::AgentHarness.is_enabled() { log::warn!("Ignoring non-Oz conversation {conversation_id}: AgentHarness flag is disabled"); return None; diff --git a/app/src/ai/harness_display.rs b/app/src/ai/harness_display.rs index 043f3eebde5..f863cedba4f 100644 --- a/app/src/ai/harness_display.rs +++ b/app/src/ai/harness_display.rs @@ -20,6 +20,7 @@ pub fn display_name(harness: Harness) -> &'static str { Harness::OpenCode => "OpenCode", Harness::Gemini => "Gemini CLI", Harness::Codex => "Codex", + Harness::WarpAi => "Warp AI", Harness::Unknown => "Unknown", } } @@ -32,6 +33,7 @@ pub fn icon_for(harness: Harness) -> Icon { Harness::OpenCode => Icon::OpenCodeLogo, Harness::Gemini => Icon::GeminiLogo, Harness::Codex => Icon::OpenAILogo, + Harness::WarpAi => Icon::Warp, Harness::Unknown => Icon::HelpCircle, } } @@ -45,6 +47,7 @@ pub fn brand_color(harness: Harness) -> Option { Harness::OpenCode => None, Harness::Gemini => Some(GEMINI_BLUE), Harness::Codex => Some(OPENAI_COLOR), + Harness::WarpAi => None, Harness::Unknown => None, } } @@ -58,6 +61,7 @@ impl From for Harness { AIAgentHarness::ClaudeCode => Harness::Claude, AIAgentHarness::Gemini => Harness::Gemini, AIAgentHarness::Codex => Harness::Codex, + AIAgentHarness::WarpAi => Harness::WarpAi, AIAgentHarness::Unknown => Harness::Unknown, } } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 3cc4772ae5e..5a98ba455b9 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -35,7 +35,7 @@ use crate::quit_warning::UnsavedStateSummary; #[cfg(target_family = "wasm")] use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::server_api::ServerApiProvider; -use crate::settings::{AISettings, DefaultSessionMode, PaneSettings}; +use crate::settings::{AISettings, DefaultHarness, DefaultSessionMode, PaneSettings}; use crate::settings_view::SettingsSection; use crate::shell_indicator::ShellIndicatorType; use crate::terminal::available_shells::{AvailableShell, AvailableShells}; @@ -3764,6 +3764,7 @@ impl PaneGroup { AIAgentHarness::ClaudeCode => Some(Harness::Claude), AIAgentHarness::Gemini => Some(Harness::Gemini), AIAgentHarness::Codex => Some(Harness::Codex), + AIAgentHarness::WarpAi => Some(Harness::WarpAi), AIAgentHarness::Oz => None, AIAgentHarness::Unknown => Some(Harness::Unknown), }; @@ -5904,6 +5905,27 @@ impl PaneGroup { }); } + // Auto-launch default harness CLI agent if configured + let default_harness = AISettings::as_ref(ctx).default_harness(ctx); + if let Some(cli_agent) = default_harness.to_cli_agent() { + if conversation_restoration.is_none() + && matches!( + default_session_mode_behavior, + DefaultSessionModeBehavior::Apply + ) + { + let prefix = cli_agent.command_prefix().to_owned(); + view.update(ctx, |terminal_view, ctx| { + terminal_view.set_pending_command(&prefix, ctx); + terminal_view.enter_agent_view_for_new_conversation( + None, + AgentViewEntryOrigin::DefaultSessionMode, + ctx, + ); + }); + } + } + new_pane_id } diff --git a/app/src/pane_group/pane/local_harness_launch.rs b/app/src/pane_group/pane/local_harness_launch.rs index d1535e8c60b..cbc6daf7a79 100644 --- a/app/src/pane_group/pane/local_harness_launch.rs +++ b/app/src/pane_group/pane/local_harness_launch.rs @@ -59,7 +59,7 @@ pub(super) fn build_local_opencode_child_command(prompt: &str) -> String { fn local_child_task_config(harness: Harness) -> Option { match harness { - Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => { + Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::WarpAi | Harness::Unknown => { None } Harness::Claude => Some(AgentConfigSnapshot { @@ -127,6 +127,7 @@ pub(super) async fn prepare_local_harness_child_launch( build_local_opencode_child_command(&prompt) } Harness::Gemini => unreachable!("normalize_local_child_harness filters out Gemini"), + Harness::WarpAi => unreachable!("normalize_local_child_harness filters out WarpAi"), }; let task_id = ai_client diff --git a/app/src/server/telemetry/events.rs b/app/src/server/telemetry/events.rs index 92942f4c3a3..eab5e9e6032 100644 --- a/app/src/server/telemetry/events.rs +++ b/app/src/server/telemetry/events.rs @@ -500,6 +500,7 @@ pub enum CLIAgentType { Auggie, Cursor, Goose, + WarpAi, Unknown, } diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 749cb131c0b..526e3c6f0e0 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -14,6 +14,7 @@ use crate::report_if_error; use crate::terminal::CLIAgent; use crate::workspaces::user_workspaces::UserWorkspaces; use cfg_if::cfg_if; +use warp_cli::agent::Harness; use chrono::{DateTime, Utc}; use lazy_static::lazy_static; use regex::Regex; @@ -330,6 +331,65 @@ impl DefaultSessionMode { } } +/// The default AI harness to use for agent sessions. +/// When set to something other than `None`, new sessions auto-launch that harness. +#[derive( + Default, + Debug, + serde::Serialize, + serde::Deserialize, + PartialEq, + Copy, + Clone, + EnumIter, + schemars::JsonSchema, + settings_value::SettingsValue, +)] +#[schemars( + description = "Default AI harness for agent sessions.", + rename_all = "snake_case" +)] +pub enum DefaultHarness { + /// No default harness — use Warp's built-in agent. + #[default] + None, + /// Use the self-contained Warp AI harness. + WarpAi, +} + +settings::macros::implement_setting_for_enum!( + DefaultHarness, + AISettings, + SupportedPlatforms::ALL, + SyncToCloud::Globally(RespectUserSyncSetting::Yes), + private: false, + toml_path: "general.default_harness", + description: "The default harness to use for agent sessions.", +); + +impl DefaultHarness { + /// Display name for the settings dropdown. + pub fn display_name(&self) -> &'static str { + match self { + DefaultHarness::None => "None", + DefaultHarness::WarpAi => "Warp AI", + } + } + + /// Convert to a `Harness` variant, if one is selected. + pub fn to_harness(&self) -> Option { + match self { + DefaultHarness::None => None, + DefaultHarness::WarpAi => Some(Harness::WarpAi), + } + } + + /// Convert to a `CLIAgent`, if a harness is selected. + pub fn to_cli_agent(&self) -> Option { + self.to_harness().and_then(CLIAgent::from_harness) + } +} + /// Controls how agent thinking/reasoning traces are displayed after streaming. #[derive( Default, @@ -1333,6 +1393,9 @@ define_settings_group!(AISettings, settings: [ // effective value, which is gated on AI availability. default_session_mode_internal: DefaultSessionMode, + // The default AI harness to use for agent sessions. + default_harness_internal: DefaultHarness, + // The file path of the tab config used when default_session_mode_internal is TabConfig. // Only read when mode is TabConfig; ignored for all other modes. // Machine-local (tab config paths vary per machine), so never synced to cloud. @@ -1532,6 +1595,16 @@ impl AISettings { } } + /// Returns the effective default harness, gated on AI being enabled. + pub fn default_harness(&self, app: &AppContext) -> DefaultHarness { + let harness = *self.default_harness_internal.value(); + if self.is_any_ai_enabled(app) { + harness + } else { + DefaultHarness::None + } + } + /// Returns the stored default tab config path (only meaningful when mode is `TabConfig`). pub fn default_tab_config_path(&self) -> &str { &self.default_tab_config_path diff --git a/app/src/settings_view/features_page.rs b/app/src/settings_view/features_page.rs index 884d02a9e0f..156f0308ef5 100644 --- a/app/src/settings_view/features_page.rs +++ b/app/src/settings_view/features_page.rs @@ -47,7 +47,7 @@ use crate::settings::{ AliasExpansionEnabled, AliasExpansionSettings, AppEditorSettings, AtContextMenuInTerminalMode, AutocompleteSymbols, AutosuggestionKeybindingHint, ChangelogSettings, CloudPreferencesSettings, CodeSettings, CommandCorrections, CompletionsOpenWhileTyping, CopyOnSelect, CtrlTabBehavior, - DefaultSessionMode, EnableSlashCommandsInTerminal, EnableSshWrapper, ErrorUnderliningEnabled, + DefaultHarness, DefaultSessionMode, EnableSlashCommandsInTerminal, EnableSshWrapper, ErrorUnderliningEnabled, ExtraMetaKeys, GPUSettings, GlobalHotkeyMode, InputSettings, InputSettingsChangedEvent, LinuxSelectionClipboard, MiddleClickPasteEnabled, MouseScrollMultiplier, OutlineCodebaseSymbolsForAtContextMenu, PreferLowPowerGPU, PreferredGraphicsBackend, @@ -629,6 +629,7 @@ pub enum FeaturesPageAction { SetPreferredGraphicsBackend(Option), SetNewTabPlacement(NewTabPlacement), SetDefaultSessionMode(DefaultSessionMode), + SetDefaultHarness(DefaultHarness), SetDefaultTabConfig(String), SearchForKeybinding(String), ToggleAutosuggestions, @@ -1038,6 +1039,10 @@ impl FeaturesPageAction { action: "SetDefaultSessionMode".to_string(), value: format!("{mode:?}"), }, + Self::SetDefaultHarness(harness) => TelemetryEvent::FeaturesPageAction { + action: "SetDefaultHarness".to_string(), + value: format!("{harness:?}"), + }, Self::SetDefaultTabConfig(path) => TelemetryEvent::FeaturesPageAction { action: "SetDefaultTabConfig".to_string(), value: path.clone(), @@ -1234,6 +1239,7 @@ pub struct FeaturesPageView { graphics_backend_dropdown: ViewHandle>, new_tab_placement_dropdown: ViewHandle>, default_session_mode_dropdown: ViewHandle>, + default_harness_dropdown: ViewHandle>, tab_behavior: Tracked, completions_keystroke: Tracked, autosuggestions_keystroke: Tracked, @@ -1741,6 +1747,7 @@ impl TypedActionView for FeaturesPageView { self.set_new_tab_placement(new_tab_placement, ctx) } SetDefaultSessionMode(mode) => self.set_default_session_mode(mode, ctx), + SetDefaultHarness(harness) => self.set_default_harness(harness, ctx), SetDefaultTabConfig(path) => { AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { report_if_error!(ai_settings @@ -2075,6 +2082,17 @@ impl FeaturesPageView { ); ctx.notify(); } + if matches!( + event, + AISettingsChangedEvent::IsAnyAIEnabled { .. } + | AISettingsChangedEvent::DefaultHarnessInternal { .. } + ) { + Self::update_default_harness_dropdown( + me.default_harness_dropdown.clone(), + ctx, + ); + ctx.notify(); + } }); let pin_position_dropdown = ctx.add_typed_action_view(|ctx| { @@ -2144,6 +2162,9 @@ impl FeaturesPageView { let default_session_mode_dropdown = ctx.add_typed_action_view(FilterableDropdown::new); Self::update_default_session_mode_dropdown(default_session_mode_dropdown.clone(), ctx); + let default_harness_dropdown = ctx.add_typed_action_view(FilterableDropdown::new); + Self::update_default_harness_dropdown(default_harness_dropdown.clone(), ctx); + ctx.subscribe_to_model(&WarpConfig::handle(ctx), |me, _, event, ctx| { if matches!(event, WarpConfigUpdateEvent::TabConfigs) { Self::update_default_session_mode_dropdown( @@ -2411,6 +2432,7 @@ impl FeaturesPageView { graphics_backend_dropdown, new_tab_placement_dropdown, default_session_mode_dropdown, + default_harness_dropdown, tab_behavior: Default::default(), window_id: ctx.window_id(), @@ -2432,7 +2454,10 @@ impl FeaturesPageView { fn build_page(ctx: &mut ViewContext) -> PageType { let mut general_widgets: Vec>> = - vec![Box::new(DefaultSessionModeWidget::default())]; + vec![ + Box::new(DefaultSessionModeWidget::default()), + Box::new(DefaultHarnessWidget::default()), + ]; let native_preference_settings = NativePreferenceSettings::as_ref(ctx); if native_preference_settings @@ -3374,6 +3399,38 @@ impl FeaturesPageView { ); } + fn update_default_harness_dropdown( + dropdown: ViewHandle>, + ctx: &mut ViewContext, + ) { + dropdown.update( + ctx, + |dropdown: &mut FilterableDropdown, ctx| { + let is_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx); + + if is_ai_enabled { + dropdown.set_enabled(ctx); + } else { + dropdown.set_disabled(ctx); + } + + let current = AISettings::as_ref(ctx).default_harness(ctx); + + let items: Vec> = DefaultHarness::iter() + .map(|val| { + DropdownItem::new( + val.display_name(), + FeaturesPageAction::SetDefaultHarness(val), + ) + }) + .collect(); + + dropdown.set_items(items, ctx); + dropdown.set_selected_by_name(current.display_name(), ctx); + }, + ); + } + fn set_default_session_mode( &mut self, value: &DefaultSessionMode, @@ -3386,6 +3443,18 @@ impl FeaturesPageView { }); } + fn set_default_harness( + &mut self, + value: &DefaultHarness, + ctx: &mut ViewContext, + ) { + AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { + report_if_error!(ai_settings + .default_harness_internal + .set_value(*value, ctx)); + }); + } + /// This function renders the component that allows the user to record a keybinding for the /// global hotkey. The default is to display the current keybinding, called the "summary". /// Once clicked, it tells the view to disable keybinding dispatching in order to record a @@ -6933,6 +7002,55 @@ impl SettingsWidget for DefaultSessionModeWidget { } } +#[derive(Default)] +struct DefaultHarnessWidget {} + +impl SettingsWidget for DefaultHarnessWidget { + type View = FeaturesPageView; + + fn search_terms(&self) -> &str { + "default harness agent warp ai custom provider" + } + + fn render( + &self, + view: &Self::View, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let label = render_dropdown_item_label( + "Default AI harness".to_string(), + None, + LocalOnlyIconState::for_setting( + DefaultHarness::storage_key(), + DefaultHarness::sync_to_cloud(), + &mut view + .button_mouse_states + .local_only_icon_tooltip_states + .borrow_mut(), + app, + ), + None, + appearance, + ); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Shrinkable::new( + 1.0, + Container::new(Align::new(label).left().finish()) + .with_margin_bottom(4.) + .with_padding_right(16.) + .finish(), + ) + .finish(), + ) + .with_child(ChildView::new(&view.default_harness_dropdown).finish()) + .finish() + } +} + #[derive(Default)] struct WorkflowsInCommandSearch { additional_info_link: MouseStateHandle, diff --git a/app/src/terminal/cli_agent.rs b/app/src/terminal/cli_agent.rs index 2126f4457b5..3b1f6a223d3 100644 --- a/app/src/terminal/cli_agent.rs +++ b/app/src/terminal/cli_agent.rs @@ -126,6 +126,7 @@ pub enum CLIAgent { Auggie, CursorCli, Goose, + WarpAi, /// Represents an unknown/custom CLI agent matched by user-configured regex patterns. Unknown, } @@ -145,6 +146,7 @@ impl CLIAgent { CLIAgent::Auggie => "auggie", CLIAgent::CursorCli => "agent", CLIAgent::Goose => "goose", + CLIAgent::WarpAi => "warp-ai", CLIAgent::Unknown => "", } } @@ -173,6 +175,7 @@ impl CLIAgent { Harness::Gemini => Some(CLIAgent::Gemini), Harness::OpenCode => Some(CLIAgent::OpenCode), Harness::Codex => Some(CLIAgent::Codex), + Harness::WarpAi => Some(CLIAgent::WarpAi), Harness::Unknown => Some(CLIAgent::Unknown), } } @@ -190,6 +193,7 @@ impl CLIAgent { CLIAgent::Auggie => "Auggie", CLIAgent::CursorCli => "Cursor", CLIAgent::Goose => "Goose", + CLIAgent::WarpAi => "Warp AI", CLIAgent::Unknown => "CLI Agent", } } @@ -208,6 +212,7 @@ impl CLIAgent { CLIAgent::Auggie => Some(Icon::AuggieLogo), CLIAgent::CursorCli => Some(Icon::CursorLogo), CLIAgent::Goose => Some(Icon::GooseLogo), + CLIAgent::WarpAi => None, CLIAgent::Unknown => None, } } @@ -236,6 +241,7 @@ impl CLIAgent { CLIAgent::Auggie => &[SkillProvider::Agents], CLIAgent::CursorCli => &[SkillProvider::Agents], CLIAgent::Goose => &[SkillProvider::Agents], + CLIAgent::WarpAi => &[], CLIAgent::Unknown => &[], } } @@ -276,6 +282,7 @@ impl CLIAgent { CLIAgent::Auggie => Some(AUGGIE_COLOR), CLIAgent::CursorCli => Some(CURSOR_COLOR), CLIAgent::Goose => Some(GOOSE_COLOR), + CLIAgent::WarpAi => None, CLIAgent::Unknown => None, } } @@ -537,6 +544,7 @@ impl From for CLIAgentType { CLIAgent::Auggie => CLIAgentType::Auggie, CLIAgent::CursorCli => CLIAgentType::Cursor, CLIAgent::Goose => CLIAgentType::Goose, + CLIAgent::WarpAi => CLIAgentType::WarpAi, CLIAgent::Unknown => CLIAgentType::Unknown, } } diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index 219d4ff88c4..b437dae69ad 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -69,6 +69,7 @@ fn create_handler(agent: &CLIAgent) -> Option> { | CLIAgent::Copilot | CLIAgent::CursorCli | CLIAgent::Goose + | CLIAgent::WarpAi | CLIAgent::Unknown => None, } } diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs index e6692008d75..35db9ec18dc 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs @@ -264,6 +264,7 @@ pub(crate) fn plugin_manager_for_with_shell( | CLIAgent::Auggie | CLIAgent::CursorCli | CLIAgent::Goose + | CLIAgent::WarpAi | CLIAgent::Unknown => None, } } diff --git a/app/src/terminal/view/ambient_agent/harness_selector.rs b/app/src/terminal/view/ambient_agent/harness_selector.rs index 52f09eff4ea..18791dfbd88 100644 --- a/app/src/terminal/view/ambient_agent/harness_selector.rs +++ b/app/src/terminal/view/ambient_agent/harness_selector.rs @@ -252,6 +252,7 @@ fn build_menu_items( item_for(Harness::Claude), item_for(Harness::Gemini), item_for(Harness::Codex), + item_for(Harness::WarpAi), ] } diff --git a/app/src/terminal/view/ambient_agent/view_impl.rs b/app/src/terminal/view/ambient_agent/view_impl.rs index 183bc7bffa2..b441ff08e79 100644 --- a/app/src/terminal/view/ambient_agent/view_impl.rs +++ b/app/src/terminal/view/ambient_agent/view_impl.rs @@ -521,6 +521,7 @@ impl TerminalView { Harness::OpenCode => matches!(cli_agent, CLIAgent::OpenCode), Harness::Gemini => matches!(cli_agent, CLIAgent::Gemini), Harness::Codex => matches!(cli_agent, CLIAgent::Codex), + Harness::WarpAi => matches!(cli_agent, CLIAgent::WarpAi), Harness::Unknown => false, } } diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index 371e3bcfad3..72356114ddb 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -127,7 +127,7 @@ fn rich_input_submit_strategy(agent: CLIAgent) -> RichInputSubmitStrategy { | CLIAgent::Gemini | CLIAgent::Auggie | CLIAgent::CursorCli => RichInputSubmitStrategy::DelayedEnter, - CLIAgent::Amp | CLIAgent::Droid | CLIAgent::Pi | CLIAgent::Goose | CLIAgent::Unknown => { + CLIAgent::Amp | CLIAgent::Droid | CLIAgent::Pi | CLIAgent::Goose | CLIAgent::WarpAi | CLIAgent::Unknown => { RichInputSubmitStrategy::Inline } } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index d76dadaff61..f079222ee10 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -173,7 +173,7 @@ use crate::server::server_api::ai::AIClient; use crate::server::server_api::auth::AuthClient; use crate::settings::{ AISettings, AISettingsChangedEvent, CodeSettings, CodeSettingsChangedEvent, CtrlTabBehavior, - DefaultSessionMode, InputModeSettings, + DefaultHarness, DefaultSessionMode, InputModeSettings, }; use crate::settings_view::environments_page::EnvironmentsPage; use crate::settings_view::pane_manager::SettingsPaneManager; @@ -10775,6 +10775,25 @@ impl Workspace { /// /// Used after adding a new tab when the session mode should default to agent view. fn enter_agent_view_on_active_tab(&self, ctx: &mut ViewContext) { + // Check if a default harness CLI agent should be auto-launched + let default_harness = AISettings::as_ref(ctx).default_harness(ctx); + if let Some(cli_agent) = default_harness.to_cli_agent() { + let prefix = cli_agent.command_prefix().to_owned(); + self.active_tab_pane_group().update(ctx, |pane_group, ctx| { + if let Some(terminal_view) = pane_group.active_session_view(ctx) { + terminal_view.update(ctx, |view, ctx| { + view.set_pending_command(&prefix, ctx); + view.enter_agent_view_for_new_conversation( + None, + AgentViewEntryOrigin::DefaultSessionMode, + ctx, + ); + }); + } + }); + return; + } + self.active_tab_pane_group().update(ctx, |pane_group, ctx| { if let Some(terminal_view) = pane_group.active_session_view(ctx) { terminal_view.update(ctx, |view, ctx| { diff --git a/crates/warp_ai_cli/Cargo.toml b/crates/warp_ai_cli/Cargo.toml new file mode 100644 index 00000000000..17080c4b8c3 --- /dev/null +++ b/crates/warp_ai_cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "warp_ai_cli" +version = "0.1.0" +edition = "2021" +description = "A thin CLI that calls any OpenAI-compatible API for use as a Warp agent harness" + +[[bin]] +name = "warp-ai" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +dirs = { workspace = true } +futures-util = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true, features = ["stream"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/crates/warp_ai_cli/src/client.rs b/crates/warp_ai_cli/src/client.rs new file mode 100644 index 00000000000..1a50decb7ff --- /dev/null +++ b/crates/warp_ai_cli/src/client.rs @@ -0,0 +1,239 @@ +use anyhow::{Context, Result}; +use futures_util::StreamExt; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::io::Write; + +use crate::config::Config; +use crate::tools::ToolCall; + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + stream: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +impl ChatMessage { + pub fn system(content: &str) -> Self { + Self { + role: "system".to_owned(), + content: Some(content.to_owned()), + tool_calls: None, + tool_call_id: None, + } + } + + pub fn user(content: &str) -> Self { + Self { + role: "user".to_owned(), + content: Some(content.to_owned()), + tool_calls: None, + tool_call_id: None, + } + } + + pub fn assistant(content: Option, tool_calls: Option>) -> Self { + Self { + role: "assistant".to_owned(), + content, + tool_calls, + tool_call_id: None, + } + } + + pub fn tool_result(tool_call_id: String, content: String) -> Self { + Self { + role: "tool".to_owned(), + content: Some(content), + tool_calls: None, + tool_call_id: Some(tool_call_id), + } + } +} + +/// The result of a single turn in the agent loop. +pub enum TurnResult { + /// The LLM produced final text output for the user. + Done(String), + /// The LLM wants to call tools. + ToolCalls { text: String, calls: Vec }, +} + +/// Send a chat request with optional tools, stream the response, and return +/// either final text or parsed tool calls. +pub async fn chat_turn( + config: &Config, + messages: Vec, + include_tools: bool, +) -> Result { + let api_key = config + .api_key + .as_ref() + .context("No API key configured. Set WARP_AI_API_KEY or add api_key to ~/.config/warp-ai/config.json")?; + + let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); + + let tools = if include_tools { + Some(crate::tools::tool_definitions()) + } else { + None + }; + + let request = ChatRequest { + model: config.model.clone(), + messages, + tools, + stream: true, + }; + + let client = Client::new(); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .with_context(|| format!("Failed to connect to {}", config.base_url))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("API request failed ({status}): {body}"); + } + + // Parse the SSE stream, collecting text deltas and tool call chunks. + let mut text_content = String::new(); + let mut tool_call_map: std::collections::HashMap = + std::collections::HashMap::new(); + let mut buffer = String::new(); + let mut stream = response.bytes_stream(); + let mut stdout = std::io::stdout(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Error reading stream")?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim().to_owned(); + buffer = buffer[line_end + 1..].to_owned(); + + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + // Stream finished — assemble results. + if tool_call_map.is_empty() { + stdout.write_all(b"\n")?; + stdout.flush()?; + return Ok(TurnResult::Done(text_content)); + } else { + let calls: Vec = tool_call_map + .into_iter() + .map(|(_, partial)| ToolCall { + id: partial.id, + name: partial.name, + arguments: partial.arguments, + }) + .collect(); + return Ok(TurnResult::ToolCalls { + text: text_content, + calls, + }); + } + } + + if let Ok(parsed) = serde_json::from_str::(data) { + if let Some(choices) = parsed.get("choices").and_then(|c| c.as_array()) { + for choice in choices { + if let Some(delta) = choice.get("delta") { + // Text content + if let Some(content) = + delta.get("content").and_then(|c| c.as_str()) + { + text_content.push_str(content); + stdout.write_all(content.as_bytes())?; + stdout.flush()?; + } + + // Tool calls + if let Some(tool_calls) = + delta.get("tool_calls").and_then(|tc| tc.as_array()) + { + for tc in tool_calls { + let index = tc + .get("index") + .and_then(|i| i.as_u64()) + .unwrap_or(0) as usize; + + let entry = + tool_call_map.entry(index).or_default(); + + if let Some(id) = + tc.get("id").and_then(|i| i.as_str()) + { + entry.id = id.to_owned(); + } + if let Some(func) = tc.get("function") { + if let Some(name) = + func.get("name").and_then(|n| n.as_str()) + { + entry.name = name.to_owned(); + } + if let Some(args) = func + .get("arguments") + .and_then(|a| a.as_str()) + { + entry.arguments.push_str(args); + } + } + } + } + } + } + } + } + } + } + } + + // Stream ended without [DONE] — return what we have. + if tool_call_map.is_empty() { + stdout.write_all(b"\n")?; + stdout.flush()?; + Ok(TurnResult::Done(text_content)) + } else { + let calls: Vec = tool_call_map + .into_iter() + .map(|(_, partial)| ToolCall { + id: partial.id, + name: partial.name, + arguments: partial.arguments, + }) + .collect(); + Ok(TurnResult::ToolCalls { + text: text_content, + calls, + }) + } +} + +/// Partial tool call being assembled from streaming chunks. +#[derive(Default)] +struct PartialToolCall { + id: String, + name: String, + arguments: String, +} diff --git a/crates/warp_ai_cli/src/config.rs b/crates/warp_ai_cli/src/config.rs new file mode 100644 index 00000000000..2b8f31efeec --- /dev/null +++ b/crates/warp_ai_cli/src/config.rs @@ -0,0 +1,75 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::PathBuf; + +const API_KEY_ENV: &str = "WARP_AI_API_KEY"; +const BASE_URL_ENV: &str = "WARP_AI_BASE_URL"; +const MODEL_ENV: &str = "WARP_AI_MODEL"; + +const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +const DEFAULT_MODEL: &str = "gpt-4o"; + +#[derive(Debug, Deserialize)] +struct ConfigFile { + api_key: Option, + base_url: Option, + model: Option, +} + +pub struct Config { + pub api_key: Option, + pub base_url: String, + pub model: String, +} + +impl Config { + pub fn resolve( + cli_api_key: Option<&str>, + cli_base_url: Option<&str>, + cli_model: Option<&str>, + ) -> Result { + let file = load_config_file().unwrap_or_else(|e| { + log::debug!("No config file loaded: {e}"); + None + }); + + let api_key = cli_api_key + .map(|s| s.to_owned()) + .or_else(|| std::env::var(API_KEY_ENV).ok()) + .or_else(|| file.as_ref().and_then(|f| f.api_key.clone())); + + let base_url = cli_base_url + .map(|s| s.to_owned()) + .or_else(|| std::env::var(BASE_URL_ENV).ok()) + .or_else(|| file.as_ref().and_then(|f| f.base_url.clone())) + .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned()); + + let model = cli_model + .map(|s| s.to_owned()) + .or_else(|| std::env::var(MODEL_ENV).ok()) + .or_else(|| file.as_ref().and_then(|f| f.model.clone())) + .unwrap_or_else(|| DEFAULT_MODEL.to_owned()); + + Ok(Config { + api_key, + base_url, + model, + }) + } +} + +fn config_file_path() -> Option { + dirs::home_dir().map(|p| p.join(".config/warp-ai/config.json")) +} + +fn load_config_file() -> Result> { + let path = config_file_path().context("could not determine config file path")?; + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let config: ConfigFile = + serde_json::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?; + Ok(Some(config)) +} diff --git a/crates/warp_ai_cli/src/main.rs b/crates/warp_ai_cli/src/main.rs new file mode 100644 index 00000000000..e28c22902d7 --- /dev/null +++ b/crates/warp_ai_cli/src/main.rs @@ -0,0 +1,145 @@ +use anyhow::{Context, Result}; +use clap::Parser; + +mod client; +mod config; +mod tools; + +use client::{ChatMessage, TurnResult}; + +#[derive(Parser)] +#[command(name = "warp-ai", about = "AI sysadmin agent that executes tasks via OpenAI-compatible APIs")] +struct Args { + /// Path to file containing the user prompt + #[arg(long = "prompt-file")] + prompt_file: String, + + /// Path to file containing the system prompt + #[arg(long = "system-prompt-file")] + system_prompt_file: String, + + /// Model to use (overrides config/env) + #[arg(long = "model")] + model: Option, + + /// API base URL (overrides config/env) + #[arg(long = "base-url")] + base_url: Option, + + /// API key (overrides config/env) + #[arg(long = "api-key")] + api_key: Option, + + /// Maximum number of agent loop iterations (default: 50) + #[arg(long = "max-turns", default_value = "50")] + max_turns: usize, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let cfg = config::Config::resolve( + args.api_key.as_deref(), + args.base_url.as_deref(), + args.model.as_deref(), + )?; + + let prompt = std::fs::read_to_string(&args.prompt_file) + .with_context(|| format!("Failed to read prompt file: {}", args.prompt_file))?; + + let system_prompt = std::fs::read_to_string(&args.system_prompt_file) + .with_context(|| format!("Failed to read system prompt file: {}", args.system_prompt_file))?; + + let mut messages = vec![ChatMessage::system(&system_prompt), ChatMessage::user(&prompt)]; + + for _ in 0..args.max_turns { + let result = client::chat_turn(&cfg, messages.clone(), true).await?; + + match result { + TurnResult::Done(_text) => { + // Final answer streamed to stdout already. + return Ok(()); + } + TurnResult::ToolCalls { text, calls } => { + // Add the assistant message with tool calls to history. + let tool_calls_json: Vec = calls + .iter() + .map(|c| { + serde_json::json!({ + "id": c.id, + "type": "function", + "function": { + "name": c.name, + "arguments": c.arguments + } + }) + }) + .collect(); + + messages.push(ChatMessage::assistant( + if text.is_empty() { None } else { Some(text) }, + Some(tool_calls_json), + )); + + // Execute each tool and add results. + for call in &calls { + eprintln!("[{}] {}", call.name, summarize_args(&call.name, &call.arguments)); + let result = tools::execute_tool(call); + eprintln!( + "[{}] {}", + if result.content.starts_with("Error:") { "FAIL" } else { "OK" }, + truncate(&result.content, 200) + ); + messages.push(ChatMessage::tool_result(result.tool_call_id, result.content)); + } + } + } + } + + // Hit max turns — ask for a final summary without tools. + messages.push(ChatMessage::user( + "Please provide a summary of what was done. Do not call any more tools.", + )); + let _ = client::chat_turn(&cfg, messages, false).await?; + Ok(()) +} + +/// Brief summary of tool call arguments for display. +fn summarize_args(name: &str, arguments: &str) -> String { + match name { + "run_command" => { + if let Ok(v) = serde_json::from_str::(arguments) { + v.get("command") + .and_then(|c| c.as_str()) + .map(|s| truncate(s, 120).to_owned()) + .unwrap_or_else(|| arguments.to_owned()) + } else { + truncate(arguments, 120).to_owned() + } + } + "read_file" => extract_path(arguments).unwrap_or_else(|| arguments.to_owned()), + "write_file" => extract_path(arguments).unwrap_or_else(|| arguments.to_owned()), + "list_directory" => extract_path(arguments).unwrap_or_else(|| ".".to_owned()), + _ => truncate(arguments, 120).to_owned(), + } +} + +fn extract_path(arguments: &str) -> Option { + serde_json::from_str::(arguments) + .ok() + .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(|s| s.to_owned())) +} + +fn truncate(s: &str, max: usize) -> &str { + if s.len() <= max { + s + } else { + // Find a valid char boundary to avoid panicking on multi-byte UTF-8. + let mut end = max; + while !s.is_char_boundary(end) && end > 0 { + end -= 1; + } + &s[..end] + } +} diff --git a/crates/warp_ai_cli/src/tools.rs b/crates/warp_ai_cli/src/tools.rs new file mode 100644 index 00000000000..d33c240beed --- /dev/null +++ b/crates/warp_ai_cli/src/tools.rs @@ -0,0 +1,268 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::process::Command; + +/// Tool definitions sent to the LLM as available functions. +pub fn tool_definitions() -> Vec { + vec![ + serde_json::json!({ + "type": "function", + "function": { + "name": "run_command", + "description": "Execute a shell command and return its stdout, stderr, and exit code. Use this for any system administration task: installing packages, managing services, checking system state, running scripts, etc.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute" + } + }, + "required": ["command"] + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "read_file", + "description": "Read the contents of a file. Returns the file content as a string.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative path to the file" + } + }, + "required": ["path"] + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories if needed.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative path to the file" + }, + "content": { + "type": "string", + "description": "The content to write" + } + }, + "required": ["path", "content"] + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "list_directory", + "description": "List files and directories at a given path. Returns names with file type indicators.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative path to the directory. Defaults to current directory." + } + } + } + } + }), + ] +} + +/// A tool call from the LLM. +#[derive(Debug, Clone)] +pub struct ToolCall { + pub id: String, + pub name: String, + pub arguments: String, +} + +/// Result of executing a tool. +#[derive(Debug)] +pub struct ToolResult { + pub tool_call_id: String, + pub content: String, +} + +impl ToolResult { + fn ok(id: String, content: String) -> Self { + Self { + tool_call_id: id, + content, + } + } + + fn err(id: String, msg: String) -> Self { + Self { + tool_call_id: id, + content: format!("Error: {msg}"), + } + } +} + +/// Execute a tool call and return its result. +pub fn execute_tool(call: &ToolCall) -> ToolResult { + match call.name.as_str() { + "run_command" => execute_run_command(call), + "read_file" => execute_read_file(call), + "write_file" => execute_write_file(call), + "list_directory" => execute_list_directory(call), + _ => ToolResult::err( + call.id.clone(), + format!("Unknown tool: {}", call.name), + ), + } +} + +#[derive(Deserialize)] +struct RunCommandArgs { + command: String, +} + +fn execute_run_command(call: &ToolCall) -> ToolResult { + let args: RunCommandArgs = match serde_json::from_str(&call.arguments) { + Ok(a) => a, + Err(e) => return ToolResult::err(call.id.clone(), format!("Invalid arguments: {e}")), + }; + + let output = match Command::new("sh") + .arg("-c") + .arg(&args.command) + .output() + { + Ok(o) => o, + Err(e) => return ToolResult::err(call.id.clone(), format!("Failed to execute: {e}")), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let exit_code = output.status.code().unwrap_or(-1); + + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("[stderr]\n"); + result.push_str(&stderr); + } + if exit_code != 0 { + result.push_str(&format!("\n[exit code: {exit_code}]")); + } + + ToolResult::ok(call.id.clone(), result) +} + +#[derive(Deserialize)] +struct ReadFileArgs { + path: String, +} + +fn execute_read_file(call: &ToolCall) -> ToolResult { + let args: ReadFileArgs = match serde_json::from_str(&call.arguments) { + Ok(a) => a, + Err(e) => return ToolResult::err(call.id.clone(), format!("Invalid arguments: {e}")), + }; + + match std::fs::read_to_string(&args.path) { + Ok(content) => ToolResult::ok(call.id.clone(), content), + Err(e) => ToolResult::err( + call.id.clone(), + format!("Failed to read {}: {e}", args.path), + ), + } +} + +#[derive(Deserialize)] +struct WriteFileArgs { + path: String, + content: String, +} + +fn execute_write_file(call: &ToolCall) -> ToolResult { + let args: WriteFileArgs = match serde_json::from_str(&call.arguments) { + Ok(a) => a, + Err(e) => return ToolResult::err(call.id.clone(), format!("Invalid arguments: {e}")), + }; + + // Create parent directories if needed + if let Some(parent) = std::path::Path::new(&args.path).parent() { + if !parent.as_os_str().is_empty() { + if let Err(e) = std::fs::create_dir_all(parent) { + return ToolResult::err( + call.id.clone(), + format!("Failed to create directories: {e}"), + ); + } + } + } + + match std::fs::write(&args.path, &args.content) { + Ok(()) => ToolResult::ok(call.id.clone(), format!("Wrote {} bytes to {}", args.content.len(), args.path)), + Err(e) => ToolResult::err( + call.id.clone(), + format!("Failed to write {}: {e}", args.path), + ), + } +} + +#[derive(Deserialize)] +struct ListDirectoryArgs { + path: Option, +} + +fn execute_list_directory(call: &ToolCall) -> ToolResult { + let args: ListDirectoryArgs = match serde_json::from_str(&call.arguments) { + Ok(a) => a, + Err(e) => return ToolResult::err(call.id.clone(), format!("Invalid arguments: {e}")), + }; + + let path = args.path.as_deref().unwrap_or("."); + let output = match Command::new("ls") + .arg("-la") + .arg(path) + .output() + { + Ok(o) => o, + Err(e) => return ToolResult::err(call.id.clone(), format!("Failed to list directory: {e}")), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let exit_code = output.status.code().unwrap_or(-1); + + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("[stderr]\n"); + result.push_str(&stderr); + } + if exit_code != 0 { + result.push_str(&format!("\n[exit code: {exit_code}]")); + } + + if exit_code != 0 { + ToolResult::err(call.id.clone(), result) + } else { + ToolResult::ok(call.id.clone(), result) + } +} diff --git a/crates/warp_cli/src/agent.rs b/crates/warp_cli/src/agent.rs index 999e5837f4c..3b4b3cdf60f 100644 --- a/crates/warp_cli/src/agent.rs +++ b/crates/warp_cli/src/agent.rs @@ -137,6 +137,9 @@ pub enum Harness { /// Delegate to the `codex` CLI. #[value(name = "codex")] Codex, + /// Delegate to the `warp-ai` CLI (self-contained OpenAI-compatible harness). + #[value(name = "warp-ai")] + WarpAi, /// A harness produced by a newer client/server that this client doesn't /// recognize. Surfaced via deserialization fallbacks (e.g. unknown GraphQL /// enum values, unknown `harness_type` strings); never selectable from the @@ -154,7 +157,7 @@ impl Harness { pub fn parse_local_child_harness(value: &str) -> Option { match Self::parse_orchestration_harness(value) { Some(harness @ (Self::Claude | Self::OpenCode)) => Some(harness), - Some(Self::Oz) | Some(Self::Gemini) | Some(Self::Codex) | Some(Self::Unknown) + Some(Self::Oz) | Some(Self::Gemini) | Some(Self::Codex) | Some(Self::WarpAi) | Some(Self::Unknown) | None => None, } } @@ -166,6 +169,7 @@ impl Harness { Self::OpenCode => "OpenCode", Self::Gemini => "Gemini CLI", Self::Codex => "Codex", + Self::WarpAi => "Warp AI", Self::Unknown => "Unknown", } } @@ -184,6 +188,7 @@ impl Harness { "opencode" => Some(Harness::OpenCode), "gemini" => Some(Harness::Gemini), "codex" => Some(Harness::Codex), + "warp-ai" => Some(Harness::WarpAi), "unknown" => Some(Harness::Unknown), _ => None, } @@ -201,6 +206,7 @@ impl Harness { Harness::OpenCode => "opencode", Harness::Gemini => "gemini", Harness::Codex => "codex", + Harness::WarpAi => "warp-ai", Harness::Unknown => "unknown", } }