fix(missions): state the INT-XX marker contract where agents actually see it

task_card_parser.rs scans every mission turn line-by-line for TASK/WORK/
HANDOFF/TEST_PASS/TEST_FAIL/REVIEW_APPROVE/REVIEW_BLOCK/COMPLETED and
materializes mission_tasks rows from them. The exact syntax it demands --
literal, own line, with the colon, no bold, no code fence, one INT id per
line -- was documented in two places the agent does not reliably read:

  1. the team-template role prompts, which are NEVER injected into mission
     turns (runtime_provision writes model_provider / risk_profile /
     mcp_bundles and nothing else), and
  2. a foundation skill the agent had to choose to fetch.

The phase directives said "emit INT-XX markers" without ever saying what one
looks like. So the parser's contract was stated nowhere load-bearing, and
whether a mission produced task cards came down to whether the model guessed
the format. This is a machine contract, not a style hint -- it belongs in
phase_task_text, the one text every mission turn receives.

Added a regression test that feeds every marker example from the generated
prompt through the real parser, so the syntax we advertise and the syntax we
accept cannot drift apart again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-30 10:50:49 -07:00
co-authored by Claude Opus 5
parent d9a1d8bb5a
commit 6926107e4f
+64 -1
View File
@@ -290,6 +290,28 @@ fn phase_task_text(kind: &str, title: &str, description: Option<&str>) -> String
or content_search first, then file_edit to patch. Write your outputs\n\ or content_search first, then file_edit to patch. Write your outputs\n\
as REAL files with file_edit — do NOT paste code blocks in your reply\n\ as REAL files with file_edit — do NOT paste code blocks in your reply\n\
expecting the platform to save them; nothing else writes files for you.\n"; expecting the platform to save them; nothing else writes files for you.\n";
// The INT-XX markers are a machine contract, not a style preference:
// task_card_parser.rs scans turn output line-by-line for these literals and
// materializes `mission_tasks` rows from them. The rules used to live only
// in the team-template role prompts -- which are never injected into mission
// turns (runtime_provision.rs writes model/risk_profile/mcp_bundles and
// nothing else) -- and in a skill the agent had to choose to fetch. So the
// parser's contract was stated nowhere the agent reliably saw it. It is
// stated here because this is the one text every mission turn receives.
let marker_protocol = "\
TASK MARKERS (parsed literally, line by line — this is a machine contract):\n\
Emit these on their own line, with the colon, no bold, no code fence,\n\
exactly one INT id per line, at the END of a substantive turn:\n\
- TASK: INT-NN — <title> open a new item\n\
- WORK: INT-NN started implementing\n\
- HANDOFF: INT-NN passed to test/review\n\
- TEST_PASS: INT-NN tests green\n\
- TEST_FAIL: INT-NN — <reason> build/tests failed\n\
- REVIEW_APPROVE: INT-NN diff approved\n\
- REVIEW_BLOCK: INT-NN — <reason> changes requested\n\
- COMPLETED: INT-NN done and pushed\n\
Never emit a marker you can't back up — COMPLETED without a corresponding\n\
commit desynchronizes the mission from the repo.\n";
let directive = match kind { let directive = match kind {
"research" => { "research" => {
"Your team is running the RESEARCH phase of this mission. \ "Your team is running the RESEARCH phase of this mission. \
@@ -323,7 +345,7 @@ fn phase_task_text(kind: &str, title: &str, description: Option<&str>) -> String
} }
_ => "Execute this mission phase according to the mission brief.", _ => "Execute this mission phase according to the mission brief.",
}; };
format!("MISSION: {title}\n\n{tool_preamble}\n{directive}\n\nBRIEF:\n{base}") format!("MISSION: {title}\n\n{tool_preamble}\n{marker_protocol}\n{directive}\n\nBRIEF:\n{base}")
} }
/// Close phases whose topology_runs are all terminal. /// Close phases whose topology_runs are all terminal.
@@ -472,6 +494,47 @@ mod tests {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
} }
/// The marker syntax we hand the agent must be the syntax we parse back.
///
/// These two sides used to live far apart — the rules were in team-template
/// role prompts that mission turns never receive — so nothing caught a
/// drift between what we asked for and what `task_card_parser` accepts.
/// Every example line in the prompt is fed through the real parser here.
#[test]
fn task_text_marker_examples_parse() {
let text = phase_task_text("coding", "Demo", Some("brief"));
let examples: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| l.starts_with("- ") && l.contains("INT-NN"))
.map(|l| l.trim_start_matches("- "))
.collect();
assert!(
examples.len() >= 8,
"expected the full marker ladder in the prompt, found {}: {examples:?}",
examples.len()
);
for ex in examples {
// Strip the trailing prose column ("open a new item") and the
// <placeholder>, leaving a marker line an agent would actually emit.
let line = ex.replace("INT-NN", "INT-05");
let line = line.split(" ").next().unwrap_or(&line).trim();
let line = line.replace("<title>", "Add retry").replace(
"<reason>",
"compile error",
);
let parsed = crate::task_card_parser::parse(&line);
assert_eq!(
parsed.len(),
1,
"prompt advertises a marker the parser does not accept: {line:?}"
);
assert_eq!(parsed[0].int_id, "INT-05", "wrong id parsed from {line:?}");
}
}
/// The regression guard: the alias must survive a round-trip through the /// The regression guard: the alias must survive a round-trip through the
/// real `TopologyGraph` deserializer and land in `attrs`. A top-level /// real `TopologyGraph` deserializer and land in `attrs`. A top-level
/// `"agent"` key alone is dropped by serde, which silently routed every /// `"agent"` key alone is dropped by serde, which silently routed every