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