use std::fmt::Write as _;
use super::DagScheduler;
use crate::graph::{TaskNode, TaskStatus};
use zeph_sanitizer::{ContentSource, ContentSourceKind};
impl DagScheduler {
pub(super) fn build_task_prompt(&self, task: &TaskNode) -> String {
if task.depends_on.is_empty() {
return task.description.clone();
}
let completed_deps: Vec<&TaskNode> = task
.depends_on
.iter()
.filter_map(|dep_id| {
let dep = &self.graph.tasks[dep_id.index()];
if dep.status == TaskStatus::Completed {
Some(dep)
} else {
None
}
})
.collect();
if completed_deps.is_empty() {
return task.description.clone();
}
let budget_per_dep = self
.dependency_context_budget
.checked_div(completed_deps.len())
.unwrap_or(self.dependency_context_budget);
let mut context_block = String::from("<completed-dependencies>\n");
for dep in &completed_deps {
let escaped_id = xml_escape(&dep.id.to_string());
let escaped_title = xml_escape(&dep.title);
let _ = writeln!(
context_block,
"## Task \"{escaped_id}\": \"{escaped_title}\" (completed)",
);
if let Some(ref result) = dep.result {
let source = ContentSource::new(ContentSourceKind::A2aMessage);
let sanitized = self.sanitizer.sanitize(&result.output, source);
let safe_output = sanitized.body;
let char_count = safe_output.chars().count();
if char_count > budget_per_dep {
let truncated: String = safe_output.chars().take(budget_per_dep).collect();
let _ = write!(
context_block,
"{truncated}...\n[truncated: {char_count} chars total]"
);
} else {
context_block.push_str(&safe_output);
}
} else {
context_block.push_str("[no output recorded]\n");
}
context_block.push('\n');
}
for dep_id in &task.depends_on {
let dep = &self.graph.tasks[dep_id.index()];
if dep.status == TaskStatus::Skipped {
let escaped_id = xml_escape(&dep.id.to_string());
let escaped_title = xml_escape(&dep.title);
let _ = writeln!(
context_block,
"## Task \"{escaped_id}\": \"{escaped_title}\" (skipped -- no output available)\n",
);
}
}
context_block.push_str("</completed-dependencies>\n\n");
format!("{context_block}Your task: {}", task.description)
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{TaskResult, TaskStatus};
use crate::scheduler::tests::*;
#[test]
fn test_build_prompt_no_deps() {
let graph = graph_from_nodes(vec![make_node(0, &[])]);
let scheduler = make_scheduler(graph);
let prompt = scheduler.build_task_prompt(&scheduler.graph.tasks[0]);
assert_eq!(prompt, "description for task 0");
}
#[test]
fn test_build_prompt_with_deps_and_truncation() {
let mut graph = graph_from_nodes(vec![make_node(0, &[]), make_node(1, &[0])]);
graph.tasks[0].status = TaskStatus::Completed;
graph.tasks[0].result = Some(TaskResult {
output: "x".repeat(200),
artifacts: vec![],
duration_ms: 10,
agent_id: None,
agent_def: None,
});
let config = zeph_config::OrchestrationConfig {
dependency_context_budget: 50,
..make_config()
};
let scheduler = DagScheduler::new(
graph,
&config,
Box::new(FirstRouter),
vec![make_def("worker")],
)
.unwrap();
let prompt = scheduler.build_task_prompt(&scheduler.graph.tasks[1]);
assert!(prompt.contains("<completed-dependencies>"));
assert!(prompt.contains("[truncated:"));
assert!(prompt.contains("Your task:"));
}
#[test]
fn test_utf8_safe_truncation() {
let mut graph = graph_from_nodes(vec![make_node(0, &[]), make_node(1, &[0])]);
graph.tasks[0].status = TaskStatus::Completed;
let unicode_output = "日本語テスト".repeat(100);
graph.tasks[0].result = Some(TaskResult {
output: unicode_output,
artifacts: vec![],
duration_ms: 10,
agent_id: None,
agent_def: None,
});
let config = zeph_config::OrchestrationConfig {
dependency_context_budget: 500,
..make_config()
};
let scheduler = DagScheduler::new(
graph,
&config,
Box::new(FirstRouter),
vec![make_def("worker")],
)
.unwrap();
let prompt = scheduler.build_task_prompt(&scheduler.graph.tasks[1]);
assert!(
prompt.contains("日"),
"Japanese characters should be in the prompt after safe truncation"
);
}
#[test]
fn test_build_prompt_chars_count_in_truncation_message() {
let mut graph = graph_from_nodes(vec![make_node(0, &[]), make_node(1, &[0])]);
graph.tasks[0].status = TaskStatus::Completed;
let output = "x".repeat(200);
graph.tasks[0].result = Some(TaskResult {
output,
artifacts: vec![],
duration_ms: 10,
agent_id: None,
agent_def: None,
});
let config = zeph_config::OrchestrationConfig {
dependency_context_budget: 10,
..make_config()
};
let scheduler = DagScheduler::new(
graph,
&config,
Box::new(FirstRouter),
vec![make_def("worker")],
)
.unwrap();
let prompt = scheduler.build_task_prompt(&scheduler.graph.tasks[1]);
assert!(
prompt.contains("chars total"),
"truncation message must use 'chars total' label. Prompt: {prompt}"
);
assert!(
prompt.contains("[truncated:"),
"prompt must contain truncation notice. Prompt: {prompt}"
);
}
}