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;
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: 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 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
698async 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;