@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.
Files changed (68) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/channels/agentcore.d.ts +16 -2
  3. package/dist/channels/agentcore.js +66 -4
  4. package/dist/channels/feishu/card.d.ts +20 -9
  5. package/dist/channels/feishu/card.js +27 -13
  6. package/dist/channels/feishu/feishu-api.d.ts +13 -2
  7. package/dist/channels/feishu/feishu.js +121 -17
  8. package/dist/channels/feishu/invoke-turn.d.ts +12 -2
  9. package/dist/channels/feishu/invoke-turn.js +159 -14
  10. package/dist/channels/feishu/normalize.js +97 -32
  11. package/dist/channels/feishu/parse.js +6 -0
  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/http.js +15 -2
  16. package/dist/channels/invoke-turn-kit.d.ts +5 -2
  17. package/dist/channels/invoke-turn-kit.js +6 -2
  18. package/dist/channels/lark/scaffold/lark-send.ts +9 -6
  19. package/dist/channels/slack/invoke-turn.js +1 -1
  20. package/dist/channels/slack/slack.js +1 -5
  21. package/dist/channels/state.d.ts +0 -10
  22. package/dist/channels/state.js +2 -19
  23. package/dist/channels/telegram/invoke-turn.js +1 -1
  24. package/dist/channels/thread-participants.d.ts +7 -0
  25. package/dist/channels/thread-participants.js +3 -0
  26. package/dist/cli/commands/deploy.js +13 -5
  27. package/dist/cli/commands/dev.js +1 -1
  28. package/dist/cli/commands/fire.js +1 -1
  29. package/dist/cli/commands/info.js +21 -1
  30. package/dist/cli/commands/invoke.js +1 -1
  31. package/dist/cli/commands/start.js +40 -6
  32. package/dist/cli/serve.d.ts +4 -0
  33. package/dist/cli/serve.js +2 -2
  34. package/dist/cli/shared.d.ts +4 -2
  35. package/dist/cli/shared.js +12 -5
  36. package/dist/collect.d.ts +30 -4
  37. package/dist/collect.js +39 -6
  38. package/dist/deploy/agentcore/plan.js +17 -0
  39. package/dist/deploy/agentcore/run.d.ts +7 -1
  40. package/dist/deploy/agentcore/run.js +93 -8
  41. package/dist/deploy/preflight.d.ts +8 -2
  42. package/dist/deploy/preflight.js +55 -10
  43. package/dist/deploy/secrets.d.ts +3 -0
  44. package/dist/deploy/secrets.js +6 -0
  45. package/dist/dev-supervisor.js +8 -2
  46. package/dist/engines/pi/create.d.ts +2 -1
  47. package/dist/engines/pi/create.js +12 -7
  48. package/dist/engines/pi/harness.d.ts +6 -3
  49. package/dist/engines/pi/harness.js +4 -3
  50. package/dist/engines/pi/invoke-session.d.ts +32 -0
  51. package/dist/engines/pi/invoke-session.js +171 -0
  52. package/dist/engines/pi/invoke.d.ts +6 -27
  53. package/dist/engines/pi/invoke.js +49 -208
  54. package/dist/engines/pi/models.d.ts +45 -11
  55. package/dist/engines/pi/models.js +55 -8
  56. package/dist/engines/pi/session-builder.js +4 -2
  57. package/dist/engines/pi/session-control.d.ts +2 -1
  58. package/dist/engines/pi/sessions.d.ts +17 -1
  59. package/dist/engines/pi/sessions.js +292 -10
  60. package/dist/engines/pi/turn-kit.d.ts +56 -0
  61. package/dist/engines/pi/turn-kit.js +161 -0
  62. package/dist/paths.d.ts +6 -0
  63. package/dist/paths.js +6 -0
  64. package/dist/pi.d.ts +3 -2
  65. package/dist/pi.js +1 -1
  66. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  67. package/dist/session-remote.js +10 -2
  68. package/package.json +1 -1
@@ -1,18 +1,136 @@
1
1
  import { log } from "../../log.js";
2
2
  import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
3
+ import { BUFFER_ATTACH_MAX } from "../context-buffer.js";
3
4
  import { parseContent } from "./parse.js";
4
5
  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.)";
6
+ /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
7
+ * concept what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
8
+ * natural fit for LLM output steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
9
+ * itself delivers it; answering through a send TOOL instead is the observed failure — the channel
10
+ * then settles an empty turn as "(no reply)" next to the tool's un-threaded duplicate). */
11
+ const REPLY_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card. This reply is " +
12
+ "delivered to the current chat by the channel itself: do not call a send tool to answer the " +
13
+ "current chat.)";
14
+ /** How far up a reply chain the walk reads, beyond the replied-to message itself. The chain's natural
15
+ * end is its ROOT — the platform threads every reply back to one — so this is an IO guard, not a
16
+ * semantic boundary: each ancestor costs one serial `getMessage`, and a pathological chain must not
17
+ * stall the turn. Field chains are 1–3 long; a capped walk says so in the block. */
18
+ const MAX_CHAIN_ANCESTORS = 8;
19
+ /** Attribution for a FETCHED message. getMessage's sender is `{ id, id_type, sender_type }` — a
20
+ * DIFFERENT shape from the event's sender (`{ sender_id: { open_id } }`) — so the label is built
21
+ * here, not via parse.senderLabel.
22
+ *
23
+ * OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"` is
24
+ * true for every one of them — matching on it alone would tell the model it wrote another bot's
25
+ * message. The identity to compare is the app id, because an app sender carries `id_type: "app_id"`:
26
+ * the cached bot open_id answers a different question (who was @mentioned) and would never match
27
+ * here. A missing or unexpected id fails CLOSED — labelled by id, never claimed as the agent's own.
28
+ * And an app is not a person: labelling another bot's message "user cli_…" is the same
29
+ * misattribution in a quieter form, so the noun follows the sender type. */
30
+ function fetchedSenderLabel(sender, appId) {
31
+ const appSender = sender?.sender_type === "app";
32
+ const senderId = sender?.id;
33
+ if (appSender && senderId === appId)
34
+ return "you, the agent";
35
+ return senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
36
+ }
37
+ /**
38
+ * Walk the reply chain ABOVE the replied-to message, to its root. Quoting a reply points at one link
39
+ * of an exchange; the pointer is only fully resolved when the model can read what that link was
40
+ * replying to — all the way up, because the platform defines where the chain ends (its root), which
41
+ * is what makes the walk bounded by STRUCTURE rather than by a level count someone picked.
42
+ *
43
+ * This is pointer resolution, not history. Session memory — what this place already knows — is a
44
+ * different track (design/participant-model.md §8): a one-hop version of this walk was removed once
45
+ * for trying to be that substitute; it returns doing only the pointer's job, which is also why it
46
+ * walks through ANY author's message — the chain is the platform's structure, not a conversation the
47
+ * agent took part in. The repetition this implies (an established session re-reads chain text it may
48
+ * already hold, each reply turn) is accepted deliberately and bounded: ancestors are CONTEXT, not
49
+ * the ask, so their text shares ONE further `REFERENT_MAX_CODE_POINTS` budget across the whole chain
50
+ * — the walk costs at most one more referent — while the pointed-at referent keeps its own full
51
+ * fidelity bound.
52
+ *
53
+ * Fail-open at every edge, but never silently at the model: any walk that ends short of the root —
54
+ * the ancestor cap, an exhausted text budget, an unreadable ancestor, a cycle — leaves the same
55
+ * neutral truncation line at the top of the block, because a chain rendered without it READS as
56
+ * complete and the model would take the oldest fetched node for the original ask. Unreadable
57
+ * ancestors and cycles also warn the operator; a cycle is corrupt platform data (reply chains are
58
+ * temporally acyclic by construction — a reply can only point at an EARLIER message — so one firing
59
+ * means the data, not the walk, is wrong).
60
+ */
61
+ async function walkReplyChain(t, start, visited) {
62
+ const nodes = [];
63
+ const images = [];
64
+ const files = [];
65
+ // No parent above the referent = no chain — not a truncated one. The marker below is only for
66
+ // walks that END SHORT of a root that exists.
67
+ if (start === undefined)
68
+ return { block: "", images, files, ids: [] };
69
+ let reachedRoot = false;
70
+ let textBudget = REFERENT_MAX_CODE_POINTS;
71
+ let next = start;
72
+ while (next !== undefined) {
73
+ if (visited.has(next)) {
74
+ log.warn(`${t.label} reply chain points back to already-visited message ${next} — corrupt platform data; the walk ends here`);
75
+ break;
76
+ }
77
+ if (nodes.length >= MAX_CHAIN_ANCESTORS || textBudget <= 0)
78
+ break;
79
+ // The annotation breaks a control-flow-analysis cycle (id → msg → next → id) that trips TS7022.
80
+ const id = next;
81
+ visited.add(id);
82
+ let failure;
83
+ const msg = await t.api.getMessage(id).catch((error) => {
84
+ failure = String(error);
85
+ return undefined;
86
+ });
87
+ if (!msg) {
88
+ log.warn(`${t.label} could not read reply-chain message ${id} (${failure ?? "no such message"}) — the chain is rendered up to it`);
89
+ break;
90
+ }
91
+ const parsed = parseContent({
92
+ message_type: msg.msg_type ?? "unknown",
93
+ content: msg.body?.content ?? "",
94
+ mentions: msg.mentions,
95
+ });
96
+ const label = fetchedSenderLabel(msg.sender, t.appId);
97
+ const from = label ?? "reply chain";
98
+ for (const key of parsed.imageKeys)
99
+ images.push({ messageId: id, key, from });
100
+ for (const ref of parsed.fileRefs)
101
+ files.push({ messageId: id, key: ref.key, name: ref.name, from });
102
+ const text = truncateCodePointPrefix(parsed.text, textBudget) || "(empty)";
103
+ textBudget -= [...text].length;
104
+ nodes.push({ id, label, text });
105
+ if (msg.parent_id === undefined)
106
+ reachedRoot = true;
107
+ next = msg.parent_id;
108
+ }
109
+ nodes.reverse(); // fetched leaf→root; rendered oldest first, the way a transcript reads
110
+ const lines = nodes.map((node) => `(msg ${node.id}${node.label ? `, from ${node.label}` : ""}): ${node.text}`);
111
+ // One line for every way of ending short of the root — cap, budget, unreadable, cycle. It names no
112
+ // cause on purpose: the model needs the SHAPE (there is more above), the operator log has the why.
113
+ if (!reachedRoot)
114
+ lines.unshift("(…the chain continues above this point)");
115
+ return {
116
+ block: `\n[reply chain above it, oldest first:\n${lines.join("\n")}]`,
117
+ images,
118
+ files,
119
+ // Walked (fetched) order = nearest first — nodes were reversed for RENDERING above, so read the
120
+ // hint order off the rendered list backwards.
121
+ ids: nodes.map((node) => node.id).reverse(),
122
+ };
123
+ }
8
124
  /**
9
- * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
10
- * (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
125
+ * Resolve a turn's inputs (module header): fetch the reply referent's content and resolve its reply
126
+ * chain, then load every image (vision) and file (disk). Primary failures throw; buffered resources
127
+ * degrade independently.
11
128
  */
12
129
  async function resolveTurnInputs(t, attachments) {
13
130
  const images = [...attachments.primary.images];
14
131
  const files = [...attachments.primary.files];
15
132
  let referentBlock = "";
133
+ let chain = { block: "", images: [], files: [], ids: [] };
16
134
  if (attachments.primary.parentId !== undefined) {
17
135
  const parentId = attachments.primary.parentId;
18
136
  // A referent is CONTEXT, not the ask. Losing it (deleted, restricted, unreadable) must not cost
@@ -44,11 +162,11 @@ async function resolveTurnInputs(t, attachments) {
44
162
  images.push({ msg: parentId, key });
45
163
  for (const ref of parsed.fileRefs)
46
164
  files.push({ msg: parentId, key: ref.key, name: ref.name });
47
- // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
48
- // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
49
- const senderId = parent.sender?.id;
50
- const from = senderId ? `user ${senderId}` : undefined;
165
+ const from = fetchedSenderLabel(parent.sender, t.appId);
51
166
  referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
167
+ // The referent's own parent starts the chain walk; the referent id seeds the cycle guard.
168
+ chain = await walkReplyChain(t, parent.parent_id, new Set([parentId]));
169
+ referentBlock += chain.block;
52
170
  }
53
171
  }
54
172
  // Primary first and fail-fast: these are resources the current user explicitly pointed at.
@@ -62,8 +180,28 @@ async function resolveTurnInputs(t, attachments) {
62
180
  // downloaded twice or rendered twice in the manifest.
63
181
  const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
64
182
  const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
65
- const bufferedImages = attachments.buffered.images.filter((ref) => !primaryImages.has(`${ref.messageId}\u0000${ref.key}`));
66
- const bufferedFiles = attachments.buffered.files.filter((ref) => !primaryFiles.has(`${ref.messageId}\u0000${ref.key}`));
183
+ // Chain ancestors and the context buffer share ONE background budget: BUFFER_ATTACH_MAX per kind.
184
+ // The cap is part of the tier's meaning, not an accident of who collected the ref a rich-text
185
+ // ancestor must not turn the walk into an unbounded fan-out of downloads. Chain refs take slots
186
+ // FIRST: they are the direct upstream of the message the user pointed at, buffer refs are ambient
187
+ // discussion. Duplicates (a chain that points back into still-buffered discussion) count once, and
188
+ // what the cap drops is counted into the missing-attachments note like every other unloaded ref.
189
+ const capMerge = (chainRefs, bufferRefs, primary) => {
190
+ const seen = new Set();
191
+ const merged = [];
192
+ for (const ref of [...chainRefs, ...bufferRefs]) {
193
+ const identity = `${ref.messageId}\u0000${ref.key}`;
194
+ if (primary.has(identity) || seen.has(identity))
195
+ continue;
196
+ seen.add(identity);
197
+ merged.push(ref);
198
+ }
199
+ return { kept: merged.slice(0, BUFFER_ATTACH_MAX), dropped: Math.max(0, merged.length - BUFFER_ATTACH_MAX) };
200
+ };
201
+ const mergedImages = capMerge(chain.images, attachments.buffered.images, primaryImages);
202
+ const mergedFiles = capMerge(chain.files, attachments.buffered.files, primaryFiles);
203
+ const bufferedImages = mergedImages.kept;
204
+ const bufferedFiles = mergedFiles.kept;
67
205
  const backgroundImages = [];
68
206
  const backgroundFiles = [];
69
207
  let lost = 0;
@@ -88,7 +226,7 @@ async function resolveTurnInputs(t, attachments) {
88
226
  log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
89
227
  }
90
228
  }
91
- const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
229
+ const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped + mergedImages.dropped + mergedFiles.dropped);
92
230
  const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
93
231
  const allFiles = [
94
232
  ...downloaded,
@@ -101,6 +239,7 @@ async function resolveTurnInputs(t, attachments) {
101
239
  return {
102
240
  images: allImages.length ? allImages : undefined,
103
241
  promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
242
+ referentIds: [...(attachments.primary.parentId !== undefined ? [attachments.primary.parentId] : []), ...chain.ids],
104
243
  };
105
244
  }
106
245
  /**
@@ -117,6 +256,12 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
117
256
  yield { type: "failed", details: `could not load attachment: ${String(e)}`, retryable: true };
118
257
  return;
119
258
  }
120
- const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
121
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
259
+ const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
260
+ // A thread turn names its lineage: parent place + the message ids that can locate the branch point
261
+ // (the referent and its chain — nearest first). The engine reads them ONCE, when the thread's
262
+ // session does not exist yet; on every later turn they ride along inertly.
263
+ const scope = transport.parentSession === undefined
264
+ ? { session }
265
+ : { session, parentSession: transport.parentSession, branchHints: resolved.referentIds };
266
+ yield* streamTurnWithBusyRetry(agent, scope, prompt, { label: transport.label, onCompleted, busyRetry });
122
267
  }
@@ -1,3 +1,62 @@
1
+ /**
2
+ * Render one paragraph of tagged nodes to a line, collecting any resource it carries INTO the sink
3
+ * the caller supplies — or none, when the caller passes no sink.
4
+ *
5
+ * Shared by `post` and `interactive` because the platform hands both out in the same shape. Card-only
6
+ * shapes are handled here rather than in a second walker: `note` nests its own `elements`, and the
7
+ * widget tags (button/select/overflow/date_picker) carry their user-visible label in `text` or
8
+ * `placeholder` — a card is read for what it SAYS, so a label is content and an unlabelled control is
9
+ * nothing.
10
+ *
11
+ * The OPTIONAL sink is the whole reason this is a parameter rather than a return value: a card's
12
+ * resources are documented as unfetchable (see the card branch), so that caller renders the same
13
+ * `[image]` / `[video]` markers into the text while collecting nothing. The markers still tell the
14
+ * model what is there; what must not happen is a key entering the turn's PRIMARY inputs, which load
15
+ * fail-fast.
16
+ */
17
+ function renderNodes(nodes, resources) {
18
+ if (!Array.isArray(nodes))
19
+ return "";
20
+ const parts = [];
21
+ for (const raw of nodes) {
22
+ if (typeof raw !== "object" || raw === null)
23
+ continue;
24
+ const node = raw;
25
+ if (node.tag === "at") {
26
+ parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
27
+ }
28
+ else if (node.tag === "a") {
29
+ parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
30
+ }
31
+ else if (node.tag === "img") {
32
+ const key = nonEmptyString(node.image_key);
33
+ if (key)
34
+ resources?.push({ kind: "image", key });
35
+ parts.push("[image]");
36
+ }
37
+ else if (node.tag === "media") {
38
+ const key = nonEmptyString(node.file_key);
39
+ if (key)
40
+ resources?.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
41
+ parts.push("[video]");
42
+ }
43
+ else if (node.tag === "code_block") {
44
+ parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
45
+ }
46
+ else if (node.tag === "note") {
47
+ const nested = renderNodes(node.elements, resources);
48
+ if (nested)
49
+ parts.push(nested);
50
+ }
51
+ else if (nonEmptyString(node.text)) {
52
+ parts.push(node.text);
53
+ }
54
+ else if (nonEmptyString(node.placeholder)) {
55
+ parts.push(node.placeholder);
56
+ }
57
+ }
58
+ return parts.join("").trim();
59
+ }
1
60
  function nonEmptyString(value) {
2
61
  return typeof value === "string" && value !== "" ? value : undefined;
3
62
  }
@@ -37,43 +96,49 @@ export function decodeFeishuContent(message) {
37
96
  lines.push(title);
38
97
  const paragraphs = Array.isArray(content.content) ? content.content : [];
39
98
  for (const paragraph of paragraphs) {
40
- if (!Array.isArray(paragraph))
41
- continue;
42
- const parts = [];
43
- for (const node of paragraph) {
44
- if (typeof node !== "object" || node === null)
45
- continue;
46
- if (node.tag === "at") {
47
- parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
48
- }
49
- else if (node.tag === "a") {
50
- parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
51
- }
52
- else if (node.tag === "img") {
53
- const key = nonEmptyString(node.image_key);
54
- if (key)
55
- resources.push({ kind: "image", key });
56
- parts.push("[image]");
57
- }
58
- else if (node.tag === "media") {
59
- const key = nonEmptyString(node.file_key);
60
- if (key)
61
- resources.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
62
- parts.push("[video]");
63
- }
64
- else if (node.tag === "code_block") {
65
- parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
66
- }
67
- else if (nonEmptyString(node.text)) {
68
- parts.push(node.text);
69
- }
70
- }
71
- const line = parts.join("").trim();
99
+ const line = renderNodes(paragraph, resources);
72
100
  if (line)
73
101
  lines.push(line);
74
102
  }
75
103
  return { text: lines.join("\n"), resources };
76
104
  }
105
+ // A CARD, as the platform hands it BACK. What we send is an entity reference
106
+ // (`{type:"card",data:{card_id}}`, card.ts) whose text lives in cardkit — but a query API renders
107
+ // the card down to `title` + `elements` (paragraphs of the same tagged nodes as `post`), so the
108
+ // content is readable without a second remote call. This is the message type the agent's OWN
109
+ // answers are, so the case that matters is a user following up on one: without this branch a
110
+ // reply-referent that is the agent's own card decoded to the bare `[interactive message]` marker
111
+ // and the model was told its own answer was unreadable (field-observed).
112
+ //
113
+ // BOTH spellings on purpose: the platform's own docs disagree with themselves — the field table
114
+ // says `interactive` (what the receive EVENT carries) while the message-object example shows
115
+ // `"msg_type": "card"`. Matching one would leave the other silently on the default branch, which
116
+ // is exactly the symptom this fixes.
117
+ case "interactive":
118
+ case "card": {
119
+ const lines = [];
120
+ const title = nonEmptyString(content.title);
121
+ if (title)
122
+ lines.push(title);
123
+ const paragraphs = Array.isArray(content.elements) ? content.elements : [];
124
+ for (const paragraph of paragraphs) {
125
+ // NO resource sink, deliberately. The platform documents that a card's resources cannot be
126
+ // fetched at all: `im/v1/messages/:id/resources/:key` answers 234043 ("Unsupported message
127
+ // type") for a card message id, by stated limitation rather than by permission. Collecting a
128
+ // key here would hand the turn a PRIMARY input that is guaranteed to fail its fail-fast load
129
+ // — turning "the card reads as a marker" (the old behaviour) into "the whole turn errors",
130
+ // which is strictly worse than the gap this branch exists to close. The text still renders
131
+ // `[image]` / `[video]`, so the model knows what is there and that it does not have it.
132
+ //
133
+ // Tolerate both shapes: elements as paragraphs (array of arrays) and a flat element list.
134
+ const line = Array.isArray(paragraph) ? renderNodes(paragraph) : renderNodes([paragraph]);
135
+ if (line)
136
+ lines.push(line);
137
+ }
138
+ // An unrenderable card (all controls, no labels) still says something by existing — keep the
139
+ // marker rather than returning empty, which reads as "the message was blank".
140
+ return { text: lines.length > 0 ? lines.join("\n") : `[${rawType} message]`, resources };
141
+ }
77
142
  case "image": {
78
143
  const key = nonEmptyString(content.image_key);
79
144
  if (key)
@@ -52,6 +52,12 @@ export function cloudEnvelope(event, tag) {
52
52
  `chat ${message.chat_id} (${message.chat_type})`,
53
53
  message.thread_id ? `topic ${message.thread_id}` : undefined,
54
54
  from ? `from ${from}` : undefined,
55
+ // The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
56
+ // enters the session transcript, and session inheritance locates a thread's branch point by
57
+ // searching the parent transcript for exactly these ids (scope.branchHints — sessions.ts).
58
+ // Remove it and every thread quietly inherits from the room's present instead of the branch
59
+ // point. It also lets the model name what it is answering in a busy chat.
60
+ `msg ${message.message_id}`,
55
61
  ]
56
62
  .filter(Boolean)
57
63
  .join(", ");
@@ -15,9 +15,10 @@ export type MountedFeishuPreview = {
15
15
  messageId: string;
16
16
  };
17
17
  /**
18
- * Mount one preview message: preferably a streaming card entity, with a static text message as the
19
- * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
20
- * has exactly the same shape the stream pump expects to take over later.
18
+ * Mount one preview message: preferably a streaming card entity (`initial` seeds the process element;
19
+ * the answer element starts empty), with a static text message as the visible fallback. Queue feedback
20
+ * and ordinary turn startup share this constructor so a queued card has exactly the same shape the
21
+ * stream pump expects to take over later.
21
22
  */
22
23
  export declare function mountFeishuPreview(api: FeishuApi, target: FeishuTarget, initial: string, label?: string): Promise<MountedFeishuPreview>;
23
24
  /** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Canonical Feishu live-preview rendering (also reused by Lark compatibility). The preview is ONE
3
- * streaming CARD (create entity mount it with a reply/send stream full-text snapshots at its
4
- * markdown element with a strictly increasing `sequence`; the client renders the typewriter effect);
5
- * on completion the same card is settled in place with the final answer (streaming off). Streaming
3
+ * streaming CARD of TWO elements the volatile `process` block and the append-only `answer` (see
4
+ * card.ts for why the split is the prefix-stability fix) (create entity → mount it with a
5
+ * reply/send stream full-text snapshots per element with a strictly increasing `sequence`; the
6
+ * client renders the typewriter effect); on completion the same card is settled in place with the
7
+ * final answer alone (streaming off). Streaming
6
8
  * updates ride the cardkit quota (50 QPS per app, 10 QPS per card entity, no edit ceiling) — NOT the
7
9
  * 5 QPS per-chat message quota or
8
10
  * the 20-edit cap on text messages, which is why the preview is a card and not an edited text message.
@@ -21,10 +23,10 @@
21
23
  */
22
24
  import { setTimeout as sleep } from "node:timers/promises";
23
25
  import { log } from "../../log.js";
24
- import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
26
+ import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
25
27
  import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
26
28
  import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
27
- import { truncateUtf8 } from "../text.js";
29
+ import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
28
30
  export { defaultErrorMessage };
29
31
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
30
32
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
@@ -33,12 +35,41 @@ export { defaultErrorMessage };
33
35
  const STREAM_THROTTLE_MS = 1000;
34
36
  /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
35
37
  const THINKING_PREVIEW = 280;
36
- /** Cap a live view to the card budget, PREFIX-STABLE: the streaming client animates only when the old
37
- * text is a prefix of the new, so an over-budget view freezes at its head rather than sliding a tail
38
- * window (which would redraw the whole card every frame). The full answer still lands at settle. */
38
+ /** Cap (code points) on the whole process block thinking tail + tool lines + retry notice. It
39
+ * redraws wholly on change anyway (it is volatile by nature), so over budget the newest COMPLETE
40
+ * lines win (see tailLines). ≤1000 points is ≤4 KB UTF-8, which together with the answer's byte cap
41
+ * stays inside the 30 KB entity budget. */
42
+ const PROCESS_MAX_POINTS = 1000;
43
+ /** Cap the live answer to the card budget, PREFIX-STABLE: the streaming client animates only when the
44
+ * old text is a prefix of the new, so an over-budget answer freezes at its head rather than sliding a
45
+ * tail window (which would re-type the element every frame). The full answer still lands at settle. */
39
46
  function capBytes(s, maxBytes) {
40
47
  return truncateUtf8(s, maxBytes);
41
48
  }
49
+ /** Tail-select COMPLETE lines within a code-point budget — the process block's cap. The block's
50
+ * lines are semantic units (a `🔧` tool call, the `💭` peek, the `⏳` notice): cutting mid-line
51
+ * would orphan a marker or tear a label, so elision happens only at line boundaries, newest lines
52
+ * kept, with a leading `…` line marking what was dropped. The in-line guard cannot trigger with the
53
+ * bounded renderers (a thinking tail ≤ ~283 points, a tool line ≤ ~135) — it exists so a future
54
+ * unbounded line degrades to a head-preserving cut instead of an empty block. */
55
+ function tailLines(text, maxPoints) {
56
+ if (Array.from(text).length <= maxPoints)
57
+ return text;
58
+ const lines = text.split("\n");
59
+ const kept = [];
60
+ let used = 2; // the leading "…\n" elision marker
61
+ for (let i = lines.length - 1; i >= 0; i--) {
62
+ const line = lines[i] ?? "";
63
+ const cost = Array.from(line).length + (kept.length > 0 ? 1 : 0); // +1 joining newline
64
+ if (used + cost > maxPoints)
65
+ break;
66
+ used += cost;
67
+ kept.unshift(line);
68
+ }
69
+ if (kept.length === 0)
70
+ return truncateCodePointPrefix(lines.at(-1) ?? "", maxPoints);
71
+ return `…\n${kept.join("\n")}`;
72
+ }
42
73
  /**
43
74
  * The terminal-write POLICY: resolve the preview into `text`. One card → settle it in place (final
44
75
  * markdown, streaming off); an over-budget answer settles the card with its first chunk and sends the
@@ -90,9 +121,10 @@ async function finalize(api, target, preview, text, seq) {
90
121
  await api.sendText(target, text);
91
122
  }
92
123
  /**
93
- * Mount one preview message: preferably a streaming card entity, with a static text message as the
94
- * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
95
- * has exactly the same shape the stream pump expects to take over later.
124
+ * Mount one preview message: preferably a streaming card entity (`initial` seeds the process element;
125
+ * the answer element starts empty), with a static text message as the visible fallback. Queue feedback
126
+ * and ordinary turn startup share this constructor so a queued card has exactly the same shape the
127
+ * stream pump expects to take over later.
96
128
  */
97
129
  export async function mountFeishuPreview(api, target, initial, label = "[feishu]") {
98
130
  try {
@@ -151,17 +183,27 @@ export async function settleFeishuPreview(api, target, preview, text) {
151
183
  */
152
184
  export async function streamFeishuReply(events, api, target, formatError, initialPreview, label = "[feishu]") {
153
185
  // Event → view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
154
- // policy, the card-budget cap, and delivery below.
186
+ // policy, the card-budget caps, and delivery below. The card is TWO elements (card.ts): the process
187
+ // block's head changes every frame (sliding thinking tail, `…`→`✓` flips), so it must never share
188
+ // an element with the answer — the client would re-type the whole card from the divergence point
189
+ // once a second. Each view feeds its own element; only the changed one is written.
155
190
  const turn = createTurnView();
156
- const view = () => {
191
+ const processView = () => {
157
192
  const v = composeTurnBody([
158
193
  thinkingLine(turn, THINKING_PREVIEW),
159
194
  toolLines(turn),
160
195
  turn.retrying ? RETRY_NOTICE : "",
161
- revealedAnswer(turn, STREAM_THROTTLE_MS),
162
196
  ]);
163
- return capBytes(v === "" ? THINKING_PLACEHOLDER : v, CARD_MARKDOWN_MAX_BYTES);
197
+ if (v !== "")
198
+ return tailLines(v, PROCESS_MAX_POINTS);
199
+ // No process content: the placeholder covers only the silence BEFORE the answer reveals — once
200
+ // the answer is streaming, an empty block goes (stays) empty; "Thinking…" pinned above a live
201
+ // answer would misstate the phase. The empty frame is a real write: it clears a mounted
202
+ // placeholder. (The block cannot otherwise flicker: thinking and tools only grow — only the
203
+ // retry notice toggles, and its empty state resolves through this same rule.)
204
+ return revealedAnswer(turn, STREAM_THROTTLE_MS).trim() === "" ? THINKING_PLACEHOLDER : "";
164
205
  };
206
+ const answerView = () => capBytes(revealedAnswer(turn, STREAM_THROTTLE_MS), CARD_MARKDOWN_MAX_BYTES);
165
207
  // The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
166
208
  // mounted lazily on this turn's first flush. `sequence` must increase strictly per card — the single-
167
209
  // writer pump guarantees it by construction. A queue card has had no updates yet, so sequence starts
@@ -172,22 +214,34 @@ export async function streamFeishuReply(events, api, target, formatError, initia
172
214
  const nextSeq = () => ++sequence;
173
215
  let streamDead = false; // the platform closed streaming (idle timeout) — freeze the live view
174
216
  let finalized = false; // a terminal write (completed/failed) ran — the finally skips its orphan cleanup
175
- let lastSent = "";
217
+ let lastProcess = "";
218
+ let lastAnswer = "";
176
219
  const flushPreview = async () => {
177
- const text = view();
220
+ const process = processView();
178
221
  if (!setupAttempted) {
179
222
  setupAttempted = true;
180
- preview = await mountFeishuPreview(api, target, text, label);
181
- lastSent = text;
223
+ // The mount seeds the process element with the current view; the answer element starts empty
224
+ // (card.ts), so the first answer snapshot is a clean prefix extension.
225
+ preview = await mountFeishuPreview(api, target, process, label);
226
+ lastProcess = process;
182
227
  return;
183
228
  }
184
229
  if (preview.kind !== "card" || streamDead)
185
230
  return; // text tier / dead stream: frozen until the terminal write
186
- if (text === lastSent)
187
- return; // skip an unchanged snapshot
188
- lastSent = text;
189
231
  try {
190
- await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, text, nextSeq());
232
+ // `last*` advances BEFORE each write: a frame that fails for a non-streaming reason is logged
233
+ // once (the pump's onError) and not re-sent until its content actually changes. An EMPTY
234
+ // process frame is written like any other — it is the placeholder being cleared (processView).
235
+ if (process !== lastProcess) {
236
+ lastProcess = process;
237
+ await api.updateCardElement(preview.cardId, PROCESS_ELEMENT_ID, process, nextSeq());
238
+ }
239
+ const answer = answerView();
240
+ // Never write an empty answer snapshot — the element is born empty and the answer only grows.
241
+ if (answer !== "" && answer !== lastAnswer) {
242
+ lastAnswer = answer;
243
+ await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, answer, nextSeq());
244
+ }
191
245
  }
192
246
  catch (e) {
193
247
  if (isCardStreamingClosed(e)) {
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
56
56
 
57
57
  export default defineTool({
58
58
  description:
59
- "Send a message to a Feishu chat: plain `text`, or `markdown` (rendered as a card headings, " +
60
- "bold, code blocks, links). Exactly one of the two. Use it for a turn NO channel is carrying — a " +
61
- "scheduled or self-scheduled (wake) turn — or to reach a chat OTHER than the one you are " +
62
- "answering. In a normal chat turn the channel already delivers your reply, so do NOT call this to " +
63
- "answer (it would post the message twice). chatId comes from the [feishu: chat ] context line in a " +
64
- "chat turn; a scheduled/woken turn has no context line, so name the destination in your instruction.",
59
+ "Send a message to a Feishu chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
60
+ "channel is carrying a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
61
+ "nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
62
+ "channel streams and delivers your reply itself, so do NOT call this to answer the current " +
63
+ "chat: it would post the message twice, outside the conversation thread. `chatId` (oc_) names " +
64
+ "the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
65
+ "or memory); the [feishu: chat …] context line only identifies the chat you are answering — the " +
66
+ "one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
67
+ "`markdown` (rendered as a card: headings, bold, code blocks, links).",
65
68
  input: z.object({
66
69
  chatId: z.string().describe("target chat id (oc_…)"),
67
70
  text: z.string().optional().describe("plain text message to send"),
@@ -56,14 +56,27 @@ export function createInvokeHandler(agent) {
56
56
  catch {
57
57
  return text("invalid json\n", 400);
58
58
  }
59
- const { session, text: promptText } = (payload ?? {});
59
+ const { session, text: promptText, parentSession, branchHints, } = (payload ?? {});
60
60
  if (typeof session !== "string" || typeof promptText !== "string") {
61
61
  return text('need { "session": string, "text": string }\n', 400);
62
62
  }
63
63
  // ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
64
+ // The OPTIONAL lineage extension (Scope): malformed values are a 400, not a silent drop — a
65
+ // caller that sent them meant them.
66
+ if (parentSession !== undefined && typeof parentSession !== "string") {
67
+ return text('"parentSession" must be a string\n', 400);
68
+ }
69
+ if (branchHints !== undefined && !(Array.isArray(branchHints) && branchHints.every((h) => typeof h === "string"))) {
70
+ return text('"branchHints" must be an array of strings\n', 400);
71
+ }
64
72
  // Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
65
73
  // run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
66
- const iterator = agent.invoke({ session }, { text: promptText })[Symbol.asyncIterator]();
74
+ const iterator = agent
75
+ .invoke({
76
+ session,
77
+ ...(parentSession !== undefined ? { parentSession } : {}),
78
+ ...(branchHints !== undefined ? { branchHints } : {}),
79
+ }, { text: promptText })[Symbol.asyncIterator]();
67
80
  // Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
68
81
  // distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
69
82
  // look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
@@ -10,7 +10,7 @@
10
10
  * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
11
  * message-scoped Feishu keys, Slack file objects) are real differences.
12
12
  */
13
- import { type Agent, type AgentEvent, type Prompt } from "../agent.ts";
13
+ import { type Agent, type AgentEvent, type Prompt, type Scope } from "../agent.ts";
14
14
  /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
15
15
  * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
16
16
  * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
@@ -35,7 +35,10 @@ export declare const DEFAULT_BUSY_RETRY: BusyRetry;
35
35
  * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
36
36
  * is ever re-run.
37
37
  */
38
- export declare function streamTurnWithBusyRetry(agent: Agent, session: string, prompt: Prompt, options: {
38
+ export declare function streamTurnWithBusyRetry(agent: Agent,
39
+ /** The full scope, not a session string — channels that set extension fields (lineage) pass them
40
+ * through here; channels that don't pass `{ session }` and nothing changes. */
41
+ scope: Scope, prompt: Prompt, options: {
39
42
  label: string;
40
43
  onCompleted?: () => void;
41
44
  busyRetry?: BusyRetry;