@fastagent-sh/fastagent 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +5 -0
- package/dist/channels/context-buffer.js +5 -0
- package/dist/channels/feishu/context-buffer.d.ts +13 -8
- package/dist/channels/feishu/context-buffer.js +60 -6
- package/dist/channels/feishu/feishu-api.js +4 -1
- package/dist/channels/feishu/feishu.d.ts +0 -12
- package/dist/channels/feishu/feishu.js +143 -54
- package/dist/channels/feishu/invoke-turn.js +35 -18
- package/dist/channels/feishu/model.d.ts +0 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +21 -7
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.js +3 -2
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +30 -1
- package/dist/channels/feishu/setup-mode.js +27 -1
- package/dist/channels/github/github.js +8 -1
- package/dist/channels/http.js +1 -1
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +7 -1
- package/dist/channels/preview-kit.js +3 -2
- package/dist/channels/slack/parse.d.ts +16 -1
- package/dist/channels/slack/parse.js +46 -3
- package/dist/channels/slack/preview.d.ts +1 -2
- package/dist/channels/slack/preview.js +68 -24
- package/dist/channels/slack/scaffold/channel.ts +5 -5
- package/dist/channels/slack/slack-api.d.ts +3 -23
- package/dist/channels/slack/slack-api.js +6 -22
- package/dist/channels/slack/slack.d.ts +13 -20
- package/dist/channels/slack/slack.js +95 -50
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +19 -12
- package/dist/channels/tasks.d.ts +0 -6
- package/dist/channels/tasks.js +16 -1
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +1 -1
- package/dist/channels/text.d.ts +14 -0
- package/dist/channels/text.js +14 -0
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +7 -0
- package/dist/cli/add-feishu.d.ts +7 -4
- package/dist/cli/add-feishu.js +57 -37
- package/dist/cli/add-slack.d.ts +2 -1
- package/dist/cli/add-slack.js +6 -11
- package/dist/cli/commands/add.js +50 -51
- package/dist/cli/commands/attach.js +8 -4
- package/dist/cli/commands/chat.js +8 -8
- package/dist/cli/commands/deploy.d.ts +1 -1
- package/dist/cli/commands/deploy.js +323 -77
- package/dist/cli/commands/dev.js +24 -22
- package/dist/cli/commands/fire.js +15 -16
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +9 -6
- package/dist/cli/commands/login.js +35 -21
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +93 -37
- package/dist/cli/commands/tool.js +22 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +57 -36
- package/dist/cli/serve.d.ts +26 -6
- package/dist/cli/serve.js +62 -15
- package/dist/cli/shared.d.ts +15 -2
- package/dist/cli/shared.js +30 -18
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +1 -1
- package/dist/deploy/docker/plan.js +12 -17
- package/dist/deploy/fly/plan.d.ts +2 -0
- package/dist/deploy/fly/plan.js +27 -19
- package/dist/deploy/fly/run.d.ts +12 -1
- package/dist/deploy/fly/run.js +36 -2
- package/dist/deploy/preflight.d.ts +11 -5
- package/dist/deploy/preflight.js +217 -65
- package/dist/deploy/railway/plan.d.ts +7 -0
- package/dist/deploy/railway/plan.js +41 -16
- package/dist/deploy/railway/run.d.ts +8 -1
- package/dist/deploy/railway/run.js +7 -2
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +9 -3
- package/dist/dev-supervisor.d.ts +11 -8
- package/dist/dev-supervisor.js +53 -51
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +1 -1
- package/dist/engines/pi/channel.js +5 -5
- package/dist/engines/pi/chat.js +2 -2
- package/dist/engines/pi/config.d.ts +6 -46
- package/dist/engines/pi/config.js +21 -108
- package/dist/engines/pi/create.d.ts +8 -8
- package/dist/engines/pi/create.js +13 -12
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/login.d.ts +1 -1
- package/dist/engines/pi/models.d.ts +3 -3
- package/dist/engines/pi/models.js +1 -1
- package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +11 -11
- package/dist/engines/pi/tool.js +4 -0
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +2 -2
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +2 -2
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +7 -3
- package/dist/scaffold/add-channel.js +55 -29
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +1 -1
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -0
- package/dist/schedule/wakeups.js +18 -0
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.js +7 -7
- package/package.json +4 -1
- package/dist/channels/feishu/owned-threads.d.ts +0 -7
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/slack/owned-threads.d.ts +0 -6
- package/dist/channels/slack/owned-threads.js +0 -43
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
[](https://github.com/fastagent-sh/fastagent/blob/main/LICENSE)
|
|
8
8
|
[](https://nodejs.org)
|
|
9
9
|
[](https://pi.dev)
|
|
10
|
+
[](https://github.com/fastagent-sh/fastagent/stargazers)
|
|
10
11
|
|
|
11
12
|
<p align="center">
|
|
12
13
|
<sub>Built on</sub>
|
|
@@ -39,7 +40,7 @@ FastAgent is the missing bridge from local agent directory to live service.
|
|
|
39
40
|
- **Channels.** Serve the same agent as a GitHub PR reviewer, a Telegram bot, a Feishu or Lark bot, an HTTP/SSE endpoint, or your own adapter: verified webhooks, streaming replies, group-aware.
|
|
40
41
|
- **Models, tools & skills.** Any model provider (OpenAI, Anthropic, Google, …) via OAuth or API key; typed tools discovered from `tools/` (the filename is the name, Zod-validated); Agent Skills loaded on demand. Built on the open-source [pi](https://github.com/earendil-works/pi) harness.
|
|
41
42
|
- **App embedding — your stack, we plug in.** Mount the agent in your Next / Astro / Hono / Bun / Node route with one handler, or call `invoke` like any function from your own code — your auth, your database, your infra. FastAgent composes with your app, never owns it.
|
|
42
|
-
- **Deploy anywhere.** No application build step — the directory is the deployable unit. `fastagent deploy docker|fly|railway` generates the container + target config and a runbook (`--run` drives it to completion). Local Docker gets user-owned Compose + durable state; optional `--tunnel` adds an ephemeral Quick Tunnel service for webhook channels. Durable ingress remains yours.
|
|
43
|
+
- **Deploy anywhere.** No application build step — the directory is the deployable unit. `fastagent deploy docker|fly|railway|agentcore` generates the container + target config and a runbook (`--run` drives it to completion). Local Docker gets user-owned Compose + durable state; optional `--tunnel` adds an ephemeral Quick Tunnel service for webhook channels; AWS Bedrock AgentCore gets a one-stack CloudFormation topology (webhooks via a forwarder Lambda, schedules via EventBridge). Durable ingress remains yours.
|
|
43
44
|
|
|
44
45
|
## Design philosophy
|
|
45
46
|
|
|
@@ -160,7 +161,7 @@ The root export intentionally contains the supported surface only.
|
|
|
160
161
|
|---|---|---|
|
|
161
162
|
| Contract | `Agent`, `AgentEvent`, `collect` | Stable within SPEC v0.1 |
|
|
162
163
|
| Channels/host | `createInvokeHandler`, `nodeListener`, `serveNode`, `router`, `Routes` | Reference implementation, pre-1.0 |
|
|
163
|
-
| pi assembly | `
|
|
164
|
+
| pi assembly | `createPiAgentFromDir`, `createPiAgentFromDefinition`, `createPiAgent` | Usable now, may tighten before 1.0 |
|
|
164
165
|
| Tool/channel authoring | `defineTool`, `z`, `loadTools`, `loadChannels`, `ChannelModule` | Usable now, may tighten before 1.0 |
|
|
165
166
|
| Injection ports | `PiSessionStore`, `inMemorySessionStore`, `jsonlSessionStore`, `Lease`, `Provider`, `createProvider` | Public because options reference them |
|
|
166
167
|
| Not exported | L0 harness adapter, pi harness factory, prompt/config internals | Internal modules; no compatibility promise |
|
|
@@ -198,10 +199,12 @@ The neutral contract leaves room for capabilities that are not complete product
|
|
|
198
199
|
- **Sandboxed execution** — `ExecutionEnv` is an assembly seam, but the pi coding tools and project-context loader are still local; a complete sandbox adapter is future work.
|
|
199
200
|
- **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
|
|
200
201
|
- **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
|
|
201
|
-
- **More deploy targets** — local Docker, Fly, and
|
|
202
|
+
- **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
|
|
202
203
|
|
|
203
204
|
See [Contributing](https://github.com/fastagent-sh/fastagent/blob/main/CONTRIBUTING.md) if one of these is the problem you want to work on.
|
|
204
205
|
|
|
206
|
+
> ☁️ **Prefer these managed?** FastAgent Cloud will run your agents with multi-instance durability, scale-to-zero, and observability built in — and self-hosting stays free forever. [Join the waitlist →](https://tally.so/r/44DVMB)
|
|
207
|
+
|
|
205
208
|
## Project
|
|
206
209
|
|
|
207
210
|
- [Contributing](https://github.com/fastagent-sh/fastagent/blob/main/CONTRIBUTING.md)
|
|
@@ -215,6 +218,13 @@ It also depends on, and is grateful to, [zod](https://github.com/colinhacks/zod)
|
|
|
215
218
|
|
|
216
219
|
The scaffolded `writing-great-skills` skill is vendored from [mattpocock/skills](https://github.com/mattpocock/skills), with its license included.
|
|
217
220
|
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
<p align="center">
|
|
224
|
+
<b>FastAgent is built in the open.</b><br/>
|
|
225
|
+
If it saves you from rewriting an agent into yet another framework, <a href="https://github.com/fastagent-sh/fastagent">give it a ⭐</a> — it's the fastest way to help other devs find it.
|
|
226
|
+
</p>
|
|
227
|
+
|
|
218
228
|
## License
|
|
219
229
|
|
|
220
230
|
[MIT](https://github.com/fastagent-sh/fastagent/blob/main/LICENSE). Runtime dependencies use permissive open-source licenses and are installed as separate npm packages; the vendored `writing-great-skills` scaffold includes its own license.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maximum original webhook body after reserving JSON-envelope overhead and base64 expansion.
|
|
3
|
+
* Enforced by both the public forwarder and the runtime adapter.
|
|
4
|
+
*/
|
|
5
|
+
export declare const MAX_WEBHOOK_BODY_BYTES: number;
|
|
6
|
+
/** Largest complete AgentCore envelope accepted by the runtime adapter. */
|
|
7
|
+
export declare const MAX_ENVELOPE_BYTES: number;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** AWS Lambda Function URLs accept request payloads up to 6 MB. */
|
|
2
|
+
const FUNCTION_URL_REQUEST_LIMIT = 6 * 1000 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Maximum original webhook body after reserving JSON-envelope overhead and base64 expansion.
|
|
5
|
+
* Enforced by both the public forwarder and the runtime adapter.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_WEBHOOK_BODY_BYTES = Math.floor((FUNCTION_URL_REQUEST_LIMIT * 3) / 4) - (64 << 10);
|
|
8
|
+
/** Largest complete AgentCore envelope accepted by the runtime adapter. */
|
|
9
|
+
export const MAX_ENVELOPE_BYTES = FUNCTION_URL_REQUEST_LIMIT;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-deploy durability for the state root on AgentCore.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: AgentCore's managed SessionStorage — the `/mnt/state` mount — is reset on every
|
|
5
|
+
* runtime VERSION UPDATE, i.e. on EVERY deploy ("On runtime version update: Data wiped — fresh file
|
|
6
|
+
* system on next invoke", AWS file-system configuration docs), and again after 14 idle days. The
|
|
7
|
+
* mount is therefore a fast LOCAL disk, not the source of truth. Without this module a deploy
|
|
8
|
+
* silently resurrects the agent with no sessions, no channel dedup, no pending wake-ups — and the
|
|
9
|
+
* wake ALARM, which lives in the operator's EventBridge and survives independently, still fires into
|
|
10
|
+
* that empty store: a miss with no error anywhere (exactly the silent-failure class the repo's
|
|
11
|
+
* fail-visibly rule exists to prevent).
|
|
12
|
+
*
|
|
13
|
+
* The durable copy is ONE S3 object reached through PRESIGNED URLS minted per-envelope by the
|
|
14
|
+
* forwarder Lambda. The container holds no AWS credentials (the platform injects none — verified on
|
|
15
|
+
* a live deployment) and stays AWS-SDK-free: a snapshot is one `fetch` GET and one `fetch` PUT.
|
|
16
|
+
*
|
|
17
|
+
* Format: gzip(JSON `{ v, files: { relPath: base64 } }`). Deliberately NOT tar — the state root is a
|
|
18
|
+
* handful of small JSON/JSONL files, and a single self-describing object makes restore ATOMIC: a
|
|
19
|
+
* half-applied state root is far worse than a slightly stale one.
|
|
20
|
+
*/
|
|
21
|
+
import { Buffer } from "node:buffer";
|
|
22
|
+
/** Snapshot envelope version — an unknown version fails the restore loudly (never a silent skip). */
|
|
23
|
+
export declare const SNAPSHOT_VERSION = 1;
|
|
24
|
+
/** Refuse to pack beyond this (before gzip): a runaway state root would OOM the microVM silently. */
|
|
25
|
+
export declare const MAX_SNAPSHOT_BYTES: number;
|
|
26
|
+
/** Presigned S3 URLs for the one snapshot object, minted per envelope by the forwarder. */
|
|
27
|
+
export interface StateUrls {
|
|
28
|
+
getUrl: string;
|
|
29
|
+
putUrl: string;
|
|
30
|
+
/** Authenticated forwarder callback that re-mints URLs with current Lambda credentials. */
|
|
31
|
+
refresh?: {
|
|
32
|
+
url: string;
|
|
33
|
+
auth: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Pack the whole state root into one gzipped snapshot object. */
|
|
37
|
+
export declare function packStateRoot(stateRoot: string, maxBytes?: number): Promise<Buffer>;
|
|
38
|
+
/** Apply a snapshot over the state root. Returns how many files were written. */
|
|
39
|
+
export declare function unpackIntoStateRoot(stateRoot: string, packed: Buffer): Promise<number>;
|
|
40
|
+
export interface StateSyncOptions {
|
|
41
|
+
stateRoot: string;
|
|
42
|
+
/** Injected in tests. */
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The per-process snapshot lifecycle: restore ONCE before anything reads the state root, then push a
|
|
47
|
+
* coalesced snapshot whenever work settles.
|
|
48
|
+
*/
|
|
49
|
+
export interface StateSync {
|
|
50
|
+
/** Remember the newest presigned URLs (each envelope carries a fresh pair). */
|
|
51
|
+
use(urls: StateUrls): void;
|
|
52
|
+
/** Resolve once the state root is authoritative. REJECTS if a snapshot exists but cannot be
|
|
53
|
+
* restored — the caller must fail the request rather than serve from an empty state root (and a
|
|
54
|
+
* failed restore also blocks {@link StateSync.save}, so bad state is never written back).
|
|
55
|
+
* Resolves immediately while no URLs are known: a direct programmatic invoke runs in its OWN
|
|
56
|
+
* isolated session/storage and must neither read nor overwrite the ingress snapshot. */
|
|
57
|
+
ready(): Promise<void>;
|
|
58
|
+
/** Whether snapshotting is active (URLs seen). Lets the caller flag a forwarder envelope that
|
|
59
|
+
* arrived WITHOUT them — a topology fault that would otherwise lose state silently. */
|
|
60
|
+
configured(): boolean;
|
|
61
|
+
/** Request a snapshot upload; coalesces while one is in flight. Errors are logged, not thrown —
|
|
62
|
+
* the next settle retries, and the local mount still holds the data until the version changes. */
|
|
63
|
+
save(): void;
|
|
64
|
+
/** Await the in-flight (and any queued) upload — the shutdown/test seam. */
|
|
65
|
+
flush(): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Push a snapshot NOW and report whether one was actually written — the pre-stop checkpoint
|
|
68
|
+
* (`--run`, before `stop-runtime-session`). Distinct from {@link StateSync.save} on both counts:
|
|
69
|
+
* it forces a fresh round rather than joining a coalescing window (the caller is about to lose
|
|
70
|
+
* this process, so "an upload from a second ago" is not good enough), and it THROWS on failure
|
|
71
|
+
* instead of logging, because the whole point of the call is to know.
|
|
72
|
+
*
|
|
73
|
+
* `written: false` is a legitimate outcome, not an error: a session that never served a forwarder
|
|
74
|
+
* envelope has no URLs and nothing of the shared state to write. The caller must not report that
|
|
75
|
+
* as a successful checkpoint — it is exactly the case where an operator would otherwise believe an
|
|
76
|
+
* in-flight turn had been protected.
|
|
77
|
+
*/
|
|
78
|
+
checkpoint(): Promise<{
|
|
79
|
+
written: boolean;
|
|
80
|
+
reason?: string;
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
export declare function createStateSync(options: StateSyncOptions): StateSync;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-deploy durability for the state root on AgentCore.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: AgentCore's managed SessionStorage — the `/mnt/state` mount — is reset on every
|
|
5
|
+
* runtime VERSION UPDATE, i.e. on EVERY deploy ("On runtime version update: Data wiped — fresh file
|
|
6
|
+
* system on next invoke", AWS file-system configuration docs), and again after 14 idle days. The
|
|
7
|
+
* mount is therefore a fast LOCAL disk, not the source of truth. Without this module a deploy
|
|
8
|
+
* silently resurrects the agent with no sessions, no channel dedup, no pending wake-ups — and the
|
|
9
|
+
* wake ALARM, which lives in the operator's EventBridge and survives independently, still fires into
|
|
10
|
+
* that empty store: a miss with no error anywhere (exactly the silent-failure class the repo's
|
|
11
|
+
* fail-visibly rule exists to prevent).
|
|
12
|
+
*
|
|
13
|
+
* The durable copy is ONE S3 object reached through PRESIGNED URLS minted per-envelope by the
|
|
14
|
+
* forwarder Lambda. The container holds no AWS credentials (the platform injects none — verified on
|
|
15
|
+
* a live deployment) and stays AWS-SDK-free: a snapshot is one `fetch` GET and one `fetch` PUT.
|
|
16
|
+
*
|
|
17
|
+
* Format: gzip(JSON `{ v, files: { relPath: base64 } }`). Deliberately NOT tar — the state root is a
|
|
18
|
+
* handful of small JSON/JSONL files, and a single self-describing object makes restore ATOMIC: a
|
|
19
|
+
* half-applied state root is far worse than a slightly stale one.
|
|
20
|
+
*/
|
|
21
|
+
import { Buffer } from "node:buffer";
|
|
22
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
23
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
24
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
25
|
+
import { log } from "../log.js";
|
|
26
|
+
import { beginWork } from "./busy.js";
|
|
27
|
+
/** Snapshot envelope version — an unknown version fails the restore loudly (never a silent skip). */
|
|
28
|
+
export const SNAPSHOT_VERSION = 1;
|
|
29
|
+
/** Refuse to pack beyond this (before gzip): a runaway state root would OOM the microVM silently. */
|
|
30
|
+
export const MAX_SNAPSHOT_BYTES = 64 << 20;
|
|
31
|
+
/** Warn past this — the operator should know the snapshot is getting expensive to round-trip. */
|
|
32
|
+
const WARN_SNAPSHOT_BYTES = 16 << 20;
|
|
33
|
+
/** Upload deadline. Generous (a large snapshot on a cold network) but finite. */
|
|
34
|
+
const PUT_TIMEOUT_MS = 60_000;
|
|
35
|
+
/**
|
|
36
|
+
* Files the snapshot must NOT carry. `control.json` is a PER-BOOT artifact (this process's control
|
|
37
|
+
* URL + token, written once the port is known): snapshotting it would hand the next boot a file
|
|
38
|
+
* advertising a dead endpoint and a token that no longer matches the one in memory.
|
|
39
|
+
*
|
|
40
|
+
* Everything else is durable and restores VERBATIM — including `auth.json`. The deploy seeds that
|
|
41
|
+
* file from the builder machine, but the box's own copy is the one that has been REFRESHED, and this
|
|
42
|
+
* snapshot is its volume: the same rule every other host states ("a credential already refreshed on
|
|
43
|
+
* the volume is never overwritten"). The seed is bootstrap for a snapshot that has none.
|
|
44
|
+
*/
|
|
45
|
+
const EXCLUDED = new Set(["control.json"]);
|
|
46
|
+
/** Every regular file under `root`, as root-relative POSIX paths (stable across platforms). */
|
|
47
|
+
async function walk(root, dir = root, out = []) {
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e.code === "ENOENT")
|
|
54
|
+
return out;
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const full = join(dir, entry.name);
|
|
59
|
+
// Symlinks/sockets/FIFOs are skipped on purpose: the state root holds plain files, and
|
|
60
|
+
// AgentCore's session storage does not support special files anyway.
|
|
61
|
+
if (entry.isDirectory())
|
|
62
|
+
await walk(root, full, out);
|
|
63
|
+
else if (entry.isFile())
|
|
64
|
+
out.push(relative(root, full).split(sep).join("/"));
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Pack the whole state root into one gzipped snapshot object. */
|
|
69
|
+
export async function packStateRoot(stateRoot, maxBytes = MAX_SNAPSHOT_BYTES) {
|
|
70
|
+
const files = {};
|
|
71
|
+
let raw = 0;
|
|
72
|
+
for (const rel of await walk(stateRoot)) {
|
|
73
|
+
if (EXCLUDED.has(rel))
|
|
74
|
+
continue;
|
|
75
|
+
const content = await readFile(join(stateRoot, rel));
|
|
76
|
+
raw += content.byteLength;
|
|
77
|
+
if (raw > maxBytes) {
|
|
78
|
+
throw new Error(`state root exceeds ${maxBytes} bytes — it cannot be snapshotted for cross-deploy durability`);
|
|
79
|
+
}
|
|
80
|
+
files[rel] = content.toString("base64");
|
|
81
|
+
}
|
|
82
|
+
if (raw > WARN_SNAPSHOT_BYTES) {
|
|
83
|
+
log.warn(`[agentcore] state snapshot is large (${Math.round(raw / (1 << 20))} MiB) — every turn round-trips it`);
|
|
84
|
+
}
|
|
85
|
+
return gzipSync(Buffer.from(JSON.stringify({ v: SNAPSHOT_VERSION, files })));
|
|
86
|
+
}
|
|
87
|
+
/** Apply a snapshot over the state root. Returns how many files were written. */
|
|
88
|
+
export async function unpackIntoStateRoot(stateRoot, packed) {
|
|
89
|
+
let snapshot;
|
|
90
|
+
try {
|
|
91
|
+
snapshot = JSON.parse(gunzipSync(packed).toString());
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
throw new Error(`state snapshot is unreadable (${String(e)})`);
|
|
95
|
+
}
|
|
96
|
+
if (snapshot?.v !== SNAPSHOT_VERSION || typeof snapshot.files !== "object" || snapshot.files === null) {
|
|
97
|
+
throw new Error(`state snapshot has an unsupported shape (v=${String(snapshot?.v)})`);
|
|
98
|
+
}
|
|
99
|
+
let written = 0;
|
|
100
|
+
for (const [rel, b64] of Object.entries(snapshot.files)) {
|
|
101
|
+
// A snapshot is written by this same code, but it arrives over the network: refuse anything
|
|
102
|
+
// that could escape the state root.
|
|
103
|
+
if (rel.startsWith("/") || rel.split("/").includes("..")) {
|
|
104
|
+
throw new Error(`state snapshot contains an unsafe path (${rel})`);
|
|
105
|
+
}
|
|
106
|
+
if (EXCLUDED.has(rel))
|
|
107
|
+
continue; // never write a per-boot artifact from an older boot
|
|
108
|
+
const target = join(stateRoot, rel);
|
|
109
|
+
await mkdir(dirname(target), { recursive: true });
|
|
110
|
+
await writeFile(target, Buffer.from(b64, "base64"));
|
|
111
|
+
written += 1;
|
|
112
|
+
}
|
|
113
|
+
return written;
|
|
114
|
+
}
|
|
115
|
+
export function createStateSync(options) {
|
|
116
|
+
const { stateRoot } = options;
|
|
117
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
118
|
+
let urls;
|
|
119
|
+
let restore;
|
|
120
|
+
let restored = false;
|
|
121
|
+
let saving;
|
|
122
|
+
let queued = false;
|
|
123
|
+
// Whether the upload loop is still able to pick up another round. Counting the upload as in-flight
|
|
124
|
+
// work (below) means ITS completion is itself a 0-in-flight edge, which re-enters save() — without
|
|
125
|
+
// this the snapshot would queue a redundant follow-up after every single upload.
|
|
126
|
+
let looping = false;
|
|
127
|
+
const runRestore = async (urls) => {
|
|
128
|
+
const res = await doFetch(urls.getUrl, { method: "GET" });
|
|
129
|
+
// ONLY a proven 404 is "first deploy". The signer holds s3:GetObject on exactly this key, so a
|
|
130
|
+
// missing object answers NoSuchKey/404; a 403 means an expired or malformed signature, or a
|
|
131
|
+
// revoked permission — i.e. the snapshot may well exist. Reading 403 as "absent" would serve an
|
|
132
|
+
// empty agent and then overwrite the real snapshot with that emptiness.
|
|
133
|
+
if (res.status === 404) {
|
|
134
|
+
log.info("[agentcore] no state snapshot yet — starting from an empty state root (first deploy)");
|
|
135
|
+
restored = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
throw new Error(`state snapshot GET failed: ${res.status}`);
|
|
140
|
+
const written = await unpackIntoStateRoot(stateRoot, Buffer.from(await res.arrayBuffer()));
|
|
141
|
+
log.info(`[agentcore] restored ${written} state file(s) from the snapshot`);
|
|
142
|
+
restored = true;
|
|
143
|
+
};
|
|
144
|
+
const refreshUrls = async () => {
|
|
145
|
+
if (!urls?.refresh)
|
|
146
|
+
return;
|
|
147
|
+
const res = await doFetch(urls.refresh.url, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ auth: urls.refresh.auth }),
|
|
151
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
throw new Error(`state snapshot URL refresh failed: ${res.status}`);
|
|
155
|
+
const fresh = (await res.json());
|
|
156
|
+
if (typeof fresh.getUrl !== "string" || typeof fresh.putUrl !== "string") {
|
|
157
|
+
throw new Error("state snapshot URL refresh returned an invalid response");
|
|
158
|
+
}
|
|
159
|
+
urls = { ...urls, getUrl: fresh.getUrl, putUrl: fresh.putUrl };
|
|
160
|
+
};
|
|
161
|
+
const runSave = async () => {
|
|
162
|
+
// Counted as in-flight work for its whole duration: `save()` fires on the 0-in-flight edge, the
|
|
163
|
+
// exact moment /ping starts answering Healthy — without this the platform may reclaim the microVM
|
|
164
|
+
// mid-upload and the turn that just finished is lost with only a log line. Bounded too: a hung
|
|
165
|
+
// PUT would otherwise pin `saving` forever and block every later snapshot.
|
|
166
|
+
const workDone = beginWork();
|
|
167
|
+
looping = true;
|
|
168
|
+
try {
|
|
169
|
+
do {
|
|
170
|
+
queued = false;
|
|
171
|
+
if (!restored || !urls)
|
|
172
|
+
return;
|
|
173
|
+
// A webhook turn may settle hours after its envelope. Re-mint immediately before every PUT
|
|
174
|
+
// instead of assuming the Lambda credentials that signed the envelope outlive the turn.
|
|
175
|
+
await refreshUrls();
|
|
176
|
+
const body = await packStateRoot(stateRoot);
|
|
177
|
+
const res = await doFetch(urls.putUrl, {
|
|
178
|
+
method: "PUT",
|
|
179
|
+
body: new Uint8Array(body),
|
|
180
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
const hint = res.status === 403 ? " (presigned URL or its temporary signing credentials may have expired)" : "";
|
|
184
|
+
throw new Error(`state snapshot PUT failed: ${res.status}${hint}`);
|
|
185
|
+
}
|
|
186
|
+
} while (queued);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
looping = false;
|
|
190
|
+
workDone();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
use(next) {
|
|
195
|
+
urls = next;
|
|
196
|
+
},
|
|
197
|
+
configured() {
|
|
198
|
+
return urls !== undefined;
|
|
199
|
+
},
|
|
200
|
+
ready() {
|
|
201
|
+
// No URLs = not an ingress envelope (a direct invoke has its own isolated storage): nothing to
|
|
202
|
+
// restore, and deliberately NOT cached, so the first envelope that does carry them still runs
|
|
203
|
+
// the restore.
|
|
204
|
+
if (!urls)
|
|
205
|
+
return Promise.resolve();
|
|
206
|
+
// One attempt per process otherwise: a rejected restore stays rejected so every subsequent
|
|
207
|
+
// envelope fails the same visible way instead of quietly serving an empty agent.
|
|
208
|
+
restore ??= runRestore(urls);
|
|
209
|
+
return restore;
|
|
210
|
+
},
|
|
211
|
+
save() {
|
|
212
|
+
if (!restored)
|
|
213
|
+
return; // never overwrite a good snapshot with a state root we failed to fill
|
|
214
|
+
if (saving) {
|
|
215
|
+
if (looping)
|
|
216
|
+
queued = true; // otherwise this is the upload's own completion edge, not new work
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
saving = runSave()
|
|
220
|
+
.catch((e) => {
|
|
221
|
+
log.error(`[agentcore] could not save the state snapshot: ${String(e)} — retrying when work next settles`);
|
|
222
|
+
})
|
|
223
|
+
.finally(() => {
|
|
224
|
+
saving = undefined;
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
async flush() {
|
|
228
|
+
while (saving)
|
|
229
|
+
await saving;
|
|
230
|
+
},
|
|
231
|
+
async checkpoint() {
|
|
232
|
+
if (!urls) {
|
|
233
|
+
return { written: false, reason: "this session has never served a forwarder envelope" };
|
|
234
|
+
}
|
|
235
|
+
if (!restored) {
|
|
236
|
+
return { written: false, reason: "the state root is not authoritative here (nothing restored)" };
|
|
237
|
+
}
|
|
238
|
+
// Never overlap an in-flight upload, then run a FRESH one: joining the running round could
|
|
239
|
+
// return before the bytes written moments ago (the interrupted turn's intent) are included.
|
|
240
|
+
while (saving)
|
|
241
|
+
await saving;
|
|
242
|
+
const run = runSave();
|
|
243
|
+
// Stored form never rejects (an unawaited rejection would be an unhandled crash); the caller
|
|
244
|
+
// awaits `run` itself and gets the error.
|
|
245
|
+
saving = run.then(() => { }, () => { });
|
|
246
|
+
const settle = saving.finally(() => {
|
|
247
|
+
saving = undefined;
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
await run;
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await settle;
|
|
254
|
+
}
|
|
255
|
+
return { written: true };
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Agent } from "../agent.ts";
|
|
2
|
+
import type { StateSync, StateUrls } from "./agentcore-state.ts";
|
|
3
|
+
import type { Routes } from "../host/node.ts";
|
|
4
|
+
import type { ScheduleFireOutcome } from "../schedule/scheduler.ts";
|
|
5
|
+
/**
|
|
6
|
+
* The HOST's webhook body limit, and the one place it is computed. Lambda Function URLs cap a request
|
|
7
|
+
* at 6 MB, so the forwarder cannot deliver more than that no matter what the adapter accepts; the
|
|
8
|
+
* body arrives base64-encoded (×4/3) inside a JSON envelope, so the ORIGINAL body ceiling is smaller
|
|
9
|
+
* still. This is a real capability difference from a resident host — the GitHub channel's own
|
|
10
|
+
* contract is 25 MiB — so `deploy agentcore` says so at plan time rather than letting an oversized
|
|
11
|
+
* payload surface as an opaque 502.
|
|
12
|
+
*/
|
|
13
|
+
/** What the forwarder Lambda / EventBridge deliver in the `/invocations` payload. Every kind may
|
|
14
|
+
* carry `wake` — the forwarder's self-resolved public URL, which the adapter persists so the wake
|
|
15
|
+
* ALARM sink (schedule/wake-alarm.ts) can call back without the URL being baked anywhere. */
|
|
16
|
+
export type AgentcoreEnvelope = {
|
|
17
|
+
/** Shared secret proving this envelope came from the forwarder (FASTAGENT_INGRESS_SECRET). The
|
|
18
|
+
* public `invoke` data plane neither has nor needs it — and may not carry the fields below. */
|
|
19
|
+
auth?: string;
|
|
20
|
+
wake?: {
|
|
21
|
+
url: string;
|
|
22
|
+
};
|
|
23
|
+
state?: StateUrls;
|
|
24
|
+
} & ({
|
|
25
|
+
kind: "webhook";
|
|
26
|
+
/** Original webhook request line, verbatim. `path` must be absolute ("/telegram"). */
|
|
27
|
+
method: string;
|
|
28
|
+
path: string;
|
|
29
|
+
/** Original raw query string (no leading `?`) — "verbatim" includes it; a channel reading
|
|
30
|
+
* `request.url.searchParams` must see what the webhook sender sent. */
|
|
31
|
+
query?: string;
|
|
32
|
+
/** Original headers — signature material (secret tokens, Feishu signatures) rides here. */
|
|
33
|
+
headers?: Record<string, string>;
|
|
34
|
+
/** Original body, base64 (webhook bodies are JSON but the tunnel must be byte-exact). */
|
|
35
|
+
bodyB64?: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "schedule-fire";
|
|
38
|
+
name: string;
|
|
39
|
+
/** The cron instant this fire is FOR (ISO) — the slot-idempotency key. */
|
|
40
|
+
slot: string;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "invoke";
|
|
43
|
+
session: string;
|
|
44
|
+
text: string;
|
|
45
|
+
}
|
|
46
|
+
/** An EventBridge wake-up poke: the invocation ITSELF is the payload — it wakes the container,
|
|
47
|
+
* whose boot drain / 30s wake pump then fires whatever is due. The handler only acks. */
|
|
48
|
+
| {
|
|
49
|
+
kind: "wake-poke";
|
|
50
|
+
}
|
|
51
|
+
/** Pre-stop checkpoint (`--run`, right before stop-runtime-session): push the state snapshot NOW.
|
|
52
|
+
* A stop cuts an in-flight turn, and its durable turn intent — written pre-ACK by every replaying
|
|
53
|
+
* channel — lives on a mount the version update is about to erase. Flushing first is what makes
|
|
54
|
+
* "channels with replay re-run it" true rather than aspirational. */
|
|
55
|
+
| {
|
|
56
|
+
kind: "checkpoint";
|
|
57
|
+
});
|
|
58
|
+
/** The webhook envelope's reply: the channel's real HTTP response, ridden inside a transport-200
|
|
59
|
+
* body so the forwarder can re-emit it verbatim (see the module header on AgentCore's 424 folding). */
|
|
60
|
+
export interface WebhookReply {
|
|
61
|
+
status: number;
|
|
62
|
+
headers: Record<string, string>;
|
|
63
|
+
bodyB64: string;
|
|
64
|
+
}
|
|
65
|
+
export interface AgentcoreAdapterOptions {
|
|
66
|
+
/** The serving routes a direct deployment would mount (channels or the builtin invoke + health). */
|
|
67
|
+
routes: Routes;
|
|
68
|
+
agent: Agent;
|
|
69
|
+
/** Where the forwarder URL from envelopes is persisted for the wake-alarm sink (the state root). */
|
|
70
|
+
stateRoot: string;
|
|
71
|
+
/** Process-wide background-work signal (busy.ts `activeWork() > 0`) — injected for tests. */
|
|
72
|
+
isBusy: () => boolean;
|
|
73
|
+
/** Slot-idempotent schedule fire ({@link fireScheduleOnce} bound to this workspace's schedules);
|
|
74
|
+
* undefined when the workspace has none — a schedule-fire envelope then 404s (deploy drift: an
|
|
75
|
+
* external clock still firing for a schedule this definition no longer has). */
|
|
76
|
+
fire?: (name: string, slot: Date) => Promise<ScheduleFireOutcome>;
|
|
77
|
+
/** Cross-deploy state durability (agentcore-state.ts). Absent = the state root is local-only,
|
|
78
|
+
* which on AgentCore means it is erased by the next deploy — the serving path always wires it. */
|
|
79
|
+
stateSync?: StateSync;
|
|
80
|
+
/** FASTAGENT_INGRESS_SECRET: what makes an envelope the FORWARDER's rather than any IAM principal's.
|
|
81
|
+
* Undefined = nothing can be trusted, so only the public `invoke` kind is served. */
|
|
82
|
+
ingressSecret?: string;
|
|
83
|
+
/** Runs ONCE, after the state root is authoritative (post-restore) — the wake-alarm reconcile, which
|
|
84
|
+
* at boot would see the mount the platform just wiped and conclude there is nothing pending. */
|
|
85
|
+
onStateReady?: () => void;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build the AgentCore serving surface: `{ "POST /invocations", "GET /ping" }`. The caller merges it
|
|
89
|
+
* over its routes (collision-checked at the mount site, serve.ts) — the inner routes stay mounted
|
|
90
|
+
* too, which is harmless (AgentCore routes only /invocations and /ping into the container) and keeps
|
|
91
|
+
* a local `curl` debug surface.
|
|
92
|
+
*/
|
|
93
|
+
export declare function agentcoreRoutes(options: AgentcoreAdapterOptions): Routes;
|
|
94
|
+
/** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
|
|
95
|
+
* not have — the adapter maps it to 404 (deploy drift stays visible in the external clock's logs). */
|
|
96
|
+
export declare class UnknownScheduleError extends Error {
|
|
97
|
+
constructor(name: string);
|
|
98
|
+
}
|