AgentRuntime.ResumeAsync
C# · package Toolnexus · SPEC §7D · Agents/AgentRuntime.cs
public async Task ResumeAsync(Answer answer)Routes an Answer to the deepest suspended handle in the runtime’s tree, resumes it from its
checkpoint (a retry-with-answer of the halted tool — turns and token usage keep accumulating,
never reset), then cascades upward: each suspended ancestor replays too, and its re-invoked task
delegation call reattaches to the already-resumed child by task key rather than spawning a
duplicate. ResumeAsync itself returns nothing — call
rt.WaitAsync(handle) afterward for the finished AgentResult.
When to use it
Section titled “When to use it”Reach for rt.ResumeAsync(answer) any time a spawned agent’s handle transitions to "suspended"
(Handle.State == "suspended", Handle.PendingRequest set) and you have — or have just obtained —
the Answer to that suspension: a human approved a payment, a login completed, a form was filled
in. There is no separate resume path per handle; one call resolves whichever handle is currently
the deepest suspended one in the tree.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — suspend, resume, get the final answer
Section titled “1. The smallest useful call — suspend, resume, get the final answer”using Toolnexus;using Toolnexus.Agents;
var turn = 0;var llm = new MockLlm(_ =>{ turn++; return Task.FromResult(turn == 1 ? MockLlm.ToolCall("c1", "charge_card", new { }) : MockLlm.Text("Done — charged."));});
var chargeCard = NativeTool.Of("charge_card", "charge the card", null, (IDictionary<string, object?> _, ToolContext? ctx) => ctx?.Answer is { } a ? (object)ToolResult.Ok($"charged (ok={a.Ok})") : ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve $500 charge?" }));
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["approve"] = new() { Name = "approve", Does = "approves a payment", Tools = new List<ITool> { chargeCard } }, },});
var h = rt.Spawn(rt.Root, "approve").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "Charge the card.");await wait; // settles once, on the pending suspension — NOT the resumed result
if (h.State != "suspended") throw new Exception($"expected suspended, got {h.State}");if (h.PendingRequest?.Kind != "approval") throw new Exception($"expected an approval request, got {h.PendingRequest?.Kind}");
// ResumeAsync itself awaits the resumed turn to completion — read h.LastResult afterward,// not a second await on `wait` (that Task already settled on the FIRST suspension).await rt.ResumeAsync(new Answer { Id = h.PendingRequest!.Id, Ok = true });var result = h.LastResult!;
if (h.State != "idle") throw new Exception($"resumed back to idle, got {h.State}");if (result.Status != "done" || result.Text != "Done — charged.") throw new Exception($"{result.Status} {result.Text}");
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: suspended -> resumed -> {result.Status}, {result.Text}");
sealed class MockLlm : System.Net.Http.HttpMessageHandler{ readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> _respond; public MockLlm(Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> respond) => _respond = respond; protected override Task<System.Net.Http.HttpResponseMessage> SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), };}2. A declined answer — the run finishes, it just doesn’t do the thing
Section titled “2. A declined answer — the run finishes, it just doesn’t do the thing”ResumeAsync’s loop rule branches only on Answer.Ok — a decline is data, not a thrown error.
using Toolnexus;using Toolnexus.Agents;
var turn = 0;var llm = new MockLlm(_ =>{ turn++; return Task.FromResult(turn == 1 ? MockLlm.ToolCall("c1", "charge_card", new { }) : MockLlm.Text("Understood — the charge was cancelled."));});
var chargeCard = NativeTool.Of("charge_card", "charge the card", null, (IDictionary<string, object?> _, ToolContext? ctx) => ctx?.Answer is { } a ? (object)ToolResult.Ok($"charge outcome ok={a.Ok}") : ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve $500 charge?" }));
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["approve"] = new() { Name = "approve", Does = "approves a payment", Tools = new List<ITool> { chargeCard } }, },});
var h = rt.Spawn(rt.Root, "approve").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "Charge the card.");await wait;
if (h.State != "suspended") throw new Exception($"expected suspended, got {h.State}");
await rt.ResumeAsync(new Answer { Id = h.PendingRequest!.Id, Ok = false, Reason = "declined" });var result = h.LastResult!;
// The RUN still completes — it just knows the charge was declined.if (result.Status != "done") throw new Exception(result.Status);if (!result.Text!.Contains("cancelled")) throw new Exception(result.Text);
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {result.Text}");
sealed class MockLlm : System.Net.Http.HttpMessageHandler{ readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> _respond; public MockLlm(Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> respond) => _respond = respond; protected override Task<System.Net.Http.HttpResponseMessage> SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), };}3. Inspecting the parked handle before resuming — Inspect/List
Section titled “3. Inspecting the parked handle before resuming — Inspect/List”A host that stores the answer separately from the runtime (a queue, a database row) still needs a
live handle to resume — List()/Inspect() give a read-only view of what’s parked, including the
pending request itself, without guessing at Children indices.
using Toolnexus;using Toolnexus.Agents;
var turn = 0;var llm = new MockLlm(_ =>{ turn++; return Task.FromResult(turn == 1 ? MockLlm.ToolCall("c1", "charge_card", new { }) : MockLlm.Text("Charged."));});
var chargeCard = NativeTool.Of("charge_card", "charge the card", null, (IDictionary<string, object?> _, ToolContext? ctx) => ctx?.Answer is { } a ? (object)ToolResult.Ok("charged") : ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve $500 charge?" }));
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["approve"] = new() { Name = "approve", Does = "approves a payment", Tools = new List<ITool> { chargeCard } }, },});
var h = rt.Spawn(rt.Root, "approve").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "Charge the card.");await wait;
// The read-only view: same Request the Handle carries, reachable from List()/Inspect() alone.var view = rt.Inspect(h.Id);if (view is null || view.State != "suspended") throw new Exception($"{view?.State}");
await rt.ResumeAsync(new Answer { Id = h.PendingRequest!.Id, Ok = true });var result = h.LastResult!;if (result.Status != "done") throw new Exception(result.Status);
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {view.State} -> {result.Status}");
sealed class MockLlm : System.Net.Http.HttpMessageHandler{ readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> _respond; public MockLlm(Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> respond) => _respond = respond; protected override Task<System.Net.Http.HttpResponseMessage> SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), };}Signature
Section titled “Signature”| Parameter | Type | What it is |
|---|---|---|
answer |
Answer |
Must echo the Id of the pending Request — routed to the deepest suspended handle. |
| returns | Task |
Call rt.WaitAsync(handle) afterward for the finished AgentResult. |
See also
Section titled “See also”Handle— The state machine for one spawned agent: pending, running, suspended, done.AgentRuntime— The six host verbs that drive sub-agents, plus the read-only list and inspect views.ToolResult.Pending— Return a Pending from a tool to park the run until someone answers.suspension/relay— The golang-only §10 preview that typically resumes viaRunWithAnswer/AskWithAnswer.