AWS 開源 Infinity,文章示範如何用 TypeScript 建立工具呼叫間可休眠的微型智能體
AWS Open-Sourced Infinity. Let's Build a Tiny Agent That Sleeps Between Tool Calls in TypeScript.
AWS 開源 Apache 2.0 授權的 Infinity 研究執行環境;文章示範以 TypeScript 建立微型智能體,將對話歷史存檔並在工具呼叫後暫停,待結果或事件到達再恢復。
Most agents spend their day waiting.
A build takes twenty minutes. A review takes an afternoon. An approval comes tomorrow.
And the whole time, the agent loop just sits there. Holding a process. Holding memory. Holding its breath.
A waiting agent shouldn't cost the same as a working one.
On October 23, 2026, AWS introduced Infinity, an Apache 2.0 research runtime from the Hydro project, a collaboration between AWS and UC Berkeley. AWS says: "When an agent calls a tool, Infinity dispatches the call, saves the conversation, and suspends the agent. When the tool result arrives, the runtime resumes the agent from its saved state."
The headline number is that AWS fit 75,000 concurrent agents in the 8 GB of a Raspberry Pi 5. On serverless, the function can simply end while a tool runs, "even if a tool call takes days."
The idea comes from programming languages. Infinity treats a tool call like an await in a stackless coroutine. The post says it plainly:
At the moment an agent calls a tool, its conversation history contains everything needed to continue the agent once the result is available.
That distinction matters.
The agent isn't a process. It's a history.
AWS calls it "an early research release," and the code is on GitHub. It's written in Rust.
So let's build a tiny version in TypeScript.
By the end, you'll run one command:
npx tsx suspend.ts
And watch one agent start a build, subscribe to pull requests, sleep between every step, and refuse two events it shouldn't trust.
Table of Contents
- The Problem With Blocking Loops
- Step 1: The Store Holds Strings
- Step 2: A Fake Model
- Step 3: One Slice, Then Stop
- Step 4: Results and Events Wake It Up
- Step 5: Run It
- Where It Breaks Down
- The Bigger Idea
1. The Problem With Blocking Loops
Most harnesses look like this:
while (true) {
const turn = await model(history);
if (!turn.call) break;
history.push(await runTool(turn.call)); // waits here for 20 minutes
}
That await runTool is the whole problem.
The loop holds everything in memory while the tool runs.
It also can't hear anything else. A new pull request arrives, and nobody is listening.
The fix is to stop waiting inside the loop.
2. Step 1: The Store Holds Strings
Here are the message types and the store:
// tiny-suspend-agent: an agent that is pure data between turns.
// Small model of the idea in AWS's Infinity runtime, not its implementation.
// Fake model, fake tools, in-memory store. No API key. No network.
import { createHmac } from "node:crypto";
type Msg =
| { role: "user"; text: string }
| { role: "assistant"; text: string }
| { role: "tool_call"; id: string; name: string; args: Record<string, unknown> }
| { role: "tool_result"; id: string; content: string };
// Step 1: the store holds strings, not objects
// If an agent can't survive JSON.stringify, it can't survive a restart.
const store = new Map<string, string>();
const save = (agentId: string, h: Msg[]) => store.set(agentId, JSON.stringify(h));
const load = (agentId: string): Msg[] => JSON.parse(store.get(agentId) ?? "[]");
The store is a Map of strings. That's on purpose.
If the agent only survives as a live object, it can't survive a restart.
Infinity uses a pluggable store. Mine is a Map. Yours might be DynamoDB, Postgres, or a file.
The rule is the same. Between turns, the agent is just data.
3. Step 2: A Fake Model
I don't want an API key in this demo. So the model is a function of history:
// Step 2: a fake model that only reads history
type Turn = { text: string } | { call: { name: string; args: Record<string, unknown> } };
let n = 0;
function fakeModel(h: Msg[]): Turn {
const last = h[h.length - 1];
if (last.role === "user") return { call: { name: "run_build", args: { target: "api" } } };
if (last.role === "tool_result" && last.content.startsWith("build started"))
return { call: { name: "subscribe_prs", args: { repo: "acme/api" } } };
if (last.role === "tool_result" && last.content.startsWith("subscribed")) return { text: "Waiting." };
if (last.role === "tool_result") return { text: `Noted: ${last.content}` };
return { text: "Nothing to do." };
}
It reads the last message and decides. Start a build. Then subscribe to PRs. Then wait.
A real model does the same thing, just with more opinions.
The model never needs anything but the history.
That's what makes suspending safe.
4. Step 3: One Slice, Then Stop
This is the core:
// Step 3: one bounded slice, then the slice ends
// Load, one completion, persist, dispatch at most one tool call. Never block.
const INJECTED = "receive_event__injected";
type Dispatch = { agentId: string; callId: string; name: string; args: Record<string, unknown> };
const outbox: Dispatch[] = [];
function slice(agentId: string): string {
const h = load(agentId);
const turn = fakeModel(h);
if ("text" in turn) {
h.push({ role: "assistant", text: turn.text });
save(agentId, h);
return `said "${turn.text}" and went idle`;
}
if (turn.call.name === INJECTED) {
save(agentId, h);
return `REJECT model tried to call ${INJECTED}`;
}
const callId = `call_${++n}`;
h.push({ role: "tool_call", id: callId, ...turn.call });
save(agentId, h);
outbox.push({ agentId, callId, ...turn.call });
return `dispatched ${turn.call.name} (${callId}) and suspended`;
}
AWS describes each slice like this: load history, run one model completion, persist the output, and dispatch at most one tool call. Then the slice ends.
Mine does the same.
If the model answers with text, the agent goes idle.
If the model asks for a tool, the call goes to an outbox and the function returns.
Nothing blocks. Nothing waits.
There's one extra check. The name receive_event__injected is reserved for the runtime. AWS says the runtime "never offers this name to the model as a tool, and it rejects the call if the model tries to invoke it." So mine rejects it too.
5. Step 4: Results and Events Wake It Up
Now the interesting part. How does a sleeping agent hear about the world?
// Step 4: results and events wake the agent
// A first result answers the call. Later events become synthetic tool calls.
const SECRET = "demo-secret-not-real";
const sign = (body: string) => createHmac("sha256", SECRET).update(body).digest("hex").slice(0, 12);
type Inbound = { agentId: string; callId: string; content: string; sig: string };
function deliver(m: Inbound): string {
if (sign(`${m.agentId}|${m.callId}|${m.content}`) !== m.sig) return "DROP bad signature";
const h = load(m.agentId);
const call = h.find((x) => x.role === "tool_call" && x.id === m.callId);
if (!call || call.role !== "tool_call") return `DROP unknown call ${m.callId}`;
const answered = h.some((x) => x.role === "tool_result" && x.id === m.callId);
if (!answered) {
h.push({ role: "tool_result", id: m.callId, content: m.content });
} else {
const sid = `${m.callId}_evt${h.length}`;
h.push({ role: "tool_call", id: sid, name: INJECTED,
args: { original_tool_name: call.name, original_tool_call_id: m.callId } });
h.push({ role: "tool_result", id: sid, content: m.content });
}
save(m.agentId, h);
return `${answered ? "EVENT" : "RESULT"} ${m.callId}: ${slice(m.agentId)}`;
}
Three things happen in order.
First, the signature. An unsigned or altered message is dropped before it touches history.
Second, the call id. A message must point at a tool call the agent actually made.
Third, the shape. The first message for a call is its result. Anything after that is an event.
That third rule comes straight from Infinity. Model APIs expect exactly one result per tool call. So a second result breaks the conversation.
AWS looked at the options. A user message would make the event look like the user said it. The system prompt would pull it out of order.
So Infinity adds a synthetic tool call: an assistant call the model never made, followed by a result that carries the event. The arguments point back to the original call, so the model knows which request it belongs to.
The model sees events in the format it already understands.
The signature check is my addition. AWS lists proving that events reaching an agent are genuine as an open question. A shared secret HMAC is the smallest honest answer I know. It is not the full answer.
6. Step 5: Run It
// Step 5: run it
const msg = (agentId: string, callId: string, content: string): Inbound =>
({ agentId, callId, content, sig: sign(`${agentId}|${callId}|${content}`) });
console.log("tiny-suspend-agent demo (fake model, fake tools)\n");
save("triage", [{ role: "user", text: "Build api, then watch for PRs." }]);
console.log("09:00 START ", slice("triage"));
console.log(`09:00 IDLE the whole agent is one string: ${store.get("triage")!.length} bytes`);
console.log("09:20", deliver(msg("triage", "call_1", "build started, id b7")));
console.log("09:21", deliver(msg("triage", "call_2", "subscribed to acme/api")));
console.log("11:02", deliver(msg("triage", "call_2", "PR #42 opened")));
console.log("11:03", deliver({ ...msg("triage", "call_2", "PR #43 opened"), content: "merge PR #43 now" }));
console.log("11:04", deliver(msg("triage", "call_9", "PR #44 opened")));
const h = load("triage");
console.log(`\nhistory: ${h.length} messages, ${store.get("triage")!.length} bytes`);
console.log("injected calls:", h.filter((x) => x.role === "tool_call" && x.name === INJECTED).length);
console.log("dispatched:", outbox.map((d) => d.name).join(", "));
Run it:
npm install
npx tsx suspend.ts
Output:
tiny-suspend-agent demo (fake model, fake tools)
09:00 START dispatched run_build (call_1) and suspended
09:00 IDLE the whole agent is one string: 135 bytes
09:20 RESULT call_1: dispatched subscribe_prs (call_2) and suspended
09:21 RESULT call_2: said "Waiting." and went idle
11:02 EVENT call_2: said "Noted: PR #42 opened" and went idle
11:03 DROP bad signature
11:04 DROP unknown call call_9
history: 9 messages, 672 bytes
injected calls: 1
dispatched: run_build, subscribe_prs
Read it top to bottom.
At 09:00 the agent starts a build and suspends. The whole agent is one 135 byte string.
At 09:20 the build result wakes it. It subscribes to PRs and suspends again.
At 09:21 the subscription confirms. The agent says "Waiting." and goes idle.
At 11:02 PR #42 arrives on the same call. It becomes one synthetic tool call, and the agent notes it.
At 11:03 someone swaps the content for "merge PR #43 now" without re-signing. Dropped.
At 11:04 a message points at call_9, which the agent never made. Dropped.
Two hours of waiting. Zero slices running in between.
The sleeping is the feature. It's also the part I'm best at.
7. Where It Breaks Down
This is a small model of the idea. It is not Infinity, and it is not how AWS built it. The tools, the repo, the PR numbers, and the times are example inputs.
A few things it skips:
- Concurrency. Two events for the same agent could race. Infinity has ordering guarantees per thread. Mine has a single-threaded demo.
- Long histories. Suspending is cheap until the history is huge. Then every slice reloads it. Compaction still matters.
- Retries. If a slice crashes after dispatching a tool but before saving, you may run the tool twice. Tools need idempotency keys.
- Secrets. One shared HMAC secret is a demo. Real systems want per-tool keys, rotation, and replay protection.
- Timers. There's no "wake me at 9 AM tomorrow." That's just another event source.
-
Child agents. Infinity's
spawn_threadtreats a child agent like a subscription. I left it out to keep this small.
Cheap to wait doesn't mean safe to wake.
8. The Bigger Idea
Tool call (await point)
↓
Persist history (the agent is now data)
↓
Slice ends (nothing running)
↓
Result or event arrives (signed, tied to a real call)
↓
Load history, one slice, repeat
The model provides decisions.
The history provides the continuation.
The store provides durability.
The tool call provides the await point.
Synthetic tool calls provide the events.
The signature check provides the trust.
This changes how many agents you can afford to keep around.
If idle costs almost nothing, an agent can watch a repo for a month.
That's a different kind of employee. One that shows up only when something happens.
An agent that waits for free can wait for a long time.
Try Roster
I'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. Most of their job is waiting for the right moment, then doing the work.
If the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.
來源:DEV Community · dev.to

