mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
fix(acp): emit per-section prompt blocks so observer counts every section (#1122)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -1032,7 +1032,7 @@ pub(crate) fn base_section(base_prompt: &str) -> String {
|
||||
format!("[Base]\n{}", base_prompt.trim_end())
|
||||
}
|
||||
|
||||
/// Format a [`FlushBatch`] into a prompt string for the agent.
|
||||
/// Format a [`FlushBatch`] into the per-section prompt blocks for the agent.
|
||||
///
|
||||
/// Produces a stable prompt with these sections (in order):
|
||||
/// 0. `[Base]` — base prompt (only for legacy agents without systemPrompt support)
|
||||
@@ -1042,9 +1042,17 @@ pub(crate) fn base_section(base_prompt: &str) -> String {
|
||||
/// 4. `[Thread Context]` or `[Conversation Context]` — if fetched
|
||||
/// 5. `[Event]` / `[Buzz events]` — the triggering event(s)
|
||||
///
|
||||
/// Each section is returned as its own block rather than one joined string so
|
||||
/// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides
|
||||
/// the body of an oversized section in place, leaving every `[Header]` line at
|
||||
/// the head of its own leaf — so the desktop "Prompt context" panel always
|
||||
/// counts every section. The receiving agent reconstructs the full prompt by
|
||||
/// joining the blocks (legacy agents see a single `\n` between sections rather
|
||||
/// than a blank line; sections self-delimit with their `[Header]` line).
|
||||
///
|
||||
/// For agents with `protocol_version >= 2`, base_prompt and system_prompt are
|
||||
/// delivered via the system role in `session/new` and omitted from this message.
|
||||
pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String {
|
||||
pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<String> {
|
||||
// Scope is always derived from the LAST event in the batch — that's the
|
||||
// one the agent is responding to. Thread/DM context is supplementary info
|
||||
// included alongside, not a scope override. This prevents mixed batches
|
||||
@@ -1053,7 +1061,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String
|
||||
Some(e) => e,
|
||||
None => {
|
||||
tracing::error!("format_prompt called with empty batch — returning empty prompt");
|
||||
return String::new();
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let thread_tags = parse_thread_tags(&last_event.event);
|
||||
@@ -1170,7 +1178,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String
|
||||
);
|
||||
}
|
||||
|
||||
sections.join("\n\n")
|
||||
sections
|
||||
}
|
||||
|
||||
// ─── Unit Tests ──────────────────────────────────────────────────────────────
|
||||
@@ -1394,7 +1402,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
|
||||
// Should contain [Context] section before the event.
|
||||
assert!(prompt.contains("[Context]"));
|
||||
@@ -1490,7 +1498,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
|
||||
assert!(prompt.contains("[Context]"));
|
||||
assert!(prompt.contains("[Buzz events — 3 events]"));
|
||||
@@ -1519,7 +1527,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
// system_prompt and base_prompt are delivered via session/new system role,
|
||||
// so they must NOT appear in the user message.
|
||||
assert!(!prompt.contains("[System]"));
|
||||
@@ -1549,7 +1557,8 @@ mod tests {
|
||||
agent_core: Some(core),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(
|
||||
prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"),
|
||||
"expected core block first, then [Context]; got: {prompt}"
|
||||
@@ -1579,7 +1588,8 @@ mod tests {
|
||||
has_system_prompt_support: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(
|
||||
!prompt.contains("[Agent Memory — core]"),
|
||||
"modern agents must not get core in the user message; got: {prompt}"
|
||||
@@ -1607,7 +1617,8 @@ mod tests {
|
||||
agent_core: Some(core),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"));
|
||||
}
|
||||
|
||||
@@ -1630,7 +1641,7 @@ mod tests {
|
||||
|
||||
// format_prompt no longer accepts or emits base_prompt/system_prompt.
|
||||
// They are delivered via session/new system role instead.
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(!prompt.contains("[Base]"));
|
||||
assert!(!prompt.contains("[System]"));
|
||||
assert!(prompt.starts_with("[Context]"));
|
||||
@@ -1663,7 +1674,8 @@ mod tests {
|
||||
agent_core: Some(core),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
// Both sections must be present
|
||||
assert!(
|
||||
@@ -1717,7 +1729,8 @@ mod tests {
|
||||
system_prompt: Some("test system prompt"),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
// Neither section should appear — they are delivered via session/new
|
||||
assert!(
|
||||
@@ -1763,7 +1776,8 @@ mod tests {
|
||||
conversation_context: Some(&ctx),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
// Verify section ordering: [Agent Memory] < [Context] < [Thread Context]
|
||||
let core_pos = prompt
|
||||
@@ -2270,7 +2284,8 @@ mod tests {
|
||||
channel_info: Some(&ci),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(prompt.contains("engineering (#"));
|
||||
assert!(prompt.contains("Scope: channel"));
|
||||
}
|
||||
@@ -2299,7 +2314,8 @@ mod tests {
|
||||
channel_info: Some(&ci),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(prompt.contains("Scope: dm"));
|
||||
}
|
||||
|
||||
@@ -2325,7 +2341,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(prompt.contains("Scope: thread"));
|
||||
assert!(prompt.contains("Thread root: root123"));
|
||||
}
|
||||
@@ -2374,7 +2390,8 @@ mod tests {
|
||||
conversation_context: Some(&ctx),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]"));
|
||||
assert!(prompt.contains("Let's refactor auth"));
|
||||
assert!(prompt.contains("Thread context included below"));
|
||||
@@ -2414,7 +2431,8 @@ mod tests {
|
||||
conversation_context: Some(&ctx),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(prompt.contains("Scope: dm"));
|
||||
assert!(prompt.contains("[Conversation Context (1 of 1 messages)]"));
|
||||
assert!(prompt.contains("Can you deploy?"));
|
||||
@@ -2473,7 +2491,8 @@ mod tests {
|
||||
profile_lookup: Some(&profiles),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
assert!(prompt.contains("From: Wes (npub:"));
|
||||
assert!(prompt.contains(
|
||||
@@ -2575,7 +2594,8 @@ mod tests {
|
||||
conversation_context: Some(&ctx),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
// Scope should be "dm", not "thread".
|
||||
assert!(
|
||||
prompt.contains("Scope: dm"),
|
||||
@@ -2620,7 +2640,8 @@ mod tests {
|
||||
channel_info: Some(&ci),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(prompt.contains("Scope: dm"));
|
||||
assert!(
|
||||
prompt.contains("buzz messages get"),
|
||||
@@ -2647,7 +2668,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("Event ID: {event_id}")),
|
||||
"prompt should contain the event ID"
|
||||
@@ -2670,7 +2691,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("From: {npub} (hex: {hex})")),
|
||||
"prompt should contain both npub and hex"
|
||||
@@ -2692,7 +2713,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains("Tags:"),
|
||||
"tags should always be included, even for stream messages"
|
||||
@@ -3016,7 +3037,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"channel thread reply should include reply instruction with triggering event ID"
|
||||
@@ -3056,7 +3077,8 @@ mod tests {
|
||||
channel_info: Some(&ci),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
"DM thread reply should include reply instruction"
|
||||
@@ -3077,7 +3099,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
!prompt.contains("--reply-to"),
|
||||
"top-level message should NOT include reply instruction"
|
||||
@@ -3108,7 +3130,8 @@ mod tests {
|
||||
channel_info: Some(&ci),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
)
|
||||
.join("\n\n");
|
||||
assert!(
|
||||
!prompt.contains("--reply-to"),
|
||||
"DM non-reply should NOT include reply instruction"
|
||||
@@ -3138,7 +3161,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
// The instruction should use the triggering event's own ID — not root or parent.
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {event_id}")),
|
||||
@@ -3181,7 +3204,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
prompt.contains(&format!("--reply-to {threaded_id}")),
|
||||
"batched prompt should use last (threaded) event's ID"
|
||||
@@ -3214,7 +3237,7 @@ mod tests {
|
||||
cancelled_events: vec![],
|
||||
};
|
||||
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
|
||||
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
|
||||
assert!(
|
||||
!prompt.contains("--reply-to"),
|
||||
"batched prompt where last event is top-level should NOT include reply instruction"
|
||||
|
||||
Reference in New Issue
Block a user