Skip to content

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.

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.

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"),
};
}
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.
  • 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 via RunWithAnswer/AskWithAnswer.