@fastagent-sh/fastagent 0.20.0 → 0.21.1
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 +5 -1
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +25 -37
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](https://nodejs.org)
|
|
9
9
|
[](https://pi.dev)
|
|
10
10
|
[](https://github.com/fastagent-sh/fastagent/stargazers)
|
|
11
|
+
[](https://x.com/kid7st)
|
|
11
12
|
|
|
12
13
|
<p align="center">
|
|
13
14
|
<sub>Built on</sub>
|
|
@@ -64,7 +65,9 @@ FastAgent stays a small serving layer, so it never dictates your stack. Capabili
|
|
|
64
65
|
|
|
65
66
|
## Install
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
Start with the [agent development guide](https://github.com/fastagent-sh/fastagent/blob/main/docs/ai-start.md), the authoring entry point for both humans and coding agents. It covers responsibilities, TypeScript tools, verification, channels, scheduling, and deployment. FastAgent's repository `AGENTS.md` is for maintainers.
|
|
69
|
+
|
|
70
|
+
To work with Claude Code, Codex, Cursor, or another coding agent, paste:
|
|
68
71
|
|
|
69
72
|
> Read https://fastagent.sh/start.md and build an agent in this project.
|
|
70
73
|
|
|
@@ -138,6 +141,7 @@ const agent = createPiAgent({
|
|
|
138
141
|
|
|
139
142
|
| Document | Purpose |
|
|
140
143
|
|---|---|
|
|
144
|
+
| [Agent development guide](https://github.com/fastagent-sh/fastagent/blob/main/docs/ai-start.md) | Canonical authoring path for humans and coding agents |
|
|
141
145
|
| [Documentation index](https://fastagent.sh/docs/) | Documentation map |
|
|
142
146
|
| [Quickstart](https://fastagent.sh/docs/quickstart/) | Scaffold, run, add a tool, and start |
|
|
143
147
|
| [Configuration](https://fastagent.sh/docs/configuration/) | Configure model, auth, ports, sessions, tools, and channels |
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire between the forwarder Lambda and the container, in ONE place: the envelope every
|
|
3
|
+
* trigger arrives as, the reply a webhook rides back in, the presigned-URL pair the state snapshot
|
|
4
|
+
* uses, the wake-alarm request, and the forwarder's reserved paths. The forwarder itself is JavaScript
|
|
5
|
+
* (`deploy/agentcore/forwarder.js`) and cannot import this, so `agentcore-forwarder.test.ts` pins its
|
|
6
|
+
* literals to these — a rename here fails there, not on a live box.
|
|
7
|
+
*
|
|
8
|
+
* Pure types and constants: the adapter, the state sync, the wake sink and the deploy driver all
|
|
9
|
+
* read it, and none of them may pull the others in for it.
|
|
10
|
+
*/
|
|
11
|
+
/** Paths the forwarder answers ITSELF — never forwarded to a channel route. */
|
|
12
|
+
export declare const RESERVED_PATHS: {
|
|
13
|
+
/** The deploy driver's post-deploy verification (ingress secret). */
|
|
14
|
+
readonly probe: "/__fastagent/probe";
|
|
15
|
+
/** The container's wake-alarm mirror callback (wake secret). */
|
|
16
|
+
readonly wakeAlarm: "/__fastagent/wake-alarm";
|
|
17
|
+
/** Re-mint the state snapshot's presigned URLs with current Lambda credentials (ingress secret). */
|
|
18
|
+
readonly stateUrls: "/__fastagent/state-urls";
|
|
19
|
+
};
|
|
20
|
+
/** Presigned S3 URLs for the one snapshot object, minted per envelope by the forwarder. */
|
|
21
|
+
export interface StateUrls {
|
|
22
|
+
getUrl: string;
|
|
23
|
+
putUrl: string;
|
|
24
|
+
/** Authenticated forwarder callback that re-mints URLs with current Lambda credentials. */
|
|
25
|
+
refresh?: {
|
|
26
|
+
url: string;
|
|
27
|
+
auth: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Every kind the container's `POST /invocations` dispatches on. Listed as a value so the forwarder
|
|
31
|
+
* pin test can check each one is spelled the same on the other side. */
|
|
32
|
+
export declare const ENVELOPE_KINDS: readonly ["webhook", "schedule-fire", "invoke", "wake-poke", "checkpoint", "probe"];
|
|
33
|
+
/** What the forwarder Lambda / EventBridge deliver in the `/invocations` payload. Every kind may
|
|
34
|
+
* carry `wake` — the forwarder's self-resolved public URL, which the adapter persists so the wake
|
|
35
|
+
* ALARM sink (schedule/wake-alarm.ts) can call back without the URL being baked anywhere. */
|
|
36
|
+
export type AgentcoreEnvelope = {
|
|
37
|
+
/** Shared secret proving this envelope came from the forwarder (FASTAGENT_INGRESS_SECRET). The
|
|
38
|
+
* public `invoke` data plane neither has nor needs it — and may not carry the fields below. */
|
|
39
|
+
auth?: string;
|
|
40
|
+
wake?: {
|
|
41
|
+
url: string;
|
|
42
|
+
};
|
|
43
|
+
state?: StateUrls;
|
|
44
|
+
} & ({
|
|
45
|
+
kind: "webhook";
|
|
46
|
+
/** Original webhook request line, verbatim. `path` must be absolute ("/telegram"). */
|
|
47
|
+
method: string;
|
|
48
|
+
path: string;
|
|
49
|
+
/** Original raw query string (no leading `?`) — "verbatim" includes it; a channel reading
|
|
50
|
+
* `request.url.searchParams` must see what the webhook sender sent. */
|
|
51
|
+
query?: string;
|
|
52
|
+
/** Original headers — signature material (secret tokens, Feishu signatures) rides here. */
|
|
53
|
+
headers?: Record<string, string>;
|
|
54
|
+
/** Original body, base64 (webhook bodies are JSON but the tunnel must be byte-exact). */
|
|
55
|
+
bodyB64?: string;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "schedule-fire";
|
|
58
|
+
name: string;
|
|
59
|
+
/** The cron instant this fire is FOR (ISO) — the slot-idempotency key. */
|
|
60
|
+
slot: string;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "invoke";
|
|
63
|
+
session: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}
|
|
66
|
+
/** An EventBridge wake-up poke: the invocation ITSELF is the payload — it wakes the container,
|
|
67
|
+
* whose boot drain / 30s wake pump then fires whatever is due. The handler only acks. */
|
|
68
|
+
| {
|
|
69
|
+
kind: "wake-poke";
|
|
70
|
+
}
|
|
71
|
+
/** Pre-stop checkpoint (`--run`, right before stop-runtime-session): push the state snapshot NOW.
|
|
72
|
+
* A stop cuts an in-flight turn, and its durable turn intent — written pre-ACK by every replaying
|
|
73
|
+
* channel — lives on a mount the version update is about to erase. Flushing first is what makes
|
|
74
|
+
* "channels with replay re-run it" true rather than aspirational. */
|
|
75
|
+
| {
|
|
76
|
+
kind: "checkpoint";
|
|
77
|
+
}
|
|
78
|
+
/** The deploy driver's post-deploy verification (relayed by the forwarder's reserved probe path,
|
|
79
|
+
* which answers on EVERY forwarder topology — schedule-only URLs refuse ordinary public traffic).
|
|
80
|
+
* Runs restore + channel construction end to end and answers a TRANSPORT-200 structured verdict
|
|
81
|
+
* `{ ok, error? }`: the ordinary webhook path folds a non-200 transport into an opaque 502 at the
|
|
82
|
+
* forwarder, which would strip exactly the diagnostics this probe exists to carry. */
|
|
83
|
+
| {
|
|
84
|
+
kind: "probe";
|
|
85
|
+
});
|
|
86
|
+
/** The webhook envelope's reply: the channel's real HTTP response, ridden inside a transport-200
|
|
87
|
+
* body so the forwarder can re-emit it verbatim (AgentCore folds a container non-2xx into its own
|
|
88
|
+
* 424 RuntimeClientError). */
|
|
89
|
+
export interface WebhookReply {
|
|
90
|
+
status: number;
|
|
91
|
+
headers: Record<string, string>;
|
|
92
|
+
bodyB64: string;
|
|
93
|
+
}
|
|
94
|
+
/** One desired alarm: mirror of a pending wake-up (id names the EventBridge schedule; at = fireAt). */
|
|
95
|
+
export interface WakeAlarm {
|
|
96
|
+
id: string;
|
|
97
|
+
at: string;
|
|
98
|
+
}
|
|
99
|
+
/** The wire shape the wake sink POSTs to {@link RESERVED_PATHS.wakeAlarm} (the forwarder validates `secret`). */
|
|
100
|
+
export interface WakeAlarmRequest {
|
|
101
|
+
secret: string;
|
|
102
|
+
alarms: WakeAlarm[];
|
|
103
|
+
}
|
|
104
|
+
/** What EventBridge hands the forwarder for a cron slot; `slot` is `<aws.scheduler.scheduled-time>`.
|
|
105
|
+
* Its sibling, the wake-alarm poke `{ wakePoke: true }`, is minted and read inside the forwarder
|
|
106
|
+
* alone (`syncAlarms` writes it, the handler reads it), so it has no type here. */
|
|
107
|
+
export interface ScheduleFireEvent {
|
|
108
|
+
scheduleFire: {
|
|
109
|
+
name: string;
|
|
110
|
+
slot: string;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire between the forwarder Lambda and the container, in ONE place: the envelope every
|
|
3
|
+
* trigger arrives as, the reply a webhook rides back in, the presigned-URL pair the state snapshot
|
|
4
|
+
* uses, the wake-alarm request, and the forwarder's reserved paths. The forwarder itself is JavaScript
|
|
5
|
+
* (`deploy/agentcore/forwarder.js`) and cannot import this, so `agentcore-forwarder.test.ts` pins its
|
|
6
|
+
* literals to these — a rename here fails there, not on a live box.
|
|
7
|
+
*
|
|
8
|
+
* Pure types and constants: the adapter, the state sync, the wake sink and the deploy driver all
|
|
9
|
+
* read it, and none of them may pull the others in for it.
|
|
10
|
+
*/
|
|
11
|
+
/** Paths the forwarder answers ITSELF — never forwarded to a channel route. */
|
|
12
|
+
export const RESERVED_PATHS = {
|
|
13
|
+
/** The deploy driver's post-deploy verification (ingress secret). */
|
|
14
|
+
probe: "/__fastagent/probe",
|
|
15
|
+
/** The container's wake-alarm mirror callback (wake secret). */
|
|
16
|
+
wakeAlarm: "/__fastagent/wake-alarm",
|
|
17
|
+
/** Re-mint the state snapshot's presigned URLs with current Lambda credentials (ingress secret). */
|
|
18
|
+
stateUrls: "/__fastagent/state-urls",
|
|
19
|
+
};
|
|
20
|
+
/** Every kind the container's `POST /invocations` dispatches on. Listed as a value so the forwarder
|
|
21
|
+
* pin test can check each one is spelled the same on the other side. */
|
|
22
|
+
export const ENVELOPE_KINDS = ["webhook", "schedule-fire", "invoke", "wake-poke", "checkpoint", "probe"];
|
|
@@ -25,10 +25,6 @@ export interface MountAgentcoreServiceOptions {
|
|
|
25
25
|
/** Runs once the state snapshot is restored. The wake-alarm reconcile passes through here because
|
|
26
26
|
* its sink is a PROCESS-global: the process entry owns that, not a service that can be closed. */
|
|
27
27
|
onStateReady?: () => void;
|
|
28
|
-
control?: {
|
|
29
|
-
tunnel?: boolean;
|
|
30
|
-
host?: string;
|
|
31
|
-
};
|
|
32
28
|
}
|
|
33
29
|
/** Is this process running inside the AgentCore Runtime? Set by the generated deploy artifacts. */
|
|
34
30
|
export declare function isAgentcoreRuntime(): boolean;
|
|
@@ -14,7 +14,7 @@ export async function mountAgentcoreService(opened, options = {}) {
|
|
|
14
14
|
const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
|
|
15
15
|
// The control plane mounts over an EMPTY route surface: the lazy channels join it later, and the
|
|
16
16
|
// collision rule runs again then (below) against what they actually brought.
|
|
17
|
-
const withControl = mountSessionControl({}, sessionControl,
|
|
17
|
+
const withControl = mountSessionControl({}, sessionControl, { agent });
|
|
18
18
|
const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule, {
|
|
19
19
|
externalClock: true,
|
|
20
20
|
});
|
|
@@ -42,7 +42,6 @@ export async function mountAgentcoreService(opened, options = {}) {
|
|
|
42
42
|
});
|
|
43
43
|
const handler = router(adapterRoutes, withControl.mounts);
|
|
44
44
|
log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
|
|
45
|
-
let unannounce;
|
|
46
45
|
return {
|
|
47
46
|
handler,
|
|
48
47
|
agent,
|
|
@@ -56,16 +55,12 @@ export async function mountAgentcoreService(opened, options = {}) {
|
|
|
56
55
|
schedules: scheduled.schedules,
|
|
57
56
|
ready: Promise.resolve(), // nothing to open: no port of our own, no resident connections
|
|
58
57
|
...(withControl.control ? { control: withControl.control } : {}),
|
|
59
|
-
announce(boundPort) {
|
|
60
|
-
unannounce = withControl.announce(boundPort);
|
|
61
|
-
},
|
|
62
58
|
async close() {
|
|
63
59
|
// UNTESTED, deliberately noted: no test observes these timers being cleared. Installing fake
|
|
64
60
|
// timers early enough to count them deadlocks the assembly's own IO. What IS tested is that
|
|
65
61
|
// close() runs and is idempotent; the stop itself rides on scheduler.stop()'s own tests.
|
|
66
62
|
scheduled.stop();
|
|
67
63
|
closed.abort();
|
|
68
|
-
unannounce?.(); // a stale discovery file would point `attach` at a stopped service
|
|
69
64
|
},
|
|
70
65
|
};
|
|
71
66
|
}
|
|
@@ -19,21 +19,15 @@
|
|
|
19
19
|
* half-applied state root is far worse than a slightly stale one.
|
|
20
20
|
*/
|
|
21
21
|
import { Buffer } from "node:buffer";
|
|
22
|
+
import type { StateUrls } from "./agentcore-protocol.ts";
|
|
22
23
|
/** Snapshot envelope version — an unknown version fails the restore loudly (never a silent skip). */
|
|
23
24
|
export declare const SNAPSHOT_VERSION = 1;
|
|
24
25
|
/** Refuse to pack beyond this (before gzip): a runaway state root would OOM the microVM silently. */
|
|
25
26
|
export declare const MAX_SNAPSHOT_BYTES: number;
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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. */
|
|
27
|
+
/** Pack the whole state root into one gzipped snapshot object.
|
|
28
|
+
* ponytail: the WHOLE root, every idle edge — O(state size) per settled turn, and session jsonl
|
|
29
|
+
* grows without bound. Upgrade path when the 16 MiB warning is real for someone: a per-file
|
|
30
|
+
* manifest with content hashes, uploading only what changed. */
|
|
37
31
|
export declare function packStateRoot(stateRoot: string, maxBytes?: number): Promise<Buffer>;
|
|
38
32
|
/** Apply a snapshot over the state root. Returns how many files were written. */
|
|
39
33
|
export declare function unpackIntoStateRoot(stateRoot: string, packed: Buffer): Promise<number>;
|
|
@@ -72,7 +72,10 @@ async function walk(root, dir = root, out = []) {
|
|
|
72
72
|
}
|
|
73
73
|
return out;
|
|
74
74
|
}
|
|
75
|
-
/** Pack the whole state root into one gzipped snapshot object.
|
|
75
|
+
/** Pack the whole state root into one gzipped snapshot object.
|
|
76
|
+
* ponytail: the WHOLE root, every idle edge — O(state size) per settled turn, and session jsonl
|
|
77
|
+
* grows without bound. Upgrade path when the 16 MiB warning is real for someone: a per-file
|
|
78
|
+
* manifest with content hashes, uploading only what changed. */
|
|
76
79
|
export async function packStateRoot(stateRoot, maxBytes = MAX_SNAPSHOT_BYTES) {
|
|
77
80
|
const files = {};
|
|
78
81
|
let raw = 0;
|
|
@@ -1,69 +1,8 @@
|
|
|
1
1
|
import type { Agent } from "../agent.ts";
|
|
2
|
-
import type { StateSync
|
|
2
|
+
import type { StateSync } from "./agentcore-state.ts";
|
|
3
3
|
import type { Routes } from "../channel.ts";
|
|
4
4
|
import { type PrefixMount } from "../channels/serve.ts";
|
|
5
5
|
import type { ScheduleFireOutcome } from "../schedule/scheduler.ts";
|
|
6
|
-
/** What the forwarder Lambda / EventBridge deliver in the `/invocations` payload. Every kind may
|
|
7
|
-
* carry `wake` — the forwarder's self-resolved public URL, which the adapter persists so the wake
|
|
8
|
-
* ALARM sink (schedule/wake-alarm.ts) can call back without the URL being baked anywhere. */
|
|
9
|
-
export type AgentcoreEnvelope = {
|
|
10
|
-
/** Shared secret proving this envelope came from the forwarder (FASTAGENT_INGRESS_SECRET). The
|
|
11
|
-
* public `invoke` data plane neither has nor needs it — and may not carry the fields below. */
|
|
12
|
-
auth?: string;
|
|
13
|
-
wake?: {
|
|
14
|
-
url: string;
|
|
15
|
-
};
|
|
16
|
-
state?: StateUrls;
|
|
17
|
-
} & ({
|
|
18
|
-
kind: "webhook";
|
|
19
|
-
/** Original webhook request line, verbatim. `path` must be absolute ("/telegram"). */
|
|
20
|
-
method: string;
|
|
21
|
-
path: string;
|
|
22
|
-
/** Original raw query string (no leading `?`) — "verbatim" includes it; a channel reading
|
|
23
|
-
* `request.url.searchParams` must see what the webhook sender sent. */
|
|
24
|
-
query?: string;
|
|
25
|
-
/** Original headers — signature material (secret tokens, Feishu signatures) rides here. */
|
|
26
|
-
headers?: Record<string, string>;
|
|
27
|
-
/** Original body, base64 (webhook bodies are JSON but the tunnel must be byte-exact). */
|
|
28
|
-
bodyB64?: string;
|
|
29
|
-
} | {
|
|
30
|
-
kind: "schedule-fire";
|
|
31
|
-
name: string;
|
|
32
|
-
/** The cron instant this fire is FOR (ISO) — the slot-idempotency key. */
|
|
33
|
-
slot: string;
|
|
34
|
-
} | {
|
|
35
|
-
kind: "invoke";
|
|
36
|
-
session: string;
|
|
37
|
-
text: string;
|
|
38
|
-
}
|
|
39
|
-
/** An EventBridge wake-up poke: the invocation ITSELF is the payload — it wakes the container,
|
|
40
|
-
* whose boot drain / 30s wake pump then fires whatever is due. The handler only acks. */
|
|
41
|
-
| {
|
|
42
|
-
kind: "wake-poke";
|
|
43
|
-
}
|
|
44
|
-
/** Pre-stop checkpoint (`--run`, right before stop-runtime-session): push the state snapshot NOW.
|
|
45
|
-
* A stop cuts an in-flight turn, and its durable turn intent — written pre-ACK by every replaying
|
|
46
|
-
* channel — lives on a mount the version update is about to erase. Flushing first is what makes
|
|
47
|
-
* "channels with replay re-run it" true rather than aspirational. */
|
|
48
|
-
| {
|
|
49
|
-
kind: "checkpoint";
|
|
50
|
-
}
|
|
51
|
-
/** The deploy driver's post-deploy verification (relayed by the forwarder's reserved
|
|
52
|
-
* `/__fastagent/probe` path, which answers on EVERY forwarder topology — schedule-only URLs
|
|
53
|
-
* refuse ordinary public traffic). Runs restore + channel construction end to end and answers a
|
|
54
|
-
* TRANSPORT-200 structured verdict `{ ok, error? }`: the ordinary webhook path folds a non-200
|
|
55
|
-
* transport into an opaque 502 at the forwarder, which would strip exactly the diagnostics this
|
|
56
|
-
* probe exists to carry. */
|
|
57
|
-
| {
|
|
58
|
-
kind: "probe";
|
|
59
|
-
});
|
|
60
|
-
/** The webhook envelope's reply: the channel's real HTTP response, ridden inside a transport-200
|
|
61
|
-
* body so the forwarder can re-emit it verbatim (see the module header on AgentCore's 424 folding). */
|
|
62
|
-
export interface WebhookReply {
|
|
63
|
-
status: number;
|
|
64
|
-
headers: Record<string, string>;
|
|
65
|
-
bodyB64: string;
|
|
66
|
-
}
|
|
67
6
|
/** What the lazy factory hands back: literal routes plus any prefix-owning mounts (the control
|
|
68
7
|
* plane), so the adapter's INNER dispatch is assembled exactly like a direct host's. */
|
|
69
8
|
export interface RouteSurface {
|
|
@@ -101,11 +40,6 @@ export interface AgentcoreAdapterOptions {
|
|
|
101
40
|
* at boot would see the mount the platform just wiped and conclude there is nothing pending. */
|
|
102
41
|
onStateReady?: () => void;
|
|
103
42
|
}
|
|
104
|
-
/**
|
|
105
|
-
* Build the AgentCore serving surface: `{ "POST /invocations", "GET /ping" }` — the whole of what the
|
|
106
|
-
* platform routes into the container. The agent's own channels are not beside these: they are a table
|
|
107
|
-
* inside the envelope dispatch below, reached only by unwrapping a forwarder envelope.
|
|
108
|
-
*/
|
|
109
43
|
export declare function agentcoreRoutes(options: AgentcoreAdapterOptions): Routes;
|
|
110
44
|
/** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
|
|
111
45
|
* not have — the adapter maps it to 404 (deploy drift stays visible in the external clock's logs). */
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* the platform reclaim the microVM (that idle-to-zero IS the point of this deployment).
|
|
27
27
|
*/
|
|
28
28
|
import { Buffer } from "node:buffer";
|
|
29
|
-
import {
|
|
29
|
+
import { ENVELOPE_KINDS } from "./agentcore-protocol.js";
|
|
30
30
|
import { beginWork, onIdle } from "./busy.js";
|
|
31
31
|
import { router } from "../channels/serve.js";
|
|
32
32
|
import { log } from "../log.js";
|
|
@@ -34,48 +34,84 @@ import { rememberWakeAlarmUrl } from "../schedule/wake-alarm.js";
|
|
|
34
34
|
import { readBodyCapped } from "./body.js";
|
|
35
35
|
import { createInvokeHandler } from "./http.js";
|
|
36
36
|
import { text } from "./respond.js";
|
|
37
|
+
import { secretEquals } from "./secret.js";
|
|
37
38
|
import { MAX_ENVELOPE_BYTES, MAX_WEBHOOK_BODY_BYTES } from "./agentcore-limits.js";
|
|
38
39
|
const unsnapshottedWarning = "[agentcore] this envelope carried no state-snapshot URLs — the state root is LOCAL ONLY and the " +
|
|
39
40
|
"platform erases it on the next deploy (redeploy with a current fastagent to restore durability)";
|
|
40
41
|
const jsonHeaders = { "content-type": "application/json" };
|
|
41
42
|
const json = (body, status) => new Response(`${JSON.stringify(body)}\n`, { status, headers: jsonHeaders });
|
|
42
|
-
/** Compare an untrusted envelope secret without leaking a matching-prefix timing signal. */
|
|
43
|
-
function secretMatches(actual, expected) {
|
|
44
|
-
if (typeof actual !== "string" || expected === undefined)
|
|
45
|
-
return false;
|
|
46
|
-
const actualBytes = Buffer.from(actual);
|
|
47
|
-
const expectedBytes = Buffer.from(expected);
|
|
48
|
-
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
49
|
-
}
|
|
50
43
|
/**
|
|
51
44
|
* Build the AgentCore serving surface: `{ "POST /invocations", "GET /ping" }` — the whole of what the
|
|
52
45
|
* platform routes into the container. The agent's own channels are not beside these: they are a table
|
|
53
46
|
* inside the envelope dispatch below, reached only by unwrapping a forwarder envelope.
|
|
54
47
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The process's BOOT, deferred to ingress. A resident host restores its state, wires its sinks and
|
|
50
|
+
* constructs its channels at start-up; here nothing before the first trusted envelope is
|
|
51
|
+
* authoritative (the mount is pre-restore, and the snapshot URLs only an envelope carries), so those
|
|
52
|
+
* steps run against the first one and are asserted again on every later one. Two things happen ONCE
|
|
53
|
+
* per process, in this order: the post-restore hook (the wake-alarm reconcile, which at boot would
|
|
54
|
+
* read the mount the platform just wiped), and channel construction — whose outcome is cached EITHER
|
|
55
|
+
* WAY. Success: the same resident channels a direct host keeps. Failure too: construction is an
|
|
56
|
+
* ACTIVATION with side effects — loadChannels builds every healthy channel (starting its queues and
|
|
57
|
+
* replaying durable turn intent) before reporting another module's failure — and there is no cleanup
|
|
58
|
+
* contract to unwind it, so re-running it per envelope could replay the same recovered turn
|
|
59
|
+
* concurrently. The first rejection is the process's answer: every later envelope fails with the same
|
|
60
|
+
* message (visible each time), the retry boundary is a fresh session (which scale-to-zero provides
|
|
61
|
+
* naturally), and the deploy driver's probe catches deterministic failures at deploy time.
|
|
62
|
+
*/
|
|
63
|
+
function createActivation(deps) {
|
|
64
|
+
const { stateSync, stateRoot, onStateReady } = deps;
|
|
65
|
+
let warnedUnsnapshotted = false;
|
|
66
|
+
let stateReadyFired = false;
|
|
67
67
|
let dispatchP;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
return {
|
|
69
|
+
async restore(envelope) {
|
|
70
|
+
if (stateSync) {
|
|
71
|
+
if (envelope.state && typeof envelope.state.getUrl === "string" && typeof envelope.state.putUrl === "string") {
|
|
72
|
+
stateSync.use(envelope.state);
|
|
73
|
+
}
|
|
74
|
+
else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
|
|
75
|
+
// webhook/schedule-fire/wake-poke/probe reach us ONLY through the forwarder, which mints the
|
|
76
|
+
// pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
|
|
77
|
+
// once per process (a direct `invoke` legitimately has none — its session storage is its own).
|
|
78
|
+
warnedUnsnapshotted = true;
|
|
79
|
+
log.warn(unsnapshottedWarning);
|
|
80
|
+
}
|
|
81
|
+
await stateSync.ready();
|
|
82
|
+
}
|
|
83
|
+
// The forwarder rides its public URL along on every envelope — persist it (write-if-changed) so
|
|
84
|
+
// the wake-alarm sink can call back. AFTER the restore: a stale snapshot copy must not win over
|
|
85
|
+
// the URL this deployment is actually reachable at. A bad persist must not fail the turn.
|
|
86
|
+
if (typeof envelope.wake?.url === "string") {
|
|
87
|
+
try {
|
|
88
|
+
rememberWakeAlarmUrl(stateRoot, envelope.wake.url);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
log.error(`[agentcore] could not persist the wake-alarm URL: ${String(e)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (onStateReady && !stateReadyFired) {
|
|
95
|
+
stateReadyFired = true;
|
|
96
|
+
onStateReady();
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
channels() {
|
|
100
|
+
if (!dispatchP) {
|
|
101
|
+
// The factory runs INSIDE the chain: a synchronous throw must land in the cached rejection,
|
|
102
|
+
// not escape before `dispatchP` is assigned (which would silently re-run the activation).
|
|
103
|
+
dispatchP = Promise.resolve()
|
|
104
|
+
.then(deps.channels)
|
|
105
|
+
.then((surface) => router(surface.routes, surface.mounts));
|
|
106
|
+
dispatchP.catch(() => { }); // observed here so the CACHED rejection is never "unhandled"
|
|
107
|
+
}
|
|
108
|
+
return dispatchP;
|
|
109
|
+
},
|
|
78
110
|
};
|
|
111
|
+
}
|
|
112
|
+
export function agentcoreRoutes(options) {
|
|
113
|
+
const { channels, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
|
|
114
|
+
const activation = createActivation({ stateSync, stateRoot, onStateReady, channels });
|
|
79
115
|
const invokeHandler = createInvokeHandler(agent);
|
|
80
116
|
// Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
|
|
81
117
|
// background, so "the request returned" is NOT when the state root settles.
|
|
@@ -83,8 +119,6 @@ export function agentcoreRoutes(options) {
|
|
|
83
119
|
const off = onIdle(() => stateSync.save());
|
|
84
120
|
options.signal?.addEventListener("abort", off, { once: true });
|
|
85
121
|
}
|
|
86
|
-
let warnedUnsnapshotted = false;
|
|
87
|
-
let stateReadyFired = false;
|
|
88
122
|
const handleInvocation = async (req) => {
|
|
89
123
|
const body = await readBodyCapped(req, MAX_ENVELOPE_BYTES);
|
|
90
124
|
if ("tooLarge" in body)
|
|
@@ -97,7 +131,7 @@ export function agentcoreRoutes(options) {
|
|
|
97
131
|
return text("invalid json\n", 400);
|
|
98
132
|
}
|
|
99
133
|
if (envelope === null || typeof envelope !== "object" || typeof envelope.kind !== "string") {
|
|
100
|
-
return text(
|
|
134
|
+
return text(`need { "kind": ${ENVELOPE_KINDS.map((k) => `"${k}"`).join(" | ")}, ... }\n`, 400);
|
|
101
135
|
}
|
|
102
136
|
// AUTHENTICATION BOUNDARY. `InvokeAgentRuntime` is an ordinary IAM action, so "reached this
|
|
103
137
|
// handler" proves nothing about the sender. Only an envelope carrying the shared secret is the
|
|
@@ -105,7 +139,7 @@ export function agentcoreRoutes(options) {
|
|
|
105
139
|
// and may NOT carry internal fields — riding a `state` or `wake` URL on a public invoke would
|
|
106
140
|
// redirect the state snapshot (auth.json) or the alarm callback (the wake secret) to the caller.
|
|
107
141
|
// Internal fields are DROPPED rather than rejected: a public caller has no business knowing them.
|
|
108
|
-
const trusted =
|
|
142
|
+
const trusted = secretEquals(envelope.auth, ingressSecret);
|
|
109
143
|
if (!trusted) {
|
|
110
144
|
if (envelope.kind !== "invoke") {
|
|
111
145
|
log.warn(`[agentcore] rejected an unauthenticated "${envelope.kind}" envelope`);
|
|
@@ -114,67 +148,34 @@ export function agentcoreRoutes(options) {
|
|
|
114
148
|
envelope.wake = undefined;
|
|
115
149
|
envelope.state = undefined;
|
|
116
150
|
}
|
|
117
|
-
// The forwarder rides its public URL along on every envelope — persist it (write-if-changed) so
|
|
118
|
-
// the wake-alarm sink can call back. Written AFTER the restore below: a stale snapshot copy must
|
|
119
|
-
// not win over the URL this deployment is actually reachable at. A bad persist must not fail the turn.
|
|
120
|
-
const rememberUrl = () => {
|
|
121
|
-
if (typeof envelope.wake?.url !== "string")
|
|
122
|
-
return;
|
|
123
|
-
try {
|
|
124
|
-
rememberWakeAlarmUrl(stateRoot, envelope.wake.url);
|
|
125
|
-
}
|
|
126
|
-
catch (e) {
|
|
127
|
-
log.error(`[agentcore] could not persist the wake-alarm URL: ${String(e)}`);
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
151
|
// Cross-deploy state: the platform wipes /mnt/state on every version update, so the durable copy
|
|
131
|
-
// must be pulled back BEFORE anything reads it.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (envelope.state && typeof envelope.state.getUrl === "string" && typeof envelope.state.putUrl === "string") {
|
|
135
|
-
stateSync.use(envelope.state);
|
|
136
|
-
}
|
|
137
|
-
else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
|
|
138
|
-
// webhook/schedule-fire/wake-poke/probe reach us ONLY through the forwarder, which mints the
|
|
139
|
-
// pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
|
|
140
|
-
// once per process (a direct `invoke` legitimately has none — its session storage is its own).
|
|
141
|
-
warnedUnsnapshotted = true;
|
|
142
|
-
log.warn(unsnapshottedWarning);
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
await stateSync.ready();
|
|
146
|
-
}
|
|
147
|
-
catch (e) {
|
|
148
|
-
log.error(`[agentcore] state restore failed: ${String(e)}`);
|
|
149
|
-
// The probe is the deploy driver's verification channel: its diagnostics must survive the
|
|
150
|
-
// forwarder, which folds a non-200 transport into an opaque 502 — so for it the failure
|
|
151
|
-
// rides a transport-200 structured verdict; every other kind keeps the plain 503.
|
|
152
|
-
if (envelope.kind === "probe")
|
|
153
|
-
return json({ ok: false, error: `state restore failed: ${String(e)}` }, 200);
|
|
154
|
-
return text(`state restore failed: ${String(e)}\n`, 503);
|
|
155
|
-
}
|
|
152
|
+
// must be pulled back BEFORE anything reads it.
|
|
153
|
+
try {
|
|
154
|
+
await activation.restore(envelope);
|
|
156
155
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
156
|
+
catch (e) {
|
|
157
|
+
log.error(`[agentcore] state restore failed: ${String(e)}`);
|
|
158
|
+
// The probe is the deploy driver's verification channel: its diagnostics must survive the
|
|
159
|
+
// forwarder, which folds a non-200 transport into an opaque 502 — so for it the failure
|
|
160
|
+
// rides a transport-200 structured verdict; every other kind keeps the plain 503.
|
|
161
|
+
if (envelope.kind === "probe")
|
|
162
|
+
return json({ ok: false, error: `state restore failed: ${String(e)}` }, 200);
|
|
163
|
+
return text(`state restore failed: ${String(e)}\n`, 503);
|
|
163
164
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
165
|
+
// The channels come up on the first trusted ingress after the state root became authoritative —
|
|
166
|
+
// whichever kind carries it, so a cold start woken by a schedule fire or an alarm poke still
|
|
167
|
+
// replays checkpointed turn intent. Two deliberate exceptions: `checkpoint` must push state even
|
|
168
|
+
// when a channel is broken, and a public `invoke` runs in its own isolated storage — constructing
|
|
169
|
+
// against THAT root would cache pre-restore emptiness for the ingress session. Failure policy is
|
|
170
|
+
// per kind below: webhook and wake-poke fail their request (503), the probe reports it
|
|
171
|
+
// structurally, and a schedule fire proceeds — cron does not consume channels, and letting an
|
|
172
|
+
// unrelated channel misconfiguration silence the clock would turn one fault into two (the error
|
|
173
|
+
// is logged here either way).
|
|
173
174
|
let constructionError;
|
|
174
175
|
let dispatch;
|
|
175
176
|
if (trusted && envelope.kind !== "checkpoint" && envelope.kind !== "invoke") {
|
|
176
177
|
try {
|
|
177
|
-
dispatch = await
|
|
178
|
+
dispatch = await activation.channels();
|
|
178
179
|
}
|
|
179
180
|
catch (e) {
|
|
180
181
|
constructionError = String(e);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The session control plane over HTTP + SSE
|
|
2
|
+
* The session control plane over HTTP + SSE (docs/design/session-control.md §13). Engine-neutral:
|
|
3
3
|
* consumes only the `SessionControl` contract. One transport serves every remote consumer (Web
|
|
4
4
|
* panel, desktop app, `fastagent attach`); the embedded API stays semantic-only and the ENVELOPE
|
|
5
5
|
* lives here: `id` (request correlation — implicit in HTTP), `epoch` (serving-process incarnation
|