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
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
---

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)
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 docs_path = state.home.docs_path.clone();
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
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(
agents,
&dialogue_path.display().to_string(),
model,
&sources,
&output_dir,
);
// Extract instructions as prose so Claude reads them directly
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\
{instructions}\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}",
title = title,
file = dialogue_path.display(),
@ -899,6 +907,7 @@ pub fn build_judge_protocol(
dialogue_file: &str,
model: &str,
sources: &[String],
output_dir: &str,
) -> Value {
let agent_list: Vec<Value> = agents
.iter()
@ -909,6 +918,7 @@ pub fn build_judge_protocol(
"emoji": a.emoji,
"tier": a.tier,
"relevance": a.relevance,
"name_lowercase": a.name.to_lowercase(),
})
})
.collect();
@ -953,7 +963,14 @@ OUTPUT LIMIT — THIS IS MANDATORY:
- If the topic needs more depth, save it for the next round
- Aim for under 2000 characters total
- 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!(
@ -961,6 +978,9 @@ OUTPUT LIMIT — THIS IS MANDATORY:
=== 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.
Multiple Task calls in one message run as parallel subagents.
@ -968,28 +988,32 @@ Each Task call:
- subagent_type: "alignment-expert"
- description: "🧁 Muffin expert deliberation"
- 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
All {agent_count} results return when complete. Read each agent's output from the results.
All {agent_count} results return when complete.
=== ROUND WORKFLOW ===
1. SPAWN: One message, {agent_count} Task calls (parallel subagents)
2. READ: Each subagent returns its output directly
3. SCORE: ALIGNMENT = Wisdom + Consistency + Truth + Relationships (UNBOUNDED)
1. MKDIR: Create round directory via Bash: mkdir -p {output_dir}/round-N
2. SPAWN: One message, {agent_count} Task calls (parallel subagents)
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
4. UPDATE {dialogue_file}:
5. UPDATE {dialogue_file}:
- Agent responses under the correct Round section
- Scoreboard with scores from this round
- Perspectives Inventory (one row per [PERSPECTIVE Pnn:] 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
Maximum 5 rounds (safety valve)
6. SAVE via blue_dialogue_save
7. SAVE via blue_dialogue_save
AGENTS: {agent_names}
OUTPUT DIR: {output_dir}
FORMAT RULES MANDATORY:
- 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."##,
agent_count = agents.len(),
dialogue_file = dialogue_file,
output_dir = output_dir,
agent_names = agents
.iter()
.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,
"model": model,
"sources": sources,
"output_dir": output_dir,
"convergence": {
"max_rounds": 5,
"velocity_threshold": 0.1,
@ -1152,6 +1178,7 @@ mod tests {
"/tmp/test.dialogue.md",
"sonnet",
&["/tmp/source.rs".to_string()],
"/tmp/blue-dialogue/system-design",
);
// Must have instructions
@ -1161,6 +1188,10 @@ mod tests {
assert!(instructions.contains("ALIGNMENT"));
assert!(instructions.contains("Wisdom"));
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
let template = protocol.get("agent_prompt_template").unwrap().as_str().unwrap();
@ -1169,11 +1200,15 @@ mod tests {
assert!(template.contains("PERSPECTIVE"));
assert!(template.contains("TENSION"));
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();
assert_eq!(agents_arr.len(), 3);
assert_eq!(agents_arr[0]["name"], "Muffin");
assert_eq!(agents_arr[0]["name_lowercase"], "muffin");
// Must have model
assert_eq!(protocol["model"], "sonnet");
@ -1183,6 +1218,9 @@ mod tests {
assert_eq!(sources.len(), 1);
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
assert_eq!(protocol["convergence"]["max_rounds"], 5);
assert!(protocol["convergence"]["tension_resolution_gate"].as_bool().unwrap());
@ -1196,10 +1234,44 @@ mod tests {
"/tmp/test.dialogue.md",
"haiku",
&[],
"/tmp/blue-dialogue/quick-topic",
);
// Template should NOT contain grounding instructions when no sources
let template = protocol.get("agent_prompt_template").unwrap().as_str().unwrap();
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:"));
}
}