Skip to main content

agentd/runtime/
steps.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Runs and steps** (RFC 0027 §6–§7, RFC 0026 §3): arming start nodes,
3//! turning start events into durable runs, scheduling ready steps every tick,
4//! executing the P3 step kinds (data steps in-loop, MCP calls on executor
5//! threads, `agent`/`think` in turn workers, `sleep` on durable timers,
6//! `finish` closing the run), retries + `on_error` routing, and the
7//! `workflow.*` tools.
8
9use super::children::ChildKind;
10use super::events::kinds;
11use super::reactor::{PendingKind, Runtime, Target};
12use super::tools::{ToolCaller, ToolOutcome};
13use crate::context::Msg;
14use crate::engine::model::{OnError, Step, Workflow, parse_workflow};
15use crate::engine::run::{
16    self, Next, RunState, RunStatus, Start, StepStatus, env_view, render_spec,
17};
18use crate::engine::template;
19use crate::governor::Admission;
20use crate::registry::Caller;
21use crate::state::{InboxEvent, Kind, now_ms, ulid};
22use crate::subagent::protocol::{TurnKind, TurnResult, TurnSpec};
23use serde_json::{Map, Value, json};
24use std::collections::BTreeMap;
25
26/// The memory key prefix runtime-created workflow definitions are stored under.
27const WORKFLOW_DEF_PREFIX: &str = "_workflows/";
28
29impl Runtime {
30    // ---- definitions -----------------------------------------------------------
31
32    /// Load the configured workflows (inline / file / uri) + runtime-created
33    /// ones from the store. Errors are collected (a bad definition is refused).
34    pub(crate) fn load_workflows(&mut self) -> Result<(), Vec<String>> {
35        let mut errs = Vec::new();
36        let docs = self.settings.workflows.clone();
37        for doc in docs {
38            let resolved = match (
39                doc.get("file").and_then(Value::as_str),
40                doc.get("uri").and_then(Value::as_str),
41            ) {
42                (Some(path), _) => match std::fs::read_to_string(path)
43                    .map_err(|e| e.to_string())
44                    .and_then(|t| {
45                        crate::config::file::parse_document(
46                            &t,
47                            crate::config::file::Format::detect(
48                                Some(std::path::Path::new(path)),
49                                &t,
50                            ),
51                        )
52                    }) {
53                    Ok(mut d) => {
54                        if d.get("name").is_none()
55                            && let Some(n) = doc.get("name")
56                        {
57                            d["name"] = n.clone();
58                        }
59                        d
60                    }
61                    Err(e) => {
62                        errs.push(format!("workflow file {path}: {e}"));
63                        continue;
64                    }
65                },
66                (None, Some(uri)) => match self.read_resource_any(uri) {
67                    Ok(text) => match crate::config::file::parse_document(
68                        &text,
69                        crate::config::file::Format::detect(Some(std::path::Path::new(uri)), &text),
70                    ) {
71                        Ok(mut d) => {
72                            if d.get("name").is_none()
73                                && let Some(n) = doc.get("name")
74                            {
75                                d["name"] = n.clone();
76                            }
77                            d
78                        }
79                        Err(e) => {
80                            errs.push(format!("workflow uri {uri}: {e}"));
81                            continue;
82                        }
83                    },
84                    Err(e) => {
85                        errs.push(format!("workflow uri {uri}: {e}"));
86                        continue;
87                    }
88                },
89                _ => doc.clone(),
90            };
91            match parse_workflow(&resolved) {
92                Ok(w) => {
93                    self.log.info("workflow.loaded", json!({"name": w.name, "hash": &w.hash[..12], "steps": w.steps.len(), "starts": w.start_steps().iter().map(|s| s.kind.clone()).collect::<Vec<_>>()}));
94                    self.workflows.insert(w.name.clone(), w);
95                }
96                Err(e) => errs.extend(e),
97            }
98        }
99        // Runtime-created definitions (durable under memory/_workflows/<name>).
100        if let Ok(list) = self.durable.list(Kind::Memory) {
101            for ks in list {
102                let Some((_, id)) = crate::store::parse_key(
103                    self.durable.prefix(),
104                    self.durable.instance(),
105                    &ks.key,
106                ) else {
107                    continue;
108                };
109                if let Some(name) = id.strip_prefix(WORKFLOW_DEF_PREFIX)
110                    && !self.workflows.contains_key(name)
111                    && let Ok(Some(env)) = self.durable.get(Kind::Memory, id)
112                    && let Some(def) = env.state.get("value")
113                {
114                    match parse_workflow(def) {
115                        Ok(w) => {
116                            self.log.info(
117                                "workflow.loaded",
118                                json!({"name": w.name, "source": "store"}),
119                            );
120                            self.workflows.insert(w.name.clone(), w);
121                        }
122                        Err(e) => self.log.warn(
123                            "workflow.stored.invalid",
124                            json!({"name": name, "errors": e}),
125                        ),
126                    }
127                }
128            }
129        }
130        // Validate tool/server references against the registry.
131        for w in self.workflows.values() {
132            for s in w.steps.values() {
133                match s.kind.as_str() {
134                    "tool" => {
135                        if let Some(n) = s.field_str("name")
136                            && !self.registry.allowed(&Caller::Workflow, n)
137                        {
138                            errs.push(format!("workflow {:?} step {:?}: tool {n:?} is unknown, disabled or not granted to workflows", w.name, s.id));
139                        }
140                    }
141                    "mcp.tool" => {
142                        if let Some(srv) = s.field_str("server")
143                            && !self.mcp.contains_key(srv)
144                        {
145                            errs.push(format!(
146                                "workflow {:?} step {:?}: mcp server {srv:?} is not connected",
147                                w.name, s.id
148                            ));
149                        }
150                    }
151                    k if (k.starts_with("memory.")
152                        || k.starts_with("artifact.")
153                        || k.starts_with("knowledge.")
154                        || k.starts_with("search."))
155                        && !self.registry.allowed(&Caller::Workflow, k) =>
156                    {
157                        errs.push(format!("workflow {:?} step {:?}: {k} is unavailable (map it with tools.overrides or configure its server)", w.name, s.id));
158                    }
159                    _ => {}
160                }
161            }
162        }
163        if errs.is_empty() { Ok(()) } else { Err(errs) }
164    }
165
166    /// Read a resource URI (`mcp://<server>/<uri>` or a URI a connected server lists).
167    pub(crate) fn read_resource_any(&self, uri: &str) -> Result<String, String> {
168        if let Some(rest) = uri.strip_prefix("mcp://") {
169            let (server, res) = rest
170                .split_once('/')
171                .ok_or("mcp:// uri needs <server>/<resource-uri>")?;
172            let c = self
173                .mcp
174                .get(server)
175                .ok_or_else(|| format!("mcp server {server:?} is not connected"))?;
176            return c
177                .read_resource(res)
178                .map(|r| r.text())
179                .map_err(|e| e.to_string());
180        }
181        let mut last = String::from("no connected server serves it");
182        for c in self.mcp.values() {
183            match c.read_resource(uri) {
184                Ok(r) => return Ok(r.text()),
185                Err(e) => last = e.to_string(),
186            }
187        }
188        Err(last)
189    }
190
191    /// Arm start nodes (RFC 0027 §4): `once` fires now unless a live run of the
192    /// workflow was restored (`policy: ensure`, default) — `always` fires anyway.
193    pub(crate) fn arm_workflows(&mut self) {
194        let names: Vec<String> = self.workflows.keys().cloned().collect();
195        for name in names {
196            let Some(w) = self.workflows.get(&name) else {
197                continue;
198            };
199            if !w.armed {
200                continue;
201            }
202            let starts: Vec<(String, String, Map<String, Value>)> = w
203                .start_steps()
204                .iter()
205                .map(|s| (s.id.clone(), s.kind.clone(), s.spec.clone()))
206                .collect();
207            for (id, kind, spec) in starts {
208                match kind.as_str() {
209                    "once" => {
210                        let policy = spec
211                            .get("policy")
212                            .and_then(Value::as_str)
213                            .unwrap_or("ensure");
214                        let live = self
215                            .runs
216                            .values()
217                            .any(|r| r.workflow == name && !r.status.is_terminal());
218                        let ever = self
219                            .runs
220                            .values()
221                            .any(|r| r.workflow == name && r.start.node == id);
222                        // A replayed (still pending) firing counts too — never fire twice.
223                        let pending = self.inbox_queue.iter().any(|e| {
224                            e.kind == kinds::START_FIRED
225                                && e.payload["workflow"] == name.as_str()
226                                && e.payload["node"] == id.as_str()
227                        });
228                        if policy == "ensure" && (live || ever || pending) {
229                            self.log.info("start.once.skipped", json!({"workflow": name, "node": id, "live": live, "pending": pending}));
230                            continue;
231                        }
232                        let inputs = spec.get("inputs").cloned().unwrap_or(json!({}));
233                        let _ = self.accept_event(kinds::START_FIRED, None, json!({"workflow": name, "node": id, "payload": {"fired_at": now_ms()}, "inputs": inputs}));
234                    }
235                    "manual" => {}
236                    // Long-lived starts are armed by arm_long_lived_starts.
237                    _ => {}
238                }
239            }
240        }
241    }
242
243    /// A start event → a run. Returns `true` when the event is consumed.
244    pub(crate) fn on_start_event(&mut self, ev: &InboxEvent) -> bool {
245        let name = ev.payload["workflow"].as_str().unwrap_or("").to_string();
246        let Some(w) = self.workflows.get(&name).cloned() else {
247            self.log.warn(
248                "start.unknown_workflow",
249                json!({"inbox_event": ev.id, "workflow": name}),
250            );
251            return true;
252        };
253        let node = ev.payload["node"]
254            .as_str()
255            .map(str::to_string)
256            .unwrap_or_else(|| default_start(&w).unwrap_or_default());
257        // Concurrency (RFC 0027 §2).
258        let live = self
259            .runs
260            .values()
261            .filter(|r| r.workflow == name && !r.status.is_terminal())
262            .count() as u32;
263        let global_live = self
264            .runs
265            .values()
266            .filter(|r| !r.status.is_terminal())
267            .count() as u32;
268        if live >= w.concurrency.max_runs
269            || global_live >= self.settings.limits.max_runs.unwrap_or(8)
270        {
271            match w.concurrency.on_overflow {
272                crate::engine::model::OnOverflow::Queue => {
273                    // Keep the event pending; it is retried on a LATER tick.
274                    // Nothing this tick can relieve the cap — only a live run
275                    // reaching a terminal status in `schedule_runs` does, and
276                    // that step has not run yet — so this must not be re-offered
277                    // now. `process_inbox` drains a snapshot for exactly that
278                    // reason: this push lands on the next tick's queue.
279                    self.inbox_queue.push_back(ev.clone());
280                    return false;
281                }
282                crate::engine::model::OnOverflow::Drop => {
283                    self.log.warn(
284                        "run.dropped",
285                        json!({"workflow": name, "reason": "concurrency"}),
286                    );
287                    return true;
288                }
289                crate::engine::model::OnOverflow::Replace => {
290                    if let Some(oldest) = self
291                        .runs
292                        .values()
293                        .filter(|r| r.workflow == name && !r.status.is_terminal())
294                        .min_by_key(|r| r.created)
295                        .map(|r| r.id.clone())
296                    {
297                        self.cancel_run(&oldest, "replaced by a newer run");
298                    }
299                }
300            }
301        }
302        // Inputs.
303        let inputs = ev.payload.get("inputs").cloned().unwrap_or(json!({}));
304        if let Some(schema) = &w.inputs_schema
305            && let Err(e) = crate::jsonschema::validate(schema, &inputs)
306        {
307            self.log
308                .warn("run.inputs.invalid", json!({"workflow": name, "errors": e}));
309            return true;
310        }
311        // A2A pre-generates the run id so its task can link before the run starts.
312        let run_id = ev
313            .payload
314            .get("run_id")
315            .and_then(Value::as_str)
316            .map(str::to_string)
317            .unwrap_or_else(|| format!("{}-{}", name, ulid::new()));
318        let mut run = RunState::new(
319            &run_id,
320            &w,
321            Start {
322                node: node.clone(),
323                payload: ev.payload.get("payload").cloned().unwrap_or(Value::Null),
324                ts: now_ms(),
325            },
326            inputs,
327        );
328        run.principal = ev.principal.clone();
329        run.parent = ev.payload.get("parent").cloned().filter(|p| !p.is_null());
330        run.conversation = ev
331            .payload
332            .get("conversation")
333            .and_then(Value::as_str)
334            .map(str::to_string);
335        run.task = ev
336            .payload
337            .get("task")
338            .and_then(Value::as_str)
339            .map(str::to_string);
340        // Durable before anything runs.
341        if let Err(e) = self.durable.put(
342            Kind::Run,
343            &run_id,
344            serde_json::to_value(&run).unwrap_or(Value::Null),
345            Some(w.hash.clone()),
346        ) {
347            self.log.error(
348                "run.create.fail",
349                json!({"workflow": name, "err": e.to_string()}),
350            );
351            // The event stays pending in the DURABLE inbox (only a consumed
352            // start is marked done), so dropping it from the in-memory queue
353            // here would make the start silently vanish until a restart
354            // replays it. Requeue it like an overflow: the next tick retries.
355            self.inbox_queue.push_back(ev.clone());
356            return false;
357        }
358        run.dirty = false;
359        self.log.info(
360            "run.start",
361            json!({"run": run_id, "workflow": name, "node": node, "inbox_event": ev.id}),
362        );
363        self.counters.runs_started += 1;
364        crate::obs::metrics::record_run_started();
365        if node_kind(&w, &node) == Some("once") && self.job_shape {
366            self.job_runs.push(run_id.clone());
367        }
368        // Answer a `workflow.run` waiter that asked for the id.
369        if ev.kind == kinds::WORKFLOW_RUN
370            && let Some(req) = ev.payload.get("request").and_then(Value::as_object)
371        {
372            let target = match (
373                req.get("node").and_then(Value::as_u64),
374                req.get("req").and_then(Value::as_u64),
375                req.get("run").and_then(Value::as_str),
376                req.get("step").and_then(Value::as_str),
377            ) {
378                (Some(n), Some(r), _, _) => {
379                    Some(Target::Child(crate::supervisor::tree::NodeId(n), r))
380                }
381                (None, None, Some(r), Some(s)) => Some(Target::Step(r.to_string(), s.to_string())),
382                _ => None,
383            };
384            if let Some(t) = target {
385                if req.get("wait").and_then(Value::as_bool).unwrap_or(false) {
386                    let deadline = now_ms()
387                        + req
388                            .get("timeout_ms")
389                            .and_then(Value::as_u64)
390                            .unwrap_or(3_600_000);
391                    self.pending.push(super::reactor::PendingTool {
392                        target: t,
393                        name: "workflow.run".into(),
394                        kind: PendingKind::Run {
395                            run: run_id.clone(),
396                            deadline_ms: deadline,
397                        },
398                        started_ms: now_ms(),
399                    });
400                } else {
401                    self.reply(
402                        &t,
403                        json!({"run": run_id, "status": "running", "workflow": name}),
404                        false,
405                    );
406                }
407            }
408        }
409        self.runs.insert(run_id, run);
410        true
411    }
412
413    // ---- scheduling ------------------------------------------------------------
414
415    /// Every tick: advance every live run.
416    pub(crate) fn schedule_runs(&mut self) {
417        if self.paused {
418            return; // operator hold (a2a.pause) — steps park until resume
419        }
420        let ids: Vec<String> = self
421            .runs
422            .iter()
423            .filter(|(_, r)| !r.status.is_terminal() && r.status != RunStatus::Paused)
424            .map(|(id, _)| id.clone())
425            .collect();
426        for id in ids {
427            self.schedule_run(&id);
428        }
429    }
430
431    /// The definition a run executes against: the one it started with (by
432    /// hash) — the current definition when unchanged, else the pinned copy a
433    /// reload kept. A restored run whose definition changed underneath it has
434    /// no match (`resume_policy: refuse`, RFC 0027 §9).
435    pub(crate) fn definition_for_run(&self, run_id: &str) -> Option<Workflow> {
436        let run = self.runs.get(run_id)?;
437        if let Some(w) = self.workflows.get(&run.workflow)
438            && w.hash == run.workflow_hash
439        {
440            return Some(w.clone());
441        }
442        self.pinned.get(&run.workflow_hash).cloned()
443    }
444
445    fn schedule_run(&mut self, run_id: &str) {
446        let Some(wf) = self.definition_for_run(run_id) else {
447            // The definition vanished or changed (hash mismatch): refuse to
448            // continue the run (`resume_policy: refuse`).
449            let (name, hash) = self
450                .runs
451                .get(run_id)
452                .map(|r| (r.workflow.clone(), r.workflow_hash.clone()))
453                .unwrap_or_default();
454            let reason = if self.workflows.contains_key(&name) {
455                format!(
456                    "workflow {name:?} definition changed (run pinned to hash {}); resume_policy refuse",
457                    &hash[..hash.len().min(12)]
458                )
459            } else {
460                format!("workflow {name:?} definition is gone")
461            };
462            self.log
463                .warn("run.refused", json!({"run": run_id, "reason": reason}));
464            if let Some(r) = self.runs.get_mut(run_id) {
465                r.finish(RunStatus::Refused, None, Some(reason));
466            }
467            self.on_run_terminal(run_id);
468            return;
469        };
470        if let Some(r) = self.runs.get(run_id)
471            && run::deadline_passed(r)
472        {
473            self.log.warn("run.deadline", json!({"run": run_id}));
474            self.cancel_children_of_run(run_id, "run deadline");
475            self.runs.get_mut(run_id).expect("present").finish(
476                RunStatus::Failed,
477                None,
478                Some("deadline exceeded".into()),
479            );
480            self.on_run_terminal(run_id);
481            return;
482        }
483        if let Some(cap) = wf.limits.steps
484            && self.runs.get(run_id).is_some_and(|r| r.steps_run >= cap)
485        {
486            self.runs.get_mut(run_id).expect("present").finish(
487                RunStatus::Failed,
488                None,
489                Some(format!("exhausted steps: limits.steps = {cap}")),
490            );
491            self.on_run_terminal(run_id);
492            return;
493        }
494        if let Some(cap) = wf.limits.tokens
495            && self.runs.get(run_id).is_some_and(|r| r.tokens >= cap)
496        {
497            self.runs.get_mut(run_id).expect("present").finish(
498                RunStatus::Failed,
499                None,
500                Some(format!("exhausted tokens: limits.tokens = {cap}")),
501            );
502            self.on_run_terminal(run_id);
503            return;
504        }
505        let data = self.run_data(run_id);
506        let next = {
507            let run = self.runs.get_mut(run_id).expect("present");
508            run::schedule(&wf, run, &data)
509        };
510        // Nested parents in flight advance every tick (rate pacing, timeouts,
511        // fresh iterations).
512        let nested: Vec<String> = self
513            .runs
514            .get(run_id)
515            .map(|r| {
516                r.steps
517                    .iter()
518                    .filter(|(_, st)| {
519                        st.status == StepStatus::Running
520                            && st.wait.as_ref().is_some_and(|w| {
521                                matches!(
522                                    w["kind"].as_str(),
523                                    Some("foreach")
524                                        | Some("batch")
525                                        | Some("iterate")
526                                        | Some("parallel")
527                                        | Some("race")
528                                        | Some("subgraph")
529                                )
530                            })
531                    })
532                    .map(|(id, _)| id.clone())
533                    .collect()
534            })
535            .unwrap_or_default();
536        for id in nested {
537            self.nested_advance(run_id, &id);
538        }
539        match next {
540            Ok(Next::Ready(steps)) => {
541                for s in steps {
542                    if self.draining {
543                        return;
544                    }
545                    self.execute_step(run_id, &s);
546                }
547            }
548            Ok(Next::Waiting) | Ok(Next::Terminal) => {}
549            Ok(Next::Stalled) => {
550                self.log.warn("run.stalled", json!({"run": run_id}));
551                self.runs.get_mut(run_id).expect("present").finish(
552                    RunStatus::Stalled,
553                    None,
554                    Some("no ready step and no finish reached".into()),
555                );
556                self.on_run_terminal(run_id);
557            }
558            Err(e) => {
559                self.runs.get_mut(run_id).expect("present").finish(
560                    RunStatus::Failed,
561                    None,
562                    Some(e),
563                );
564                self.on_run_terminal(run_id);
565            }
566        }
567    }
568
569    /// The template data of a run (RFC 0027 §3): memory is a read-through map
570    /// of the keys the workflow references would be nicer; here the whole
571    /// (bounded) memory listing is offered lazily as `{key: value}`.
572    pub(crate) fn run_data(&mut self, run_id: &str) -> template::Data {
573        let env = env_view(
574            &self.instance,
575            run_id,
576            Some(&self.instruction.text),
577            self.settings.agent.prompt.as_deref(),
578        );
579        // Memory read-through: resolve every `memory.<key>` the definition names.
580        let mut memory = Map::new();
581        if let Some(wf) = self.definition_for_run(run_id) {
582            let mut keys: Vec<String> = Vec::new();
583            for s in wf.steps.values() {
584                for (_, v) in &s.spec {
585                    collect_memory_keys(v, &mut keys);
586                }
587                if let Some(w) = &s.when {
588                    collect_memory_keys(&Value::String(w.clone()), &mut keys);
589                }
590            }
591            for k in keys {
592                if let Ok(v) = self.memory.get(&self.durable, &k)
593                    && v["found"] == json!(true)
594                {
595                    memory.insert(k, v["value"].clone());
596                }
597            }
598        }
599        let mut data = self
600            .runs
601            .get(run_id)
602            .map(|r| r.data(env, Value::Object(memory)))
603            .unwrap_or_default();
604        // Artifact-backed values (`{"$artifact": id}`) dereference transparently
605        // for templates (RFC 0027 §3).
606        for key in ["steps", "vars", "inputs"] {
607            if let Some(v) = data.get_mut(key) {
608                self.deref_artifacts(v);
609            }
610        }
611        data
612    }
613
614    /// Replace `{"$artifact": id, …}` objects with the artifact's content.
615    pub(crate) fn deref_artifacts(&self, v: &mut Value) {
616        match v {
617            Value::Object(o) => {
618                if let Some(id) = o.get("$artifact").and_then(Value::as_str) {
619                    if let Some(a) = self.artifacts.get(id) {
620                        *v = a.content.clone();
621                    }
622                    return;
623                }
624                for x in o.values_mut() {
625                    self.deref_artifacts(x);
626                }
627            }
628            Value::Array(a) => {
629                for x in a.iter_mut() {
630                    self.deref_artifacts(x);
631                }
632            }
633            _ => {}
634        }
635    }
636
637    // ---- execution -------------------------------------------------------------
638
639    /// Execute one ready step of a run.
640    pub(crate) fn execute_step_pub(&mut self, run_id: &str, step_id: &str) {
641        self.execute_step(run_id, step_id)
642    }
643
644    fn execute_step(&mut self, run_id: &str, step_id: &str) {
645        if self.runs.get(run_id).is_none_or(|r| r.status.is_terminal()) {
646            return;
647        }
648        let Some(wf) = self
649            .runs
650            .get(run_id)
651            .and_then(|_| self.definition_for_run(run_id))
652        else {
653            return;
654        };
655        let Some((step, scope)) = self
656            .runs
657            .get(run_id)
658            .and_then(|r| self.resolve_step(&wf, r, step_id))
659        else {
660            return;
661        };
662        let attempt = self
663            .runs
664            .get_mut(run_id)
665            .expect("present")
666            .begin_step(step_id);
667        // Durable `running` BEFORE the effect (RFC 0025 §7).
668        crate::state::kill_point("step.running");
669        self.checkpoint(false);
670        self.log.info(
671            "step.start",
672            json!({"run": run_id, "step": step_id, "kind": step.kind, "attempt": attempt}),
673        );
674        let data = match &scope {
675            Some(sc) => self.scoped_data(run_id, sc),
676            None => self.run_data(run_id),
677        };
678        let spec = match render_spec(&step, &data) {
679            Ok(s) => s,
680            Err(e) => {
681                self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
682                return;
683            }
684        };
685        let step_caller = ToolCaller {
686            run: Some(run_id.to_string()),
687            step: Some(step_id.to_string()),
688            req: attempt as u64,
689            principal: self.runs.get(run_id).and_then(|r| r.principal.clone()),
690            ctx: self.runs.get(run_id).and_then(|r| r.conversation.clone()),
691            ..Default::default()
692        };
693        // `cache {key, ttl}`: a fresh memoized output skips the effect.
694        let cache_key = match self.cache_lookup(&step, &spec, &data) {
695            Some((_key, Some(hit))) => {
696                self.log
697                    .info("step.cache_hit", json!({"run": run_id, "step": step_id}));
698                self.finish_step(run_id, step_id, StepStatus::Done, Some(hit), None, 0);
699                return;
700            }
701            Some((key, None)) => Some(key),
702            None => None,
703        };
704        if let Some(k) = cache_key
705            && let Some(st) = self
706                .runs
707                .get_mut(run_id)
708                .and_then(|r| r.steps.get_mut(step_id))
709        {
710            st.wait = Some(json!({"cache_key": k}));
711        }
712        match step.kind.as_str() {
713            "noop" | "checkpoint" => self.finish_step(
714                run_id,
715                step_id,
716                StepStatus::Done,
717                Some(Value::Null),
718                None,
719                0,
720            ),
721            "assign" | "transform" => {
722                let value = spec.get("value").cloned().unwrap_or(Value::Null);
723                let key = spec
724                    .get("writes")
725                    .and_then(Value::as_str)
726                    .unwrap_or(step_id)
727                    .to_string();
728                let mode = spec
729                    .get("mode")
730                    .and_then(Value::as_str)
731                    .unwrap_or("overwrite")
732                    .to_string();
733                self.runs
734                    .get_mut(run_id)
735                    .expect("present")
736                    .write_var(&key, value.clone(), &mode);
737                self.finish_step(run_id, step_id, StepStatus::Done, Some(value), None, 0);
738            }
739            "template" => {
740                let out = spec
741                    .get("text")
742                    .cloned()
743                    .or_else(|| spec.get("value").cloned())
744                    .unwrap_or(Value::String(String::new()));
745                self.finish_step(run_id, step_id, StepStatus::Done, Some(out), None, 0);
746            }
747            "validate" => {
748                let value = spec.get("value").cloned().unwrap_or(Value::Null);
749                let schema = spec.get("schema").cloned().unwrap_or(json!({}));
750                match crate::jsonschema::validate(&schema, &value) {
751                    Ok(()) => {
752                        self.finish_step(run_id, step_id, StepStatus::Done, Some(value), None, 0)
753                    }
754                    Err(e) => self.finish_step(
755                        run_id,
756                        step_id,
757                        StepStatus::Failed,
758                        Some(value),
759                        Some(format!(
760                            "validation failed: {}",
761                            crate::jsonschema::explain(&e)
762                        )),
763                        0,
764                    ),
765                }
766            }
767            "assert" => {
768                let cond = step
769                    .field_str("condition")
770                    .unwrap_or("false")
771                    .trim()
772                    .trim_start_matches("CEL:")
773                    .trim()
774                    .to_string();
775                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
776                match crate::cel::eval_bool(&cond, &vars) {
777                    Ok(true) => self.finish_step(
778                        run_id,
779                        step_id,
780                        StepStatus::Done,
781                        Some(json!(true)),
782                        None,
783                        0,
784                    ),
785                    Ok(false) => self.finish_step(
786                        run_id,
787                        step_id,
788                        StepStatus::Failed,
789                        Some(json!(false)),
790                        Some(
791                            spec.get("message")
792                                .and_then(Value::as_str)
793                                .map(str::to_string)
794                                .unwrap_or_else(|| format!("assertion failed: {cond}")),
795                        ),
796                        0,
797                    ),
798                    Err(e) => self.finish_step(
799                        run_id,
800                        step_id,
801                        StepStatus::Failed,
802                        None,
803                        Some(format!("assert: {e}")),
804                        0,
805                    ),
806                }
807            }
808            "fail" => {
809                let msg = spec
810                    .get("message")
811                    .and_then(Value::as_str)
812                    .unwrap_or("deliberate failure")
813                    .to_string();
814                self.finish_step(
815                    run_id,
816                    step_id,
817                    StepStatus::Failed,
818                    spec.get("code").cloned(),
819                    Some(msg),
820                    0,
821                );
822            }
823            "emit" => {
824                if let Some(n) = spec.get("note").and_then(Value::as_str) {
825                    let text = format!("run {run_id}: {n}");
826                    self.note_root(text);
827                }
828                if let Some(a) = spec.get("audit") {
829                    self.log.info(
830                        "audit.emit",
831                        json!({"run": run_id, "step": step_id, "audit": a}),
832                    );
833                }
834                self.finish_step(
835                    run_id,
836                    step_id,
837                    StepStatus::Done,
838                    spec.get("value").cloned().or(Some(Value::Null)),
839                    None,
840                    0,
841                );
842            }
843            "finish" => {
844                let status = match spec
845                    .get("status")
846                    .and_then(Value::as_str)
847                    .unwrap_or("completed")
848                {
849                    "completed" => RunStatus::Completed,
850                    "refused" => RunStatus::Refused,
851                    "cancelled" => RunStatus::Cancelled,
852                    _ => RunStatus::Failed,
853                };
854                let output = spec.get("output").cloned();
855                let reason = spec
856                    .get("reason")
857                    .and_then(Value::as_str)
858                    .map(str::to_string);
859                self.runs.get_mut(run_id).expect("present").end_step(
860                    step_id,
861                    StepStatus::Done,
862                    output.clone(),
863                    None,
864                );
865                self.runs
866                    .get_mut(run_id)
867                    .expect("present")
868                    .finish(status, output, reason);
869                self.on_run_terminal(run_id);
870            }
871            "sleep" => {
872                let ms = spec
873                    .get("duration")
874                    .map(crate::engine::model::duration_ms)
875                    .unwrap_or(Ok(0))
876                    .unwrap_or(0);
877                match self.timers.arm(
878                    &self.durable,
879                    now_ms() + ms,
880                    json!({"kind": "step", "run": run_id, "step": step_id}),
881                    json!({"slept_ms": ms}),
882                ) {
883                    Ok(id) => {
884                        self.runs.get_mut(run_id).expect("present").suspend_step(
885                            step_id,
886                            json!({"kind": "sleep", "timer": id, "deadline_ms": now_ms() + ms}),
887                        );
888                        self.checkpoint(false);
889                    }
890                    Err(e) => self.finish_step(
891                        run_id,
892                        step_id,
893                        StepStatus::Failed,
894                        None,
895                        Some(format!("sleep: {e}")),
896                        0,
897                    ),
898                }
899            }
900            "tool" => {
901                let name = spec
902                    .get("name")
903                    .and_then(Value::as_str)
904                    .unwrap_or("")
905                    .to_string();
906                let args = spec.get("args").cloned().unwrap_or(json!({}));
907                self.step_tool_call(run_id, step_id, &step_caller, &name, args);
908            }
909            "http" => self.step_http(run_id, step_id, &spec),
910            k if k.starts_with("memory.")
911                || k.starts_with("artifact.")
912                || k.starts_with("knowledge.")
913                || k.starts_with("search.") =>
914            {
915                let mut args = spec.clone();
916                // `ttl` etc. pass through as-is; the contract validates.
917                args.retain(|_, v| !v.is_null());
918                self.step_tool_call(run_id, step_id, &step_caller, k, Value::Object(args));
919            }
920            "mcp.tool" => {
921                let server = spec
922                    .get("server")
923                    .and_then(Value::as_str)
924                    .unwrap_or("")
925                    .to_string();
926                let tool = spec
927                    .get("tool")
928                    .and_then(Value::as_str)
929                    .unwrap_or("")
930                    .to_string();
931                let args = spec.get("args").cloned().unwrap_or(json!({}));
932                let Some(client) = self.mcp.get(&server).cloned() else {
933                    self.finish_step(
934                        run_id,
935                        step_id,
936                        StepStatus::Failed,
937                        None,
938                        Some(format!("mcp server {server:?} is not connected")),
939                        0,
940                    );
941                    return;
942                };
943                let meta = json!({"agent/idempotency_key": format!("{}/{run_id}/{step_id}#{attempt}", self.instance), "agent/instance": self.instance, "agent/run": run_id});
944                let timeout = step
945                    .timeout_ms
946                    .map(std::time::Duration::from_millis)
947                    .unwrap_or(
948                        self.settings
949                            .limits
950                            .step_timeout
951                            .map(|d| d.0)
952                            .unwrap_or(std::time::Duration::from_secs(600)),
953                    );
954                let tx = self.events_tx.clone();
955                let (r, s) = (run_id.to_string(), step_id.to_string());
956                self.executing
957                    .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
958                std::thread::Builder::new()
959                    .name(format!("step:{server}.{tool}"))
960                    .spawn(move || {
961                        let (output, is_error, error) = match client.call_tool_with_meta_within(
962                            &tool,
963                            Some(args),
964                            meta,
965                            timeout,
966                        ) {
967                            Ok(res) => {
968                                let v = super::worker::tool_result_value(&res);
969                                if res.is_error() {
970                                    (v.clone(), true, Some(res.text()))
971                                } else {
972                                    (v, false, None)
973                                }
974                            }
975                            Err(e) => (Value::Null, true, Some(format!("transport error: {e}"))),
976                        };
977                        let _ = tx.send(super::events::Event::StepDone {
978                            run: r,
979                            step: s,
980                            output,
981                            is_error,
982                            error,
983                            tokens: 0,
984                        });
985                    })
986                    .ok();
987            }
988            "agent" | "think" => self.step_turn(run_id, step_id, &step, &spec, &data),
989            "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph" => {
990                self.nested_start(run_id, step_id, &step, &spec)
991            }
992            "wait" | "join" | "workflow" | "workflow.signal" | "workflow.wait"
993            | "workflow.cancel" | "subagent" | "human" | "mcp.resource" | "a2a.delegate"
994            | "a2a.send" | "a2a.wait" | "classify" | "extract" | "summarize" | "judge"
995            | "route" => {
996                self.execute_orchestration_step(run_id, step_id, &step, &spec, &data, &step_caller)
997            }
998            "switch" => {
999                let on = spec.get("on").cloned().unwrap_or(Value::Null);
1000                let key = match &on {
1001                    Value::String(x) => x.clone(),
1002                    other => other.to_string(),
1003                };
1004                let cases = step
1005                    .field("cases")
1006                    .and_then(Value::as_object)
1007                    .cloned()
1008                    .unwrap_or_default();
1009                let target = cases
1010                    .get(&key)
1011                    .and_then(Value::as_str)
1012                    .map(str::to_string)
1013                    .or_else(|| step.field_str("default").map(str::to_string));
1014                match target {
1015                    Some(t) => {
1016                        // The chosen case runs even without its deps being terminal
1017                        // (an explicit routing edge); the other cases are skipped.
1018                        let scope_prefix = step_id
1019                            .rsplit_once('.')
1020                            .map(|(p, _)| format!("{p}."))
1021                            .unwrap_or_default();
1022                        let mut skipped = Vec::new();
1023                        // Every other target (cases + default) still pending is skipped;
1024                        // the chosen one is forced (runs even without its deps).
1025                        let mut others: Vec<String> = cases
1026                            .values()
1027                            .filter_map(Value::as_str)
1028                            .map(str::to_string)
1029                            .collect();
1030                        if let Some(d) = step.field_str("default") {
1031                            others.push(d.to_string());
1032                        }
1033                        if let Some(run) = self.runs.get_mut(run_id) {
1034                            for tid in others {
1035                                if tid == t {
1036                                    continue;
1037                                }
1038                                let sid = format!("{scope_prefix}{tid}");
1039                                if let Some(st) = run.steps.get_mut(&sid)
1040                                    && st.status == StepStatus::Pending
1041                                {
1042                                    st.status = StepStatus::Skipped;
1043                                    skipped.push(sid);
1044                                }
1045                            }
1046                            let sid = format!("{scope_prefix}{t}");
1047                            if let Some(st) = run.steps.get_mut(&sid) {
1048                                st.status = StepStatus::Pending;
1049                                st.forced = true;
1050                            }
1051                        }
1052                        self.finish_step(
1053                            run_id,
1054                            step_id,
1055                            StepStatus::Done,
1056                            Some(json!({"case": key, "goto": t, "skipped": skipped})),
1057                            None,
1058                            0,
1059                        );
1060                    }
1061                    None => self.finish_step(
1062                        run_id,
1063                        step_id,
1064                        StepStatus::Failed,
1065                        Some(json!({"case": key})),
1066                        Some(format!("switch: no case for {key:?} and no default")),
1067                        0,
1068                    ),
1069                }
1070            }
1071            "map" | "filter" | "reduce" | "sort" | "dedupe" | "chunk" | "parse" => {
1072                let out = match step.kind.as_str() {
1073                    "map" => crate::engine::data::map(
1074                        spec.get("over").unwrap_or(&Value::Null),
1075                        step.field_str("expr").unwrap_or(""),
1076                        step.field_str("as").unwrap_or("item"),
1077                        &data,
1078                    ),
1079                    "filter" => crate::engine::data::filter(
1080                        spec.get("over").unwrap_or(&Value::Null),
1081                        step.field_str("expr").unwrap_or(""),
1082                        step.field_str("as").unwrap_or("item"),
1083                        &data,
1084                    ),
1085                    "reduce" => crate::engine::data::reduce(
1086                        spec.get("over").unwrap_or(&Value::Null),
1087                        step.field_str("expr").unwrap_or(""),
1088                        spec.get("initial").cloned().unwrap_or(Value::Null),
1089                        step.field_str("as").unwrap_or("item"),
1090                        step.field_str("acc").unwrap_or("acc"),
1091                        &data,
1092                    ),
1093                    "sort" => crate::engine::data::sort(
1094                        spec.get("over").unwrap_or(&Value::Null),
1095                        spec.get("by").and_then(Value::as_str),
1096                        spec.get("order").and_then(Value::as_str),
1097                    ),
1098                    "dedupe" => crate::engine::data::dedupe(
1099                        spec.get("over").unwrap_or(&Value::Null),
1100                        spec.get("by").and_then(Value::as_str),
1101                    ),
1102                    "chunk" => crate::engine::data::chunk(
1103                        spec.get("value").unwrap_or(&Value::Null),
1104                        spec.get("by").and_then(Value::as_str),
1105                        spec.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
1106                        spec.get("overlap").and_then(Value::as_u64).unwrap_or(0) as usize,
1107                    ),
1108                    _ => crate::engine::data::parse(
1109                        spec.get("text").and_then(Value::as_str).unwrap_or(""),
1110                        spec.get("format").and_then(Value::as_str),
1111                    ),
1112                };
1113                match out {
1114                    Ok(v) => self.finish_step(run_id, step_id, StepStatus::Done, Some(v), None, 0),
1115                    Err(e) => {
1116                        self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1117                    }
1118                }
1119            }
1120            other => self.finish_step(
1121                run_id,
1122                step_id,
1123                StepStatus::Failed,
1124                None,
1125                Some(format!(
1126                    "step kind {other:?} is not executable in this build (P4)"
1127                )),
1128                0,
1129            ),
1130        }
1131    }
1132
1133    /// A step's internal tool call (in-loop or deferred/executor).
1134    fn step_tool_call(
1135        &mut self,
1136        run_id: &str,
1137        step_id: &str,
1138        caller: &ToolCaller,
1139        name: &str,
1140        args: Value,
1141    ) {
1142        match self.execute_tool(caller, name, args) {
1143            ToolOutcome::Ready(v, is_error) => {
1144                let err = is_error.then(|| match &v {
1145                    Value::String(s) => s.clone(),
1146                    o => o.to_string(),
1147                });
1148                self.finish_step(
1149                    run_id,
1150                    step_id,
1151                    if is_error {
1152                        StepStatus::Failed
1153                    } else {
1154                        StepStatus::Done
1155                    },
1156                    Some(v),
1157                    err,
1158                    0,
1159                );
1160            }
1161            ToolOutcome::Deferred(kind) => {
1162                let wait = match &kind {
1163                    PendingKind::Timer { id } => json!({"kind": "timer", "timer": id}),
1164                    PendingKind::Subagent { handle } => {
1165                        json!({"kind": "subagent", "handle": handle})
1166                    }
1167                    PendingKind::Think { .. } => json!({"kind": "think"}),
1168                    PendingKind::Run { run, .. } => json!({"kind": "run", "run": run}),
1169                    PendingKind::Await {
1170                        condition,
1171                        deadline_ms,
1172                    } => {
1173                        json!({"kind": "await", "condition": condition, "deadline_ms": deadline_ms})
1174                    }
1175                    PendingKind::Human {
1176                        task, deadline_ms, ..
1177                    } => {
1178                        json!({"kind": "human", "task": task, "deadline_ms": deadline_ms})
1179                    }
1180                };
1181                self.runs
1182                    .get_mut(run_id)
1183                    .expect("present")
1184                    .suspend_step(step_id, wait);
1185                if !matches!(kind, PendingKind::Timer { .. }) {
1186                    self.pending.push(super::reactor::PendingTool {
1187                        target: Target::Step(run_id.to_string(), step_id.to_string()),
1188                        name: name.to_string(),
1189                        kind,
1190                        started_ms: now_ms(),
1191                    });
1192                }
1193                self.checkpoint(false);
1194            }
1195            ToolOutcome::Executing => {
1196                self.executing
1197                    .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1198            }
1199        }
1200    }
1201
1202    pub(crate) fn step_turn_pub(
1203        &mut self,
1204        run_id: &str,
1205        step_id: &str,
1206        step: &Step,
1207        spec: &Map<String, Value>,
1208        data: &template::Data,
1209    ) {
1210        self.step_turn(run_id, step_id, step, spec, data)
1211    }
1212
1213    /// An `agent`/`think` step → a turn worker (budget-admitted).
1214    fn step_turn(
1215        &mut self,
1216        run_id: &str,
1217        step_id: &str,
1218        step: &Step,
1219        spec: &Map<String, Value>,
1220        data: &template::Data,
1221    ) {
1222        let is_think = step.kind == "think";
1223        let prompt = if is_think {
1224            spec.get("prompt").and_then(Value::as_str).unwrap_or("")
1225        } else {
1226            spec.get("instruction")
1227                .and_then(Value::as_str)
1228                .unwrap_or("")
1229        }
1230        .to_string();
1231        let output_schema = spec.get("output_schema").cloned();
1232        let mut messages = Vec::new();
1233        // `reads`: fold named run data into the prompt.
1234        if let Some(reads) = spec.get("reads").and_then(Value::as_array) {
1235            for path in reads.iter().filter_map(Value::as_str) {
1236                if let Some(v) = template::lookup(path, data) {
1237                    messages.push(Msg::system(format!("{path} = {v}")));
1238                }
1239            }
1240        }
1241        // `context`: seed messages.
1242        if let Some(seed) = spec.get("context").and_then(Value::as_array) {
1243            for m in seed {
1244                match (m["role"].as_str(), m["content"].as_str()) {
1245                    (Some("system"), Some(c)) => messages.push(Msg::system(c)),
1246                    (Some("assistant"), Some(c)) => {
1247                        messages.push(Msg::assistant(Some(c.to_string()), vec![]))
1248                    }
1249                    (_, Some(c)) => messages.push(Msg::user(c, None)),
1250                    _ => {}
1251                }
1252            }
1253        }
1254        let mut user = prompt.clone();
1255        if let Some(c) = spec.get("output_contract").and_then(Value::as_str) {
1256            user.push_str(&format!("\n\nOutput contract:\n{c}"));
1257        }
1258        if let Some(s) = &output_schema {
1259            user.push_str(&format!(
1260                "\n\nReply with ONLY one JSON object matching this JSON Schema:\n{s}"
1261            ));
1262        }
1263        messages.push(Msg::user(user, None));
1264        // Skills for the step.
1265        let skill_bodies: Vec<String> = step
1266            .skills
1267            .iter()
1268            .chain(
1269                spec.get("skills")
1270                    .and_then(Value::as_array)
1271                    .map(|a| {
1272                        a.iter()
1273                            .filter_map(Value::as_str)
1274                            .map(str::to_string)
1275                            .collect::<Vec<_>>()
1276                    })
1277                    .unwrap_or_default()
1278                    .iter(),
1279            )
1280            .filter_map(|name| {
1281                let mcp = self.mcp.clone();
1282                let resolver = move |server: &str| -> Option<
1283                    std::sync::Arc<dyn crate::context::skills::SkillServer>,
1284                > {
1285                    mcp.get(server).map(|c| {
1286                        c.clone() as std::sync::Arc<dyn crate::context::skills::SkillServer>
1287                    })
1288                };
1289                self.skills
1290                    .load(name, None, &resolver)
1291                    .ok()
1292                    .map(|b| format!("### Skill: {}\n{}", b.name, b.body))
1293            })
1294            .collect();
1295        let extra = if skill_bodies.is_empty() {
1296            None
1297        } else {
1298            Some(format!(
1299                "Loaded skills — follow these instructions when relevant:\n{}",
1300                skill_bodies.join("\n\n")
1301            ))
1302        };
1303        let system = match spec.get("system").and_then(Value::as_str) {
1304            Some(s) => s.to_string(),
1305            None if is_think => format!(
1306                "You are the reasoning module of {}. Reply with {}. No tools are available.",
1307                self.instance,
1308                if output_schema.is_some() {
1309                    "ONLY one JSON object matching the schema"
1310                } else {
1311                    "your conclusion"
1312                }
1313            ),
1314            None => self.system_prompt(None, extra.as_deref()),
1315        };
1316        let (tools, internal, routes) = if is_think {
1317            (Vec::new(), Vec::new(), BTreeMap::new())
1318        } else {
1319            let allow: Option<Vec<String>> = spec.get("tools").and_then(Value::as_array).map(|a| {
1320                a.iter()
1321                    .filter_map(Value::as_str)
1322                    .map(str::to_string)
1323                    .collect()
1324            });
1325            self.tool_plan(&Caller::Workflow, allow.as_deref())
1326        };
1327        let servers: Vec<String> = match spec.get("servers").and_then(Value::as_array) {
1328            Some(a) => a
1329                .iter()
1330                .filter_map(Value::as_str)
1331                .map(str::to_string)
1332                .collect(),
1333            None => routes
1334                .values()
1335                .map(|(s, _)| s.clone())
1336                .collect::<std::collections::BTreeSet<_>>()
1337                .into_iter()
1338                .collect(),
1339        };
1340        // Budget admission (RFC 0026 §7): the run's scope + the instance.
1341        let est: u64 = messages.iter().map(Msg::est_tokens).sum::<u64>()
1342            + crate::context::tokens::estimate(&system)
1343            + 4096;
1344        let scopes = self.run_scopes(run_id);
1345        let reservation = match self.governor.admit(est, &scopes, now_ms()) {
1346            Admission::Ok { reservation, model } => {
1347                if let Some(m) = model {
1348                    self.log.info(
1349                        "budget.degraded",
1350                        json!({"run": run_id, "step": step_id, "model": m}),
1351                    );
1352                }
1353                Some(reservation)
1354            }
1355            Admission::Wait { until_ms, reason } => {
1356                self.log.info(
1357                    "budget.wait",
1358                    json!({"run": run_id, "step": step_id, "until_ms": until_ms, "reason": reason}),
1359                );
1360                crate::state::kill_point("budget.waiting");
1361                match self.timers.arm(
1362                    &self.durable,
1363                    until_ms,
1364                    json!({"kind": "step_budget", "run": run_id, "step": step_id}),
1365                    Value::Null,
1366                ) {
1367                    Ok(id) => {
1368                        self.runs.get_mut(run_id).expect("present").suspend_step(step_id, json!({"kind": "waiting_budget", "timer": id, "until_ms": until_ms, "reason": reason}));
1369                        self.checkpoint(false);
1370                    }
1371                    Err(e) => self.finish_step(
1372                        run_id,
1373                        step_id,
1374                        StepStatus::Failed,
1375                        None,
1376                        Some(format!("budget wait: {e}")),
1377                        0,
1378                    ),
1379                }
1380                return;
1381            }
1382            Admission::Refuse { reason } | Admission::Fail { reason } => {
1383                self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(reason), 0);
1384                return;
1385            }
1386        };
1387        let limits = spec.get("limits").cloned().unwrap_or(json!({}));
1388        let max_steps = limits
1389            .get("steps")
1390            .and_then(Value::as_u64)
1391            .map(|s| s as u32)
1392            .unwrap_or(self.settings.limits.run.steps());
1393        let max_tokens = step
1394            .budget
1395            .or_else(|| limits.get("tokens").and_then(Value::as_u64))
1396            .unwrap_or(self.settings.limits.run.tokens());
1397        let deadline_ms = step.timeout_ms.unwrap_or(
1398            self.settings
1399                .limits
1400                .step_timeout
1401                .map(|d| d.0.as_millis() as u64)
1402                .unwrap_or(600_000),
1403        );
1404        let spec = TurnSpec {
1405            kind: if is_think {
1406                TurnKind::Think
1407            } else {
1408                TurnKind::Agent
1409            },
1410            system,
1411            messages,
1412            tools,
1413            internal,
1414            mcp_routes: routes,
1415            output_schema,
1416            max_rounds: if is_think { 3 } else { 0 },
1417            budget_admission: self.governor.is_active(),
1418            idempotency_prefix: format!("{}/{run_id}/{step_id}", self.instance),
1419            tool_meta: Some(
1420                json!({"agent/run": run_id, "agent/step": step_id, "agent/instance": self.instance}),
1421            ),
1422            temperature: None,
1423            max_tokens_per_call: 0,
1424            turn_id: format!(
1425                "{run_id}/{step_id}#{}",
1426                self.runs
1427                    .get(run_id)
1428                    .and_then(|r| r.step(step_id))
1429                    .map(|s| s.attempt)
1430                    .unwrap_or(1)
1431            ),
1432        };
1433        let launch = super::turns::TurnLaunch {
1434            spec,
1435            kind: ChildKind::StepTurn {
1436                run: run_id.to_string(),
1437                step: step_id.to_string(),
1438                reservation,
1439            },
1440            servers,
1441            max_steps,
1442            max_tokens,
1443            deadline_ms,
1444            agent_path: format!("run/{run_id}/{step_id}"),
1445        };
1446        match self.spawn_turn(launch) {
1447            Ok(node) => {
1448                if let Some(st) = self
1449                    .runs
1450                    .get_mut(run_id)
1451                    .and_then(|r| r.steps.get_mut(step_id))
1452                {
1453                    st.worker = Some(node.0.to_string());
1454                }
1455                self.log.info(
1456                    "step.turn.spawn",
1457                    json!({"run": run_id, "step": step_id, "node": node.0}),
1458                );
1459            }
1460            Err(e) => {
1461                if let Some(r) = reservation {
1462                    self.governor.release(r);
1463                }
1464                self.finish_step(
1465                    run_id,
1466                    step_id,
1467                    StepStatus::Failed,
1468                    None,
1469                    Some(format!("spawn: {e}")),
1470                    0,
1471                );
1472            }
1473        }
1474    }
1475
1476    /// The governor scopes a run's turns are charged to (workflow budget → run scope).
1477    fn run_scopes(&mut self, run_id: &str) -> Vec<String> {
1478        let Some(wf) = self.definition_for_run(run_id) else {
1479            return Vec::new();
1480        };
1481        match wf
1482            .limits
1483            .budget
1484            .as_ref()
1485            .and_then(|b| serde_json::from_value::<crate::config::v2::Budget>(b.clone()).ok())
1486        {
1487            Some(b) => {
1488                let key = format!("run:{run_id}");
1489                self.governor.ensure_scope(&key, &b);
1490                vec![key]
1491            }
1492            None => Vec::new(),
1493        }
1494    }
1495
1496    // ---- outcomes --------------------------------------------------------------
1497
1498    /// An executor / deferred tool finished a step.
1499    pub(crate) fn on_step_done(
1500        &mut self,
1501        run_id: &str,
1502        step_id: &str,
1503        output: Value,
1504        is_error: bool,
1505        error: Option<String>,
1506        tokens: u64,
1507    ) {
1508        self.executing.remove(&format!("{run_id}/{step_id}"));
1509        self.finish_step(
1510            run_id,
1511            step_id,
1512            if is_error {
1513                StepStatus::Failed
1514            } else {
1515                StepStatus::Done
1516            },
1517            Some(output),
1518            error,
1519            tokens,
1520        );
1521    }
1522
1523    /// A step's turn worker finished.
1524    pub(crate) fn on_step_turn_done(&mut self, run_id: &str, step_id: &str, turn: TurnResult) {
1525        let tokens = turn.usage.total();
1526        if turn.status == "completed" {
1527            let output = turn
1528                .value
1529                .clone()
1530                .or_else(|| turn.finish.as_ref().and_then(|f| f.get("output").cloned()))
1531                .or_else(|| turn.text.clone().map(Value::String))
1532                .unwrap_or(Value::Null);
1533            // A `finish {status: failed|refused}` from an agent step fails the step.
1534            let failed = turn
1535                .finish
1536                .as_ref()
1537                .and_then(|f| f.get("status"))
1538                .and_then(Value::as_str)
1539                .is_some_and(|s| s != "completed");
1540            if failed {
1541                let reason = turn
1542                    .finish
1543                    .as_ref()
1544                    .and_then(|f| f.get("reason"))
1545                    .and_then(Value::as_str)
1546                    .unwrap_or("agent finished with a non-completed status")
1547                    .to_string();
1548                self.finish_step(
1549                    run_id,
1550                    step_id,
1551                    StepStatus::Failed,
1552                    Some(output),
1553                    Some(reason),
1554                    tokens,
1555                );
1556            } else {
1557                self.finish_step(
1558                    run_id,
1559                    step_id,
1560                    StepStatus::Done,
1561                    Some(output),
1562                    None,
1563                    tokens,
1564                );
1565            }
1566        } else {
1567            let status = if turn.status == "deadline" {
1568                StepStatus::Timeout
1569            } else {
1570                StepStatus::Failed
1571            };
1572            self.finish_step(
1573                run_id,
1574                step_id,
1575                status,
1576                turn.value
1577                    .clone()
1578                    .or_else(|| turn.text.clone().map(Value::String)),
1579                Some(format!(
1580                    "turn {}{}",
1581                    turn.status,
1582                    turn.error
1583                        .as_deref()
1584                        .map(|e| format!(": {e}"))
1585                        .unwrap_or_default()
1586                )),
1587                tokens,
1588            );
1589        }
1590    }
1591
1592    /// A step timer fired (`sleep` done, or a budget window opened).
1593    pub(crate) fn on_step_timer(
1594        &mut self,
1595        run_id: &str,
1596        step_id: &str,
1597        budget: bool,
1598        payload: &Value,
1599    ) {
1600        if budget {
1601            if let Some(st) = self
1602                .runs
1603                .get_mut(run_id)
1604                .and_then(|r| r.steps.get_mut(step_id))
1605            {
1606                st.status = StepStatus::Pending;
1607                st.wait = None;
1608            }
1609            if let Some(r) = self.runs.get_mut(run_id) {
1610                r.touch();
1611            }
1612            return;
1613        }
1614        self.finish_step(
1615            run_id,
1616            step_id,
1617            StepStatus::Done,
1618            Some(payload.clone()),
1619            None,
1620            0,
1621        );
1622    }
1623
1624    /// Record a step's terminal outcome; retry / route failures; checkpoint.
1625    pub(crate) fn finish_step_pub(
1626        &mut self,
1627        run_id: &str,
1628        step_id: &str,
1629        status: StepStatus,
1630        output: Option<Value>,
1631        error: Option<String>,
1632        tokens: u64,
1633    ) {
1634        self.finish_step(run_id, step_id, status, output, error, tokens)
1635    }
1636
1637    fn finish_step(
1638        &mut self,
1639        run_id: &str,
1640        step_id: &str,
1641        status: StepStatus,
1642        output: Option<Value>,
1643        error: Option<String>,
1644        tokens: u64,
1645    ) {
1646        let Some(wf) = self
1647            .runs
1648            .get(run_id)
1649            .and_then(|_| self.definition_for_run(run_id))
1650        else {
1651            return;
1652        };
1653        let Some((step, scope)) = self
1654            .runs
1655            .get(run_id)
1656            .and_then(|r| self.resolve_step(&wf, r, step_id))
1657        else {
1658            return;
1659        };
1660        {
1661            let run = self.runs.get_mut(run_id).expect("present");
1662            if run.status.is_terminal() {
1663                return; // a late result for a finished run
1664            }
1665            run.tokens += tokens;
1666        }
1667        crate::obs::metrics::record_step(match status {
1668            StepStatus::Done => "done",
1669            StepStatus::Failed => "failed",
1670            _ => "other",
1671        });
1672        // Large outputs become artifacts (`limits.inline_max_bytes`, RFC 0027 §3).
1673        let output = match output {
1674            Some(v) if !v.is_null() => {
1675                let cap = self.settings.limits.inline_max_bytes.unwrap_or(65_536) as usize;
1676                if v.to_string().len() > cap {
1677                    match self.artifacts.create(
1678                        &self.durable,
1679                        super::artifacts::NewArtifact {
1680                            name: &format!("{run_id}/{step_id}/output.json"),
1681                            mime: Some("application/json"),
1682                            content: v.clone(),
1683                            created_by: Some("engine"),
1684                            sensitive: false,
1685                            owner: Some(run_id),
1686                        },
1687                    ) {
1688                        Ok(meta) => {
1689                            self.log.info("step.output.artifact", json!({"run": run_id, "step": step_id, "artifact": meta["id"], "size": meta["size"]}));
1690                            Some(json!({"$artifact": meta["id"], "size": meta["size"]}))
1691                        }
1692                        Err(e) => {
1693                            self.log.warn(
1694                                "step.output.artifact_fail",
1695                                json!({"run": run_id, "step": step_id, "err": e}),
1696                            );
1697                            Some(v)
1698                        }
1699                    }
1700                } else {
1701                    Some(v)
1702                }
1703            }
1704            other => other,
1705        };
1706        // Output schema (RFC 0027 §6 structured I/O).
1707        let (status, error) = match (&status, &step.output_schema, &output) {
1708            (StepStatus::Done, Some(schema), Some(out)) => {
1709                match crate::jsonschema::validate(schema, out) {
1710                    Ok(()) => (status, error),
1711                    Err(e) => (
1712                        StepStatus::Failed,
1713                        Some(format!(
1714                            "output does not match output_schema: {}",
1715                            crate::jsonschema::explain(&e)
1716                        )),
1717                    ),
1718                }
1719            }
1720            _ => (status, error),
1721        };
1722        let attempt = self
1723            .runs
1724            .get(run_id)
1725            .and_then(|r| r.step(step_id))
1726            .map(|s| s.attempt)
1727            .unwrap_or(1);
1728        self.log.info("step.done", json!({"run": run_id, "step": step_id, "status": status, "attempt": attempt, "tokens": tokens, "err": error}));
1729        if matches!(status, StepStatus::Failed | StepStatus::Timeout) {
1730            // Retry?
1731            if let Some(retry) = &step.retry
1732                && attempt <= retry.max
1733            {
1734                let backoff = retry
1735                    .backoff_ms
1736                    .saturating_mul(1u64 << (attempt.saturating_sub(1)).min(10));
1737                self.log.info("step.retry", json!({"run": run_id, "step": step_id, "attempt": attempt, "backoff_ms": backoff}));
1738                if backoff == 0 {
1739                    if let Some(st) = self
1740                        .runs
1741                        .get_mut(run_id)
1742                        .and_then(|r| r.steps.get_mut(step_id))
1743                    {
1744                        st.status = StepStatus::Pending;
1745                        st.error = error;
1746                    }
1747                } else {
1748                    match self.timers.arm(
1749                        &self.durable,
1750                        now_ms() + backoff,
1751                        json!({"kind": "step_budget", "run": run_id, "step": step_id}),
1752                        Value::Null,
1753                    ) {
1754                        Ok(id) => {
1755                            self.runs.get_mut(run_id).expect("present").suspend_step(
1756                                step_id,
1757                                json!({"kind": "retry_backoff", "timer": id, "error": error}),
1758                            );
1759                        }
1760                        Err(_) => {
1761                            if let Some(st) = self
1762                                .runs
1763                                .get_mut(run_id)
1764                                .and_then(|r| r.steps.get_mut(step_id))
1765                            {
1766                                st.status = StepStatus::Pending;
1767                            }
1768                        }
1769                    }
1770                }
1771                self.checkpoint(false);
1772                return;
1773            }
1774            let err_text = error.clone().unwrap_or_else(|| "failed".into());
1775            self.runs
1776                .get_mut(run_id)
1777                .expect("present")
1778                .end_step(step_id, status, output, error);
1779            if let Some(sc) = &scope {
1780                // Inside a body: `continue` marks done-with-error, `goto` re-arms
1781                // a sibling, `fail` leaves the step failed for the parent to judge.
1782                match &step.on_error {
1783                    OnError::Continue => {
1784                        if let Some(st) = self
1785                            .runs
1786                            .get_mut(run_id)
1787                            .and_then(|r| r.steps.get_mut(step_id))
1788                        {
1789                            st.status = StepStatus::Done;
1790                            st.error = Some(err_text.clone());
1791                            if st.output.is_none() {
1792                                st.output = Some(json!({"error": err_text}));
1793                            }
1794                        }
1795                    }
1796                    OnError::Goto(t) => {
1797                        let sid = super::nested::scoped_id(&sc.parent, t);
1798                        if let Some(st) = self
1799                            .runs
1800                            .get_mut(run_id)
1801                            .and_then(|r| r.steps.get_mut(&sid))
1802                        {
1803                            st.status = StepStatus::Pending;
1804                            st.forced = true;
1805                        }
1806                    }
1807                    OnError::Fail => {}
1808                }
1809                crate::state::kill_point("step.before_done");
1810                self.checkpoint(false);
1811                self.on_scoped_step_done(run_id, step_id);
1812                return;
1813            }
1814            let routed = run::route_failure(
1815                &wf,
1816                self.runs.get_mut(run_id).expect("present"),
1817                &step,
1818                &err_text,
1819            );
1820            match routed {
1821                Ok(next) => {
1822                    if !next.is_empty() {
1823                        self.log.info(
1824                            "step.goto",
1825                            json!({"run": run_id, "from": step_id, "to": next}),
1826                        );
1827                    }
1828                }
1829                Err(reason) => {
1830                    self.cancel_children_of_run(run_id, "run failed");
1831                    self.runs.get_mut(run_id).expect("present").finish(
1832                        RunStatus::Failed,
1833                        None,
1834                        Some(reason),
1835                    );
1836                    self.on_run_terminal(run_id);
1837                    return;
1838                }
1839            }
1840            if let OnError::Continue = step.on_error {
1841                // Already marked done-with-error by route_failure.
1842            }
1843        } else {
1844            // Memoize (`cache`).
1845            if step.cache.is_some()
1846                && status == StepStatus::Done
1847                && let Some(key) = self
1848                    .runs
1849                    .get(run_id)
1850                    .and_then(|r| r.steps.get(step_id))
1851                    .and_then(|st| st.wait.as_ref())
1852                    .and_then(|w| w.get("cache_key"))
1853                    .and_then(Value::as_str)
1854                    .map(str::to_string)
1855                && let Some(out) = &output
1856            {
1857                self.cache_store(&key, out);
1858            }
1859            self.runs
1860                .get_mut(run_id)
1861                .expect("present")
1862                .end_step(step_id, status, output, error);
1863        }
1864        crate::state::kill_point("step.before_done");
1865        self.checkpoint(false);
1866        if scope.is_some() {
1867            self.on_scoped_step_done(run_id, step_id);
1868        }
1869    }
1870
1871    /// The run reached a terminal state: report, wake, plan bindings, counters.
1872    pub(crate) fn on_run_terminal(&mut self, run_id: &str) {
1873        let Some(run) = self.runs.get(run_id) else {
1874            return;
1875        };
1876        let (status, output, error, workflow) = (
1877            run.status,
1878            run.output.clone(),
1879            run.error.clone(),
1880            run.workflow.clone(),
1881        );
1882        #[cfg(feature = "a2a")]
1883        let a2a_task = run.task.clone();
1884        // A `respond: sync` webhook awaiting this run gets its result now.
1885        #[cfg(feature = "a2a")]
1886        self.webhook_sync_reply(run_id);
1887        // A queued child run (`child_run` wait) resolves its parent step.
1888        if let Some(parent) = self.runs.get(run_id).and_then(|r| r.parent.clone())
1889            && let (Some(pr), Some(ps)) = (
1890                parent["run"].as_str().map(str::to_string),
1891                parent["step"].as_str().map(str::to_string),
1892            )
1893            && self
1894                .runs
1895                .get(&pr)
1896                .and_then(|r| r.steps.get(&ps))
1897                .is_some_and(|st| {
1898                    st.status == StepStatus::Suspended
1899                        && st.wait.as_ref().is_some_and(|w| w["kind"] == "child_run")
1900                })
1901        {
1902            self.finish_step_pub(
1903                &pr,
1904                &ps,
1905                if status == RunStatus::Completed {
1906                    StepStatus::Done
1907                } else {
1908                    StepStatus::Failed
1909                },
1910                Some(json!({"run": run_id, "status": status, "output": output, "error": error})),
1911                (status != RunStatus::Completed).then(|| {
1912                    error
1913                        .clone()
1914                        .unwrap_or_else(|| format!("child run {}", status.as_str()))
1915                }),
1916                0,
1917            );
1918        }
1919        self.counters.runs_finished += 1;
1920        crate::obs::metrics::record_run(match status {
1921            RunStatus::Completed => crate::obs::metrics::RunOutcome::Completed,
1922            RunStatus::Cancelled => crate::obs::metrics::RunOutcome::Killed,
1923            _ => crate::obs::metrics::RunOutcome::Failed,
1924        });
1925        crate::obs::metrics::record_run_status(status.as_str());
1926        self.log.info("run.done", json!({"run": run_id, "workflow": workflow, "status": status, "err": error, "output": if self.log.content_capture() { output.clone().unwrap_or(Value::Null) } else { Value::Null }}));
1927        self.governor.drop_scope(&format!("run:{run_id}"));
1928        // Answer waiters (workflow.wait / run sync).
1929        let waiting: Vec<Target> = self
1930            .pending
1931            .iter()
1932            .filter(|p| matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id))
1933            .map(|p| p.target.clone())
1934            .collect();
1935        self.pending
1936            .retain(|p| !matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id));
1937        for t in waiting {
1938            self.reply(
1939                &t,
1940                json!({"run": run_id, "status": status, "output": output, "error": error}),
1941                false,
1942            );
1943        }
1944        // Plan bindings + the root note (wake policy).
1945        let ok = status == RunStatus::Completed;
1946        let note = error
1947            .clone()
1948            .or_else(|| output.as_ref().map(|o| o.to_string()))
1949            .unwrap_or_default();
1950        self.settle_plan_bindings(
1951            &crate::context::plan::Binding::Run {
1952                id: run_id.to_string(),
1953            },
1954            ok,
1955            &note,
1956        );
1957        let wake = self.settings.agent.wake_on();
1958        let notify = match self.settings.agent.on_workflow_finished {
1959            crate::config::v2::OnWorkflowFinished::Ignore => false,
1960            _ => {
1961                ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFinished)
1962                    || !ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFailed)
1963            }
1964        };
1965        if notify && !self.job_shape {
1966            let short = if note.chars().count() > 400 {
1967                format!("{}…", note.chars().take(400).collect::<String>())
1968            } else {
1969                note.clone()
1970            };
1971            self.note_root(format!(
1972                "workflow {workflow} run {run_id} {}: {short}",
1973                status.as_str()
1974            ));
1975        }
1976        // A `loop` start re-arms the next iteration; `event` start nodes fire on
1977        // workflow.finished/failed.
1978        if let Some((wf, node, spec, kind)) = self.run_start_spec(run_id)
1979            && kind == "loop"
1980        {
1981            self.on_loop_run_finished(
1982                &wf,
1983                &node,
1984                &spec,
1985                ok,
1986                &output.clone().unwrap_or(Value::Null),
1987            );
1988        }
1989        if let Some(ev) = super::starts::run_event(status) {
1990            self.fire_event_starts(
1991                ev,
1992                &json!({"run": run_id, "workflow": workflow, "status": status.as_str()}),
1993            );
1994        }
1995        // A run started over A2A drives its task to the run's outcome.
1996        #[cfg(feature = "a2a")]
1997        if let Some(tid) = &a2a_task {
1998            self.a2a_task_for_run(tid, status.as_str(), output.as_ref(), error.as_deref());
1999        }
2000        self.checkpoint(false);
2001    }
2002
2003    /// Cancel a run: cancel its children, fail suspended waits, mark cancelled.
2004    pub(crate) fn cancel_run(&mut self, run_id: &str, reason: &str) {
2005        // Cascade to child runs started with `cascade: true` (RFC 0027 §6).
2006        let kids: Vec<String> = self
2007            .runs
2008            .values()
2009            .filter(|r| {
2010                !r.status.is_terminal()
2011                    && r.parent.as_ref().is_some_and(|p| {
2012                        p["run"].as_str() == Some(run_id) && p["cascade"].as_bool().unwrap_or(true)
2013                    })
2014            })
2015            .map(|r| r.id.clone())
2016            .collect();
2017        for k in kids {
2018            self.cancel_run(&k, "parent run cancelled");
2019        }
2020        self.cancel_children_of_run(run_id, reason);
2021        let timers = self.timers.owned_by(|o| o["run"].as_str() == Some(run_id));
2022        for t in timers {
2023            let _ = self.timers.disarm(&self.durable, &t);
2024        }
2025        self.pending
2026            .retain(|p| !matches!(&p.target, Target::Step(r, _) if r == run_id));
2027        if let Some(r) = self.runs.get_mut(run_id)
2028            && !r.status.is_terminal()
2029        {
2030            r.finish(RunStatus::Cancelled, None, Some(reason.to_string()));
2031            self.on_run_terminal(run_id);
2032        }
2033    }
2034
2035    fn cancel_children_of_run(&mut self, run_id: &str, reason: &str) {
2036        let nodes: Vec<_> = self
2037            .children
2038            .iter()
2039            .filter(|(_, c)| matches!(&c.kind, ChildKind::StepTurn { run, .. } if run == run_id))
2040            .map(|(n, _)| *n)
2041            .collect();
2042        for n in nodes {
2043            self.children.cancel(n, reason);
2044        }
2045    }
2046
2047    // ---- workflow.* tools ------------------------------------------------------
2048
2049    pub(crate) fn workflow_tool(
2050        &mut self,
2051        caller: &ToolCaller,
2052        name: &str,
2053        args: Value,
2054    ) -> ToolOutcome {
2055        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
2056        match name {
2057            "workflow.run" => {
2058                let wname = args["name"].as_str().unwrap_or("").to_string();
2059                let Some(w) = self.workflows.get(&wname) else {
2060                    return err(format!("no such workflow {wname:?}"));
2061                };
2062                let start = match args.get("start").and_then(Value::as_str) {
2063                    Some(s) => match w.step(s) {
2064                        Some(st) if st.is_start() => s.to_string(),
2065                        _ => return err(format!("workflow {wname:?} has no start node {s:?}")),
2066                    },
2067                    None => match default_start(w) {
2068                        Some(s) => s,
2069                        None => return err(format!("workflow {wname:?} has no start node")),
2070                    },
2071                };
2072                let wait = args.get("wait").and_then(Value::as_bool).unwrap_or(false);
2073                let timeout_ms = args
2074                    .get("timeout")
2075                    .and_then(Value::as_str)
2076                    .and_then(|t| crate::config::parse_duration(t).ok())
2077                    .map(|d| d.as_millis() as u64)
2078                    .unwrap_or(3_600_000);
2079                let request = match (caller.node, &caller.run, &caller.step) {
2080                    (Some(n), _, _) => {
2081                        json!({"node": n.0, "req": caller.req, "wait": wait, "timeout_ms": timeout_ms})
2082                    }
2083                    (None, Some(r), Some(s)) => {
2084                        json!({"run": r, "step": s, "wait": wait, "timeout_ms": timeout_ms})
2085                    }
2086                    _ => Value::Null,
2087                };
2088                let payload = json!({"workflow": wname, "node": start, "payload": {"requested_by": caller.label_pub()}, "inputs": args.get("inputs").cloned().unwrap_or(json!({})), "request": request, "conversation": caller.ctx});
2089                match self.accept_event(kinds::WORKFLOW_RUN, caller.principal.clone(), payload) {
2090                    Ok(_) => {
2091                        // Process it right away so the caller learns the run id.
2092                        if let Some(ev) = self.inbox_queue.pop_back() {
2093                            let done = self.on_start_event(&ev);
2094                            if done {
2095                                self.inbox_done(&ev.id);
2096                            }
2097                        }
2098                        // The reply is (or will be) delivered through the request
2099                        // target: immediately with the run id, or when the run
2100                        // finishes for `wait: true` (registered by `on_start_event`).
2101                        ToolOutcome::Executing
2102                    }
2103                    Err(e) => err(e),
2104                }
2105            }
2106            "workflow.list" => ToolOutcome::Ready(
2107                json!({"workflows": self.workflows.values().map(|w| json!({
2108                    "name": w.name, "description": w.description, "armed": w.armed, "hash": w.hash,
2109                    "starts": w.start_steps().iter().map(|s| json!({"node": s.id, "kind": s.kind})).collect::<Vec<_>>(),
2110                    "runs": self.runs.values().filter(|r| r.workflow == w.name).map(|r| json!({"id": r.id, "status": r.status})).collect::<Vec<_>>(),
2111                })).collect::<Vec<_>>()}),
2112                false,
2113            ),
2114            "workflow.status" => {
2115                let runs: Vec<Value> = match (
2116                    args.get("run").and_then(Value::as_str),
2117                    args.get("name").and_then(Value::as_str),
2118                ) {
2119                    (Some(id), _) => self
2120                        .runs
2121                        .get(id)
2122                        .map(|r| vec![run_detail(r)])
2123                        .unwrap_or_default(),
2124                    (None, Some(n)) => self
2125                        .runs
2126                        .values()
2127                        .filter(|r| r.workflow == n)
2128                        .map(RunState::summary)
2129                        .collect(),
2130                    _ => self.runs.values().map(RunState::summary).collect(),
2131                };
2132                ToolOutcome::Ready(json!({"runs": runs}), false)
2133            }
2134            "workflow.cancel" => {
2135                let id = args["run"].as_str().unwrap_or("").to_string();
2136                if !self.runs.contains_key(&id) {
2137                    return err(format!("no such run {id:?}"));
2138                }
2139                let reason = args
2140                    .get("reason")
2141                    .and_then(Value::as_str)
2142                    .unwrap_or("cancelled by request")
2143                    .to_string();
2144                self.cancel_run(&id, &reason);
2145                ToolOutcome::Ready(
2146                    json!({"ok": true, "status": self.runs.get(&id).map(|r| r.status.as_str()).unwrap_or("cancelled")}),
2147                    false,
2148                )
2149            }
2150            "workflow.wait" => {
2151                let id = args["run"].as_str().unwrap_or("").to_string();
2152                let timeout_ms = args
2153                    .get("timeout")
2154                    .and_then(Value::as_str)
2155                    .and_then(|t| crate::config::parse_duration(t).ok())
2156                    .map(|d| d.as_millis() as u64)
2157                    .unwrap_or(3_600_000);
2158                match self.runs.get(&id) {
2159                    None => err(format!("no such run {id:?}")),
2160                    Some(r) if r.status.is_terminal() => ToolOutcome::Ready(
2161                        json!({"run": id, "status": r.status, "output": r.output, "error": r.error}),
2162                        false,
2163                    ),
2164                    Some(_) => ToolOutcome::Deferred(PendingKind::Run {
2165                        run: id,
2166                        deadline_ms: now_ms() + timeout_ms,
2167                    }),
2168                }
2169            }
2170            "workflow.pause" | "workflow.resume" => {
2171                let pause = name == "workflow.pause";
2172                if let Some(id) = args.get("run").and_then(Value::as_str) {
2173                    match self.runs.get_mut(id) {
2174                        None => return err(format!("no such run {id:?}")),
2175                        Some(r) if r.status.is_terminal() => {
2176                            return err(format!("run {id:?} is already {}", r.status.as_str()));
2177                        }
2178                        Some(r) => {
2179                            r.status = if pause {
2180                                RunStatus::Paused
2181                            } else {
2182                                RunStatus::Running
2183                            };
2184                            r.touch();
2185                        }
2186                    }
2187                    return ToolOutcome::Ready(json!({"ok": true}), false);
2188                }
2189                if let Some(n) = args.get("name").and_then(Value::as_str) {
2190                    match self.workflows.get_mut(n) {
2191                        None => return err(format!("no such workflow {n:?}")),
2192                        Some(w) => w.armed = !pause,
2193                    }
2194                    if !pause {
2195                        self.arm_workflows();
2196                    }
2197                    return ToolOutcome::Ready(json!({"ok": true}), false);
2198                }
2199                err(format!("{name}: give run or name"))
2200            }
2201            "workflow.create" | "workflow.update" => {
2202                let def = args["definition"].clone();
2203                match parse_workflow(&def) {
2204                    Err(e) => err(format!("{name}: {}", e.join("; "))),
2205                    Ok(w) => {
2206                        if name == "workflow.create" && self.workflows.contains_key(&w.name) {
2207                            return err(format!(
2208                                "workflow {:?} exists (use workflow.update)",
2209                                w.name
2210                            ));
2211                        }
2212                        if name == "workflow.update" && !self.workflows.contains_key(&w.name) {
2213                            return err(format!(
2214                                "workflow {:?} does not exist (use workflow.create)",
2215                                w.name
2216                            ));
2217                        }
2218                        let (wname, hash) = (w.name.clone(), w.hash.clone());
2219                        // Durable definition (memory/_workflows/<name>).
2220                        let rec = crate::context::memory::Record {
2221                            value: def,
2222                            ts: now_ms(),
2223                            ttl_ms: None,
2224                            by: Some(caller.label_pub()),
2225                        };
2226                        if let Err(e) = self.durable.put(
2227                            Kind::Memory,
2228                            &format!("{WORKFLOW_DEF_PREFIX}{wname}"),
2229                            serde_json::to_value(&rec).unwrap_or(Value::Null),
2230                            None,
2231                        ) {
2232                            return err(format!("{name}: store: {e}"));
2233                        }
2234                        let arm = args.get("arm").and_then(Value::as_bool).unwrap_or(true);
2235                        let mut w = w;
2236                        w.armed = arm;
2237                        self.workflows.insert(wname.clone(), w);
2238                        self.log.info(
2239                            "workflow.defined",
2240                            json!({"name": wname, "hash": &hash[..12], "op": name}),
2241                        );
2242                        if arm {
2243                            self.arm_workflows();
2244                        }
2245                        ToolOutcome::Ready(
2246                            json!({"name": wname, "hash": hash, "armed": arm}),
2247                            false,
2248                        )
2249                    }
2250                }
2251            }
2252            "workflow.delete" => {
2253                let wname = args["name"].as_str().unwrap_or("").to_string();
2254                if self.workflows.remove(&wname).is_none() {
2255                    return err(format!("no such workflow {wname:?}"));
2256                }
2257                let _ = self
2258                    .durable
2259                    .delete(Kind::Memory, &format!("{WORKFLOW_DEF_PREFIX}{wname}"));
2260                self.log.info("workflow.deleted", json!({"name": wname}));
2261                ToolOutcome::Ready(json!({"ok": true}), false)
2262            }
2263            "workflow.signal" => {
2264                let sname = args["name"].as_str().unwrap_or("").to_string();
2265                let _ = self.accept_event(kinds::SIGNAL, caller.principal.clone(), json!({"name": sname, "payload": args.get("payload").cloned().unwrap_or(Value::Null), "run": args.get("run"), "from": caller.label_pub()}));
2266                // Signal start nodes / waits land with P4; the signal is recorded.
2267                ToolOutcome::Ready(
2268                    json!({"delivered": 0, "note": "signal recorded; signal start nodes and waits land with the P4 engine"}),
2269                    false,
2270                )
2271            }
2272            _ => err(format!("unknown workflow tool {name}")),
2273        }
2274    }
2275}
2276
2277impl ToolCaller {
2278    pub(crate) fn label_pub(&self) -> String {
2279        if let Some(s) = &self.subagent {
2280            return format!("subagent:{s}");
2281        }
2282        if let (Some(r), Some(s)) = (&self.run, &self.step) {
2283            return format!("step:{r}/{s}");
2284        }
2285        format!(
2286            "ctx:{}",
2287            self.ctx.as_deref().unwrap_or(crate::context::ROOT)
2288        )
2289    }
2290}
2291
2292/// The start node `workflow.run` uses by default: `manual`, else the first.
2293fn default_start(w: &Workflow) -> Option<String> {
2294    let starts = w.start_steps();
2295    starts
2296        .iter()
2297        .find(|s| s.kind == "manual")
2298        .or_else(|| starts.first())
2299        .map(|s| s.id.clone())
2300}
2301
2302fn node_kind<'a>(w: &'a Workflow, node: &str) -> Option<&'a str> {
2303    w.step(node).map(|s| s.kind.as_str())
2304}
2305
2306fn run_detail(r: &RunState) -> Value {
2307    let mut v = r.summary();
2308    v["step_states"] = json!(r.steps);
2309    v["vars"] = Value::Object(r.vars.clone());
2310    v
2311}
2312
2313/// The `memory.<key>` roots a value's templates reference.
2314fn collect_memory_keys(v: &Value, out: &mut Vec<String>) {
2315    match v {
2316        Value::String(s) => {
2317            let mut rest = s.as_str();
2318            while let Some(i) = rest.find("memory.") {
2319                let after = &rest[i + "memory.".len()..];
2320                let key: String = after
2321                    .chars()
2322                    .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | ':'))
2323                    .collect();
2324                if !key.is_empty() && !out.contains(&key) {
2325                    out.push(key.clone());
2326                }
2327                rest = &after[key.len().min(after.len())..];
2328            }
2329        }
2330        Value::Array(a) => a.iter().for_each(|x| collect_memory_keys(x, out)),
2331        Value::Object(o) => o.values().for_each(|x| collect_memory_keys(x, out)),
2332        _ => {}
2333    }
2334}