use crate::core::agent_context::AgentContextView;
use crate::core::ctx::Ctx;
use crate::core::engine::Engine;
use crate::types::{CapToken, StepId};
use crate::worker::Worker;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SpawnError {
#[error("worker not registered: {0}")]
NotRegistered(String),
#[error("spawn rejected by middleware: {0}")]
RejectedByMiddleware(String),
#[error("internal: {0}")]
Internal(String),
}
#[derive(Debug, Error)]
pub enum WorkerError {
#[error("worker fn returned error: {0}")]
Failed(String),
#[error("cancelled")]
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WorkerResult {
pub value: Value,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<crate::store::trace::WorkerStats>,
}
impl WorkerResult {
pub fn ensure_worker_kind(mut self, kind: &str) -> Self {
let stats = self
.stats
.get_or_insert_with(crate::store::trace::WorkerStats::default);
if stats.worker_kind.is_none() {
stats.worker_kind = Some(kind.to_string());
}
self
}
}
#[async_trait]
pub trait SpawnerAdapter: Send + Sync {
async fn spawn(
&self,
engine: &Engine,
ctx: &Ctx,
task_id: StepId,
attempt: u32,
token: CapToken,
) -> Result<Box<dyn Worker>, SpawnError>;
}
#[derive(Clone)]
#[non_exhaustive]
pub struct WorkerInvocation {
pub token: CapToken,
pub task_id: StepId,
pub attempt: u32,
pub agent: String,
pub prompt: String,
pub sink: Option<std::sync::Arc<dyn crate::worker::output::OutputSink>>,
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
pub context: Option<AgentContextView>,
}
impl WorkerInvocation {
pub fn new(
token: CapToken,
task_id: StepId,
attempt: u32,
agent: impl Into<String>,
prompt: impl Into<String>,
) -> Self {
Self {
token,
task_id,
attempt,
agent: agent.into(),
prompt: prompt.into(),
sink: None,
cancel_token: None,
context: None,
}
}
pub fn with_sink(
mut self,
sink: std::sync::Arc<dyn crate::worker::output::OutputSink>,
) -> Self {
self.sink = Some(sink);
self
}
pub fn with_cancel_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
self.cancel_token = Some(token);
self
}
pub fn with_context(mut self, context: AgentContextView) -> Self {
self.context = Some(context);
self
}
}
impl std::fmt::Debug for WorkerInvocation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkerInvocation")
.field("token", &self.token)
.field("task_id", &self.task_id)
.field("attempt", &self.attempt)
.field("agent", &self.agent)
.field("prompt", &self.prompt)
.field("sink", &self.sink.as_ref().map(|_| "<OutputSink>"))
.field(
"cancel_token",
&self.cancel_token.as_ref().map(|_| "<CancellationToken>"),
)
.finish()
}
}
pub type WorkerFn = Arc<
dyn Fn(
WorkerInvocation,
) -> Pin<Box<dyn Future<Output = Result<WorkerResult, WorkerError>> + Send>>
+ Send
+ Sync,
>;
pub struct InProcSpawner<W = crate::worker::MiddlewareWorker> {
pub registry: HashMap<String, WorkerFn>,
_phantom: std::marker::PhantomData<W>,
}
impl InProcSpawner {
pub fn new() -> Self {
Self {
registry: HashMap::new(),
_phantom: std::marker::PhantomData,
}
}
pub fn register<F, Fut>(&mut self, agent: impl Into<String>, f: F) -> &mut Self
where
F: Fn(WorkerInvocation) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<WorkerResult, WorkerError>> + Send + 'static,
{
let f = Arc::new(f);
let wrapped: WorkerFn = Arc::new(move |inv| {
let f = f.clone();
Box::pin(f(inv))
});
self.registry.insert(agent.into(), wrapped);
self
}
}
impl<W> InProcSpawner<W>
where
W: Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
{
pub fn typed() -> Self {
Self {
registry: HashMap::new(),
_phantom: std::marker::PhantomData,
}
}
}
impl Default for InProcSpawner {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl<W: Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static> SpawnerAdapter
for InProcSpawner<W>
{
async fn spawn(
&self,
engine: &Engine,
ctx: &Ctx,
task_id: StepId,
attempt: u32,
token: CapToken,
) -> Result<Box<dyn Worker>, SpawnError> {
let f = self
.registry
.get(&ctx.agent)
.cloned()
.ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
let prompt = engine
.fetch_prompt(&token, &task_id)
.await
.map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
let prompt = crate::core::engine::render_directive_to_string(&prompt);
let (tx, rx) = tokio::sync::oneshot::channel();
let cancel = tokio_util::sync::CancellationToken::new();
let cancel_inner = cancel.clone();
let worker_id = crate::types::WorkerId::new();
tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (rustfn)");
let engine_for_emit = engine.clone();
let token_for_emit = token.clone();
let task_id_for_emit = task_id.clone();
let sink = std::sync::Arc::new(crate::worker::output::EngineSink::new(
engine.clone(),
token.clone(),
task_id.clone(),
attempt,
)) as std::sync::Arc<dyn crate::worker::output::OutputSink>;
let inv = WorkerInvocation::new(token, task_id, attempt, ctx.agent.clone(), prompt)
.with_sink(sink)
.with_cancel_token(cancel_inner.clone())
.with_context(AgentContextView::materialized_or_from_ctx(ctx));
tokio::spawn(async move {
let result = tokio::select! {
r = f(inv) => r,
_ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
};
let mut emit_rejection: Option<String> = None;
if let Ok(wr) = &result {
if let Some(stats) = wr.stats.clone() {
engine_for_emit
.record_worker_stats(&task_id_for_emit, attempt, stats)
.await;
}
let ev = crate::worker::output::OutputEvent::Final {
content: crate::worker::output::ContentRef::Inline {
value: wr.value.clone(),
},
ok: wr.ok,
};
if let Err(e) = engine_for_emit
.submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
.await
{
let blocks_the_final = matches!(
e,
crate::EngineError::VerdictValueRejected { .. }
| crate::EngineError::VerdictPartMissing { .. }
);
tracing::warn!(
step_id = %task_id_for_emit,
attempt,
error = %e,
blocks_the_final,
"in-process worker's Final submission returned an error"
);
if blocks_the_final {
emit_rejection = Some(e.to_string());
}
}
}
let signal: Result<(), WorkerError> = match emit_rejection {
Some(reason) => Err(WorkerError::Failed(format!(
"Final rejected before output_tail: {reason}"
))),
None => result.map(|_| ()),
};
let _ = tx.send(signal);
});
let handler = crate::worker::WorkerJoinHandler {
worker_id,
cancel,
completion: rx,
};
Ok(Box::new(W::from(handler)))
}
}