Files
huskies/server/src/agents/runtime/mod.rs
T

228 lines
8.7 KiB
Rust
Raw Normal View History

//! Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents.
mod claude_code;
mod gemini;
mod openai;
pub use claude_code::ClaudeCodeRuntime;
pub use gemini::GeminiRuntime;
pub use openai::OpenAiRuntime;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
use crate::agent_log::AgentLogWriter;
2026-04-29 21:35:55 +00:00
use crate::http::context::AppContext;
use super::{AgentEvent, TokenUsage};
/// Context passed to a runtime when launching an agent session.
pub struct RuntimeContext {
pub story_id: String,
pub agent_name: String,
pub command: String,
pub args: Vec<String>,
pub prompt: String,
pub cwd: String,
pub inactivity_timeout_secs: u64,
2026-04-29 21:35:55 +00:00
/// Shared application context, used by API-based runtimes (Gemini, OpenAI)
/// to invoke MCP tool dispatch directly without an HTTP round-trip.
/// `None` in tests or when the pool is created before `AppContext` exists.
pub app_ctx: Option<Arc<AppContext>>,
/// When set, resume a previous Claude Code session instead of starting fresh.
///
/// The CLI is invoked as `claude --resume <session_id> [-p <prompt>]` rather
/// than `claude -p <full_prompt>`. The agent re-enters the previous
/// conversation and receives the `prompt` (if non-empty) as a new message.
pub session_id_to_resume: Option<String>,
/// Full rendered prompt for a fresh session, kept as fallback if resume fails.
///
/// When `session_id_to_resume` is `Some`, `prompt` contains only the
/// resume context (e.g. gate failure output). If the CLI rejects the
/// resume (session expired, file missing, version mismatch), the runtime
/// retries with this full prompt and no `--resume` flag.
pub fresh_prompt: Option<String>,
2026-05-13 12:34:35 +00:00
/// Project root path — passed to the PTY runner so it can eagerly record
/// the session_id as soon as the `"system"` event is seen (bug 967).
/// Eager recording ensures the session survives a watchdog kill that aborts
/// the tokio task before `run_agent_spawn`'s `record_session()` call runs.
pub project_root: std::path::PathBuf,
2026-05-13 23:33:30 +00:00
/// Agent model — forms part of the session store key used for eager
/// recording (bug 967). `None` disables eager recording.
pub model: Option<crate::agents::AgentModel>,
}
/// Result returned by a runtime after the agent session completes.
pub struct RuntimeResult {
pub session_id: Option<String>,
pub token_usage: Option<TokenUsage>,
/// `true` when the process exited with exit code 0; `false` for non-zero exits
/// (API errors, network failures, or Claude-API-level budget exhaustion). Always
/// `true` for API-based runtimes (OpenAI, Gemini) which have no exit-code concept.
/// Used by the commit-recovery path to skip the stuck-respawn counter for forced
/// exits (story 1089, AC1).
pub exit_ok: bool,
2026-04-30 00:31:08 +00:00
/// `true` when the process exited with a failure AND no session was established.
///
/// This indicates the Claude Code CLI crashed (e.g. SIGABRT from an assertion
/// failure) before it could emit its first "system" event — the classic
/// "signal=Aborted, Session: None" case (bug 882). The completion handler
/// uses this flag to skip acceptance gates and respawn without consuming a
/// retry slot. Always `false` for API-based runtimes (Gemini, OpenAI).
pub aborted_signal: bool,
2026-05-14 18:32:43 +00:00
/// `true` when the Claude Code CLI received a rate-limit hard block event
/// before exiting (bug 1053).
///
/// The completion handler uses this to distinguish a rate-limit-forced exit
/// from a genuine no-progress exit, so commit-recovery counters are not
/// incremented. Always `false` for API-based runtimes (Gemini, OpenAI).
pub rate_limit_exit: bool,
/// When the API rate limit is scheduled to reset.
///
/// Populated from the `reset_at` field of the `rate_limit_event` JSON when
/// `rate_limit_exit` is `true`. The completion handler honours this window
/// before re-attempting the agent spawn. `None` for API-based runtimes.
pub rate_limit_reset_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Runtime status reported by the backend.
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum RuntimeStatus {
Idle,
Running,
Completed,
Failed,
}
/// Abstraction over different agent execution backends.
///
/// Implementations:
/// - [`ClaudeCodeRuntime`]: spawns the `claude` CLI via a PTY (default, `runtime = "claude-code"`)
///
/// Future implementations could include OpenAI and Gemini API runtimes.
#[allow(dead_code)]
pub trait AgentRuntime: Send + Sync {
/// Start the agent and drive it to completion, streaming events through
/// the provided broadcast sender and event log.
///
/// Returns when the agent session finishes (success or error).
async fn start(
&self,
ctx: RuntimeContext,
tx: broadcast::Sender<AgentEvent>,
event_log: Arc<Mutex<Vec<AgentEvent>>>,
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
) -> Result<RuntimeResult, String>;
/// Stop the running agent.
fn stop(&self);
/// Get the current runtime status.
fn get_status(&self) -> RuntimeStatus;
/// Return any events buffered outside the broadcast channel.
///
/// PTY-based runtimes stream directly to the broadcast channel; this
/// returns empty by default. API-based runtimes may buffer events here.
fn stream_events(&self) -> Vec<AgentEvent> {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
2026-04-29 21:35:55 +00:00
use crate::http::context::AppContext;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
#[test]
fn runtime_context_fields() {
let ctx = RuntimeContext {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
command: "claude".to_string(),
args: vec!["--model".to_string(), "sonnet".to_string()],
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
2026-04-29 21:35:55 +00:00
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
2026-05-13 12:34:35 +00:00
project_root: std::path::PathBuf::from("/tmp/project"),
2026-05-13 23:33:30 +00:00
model: Some(crate::agents::AgentModel::Sonnet),
};
assert_eq!(ctx.story_id, "42_story_foo");
assert_eq!(ctx.agent_name, "coder-1");
assert_eq!(ctx.command, "claude");
assert_eq!(ctx.args.len(), 2);
assert_eq!(ctx.prompt, "Do the thing");
assert_eq!(ctx.cwd, "/tmp/wt");
assert_eq!(ctx.inactivity_timeout_secs, 300);
}
#[test]
fn runtime_result_fields() {
let result = RuntimeResult {
session_id: Some("sess-123".to_string()),
token_usage: Some(TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.01,
}),
exit_ok: true,
2026-04-30 00:31:08 +00:00
aborted_signal: false,
2026-05-14 18:32:43 +00:00
rate_limit_exit: false,
rate_limit_reset_at: None,
};
assert_eq!(result.session_id, Some("sess-123".to_string()));
assert!(result.token_usage.is_some());
let usage = result.token_usage.unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.total_cost_usd, 0.01);
}
#[test]
fn runtime_result_no_usage() {
let result = RuntimeResult {
session_id: None,
token_usage: None,
exit_ok: true,
2026-04-30 00:31:08 +00:00
aborted_signal: false,
2026-05-14 18:32:43 +00:00
rate_limit_exit: false,
rate_limit_reset_at: None,
};
assert!(result.session_id.is_none());
assert!(result.token_usage.is_none());
}
#[test]
fn runtime_status_variants() {
assert_eq!(RuntimeStatus::Idle, RuntimeStatus::Idle);
assert_ne!(RuntimeStatus::Running, RuntimeStatus::Completed);
assert_ne!(RuntimeStatus::Failed, RuntimeStatus::Idle);
}
#[test]
fn claude_code_runtime_get_status_returns_idle() {
use crate::io::watcher::WatcherEvent;
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
let runtime = ClaudeCodeRuntime::new(watcher_tx);
assert_eq!(runtime.get_status(), RuntimeStatus::Idle);
}
#[test]
fn claude_code_runtime_stream_events_empty() {
use crate::io::watcher::WatcherEvent;
let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(16);
let runtime = ClaudeCodeRuntime::new(watcher_tx);
assert!(runtime.stream_events().is_empty());
}
}