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:
archipelago
2026-08-05 14:50:17 -04:00
co-authored by Claude Fable 5
parent 44c864ac14
commit 2d1f09d8c8
5 changed files with 118 additions and 5 deletions
@@ -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(),
+18 -1
View File
@@ -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(),
};
}
}
+77 -1
View File
@@ -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]
+9
View File
@@ -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',