use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use theway_core::multiagent::jobs::{
SubagentControlHandle, SubagentJobInit, SubagentJobRegistry, SubagentJobStatus,
metrics_listener,
};
use theway_core::{
AgentHarness, AgentHarnessOptions, AgentMessage, AgentRunError, AgentTool, LoopEvent,
MemorySessionStorage, ObservationContext, OperationId, Session, SessionStorage, StreamFn,
ThinkingLevel,
};
use theway_llm_provider::{Message as PiMessage, Model, Provider, get_model, list_models};
use tokio_util::sync::CancellationToken;
use super::types::AgentRunParams;
pub struct AgentRunOptions {
pub launch: AgentRunParams,
pub tools: Vec<Arc<dyn AgentTool>>,
pub prompt: String,
pub model: Model,
pub stream_fn: Option<StreamFn>,
pub timeout: Option<u64>,
pub thinking: Option<String>,
pub registry: SubagentJobRegistry,
pub source: String,
pub run_id: Option<String>,
pub node_id: Option<String>,
pub session_id: Option<String>,
pub observation_parent: Option<OperationId>,
pub cancel: CancellationToken,
pub system_prompt_extra: Option<String>,
pub on_turn_end: Option<Arc<dyn Fn(&str, u64, u64) + Send + Sync>>,
}
#[allow(dead_code)]
pub struct AgentRunResult {
pub text: String,
pub success: bool,
pub error: Option<String>,
pub input_tokens: u64,
pub output_tokens: u64,
pub duration_ms: u64,
pub job_id: String,
}
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 120;
pub fn resolve_run_model(
parent: Option<&Model>,
provider: Option<&str>,
model_id: Option<&str>,
) -> Result<Option<Model>, String> {
match (provider, model_id) {
(Some(provider), Some(id)) => {
let provider_obj = Provider::from(provider);
get_model(&provider_obj, id)
.map(Some)
.ok_or_else(|| subagent_model_not_found_message(provider, id))
}
(Some(_), None) => {
Err("provider override requires a model override: pass both provider and model".into())
}
(None, Some(id)) => {
let Some(parent) = parent else {
return Ok(None);
};
if id == parent.id {
return Ok(Some(parent.clone()));
}
if let Some(catalog_model) = get_model(&parent.provider, id) {
return Ok(Some(catalog_model));
}
Ok(Some(Model {
id: id.to_string(),
..parent.clone()
}))
}
(None, None) => Ok(parent.cloned()),
}
}
fn subagent_model_not_found_message(provider: &str, id: &str) -> String {
let mut by_provider = std::collections::BTreeMap::<String, Vec<String>>::new();
for model in list_models() {
by_provider
.entry(model.provider.0)
.or_default()
.push(model.id);
}
let Some(models) = by_provider.get_mut(provider) else {
let providers = by_provider
.iter()
.map(|(provider, models)| format!("{provider}({})", models.len()))
.collect::<Vec<_>>()
.join(", ");
return format!(
"model provider not found in catalog: provider={provider}. Known providers: {providers}"
);
};
models.sort();
let candidates = models
.iter()
.take(12)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
let more = if models.len() > 12 {
format!(
"; run `/model list {provider}` inside theway for all {} models",
models.len()
)
} else {
String::new()
};
format!(
"model not found in catalog: provider={provider} id={id}. Candidates: {candidates}{more}"
)
}
const IDLE_KILL_GRACE_SECS: u64 = 5;
pub fn filter_tool_set(
tools: Vec<Arc<dyn AgentTool>>,
allow: &[String],
) -> Result<Vec<Arc<dyn AgentTool>>, String> {
if allow.is_empty() {
return Ok(tools);
}
let available: Vec<&str> = tools.iter().map(|t| t.definition().name.as_str()).collect();
for name in allow {
if !available.contains(&name.as_str()) {
return Err(format!(
"unknown tool in allowlist: {name} (available: {})",
available.join(", ")
));
}
}
Ok(tools
.into_iter()
.filter(|t| allow.iter().any(|a| a == &t.definition().name))
.collect())
}
pub async fn run_agent(opts: AgentRunOptions) -> AgentRunResult {
let started = Instant::now();
let job_id = opts.registry.register_observed(
SubagentJobInit {
agent: opts.launch.name.to_string(),
source: opts.source.clone(),
run_id: opts.run_id.clone(),
node_id: opts.node_id.clone(),
session_id: opts.session_id.clone(),
},
opts.observation_parent,
);
let job_operation = opts.registry.operation_id(&job_id);
let storage = Arc::new(MemorySessionStorage::new());
let session = Session::new(storage as Arc<dyn SessionStorage>);
let mut harness_opts = AgentHarnessOptions::new(Some(opts.model), session);
harness_opts.observer = opts.registry.observer();
harness_opts.observation_context = ObservationContext {
session_id: opts.session_id.clone(),
run_id: opts.run_id.clone(),
job_id: Some(job_id.clone()),
node_id: opts.node_id.clone(),
..ObservationContext::default()
};
harness_opts.observation_parent = job_operation;
harness_opts.system_prompt = match opts.system_prompt_extra {
Some(extra) => format!("{}\n{extra}", opts.launch.system_prompt),
None => opts.launch.system_prompt.to_string(),
};
harness_opts.tools = opts.tools;
harness_opts.stream_fn = opts.stream_fn;
harness_opts.max_iterations = Some(opts.launch.max_iterations);
if let Some(level) = opts
.thinking
.as_deref()
.and_then(|t| t.parse::<ThinkingLevel>().ok())
{
harness_opts.thinking_level = level;
}
let sub = Arc::new(AgentHarness::new(harness_opts));
{
let sub_ctl = sub.clone();
let sub_steer = sub.clone();
opts.registry.set_control(
&job_id,
Some(SubagentControlHandle {
interrupt: Arc::new(move || sub_ctl.interrupt()),
steer: Arc::new(move |text: String| {
let msg =
AgentMessage::Llm(PiMessage::User(theway_llm_provider::UserMessage {
role: theway_llm_provider::UserRole::User,
content: theway_llm_provider::UserContent::Text(text),
timestamp: chrono::Utc::now().timestamp_millis(),
}));
sub_steer.enqueue_steering(msg);
}),
}),
);
}
let _metrics_sub = sub
.agent()
.subscribe_sync(metrics_listener(opts.registry.clone(), job_id.clone()));
let last_activity: Arc<Mutex<Instant>> = Arc::new(Mutex::new(Instant::now()));
let activity = last_activity.clone();
let final_text: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let collector = final_text.clone();
let on_turn_end = opts.on_turn_end.clone();
let sub_for_events = sub.clone();
let _unsub = sub.agent().subscribe_sync(Arc::new(move |event| {
*activity.lock() = Instant::now();
if let LoopEvent::MessageEnd {
message: AgentMessage::Llm(PiMessage::Assistant(a)),
} = event
{
let text = a
.content
.iter()
.filter_map(|b| match b {
theway_llm_provider::ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
*collector.lock() = text.clone();
}
if let Some(cb) = on_turn_end.as_ref() {
let snap = sub_for_events.cost();
cb(&text, snap.tokens.input, snap.tokens.output);
}
}
}));
let sub_for_cancel = sub.clone();
let cancel = opts.cancel.clone();
let watcher = tokio::spawn(async move {
cancel.cancelled().await;
sub_for_cancel.abort();
});
let idle_secs = opts.timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS);
let run = if idle_secs == 0 {
sub.prompt(opts.prompt).await
} else {
let sub_for_prompt = sub.clone();
let mut handle = tokio::spawn(async move { sub_for_prompt.prompt(opts.prompt).await });
let wd_activity = last_activity.clone();
let wd_sub = sub.clone();
let wd_fired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let wd_fired_inner = wd_fired.clone();
let wd_stop = CancellationToken::new();
let wd_stop_inner = wd_stop.clone();
let (wd_fire_tx, mut wd_fire_rx) = tokio::sync::oneshot::channel::<()>();
let wd = tokio::spawn(async move {
let idle = Duration::from_secs(idle_secs);
loop {
let deadline = tokio::time::Instant::from_std(*wd_activity.lock()) + idle;
tokio::select! {
_ = tokio::time::sleep_until(deadline) => {}
_ = wd_stop_inner.cancelled() => return,
}
if wd_activity.lock().elapsed() >= idle {
wd_sub.abort();
wd_fired_inner.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = wd_fire_tx.send(());
return;
}
}
});
enum Run {
Done(Result<Result<(), AgentRunError>, tokio::task::JoinError>),
TimedOut,
}
let outcome = tokio::select! {
r = &mut handle => {
wd_stop.cancel();
Run::Done(r)
}
fired = &mut wd_fire_rx => match fired {
Ok(()) => {
if !handle.is_finished()
&& tokio::time::timeout(
Duration::from_secs(IDLE_KILL_GRACE_SECS),
&mut handle,
)
.await
.is_err()
{
handle.abort();
}
Run::TimedOut
}
Err(_) if !handle.is_finished() => Run::Done(handle.await),
Err(_) => Run::TimedOut,
},
};
wd_stop.cancel();
let _ = wd.await;
let timeout_err = || {
AgentRunError::Other(format!(
"Timed out: no output for {idle_secs}s (idle timeout)"
))
};
match outcome {
Run::Done(r) if !wd_fired.load(std::sync::atomic::Ordering::SeqCst) => match r {
Ok(inner) => inner,
Err(e) => Err(AgentRunError::Other(format!("subagent task failed: {e}"))),
},
Run::Done(_) | Run::TimedOut => Err(timeout_err()),
}
};
watcher.abort();
let duration_ms = started.elapsed().as_millis() as u64;
let snap = sub.cost();
if opts.cancel.is_cancelled() {
opts.registry
.finish(&job_id, SubagentJobStatus::Cancelled, None);
return AgentRunResult {
text: String::new(),
success: false,
error: Some("cancelled".into()),
input_tokens: snap.tokens.input,
output_tokens: snap.tokens.output,
duration_ms,
job_id,
};
}
let interrupted = matches!(run, Err(AgentRunError::TurnInterrupted));
let success = run.is_ok();
let error = run.err().map(|e| e.to_string());
opts.registry.finish(
&job_id,
if interrupted {
SubagentJobStatus::Interrupted
} else if success {
SubagentJobStatus::Succeeded
} else {
SubagentJobStatus::Failed
},
error.clone(),
);
AgentRunResult {
text: std::mem::take(&mut *final_text.lock()),
success,
error,
input_tokens: snap.tokens.input,
output_tokens: snap.tokens.output,
duration_ms,
job_id,
}
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("multiagent/runner");