@fastagent-sh/fastagent 0.15.0 → 0.16.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 +14 -4
- package/dist/bind.d.ts +34 -0
- package/dist/bind.js +74 -0
- 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 +266 -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 +14 -1
- package/dist/cli/commands/deploy.js +330 -78
- package/dist/cli/commands/dev.d.ts +1 -0
- package/dist/cli/commands/dev.js +38 -25
- 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.d.ts +1 -0
- package/dist/cli/commands/start.js +101 -37
- package/dist/cli/commands/tool.js +30 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +66 -36
- package/dist/cli/serve.d.ts +52 -8
- package/dist/cli/serve.js +133 -32
- package/dist/cli/shared.d.ts +21 -2
- package/dist/cli/shared.js +44 -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 +235 -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 +12 -48
- package/dist/engines/pi/config.js +29 -110
- package/dist/engines/pi/create.d.ts +32 -24
- package/dist/engines/pi/create.js +47 -23
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +19 -5
- package/dist/engines/pi/harness.js +3 -5
- 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} +31 -25
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/read-image.d.ts +4 -0
- package/dist/engines/pi/read-image.js +62 -0
- package/dist/engines/pi/search-tools.d.ts +6 -4
- package/dist/engines/pi/search-tools.js +3 -1
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +18 -13
- package/dist/engines/pi/tool.d.ts +13 -5
- package/dist/engines/pi/tool.js +4 -0
- package/dist/engines/pi/wake-tool.d.ts +3 -3
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +4 -2
- package/dist/host/node.js +2 -1
- 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 +2 -2
- 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 +7 -4
- 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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS AgentCore Runtime adapter: serve fastagent's whole HTTP surface through the Runtime's service
|
|
3
|
+
* contract. AgentCore gives a container exactly TWO paths — `POST /invocations` (the only ingress,
|
|
4
|
+
* reached via the SigV4 `InvokeAgentRuntime` API) and `GET /ping` (health) — and no public URL, so
|
|
5
|
+
* the deployment fronts webhooks with a thin forwarder Lambda and delivers cron slots from
|
|
6
|
+
* EventBridge Scheduler; both arrive here as an ENVELOPE in the /invocations payload:
|
|
7
|
+
*
|
|
8
|
+
* - `{ kind: "webhook", method, path, headers?, bodyB64? }` — a verbatim webhook request captured
|
|
9
|
+
* by the forwarder. Reconstructed into a real `Request` and dispatched to the SAME channel routes
|
|
10
|
+
* a direct deployment serves — signature verification (Telegram secret token, Feishu signatures)
|
|
11
|
+
* runs unchanged inside the channel. The channel's HTTP response travels back INSIDE the
|
|
12
|
+
* transport reply (`{ status, headers, bodyB64 }`, transport always 200): AgentCore folds a
|
|
13
|
+
* container non-2xx into its own 424 RuntimeClientError, so riding the real status inside the
|
|
14
|
+
* envelope is the only way the forwarder can re-emit it verbatim (a Feishu URL-verification
|
|
15
|
+
* challenge needs the exact body + content-type back).
|
|
16
|
+
* - `{ kind: "schedule-fire", name, slot }` — one cron instant from the external clock. Dispatched
|
|
17
|
+
* to {@link fireScheduleOnce}-shaped `fire` with the slot as the idempotency key (EventBridge
|
|
18
|
+
* delivery is at-least-once; a duplicate slot must not double-fire).
|
|
19
|
+
* - `{ kind: "invoke", session, text }` — the programmatic data plane; streams the invoke back as
|
|
20
|
+
* SSE (AgentCore's streaming response form), reusing the HTTP channel's handler wholesale.
|
|
21
|
+
*
|
|
22
|
+
* `/ping` reports `HealthyBusy` while process-wide background work is in flight (busy.ts) — webhook
|
|
23
|
+
* channels ACK fast and run turns fire-and-forget, and AgentCore ends an idle session, so without
|
|
24
|
+
* this signal a long turn would be killed mid-flight right after its ACK. `Healthy` when idle lets
|
|
25
|
+
* the platform reclaim the microVM (that idle-to-zero IS the point of this deployment).
|
|
26
|
+
*/
|
|
27
|
+
import { Buffer } from "node:buffer";
|
|
28
|
+
import { timingSafeEqual } from "node:crypto";
|
|
29
|
+
import { beginWork, onIdle } from "./busy.js";
|
|
30
|
+
import { router } from "../host/node.js";
|
|
31
|
+
import { log } from "../log.js";
|
|
32
|
+
import { rememberWakeAlarmUrl } from "../schedule/wake-alarm.js";
|
|
33
|
+
import { readBodyCapped } from "./body.js";
|
|
34
|
+
import { createInvokeHandler } from "./http.js";
|
|
35
|
+
import { text } from "./respond.js";
|
|
36
|
+
import { MAX_ENVELOPE_BYTES, MAX_WEBHOOK_BODY_BYTES } from "./agentcore-limits.js";
|
|
37
|
+
const unsnapshottedWarning = "[agentcore] this envelope carried no state-snapshot URLs — the state root is LOCAL ONLY and the " +
|
|
38
|
+
"platform erases it on the next deploy (redeploy with a current fastagent to restore durability)";
|
|
39
|
+
const jsonHeaders = { "content-type": "application/json" };
|
|
40
|
+
const json = (body, status) => new Response(`${JSON.stringify(body)}\n`, { status, headers: jsonHeaders });
|
|
41
|
+
/** Compare an untrusted envelope secret without leaking a matching-prefix timing signal. */
|
|
42
|
+
function secretMatches(actual, expected) {
|
|
43
|
+
if (typeof actual !== "string" || expected === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
const actualBytes = Buffer.from(actual);
|
|
46
|
+
const expectedBytes = Buffer.from(expected);
|
|
47
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the AgentCore serving surface: `{ "POST /invocations", "GET /ping" }`. The caller merges it
|
|
51
|
+
* over its routes (collision-checked at the mount site, serve.ts) — the inner routes stay mounted
|
|
52
|
+
* too, which is harmless (AgentCore routes only /invocations and /ping into the container) and keeps
|
|
53
|
+
* a local `curl` debug surface.
|
|
54
|
+
*/
|
|
55
|
+
export function agentcoreRoutes(options) {
|
|
56
|
+
const { routes, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
|
|
57
|
+
const dispatch = router(routes);
|
|
58
|
+
const invokeHandler = createInvokeHandler(agent);
|
|
59
|
+
// Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
|
|
60
|
+
// background, so "the request returned" is NOT when the state root settles.
|
|
61
|
+
if (stateSync)
|
|
62
|
+
onIdle(() => stateSync.save());
|
|
63
|
+
let warnedUnsnapshotted = false;
|
|
64
|
+
let stateReadyFired = false;
|
|
65
|
+
const handleInvocation = async (req) => {
|
|
66
|
+
const body = await readBodyCapped(req, MAX_ENVELOPE_BYTES);
|
|
67
|
+
if ("tooLarge" in body)
|
|
68
|
+
return text("envelope too large\n", 413);
|
|
69
|
+
let envelope;
|
|
70
|
+
try {
|
|
71
|
+
envelope = JSON.parse(body.text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return text("invalid json\n", 400);
|
|
75
|
+
}
|
|
76
|
+
if (envelope === null || typeof envelope !== "object" || typeof envelope.kind !== "string") {
|
|
77
|
+
return text('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint", ... }\n', 400);
|
|
78
|
+
}
|
|
79
|
+
// AUTHENTICATION BOUNDARY. `InvokeAgentRuntime` is an ordinary IAM action, so "reached this
|
|
80
|
+
// handler" proves nothing about the sender. Only an envelope carrying the shared secret is the
|
|
81
|
+
// forwarder's; anything else is the PUBLIC data plane, which may run exactly one kind (`invoke`)
|
|
82
|
+
// and may NOT carry internal fields — riding a `state` or `wake` URL on a public invoke would
|
|
83
|
+
// redirect the state snapshot (auth.json) or the alarm callback (the wake secret) to the caller.
|
|
84
|
+
// Internal fields are DROPPED rather than rejected: a public caller has no business knowing them.
|
|
85
|
+
const trusted = secretMatches(envelope.auth, ingressSecret);
|
|
86
|
+
if (!trusted) {
|
|
87
|
+
if (envelope.kind !== "invoke") {
|
|
88
|
+
log.warn(`[agentcore] rejected an unauthenticated "${envelope.kind}" envelope`);
|
|
89
|
+
return text("forbidden\n", 403);
|
|
90
|
+
}
|
|
91
|
+
envelope.wake = undefined;
|
|
92
|
+
envelope.state = undefined;
|
|
93
|
+
}
|
|
94
|
+
// The forwarder rides its public URL along on every envelope — persist it (write-if-changed) so
|
|
95
|
+
// the wake-alarm sink can call back. Written AFTER the restore below: a stale snapshot copy must
|
|
96
|
+
// not win over the URL this deployment is actually reachable at. A bad persist must not fail the turn.
|
|
97
|
+
const rememberUrl = () => {
|
|
98
|
+
if (typeof envelope.wake?.url !== "string")
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
rememberWakeAlarmUrl(stateRoot, envelope.wake.url);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
log.error(`[agentcore] could not persist the wake-alarm URL: ${String(e)}`);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// Cross-deploy state: the platform wipes /mnt/state on every version update, so the durable copy
|
|
108
|
+
// must be pulled back BEFORE anything reads it. A failed restore fails the request — serving an
|
|
109
|
+
// empty agent (and then snapshotting that emptiness over the good copy) is the worse outcome.
|
|
110
|
+
if (stateSync) {
|
|
111
|
+
if (envelope.state && typeof envelope.state.getUrl === "string" && typeof envelope.state.putUrl === "string") {
|
|
112
|
+
stateSync.use(envelope.state);
|
|
113
|
+
}
|
|
114
|
+
else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
|
|
115
|
+
// webhook/schedule-fire/wake-poke reach us ONLY through the forwarder, which always mints the
|
|
116
|
+
// pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
|
|
117
|
+
// once per process (a direct `invoke` legitimately has none — its session storage is its own).
|
|
118
|
+
warnedUnsnapshotted = true;
|
|
119
|
+
log.warn(unsnapshottedWarning);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await stateSync.ready();
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
log.error(`[agentcore] state restore failed: ${String(e)}`);
|
|
126
|
+
return text(`state restore failed: ${String(e)}\n`, 503);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
rememberUrl();
|
|
130
|
+
// The state root is authoritative only now — anything that must READ it at startup (the wake-alarm
|
|
131
|
+
// reconcile) runs here, once, rather than at boot against a mount the platform just wiped.
|
|
132
|
+
if (onStateReady && !stateReadyFired) {
|
|
133
|
+
stateReadyFired = true;
|
|
134
|
+
onStateReady();
|
|
135
|
+
}
|
|
136
|
+
switch (envelope.kind) {
|
|
137
|
+
case "webhook": {
|
|
138
|
+
const { method, path, query, headers, bodyB64 } = envelope;
|
|
139
|
+
if (typeof method !== "string" || typeof path !== "string" || !path.startsWith("/")) {
|
|
140
|
+
return text('webhook envelope needs { "method": string, "path": "/..." }\n', 400);
|
|
141
|
+
}
|
|
142
|
+
if (typeof bodyB64 === "string" && Buffer.byteLength(bodyB64, "base64") > MAX_WEBHOOK_BODY_BYTES) {
|
|
143
|
+
return json({
|
|
144
|
+
status: 413,
|
|
145
|
+
headers: { "content-type": "text/plain" },
|
|
146
|
+
bodyB64: Buffer.from("payload too large\n").toString("base64"),
|
|
147
|
+
}, 200);
|
|
148
|
+
}
|
|
149
|
+
const inner = new Request(`http://agentcore.local${path}${typeof query === "string" && query !== "" ? `?${query}` : ""}`, {
|
|
150
|
+
method,
|
|
151
|
+
headers: headers ?? {},
|
|
152
|
+
body: typeof bodyB64 === "string" && method !== "GET" && method !== "HEAD"
|
|
153
|
+
? Buffer.from(bodyB64, "base64")
|
|
154
|
+
: undefined,
|
|
155
|
+
});
|
|
156
|
+
const response = await dispatch(inner);
|
|
157
|
+
// Buffer the channel's ACK (webhook ACKs are small by design — the turn itself runs
|
|
158
|
+
// fire-and-forget) and ride it inside the transport reply, byte-exact.
|
|
159
|
+
const replyBody = Buffer.from(await response.arrayBuffer());
|
|
160
|
+
const replyHeaders = {};
|
|
161
|
+
response.headers.forEach((value, key) => {
|
|
162
|
+
replyHeaders[key] = value;
|
|
163
|
+
});
|
|
164
|
+
const reply = {
|
|
165
|
+
status: response.status,
|
|
166
|
+
headers: replyHeaders,
|
|
167
|
+
bodyB64: replyBody.toString("base64"),
|
|
168
|
+
};
|
|
169
|
+
return json(reply, 200);
|
|
170
|
+
}
|
|
171
|
+
case "schedule-fire": {
|
|
172
|
+
const { name, slot } = envelope;
|
|
173
|
+
if (typeof name !== "string" || typeof slot !== "string" || Number.isNaN(Date.parse(slot))) {
|
|
174
|
+
return text('schedule-fire envelope needs { "name": string, "slot": ISO-date }\n', 400);
|
|
175
|
+
}
|
|
176
|
+
// No fire capability (no schedules in this definition) or an unknown name is deploy drift —
|
|
177
|
+
// an external clock rule outliving the schedule it fired for. 404 keeps it VISIBLE in the
|
|
178
|
+
// clock's logs (a 200 would silently absorb every future fire).
|
|
179
|
+
if (!fire)
|
|
180
|
+
return text(`no schedules in this deployment (schedule-fire "${name}")\n`, 404);
|
|
181
|
+
// The whole agent turn runs inside this request — but the CALLER (the forwarder Lambda) may
|
|
182
|
+
// time out and drop the connection while the turn keeps running server-side. Count it as
|
|
183
|
+
// in-flight work so /ping holds the session (HealthyBusy) for the remainder.
|
|
184
|
+
const workDone = beginWork();
|
|
185
|
+
try {
|
|
186
|
+
const outcome = await fire(name, new Date(slot));
|
|
187
|
+
return json(outcome, 200);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
if (e instanceof UnknownScheduleError)
|
|
191
|
+
return text(`${e.message}\n`, 404);
|
|
192
|
+
// A claim-state fault (unreadable/unwritable fires.json) — surface it as the request's
|
|
193
|
+
// failure so the external clock's logs carry it (fail visibly, never a silent absorb).
|
|
194
|
+
log.error(`[agentcore] schedule-fire ${name} failed: ${String(e)}`);
|
|
195
|
+
return text(`schedule-fire failed: ${String(e)}\n`, 500);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
workDone();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
case "checkpoint": {
|
|
202
|
+
// Deliberately does NOT wait for idle: the durable turn intent is persisted BEFORE the
|
|
203
|
+
// webhook ACK (turn-store.ts), so a flush right now already carries the interrupted turn —
|
|
204
|
+
// and blocking a deploy on a turn that may run for minutes would be the worse trade.
|
|
205
|
+
if (!stateSync)
|
|
206
|
+
return json({ written: false, reason: "no state sync in this deployment" }, 200);
|
|
207
|
+
try {
|
|
208
|
+
// The reply is the ONLY thing telling the operator whether an in-flight turn was
|
|
209
|
+
// protected, so it reports what actually happened — `written: false` (nothing to write)
|
|
210
|
+
// reads differently from `written: true`, and a failure is a failure.
|
|
211
|
+
return json(await stateSync.checkpoint(), 200);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
log.error(`[agentcore] checkpoint failed: ${String(e)}`);
|
|
215
|
+
return text(`checkpoint failed: ${String(e)}\n`, 500);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
case "wake-poke": {
|
|
219
|
+
// The poke's job is DONE by arriving: the invocation woke (or kept awake) the container, and
|
|
220
|
+
// the wake pump (boot drain + 30s poll) fires whatever is due. Nothing to dispatch.
|
|
221
|
+
return json({ ok: true }, 200);
|
|
222
|
+
}
|
|
223
|
+
case "invoke": {
|
|
224
|
+
// Reuse the HTTP channel's handler wholesale (SSE, cancellation, backpressure) by handing it
|
|
225
|
+
// the shape it already validates — one protocol, one implementation.
|
|
226
|
+
const inner = new Request("http://agentcore.local/invoke", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: jsonHeaders,
|
|
229
|
+
body: JSON.stringify({ session: envelope.session, text: envelope.text }),
|
|
230
|
+
});
|
|
231
|
+
return invokeHandler(inner);
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
return text(`unknown envelope kind "${envelope.kind}"\n`, 400);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
// Settle-then-snapshot: when the envelope leaves nothing in flight, its writes are final now (a
|
|
238
|
+
// background turn instead reports through the idle edge above).
|
|
239
|
+
const invocations = async (req) => {
|
|
240
|
+
const response = await handleInvocation(req);
|
|
241
|
+
if (stateSync && !isBusy())
|
|
242
|
+
stateSync.save();
|
|
243
|
+
return response;
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
"POST /invocations": invocations,
|
|
247
|
+
// The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
|
|
248
|
+
// (background turns in flight). No time_of_last_update — the platform tracks status changes
|
|
249
|
+
// itself, and a timestamp advancing every ping would defeat the idle timeout (their docs warn).
|
|
250
|
+
"GET /ping": () => json({ status: isBusy() ? "HealthyBusy" : "Healthy" }, 200),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
|
|
254
|
+
* not have — the adapter maps it to 404 (deploy drift stays visible in the external clock's logs). */
|
|
255
|
+
export class UnknownScheduleError extends Error {
|
|
256
|
+
constructor(name) {
|
|
257
|
+
super(`unknown schedule "${name}"`);
|
|
258
|
+
this.name = "UnknownScheduleError";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
|
|
3
|
+
* fire-and-forget on this process's event loop (host/node.ts) — so "is this process busy?" is not
|
|
4
|
+
* derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
|
|
5
|
+
* task-tracker side tasks) report here; a serving surface that must stay alive while background
|
|
6
|
+
* work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately a counter, not a registry: consumers need only "any work in flight?"; keeping the
|
|
9
|
+
* module dependency-free lets both channel primitives import it without cycles.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Mark one unit of background work as started. Returns its completion callback — idempotent, so a
|
|
13
|
+
* caller may safely settle it from multiple cleanup paths (finally + catch) without double-counting.
|
|
14
|
+
*/
|
|
15
|
+
export declare function beginWork(): () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Subscribe to the 0-in-flight edge: the moment the process finishes its last background turn.
|
|
18
|
+
* Returns an unsubscribe. Used by the AgentCore adapter to push its state snapshot exactly when the
|
|
19
|
+
* state root has settled and before the platform may reclaim the idle microVM.
|
|
20
|
+
*/
|
|
21
|
+
export declare function onIdle(listener: () => void): () => void;
|
|
22
|
+
/** How many units of background work are currently in flight (0 = idle). */
|
|
23
|
+
export declare function activeWork(): number;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
|
|
3
|
+
* fire-and-forget on this process's event loop (host/node.ts) — so "is this process busy?" is not
|
|
4
|
+
* derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
|
|
5
|
+
* task-tracker side tasks) report here; a serving surface that must stay alive while background
|
|
6
|
+
* work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately a counter, not a registry: consumers need only "any work in flight?"; keeping the
|
|
9
|
+
* module dependency-free lets both channel primitives import it without cycles.
|
|
10
|
+
*/
|
|
11
|
+
let inFlight = 0;
|
|
12
|
+
const idleListeners = new Set();
|
|
13
|
+
/**
|
|
14
|
+
* Mark one unit of background work as started. Returns its completion callback — idempotent, so a
|
|
15
|
+
* caller may safely settle it from multiple cleanup paths (finally + catch) without double-counting.
|
|
16
|
+
*/
|
|
17
|
+
export function beginWork() {
|
|
18
|
+
inFlight += 1;
|
|
19
|
+
let done = false;
|
|
20
|
+
return () => {
|
|
21
|
+
if (done)
|
|
22
|
+
return;
|
|
23
|
+
done = true;
|
|
24
|
+
inFlight -= 1;
|
|
25
|
+
if (inFlight > 0)
|
|
26
|
+
return;
|
|
27
|
+
for (const listener of idleListeners) {
|
|
28
|
+
// A listener fault must not corrupt the counter or starve the others (this runs inside a
|
|
29
|
+
// channel's `finally`): report and carry on.
|
|
30
|
+
try {
|
|
31
|
+
listener();
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
console.error(`[fastagent] idle listener failed: ${String(e)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to the 0-in-flight edge: the moment the process finishes its last background turn.
|
|
41
|
+
* Returns an unsubscribe. Used by the AgentCore adapter to push its state snapshot exactly when the
|
|
42
|
+
* state root has settled and before the platform may reclaim the idle microVM.
|
|
43
|
+
*/
|
|
44
|
+
export function onIdle(listener) {
|
|
45
|
+
idleListeners.add(listener);
|
|
46
|
+
return () => {
|
|
47
|
+
idleListeners.delete(listener);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** How many units of background work are currently in flight (0 = idle). */
|
|
51
|
+
export function activeWork() {
|
|
52
|
+
return inFlight;
|
|
53
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Per-message bound INSIDE that budget. The fold is a digest of many messages competing for one
|
|
2
|
+
* allowance, so the job here is fairness, not fidelity: one rambler must not price out everyone who
|
|
3
|
+
* spoke after them. 280 against 4000 keeps at least ~14 messages in a full buffer. A referent — the
|
|
4
|
+
* single message the asker points at — is the opposite job and takes REFERENT_MAX_CODE_POINTS. */
|
|
5
|
+
export declare const BUFFER_LINE_MAX_CHARS = 280;
|
|
1
6
|
/** How many buffered files and images (each, most recent first) a summon pulls in with the folded
|
|
2
7
|
* discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
|
|
3
8
|
* group posting many attachments between summons. Skipped ones must be counted into the prompt
|
|
@@ -28,6 +28,11 @@ import { loadStateFile, saveStateFile } from "./state.js";
|
|
|
28
28
|
* sparse-but-relevant lines, a busy burst is capped). The `line` renderer is the eviction cost
|
|
29
29
|
* basis: the budget must price what the fold actually renders, or it would systematically overrun. */
|
|
30
30
|
const BUFFER_MAX_CHARS = 4000;
|
|
31
|
+
/** Per-message bound INSIDE that budget. The fold is a digest of many messages competing for one
|
|
32
|
+
* allowance, so the job here is fairness, not fidelity: one rambler must not price out everyone who
|
|
33
|
+
* spoke after them. 280 against 4000 keeps at least ~14 messages in a full buffer. A referent — the
|
|
34
|
+
* single message the asker points at — is the opposite job and takes REFERENT_MAX_CODE_POINTS. */
|
|
35
|
+
export const BUFFER_LINE_MAX_CHARS = 280;
|
|
31
36
|
/** How many buffered files and images (each, most recent first) a summon pulls in with the folded
|
|
32
37
|
* discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
|
|
33
38
|
* group posting many attachments between summons. Skipped ones must be counted into the prompt
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
|
|
3
|
-
* ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
|
|
4
|
-
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
|
-
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
|
-
*/
|
|
7
1
|
import { type ContextBuffer } from "../context-buffer.ts";
|
|
8
2
|
import type { NormalizedFeishuMessage } from "./model.ts";
|
|
9
3
|
export interface FeishuBufferedResource {
|
|
@@ -23,8 +17,19 @@ export interface FeishuBufferEntry {
|
|
|
23
17
|
export interface FeishuBufferedRef extends FeishuBufferedResource {
|
|
24
18
|
from: string;
|
|
25
19
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The place a message belongs to: the main chat, or a thread within it. Keyed by `thread_id`, the
|
|
22
|
+
* platform's own identity for a side conversation — NOT `root_id`, which tracks the reply chain and
|
|
23
|
+
* can differ between messages of one thread (which would split a thread's context across buckets).
|
|
24
|
+
* A quoted reply outside a thread carries a root but is main-chat discussion, so it buckets there.
|
|
25
|
+
*
|
|
26
|
+
* Its own namespace, deliberately: this names a BUCKET of undelivered text, while `parse.ts`'s
|
|
27
|
+
* `placeKey` names a SESSION. Nothing here claims anything about that session — unlike thread
|
|
28
|
+
* participation, which asserts "the agent answered into this memory" and is therefore keyed by the
|
|
29
|
+
* session itself. Re-keying either one leaves the other correct, and converging them now would strand
|
|
30
|
+
* live buckets for no gain (Slack keeps its own shape for the same reason).
|
|
31
|
+
*/
|
|
32
|
+
export declare function feishuBufferPlaceKey(conversation: Pick<NormalizedFeishuMessage["conversation"], "chatId" | "threadId">): string;
|
|
28
33
|
/** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
|
|
29
34
|
export declare function feishuBufferText(text: string): string;
|
|
30
35
|
/**
|
|
@@ -4,21 +4,34 @@
|
|
|
4
4
|
* buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
|
|
5
5
|
* concrete thread root) and folded into the next answered turn in that place.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { log } from "../../log.js";
|
|
8
|
+
import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
|
|
9
|
+
import { loadStateFile, saveStateFile } from "../state.js";
|
|
10
|
+
import { truncateCodePointPrefix } from "../text.js";
|
|
8
11
|
function bufferLine(entry) {
|
|
9
12
|
const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
|
|
10
13
|
.filter(Boolean)
|
|
11
14
|
.join(", ");
|
|
12
15
|
return `${entry.sender} (${meta}): ${entry.body}`;
|
|
13
16
|
}
|
|
14
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The place a message belongs to: the main chat, or a thread within it. Keyed by `thread_id`, the
|
|
19
|
+
* platform's own identity for a side conversation — NOT `root_id`, which tracks the reply chain and
|
|
20
|
+
* can differ between messages of one thread (which would split a thread's context across buckets).
|
|
21
|
+
* A quoted reply outside a thread carries a root but is main-chat discussion, so it buckets there.
|
|
22
|
+
*
|
|
23
|
+
* Its own namespace, deliberately: this names a BUCKET of undelivered text, while `parse.ts`'s
|
|
24
|
+
* `placeKey` names a SESSION. Nothing here claims anything about that session — unlike thread
|
|
25
|
+
* participation, which asserts "the agent answered into this memory" and is therefore keyed by the
|
|
26
|
+
* session itself. Re-keying either one leaves the other correct, and converging them now would strand
|
|
27
|
+
* live buckets for no gain (Slack keeps its own shape for the same reason).
|
|
28
|
+
*/
|
|
15
29
|
export function feishuBufferPlaceKey(conversation) {
|
|
16
|
-
|
|
17
|
-
return topic ? `${conversation.chatId}:root:${topic}` : conversation.chatId;
|
|
30
|
+
return conversation.threadId ? `${conversation.chatId}:thread:${conversation.threadId}` : conversation.chatId;
|
|
18
31
|
}
|
|
19
32
|
/** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
|
|
20
33
|
export function feishuBufferText(text) {
|
|
21
|
-
return text.replace(/\s+/g, " ").trim()
|
|
34
|
+
return truncateCodePointPrefix(text.replace(/\s+/g, " ").trim(), BUFFER_LINE_MAX_CHARS);
|
|
22
35
|
}
|
|
23
36
|
function resourceIdentity(resource) {
|
|
24
37
|
return `${resource.messageId}\u0000${resource.key}`;
|
|
@@ -67,6 +80,47 @@ function isEntry(value) {
|
|
|
67
80
|
resources(entry.files) &&
|
|
68
81
|
resources(entry.images));
|
|
69
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Buckets from the pre-participant-model keying (`<chat>:root:<root_id>`) can never be produced again —
|
|
85
|
+
* a place is `<chat>` or `<chat>:thread:<thread_id>` — so nothing could ever fold or clear them, and
|
|
86
|
+
* they would hold chat content on disk forever. Dropped here, before the buffer loads, so the shared
|
|
87
|
+
* kernel never learns about a key shape one channel retired.
|
|
88
|
+
*
|
|
89
|
+
* TWO one-time losses, both accepted and both logged by count. (1) The retired shape covered every
|
|
90
|
+
* thread bucket and every main-chat quoted-reply bucket, so buffered discussion in threads does not
|
|
91
|
+
* survive the upgrade — it becomes unreachable BECAUSE of the re-keying, not before it. (2)
|
|
92
|
+
* `turns.json` persists each in-flight turn's `bufferKey` verbatim and this runs before turn recovery,
|
|
93
|
+
* so a turn spanning the upgrade finds its bucket already gone. Sparing referenced keys would couple
|
|
94
|
+
* the buffer to the turn store to protect a single upgrade, and would not help (1) at all.
|
|
95
|
+
*
|
|
96
|
+
* PERMANENT, unlike the `owned-threads.json` cleanup it otherwise resembles. That one leaves an inert
|
|
97
|
+
* orphan file, so deleting it a release later is free; this one is what stops user chat content
|
|
98
|
+
* lingering, and a deployment that skips from before the model to well after it would never run an
|
|
99
|
+
* expired version of this code. The standing cost is one key scan at load, and nothing when no retired
|
|
100
|
+
* key is present.
|
|
101
|
+
*/
|
|
102
|
+
function dropRetiredBuckets(path, label) {
|
|
103
|
+
const raw = loadStateFile(path);
|
|
104
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
105
|
+
return;
|
|
106
|
+
const live = Object.entries(raw).filter(([placeKey]) => !placeKey.includes(":root:"));
|
|
107
|
+
const dropped = Object.keys(raw).length - live.length;
|
|
108
|
+
if (dropped === 0)
|
|
109
|
+
return;
|
|
110
|
+
log.info(`${label} dropped ${dropped} context bucket(s) with a retired key shape`);
|
|
111
|
+
try {
|
|
112
|
+
saveStateFile(path, Object.fromEntries(live));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
log.warn(`${label} could not rewrite ${path} after dropping retired buckets: ${String(error)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
70
118
|
export function createFeishuContextBuffer(path, label) {
|
|
71
|
-
|
|
119
|
+
dropRetiredBuckets(path, label);
|
|
120
|
+
return createGenericContextBuffer({
|
|
121
|
+
path,
|
|
122
|
+
label,
|
|
123
|
+
isEntry,
|
|
124
|
+
line: bufferLine,
|
|
125
|
+
});
|
|
72
126
|
}
|
|
@@ -236,7 +236,10 @@ export function createFeishuApi(opts) {
|
|
|
236
236
|
await call("deleteMessage", "DELETE", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
|
|
237
237
|
},
|
|
238
238
|
async getMessage(messageId) {
|
|
239
|
-
const data = await call("getMessage", "GET",
|
|
239
|
+
const data = await call("getMessage", "GET",
|
|
240
|
+
// Pin the id type: callers match mentions/sender against open_ids, so the response's id shape
|
|
241
|
+
// must not depend on the platform's default staying open_id.
|
|
242
|
+
`/open-apis/im/v1/messages/${encodeURIComponent(messageId)}?user_id_type=open_id`);
|
|
240
243
|
return data.data?.items?.[0];
|
|
241
244
|
},
|
|
242
245
|
async downloadResource(messageId, fileKey, type) {
|
|
@@ -10,16 +10,6 @@ interface FeishuChannelBaseOptions {
|
|
|
10
10
|
appId: string;
|
|
11
11
|
/** App Secret (same page) — drives both ingress authentication and outbound API calls. */
|
|
12
12
|
appSecret: string;
|
|
13
|
-
/** Direct-message context + delivery policy. `threaded` (default) gives every top-level p2p message
|
|
14
|
-
* its own session, creates a platform thread for the answer, and routes later thread messages back
|
|
15
|
-
* by root message id. `continuous` keeps one session per p2p chat and sends ordinary unquoted replies. */
|
|
16
|
-
directMessageSession?: "continuous" | "threaded";
|
|
17
|
-
/** Group-message context + delivery policy. `threaded` (default) gives every top-level summoned
|
|
18
|
-
* message its own session and platform thread; later bare user messages in that managed thread answer
|
|
19
|
-
* in the same root session, while @other-only discussion buffers. `continuous` preserves the legacy
|
|
20
|
-
* chat/topic sessions (`chat_id` / `chat_id:thread_id`). Buffering and bare continuations require
|
|
21
|
-
* `im:message.group_msg`. */
|
|
22
|
-
groupMessageSession?: "continuous" | "threaded";
|
|
23
13
|
/** Policy: whether/where to answer an event (return null to ignore). Defaults to {@link defaultFeishuRoute}. */
|
|
24
14
|
route?: (event: FeishuMessageEvent) => FeishuRoute | null;
|
|
25
15
|
/** Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator
|
|
@@ -30,8 +20,6 @@ interface FeishuChannelBaseOptions {
|
|
|
30
20
|
* `https://open.feishu.cn`; Lark factories default to `https://open.larksuite.com`. Named to match
|
|
31
21
|
* the other channels (telegram/slack). */
|
|
32
22
|
apiBaseUrl?: string;
|
|
33
|
-
/** @deprecated Alias of {@link apiBaseUrl}; kept for existing feishu/lark channel files. */
|
|
34
|
-
baseUrl?: string;
|
|
35
23
|
/** How long (ms) a turn waits before its reply-quoted "⏳ Queued" card mounts. Defaults to 0
|
|
36
24
|
* (immediate); the same card is later taken over by the live preview/final answer. */
|
|
37
25
|
queueNoticeDelayMs?: number;
|