1use 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(¶meter.name));
84 match (value, ¶meter.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(¶meter.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 ¶meter_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(®istry),
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(®istry),
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}