This repository has been archived on 2026-10-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
secret-agent/src/runner.rs

495 lines
15 KiB
Rust

use std::fs;
use std::process::Stdio;
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::Utc;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use tokio::time::sleep;
use crate::adapters::{OutputMode, build_command};
use crate::types::{
AgentRequest, RunResult, RunnerEvent, StreamSource, UsageAlert, UsageAlertKind,
};
#[derive(Debug)]
enum InternalEvent {
ToolConversationId(String),
Chunk(StreamSource, String),
StreamClosed,
}
pub struct RunningHandle {
pub receiver: mpsc::UnboundedReceiver<RunnerEvent>,
cancel: Option<oneshot::Sender<()>>,
}
impl RunningHandle {
pub fn cancel(&mut self) {
if let Some(cancel) = self.cancel.take() {
let _ = cancel.send(());
}
}
}
pub fn spawn(request: AgentRequest) -> RunningHandle {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (cancel_tx, cancel_rx) = oneshot::channel();
tokio::spawn(async move {
if let Err(err) = run(request, event_tx.clone(), cancel_rx).await {
let _ = event_tx.send(RunnerEvent::Failed(err.to_string()));
}
});
RunningHandle {
receiver: event_rx,
cancel: Some(cancel_tx),
}
}
async fn run(
request: AgentRequest,
event_tx: mpsc::UnboundedSender<RunnerEvent>,
mut cancel_rx: oneshot::Receiver<()>,
) -> Result<()> {
let started_at = Utc::now();
let _ = event_tx.send(RunnerEvent::Started(started_at));
let command_spec = build_command(&request)?;
if let Some(tool_conversation_id) = &command_spec.tool_conversation_id_hint {
let _ = event_tx.send(RunnerEvent::ToolConversationId(
tool_conversation_id.clone(),
));
}
let mut child = Command::new(&command_spec.program)
.args(&command_spec.args)
.current_dir(&request.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn {}", request.tool.command_name()))?;
let stdout = child
.stdout
.take()
.context("failed to capture child stdout")?;
let stderr = child
.stderr
.take()
.context("failed to capture child stderr")?;
let (internal_tx, mut internal_rx) = mpsc::unbounded_channel();
match command_spec.output_mode {
OutputMode::PlainText => {
tokio::spawn(read_plain_stream(
stdout,
StreamSource::Stdout,
internal_tx.clone(),
));
}
OutputMode::AgyJson | OutputMode::CodexJson => {
tokio::spawn(read_structured_stream(
stdout,
command_spec.output_mode,
internal_tx.clone(),
));
}
}
tokio::spawn(read_plain_stream(
stderr,
StreamSource::Stderr,
internal_tx.clone(),
));
let mut stdout_buf = String::new();
let mut stderr_buf = String::new();
let mut closed_streams = 0usize;
let mut exit_code = None;
let mut cancelled = false;
let mut usage_alert = None;
let mut tool_conversation_id = command_spec.tool_conversation_id_hint.clone();
loop {
tokio::select! {
maybe_event = internal_rx.recv() => {
match maybe_event {
Some(InternalEvent::ToolConversationId(id)) => {
if tool_conversation_id.as_deref() != Some(id.as_str()) {
tool_conversation_id = Some(id.clone());
let _ = event_tx.send(RunnerEvent::ToolConversationId(id));
}
}
Some(InternalEvent::Chunk(source, text)) => {
if let Some(alert) = detect_usage_issue(&text)
&& usage_alert.is_none()
{
usage_alert = Some(alert.clone());
let _ = event_tx.send(RunnerEvent::Alert(alert));
}
match source {
StreamSource::Stdout => stdout_buf.push_str(&text),
StreamSource::Stderr => stderr_buf.push_str(&text),
}
let _ = event_tx.send(RunnerEvent::Chunk { source, text });
}
Some(InternalEvent::StreamClosed) => {
closed_streams += 1;
}
None => break,
}
}
_ = &mut cancel_rx, if !cancelled => {
cancelled = true;
let _ = child.kill().await;
}
_ = sleep(Duration::from_millis(50)) => {
if exit_code.is_none() {
if let Some(status) = child.try_wait().context("failed waiting on child process")? {
exit_code = status.code();
}
}
}
}
if exit_code.is_some() && closed_streams >= 2 {
break;
}
}
if exit_code.is_none() {
let status = child
.wait()
.await
.context("failed waiting on child process")?;
exit_code = status.code();
}
let ended_at = Utc::now();
let assistant_response = if let Some(path) = &command_spec.response_capture_file {
let response = fs::read_to_string(path).unwrap_or_else(|_| stdout_buf.clone());
let _ = fs::remove_file(path);
response
} else {
stdout_buf.clone()
};
let result = RunResult {
stdout: stdout_buf,
stderr: stderr_buf,
assistant_response,
tool_conversation_id,
exit_code,
started_at,
ended_at,
cancelled,
usage_alert,
};
let _ = event_tx.send(RunnerEvent::Finished(result));
Ok(())
}
async fn read_plain_stream<R>(
mut reader: R,
source: StreamSource,
tx: mpsc::UnboundedSender<InternalEvent>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
let mut buf = vec![0_u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) => {
let _ = tx.send(InternalEvent::StreamClosed);
break;
}
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]).to_string();
let _ = tx.send(InternalEvent::Chunk(source, text));
}
Err(err) => {
let _ = tx.send(InternalEvent::Chunk(
StreamSource::Stderr,
format!("stream read error: {err}\n"),
));
let _ = tx.send(InternalEvent::StreamClosed);
break;
}
}
}
}
async fn read_structured_stream<R>(
reader: R,
output_mode: OutputMode,
tx: mpsc::UnboundedSender<InternalEvent>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
let mut lines = BufReader::new(reader).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => match parse_structured_line(output_mode, &line) {
Ok(parsed) => {
if let Some(tool_conversation_id) = parsed.tool_conversation_id {
let _ = tx.send(InternalEvent::ToolConversationId(tool_conversation_id));
}
if let Some(display_text) = parsed.display_text
&& !display_text.is_empty()
{
let _ = tx.send(InternalEvent::Chunk(StreamSource::Stdout, display_text));
}
}
Err(_) => {
let _ = tx.send(InternalEvent::Chunk(
StreamSource::Stdout,
format!("{line}\n"),
));
}
},
Ok(None) => {
let _ = tx.send(InternalEvent::StreamClosed);
break;
}
Err(err) => {
let _ = tx.send(InternalEvent::Chunk(
StreamSource::Stderr,
format!("stream read error: {err}\n"),
));
let _ = tx.send(InternalEvent::StreamClosed);
break;
}
}
}
}
#[derive(Debug)]
struct ParsedStructuredLine {
tool_conversation_id: Option<String>,
display_text: Option<String>,
}
fn parse_structured_line(output_mode: OutputMode, line: &str) -> Result<ParsedStructuredLine> {
let value: Value = serde_json::from_str(line)?;
let tool_conversation_id = find_tool_conversation_id(output_mode, &value);
let display_text = match output_mode {
OutputMode::AgyJson => extract_agy_display_text(&value),
OutputMode::CodexJson => extract_codex_display_text(&value),
OutputMode::PlainText => None,
};
Ok(ParsedStructuredLine {
tool_conversation_id,
display_text,
})
}
fn extract_agy_display_text(value: &Value) -> Option<String> {
let event_type = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
let role = value
.get("role")
.and_then(Value::as_str)
.unwrap_or_default();
if event_type == "message" && role == "assistant" {
return value
.get("content")
.and_then(Value::as_str)
.map(ToString::to_string);
}
if event_type == "error" {
return value
.get("message")
.and_then(Value::as_str)
.map(|message| format!("{message}\n"));
}
None
}
fn extract_codex_display_text(value: &Value) -> Option<String> {
let event_type = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if event_type.starts_with("response.output_text.delta") {
return value
.get("delta")
.and_then(Value::as_str)
.map(ToString::to_string);
}
if event_type.starts_with("response.output_text.done") {
return value
.get("text")
.and_then(Value::as_str)
.or_else(|| value.get("content").and_then(Value::as_str))
.map(ToString::to_string);
}
if event_type.contains("error") {
return value
.get("message")
.and_then(Value::as_str)
.or_else(|| value.get("content").and_then(Value::as_str))
.map(|message| format!("{message}\n"));
}
if value.get("role").and_then(Value::as_str) == Some("assistant") {
return value
.get("content")
.and_then(Value::as_str)
.map(ToString::to_string);
}
None
}
fn find_tool_conversation_id(output_mode: OutputMode, value: &Value) -> Option<String> {
let keys = match output_mode {
OutputMode::AgyJson => &["session_id", "sessionId"][..],
OutputMode::CodexJson => &["thread_id", "threadId", "session_id", "sessionId"][..],
OutputMode::PlainText => return None,
};
find_string_recursive(value, keys)
}
fn find_string_recursive(value: &Value, keys: &[&str]) -> Option<String> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(candidate) = map.get(*key).and_then(Value::as_str)
&& !candidate.trim().is_empty()
{
return Some(candidate.to_string());
}
}
for nested in map.values() {
if let Some(found) = find_string_recursive(nested, keys) {
return Some(found);
}
}
None
}
Value::Array(values) => values
.iter()
.find_map(|nested| find_string_recursive(nested, keys)),
_ => None,
}
}
fn detect_usage_issue(text: &str) -> Option<UsageAlert> {
let lower = text.to_ascii_lowercase();
let lower = lower.trim();
let quota_patterns = [
"insufficient_quota",
"usage limit reached",
"quota exceeded",
"exceeded your current quota",
"out of premium requests",
"premium requests quota",
"request limit reached",
"out of usage tokens",
"out of tokens",
"quota exhausted",
"monthly quota",
];
let rate_patterns = [
"rate limit",
"too many requests",
"please try again later",
"temporarily unavailable",
"server overloaded",
"429",
];
if quota_patterns.iter().any(|pattern| lower.contains(pattern)) {
return Some(UsageAlert {
kind: UsageAlertKind::QuotaExhausted,
evidence: summarize_evidence(text),
});
}
if rate_patterns.iter().any(|pattern| lower.contains(pattern)) {
return Some(UsageAlert {
kind: UsageAlertKind::RateLimited,
evidence: summarize_evidence(text),
});
}
None
}
fn summarize_evidence(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(text.trim())
.chars()
.take(160)
.collect()
}
#[cfg(test)]
mod tests {
use super::{OutputMode, detect_usage_issue, parse_structured_line, summarize_evidence};
use crate::types::UsageAlertKind;
#[test]
fn detects_quota_exhaustion() {
let alert = detect_usage_issue("Error: usage limit reached for this account")
.expect("alert should be detected");
assert_eq!(alert.kind, UsageAlertKind::QuotaExhausted);
}
#[test]
fn detects_rate_limits() {
let alert = detect_usage_issue("429 Too many requests").expect("alert should be detected");
assert_eq!(alert.kind, UsageAlertKind::RateLimited);
}
#[test]
fn trims_evidence() {
let summary = summarize_evidence(" \n hello world \n second line");
assert_eq!(summary, "hello world");
}
#[test]
fn parses_agy_session_id() {
let parsed = parse_structured_line(
OutputMode::AgyJson,
r#"{"type":"init","session_id":"agy-session-1"}"#,
)
.expect("structured line should parse");
assert_eq!(
parsed.tool_conversation_id.as_deref(),
Some("agy-session-1")
);
}
#[test]
fn parses_codex_thread_id() {
let parsed = parse_structured_line(
OutputMode::CodexJson,
r#"{"type":"response.output_text.delta","delta":"hi","thread_id":"thread-1"}"#,
)
.expect("structured line should parse");
assert_eq!(parsed.tool_conversation_id.as_deref(), Some("thread-1"));
assert_eq!(parsed.display_text.as_deref(), Some("hi"));
}
}