@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.
- package/README.md +13 -3
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +5 -0
- package/dist/channels/context-buffer.js +5 -0
- package/dist/channels/feishu/context-buffer.d.ts +13 -8
- package/dist/channels/feishu/context-buffer.js +60 -6
- package/dist/channels/feishu/feishu-api.js +4 -1
- package/dist/channels/feishu/feishu.d.ts +0 -12
- package/dist/channels/feishu/feishu.js +143 -54
- package/dist/channels/feishu/invoke-turn.js +35 -18
- package/dist/channels/feishu/model.d.ts +0 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +21 -7
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.js +3 -2
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +30 -1
- package/dist/channels/feishu/setup-mode.js +27 -1
- package/dist/channels/github/github.js +8 -1
- package/dist/channels/http.js +1 -1
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +7 -1
- package/dist/channels/preview-kit.js +3 -2
- package/dist/channels/slack/parse.d.ts +16 -1
- package/dist/channels/slack/parse.js +46 -3
- package/dist/channels/slack/preview.d.ts +1 -2
- package/dist/channels/slack/preview.js +68 -24
- package/dist/channels/slack/scaffold/channel.ts +5 -5
- package/dist/channels/slack/slack-api.d.ts +3 -23
- package/dist/channels/slack/slack-api.js +6 -22
- package/dist/channels/slack/slack.d.ts +13 -20
- package/dist/channels/slack/slack.js +95 -50
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +19 -12
- package/dist/channels/tasks.d.ts +0 -6
- package/dist/channels/tasks.js +16 -1
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +1 -1
- package/dist/channels/text.d.ts +14 -0
- package/dist/channels/text.js +14 -0
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +7 -0
- package/dist/cli/add-feishu.d.ts +7 -4
- package/dist/cli/add-feishu.js +57 -37
- package/dist/cli/add-slack.d.ts +2 -1
- package/dist/cli/add-slack.js +6 -11
- package/dist/cli/commands/add.js +50 -51
- package/dist/cli/commands/attach.js +8 -4
- package/dist/cli/commands/chat.js +8 -8
- package/dist/cli/commands/deploy.d.ts +1 -1
- package/dist/cli/commands/deploy.js +323 -77
- package/dist/cli/commands/dev.js +24 -22
- package/dist/cli/commands/fire.js +15 -16
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +9 -6
- package/dist/cli/commands/login.js +35 -21
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +93 -37
- package/dist/cli/commands/tool.js +22 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +57 -36
- package/dist/cli/serve.d.ts +26 -6
- package/dist/cli/serve.js +62 -15
- package/dist/cli/shared.d.ts +15 -2
- package/dist/cli/shared.js +30 -18
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +1 -1
- package/dist/deploy/docker/plan.js +12 -17
- package/dist/deploy/fly/plan.d.ts +2 -0
- package/dist/deploy/fly/plan.js +27 -19
- package/dist/deploy/fly/run.d.ts +12 -1
- package/dist/deploy/fly/run.js +36 -2
- package/dist/deploy/preflight.d.ts +11 -5
- package/dist/deploy/preflight.js +217 -65
- package/dist/deploy/railway/plan.d.ts +7 -0
- package/dist/deploy/railway/plan.js +41 -16
- package/dist/deploy/railway/run.d.ts +8 -1
- package/dist/deploy/railway/run.js +7 -2
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +9 -3
- package/dist/dev-supervisor.d.ts +11 -8
- package/dist/dev-supervisor.js +53 -51
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +1 -1
- package/dist/engines/pi/channel.js +5 -5
- package/dist/engines/pi/chat.js +2 -2
- package/dist/engines/pi/config.d.ts +6 -46
- package/dist/engines/pi/config.js +21 -108
- package/dist/engines/pi/create.d.ts +8 -8
- package/dist/engines/pi/create.js +13 -12
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/login.d.ts +1 -1
- package/dist/engines/pi/models.d.ts +3 -3
- package/dist/engines/pi/models.js +1 -1
- package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +11 -11
- package/dist/engines/pi/tool.js +4 -0
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +2 -2
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +2 -2
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +7 -3
- package/dist/scaffold/add-channel.js +55 -29
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +1 -1
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -0
- package/dist/schedule/wakeups.js +18 -0
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.js +7 -7
- package/package.json +4 -1
- package/dist/channels/feishu/owned-threads.d.ts +0 -7
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/slack/owned-threads.d.ts +0 -6
- package/dist/channels/slack/owned-threads.js +0 -43
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
|
83
|
-
const baseUrl = opts.apiBaseUrl ??
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 pending — the 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
|
|
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); //
|
|
122
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 = [];
|
|
@@ -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
|
-
/**
|
|
11
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
|
19
|
+
const id = senderId(sender);
|
|
19
20
|
return id ? `user ${id}` : undefined;
|
|
20
21
|
}
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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;
|
|
67
|
-
//
|
|
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
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
|
36
|
-
//
|
|
37
|
-
// @other-only messages
|
|
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
|
-
//
|
|
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.
|
|
61
|
-
"
|
|
62
|
-
"
|
|
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"),
|