use anyhow::Result;
use chrono::Local;
use serde_json::{Value, json};
use std::process::Command;
use super::truncate::truncate_text;
use super::RunOpts;
use crate::rate_limit;
use crate::types::*;
pub struct DroidAgent;
impl super::Agent for DroidAgent {
fn kind(&self) -> AgentKind {
AgentKind::Droid
}
fn streaming(&self) -> bool {
true
}
fn build_command(&self, prompt: &str, opts: &RunOpts) -> Result<Command> {
let mut cmd = Command::new("droid");
cmd.args(["exec", "--output-format", "stream-json"]);
if opts.read_only {
cmd.arg("--use-spec");
} else {
cmd.arg("--skip-permissions-unsafe");
}
if let Some(ref model) = opts.model {
let mapped = map_model_name(model);
cmd.args(["-m", mapped.as_str()]);
}
if let Some(ref session_id) = opts.session_id {
cmd.args(["-s", session_id]);
}
for file in &opts.context_files {
cmd.args(["--append-system-prompt-file", file]);
}
if let Some(ref dir) = opts.dir {
cmd.args(["--cwd", dir]);
cmd.current_dir(dir);
}
cmd.arg(prompt);
Ok(cmd)
}
fn parse_event(&self, task_id: &TaskId, line: &str) -> Option<TaskEvent> {
let v: Value = serde_json::from_str(line).ok()?;
let now = Local::now();
let event_type = v.get("type")?.as_str()?;
match event_type {
"assistant_message" | "text" => {
let text = v
.get("content")
.or_else(|| v.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
if text.is_empty() {
return None;
}
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Reasoning,
detail: truncate_text(text, 80),
metadata: None,
})
}
"tool_call" => {
let name = v
.get("toolName")
.or_else(|| v.get("toolId"))
.or_else(|| v.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("tool");
let params = v
.get("parameters")
.or_else(|| v.get("arguments"))
.or_else(|| v.get("input"))
.or_else(|| v.get("args"));
let mut signature: Option<String> = None;
if let Some(p) = params {
let get_first = |keys: &[&str]| -> Option<String> {
for k in keys {
if let Some(val) = p.get(*k).and_then(|v| v.as_str()) {
return Some(val.to_string());
}
}
None
};
match name.to_lowercase().as_str() {
"read" | "readtool" | "readtoolcall" | "read_tool" => {
if let Some(path) = get_first(&["file_path", "path", "filePath"]) {
signature = Some(path);
}
}
"write" | "edit" | "writetool" | "write_tool" => {
if let Some(path) = get_first(&["file_path", "path", "filePath"]) {
signature = Some(path);
}
}
"grep" | "greet" => {
let pattern = get_first(&["pattern", "globPattern", "query"]);
let path = get_first(&["path", "targetDirectory", "file_path", "filePath"]);
signature = match (pattern, path) {
(Some(pat), Some(pth)) => Some(format!("{pat} {pth}")),
(Some(pat), None) => Some(pat),
(None, Some(pth)) => Some(pth),
_ => None,
};
}
"bash" | "shell" | "execute" | "sh" | "command" => {
if let Some(cmd) = get_first(&["command", "cmd", "shell"]) {
signature = Some(cmd);
}
}
_ => {
if let Some((k, v)) = p.as_object().and_then(|o| o.iter().next()) {
if let Some(s) = v.as_str() {
signature = Some(s.to_string());
} else {
signature = Some(k.to_string());
}
}
}
}
}
let detail = match &signature {
Some(sig) => truncate_text(&format!("{} {}", name, sig), 80),
None => truncate_text(name, 80),
};
let metadata = signature.as_ref().map(|s| json!({ "command": s }));
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::ToolCall,
detail,
metadata,
})
}
"tool_use" | "tool_result" => None,
"mission_step" => parse_mission_step(task_id, &v, now),
"session_forked" => parse_session_forked(task_id, &v, now),
"usage" | "turn_complete" => {
let input = v.get("input_tokens").and_then(|t| t.as_i64()).unwrap_or(0);
let output = v.get("output_tokens").and_then(|t| t.as_i64()).unwrap_or(0);
let total = input + output;
let cost = v.get("cost_usd").and_then(|c| c.as_f64());
let model = v.get("model").and_then(|m| m.as_str()).map(ToOwned::to_owned);
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Completion,
detail: format!("tokens: {input} in + {output} out = {total}"),
metadata: Some(json!({
"tokens": total, "input_tokens": input, "output_tokens": output,
"model": model, "cost_usd": cost,
})),
})
}
"error" => parse_error_event(task_id, &v, now),
_ => None,
}
}
fn parse_completion(&self, _output: &str) -> CompletionInfo {
CompletionInfo {
tokens: None,
status: TaskStatus::Done,
model: None,
cost_usd: None,
exit_code: None,
}
}
}
fn map_model_name(model: &str) -> String {
match model {
"haiku" => "claude-haiku-4-5-20251001".to_string(),
"sonnet" => "claude-sonnet-4-6".to_string(),
"opus" => "claude-opus-4-7".to_string(),
"gpt-4.1-nano" => "gpt-5.4-mini".to_string(),
"gpt-4.1-mini" => "gpt-5.4-fast".to_string(),
other => other.to_string(),
}
}
fn parse_mission_step(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let description = v.get("description")?.as_str()?.trim();
if description.is_empty() {
return None;
}
let detail = match v.get("step").and_then(|value| value.as_str()) {
Some(step) if !step.is_empty() => truncate_text(&format!("{step} {description}"), 80),
_ => truncate_text(description, 80),
};
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Milestone,
detail,
metadata: None,
})
}
fn parse_session_forked(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let new_id = v.get("new_id")?.as_str()?;
let detail = match v.get("parent_id").and_then(|value| value.as_str()) {
Some(parent_id) if !parent_id.is_empty() => format!("forked {new_id} from {parent_id}"),
_ => format!("forked {new_id}"),
};
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Milestone,
detail,
metadata: Some(json!({ "agent_session_id": new_id })),
})
}
fn parse_error_event(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let detail = droid_error_detail(v);
if is_droid_rate_limit(v, detail.as_deref()) {
let rate_limit_message = detail.clone().unwrap_or_else(|| "status 429".to_string());
rate_limit::mark_rate_limited(&AgentKind::Droid, &rate_limit_message);
}
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Error,
detail: truncate_text(detail.as_deref().unwrap_or("unknown error"), 80),
metadata: None,
})
}
fn droid_error_detail(v: &Value) -> Option<String> {
let message = v
.get("message")
.and_then(|value| value.as_str())
.or_else(|| v.get("error").and_then(|value| value.as_str()))
.or_else(|| v.pointer("/error/message").and_then(|value| value.as_str()));
if let Some(message) = message
&& !message.is_empty()
{
return Some(message.to_string());
}
v.get("error_type")
.and_then(|value| value.as_str())
.or_else(|| v.pointer("/error/type").and_then(|value| value.as_str()))
.map(ToOwned::to_owned)
}
fn is_droid_rate_limit(v: &Value, detail: Option<&str>) -> bool {
if detail.is_some_and(rate_limit::is_rate_limit_error) {
return true;
}
let status = v
.get("status")
.and_then(|value| value.as_i64())
.or_else(|| v.pointer("/error/status").and_then(|value| value.as_i64()));
if status == Some(429) {
return true;
}
v.get("error_type")
.and_then(|value| value.as_str())
.or_else(|| v.pointer("/error/type").and_then(|value| value.as_str()))
.is_some_and(|value| value.eq_ignore_ascii_case("rate_limit_exceeded"))
}
#[cfg(test)]
mod model_name_tests {
use super::map_model_name;
#[test]
fn maps_common_shorthand_models() {
assert_eq!(map_model_name("haiku"), "claude-haiku-4-5-20251001");
assert_eq!(map_model_name("sonnet"), "claude-sonnet-4-6");
assert_eq!(map_model_name("opus"), "claude-opus-4-7");
assert_eq!(map_model_name("gpt-4.1-nano"), "gpt-5.4-mini");
assert_eq!(map_model_name("gpt-4.1-mini"), "gpt-5.4-fast");
}
#[test]
fn preserves_full_model_ids() {
assert_eq!(
map_model_name("claude-haiku-4-5-20251001"),
"claude-haiku-4-5-20251001"
);
}
}
#[cfg(test)]
mod tests;