1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::artifact::{ArtifactRegistry, SharedState};
8use super::escape_for_bash_dquote;
9use super::output::AgentOutput;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AgentContextRef {
14 pub task_id: String,
16 pub item_id: String,
18 pub cycle: u32,
20 pub phase: Option<String>,
22 pub workspace_root: String,
24 pub workspace_id: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct AgentContext {
31 pub task_id: String,
33 pub item_id: String,
35 pub cycle: u32,
37 pub phase: String,
39 pub workspace_root: PathBuf,
41 pub workspace_id: String,
43 pub execution_history: Vec<PhaseRecord>,
45 pub upstream_outputs: Vec<AgentOutput>,
47 pub artifacts: ArtifactRegistry,
49 pub shared_state: SharedState,
51}
52
53impl AgentContext {
54 pub fn new(
56 task_id: String,
57 item_id: String,
58 cycle: u32,
59 phase: String,
60 workspace_root: PathBuf,
61 workspace_id: String,
62 ) -> Self {
63 Self {
64 task_id,
65 item_id,
66 cycle,
67 phase,
68 workspace_root,
69 workspace_id,
70 execution_history: Vec::new(),
71 upstream_outputs: Vec::new(),
72 artifacts: ArtifactRegistry::default(),
73 shared_state: SharedState::default(),
74 }
75 }
76
77 pub fn add_upstream_output(&mut self, output: AgentOutput) {
79 self.upstream_outputs.push(output.clone());
80
81 for artifact in output.artifacts {
82 self.artifacts.register(self.phase.clone(), artifact);
83 }
84 }
85
86 pub fn render_template(&self, template: &str) -> String {
92 self.render_template_with_pipeline(template, None)
93 }
94
95 pub fn render_template_with_pipeline(
97 &self,
98 template: &str,
99 pipeline: Option<&orchestrator_config::config::PipelineVariables>,
100 ) -> String {
101 let mut result = template.to_string();
102
103 result = result.replace("{task_id}", &self.task_id);
104 result = result.replace("{item_id}", &self.item_id);
105 result = result.replace("{cycle}", &self.cycle.to_string());
106 result = result.replace("{phase}", &self.phase);
107 result = result.replace("{workspace_root}", &self.workspace_root.to_string_lossy());
108 result = result.replace("{source_tree}", &self.workspace_root.to_string_lossy());
109
110 if let Some(pipeline) = pipeline {
111 result = result.replace(
112 "{build_output}",
113 &escape_for_bash_dquote(&pipeline.prev_stdout),
114 );
115 result = result.replace(
116 "{test_output}",
117 &escape_for_bash_dquote(&pipeline.prev_stdout),
118 );
119 result = result.replace("{diff}", &escape_for_bash_dquote(&pipeline.diff));
120
121 if !pipeline.build_errors.is_empty() {
122 let errors_json = serde_json::to_string(&pipeline.build_errors).unwrap_or_default();
123 result = result.replace("{build_errors}", &errors_json);
124 } else {
125 result = result.replace("{build_errors}", "[]");
126 }
127
128 if !pipeline.test_failures.is_empty() {
129 let failures_json =
130 serde_json::to_string(&pipeline.test_failures).unwrap_or_default();
131 result = result.replace("{test_failures}", &failures_json);
132 } else {
133 result = result.replace("{test_failures}", "[]");
134 }
135
136 for (key, value) in &pipeline.vars {
137 result = result.replace(&format!("{{{}}}", key), &escape_for_bash_dquote(value));
138 }
139 }
140
141 for (i, output) in self.upstream_outputs.iter().enumerate() {
142 let prefix = format!("upstream[{}]", i);
143
144 result = result.replace(
145 &format!("{}.exit_code", prefix),
146 &output.exit_code.to_string(),
147 );
148 result = result.replace(
149 &format!("{}.confidence", prefix),
150 &output.confidence.to_string(),
151 );
152 result = result.replace(
153 &format!("{}.quality_score", prefix),
154 &output.quality_score.to_string(),
155 );
156 result = result.replace(
157 &format!("{}.duration_ms", prefix),
158 &output.metrics.duration_ms.to_string(),
159 );
160
161 for (j, artifact) in output.artifacts.iter().enumerate() {
162 if let Some(content) = &artifact.content {
163 let key = format!("{}.artifacts[{}].content", prefix, j);
164 result = result.replace(
165 &format!("{{{}}}", key),
166 &serde_json::to_string(content).unwrap_or_default(),
167 );
168 }
169 }
170 }
171
172 result = self.shared_state.render_template(&result);
173
174 result = result.replace("{artifacts.count}", &self.artifacts.count().to_string());
175
176 result
177 }
178
179 pub fn to_ref(&self) -> AgentContextRef {
181 AgentContextRef {
182 task_id: self.task_id.clone(),
183 item_id: self.item_id.clone(),
184 cycle: self.cycle,
185 phase: Some(self.phase.clone()),
186 workspace_root: self.workspace_root.to_string_lossy().to_string(),
187 workspace_id: self.workspace_id.clone(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct PhaseRecord {
195 pub phase: String,
197 pub agent_id: String,
199 pub run_id: Uuid,
201 pub exit_code: i64,
203 pub output: Option<AgentOutput>,
205 pub started_at: DateTime<Utc>,
207 pub ended_at: DateTime<Utc>,
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::{Artifact, ArtifactKind};
215
216 #[test]
217 fn test_agent_context_template() {
218 let ctx = AgentContext::new(
219 "task1".to_string(),
220 "item1".to_string(),
221 1,
222 "qa".to_string(),
223 PathBuf::from("/workspace"),
224 "ws1".to_string(),
225 );
226
227 let result = ctx.render_template("Task: {task_id}, Item: {item_id}, Cycle: {cycle}");
228 assert_eq!(result, "Task: task1, Item: item1, Cycle: 1");
229 }
230
231 #[test]
232 fn test_agent_context_to_ref() {
233 let ctx = AgentContext::new(
234 "t1".to_string(),
235 "i1".to_string(),
236 2,
237 "qa".to_string(),
238 PathBuf::from("/ws"),
239 "ws1".to_string(),
240 );
241 let r = ctx.to_ref();
242 assert_eq!(r.task_id, "t1");
243 assert_eq!(r.item_id, "i1");
244 assert_eq!(r.cycle, 2);
245 assert_eq!(r.phase, Some("qa".to_string()));
246 }
247
248 #[test]
249 fn test_agent_context_add_upstream_output() {
250 let mut ctx = AgentContext::new(
251 "t1".to_string(),
252 "i1".to_string(),
253 1,
254 "qa".to_string(),
255 PathBuf::from("/ws"),
256 "ws1".to_string(),
257 );
258
259 let output = AgentOutput::new(
260 Uuid::new_v4(),
261 "plan_agent".to_string(),
262 "plan".to_string(),
263 0,
264 "plan output".to_string(),
265 "".to_string(),
266 )
267 .with_artifacts(vec![Artifact::new(ArtifactKind::Custom {
268 name: "plan_doc".to_string(),
269 })]);
270
271 ctx.add_upstream_output(output);
272 assert_eq!(ctx.upstream_outputs.len(), 1);
273 assert_eq!(ctx.artifacts.count(), 1);
274 }
275
276 #[test]
277 fn test_agent_context_render_source_tree_alias() {
278 let ctx = AgentContext::new(
279 "t1".to_string(),
280 "i1".to_string(),
281 1,
282 "qa".to_string(),
283 PathBuf::from("/workspace"),
284 "ws1".to_string(),
285 );
286 let result = ctx.render_template("root={source_tree}");
287 assert_eq!(result, "root=/workspace");
288 }
289
290 #[test]
291 fn test_pipeline_vars_escaped_in_template() {
292 let ctx = AgentContext::new(
293 "task1".to_string(),
294 "item1".to_string(),
295 1,
296 "plan".to_string(),
297 PathBuf::from("/workspace"),
298 "ws1".to_string(),
299 );
300
301 let mut pipeline = orchestrator_config::config::PipelineVariables::default();
302 pipeline.vars.insert(
303 "plan_output".to_string(),
304 "Split `resource.rs` into `mod.rs` and `api.rs`".to_string(),
305 );
306
307 let template = r#"claude "Plan: {plan_output}""#;
308 let rendered = ctx.render_template_with_pipeline(template, Some(&pipeline));
309
310 assert!(rendered.contains("\\`resource.rs\\`"));
311 assert!(rendered.contains("\\`mod.rs\\`"));
312 assert!(rendered.contains("\\`api.rs\\`"));
313 assert!(!rendered.contains(" `resource.rs` "));
314 }
315}