Skip to main content

a3s_code_core/
program.rs

1//! Programmatic tool calling primitives.
2//!
3//! A program is a named, deterministic chain of tool invocations executed by
4//! the harness instead of expanded turn-by-turn through the model loop.
5
6use crate::tools::{ToolContext, ToolRegistry};
7use anyhow::{bail, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10use std::sync::Arc;
11
12pub const PROGRAM_TRACE_SCHEMA: &str = "a3s.program_trace.v1";
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct Program {
16    pub name: String,
17    pub description: String,
18    pub steps: Vec<ProgramStep>,
19}
20
21impl Program {
22    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
23        Self {
24            name: name.into(),
25            description: description.into(),
26            steps: Vec::new(),
27        }
28    }
29
30    pub fn with_step(mut self, step: ProgramStep) -> Self {
31        self.steps.push(step);
32        self
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct ProgramTemplate {
38    pub name: String,
39    pub description: String,
40    pub parameters: Vec<ProgramParameter>,
41    pub steps: Vec<ProgramStepTemplate>,
42}
43
44impl ProgramTemplate {
45    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
46        Self {
47            name: name.into(),
48            description: description.into(),
49            parameters: Vec::new(),
50            steps: Vec::new(),
51        }
52    }
53
54    pub fn with_parameter(mut self, parameter: ProgramParameter) -> Self {
55        self.parameters.push(parameter);
56        self
57    }
58
59    pub fn with_step(mut self, step: ProgramStepTemplate) -> Self {
60        self.steps.push(step);
61        self
62    }
63
64    pub fn validate(&self) -> ProgramTemplateValidation {
65        ProgramTemplateValidation::validate(self)
66    }
67
68    pub fn ensure_valid(&self) -> Result<()> {
69        let validation = self.validate();
70        if validation.is_valid() {
71            Ok(())
72        } else {
73            bail!("{}", validation.summary());
74        }
75    }
76
77    pub fn instantiate(&self, inputs: &serde_json::Value) -> Result<Program> {
78        self.ensure_valid()?;
79        let input_object = inputs.as_object();
80        let mut bindings = serde_json::Map::new();
81
82        for parameter in &self.parameters {
83            let value = input_object.and_then(|object| object.get(&parameter.name));
84            match (value, &parameter.default, parameter.required) {
85                (Some(value), _, _) => {
86                    bindings.insert(parameter.name.clone(), value.clone());
87                }
88                (None, Some(default), _) => {
89                    bindings.insert(parameter.name.clone(), default.clone());
90                }
91                (None, None, true) => {
92                    bail!("Missing required program parameter: {}", parameter.name);
93                }
94                (None, None, false) => {}
95            }
96        }
97
98        let bindings = serde_json::Value::Object(bindings);
99        let mut program = Program::new(self.name.clone(), self.description.clone());
100        for step in &self.steps {
101            program = program.with_step(ProgramStep {
102                tool_name: step.tool_name.clone(),
103                args: render_template_value(&step.args, &bindings),
104                label: step.label.clone(),
105            });
106        }
107        Ok(program)
108    }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ProgramTemplateValidation {
113    pub template_name: String,
114    pub issues: Vec<ProgramTemplateIssue>,
115}
116
117impl ProgramTemplateValidation {
118    pub fn validate(template: &ProgramTemplate) -> Self {
119        let mut issues = Vec::new();
120        validate_program_template(template, &mut issues);
121        Self {
122            template_name: template.name.clone(),
123            issues,
124        }
125    }
126
127    pub fn is_valid(&self) -> bool {
128        self.issues.is_empty()
129    }
130
131    pub fn summary(&self) -> String {
132        if self.is_valid() {
133            return format!("Program template '{}' is valid", self.template_name);
134        }
135
136        let issues = self
137            .issues
138            .iter()
139            .map(|issue| format!("{}: {}", issue.path, issue.message))
140            .collect::<Vec<_>>()
141            .join("; ");
142        format!(
143            "Program template '{}' is invalid: {}",
144            self.template_name, issues
145        )
146    }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ProgramTemplateIssue {
151    pub code: String,
152    pub path: String,
153    pub message: String,
154}
155
156impl ProgramTemplateIssue {
157    fn new(code: impl Into<String>, path: impl Into<String>, message: impl Into<String>) -> Self {
158        Self {
159            code: code.into(),
160            path: path.into(),
161            message: message.into(),
162        }
163    }
164}
165
166fn validate_program_template(template: &ProgramTemplate, issues: &mut Vec<ProgramTemplateIssue>) {
167    if template.name.trim().is_empty() {
168        issues.push(ProgramTemplateIssue::new(
169            "empty_name",
170            "name",
171            "program template name is required",
172        ));
173    } else if !is_program_identifier(&template.name) {
174        issues.push(ProgramTemplateIssue::new(
175            "invalid_name",
176            "name",
177            "program template name must contain only ASCII letters, numbers, '_' or '-'",
178        ));
179    }
180
181    if template.description.trim().is_empty() {
182        issues.push(ProgramTemplateIssue::new(
183            "empty_description",
184            "description",
185            "program template description is required",
186        ));
187    }
188
189    let mut parameter_names = HashSet::new();
190    for (index, parameter) in template.parameters.iter().enumerate() {
191        let path = format!("parameters[{index}].name");
192        if parameter.name.trim().is_empty() {
193            issues.push(ProgramTemplateIssue::new(
194                "empty_parameter_name",
195                path,
196                "program parameter name is required",
197            ));
198        } else if !is_program_identifier(&parameter.name) {
199            issues.push(ProgramTemplateIssue::new(
200                "invalid_parameter_name",
201                path,
202                "program parameter name must contain only ASCII letters, numbers, '_' or '-'",
203            ));
204        } else if !parameter_names.insert(parameter.name.clone()) {
205            issues.push(ProgramTemplateIssue::new(
206                "duplicate_parameter",
207                path,
208                format!("duplicate program parameter '{}'", parameter.name),
209            ));
210        }
211
212        if parameter.required && parameter.default.is_some() {
213            issues.push(ProgramTemplateIssue::new(
214                "required_parameter_with_default",
215                format!("parameters[{index}].default"),
216                "required program parameters must not define defaults",
217            ));
218        }
219    }
220
221    if template.steps.is_empty() {
222        issues.push(ProgramTemplateIssue::new(
223            "empty_steps",
224            "steps",
225            "program template must contain at least one step",
226        ));
227    }
228
229    let mut labels = HashSet::new();
230    for (index, step) in template.steps.iter().enumerate() {
231        if step.tool_name.trim().is_empty() {
232            issues.push(ProgramTemplateIssue::new(
233                "empty_tool_name",
234                format!("steps[{index}].tool_name"),
235                "program step tool_name is required",
236            ));
237        }
238
239        if let Some(label) = &step.label {
240            if label.trim().is_empty() {
241                issues.push(ProgramTemplateIssue::new(
242                    "empty_step_label",
243                    format!("steps[{index}].label"),
244                    "program step label must not be empty",
245                ));
246            } else if !labels.insert(label.clone()) {
247                issues.push(ProgramTemplateIssue::new(
248                    "duplicate_step_label",
249                    format!("steps[{index}].label"),
250                    format!("duplicate program step label '{label}'"),
251                ));
252            }
253        }
254
255        validate_template_value(
256            &step.args,
257            &format!("steps[{index}].args"),
258            &parameter_names,
259            issues,
260        );
261    }
262}
263
264fn validate_template_value(
265    value: &serde_json::Value,
266    path: &str,
267    parameter_names: &HashSet<String>,
268    issues: &mut Vec<ProgramTemplateIssue>,
269) {
270    match value {
271        serde_json::Value::String(text) => {
272            for placeholder in template_placeholders(text) {
273                match placeholder {
274                    Ok(name) if !is_program_identifier(&name) => {
275                        issues.push(ProgramTemplateIssue::new(
276                            "invalid_placeholder",
277                            path,
278                            format!("invalid placeholder '{{{{{name}}}}}'"),
279                        ));
280                    }
281                    Ok(name) if !parameter_names.contains(&name) => {
282                        issues.push(ProgramTemplateIssue::new(
283                            "unknown_placeholder",
284                            path,
285                            format!("unknown program parameter placeholder '{{{{{name}}}}}'"),
286                        ));
287                    }
288                    Ok(_) => {}
289                    Err(message) => {
290                        issues.push(ProgramTemplateIssue::new(
291                            "malformed_placeholder",
292                            path,
293                            message,
294                        ));
295                    }
296                }
297            }
298        }
299        serde_json::Value::Array(items) => {
300            for (index, item) in items.iter().enumerate() {
301                validate_template_value(item, &format!("{path}[{index}]"), parameter_names, issues);
302            }
303        }
304        serde_json::Value::Object(object) => {
305            for (key, value) in object {
306                validate_template_value(value, &format!("{path}.{key}"), parameter_names, issues);
307            }
308        }
309        _ => {}
310    }
311}
312
313fn is_program_identifier(value: &str) -> bool {
314    !value.is_empty()
315        && value
316            .chars()
317            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
318}
319
320fn template_placeholders(text: &str) -> Vec<std::result::Result<String, String>> {
321    let mut placeholders = Vec::new();
322    let mut rest = text;
323
324    while let Some(start) = rest.find("{{") {
325        let after_start = &rest[start + 2..];
326        let Some(end) = after_start.find("}}") else {
327            placeholders.push(Err(
328                "malformed placeholder: missing closing '}}'".to_string()
329            ));
330            return placeholders;
331        };
332        let name = after_start[..end].trim();
333        if name.is_empty() {
334            placeholders.push(Err("malformed placeholder: empty name".to_string()));
335        } else {
336            placeholders.push(Ok(name.to_string()));
337        }
338        rest = &after_start[end + 2..];
339    }
340
341    if rest.contains("}}") {
342        placeholders.push(Err(
343            "malformed placeholder: missing opening '{{'".to_string()
344        ));
345    }
346
347    placeholders
348}
349
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
351pub struct ProgramParameter {
352    pub name: String,
353    pub description: String,
354    pub required: bool,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub default: Option<serde_json::Value>,
357}
358
359impl ProgramParameter {
360    pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
361        Self {
362            name: name.into(),
363            description: description.into(),
364            required: true,
365            default: None,
366        }
367    }
368
369    pub fn optional(
370        name: impl Into<String>,
371        description: impl Into<String>,
372        default: serde_json::Value,
373    ) -> Self {
374        Self {
375            name: name.into(),
376            description: description.into(),
377            required: false,
378            default: Some(default),
379        }
380    }
381}
382
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
384pub struct ProgramStep {
385    pub tool_name: String,
386    #[serde(default)]
387    pub args: serde_json::Value,
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub label: Option<String>,
390}
391
392impl ProgramStep {
393    pub fn new(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
394        Self {
395            tool_name: tool_name.into(),
396            args,
397            label: None,
398        }
399    }
400
401    pub fn with_label(mut self, label: impl Into<String>) -> Self {
402        self.label = Some(label.into());
403        self
404    }
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct ProgramStepTemplate {
409    pub tool_name: String,
410    #[serde(default)]
411    pub args: serde_json::Value,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub label: Option<String>,
414}
415
416impl ProgramStepTemplate {
417    pub fn new(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
418        Self {
419            tool_name: tool_name.into(),
420            args,
421            label: None,
422        }
423    }
424
425    pub fn with_label(mut self, label: impl Into<String>) -> Self {
426        self.label = Some(label.into());
427        self
428    }
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct ProgramResult {
433    pub program_name: String,
434    pub success: bool,
435    pub summary: String,
436    pub steps: Vec<ProgramStepResult>,
437}
438
439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
440pub struct ProgramStepResult {
441    pub tool_name: String,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub label: Option<String>,
444    pub success: bool,
445    pub output: String,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub metadata: Option<serde_json::Value>,
448}
449
450#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
451pub struct ProgramTrace {
452    pub schema: String,
453    #[serde(rename = "type")]
454    pub trace_type: String,
455    pub program_name: String,
456    pub success: bool,
457    pub summary: String,
458    pub step_count: usize,
459    pub failed_steps: usize,
460    pub steps: Vec<ProgramTraceStep>,
461}
462
463impl ProgramTrace {
464    pub fn from_result(result: &ProgramResult, steps: Vec<ProgramTraceStep>) -> Self {
465        Self {
466            schema: PROGRAM_TRACE_SCHEMA.to_string(),
467            trace_type: "program_execution".to_string(),
468            program_name: result.program_name.clone(),
469            success: result.success,
470            summary: result.summary.clone(),
471            step_count: steps.len(),
472            failed_steps: steps.iter().filter(|step| !step.success).count(),
473            steps,
474        }
475    }
476
477    pub fn to_value(&self) -> serde_json::Value {
478        serde_json::to_value(self).unwrap_or_else(|_| {
479            serde_json::json!({
480                "schema": PROGRAM_TRACE_SCHEMA,
481                "type": "program_execution",
482                "program_name": self.program_name,
483                "success": self.success,
484                "summary": self.summary,
485                "step_count": self.step_count,
486                "failed_steps": self.failed_steps,
487                "steps": [],
488            })
489        })
490    }
491}
492
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494pub struct ProgramTraceStep {
495    pub index: usize,
496    pub label: String,
497    pub tool_name: String,
498    pub success: bool,
499    pub output_bytes: usize,
500    pub compacted: bool,
501    #[serde(default)]
502    pub artifact: Option<ProgramTraceArtifact>,
503    #[serde(default)]
504    pub metadata: Option<serde_json::Value>,
505}
506
507impl ProgramTraceStep {
508    pub fn from_result(
509        index: usize,
510        step: &ProgramStepResult,
511        compacted: bool,
512        artifact: Option<ProgramTraceArtifact>,
513    ) -> Self {
514        Self {
515            index,
516            label: step.label.clone().unwrap_or_else(|| step.tool_name.clone()),
517            tool_name: step.tool_name.clone(),
518            success: step.success,
519            output_bytes: step.output.len(),
520            compacted,
521            artifact,
522            metadata: step.metadata.clone(),
523        }
524    }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct ProgramTraceArtifact {
529    pub artifact_id: String,
530    pub artifact_uri: String,
531    pub original_bytes: usize,
532    pub shown_bytes: usize,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
536pub struct ProgramVerificationHint {
537    pub kind: String,
538    pub message: String,
539    #[serde(default)]
540    pub required: bool,
541    #[serde(default, skip_serializing_if = "Vec::is_empty")]
542    pub suggested_tools: Vec<String>,
543    #[serde(default, skip_serializing_if = "Vec::is_empty")]
544    pub evidence_uris: Vec<String>,
545}
546
547impl ProgramVerificationHint {
548    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
549        Self {
550            kind: kind.into(),
551            message: message.into(),
552            required: false,
553            suggested_tools: Vec::new(),
554            evidence_uris: Vec::new(),
555        }
556    }
557
558    pub fn required(mut self) -> Self {
559        self.required = true;
560        self
561    }
562
563    pub fn with_suggested_tools(
564        mut self,
565        tools: impl IntoIterator<Item = impl Into<String>>,
566    ) -> Self {
567        self.suggested_tools = tools.into_iter().map(Into::into).collect();
568        self
569    }
570
571    pub fn with_evidence_uris(mut self, uris: impl IntoIterator<Item = impl Into<String>>) -> Self {
572        self.evidence_uris = uris.into_iter().map(Into::into).collect();
573        self
574    }
575
576    pub fn to_values(hints: &[Self]) -> Vec<serde_json::Value> {
577        hints
578            .iter()
579            .map(|hint| serde_json::to_value(hint).unwrap_or_else(|_| serde_json::json!({})))
580            .collect()
581    }
582}
583
584pub fn program_verification_hints(
585    result: &ProgramResult,
586    trace: Option<&ProgramTrace>,
587) -> Vec<ProgramVerificationHint> {
588    let mut hints = match result.program_name.as_str() {
589        "program_code_search" => vec![ProgramVerificationHint::new(
590            "inspect_matches",
591            "Review matched files before editing or drawing conclusions.",
592        )
593        .required()
594        .with_suggested_tools(["read", "grep"])],
595        "program_repo_map" => vec![ProgramVerificationHint::new(
596            "inspect_project_files",
597            "Use detected project files to choose build, test, and lint commands.",
598        )
599        .required()
600        .with_suggested_tools(["read", "glob"])],
601        _ => Vec::new(),
602    };
603
604    if !result.success {
605        let failed_steps = result
606            .steps
607            .iter()
608            .filter(|step| !step.success)
609            .map(|step| step.label.as_deref().unwrap_or(&step.tool_name))
610            .collect::<Vec<_>>();
611        let message = if failed_steps.is_empty() {
612            "Investigate the failed program execution before relying on its result.".to_string()
613        } else {
614            format!(
615                "Investigate failed program step(s): {}.",
616                failed_steps.join(", ")
617            )
618        };
619        hints.push(
620            ProgramVerificationHint::new("investigate_failed_steps", message)
621                .required()
622                .with_suggested_tools(["read", "grep"]),
623        );
624    }
625
626    if let Some(trace) = trace {
627        let evidence_uris = trace
628            .steps
629            .iter()
630            .filter_map(|step| step.artifact.as_ref())
631            .map(|artifact| artifact.artifact_uri.clone())
632            .collect::<Vec<_>>();
633
634        if !evidence_uris.is_empty() {
635            hints.push(
636                ProgramVerificationHint::new(
637                    "inspect_artifacts",
638                    "Inspect compacted program artifacts before treating summarized output as complete evidence.",
639                )
640                .required()
641                .with_evidence_uris(evidence_uris),
642            );
643        }
644    }
645
646    hints
647}
648
649pub struct ProgramExecutor {
650    registry: Arc<ToolRegistry>,
651    context: ToolContext,
652}
653
654#[derive(Debug, Clone, Default)]
655pub struct ProgramCatalog {
656    templates: Vec<ProgramTemplate>,
657}
658
659impl ProgramCatalog {
660    pub fn new() -> Self {
661        Self::default()
662    }
663
664    pub fn with_builtin_programs() -> Self {
665        let mut catalog = Self::new();
666        for template in builtin_program_templates() {
667            catalog.register(template);
668        }
669        catalog
670    }
671
672    pub fn register(&mut self, template: ProgramTemplate) {
673        self.insert(template);
674    }
675
676    pub fn try_register(&mut self, template: ProgramTemplate) -> Result<()> {
677        template.ensure_valid()?;
678        self.insert(template);
679        Ok(())
680    }
681
682    fn insert(&mut self, template: ProgramTemplate) {
683        if let Some(existing) = self
684            .templates
685            .iter_mut()
686            .find(|existing| existing.name == template.name)
687        {
688            *existing = template;
689        } else {
690            self.templates.push(template);
691        }
692    }
693
694    pub fn get(&self, name: &str) -> Option<&ProgramTemplate> {
695        self.templates.iter().find(|template| template.name == name)
696    }
697
698    pub fn list(&self) -> &[ProgramTemplate] {
699        &self.templates
700    }
701
702    pub fn instantiate(&self, name: &str, inputs: &serde_json::Value) -> Result<Program> {
703        let template = self
704            .get(name)
705            .ok_or_else(|| anyhow::anyhow!("Unknown program: {}", name))?;
706        template.instantiate(inputs)
707    }
708}
709
710impl ProgramExecutor {
711    pub fn new(registry: Arc<ToolRegistry>, context: ToolContext) -> Self {
712        Self { registry, context }
713    }
714
715    pub async fn execute(&self, program: &Program) -> Result<ProgramResult> {
716        let mut steps = Vec::with_capacity(program.steps.len());
717        let mut success = true;
718
719        for step in &program.steps {
720            let result = self
721                .registry
722                .execute_with_context(&step.tool_name, &step.args, &self.context)
723                .await?;
724
725            let step_success = result.exit_code == 0;
726            success &= step_success;
727            steps.push(ProgramStepResult {
728                tool_name: step.tool_name.clone(),
729                label: step.label.clone(),
730                success: step_success,
731                output: result.output,
732                metadata: result.metadata,
733            });
734
735            if !step_success {
736                break;
737            }
738        }
739
740        Ok(ProgramResult {
741            program_name: program.name.clone(),
742            success,
743            summary: summarize_program_result(program, success, steps.len()),
744            steps,
745        })
746    }
747}
748
749pub fn builtin_program_templates() -> Vec<ProgramTemplate> {
750    vec![program_code_search(), program_repo_map()]
751}
752
753pub fn program_code_search() -> ProgramTemplate {
754    ProgramTemplate::new(
755        "program_code_search",
756        "Search code with a bounded grep pass and return file/line matches.",
757    )
758    .with_parameter(ProgramParameter::required(
759        "query",
760        "Regex or literal pattern to search for.",
761    ))
762    .with_parameter(ProgramParameter::optional(
763        "path",
764        "Workspace-relative path to search.",
765        serde_json::json!("."),
766    ))
767    .with_parameter(ProgramParameter::optional(
768        "glob",
769        "Optional file glob filter.",
770        serde_json::json!("*"),
771    ))
772    .with_step(
773        ProgramStepTemplate::new(
774            "grep",
775            serde_json::json!({
776                "pattern": "{{query}}",
777                "path": "{{path}}",
778                "glob": "{{glob}}",
779                "context": 2
780            }),
781        )
782        .with_label("search_code"),
783    )
784}
785
786pub fn program_repo_map() -> ProgramTemplate {
787    let mut template = ProgramTemplate::new(
788        "program_repo_map",
789        "Map the repository shape with root listing and key project files.",
790    )
791    .with_parameter(ProgramParameter::optional(
792        "path",
793        "Workspace-relative path to map.",
794        serde_json::json!("."),
795    ))
796    .with_step(
797        ProgramStepTemplate::new("ls", serde_json::json!({ "path": "{{path}}" }))
798            .with_label("list_root"),
799    );
800
801    for pattern in [
802        "Cargo.toml",
803        "package.json",
804        "pyproject.toml",
805        "go.mod",
806        "README.md",
807        "AGENTS.md",
808    ] {
809        template = template.with_step(
810            ProgramStepTemplate::new(
811                "glob",
812                serde_json::json!({
813                    "path": "{{path}}",
814                    "pattern": pattern
815                }),
816            )
817            .with_label(format!("find_{pattern}")),
818        );
819    }
820
821    template
822}
823
824fn summarize_program_result(program: &Program, success: bool, completed_steps: usize) -> String {
825    let status = if success { "completed" } else { "stopped" };
826    format!(
827        "Program '{}' {} after {}/{} steps.",
828        program.name,
829        status,
830        completed_steps,
831        program.steps.len()
832    )
833}
834
835fn render_template_value(
836    value: &serde_json::Value,
837    bindings: &serde_json::Value,
838) -> serde_json::Value {
839    match value {
840        serde_json::Value::String(text) => render_template_string(text, bindings),
841        serde_json::Value::Array(items) => serde_json::Value::Array(
842            items
843                .iter()
844                .map(|item| render_template_value(item, bindings))
845                .collect(),
846        ),
847        serde_json::Value::Object(object) => serde_json::Value::Object(
848            object
849                .iter()
850                .map(|(key, value)| (key.clone(), render_template_value(value, bindings)))
851                .collect(),
852        ),
853        value => value.clone(),
854    }
855}
856
857fn render_template_string(text: &str, bindings: &serde_json::Value) -> serde_json::Value {
858    if let Some(name) = exact_placeholder_name(text) {
859        return bindings.get(name).cloned().unwrap_or_default();
860    }
861
862    let mut rendered = text.to_string();
863    if let Some(object) = bindings.as_object() {
864        for (key, value) in object {
865            let replacement = value
866                .as_str()
867                .map(ToString::to_string)
868                .unwrap_or_else(|| value.to_string());
869            rendered = rendered.replace(&format!("{{{{{key}}}}}"), &replacement);
870        }
871    }
872    serde_json::Value::String(rendered)
873}
874
875fn exact_placeholder_name(text: &str) -> Option<&str> {
876    text.strip_prefix("{{")?.strip_suffix("}}")
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::tools::{Tool, ToolOutput};
883    use anyhow::Result;
884    use async_trait::async_trait;
885    use std::path::PathBuf;
886
887    #[test]
888    fn program_template_instantiates_step_args() {
889        let template = ProgramTemplate::new("search", "Search")
890            .with_parameter(ProgramParameter::required("query", "Search query"))
891            .with_parameter(ProgramParameter::optional(
892                "path",
893                "Search path",
894                serde_json::json!("."),
895            ))
896            .with_step(ProgramStepTemplate::new(
897                "grep",
898                serde_json::json!({
899                    "pattern": "{{query}}",
900                    "path": "{{path}}",
901                    "message": "query={{query}}"
902                }),
903            ));
904
905        let program = template
906            .instantiate(&serde_json::json!({ "query": "AgentLoop" }))
907            .unwrap();
908
909        assert_eq!(program.name, "search");
910        assert_eq!(program.steps.len(), 1);
911        assert_eq!(program.steps[0].args["pattern"], "AgentLoop");
912        assert_eq!(program.steps[0].args["path"], ".");
913        assert_eq!(program.steps[0].args["message"], "query=AgentLoop");
914    }
915
916    #[test]
917    fn program_template_requires_declared_inputs() {
918        let template = ProgramTemplate::new("search", "Search")
919            .with_parameter(ProgramParameter::required("query", "Search query"))
920            .with_step(ProgramStepTemplate::new(
921                "grep",
922                serde_json::json!({ "pattern": "{{query}}" }),
923            ));
924
925        let err = template.instantiate(&serde_json::json!({})).unwrap_err();
926
927        assert!(err
928            .to_string()
929            .contains("Missing required program parameter"));
930    }
931
932    #[test]
933    fn builtin_program_catalog_contains_first_ptc_programs() {
934        let catalog = ProgramCatalog::with_builtin_programs();
935
936        assert!(catalog.get("program_code_search").is_some());
937        assert!(catalog.get("program_repo_map").is_some());
938        assert_eq!(catalog.list().len(), 2);
939    }
940
941    #[test]
942    fn code_search_program_uses_query_path_and_glob() {
943        let catalog = ProgramCatalog::with_builtin_programs();
944        let program = catalog
945            .instantiate(
946                "program_code_search",
947                &serde_json::json!({
948                    "query": "ContextAssembler",
949                    "path": "core/src",
950                    "glob": "*.rs"
951                }),
952            )
953            .unwrap();
954
955        assert_eq!(program.steps.len(), 1);
956        assert_eq!(program.steps[0].tool_name, "grep");
957        assert_eq!(program.steps[0].label.as_deref(), Some("search_code"));
958        assert_eq!(program.steps[0].args["pattern"], "ContextAssembler");
959        assert_eq!(program.steps[0].args["path"], "core/src");
960        assert_eq!(program.steps[0].args["glob"], "*.rs");
961    }
962
963    #[test]
964    fn repo_map_program_uses_bounded_root_steps() {
965        let catalog = ProgramCatalog::with_builtin_programs();
966        let program = catalog
967            .instantiate("program_repo_map", &serde_json::json!({ "path": "." }))
968            .unwrap();
969
970        assert_eq!(program.steps.len(), 7);
971        assert_eq!(program.steps[0].tool_name, "ls");
972        assert_eq!(program.steps[0].label.as_deref(), Some("list_root"));
973        assert!(program.steps[1..]
974            .iter()
975            .all(|step| step.tool_name == "glob"));
976        assert_eq!(program.steps[1].label.as_deref(), Some("find_Cargo.toml"));
977        assert_eq!(program.steps[1].args["pattern"], "Cargo.toml");
978        assert_eq!(program.steps[6].args["pattern"], "AGENTS.md");
979    }
980
981    #[test]
982    fn program_template_validation_accepts_builtin_templates() {
983        for template in builtin_program_templates() {
984            let validation = template.validate();
985            assert!(
986                validation.is_valid(),
987                "unexpected validation errors: {}",
988                validation.summary()
989            );
990        }
991    }
992
993    #[test]
994    fn program_template_validation_reports_asset_issues() {
995        let template = ProgramTemplate::new("bad name", "")
996            .with_parameter(ProgramParameter::required("query", "Query"))
997            .with_parameter(ProgramParameter::required("query", "Duplicate query"))
998            .with_step(
999                ProgramStepTemplate::new(
1000                    "",
1001                    serde_json::json!({
1002                        "pattern": "{{missing}}",
1003                        "dangling": "{{query"
1004                    }),
1005                )
1006                .with_label("scan"),
1007            )
1008            .with_step(
1009                ProgramStepTemplate::new("grep", serde_json::json!({ "pattern": "{{query}}" }))
1010                    .with_label("scan"),
1011            );
1012
1013        let validation = template.validate();
1014        let codes = validation
1015            .issues
1016            .iter()
1017            .map(|issue| issue.code.as_str())
1018            .collect::<Vec<_>>();
1019
1020        assert!(!validation.is_valid());
1021        assert!(codes.contains(&"invalid_name"));
1022        assert!(codes.contains(&"empty_description"));
1023        assert!(codes.contains(&"duplicate_parameter"));
1024        assert!(codes.contains(&"empty_tool_name"));
1025        assert!(codes.contains(&"unknown_placeholder"));
1026        assert!(codes.contains(&"malformed_placeholder"));
1027        assert!(codes.contains(&"duplicate_step_label"));
1028    }
1029
1030    #[test]
1031    fn program_catalog_try_register_rejects_invalid_template() {
1032        let mut catalog = ProgramCatalog::new();
1033        let template = ProgramTemplate::new("empty_steps", "Missing steps");
1034
1035        let err = catalog.try_register(template).unwrap_err();
1036
1037        assert!(err.to_string().contains("empty_steps"));
1038        assert!(catalog.list().is_empty());
1039    }
1040
1041    #[test]
1042    fn program_trace_serializes_with_stable_schema() {
1043        let result = ProgramResult {
1044            program_name: "program_code_search".to_string(),
1045            success: true,
1046            summary: "done".to_string(),
1047            steps: vec![ProgramStepResult {
1048                tool_name: "grep".to_string(),
1049                label: Some("search_code".to_string()),
1050                success: true,
1051                output: "match".to_string(),
1052                metadata: Some(serde_json::json!({ "exit_code": 0 })),
1053            }],
1054        };
1055
1056        let step_trace = ProgramTraceStep::from_result(
1057            0,
1058            &result.steps[0],
1059            true,
1060            Some(ProgramTraceArtifact {
1061                artifact_id: "artifact-1".to_string(),
1062                artifact_uri: "artifact://tool-output/artifact-1".to_string(),
1063                original_bytes: 100,
1064                shown_bytes: 10,
1065            }),
1066        );
1067        let trace = ProgramTrace::from_result(&result, vec![step_trace]);
1068        let value = trace.to_value();
1069
1070        assert_eq!(value["schema"], PROGRAM_TRACE_SCHEMA);
1071        assert_eq!(value["type"], "program_execution");
1072        assert_eq!(value["program_name"], "program_code_search");
1073        assert_eq!(value["step_count"], 1);
1074        assert_eq!(value["failed_steps"], 0);
1075        assert_eq!(value["steps"][0]["label"], "search_code");
1076        assert_eq!(value["steps"][0]["output_bytes"], 5);
1077        assert_eq!(value["steps"][0]["metadata"]["exit_code"], 0);
1078        assert_eq!(
1079            value["steps"][0]["artifact"]["artifact_uri"],
1080            "artifact://tool-output/artifact-1"
1081        );
1082    }
1083
1084    #[test]
1085    fn program_verification_hints_include_program_contract() {
1086        let result = ProgramResult {
1087            program_name: "program_repo_map".to_string(),
1088            success: true,
1089            summary: "done".to_string(),
1090            steps: vec![],
1091        };
1092
1093        let hints = program_verification_hints(&result, None);
1094
1095        assert_eq!(hints.len(), 1);
1096        assert_eq!(hints[0].kind, "inspect_project_files");
1097        assert!(hints[0].required);
1098        assert_eq!(hints[0].suggested_tools, vec!["read", "glob"]);
1099    }
1100
1101    #[test]
1102    fn program_verification_hints_include_failures_and_artifacts() {
1103        let result = ProgramResult {
1104            program_name: "custom_program".to_string(),
1105            success: false,
1106            summary: "stopped".to_string(),
1107            steps: vec![ProgramStepResult {
1108                tool_name: "grep".to_string(),
1109                label: Some("scan".to_string()),
1110                success: false,
1111                output: "failed".to_string(),
1112                metadata: None,
1113            }],
1114        };
1115        let trace = ProgramTrace::from_result(
1116            &result,
1117            vec![ProgramTraceStep {
1118                index: 0,
1119                label: "scan".to_string(),
1120                tool_name: "grep".to_string(),
1121                success: false,
1122                output_bytes: 6,
1123                compacted: true,
1124                artifact: Some(ProgramTraceArtifact {
1125                    artifact_id: "artifact-1".to_string(),
1126                    artifact_uri: "artifact://tool-output/artifact-1".to_string(),
1127                    original_bytes: 100,
1128                    shown_bytes: 6,
1129                }),
1130                metadata: None,
1131            }],
1132        );
1133
1134        let hints = program_verification_hints(&result, Some(&trace));
1135
1136        assert_eq!(hints.len(), 2);
1137        assert_eq!(hints[0].kind, "investigate_failed_steps");
1138        assert!(hints[0].message.contains("scan"));
1139        assert_eq!(hints[1].kind, "inspect_artifacts");
1140        assert_eq!(
1141            hints[1].evidence_uris,
1142            vec!["artifact://tool-output/artifact-1"]
1143        );
1144    }
1145
1146    struct EchoTool;
1147
1148    #[async_trait]
1149    impl Tool for EchoTool {
1150        fn name(&self) -> &str {
1151            "echo"
1152        }
1153
1154        fn description(&self) -> &str {
1155            "Echoes the message argument"
1156        }
1157
1158        fn parameters(&self) -> serde_json::Value {
1159            serde_json::json!({
1160                "type": "object",
1161                "additionalProperties": false,
1162                "properties": {
1163                    "message": { "type": "string" }
1164                },
1165                "required": ["message"]
1166            })
1167        }
1168
1169        async fn execute(
1170            &self,
1171            args: &serde_json::Value,
1172            _ctx: &ToolContext,
1173        ) -> Result<ToolOutput> {
1174            Ok(ToolOutput::success(
1175                args["message"].as_str().unwrap_or_default(),
1176            ))
1177        }
1178    }
1179
1180    struct FailTool;
1181
1182    #[async_trait]
1183    impl Tool for FailTool {
1184        fn name(&self) -> &str {
1185            "fail"
1186        }
1187
1188        fn description(&self) -> &str {
1189            "Always fails"
1190        }
1191
1192        fn parameters(&self) -> serde_json::Value {
1193            serde_json::json!({
1194                "type": "object",
1195                "additionalProperties": false,
1196                "properties": {},
1197                "required": []
1198            })
1199        }
1200
1201        async fn execute(
1202            &self,
1203            _args: &serde_json::Value,
1204            _ctx: &ToolContext,
1205        ) -> Result<ToolOutput> {
1206            Ok(ToolOutput::error("failed"))
1207        }
1208    }
1209
1210    #[tokio::test]
1211    async fn program_executor_runs_steps_in_order() {
1212        let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
1213        registry.register(Arc::new(EchoTool));
1214        let executor = ProgramExecutor::new(
1215            Arc::clone(&registry),
1216            ToolContext::new(PathBuf::from("/tmp")),
1217        );
1218        let program = Program::new("two_echoes", "Run two echo steps")
1219            .with_step(ProgramStep::new(
1220                "echo",
1221                serde_json::json!({ "message": "one" }),
1222            ))
1223            .with_step(ProgramStep::new(
1224                "echo",
1225                serde_json::json!({ "message": "two" }),
1226            ));
1227
1228        let result = executor.execute(&program).await.unwrap();
1229
1230        assert!(result.success);
1231        assert_eq!(result.steps.len(), 2);
1232        assert_eq!(result.steps[0].output, "one");
1233        assert_eq!(result.steps[1].output, "two");
1234        assert_eq!(result.steps[0].label, None);
1235        assert_eq!(
1236            result.summary,
1237            "Program 'two_echoes' completed after 2/2 steps."
1238        );
1239    }
1240
1241    #[tokio::test]
1242    async fn program_executor_stops_after_failed_step() {
1243        let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
1244        registry.register(Arc::new(EchoTool));
1245        registry.register(Arc::new(FailTool));
1246        let executor = ProgramExecutor::new(
1247            Arc::clone(&registry),
1248            ToolContext::new(PathBuf::from("/tmp")),
1249        );
1250        let program = Program::new("fail_fast", "Stop after a failed step")
1251            .with_step(ProgramStep::new(
1252                "echo",
1253                serde_json::json!({ "message": "before" }),
1254            ))
1255            .with_step(ProgramStep::new("fail", serde_json::json!({})))
1256            .with_step(ProgramStep::new(
1257                "echo",
1258                serde_json::json!({ "message": "after" }),
1259            ));
1260
1261        let result = executor.execute(&program).await.unwrap();
1262
1263        assert!(!result.success);
1264        assert_eq!(result.steps.len(), 2);
1265        assert_eq!(result.steps[0].output, "before");
1266        assert_eq!(result.steps[1].output, "failed");
1267        assert_eq!(
1268            result.summary,
1269            "Program 'fail_fast' stopped after 2/3 steps."
1270        );
1271    }
1272}