@fastagent-sh/fastagent 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +11 -0
- package/dist/channels/agentcore.d.ts +16 -2
- package/dist/channels/agentcore.js +66 -4
- package/dist/channels/feishu/card.d.ts +20 -9
- package/dist/channels/feishu/card.js +27 -13
- package/dist/channels/feishu/feishu-api.d.ts +13 -2
- package/dist/channels/feishu/feishu.js +121 -17
- package/dist/channels/feishu/invoke-turn.d.ts +12 -2
- package/dist/channels/feishu/invoke-turn.js +159 -14
- package/dist/channels/feishu/normalize.js +97 -32
- package/dist/channels/feishu/parse.js +6 -0
- package/dist/channels/feishu/preview.d.ts +4 -3
- package/dist/channels/feishu/preview.js +77 -23
- package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
- package/dist/channels/http.js +15 -2
- package/dist/channels/invoke-turn-kit.d.ts +5 -2
- package/dist/channels/invoke-turn-kit.js +6 -2
- package/dist/channels/lark/scaffold/lark-send.ts +9 -6
- package/dist/channels/slack/invoke-turn.js +1 -1
- package/dist/channels/slack/slack.js +1 -5
- package/dist/channels/state.d.ts +0 -10
- package/dist/channels/state.js +2 -19
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/thread-participants.d.ts +7 -0
- package/dist/channels/thread-participants.js +3 -0
- package/dist/cli/commands/deploy.js +13 -5
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +21 -1
- package/dist/cli/commands/invoke.js +1 -1
- package/dist/cli/commands/start.js +40 -6
- package/dist/cli/serve.d.ts +4 -0
- package/dist/cli/serve.js +2 -2
- package/dist/cli/shared.d.ts +4 -2
- package/dist/cli/shared.js +12 -5
- package/dist/collect.d.ts +30 -4
- package/dist/collect.js +39 -6
- package/dist/deploy/agentcore/plan.js +17 -0
- package/dist/deploy/agentcore/run.d.ts +7 -1
- package/dist/deploy/agentcore/run.js +93 -8
- package/dist/deploy/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +55 -10
- package/dist/deploy/secrets.d.ts +3 -0
- package/dist/deploy/secrets.js +6 -0
- package/dist/dev-supervisor.js +8 -2
- package/dist/engines/pi/create.d.ts +2 -1
- package/dist/engines/pi/create.js +12 -7
- package/dist/engines/pi/harness.d.ts +6 -3
- package/dist/engines/pi/harness.js +4 -3
- package/dist/engines/pi/invoke-session.d.ts +32 -0
- package/dist/engines/pi/invoke-session.js +171 -0
- package/dist/engines/pi/invoke.d.ts +6 -27
- package/dist/engines/pi/invoke.js +49 -208
- package/dist/engines/pi/models.d.ts +45 -11
- package/dist/engines/pi/models.js +55 -8
- package/dist/engines/pi/session-builder.js +4 -2
- package/dist/engines/pi/session-control.d.ts +2 -1
- package/dist/engines/pi/sessions.d.ts +17 -1
- package/dist/engines/pi/sessions.js +292 -10
- package/dist/engines/pi/turn-kit.d.ts +56 -0
- package/dist/engines/pi/turn-kit.js +161 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +6 -0
- package/dist/pi.d.ts +3 -2
- package/dist/pi.js +1 -1
- package/dist/scaffold/templates/fastagent.config.mjs +2 -0
- package/dist/session-remote.js +10 -2
- package/package.json +1 -1
package/dist/agent.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export interface Prompt {
|
|
|
19
19
|
export interface Scope {
|
|
20
20
|
/** Opaque session anchor: turns of the same logical conversation MUST reuse the same value. */
|
|
21
21
|
session: string;
|
|
22
|
+
/** EXTENSION (SPEC §8): the session this one branched from — a channel sets it when the place it
|
|
23
|
+
* is invoking for was born out of another place (a thread opened in a room). Read ONLY when
|
|
24
|
+
* `session` does not exist yet: an engine that understands it seeds the NEW session from the
|
|
25
|
+
* parent once, at creation; after that the field is ignored, and an engine that does not
|
|
26
|
+
* understand it ignores it entirely (the session starts empty, yesterday's behavior). */
|
|
27
|
+
parentSession?: string;
|
|
28
|
+
/** EXTENSION (SPEC §8): opaque markers that MAY locate the branch point inside `parentSession` —
|
|
29
|
+
* e.g. platform message ids its transcript embeds. Best-effort by design: the first marker found
|
|
30
|
+
* in the parent's transcript wins, and no match falls back to the parent's present. Meaningless
|
|
31
|
+
* without `parentSession`. */
|
|
32
|
+
branchHints?: string[];
|
|
22
33
|
}
|
|
23
34
|
export type AgentEvent = {
|
|
24
35
|
type: "text";
|
|
@@ -54,6 +54,15 @@ export type AgentcoreEnvelope = {
|
|
|
54
54
|
* "channels with replay re-run it" true rather than aspirational. */
|
|
55
55
|
| {
|
|
56
56
|
kind: "checkpoint";
|
|
57
|
+
}
|
|
58
|
+
/** The deploy driver's post-deploy verification (relayed by the forwarder's reserved
|
|
59
|
+
* `/__fastagent/probe` path, which answers on EVERY forwarder topology — schedule-only URLs
|
|
60
|
+
* refuse ordinary public traffic). Runs restore + channel construction end to end and answers a
|
|
61
|
+
* TRANSPORT-200 structured verdict `{ ok, error? }`: the ordinary webhook path folds a non-200
|
|
62
|
+
* transport into an opaque 502 at the forwarder, which would strip exactly the diagnostics this
|
|
63
|
+
* probe exists to carry. */
|
|
64
|
+
| {
|
|
65
|
+
kind: "probe";
|
|
57
66
|
});
|
|
58
67
|
/** The webhook envelope's reply: the channel's real HTTP response, ridden inside a transport-200
|
|
59
68
|
* body so the forwarder can re-emit it verbatim (see the module header on AgentCore's 424 folding). */
|
|
@@ -63,8 +72,13 @@ export interface WebhookReply {
|
|
|
63
72
|
bodyB64: string;
|
|
64
73
|
}
|
|
65
74
|
export interface AgentcoreAdapterOptions {
|
|
66
|
-
/** The serving routes a direct deployment would mount (channels or the builtin invoke + health).
|
|
67
|
-
|
|
75
|
+
/** The serving routes a direct deployment would mount (channels or the builtin invoke + health).
|
|
76
|
+
* The serving path passes a LAZY factory: channel construction loads channel state and replays
|
|
77
|
+
* durable turn intent, so on AgentCore it must not run until the state root is authoritative —
|
|
78
|
+
* which happens at the first envelope's `stateSync.ready()` (the restore URLs only an envelope
|
|
79
|
+
* carries), never at boot, where the mount is pre-restore (empty after every version update).
|
|
80
|
+
* An eager `Routes` value remains supported for wirings whose state root is already durable. */
|
|
81
|
+
routes: Routes | (() => Promise<Routes> | Routes);
|
|
68
82
|
agent: Agent;
|
|
69
83
|
/** Where the forwarder URL from envelopes is persisted for the wake-alarm sink (the state root). */
|
|
70
84
|
stateRoot: string;
|
|
@@ -55,7 +55,28 @@ function secretMatches(actual, expected) {
|
|
|
55
55
|
*/
|
|
56
56
|
export function agentcoreRoutes(options) {
|
|
57
57
|
const { routes, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
|
|
58
|
-
|
|
58
|
+
// Lazy channel construction (see AgentcoreAdapterOptions.routes) — resolved ONCE per process, on
|
|
59
|
+
// the first trusted envelope after the state root is authoritative, and the outcome is cached
|
|
60
|
+
// EITHER WAY. Success: the same resident channels a direct host keeps. Failure too: construction
|
|
61
|
+
// is an ACTIVATION with side effects — loadChannels builds every healthy channel (starting its
|
|
62
|
+
// queues and replaying durable turn intent) before reporting another module's failure — and there
|
|
63
|
+
// is no cleanup contract to unwind it, so re-running it per envelope could replay the same
|
|
64
|
+
// recovered turn concurrently. The first rejection is therefore the process's answer: every later
|
|
65
|
+
// envelope fails with the same message (visible each time), the retry boundary is a fresh session
|
|
66
|
+
// (which scale-to-zero provides naturally), and the deploy driver's probe catches deterministic
|
|
67
|
+
// failures at deploy time.
|
|
68
|
+
let dispatchP;
|
|
69
|
+
const resolveDispatch = () => {
|
|
70
|
+
if (!dispatchP) {
|
|
71
|
+
// The factory runs INSIDE the chain: a synchronous throw must land in the cached rejection,
|
|
72
|
+
// not escape before `dispatchP` is assigned (which would silently re-run the activation).
|
|
73
|
+
dispatchP = Promise.resolve()
|
|
74
|
+
.then(() => (typeof routes === "function" ? routes() : routes))
|
|
75
|
+
.then(router);
|
|
76
|
+
dispatchP.catch(() => { }); // observed here so the CACHED rejection is never "unhandled"
|
|
77
|
+
}
|
|
78
|
+
return dispatchP;
|
|
79
|
+
};
|
|
59
80
|
const invokeHandler = createInvokeHandler(agent);
|
|
60
81
|
// Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
|
|
61
82
|
// background, so "the request returned" is NOT when the state root settles.
|
|
@@ -75,7 +96,7 @@ export function agentcoreRoutes(options) {
|
|
|
75
96
|
return text("invalid json\n", 400);
|
|
76
97
|
}
|
|
77
98
|
if (envelope === null || typeof envelope !== "object" || typeof envelope.kind !== "string") {
|
|
78
|
-
return text('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint", ... }\n', 400);
|
|
99
|
+
return text('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint" | "probe", ... }\n', 400);
|
|
79
100
|
}
|
|
80
101
|
// AUTHENTICATION BOUNDARY. `InvokeAgentRuntime` is an ordinary IAM action, so "reached this
|
|
81
102
|
// handler" proves nothing about the sender. Only an envelope carrying the shared secret is the
|
|
@@ -113,7 +134,7 @@ export function agentcoreRoutes(options) {
|
|
|
113
134
|
stateSync.use(envelope.state);
|
|
114
135
|
}
|
|
115
136
|
else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
|
|
116
|
-
// webhook/schedule-fire/wake-poke reach us ONLY through the forwarder, which
|
|
137
|
+
// webhook/schedule-fire/wake-poke/probe reach us ONLY through the forwarder, which mints the
|
|
117
138
|
// pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
|
|
118
139
|
// once per process (a direct `invoke` legitimately has none — its session storage is its own).
|
|
119
140
|
warnedUnsnapshotted = true;
|
|
@@ -124,6 +145,11 @@ export function agentcoreRoutes(options) {
|
|
|
124
145
|
}
|
|
125
146
|
catch (e) {
|
|
126
147
|
log.error(`[agentcore] state restore failed: ${String(e)}`);
|
|
148
|
+
// The probe is the deploy driver's verification channel: its diagnostics must survive the
|
|
149
|
+
// forwarder, which folds a non-200 transport into an opaque 502 — so for it the failure
|
|
150
|
+
// rides a transport-200 structured verdict; every other kind keeps the plain 503.
|
|
151
|
+
if (envelope.kind === "probe")
|
|
152
|
+
return json({ ok: false, error: `state restore failed: ${String(e)}` }, 200);
|
|
127
153
|
return text(`state restore failed: ${String(e)}\n`, 503);
|
|
128
154
|
}
|
|
129
155
|
}
|
|
@@ -134,6 +160,26 @@ export function agentcoreRoutes(options) {
|
|
|
134
160
|
stateReadyFired = true;
|
|
135
161
|
onStateReady();
|
|
136
162
|
}
|
|
163
|
+
// PROCESS INITIALIZATION, kind-independent: the (lazy) channels are constructed on the first
|
|
164
|
+
// trusted ingress after the state root became authoritative — whichever kind carries it, so a
|
|
165
|
+
// cold start woken by a schedule fire or an alarm poke still replays checkpointed turn intent.
|
|
166
|
+
// Two deliberate exceptions: `checkpoint` must push state even when a channel is broken, and a
|
|
167
|
+
// public `invoke` runs in its own isolated storage — constructing against THAT root would cache
|
|
168
|
+
// pre-restore emptiness for the ingress session. Failure policy is per kind below: webhook and
|
|
169
|
+
// wake-poke fail their request (503), the probe reports it structurally, and a schedule fire
|
|
170
|
+
// proceeds — cron does not consume channels, and letting an unrelated channel misconfiguration
|
|
171
|
+
// silence the clock would turn one fault into two (the error is logged here either way).
|
|
172
|
+
let constructionError;
|
|
173
|
+
let dispatch;
|
|
174
|
+
if (trusted && envelope.kind !== "checkpoint" && envelope.kind !== "invoke") {
|
|
175
|
+
try {
|
|
176
|
+
dispatch = await resolveDispatch();
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
constructionError = String(e);
|
|
180
|
+
log.error(`[agentcore] channel construction failed: ${constructionError}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
137
183
|
switch (envelope.kind) {
|
|
138
184
|
case "webhook": {
|
|
139
185
|
const { method, path, query, headers, bodyB64 } = envelope;
|
|
@@ -154,6 +200,10 @@ export function agentcoreRoutes(options) {
|
|
|
154
200
|
? Buffer.from(bodyB64, "base64")
|
|
155
201
|
: undefined,
|
|
156
202
|
});
|
|
203
|
+
// A construction failure is the request's failure (503 through the forwarder, so the
|
|
204
|
+
// platform retries and the operator sees the message), never a silently-empty channel.
|
|
205
|
+
if (!dispatch)
|
|
206
|
+
return text(`channel construction failed: ${constructionError ?? "unavailable"}\n`, 503);
|
|
157
207
|
const response = await dispatch(inner);
|
|
158
208
|
// Buffer the channel's ACK (webhook ACKs are small by design — the turn itself runs
|
|
159
209
|
// fire-and-forget) and ride it inside the transport reply, byte-exact.
|
|
@@ -218,9 +268,21 @@ export function agentcoreRoutes(options) {
|
|
|
218
268
|
}
|
|
219
269
|
case "wake-poke": {
|
|
220
270
|
// The poke's job is DONE by arriving: the invocation woke (or kept awake) the container, and
|
|
221
|
-
// the wake pump (boot drain + 30s poll) fires whatever is due. Nothing to dispatch
|
|
271
|
+
// the wake pump (boot drain + 30s poll) fires whatever is due. Nothing to dispatch — the
|
|
272
|
+
// initialization above already resolved construction (replaying checkpointed turn intent),
|
|
273
|
+
// and its failure is this request's failure so the alarm's log line names it.
|
|
274
|
+
if (constructionError !== undefined)
|
|
275
|
+
return text(`channel construction failed: ${constructionError}\n`, 503);
|
|
222
276
|
return json({ ok: true }, 200);
|
|
223
277
|
}
|
|
278
|
+
case "probe": {
|
|
279
|
+
// The structured verdict (transport-200 — see the envelope doc): the deploy driver reads it
|
|
280
|
+
// through the forwarder's reserved path, so the error text survives the hop that turns any
|
|
281
|
+
// non-200 transport into an opaque 502.
|
|
282
|
+
return json(constructionError === undefined
|
|
283
|
+
? { ok: true }
|
|
284
|
+
: { ok: false, error: `channel construction failed: ${constructionError}` }, 200);
|
|
285
|
+
}
|
|
224
286
|
case "invoke": {
|
|
225
287
|
// Reuse the HTTP channel's handler wholesale (SSE, cancellation, backpressure) by handing it
|
|
226
288
|
// the shape it already validates — one protocol, one implementation.
|
|
@@ -3,16 +3,24 @@
|
|
|
3
3
|
* final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
|
|
4
4
|
* the card DSL is data-in → string-out and testable without the pump.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
* renders the typewriter effect.
|
|
9
|
-
*
|
|
6
|
+
* The streaming card is TWO markdown elements: `process` (the volatile block — thinking tail, tool
|
|
7
|
+
* lines, retry notice) and `answer` (append-only). The pump PUTs full-text snapshots per element
|
|
8
|
+
* (feishu-api.ts `updateCardElement`) and the client renders the typewriter effect. The split is the
|
|
9
|
+
* prefix-stability rule made structural: the client animates an element's update only when the old
|
|
10
|
+
* text is a PREFIX of the new — otherwise it re-types everything after the first divergent character.
|
|
11
|
+
* The process block's head changes every frame (a sliding thinking tail, `…`→`✓` status flips), so
|
|
12
|
+
* sharing one element with the answer re-typed the whole card once a second; two elements confine the
|
|
13
|
+
* churn to the small process block and keep the answer's typewriter smooth. Settling replaces the
|
|
14
|
+
* whole entity (`updateCard`) with the answer element alone, `streaming_mode` off — one write flips
|
|
15
|
+
* content and mode together and drops the process block.
|
|
10
16
|
*
|
|
11
17
|
* Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
|
|
12
18
|
* well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
|
|
13
19
|
*/
|
|
14
|
-
/** The
|
|
20
|
+
/** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
|
|
15
21
|
export declare const ANSWER_ELEMENT_ID = "answer";
|
|
22
|
+
/** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
|
|
23
|
+
export declare const PROCESS_ELEMENT_ID = "process";
|
|
16
24
|
/** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
|
|
17
25
|
export declare const CARD_MARKDOWN_MAX_BYTES: number;
|
|
18
26
|
/**
|
|
@@ -23,10 +31,13 @@ export declare const CARD_MARKDOWN_MAX_BYTES: number;
|
|
|
23
31
|
* emphasis/heading/list markers removed.
|
|
24
32
|
*/
|
|
25
33
|
export declare function cardSummary(markdown: string): string;
|
|
26
|
-
/** The live-preview card entity: streaming on, seeded with the placeholder/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
/** The live-preview card entity: streaming on, the process element seeded with the placeholder/queue
|
|
35
|
+
* status and the answer element seeded EMPTY (the platform accepts an empty markdown element; it
|
|
36
|
+
* renders zero-height until the first answer snapshot lands as a clean prefix extension of ""). */
|
|
37
|
+
export declare function streamingCardJson(initialProcess: string): string;
|
|
38
|
+
/** The settled card: final markdown alone (the process block was preview-only), streaming off (stops
|
|
39
|
+
* the client's streaming affordance), plus the answer-derived summary so the chat list / notification
|
|
40
|
+
* shows the reply, not "[Card]". */
|
|
30
41
|
export declare function finalCardJson(markdown: string): string;
|
|
31
42
|
/** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
|
|
32
43
|
export declare function cardEntityContent(cardId: string): string;
|
|
@@ -3,17 +3,25 @@
|
|
|
3
3
|
* final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
|
|
4
4
|
* the card DSL is data-in → string-out and testable without the pump.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
* renders the typewriter effect.
|
|
9
|
-
*
|
|
6
|
+
* The streaming card is TWO markdown elements: `process` (the volatile block — thinking tail, tool
|
|
7
|
+
* lines, retry notice) and `answer` (append-only). The pump PUTs full-text snapshots per element
|
|
8
|
+
* (feishu-api.ts `updateCardElement`) and the client renders the typewriter effect. The split is the
|
|
9
|
+
* prefix-stability rule made structural: the client animates an element's update only when the old
|
|
10
|
+
* text is a PREFIX of the new — otherwise it re-types everything after the first divergent character.
|
|
11
|
+
* The process block's head changes every frame (a sliding thinking tail, `…`→`✓` status flips), so
|
|
12
|
+
* sharing one element with the answer re-typed the whole card once a second; two elements confine the
|
|
13
|
+
* churn to the small process block and keep the answer's typewriter smooth. Settling replaces the
|
|
14
|
+
* whole entity (`updateCard`) with the answer element alone, `streaming_mode` off — one write flips
|
|
15
|
+
* content and mode together and drops the process block.
|
|
10
16
|
*
|
|
11
17
|
* Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
|
|
12
18
|
* well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
|
|
13
19
|
*/
|
|
14
20
|
import { truncateCodePointPrefix } from "../text.js";
|
|
15
|
-
/** The
|
|
21
|
+
/** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
|
|
16
22
|
export const ANSWER_ELEMENT_ID = "answer";
|
|
23
|
+
/** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
|
|
24
|
+
export const PROCESS_ELEMENT_ID = "process";
|
|
17
25
|
/** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
|
|
18
26
|
export const CARD_MARKDOWN_MAX_BYTES = 20 * 1024;
|
|
19
27
|
/** Character budget for the settled card's summary (the chat-list / push-notification preview). */
|
|
@@ -38,7 +46,7 @@ export function cardSummary(markdown) {
|
|
|
38
46
|
.find((l) => l !== "") ?? "";
|
|
39
47
|
return truncateCodePointPrefix(line, SUMMARY_MAX_CHARS);
|
|
40
48
|
}
|
|
41
|
-
function cardJson(
|
|
49
|
+
function cardJson(elements, streaming, summary) {
|
|
42
50
|
return JSON.stringify({
|
|
43
51
|
schema: "2.0",
|
|
44
52
|
config: {
|
|
@@ -48,17 +56,23 @@ function cardJson(markdown, streaming, summary) {
|
|
|
48
56
|
// "[Generating…]") is better than any fixed text we could pin.
|
|
49
57
|
...(summary ? { summary: { content: summary } } : {}),
|
|
50
58
|
},
|
|
51
|
-
body: { elements
|
|
59
|
+
body: { elements },
|
|
52
60
|
});
|
|
53
61
|
}
|
|
54
|
-
/** The live-preview card entity: streaming on, seeded with the placeholder/
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
/** The live-preview card entity: streaming on, the process element seeded with the placeholder/queue
|
|
63
|
+
* status and the answer element seeded EMPTY (the platform accepts an empty markdown element; it
|
|
64
|
+
* renders zero-height until the first answer snapshot lands as a clean prefix extension of ""). */
|
|
65
|
+
export function streamingCardJson(initialProcess) {
|
|
66
|
+
return cardJson([
|
|
67
|
+
{ tag: "markdown", content: initialProcess, element_id: PROCESS_ELEMENT_ID },
|
|
68
|
+
{ tag: "markdown", content: "", element_id: ANSWER_ELEMENT_ID },
|
|
69
|
+
], true);
|
|
57
70
|
}
|
|
58
|
-
/** The settled card: final markdown
|
|
59
|
-
* the answer-derived summary so the chat list / notification
|
|
71
|
+
/** The settled card: final markdown alone (the process block was preview-only), streaming off (stops
|
|
72
|
+
* the client's streaming affordance), plus the answer-derived summary so the chat list / notification
|
|
73
|
+
* shows the reply, not "[Card]". */
|
|
60
74
|
export function finalCardJson(markdown) {
|
|
61
|
-
return cardJson(markdown, false, cardSummary(markdown));
|
|
75
|
+
return cardJson([{ tag: "markdown", content: markdown, element_id: ANSWER_ELEMENT_ID }], false, cardSummary(markdown));
|
|
62
76
|
}
|
|
63
77
|
/** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
|
|
64
78
|
export function cardEntityContent(cardId) {
|
|
@@ -65,15 +65,26 @@ export interface FeishuApi {
|
|
|
65
65
|
editTextMessage(messageId: string, text: string): Promise<void>;
|
|
66
66
|
/** Recall (delete) a message the bot sent. */
|
|
67
67
|
deleteMessage(messageId: string): Promise<void>;
|
|
68
|
-
/** Fetch one message (the reply-referent path). Undefined when the API returns no item.
|
|
68
|
+
/** Fetch one message (the reply-referent path). Undefined when the API returns no item.
|
|
69
|
+
*
|
|
70
|
+
* `sender` is typed rather than `unknown` because the referent path READS it: an app-sent message
|
|
71
|
+
* whose id is THIS app's is the agent's own, which the prompt must say instead of attributing it to
|
|
72
|
+
* "user cli_…". `parent_id` is read by the reply-chain walk (invoke-turn): the message this one
|
|
73
|
+
* itself replied to. The message object carries more (`root_id`, `thread_id`); they stay unnamed
|
|
74
|
+
* until something reads them — this type is the surface in use, not a mirror of the wire. */
|
|
69
75
|
getMessage(messageId: string): Promise<{
|
|
70
76
|
message_id?: string;
|
|
71
77
|
msg_type?: string;
|
|
78
|
+
parent_id?: string;
|
|
72
79
|
body?: {
|
|
73
80
|
content?: string;
|
|
74
81
|
};
|
|
75
82
|
mentions?: unknown[];
|
|
76
|
-
sender?:
|
|
83
|
+
sender?: {
|
|
84
|
+
id?: string;
|
|
85
|
+
id_type?: string;
|
|
86
|
+
sender_type?: string;
|
|
87
|
+
};
|
|
77
88
|
} | undefined>;
|
|
78
89
|
/** Download a message resource (image/file bytes). Caps at {@link MAX_DOWNLOAD_BYTES}. */
|
|
79
90
|
downloadResource(messageId: string, fileKey: string, type: "image" | "file"): Promise<{
|
|
@@ -14,7 +14,7 @@ import { readBodyCapped } from "../body.js";
|
|
|
14
14
|
import { text } from "../respond.js";
|
|
15
15
|
import { createSeenRing } from "../seen.js";
|
|
16
16
|
import { createTaskTracker } from "../tasks.js";
|
|
17
|
-
import { ensureStateHome,
|
|
17
|
+
import { ensureStateHome, loadStateFile, saveStateFile } from "../state.js";
|
|
18
18
|
import { dispatchStop, isStopText } from "../stop-command.js";
|
|
19
19
|
import { createTurnQueue } from "../turn-queue.js";
|
|
20
20
|
import { createTurnStore } from "../turn-store.js";
|
|
@@ -58,6 +58,8 @@ function isStoredFeishuTurn(t) {
|
|
|
58
58
|
(r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
|
|
59
59
|
(r.replyInThread === undefined || typeof r.replyInThread === "boolean") &&
|
|
60
60
|
(r.parentId === undefined || typeof r.parentId === "string") &&
|
|
61
|
+
(r.parentSession === undefined || typeof r.parentSession === "string") &&
|
|
62
|
+
(r.roomBufferKey === undefined || typeof r.roomBufferKey === "string") &&
|
|
61
63
|
refs(r.images) &&
|
|
62
64
|
refs(r.files) &&
|
|
63
65
|
typeof r.attempts === "number");
|
|
@@ -93,16 +95,45 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
93
95
|
}
|
|
94
96
|
const formatError = onError ?? defaultErrorMessage;
|
|
95
97
|
const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
|
|
96
|
-
// One bot/v3/info
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
98
|
+
// One bot/v3/info per process refreshes the bot's own open_id — the identity the default route
|
|
99
|
+
// matches group @mentions against. The CACHED copy (bot.json, seeded synchronously once the state
|
|
100
|
+
// home exists below) is what makes the first envelope safe: this fetch is fire-and-forget, and
|
|
101
|
+
// under the AgentCore posture channel construction happens INSIDE the first envelope
|
|
102
|
+
// (channels/agentcore.ts lazy construction), so a network round trip can never beat that same
|
|
103
|
+
// envelope's own dispatch — without the seed, every cold start's FIRST group mention raced this
|
|
104
|
+
// fetch and lost (field-observed: an explicit @ buffered as bystander context). The open_id is a
|
|
105
|
+
// stable property of the app, so disk beats network; the only envelope a deployment ever serves
|
|
106
|
+
// without the file is its first one, which is the deploy driver's probe — it carries no mention.
|
|
107
|
+
// No identity at all (fresh dir, no cache, fetch pending/failed) keeps today's fail-closed
|
|
108
|
+
// behavior: unmatched mentions buffer as context — delayed, never lost.
|
|
100
109
|
let botOpenId;
|
|
110
|
+
let persistBotIdentity = () => { }; // bound once the state home exists
|
|
111
|
+
let invalidateBotIdentity = () => { }; // likewise
|
|
101
112
|
void api.botInfo().then((me) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
if (me.openId) {
|
|
114
|
+
if (botOpenId !== undefined && botOpenId !== me.openId) {
|
|
115
|
+
log.info(`${label} bot open_id changed (${botOpenId} → ${me.openId}) — updating the cached identity`);
|
|
116
|
+
}
|
|
117
|
+
botOpenId = me.openId;
|
|
118
|
+
persistBotIdentity(me.openId);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// The call SUCCEEDED and the platform reported no identity — an affirmative "there is no
|
|
122
|
+
// bot here" (capability off, app reconfigured), not transport weather. This is the one
|
|
123
|
+
// answer that must also INVALIDATE the cache: keeping a summon identity the platform just
|
|
124
|
+
// declined to confirm would quietly turn fail-closed into fail-open.
|
|
125
|
+
log.warn(botOpenId === undefined
|
|
126
|
+
? `${label} bot/v3/info returned no open_id — group @mention summon stays off`
|
|
127
|
+
: `${label} bot/v3/info returned no open_id — cached identity cleared; group @mention summon stays off`);
|
|
128
|
+
botOpenId = undefined;
|
|
129
|
+
invalidateBotIdentity();
|
|
130
|
+
}
|
|
131
|
+
}, (e) =>
|
|
132
|
+
// A FAILED call is transport weather (network, rate limit): the platform said nothing about
|
|
133
|
+
// the identity, so a cached one keeps serving — the degradation this cache exists for.
|
|
134
|
+
log.warn(botOpenId === undefined
|
|
135
|
+
? `${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`
|
|
136
|
+
: `${label} bot/v3/info failed; running on the cached identity (bot.json): ${String(e)}`));
|
|
106
137
|
void api.listAppScopes().then((scopes) => {
|
|
107
138
|
const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
|
|
108
139
|
if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
|
|
@@ -131,10 +162,51 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
131
162
|
}
|
|
132
163
|
const stateHome = join(stateRoot, "channels", kind);
|
|
133
164
|
ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
|
|
134
|
-
// The
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
165
|
+
// The cached bot identity (rationale at the botInfo block above): seed synchronously — the
|
|
166
|
+
// factory runs to completion before any promise resolves, so botOpenId is still unset here and
|
|
167
|
+
// the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
|
|
168
|
+
//
|
|
169
|
+
// BOUND TO THE APP: the state home is per channel KIND, and an operator can point kept state at
|
|
170
|
+
// a different app (a recreated app, a tenant migration). A cached identity from another app
|
|
171
|
+
// would make THIS bot treat mentions of the OLD bot as its own summons — identity impersonation,
|
|
172
|
+
// strictly worse than the race the cache removes — so the cache counts only when it names the
|
|
173
|
+
// current appId. A mismatch is not noise worth warning about: the next persist IS the migration.
|
|
174
|
+
const botFile = join(stateHome, "bot.json");
|
|
175
|
+
const storedBot = loadStateFile(botFile);
|
|
176
|
+
let cachedOpenId;
|
|
177
|
+
if (storedBot !== undefined) {
|
|
178
|
+
if (typeof storedBot.appId !== "string") {
|
|
179
|
+
log.warn(`${label} unexpected shape in ${botFile} — ignoring the cached bot identity`);
|
|
180
|
+
}
|
|
181
|
+
else if (storedBot.appId === appId && typeof storedBot.openId === "string") {
|
|
182
|
+
cachedOpenId = storedBot.openId;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
botOpenId ??= cachedOpenId;
|
|
186
|
+
persistBotIdentity = (openId) => {
|
|
187
|
+
if (openId === cachedOpenId)
|
|
188
|
+
return;
|
|
189
|
+
cachedOpenId = openId;
|
|
190
|
+
try {
|
|
191
|
+
saveStateFile(botFile, { appId, openId });
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
log.warn(`${label} could not persist the bot identity to ${botFile} — the next cold start races bot/v3/info again: ${String(e)}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
invalidateBotIdentity = () => {
|
|
198
|
+
if (cachedOpenId === undefined)
|
|
199
|
+
return;
|
|
200
|
+
cachedOpenId = undefined;
|
|
201
|
+
try {
|
|
202
|
+
// `{ appId }` with no openId reads as "no cache" at the loader — the atomic write is reused
|
|
203
|
+
// instead of introducing a deletion path.
|
|
204
|
+
saveStateFile(botFile, { appId });
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
log.warn(`${label} could not clear the cached bot identity ${botFile}: ${String(e)}`);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
138
210
|
const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
|
|
139
211
|
/** This channel's place key for a thread (the shared store is key-agnostic). */
|
|
140
212
|
// The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
|
|
@@ -235,13 +307,31 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
235
307
|
// this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
|
|
236
308
|
// would instead couple otherwise-independent root sessions and require failure rollback.
|
|
237
309
|
const { text: recent, consumed } = buffer.peek(rec.bufferKey);
|
|
238
|
-
|
|
239
|
-
const
|
|
310
|
+
// PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
|
|
311
|
+
const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
|
|
312
|
+
const roomBlock = room?.text
|
|
313
|
+
? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
|
|
314
|
+
: "";
|
|
315
|
+
const threadBlock = recent ? `[recent group discussion:\n${recent}\n]\n\n` : "";
|
|
316
|
+
const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
|
|
317
|
+
// Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
|
|
318
|
+
// attachments win the slots.
|
|
319
|
+
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
|
|
240
320
|
images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
|
|
241
321
|
files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
|
|
242
322
|
});
|
|
323
|
+
// Recorded at ingress (see submit) — never re-derived from the session key, which may be a
|
|
324
|
+
// routed OPAQUE id that only looks like a place key.
|
|
325
|
+
const parentSession = rec.parentSession;
|
|
243
326
|
try {
|
|
244
|
-
await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
|
|
327
|
+
await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
|
|
328
|
+
api,
|
|
329
|
+
chatId: rec.chatId,
|
|
330
|
+
filesDir: join(stateHome, "files"),
|
|
331
|
+
label,
|
|
332
|
+
appId,
|
|
333
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
334
|
+
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
|
|
245
335
|
// Drop intent first: a crash between these writes may re-fold answered context later,
|
|
246
336
|
// but can never replay this turn after its context was removed.
|
|
247
337
|
store.remove(rec.id);
|
|
@@ -391,7 +481,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
391
481
|
// Memory follows the place (participant model §5): one session per chat, and one per thread.
|
|
392
482
|
// Keyed by `thread_id`, never `root_id` — the platform's root_id tracks the reply chain and can
|
|
393
483
|
// differ between messages of ONE thread, which would split a side conversation in two.
|
|
394
|
-
const
|
|
484
|
+
const routed = r.session;
|
|
485
|
+
const session = routed ?? placeKey(kind, m);
|
|
486
|
+
// Lineage is recorded ONLY for the default place-derived session. A routed session id is
|
|
487
|
+
// OPAQUE (the route contract), and re-parsing it as a place key would let a three-segment id
|
|
488
|
+
// like "tenant:user:alice" masquerade as a thread and inherit from "tenant:user" — a
|
|
489
|
+
// cross-session injection. Derived from the MESSAGE (the fact this channel owns), at record
|
|
490
|
+
// time, where routed-ness is still known; the dequeue path only reads it back.
|
|
491
|
+
const parentSession = routed === undefined && m.thread_id !== undefined ? placeKey(kind, { chat_id: m.chat_id }) : undefined;
|
|
492
|
+
// Read BEFORE this turn records its own participation below, or it is always true. Keyed by the
|
|
493
|
+
// SOURCE chat, never the answer target a route may name (§8).
|
|
494
|
+
const roomBufferKey = parentSession !== undefined && !threadParticipants.agentSpokeIn(session)
|
|
495
|
+
? feishuBufferPlaceKey({ chatId: m.chat_id })
|
|
496
|
+
: undefined;
|
|
395
497
|
const chatId = r.chatId ?? m.chat_id;
|
|
396
498
|
const sameTarget = chatId === m.chat_id;
|
|
397
499
|
// Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
|
|
@@ -443,6 +545,8 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
443
545
|
// already have; it also pins WHICH message is being answered, which a long thread benefits
|
|
444
546
|
// from anyway.
|
|
445
547
|
parentId: m.parent_id,
|
|
548
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
549
|
+
...(roomBufferKey !== undefined ? { roomBufferKey } : {}),
|
|
446
550
|
images,
|
|
447
551
|
files,
|
|
448
552
|
}, true);
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
|
|
10
10
|
* any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
|
|
11
|
-
* BUFFERED resources come from earlier un-summoned thread/group discussion and
|
|
12
|
-
* one expired background file must not block the current ask
|
|
11
|
+
* BUFFERED resources come from earlier un-summoned thread/group discussion and from reply-chain
|
|
12
|
+
* ancestors, and degrade per attachment: one expired background file must not block the current ask
|
|
13
|
+
* or hide its still-readable siblings.
|
|
13
14
|
*/
|
|
14
15
|
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
15
16
|
import { type BusyRetry } from "../invoke-turn-kit.ts";
|
|
@@ -21,6 +22,15 @@ export interface FeishuTurnTransport {
|
|
|
21
22
|
chatId: string;
|
|
22
23
|
filesDir: string;
|
|
23
24
|
label: string;
|
|
25
|
+
/** THIS app's own id (`cli_…`) — the identity a fetched message's `sender.id` carries when the
|
|
26
|
+
* sender is an app. Needed to tell the agent's OWN messages from any other bot's in the same chat:
|
|
27
|
+
* `sender_type` alone says "some app", which is not the question the referent path asks. */
|
|
28
|
+
appId: string;
|
|
29
|
+
/** The place this thread branched from (the chat's main place), when the turn runs in a thread —
|
|
30
|
+
* rides the Scope's lineage extension so a NEW thread session starts from what the room knew
|
|
31
|
+
* (participant-model.md §5). The engine reads it once, at session creation; every later turn
|
|
32
|
+
* carries it inertly. */
|
|
33
|
+
parentSession?: string;
|
|
24
34
|
}
|
|
25
35
|
/** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
|
|
26
36
|
* bytes by message_id + key, so the pair travels together through the turn record). */
|