use crate::commands::agent::run::pause::EXIT_CODE_PAUSED;
use stakpak_gateway::client::{
ClientError, SendMessageOptions, StakpakClient, ToolDecisionAction, ToolDecisionInput,
};
use stakpak_shared::models::async_manifest::PauseReason;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct AgentResult {
pub exit_code: Option<i32>,
pub session_id: Option<String>,
pub checkpoint_id: Option<String>,
pub timed_out: bool,
pub paused: bool,
pub pause_reason: Option<PauseReason>,
pub resume_hint: Option<String>,
pub stdout: String,
pub stderr: String,
}
impl AgentResult {
pub fn success(&self) -> bool {
self.exit_code == Some(0)
}
pub fn is_paused(&self) -> bool {
self.paused || self.exit_code == Some(EXIT_CODE_PAUSED)
}
pub fn failed(&self) -> bool {
self.timed_out
|| matches!(self.exit_code, Some(code) if code != 0 && code != EXIT_CODE_PAUSED)
}
}
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
#[error("Failed to spawn agent: {0}")]
SpawnError(String),
}
#[derive(Debug, Clone)]
pub struct AgentServerConnection {
pub url: String,
pub token: String,
pub model: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SpawnConfig {
pub prompt: String,
pub profile: String,
pub timeout: Duration,
pub workdir: Option<String>,
pub enable_slack_tools: bool,
pub enable_subagents: bool,
pub pause_on_approval: bool,
pub sandbox: bool,
pub server: AgentServerConnection,
}
pub async fn spawn_agent(config: SpawnConfig) -> Result<AgentResult, AgentError> {
let server = &config.server;
let client = StakpakClient::new(server.url.clone(), server.token.clone());
debug!(
server_url = %server.url,
model = ?server.model,
timeout_secs = config.timeout.as_secs(),
"Spawning agent via server API"
);
let result = tokio::time::timeout(config.timeout, async {
run_server_session(&client, server, &config).await
})
.await;
match result {
Ok(Ok(agent_result)) => Ok(agent_result),
Ok(Err(e)) => Err(AgentError::SpawnError(format!("Server API error: {}", e))),
Err(_) => {
warn!(
timeout_secs = config.timeout.as_secs(),
"Agent timed out (server API)"
);
Ok(AgentResult {
exit_code: None,
session_id: None,
checkpoint_id: None,
timed_out: true,
paused: false,
pause_reason: None,
resume_hint: None,
stdout: String::new(),
stderr: String::new(),
})
}
}
}
async fn run_server_session(
client: &StakpakClient,
server: &AgentServerConnection,
config: &SpawnConfig,
) -> Result<AgentResult, ClientError> {
let session = client
.create_session(&format!("autopilot: {}", config.profile))
.await?;
let session_id = session.id.to_string();
let message = stakai::Message {
role: stakai::Role::User,
content: stakai::MessageContent::Text(config.prompt.clone()),
name: None,
provider_options: None,
};
let opts = SendMessageOptions {
model: server.model.clone(),
sandbox: if config.sandbox { Some(true) } else { None },
..Default::default()
};
let send_resp = client
.send_messages(&session_id, vec![message], opts)
.await?;
let run_id = send_resp.run_id.to_string();
let mut event_stream = client.subscribe_events(&session_id, None).await?;
let mut agent_message = String::new();
let mut paused = false;
let mut pause_reason: Option<PauseReason> = None;
loop {
let Some(event) = event_stream.next_event().await? else {
break;
};
if let Some(delta) = event.as_text_delta() {
agent_message.push_str(&delta);
}
if let Some(proposed) = event.as_tool_calls_proposed() {
if config.pause_on_approval {
paused = true;
let pending: Vec<stakpak_shared::models::async_manifest::PendingToolCall> =
proposed
.tool_calls
.iter()
.map(
|tc| stakpak_shared::models::async_manifest::PendingToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
)
.collect();
pause_reason = Some(PauseReason::ToolApprovalRequired {
pending_tool_calls: pending,
});
break;
}
let mut decisions = HashMap::new();
for tc in &proposed.tool_calls {
decisions.insert(
tc.id.clone(),
ToolDecisionInput {
action: ToolDecisionAction::Accept,
content: None,
},
);
}
client
.resolve_tools(&session_id, &run_id, decisions)
.await?;
}
if event.as_run_completed().is_some() || event.as_run_error().is_some() {
if let Some(err) = event.as_run_error() {
let error_msg = err.error.unwrap_or_else(|| "unknown error".to_string());
info!(session_id = %session_id, error = %error_msg, "Agent run error");
return Ok(AgentResult {
exit_code: Some(1),
session_id: Some(session_id),
checkpoint_id: None,
timed_out: false,
paused: false,
pause_reason: None,
resume_hint: None,
stdout: agent_message,
stderr: error_msg,
});
}
break;
}
}
info!(
session_id = %session_id,
paused = paused,
"Agent completed via server API"
);
Ok(AgentResult {
exit_code: if paused {
Some(EXIT_CODE_PAUSED)
} else {
Some(0)
},
session_id: Some(session_id),
checkpoint_id: None,
timed_out: false,
paused,
pause_reason,
resume_hint: None,
stdout: agent_message,
stderr: String::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_result_success() {
let result = AgentResult {
exit_code: Some(0),
session_id: Some("test-session".to_string()),
checkpoint_id: Some("test-checkpoint".to_string()),
timed_out: false,
paused: false,
pause_reason: None,
resume_hint: None,
stdout: String::new(),
stderr: String::new(),
};
assert!(result.success());
assert!(!result.failed());
assert!(!result.is_paused());
}
#[test]
fn test_agent_result_failure() {
let result = AgentResult {
exit_code: Some(1),
session_id: None,
checkpoint_id: None,
timed_out: false,
paused: false,
pause_reason: None,
resume_hint: None,
stdout: String::new(),
stderr: "Error occurred".to_string(),
};
assert!(!result.success());
assert!(result.failed());
assert!(!result.is_paused());
}
#[test]
fn test_agent_result_timeout() {
let result = AgentResult {
exit_code: None,
session_id: None,
checkpoint_id: None,
timed_out: true,
paused: false,
pause_reason: None,
resume_hint: None,
stdout: String::new(),
stderr: String::new(),
};
assert!(!result.success());
assert!(result.failed());
assert!(!result.is_paused());
}
#[test]
fn test_agent_result_paused() {
let result = AgentResult {
exit_code: Some(EXIT_CODE_PAUSED),
session_id: Some("test-session".to_string()),
checkpoint_id: Some("test-checkpoint".to_string()),
timed_out: false,
paused: true,
pause_reason: Some(PauseReason::ToolApprovalRequired {
pending_tool_calls: vec![],
}),
resume_hint: Some("stakpak -c test-checkpoint --approve-all".to_string()),
stdout: String::new(),
stderr: String::new(),
};
assert!(!result.success());
assert!(!result.failed());
assert!(result.is_paused());
}
}