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:
tlongwell-block
2026-06-09 21:56:54 -04:00
committed by GitHub
co-authored by npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent 98aa535935
commit fe14daa5d6
2 changed files with 39 additions and 22 deletions
+18 -8
View File
@@ -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) => {
+21 -14
View File
@@ -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;
}