pub mod render;
pub use render::{render_system, slots_from_prompt, RenderError};
use crate::core::ctx::Ctx;
use crate::core::engine::Engine;
use crate::types::{CapToken, StepId, WorkerId};
use crate::worker::adapter::{SpawnError, SpawnerAdapter, WorkerError, WorkerResult};
use crate::worker::output::{ContentRef, OutputEvent};
use crate::worker::{Worker, WorkerJoinHandler};
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WorkerBinding {
#[serde(alias = "subagent_type")]
pub variant: String,
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_digest: Option<crate::blueprint::BindingDigest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_model: Option<String>,
}
#[async_trait]
pub trait Operator: Send + Sync {
async fn execute(
&self,
ctx: &Ctx,
system: Option<String>,
prompt: Value,
worker: Option<WorkerBinding>,
worker_token: CapToken,
) -> Result<WorkerResult, WorkerError>;
fn requires_worker_binding(&self) -> bool {
false
}
}
pub trait OperatorSlotResolver: Send + Sync {
fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>>;
}
pub struct OperatorSpawner {
operator: Arc<dyn Operator>,
system_prompt: Option<String>,
worker_binding: Option<WorkerBinding>,
}
impl OperatorSpawner {
pub fn new(
operator: Arc<dyn Operator>,
system_prompt: Option<String>,
worker_binding: Option<WorkerBinding>,
) -> Self {
Self {
operator,
system_prompt,
worker_binding,
}
}
}
#[async_trait]
impl SpawnerAdapter for OperatorSpawner {
async fn spawn(
&self,
engine: &Engine,
ctx: &Ctx,
task_id: StepId,
attempt: u32,
token: CapToken,
) -> Result<Box<dyn Worker>, SpawnError> {
let prompt = engine
.fetch_prompt(&token, &task_id)
.await
.map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
let system = match self.system_prompt.as_deref() {
Some(tmpl) => {
let slots = render::slots_from_prompt(&prompt);
let rendered = render::render_system(tmpl, &slots)
.map_err(|e| SpawnError::Internal(format!("render system_prompt: {e}")))?;
Some(rendered)
}
None => None,
};
engine
.bake_worker_system_prompt(&task_id, attempt, system.clone())
.await
.map_err(|e| SpawnError::Internal(format!("bake system_prompt: {e}")))?;
let op = self.operator.clone();
let engine_clone = engine.clone();
let token_clone = token.clone();
let token_for_op = token.clone();
let task_id_clone = task_id.clone();
let ctx_clone = ctx.clone();
let worker_binding = self.worker_binding.clone();
let (tx, rx) = oneshot::channel();
let cancel = CancellationToken::new();
let cancel_inner = cancel.clone();
let worker_id = WorkerId::new();
tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (operator spawner)");
tokio::spawn(async move {
let result: Result<WorkerResult, WorkerError> = tokio::select! {
r = op.execute(&ctx_clone, system, prompt, worker_binding, token_for_op) => r,
_ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
};
let result = result.map(|wr| wr.ensure_worker_kind("operator"));
if let Ok(wr) = &result {
if let Some(stats) = wr.stats.clone() {
engine_clone
.record_worker_stats(&task_id_clone, attempt, stats)
.await;
}
}
if let Ok(wr) = &result {
let tail = engine_clone.output_tail(&task_id_clone, attempt).await;
let has_final = tail
.iter()
.any(|ev| matches!(ev, OutputEvent::Final { .. }));
if !has_final {
let ev = OutputEvent::Final {
content: ContentRef::Inline {
value: wr.value.clone(),
},
ok: wr.ok,
};
let submit_token = if token_clone.is_expired(crate::types::now_unix()) {
match engine_clone.remint_worker_token(&token_clone).await {
Ok(fresh) => fresh,
Err(e) => {
tracing::error!(
step_id = %task_id_clone,
attempt,
error = %e,
"operator fallback Final dropped: the worker capability \
lapsed while the spawn frame was parked and could not be \
re-minted; this attempt has no Final"
);
let _ = tx.send(result.map(|_| ()));
return;
}
}
} else {
token_clone.clone()
};
if let Err(e) = engine_clone
.submit_output(&submit_token, &task_id_clone, attempt, ev)
.await
{
if matches!(
e,
crate::core::errors::EngineError::VerdictValueRejected { .. }
| crate::core::errors::EngineError::VerdictPartMissing { .. }
) {
tracing::warn!(
step_id = %task_id_clone,
attempt,
error = %e,
"operator fallback Final rejected by verdict-contract \
completion gate"
);
} else {
tracing::error!(
step_id = %task_id_clone,
attempt,
error = %e,
"operator fallback Final was not written; this attempt has \
no Final"
);
}
}
}
}
let signal: Result<(), WorkerError> = result.map(|_| ());
let _ = tx.send(signal);
});
Ok(Box::new(OperatorWorker {
handler: WorkerJoinHandler {
worker_id,
cancel,
completion: rx,
},
}))
}
}
pub struct OperatorWorker {
pub handler: WorkerJoinHandler,
}
#[async_trait]
impl Worker for OperatorWorker {
fn id(&self) -> &WorkerId {
&self.handler.worker_id
}
fn cancel_token(&self) -> CancellationToken {
self.handler.cancel.clone()
}
async fn join(self: Box<Self>) -> Result<(), WorkerError> {
self.handler.await_completion().await
}
}
#[cfg(test)]
mod parked_fallback_capability_tests {
use super::*;
use crate::core::config::EngineCfg;
use crate::core::state::TaskSpec;
use crate::types::Role;
use crate::worker::adapter::SpawnerAdapter;
use std::time::Duration;
const TTL_SECS: u64 = 1;
struct SilentOperator {
hold: Duration,
}
#[async_trait]
impl Operator for SilentOperator {
async fn execute(
&self,
_ctx: &Ctx,
_system: Option<String>,
_prompt: Value,
_worker: Option<WorkerBinding>,
_worker_token: CapToken,
) -> Result<WorkerResult, WorkerError> {
tokio::time::sleep(self.hold).await;
Ok(WorkerResult {
value: serde_json::json!({"held": true}),
ok: true,
stats: None,
})
}
}
async fn dispatch_holding_for(hold: Duration) -> (Engine, StepId) {
let engine = Engine::new(EngineCfg {
worker_token_ttl_secs: TTL_SECS,
..EngineCfg::default()
});
let op_token = engine
.attach(
"op-parked-fallback",
Role::Operator,
Duration::from_secs(600),
)
.await
.expect("attach");
let task_id = engine
.start_task(
&op_token,
TaskSpec {
agent: "held-agent".to_string(),
initial_directive: Value::String("go".to_string()),
step_ctx: None,
check_policy: None,
},
)
.await
.expect("start_task");
let spawner: Arc<dyn SpawnerAdapter> = Arc::new(OperatorSpawner::new(
Arc::new(SilentOperator { hold }),
None,
None,
));
engine
.dispatch_attempt_with(&op_token, &task_id, &spawner, None)
.await
.expect("dispatch_attempt_with");
(engine, task_id)
}
async fn records_bound_to(engine: &Engine, task_id: &StepId) -> usize {
let wanted = task_id.clone();
engine
.with_state("test.count_bound_records", move |s| {
s.tokens
.values()
.filter(|r| r.task_id.as_ref() == Some(&wanted))
.count()
})
.await
.expect("read token records")
}
fn has_final(tail: &[OutputEvent]) -> bool {
tail.iter()
.any(|ev| matches!(ev, OutputEvent::Final { .. }))
}
#[tokio::test]
async fn a_hold_past_the_ttl_still_lands_the_fallback_final() {
let (engine, task_id) =
dispatch_holding_for(Duration::from_millis(TTL_SECS * 1000 + 500)).await;
let tail = engine.output_tail(&task_id, 1).await;
assert!(
has_final(&tail),
"the fallback Final must survive a hold longer than the worker-token TTL, \
got tail: {tail:?}"
);
assert_eq!(
records_bound_to(&engine, &task_id).await,
2,
"the surviving Final must be the re-minted capability's doing — one record \
for the spawn token, one for the reissue"
);
}
#[tokio::test]
async fn a_dispatch_inside_the_ttl_lands_its_final_without_re_minting() {
let (engine, task_id) = dispatch_holding_for(Duration::from_millis(10)).await;
let tail = engine.output_tail(&task_id, 1).await;
assert!(
has_final(&tail),
"an un-parked operator's fallback Final must land unchanged, got tail: {tail:?}"
);
assert_eq!(
records_bound_to(&engine, &task_id).await,
1,
"a live token needs no reissue — only the spawn token should be on record"
);
}
}