@fastagent-sh/fastagent 0.17.1 → 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 (54) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  3. package/dist/channels/feishu/feishu.js +39 -9
  4. package/dist/channels/feishu/invoke-turn.d.ts +8 -2
  5. package/dist/channels/feishu/invoke-turn.js +150 -31
  6. package/dist/channels/feishu/parse.js +6 -0
  7. package/dist/channels/http.js +15 -2
  8. package/dist/channels/invoke-turn-kit.d.ts +5 -2
  9. package/dist/channels/invoke-turn-kit.js +6 -2
  10. package/dist/channels/slack/invoke-turn.js +1 -1
  11. package/dist/channels/slack/slack.js +1 -5
  12. package/dist/channels/state.d.ts +0 -10
  13. package/dist/channels/state.js +2 -19
  14. package/dist/channels/telegram/invoke-turn.js +1 -1
  15. package/dist/channels/thread-participants.d.ts +7 -0
  16. package/dist/channels/thread-participants.js +3 -0
  17. package/dist/cli/commands/deploy.js +13 -5
  18. package/dist/cli/commands/dev.js +1 -1
  19. package/dist/cli/commands/fire.js +1 -1
  20. package/dist/cli/commands/info.js +21 -1
  21. package/dist/cli/commands/invoke.js +1 -1
  22. package/dist/cli/commands/start.js +1 -1
  23. package/dist/cli/shared.d.ts +4 -2
  24. package/dist/cli/shared.js +12 -5
  25. package/dist/collect.d.ts +30 -4
  26. package/dist/collect.js +39 -6
  27. package/dist/deploy/preflight.d.ts +8 -2
  28. package/dist/deploy/preflight.js +21 -3
  29. package/dist/deploy/secrets.d.ts +3 -0
  30. package/dist/deploy/secrets.js +6 -0
  31. package/dist/dev-supervisor.js +8 -2
  32. package/dist/engines/pi/create.d.ts +2 -1
  33. package/dist/engines/pi/create.js +12 -7
  34. package/dist/engines/pi/harness.d.ts +6 -3
  35. package/dist/engines/pi/harness.js +4 -3
  36. package/dist/engines/pi/invoke-session.d.ts +32 -0
  37. package/dist/engines/pi/invoke-session.js +171 -0
  38. package/dist/engines/pi/invoke.d.ts +6 -27
  39. package/dist/engines/pi/invoke.js +49 -208
  40. package/dist/engines/pi/models.d.ts +45 -11
  41. package/dist/engines/pi/models.js +55 -8
  42. package/dist/engines/pi/session-builder.js +4 -2
  43. package/dist/engines/pi/session-control.d.ts +2 -1
  44. package/dist/engines/pi/sessions.d.ts +17 -1
  45. package/dist/engines/pi/sessions.js +292 -10
  46. package/dist/engines/pi/turn-kit.d.ts +56 -0
  47. package/dist/engines/pi/turn-kit.js +161 -0
  48. package/dist/paths.d.ts +6 -0
  49. package/dist/paths.js +6 -0
  50. package/dist/pi.d.ts +3 -2
  51. package/dist/pi.js +1 -1
  52. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  53. package/dist/session-remote.js +10 -2
  54. 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";
@@ -69,11 +69,13 @@ export interface FeishuApi {
69
69
  *
70
70
  * `sender` is typed rather than `unknown` because the referent path READS it: an app-sent message
71
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. */
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. */
74
75
  getMessage(messageId: string): Promise<{
75
76
  message_id?: string;
76
77
  msg_type?: string;
78
+ parent_id?: string;
77
79
  body?: {
78
80
  content?: string;
79
81
  };
@@ -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, loadStateFile, removeRetiredStateFile, saveStateFile } from "../state.js";
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");
@@ -160,10 +162,6 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
160
162
  }
161
163
  const stateHome = join(stateRoot, "channels", kind);
162
164
  ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
163
- // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
164
- // after the release following the participant model ships — by then no live deployment can still
165
- // be carrying the file. test/migration-deadline.test.ts fails when due.
166
- removeRetiredStateFile(stateHome, "owned-threads.json", label);
167
165
  // The cached bot identity (rationale at the botInfo block above): seed synchronously — the
168
166
  // factory runs to completion before any promise resolves, so botOpenId is still unset here and
169
167
  // the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
@@ -309,13 +307,31 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
309
307
  // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
310
308
  // would instead couple otherwise-independent root sessions and require failure rollback.
311
309
  const { text: recent, consumed } = buffer.peek(rec.bufferKey);
312
- const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
313
- const buffered = collectFeishuBufferedAttachments(consumed, {
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], {
314
320
  images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
315
321
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
316
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;
317
326
  try {
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 }, () => {
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 }, () => {
319
335
  // Drop intent first: a crash between these writes may re-fold answered context later,
320
336
  // but can never replay this turn after its context was removed.
321
337
  store.remove(rec.id);
@@ -465,7 +481,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
465
481
  // Memory follows the place (participant model §5): one session per chat, and one per thread.
466
482
  // Keyed by `thread_id`, never `root_id` — the platform's root_id tracks the reply chain and can
467
483
  // differ between messages of ONE thread, which would split a side conversation in two.
468
- const session = r.session ?? placeKey(kind, m);
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;
469
497
  const chatId = r.chatId ?? m.chat_id;
470
498
  const sameTarget = chatId === m.chat_id;
471
499
  // Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
@@ -517,6 +545,8 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
517
545
  // already have; it also pins WHICH message is being answered, which a long thread benefits
518
546
  // from anyway.
519
547
  parentId: m.parent_id,
548
+ ...(parentSession !== undefined ? { parentSession } : {}),
549
+ ...(roomBufferKey !== undefined ? { roomBufferKey } : {}),
520
550
  images,
521
551
  files,
522
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 degrade per attachment:
12
- * one expired background file must not block the current ask or hide its still-readable siblings.
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";
@@ -25,6 +26,11 @@ export interface FeishuTurnTransport {
25
26
  * sender is an app. Needed to tell the agent's OWN messages from any other bot's in the same chat:
26
27
  * `sender_type` alone says "some app", which is not the question the referent path asks. */
27
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;
28
34
  }
29
35
  /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
30
36
  * bytes by message_id + key, so the pair travels together through the turn record). */
@@ -1,5 +1,6 @@
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
6
  /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
@@ -10,14 +11,126 @@ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
10
11
  const REPLY_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card. This reply is " +
11
12
  "delivered to the current chat by the channel itself: do not call a send tool to answer the " +
12
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
+ }
13
124
  /**
14
- * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
15
- * (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.
16
128
  */
17
129
  async function resolveTurnInputs(t, attachments) {
18
130
  const images = [...attachments.primary.images];
19
131
  const files = [...attachments.primary.files];
20
132
  let referentBlock = "";
133
+ let chain = { block: "", images: [], files: [], ids: [] };
21
134
  if (attachments.primary.parentId !== undefined) {
22
135
  const parentId = attachments.primary.parentId;
23
136
  // A referent is CONTEXT, not the ask. Losing it (deleted, restricted, unreadable) must not cost
@@ -49,32 +162,11 @@ async function resolveTurnInputs(t, attachments) {
49
162
  images.push({ msg: parentId, key });
50
163
  for (const ref of parsed.fileRefs)
51
164
  files.push({ msg: parentId, key: ref.key, name: ref.name });
52
- // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
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";
62
- const senderId = parent.sender?.id;
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;
165
+ const from = fetchedSenderLabel(parent.sender, t.appId);
67
166
  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.
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;
78
170
  }
79
171
  }
80
172
  // Primary first and fail-fast: these are resources the current user explicitly pointed at.
@@ -88,8 +180,28 @@ async function resolveTurnInputs(t, attachments) {
88
180
  // downloaded twice or rendered twice in the manifest.
89
181
  const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
90
182
  const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
91
- const bufferedImages = attachments.buffered.images.filter((ref) => !primaryImages.has(`${ref.messageId}\u0000${ref.key}`));
92
- 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;
93
205
  const backgroundImages = [];
94
206
  const backgroundFiles = [];
95
207
  let lost = 0;
@@ -114,7 +226,7 @@ async function resolveTurnInputs(t, attachments) {
114
226
  log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
115
227
  }
116
228
  }
117
- const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
229
+ const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped + mergedImages.dropped + mergedFiles.dropped);
118
230
  const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
119
231
  const allFiles = [
120
232
  ...downloaded,
@@ -127,6 +239,7 @@ async function resolveTurnInputs(t, attachments) {
127
239
  return {
128
240
  images: allImages.length ? allImages : undefined,
129
241
  promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
242
+ referentIds: [...(attachments.primary.parentId !== undefined ? [attachments.primary.parentId] : []), ...chain.ids],
130
243
  };
131
244
  }
132
245
  /**
@@ -144,5 +257,11 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
144
257
  return;
145
258
  }
146
259
  const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
147
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
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 });
148
267
  }
@@ -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(", ");
@@ -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;
@@ -33,13 +33,17 @@ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
33
33
  * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
34
34
  * is ever re-run.
35
35
  */
36
- export async function* streamTurnWithBusyRetry(agent, session, prompt, options) {
36
+ export async function* streamTurnWithBusyRetry(agent,
37
+ /** The full scope, not a session string — channels that set extension fields (lineage) pass them
38
+ * through here; channels that don't pass `{ session }` and nothing changes. */
39
+ scope, prompt, options) {
37
40
  const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
41
+ const session = scope.session;
38
42
  const deadline = Date.now() + busyRetry.maxWaitMs;
39
43
  for (;;) {
40
44
  let retryBusy = false;
41
45
  let first = true;
42
- for await (const e of agent.invoke({ session }, prompt)) {
46
+ for await (const e of agent.invoke(scope, prompt)) {
43
47
  if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
44
48
  retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
45
49
  break;
@@ -59,5 +59,5 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
59
59
  return;
60
60
  }
61
61
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
62
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
62
+ yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: transport.label, onCompleted, busyRetry });
63
63
  }
@@ -7,7 +7,7 @@ import { text } from "../respond.js";
7
7
  import { createSeenRing } from "../seen.js";
8
8
  import { createThreadParticipants } from "../thread-participants.js";
9
9
  import { createTaskTracker } from "../tasks.js";
10
- import { ensureStateHome, removeRetiredStateFile } from "../state.js";
10
+ import { ensureStateHome } from "../state.js";
11
11
  import { dispatchStop, isStopText } from "../stop-command.js";
12
12
  import { codePointPrefix } from "../text.js";
13
13
  import { createTurnQueue } from "../turn-queue.js";
@@ -141,10 +141,6 @@ export function slackChannel(options) {
141
141
  * under a custom route is the `routed.session === undefined` condition on the write, not the
142
142
  * absence of a route: a route that supplies its own session records nothing here. */
143
143
  const threadKey = (teamId, channelId, threadTs) => `slack:${teamId}:${channelId}:${threadTs}`;
144
- // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
145
- // after the release following the participant model ships — by then no live deployment can still
146
- // be carrying the file. test/migration-deadline.test.ts fails when due.
147
- removeRetiredStateFile(stateHome, "owned-threads.json", label);
148
144
  const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
149
145
  const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
150
146
  const store = createTurnStore(join(stateHome, "turns.json"), {
@@ -4,13 +4,3 @@ export declare function ensureStateHome(dir: string): void;
4
4
  * caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
5
5
  export declare function loadStateFile(path: string): unknown;
6
6
  export declare function saveStateFile(path: string, value: unknown): void;
7
- /**
8
- * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
9
- * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
10
- * whose loss changes behaviour needs a migration, not a delete.
11
- *
12
- * Shared because a retired file is usually retired in every channel at once: one best-effort
13
- * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
14
- * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
15
- */
16
- export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
@@ -12,8 +12,8 @@
12
12
  * is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
13
13
  * with silently-empty state would hide real data behind a config mistake.
14
14
  */
15
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
- import { dirname, join } from "node:path";
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
17
  import { log } from "../log.js";
18
18
  /** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
19
19
  export function ensureStateHome(dir) {
@@ -48,20 +48,3 @@ export function saveStateFile(path, value) {
48
48
  writeFileSync(tmp, JSON.stringify(value));
49
49
  renameSync(tmp, path);
50
50
  }
51
- /**
52
- * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
53
- * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
54
- * whose loss changes behaviour needs a migration, not a delete.
55
- *
56
- * Shared because a retired file is usually retired in every channel at once: one best-effort
57
- * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
58
- * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
59
- */
60
- export function removeRetiredStateFile(stateHome, name, label) {
61
- try {
62
- rmSync(join(stateHome, name), { force: true });
63
- }
64
- catch (error) {
65
- log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
66
- }
67
- }
@@ -76,5 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
76
76
  return;
77
77
  }
78
78
  const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
79
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
79
+ yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: "[telegram]", onCompleted, busyRetry });
80
80
  }
@@ -6,6 +6,13 @@ export interface ThreadParticipants {
6
6
  * get wrong twice, and "a second human restores the mention requirement" must have one place to change.
7
7
  */
8
8
  admitsBareMessage(key: string): boolean;
9
+ /**
10
+ * Has the agent answered into this thread before — the "first answered turn" fact
11
+ * (participant-model.md §8), unlike {@link ThreadParticipants.admitsBareMessage} which also weighs
12
+ * the second-human rule. An evicted record answers false (this store is a cache — see the header),
13
+ * so gate a repeatable read on it, never a durable claim.
14
+ */
15
+ agentSpokeIn(key: string): boolean;
9
16
  /**
10
17
  * Merge in what was just heard. Idempotent; a failed write is a warning, never a failed delivery.
11
18
  *
@@ -68,6 +68,9 @@ export function createThreadParticipants(path, label) {
68
68
  const heard = records.get(key);
69
69
  return heard?.agentSpoke === true && heard.humans.length <= 1;
70
70
  },
71
+ agentSpokeIn(key) {
72
+ return records.get(key)?.agentSpoke === true;
73
+ },
71
74
  merge(key, heard) {
72
75
  const previous = records.get(key);
73
76
  const humans = new Set(previous?.humans ?? []);