ToolContext
C# · package Toolnexus · SPEC §1 · ToolContext.cs
public sealed class ToolContext{ public ToolContext(long? timeoutMs = null, CancellationToken cancellationToken = default, Answer? answer = null);
public long? TimeoutMs { get; } public CancellationToken CancellationToken { get; } public Answer? Answer { get; } public bool IsCancelled { get; } // => CancellationToken.IsCancellationRequested}The optional second argument to ExecuteAsync. It carries the three things a tool needs to be a
good citizen: a cancellation token, a deadline, and — on a resumed call — the answer to a question
it previously asked.
When to use it
Section titled “When to use it”Read ctx when your tool does anything slow or interactive:
IsCancelled/CancellationToken— long work that should stop when the run is cancelled.TimeoutMs— the caller’s deadline for this specific call, in milliseconds.Answer— you returned a suspension on a previous attempt and the host has now resolved it.
A pure, fast, local computation can ignore ctx entirely.
Why a wrapper around CancellationToken
Section titled “Why a wrapper around CancellationToken”.NET already has CancellationToken, so why the wrapper? Because the §1 contract carries two more
things — a per-call timeout and the §10 Answer — and threading three parameters through every tool
source would be worse. The CancellationToken is still in there, and you can hand it straight to
any async API that accepts one.
Examples
Section titled “Examples”1. Respecting cancellation
Section titled “1. Respecting cancellation”Check before starting work and between steps. A cancelled tool returns an error result promptly rather than throwing.
using Toolnexus;
ToolResult Crunch(int steps, ToolContext? ctx){ var done = 0; for (var i = 0; i < steps; i++) { // ctx is nullable, and ?. on a bool yields bool? — compare against true. if (ctx?.IsCancelled == true) return ToolResult.Error($"cancelled after {done} step(s)"); done++; } return ToolResult.Ok($"completed {done} step(s)");}
// No context at all — the tool still runs.var plain = Crunch(3, null);if (plain.Output != "completed 3 step(s)") throw new Exception(plain.Output);
// Cancelled before it starts.using var cts = new CancellationTokenSource();cts.Cancel();var stopped = Crunch(3, new ToolContext(cancellationToken: cts.Token));if (!stopped.IsError || stopped.Output != "cancelled after 0 step(s)") throw new Exception(stopped.Output);
// A live token reads as not cancelled.using var live = new CancellationTokenSource();if (new ToolContext(cancellationToken: live.Token).IsCancelled) throw new Exception("should be live");
Console.WriteLine($"ok: {plain.Output} | {stopped.Output}");2. Honouring the caller’s timeout
Section titled “2. Honouring the caller’s timeout”TimeoutMs is a long? — null means “not set”. Fall back to your own default rather than
treating it as zero.
using Toolnexus;
ToolResult Fetchish(string url, ToolContext? ctx){ // null means unset — use your own default. var budget = ctx?.TimeoutMs ?? 30_000L;
if (budget < 100) return ToolResult.Error($"budget {budget}ms is too small to try"); return ToolResult.Ok($"fetched {url} within {budget}ms");}
var generous = Fetchish("/a", new ToolContext(5000));if (generous.Output != "fetched /a within 5000ms") throw new Exception(generous.Output);
var stingy = Fetchish("/a", new ToolContext(10));if (!stingy.IsError) throw new Exception("expected the tiny budget to be refused");
var defaulted = Fetchish("/a", new ToolContext());if (!defaulted.Output.Contains("30000ms")) throw new Exception(defaulted.Output);
var noCtx = Fetchish("/a", null);if (!noCtx.Output.Contains("30000ms")) throw new Exception(noCtx.Output);
Console.WriteLine($"ok: {generous.Output} | {stingy.Output}");3. Answer — the second half of a suspension
Section titled “3. Answer — the second half of a suspension”This is the property that makes the human-in-the-loop contract work. On the first call the tool
returns a Pending. The host resolves it, then calls the same tool again with Answer set.
using Toolnexus;
ToolResult Deploy(ToolContext? ctx){ // Second pass: the host resolved the question and handed the answer back. if (ctx?.Answer is { } answer) { if (!answer.Ok) return ToolResult.Error($"declined: {answer.Reason ?? "no reason"}"); // Data is IDictionary<string, object?> — TryGetValue, not GetValueOrDefault // (that extension is declared on IReadOnlyDictionary and will not infer here). object? env = null; answer.Data?.TryGetValue("env", out env); return ToolResult.Ok($"deployed to {env ?? "unknown"}"); }
// First pass: park the run and ask. return ToolResult.Pending(new Request { Kind = "input", Prompt = "Which environment?" });}
// First pass — a suspension, not an answer.var first = Deploy(null);var req = ToolResult.PendingOf(first);if (req is null || req.Kind != "input") throw new Exception("expected a suspension");
// Second pass — the host supplies the resolution, echoing the request id.var resumed = new ToolContext(answer: new Answer{ Id = req.Id, Ok = true, Data = new Dictionary<string, object?> { ["env"] = "staging" },});var second = Deploy(resumed);if (second.IsError || second.Output != "deployed to staging") throw new Exception(second.Output);
// A refusal is a normal outcome, not a crash.var declined = new ToolContext(answer: new Answer { Id = req.Id, Ok = false, Reason = "declined" });var refused = Deploy(declined);if (!refused.IsError) throw new Exception("expected an error result");
Console.WriteLine($"ok: {second.Output} | {refused.Output}");Members
Section titled “Members”| Member | Type | What it is |
|---|---|---|
TimeoutMs |
long? |
This call’s budget in milliseconds. null means unset. |
CancellationToken |
CancellationToken |
Hand it straight to any async API that takes one. |
IsCancelled |
bool |
Shorthand for CancellationToken.IsCancellationRequested. |
Answer |
Answer? |
Present only on a post-WaitFor retry. |
See also
Section titled “See also”ITool— what receives thisToolResult— whatExecuteAsyncreturnsSuspension.Pending— ask a question mid-call