mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
fix(buzz-acp): anchor replies for natively-steered events
When a message reaches an agent mid-turn via the goose-native steer path (try_native_steer), the steer body carried only the framing header, the event block, and the closing note — no reply-anchor instruction. The agent's only in-context --reply-to guidance was the stale anchor from its original prompt, so it replied into the old thread even when the steered message was top-level or belonged to a different thread. Observed in the wild: a top-level 'give me a status report' mention was answered inside the agent's old task thread. Every other delivery path already anchors: fresh top-level turns get append_new_thread_reply_instruction, fresh thread turns get append_reply_instruction, and the cancel+merge fallback re-derives the anchor in format_prompt from the last event. Native steer was the only path with no anchor at all. Fix: native_steer_reply_instruction derives the anchor from the steered event's own thread tags — its thread root when threaded, the event itself when top-level — mirroring resolve_reply_anchor, and is appended to the steer body after the event block. No profile lookup is available on this synchronous path, so no agent-to-agent exemption is applied; that matches turn_is_human_facing's documented fail-open policy (unknown identity is treated as human). The instruction is scoped to 'replies to this new message' so in-progress work keeps its original anchor. Tests pin both derivations (top-level anchors to the event, threaded anchors to the steered event's root, never the event id). Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
parent
2eebeb54bc
commit
885585188f
@@ -2504,13 +2504,20 @@ fn try_native_steer(
|
||||
// steering (which is to inject only what's new).
|
||||
let (header, closing) = queue::native_steer_framing();
|
||||
let event_id_hex = event.id.to_hex();
|
||||
// Reply anchor for the steered event: without this, the agent's only
|
||||
// in-context reply instruction is the (stale) anchor from its original
|
||||
// prompt, so it replies into the old thread even when the new message is
|
||||
// top-level or in a different thread. See `native_steer_reply_instruction`.
|
||||
let reply_instruction = queue::native_steer_reply_instruction(&event);
|
||||
let be = queue::BatchEvent {
|
||||
event,
|
||||
prompt_tag: prompt_tag.clone(),
|
||||
received_at: std::time::Instant::now(),
|
||||
};
|
||||
let event_block = queue::format_event_block(channel_id, None, &be, None);
|
||||
let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}");
|
||||
let body = format!(
|
||||
"{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n{reply_instruction}\n\n{closing}"
|
||||
);
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::<pool::SteerAck>();
|
||||
let request = pool::SteerRequest {
|
||||
|
||||
@@ -1545,6 +1545,47 @@ pub(crate) fn native_steer_framing() -> (&'static str, &'static str) {
|
||||
(framing.new_header_single, framing.closing_note)
|
||||
}
|
||||
|
||||
/// Build the reply-anchor instruction for a natively-steered event.
|
||||
///
|
||||
/// A native steer injects a *new* event into a live turn, but the only reply
|
||||
/// instruction in the agent's context is the (now possibly stale) anchor from
|
||||
/// the original prompt. Without a fresh anchor the agent replies into the old
|
||||
/// thread even when the steered message is top-level or belongs to a different
|
||||
/// thread — the exact confusion this section prevents.
|
||||
///
|
||||
/// Anchor derivation mirrors [`resolve_reply_anchor`]:
|
||||
/// - steered event in a thread → that thread's ROOT
|
||||
/// - steered event top-level → the event itself (it becomes the new root)
|
||||
///
|
||||
/// Unlike the batch path we have no profile lookup here (the steer body is
|
||||
/// built synchronously in the main loop), so no agent↔agent exemption is
|
||||
/// applied. That matches the documented fail-open policy in
|
||||
/// [`turn_is_human_facing`]: unknown identity is treated as human, because
|
||||
/// humans must not lose thread visibility to a misclassification. The
|
||||
/// instruction text is scoped to "replies to this new message", so an agent
|
||||
/// weaving the steer into unrelated in-progress work keeps its original
|
||||
/// anchor for that work.
|
||||
pub(crate) fn native_steer_reply_instruction(event: &Event) -> String {
|
||||
let tags = parse_thread_tags(event);
|
||||
let event_id = event.id.to_hex();
|
||||
let mut s = String::new();
|
||||
match tags.root_event_id {
|
||||
Some(root) => s.push_str(&format!(
|
||||
"\nIMPORTANT: For replies to this new message, use `--reply-to {root}` \
|
||||
on `buzz messages send` — it belongs to that thread, which may differ \
|
||||
from the thread you were working in. Do not reuse the reply anchor \
|
||||
from your original prompt for replies to this message."
|
||||
)),
|
||||
None => s.push_str(&format!(
|
||||
"\nIMPORTANT: This new message is top-level (not in your current \
|
||||
thread). For replies to it, use `--reply-to {event_id}` on \
|
||||
`buzz messages send` — it becomes the root of a new thread. Do NOT \
|
||||
reply to it inside the thread you were working in."
|
||||
)),
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3394,6 +3435,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_steer_reply_instruction_top_level_anchors_to_event() {
|
||||
// A steered event with no thread tags is top-level: replies to it must
|
||||
// anchor to the event itself (the new thread root), not the stale
|
||||
// anchor from the original prompt.
|
||||
let event = make_event("give me a status report");
|
||||
let event_id = event.id.to_hex();
|
||||
let instruction = native_steer_reply_instruction(&event);
|
||||
assert!(
|
||||
instruction.contains(&format!("--reply-to {event_id}")),
|
||||
"top-level steer must anchor replies to the steered event: {instruction}"
|
||||
);
|
||||
assert!(
|
||||
instruction.contains("top-level"),
|
||||
"instruction should flag the message as top-level: {instruction}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_steer_reply_instruction_threaded_anchors_to_root() {
|
||||
// A steered event carrying thread tags anchors replies to ITS root —
|
||||
// which may differ from the thread the in-flight turn was working in.
|
||||
let root = "a".repeat(64);
|
||||
let parent = "b".repeat(64);
|
||||
let event = make_event_with_tags(
|
||||
"steer into another thread",
|
||||
vec![
|
||||
vec!["e".into(), root.clone(), "".into(), "root".into()],
|
||||
vec!["e".into(), parent, "".into(), "reply".into()],
|
||||
],
|
||||
);
|
||||
let event_id = event.id.to_hex();
|
||||
let instruction = native_steer_reply_instruction(&event);
|
||||
assert!(
|
||||
instruction.contains(&format!("--reply-to {root}")),
|
||||
"threaded steer must anchor replies to the steered event's thread root: {instruction}"
|
||||
);
|
||||
assert!(
|
||||
!instruction.contains(&format!("--reply-to {event_id}")),
|
||||
"threaded steer must not anchor to the event itself: {instruction}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drain_channel_removes_pending_events() {
|
||||
let mut q = EventQueue::new(DedupMode::Queue);
|
||||
|
||||
Reference in New Issue
Block a user