1use 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, Weak};
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
42pub const DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH: &str = ".a3s/workflow";
44
45pub 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
52pub 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#[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 #[serde(default, skip_serializing)]
120 pub max_concurrent_generations: Option<usize>,
121}
122
123#[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(®istry);
141 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
513pub struct DynamicWorkflowTool {
515 registry: DynamicWorkflowRegistry,
516 graph_observer: Option<FlowGraphObserver>,
517}
518
519enum DynamicWorkflowRegistry {
520 Standalone(Arc<ToolRegistry>),
521 RegistryBound(Weak<ToolRegistry>),
522}
523
524impl DynamicWorkflowRegistry {
525 fn resolve(&self) -> Option<Arc<ToolRegistry>> {
526 match self {
527 Self::Standalone(registry) => Some(Arc::clone(registry)),
528 Self::RegistryBound(registry) => registry.upgrade(),
529 }
530 }
531}
532
533impl DynamicWorkflowTool {
534 pub fn new(registry: Arc<ToolRegistry>) -> Self {
535 Self {
536 registry: DynamicWorkflowRegistry::Standalone(registry),
537 graph_observer: None,
538 }
539 }
540
541 fn new_registry_bound(registry: Arc<ToolRegistry>) -> Self {
542 Self {
543 registry: DynamicWorkflowRegistry::RegistryBound(Arc::downgrade(®istry)),
544 graph_observer: None,
545 }
546 }
547
548 pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
551 self.graph_observer = Some(observer);
552 self
553 }
554}
555
556#[async_trait]
557impl Tool for DynamicWorkflowTool {
558 fn name(&self) -> &str {
559 DYNAMIC_WORKFLOW_TOOL
560 }
561
562 fn description(&self) -> &str {
563 "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."
564 }
565
566 fn parameters(&self) -> Value {
567 json!({
568 "type": "object",
569 "additionalProperties": false,
570 "properties": {
571 "source": {
572 "type": "string",
573 "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."
574 },
575 "input": {
576 "type": "object",
577 "description": "Initial workflow input."
578 },
579 "run_id": {
580 "type": "string",
581 "description": "Optional durable run id. Reusing it with the same source and input is idempotent."
582 },
583 "allowed_tools": {
584 "type": "array",
585 "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.",
586 "items": { "type": "string" }
587 },
588 "limits": {
589 "type": "object",
590 "additionalProperties": false,
591 "properties": {
592 "timeoutMs": { "type": "integer", "minimum": 1 },
593 "maxToolCalls": { "type": "integer", "minimum": 1 },
594 "maxOutputBytes": { "type": "integer", "minimum": 1 },
595 "maxConcurrentGenerations": {
596 "type": "integer",
597 "minimum": 1,
598 "maximum": 4,
599 "description": "Optional bounded fan-out for independently session-bound generate_object steps. Providers without session forking remain single-flight."
600 }
601 }
602 }
603 },
604 "required": ["source"]
605 })
606 }
607
608 async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
609 let Some(registry) = self.registry.resolve() else {
610 return Ok(ToolOutput::error("Tool registry is closed"));
611 };
612 let Some(source) = args.get("source").and_then(Value::as_str) else {
613 return Ok(ToolOutput::error("dynamic_workflow requires source"));
614 };
615 let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
616 let allowed_tools = args
617 .get("allowed_tools")
618 .and_then(Value::as_array)
619 .map(|items| {
620 items
621 .iter()
622 .filter_map(Value::as_str)
623 .map(ToString::to_string)
624 .collect::<Vec<_>>()
625 })
626 .unwrap_or_else(|| default_allowed_tools(®istry));
627 let limits = args
628 .get("limits")
629 .cloned()
630 .and_then(|value| serde_json::from_value(value).ok())
631 .unwrap_or_default();
632
633 let runtime = Arc::new(
634 DynamicWorkflowRuntime::new(registry, ctx.clone(), source)
635 .with_allowed_tools(allowed_tools)
636 .with_limits(limits),
637 );
638 let requested_run_id = args.get("run_id").and_then(Value::as_str);
639 let store = match flow_store_for_context(ctx, requested_run_id).await {
640 Ok(store) => store,
641 Err(error) => return Ok(ToolOutput::error(error.to_string())),
642 };
643 let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
644 if let Some(tx) = ctx.agent_event_tx.clone() {
645 observers.push(Arc::new(AgentEventFlowObserver::new(
646 tx,
647 ctx.session_id.clone().unwrap_or_default(),
648 )));
649 }
650 if let Some(observer) = &self.graph_observer {
651 observers.push(Arc::new(observer.clone()));
652 }
653 let engine = if observers.is_empty() {
654 FlowEngine::new(store, runtime)
655 } else {
656 FlowEngine::builder(runtime)
657 .with_store(store)
658 .with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)))
659 .build()
660 };
661 let source_hash = source_hash(source);
662 let spec = WorkflowSpec::rust_embedded(
663 "a3s-code.dynamic-workflow",
664 source_hash.as_str(),
665 "ptc",
666 "run",
667 );
668
669 let run_id = match requested_run_id {
670 Some(run_id) => match engine.start_with_id(run_id, spec, input).await {
671 Ok(run_id) => run_id,
672 Err(err) => return Ok(ToolOutput::error(err.to_string())),
673 },
674 None => match engine.start(spec, input).await {
675 Ok(run_id) => run_id,
676 Err(err) => return Ok(ToolOutput::error(err.to_string())),
677 },
678 };
679
680 let snapshot = match drive_inline_retries(&engine, &run_id, ctx).await {
681 Ok(snapshot) => snapshot,
682 Err(err) => return Ok(ToolOutput::error(err.to_string())),
683 };
684 let history = match engine.history(&run_id).await {
685 Ok(history) => history,
686 Err(err) => return Ok(ToolOutput::error(err.to_string())),
687 };
688
689 let output = match &snapshot.output {
690 Some(output) => {
691 serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string())
692 }
693 None => snapshot
694 .error
695 .clone()
696 .unwrap_or_else(|| format!("workflow status: {:?}", snapshot.status)),
697 };
698
699 let status = snapshot.status;
700 let metadata = json!({
701 "dynamic_workflow": {
702 "run_id": run_id,
703 "status": format!("{:?}", snapshot.status),
704 "last_sequence": snapshot.last_sequence,
705 "source_hash": source_hash,
706 "snapshot": snapshot,
707 "history": history,
708 }
709 });
710 let output = match status {
711 WorkflowRunStatus::Completed => ToolOutput::success(output),
712 WorkflowRunStatus::Failed | WorkflowRunStatus::Cancelled => ToolOutput::error(output),
713 _ => ToolOutput::error(format!(
714 "dynamic_workflow ended without a terminal result: {status:?}; {output}"
715 )),
716 };
717
718 Ok(output.with_metadata(metadata))
719 }
720}
721
722async fn drive_inline_retries(
732 engine: &FlowEngine,
733 run_id: &str,
734 ctx: &ToolContext,
735) -> Result<WorkflowRunSnapshot> {
736 for _ in 0..MAX_INLINE_RETRY_RESUMES {
737 let snapshot = engine.snapshot(run_id).await?;
738 if snapshot.status.is_terminal() {
739 return Ok(snapshot);
740 }
741 let Some(retry_after) = snapshot
742 .steps
743 .values()
744 .filter(|step| step.status == StepStatus::Pending)
745 .filter_map(|step| step.retry_after)
746 .min()
747 else {
748 return Ok(snapshot);
749 };
750 let delay = retry_after
751 .signed_duration_since(Utc::now())
752 .to_std()
753 .unwrap_or_default();
754 if delay > MAX_INLINE_RETRY_DELAY {
755 return Ok(snapshot);
756 }
757 let cancellation = ctx.cancellation_token();
758 tokio::select! {
759 biased;
760 _ = cancellation.cancelled() => {
761 anyhow::bail!("dynamic_workflow cancelled while waiting for a scheduled retry");
762 }
763 _ = tokio::time::sleep(delay) => {}
764 }
765 engine.drive(run_id).await?;
766 }
767 engine.snapshot(run_id).await.map_err(Into::into)
768}
769
770pub fn register_dynamic_workflow(registry: &Arc<ToolRegistry>) {
771 registry.register(Arc::new(DynamicWorkflowTool::new_registry_bound(
772 Arc::clone(registry),
773 )));
774}
775
776async fn flow_store_for_context(
777 ctx: &ToolContext,
778 requested_run_id: Option<&str>,
779) -> Result<Arc<dyn FlowEventStore>> {
780 match ctx.workspace_services.local_root() {
781 Some(root) => {
782 let store = dynamic_workflow_store_path(root);
783 validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
784 validate_dynamic_workflow_directory(&store, ".a3s/workflow").await?;
785 if let Some(run_id) = requested_run_id.filter(|run_id| safe_workflow_run_id(run_id)) {
786 validate_dynamic_workflow_log(&store.join(format!("{run_id}.jsonl"))).await?;
787 }
788 Ok(Arc::new(LocalFileEventStore::new(store)))
789 }
790 None => Ok(Arc::new(InMemoryEventStore::new())),
791 }
792}
793
794async fn validate_dynamic_workflow_directory(path: &Path, label: &str) -> Result<()> {
795 match tokio::fs::symlink_metadata(path).await {
796 Ok(metadata) if metadata.file_type().is_symlink() => {
797 anyhow::bail!("refusing to use symlinked dynamic workflow directory {label}")
798 }
799 Ok(metadata) if !metadata.is_dir() => {
800 anyhow::bail!("dynamic workflow path {label} exists but is not a directory")
801 }
802 Ok(_) => Ok(()),
803 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
804 Err(error) => Err(error).with_context(|| format!("inspect dynamic workflow path {label}")),
805 }
806}
807
808async fn validate_dynamic_workflow_log(path: &Path) -> Result<()> {
809 match tokio::fs::symlink_metadata(path).await {
810 Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
811 "refusing to read or append symlinked dynamic workflow history {}",
812 path.display()
813 ),
814 Ok(metadata) if !metadata.is_file() => anyhow::bail!(
815 "dynamic workflow history path {} exists but is not a file",
816 path.display()
817 ),
818 Ok(_) => Ok(()),
819 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
820 Err(error) => Err(error)
821 .with_context(|| format!("inspect dynamic workflow history {}", path.display())),
822 }
823}
824
825fn safe_workflow_run_id(run_id: &str) -> bool {
826 !run_id.is_empty()
827 && run_id
828 .chars()
829 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
830}
831
832struct PayloadBuilder {
833 value: Map<String, Value>,
834}
835
836impl PayloadBuilder {
837 fn with(mut self, key: &str, value: impl Serialize) -> Self {
838 self.value.insert(
839 key.to_string(),
840 serde_json::to_value(value).unwrap_or(Value::Null),
841 );
842 self
843 }
844
845 fn into_value(self) -> Value {
846 Value::Object(self.value)
847 }
848}
849
850fn invocation_payload(kind: &str, run_id: &str, history: &[FlowEventEnvelope]) -> PayloadBuilder {
851 let mut value = Map::new();
852 value.insert("kind".to_string(), json!(kind));
853 value.insert("run_id".to_string(), json!(run_id));
854 value.insert("history".to_string(), json!(history));
855 value.insert("step_outputs".to_string(), completed_step_outputs(history));
856 value.insert("step_failures".to_string(), failed_step_outputs(history));
857 PayloadBuilder { value }
858}
859
860fn completed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
861 let mut outputs = Map::new();
862 for envelope in history {
863 if let FlowEvent::StepCompleted { step_id, output } = &envelope.event {
864 outputs.insert(step_id.clone(), output.clone());
865 }
866 }
867 Value::Object(outputs)
868}
869
870fn failed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
871 let mut outputs = Map::new();
872 for envelope in history {
873 if let FlowEvent::StepFailed {
874 step_id,
875 attempt,
876 error,
877 } = &envelope.event
878 {
879 outputs.insert(
880 step_id.clone(),
881 json!({
882 "attempt": attempt,
883 "error": error,
884 }),
885 );
886 }
887 }
888 Value::Object(outputs)
889}
890
891fn script_result(result: &ToolResult) -> a3s_flow::Result<Value> {
892 result
893 .metadata
894 .as_ref()
895 .and_then(|metadata| metadata.get("script_result"))
896 .cloned()
897 .ok_or_else(|| {
898 a3s_flow::FlowError::Runtime(
899 "PTC program result did not include script_result metadata".to_string(),
900 )
901 })
902}
903
904fn default_allowed_tools(registry: &ToolRegistry) -> Vec<String> {
905 sanitize_allowed_tools(registry.list())
906}
907
908fn sanitize_allowed_tools(items: impl IntoIterator<Item = String>) -> Vec<String> {
909 let mut tools = items.into_iter().collect::<BTreeSet<_>>();
910 tools.remove(PROGRAM_TOOL);
911 tools.remove(DYNAMIC_WORKFLOW_TOOL);
912 tools.remove(PARALLEL_TASK_TOOL);
913 tools.into_iter().collect()
914}
915
916fn source_hash(source: &str) -> String {
917 sha256::digest(source.as_bytes())
918}
919
920#[cfg(test)]
921#[path = "dynamic_workflow/tests.rs"]
922mod tests;