Skip to content

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::relay

The 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).

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.

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);
}