Skip to main content

nika_core/ast/analyzed/
task.rs

1//! Analyzed task AST.
2//!
3//! Tasks with resolved references - TaskId instead of String.
4//!
5
6use indexmap::IndexMap;
7
8use super::ids::TaskId;
9use crate::ast::artifact::ArtifactSpec;
10use crate::ast::decompose::DecomposeSpec;
11use crate::ast::logging::LogConfig;
12use crate::ast::structured::StructuredOutputSpec;
13use crate::binding::WithSpec;
14use crate::source::Span;
15
16/// An analyzed task - validated and resolved.
17///
18/// All string references are replaced with interned IDs.
19///
20#[derive(Debug, Clone)]
21pub struct AnalyzedTask {
22    /// Task ID (interned)
23    pub id: TaskId,
24
25    /// Task name (for display/debugging)
26    pub name: String,
27
28    /// Optional description
29    pub description: Option<String>,
30
31    /// The action this task performs
32    pub action: AnalyzedTaskAction,
33
34    /// Task-specific provider override
35    pub provider: Option<String>,
36
37    /// Task-specific model override
38    pub model: Option<String>,
39
40    /// Parsed `with:` bindings (alias → WithEntry with source, transforms, defaults)
41    ///
42    /// Phase 2 parses raw `Spanned<String>` values via `parse_with_entry()`.
43    /// Each entry has a `BindingPath` source, optional transforms, defaults, and type.
44    pub with_spec: WithSpec,
45
46    /// Explicit ordering dependencies: `depends_on: [task_id1, task_id2]`
47    ///
48    /// These are pure ordering edges — no data flows through them.
49    /// Resolved from raw string task names to interned `TaskId`.
50    pub depends_on: Vec<TaskId>,
51
52    /// Implicit dependencies auto-extracted from `with:` bindings.
53    ///
54    /// When a `WithEntry` source references a task (e.g., `step1.data`),
55    /// the analyzer extracts `step1` as an implicit dependency.
56    /// These are used by the DAG builder alongside `depends_on`.
57    pub implicit_deps: Vec<TaskId>,
58
59    /// Output configuration
60    pub output: Option<AnalyzedOutput>,
61
62    /// For-each iteration configuration
63    pub for_each: Option<AnalyzedForEach>,
64
65    /// Retry configuration
66    pub retry: Option<AnalyzedRetry>,
67
68    /// Decompose modifier for runtime DAG expansion
69    pub decompose: Option<DecomposeSpec>,
70
71    /// Standalone concurrency (used with decompose when no for_each)
72    pub concurrency: Option<u32>,
73
74    /// Standalone fail_fast (used with decompose when no for_each)
75    pub fail_fast: Option<bool>,
76
77    /// Artifact configuration for file persistence
78    pub artifact: Option<ArtifactSpec>,
79
80    /// Per-task log configuration
81    pub log: Option<LogConfig>,
82
83    /// Structured output specification (JSON schema enforcement)
84    pub structured: Option<StructuredOutputSpec>,
85
86    /// Span of the task
87    pub span: Span,
88}
89
90/// The action a task performs (analyzed).
91#[derive(Debug, Clone)]
92pub enum AnalyzedTaskAction {
93    /// LLM inference
94    Infer(AnalyzedInferAction),
95
96    /// Shell command execution
97    Exec(AnalyzedExecAction),
98
99    /// HTTP fetch
100    Fetch(AnalyzedFetchAction),
101
102    /// MCP tool invocation
103    Invoke(AnalyzedInvokeAction),
104
105    /// Autonomous agent
106    Agent(Box<AnalyzedAgentAction>),
107}
108
109impl Default for AnalyzedTaskAction {
110    fn default() -> Self {
111        AnalyzedTaskAction::Infer(AnalyzedInferAction::default())
112    }
113}
114
115impl AnalyzedTaskAction {
116    /// Get the verb name.
117    pub fn verb_name(&self) -> &'static str {
118        match self {
119            AnalyzedTaskAction::Infer(_) => "infer",
120            AnalyzedTaskAction::Exec(_) => "exec",
121            AnalyzedTaskAction::Fetch(_) => "fetch",
122            AnalyzedTaskAction::Invoke(_) => "invoke",
123            AnalyzedTaskAction::Agent(_) => "agent",
124        }
125    }
126}
127
128/// Analyzed infer action.
129#[derive(Debug, Clone, Default)]
130pub struct AnalyzedInferAction {
131    /// The prompt to send to the LLM (may be empty when content is present)
132    pub prompt: String,
133
134    /// System prompt override
135    pub system: Option<String>,
136
137    /// Temperature (validated: 0.0 - 2.0)
138    pub temperature: Option<f64>,
139
140    /// Maximum tokens to generate
141    pub max_tokens: Option<u32>,
142
143    /// Enable extended thinking
144    pub extended_thinking: Option<bool>,
145
146    /// Thinking budget tokens
147    pub thinking_budget: Option<u32>,
148
149    /// Multimodal content parts for vision (analyzed, spans stripped)
150    pub content: Option<Vec<crate::ast::content::AnalyzedContentPart>>,
151
152    /// Expected response format: text, json, markdown
153    pub response_format: Option<String>,
154
155    /// Guardrails for validating infer output
156    pub guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
157
158    /// Span of the action
159    pub span: Span,
160}
161
162/// Analyzed exec action.
163#[derive(Debug, Clone, Default)]
164pub struct AnalyzedExecAction {
165    /// Command to execute
166    pub command: String,
167
168    /// Run through shell
169    pub shell: bool,
170
171    /// Working directory
172    pub cwd: Option<String>,
173
174    /// Environment variables
175    pub env: IndexMap<String, String>,
176
177    /// Timeout in milliseconds
178    pub timeout_ms: Option<u64>,
179
180    /// Span of the action
181    pub span: Span,
182}
183
184/// Analyzed fetch action.
185#[derive(Debug, Clone, Default)]
186pub struct AnalyzedFetchAction {
187    /// URL to fetch
188    pub url: String,
189
190    /// HTTP method
191    pub method: HttpMethod,
192
193    /// HTTP headers
194    pub headers: IndexMap<String, String>,
195
196    /// Request body
197    pub body: Option<String>,
198
199    /// Request body as JSON
200    pub json: Option<serde_json::Value>,
201
202    /// Timeout in milliseconds
203    pub timeout_ms: Option<u64>,
204
205    /// Follow redirects
206    pub follow_redirects: bool,
207
208    /// Response mode: "full" or "binary"
209    pub response: Option<String>,
210
211    /// Extraction mode: markdown, article, text, selector, metadata, links, feed, jsonpath, llm_txt
212    pub extract: Option<String>,
213
214    /// CSS selector or JSONPath expression (used with extract)
215    pub selector: Option<String>,
216
217    /// Span of the action
218    pub span: Span,
219}
220
221/// HTTP methods.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223pub enum HttpMethod {
224    #[default]
225    Get,
226    Post,
227    Put,
228    Patch,
229    Delete,
230    Head,
231    Options,
232}
233
234impl HttpMethod {
235    /// Parse an HTTP method string.
236    pub fn parse(s: &str) -> Option<Self> {
237        match s.to_uppercase().as_str() {
238            "GET" => Some(Self::Get),
239            "POST" => Some(Self::Post),
240            "PUT" => Some(Self::Put),
241            "PATCH" => Some(Self::Patch),
242            "DELETE" => Some(Self::Delete),
243            "HEAD" => Some(Self::Head),
244            "OPTIONS" => Some(Self::Options),
245            _ => None,
246        }
247    }
248
249    /// Get the method as a string.
250    pub fn as_str(&self) -> &'static str {
251        match self {
252            Self::Get => "GET",
253            Self::Post => "POST",
254            Self::Put => "PUT",
255            Self::Patch => "PATCH",
256            Self::Delete => "DELETE",
257            Self::Head => "HEAD",
258            Self::Options => "OPTIONS",
259        }
260    }
261}
262
263/// Analyzed invoke action.
264#[derive(Debug, Clone, Default)]
265pub struct AnalyzedInvokeAction {
266    /// MCP server name (None = first available)
267    pub server: Option<String>,
268
269    /// Tool name (empty string if resource-only invoke)
270    pub tool: String,
271
272    /// MCP resource URI (alternative to tool call)
273    pub resource: Option<String>,
274
275    /// Tool parameters
276    pub params: Option<serde_json::Value>,
277
278    /// Timeout for tool execution
279    pub timeout_ms: Option<u64>,
280
281    /// Span of the action
282    pub span: Span,
283}
284
285/// Analyzed agent action.
286#[derive(Debug, Clone, Default)]
287pub struct AnalyzedAgentAction {
288    /// The prompt for the agent
289    pub prompt: String,
290
291    /// Available tools
292    pub tools: Vec<String>,
293
294    /// Maximum turns
295    pub max_turns: Option<u32>,
296
297    /// Maximum tokens per response
298    pub max_tokens: Option<u32>,
299
300    /// Agent definition reference (resolved)
301    pub from: Option<String>,
302
303    /// Skills to inject
304    pub skills: Vec<String>,
305
306    /// MCP servers for tool access
307    pub mcp: Vec<String>,
308
309    /// System prompt (agent persona)
310    pub system: Option<String>,
311
312    /// Temperature for LLM sampling
313    pub temperature: Option<f64>,
314
315    /// Token budget for the agent
316    pub token_budget: Option<u32>,
317
318    /// Enable extended thinking (Claude)
319    pub extended_thinking: Option<bool>,
320
321    /// Thinking budget tokens
322    pub thinking_budget: Option<u32>,
323
324    /// Max spawn_agent recursion depth
325    pub depth_limit: Option<u32>,
326
327    /// Tool choice behavior: auto, required, none
328    pub tool_choice: Option<String>,
329
330    /// Sequences that stop generation (passed to LLM)
331    pub stop_sequences: Vec<String>,
332
333    /// Scope preset (full, minimal, debug)
334    pub scope: Option<String>,
335    /// Guardrails for validating agent outputs.
336    pub guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
337    /// Completion behavior configuration.
338    pub completion: Option<crate::ast::completion::CompletionConfig>,
339    /// Execution limits for cost control.
340    pub limits: Option<crate::ast::limits::LimitsConfig>,
341
342    /// Span of the action
343    pub span: Span,
344}
345
346/// Analyzed output configuration.
347#[derive(Debug, Clone)]
348pub struct AnalyzedOutput {
349    /// Output format
350    pub format: OutputFormat,
351
352    /// JSON Schema for validation (validated)
353    pub schema: Option<serde_json::Value>,
354
355    /// Schema reference: file path or named ref (from `schema_ref:` / `$ref:`)
356    pub schema_ref: Option<String>,
357
358    /// Maximum retries on validation failure
359    pub max_retries: Option<u32>,
360
361    /// Span of the output config
362    pub span: Span,
363}
364
365/// Output format.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
367pub enum OutputFormat {
368    #[default]
369    Text,
370    Json,
371    Yaml,
372}
373
374impl OutputFormat {
375    /// Parse an output format string.
376    pub fn parse(s: &str) -> Option<Self> {
377        match s.to_lowercase().as_str() {
378            "text" => Some(Self::Text),
379            "json" => Some(Self::Json),
380            "yaml" => Some(Self::Yaml),
381            _ => None,
382        }
383    }
384
385    /// Get the format as a string.
386    pub fn as_str(&self) -> &'static str {
387        match self {
388            Self::Text => "text",
389            Self::Json => "json",
390            Self::Yaml => "yaml",
391        }
392    }
393}
394
395/// Analyzed for-each iteration configuration.
396#[derive(Debug, Clone)]
397pub struct AnalyzedForEach {
398    /// Items expression (binding expression or serialized array)
399    pub items: String,
400
401    /// Loop variable name (default: "item")
402    pub as_var: String,
403
404    /// Maximum concurrency (None = unlimited)
405    pub concurrency: Option<u32>,
406
407    /// Fail fast on first error (default: true)
408    pub fail_fast: bool,
409
410    /// Span of the for_each config
411    pub span: Span,
412}
413
414impl Default for AnalyzedForEach {
415    fn default() -> Self {
416        Self {
417            items: String::new(),
418            as_var: "item".to_string(),
419            concurrency: Some(1), // Default to sequential
420            fail_fast: true,
421            span: Span::dummy(),
422        }
423    }
424}
425
426impl AnalyzedForEach {
427    /// Check if this is a binding expression.
428    pub fn is_binding(&self) -> bool {
429        self.items.starts_with("{{") || self.items.starts_with("$")
430    }
431
432    /// Check if items is a literal array.
433    pub fn is_array(&self) -> bool {
434        self.items.starts_with('[')
435    }
436
437    /// Parse items as a JSON array if it's a literal.
438    pub fn parse_items(&self) -> Option<Vec<serde_json::Value>> {
439        if self.is_array() {
440            serde_json::from_str(&self.items).ok()
441        } else {
442            None
443        }
444    }
445}
446
447/// Analyzed retry configuration.
448#[derive(Debug, Clone)]
449pub struct AnalyzedRetry {
450    /// Maximum retry attempts (validated: 1-10)
451    pub max_attempts: u32,
452
453    /// Delay between retries in milliseconds (validated: 0-60000)
454    pub delay_ms: u64,
455
456    /// Exponential backoff multiplier (validated: 1.0-5.0)
457    pub backoff: Option<f64>,
458
459    /// Span of the retry config
460    pub span: Span,
461}
462
463impl Default for AnalyzedRetry {
464    fn default() -> Self {
465        Self {
466            max_attempts: 3,
467            delay_ms: 1000,
468            backoff: None,
469            span: Span::dummy(),
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_http_method_parse() {
480        assert_eq!(HttpMethod::parse("GET"), Some(HttpMethod::Get));
481        assert_eq!(HttpMethod::parse("get"), Some(HttpMethod::Get));
482        assert_eq!(HttpMethod::parse("POST"), Some(HttpMethod::Post));
483        assert_eq!(HttpMethod::parse("UNKNOWN"), None);
484    }
485
486    #[test]
487    fn test_output_format_parse() {
488        assert_eq!(OutputFormat::parse("text"), Some(OutputFormat::Text));
489        assert_eq!(OutputFormat::parse("JSON"), Some(OutputFormat::Json));
490        assert_eq!(OutputFormat::parse("yaml"), Some(OutputFormat::Yaml));
491        assert_eq!(OutputFormat::parse("unknown"), None);
492    }
493
494    #[test]
495    fn test_analyzed_task_action_verb() {
496        let infer = AnalyzedTaskAction::Infer(AnalyzedInferAction::default());
497        assert_eq!(infer.verb_name(), "infer");
498
499        let exec = AnalyzedTaskAction::Exec(AnalyzedExecAction::default());
500        assert_eq!(exec.verb_name(), "exec");
501    }
502
503    #[test]
504    fn test_analyzed_task_with_spec() {
505        use crate::binding::types::{BindingPath, BindingSource, PathSegment};
506        use crate::binding::{WithEntry, WithSpec};
507
508        let mut with_spec = WithSpec::default();
509        with_spec.insert(
510            "data".to_string(),
511            WithEntry::simple(BindingPath {
512                source: BindingSource::Task("step1".into()),
513                segments: vec![PathSegment::Field("result".into())],
514            }),
515        );
516
517        assert_eq!(with_spec.len(), 1);
518        let entry = with_spec.get("data").unwrap();
519        assert_eq!(entry.task_id(), Some("step1"));
520    }
521
522    #[test]
523    fn test_analyzed_for_each_default() {
524        let for_each = AnalyzedForEach::default();
525        assert_eq!(for_each.as_var, "item");
526        assert_eq!(for_each.concurrency, Some(1)); // Sequential by default
527        assert!(for_each.fail_fast);
528    }
529
530    #[test]
531    fn test_analyzed_for_each_is_binding() {
532        let for_each = AnalyzedForEach {
533            items: "{{with.items}}".to_string(),
534            ..Default::default()
535        };
536        assert!(for_each.is_binding());
537
538        let for_each = AnalyzedForEach {
539            items: "$items".to_string(),
540            ..Default::default()
541        };
542        assert!(for_each.is_binding());
543
544        let for_each = AnalyzedForEach {
545            items: r#"["a", "b", "c"]"#.to_string(),
546            ..Default::default()
547        };
548        assert!(!for_each.is_binding());
549    }
550
551    #[test]
552    fn test_analyzed_for_each_is_array() {
553        let for_each = AnalyzedForEach {
554            items: r#"["a", "b", "c"]"#.to_string(),
555            ..Default::default()
556        };
557        assert!(for_each.is_array());
558
559        let for_each = AnalyzedForEach {
560            items: "{{with.items}}".to_string(),
561            ..Default::default()
562        };
563        assert!(!for_each.is_array());
564    }
565
566    #[test]
567    fn test_analyzed_for_each_parse_items() {
568        let for_each = AnalyzedForEach {
569            items: r#"["a", "b", "c"]"#.to_string(),
570            ..Default::default()
571        };
572        let items = for_each.parse_items().unwrap();
573        assert_eq!(items.len(), 3);
574        assert_eq!(items[0], serde_json::Value::String("a".to_string()));
575
576        let for_each = AnalyzedForEach {
577            items: "{{with.items}}".to_string(),
578            ..Default::default()
579        };
580        assert!(for_each.parse_items().is_none());
581    }
582
583    #[test]
584    fn test_analyzed_retry_default() {
585        let retry = AnalyzedRetry::default();
586        assert_eq!(retry.max_attempts, 3);
587        assert_eq!(retry.delay_ms, 1000);
588        assert!(retry.backoff.is_none());
589    }
590}