feat: RFC 0029 file-based subagent output for alignment dialogues

Replace JSONL extraction pipeline with direct file writes: alignment-expert
agents write perspectives to /tmp/blue-dialogue/{slug}/round-{n}/{name}.md,
and the Judge reads those files directly after Task completion.

Changes:
- alignment-expert.md: add Write tool
- dialogue.rs: create output_dir, pass to build_judge_protocol
- Add name_lowercase field to agent JSON for filename generation
- Add WRITE YOUR OUTPUT section to agent prompt template
- Update Judge instructions with mkdir + Read tool workflow
- Add output_dir to returned protocol JSON
- New test: test_build_judge_protocol_output_paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-26 12:37:39 -05:00
parent 0fea499957
commit 37de7759b5
2 changed files with 85 additions and 13 deletions

View file

@ -1,7 +1,7 @@
--- ---
name: alignment-expert name: alignment-expert
description: Expert agent for alignment dialogues. Produces focused perspectives with inline markers. Use when orchestrating multi-expert alignment dialogues via blue_dialogue_create. description: Expert agent for alignment dialogues. Produces focused perspectives with inline markers. Use when orchestrating multi-expert alignment dialogues via blue_dialogue_create.
tools: Read, Grep, Glob tools: Read, Grep, Glob, Write
model: sonnet model: sonnet
--- ---

View file

@ -346,7 +346,8 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
// Generate file path with ISO 8601 timestamp prefix (RFC 0031) // Generate file path with ISO 8601 timestamp prefix (RFC 0031)
let timestamp = blue_core::utc_timestamp(); let timestamp = blue_core::utc_timestamp();
let file_name = format!("{}-{}.dialogue.recorded.md", timestamp, title_to_slug(title)); let slug = title_to_slug(title);
let file_name = format!("{}-{}.dialogue.recorded.md", timestamp, slug);
let file_path = PathBuf::from("dialogues").join(&file_name); let file_path = PathBuf::from("dialogues").join(&file_name);
let docs_path = state.home.docs_path.clone(); let docs_path = state.home.docs_path.clone();
let dialogue_path = docs_path.join(&file_path); let dialogue_path = docs_path.join(&file_path);
@ -410,11 +411,18 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
// Build response — RFC 0023: inject protocol as prose in message field // Build response — RFC 0023: inject protocol as prose in message field
let (message, judge_protocol) = if let Some(ref agents) = pastry_agents { let (message, judge_protocol) = if let Some(ref agents) = pastry_agents {
// RFC 0029: Create output directory for file-based subagent output
let output_dir = format!("/tmp/blue-dialogue/{}", slug);
fs::create_dir_all(&output_dir).map_err(|e| {
ServerError::CommandFailed(format!("Failed to create output dir {}: {}", output_dir, e))
})?;
let protocol = build_judge_protocol( let protocol = build_judge_protocol(
agents, agents,
&dialogue_path.display().to_string(), &dialogue_path.display().to_string(),
model, model,
&sources, &sources,
&output_dir,
); );
// Extract instructions as prose so Claude reads them directly // Extract instructions as prose so Claude reads them directly
let instructions = protocol["instructions"].as_str().unwrap_or(""); let instructions = protocol["instructions"].as_str().unwrap_or("");
@ -425,7 +433,7 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
## JUDGE PROTOCOL FOLLOW THESE INSTRUCTIONS\n\n\ ## JUDGE PROTOCOL FOLLOW THESE INSTRUCTIONS\n\n\
{instructions}\n\n\ {instructions}\n\n\
## AGENT PROMPT TEMPLATE\n\n\ ## AGENT PROMPT TEMPLATE\n\n\
Substitute {{{{NAME}}}}, {{{{EMOJI}}}}, {{{{ROLE}}}} for each agent:\n\n\ Substitute {{{{NAME}}}}, {{{{EMOJI}}}}, {{{{ROLE}}}}, {{{{OUTPUT_FILE}}}} for each agent:\n\n\
{template}", {template}",
title = title, title = title,
file = dialogue_path.display(), file = dialogue_path.display(),
@ -899,6 +907,7 @@ pub fn build_judge_protocol(
dialogue_file: &str, dialogue_file: &str,
model: &str, model: &str,
sources: &[String], sources: &[String],
output_dir: &str,
) -> Value { ) -> Value {
let agent_list: Vec<Value> = agents let agent_list: Vec<Value> = agents
.iter() .iter()
@ -909,6 +918,7 @@ pub fn build_judge_protocol(
"emoji": a.emoji, "emoji": a.emoji,
"tier": a.tier, "tier": a.tier,
"relevance": a.relevance, "relevance": a.relevance,
"name_lowercase": a.name.to_lowercase(),
}) })
}) })
.collect(); .collect();
@ -953,7 +963,14 @@ OUTPUT LIMIT — THIS IS MANDATORY:
- If the topic needs more depth, save it for the next round - If the topic needs more depth, save it for the next round
- Aim for under 2000 characters total - Aim for under 2000 characters total
- DO NOT write essays, literature reviews, or exhaustive analyses - DO NOT write essays, literature reviews, or exhaustive analyses
- Be pointed and specific, not comprehensive{source_read_instructions}"## - Be pointed and specific, not comprehensive
WRITE YOUR OUTPUT THIS IS MANDATORY:
Use the Write tool to write your COMPLETE response to:
{{{{OUTPUT_FILE}}}}
Write your full perspective to this file. This is your primary output mechanism.
After writing the file, you may stop.{source_read_instructions}"##
); );
let instructions = format!( let instructions = format!(
@ -961,6 +978,9 @@ OUTPUT LIMIT — THIS IS MANDATORY:
=== HOW TO SPAWN EXPERT SUBAGENTS === === HOW TO SPAWN EXPERT SUBAGENTS ===
BEFORE spawning each round, create the round directory:
Use Bash: mkdir -p {output_dir}/round-N
Spawn ALL {agent_count} experts in a SINGLE message with {agent_count} Task tool calls. Spawn ALL {agent_count} experts in a SINGLE message with {agent_count} Task tool calls.
Multiple Task calls in one message run as parallel subagents. Multiple Task calls in one message run as parallel subagents.
@ -968,28 +988,32 @@ Each Task call:
- subagent_type: "alignment-expert" - subagent_type: "alignment-expert"
- description: "🧁 Muffin expert deliberation" - description: "🧁 Muffin expert deliberation"
- max_turns: 10 - max_turns: 10
- prompt: the AGENT PROMPT TEMPLATE with {{{{NAME}}}}, {{{{EMOJI}}}}, {{{{ROLE}}}} substituted - prompt: the AGENT PROMPT TEMPLATE with {{{{NAME}}}}, {{{{EMOJI}}}}, {{{{ROLE}}}}, {{{{OUTPUT_FILE}}}} substituted
- {{{{OUTPUT_FILE}}}} {output_dir}/round-N/AGENT_NAME_LOWERCASE.md
- run_in_background: true - run_in_background: true
All {agent_count} results return when complete. Read each agent's output from the results. All {agent_count} results return when complete.
=== ROUND WORKFLOW === === ROUND WORKFLOW ===
1. SPAWN: One message, {agent_count} Task calls (parallel subagents) 1. MKDIR: Create round directory via Bash: mkdir -p {output_dir}/round-N
2. READ: Each subagent returns its output directly 2. SPAWN: One message, {agent_count} Task calls (parallel subagents)
3. SCORE: ALIGNMENT = Wisdom + Consistency + Truth + Relationships (UNBOUNDED) 3. READ: After all agents complete, read each file with Read tool
If a file is missing, fall back to blue_extract_dialogue(task_id=TASK_ID)
4. SCORE: ALIGNMENT = Wisdom + Consistency + Truth + Relationships (UNBOUNDED)
- Score ONLY AFTER reading output NEVER pre-fill scores - Score ONLY AFTER reading output NEVER pre-fill scores
4. UPDATE {dialogue_file}: 5. UPDATE {dialogue_file}:
- Agent responses under the correct Round section - Agent responses under the correct Round section
- Scoreboard with scores from this round - Scoreboard with scores from this round
- Perspectives Inventory (one row per [PERSPECTIVE Pnn:] marker) - Perspectives Inventory (one row per [PERSPECTIVE Pnn:] marker)
- Tensions Tracker (one row per [TENSION Tn:] marker) - Tensions Tracker (one row per [TENSION Tn:] marker)
5. CONVERGE: If velocity approaches 0 OR all tensions resolved declare convergence 6. CONVERGE: If velocity approaches 0 OR all tensions resolved declare convergence
Otherwise, start next round with updated prompt including prior perspectives Otherwise, start next round with updated prompt including prior perspectives
Maximum 5 rounds (safety valve) Maximum 5 rounds (safety valve)
6. SAVE via blue_dialogue_save 7. SAVE via blue_dialogue_save
AGENTS: {agent_names} AGENTS: {agent_names}
OUTPUT DIR: {output_dir}
FORMAT RULES MANDATORY: FORMAT RULES MANDATORY:
- ALWAYS prefix agent names with their emoji (🧁 Muffin) not bare name (Muffin) - ALWAYS prefix agent names with their emoji (🧁 Muffin) not bare name (Muffin)
@ -1001,6 +1025,7 @@ FORMAT RULES — MANDATORY:
IMPORTANT: Each agent has NO memory of other agents. They see only the topic and their role."##, IMPORTANT: Each agent has NO memory of other agents. They see only the topic and their role."##,
agent_count = agents.len(), agent_count = agents.len(),
dialogue_file = dialogue_file, dialogue_file = dialogue_file,
output_dir = output_dir,
agent_names = agents agent_names = agents
.iter() .iter()
.map(|a| format!("{} {} ({})", a.emoji, a.name, a.role)) .map(|a| format!("{} {} ({})", a.emoji, a.name, a.role))
@ -1015,6 +1040,7 @@ IMPORTANT: Each agent has NO memory of other agents. They see only the topic and
"dialogue_file": dialogue_file, "dialogue_file": dialogue_file,
"model": model, "model": model,
"sources": sources, "sources": sources,
"output_dir": output_dir,
"convergence": { "convergence": {
"max_rounds": 5, "max_rounds": 5,
"velocity_threshold": 0.1, "velocity_threshold": 0.1,
@ -1152,6 +1178,7 @@ mod tests {
"/tmp/test.dialogue.md", "/tmp/test.dialogue.md",
"sonnet", "sonnet",
&["/tmp/source.rs".to_string()], &["/tmp/source.rs".to_string()],
"/tmp/blue-dialogue/system-design",
); );
// Must have instructions // Must have instructions
@ -1161,6 +1188,10 @@ mod tests {
assert!(instructions.contains("ALIGNMENT")); assert!(instructions.contains("ALIGNMENT"));
assert!(instructions.contains("Wisdom")); assert!(instructions.contains("Wisdom"));
assert!(instructions.contains("convergence")); assert!(instructions.contains("convergence"));
// RFC 0029: file-based output instructions
assert!(instructions.contains("/tmp/blue-dialogue/system-design"));
assert!(instructions.contains("mkdir"));
assert!(instructions.contains("Read tool"));
// Must have agent prompt template with Read tool reference // Must have agent prompt template with Read tool reference
let template = protocol.get("agent_prompt_template").unwrap().as_str().unwrap(); let template = protocol.get("agent_prompt_template").unwrap().as_str().unwrap();
@ -1169,11 +1200,15 @@ mod tests {
assert!(template.contains("PERSPECTIVE")); assert!(template.contains("PERSPECTIVE"));
assert!(template.contains("TENSION")); assert!(template.contains("TENSION"));
assert!(template.contains("Read tool")); assert!(template.contains("Read tool"));
// RFC 0029: WRITE YOUR OUTPUT section
assert!(template.contains("WRITE YOUR OUTPUT"));
assert!(template.contains("{{OUTPUT_FILE}}"));
// Must have agents array // Must have agents array with name_lowercase
let agents_arr = protocol.get("agents").unwrap().as_array().unwrap(); let agents_arr = protocol.get("agents").unwrap().as_array().unwrap();
assert_eq!(agents_arr.len(), 3); assert_eq!(agents_arr.len(), 3);
assert_eq!(agents_arr[0]["name"], "Muffin"); assert_eq!(agents_arr[0]["name"], "Muffin");
assert_eq!(agents_arr[0]["name_lowercase"], "muffin");
// Must have model // Must have model
assert_eq!(protocol["model"], "sonnet"); assert_eq!(protocol["model"], "sonnet");
@ -1183,6 +1218,9 @@ mod tests {
assert_eq!(sources.len(), 1); assert_eq!(sources.len(), 1);
assert_eq!(sources[0], "/tmp/source.rs"); assert_eq!(sources[0], "/tmp/source.rs");
// Must have output_dir
assert_eq!(protocol["output_dir"], "/tmp/blue-dialogue/system-design");
// Must have convergence params // Must have convergence params
assert_eq!(protocol["convergence"]["max_rounds"], 5); assert_eq!(protocol["convergence"]["max_rounds"], 5);
assert!(protocol["convergence"]["tension_resolution_gate"].as_bool().unwrap()); assert!(protocol["convergence"]["tension_resolution_gate"].as_bool().unwrap());
@ -1196,10 +1234,44 @@ mod tests {
"/tmp/test.dialogue.md", "/tmp/test.dialogue.md",
"haiku", "haiku",
&[], &[],
"/tmp/blue-dialogue/quick-topic",
); );
// Template should NOT contain grounding instructions when no sources // Template should NOT contain grounding instructions when no sources
let template = protocol.get("agent_prompt_template").unwrap().as_str().unwrap(); let template = protocol.get("agent_prompt_template").unwrap().as_str().unwrap();
assert!(!template.contains("GROUNDING")); assert!(!template.contains("GROUNDING"));
} }
#[test]
fn test_build_judge_protocol_output_paths() {
let agents = assign_pastry_agents(4, "api design");
let protocol = build_judge_protocol(
&agents,
"/tmp/test.dialogue.md",
"sonnet",
&[],
"/tmp/blue-dialogue/api-design",
);
// output_dir in JSON
assert_eq!(protocol["output_dir"], "/tmp/blue-dialogue/api-design");
// All agents have name_lowercase
let agents_arr = protocol["agents"].as_array().unwrap();
assert_eq!(agents_arr[0]["name_lowercase"], "muffin");
assert_eq!(agents_arr[1]["name_lowercase"], "cupcake");
assert_eq!(agents_arr[2]["name_lowercase"], "scone");
assert_eq!(agents_arr[3]["name_lowercase"], "eclair");
// WRITE YOUR OUTPUT in template
let template = protocol["agent_prompt_template"].as_str().unwrap();
assert!(template.contains("WRITE YOUR OUTPUT"));
assert!(template.contains("{{OUTPUT_FILE}}"));
assert!(template.contains("Write tool"));
// output_dir referenced in instructions
let instructions = protocol["instructions"].as_str().unwrap();
assert!(instructions.contains("/tmp/blue-dialogue/api-design"));
assert!(instructions.contains("OUTPUT DIR:"));
}
} }