use std::collections::HashMap;
use std::sync::Arc;
use chrono::Utc;
use parking_lot::Mutex;
use uuid::Uuid;
pub use crate::agent::session::session::SubagentJobSnapshot;
use crate::observability::{
ErrorCategory, ObservationContent, ObservationContext, OperationDetail, OperationId,
OperationOutcome, OperationScope, RuntimeMeasurements, RuntimeObserver, noop_runtime_observer,
};
#[cfg(test)]
use crate::AgentMessage;
#[cfg(test)]
use crate::LoopEvent;
#[cfg(test)]
use theway_llm_provider::Message as PiMessage;
pub use super::job_events::{
SUBAGENT_JOB_EVENT_BROADCAST_CAPACITY, SubagentJobEvent, SubagentJobStatus,
};
pub use super::job_metrics::metrics_listener;
pub use super::job_transcript::{
JobTranscript, JobTranscriptStore, agent_message_to_json, append_message, append_output,
};
pub const MAX_JOBS: usize = 64;
pub const MAX_OUTPUT_BYTES: usize = 1024 * 1024;
pub const MAX_MESSAGES_BYTES: usize = 512 * 1024;
#[derive(Clone)]
pub struct SubagentControlHandle {
pub interrupt: Arc<dyn Fn() + Send + Sync>,
pub steer: Arc<dyn Fn(String) + Send + Sync>,
}
impl std::fmt::Debug for SubagentControlHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SubagentControlHandle")
}
}
#[derive(Clone, Debug)]
pub struct SubagentJob {
pub id: String,
pub agent: String,
pub source: String,
pub run_id: Option<String>,
pub node_id: Option<String>,
pub session_id: Option<String>,
pub status: SubagentJobStatus,
pub started_at: Option<i64>,
pub completed_at: Option<i64>,
pub attempt: u32,
pub total_attempts: u32,
pub input_tokens: u64,
pub output_tokens: u64,
pub chars: u64,
pub tools_called: u64,
pub turn: u32,
pub error: Option<String>,
pub output: String,
pub truncated: bool,
pub messages: Vec<serde_json::Value>,
pub messages_truncated: bool,
pub control: Option<SubagentControlHandle>,
}
impl SubagentJob {
fn new(
id: String,
agent: String,
source: String,
run_id: Option<String>,
node_id: Option<String>,
session_id: Option<String>,
) -> Self {
Self {
id,
agent,
source,
run_id,
node_id,
session_id,
status: SubagentJobStatus::Running,
started_at: Some(now_ms()),
completed_at: None,
attempt: 1,
total_attempts: 1,
input_tokens: 0,
output_tokens: 0,
chars: 0,
tools_called: 0,
turn: 0,
error: None,
output: String::new(),
truncated: false,
messages: Vec::new(),
messages_truncated: false,
control: None,
}
}
pub fn tps(&self) -> Option<f64> {
let elapsed = self.elapsed_secs()?;
if elapsed <= 0.0 {
return None;
}
Some(self.output_tokens as f64 / elapsed)
}
pub fn cps(&self) -> Option<f64> {
let elapsed = self.elapsed_secs()?;
if elapsed <= 0.0 {
return None;
}
Some(self.chars as f64 / elapsed)
}
fn elapsed_secs(&self) -> Option<f64> {
let end = self.completed_at.or(self.started_at)?;
let start = self.started_at?;
Some((end - start) as f64 / 1000.0)
}
}
impl From<&SubagentJob> for SubagentJobSnapshot {
fn from(job: &SubagentJob) -> Self {
Self {
id: job.id.clone(),
agent: job.agent.clone(),
source: job.source.clone(),
run_id: job.run_id.clone(),
node_id: job.node_id.clone(),
session_id: job.session_id.clone(),
status: job.status.as_str().to_string(),
started_at: job.started_at,
completed_at: job.completed_at,
attempt: job.attempt,
total_attempts: job.total_attempts,
input_tokens: job.input_tokens,
output_tokens: job.output_tokens,
chars: job.chars,
tools_called: job.tools_called,
turn: job.turn,
error: job.error.clone(),
output_tail: job.output.clone(),
truncated: job.truncated,
live_preview: None,
tps: job.tps(),
cps: job.cps(),
}
}
}
#[derive(Default)]
struct Inner {
jobs: Vec<SubagentJob>,
transcript_store: Option<Arc<dyn JobTranscriptStore>>,
session_transcript_stores: HashMap<Option<String>, Arc<dyn JobTranscriptStore>>,
}
#[derive(Clone)]
pub struct SubagentJobRegistry {
inner: Arc<Mutex<Inner>>,
observer: Arc<dyn RuntimeObserver>,
operations: Arc<Mutex<HashMap<String, OperationScope>>>,
events: tokio::sync::broadcast::Sender<SubagentJobEvent>,
}
pub struct SubagentJobInit {
pub agent: String,
pub source: String,
pub run_id: Option<String>,
pub node_id: Option<String>,
pub session_id: Option<String>,
}
impl SubagentJobRegistry {
pub fn new() -> Self {
Self::with_observer(noop_runtime_observer())
}
pub fn with_observer(observer: Arc<dyn RuntimeObserver>) -> Self {
let (events, _) = tokio::sync::broadcast::channel(SUBAGENT_JOB_EVENT_BROADCAST_CAPACITY);
Self {
inner: Arc::new(Mutex::new(Inner::default())),
observer,
operations: Arc::new(Mutex::new(HashMap::new())),
events,
}
}
pub fn observer(&self) -> Arc<dyn RuntimeObserver> {
Arc::clone(&self.observer)
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<SubagentJobEvent> {
self.events.subscribe()
}
pub fn set_transcript_store(&self, store: Option<Arc<dyn JobTranscriptStore>>) {
self.inner.lock().transcript_store = store;
}
pub fn set_session_transcript_store(
&self,
session_id: Option<String>,
store: Arc<dyn JobTranscriptStore>,
) {
self.inner
.lock()
.session_transcript_stores
.insert(session_id, store);
}
pub fn register(&self, init: SubagentJobInit) -> String {
self.register_observed(init, None)
}
pub fn register_observed(&self, init: SubagentJobInit, parent: Option<OperationId>) -> String {
let id = Uuid::now_v7().to_string();
let scope = OperationScope::start(
self.observer(),
parent,
ObservationContext {
session_id: init.session_id.clone(),
run_id: init.run_id.clone(),
job_id: Some(id.clone()),
node_id: init.node_id.clone(),
..ObservationContext::default()
},
OperationDetail::SubagentJob {
agent: init.agent.clone(),
source: init.source.clone(),
},
);
let mut inner = self.inner.lock();
inner.jobs.push(SubagentJob::new(
id.clone(),
init.agent.clone(),
init.source.clone(),
init.run_id.clone(),
init.node_id.clone(),
init.session_id.clone(),
));
Self::evict(&mut inner.jobs);
drop(inner);
self.operations.lock().insert(id.clone(), scope);
self.emit(SubagentJobEvent::Started {
id: id.clone(),
agent: init.agent,
source: init.source,
run_id: init.run_id,
node_id: init.node_id,
session_id: init.session_id,
});
id
}
pub fn operation_id(&self, id: &str) -> Option<OperationId> {
self.operations.lock().get(id).map(OperationScope::id)
}
pub fn update(&self, id: &str, f: impl FnOnce(&mut SubagentJob)) {
let mut inner = self.inner.lock();
if let Some(job) = inner.jobs.iter_mut().find(|j| j.id == id) {
f(job);
}
}
pub fn set_control(&self, id: &str, control: Option<SubagentControlHandle>) {
self.update(id, |job| job.control = control);
}
pub fn interrupt(&self, id: &str) -> bool {
let Some(control) = self.control_for(id) else {
return false;
};
(control.interrupt)();
true
}
pub fn steer(&self, id: &str, text: String) -> bool {
let Some(control) = self.control_for(id) else {
return false;
};
(control.steer)(text);
true
}
pub fn interrupt_node(&self, run_id: &str, node_id: &str) -> bool {
let Some(job) = self.find_node(run_id, node_id) else {
return false;
};
self.interrupt(&job.id)
}
pub fn steer_node(&self, run_id: &str, node_id: &str, text: String) -> bool {
let Some(job) = self.find_node(run_id, node_id) else {
return false;
};
self.steer(&job.id, text)
}
fn control_for(&self, id: &str) -> Option<SubagentControlHandle> {
self.inner
.lock()
.jobs
.iter()
.find(|j| j.id == id)?
.control
.clone()
}
pub fn job(&self, id: &str) -> Option<SubagentJob> {
let inner = self.inner.lock();
inner.jobs.iter().find(|j| j.id == id).cloned()
}
pub(crate) fn session_id(&self, id: &str) -> Option<String> {
self.inner
.lock()
.jobs
.iter()
.find(|j| j.id == id)
.and_then(|job| job.session_id.clone())
}
pub fn job_for_node(&self, run_id: &str, node_id: &str) -> Option<SubagentJob> {
let inner = self.inner.lock();
inner
.jobs
.iter()
.rev()
.find(|j| j.run_id.as_deref() == Some(run_id) && j.node_id.as_deref() == Some(node_id))
.cloned()
}
pub fn finish(&self, id: &str, status: SubagentJobStatus, error: Option<String>) {
self.update(id, |job| {
job.status = status;
job.error = error.clone();
job.completed_at = Some(now_ms());
job.control = None;
});
if let Some(job) = self.job(id) {
self.persist_messages(&job);
self.emit(SubagentJobEvent::Completed {
id: job.id.clone(),
status,
error: error.clone(),
chars: job.chars,
tokens_in: job.input_tokens,
tokens_out: job.output_tokens,
tools_called: job.tools_called,
session_id: job.session_id.clone(),
});
if let Some(mut scope) = self.operations.lock().remove(id) {
let timed_out = error.as_deref().is_some_and(|message| {
let message = message.to_ascii_lowercase();
message.contains("timed out") || message.contains("timeout")
});
let (outcome, category) = match status {
SubagentJobStatus::Running => {
(OperationOutcome::Abandoned, Some(ErrorCategory::Runtime))
}
SubagentJobStatus::Succeeded => (OperationOutcome::Succeeded, None),
SubagentJobStatus::Failed if timed_out => {
(OperationOutcome::TimedOut, Some(ErrorCategory::Timeout))
}
SubagentJobStatus::Failed => {
(OperationOutcome::Failed, Some(ErrorCategory::Runtime))
}
SubagentJobStatus::Cancelled => (
OperationOutcome::Cancelled,
Some(ErrorCategory::Cancellation),
),
SubagentJobStatus::Interrupted => (
OperationOutcome::Interrupted,
Some(ErrorCategory::Cancellation),
),
};
if self.observer().include_content() {
scope.attach_content(ObservationContent {
input: Some(serde_json::json!({
"agent": job.agent,
"source": job.source,
"runId": job.run_id,
"nodeId": job.node_id,
})),
output: Some(serde_json::json!({
"status": status.as_str(),
"error": error,
"output": job.output,
"outputTruncated": job.truncated,
"messages": job.messages,
"messagesTruncated": job.messages_truncated,
})),
});
}
scope.finish(
outcome,
category,
RuntimeMeasurements {
input_tokens: job.input_tokens,
output_tokens: job.output_tokens,
characters: job.chars,
turns: u64::from(job.turn),
tool_calls: job.tools_called,
..RuntimeMeasurements::default()
},
);
}
}
let mut inner = self.inner.lock();
Self::evict(&mut inner.jobs);
}
pub fn node_messages(&self, run_id: &str, node_id: &str) -> Option<Vec<serde_json::Value>> {
if let Some(job) = self.find_node(run_id, node_id) {
if !job.messages.is_empty() {
return Some(job.messages);
}
}
let store = self.inner.lock().transcript_store.clone()?;
store.load_node(run_id, node_id)
}
pub fn node_messages_for_session(
&self,
session_id: Option<&str>,
run_id: &str,
node_id: &str,
) -> Option<Vec<serde_json::Value>> {
let inner = self.inner.lock();
if let Some(job) = inner.jobs.iter().rev().find(|job| {
job.run_id.as_deref() == Some(run_id)
&& job.node_id.as_deref() == Some(node_id)
&& job.session_id.as_deref() == session_id
&& !job.messages.is_empty()
}) {
return Some(job.messages.clone());
}
let store = inner
.session_transcript_stores
.get(&session_id.map(str::to_string))
.cloned()
.or_else(|| inner.transcript_store.clone())?;
store.load_node(run_id, node_id)
}
pub fn job_messages(&self, job_id: &str) -> Option<Vec<serde_json::Value>> {
if let Some(job) = self.job(job_id) {
if !job.messages.is_empty() {
return Some(job.messages);
}
}
let store = self.inner.lock().transcript_store.clone()?;
store.load_job(job_id)
}
fn persist_messages(&self, job: &SubagentJob) {
if job.messages.is_empty() {
return;
}
let store = {
let inner = self.inner.lock();
inner
.session_transcript_stores
.get(&job.session_id)
.cloned()
.or_else(|| inner.transcript_store.clone())
};
let Some(store) = store else {
return;
};
store.save(&JobTranscript {
job_id: &job.id,
run_id: job.run_id.as_deref(),
node_id: job.node_id.as_deref(),
messages: &job.messages,
});
}
pub(crate) fn emit(&self, event: SubagentJobEvent) {
let _ = self.events.send(event);
}
pub fn find_node(&self, run_id: &str, node_id: &str) -> Option<SubagentJob> {
self.job_for_node(run_id, node_id)
}
pub fn list(&self) -> Vec<SubagentJob> {
let inner = self.inner.lock();
let mut jobs = inner.jobs.clone();
jobs.reverse();
jobs
}
pub fn snapshot_for_session(&self, session_id: Option<&str>) -> Vec<SubagentJobSnapshot> {
self.list()
.into_iter()
.filter(|job| job.session_id.as_deref() == session_id)
.map(|job| SubagentJobSnapshot::from(&job))
.collect()
}
fn evict(jobs: &mut Vec<SubagentJob>) {
while jobs.len() > MAX_JOBS {
let Some(idx) = jobs
.iter()
.position(|job| job.status != SubagentJobStatus::Running)
else {
break;
};
jobs.remove(idx);
}
}
}
fn now_ms() -> i64 {
Utc::now().timestamp_millis()
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("multiagent/jobs");