@fastagent-sh/fastagent 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +11 -0
- package/dist/channels/feishu/feishu-api.d.ts +4 -2
- package/dist/channels/feishu/feishu.js +39 -9
- package/dist/channels/feishu/invoke-turn.d.ts +8 -2
- package/dist/channels/feishu/invoke-turn.js +150 -31
- package/dist/channels/feishu/parse.js +6 -0
- package/dist/channels/http.js +15 -2
- package/dist/channels/invoke-turn-kit.d.ts +5 -2
- package/dist/channels/invoke-turn-kit.js +6 -2
- package/dist/channels/slack/invoke-turn.js +1 -1
- package/dist/channels/slack/slack.js +1 -5
- package/dist/channels/state.d.ts +0 -10
- package/dist/channels/state.js +2 -19
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/thread-participants.d.ts +7 -0
- package/dist/channels/thread-participants.js +3 -0
- package/dist/cli/commands/deploy.js +13 -5
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +21 -1
- package/dist/cli/commands/invoke.js +1 -1
- package/dist/cli/commands/start.js +1 -1
- package/dist/cli/shared.d.ts +4 -2
- package/dist/cli/shared.js +12 -5
- package/dist/collect.d.ts +30 -4
- package/dist/collect.js +39 -6
- package/dist/deploy/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +21 -3
- package/dist/deploy/secrets.d.ts +3 -0
- package/dist/deploy/secrets.js +6 -0
- package/dist/dev-supervisor.js +8 -2
- package/dist/engines/pi/create.d.ts +2 -1
- package/dist/engines/pi/create.js +12 -7
- package/dist/engines/pi/harness.d.ts +6 -3
- package/dist/engines/pi/harness.js +4 -3
- package/dist/engines/pi/invoke-session.d.ts +32 -0
- package/dist/engines/pi/invoke-session.js +171 -0
- package/dist/engines/pi/invoke.d.ts +6 -27
- package/dist/engines/pi/invoke.js +49 -208
- package/dist/engines/pi/models.d.ts +45 -11
- package/dist/engines/pi/models.js +55 -8
- package/dist/engines/pi/session-builder.js +4 -2
- package/dist/engines/pi/session-control.d.ts +2 -1
- package/dist/engines/pi/sessions.d.ts +17 -1
- package/dist/engines/pi/sessions.js +292 -10
- package/dist/engines/pi/turn-kit.d.ts +56 -0
- package/dist/engines/pi/turn-kit.js +161 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +6 -0
- package/dist/pi.d.ts +3 -2
- package/dist/pi.js +1 -1
- package/dist/scaffold/templates/fastagent.config.mjs +2 -0
- package/dist/session-remote.js +10 -2
- package/package.json +1 -1
package/dist/agent.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export interface Prompt {
|
|
|
19
19
|
export interface Scope {
|
|
20
20
|
/** Opaque session anchor: turns of the same logical conversation MUST reuse the same value. */
|
|
21
21
|
session: string;
|
|
22
|
+
/** EXTENSION (SPEC §8): the session this one branched from — a channel sets it when the place it
|
|
23
|
+
* is invoking for was born out of another place (a thread opened in a room). Read ONLY when
|
|
24
|
+
* `session` does not exist yet: an engine that understands it seeds the NEW session from the
|
|
25
|
+
* parent once, at creation; after that the field is ignored, and an engine that does not
|
|
26
|
+
* understand it ignores it entirely (the session starts empty, yesterday's behavior). */
|
|
27
|
+
parentSession?: string;
|
|
28
|
+
/** EXTENSION (SPEC §8): opaque markers that MAY locate the branch point inside `parentSession` —
|
|
29
|
+
* e.g. platform message ids its transcript embeds. Best-effort by design: the first marker found
|
|
30
|
+
* in the parent's transcript wins, and no match falls back to the parent's present. Meaningless
|
|
31
|
+
* without `parentSession`. */
|
|
32
|
+
branchHints?: string[];
|
|
22
33
|
}
|
|
23
34
|
export type AgentEvent = {
|
|
24
35
|
type: "text";
|
|
@@ -69,11 +69,13 @@ export interface FeishuApi {
|
|
|
69
69
|
*
|
|
70
70
|
* `sender` is typed rather than `unknown` because the referent path READS it: an app-sent message
|
|
71
71
|
* whose id is THIS app's is the agent's own, which the prompt must say instead of attributing it to
|
|
72
|
-
* "user cli_…".
|
|
73
|
-
*
|
|
72
|
+
* "user cli_…". `parent_id` is read by the reply-chain walk (invoke-turn): the message this one
|
|
73
|
+
* itself replied to. The message object carries more (`root_id`, `thread_id`); they stay unnamed
|
|
74
|
+
* until something reads them — this type is the surface in use, not a mirror of the wire. */
|
|
74
75
|
getMessage(messageId: string): Promise<{
|
|
75
76
|
message_id?: string;
|
|
76
77
|
msg_type?: string;
|
|
78
|
+
parent_id?: string;
|
|
77
79
|
body?: {
|
|
78
80
|
content?: string;
|
|
79
81
|
};
|
|
@@ -14,7 +14,7 @@ import { readBodyCapped } from "../body.js";
|
|
|
14
14
|
import { text } from "../respond.js";
|
|
15
15
|
import { createSeenRing } from "../seen.js";
|
|
16
16
|
import { createTaskTracker } from "../tasks.js";
|
|
17
|
-
import { ensureStateHome, loadStateFile,
|
|
17
|
+
import { ensureStateHome, loadStateFile, saveStateFile } from "../state.js";
|
|
18
18
|
import { dispatchStop, isStopText } from "../stop-command.js";
|
|
19
19
|
import { createTurnQueue } from "../turn-queue.js";
|
|
20
20
|
import { createTurnStore } from "../turn-store.js";
|
|
@@ -58,6 +58,8 @@ function isStoredFeishuTurn(t) {
|
|
|
58
58
|
(r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
|
|
59
59
|
(r.replyInThread === undefined || typeof r.replyInThread === "boolean") &&
|
|
60
60
|
(r.parentId === undefined || typeof r.parentId === "string") &&
|
|
61
|
+
(r.parentSession === undefined || typeof r.parentSession === "string") &&
|
|
62
|
+
(r.roomBufferKey === undefined || typeof r.roomBufferKey === "string") &&
|
|
61
63
|
refs(r.images) &&
|
|
62
64
|
refs(r.files) &&
|
|
63
65
|
typeof r.attempts === "number");
|
|
@@ -160,10 +162,6 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
160
162
|
}
|
|
161
163
|
const stateHome = join(stateRoot, "channels", kind);
|
|
162
164
|
ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
|
|
163
|
-
// The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
|
|
164
|
-
// after the release following the participant model ships — by then no live deployment can still
|
|
165
|
-
// be carrying the file. test/migration-deadline.test.ts fails when due.
|
|
166
|
-
removeRetiredStateFile(stateHome, "owned-threads.json", label);
|
|
167
165
|
// The cached bot identity (rationale at the botInfo block above): seed synchronously — the
|
|
168
166
|
// factory runs to completion before any promise resolves, so botOpenId is still unset here and
|
|
169
167
|
// the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
|
|
@@ -309,13 +307,31 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
309
307
|
// this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
|
|
310
308
|
// would instead couple otherwise-independent root sessions and require failure rollback.
|
|
311
309
|
const { text: recent, consumed } = buffer.peek(rec.bufferKey);
|
|
312
|
-
|
|
313
|
-
const
|
|
310
|
+
// PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
|
|
311
|
+
const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
|
|
312
|
+
const roomBlock = room?.text
|
|
313
|
+
? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
|
|
314
|
+
: "";
|
|
315
|
+
const threadBlock = recent ? `[recent group discussion:\n${recent}\n]\n\n` : "";
|
|
316
|
+
const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
|
|
317
|
+
// Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
|
|
318
|
+
// attachments win the slots.
|
|
319
|
+
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
|
|
314
320
|
images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
|
|
315
321
|
files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
|
|
316
322
|
});
|
|
323
|
+
// Recorded at ingress (see submit) — never re-derived from the session key, which may be a
|
|
324
|
+
// routed OPAQUE id that only looks like a place key.
|
|
325
|
+
const parentSession = rec.parentSession;
|
|
317
326
|
try {
|
|
318
|
-
await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
|
|
327
|
+
await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
|
|
328
|
+
api,
|
|
329
|
+
chatId: rec.chatId,
|
|
330
|
+
filesDir: join(stateHome, "files"),
|
|
331
|
+
label,
|
|
332
|
+
appId,
|
|
333
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
334
|
+
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
|
|
319
335
|
// Drop intent first: a crash between these writes may re-fold answered context later,
|
|
320
336
|
// but can never replay this turn after its context was removed.
|
|
321
337
|
store.remove(rec.id);
|
|
@@ -465,7 +481,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
465
481
|
// Memory follows the place (participant model §5): one session per chat, and one per thread.
|
|
466
482
|
// Keyed by `thread_id`, never `root_id` — the platform's root_id tracks the reply chain and can
|
|
467
483
|
// differ between messages of ONE thread, which would split a side conversation in two.
|
|
468
|
-
const
|
|
484
|
+
const routed = r.session;
|
|
485
|
+
const session = routed ?? placeKey(kind, m);
|
|
486
|
+
// Lineage is recorded ONLY for the default place-derived session. A routed session id is
|
|
487
|
+
// OPAQUE (the route contract), and re-parsing it as a place key would let a three-segment id
|
|
488
|
+
// like "tenant:user:alice" masquerade as a thread and inherit from "tenant:user" — a
|
|
489
|
+
// cross-session injection. Derived from the MESSAGE (the fact this channel owns), at record
|
|
490
|
+
// time, where routed-ness is still known; the dequeue path only reads it back.
|
|
491
|
+
const parentSession = routed === undefined && m.thread_id !== undefined ? placeKey(kind, { chat_id: m.chat_id }) : undefined;
|
|
492
|
+
// Read BEFORE this turn records its own participation below, or it is always true. Keyed by the
|
|
493
|
+
// SOURCE chat, never the answer target a route may name (§8).
|
|
494
|
+
const roomBufferKey = parentSession !== undefined && !threadParticipants.agentSpokeIn(session)
|
|
495
|
+
? feishuBufferPlaceKey({ chatId: m.chat_id })
|
|
496
|
+
: undefined;
|
|
469
497
|
const chatId = r.chatId ?? m.chat_id;
|
|
470
498
|
const sameTarget = chatId === m.chat_id;
|
|
471
499
|
// Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
|
|
@@ -517,6 +545,8 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
517
545
|
// already have; it also pins WHICH message is being answered, which a long thread benefits
|
|
518
546
|
// from anyway.
|
|
519
547
|
parentId: m.parent_id,
|
|
548
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
549
|
+
...(roomBufferKey !== undefined ? { roomBufferKey } : {}),
|
|
520
550
|
images,
|
|
521
551
|
files,
|
|
522
552
|
}, true);
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
|
|
10
10
|
* any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
|
|
11
|
-
* BUFFERED resources come from earlier un-summoned thread/group discussion and
|
|
12
|
-
* one expired background file must not block the current ask
|
|
11
|
+
* BUFFERED resources come from earlier un-summoned thread/group discussion and from reply-chain
|
|
12
|
+
* ancestors, and degrade per attachment: one expired background file must not block the current ask
|
|
13
|
+
* or hide its still-readable siblings.
|
|
13
14
|
*/
|
|
14
15
|
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
15
16
|
import { type BusyRetry } from "../invoke-turn-kit.ts";
|
|
@@ -25,6 +26,11 @@ export interface FeishuTurnTransport {
|
|
|
25
26
|
* sender is an app. Needed to tell the agent's OWN messages from any other bot's in the same chat:
|
|
26
27
|
* `sender_type` alone says "some app", which is not the question the referent path asks. */
|
|
27
28
|
appId: string;
|
|
29
|
+
/** The place this thread branched from (the chat's main place), when the turn runs in a thread —
|
|
30
|
+
* rides the Scope's lineage extension so a NEW thread session starts from what the room knew
|
|
31
|
+
* (participant-model.md §5). The engine reads it once, at session creation; every later turn
|
|
32
|
+
* carries it inertly. */
|
|
33
|
+
parentSession?: string;
|
|
28
34
|
}
|
|
29
35
|
/** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
|
|
30
36
|
* bytes by message_id + key, so the pair travels together through the turn record). */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { log } from "../../log.js";
|
|
2
2
|
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
|
|
3
|
+
import { BUFFER_ATTACH_MAX } from "../context-buffer.js";
|
|
3
4
|
import { parseContent } from "./parse.js";
|
|
4
5
|
import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
|
|
5
6
|
/** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
|
|
@@ -10,14 +11,126 @@ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
|
|
|
10
11
|
const REPLY_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card. This reply is " +
|
|
11
12
|
"delivered to the current chat by the channel itself: do not call a send tool to answer the " +
|
|
12
13
|
"current chat.)";
|
|
14
|
+
/** How far up a reply chain the walk reads, beyond the replied-to message itself. The chain's natural
|
|
15
|
+
* end is its ROOT — the platform threads every reply back to one — so this is an IO guard, not a
|
|
16
|
+
* semantic boundary: each ancestor costs one serial `getMessage`, and a pathological chain must not
|
|
17
|
+
* stall the turn. Field chains are 1–3 long; a capped walk says so in the block. */
|
|
18
|
+
const MAX_CHAIN_ANCESTORS = 8;
|
|
19
|
+
/** Attribution for a FETCHED message. getMessage's sender is `{ id, id_type, sender_type }` — a
|
|
20
|
+
* DIFFERENT shape from the event's sender (`{ sender_id: { open_id } }`) — so the label is built
|
|
21
|
+
* here, not via parse.senderLabel.
|
|
22
|
+
*
|
|
23
|
+
* OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"` is
|
|
24
|
+
* true for every one of them — matching on it alone would tell the model it wrote another bot's
|
|
25
|
+
* message. The identity to compare is the app id, because an app sender carries `id_type: "app_id"`:
|
|
26
|
+
* the cached bot open_id answers a different question (who was @mentioned) and would never match
|
|
27
|
+
* here. A missing or unexpected id fails CLOSED — labelled by id, never claimed as the agent's own.
|
|
28
|
+
* And an app is not a person: labelling another bot's message "user cli_…" is the same
|
|
29
|
+
* misattribution in a quieter form, so the noun follows the sender type. */
|
|
30
|
+
function fetchedSenderLabel(sender, appId) {
|
|
31
|
+
const appSender = sender?.sender_type === "app";
|
|
32
|
+
const senderId = sender?.id;
|
|
33
|
+
if (appSender && senderId === appId)
|
|
34
|
+
return "you, the agent";
|
|
35
|
+
return senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Walk the reply chain ABOVE the replied-to message, to its root. Quoting a reply points at one link
|
|
39
|
+
* of an exchange; the pointer is only fully resolved when the model can read what that link was
|
|
40
|
+
* replying to — all the way up, because the platform defines where the chain ends (its root), which
|
|
41
|
+
* is what makes the walk bounded by STRUCTURE rather than by a level count someone picked.
|
|
42
|
+
*
|
|
43
|
+
* This is pointer resolution, not history. Session memory — what this place already knows — is a
|
|
44
|
+
* different track (design/participant-model.md §8): a one-hop version of this walk was removed once
|
|
45
|
+
* for trying to be that substitute; it returns doing only the pointer's job, which is also why it
|
|
46
|
+
* walks through ANY author's message — the chain is the platform's structure, not a conversation the
|
|
47
|
+
* agent took part in. The repetition this implies (an established session re-reads chain text it may
|
|
48
|
+
* already hold, each reply turn) is accepted deliberately and bounded: ancestors are CONTEXT, not
|
|
49
|
+
* the ask, so their text shares ONE further `REFERENT_MAX_CODE_POINTS` budget across the whole chain
|
|
50
|
+
* — the walk costs at most one more referent — while the pointed-at referent keeps its own full
|
|
51
|
+
* fidelity bound.
|
|
52
|
+
*
|
|
53
|
+
* Fail-open at every edge, but never silently at the model: any walk that ends short of the root —
|
|
54
|
+
* the ancestor cap, an exhausted text budget, an unreadable ancestor, a cycle — leaves the same
|
|
55
|
+
* neutral truncation line at the top of the block, because a chain rendered without it READS as
|
|
56
|
+
* complete and the model would take the oldest fetched node for the original ask. Unreadable
|
|
57
|
+
* ancestors and cycles also warn the operator; a cycle is corrupt platform data (reply chains are
|
|
58
|
+
* temporally acyclic by construction — a reply can only point at an EARLIER message — so one firing
|
|
59
|
+
* means the data, not the walk, is wrong).
|
|
60
|
+
*/
|
|
61
|
+
async function walkReplyChain(t, start, visited) {
|
|
62
|
+
const nodes = [];
|
|
63
|
+
const images = [];
|
|
64
|
+
const files = [];
|
|
65
|
+
// No parent above the referent = no chain — not a truncated one. The marker below is only for
|
|
66
|
+
// walks that END SHORT of a root that exists.
|
|
67
|
+
if (start === undefined)
|
|
68
|
+
return { block: "", images, files, ids: [] };
|
|
69
|
+
let reachedRoot = false;
|
|
70
|
+
let textBudget = REFERENT_MAX_CODE_POINTS;
|
|
71
|
+
let next = start;
|
|
72
|
+
while (next !== undefined) {
|
|
73
|
+
if (visited.has(next)) {
|
|
74
|
+
log.warn(`${t.label} reply chain points back to already-visited message ${next} — corrupt platform data; the walk ends here`);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (nodes.length >= MAX_CHAIN_ANCESTORS || textBudget <= 0)
|
|
78
|
+
break;
|
|
79
|
+
// The annotation breaks a control-flow-analysis cycle (id → msg → next → id) that trips TS7022.
|
|
80
|
+
const id = next;
|
|
81
|
+
visited.add(id);
|
|
82
|
+
let failure;
|
|
83
|
+
const msg = await t.api.getMessage(id).catch((error) => {
|
|
84
|
+
failure = String(error);
|
|
85
|
+
return undefined;
|
|
86
|
+
});
|
|
87
|
+
if (!msg) {
|
|
88
|
+
log.warn(`${t.label} could not read reply-chain message ${id} (${failure ?? "no such message"}) — the chain is rendered up to it`);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const parsed = parseContent({
|
|
92
|
+
message_type: msg.msg_type ?? "unknown",
|
|
93
|
+
content: msg.body?.content ?? "",
|
|
94
|
+
mentions: msg.mentions,
|
|
95
|
+
});
|
|
96
|
+
const label = fetchedSenderLabel(msg.sender, t.appId);
|
|
97
|
+
const from = label ?? "reply chain";
|
|
98
|
+
for (const key of parsed.imageKeys)
|
|
99
|
+
images.push({ messageId: id, key, from });
|
|
100
|
+
for (const ref of parsed.fileRefs)
|
|
101
|
+
files.push({ messageId: id, key: ref.key, name: ref.name, from });
|
|
102
|
+
const text = truncateCodePointPrefix(parsed.text, textBudget) || "(empty)";
|
|
103
|
+
textBudget -= [...text].length;
|
|
104
|
+
nodes.push({ id, label, text });
|
|
105
|
+
if (msg.parent_id === undefined)
|
|
106
|
+
reachedRoot = true;
|
|
107
|
+
next = msg.parent_id;
|
|
108
|
+
}
|
|
109
|
+
nodes.reverse(); // fetched leaf→root; rendered oldest first, the way a transcript reads
|
|
110
|
+
const lines = nodes.map((node) => `(msg ${node.id}${node.label ? `, from ${node.label}` : ""}): ${node.text}`);
|
|
111
|
+
// One line for every way of ending short of the root — cap, budget, unreadable, cycle. It names no
|
|
112
|
+
// cause on purpose: the model needs the SHAPE (there is more above), the operator log has the why.
|
|
113
|
+
if (!reachedRoot)
|
|
114
|
+
lines.unshift("(…the chain continues above this point)");
|
|
115
|
+
return {
|
|
116
|
+
block: `\n[reply chain above it, oldest first:\n${lines.join("\n")}]`,
|
|
117
|
+
images,
|
|
118
|
+
files,
|
|
119
|
+
// Walked (fetched) order = nearest first — nodes were reversed for RENDERING above, so read the
|
|
120
|
+
// hint order off the rendered list backwards.
|
|
121
|
+
ids: nodes.map((node) => node.id).reverse(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
13
124
|
/**
|
|
14
|
-
* Resolve a turn's inputs (module header): fetch the reply referent's content
|
|
15
|
-
* (vision) and file (disk). Primary failures throw; buffered resources
|
|
125
|
+
* Resolve a turn's inputs (module header): fetch the reply referent's content and resolve its reply
|
|
126
|
+
* chain, then load every image (vision) and file (disk). Primary failures throw; buffered resources
|
|
127
|
+
* degrade independently.
|
|
16
128
|
*/
|
|
17
129
|
async function resolveTurnInputs(t, attachments) {
|
|
18
130
|
const images = [...attachments.primary.images];
|
|
19
131
|
const files = [...attachments.primary.files];
|
|
20
132
|
let referentBlock = "";
|
|
133
|
+
let chain = { block: "", images: [], files: [], ids: [] };
|
|
21
134
|
if (attachments.primary.parentId !== undefined) {
|
|
22
135
|
const parentId = attachments.primary.parentId;
|
|
23
136
|
// A referent is CONTEXT, not the ask. Losing it (deleted, restricted, unreadable) must not cost
|
|
@@ -49,32 +162,11 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
49
162
|
images.push({ msg: parentId, key });
|
|
50
163
|
for (const ref of parsed.fileRefs)
|
|
51
164
|
files.push({ msg: parentId, key: ref.key, name: ref.name });
|
|
52
|
-
|
|
53
|
-
// sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
|
|
54
|
-
//
|
|
55
|
-
// OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"`
|
|
56
|
-
// is true for every one of them — matching on it alone would tell the model it wrote another
|
|
57
|
-
// bot's message. The identity to compare is the app id, because an app sender carries
|
|
58
|
-
// `id_type: "app_id"`: the cached bot open_id answers a different question (who was @mentioned)
|
|
59
|
-
// and would never match here. A missing or unexpected id fails CLOSED — labelled by id, never
|
|
60
|
-
// claimed as the agent's own.
|
|
61
|
-
const appSender = parent.sender?.sender_type === "app";
|
|
62
|
-
const senderId = parent.sender?.id;
|
|
63
|
-
const ownMessage = appSender && senderId === t.appId;
|
|
64
|
-
// An app is not a person: labelling another bot's message "user cli_…" is the same misattribution
|
|
65
|
-
// in a quieter form, so the noun follows the sender type.
|
|
66
|
-
const from = ownMessage ? "you, the agent" : senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
|
|
165
|
+
const from = fetchedSenderLabel(parent.sender, t.appId);
|
|
67
166
|
referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
|
|
68
|
-
// The
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// many levels? what about the level above that? how is it deduplicated against what the session
|
|
72
|
-
// already holds? how does an IMAGE two levels up become prompt text at all?), and every one of
|
|
73
|
-
// those questions is a symptom of solving a session-layer problem in the prompt layer. The real
|
|
74
|
-
// gap it was papering over — a thread opened on a room answer starts with an EMPTY session while
|
|
75
|
-
// the room's session holds the exchange — belongs to memory inheritance (design/participant-
|
|
76
|
-
// model.md §8, rungs 3-4), where images and tool results come along for free because they are
|
|
77
|
-
// already in the history rather than being re-serialised into a prompt string.
|
|
167
|
+
// The referent's own parent starts the chain walk; the referent id seeds the cycle guard.
|
|
168
|
+
chain = await walkReplyChain(t, parent.parent_id, new Set([parentId]));
|
|
169
|
+
referentBlock += chain.block;
|
|
78
170
|
}
|
|
79
171
|
}
|
|
80
172
|
// Primary first and fail-fast: these are resources the current user explicitly pointed at.
|
|
@@ -88,8 +180,28 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
88
180
|
// downloaded twice or rendered twice in the manifest.
|
|
89
181
|
const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
|
|
90
182
|
const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
|
|
91
|
-
|
|
92
|
-
|
|
183
|
+
// Chain ancestors and the context buffer share ONE background budget: BUFFER_ATTACH_MAX per kind.
|
|
184
|
+
// The cap is part of the tier's meaning, not an accident of who collected the ref — a rich-text
|
|
185
|
+
// ancestor must not turn the walk into an unbounded fan-out of downloads. Chain refs take slots
|
|
186
|
+
// FIRST: they are the direct upstream of the message the user pointed at, buffer refs are ambient
|
|
187
|
+
// discussion. Duplicates (a chain that points back into still-buffered discussion) count once, and
|
|
188
|
+
// what the cap drops is counted into the missing-attachments note like every other unloaded ref.
|
|
189
|
+
const capMerge = (chainRefs, bufferRefs, primary) => {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const merged = [];
|
|
192
|
+
for (const ref of [...chainRefs, ...bufferRefs]) {
|
|
193
|
+
const identity = `${ref.messageId}\u0000${ref.key}`;
|
|
194
|
+
if (primary.has(identity) || seen.has(identity))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(identity);
|
|
197
|
+
merged.push(ref);
|
|
198
|
+
}
|
|
199
|
+
return { kept: merged.slice(0, BUFFER_ATTACH_MAX), dropped: Math.max(0, merged.length - BUFFER_ATTACH_MAX) };
|
|
200
|
+
};
|
|
201
|
+
const mergedImages = capMerge(chain.images, attachments.buffered.images, primaryImages);
|
|
202
|
+
const mergedFiles = capMerge(chain.files, attachments.buffered.files, primaryFiles);
|
|
203
|
+
const bufferedImages = mergedImages.kept;
|
|
204
|
+
const bufferedFiles = mergedFiles.kept;
|
|
93
205
|
const backgroundImages = [];
|
|
94
206
|
const backgroundFiles = [];
|
|
95
207
|
let lost = 0;
|
|
@@ -114,7 +226,7 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
114
226
|
log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
|
|
115
227
|
}
|
|
116
228
|
}
|
|
117
|
-
const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
|
|
229
|
+
const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped + mergedImages.dropped + mergedFiles.dropped);
|
|
118
230
|
const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
|
|
119
231
|
const allFiles = [
|
|
120
232
|
...downloaded,
|
|
@@ -127,6 +239,7 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
127
239
|
return {
|
|
128
240
|
images: allImages.length ? allImages : undefined,
|
|
129
241
|
promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
|
|
242
|
+
referentIds: [...(attachments.primary.parentId !== undefined ? [attachments.primary.parentId] : []), ...chain.ids],
|
|
130
243
|
};
|
|
131
244
|
}
|
|
132
245
|
/**
|
|
@@ -144,5 +257,11 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
|
|
|
144
257
|
return;
|
|
145
258
|
}
|
|
146
259
|
const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
|
|
147
|
-
|
|
260
|
+
// A thread turn names its lineage: parent place + the message ids that can locate the branch point
|
|
261
|
+
// (the referent and its chain — nearest first). The engine reads them ONCE, when the thread's
|
|
262
|
+
// session does not exist yet; on every later turn they ride along inertly.
|
|
263
|
+
const scope = transport.parentSession === undefined
|
|
264
|
+
? { session }
|
|
265
|
+
: { session, parentSession: transport.parentSession, branchHints: resolved.referentIds };
|
|
266
|
+
yield* streamTurnWithBusyRetry(agent, scope, prompt, { label: transport.label, onCompleted, busyRetry });
|
|
148
267
|
}
|
|
@@ -52,6 +52,12 @@ export function cloudEnvelope(event, tag) {
|
|
|
52
52
|
`chat ${message.chat_id} (${message.chat_type})`,
|
|
53
53
|
message.thread_id ? `topic ${message.thread_id}` : undefined,
|
|
54
54
|
from ? `from ${from}` : undefined,
|
|
55
|
+
// The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
|
|
56
|
+
// enters the session transcript, and session inheritance locates a thread's branch point by
|
|
57
|
+
// searching the parent transcript for exactly these ids (scope.branchHints — sessions.ts).
|
|
58
|
+
// Remove it and every thread quietly inherits from the room's present instead of the branch
|
|
59
|
+
// point. It also lets the model name what it is answering in a busy chat.
|
|
60
|
+
`msg ${message.message_id}`,
|
|
55
61
|
]
|
|
56
62
|
.filter(Boolean)
|
|
57
63
|
.join(", ");
|
package/dist/channels/http.js
CHANGED
|
@@ -56,14 +56,27 @@ export function createInvokeHandler(agent) {
|
|
|
56
56
|
catch {
|
|
57
57
|
return text("invalid json\n", 400);
|
|
58
58
|
}
|
|
59
|
-
const { session, text: promptText } = (payload ?? {});
|
|
59
|
+
const { session, text: promptText, parentSession, branchHints, } = (payload ?? {});
|
|
60
60
|
if (typeof session !== "string" || typeof promptText !== "string") {
|
|
61
61
|
return text('need { "session": string, "text": string }\n', 400);
|
|
62
62
|
}
|
|
63
63
|
// ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
|
|
64
|
+
// The OPTIONAL lineage extension (Scope): malformed values are a 400, not a silent drop — a
|
|
65
|
+
// caller that sent them meant them.
|
|
66
|
+
if (parentSession !== undefined && typeof parentSession !== "string") {
|
|
67
|
+
return text('"parentSession" must be a string\n', 400);
|
|
68
|
+
}
|
|
69
|
+
if (branchHints !== undefined && !(Array.isArray(branchHints) && branchHints.every((h) => typeof h === "string"))) {
|
|
70
|
+
return text('"branchHints" must be an array of strings\n', 400);
|
|
71
|
+
}
|
|
64
72
|
// Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
|
|
65
73
|
// run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
|
|
66
|
-
const iterator = agent
|
|
74
|
+
const iterator = agent
|
|
75
|
+
.invoke({
|
|
76
|
+
session,
|
|
77
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
78
|
+
...(branchHints !== undefined ? { branchHints } : {}),
|
|
79
|
+
}, { text: promptText })[Symbol.asyncIterator]();
|
|
67
80
|
// Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
|
|
68
81
|
// distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
|
|
69
82
|
// look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
|
|
11
11
|
* message-scoped Feishu keys, Slack file objects) are real differences.
|
|
12
12
|
*/
|
|
13
|
-
import { type Agent, type AgentEvent, type Prompt } from "../agent.ts";
|
|
13
|
+
import { type Agent, type AgentEvent, type Prompt, type Scope } from "../agent.ts";
|
|
14
14
|
/** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
|
|
15
15
|
* EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
|
|
16
16
|
* channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
|
|
@@ -35,7 +35,10 @@ export declare const DEFAULT_BUSY_RETRY: BusyRetry;
|
|
|
35
35
|
* busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
|
|
36
36
|
* is ever re-run.
|
|
37
37
|
*/
|
|
38
|
-
export declare function streamTurnWithBusyRetry(agent: Agent,
|
|
38
|
+
export declare function streamTurnWithBusyRetry(agent: Agent,
|
|
39
|
+
/** The full scope, not a session string — channels that set extension fields (lineage) pass them
|
|
40
|
+
* through here; channels that don't pass `{ session }` and nothing changes. */
|
|
41
|
+
scope: Scope, prompt: Prompt, options: {
|
|
39
42
|
label: string;
|
|
40
43
|
onCompleted?: () => void;
|
|
41
44
|
busyRetry?: BusyRetry;
|
|
@@ -33,13 +33,17 @@ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
|
33
33
|
* busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
|
|
34
34
|
* is ever re-run.
|
|
35
35
|
*/
|
|
36
|
-
export async function* streamTurnWithBusyRetry(agent,
|
|
36
|
+
export async function* streamTurnWithBusyRetry(agent,
|
|
37
|
+
/** The full scope, not a session string — channels that set extension fields (lineage) pass them
|
|
38
|
+
* through here; channels that don't pass `{ session }` and nothing changes. */
|
|
39
|
+
scope, prompt, options) {
|
|
37
40
|
const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
|
|
41
|
+
const session = scope.session;
|
|
38
42
|
const deadline = Date.now() + busyRetry.maxWaitMs;
|
|
39
43
|
for (;;) {
|
|
40
44
|
let retryBusy = false;
|
|
41
45
|
let first = true;
|
|
42
|
-
for await (const e of agent.invoke(
|
|
46
|
+
for await (const e of agent.invoke(scope, prompt)) {
|
|
43
47
|
if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
|
|
44
48
|
retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
|
|
45
49
|
break;
|
|
@@ -59,5 +59,5 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
|
|
62
|
-
yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
|
|
62
|
+
yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: transport.label, onCompleted, busyRetry });
|
|
63
63
|
}
|
|
@@ -7,7 +7,7 @@ import { text } from "../respond.js";
|
|
|
7
7
|
import { createSeenRing } from "../seen.js";
|
|
8
8
|
import { createThreadParticipants } from "../thread-participants.js";
|
|
9
9
|
import { createTaskTracker } from "../tasks.js";
|
|
10
|
-
import { ensureStateHome
|
|
10
|
+
import { ensureStateHome } from "../state.js";
|
|
11
11
|
import { dispatchStop, isStopText } from "../stop-command.js";
|
|
12
12
|
import { codePointPrefix } from "../text.js";
|
|
13
13
|
import { createTurnQueue } from "../turn-queue.js";
|
|
@@ -141,10 +141,6 @@ export function slackChannel(options) {
|
|
|
141
141
|
* under a custom route is the `routed.session === undefined` condition on the write, not the
|
|
142
142
|
* absence of a route: a route that supplies its own session records nothing here. */
|
|
143
143
|
const threadKey = (teamId, channelId, threadTs) => `slack:${teamId}:${channelId}:${threadTs}`;
|
|
144
|
-
// The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
|
|
145
|
-
// after the release following the participant model ships — by then no live deployment can still
|
|
146
|
-
// be carrying the file. test/migration-deadline.test.ts fails when due.
|
|
147
|
-
removeRetiredStateFile(stateHome, "owned-threads.json", label);
|
|
148
144
|
const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
|
|
149
145
|
const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
|
|
150
146
|
const store = createTurnStore(join(stateHome, "turns.json"), {
|
package/dist/channels/state.d.ts
CHANGED
|
@@ -4,13 +4,3 @@ export declare function ensureStateHome(dir: string): void;
|
|
|
4
4
|
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
5
5
|
export declare function loadStateFile(path: string): unknown;
|
|
6
6
|
export declare function saveStateFile(path: string, value: unknown): void;
|
|
7
|
-
/**
|
|
8
|
-
* Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
|
|
9
|
-
* so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
|
|
10
|
-
* whose loss changes behaviour needs a migration, not a delete.
|
|
11
|
-
*
|
|
12
|
-
* Shared because a retired file is usually retired in every channel at once: one best-effort
|
|
13
|
-
* semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
|
|
14
|
-
* here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
|
|
15
|
-
*/
|
|
16
|
-
export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
|
package/dist/channels/state.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
|
|
13
13
|
* with silently-empty state would hide real data behind a config mistake.
|
|
14
14
|
*/
|
|
15
|
-
import { mkdirSync, readFileSync, renameSync,
|
|
16
|
-
import { dirname
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
17
|
import { log } from "../log.js";
|
|
18
18
|
/** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
|
|
19
19
|
export function ensureStateHome(dir) {
|
|
@@ -48,20 +48,3 @@ export function saveStateFile(path, value) {
|
|
|
48
48
|
writeFileSync(tmp, JSON.stringify(value));
|
|
49
49
|
renameSync(tmp, path);
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
|
|
53
|
-
* so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
|
|
54
|
-
* whose loss changes behaviour needs a migration, not a delete.
|
|
55
|
-
*
|
|
56
|
-
* Shared because a retired file is usually retired in every channel at once: one best-effort
|
|
57
|
-
* semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
|
|
58
|
-
* here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
|
|
59
|
-
*/
|
|
60
|
-
export function removeRetiredStateFile(stateHome, name, label) {
|
|
61
|
-
try {
|
|
62
|
-
rmSync(join(stateHome, name), { force: true });
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
@@ -76,5 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
|
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
|
|
79
|
-
yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
|
|
79
|
+
yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: "[telegram]", onCompleted, busyRetry });
|
|
80
80
|
}
|
|
@@ -6,6 +6,13 @@ export interface ThreadParticipants {
|
|
|
6
6
|
* get wrong twice, and "a second human restores the mention requirement" must have one place to change.
|
|
7
7
|
*/
|
|
8
8
|
admitsBareMessage(key: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Has the agent answered into this thread before — the "first answered turn" fact
|
|
11
|
+
* (participant-model.md §8), unlike {@link ThreadParticipants.admitsBareMessage} which also weighs
|
|
12
|
+
* the second-human rule. An evicted record answers false (this store is a cache — see the header),
|
|
13
|
+
* so gate a repeatable read on it, never a durable claim.
|
|
14
|
+
*/
|
|
15
|
+
agentSpokeIn(key: string): boolean;
|
|
9
16
|
/**
|
|
10
17
|
* Merge in what was just heard. Idempotent; a failed write is a warning, never a failed delivery.
|
|
11
18
|
*
|
|
@@ -68,6 +68,9 @@ export function createThreadParticipants(path, label) {
|
|
|
68
68
|
const heard = records.get(key);
|
|
69
69
|
return heard?.agentSpoke === true && heard.humans.length <= 1;
|
|
70
70
|
},
|
|
71
|
+
agentSpokeIn(key) {
|
|
72
|
+
return records.get(key)?.agentSpoke === true;
|
|
73
|
+
},
|
|
71
74
|
merge(key, heard) {
|
|
72
75
|
const previous = records.get(key);
|
|
73
76
|
const humans = new Set(previous?.humans ?? []);
|