@fastagent-sh/fastagent 0.17.1 → 0.19.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 +25 -7
- package/dist/agent.d.ts +12 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +19 -0
- package/dist/atomic-write.js +48 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-service.d.ts +54 -0
- package/dist/channels/agentcore-service.js +111 -0
- package/dist/channels/agentcore.d.ts +12 -2
- package/dist/channels/agentcore.js +6 -4
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +16 -6
- package/dist/channels/control.js +106 -4
- package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
- package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.d.ts +4 -2
- package/dist/channels/feishu/feishu-api.js +1 -1
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +45 -15
- package/dist/channels/feishu/invoke-turn.d.ts +9 -3
- package/dist/channels/feishu/invoke-turn.js +152 -34
- package/dist/channels/feishu/parse.js +6 -0
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +16 -111
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/kit/state.d.ts +6 -0
- package/dist/channels/{state.js → kit/state.js} +4 -24
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
- package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
- package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +9 -6
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/slack-api.js +1 -1
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +8 -12
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +2 -2
- package/dist/channels/telegram/parse.js +2 -2
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/telegram.d.ts +1 -1
- package/dist/channels/telegram/telegram.js +3 -3
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/commands/add.js +1 -1
- package/dist/cli/commands/deploy.js +13 -5
- package/dist/cli/commands/dev.js +26 -14
- package/dist/cli/commands/fire.js +2 -2
- package/dist/cli/commands/info.js +28 -5
- package/dist/cli/commands/invoke.js +1 -1
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +64 -95
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +18 -57
- package/dist/cli/serve.js +57 -243
- package/dist/cli/shared.d.ts +5 -3
- package/dist/cli/shared.js +14 -7
- package/dist/collect.d.ts +30 -4
- package/dist/collect.js +39 -6
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +22 -4
- package/dist/deploy/secrets.d.ts +3 -0
- package/dist/deploy/secrets.js +6 -0
- package/dist/dev-supervisor.js +16 -4
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +314 -0
- package/dist/engines/pi/config.d.ts +4 -4
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +56 -42
- package/dist/engines/pi/create.js +150 -75
- package/dist/engines/pi/definition.d.ts +41 -7
- package/dist/engines/pi/definition.js +115 -11
- package/dist/engines/pi/invoke-session.d.ts +35 -0
- package/dist/engines/pi/invoke-session.js +357 -0
- package/dist/engines/pi/login.js +7 -4
- package/dist/engines/pi/models.d.ts +59 -11
- package/dist/engines/pi/models.js +64 -9
- package/dist/engines/pi/open.d.ts +9 -6
- package/dist/engines/pi/open.js +20 -10
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +19 -0
- package/dist/engines/pi/session-builder.js +88 -90
- package/dist/engines/pi/session-control.d.ts +11 -9
- package/dist/engines/pi/session-control.js +114 -77
- package/dist/engines/pi/session-inheritance.d.ts +44 -0
- package/dist/engines/pi/session-inheritance.js +242 -0
- package/dist/engines/pi/session-settings.d.ts +14 -2
- package/dist/engines/pi/session-settings.js +18 -0
- package/dist/engines/pi/session-store.d.ts +70 -0
- package/dist/engines/pi/session-store.js +313 -0
- package/dist/engines/pi/tool-context.d.ts +18 -8
- package/dist/engines/pi/tool-context.js +23 -1
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +1 -1
- package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
- package/dist/engines/pi/turn-kit.js +188 -0
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +16 -0
- package/dist/pi.d.ts +15 -8
- package/dist/pi.js +7 -6
- package/dist/scaffold/templates/fastagent.config.mjs +2 -0
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +1 -1
- package/dist/service.d.ts +158 -0
- package/dist/service.js +354 -0
- package/dist/session-remote.d.ts +1 -1
- package/dist/session-remote.js +10 -2
- package/dist/session.d.ts +4 -2
- package/dist/session.js +1 -1
- package/package.json +14 -5
- package/dist/channels/state.d.ts +0 -16
- package/dist/engines/pi/harness.d.ts +0 -112
- package/dist/engines/pi/harness.js +0 -153
- package/dist/engines/pi/invoke.js +0 -618
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -44
- package/dist/engines/pi/sessions.js +0 -182
- package/dist/host/node.js +0 -52
- /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
- /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { log } from "../../log.js";
|
|
2
|
-
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
|
|
2
|
+
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
|
|
3
|
+
import { BUFFER_ATTACH_MAX } from "../kit/context-buffer.js";
|
|
3
4
|
import { parseContent } from "./parse.js";
|
|
4
|
-
import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
|
|
5
|
+
import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../kit/text.js";
|
|
5
6
|
/** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
|
|
6
7
|
* concept — what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
|
|
7
8
|
* natural fit for LLM output — steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
|
|
@@ -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,35 +162,13 @@ 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
|
-
// Primary first and fail-fast: these are resources the current user explicitly pointed at.
|
|
81
172
|
const imageRefs = [];
|
|
82
173
|
for (const ref of images)
|
|
83
174
|
imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
|
|
@@ -88,8 +179,28 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
88
179
|
// downloaded twice or rendered twice in the manifest.
|
|
89
180
|
const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
|
|
90
181
|
const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
|
|
91
|
-
|
|
92
|
-
|
|
182
|
+
// Chain ancestors and the context buffer share ONE background budget: BUFFER_ATTACH_MAX per kind.
|
|
183
|
+
// The cap is part of the tier's meaning, not an accident of who collected the ref — a rich-text
|
|
184
|
+
// ancestor must not turn the walk into an unbounded fan-out of downloads. Chain refs take slots
|
|
185
|
+
// FIRST: they are the direct upstream of the message the user pointed at, buffer refs are ambient
|
|
186
|
+
// discussion. Duplicates (a chain that points back into still-buffered discussion) count once, and
|
|
187
|
+
// what the cap drops is counted into the missing-attachments note like every other unloaded ref.
|
|
188
|
+
const capMerge = (chainRefs, bufferRefs, primary) => {
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
const merged = [];
|
|
191
|
+
for (const ref of [...chainRefs, ...bufferRefs]) {
|
|
192
|
+
const identity = `${ref.messageId}\u0000${ref.key}`;
|
|
193
|
+
if (primary.has(identity) || seen.has(identity))
|
|
194
|
+
continue;
|
|
195
|
+
seen.add(identity);
|
|
196
|
+
merged.push(ref);
|
|
197
|
+
}
|
|
198
|
+
return { kept: merged.slice(0, BUFFER_ATTACH_MAX), dropped: Math.max(0, merged.length - BUFFER_ATTACH_MAX) };
|
|
199
|
+
};
|
|
200
|
+
const mergedImages = capMerge(chain.images, attachments.buffered.images, primaryImages);
|
|
201
|
+
const mergedFiles = capMerge(chain.files, attachments.buffered.files, primaryFiles);
|
|
202
|
+
const bufferedImages = mergedImages.kept;
|
|
203
|
+
const bufferedFiles = mergedFiles.kept;
|
|
93
204
|
const backgroundImages = [];
|
|
94
205
|
const backgroundFiles = [];
|
|
95
206
|
let lost = 0;
|
|
@@ -114,7 +225,7 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
114
225
|
log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
|
|
115
226
|
}
|
|
116
227
|
}
|
|
117
|
-
const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
|
|
228
|
+
const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped + mergedImages.dropped + mergedFiles.dropped);
|
|
118
229
|
const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
|
|
119
230
|
const allFiles = [
|
|
120
231
|
...downloaded,
|
|
@@ -127,6 +238,7 @@ async function resolveTurnInputs(t, attachments) {
|
|
|
127
238
|
return {
|
|
128
239
|
images: allImages.length ? allImages : undefined,
|
|
129
240
|
promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
|
|
241
|
+
referentIds: [...(attachments.primary.parentId !== undefined ? [attachments.primary.parentId] : []), ...chain.ids],
|
|
130
242
|
};
|
|
131
243
|
}
|
|
132
244
|
/**
|
|
@@ -144,5 +256,11 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
|
|
|
144
256
|
return;
|
|
145
257
|
}
|
|
146
258
|
const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
|
|
147
|
-
|
|
259
|
+
// A thread turn names its lineage: parent place + the message ids that can locate the branch point
|
|
260
|
+
// (the referent and its chain — nearest first). The engine reads them ONCE, when the thread's
|
|
261
|
+
// session does not exist yet; on every later turn they ride along inertly.
|
|
262
|
+
const scope = transport.parentSession === undefined
|
|
263
|
+
? { session }
|
|
264
|
+
: { session, parentSession: transport.parentSession, branchHints: resolved.referentIds };
|
|
265
|
+
yield* streamTurnWithBusyRetry(agent, scope, prompt, { label: transport.label, onCompleted, busyRetry });
|
|
148
266
|
}
|
|
@@ -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(", ");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentEvent } from "../../agent.ts";
|
|
2
2
|
import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
|
|
3
|
-
import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
|
|
3
|
+
import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
|
|
4
4
|
/** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
|
|
5
5
|
export type FeishuFailure = ChannelFailure;
|
|
6
6
|
export { defaultErrorMessage };
|
|
@@ -25,8 +25,8 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
25
25
|
import { log } from "../../log.js";
|
|
26
26
|
import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
|
|
27
27
|
import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
|
|
28
|
-
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
|
|
29
|
-
import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
|
|
28
|
+
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../kit/preview-kit.js";
|
|
29
|
+
import { truncateCodePointPrefix, truncateUtf8 } from "../kit/text.js";
|
|
30
30
|
export { defaultErrorMessage };
|
|
31
31
|
/** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
|
|
32
32
|
* allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
|
|
@@ -286,7 +286,11 @@ export async function streamFeishuReply(events, api, target, formatError, initia
|
|
|
286
286
|
// we throw below regardless.
|
|
287
287
|
finalized = true;
|
|
288
288
|
{
|
|
289
|
-
const msg = formatError({
|
|
289
|
+
const msg = formatError({
|
|
290
|
+
details: e.details,
|
|
291
|
+
retryable: e.retryable,
|
|
292
|
+
...(e.code !== undefined ? { code: e.code } : {}),
|
|
293
|
+
}) ?? "";
|
|
290
294
|
try {
|
|
291
295
|
await settle(msg);
|
|
292
296
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Schema } from "@octokit/webhooks-types";
|
|
2
|
-
import type { ChannelModule } from "../../
|
|
2
|
+
import type { ChannelModule } from "../../channel.ts";
|
|
3
3
|
/** A verified GitHub webhook event. Header fields plus the official typed payload. */
|
|
4
4
|
export interface GithubEvent {
|
|
5
5
|
/** `X-GitHub-Event` (e.g. "pull_request", "issue_comment"). */
|
package/dist/channels/http.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
|
|
3
|
+
*
|
|
4
|
+
* The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
|
|
5
|
+
* embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
|
|
6
|
+
* web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
|
|
7
|
+
* invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
|
|
8
|
+
*
|
|
9
|
+
* Serving it is somebody else's job: the node:http bridge and the route table belong to the HOST
|
|
10
|
+
* (`./serve.ts`). This file knows only the Agent contract and the wire shape of one stream.
|
|
11
|
+
*/
|
|
2
12
|
import type { Agent } from "../agent.ts";
|
|
3
13
|
/** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
|
|
4
14
|
* dispatch imports it), so the two caps cannot drift apart. */
|
|
@@ -19,9 +29,3 @@ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hell
|
|
|
19
29
|
* Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
|
|
20
30
|
*/
|
|
21
31
|
export declare function createInvokeHandler(agent: Agent): (req: Request) => Promise<Response>;
|
|
22
|
-
/**
|
|
23
|
-
* node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
|
|
24
|
-
* Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
|
|
25
|
-
* cancels both the request signal and the response stream (→ invoke cancellation).
|
|
26
|
-
*/
|
|
27
|
-
export declare function nodeListener(handler: (req: Request) => Promise<Response>): (req: IncomingMessage, res: ServerResponse) => void;
|
package/dist/channels/http.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
|
|
3
|
-
*
|
|
4
|
-
* The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
|
|
5
|
-
* embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
|
|
6
|
-
* web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
|
|
7
|
-
* invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
|
|
8
|
-
*
|
|
9
|
-
* `nodeListener` is the thin node:http adapter for the embedded `fastagent dev/start` server.
|
|
10
|
-
*/
|
|
11
|
-
import { Readable } from "node:stream";
|
|
12
|
-
import { log } from "../log.js";
|
|
13
1
|
import { readBodyCapped } from "./body.js";
|
|
14
|
-
import { text
|
|
2
|
+
import { text } from "./respond.js";
|
|
15
3
|
/** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
|
|
16
4
|
* dispatch imports it), so the two caps cannot drift apart. */
|
|
17
5
|
export const MAX_BODY_BYTES = 1 << 20;
|
|
@@ -56,14 +44,27 @@ export function createInvokeHandler(agent) {
|
|
|
56
44
|
catch {
|
|
57
45
|
return text("invalid json\n", 400);
|
|
58
46
|
}
|
|
59
|
-
const { session, text: promptText } = (payload ?? {});
|
|
47
|
+
const { session, text: promptText, parentSession, branchHints, } = (payload ?? {});
|
|
60
48
|
if (typeof session !== "string" || typeof promptText !== "string") {
|
|
61
49
|
return text('need { "session": string, "text": string }\n', 400);
|
|
62
50
|
}
|
|
63
51
|
// ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
|
|
52
|
+
// The OPTIONAL lineage extension (Scope): malformed values are a 400, not a silent drop — a
|
|
53
|
+
// caller that sent them meant them.
|
|
54
|
+
if (parentSession !== undefined && typeof parentSession !== "string") {
|
|
55
|
+
return text('"parentSession" must be a string\n', 400);
|
|
56
|
+
}
|
|
57
|
+
if (branchHints !== undefined && !(Array.isArray(branchHints) && branchHints.every((h) => typeof h === "string"))) {
|
|
58
|
+
return text('"branchHints" must be an array of strings\n', 400);
|
|
59
|
+
}
|
|
64
60
|
// Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
|
|
65
61
|
// run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
|
|
66
|
-
const iterator = agent
|
|
62
|
+
const iterator = agent
|
|
63
|
+
.invoke({
|
|
64
|
+
session,
|
|
65
|
+
...(parentSession !== undefined ? { parentSession } : {}),
|
|
66
|
+
...(branchHints !== undefined ? { branchHints } : {}),
|
|
67
|
+
}, { text: promptText })[Symbol.asyncIterator]();
|
|
67
68
|
// Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
|
|
68
69
|
// distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
|
|
69
70
|
// look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
|
|
@@ -96,99 +97,3 @@ export function createInvokeHandler(agent) {
|
|
|
96
97
|
});
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
|
-
/**
|
|
100
|
-
* node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
|
|
101
|
-
* Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
|
|
102
|
-
* cancels both the request signal and the response stream (→ invoke cancellation).
|
|
103
|
-
*/
|
|
104
|
-
export function nodeListener(handler) {
|
|
105
|
-
return (req, res) => {
|
|
106
|
-
void pump(handler, req, res); // safe: pump is TOTAL (never rejects) — see its contract below
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Consume ONE request and drive its response to a terminal state. pump is TOTAL: a SINGLE try/catch wraps
|
|
111
|
-
* the whole request→response→stream path, so EVERY failure — a handler throw, a non-Response return
|
|
112
|
-
* (`response.headers` undefined), a header Node rejects, `getReader`, or a body stream that errors
|
|
113
|
-
* mid-flight — ends the response and the returned promise NEVER rejects, which is what lets the
|
|
114
|
-
* `void pump(...)` above be safe. Before any byte goes out (headers not sent) it is a clean 500; once the
|
|
115
|
-
* response is streaming, the only honest signal left is to destroy the socket (truncated stream, not a
|
|
116
|
-
* hang). The process installs no `unhandledRejection` handler by design: robustness against a background
|
|
117
|
-
* throw is each fire-and-forget's OWN contract (fail into a terminal HTTP response here), not a global net
|
|
118
|
-
* that would blanket-swallow.
|
|
119
|
-
*/
|
|
120
|
-
async function pump(handler, req, res) {
|
|
121
|
-
const controller = new AbortController();
|
|
122
|
-
res.on("close", () => controller.abort());
|
|
123
|
-
try {
|
|
124
|
-
const method = req.method ?? "GET";
|
|
125
|
-
const hasBody = method !== "GET" && method !== "HEAD";
|
|
126
|
-
const headers = new Headers();
|
|
127
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
128
|
-
if (Array.isArray(v))
|
|
129
|
-
for (const vv of v)
|
|
130
|
-
headers.append(k, vv);
|
|
131
|
-
else if (v != null)
|
|
132
|
-
headers.set(k, v);
|
|
133
|
-
}
|
|
134
|
-
const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
|
|
135
|
-
method,
|
|
136
|
-
headers,
|
|
137
|
-
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
138
|
-
duplex: "half",
|
|
139
|
-
signal: controller.signal,
|
|
140
|
-
});
|
|
141
|
-
const response = await handler(request);
|
|
142
|
-
const outHeaders = {};
|
|
143
|
-
response.headers.forEach((value, key) => {
|
|
144
|
-
outHeaders[key] = value;
|
|
145
|
-
});
|
|
146
|
-
res.writeHead(response.status, outHeaders);
|
|
147
|
-
if (!response.body) {
|
|
148
|
-
res.end();
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
const reader = response.body.getReader();
|
|
152
|
-
res.on("close", () => void reader.cancel());
|
|
153
|
-
for (;;) {
|
|
154
|
-
const { done, value } = await reader.read();
|
|
155
|
-
if (done || res.destroyed)
|
|
156
|
-
break;
|
|
157
|
-
// Backpressure: wait for drain, but ALSO resolve on close. A client disconnect after write()
|
|
158
|
-
// returned false never emits 'drain' on the closed socket, so waiting on 'drain' alone would
|
|
159
|
-
// suspend pump() forever (leaking the request/stream).
|
|
160
|
-
if (!res.write(value)) {
|
|
161
|
-
await new Promise((resolve) => {
|
|
162
|
-
const settle = () => {
|
|
163
|
-
res.off("drain", settle);
|
|
164
|
-
res.off("close", settle);
|
|
165
|
-
resolve();
|
|
166
|
-
};
|
|
167
|
-
res.once("drain", settle);
|
|
168
|
-
res.once("close", settle);
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (!res.destroyed)
|
|
173
|
-
res.end(); // normal completion
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
// The ONE totality boundary: every failure above lands here, so pump never rejects (see the header
|
|
177
|
-
// doc) — which REQUIRES the catch itself not to throw. Don't leak the internal message to the client.
|
|
178
|
-
log.error(`[host] request failed: ${String(error)}`);
|
|
179
|
-
// Never touch an already-terminal res: a client that disconnects during the handler await destroys res
|
|
180
|
-
// (headers not yet sent), and writeHead/end on a dead socket can throw ERR_STREAM_DESTROYED here — which
|
|
181
|
-
// WOULD be the unhandled rejection this boundary exists to kill. One named gate states the invariant;
|
|
182
|
-
// with it the catch is provably non-throwing (writeHead only when !headersSent && !destroyed, destroy is
|
|
183
|
-
// idempotent).
|
|
184
|
-
if (res.destroyed)
|
|
185
|
-
return;
|
|
186
|
-
if (res.headersSent) {
|
|
187
|
-
res.destroy(error instanceof Error ? error : undefined); // streaming → truncate (not a hang)
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
res.writeHead(500, textHeaders); // pre-header → a clean 500
|
|
191
|
-
res.end("internal error\n");
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* un-summoned messages per conversation "place", kept under a char budget and folded into the next
|
|
5
5
|
* answered turn in that place, so a summoned agent has the discussion it didn't see turn-by-turn.
|
|
6
6
|
*
|
|
7
|
-
* Channel-neutral and generic over the entry shape (like ../turn-store.ts): the channel supplies its
|
|
7
|
+
* Channel-neutral and generic over the entry shape (like ../kit/turn-store.ts): the channel supplies its
|
|
8
8
|
* entry type, the shape validator (state files are an IO boundary — valid JSON of the WRONG shape
|
|
9
9
|
* must degrade exactly like a corrupt file: warn + empty, never flow in as trusted data), the
|
|
10
10
|
* fold-line renderer, and its log label. What stays per channel: the entry type itself, place-key
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* message that arrives while the turn runs survives for the next answered turn (a whole-bucket
|
|
22
22
|
* delete would lose it).
|
|
23
23
|
*/
|
|
24
|
-
import { log } from "
|
|
24
|
+
import { log } from "../../log.js";
|
|
25
25
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
26
26
|
/** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
|
|
27
27
|
* the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
|
|
8
8
|
* {@link missingAttachmentsNote}, {@link attributedFileName}.
|
|
9
9
|
*
|
|
10
|
+
* None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
|
|
11
|
+
* states what it found; deciding whether to open a file is the agent's, and one assembled without a
|
|
12
|
+
* file tool answers that it cannot — visibly, at the moment it is asked.
|
|
13
|
+
*
|
|
10
14
|
* Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
|
|
11
15
|
* message-scoped Feishu keys, Slack file objects) are real differences.
|
|
12
16
|
*/
|
|
13
|
-
import { type Agent, type AgentEvent, type Prompt } from "
|
|
17
|
+
import { type Agent, type AgentEvent, type Prompt, type Scope } from "../../agent.ts";
|
|
14
18
|
/** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
|
|
15
19
|
* EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
|
|
16
20
|
* channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
|
|
@@ -35,7 +39,10 @@ export declare const DEFAULT_BUSY_RETRY: BusyRetry;
|
|
|
35
39
|
* busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
|
|
36
40
|
* is ever re-run.
|
|
37
41
|
*/
|
|
38
|
-
export declare function streamTurnWithBusyRetry(agent: Agent,
|
|
42
|
+
export declare function streamTurnWithBusyRetry(agent: Agent,
|
|
43
|
+
/** The full scope, not a session string — channels that set extension fields (lineage) pass them
|
|
44
|
+
* through here; channels that don't pass `{ session }` and nothing changes. */
|
|
45
|
+
scope: Scope, prompt: Prompt, options: {
|
|
39
46
|
label: string;
|
|
40
47
|
onCompleted?: () => void;
|
|
41
48
|
busyRetry?: BusyRetry;
|
|
@@ -46,8 +53,15 @@ export interface ManifestFile {
|
|
|
46
53
|
size: number;
|
|
47
54
|
path: string;
|
|
48
55
|
}
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
56
|
+
/**
|
|
57
|
+
* The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
|
|
58
|
+
*
|
|
59
|
+
* It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
|
|
60
|
+
* assumption about the reader, and an assumption has to be verified — which is where a capability
|
|
61
|
+
* flag threaded through eight files came from. An agent with a file tool decides for itself whether
|
|
62
|
+
* to open one, and how much of it; an agent without one says so. Neither needs this line to have
|
|
63
|
+
* guessed first.
|
|
64
|
+
*/
|
|
51
65
|
export declare function attachedFilesManifest(files: readonly ManifestFile[]): string;
|
|
52
66
|
/** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
|
|
53
67
|
* the way the fold attributes text lines. */
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
|
|
8
8
|
* {@link missingAttachmentsNote}, {@link attributedFileName}.
|
|
9
9
|
*
|
|
10
|
+
* None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
|
|
11
|
+
* states what it found; deciding whether to open a file is the agent's, and one assembled without a
|
|
12
|
+
* file tool answers that it cannot — visibly, at the moment it is asked.
|
|
13
|
+
*
|
|
10
14
|
* Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
|
|
11
15
|
* message-scoped Feishu keys, Slack file objects) are real differences.
|
|
12
16
|
*/
|
|
13
|
-
import { SESSION_BUSY_CODE } from "
|
|
14
|
-
import { log } from "
|
|
15
|
-
// Each retry is a lease-check-level reject (tryAcquire runs before
|
|
17
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
18
|
+
import { log } from "../../log.js";
|
|
19
|
+
// Each retry is a lease-check-level reject (tryAcquire runs before the session is bound) — waiting is nearly
|
|
16
20
|
// free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
|
|
17
21
|
// real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
|
|
18
22
|
// this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
|
|
@@ -33,13 +37,17 @@ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
|
33
37
|
* busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
|
|
34
38
|
* is ever re-run.
|
|
35
39
|
*/
|
|
36
|
-
export async function* streamTurnWithBusyRetry(agent,
|
|
40
|
+
export async function* streamTurnWithBusyRetry(agent,
|
|
41
|
+
/** The full scope, not a session string — channels that set extension fields (lineage) pass them
|
|
42
|
+
* through here; channels that don't pass `{ session }` and nothing changes. */
|
|
43
|
+
scope, prompt, options) {
|
|
37
44
|
const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
|
|
45
|
+
const session = scope.session;
|
|
38
46
|
const deadline = Date.now() + busyRetry.maxWaitMs;
|
|
39
47
|
for (;;) {
|
|
40
48
|
let retryBusy = false;
|
|
41
49
|
let first = true;
|
|
42
|
-
for await (const e of agent.invoke(
|
|
50
|
+
for await (const e of agent.invoke(scope, prompt)) {
|
|
43
51
|
if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
|
|
44
52
|
retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
|
|
45
53
|
break;
|
|
@@ -55,11 +63,18 @@ export async function* streamTurnWithBusyRetry(agent, session, prompt, options)
|
|
|
55
63
|
await new Promise((r) => setTimeout(r, busyRetry.delayMs));
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
66
|
+
/**
|
|
67
|
+
* The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
|
|
68
|
+
*
|
|
69
|
+
* It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
|
|
70
|
+
* assumption about the reader, and an assumption has to be verified — which is where a capability
|
|
71
|
+
* flag threaded through eight files came from. An agent with a file tool decides for itself whether
|
|
72
|
+
* to open one, and how much of it; an agent without one says so. Neither needs this line to have
|
|
73
|
+
* guessed first.
|
|
74
|
+
*/
|
|
60
75
|
export function attachedFilesManifest(files) {
|
|
61
76
|
return files.length
|
|
62
|
-
? `\n\n[attached files
|
|
77
|
+
? `\n\n[attached files:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
|
|
63
78
|
: "";
|
|
64
79
|
}
|
|
65
80
|
/** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* everything platform-independent lives here, so a new event type or a wording change lands in ONE
|
|
9
9
|
* place instead of one hunk per channel.
|
|
10
10
|
*/
|
|
11
|
-
import type { AgentEvent, Json } from "
|
|
11
|
+
import type { AgentEvent, Json } from "../../agent.ts";
|
|
12
12
|
/** A terminal failure, as a channel hands it to its `onError`. */
|
|
13
13
|
export interface ChannelFailure {
|
|
14
14
|
details: string;
|
|
15
15
|
retryable: boolean;
|
|
16
|
+
/** The engine's failure code, when it set one. */
|
|
17
|
+
code?: string;
|
|
16
18
|
}
|
|
17
19
|
/** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
|
|
18
20
|
* offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
|
|
2
|
-
import { log } from "
|
|
2
|
+
import { log } from "../../log.js";
|
|
3
3
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
4
4
|
export function createSeenRing(path, label, cap = 2000) {
|
|
5
5
|
const raw = loadStateFile(path);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
|
|
2
|
+
export declare function ensureStateHome(dir: string): void;
|
|
3
|
+
/** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
|
|
4
|
+
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
5
|
+
export declare function loadStateFile(path: string): unknown;
|
|
6
|
+
export declare function saveStateFile(path: string, value: unknown): void;
|