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
649/// Low-level deterministic host executor for callers that deliberately own the
650/// tool registry and context.
651///
652/// This type does not install agent/session permission, hook, budget, queue, or
653/// sanitization policy. Agent and session code must invoke the model-visible
654/// `program` tool through the governed tool gateway instead.
655/// Low-level executor for deterministic standalone [`Program`] values.
656///
657/// This type invokes its registry directly and therefore does not install
658/// agent/session permission, HITL, hook, budget, queue, timeout, cancellation,
659/// or sanitization policy. Agent and session code should use the model-visible
660/// `program` tool through the scoped tool invocation gateway instead.
661pub struct ProgramExecutor {
662    registry: Arc<ToolRegistry>,
663    context: ToolContext,
664}
665
666#[derive(Debug, Clone, Default)]
667pub struct ProgramCatalog {
668    templates: Vec<ProgramTemplate>,
669}
670
671impl ProgramCatalog {
672    pub fn new() -> Self {
673        Self::default()
674    }
675
676    pub fn with_builtin_programs() -> Self {
677        let mut catalog = Self::new();
678        for template in builtin_program_templates() {
679            catalog.register(template);
680        }
681        catalog
682    }
683
684    pub fn register(&mut self, template: ProgramTemplate) {
685        self.insert(template);
686    }
687
688    pub fn try_register(&mut self, template: ProgramTemplate) -> Result<()> {
689        template.ensure_valid()?;
690        self.insert(template);
691        Ok(())
692    }
693
694    fn insert(&mut self, template: ProgramTemplate) {
695        if let Some(existing) = self
696            .templates
697            .iter_mut()
698            .find(|existing| existing.name == template.name)
699        {
700            *existing = template;
701        } else {
702            self.templates.push(template);
703        }
704    }
705
706    pub fn get(&self, name: &str) -> Option<&ProgramTemplate> {
707        self.templates.iter().find(|template| template.name == name)
708    }
709
710    pub fn list(&self) -> &[ProgramTemplate] {
711        &self.templates
712    }
713
714    pub fn instantiate(&self, name: &str, inputs: &serde_json::Value) -> Result<Program> {
715        let template = self
716            .get(name)
717            .ok_or_else(|| anyhow::anyhow!("Unknown program: {}", name))?;
718        template.instantiate(inputs)
719    }
720}
721
722impl ProgramExecutor {
723    pub fn new(registry: Arc<ToolRegistry>, context: ToolContext) -> Self {
724        Self { registry, context }
725    }
726
727    pub async fn execute(&self, program: &Program) -> Result<ProgramResult> {
728        let mut steps = Vec::with_capacity(program.steps.len());
729        let mut success = true;
730
731        for step in &program.steps {
732            let result = self
733                .registry
734                .execute_with_context(&step.tool_name, &step.args, &self.context)
735                .await?;
736
737            let step_success = result.exit_code == 0;
738            success &= step_success;
739            steps.push(ProgramStepResult {
740                tool_name: step.tool_name.clone(),
741                label: step.label.clone(),
742                success: step_success,
743                output: result.output,
744                metadata: result.metadata,
745            });
746
747            if !step_success {
748                break;
749            }
750        }
751
752        Ok(ProgramResult {
753            program_name: program.name.clone(),
754            success,
755            summary: summarize_program_result(program, success, steps.len()),
756            steps,
757        })
758    }
759}
760
761pub fn builtin_program_templates() -> Vec<ProgramTemplate> {
762    vec![program_code_search(), program_repo_map()]
763}
764
765pub fn program_code_search() -> ProgramTemplate {
766    ProgramTemplate::new(
767        "program_code_search",
768        "Search code with a bounded grep pass and return file/line matches.",
769    )
770    .with_parameter(ProgramParameter::required(
771        "query",
772        "Regex or literal pattern to search for.",
773    ))
774    .with_parameter(ProgramParameter::optional(
775        "path",
776        "Workspace-relative path to search.",
777        serde_json::json!("."),
778    ))
779    .with_parameter(ProgramParameter::optional(
780        "glob",
781        "Optional file glob filter.",
782        serde_json::json!("*"),
783    ))
784    .with_step(
785        ProgramStepTemplate::new(
786            "grep",
787            serde_json::json!({
788                "pattern": "{{query}}",
789                "path": "{{path}}",
790                "glob": "{{glob}}",
791                "context": 2
792            }),
793        )
794        .with_label("search_code"),
795    )
796}
797
798pub fn program_repo_map() -> ProgramTemplate {
799    let mut template = ProgramTemplate::new(
800        "program_repo_map",
801        "Map the repository shape with root listing and key project files.",
802    )
803    .with_parameter(ProgramParameter::optional(
804        "path",
805        "Workspace-relative path to map.",
806        serde_json::json!("."),
807    ))
808    .with_step(
809        ProgramStepTemplate::new("ls", serde_json::json!({ "path": "{{path}}" }))
810            .with_label("list_root"),
811    );
812
813    for pattern in [
814        "Cargo.toml",
815        "package.json",
816        "pyproject.toml",
817        "go.mod",
818        "README.md",
819        "AGENTS.md",
820    ] {
821        template = template.with_step(
822            ProgramStepTemplate::new(
823                "glob",
824                serde_json::json!({
825                    "path": "{{path}}",
826                    "pattern": pattern
827                }),
828            )
829            .with_label(format!("find_{pattern}")),
830        );
831    }
832
833    template
834}
835
836fn summarize_program_result(program: &Program, success: bool, completed_steps: usize) -> String {
837    let status = if success { "completed" } else { "stopped" };
838    format!(
839        "Program '{}' {} after {}/{} steps.",
840        program.name,
841        status,
842        completed_steps,
843        program.steps.len()
844    )
845}
846
847fn render_template_value(
848    value: &serde_json::Value,
849    bindings: &serde_json::Value,
850) -> serde_json::Value {
851    match value {
852        serde_json::Value::String(text) => render_template_string(text, bindings),
853        serde_json::Value::Array(items) => serde_json::Value::Array(
854            items
855                .iter()
856                .map(|item| render_template_value(item, bindings))
857                .collect(),
858        ),
859        serde_json::Value::Object(object) => serde_json::Value::Object(
860            object
861                .iter()
862                .map(|(key, value)| (key.clone(), render_template_value(value, bindings)))
863                .collect(),
864        ),
865        value => value.clone(),
866    }
867}
868
869fn render_template_string(text: &str, bindings: &serde_json::Value) -> serde_json::Value {
870    if let Some(name) = exact_placeholder_name(text) {
871        return bindings.get(name).cloned().unwrap_or_default();
872    }
873
874    let mut rendered = text.to_string();
875    if let Some(object) = bindings.as_object() {
876        for (key, value) in object {
877            let replacement = value
878                .as_str()
879                .map(ToString::to_string)
880                .unwrap_or_else(|| value.to_string());
881            rendered = rendered.replace(&format!("{{{{{key}}}}}"), &replacement);
882        }
883    }
884    serde_json::Value::String(rendered)
885}
886
887fn exact_placeholder_name(text: &str) -> Option<&str> {
888    text.strip_prefix("{{")?.strip_suffix("}}")
889}
890
891#[cfg(test)]
892#[path = "program/tests.rs"]
893mod tests;