mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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:
co-authored by
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent
98aa535935
commit
fe14daa5d6
@@ -1,4 +1,4 @@
|
||||
use crate::agent::{push_hook_outputs_as_tool_results, RunCtx};
|
||||
use crate::agent::RunCtx;
|
||||
use crate::config::{
|
||||
HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
|
||||
HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS,
|
||||
@@ -68,15 +68,17 @@ impl RunCtx<'_> {
|
||||
&self.cfg.hook_servers,
|
||||
)
|
||||
.await;
|
||||
// Handoff summary is trusted (we generated it). Push as User so
|
||||
// it anchors the new context.
|
||||
let handoff_text = format!("[Context Handoff]\n{summary}");
|
||||
self.history.push(HistoryItem::User(handoff_text));
|
||||
// Hook output is untrusted — inject as synthetic tool results so a
|
||||
// malicious _PostCompact can't impersonate the user/system.
|
||||
// Handoff summary and hook output are injected as a synthetic user
|
||||
// message in one block. This keeps `_PostCompact` untrusted while also
|
||||
// avoiding orphan tool-result messages in the fresh context: OpenAI
|
||||
// Chat/Responses require tool outputs to follow an assistant tool call,
|
||||
// but handoff reset intentionally discards the old assistant turn.
|
||||
let mut handoff_text = format!("[Context Handoff]\n{summary}");
|
||||
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 {
|
||||
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) {
|
||||
match item {
|
||||
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
|
||||
/// re-injected into the fresh history as a synthetic tool result. The
|
||||
/// next LLM request must therefore see the post-compact text in
|
||||
/// `messages` (role=tool) — proving the hook ran on the *new* context,
|
||||
/// not the discarded one.
|
||||
/// folded into the fresh `[Context Handoff]` user-context block as explicitly
|
||||
/// untrusted text. The next LLM request must therefore see the post-compact
|
||||
/// text without any orphan `role=tool` messages — proving the hook ran on the
|
||||
/// *new* context, not the discarded one.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hook_post_compact_injects_after_handoff() {
|
||||
// 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:
|
||||
// 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 post_compact_visible = captured.iter().any(|req| {
|
||||
let msgs = match req["messages"].as_array() {
|
||||
@@ -1105,22 +1107,27 @@ async fn hook_post_compact_injects_after_handoff() {
|
||||
None => return false,
|
||||
};
|
||||
msgs.iter().any(|m| {
|
||||
if m["role"] != "tool" {
|
||||
if m["role"] != "user" {
|
||||
return false;
|
||||
}
|
||||
let content = m["content"].as_str().unwrap_or("");
|
||||
let parsed: Value = match serde_json::from_str(content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
parsed["hook"] == "_PostCompact"
|
||||
&& parsed["server"] == "fake"
|
||||
&& parsed["text"] == "todo state here"
|
||||
content.contains("[Post-compact hook output — untrusted]")
|
||||
&& content.contains("[fake]")
|
||||
&& content.contains("todo state here")
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user