Skip to main content

a3s_code_core/
dynamic_workflow.rs

1//! A3S Flow-backed dynamic workflow runtime.
2//!
3//! `DynamicWorkflowRuntime` lets hosts run a sandboxed PTC script as an A3S
4//! Flow runtime. Flow owns durable replay and step lifecycle; A3S Code's
5//! existing `program` tool remains the sandbox and tool-call boundary.
6
7use crate::llm::{ModelGenerationAdmission, ModelGenerationConcurrency};
8use crate::tools::{
9    registry_tool_invoker, Tool, ToolContext, ToolInvoker, ToolOutput, ToolRegistry, ToolResult,
10};
11use crate::{
12    agent::AgentEvent,
13    flow_graph::FlowGraphObserver,
14    planning::{Complexity, ExecutionPlan, Task, TaskStatus},
15};
16use a3s_flow::{
17    FanoutFlowEventObserver, FlowEngine, FlowEvent, FlowEventEnvelope, FlowEventObserver,
18    FlowEventStore, FlowRuntime, InMemoryEventStore, LocalFileEventStore, RuntimeCommand,
19    StepInvocation, StepStatus, WorkflowInvocation, WorkflowRunSnapshot, WorkflowRunStatus,
20    WorkflowSpec,
21};
22use anyhow::{Context, Result};
23use async_trait::async_trait;
24use chrono::Utc;
25use serde::{Deserialize, Serialize};
26use serde_json::{json, Map, Value};
27use std::collections::BTreeSet;
28use std::num::NonZeroUsize;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::sync::{broadcast, Mutex};
33
34const DYNAMIC_WORKFLOW_TOOL: &str = "dynamic_workflow";
35const GENERATE_OBJECT_TOOL: &str = "generate_object";
36const PROGRAM_TOOL: &str = "program";
37const PARALLEL_TASK_TOOL: &str = "parallel_task";
38const MAX_INLINE_RETRY_RESUMES: usize = 8;
39const MAX_INLINE_RETRY_DELAY: Duration = Duration::from_secs(5);
40
41/// Project-relative directory used for durable dynamic workflow history.
42pub const DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH: &str = ".a3s/workflow";
43
44/// Resolve the durable dynamic workflow history directory for a local workspace.
45pub fn dynamic_workflow_store_path(workspace_root: impl AsRef<Path>) -> PathBuf {
46    workspace_root
47        .as_ref()
48        .join(DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH)
49}
50
51/// Recover one completed step output from the exact durable workflow run.
52///
53/// Recovery is bound to the requested run ID and the original input query. It
54/// never acts as a cross-run query cache and never promotes an incomplete step.
55pub async fn recover_dynamic_workflow_step_output(
56    workspace_root: impl AsRef<Path>,
57    run_id: &str,
58    expected_query: &str,
59    step_id: &str,
60) -> Result<Option<Value>> {
61    if !safe_workflow_run_id(run_id) || expected_query.is_empty() || step_id.is_empty() {
62        return Ok(None);
63    }
64    let workspace_root = workspace_root.as_ref();
65    let store_root = dynamic_workflow_store_path(workspace_root);
66    let log_path = store_root.join(format!("{run_id}.jsonl"));
67    match tokio::fs::symlink_metadata(&log_path).await {
68        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
69            return Ok(None)
70        }
71        Ok(_) => {}
72        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
73        Err(error) => {
74            return Err(error).with_context(|| {
75                format!("inspect dynamic workflow history {}", log_path.display())
76            })
77        }
78    }
79    validate_dynamic_workflow_directory(&workspace_root.join(".a3s"), ".a3s").await?;
80    validate_dynamic_workflow_directory(&store_root, ".a3s/workflow").await?;
81    validate_dynamic_workflow_log(&log_path).await?;
82
83    let events = LocalFileEventStore::new(store_root).list(run_id).await?;
84    let input_matches = events.iter().any(|envelope| {
85        matches!(
86            &envelope.event,
87            FlowEvent::RunCreated { input, .. }
88                if input.get("query").and_then(Value::as_str) == Some(expected_query)
89        )
90    });
91    if !input_matches {
92        return Ok(None);
93    }
94    Ok(events
95        .iter()
96        .rev()
97        .find_map(|envelope| match &envelope.event {
98            FlowEvent::StepCompleted {
99                step_id: completed_step_id,
100                output,
101            } if completed_step_id == step_id => Some(output.clone()),
102            _ => None,
103        }))
104}
105
106/// Limits forwarded to the underlying PTC `program` tool.
107#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub struct DynamicWorkflowScriptLimits {
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub timeout_ms: Option<u64>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub max_tool_calls: Option<usize>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub max_output_bytes: Option<usize>,
116    /// Maximum independently session-bound model generations active at once.
117    /// This orchestration limit is not forwarded to the PTC program sandbox.
118    #[serde(default, skip_serializing)]
119    pub max_concurrent_generations: Option<usize>,
120}
121
122/// Runs A3S Flow workflow and step invocations through a sandboxed PTC script.
123#[derive(Clone)]
124pub struct DynamicWorkflowRuntime {
125    invoker: Arc<dyn ToolInvoker>,
126    context: ToolContext,
127    source: Arc<str>,
128    allowed_tools: Vec<String>,
129    limits: DynamicWorkflowScriptLimits,
130    parallel_generation_admission: Option<ModelGenerationAdmission>,
131}
132
133impl DynamicWorkflowRuntime {
134    pub fn new(
135        registry: Arc<ToolRegistry>,
136        context: ToolContext,
137        source: impl Into<String>,
138    ) -> Self {
139        let allowed_tools = default_allowed_tools(&registry);
140        // Session/agent callers install the governed gateway in ToolContext.
141        // The raw registry adapter is retained only for explicit low-level
142        // callers that construct this public runtime outside an AgentSession.
143        let invoker = context
144            .tool_invoker()
145            .unwrap_or_else(|| registry_tool_invoker(registry));
146        Self {
147            invoker,
148            context,
149            source: Arc::from(source.into()),
150            allowed_tools,
151            limits: DynamicWorkflowScriptLimits::default(),
152            parallel_generation_admission: None,
153        }
154    }
155
156    pub fn with_allowed_tools(mut self, allowed_tools: impl IntoIterator<Item = String>) -> Self {
157        self.allowed_tools = sanitize_allowed_tools(allowed_tools);
158        self
159    }
160
161    pub fn with_limits(mut self, limits: DynamicWorkflowScriptLimits) -> Self {
162        let generation_concurrency = limits.max_concurrent_generations.unwrap_or(1).clamp(1, 4);
163        self.parallel_generation_admission = NonZeroUsize::new(generation_concurrency)
164            .filter(|maximum| maximum.get() > 1)
165            .map(|maximum| {
166                ModelGenerationAdmission::new(ModelGenerationConcurrency::bounded(maximum))
167            });
168        self.limits = limits;
169        self
170    }
171
172    async fn run_script(
173        &self,
174        payload: Value,
175        context: &ToolContext,
176    ) -> a3s_flow::Result<ToolResult> {
177        let mut args = json!({
178            "type": "script",
179            "language": "javascript",
180            "source": self.source.as_ref(),
181            "inputs": payload,
182            "allowed_tools": self.allowed_tools,
183        });
184        if let Some(object) = args.as_object_mut() {
185            if let Ok(Value::Object(limits)) = serde_json::to_value(&self.limits) {
186                if !limits.is_empty() {
187                    object.insert("limits".to_string(), Value::Object(limits));
188                }
189            }
190        }
191
192        let result = self
193            .invoker
194            .invoke(context.nested_tool_invocation(PROGRAM_TOOL, args), context)
195            .await;
196        if result.exit_code != 0 {
197            return Err(a3s_flow::FlowError::Runtime(result.output));
198        }
199        Ok(result)
200    }
201
202    async fn context_for_step(
203        &self,
204        run_id: &str,
205        step_id: &str,
206        step_name: &str,
207    ) -> a3s_flow::Result<ToolContext> {
208        if step_name != GENERATE_OBJECT_TOOL {
209            return Ok(self.context.clone());
210        }
211        if let (Some(admission), Some(client)) = (
212            self.parallel_generation_admission.as_ref(),
213            self.context.llm_client(),
214        ) {
215            let fork_id = format!("{run_id}:{step_id}");
216            if let Some(forked_client) = client.fork_for_session(&fork_id) {
217                let permit = admission
218                    .acquire(&self.context.cancellation_token())
219                    .await
220                    .map_err(|error| {
221                        a3s_flow::FlowError::Runtime(format!(
222                            "parallel model-generation admission failed before workflow step: {error}"
223                        ))
224                    })?;
225                return self
226                    .context
227                    .clone()
228                    .with_llm_client(forked_client)
229                    .with_model_generation_permit(admission.clone(), Arc::new(permit))
230                    .map_err(|error| {
231                        a3s_flow::FlowError::Runtime(format!(
232                            "bind parallel model-generation admission to workflow step: {error}"
233                        ))
234                    });
235            }
236        }
237        let Some(admission) = self.context.model_generation_admission() else {
238            return Ok(self.context.clone());
239        };
240        let permit = admission
241            .acquire(&self.context.cancellation_token())
242            .await
243            .map_err(|error| {
244                a3s_flow::FlowError::Runtime(format!(
245                    "model-generation admission failed before workflow step: {error}"
246                ))
247            })?;
248        self.context
249            .clone()
250            .with_model_generation_permit(admission, Arc::new(permit))
251            .map_err(|error| {
252                a3s_flow::FlowError::Runtime(format!(
253                    "bind model-generation admission to workflow step: {error}"
254                ))
255            })
256    }
257
258    async fn run_tool_step(&self, tool_name: &str, args: Value) -> a3s_flow::Result<Value> {
259        let result = self
260            .invoker
261            .invoke(
262                self.context
263                    .nested_tool_invocation(tool_name.to_string(), args),
264                &self.context,
265            )
266            .await;
267        if result.exit_code != 0 {
268            return Err(a3s_flow::FlowError::Runtime(result.output));
269        }
270        Ok(json!({
271            "tool": result.name,
272            "output": result.output,
273            "exit_code": result.exit_code,
274            "metadata": result.metadata,
275        }))
276    }
277}
278
279#[async_trait]
280impl FlowRuntime for DynamicWorkflowRuntime {
281    async fn run_workflow(
282        &self,
283        invocation: WorkflowInvocation,
284    ) -> a3s_flow::Result<RuntimeCommand> {
285        let payload = invocation_payload("workflow", &invocation.run_id, &invocation.history)
286            .with("input", invocation.input);
287        let result = self.run_script(payload.into_value(), &self.context).await?;
288        serde_json::from_value(script_result(&result)?).map_err(a3s_flow::FlowError::from)
289    }
290
291    async fn run_step(&self, invocation: StepInvocation) -> a3s_flow::Result<Value> {
292        if invocation.step_name == PARALLEL_TASK_TOOL {
293            return self
294                .run_tool_step(PARALLEL_TASK_TOOL, invocation.input)
295                .await;
296        }
297
298        let context = self
299            .context_for_step(
300                &invocation.run_id,
301                &invocation.step_id,
302                &invocation.step_name,
303            )
304            .await?;
305        let payload = invocation_payload("step", &invocation.run_id, &invocation.history)
306            .with("step_id", invocation.step_id)
307            .with("step_name", invocation.step_name)
308            .with("input", invocation.input);
309        let result = self.run_script(payload.into_value(), &context).await?;
310        script_result(&result)
311    }
312}
313
314struct WorkflowProgressState {
315    tasks: Vec<Task>,
316}
317
318impl WorkflowProgressState {
319    fn new() -> Self {
320        Self { tasks: Vec::new() }
321    }
322
323    fn upsert_step(
324        &mut self,
325        step_id: &str,
326        step_name: &str,
327        input: Option<&Value>,
328        status: TaskStatus,
329    ) {
330        let content = workflow_step_description(step_id, step_name, input);
331        if let Some(task) = self.tasks.iter_mut().find(|task| task.id == step_id) {
332            task.content = content;
333            task.status = status;
334            task.tool = Some(step_name.to_string());
335        } else {
336            self.tasks
337                .push(Task::new(step_id.to_string(), content).with_tool(step_name));
338            if let Some(task) = self.tasks.last_mut() {
339                task.status = status;
340            }
341        }
342    }
343
344    fn mark_status(&mut self, step_id: &str, status: TaskStatus) {
345        if let Some(task) = self.tasks.iter_mut().find(|task| task.id == step_id) {
346            task.status = status;
347        }
348    }
349
350    fn step_position(&self, step_id: &str) -> (usize, usize) {
351        let total = self.tasks.len().max(1);
352        let number = self
353            .tasks
354            .iter()
355            .position(|task| task.id == step_id)
356            .map(|idx| idx + 1)
357            .unwrap_or(total);
358        (number, total)
359    }
360
361    fn step_description(&self, step_id: &str) -> String {
362        self.tasks
363            .iter()
364            .find(|task| task.id == step_id)
365            .map(|task| task.content.clone())
366            .unwrap_or_else(|| step_id.to_string())
367    }
368}
369
370struct AgentEventFlowObserver {
371    tx: broadcast::Sender<AgentEvent>,
372    session_id: String,
373    state: Mutex<WorkflowProgressState>,
374}
375
376impl AgentEventFlowObserver {
377    fn new(tx: broadcast::Sender<AgentEvent>, session_id: String) -> Self {
378        Self {
379            tx,
380            session_id,
381            state: Mutex::new(WorkflowProgressState::new()),
382        }
383    }
384
385    fn emit_task_update(&self, tasks: &[Task]) {
386        let _ = self.tx.send(AgentEvent::TaskUpdated {
387            session_id: self.session_id.clone(),
388            tasks: tasks.to_vec(),
389        });
390    }
391}
392
393#[async_trait]
394impl FlowEventObserver for AgentEventFlowObserver {
395    async fn observe(&self, envelope: FlowEventEnvelope) {
396        match envelope.event {
397            FlowEvent::RunStarted => {
398                let _ = self.tx.send(AgentEvent::PlanningStart {
399                    prompt: "dynamic_workflow".to_string(),
400                });
401            }
402            FlowEvent::StepCreated {
403                step_id,
404                step_name,
405                input,
406                ..
407            } => {
408                let mut state = self.state.lock().await;
409                state.upsert_step(&step_id, &step_name, Some(&input), TaskStatus::Pending);
410                self.emit_task_update(&state.tasks);
411                let mut plan = ExecutionPlan::new("dynamic workflow", Complexity::Medium);
412                for task in state.tasks.iter().cloned() {
413                    plan.add_step(task);
414                }
415                let _ = self.tx.send(AgentEvent::PlanningEnd {
416                    estimated_steps: plan.steps.len(),
417                    plan,
418                });
419            }
420            FlowEvent::StepStarted { step_id, .. } => {
421                let mut state = self.state.lock().await;
422                state.mark_status(&step_id, TaskStatus::InProgress);
423                self.emit_task_update(&state.tasks);
424                let (step_number, total_steps) = state.step_position(&step_id);
425                let _ = self.tx.send(AgentEvent::StepStart {
426                    description: state.step_description(&step_id),
427                    step_id,
428                    step_number,
429                    total_steps,
430                });
431            }
432            FlowEvent::StepCompleted { step_id, .. } => {
433                let mut state = self.state.lock().await;
434                state.mark_status(&step_id, TaskStatus::Completed);
435                self.emit_task_update(&state.tasks);
436                let (step_number, total_steps) = state.step_position(&step_id);
437                let _ = self.tx.send(AgentEvent::StepEnd {
438                    step_id,
439                    status: TaskStatus::Completed,
440                    step_number,
441                    total_steps,
442                });
443            }
444            FlowEvent::StepRetrying { step_id, .. } => {
445                let mut state = self.state.lock().await;
446                state.mark_status(&step_id, TaskStatus::InProgress);
447                self.emit_task_update(&state.tasks);
448            }
449            FlowEvent::StepFailed { step_id, .. } => {
450                let mut state = self.state.lock().await;
451                state.mark_status(&step_id, TaskStatus::Failed);
452                self.emit_task_update(&state.tasks);
453                let (step_number, total_steps) = state.step_position(&step_id);
454                let _ = self.tx.send(AgentEvent::StepEnd {
455                    step_id,
456                    status: TaskStatus::Failed,
457                    step_number,
458                    total_steps,
459                });
460            }
461            FlowEvent::RunFailed { .. } => {
462                let mut state = self.state.lock().await;
463                for task in &mut state.tasks {
464                    if task.status.is_active() {
465                        task.status = TaskStatus::Failed;
466                    }
467                }
468                self.emit_task_update(&state.tasks);
469            }
470            FlowEvent::RunCancelled { .. } => {
471                let mut state = self.state.lock().await;
472                for task in &mut state.tasks {
473                    if task.status.is_active() {
474                        task.status = TaskStatus::Cancelled;
475                    }
476                }
477                self.emit_task_update(&state.tasks);
478            }
479            _ => {}
480        }
481    }
482}
483
484fn workflow_step_description(step_id: &str, step_name: &str, input: Option<&Value>) -> String {
485    if step_name == PARALLEL_TASK_TOOL {
486        let count = input
487            .and_then(|value| value.get("tasks"))
488            .and_then(Value::as_array)
489            .map(Vec::len)
490            .unwrap_or(0);
491        if count > 0 {
492            return format!("Fan out {count} parallel subagent task(s)");
493        }
494    }
495
496    input
497        .and_then(|value| value.get("description").or_else(|| value.get("title")))
498        .and_then(Value::as_str)
499        .map(ToString::to_string)
500        .unwrap_or_else(|| {
501            if step_name == step_id {
502                step_id.to_string()
503            } else {
504                format!("{step_name}: {step_id}")
505            }
506        })
507}
508
509/// Model-visible tool that executes a dynamic workflow through A3S Flow.
510pub struct DynamicWorkflowTool {
511    registry: Arc<ToolRegistry>,
512    graph_observer: Option<FlowGraphObserver>,
513}
514
515impl DynamicWorkflowTool {
516    pub fn new(registry: Arc<ToolRegistry>) -> Self {
517        Self {
518            registry,
519            graph_observer: None,
520        }
521    }
522
523    /// Project committed Flow events into an optional reactive state graph.
524    /// A3S Flow remains the workflow execution source of truth.
525    pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
526        self.graph_observer = Some(observer);
527        self
528    }
529}
530
531#[async_trait]
532impl Tool for DynamicWorkflowTool {
533    fn name(&self) -> &str {
534        DYNAMIC_WORKFLOW_TOOL
535    }
536
537    fn description(&self) -> &str {
538        "Run a local dynamic workflow with A3S Flow. The workflow source is a sandboxed JavaScript PTC script that may call allowed ctx tools; A3S Flow records workflow and step history."
539    }
540
541    fn parameters(&self) -> Value {
542        json!({
543            "type": "object",
544            "additionalProperties": false,
545            "properties": {
546                "source": {
547                    "type": "string",
548                    "description": "JavaScript PTC source defining async function run(ctx, inputs). For inputs.kind='workflow', return a Flow command: {type:'complete', output}, {type:'fail', error}, {type:'schedule_step', step_id, step_name, input, retry?}, or {type:'schedule_steps', steps:[...]}. For inputs.kind='step', return the step JSON output. A scheduled step with step_name='parallel_task' bypasses QuickJS and calls the host parallel_task tool directly with input as its arguments."
549                },
550                "input": {
551                    "type": "object",
552                    "description": "Initial workflow input."
553                },
554                "run_id": {
555                    "type": "string",
556                    "description": "Optional durable run id. Reusing it with the same source and input is idempotent."
557                },
558                "allowed_tools": {
559                    "type": "array",
560                    "description": "Tool names the workflow script may call through ctx. Defaults to all registered tools except program, dynamic_workflow, and parallel_task. Login-registered tools such as runtime are allowed when present.",
561                    "items": { "type": "string" }
562                },
563                "limits": {
564                    "type": "object",
565                    "additionalProperties": false,
566                    "properties": {
567                        "timeoutMs": { "type": "integer", "minimum": 1 },
568                        "maxToolCalls": { "type": "integer", "minimum": 1 },
569                        "maxOutputBytes": { "type": "integer", "minimum": 1 },
570                        "maxConcurrentGenerations": {
571                            "type": "integer",
572                            "minimum": 1,
573                            "maximum": 4,
574                            "description": "Optional bounded fan-out for independently session-bound generate_object steps. Providers without session forking remain single-flight."
575                        }
576                    }
577                }
578            },
579            "required": ["source"]
580        })
581    }
582
583    async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
584        let Some(source) = args.get("source").and_then(Value::as_str) else {
585            return Ok(ToolOutput::error("dynamic_workflow requires source"));
586        };
587        let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
588        let allowed_tools = args
589            .get("allowed_tools")
590            .and_then(Value::as_array)
591            .map(|items| {
592                items
593                    .iter()
594                    .filter_map(Value::as_str)
595                    .map(ToString::to_string)
596                    .collect::<Vec<_>>()
597            })
598            .unwrap_or_else(|| default_allowed_tools(&self.registry));
599        let limits = args
600            .get("limits")
601            .cloned()
602            .and_then(|value| serde_json::from_value(value).ok())
603            .unwrap_or_default();
604
605        let runtime = Arc::new(
606            DynamicWorkflowRuntime::new(Arc::clone(&self.registry), ctx.clone(), source)
607                .with_allowed_tools(allowed_tools)
608                .with_limits(limits),
609        );
610        let requested_run_id = args.get("run_id").and_then(Value::as_str);
611        let store = match flow_store_for_context(ctx, requested_run_id).await {
612            Ok(store) => store,
613            Err(error) => return Ok(ToolOutput::error(error.to_string())),
614        };
615        let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
616        if let Some(tx) = ctx.agent_event_tx.clone() {
617            observers.push(Arc::new(AgentEventFlowObserver::new(
618                tx,
619                ctx.session_id.clone().unwrap_or_default(),
620            )));
621        }
622        if let Some(observer) = &self.graph_observer {
623            observers.push(Arc::new(observer.clone()));
624        }
625        let engine = if observers.is_empty() {
626            FlowEngine::new(store, runtime)
627        } else {
628            FlowEngine::builder(runtime)
629                .with_store(store)
630                .with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)))
631                .build()
632        };
633        let source_hash = source_hash(source);
634        let spec = WorkflowSpec::rust_embedded(
635            "a3s-code.dynamic-workflow",
636            source_hash.as_str(),
637            "ptc",
638            "run",
639        );
640
641        let run_id = match requested_run_id {
642            Some(run_id) => match engine.start_with_id(run_id, spec, input).await {
643                Ok(run_id) => run_id,
644                Err(err) => return Ok(ToolOutput::error(err.to_string())),
645            },
646            None => match engine.start(spec, input).await {
647                Ok(run_id) => run_id,
648                Err(err) => return Ok(ToolOutput::error(err.to_string())),
649            },
650        };
651
652        let snapshot = match drive_inline_retries(&engine, &run_id, ctx).await {
653            Ok(snapshot) => snapshot,
654            Err(err) => return Ok(ToolOutput::error(err.to_string())),
655        };
656        let history = match engine.history(&run_id).await {
657            Ok(history) => history,
658            Err(err) => return Ok(ToolOutput::error(err.to_string())),
659        };
660
661        let output = match &snapshot.output {
662            Some(output) => {
663                serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string())
664            }
665            None => snapshot
666                .error
667                .clone()
668                .unwrap_or_else(|| format!("workflow status: {:?}", snapshot.status)),
669        };
670
671        let status = snapshot.status;
672        let metadata = json!({
673            "dynamic_workflow": {
674                "run_id": run_id,
675                "status": format!("{:?}", snapshot.status),
676                "last_sequence": snapshot.last_sequence,
677                "source_hash": source_hash,
678                "snapshot": snapshot,
679                "history": history,
680            }
681        });
682        let output = match status {
683            WorkflowRunStatus::Completed => ToolOutput::success(output),
684            WorkflowRunStatus::Failed | WorkflowRunStatus::Cancelled => ToolOutput::error(output),
685            _ => ToolOutput::error(format!(
686                "dynamic_workflow ended without a terminal result: {status:?}; {output}"
687            )),
688        };
689
690        Ok(output.with_metadata(metadata))
691    }
692}
693
694/// Drive short, persisted step retries inside the originating tool call.
695///
696/// A3S Flow deliberately suspends at a delayed retry boundary. Interactive
697/// waits and hooks must remain suspended for an external host, but a bounded
698/// retry delay is ordinary fault recovery: returning it as a terminal tool
699/// error forces every caller to reimplement the scheduler and previously made
700/// DeepResearch abandon its event-sourced run. Retry attempts and their delay
701/// remain authoritative in the Flow journal; this helper only waits for the
702/// due time and asks the engine to replay the same run.
703async fn drive_inline_retries(
704    engine: &FlowEngine,
705    run_id: &str,
706    ctx: &ToolContext,
707) -> Result<WorkflowRunSnapshot> {
708    for _ in 0..MAX_INLINE_RETRY_RESUMES {
709        let snapshot = engine.snapshot(run_id).await?;
710        if snapshot.status.is_terminal() {
711            return Ok(snapshot);
712        }
713        let Some(retry_after) = snapshot
714            .steps
715            .values()
716            .filter(|step| step.status == StepStatus::Pending)
717            .filter_map(|step| step.retry_after)
718            .min()
719        else {
720            return Ok(snapshot);
721        };
722        let delay = retry_after
723            .signed_duration_since(Utc::now())
724            .to_std()
725            .unwrap_or_default();
726        if delay > MAX_INLINE_RETRY_DELAY {
727            return Ok(snapshot);
728        }
729        let cancellation = ctx.cancellation_token();
730        tokio::select! {
731            biased;
732            _ = cancellation.cancelled() => {
733                anyhow::bail!("dynamic_workflow cancelled while waiting for a scheduled retry");
734            }
735            _ = tokio::time::sleep(delay) => {}
736        }
737        engine.drive(run_id).await?;
738    }
739    engine.snapshot(run_id).await.map_err(Into::into)
740}
741
742pub fn register_dynamic_workflow(registry: &Arc<ToolRegistry>) {
743    registry.register(Arc::new(DynamicWorkflowTool::new(Arc::clone(registry))));
744}
745
746async fn flow_store_for_context(
747    ctx: &ToolContext,
748    requested_run_id: Option<&str>,
749) -> Result<Arc<dyn FlowEventStore>> {
750    match ctx.workspace_services.local_root() {
751        Some(root) => {
752            let store = dynamic_workflow_store_path(root);
753            validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
754            validate_dynamic_workflow_directory(&store, ".a3s/workflow").await?;
755            if let Some(run_id) = requested_run_id.filter(|run_id| safe_workflow_run_id(run_id)) {
756                validate_dynamic_workflow_log(&store.join(format!("{run_id}.jsonl"))).await?;
757            }
758            Ok(Arc::new(LocalFileEventStore::new(store)))
759        }
760        None => Ok(Arc::new(InMemoryEventStore::new())),
761    }
762}
763
764async fn validate_dynamic_workflow_directory(path: &Path, label: &str) -> Result<()> {
765    match tokio::fs::symlink_metadata(path).await {
766        Ok(metadata) if metadata.file_type().is_symlink() => {
767            anyhow::bail!("refusing to use symlinked dynamic workflow directory {label}")
768        }
769        Ok(metadata) if !metadata.is_dir() => {
770            anyhow::bail!("dynamic workflow path {label} exists but is not a directory")
771        }
772        Ok(_) => Ok(()),
773        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
774        Err(error) => Err(error).with_context(|| format!("inspect dynamic workflow path {label}")),
775    }
776}
777
778async fn validate_dynamic_workflow_log(path: &Path) -> Result<()> {
779    match tokio::fs::symlink_metadata(path).await {
780        Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
781            "refusing to read or append symlinked dynamic workflow history {}",
782            path.display()
783        ),
784        Ok(metadata) if !metadata.is_file() => anyhow::bail!(
785            "dynamic workflow history path {} exists but is not a file",
786            path.display()
787        ),
788        Ok(_) => Ok(()),
789        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
790        Err(error) => Err(error)
791            .with_context(|| format!("inspect dynamic workflow history {}", path.display())),
792    }
793}
794
795fn safe_workflow_run_id(run_id: &str) -> bool {
796    !run_id.is_empty()
797        && run_id
798            .chars()
799            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
800}
801
802struct PayloadBuilder {
803    value: Map<String, Value>,
804}
805
806impl PayloadBuilder {
807    fn with(mut self, key: &str, value: impl Serialize) -> Self {
808        self.value.insert(
809            key.to_string(),
810            serde_json::to_value(value).unwrap_or(Value::Null),
811        );
812        self
813    }
814
815    fn into_value(self) -> Value {
816        Value::Object(self.value)
817    }
818}
819
820fn invocation_payload(kind: &str, run_id: &str, history: &[FlowEventEnvelope]) -> PayloadBuilder {
821    let mut value = Map::new();
822    value.insert("kind".to_string(), json!(kind));
823    value.insert("run_id".to_string(), json!(run_id));
824    value.insert("history".to_string(), json!(history));
825    value.insert("step_outputs".to_string(), completed_step_outputs(history));
826    value.insert("step_failures".to_string(), failed_step_outputs(history));
827    PayloadBuilder { value }
828}
829
830fn completed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
831    let mut outputs = Map::new();
832    for envelope in history {
833        if let FlowEvent::StepCompleted { step_id, output } = &envelope.event {
834            outputs.insert(step_id.clone(), output.clone());
835        }
836    }
837    Value::Object(outputs)
838}
839
840fn failed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
841    let mut outputs = Map::new();
842    for envelope in history {
843        if let FlowEvent::StepFailed {
844            step_id,
845            attempt,
846            error,
847        } = &envelope.event
848        {
849            outputs.insert(
850                step_id.clone(),
851                json!({
852                    "attempt": attempt,
853                    "error": error,
854                }),
855            );
856        }
857    }
858    Value::Object(outputs)
859}
860
861fn script_result(result: &ToolResult) -> a3s_flow::Result<Value> {
862    result
863        .metadata
864        .as_ref()
865        .and_then(|metadata| metadata.get("script_result"))
866        .cloned()
867        .ok_or_else(|| {
868            a3s_flow::FlowError::Runtime(
869                "PTC program result did not include script_result metadata".to_string(),
870            )
871        })
872}
873
874fn default_allowed_tools(registry: &ToolRegistry) -> Vec<String> {
875    sanitize_allowed_tools(registry.list())
876}
877
878fn sanitize_allowed_tools(items: impl IntoIterator<Item = String>) -> Vec<String> {
879    let mut tools = items.into_iter().collect::<BTreeSet<_>>();
880    tools.remove(PROGRAM_TOOL);
881    tools.remove(DYNAMIC_WORKFLOW_TOOL);
882    tools.remove(PARALLEL_TASK_TOOL);
883    tools.into_iter().collect()
884}
885
886fn source_hash(source: &str) -> String {
887    sha256::digest(source.as_bytes())
888}
889
890#[cfg(test)]
891#[path = "dynamic_workflow/tests.rs"]
892mod tests;