diff --git a/aiui/packages/app/src/services/archyBridge.ts b/aiui/packages/app/src/services/archyBridge.ts index b4a16be7..cf3c71e2 100644 --- a/aiui/packages/app/src/services/archyBridge.ts +++ b/aiui/packages/app/src/services/archyBridge.ts @@ -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) }) }, diff --git a/core/archipelago/src/assistant/confirm.rs b/core/archipelago/src/assistant/confirm.rs index fc56b4a8..85a49968 100644 --- a/core/archipelago/src/assistant/confirm.rs +++ b/core/archipelago/src/assistant/confirm.rs @@ -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(), diff --git a/core/archipelago/src/assistant/loop_.rs b/core/archipelago/src/assistant/loop_.rs index 6062cd6d..79675b0f 100644 --- a/core/archipelago/src/assistant/loop_.rs +++ b/core/archipelago/src/assistant/loop_.rs @@ -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(), }; } } diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index 7753b863..2566867a 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -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, validation_failures: Mutex>, + /// 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>, } 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] diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 0ad92144..275fa522 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -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',