ToolResult.PendingOf
C# · package Toolnexus · SPEC §10 · ToolResult.cs
public sealed record ToolResult(string Output, bool IsError, IDictionary<string, object?>? Metadata = null){ public static Request? PendingOf(ToolResult? result);}Reads Metadata["pending"] back off a ToolResult as a typed Request, or returns null when
the result is an ordinary success/error. It is the one honest way to ask “is this result actually
a suspension?” — IsError alone can’t tell you, because a suspension also sets IsError = true.
PendingOf accepts a nullable ToolResult, so it’s safe to call on a possibly-absent result
without a separate null check.
When to use it
Section titled “When to use it”Inside a Hooks.BeforeTool/AfterTool callback, or any code that
inspects a ToolResult before the client loop’s own suspension handling runs — logging, metrics,
a custom retry policy. The loop itself already uses PendingOf internally to decide whether a
result should trigger WaitFor; reach for it yourself whenever you’re building similar logic on
top of a raw ToolResult.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — success, error, and suspension side by side
Section titled “1. The smallest useful call — success, error, and suspension side by side”using Toolnexus;
var ok = ToolResult.Ok("done");var err = ToolResult.Error("not found");var suspended = ToolResult.Pending(new Request { Kind = "input", Prompt = "which environment?" });
if (ToolResult.PendingOf(ok) is not null) throw new Exception("an ordinary success is not a suspension");if (ToolResult.PendingOf(err) is not null) throw new Exception("an ordinary error is not a suspension");var req = ToolResult.PendingOf(suspended);if (req is null || req.Kind != "input") throw new Exception("expected the suspension's Request back");
// Safe on null, too — no separate has-value check needed.if (ToolResult.PendingOf(null) is not null) throw new Exception("null in, null out");
Console.WriteLine($"ok: ok={ToolResult.PendingOf(ok)}, err={ToolResult.PendingOf(err)}, suspended={req.Kind}");2. The realistic case — an AfterTool hook that tells suspensions from real failures
Section titled “2. The realistic case — an AfterTool hook that tells suspensions from real failures”using System.Net;using System.Text;using Toolnexus;
using var stub = new Stub(ctx =>{ Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"approve_purchase","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """);});
var approve = NativeTool.Of("approve_purchase", "requests approval", null, (IDictionary<string, object?> _, ToolContext? _) => (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = "approve?" }));
var sawSuspension = false;var sawFailure = false;var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Hooks = new LlmClient.Hooks { AfterTool = ev => { // Distinguish "parked, waiting on the world" from "actually broke" — IsError is true // on BOTH, so PendingOf is the only reliable check. if (ToolResult.PendingOf(ev.Result) is not null) sawSuspension = true; else if (ev.Result.IsError) sawFailure = true; return null; }, }, // No WaitFor here — this run is expected to halt as "pending", not resolve inline.});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List<ITool> { approve } });var result = await client.RunAsync("buy a gpu", tk);
if (result.Status != "pending") throw new Exception(result.Status);// AfterTool is NOT called for a suspension (SPEC §10) — only PendingOf inside the loop itself sees it.if (sawSuspension) throw new Exception("AfterTool must not fire on a suspended call");if (sawFailure) throw new Exception("a suspension must never be classified as a failure");
Console.WriteLine($"ok: status={result.Status}, AfterTool saw neither a suspension nor a failure");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}3. Full surface — reading a suspension straight off RunResult.Pending
Section titled “3. Full surface — reading a suspension straight off RunResult.Pending”using System.Net;using System.Text;using Toolnexus;
using var stub = new Stub(ctx =>{ Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_calendar","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}} """);});
var calendar = NativeTool.Of("read_calendar", "reads events", null, (IDictionary<string, object?> _, ToolContext? _) => (object)ToolResult.AuthRequired("https://accounts.example.com/authorize"));
var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key" });await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List<ITool> { calendar } });
var result = await client.RunAsync("what's on my calendar?", tk);
// RunResult already surfaces the Request directly — no need to dig back through Metadata here.if (result.Status != "pending") throw new Exception(result.Status);if (result.Pending is null) throw new Exception("expected RunResult.Pending to be set");if (result.Pending.Kind != "authorization") throw new Exception(result.Pending.Kind);if (result.Pending.Url != "https://accounts.example.com/authorize") throw new Exception(result.Pending.Url);
Console.WriteLine($"ok: parked on {result.Pending.Kind} at {result.Pending.Url}");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}Parameters
Section titled “Parameters”| Parameter | Type | What it is |
|---|---|---|
result |
ToolResult? |
The result to inspect. null is accepted and returns null. |
Returns
Section titled “Returns”Request? — the suspension’s request when result.Metadata["pending"] is present, otherwise null.
See also
Section titled “See also”ToolResult.Pending— Return a Pending from a tool to park the run until someone answers.ToolResult.AuthRequired— The auth-shaped suspension: hand back a URL, resume once the user has granted access.LlmClient.WaitFor— The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.ToolResult— The result envelope this rides on.