Skip to main content

orchestrator_collab/
context.rs

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/// Lightweight reference to agent context for serialized message payloads.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AgentContextRef {
14    /// Parent task identifier.
15    pub task_id: String,
16    /// Current task-item identifier.
17    pub item_id: String,
18    /// Execution cycle number.
19    pub cycle: u32,
20    /// Optional phase name when known.
21    pub phase: Option<String>,
22    /// Workspace root serialized as a string path.
23    pub workspace_root: String,
24    /// Workspace identifier from configuration.
25    pub workspace_id: String,
26}
27
28/// Full agent context available during phase execution.
29#[derive(Debug, Clone)]
30pub struct AgentContext {
31    /// Parent task identifier.
32    pub task_id: String,
33    /// Current task-item identifier.
34    pub item_id: String,
35    /// Execution cycle number.
36    pub cycle: u32,
37    /// Current phase name.
38    pub phase: String,
39    /// Absolute workspace root used for command execution.
40    pub workspace_root: PathBuf,
41    /// Workspace identifier from configuration.
42    pub workspace_id: String,
43    /// Historical phase executions accumulated so far.
44    pub execution_history: Vec<PhaseRecord>,
45    /// Outputs produced by upstream phases.
46    pub upstream_outputs: Vec<AgentOutput>,
47    /// Artifact registry accumulated across phases.
48    pub artifacts: ArtifactRegistry,
49    /// Shared key-value state available to templates and follow-up steps.
50    pub shared_state: SharedState,
51}
52
53impl AgentContext {
54    /// Creates a fresh execution context for an agent phase.
55    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    /// Adds an upstream output and merges its artifacts into the registry.
78    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    /// Renders a template using context variables only.
87    ///
88    /// Note: Pipeline variable values are escaped for safe use inside
89    /// bash double-quoted strings. This prevents content like markdown
90    /// backticks from triggering shell command substitution.
91    pub fn render_template(&self, template: &str) -> String {
92        self.render_template_with_pipeline(template, None)
93    }
94
95    /// Renders a template using context variables and optional pipeline values.
96    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    /// Converts the full context into a lightweight serializable reference.
180    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/// Record of a single completed or attempted phase execution.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct PhaseRecord {
195    /// Phase identifier.
196    pub phase: String,
197    /// Agent identifier selected for the phase.
198    pub agent_id: String,
199    /// Run identifier for the phase execution.
200    pub run_id: Uuid,
201    /// Process exit code returned by the agent command.
202    pub exit_code: i64,
203    /// Optional structured output captured from the run.
204    pub output: Option<AgentOutput>,
205    /// Start timestamp.
206    pub started_at: DateTime<Utc>,
207    /// End timestamp.
208    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}