@fastagent-sh/fastagent 0.16.2 → 0.17.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.
Files changed (43) hide show
  1. package/dist/channels/agentcore.d.ts +16 -2
  2. package/dist/channels/agentcore.js +90 -9
  3. package/dist/channels/control.d.ts +1 -1
  4. package/dist/channels/control.js +2 -1
  5. package/dist/channels/feishu/card.d.ts +20 -9
  6. package/dist/channels/feishu/card.js +27 -13
  7. package/dist/channels/feishu/feishu-api.d.ts +11 -2
  8. package/dist/channels/feishu/feishu.js +84 -10
  9. package/dist/channels/feishu/invoke-turn.d.ts +4 -0
  10. package/dist/channels/feishu/invoke-turn.js +31 -5
  11. package/dist/channels/feishu/normalize.js +97 -32
  12. package/dist/channels/feishu/preview.d.ts +4 -3
  13. package/dist/channels/feishu/preview.js +77 -23
  14. package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
  15. package/dist/channels/lark/scaffold/lark-send.ts +9 -6
  16. package/dist/cli/commands/attach.d.ts +18 -0
  17. package/dist/cli/commands/attach.js +46 -2
  18. package/dist/cli/commands/dev.js +2 -2
  19. package/dist/cli/commands/info.js +2 -2
  20. package/dist/cli/commands/start.js +41 -7
  21. package/dist/cli/program.js +2 -2
  22. package/dist/cli/serve.d.ts +4 -0
  23. package/dist/cli/serve.js +2 -2
  24. package/dist/deploy/agentcore/logs.d.ts +10 -5
  25. package/dist/deploy/agentcore/logs.js +2 -5
  26. package/dist/deploy/agentcore/plan.d.ts +3 -2
  27. package/dist/deploy/agentcore/plan.js +23 -5
  28. package/dist/deploy/agentcore/run.d.ts +7 -1
  29. package/dist/deploy/agentcore/run.js +93 -8
  30. package/dist/deploy/preflight.js +34 -7
  31. package/dist/engines/pi/create.js +7 -16
  32. package/dist/engines/pi/definition.d.ts +15 -0
  33. package/dist/engines/pi/definition.js +22 -1
  34. package/dist/engines/pi/open.d.ts +1 -1
  35. package/dist/engines/pi/open.js +19 -0
  36. package/dist/engines/pi/report.d.ts +16 -0
  37. package/dist/engines/pi/report.js +30 -0
  38. package/dist/engines/pi/session-builder.js +2 -2
  39. package/dist/engines/pi/session-control.d.ts +11 -1
  40. package/dist/engines/pi/session-control.js +3 -0
  41. package/dist/session-remote.js +17 -0
  42. package/dist/session.d.ts +20 -0
  43. package/package.json +1 -1
@@ -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
- routes: Routes;
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;
@@ -19,7 +19,8 @@
19
19
  * - `{ kind: "invoke", session, text }` — the programmatic data plane; streams the invoke back as
20
20
  * SSE (AgentCore's streaming response form), reusing the HTTP channel's handler wholesale.
21
21
  *
22
- * `/ping` reports `HealthyBusy` while process-wide background work is in flight (busy.ts) — webhook
22
+ * `/ping` reports `HealthyBusy` (+ `time_of_last_update`, required see the handler) while
23
+ * process-wide background work is in flight (busy.ts) — webhook
23
24
  * channels ACK fast and run turns fire-and-forget, and AgentCore ends an idle session, so without
24
25
  * this signal a long turn would be killed mid-flight right after its ACK. `Healthy` when idle lets
25
26
  * the platform reclaim the microVM (that idle-to-zero IS the point of this deployment).
@@ -54,7 +55,28 @@ function secretMatches(actual, expected) {
54
55
  */
55
56
  export function agentcoreRoutes(options) {
56
57
  const { routes, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
57
- const dispatch = router(routes);
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
+ };
58
80
  const invokeHandler = createInvokeHandler(agent);
59
81
  // Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
60
82
  // background, so "the request returned" is NOT when the state root settles.
@@ -74,7 +96,7 @@ export function agentcoreRoutes(options) {
74
96
  return text("invalid json\n", 400);
75
97
  }
76
98
  if (envelope === null || typeof envelope !== "object" || typeof envelope.kind !== "string") {
77
- 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);
78
100
  }
79
101
  // AUTHENTICATION BOUNDARY. `InvokeAgentRuntime` is an ordinary IAM action, so "reached this
80
102
  // handler" proves nothing about the sender. Only an envelope carrying the shared secret is the
@@ -112,7 +134,7 @@ export function agentcoreRoutes(options) {
112
134
  stateSync.use(envelope.state);
113
135
  }
114
136
  else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
115
- // webhook/schedule-fire/wake-poke reach us ONLY through the forwarder, which always mints the
137
+ // webhook/schedule-fire/wake-poke/probe reach us ONLY through the forwarder, which mints the
116
138
  // pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
117
139
  // once per process (a direct `invoke` legitimately has none — its session storage is its own).
118
140
  warnedUnsnapshotted = true;
@@ -123,6 +145,11 @@ export function agentcoreRoutes(options) {
123
145
  }
124
146
  catch (e) {
125
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);
126
153
  return text(`state restore failed: ${String(e)}\n`, 503);
127
154
  }
128
155
  }
@@ -133,6 +160,26 @@ export function agentcoreRoutes(options) {
133
160
  stateReadyFired = true;
134
161
  onStateReady();
135
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
+ }
136
183
  switch (envelope.kind) {
137
184
  case "webhook": {
138
185
  const { method, path, query, headers, bodyB64 } = envelope;
@@ -153,6 +200,10 @@ export function agentcoreRoutes(options) {
153
200
  ? Buffer.from(bodyB64, "base64")
154
201
  : undefined,
155
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);
156
207
  const response = await dispatch(inner);
157
208
  // Buffer the channel's ACK (webhook ACKs are small by design — the turn itself runs
158
209
  // fire-and-forget) and ride it inside the transport reply, byte-exact.
@@ -217,9 +268,21 @@ export function agentcoreRoutes(options) {
217
268
  }
218
269
  case "wake-poke": {
219
270
  // 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.
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);
221
276
  return json({ ok: true }, 200);
222
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
+ }
223
286
  case "invoke": {
224
287
  // Reuse the HTTP channel's handler wholesale (SSE, cancellation, backpressure) by handing it
225
288
  // the shape it already validates — one protocol, one implementation.
@@ -242,12 +305,30 @@ export function agentcoreRoutes(options) {
242
305
  stateSync.save();
243
306
  return response;
244
307
  };
308
+ // The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
309
+ // (background turns in flight). `time_of_last_update` is REQUIRED for the keep-alive to work,
310
+ // despite the contract documenting it as optional ("If you omit the field, the platform tracks
311
+ // status changes on its own"): measured on a live Runtime (us-east-1, 2026-08-04), the platform's
312
+ // idle measurement reads ONLY this field — with it omitted, a session polling every ~2s and
313
+ // receiving HealthyBusy 200s was still reclaimed at exactly IdleRuntimeSessionTimeout after the
314
+ // last InvokeAgentRuntime, mid-turn, 2s after the last HealthyBusy answer; with the field present
315
+ // the same turn survived 3.5× the idle timeout with zero invocations and completed. The value
316
+ // updates ONLY on a real status change: a timestamp advancing on every ping declares a perpetual
317
+ // status change, so the idle timeout never fires and dead-idle sessions live to MaxLifetime
318
+ // (quota exhaustion — the failure mode the contract's warning describes).
319
+ let lastStatus = "Healthy";
320
+ let lastTransition = Math.floor(Date.now() / 1000);
245
321
  return {
246
322
  "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),
323
+ "GET /ping": () => {
324
+ const status = isBusy() ? "HealthyBusy" : "Healthy";
325
+ if (status !== lastStatus) {
326
+ lastTransition = Math.floor(Date.now() / 1000);
327
+ log.debug(`[agentcore] ping status: ${lastStatus} → ${status}`);
328
+ lastStatus = status;
329
+ }
330
+ return json({ status, time_of_last_update: lastTransition }, 200);
331
+ },
251
332
  };
252
333
  }
253
334
  /** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
@@ -22,7 +22,7 @@ export interface ControlRoutesOptions {
22
22
  agent?: Agent;
23
23
  }
24
24
  /**
25
- * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
25
+ * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
26
26
  * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
27
27
  */
28
28
  export declare function controlRoutes(control: SessionControl, options: ControlRoutesOptions): Routes;
@@ -84,7 +84,7 @@ function parseWireCommand(raw) {
84
84
  }
85
85
  }
86
86
  /**
87
- * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
87
+ * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
88
88
  * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
89
89
  */
90
90
  export function controlRoutes(control, options) {
@@ -111,6 +111,7 @@ export function controlRoutes(control, options) {
111
111
  return {
112
112
  ...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
113
113
  "GET /control/capabilities": guard(() => json(control.capabilities())),
114
+ "GET /control/commands": guard(async () => json(await control.commands())),
114
115
  "GET /control/state": guard(async (_req, url) => {
115
116
  const session = sessionParam(url);
116
117
  if (!session)
@@ -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 preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
7
- * the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
8
- * renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
9
- * element, `streaming_mode` off one write flips content and mode together.
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 one streamed element's id — shared by create (card.ts) and update (preview.ts). */
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/first view. */
27
- export declare function streamingCardJson(initial: string): string;
28
- /** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
29
- * the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
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 preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
7
- * the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
8
- * renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
9
- * element, `streaming_mode` off one write flips content and mode together.
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 one streamed element's id — shared by create (card.ts) and update (preview.ts). */
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(markdown, streaming, summary) {
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: [{ tag: "markdown", content: markdown, element_id: ANSWER_ELEMENT_ID }] },
59
+ body: { elements },
52
60
  });
53
61
  }
54
- /** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
55
- export function streamingCardJson(initial) {
56
- return cardJson(initial, true);
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, streaming off (stops the client's streaming affordance), plus
59
- * the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
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,7 +65,12 @@ 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_…". The message object carries more (`parent_id`, `root_id`, `thread_id`); they stay
73
+ * unnamed until something reads them — this type is the surface in use, not a mirror of the wire. */
69
74
  getMessage(messageId: string): Promise<{
70
75
  message_id?: string;
71
76
  msg_type?: string;
@@ -73,7 +78,11 @@ export interface FeishuApi {
73
78
  content?: string;
74
79
  };
75
80
  mentions?: unknown[];
76
- sender?: unknown;
81
+ sender?: {
82
+ id?: string;
83
+ id_type?: string;
84
+ sender_type?: string;
85
+ };
77
86
  } | undefined>;
78
87
  /** Download a message resource (image/file bytes). Caps at {@link MAX_DOWNLOAD_BYTES}. */
79
88
  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, removeRetiredStateFile } from "../state.js";
17
+ import { ensureStateHome, loadStateFile, removeRetiredStateFile, 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";
@@ -93,16 +93,45 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
93
93
  }
94
94
  const formatError = onError ?? defaultErrorMessage;
95
95
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
96
- // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
97
- // Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works. A
98
- // mention landing in that first moment is buffered as context rather than answered; it is folded
99
- // into the next answered turn in that place, so the ask is delayed, never lost.
96
+ // One bot/v3/info per process refreshes the bot's own open_id the identity the default route
97
+ // matches group @mentions against. The CACHED copy (bot.json, seeded synchronously once the state
98
+ // home exists below) is what makes the first envelope safe: this fetch is fire-and-forget, and
99
+ // under the AgentCore posture channel construction happens INSIDE the first envelope
100
+ // (channels/agentcore.ts lazy construction), so a network round trip can never beat that same
101
+ // envelope's own dispatch — without the seed, every cold start's FIRST group mention raced this
102
+ // fetch and lost (field-observed: an explicit @ buffered as bystander context). The open_id is a
103
+ // stable property of the app, so disk beats network; the only envelope a deployment ever serves
104
+ // without the file is its first one, which is the deploy driver's probe — it carries no mention.
105
+ // No identity at all (fresh dir, no cache, fetch pending/failed) keeps today's fail-closed
106
+ // behavior: unmatched mentions buffer as context — delayed, never lost.
100
107
  let botOpenId;
108
+ let persistBotIdentity = () => { }; // bound once the state home exists
109
+ let invalidateBotIdentity = () => { }; // likewise
101
110
  void api.botInfo().then((me) => {
102
- botOpenId = me.openId;
103
- if (!botOpenId)
104
- log.warn(`${label} bot/v3/info returned no open_idgroup @mention summon stays off`);
105
- }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
111
+ if (me.openId) {
112
+ if (botOpenId !== undefined && botOpenId !== me.openId) {
113
+ log.info(`${label} bot open_id changed (${botOpenId} → ${me.openId}) updating the cached identity`);
114
+ }
115
+ botOpenId = me.openId;
116
+ persistBotIdentity(me.openId);
117
+ }
118
+ else {
119
+ // The call SUCCEEDED and the platform reported no identity — an affirmative "there is no
120
+ // bot here" (capability off, app reconfigured), not transport weather. This is the one
121
+ // answer that must also INVALIDATE the cache: keeping a summon identity the platform just
122
+ // declined to confirm would quietly turn fail-closed into fail-open.
123
+ log.warn(botOpenId === undefined
124
+ ? `${label} bot/v3/info returned no open_id — group @mention summon stays off`
125
+ : `${label} bot/v3/info returned no open_id — cached identity cleared; group @mention summon stays off`);
126
+ botOpenId = undefined;
127
+ invalidateBotIdentity();
128
+ }
129
+ }, (e) =>
130
+ // A FAILED call is transport weather (network, rate limit): the platform said nothing about
131
+ // the identity, so a cached one keeps serving — the degradation this cache exists for.
132
+ log.warn(botOpenId === undefined
133
+ ? `${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`
134
+ : `${label} bot/v3/info failed; running on the cached identity (bot.json): ${String(e)}`));
106
135
  void api.listAppScopes().then((scopes) => {
107
136
  const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
108
137
  if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
@@ -135,6 +164,51 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
135
164
  // after the release following the participant model ships — by then no live deployment can still
136
165
  // be carrying the file. test/migration-deadline.test.ts fails when due.
137
166
  removeRetiredStateFile(stateHome, "owned-threads.json", label);
167
+ // The cached bot identity (rationale at the botInfo block above): seed synchronously — the
168
+ // factory runs to completion before any promise resolves, so botOpenId is still unset here and
169
+ // the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
170
+ //
171
+ // BOUND TO THE APP: the state home is per channel KIND, and an operator can point kept state at
172
+ // a different app (a recreated app, a tenant migration). A cached identity from another app
173
+ // would make THIS bot treat mentions of the OLD bot as its own summons — identity impersonation,
174
+ // strictly worse than the race the cache removes — so the cache counts only when it names the
175
+ // current appId. A mismatch is not noise worth warning about: the next persist IS the migration.
176
+ const botFile = join(stateHome, "bot.json");
177
+ const storedBot = loadStateFile(botFile);
178
+ let cachedOpenId;
179
+ if (storedBot !== undefined) {
180
+ if (typeof storedBot.appId !== "string") {
181
+ log.warn(`${label} unexpected shape in ${botFile} — ignoring the cached bot identity`);
182
+ }
183
+ else if (storedBot.appId === appId && typeof storedBot.openId === "string") {
184
+ cachedOpenId = storedBot.openId;
185
+ }
186
+ }
187
+ botOpenId ??= cachedOpenId;
188
+ persistBotIdentity = (openId) => {
189
+ if (openId === cachedOpenId)
190
+ return;
191
+ cachedOpenId = openId;
192
+ try {
193
+ saveStateFile(botFile, { appId, openId });
194
+ }
195
+ catch (e) {
196
+ log.warn(`${label} could not persist the bot identity to ${botFile} — the next cold start races bot/v3/info again: ${String(e)}`);
197
+ }
198
+ };
199
+ invalidateBotIdentity = () => {
200
+ if (cachedOpenId === undefined)
201
+ return;
202
+ cachedOpenId = undefined;
203
+ try {
204
+ // `{ appId }` with no openId reads as "no cache" at the loader — the atomic write is reused
205
+ // instead of introducing a deletion path.
206
+ saveStateFile(botFile, { appId });
207
+ }
208
+ catch (e) {
209
+ log.warn(`${label} could not clear the cached bot identity ${botFile}: ${String(e)}`);
210
+ }
211
+ };
138
212
  const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
213
  /** This channel's place key for a thread (the shared store is key-agnostic). */
140
214
  // The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
@@ -241,7 +315,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
241
315
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
242
316
  });
243
317
  try {
244
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
318
+ await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label, appId }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
245
319
  // Drop intent first: a crash between these writes may re-fold answered context later,
246
320
  // but can never replay this turn after its context was removed.
247
321
  store.remove(rec.id);
@@ -21,6 +21,10 @@ export interface FeishuTurnTransport {
21
21
  chatId: string;
22
22
  filesDir: string;
23
23
  label: string;
24
+ /** THIS app's own id (`cli_…`) — the identity a fetched message's `sender.id` carries when the
25
+ * sender is an app. Needed to tell the agent's OWN messages from any other bot's in the same chat:
26
+ * `sender_type` alone says "some app", which is not the question the referent path asks. */
27
+ appId: string;
24
28
  }
25
29
  /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
26
30
  * bytes by message_id + key, so the pair travels together through the turn record). */
@@ -2,9 +2,14 @@ import { log } from "../../log.js";
2
2
  import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
3
3
  import { parseContent } from "./parse.js";
4
4
  import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
5
- /** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
6
- * card's markdown element is the natural fit for LLM output steer away from HTML/plain. */
7
- const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown it is rendered in a Feishu/Lark card.)";
5
+ /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
6
+ * concept what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
7
+ * natural fit for LLM output steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
8
+ * itself delivers it; answering through a send TOOL instead is the observed failure — the channel
9
+ * then settles an empty turn as "(no reply)" next to the tool's un-threaded duplicate). */
10
+ const REPLY_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card. This reply is " +
11
+ "delivered to the current chat by the channel itself: do not call a send tool to answer the " +
12
+ "current chat.)";
8
13
  /**
9
14
  * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
10
15
  * (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
@@ -46,9 +51,30 @@ async function resolveTurnInputs(t, attachments) {
46
51
  files.push({ msg: parentId, key: ref.key, name: ref.name });
47
52
  // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
48
53
  // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
54
+ //
55
+ // OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"`
56
+ // is true for every one of them — matching on it alone would tell the model it wrote another
57
+ // bot's message. The identity to compare is the app id, because an app sender carries
58
+ // `id_type: "app_id"`: the cached bot open_id answers a different question (who was @mentioned)
59
+ // and would never match here. A missing or unexpected id fails CLOSED — labelled by id, never
60
+ // claimed as the agent's own.
61
+ const appSender = parent.sender?.sender_type === "app";
49
62
  const senderId = parent.sender?.id;
50
- const from = senderId ? `user ${senderId}` : undefined;
63
+ const ownMessage = appSender && senderId === t.appId;
64
+ // An app is not a person: labelling another bot's message "user cli_…" is the same misattribution
65
+ // in a quieter form, so the noun follows the sender type.
66
+ const from = ownMessage ? "you, the agent" : senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
51
67
  referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
68
+ // The chain STOPS here, at the one message the user pointed at. Walking further — to what that
69
+ // message was itself replying to — was built and removed: it reconstructs HISTORY out of reply
70
+ // pointers, and history is the session's job. That framing has no non-arbitrary answers (how
71
+ // many levels? what about the level above that? how is it deduplicated against what the session
72
+ // already holds? how does an IMAGE two levels up become prompt text at all?), and every one of
73
+ // those questions is a symptom of solving a session-layer problem in the prompt layer. The real
74
+ // gap it was papering over — a thread opened on a room answer starts with an EMPTY session while
75
+ // the room's session holds the exchange — belongs to memory inheritance (design/participant-
76
+ // model.md §8, rungs 3-4), where images and tool results come along for free because they are
77
+ // already in the history rather than being re-serialised into a prompt string.
52
78
  }
53
79
  }
54
80
  // Primary first and fail-fast: these are resources the current user explicitly pointed at.
@@ -117,6 +143,6 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
117
143
  yield { type: "failed", details: `could not load attachment: ${String(e)}`, retryable: true };
118
144
  return;
119
145
  }
120
- const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
146
+ const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
121
147
  yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
122
148
  }