Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions agent-loop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ pub trait ToolHost: Send + Sync {
}
}

/// HGW-1: why a tainted session's effectful call is declined when no member can be asked: the
/// host is not one that shows approval cards (a headless or scheduled run, or a client that did
/// not promise to route `hic: "required"` to a person). Hosts that decline for the same reason
/// use this text too, so the model and the log read one message. `source` names what tainted the
/// session, when known.
pub fn cannot_ask_reason(source: Option<&str>) -> String {
let from = source.map(|s| format!(" (from {s})")).unwrap_or_default();
format!(
"this session read untrusted content{from}, so this action needs a member's explicit approval, and no member can be asked in this session (it was not opened by an app that shows approval cards, for example a headless or scheduled run)"
)
}

/// The record of what tainted a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaintRecord {
Expand Down Expand Up @@ -912,10 +924,9 @@ pub fn run_turn_with(
(None, None) if declined.is_some() => {
ToolOutcome::Denied(declined.clone().unwrap_or_default())
}
(None, None) if refused_hic => ToolOutcome::Denied(
"this session read untrusted content and this action needs a member's explicit approval, which this tool's host cannot ask for"
.into(),
),
(None, None) if refused_hic => ToolOutcome::Denied(cannot_ask_reason(
tools.taint().record().as_ref().map(|r| r.source.as_str()),
)),
(None, None) => {
ToolOutcome::Error(format!("'{}' is not available in this session", call.name))
}
Expand Down
42 changes: 42 additions & 0 deletions agent-loop/tests/taint_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,48 @@ fn a_host_that_cannot_ask_a_member_refuses_instead_of_running() {
assert!(msg.content.starts_with("declined:"), "{}", msg.content);
}

/// HGW-1: when nobody can be asked (a headless or scheduled session), the refusal says what
/// tainted the session and why it cannot ask, so the model (and a person reading the log) can
/// tell a member's "no" from "this run has no member to ask".
#[test]
fn a_headless_refusal_names_the_source_and_says_nobody_can_be_asked() {
let llm = ScriptLlm::new(vec![
AssistantTurn::tools(vec![call("c1", "web_fetch"), call("c2", "write_file")]),
AssistantTurn::text("done"),
]);
let host = Arc::new(RecHost::new(false, ToolOutcome::Ok("ok".into())));
let tools = ToolRegistry::new(catalog()).with_host(HostKind::Core, host.clone());
let sink = Sink::default();
let mut history = vec![];
run_turn(
&cfg(),
&llm,
&tools,
&sink,
&StopFlag::default(),
&mut history,
"go",
);
let msg = history
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("c2"))
.unwrap();
assert!(msg.content.contains("from web_fetch"), "{}", msg.content);
assert!(msg.content.contains("explicit approval"), "{}", msg.content);
assert!(
msg.content.contains("no member can be asked"),
"{}",
msg.content
);
assert_eq!(
msg.content,
format!(
"declined: {}. Nothing was done.",
cannot_ask_reason(Some("web_fetch"))
)
);
}

#[test]
fn the_taint_persists_across_steps_and_turns() {
let llm = ScriptLlm::new(vec![
Expand Down
Loading
Loading