use async_trait::async_trait;
use serde_json::json;
use trusty_agents_common::connectors::{
AgentSpec, AttachHandle, BackendParams, ConnectorError, CreateSessionReq, DelegateHandle,
SessionInfo, SessionStatus, WorkstreamConnector,
};
use crate::client::{
ManagedAttachCmdResponse, ManagedListResponse, ManagedSendInputResponse, ManagedSessionSummary,
ManagedSpawnResponse,
http_client::{PROVISION_REQUEST_TIMEOUT, default_client},
};
use crate::core::discovery::resolve_daemon_url;
pub struct TmConnector {
http: reqwest::Client,
daemon_url: String,
}
impl TmConnector {
pub fn new() -> Self {
Self::with_daemon_url(resolve_daemon_url(None))
}
pub fn with_daemon_url(daemon_url: impl Into<String>) -> Self {
Self::with_client(default_client(), daemon_url)
}
pub fn with_client(http: reqwest::Client, daemon_url: impl Into<String>) -> Self {
Self {
http,
daemon_url: daemon_url.into(),
}
}
}
impl Default for TmConnector {
fn default() -> Self {
Self::new()
}
}
async fn map_error_response(resp: reqwest::Response) -> ConnectorError {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let message = if body.trim().is_empty() {
status.to_string()
} else {
format!("{status}: {}", body.trim())
};
if status == reqwest::StatusCode::NOT_FOUND {
ConnectorError::NotFound(message)
} else if status == reqwest::StatusCode::BAD_REQUEST {
ConnectorError::InvalidRequest(message)
} else {
ConnectorError::Backend(message)
}
}
fn transport_err(e: reqwest::Error) -> ConnectorError {
ConnectorError::Transport(e.to_string())
}
fn decode_err(e: reqwest::Error) -> ConnectorError {
ConnectorError::Transport(format!("failed to decode daemon response: {e}"))
}
#[async_trait]
impl WorkstreamConnector for TmConnector {
async fn create_session(&self, req: CreateSessionReq) -> Result<SessionInfo, ConnectorError> {
let CreateSessionReq {
task,
name_hint,
agent: _agent,
backend,
} = req;
let (repo_url, git_ref, runtime, ephemeral) = match backend {
BackendParams::Tm {
repo_url,
git_ref,
runtime,
ephemeral,
} => (repo_url, git_ref, runtime, ephemeral),
BackendParams::Tcode { .. } => {
return Err(ConnectorError::InvalidRequest(
"tm connector requires CreateSessionReq::backend = BackendParams::Tm".into(),
));
}
};
let body = json!({
"repo_url": repo_url,
"ref": git_ref,
"task": task,
"name_hint": name_hint,
"runtime": runtime,
"ephemeral": ephemeral,
});
let url = format!("{}/api/v1/sessions/managed", self.daemon_url);
let resp = self
.http
.post(&url)
.json(&body)
.timeout(PROVISION_REQUEST_TIMEOUT)
.send()
.await
.map_err(transport_err)?;
if !resp.status().is_success() {
return Err(map_error_response(resp).await);
}
let spawned: ManagedSpawnResponse = resp.json().await.map_err(decode_err)?;
Ok(SessionInfo {
id: spawned.id,
name: spawned.name,
state: spawned.state,
task: Some(task),
})
}
async fn list_sessions(&self) -> Result<Vec<SessionInfo>, ConnectorError> {
let url = format!("{}/api/v1/sessions/managed", self.daemon_url);
let resp = self.http.get(&url).send().await.map_err(transport_err)?;
if !resp.status().is_success() {
return Err(map_error_response(resp).await);
}
let listed: ManagedListResponse = resp.json().await.map_err(decode_err)?;
Ok(listed.sessions.into_iter().map(summary_to_info).collect())
}
async fn session_status(&self, session_id: &str) -> Result<SessionStatus, ConnectorError> {
let url = format!("{}/api/v1/sessions/managed/{session_id}", self.daemon_url);
let resp = self.http.get(&url).send().await.map_err(transport_err)?;
if !resp.status().is_success() {
return Err(map_error_response(resp).await);
}
let summary: ManagedSessionSummary = resp.json().await.map_err(decode_err)?;
Ok(SessionStatus {
id: summary.id,
state: summary.state,
pending_decision: summary.pending_decision,
})
}
async fn send_input(&self, session_id: &str, input: &str) -> Result<(), ConnectorError> {
let url = format!(
"{}/api/v1/sessions/managed/{session_id}/send",
self.daemon_url
);
let resp = self
.http
.post(&url)
.json(&json!({ "text": input }))
.send()
.await
.map_err(transport_err)?;
if !resp.status().is_success() {
return Err(map_error_response(resp).await);
}
let _ack: ManagedSendInputResponse = resp.json().await.map_err(decode_err)?;
Ok(())
}
async fn attach(&self, session_id: &str) -> Result<AttachHandle, ConnectorError> {
let url = format!(
"{}/api/v1/sessions/managed/{session_id}/attach-cmd",
self.daemon_url
);
let resp = self.http.get(&url).send().await.map_err(transport_err)?;
if !resp.status().is_success() {
return Err(map_error_response(resp).await);
}
let cmd: ManagedAttachCmdResponse = resp.json().await.map_err(decode_err)?;
Ok(AttachHandle::ShellCommand(cmd.attach_cmd))
}
async fn delegate(
&self,
session_id: &str,
agent_spec: &AgentSpec,
) -> Result<DelegateHandle, ConnectorError> {
let mut arguments = json!({
"session_id": session_id,
"agent": agent_spec.agent_name,
"task": agent_spec.task,
});
if let Some(tier) = &agent_spec.tier {
arguments["tier"] = json!(tier);
}
let rpc_req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "agent_delegate", "arguments": arguments },
});
let url = format!("{}/rpc", self.daemon_url);
let resp = self
.http
.post(&url)
.json(&rpc_req)
.send()
.await
.map_err(transport_err)?;
if !resp.status().is_success() {
return Err(map_error_response(resp).await);
}
let envelope: serde_json::Value = resp.json().await.map_err(decode_err)?;
let is_error = envelope
.get("result")
.and_then(|r| r.get("isError"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
let text = envelope
.get("result")
.and_then(|r| r.get("content"))
.and_then(|c| c.get(0))
.and_then(|block| block.get("text"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
ConnectorError::Transport(format!(
"malformed agent_delegate tools/call response: {envelope}"
))
})?;
if is_error {
return Err(ConnectorError::Backend(text.to_string()));
}
let parsed: serde_json::Value = serde_json::from_str(text).map_err(|e| {
ConnectorError::Transport(format!("agent_delegate result was not JSON: {e}"))
})?;
let delegate_id = parsed
.get("delegation_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
ConnectorError::Transport("agent_delegate result missing delegation_id".to_string())
})?
.to_string();
let note = parsed.get("circuit").map(|v| {
let rendered = v
.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string());
format!("circuit breaker state: {rendered}")
});
Ok(DelegateHandle { delegate_id, note })
}
}
fn summary_to_info(summary: ManagedSessionSummary) -> SessionInfo {
SessionInfo {
id: summary.id,
name: summary.name,
state: summary.state,
task: summary.task,
}
}
#[cfg(test)]
#[path = "tm_tests.rs"]
mod tests;