@fastagent-sh/fastagent 0.15.0 → 0.16.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 (168) hide show
  1. package/README.md +13 -3
  2. package/dist/channels/agentcore-limits.d.ts +7 -0
  3. package/dist/channels/agentcore-limits.js +9 -0
  4. package/dist/channels/agentcore-state.d.ts +83 -0
  5. package/dist/channels/agentcore-state.js +258 -0
  6. package/dist/channels/agentcore.d.ts +98 -0
  7. package/dist/channels/agentcore.js +260 -0
  8. package/dist/channels/busy.d.ts +23 -0
  9. package/dist/channels/busy.js +53 -0
  10. package/dist/channels/context-buffer.d.ts +5 -0
  11. package/dist/channels/context-buffer.js +5 -0
  12. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  13. package/dist/channels/feishu/context-buffer.js +60 -6
  14. package/dist/channels/feishu/feishu-api.js +4 -1
  15. package/dist/channels/feishu/feishu.d.ts +0 -12
  16. package/dist/channels/feishu/feishu.js +143 -54
  17. package/dist/channels/feishu/invoke-turn.js +35 -18
  18. package/dist/channels/feishu/model.d.ts +0 -1
  19. package/dist/channels/feishu/normalize.js +0 -1
  20. package/dist/channels/feishu/parse.d.ts +21 -7
  21. package/dist/channels/feishu/parse.js +24 -7
  22. package/dist/channels/feishu/preview.js +3 -2
  23. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  26. package/dist/channels/feishu/setup-mode.js +27 -1
  27. package/dist/channels/github/github.js +8 -1
  28. package/dist/channels/http.js +1 -1
  29. package/dist/channels/lark/scaffold/channel.ts +9 -8
  30. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  31. package/dist/channels/preview-kit.d.ts +7 -1
  32. package/dist/channels/preview-kit.js +3 -2
  33. package/dist/channels/slack/parse.d.ts +16 -1
  34. package/dist/channels/slack/parse.js +46 -3
  35. package/dist/channels/slack/preview.d.ts +1 -2
  36. package/dist/channels/slack/preview.js +68 -24
  37. package/dist/channels/slack/scaffold/channel.ts +5 -5
  38. package/dist/channels/slack/slack-api.d.ts +3 -23
  39. package/dist/channels/slack/slack-api.js +6 -22
  40. package/dist/channels/slack/slack.d.ts +13 -20
  41. package/dist/channels/slack/slack.js +95 -50
  42. package/dist/channels/state.d.ts +11 -4
  43. package/dist/channels/state.js +19 -12
  44. package/dist/channels/tasks.d.ts +0 -6
  45. package/dist/channels/tasks.js +16 -1
  46. package/dist/channels/telegram/parse.d.ts +0 -7
  47. package/dist/channels/telegram/parse.js +4 -2
  48. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  49. package/dist/channels/telegram/telegram.js +1 -1
  50. package/dist/channels/text.d.ts +14 -0
  51. package/dist/channels/text.js +14 -0
  52. package/dist/channels/thread-participants.d.ts +21 -0
  53. package/dist/channels/thread-participants.js +132 -0
  54. package/dist/channels/turn-queue.js +7 -0
  55. package/dist/cli/add-feishu.d.ts +7 -4
  56. package/dist/cli/add-feishu.js +57 -37
  57. package/dist/cli/add-slack.d.ts +2 -1
  58. package/dist/cli/add-slack.js +6 -11
  59. package/dist/cli/commands/add.js +50 -51
  60. package/dist/cli/commands/attach.js +8 -4
  61. package/dist/cli/commands/chat.js +8 -8
  62. package/dist/cli/commands/deploy.d.ts +1 -1
  63. package/dist/cli/commands/deploy.js +323 -77
  64. package/dist/cli/commands/dev.js +24 -22
  65. package/dist/cli/commands/fire.js +15 -16
  66. package/dist/cli/commands/info.js +36 -29
  67. package/dist/cli/commands/init.d.ts +1 -1
  68. package/dist/cli/commands/init.js +65 -53
  69. package/dist/cli/commands/invoke.js +9 -6
  70. package/dist/cli/commands/login.js +35 -21
  71. package/dist/cli/commands/schedule.js +6 -8
  72. package/dist/cli/commands/start.js +93 -37
  73. package/dist/cli/commands/tool.js +22 -18
  74. package/dist/cli/fail.d.ts +17 -0
  75. package/dist/cli/fail.js +24 -0
  76. package/dist/cli/program.js +57 -36
  77. package/dist/cli/serve.d.ts +26 -6
  78. package/dist/cli/serve.js +62 -15
  79. package/dist/cli/shared.d.ts +15 -2
  80. package/dist/cli/shared.js +30 -18
  81. package/dist/deploy/agentcore/plan.d.ts +117 -0
  82. package/dist/deploy/agentcore/plan.js +721 -0
  83. package/dist/deploy/agentcore/run.d.ts +73 -0
  84. package/dist/deploy/agentcore/run.js +412 -0
  85. package/dist/deploy/agentcore/zip.d.ts +17 -0
  86. package/dist/deploy/agentcore/zip.js +68 -0
  87. package/dist/deploy/container.d.ts +26 -25
  88. package/dist/deploy/container.js +93 -89
  89. package/dist/deploy/docker/plan.d.ts +1 -1
  90. package/dist/deploy/docker/plan.js +12 -17
  91. package/dist/deploy/fly/plan.d.ts +2 -0
  92. package/dist/deploy/fly/plan.js +27 -19
  93. package/dist/deploy/fly/run.d.ts +12 -1
  94. package/dist/deploy/fly/run.js +36 -2
  95. package/dist/deploy/preflight.d.ts +11 -5
  96. package/dist/deploy/preflight.js +217 -65
  97. package/dist/deploy/railway/plan.d.ts +7 -0
  98. package/dist/deploy/railway/plan.js +41 -16
  99. package/dist/deploy/railway/run.d.ts +8 -1
  100. package/dist/deploy/railway/run.js +7 -2
  101. package/dist/deploy/runner.d.ts +5 -2
  102. package/dist/deploy/runner.js +9 -3
  103. package/dist/dev-supervisor.d.ts +11 -8
  104. package/dist/dev-supervisor.js +53 -51
  105. package/dist/engines/pi/auth.d.ts +8 -7
  106. package/dist/engines/pi/auth.js +12 -10
  107. package/dist/engines/pi/channel.d.ts +1 -1
  108. package/dist/engines/pi/channel.js +5 -5
  109. package/dist/engines/pi/chat.js +2 -2
  110. package/dist/engines/pi/config.d.ts +6 -46
  111. package/dist/engines/pi/config.js +21 -108
  112. package/dist/engines/pi/create.d.ts +8 -8
  113. package/dist/engines/pi/create.js +13 -12
  114. package/dist/engines/pi/definition.d.ts +7 -26
  115. package/dist/engines/pi/definition.js +8 -54
  116. package/dist/engines/pi/login.d.ts +1 -1
  117. package/dist/engines/pi/models.d.ts +3 -3
  118. package/dist/engines/pi/models.js +1 -1
  119. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
  120. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  121. package/dist/engines/pi/session-builder.d.ts +2 -2
  122. package/dist/engines/pi/session-builder.js +11 -11
  123. package/dist/engines/pi/tool.js +4 -0
  124. package/dist/env.d.ts +16 -4
  125. package/dist/env.js +43 -5
  126. package/dist/host/node.d.ts +2 -2
  127. package/dist/loader.d.ts +2 -2
  128. package/dist/loader.js +3 -3
  129. package/dist/log.d.ts +1 -1
  130. package/dist/log.js +1 -1
  131. package/dist/paths.d.ts +138 -0
  132. package/dist/paths.js +326 -0
  133. package/dist/pi.d.ts +1 -1
  134. package/dist/pi.js +2 -2
  135. package/dist/runtime.d.ts +7 -5
  136. package/dist/runtime.js +2 -2
  137. package/dist/scaffold/add-channel.d.ts +7 -3
  138. package/dist/scaffold/add-channel.js +55 -29
  139. package/dist/scaffold/init.d.ts +32 -41
  140. package/dist/scaffold/init.js +161 -185
  141. package/dist/scaffold/templates/env.example +15 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  143. package/dist/scaffold/templates/gitignore +14 -6
  144. package/dist/scaffold/templates/persona.md +4 -2
  145. package/dist/scaffold/templates/secrets.gitignore +5 -0
  146. package/dist/scaffold/templates.d.ts +1 -7
  147. package/dist/scaffold/templates.js +3 -25
  148. package/dist/scaffold/vendor-skill.d.ts +2 -2
  149. package/dist/scaffold/vendor-skill.js +13 -13
  150. package/dist/schedule/discover.js +4 -4
  151. package/dist/schedule/scheduler.d.ts +40 -1
  152. package/dist/schedule/scheduler.js +89 -56
  153. package/dist/schedule/state.js +1 -1
  154. package/dist/schedule/wake-alarm.d.ts +47 -0
  155. package/dist/schedule/wake-alarm.js +136 -0
  156. package/dist/schedule/wakeups.d.ts +1 -0
  157. package/dist/schedule/wakeups.js +18 -0
  158. package/dist/tunnel.d.ts +3 -3
  159. package/dist/tunnel.js +7 -7
  160. package/package.json +4 -1
  161. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  162. package/dist/channels/feishu/owned-threads.js +0 -47
  163. package/dist/channels/slack/owned-threads.d.ts +0 -6
  164. package/dist/channels/slack/owned-threads.js +0 -43
  165. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  166. package/dist/scaffold/templates/gitignore.kit +0 -2
  167. package/dist/workspace.d.ts +0 -9
  168. package/dist/workspace.js +0 -45
@@ -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 } from "../state.js";
17
+ import { ensureStateHome, removeRetiredStateFile } 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";
@@ -24,9 +24,9 @@ import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
24
24
  import { invokeFeishuTurn } from "./invoke-turn.js";
25
25
  import { createFeishuApi } from "./feishu-api.js";
26
26
  import { normalizeFeishuMessage } from "./normalize.js";
27
- import { createOwnedFeishuThreads } from "./owned-threads.js";
28
- import { FEISHU_GROUP_CONTEXT_SCOPE } from "./setup-mode.js";
29
- import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderLabel, } from "./parse.js";
27
+ import { createThreadParticipants } from "../thread-participants.js";
28
+ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
29
+ import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
30
30
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
31
31
  import { connectFeishuWs } from "./ws-ingress.js";
32
32
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
@@ -52,7 +52,7 @@ function isStoredFeishuTurn(t) {
52
52
  typeof r.seq === "number" &&
53
53
  typeof r.session === "string" &&
54
54
  typeof r.baseText === "string" &&
55
- (r.bufferKey === undefined || typeof r.bufferKey === "string") &&
55
+ typeof r.bufferKey === "string" &&
56
56
  typeof r.chatId === "string" &&
57
57
  (r.replyTo === undefined || typeof r.replyTo === "string") &&
58
58
  (r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
@@ -70,17 +70,19 @@ export function feishuChannel(opts) {
70
70
  export function feishuWebSocketChannel(opts) {
71
71
  return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
72
72
  }
73
- function validateSessionOptions(opts, factoryName) {
74
- if (opts.directMessageSession !== undefined && !["continuous", "threaded"].includes(opts.directMessageSession)) {
75
- throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
76
- }
77
- if (opts.groupMessageSession !== undefined && !["continuous", "threaded"].includes(opts.groupMessageSession)) {
78
- throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
73
+ /** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
74
+ * workspace still passing one would otherwise start fine and silently get different placement AND a
75
+ * different memory boundary the one breaking change most likely to be hit, and invisible. */
76
+ function rejectRemovedSessionOptions(opts, factoryName) {
77
+ const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
78
+ if (removed.length > 0) {
79
+ throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
80
+ "and the summon rule no longer depends on the mode — remove the option (see docs/design/participant-model.md)");
79
81
  }
80
82
  }
81
83
  function createFeishuRuntimeFactory(profile, opts, factoryName) {
82
- const { appId, appSecret, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, } = opts;
83
- const baseUrl = opts.apiBaseUrl ?? opts.baseUrl ?? profile.apiBase;
84
+ const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
85
+ const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
84
86
  const { kind } = profile;
85
87
  const label = `[${kind}]`;
86
88
  return ({ agent, stateRoot, control }) => {
@@ -92,7 +94,9 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
92
94
  const formatError = onError ?? defaultErrorMessage;
93
95
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
94
96
  // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
95
- // Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works.
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
100
  let botOpenId;
97
101
  void api.botInfo().then((me) => {
98
102
  botOpenId = me.openId;
@@ -100,14 +104,22 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
100
104
  log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
101
105
  }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
102
106
  void api.listAppScopes().then((scopes) => {
103
- const contextAware = scopes.some((scope) => scope.name === FEISHU_GROUP_CONTEXT_SCOPE &&
104
- scope.grantStatus === 1 &&
105
- (scope.type === undefined || scope.type === "tenant"));
106
- if (contextAware) {
107
- log.info(`${label} group visibility: context-aware bare managed-thread replies + buffered discussion enabled`);
107
+ const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
108
+ if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
109
+ // This scope settles the rule's whole input: it delivers the un-mentioned group messages the
110
+ // channel buffers, which is also what lets it HEAR a thread. Nothing is fetched, so nothing
111
+ // is pendingthe only bootstrap left is social, one mention inside a thread. The read scope
112
+ // is a separate, softer dependency, so it is reported separately rather than folded in.
113
+ log.info(`${label} group visibility: context-aware — buffered discussion enabled; bare replies work in a thread once the agent has been mentioned in it`);
108
114
  }
109
115
  else {
110
- log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare managed-thread replies + group context buffering are unavailable`);
116
+ log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare replies in the agent's threads + group context buffering are unavailable`);
117
+ }
118
+ // Reported OUTSIDE the branch above: the quoted-message read runs in every chat type and every
119
+ // posture (a p2p thread's opening ask, any quoted @mention in a group), so pairing this warning
120
+ // with the group scope would leave a mention-only deployment silently losing every referent.
121
+ if (!scopeSatisfied(FEISHU_MESSAGE_READ_REQUEST, grantedScope)) {
122
+ log.warn(`${label} ${FEISHU_MESSAGE_READ_SCOPE} is not granted — a message quoted by an ask cannot be read, and degrades to a marker in the prompt`);
111
123
  }
112
124
  }, (error) => log.warn(`${label} could not inspect group visibility: ${String(error)}`));
113
125
  const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
@@ -118,8 +130,16 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
118
130
  throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
119
131
  }
120
132
  const stateHome = join(stateRoot, "channels", kind);
121
- ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
122
- const ownedThreads = createOwnedFeishuThreads(join(stateHome, "owned-threads.json"), label);
133
+ ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
134
+ // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
135
+ // after the release following the participant model ships — by then no live deployment can still
136
+ // be carrying the file. test/migration-deadline.test.ts fails when due.
137
+ removeRetiredStateFile(stateHome, "owned-threads.json", label);
138
+ const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
+ /** This channel's place key for a thread (the shared store is key-agnostic). */
140
+ // The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
141
+ // future re-keying silently split participation from the sessions it is supposed to describe.
142
+ const threadKey = (chatId, threadId) => placeKey(kind, { chat_id: chatId, thread_id: threadId });
123
143
  const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
124
144
  const store = createTurnStore(join(stateHome, "turns.json"), {
125
145
  label,
@@ -264,14 +284,45 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
264
284
  if (recovered.length > 0)
265
285
  log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
266
286
  let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
267
- for (const { attempts: _a, ...intent } of recovered) {
268
- // A pre-buffer-version record has no trustworthy place identity. Give it an empty private bucket
269
- // rather than risk consuming new main-chat context that arrived after this restart.
270
- const bufferKey = intent.bufferKey ?? `${intent.chatId}:legacy-turn:${intent.id}`;
271
- submit({ ...intent, bufferKey, preview: undefined }, false);
272
- }
273
- // Transport-neutral acceptance boundary. It performs only the fast pre-ACK work: normalize,
274
- // route, persist intent/context, and enqueue. The minutes-long Agent turn remains fire-and-forget.
287
+ for (const { attempts: _a, ...intent } of recovered)
288
+ submit({ ...intent, preview: undefined }, false);
289
+ // Who the agent has heard in a thread decides whether a bare message addresses it (participant
290
+ // model §3), and it comes from what this channel observed — see thread-participants.ts.
291
+ let warnedUnidentified = false;
292
+ /**
293
+ * What this delivery contributes to thread participation, or undefined when it contributes nothing.
294
+ *
295
+ * ONE gate for BOTH writes (the humans observation on the way in, and the `agentSpoke` merge once
296
+ * the turn is durable), and one definition of the synthetic speaker id — hand-written conditions in
297
+ * two places is the drift that has already bitten this branch once. Structural facts only; see
298
+ * thread-participants.ts for why configuration must not appear here.
299
+ *
300
+ * Feishu-specific: p2p is excluded (nothing reads those records), and a custom route may admit a
301
+ * bot the default route filters out — answering one is not participation the summon rule should
302
+ * act on, so such a thread keeps no record and the first human still needs the mention bootstrap.
303
+ */
304
+ const heardIn = (m, sender) => {
305
+ if (m.chat_type !== "group" || m.thread_id === undefined || sender?.sender_type !== "user")
306
+ return undefined;
307
+ const speakerId = senderId(sender);
308
+ if (speakerId === undefined && !warnedUnidentified) {
309
+ // Once per MOUNT — the flag lives in this channel's closure on purpose: the condition is a
310
+ // property of THIS app's event configuration, so a `feishu` and a `lark` mount must each be
311
+ // able to report it. A per-thread set would instead grow without bound to repeat one fact.
312
+ warnedUnidentified = true;
313
+ log.warn(`${label} human senders arrive with no usable id (first seen in thread ${m.thread_id}) — each counts as a distinct speaker, so affected threads permanently require an @mention until thread-participants.json is deleted`);
314
+ }
315
+ // A human whose id no tenant flavour carries still SPOKE, and no human may speak unrecorded. A
316
+ // per-MESSAGE synthetic id keeps them distinct; a per-thread one would collapse every human on an
317
+ // id-less tenant into one, which is the direction that barges into a crowd. Its cost is PERMANENT
318
+ // — two such messages fill MAX_HUMANS and records never shed, so that thread needs an @mention
319
+ // from then on and only deleting the file resets it (§3).
320
+ return { key: threadKey(m.chat_id, m.thread_id), speaker: speakerId ?? `unidentified:${m.message_id}` };
321
+ };
322
+ // Transport-neutral acceptance boundary: normalize, route, persist intent/context, enqueue. It
323
+ // touches no network, so it stays synchronous inside the platform's ACK window and the delivery
324
+ // dedup ring alone is enough — there is no await for a duplicate push to race through. The
325
+ // minutes-long Agent turn remains fire-and-forget.
275
326
  const acceptEvent = (event) => {
276
327
  const m = event.message;
277
328
  if (!m?.message_id || !m.chat_id)
@@ -286,13 +337,20 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
286
337
  return;
287
338
  const bufferKey = feishuBufferPlaceKey(normalized.conversation);
288
339
  const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
289
- const managedThread = groupMessageSession === "threaded" &&
340
+ // Listening is not speaking: every message the channel can see refines who takes part in its
341
+ // thread, whether or not it is answered. The sender counts toward the rule immediately — a
342
+ // second human speaking is exactly what makes addressing ambiguous again.
343
+ const heard = heardIn(m, event.sender);
344
+ if (heard)
345
+ threadParticipants.merge(heard.key, { humans: [heard.speaker] });
346
+ if (!r &&
347
+ route === undefined &&
290
348
  isHumanGroup &&
291
349
  m.thread_id !== undefined &&
292
- m.root_id !== undefined &&
293
- ownedThreads.has(m.chat_id, m.root_id);
294
- if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
350
+ !normalized.content.hasMentions &&
351
+ threadParticipants.admitsBareMessage(threadKey(m.chat_id, m.thread_id))) {
295
352
  r = {};
353
+ }
296
354
  if (!r) {
297
355
  if (route === undefined && isHumanGroup) {
298
356
  const bodyText = feishuBufferText(normalized.content.text);
@@ -330,20 +388,18 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
330
388
  }
331
389
  return;
332
390
  }
333
- const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
334
- const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
335
- const threadedConversation = threadedP2p || threadedGroup;
336
- if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
337
- log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id — session continuity cannot be guaranteed`);
338
- }
339
- const defaultSession = threadedConversation
340
- ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
341
- : placeKey(m);
342
- const session = r.session ?? defaultSession;
391
+ // Memory follows the place (participant model §5): one session per chat, and one per thread.
392
+ // Keyed by `thread_id`, never `root_id` the platform's root_id tracks the reply chain and can
393
+ // differ between messages of ONE thread, which would split a side conversation in two.
394
+ const session = r.session ?? placeKey(kind, m);
343
395
  const chatId = r.chatId ?? m.chat_id;
344
396
  const sameTarget = chatId === m.chat_id;
345
- const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
346
- const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
397
+ // Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
398
+ // stay plain in an ordinary direct message, and stay inside a thread whenever the question came
399
+ // from one — a direct message's thread is a place too, and relocating out of it is the silent
400
+ // move the model refuses.
401
+ const replyTo = sameTarget && (m.chat_type === "group" || m.thread_id !== undefined) ? m.message_id : undefined;
402
+ const replyInThread = replyTo !== undefined && m.thread_id !== undefined ? true : undefined;
347
403
  const queueReplyTo = sameTarget ? m.message_id : undefined;
348
404
  // Explicit user stop: a control action, never a turn — it must not queue behind the run it
349
405
  // stops. Mentions arrive as @name tokens; strip them before matching the bare word. Record the
@@ -365,12 +421,9 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
365
421
  const baseText = r.text ?? cloudEnvelope(event, kind);
366
422
  if (baseText.trim() === "" && images.length === 0 && files.length === 0)
367
423
  return;
368
- if (route === undefined && threadedGroup && m.thread_id === undefined && sameTarget && replyInThread === true) {
369
- ownedThreads.add(m.chat_id, m.message_id);
370
- }
371
424
  submit({
372
425
  id: m.message_id,
373
- seq: ++seqCounter,
426
+ seq: ++seqCounter, // arrival order; the turn store replays by it
374
427
  session,
375
428
  baseText,
376
429
  bufferKey,
@@ -378,10 +431,46 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
378
431
  replyTo,
379
432
  queueReplyTo,
380
433
  replyInThread,
381
- parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
434
+ // A quote is the user explicitly pointing at something that may predate this session
435
+ // (§8 rung 2), so it is always loaded.
436
+ //
437
+ // There used to be an exception: skip it inside a thread the agent had already answered in,
438
+ // since the session would hold it. That needed a second fact — did the channel RECEIVE the
439
+ // messages in between? — which depends on a scope that changes over time, while the record it
440
+ // was read against is durable. Every attempt to gate it correctly failed in the same
441
+ // direction (a silently missing quote), for an optimisation worth one `getMessage` on a
442
+ // quote-reply inside an active thread. Loading it costs a call and some text the session may
443
+ // already have; it also pins WHICH message is being answered, which a long thread benefits
444
+ // from anyway.
445
+ parentId: m.parent_id,
382
446
  images,
383
447
  files,
384
448
  }, true);
449
+ // Answering inside a thread makes the agent a participant of it, which is what lets the NEXT
450
+ // bare message address it without a mention (§3).
451
+ //
452
+ // `r.session === undefined` is what makes this fact mean what its reader assumes. Participation
453
+ // is keyed by THREAD while the memory it stands in for is keyed by SESSION, and those agree only
454
+ // when the session is derived from the place. A route supplying its own (the scaffold's
455
+ // `session: user:<open_id>` example) can put two people's turns in one thread into different
456
+ // sessions — recording `agentSpoke` from one of them would tell the summon rule the agent took
457
+ // part in a conversation it cannot remember. So the flag records "the agent answered into THIS
458
+ // THREAD'S session". Such a thread keeps a bystander record and needs the ordinary mention to
459
+ // bootstrap if the route is later dropped. `group` matches the observation above so the record is
460
+ // never half-written.
461
+ //
462
+ // Recorded only once the intent is durable: `submit` can throw, and a redelivery must still see
463
+ // the thread as the agent has actually left it. A later delivery failure does not undo it —
464
+ // entering the conversation is the intent, not the send.
465
+ // Reuses `heardIn` from the way in, so both writes share ONE gate by construction. The extra
466
+ // conditions are about the ANSWER, not the speaker: it must land in this thread (`sameTarget`,
467
+ // `replyInThread`) and in the place's own session, or the flag would claim a memory that never
468
+ // held the turn.
469
+ if (heard && replyInThread === true && sameTarget && r.session === undefined) {
470
+ // Both halves in ONE merge, like Slack's: a record that needed an earlier merge to survive
471
+ // could otherwise say "answered here, heard nobody" — which admits bare messages forever.
472
+ threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
473
+ }
385
474
  };
386
475
  return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
387
476
  };
@@ -466,7 +555,7 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
466
555
  return { [`POST /${kind}`]: handler };
467
556
  }
468
557
  export function buildFeishuChannel(profile, opts, factoryName) {
469
- validateSessionOptions(opts, factoryName);
558
+ rejectRemovedSessionOptions(opts, factoryName);
470
559
  const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
471
560
  return (ctx) => {
472
561
  if (!opts.verificationToken) {
@@ -476,7 +565,7 @@ export function buildFeishuChannel(profile, opts, factoryName) {
476
565
  };
477
566
  }
478
567
  export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
479
- validateSessionOptions(opts, factoryName);
568
+ rejectRemovedSessionOptions(opts, factoryName);
480
569
  const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
481
570
  return {
482
571
  name: `${profile.kind} websocket`,
@@ -486,7 +575,7 @@ export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {
486
575
  kind: profile.kind,
487
576
  appId: opts.appId,
488
577
  appSecret: opts.appSecret,
489
- domain: opts.apiBaseUrl ?? opts.baseUrl ?? profile.apiBase,
578
+ domain: opts.apiBaseUrl ?? profile.apiBase,
490
579
  onEvent: runtime.acceptEvent,
491
580
  }, signal);
492
581
  },
@@ -1,7 +1,7 @@
1
1
  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
- import { codePointPrefix } from "../text.js";
4
+ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
5
5
  /** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
6
6
  * card's markdown element is the natural fit for LLM output — steer away from HTML/plain. */
7
7
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
@@ -15,24 +15,41 @@ async function resolveTurnInputs(t, attachments) {
15
15
  let referentBlock = "";
16
16
  if (attachments.primary.parentId !== undefined) {
17
17
  const parentId = attachments.primary.parentId;
18
- const parent = await t.api.getMessage(parentId);
19
- if (!parent)
20
- throw new Error(`replied-to message ${parentId} is not readable`);
21
- const parsed = parseContent({
22
- message_type: parent.msg_type ?? "unknown",
23
- content: parent.body?.content ?? "",
24
- mentions: parent.mentions,
18
+ // A referent is CONTEXT, not the ask. Losing it (deleted, restricted, unreadable) must not cost
19
+ // the user their answer — every first message of a thread carries one, so a hard failure here
20
+ // would turn an ordinary platform edge into a lost turn. Degrade visibly instead: the operator
21
+ // gets a warning, and the model is told the quote could not be read rather than being left to
22
+ // guess what "about that" refers to.
23
+ // A deleted or invisible message comes back as an EMPTY item list rather than an error, so the
24
+ // warning belongs on the branch that renders the marker — that is the one the operator must see.
25
+ let failure;
26
+ const parent = await t.api.getMessage(parentId).catch((error) => {
27
+ failure = String(error);
28
+ return undefined;
25
29
  });
26
- // The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
27
- for (const key of parsed.imageKeys)
28
- images.push({ msg: parentId, key });
29
- for (const ref of parsed.fileRefs)
30
- files.push({ msg: parentId, key: ref.key, name: ref.name });
31
- // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
32
- // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
33
- const senderId = parent.sender?.id;
34
- const from = senderId ? `user ${senderId}` : undefined;
35
- referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
30
+ if (!parent) {
31
+ log.warn(`${t.label} could not read replied-to message ${parentId} (${failure ?? "no such message"}) — the model is told the quote is unreadable`);
32
+ // Fall THROUGH: the resources this turn carries are the ask itself. Returning here would drop
33
+ // the images and files the user explicitly attached along with the referent they merely quoted.
34
+ referentBlock = `\n\n[replied-to message (msg ${parentId}) could not be read]`;
35
+ }
36
+ else {
37
+ const parsed = parseContent({
38
+ message_type: parent.msg_type ?? "unknown",
39
+ content: parent.body?.content ?? "",
40
+ mentions: parent.mentions,
41
+ });
42
+ // The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
43
+ for (const key of parsed.imageKeys)
44
+ images.push({ msg: parentId, key });
45
+ for (const ref of parsed.fileRefs)
46
+ 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;
51
+ referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
52
+ }
36
53
  }
37
54
  // Primary first and fail-fast: these are resources the current user explicitly pointed at.
38
55
  const imageRefs = [];
@@ -87,7 +87,6 @@ export interface NormalizedFeishuMessage {
87
87
  conversation: {
88
88
  chatId: string;
89
89
  threadId?: string;
90
- rootId?: string;
91
90
  };
92
91
  content: {
93
92
  text: string;
@@ -121,7 +121,6 @@ export function normalizeFeishuMessage(event) {
121
121
  conversation: {
122
122
  chatId: message.chat_id,
123
123
  threadId: message.thread_id,
124
- rootId: message.root_id,
125
124
  },
126
125
  content: {
127
126
  text: decoded.text,
@@ -7,8 +7,8 @@
7
7
  import type { FeishuCloudKind } from "./cloud.ts";
8
8
  import type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender } from "./model.ts";
9
9
  export type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender };
10
- /** Legacy compatibility shape returned by {@link parseContent}. New internal code consumes normalized
11
- * resource refs, which retain resource kind + carrying message id. */
10
+ /** A resource reduced to what a caller needs to fetch and name it — the kind and carrying message id
11
+ * that {@link decodeFeishuContent} attaches are supplied by the caller's own context. */
12
12
  interface FeishuAttachmentRef {
13
13
  key: string;
14
14
  name?: string;
@@ -19,14 +19,27 @@ export interface ParsedFeishuContent {
19
19
  fileRefs: FeishuAttachmentRef[];
20
20
  }
21
21
  /**
22
- * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
23
- * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
22
+ * The decoder projected onto flat per-kind arrays, for callers that resolve a message on their own
23
+ * (the prompt envelope, and the quoted parent whose resources are carried by the PARENT message id).
24
+ * `decodeFeishuContent` stays canonical: this only reshapes what it returns.
24
25
  */
25
26
  export declare function parseContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): ParsedFeishuContent;
26
27
  /** A stable sender label for attribution. Display names require an additional contacts permission. */
27
28
  export declare function senderLabel(sender: FeishuSender | undefined): string | undefined;
28
- /** The place a message lives (chat, or chat:topic in a topic group) the legacy default session key. */
29
- export declare function placeKey(message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
29
+ /** Whichever id flavour the tenant populates. `sender_id` is a union and which members are filled is
30
+ * app configuration, not an invariant callers only ever compare these for distinctness. */
31
+ export declare function senderId(sender: FeishuSender | undefined): string | undefined;
32
+ /**
33
+ * The place a message lives (the chat, or a thread within it) — the session key (participant model §5),
34
+ * and the key thread participation is recorded under, since that record is a claim about this session.
35
+ *
36
+ * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
37
+ * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
38
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
39
+ * percent-encodes it, so each `:` costs three), and the worst case here — brand + a 35-char chat id +
40
+ * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
41
+ */
42
+ export declare function placeKey(kind: string, message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
30
43
  /** The canonical Feishu-branded prompt envelope. */
31
44
  export declare function feishuEnvelope(event: FeishuMessageEvent): string;
32
45
  /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
@@ -36,7 +49,8 @@ export declare function mentionsBot(message: Pick<FeishuMessage, "mentions">, bo
36
49
  /**
37
50
  * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
38
51
  * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
39
- * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
52
+ * stateful channel wiring may additionally admit unmentioned messages in a thread it takes part in
53
+ * (docs/design/participant-model.md §3).
40
54
  */
41
55
  export declare function defaultFeishuRoute(event: FeishuMessageEvent, options?: {
42
56
  botOpenId?: string;
@@ -1,7 +1,8 @@
1
1
  import { decodeFeishuContent } from "./normalize.js";
2
2
  /**
3
- * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
4
- * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
3
+ * The decoder projected onto flat per-kind arrays, for callers that resolve a message on their own
4
+ * (the prompt envelope, and the quoted parent whose resources are carried by the PARENT message id).
5
+ * `decodeFeishuContent` stays canonical: this only reshapes what it returns.
5
6
  */
6
7
  export function parseContent(message) {
7
8
  const decoded = decodeFeishuContent(message);
@@ -15,12 +16,27 @@ export function parseContent(message) {
15
16
  }
16
17
  /** A stable sender label for attribution. Display names require an additional contacts permission. */
17
18
  export function senderLabel(sender) {
18
- const id = sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
19
+ const id = senderId(sender);
19
20
  return id ? `user ${id}` : undefined;
20
21
  }
21
- /** The place a message lives (chat, or chat:topic in a topic group) the legacy default session key. */
22
- export function placeKey(message) {
23
- return message.thread_id ? `${message.chat_id}:${message.thread_id}` : message.chat_id;
22
+ /** Whichever id flavour the tenant populates. `sender_id` is a union and which members are filled is
23
+ * app configuration, not an invariant — callers only ever compare these for distinctness. */
24
+ export function senderId(sender) {
25
+ return sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
26
+ }
27
+ /**
28
+ * The place a message lives (the chat, or a thread within it) — the session key (participant model §5),
29
+ * and the key thread participation is recorded under, since that record is a claim about this session.
30
+ *
31
+ * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
32
+ * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
33
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
34
+ * percent-encodes it, so each `:` costs three), and the worst case here — brand + a 35-char chat id +
35
+ * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
36
+ */
37
+ export function placeKey(kind, message) {
38
+ const chat = `${kind}:${message.chat_id}`;
39
+ return message.thread_id ? `${chat}:${message.thread_id}` : chat;
24
40
  }
25
41
  /** The canonical Feishu-branded prompt envelope. */
26
42
  export function feishuEnvelope(event) {
@@ -52,7 +68,8 @@ export function mentionsBot(message, botOpenId) {
52
68
  /**
53
69
  * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
54
70
  * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
55
- * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
71
+ * stateful channel wiring may additionally admit unmentioned messages in a thread it takes part in
72
+ * (docs/design/participant-model.md §3).
56
73
  */
57
74
  export function defaultFeishuRoute(event, options) {
58
75
  const message = event.message;
@@ -63,8 +63,9 @@ async function finalize(api, target, preview, text, seq) {
63
63
  // Settle failed (card expired / rejected) — fall through to delete + fresh send below.
64
64
  }
65
65
  if (settled) {
66
- // Threaded continuations must keep reply_in_thread; continuous top-level group replies intentionally
67
- // avoid repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
66
+ // Threaded continuations must keep reply_in_thread; top-level group replies (target.replyInThread
67
+ // unset a custom route, or an ask outside a thread) intentionally avoid repeating the quote on
68
+ // every chunk. sendText owns the same distinction for its own chunking.
68
69
  // A continuation failure propagates: the card is already authoritative, so deleting it and sending
69
70
  // the full answer again would deterministically duplicate every continuation that already landed.
70
71
  const continuationTarget = target.replyInThread ? target : { chatId: target.chatId };
@@ -8,8 +8,10 @@ import { feishuChannel } from "@fastagent-sh/fastagent/feishu";
8
8
  // 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
9
9
  // (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
10
10
  // card scope ("Create and update card" — the live preview streams through a card). To answer bare
11
- // messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
12
- // sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
11
+ // messages in threads the Agent takes part in, and buffer other unsummoned group/thread context, also add the
12
+ // sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version. Add a
13
+ // message-read scope (e.g. `im:message:readonly`) too, so a thread's opening ask can carry the
14
+ // message it quotes; without it that quote degrades to a marker in the prompt.
13
15
  // 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
14
16
  // .env; RECOMMENDED: set an Encrypt Key there and mirror it in FEISHU_ENCRYPT_KEY
15
17
  // 4. the event Request URL (https://your.host/feishu) is registered AUTOMATICALLY by
@@ -23,18 +25,17 @@ export default feishuChannel({
23
25
  appSecret: process.env.FEISHU_APP_SECRET ?? "",
24
26
  verificationToken: process.env.FEISHU_VERIFICATION_TOKEN ?? "", // authenticates inbound events
25
27
  encryptKey: process.env.FEISHU_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
26
- // Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
27
- // directMessageSession: "continuous",
28
- // groupMessageSession: "continuous",
28
+ // No session modes: a chat is one session and a thread is another, and where the answer goes follows
29
+ // from that (docs/design/participant-model.md).
29
30
  // Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
30
31
  // chat is customer-facing by default — for a public bot, drop this or return a neutral string;
31
32
  // full details always go to the server log regardless.
32
33
  onError: (failed) => `⚠️ ${failed.details}`,
33
34
  // The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
34
35
  // the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultFeishuRoute:
35
- // p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
36
- // threads. Other human group/thread discussion buffers until that place's next answered turn;
37
- // @other-only messages in managed threads buffer rather than triggering the Agent.
36
+ // p2p chats always answer; groups answer on @this-bot, plus bare messages in a thread where the
37
+ // Agent takes part and exactly ONE human does. Other human group/thread discussion buffers until
38
+ // that place's next answered turn; @other-only messages buffer rather than triggering the Agent.
38
39
  // Override to customise explicit routing, reusing the export:
39
40
  // route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
40
41
  // route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { text: `${feishuEnvelope(e)}\n[extra]` },
@@ -6,7 +6,7 @@ import { defineTool, z } from "@fastagent-sh/fastagent";
6
6
  // context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
7
7
  // the target chat id. tools/ is auto-discovered.
8
8
 
9
- // Standalone copy of the channel transport's discipline: a timeout so a wedged connection can't hang
9
+ // Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
10
10
  // the tool call (and the turn), named errors, and success gated on the body's own code===0.
11
11
  // Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
12
12
  // fail-fast beats a silently sleeping tool.
@@ -57,9 +57,11 @@ async function tenantToken(): Promise<string> {
57
57
  export default defineTool({
58
58
  description:
59
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. In a chat turn take chatId from the " +
61
- "[feishu: chat …] context line; in a scheduled/woken turn (no context line) the chat id must come " +
62
- "from your instruction.",
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.",
63
65
  input: z.object({
64
66
  chatId: z.string().describe("target chat id (oc_…)"),
65
67
  text: z.string().optional().describe("plain text message to send"),