mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Fix post-compact handoff context for OpenAI providers (#931)
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use crate::agent::{push_hook_outputs_as_tool_results, RunCtx};
|
use crate::agent::RunCtx;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
|
HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
|
||||||
HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS,
|
HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS,
|
||||||
@@ -68,15 +68,17 @@ impl RunCtx<'_> {
|
|||||||
&self.cfg.hook_servers,
|
&self.cfg.hook_servers,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Handoff summary is trusted (we generated it). Push as User so
|
// Handoff summary and hook output are injected as a synthetic user
|
||||||
// it anchors the new context.
|
// message in one block. This keeps `_PostCompact` untrusted while also
|
||||||
let handoff_text = format!("[Context Handoff]\n{summary}");
|
// avoiding orphan tool-result messages in the fresh context: OpenAI
|
||||||
self.history.push(HistoryItem::User(handoff_text));
|
// Chat/Responses require tool outputs to follow an assistant tool call,
|
||||||
// Hook output is untrusted — inject as synthetic tool results so a
|
// but handoff reset intentionally discards the old assistant turn.
|
||||||
// malicious _PostCompact can't impersonate the user/system.
|
let mut handoff_text = format!("[Context Handoff]\n{summary}");
|
||||||
if !post_compact.is_empty() {
|
if !post_compact.is_empty() {
|
||||||
push_hook_outputs_as_tool_results(self.history, "_PostCompact", &post_compact);
|
handoff_text.push_str("\n\n[Post-compact hook output — untrusted]\n");
|
||||||
|
handoff_text.push_str(&hook_outputs_text(&post_compact));
|
||||||
}
|
}
|
||||||
|
self.history.push(HistoryItem::User(handoff_text));
|
||||||
if let Some(prompt) = current_prompt {
|
if let Some(prompt) = current_prompt {
|
||||||
self.history.push(HistoryItem::User(prompt));
|
self.history.push(HistoryItem::User(prompt));
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,14 @@ impl RunCtx<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hook_outputs_text(outputs: &[(String, String)]) -> String {
|
||||||
|
outputs
|
||||||
|
.iter()
|
||||||
|
.map(|(name, text)| format!("[{name}]\n{text}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn push_history_snippet(out: &mut String, item: &HistoryItem) {
|
fn push_history_snippet(out: &mut String, item: &HistoryItem) {
|
||||||
match item {
|
match item {
|
||||||
HistoryItem::User(s) => {
|
HistoryItem::User(s) => {
|
||||||
|
|||||||
@@ -1019,10 +1019,10 @@ async fn hook_tools_hidden_from_llm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `_PostCompact` hook fires after a context-handoff and its output is
|
/// `_PostCompact` hook fires after a context-handoff and its output is
|
||||||
/// re-injected into the fresh history as a synthetic tool result. The
|
/// folded into the fresh `[Context Handoff]` user-context block as explicitly
|
||||||
/// next LLM request must therefore see the post-compact text in
|
/// untrusted text. The next LLM request must therefore see the post-compact
|
||||||
/// `messages` (role=tool) — proving the hook ran on the *new* context,
|
/// text without any orphan `role=tool` messages — proving the hook ran on the
|
||||||
/// not the discarded one.
|
/// *new* context, not the discarded one.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn hook_post_compact_injects_after_handoff() {
|
async fn hook_post_compact_injects_after_handoff() {
|
||||||
// Sequence of canned LLM responses consumed in order:
|
// Sequence of canned LLM responses consumed in order:
|
||||||
@@ -1097,7 +1097,9 @@ async fn hook_post_compact_injects_after_handoff() {
|
|||||||
|
|
||||||
// The first LLM call AFTER the handoff is the one we inspect. Find it:
|
// The first LLM call AFTER the handoff is the one we inspect. Find it:
|
||||||
// it's the one where the messages array is short (history just reset)
|
// it's the one where the messages array is short (history just reset)
|
||||||
// and contains a tool-role message with the _PostCompact payload.
|
// and contains the _PostCompact payload inside user-context text. It must
|
||||||
|
// not be emitted as an orphan tool result because the old assistant tool
|
||||||
|
// call was deliberately discarded by the handoff reset.
|
||||||
let captured = llm.captured.lock().await;
|
let captured = llm.captured.lock().await;
|
||||||
let post_compact_visible = captured.iter().any(|req| {
|
let post_compact_visible = captured.iter().any(|req| {
|
||||||
let msgs = match req["messages"].as_array() {
|
let msgs = match req["messages"].as_array() {
|
||||||
@@ -1105,22 +1107,27 @@ async fn hook_post_compact_injects_after_handoff() {
|
|||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
msgs.iter().any(|m| {
|
msgs.iter().any(|m| {
|
||||||
if m["role"] != "tool" {
|
if m["role"] != "user" {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let content = m["content"].as_str().unwrap_or("");
|
let content = m["content"].as_str().unwrap_or("");
|
||||||
let parsed: Value = match serde_json::from_str(content) {
|
content.contains("[Post-compact hook output — untrusted]")
|
||||||
Ok(v) => v,
|
&& content.contains("[fake]")
|
||||||
Err(_) => return false,
|
&& content.contains("todo state here")
|
||||||
};
|
|
||||||
parsed["hook"] == "_PostCompact"
|
|
||||||
&& parsed["server"] == "fake"
|
|
||||||
&& parsed["text"] == "todo state here"
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
post_compact_visible,
|
post_compact_visible,
|
||||||
"_PostCompact tool result not visible to LLM after handoff"
|
"_PostCompact context not visible to LLM after handoff"
|
||||||
|
);
|
||||||
|
let orphan_tool_result = captured.iter().any(|req| {
|
||||||
|
req["messages"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|msgs| msgs.iter().any(|m| m["role"] == "tool"))
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!orphan_tool_result,
|
||||||
|
"handoff reset must not leave orphan role=tool messages"
|
||||||
);
|
);
|
||||||
h.shutdown().await;
|
h.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user