@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
|
@@ -7,20 +7,21 @@
|
|
|
7
7
|
* Mechanism: the application-v7 config PATCH (`updateEventSubscription`) flips the app's event
|
|
8
8
|
* subscription to webhook mode and points it at `<baseUrl>/<kind>`. Two properties make this the full
|
|
9
9
|
* telegram-setWebhook parity: the platform applies a request-URL change IMMEDIATELY (no version
|
|
10
|
-
* publish), and it VERIFIES the URL with a url_verification challenge during the PATCH — which
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* publish), and it VERIFIES the URL with a url_verification challenge during the PATCH — which makes
|
|
11
|
+
* the PATCH its own readiness probe, retried below. (It used to poll `<baseUrl>/health` from here
|
|
12
|
+
* first; that asked whether THIS machine could reach a freshly minted hostname, which is routinely
|
|
13
|
+
* false for a minute or more while the platform reaches it fine — #421.) Requires the
|
|
14
|
+
* `application:application:patch` scope (field-tested: `self_manage` does NOT cover this PATCH) —
|
|
15
|
+
* `add feishu` requests it at creation via addons; without it the PATCH fails visibly and the manual
|
|
16
|
+
* console instruction is printed.
|
|
15
17
|
*
|
|
16
18
|
* CLOUD LAG: the application-v7 config API exists on open.feishu.cn but (as of 2026-07) is NOT
|
|
17
19
|
* deployed on open.larksuite.com — the route 404s there. The registrar still attempts it (the day the
|
|
18
20
|
* platform ships it, registration starts working with no change here) and names the real cause in the
|
|
19
21
|
* fallback instead of blaming the app's scopes.
|
|
20
22
|
*/
|
|
21
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
22
23
|
import { log } from "../../log.js";
|
|
23
|
-
import {
|
|
24
|
+
import { retryWhile } from "../registration.js";
|
|
24
25
|
import { cloudFor } from "./cloud.js";
|
|
25
26
|
import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError } from "./feishu-api.js";
|
|
26
27
|
export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
|
|
@@ -35,16 +36,6 @@ export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
|
|
|
35
36
|
log.info(`[fastagent] ${kind}: set ${envPrefix}_APP_ID + ${envPrefix}_APP_SECRET in .env, then re-run to auto-register. Or ${manual}`);
|
|
36
37
|
return "manual";
|
|
37
38
|
}
|
|
38
|
-
// Align registration with the server actually serving: the PATCH triggers the platform's
|
|
39
|
-
// url_verification challenge against requestUrl — registering before /health serves would fail.
|
|
40
|
-
log.info(`[fastagent] ${kind}: waiting for ${baseUrl} to be reachable before registering the event URL…`);
|
|
41
|
-
const ready = await waitForHealth(`${baseUrl}/health`, opts.readyTimeoutMs ?? 120_000, opts.readyIntervalMs ?? 3_000);
|
|
42
|
-
if (!ready) {
|
|
43
|
-
// Terminal for this run (registration will not be retried) — error, not warn: the event URL is NOT
|
|
44
|
-
// registered and the operator must act. Same taxonomy as the permanent PATCH failure below.
|
|
45
|
-
log.error(`[fastagent] ${kind}: ${baseUrl}/health did not come up in time — the app may still be starting. ${manual}`);
|
|
46
|
-
return "failed";
|
|
47
|
-
}
|
|
48
39
|
const api = createFeishuApi({ kind, baseUrl: apiBase, appId, appSecret });
|
|
49
40
|
const consoleUrl = `${apiBase}/app/${encodeURIComponent(appId)}/event`;
|
|
50
41
|
const versionUrl = `${apiBase}/app/${encodeURIComponent(appId)}/version`;
|
|
@@ -61,46 +52,41 @@ export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
|
|
|
61
52
|
log.warn(`[fastagent] ${kind}: could not open Events & Callbacks: ${String(callbackError)}`);
|
|
62
53
|
}
|
|
63
54
|
};
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
if (!isTransientFeishuRegistrationError(e)) {
|
|
93
|
-
log.error(`[fastagent] ${kind}: could not register the event URL (${String(e)}). ` +
|
|
94
|
-
`The app may lack the "application:application:patch" scope (console → Permissions) or be under review; manual registration is available below.`);
|
|
95
|
-
manualRegistration();
|
|
96
|
-
return "failed";
|
|
97
|
-
}
|
|
55
|
+
// The PATCH is the probe (same lesson as the token bootstrap): the platform verifies request_url
|
|
56
|
+
// with a challenge DURING the call, so its 210042 "request_url validation failed" is the readiness
|
|
57
|
+
// signal and is retried with backoff, alongside transient network errors. Only a permanent config
|
|
58
|
+
// error (missing scope, app under review, the intl 404) is reported once with the manual path.
|
|
59
|
+
try {
|
|
60
|
+
await retryWhile(() => api.updateEventSubscription(appId, { subscriptionType: "webhook", requestUrl }), isTransientFeishuRegistrationError, {
|
|
61
|
+
attempts: opts.attempts,
|
|
62
|
+
retryMs: opts.retryMs,
|
|
63
|
+
onRetry: ({ attempt, attempts }) => log.info(`[fastagent] ${kind}: the platform cannot verify ${requestUrl} yet (attempt ${attempt}/${attempts}); retrying…`),
|
|
64
|
+
});
|
|
65
|
+
log.info(`[fastagent] ${kind}: event Request URL registered → ${requestUrl}`);
|
|
66
|
+
// Field-tested: a URL change applies immediately, but the MODE flip (the template's long
|
|
67
|
+
// connection → webhook) only takes effect when a version is published — the dispatcher serves
|
|
68
|
+
// the published snapshot, and version publishing has no open API. One console click, once.
|
|
69
|
+
log.info(`[fastagent] ${kind}: if messages do not arrive, publish a version (one click, prompted) — the switch to webhook mode takes effect on publish: ${versionUrl}`);
|
|
70
|
+
return "registered";
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
// A 404 on the config route is the CLOUD lagging, not this app's configuration: the v7 API is
|
|
74
|
+
// live on open.feishu.cn but not yet on open.larksuite.com. Name that — "check your scopes"
|
|
75
|
+
// would send the operator hunting for a problem they cannot fix. It stays WARN: on that cloud the
|
|
76
|
+
// manual path is the known norm, not an exceptional failure.
|
|
77
|
+
if (isFeishuConfigApiMissing(e)) {
|
|
78
|
+
log.warn(`[fastagent] ${kind}: this cloud (${apiBase}) returned HTTP 404 for the app-config API — ` +
|
|
79
|
+
`manual registration is required`);
|
|
80
|
+
manualRegistration();
|
|
81
|
+
return "manual"; // that cloud has no config API — the manual path is the norm there
|
|
98
82
|
}
|
|
83
|
+
// Exhausted retries end in the same state as a permanent config error (event URL not registered,
|
|
84
|
+
// manual action required) — report at the same level.
|
|
85
|
+
log.error(isTransientFeishuRegistrationError(e)
|
|
86
|
+
? `[fastagent] ${kind}: the platform could not verify ${requestUrl} after retries — manual registration is required`
|
|
87
|
+
: `[fastagent] ${kind}: could not register the event URL (${String(e)}). ` +
|
|
88
|
+
`The app may lack the "application:application:patch" scope (console → Permissions) or be under review; manual registration is available below.`);
|
|
89
|
+
manualRegistration();
|
|
90
|
+
return "failed";
|
|
99
91
|
}
|
|
100
|
-
// Exhausted retries end in the same state as a permanent error (event URL not registered, manual
|
|
101
|
-
// action required) — report at the same level. The cloud-lag 404 above stays warn: on that cloud the
|
|
102
|
-
// manual path is the known norm, not an exceptional failure.
|
|
103
|
-
log.error(`[fastagent] ${kind}: registration still failing after retries — manual registration is required`);
|
|
104
|
-
manualRegistration();
|
|
105
|
-
return "failed";
|
|
106
92
|
}
|
|
@@ -1,58 +1,8 @@
|
|
|
1
1
|
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
+
import { feishuTransport } from "@fastagent-sh/fastagent/feishu";
|
|
2
3
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [feishu: chat …]
|
|
6
|
-
// context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
|
|
7
|
-
// the target chat id. tools/ is auto-discovered.
|
|
8
|
-
|
|
9
|
-
// Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
|
|
10
|
-
// the tool call (and the turn), named errors, and success gated on the body's own code===0.
|
|
11
|
-
// Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
|
|
12
|
-
// fail-fast beats a silently sleeping tool.
|
|
13
|
-
const BASE = "https://open.feishu.cn";
|
|
14
|
-
|
|
15
|
-
async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
|
|
16
|
-
let res: Response;
|
|
17
|
-
let raw: string;
|
|
18
|
-
try {
|
|
19
|
-
res = await fetch(`${BASE}${path}`, {
|
|
20
|
-
method: "POST",
|
|
21
|
-
headers: {
|
|
22
|
-
"content-type": "application/json; charset=utf-8",
|
|
23
|
-
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
24
|
-
},
|
|
25
|
-
body: JSON.stringify(body),
|
|
26
|
-
signal: AbortSignal.timeout(30_000),
|
|
27
|
-
});
|
|
28
|
-
raw = await res.text();
|
|
29
|
-
} catch (e) {
|
|
30
|
-
throw new Error(`feishu ${path}: ${String(e)}`, { cause: e });
|
|
31
|
-
}
|
|
32
|
-
let data: { code?: number; msg?: string; [k: string]: unknown };
|
|
33
|
-
try {
|
|
34
|
-
data = JSON.parse(raw) as typeof data;
|
|
35
|
-
} catch {
|
|
36
|
-
data = {};
|
|
37
|
-
}
|
|
38
|
-
if (!res.ok || data.code !== 0) {
|
|
39
|
-
throw new Error(`feishu ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
|
|
40
|
-
}
|
|
41
|
-
return data;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function tenantToken(): Promise<string> {
|
|
45
|
-
const appId = process.env.FEISHU_APP_ID;
|
|
46
|
-
const appSecret = process.env.FEISHU_APP_SECRET;
|
|
47
|
-
if (!appId || !appSecret) throw new Error("FEISHU_APP_ID / FEISHU_APP_SECRET are not set");
|
|
48
|
-
const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
|
|
49
|
-
app_id: appId,
|
|
50
|
-
app_secret: appSecret,
|
|
51
|
-
});
|
|
52
|
-
const token = data.tenant_access_token;
|
|
53
|
-
if (typeof token !== "string") throw new Error("feishu tenant_access_token: response carried no token");
|
|
54
|
-
return token;
|
|
55
|
-
}
|
|
4
|
+
// Proactive delivery uses the mounted channel's credentials, gateway and token cache. With no
|
|
5
|
+
// channel mounted (fire/invoke/tool), it reads FEISHU_APP_ID / FEISHU_APP_SECRET from the environment.
|
|
56
6
|
|
|
57
7
|
export default defineTool({
|
|
58
8
|
description:
|
|
@@ -70,23 +20,18 @@ export default defineTool({
|
|
|
70
20
|
text: z.string().optional().describe("plain text message to send"),
|
|
71
21
|
markdown: z.string().optional().describe("markdown to send as a card"),
|
|
72
22
|
}),
|
|
73
|
-
async execute({ chatId, text, markdown }) {
|
|
23
|
+
async execute({ chatId, text, markdown }, ctx) {
|
|
74
24
|
if ((text === undefined) === (markdown === undefined)) {
|
|
75
25
|
throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
|
|
76
26
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
schema: "2.0",
|
|
86
|
-
body: { elements: [{ tag: "markdown", content: markdown }] },
|
|
87
|
-
}),
|
|
88
|
-
};
|
|
89
|
-
await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
|
|
27
|
+
const api = feishuTransport(ctx.cwd);
|
|
28
|
+
if (text !== undefined) await api.sendText({ chatId }, text);
|
|
29
|
+
else {
|
|
30
|
+
await api.sendMessage(chatId, "interactive", JSON.stringify({
|
|
31
|
+
schema: "2.0",
|
|
32
|
+
body: { elements: [{ tag: "markdown", content: markdown }] },
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
90
35
|
return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
|
|
91
36
|
},
|
|
92
37
|
});
|
|
@@ -4,6 +4,36 @@ export type FeishuSubscriptionMode = "webhook" | "websocket";
|
|
|
4
4
|
* runtime remains capability-driven because the platform, not channel source, decides which events
|
|
5
5
|
* are delivered. */
|
|
6
6
|
export type FeishuGroupBehavior = "context" | "mentions";
|
|
7
|
+
/**
|
|
8
|
+
* Configuring THIS app: what every v7 config PATCH needs — registering a webhook Request URL, and
|
|
9
|
+
* adding a scope to the app draft during context-aware group setup.
|
|
10
|
+
*
|
|
11
|
+
* Requested at creation for BOTH ingress modes, though only webhook uses it on day one. A WebSocket
|
|
12
|
+
* app that later wants webhook delivery cannot acquire it in passing: changing mode is a migration
|
|
13
|
+
* the CLI deliberately refuses to perform (`resolveIngress`), so the app would have to be recreated
|
|
14
|
+
* or the scope added by hand in the console after a failed PATCH. It is not a data scope — it reads
|
|
15
|
+
* and writes this app's own configuration — so carrying it costs an app nothing and keeps the one
|
|
16
|
+
* door open that the alternative nails shut.
|
|
17
|
+
*/
|
|
18
|
+
export declare const FEISHU_APP_CONFIG_SCOPE = "application:application:patch";
|
|
19
|
+
/** The one event an agent app cannot serve without: an inbound message. */
|
|
20
|
+
export declare const FEISHU_MESSAGE_RECEIVE_EVENT = "im.message.receive_v1";
|
|
21
|
+
/**
|
|
22
|
+
* What a created app carries on top of the platform's agent template, for EITHER ingress — the
|
|
23
|
+
* `addons` merged onto the confirm page. Here rather than inline at the call site because it is a
|
|
24
|
+
* policy decision (which capabilities an app is born with), and the CLI layer that requests it is
|
|
25
|
+
* terminal wiring with no seam to test through.
|
|
26
|
+
*/
|
|
27
|
+
export declare function feishuAppAddons(): {
|
|
28
|
+
scopes: {
|
|
29
|
+
tenant: string[];
|
|
30
|
+
};
|
|
31
|
+
events: {
|
|
32
|
+
items: {
|
|
33
|
+
tenant: string[];
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
7
37
|
/** The sensitive tenant scope behind both bare replies in the agent's threads and group context buffering. */
|
|
8
38
|
export declare const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
|
|
9
39
|
/** Reading a QUOTED message by id, so an ask carries what it replies to (participant-model.md §8).
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuring THIS app: what every v7 config PATCH needs — registering a webhook Request URL, and
|
|
3
|
+
* adding a scope to the app draft during context-aware group setup.
|
|
4
|
+
*
|
|
5
|
+
* Requested at creation for BOTH ingress modes, though only webhook uses it on day one. A WebSocket
|
|
6
|
+
* app that later wants webhook delivery cannot acquire it in passing: changing mode is a migration
|
|
7
|
+
* the CLI deliberately refuses to perform (`resolveIngress`), so the app would have to be recreated
|
|
8
|
+
* or the scope added by hand in the console after a failed PATCH. It is not a data scope — it reads
|
|
9
|
+
* and writes this app's own configuration — so carrying it costs an app nothing and keeps the one
|
|
10
|
+
* door open that the alternative nails shut.
|
|
11
|
+
*/
|
|
12
|
+
export const FEISHU_APP_CONFIG_SCOPE = "application:application:patch";
|
|
13
|
+
/** The one event an agent app cannot serve without: an inbound message. */
|
|
14
|
+
export const FEISHU_MESSAGE_RECEIVE_EVENT = "im.message.receive_v1";
|
|
15
|
+
/**
|
|
16
|
+
* What a created app carries on top of the platform's agent template, for EITHER ingress — the
|
|
17
|
+
* `addons` merged onto the confirm page. Here rather than inline at the call site because it is a
|
|
18
|
+
* policy decision (which capabilities an app is born with), and the CLI layer that requests it is
|
|
19
|
+
* terminal wiring with no seam to test through.
|
|
20
|
+
*/
|
|
21
|
+
export function feishuAppAddons() {
|
|
22
|
+
return {
|
|
23
|
+
scopes: { tenant: [FEISHU_APP_CONFIG_SCOPE] },
|
|
24
|
+
events: { items: { tenant: [FEISHU_MESSAGE_RECEIVE_EVENT] } },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
1
27
|
/** The sensitive tenant scope behind both bare replies in the agent's threads and group context buffering. */
|
|
2
28
|
export const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
|
|
3
29
|
/** Reading a QUOTED message by id, so an ask carries what it replies to (participant-model.md §8).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type FeishuCloudKind } from "./cloud.ts";
|
|
2
|
+
import { type FeishuApi } from "./feishu-api.ts";
|
|
3
|
+
/** The delivery methods a proactive sender needs. */
|
|
4
|
+
export type FeishuTransport = Pick<FeishuApi, "sendText" | "sendMessage">;
|
|
5
|
+
/** A remount replaces the transport; separate clouds can share one agent state root. */
|
|
6
|
+
export declare function registerFeishuApi(stateRoot: string, kind: FeishuCloudKind, api: FeishuApi): void;
|
|
7
|
+
/** Resolve the Feishu transport for a tool's workspace (`ctx.cwd`). */
|
|
8
|
+
export declare function feishuTransport(cwd: string): FeishuTransport;
|
|
9
|
+
/** Resolve the Lark transport for a tool's workspace (`ctx.cwd`). */
|
|
10
|
+
export declare function larkTransport(cwd: string): FeishuTransport;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The mounted channel and proactive tools share credentials, token cache, gateway and retry policy. */
|
|
2
|
+
import { findAgentDir, resolveStateRoot } from "../../paths.js";
|
|
3
|
+
import { cloudFor } from "./cloud.js";
|
|
4
|
+
import { createFeishuApi } from "./feishu-api.js";
|
|
5
|
+
const byKind = {
|
|
6
|
+
feishu: new Map(),
|
|
7
|
+
lark: new Map(),
|
|
8
|
+
};
|
|
9
|
+
/** A remount replaces the transport; separate clouds can share one agent state root. */
|
|
10
|
+
export function registerFeishuApi(stateRoot, kind, api) {
|
|
11
|
+
byKind[kind].set(stateRoot, api);
|
|
12
|
+
}
|
|
13
|
+
function cloudTransport(cwd, kind) {
|
|
14
|
+
// Embedded senders may use a bare definition directory or an independent workspace.
|
|
15
|
+
const stateRoot = resolveStateRoot(findAgentDir(cwd) ?? cwd);
|
|
16
|
+
const transports = byKind[kind];
|
|
17
|
+
let api = transports.get(stateRoot);
|
|
18
|
+
if (!api) {
|
|
19
|
+
// fire/invoke/tool do not mount channels, so their credentials come from the agent's environment.
|
|
20
|
+
const { apiBase, envPrefix } = cloudFor(kind);
|
|
21
|
+
const appId = process.env[`${envPrefix}_APP_ID`];
|
|
22
|
+
const appSecret = process.env[`${envPrefix}_APP_SECRET`];
|
|
23
|
+
if (!appId || !appSecret) {
|
|
24
|
+
throw new Error(`${envPrefix}_APP_ID / ${envPrefix}_APP_SECRET are not set and no ${kind} channel is mounted`);
|
|
25
|
+
}
|
|
26
|
+
api = createFeishuApi({ kind, baseUrl: apiBase, appId, appSecret });
|
|
27
|
+
transports.set(stateRoot, api);
|
|
28
|
+
}
|
|
29
|
+
return api;
|
|
30
|
+
}
|
|
31
|
+
/** Resolve the Feishu transport for a tool's workspace (`ctx.cwd`). */
|
|
32
|
+
export function feishuTransport(cwd) {
|
|
33
|
+
return cloudTransport(cwd, "feishu");
|
|
34
|
+
}
|
|
35
|
+
/** Resolve the Lark transport for a tool's workspace (`ctx.cwd`). */
|
|
36
|
+
export function larkTransport(cwd) {
|
|
37
|
+
return cloudTransport(cwd, "lark");
|
|
38
|
+
}
|
package/dist/channels/http.d.ts
CHANGED
|
@@ -13,14 +13,6 @@ import type { Agent } from "../agent.ts";
|
|
|
13
13
|
/** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
|
|
14
14
|
* dispatch imports it), so the two caps cannot drift apart. */
|
|
15
15
|
export declare const MAX_BODY_BYTES: number;
|
|
16
|
-
/** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
|
|
17
|
-
* it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
|
|
18
|
-
export declare const SSE_HEARTBEAT_MS = 30000;
|
|
19
|
-
/** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
|
|
20
|
-
* `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
|
|
21
|
-
* implementation for every SSE surface, so the emission side cannot regress on one route while
|
|
22
|
-
* the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
|
|
23
|
-
export declare function sseHeartbeat(controller: ReadableStreamDefaultController<Uint8Array>): () => void;
|
|
24
16
|
/** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
|
|
25
17
|
* satisfy, so the CLI's "try it" hint can't drift from the protocol. */
|
|
26
18
|
export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hello\"}";
|
package/dist/channels/http.js
CHANGED
|
@@ -1,28 +1,9 @@
|
|
|
1
1
|
import { readBodyCapped } from "./body.js";
|
|
2
2
|
import { text } from "./respond.js";
|
|
3
|
+
import { sseResponse } from "./sse.js";
|
|
3
4
|
/** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
|
|
4
5
|
* dispatch imports it), so the two caps cannot drift apart. */
|
|
5
6
|
export const MAX_BODY_BYTES = 1 << 20;
|
|
6
|
-
const encoder = new TextEncoder();
|
|
7
|
-
/** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
|
|
8
|
-
* it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
|
|
9
|
-
export const SSE_HEARTBEAT_MS = 30_000;
|
|
10
|
-
/** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
|
|
11
|
-
* `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
|
|
12
|
-
* implementation for every SSE surface, so the emission side cannot regress on one route while
|
|
13
|
-
* the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
|
|
14
|
-
export function sseHeartbeat(controller) {
|
|
15
|
-
const encoder = new TextEncoder();
|
|
16
|
-
const timer = setInterval(() => {
|
|
17
|
-
try {
|
|
18
|
-
controller.enqueue(encoder.encode(": ping\n\n"));
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
clearInterval(timer);
|
|
22
|
-
}
|
|
23
|
-
}, SSE_HEARTBEAT_MS);
|
|
24
|
-
return () => clearInterval(timer);
|
|
25
|
-
}
|
|
26
7
|
/** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
|
|
27
8
|
* satisfy, so the CLI's "try it" hint can't drift from the protocol. */
|
|
28
9
|
export const INVOKE_EXAMPLE_BODY = '{"session":"dev","text":"hello"}';
|
|
@@ -48,7 +29,7 @@ export function createInvokeHandler(agent) {
|
|
|
48
29
|
if (typeof session !== "string" || typeof promptText !== "string") {
|
|
49
30
|
return text('need { "session": string, "text": string }\n', 400);
|
|
50
31
|
}
|
|
51
|
-
//
|
|
32
|
+
// INVOKE_EXAMPLE_BODY must keep satisfying this request shape.
|
|
52
33
|
// The OPTIONAL lineage extension (Scope): malformed values are a 400, not a silent drop — a
|
|
53
34
|
// caller that sent them meant them.
|
|
54
35
|
if (parentSession !== undefined && typeof parentSession !== "string") {
|
|
@@ -57,43 +38,10 @@ export function createInvokeHandler(agent) {
|
|
|
57
38
|
if (branchHints !== undefined && !(Array.isArray(branchHints) && branchHints.every((h) => typeof h === "string"))) {
|
|
58
39
|
return text('"branchHints" must be an array of strings\n', 400);
|
|
59
40
|
}
|
|
60
|
-
|
|
61
|
-
// run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
|
|
62
|
-
const iterator = agent
|
|
63
|
-
.invoke({
|
|
41
|
+
return sseResponse(agent.invoke({
|
|
64
42
|
session,
|
|
65
43
|
...(parentSession !== undefined ? { parentSession } : {}),
|
|
66
44
|
...(branchHints !== undefined ? { branchHints } : {}),
|
|
67
|
-
}, { text: promptText })
|
|
68
|
-
// Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
|
|
69
|
-
// distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
|
|
70
|
-
// look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
|
|
71
|
-
let stopHeartbeat = () => { };
|
|
72
|
-
const stream = new ReadableStream({
|
|
73
|
-
start(controller) {
|
|
74
|
-
stopHeartbeat = sseHeartbeat(controller);
|
|
75
|
-
},
|
|
76
|
-
async pull(controller) {
|
|
77
|
-
const { value, done } = await iterator.next();
|
|
78
|
-
if (done) {
|
|
79
|
-
stopHeartbeat();
|
|
80
|
-
controller.close();
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
|
|
84
|
-
},
|
|
85
|
-
async cancel() {
|
|
86
|
-
stopHeartbeat();
|
|
87
|
-
await iterator.return?.();
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
return new Response(stream, {
|
|
91
|
-
status: 200,
|
|
92
|
-
headers: {
|
|
93
|
-
"content-type": "text/event-stream",
|
|
94
|
-
"cache-control": "no-cache",
|
|
95
|
-
connection: "keep-alive",
|
|
96
|
-
},
|
|
97
|
-
});
|
|
45
|
+
}, { text: promptText }));
|
|
98
46
|
};
|
|
99
47
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ContextBuffer } from "./context-buffer.ts";
|
|
2
|
+
import { type TurnRecordBase, type TurnStore } from "./turn-store.ts";
|
|
3
|
+
/** A pending turn is the persisted intent minus its attempt count, plus live-only fields the
|
|
4
|
+
* channel adds (a notice's message id) — never persisted, reconstructed fresh on replay. */
|
|
5
|
+
export type PendingBase<S extends TurnRecordBase> = Omit<S, "attempts">;
|
|
6
|
+
export interface TurnRunnerOptions<R extends PendingBase<S>, S extends TurnRecordBase, E> {
|
|
7
|
+
label: string;
|
|
8
|
+
store: TurnStore<S>;
|
|
9
|
+
buffer: ContextBuffer<E>;
|
|
10
|
+
/** Delivery dedup by platform id, recorded post-persist (Slack, Feishu). */
|
|
11
|
+
seen?: {
|
|
12
|
+
add(id: string): void;
|
|
13
|
+
};
|
|
14
|
+
/** The persisted intent for a pending turn — drops the live-only fields. */
|
|
15
|
+
toStored(rec: R): S;
|
|
16
|
+
/** A recovered intent as a pending turn — live-only fields start absent. */
|
|
17
|
+
fromStored(stored: S): R;
|
|
18
|
+
/** The context-buffer bucket this turn folds. */
|
|
19
|
+
bufferKey(rec: R): string;
|
|
20
|
+
/** The place, for the lifecycle log line (`chat=… thread=…`). */
|
|
21
|
+
where(rec: R): string;
|
|
22
|
+
/** Queue feedback when a turn is scheduled BEHIND an active one. Returns what the runner awaits at
|
|
23
|
+
* dequeue (so the turn reliably takes the notice over instead of racing it) and, optionally, how
|
|
24
|
+
* to cancel a notice that has not fired yet. `done` may reject — posting the notice is a platform
|
|
25
|
+
* call — and the runner logs that and runs the turn anyway. */
|
|
26
|
+
onQueuedBehind?(rec: R): {
|
|
27
|
+
done: Promise<void>;
|
|
28
|
+
cancel?: () => void;
|
|
29
|
+
};
|
|
30
|
+
/** Runs before the attempt is counted. Answer false to leave the intent untouched for a later run
|
|
31
|
+
* (Slack: its transport is known to be down, so an Agent turn now would have nowhere to answer). */
|
|
32
|
+
beforeRun?(rec: R): Promise<boolean>;
|
|
33
|
+
/** The attempt could not be recorded (disk failure): a restart replays the turn, so say so on any
|
|
34
|
+
* notice it holds rather than leaving it pinned at "Queued". */
|
|
35
|
+
onDeferred(rec: R): void;
|
|
36
|
+
/** The turn started the ceiling's worth of times without finishing: tell the asker. */
|
|
37
|
+
notifyDropped(rec: R): void;
|
|
38
|
+
/** Run the turn. `onCompleted` is the durable-commit point (the turn's `completed` event): it drops
|
|
39
|
+
* the intent and commits the folded discussion, in that order. A throw is logged as the turn's
|
|
40
|
+
* failure; the intent is dropped either way. */
|
|
41
|
+
execute(rec: R, discussion: {
|
|
42
|
+
text: string;
|
|
43
|
+
consumed: E[];
|
|
44
|
+
}, onCompleted: () => void): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export interface TurnRunner<R, S> {
|
|
47
|
+
/** Accept a turn: persist its intent (pre-ACK — a failed write throws so the platform redelivers),
|
|
48
|
+
* record the delivery id, enqueue. Recovery re-enqueues without re-persisting. */
|
|
49
|
+
submit(rec: R, persist: boolean): void;
|
|
50
|
+
/** Re-enqueue the turns a prior crash left mid-flight; returns them so a channel can continue its
|
|
51
|
+
* arrival counter. */
|
|
52
|
+
recover(): S[];
|
|
53
|
+
/** Resolve once no turn is in flight — the test/observability seam. */
|
|
54
|
+
idle(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export declare function createTurnRunner<R extends PendingBase<S> & {
|
|
57
|
+
id: string;
|
|
58
|
+
session: string;
|
|
59
|
+
}, S extends TurnRecordBase, E>(options: TurnRunnerOptions<R, S, E>): TurnRunner<R, S>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable-turn LIFECYCLE the stateful chat channels share, over the kit's parts: accept
|
|
3
|
+
* (persist pre-ACK, dedup, enqueue) → dequeue (settle the queue notice, count the attempt against
|
|
4
|
+
* the poison ceiling, fold the buffered discussion) → execute → end (log, drop the intent). Telegram,
|
|
5
|
+
* Slack and Feishu each wrote this out; the copies had already drifted in small ways that were not
|
|
6
|
+
* decisions (which one deletes its notice on defer, which one logs the duration on failure).
|
|
7
|
+
*
|
|
8
|
+
* What stays with the platform is everything that names a platform object: how a queue notice is
|
|
9
|
+
* mounted and taken over, what the prompt looks like, how attachments resolve, what a dropped turn
|
|
10
|
+
* says and where. Those arrive as hooks; the ORDER they run in is this module's.
|
|
11
|
+
*/
|
|
12
|
+
import { log } from "../../log.js";
|
|
13
|
+
import { createTurnQueue } from "./turn-queue.js";
|
|
14
|
+
import { commitAnsweredTurn } from "./turn-store.js";
|
|
15
|
+
export function createTurnRunner(options) {
|
|
16
|
+
const { label, store, buffer, seen, onQueuedBehind } = options;
|
|
17
|
+
const notices = new Map();
|
|
18
|
+
const queue = createTurnQueue({
|
|
19
|
+
label,
|
|
20
|
+
onQueuedBehind: onQueuedBehind && ((rec) => notices.set(rec.id, onQueuedBehind(rec))),
|
|
21
|
+
run: async (rec) => {
|
|
22
|
+
// Runs at DEQUEUE time (serialized). The queue wait is over: cancel a notice that has not
|
|
23
|
+
// fired, then settle so the turn's preview handle is final — in the common path this await is
|
|
24
|
+
// instant. BEFORE the ceiling check so a dropped or deferred turn can take the notice over too.
|
|
25
|
+
const notice = notices.get(rec.id);
|
|
26
|
+
notice?.cancel?.();
|
|
27
|
+
// The notice is the PLATFORM's feedback, not the turn: a failed post must not throw out of the
|
|
28
|
+
// run, which would leak the persisted intent (removed only below) into a replay that burns an
|
|
29
|
+
// attempt against the ceiling. Logged, then the turn proceeds without its handle.
|
|
30
|
+
await notice?.done.catch((error) => log.warn(`${label} queue notice failed: turn=${rec.id} session=${rec.session}: ${String(error)}`));
|
|
31
|
+
notices.delete(rec.id);
|
|
32
|
+
if (options.beforeRun && !(await options.beforeRun(rec)))
|
|
33
|
+
return;
|
|
34
|
+
const decision = store.startAttempt(rec.id);
|
|
35
|
+
if (decision === "exceeded") {
|
|
36
|
+
options.notifyDropped(rec);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (decision === "defer") {
|
|
40
|
+
options.onDeferred(rec);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
log.info(`${label} turn start: turn=${rec.id} session=${rec.session} ${options.where(rec)}`);
|
|
45
|
+
// Snapshot the discussion at dequeue; commit only this snapshot on `completed`, so a message
|
|
46
|
+
// arriving while the turn runs stays buffered for the next answered turn.
|
|
47
|
+
const bufferKey = options.bufferKey(rec);
|
|
48
|
+
const discussion = buffer.peek(bufferKey);
|
|
49
|
+
try {
|
|
50
|
+
await options.execute(rec, discussion, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey, consumed: discussion.consumed }));
|
|
51
|
+
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
// Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
|
|
58
|
+
// never reach the completed hook. Idempotent. Only an INTERRUPTED run (this finally never
|
|
59
|
+
// runs — a crash or SIGTERM deploy) leaves the record for replay; a transport throw is
|
|
60
|
+
// dropped, not retried (safe retry needs an L2 delivery key).
|
|
61
|
+
store.remove(rec.id);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const submit = (rec, persist) => {
|
|
66
|
+
if (persist) {
|
|
67
|
+
store.add(options.toStored(rec)); // pre-ACK: a failed write throws → 500 → redelivery
|
|
68
|
+
seen?.add(rec.id); // post-persist — recording first could turn a failed write into silent loss
|
|
69
|
+
}
|
|
70
|
+
queue.accept(rec);
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
submit,
|
|
74
|
+
recover() {
|
|
75
|
+
const recovered = store.recover();
|
|
76
|
+
if (recovered.length > 0)
|
|
77
|
+
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
78
|
+
for (const stored of recovered)
|
|
79
|
+
submit(options.fromStored(stored), false);
|
|
80
|
+
return recovered;
|
|
81
|
+
},
|
|
82
|
+
idle: () => queue.idle(),
|
|
83
|
+
};
|
|
84
|
+
}
|