Build the Answer that resumes a run after a human declines to respond
Rust · crate toolnexus · SPEC §10 · rust/src/relay.rs
pub fn answer_declined(id: impl Into<String>, reason: &str) -> Answer // toolnexus::relayThe Answer for a refused request: ok: false and a reason ("" ⇒ "declined"). A declined relay
is an error tool result, not an aborted run — the model sees the refusal and recovers. Through the
MCP elicitation bridge, reason: "declined" maps to decline (anything else to cancel).
When to use it
Section titled “When to use it”Reach for answer_declined any time the party who would resolve a suspension says no: a human
rejects an approval, a policy check refuses a relayed call, a request expires. Return it from
wait_for, or pass it to Client::run_with_answer on the durable path.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — build the Answer and inspect its shape
Section titled “1. The smallest useful call — build the Answer and inspect its shape”use toolnexus::relay::answer_declined;
fn main() { let ans = answer_declined("req-1", "not authorized"); assert_eq!(ans.id, "req-1"); assert!(!ans.ok); assert_eq!(ans.reason, "not authorized");
// An empty reason defaults to "declined". let def = answer_declined("req-2", ""); assert_eq!(def.reason, "declined"); println!("ok: {} / {}", ans.reason, def.reason);}2. The realistic case — decline a relayed tool call and let the run finish anyway
Section titled “2. The realistic case — decline a relayed tool call and let the run finish anyway”use std::sync::Arc;
use serde_json::json;use tokio_util::sync::CancellationToken;use toolnexus::client::{ GenerateFn, InProcessOptions, InProcessRequest, InProcessResponse, InProcessToolCall, create_in_process_client,};use toolnexus::relay::{answer_declined, relay_tool};use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit};
fn model() -> GenerateFn { Arc::new(|req: InProcessRequest| { Box::pin(async move { Ok(if req.messages.iter().any(|m| m["role"] == "tool") { InProcessResponse { content: "understood, no refund issued".into(), ..Default::default() } } else { InProcessResponse { tool_calls: vec![InProcessToolCall { id: "c1".into(), name: "refund".into(), arguments: json!({}) }], ..Default::default() } }) }) })}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), extra_tools: vec![relay_tool("refund", "issue a refund", None)], ..Default::default() }) .await?; let client = create_in_process_client(InProcessOptions { generate: Some(model()), ..Default::default() });
let halted = client.run("refund the customer", &tk).await?; assert_eq!(halted.status, "pending", "expected a durable halt"); let req = halted.pending.clone().unwrap();
// The approver said no — the run still completes, it just knows it was refused. let resumed = client .run_with_answer( CancellationToken::new(), &tk, halted.messages, &req, &answer_declined(&req.id, "amount exceeds refund policy"), ) .await?; assert_eq!(resumed.status, "done"); assert!(resumed.text.contains("no refund"), "got {:?}", resumed.text); println!("ok: {}", resumed.text); Ok(())}3. The full surface — comparing a decline against a successful answer for the same call
Section titled “3. The full surface — comparing a decline against a successful answer for the same call”use toolnexus::relay::{answer_declined, answer_output};
fn main() { let declined = answer_declined("req-1", ""); let output = answer_output("req-1", "approved");
assert!(!declined.ok, "a decline must carry ok: false"); assert!(output.ok, "a real output must carry ok: true"); assert!(declined.data.is_none(), "a decline carries no payload"); assert!(output.reason.is_empty(), "a success carries no reason"); println!("ok: declined.ok = {} output.ok = {}", declined.ok, output.ok);}