fix(13-08): declined actions never re-prompt + timeout chain covers the human wait
Two more on-device UAT findings: 1. Deny-retry loop: the model, told 'the user declined', simply called the tool again — each retry minted a fresh pending and re-opened the dialog (T-13-50 habituation, mechanized). ToolExecCtx now remembers declined actions for the turn, keyed by confirm::action_key — the same canonical (tool_name, validated_args) identity the nonce binds — and execute_tool refuses a re-ask before the gate, minting nothing. Regression test declined_action_never_reprompts_same_turn. 2. Timeout chain: rpcClient's 15s default aborted every confirmable turn client-side while the node kept the pending alive — the next turn then re-announced it (modal over and over) and every wait read as 'timed out'. assistant.chat now rides a 420s timeout; AIUI's bridge goes 180s→430s so the host's error path (which also expires the dialog) always fires first. Declined ToolResult text now also tells the model to stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
44c864ac14
commit
2d1f09d8c8
@@ -278,14 +278,17 @@ export const archyBridge = {
|
||||
text,
|
||||
})
|
||||
|
||||
// Matches the node's ASSISTANT_HTTP_TIMEOUT (180s) — the assistant
|
||||
// loop's tool-calling round trip can legitimately take that long.
|
||||
// Must cover the node's WHOLE turn: multiple model round trips
|
||||
// (ASSISTANT_HTTP_TIMEOUT 180s each) plus the confirm gate's
|
||||
// human-speed wait (CONFIRM_TIMEOUT 300s). Kept just ABOVE the host
|
||||
// page's own assistant.chat RPC timeout (420s) so the host's error
|
||||
// path — which also expires the confirm dialog — fires first.
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id)
|
||||
reject(new Error('Chat request timed out'))
|
||||
}
|
||||
}, 180000)
|
||||
}, 430000)
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -239,6 +239,14 @@ pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String {
|
||||
/// Canonical, deterministic form of the validated arguments — the byte
|
||||
/// string the nonce binds. Hand-written per args shape, matching D-06's
|
||||
/// hand-written registry.
|
||||
/// The canonical identity of an action — the same `(tool_name, args)`
|
||||
/// byte string the nonce binds. Used by the loop's declined-action memory
|
||||
/// (13-08 UAT): "the thing the human said no to" must be compared by
|
||||
/// exactly what would execute, not by tool name alone.
|
||||
pub(crate) fn action_key(tool_name: &str, args: &ToolArgs) -> String {
|
||||
format!("{tool_name}\0{}", canonical_args(args))
|
||||
}
|
||||
|
||||
fn canonical_args(args: &ToolArgs) -> String {
|
||||
match args {
|
||||
ToolArgs::Empty(_) => json!({}).to_string(),
|
||||
|
||||
@@ -135,6 +135,20 @@ pub(crate) async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResu
|
||||
}
|
||||
|
||||
if tool.destructive {
|
||||
// 13-08 UAT (T-13-50): an action the human already declined this
|
||||
// turn never re-prompts — a model retrying after "declined" would
|
||||
// otherwise re-open the dialog until the human gives in. Refused
|
||||
// here, before the gate, so no fresh confirmation is even minted.
|
||||
let action_key = super::confirm::action_key(&call.name, &args);
|
||||
if ctx.was_declined(&action_key) {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: "the user already declined exactly this action in this turn — do NOT \
|
||||
request it again. Tell the user it was declined and stop."
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
// D-07/D-11: the loop suspends here. The gate publishes a
|
||||
// NODE-AUTHORED description (never model text) that neode-ui's
|
||||
// trusted chrome fetches over the authenticated RPC session, and
|
||||
@@ -145,10 +159,13 @@ pub(crate) async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResu
|
||||
match ctx.confirm.request(&call.id, tool, &args).await {
|
||||
super::confirm::Confirmed::Yes => {}
|
||||
super::confirm::Confirmed::No | super::confirm::Confirmed::TimedOut => {
|
||||
ctx.note_declined(action_key);
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: "the user declined this action — nothing was changed".to_string(),
|
||||
content: "the user declined this action — nothing was changed. Do not retry \
|
||||
it and do not ask again; acknowledge the decline and stop."
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub mod grants;
|
||||
pub mod loop_;
|
||||
pub mod tools;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -137,6 +137,13 @@ pub struct ToolExecCtx {
|
||||
/// [`ToolExecCtx::with_confirm_gate`] for isolation.
|
||||
pub confirm: Arc<confirm::ConfirmGate>,
|
||||
validation_failures: Mutex<HashMap<String, u32>>,
|
||||
/// 13-08 on-device UAT (T-13-50): actions the human declined this turn,
|
||||
/// keyed by the same canonical `(tool_name, validated_args)` identity
|
||||
/// the nonce binds. A declined action must not re-prompt within the
|
||||
/// turn — the model retrying after "the user declined" would otherwise
|
||||
/// mint a fresh confirmation and re-open the dialog until the human
|
||||
/// gives in, which is the habituation failure, mechanized.
|
||||
declined_actions: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl ToolExecCtx {
|
||||
@@ -160,9 +167,27 @@ impl ToolExecCtx {
|
||||
handler,
|
||||
confirm,
|
||||
validation_failures: Mutex::new(HashMap::new()),
|
||||
declined_actions: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The human declined this exact action; remember it for the rest of
|
||||
/// the turn so it can never re-prompt.
|
||||
pub(crate) fn note_declined(&self, action_key: String) {
|
||||
self.declined_actions
|
||||
.lock()
|
||||
.expect("declined_actions mutex poisoned")
|
||||
.insert(action_key);
|
||||
}
|
||||
|
||||
/// Whether this exact action was already declined this turn.
|
||||
pub(crate) fn was_declined(&self, action_key: &str) -> bool {
|
||||
self.declined_actions
|
||||
.lock()
|
||||
.expect("declined_actions mutex poisoned")
|
||||
.contains(action_key)
|
||||
}
|
||||
|
||||
/// A tool name's argument validation just succeeded — its consecutive
|
||||
/// failure streak resets.
|
||||
pub(crate) fn reset_validation_failures(&self, tool_name: &str) {
|
||||
@@ -479,6 +504,57 @@ mod tests {
|
||||
assert_eq!(answer, "Understood — I did not restart it.");
|
||||
}
|
||||
|
||||
/// 13-08 UAT / T-13-50: once the human declines an action, the same
|
||||
/// action re-requested in the same turn is refused immediately — no
|
||||
/// fresh pending confirmation is minted, so the dialog cannot re-open
|
||||
/// until the human gives in.
|
||||
#[tokio::test]
|
||||
async fn declined_action_never_reprompts_same_turn() {
|
||||
let (handler, _tmp) = test_rpc_handler_with_app("demo-app").await;
|
||||
grant(&handler, PermissionCategory::Apps).await;
|
||||
|
||||
let gate = Arc::new(confirm::ConfirmGate::new());
|
||||
let ctx = Arc::new(ToolExecCtx::with_confirm_gate(
|
||||
tools::registry(),
|
||||
CallerScope::LocalOperator {
|
||||
session_id: "s".to_string(),
|
||||
},
|
||||
handler.clone(),
|
||||
gate.clone(),
|
||||
));
|
||||
let call = tools::ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "app_restart".to_string(),
|
||||
arguments: serde_json::json!({ "app_id": "demo-app" }),
|
||||
};
|
||||
|
||||
// First ask: suspend, then the human declines.
|
||||
let (ctx_task, call_task) = (ctx.clone(), call.clone());
|
||||
let suspended =
|
||||
tokio::spawn(async move { loop_::execute_tool(&call_task, &ctx_task).await });
|
||||
let snap = wait_pending(&gate).await;
|
||||
gate.resolve(&snap.req_id, &snap.nonce, false)
|
||||
.expect("decline resolves");
|
||||
let first = suspended.await.expect("join");
|
||||
assert!(first.is_error);
|
||||
|
||||
// Second ask, same action, same turn: refused without suspending
|
||||
// and without minting a new pending confirmation.
|
||||
let mut second_call = call.clone();
|
||||
second_call.id = "call-2".to_string();
|
||||
let second = loop_::execute_tool(&second_call, &ctx).await;
|
||||
assert!(second.is_error, "a re-asked declined action must be an error");
|
||||
assert!(
|
||||
second.content.to_lowercase().contains("already declined"),
|
||||
"the refusal must tell the model it was already declined: {}",
|
||||
second.content
|
||||
);
|
||||
assert!(
|
||||
gate.peek().is_none(),
|
||||
"no new pending confirmation may be minted for a declined action"
|
||||
);
|
||||
}
|
||||
|
||||
/// S-06 / D-16: a fresh node's `LocalOperator` resolves to no granted
|
||||
/// categories at all.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -202,9 +202,18 @@ export class ContextBroker {
|
||||
// trusted chrome can draw the dialog (see handleToolConfirmRequest).
|
||||
this.beginConfirmPolling()
|
||||
try {
|
||||
// 13-08 UAT: a chat turn legitimately spans multiple model round
|
||||
// trips (ASSISTANT_HTTP_TIMEOUT 180s each) plus a human-speed
|
||||
// confirm-gate wait (CONFIRM_TIMEOUT 300s). The rpcClient default
|
||||
// (15s) aborted every confirmable turn client-side while the node
|
||||
// kept the pending confirmation alive — the modal then re-announced
|
||||
// on the next turn, over and over. 420s must stay below AIUI's
|
||||
// bridge timeout (430s) so this error, not the bridge's, is the one
|
||||
// the user sees.
|
||||
const result = await rpcClient.call<{ text: string }>({
|
||||
method: 'assistant.chat',
|
||||
params: { text },
|
||||
timeout: 420_000,
|
||||
})
|
||||
this.postToIframe({
|
||||
type: 'chat:response',
|
||||
|
||||
Reference in New Issue
Block a user