@fastagent-sh/fastagent 0.20.0 → 0.21.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.
Files changed (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +22 -31
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  [![node](https://img.shields.io/node/v/@fastagent-sh/fastagent.svg)](https://nodejs.org)
9
9
  [![built with pi](https://img.shields.io/badge/built%20with-pi-0b7285.svg)](https://pi.dev)
10
10
  [![GitHub stars](https://img.shields.io/github/stars/fastagent-sh/fastagent?style=social)](https://github.com/fastagent-sh/fastagent/stargazers)
11
+ [![follow @kid7st](https://img.shields.io/badge/follow-%40kid7st-000?logo=x)](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
- For agents paste this into Claude Code, Codex, Cursor, or any coding agent that reads the web:
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, stateRoot, { ...options.control, agent });
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
- /** 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. */
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, StateUrls } from "./agentcore-state.ts";
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 { timingSafeEqual } from "node:crypto";
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
- export function agentcoreRoutes(options) {
56
- const { channels, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
57
- // Lazy channel construction (see AgentcoreAdapterOptions.channels) resolved ONCE per process, on
58
- // the first trusted envelope after the state root is authoritative, and the outcome is cached
59
- // EITHER WAY. Success: the same resident channels a direct host keeps. Failure too: construction
60
- // is an ACTIVATION with side effects loadChannels builds every healthy channel (starting its
61
- // queues and replaying durable turn intent) before reporting another module's failureand there
62
- // is no cleanup contract to unwind it, so re-running it per envelope could replay the same
63
- // recovered turn concurrently. The first rejection is therefore the process's answer: every later
64
- // envelope fails with the same message (visible each time), the retry boundary is a fresh session
65
- // (which scale-to-zero provides naturally), and the deploy driver's probe catches deterministic
66
- // failures at deploy time.
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 constructionwhose 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
- const resolveDispatch = () => {
69
- if (!dispatchP) {
70
- // The factory runs INSIDE the chain: a synchronous throw must land in the cached rejection,
71
- // not escape before `dispatchP` is assigned (which would silently re-run the activation).
72
- dispatchP = Promise.resolve()
73
- .then(channels)
74
- .then((surface) => router(surface.routes, surface.mounts));
75
- dispatchP.catch(() => { }); // observed here so the CACHED rejection is never "unhandled"
76
- }
77
- return dispatchP;
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('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint" | "probe", ... }\n', 400);
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 = secretMatches(envelope.auth, ingressSecret);
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. A failed restore fails the request — serving an
132
- // empty agent (and then snapshotting that emptiness over the good copy) is the worse outcome.
133
- if (stateSync) {
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
- rememberUrl();
158
- // The state root is authoritative only now — anything that must READ it at startup (the wake-alarm
159
- // reconcile) runs here, once, rather than at boot against a mount the platform just wiped.
160
- if (onStateReady && !stateReadyFired) {
161
- stateReadyFired = true;
162
- onStateReady();
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
- // PROCESS INITIALIZATION, kind-independent: the (lazy) channels are constructed on the first
165
- // trusted ingress after the state root became authoritative whichever kind carries it, so a
166
- // cold start woken by a schedule fire or an alarm poke still replays checkpointed turn intent.
167
- // Two deliberate exceptions: `checkpoint` must push state even when a channel is broken, and a
168
- // public `invoke` runs in its own isolated storage constructing against THAT root would cache
169
- // pre-restore emptiness for the ingress session. Failure policy is per kind below: webhook and
170
- // wake-poke fail their request (503), the probe reports it structurally, and a schedule fire
171
- // proceeds cron does not consume channels, and letting an unrelated channel misconfiguration
172
- // silence the clock would turn one fault into two (the error is logged here either way).
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 resolveDispatch();
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 — the Phase 3 transport (design §13). Engine-neutral:
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