@fastagent-sh/fastagent 0.16.2 → 0.17.1
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/channels/agentcore.d.ts +16 -2
- package/dist/channels/agentcore.js +90 -9
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +2 -1
- package/dist/channels/feishu/card.d.ts +20 -9
- package/dist/channels/feishu/card.js +27 -13
- package/dist/channels/feishu/feishu-api.d.ts +11 -2
- package/dist/channels/feishu/feishu.js +84 -10
- package/dist/channels/feishu/invoke-turn.d.ts +4 -0
- package/dist/channels/feishu/invoke-turn.js +31 -5
- package/dist/channels/feishu/normalize.js +97 -32
- package/dist/channels/feishu/preview.d.ts +4 -3
- package/dist/channels/feishu/preview.js +77 -23
- package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
- package/dist/channels/lark/scaffold/lark-send.ts +9 -6
- package/dist/cli/commands/attach.d.ts +18 -0
- package/dist/cli/commands/attach.js +46 -2
- package/dist/cli/commands/dev.js +2 -2
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/start.js +41 -7
- package/dist/cli/program.js +2 -2
- package/dist/cli/serve.d.ts +4 -0
- package/dist/cli/serve.js +2 -2
- package/dist/deploy/agentcore/logs.d.ts +10 -5
- package/dist/deploy/agentcore/logs.js +2 -5
- package/dist/deploy/agentcore/plan.d.ts +3 -2
- package/dist/deploy/agentcore/plan.js +23 -5
- package/dist/deploy/agentcore/run.d.ts +7 -1
- package/dist/deploy/agentcore/run.js +93 -8
- package/dist/deploy/preflight.js +34 -7
- package/dist/engines/pi/create.js +7 -16
- package/dist/engines/pi/definition.d.ts +15 -0
- package/dist/engines/pi/definition.js +22 -1
- package/dist/engines/pi/open.d.ts +1 -1
- package/dist/engines/pi/open.js +19 -0
- package/dist/engines/pi/report.d.ts +16 -0
- package/dist/engines/pi/report.js +30 -0
- package/dist/engines/pi/session-builder.js +2 -2
- package/dist/engines/pi/session-control.d.ts +11 -1
- package/dist/engines/pi/session-control.js +3 -0
- package/dist/session-remote.js +17 -0
- package/dist/session.d.ts +20 -0
- package/package.json +1 -1
|
@@ -1,3 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render one paragraph of tagged nodes to a line, collecting any resource it carries INTO the sink
|
|
3
|
+
* the caller supplies — or none, when the caller passes no sink.
|
|
4
|
+
*
|
|
5
|
+
* Shared by `post` and `interactive` because the platform hands both out in the same shape. Card-only
|
|
6
|
+
* shapes are handled here rather than in a second walker: `note` nests its own `elements`, and the
|
|
7
|
+
* widget tags (button/select/overflow/date_picker) carry their user-visible label in `text` or
|
|
8
|
+
* `placeholder` — a card is read for what it SAYS, so a label is content and an unlabelled control is
|
|
9
|
+
* nothing.
|
|
10
|
+
*
|
|
11
|
+
* The OPTIONAL sink is the whole reason this is a parameter rather than a return value: a card's
|
|
12
|
+
* resources are documented as unfetchable (see the card branch), so that caller renders the same
|
|
13
|
+
* `[image]` / `[video]` markers into the text while collecting nothing. The markers still tell the
|
|
14
|
+
* model what is there; what must not happen is a key entering the turn's PRIMARY inputs, which load
|
|
15
|
+
* fail-fast.
|
|
16
|
+
*/
|
|
17
|
+
function renderNodes(nodes, resources) {
|
|
18
|
+
if (!Array.isArray(nodes))
|
|
19
|
+
return "";
|
|
20
|
+
const parts = [];
|
|
21
|
+
for (const raw of nodes) {
|
|
22
|
+
if (typeof raw !== "object" || raw === null)
|
|
23
|
+
continue;
|
|
24
|
+
const node = raw;
|
|
25
|
+
if (node.tag === "at") {
|
|
26
|
+
parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
|
|
27
|
+
}
|
|
28
|
+
else if (node.tag === "a") {
|
|
29
|
+
parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
|
|
30
|
+
}
|
|
31
|
+
else if (node.tag === "img") {
|
|
32
|
+
const key = nonEmptyString(node.image_key);
|
|
33
|
+
if (key)
|
|
34
|
+
resources?.push({ kind: "image", key });
|
|
35
|
+
parts.push("[image]");
|
|
36
|
+
}
|
|
37
|
+
else if (node.tag === "media") {
|
|
38
|
+
const key = nonEmptyString(node.file_key);
|
|
39
|
+
if (key)
|
|
40
|
+
resources?.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
|
|
41
|
+
parts.push("[video]");
|
|
42
|
+
}
|
|
43
|
+
else if (node.tag === "code_block") {
|
|
44
|
+
parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
|
|
45
|
+
}
|
|
46
|
+
else if (node.tag === "note") {
|
|
47
|
+
const nested = renderNodes(node.elements, resources);
|
|
48
|
+
if (nested)
|
|
49
|
+
parts.push(nested);
|
|
50
|
+
}
|
|
51
|
+
else if (nonEmptyString(node.text)) {
|
|
52
|
+
parts.push(node.text);
|
|
53
|
+
}
|
|
54
|
+
else if (nonEmptyString(node.placeholder)) {
|
|
55
|
+
parts.push(node.placeholder);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return parts.join("").trim();
|
|
59
|
+
}
|
|
1
60
|
function nonEmptyString(value) {
|
|
2
61
|
return typeof value === "string" && value !== "" ? value : undefined;
|
|
3
62
|
}
|
|
@@ -37,43 +96,49 @@ export function decodeFeishuContent(message) {
|
|
|
37
96
|
lines.push(title);
|
|
38
97
|
const paragraphs = Array.isArray(content.content) ? content.content : [];
|
|
39
98
|
for (const paragraph of paragraphs) {
|
|
40
|
-
|
|
41
|
-
continue;
|
|
42
|
-
const parts = [];
|
|
43
|
-
for (const node of paragraph) {
|
|
44
|
-
if (typeof node !== "object" || node === null)
|
|
45
|
-
continue;
|
|
46
|
-
if (node.tag === "at") {
|
|
47
|
-
parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
|
|
48
|
-
}
|
|
49
|
-
else if (node.tag === "a") {
|
|
50
|
-
parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
|
|
51
|
-
}
|
|
52
|
-
else if (node.tag === "img") {
|
|
53
|
-
const key = nonEmptyString(node.image_key);
|
|
54
|
-
if (key)
|
|
55
|
-
resources.push({ kind: "image", key });
|
|
56
|
-
parts.push("[image]");
|
|
57
|
-
}
|
|
58
|
-
else if (node.tag === "media") {
|
|
59
|
-
const key = nonEmptyString(node.file_key);
|
|
60
|
-
if (key)
|
|
61
|
-
resources.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
|
|
62
|
-
parts.push("[video]");
|
|
63
|
-
}
|
|
64
|
-
else if (node.tag === "code_block") {
|
|
65
|
-
parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
|
|
66
|
-
}
|
|
67
|
-
else if (nonEmptyString(node.text)) {
|
|
68
|
-
parts.push(node.text);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
const line = parts.join("").trim();
|
|
99
|
+
const line = renderNodes(paragraph, resources);
|
|
72
100
|
if (line)
|
|
73
101
|
lines.push(line);
|
|
74
102
|
}
|
|
75
103
|
return { text: lines.join("\n"), resources };
|
|
76
104
|
}
|
|
105
|
+
// A CARD, as the platform hands it BACK. What we send is an entity reference
|
|
106
|
+
// (`{type:"card",data:{card_id}}`, card.ts) whose text lives in cardkit — but a query API renders
|
|
107
|
+
// the card down to `title` + `elements` (paragraphs of the same tagged nodes as `post`), so the
|
|
108
|
+
// content is readable without a second remote call. This is the message type the agent's OWN
|
|
109
|
+
// answers are, so the case that matters is a user following up on one: without this branch a
|
|
110
|
+
// reply-referent that is the agent's own card decoded to the bare `[interactive message]` marker
|
|
111
|
+
// and the model was told its own answer was unreadable (field-observed).
|
|
112
|
+
//
|
|
113
|
+
// BOTH spellings on purpose: the platform's own docs disagree with themselves — the field table
|
|
114
|
+
// says `interactive` (what the receive EVENT carries) while the message-object example shows
|
|
115
|
+
// `"msg_type": "card"`. Matching one would leave the other silently on the default branch, which
|
|
116
|
+
// is exactly the symptom this fixes.
|
|
117
|
+
case "interactive":
|
|
118
|
+
case "card": {
|
|
119
|
+
const lines = [];
|
|
120
|
+
const title = nonEmptyString(content.title);
|
|
121
|
+
if (title)
|
|
122
|
+
lines.push(title);
|
|
123
|
+
const paragraphs = Array.isArray(content.elements) ? content.elements : [];
|
|
124
|
+
for (const paragraph of paragraphs) {
|
|
125
|
+
// NO resource sink, deliberately. The platform documents that a card's resources cannot be
|
|
126
|
+
// fetched at all: `im/v1/messages/:id/resources/:key` answers 234043 ("Unsupported message
|
|
127
|
+
// type") for a card message id, by stated limitation rather than by permission. Collecting a
|
|
128
|
+
// key here would hand the turn a PRIMARY input that is guaranteed to fail its fail-fast load
|
|
129
|
+
// — turning "the card reads as a marker" (the old behaviour) into "the whole turn errors",
|
|
130
|
+
// which is strictly worse than the gap this branch exists to close. The text still renders
|
|
131
|
+
// `[image]` / `[video]`, so the model knows what is there and that it does not have it.
|
|
132
|
+
//
|
|
133
|
+
// Tolerate both shapes: elements as paragraphs (array of arrays) and a flat element list.
|
|
134
|
+
const line = Array.isArray(paragraph) ? renderNodes(paragraph) : renderNodes([paragraph]);
|
|
135
|
+
if (line)
|
|
136
|
+
lines.push(line);
|
|
137
|
+
}
|
|
138
|
+
// An unrenderable card (all controls, no labels) still says something by existing — keep the
|
|
139
|
+
// marker rather than returning empty, which reads as "the message was blank".
|
|
140
|
+
return { text: lines.length > 0 ? lines.join("\n") : `[${rawType} message]`, resources };
|
|
141
|
+
}
|
|
77
142
|
case "image": {
|
|
78
143
|
const key = nonEmptyString(content.image_key);
|
|
79
144
|
if (key)
|
|
@@ -15,9 +15,10 @@ export type MountedFeishuPreview = {
|
|
|
15
15
|
messageId: string;
|
|
16
16
|
};
|
|
17
17
|
/**
|
|
18
|
-
* Mount one preview message: preferably a streaming card entity
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Mount one preview message: preferably a streaming card entity (`initial` seeds the process element;
|
|
19
|
+
* the answer element starts empty), with a static text message as the visible fallback. Queue feedback
|
|
20
|
+
* and ordinary turn startup share this constructor so a queued card has exactly the same shape the
|
|
21
|
+
* stream pump expects to take over later.
|
|
21
22
|
*/
|
|
22
23
|
export declare function mountFeishuPreview(api: FeishuApi, target: FeishuTarget, initial: string, label?: string): Promise<MountedFeishuPreview>;
|
|
23
24
|
/** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Canonical Feishu live-preview rendering (also reused by Lark compatibility). The preview is ONE
|
|
3
|
-
* streaming CARD
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* streaming CARD of TWO elements — the volatile `process` block and the append-only `answer` (see
|
|
4
|
+
* card.ts for why the split is the prefix-stability fix) — (create entity → mount it with a
|
|
5
|
+
* reply/send → stream full-text snapshots per element with a strictly increasing `sequence`; the
|
|
6
|
+
* client renders the typewriter effect); on completion the same card is settled in place with the
|
|
7
|
+
* final answer alone (streaming off). Streaming
|
|
6
8
|
* updates ride the cardkit quota (50 QPS per app, 10 QPS per card entity, no edit ceiling) — NOT the
|
|
7
9
|
* 5 QPS per-chat message quota or
|
|
8
10
|
* the 20-edit cap on text messages, which is why the preview is a card and not an edited text message.
|
|
@@ -21,10 +23,10 @@
|
|
|
21
23
|
*/
|
|
22
24
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
23
25
|
import { log } from "../../log.js";
|
|
24
|
-
import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
|
|
26
|
+
import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
|
|
25
27
|
import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
|
|
26
28
|
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
|
|
27
|
-
import { truncateUtf8 } from "../text.js";
|
|
29
|
+
import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
|
|
28
30
|
export { defaultErrorMessage };
|
|
29
31
|
/** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
|
|
30
32
|
* allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
|
|
@@ -33,12 +35,41 @@ export { defaultErrorMessage };
|
|
|
33
35
|
const STREAM_THROTTLE_MS = 1000;
|
|
34
36
|
/** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
|
|
35
37
|
const THINKING_PREVIEW = 280;
|
|
36
|
-
/** Cap
|
|
37
|
-
*
|
|
38
|
-
*
|
|
38
|
+
/** Cap (code points) on the whole process block — thinking tail + tool lines + retry notice. It
|
|
39
|
+
* redraws wholly on change anyway (it is volatile by nature), so over budget the newest COMPLETE
|
|
40
|
+
* lines win (see tailLines). ≤1000 points is ≤4 KB UTF-8, which together with the answer's byte cap
|
|
41
|
+
* stays inside the 30 KB entity budget. */
|
|
42
|
+
const PROCESS_MAX_POINTS = 1000;
|
|
43
|
+
/** Cap the live answer to the card budget, PREFIX-STABLE: the streaming client animates only when the
|
|
44
|
+
* old text is a prefix of the new, so an over-budget answer freezes at its head rather than sliding a
|
|
45
|
+
* tail window (which would re-type the element every frame). The full answer still lands at settle. */
|
|
39
46
|
function capBytes(s, maxBytes) {
|
|
40
47
|
return truncateUtf8(s, maxBytes);
|
|
41
48
|
}
|
|
49
|
+
/** Tail-select COMPLETE lines within a code-point budget — the process block's cap. The block's
|
|
50
|
+
* lines are semantic units (a `🔧` tool call, the `💭` peek, the `⏳` notice): cutting mid-line
|
|
51
|
+
* would orphan a marker or tear a label, so elision happens only at line boundaries, newest lines
|
|
52
|
+
* kept, with a leading `…` line marking what was dropped. The in-line guard cannot trigger with the
|
|
53
|
+
* bounded renderers (a thinking tail ≤ ~283 points, a tool line ≤ ~135) — it exists so a future
|
|
54
|
+
* unbounded line degrades to a head-preserving cut instead of an empty block. */
|
|
55
|
+
function tailLines(text, maxPoints) {
|
|
56
|
+
if (Array.from(text).length <= maxPoints)
|
|
57
|
+
return text;
|
|
58
|
+
const lines = text.split("\n");
|
|
59
|
+
const kept = [];
|
|
60
|
+
let used = 2; // the leading "…\n" elision marker
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
62
|
+
const line = lines[i] ?? "";
|
|
63
|
+
const cost = Array.from(line).length + (kept.length > 0 ? 1 : 0); // +1 joining newline
|
|
64
|
+
if (used + cost > maxPoints)
|
|
65
|
+
break;
|
|
66
|
+
used += cost;
|
|
67
|
+
kept.unshift(line);
|
|
68
|
+
}
|
|
69
|
+
if (kept.length === 0)
|
|
70
|
+
return truncateCodePointPrefix(lines.at(-1) ?? "", maxPoints);
|
|
71
|
+
return `…\n${kept.join("\n")}`;
|
|
72
|
+
}
|
|
42
73
|
/**
|
|
43
74
|
* The terminal-write POLICY: resolve the preview into `text`. One card → settle it in place (final
|
|
44
75
|
* markdown, streaming off); an over-budget answer settles the card with its first chunk and sends the
|
|
@@ -90,9 +121,10 @@ async function finalize(api, target, preview, text, seq) {
|
|
|
90
121
|
await api.sendText(target, text);
|
|
91
122
|
}
|
|
92
123
|
/**
|
|
93
|
-
* Mount one preview message: preferably a streaming card entity
|
|
94
|
-
*
|
|
95
|
-
*
|
|
124
|
+
* Mount one preview message: preferably a streaming card entity (`initial` seeds the process element;
|
|
125
|
+
* the answer element starts empty), with a static text message as the visible fallback. Queue feedback
|
|
126
|
+
* and ordinary turn startup share this constructor so a queued card has exactly the same shape the
|
|
127
|
+
* stream pump expects to take over later.
|
|
96
128
|
*/
|
|
97
129
|
export async function mountFeishuPreview(api, target, initial, label = "[feishu]") {
|
|
98
130
|
try {
|
|
@@ -151,17 +183,27 @@ export async function settleFeishuPreview(api, target, preview, text) {
|
|
|
151
183
|
*/
|
|
152
184
|
export async function streamFeishuReply(events, api, target, formatError, initialPreview, label = "[feishu]") {
|
|
153
185
|
// Event → view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
|
|
154
|
-
// policy, the card-budget
|
|
186
|
+
// policy, the card-budget caps, and delivery below. The card is TWO elements (card.ts): the process
|
|
187
|
+
// block's head changes every frame (sliding thinking tail, `…`→`✓` flips), so it must never share
|
|
188
|
+
// an element with the answer — the client would re-type the whole card from the divergence point
|
|
189
|
+
// once a second. Each view feeds its own element; only the changed one is written.
|
|
155
190
|
const turn = createTurnView();
|
|
156
|
-
const
|
|
191
|
+
const processView = () => {
|
|
157
192
|
const v = composeTurnBody([
|
|
158
193
|
thinkingLine(turn, THINKING_PREVIEW),
|
|
159
194
|
toolLines(turn),
|
|
160
195
|
turn.retrying ? RETRY_NOTICE : "",
|
|
161
|
-
revealedAnswer(turn, STREAM_THROTTLE_MS),
|
|
162
196
|
]);
|
|
163
|
-
|
|
197
|
+
if (v !== "")
|
|
198
|
+
return tailLines(v, PROCESS_MAX_POINTS);
|
|
199
|
+
// No process content: the placeholder covers only the silence BEFORE the answer reveals — once
|
|
200
|
+
// the answer is streaming, an empty block goes (stays) empty; "Thinking…" pinned above a live
|
|
201
|
+
// answer would misstate the phase. The empty frame is a real write: it clears a mounted
|
|
202
|
+
// placeholder. (The block cannot otherwise flicker: thinking and tools only grow — only the
|
|
203
|
+
// retry notice toggles, and its empty state resolves through this same rule.)
|
|
204
|
+
return revealedAnswer(turn, STREAM_THROTTLE_MS).trim() === "" ? THINKING_PLACEHOLDER : "";
|
|
164
205
|
};
|
|
206
|
+
const answerView = () => capBytes(revealedAnswer(turn, STREAM_THROTTLE_MS), CARD_MARKDOWN_MAX_BYTES);
|
|
165
207
|
// The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
|
|
166
208
|
// mounted lazily on this turn's first flush. `sequence` must increase strictly per card — the single-
|
|
167
209
|
// writer pump guarantees it by construction. A queue card has had no updates yet, so sequence starts
|
|
@@ -172,22 +214,34 @@ export async function streamFeishuReply(events, api, target, formatError, initia
|
|
|
172
214
|
const nextSeq = () => ++sequence;
|
|
173
215
|
let streamDead = false; // the platform closed streaming (idle timeout) — freeze the live view
|
|
174
216
|
let finalized = false; // a terminal write (completed/failed) ran — the finally skips its orphan cleanup
|
|
175
|
-
let
|
|
217
|
+
let lastProcess = "";
|
|
218
|
+
let lastAnswer = "";
|
|
176
219
|
const flushPreview = async () => {
|
|
177
|
-
const
|
|
220
|
+
const process = processView();
|
|
178
221
|
if (!setupAttempted) {
|
|
179
222
|
setupAttempted = true;
|
|
180
|
-
|
|
181
|
-
|
|
223
|
+
// The mount seeds the process element with the current view; the answer element starts empty
|
|
224
|
+
// (card.ts), so the first answer snapshot is a clean prefix extension.
|
|
225
|
+
preview = await mountFeishuPreview(api, target, process, label);
|
|
226
|
+
lastProcess = process;
|
|
182
227
|
return;
|
|
183
228
|
}
|
|
184
229
|
if (preview.kind !== "card" || streamDead)
|
|
185
230
|
return; // text tier / dead stream: frozen until the terminal write
|
|
186
|
-
if (text === lastSent)
|
|
187
|
-
return; // skip an unchanged snapshot
|
|
188
|
-
lastSent = text;
|
|
189
231
|
try {
|
|
190
|
-
|
|
232
|
+
// `last*` advances BEFORE each write: a frame that fails for a non-streaming reason is logged
|
|
233
|
+
// once (the pump's onError) and not re-sent until its content actually changes. An EMPTY
|
|
234
|
+
// process frame is written like any other — it is the placeholder being cleared (processView).
|
|
235
|
+
if (process !== lastProcess) {
|
|
236
|
+
lastProcess = process;
|
|
237
|
+
await api.updateCardElement(preview.cardId, PROCESS_ELEMENT_ID, process, nextSeq());
|
|
238
|
+
}
|
|
239
|
+
const answer = answerView();
|
|
240
|
+
// Never write an empty answer snapshot — the element is born empty and the answer only grows.
|
|
241
|
+
if (answer !== "" && answer !== lastAnswer) {
|
|
242
|
+
lastAnswer = answer;
|
|
243
|
+
await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, answer, nextSeq());
|
|
244
|
+
}
|
|
191
245
|
}
|
|
192
246
|
catch (e) {
|
|
193
247
|
if (isCardStreamingClosed(e)) {
|
|
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
|
|
|
56
56
|
|
|
57
57
|
export default defineTool({
|
|
58
58
|
description:
|
|
59
|
-
"Send a message to a Feishu chat
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
59
|
+
"Send a message to a Feishu chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
|
|
60
|
+
"channel is carrying — a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
|
|
61
|
+
"nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
|
|
62
|
+
"channel streams and delivers your reply itself, so do NOT call this to answer the current " +
|
|
63
|
+
"chat: it would post the message twice, outside the conversation thread. `chatId` (oc_…) names " +
|
|
64
|
+
"the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
|
|
65
|
+
"or memory); the [feishu: chat …] context line only identifies the chat you are answering — the " +
|
|
66
|
+
"one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
|
|
67
|
+
"`markdown` (rendered as a card: headings, bold, code blocks, links).",
|
|
65
68
|
input: z.object({
|
|
66
69
|
chatId: z.string().describe("target chat id (oc_…)"),
|
|
67
70
|
text: z.string().optional().describe("plain text message to send"),
|
|
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
|
|
|
56
56
|
|
|
57
57
|
export default defineTool({
|
|
58
58
|
description:
|
|
59
|
-
"Send a message to a Lark chat
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
59
|
+
"Send a message to a Lark chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
|
|
60
|
+
"channel is carrying — a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
|
|
61
|
+
"nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
|
|
62
|
+
"channel streams and delivers your reply itself, so do NOT call this to answer the current " +
|
|
63
|
+
"chat: it would post the message twice, outside the conversation thread. `chatId` (oc_…) names " +
|
|
64
|
+
"the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
|
|
65
|
+
"or memory); the [lark: chat …] context line only identifies the chat you are answering — the " +
|
|
66
|
+
"one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
|
|
67
|
+
"`markdown` (rendered as a card: headings, bold, code blocks, links).",
|
|
65
68
|
input: z.object({
|
|
66
69
|
chatId: z.string().describe("target chat id (oc_…)"),
|
|
67
70
|
text: z.string().optional().describe("plain text message to send"),
|
|
@@ -72,6 +72,24 @@ export interface AttachIo {
|
|
|
72
72
|
* whole — the one case where "unknown" must not read as "off-path".
|
|
73
73
|
*/
|
|
74
74
|
export declare function activePathSlice(entries: SessionEntry[], leafEntryId: string | undefined): SessionEntry[];
|
|
75
|
+
/**
|
|
76
|
+
* Answer a RESERVED-SLASH line (anything starting with `/` that is not `/abort`). Two intents share
|
|
77
|
+
* the prefix — a mistyped control command and an attempt to invoke a name — and they are answered
|
|
78
|
+
* differently:
|
|
79
|
+
*
|
|
80
|
+
* - a mistyped slash gets the certain half NOW (a leading `/` is reserved here, whatever the token
|
|
81
|
+
* turns out to be), because waiting on a remote read that can be slow or fail would leave the
|
|
82
|
+
* input unanswered; it deliberately does not pre-judge the token as unknown — the read may be
|
|
83
|
+
* about to prove it names a real skill;
|
|
84
|
+
* - `/commands` prints nothing first: its whole answer IS the read, and a placeholder is noise;
|
|
85
|
+
* - the enumeration answers `/commands` ONLY — dumping every skill at a mistyped `/aboort` answers
|
|
86
|
+
* an intent the typo did not express.
|
|
87
|
+
*
|
|
88
|
+
* Names print BARE: this composer cannot expand `/name` (the data plane takes prompts as text), so
|
|
89
|
+
* printing them with a slash would invite the user straight back into this branch. Returns the
|
|
90
|
+
* promise for the remote half, so a caller (or a test) can await the second line.
|
|
91
|
+
*/
|
|
92
|
+
export declare function answerSlashInput(trimmed: string, control: Pick<SessionControl, "commands">, println: (line: string) => void): Promise<void>;
|
|
75
93
|
/**
|
|
76
94
|
* ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
|
|
77
95
|
* until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
|
|
@@ -185,7 +185,7 @@ export async function runAttach(sessionArg, dirArg, opts) {
|
|
|
185
185
|
// invoke) — give the human a corrective signal.
|
|
186
186
|
log.warn(`[fastagent] no durable record for "${sessionArg}" yet — a new session, or a typo?`);
|
|
187
187
|
}
|
|
188
|
-
log.info(`[fastagent] type to steer the active run; /abort to stop it; Ctrl+C to detach`);
|
|
188
|
+
log.info(`[fastagent] type to steer the active run; /abort to stop it; /commands to list what this agent defines; Ctrl+C to detach`);
|
|
189
189
|
// stdin → the two planes: a line steers the ACTIVE run; with no run to join (no_active_run) it
|
|
190
190
|
// falls back to STARTING one over the remote data plane (`POST /control/invoke`) — try-steer-
|
|
191
191
|
// then-prompt avoids a state() pre-check race. Acceptance is not outcome: rejections print and
|
|
@@ -222,7 +222,7 @@ export async function runAttach(sessionArg, dirArg, opts) {
|
|
|
222
222
|
// `/` is a reserved command prefix: a typo'd /aboort silently steering the model (injecting a
|
|
223
223
|
// prompt when the user meant to STOP the run) is the dangerous direction of the ambiguity.
|
|
224
224
|
if (trimmed.startsWith("/") && trimmed !== "/abort") {
|
|
225
|
-
|
|
225
|
+
void answerSlashInput(trimmed, control, (l) => console.log(l));
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
228
228
|
const command = trimmed === "/abort" ? { type: "abort" } : { type: "steer", prompt: { text: trimmed } };
|
|
@@ -449,6 +449,50 @@ export function activePathSlice(entries, leafEntryId) {
|
|
|
449
449
|
}
|
|
450
450
|
return entries.filter((e) => onPath.has(e.id));
|
|
451
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Answer a RESERVED-SLASH line (anything starting with `/` that is not `/abort`). Two intents share
|
|
454
|
+
* the prefix — a mistyped control command and an attempt to invoke a name — and they are answered
|
|
455
|
+
* differently:
|
|
456
|
+
*
|
|
457
|
+
* - a mistyped slash gets the certain half NOW (a leading `/` is reserved here, whatever the token
|
|
458
|
+
* turns out to be), because waiting on a remote read that can be slow or fail would leave the
|
|
459
|
+
* input unanswered; it deliberately does not pre-judge the token as unknown — the read may be
|
|
460
|
+
* about to prove it names a real skill;
|
|
461
|
+
* - `/commands` prints nothing first: its whole answer IS the read, and a placeholder is noise;
|
|
462
|
+
* - the enumeration answers `/commands` ONLY — dumping every skill at a mistyped `/aboort` answers
|
|
463
|
+
* an intent the typo did not express.
|
|
464
|
+
*
|
|
465
|
+
* Names print BARE: this composer cannot expand `/name` (the data plane takes prompts as text), so
|
|
466
|
+
* printing them with a slash would invite the user straight back into this branch. Returns the
|
|
467
|
+
* promise for the remote half, so a caller (or a test) can await the second line.
|
|
468
|
+
*/
|
|
469
|
+
export async function answerSlashInput(trimmed, control, println) {
|
|
470
|
+
// The first WORD is the token: slash input naturally carries arguments (`/triage my inbox`), and
|
|
471
|
+
// taking the whole line would answer "names nothing" for a name the user did give.
|
|
472
|
+
const word = trimmed.slice(1).split(/\s+/)[0] ?? "";
|
|
473
|
+
const listing = word === "commands";
|
|
474
|
+
if (!listing)
|
|
475
|
+
println("[a leading / is reserved — /abort stops the run, /commands lists what this agent defines]");
|
|
476
|
+
let commands;
|
|
477
|
+
try {
|
|
478
|
+
commands = await control.commands();
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
println(`[command list unavailable: ${error}]`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (listing) {
|
|
485
|
+
// `description` is what makes a listing usable — a bare name tells the author nothing they did
|
|
486
|
+
// not already know from the directory.
|
|
487
|
+
const listed = commands.map((c) => (c.description ? `${c.name} — ${c.description}` : c.name));
|
|
488
|
+
println(listed.length ? `[this agent defines: ${listed.join("; ")}]` : "[this agent defines no names]");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const hit = commands.find((c) => c.name === word);
|
|
492
|
+
println(hit
|
|
493
|
+
? `[${hit.name} is a ${hit.source}${hit.description ? ` — ${hit.description}` : ""}; name it in a normal message, without the /]`
|
|
494
|
+
: `[/${word} names nothing this agent defines]`);
|
|
495
|
+
}
|
|
452
496
|
/**
|
|
453
497
|
* ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
|
|
454
498
|
* until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { runDevSupervisor } from "../../dev-supervisor.js";
|
|
8
8
|
import { loadDotEnv } from "../../env.js";
|
|
9
|
-
import {
|
|
9
|
+
import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
|
|
10
10
|
import { createPiAgentFromDir } from "../../engines/pi/open.js";
|
|
11
11
|
import { setLogLevel } from "../../log.js";
|
|
12
12
|
import { logAgentLoop } from "../../observe.js";
|
|
@@ -91,5 +91,5 @@ function reportAgentsSkillsTools(a) {
|
|
|
91
91
|
}
|
|
92
92
|
reportToolCollisions(a.toolCollisions);
|
|
93
93
|
reportModuleLoadFailures(a.toolFailures);
|
|
94
|
-
|
|
94
|
+
reportFindingsIfChanged(a.definition.dir, a.definition);
|
|
95
95
|
}
|
|
@@ -6,7 +6,7 @@ import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModelSpec, reso
|
|
|
6
6
|
import { resolveStateRoot, workspaceHint } from "../../paths.js";
|
|
7
7
|
import { resolveAgentTools } from "../../engines/pi/create.js";
|
|
8
8
|
import { loadAgentDefinition } from "../../engines/pi/definition.js";
|
|
9
|
-
import {
|
|
9
|
+
import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
|
|
10
10
|
import { log } from "../../log.js";
|
|
11
11
|
import { nextRun } from "../../schedule/cron.js";
|
|
12
12
|
import { loadSchedules } from "../../schedule/discover.js";
|
|
@@ -111,5 +111,5 @@ export async function runInfo(dirArg, opts) {
|
|
|
111
111
|
reportModuleLoadFailures(sched.failures);
|
|
112
112
|
if (tools.error)
|
|
113
113
|
log.warn(`[fastagent] ${tools.error}`);
|
|
114
|
-
|
|
114
|
+
reportFindingsIfChanged(definition.dir, definition);
|
|
115
115
|
}
|
|
@@ -9,7 +9,7 @@ import { loadDotEnv } from "../../env.js";
|
|
|
9
9
|
import { resolveAuthPath, resolveSessionsDirOverride } from "../../engines/pi/config.js";
|
|
10
10
|
import { resolveSecretsDir, workspaceHint } from "../../paths.js";
|
|
11
11
|
import { isUnderDir } from "../../engines/pi/definition.js";
|
|
12
|
-
import {
|
|
12
|
+
import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
|
|
13
13
|
import { createPiAgentFromDir } from "../../engines/pi/open.js";
|
|
14
14
|
import { log, setLogLevel } from "../../log.js";
|
|
15
15
|
import { createWakeAlarmSink, reconcileWakeAlarms } from "../../schedule/wake-alarm.js";
|
|
@@ -18,6 +18,7 @@ import { logAgentLoop } from "../../observe.js";
|
|
|
18
18
|
import { installProxyFetch } from "../../proxy.js";
|
|
19
19
|
import { exists } from "../../paths.js";
|
|
20
20
|
import { bindAddress } from "../../bind.js";
|
|
21
|
+
import { parseRouteKey } from "../../host/node.js";
|
|
21
22
|
import { failStartup, placementOrExit } from "../fail.js";
|
|
22
23
|
import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
|
|
23
24
|
import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
|
|
@@ -80,7 +81,7 @@ export async function runStart(dirArg, opts) {
|
|
|
80
81
|
log.info(`[fastagent] note: secrets (.env, rotated auth.json) live under the definition dir; point ` +
|
|
81
82
|
`FASTAGENT_SECRETS_DIR at a persistent volume so a redeploy that replaces the dir does not wipe them.`);
|
|
82
83
|
}
|
|
83
|
-
|
|
84
|
+
reportFindingsIfChanged(definition.dir, definition);
|
|
84
85
|
// AgentCore Runtime posture (FASTAGENT_AGENTCORE=1, set by the generated deploy artifacts): the
|
|
85
86
|
// adapter (POST /invocations + GET /ping) is the container's only reachable surface, and cron
|
|
86
87
|
// slots arrive from the external clock through it — so no resident cron timers. In particular,
|
|
@@ -89,13 +90,21 @@ export async function runStart(dirArg, opts) {
|
|
|
89
90
|
const agentcore = process.env.FASTAGENT_AGENTCORE === "1";
|
|
90
91
|
// Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
|
|
91
92
|
const traced = logAgentLoop(agent);
|
|
92
|
-
|
|
93
|
+
// On AgentCore the channels are constructed LAZILY (mountAgentcore's lazyChannels, resolved on the
|
|
94
|
+
// first envelope after the state-snapshot restore): construction loads channel state and replays
|
|
95
|
+
// turn intent, and at boot the state mount is PRE-RESTORE — empty after every version update — so
|
|
96
|
+
// an eager build would cache that emptiness (thread participation, delivery dedup, pending turns)
|
|
97
|
+
// and then clobber the restored files with it. Everywhere else the state root is durable at boot,
|
|
98
|
+
// so channels mount eagerly and a broken channel fails startup.
|
|
99
|
+
const routed = agentcore
|
|
100
|
+
? undefined
|
|
101
|
+
: await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: true }).catch(failStartup);
|
|
93
102
|
// `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
|
|
94
103
|
// configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
|
|
95
104
|
const configured = config.http?.host;
|
|
96
105
|
const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
|
|
97
106
|
assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
|
|
98
|
-
const withControl = mountSessionControl(routed
|
|
107
|
+
const withControl = mountSessionControl(routed?.routes ?? {}, sessionControl, stateRoot, {
|
|
99
108
|
tunnel: opts.tunnel ?? false,
|
|
100
109
|
agent: traced,
|
|
101
110
|
host,
|
|
@@ -126,17 +135,42 @@ export async function runStart(dirArg, opts) {
|
|
|
126
135
|
});
|
|
127
136
|
let routes = withControl.routes;
|
|
128
137
|
if (agentcore) {
|
|
138
|
+
// The lazy channel surface the adapter resolves post-restore. Control routes ride along so a
|
|
139
|
+
// forwarder-relayed /control/* request dispatches the same as on a direct host. Long-connection
|
|
140
|
+
// channels cannot serve here — scale-to-zero severs a resident connection and nothing
|
|
141
|
+
// re-establishes it — so their presence is a configuration error, surfaced per envelope and by
|
|
142
|
+
// the deploy driver's health probe (there is no boot to fail on this host).
|
|
143
|
+
const lazyChannels = async () => {
|
|
144
|
+
const surface = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: false });
|
|
145
|
+
if (surface.longConnections.length > 0) {
|
|
146
|
+
throw new Error(`long-connection channel(s) ${surface.longConnections.map((c) => c.name).join(", ")} cannot serve on ` +
|
|
147
|
+
`AgentCore (scale-to-zero severs resident connections) — use the channel's webhook form`);
|
|
148
|
+
}
|
|
149
|
+
// mountSessionControl's PATH-level collision rule, re-asserted here: with no channels at boot
|
|
150
|
+
// its own check ran against an empty base, and a spread merge would silently let control win —
|
|
151
|
+
// but a channel on /control/* is the same configuration error it is on every other host.
|
|
152
|
+
const controlPaths = new Set(Object.keys(withControl.routes).map((key) => parseRouteKey(key).path));
|
|
153
|
+
const collisions = Object.keys(surface.routes).filter((key) => controlPaths.has(parseRouteKey(key).path));
|
|
154
|
+
if (collisions.length > 0) {
|
|
155
|
+
throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control ` +
|
|
156
|
+
`plane — rename the channel route or disable sessionControl in fastagent.config`);
|
|
157
|
+
}
|
|
158
|
+
return { ...surface.routes, ...withControl.routes };
|
|
159
|
+
};
|
|
129
160
|
try {
|
|
130
|
-
routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady });
|
|
161
|
+
routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady, lazyChannels });
|
|
131
162
|
}
|
|
132
163
|
catch (e) {
|
|
133
164
|
failStartup(e);
|
|
134
165
|
}
|
|
135
166
|
log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
|
|
136
167
|
}
|
|
137
|
-
serve({
|
|
168
|
+
serve({
|
|
169
|
+
...(routed ?? { longConnections: [], routeChannels: [], builtinInvoke: false, markReady() { } }),
|
|
170
|
+
routes,
|
|
171
|
+
}, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
|
|
138
172
|
withControl.announce(p);
|
|
139
|
-
maybeTunnel(agentDir, routed
|
|
173
|
+
maybeTunnel(agentDir, routed?.routeChannels ?? [], p, opts.tunnel ?? false, stateRoot);
|
|
140
174
|
});
|
|
141
175
|
// No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
|
|
142
176
|
// in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
|
package/dist/cli/program.js
CHANGED
|
@@ -457,8 +457,8 @@ const logs = {
|
|
|
457
457
|
name: "logs",
|
|
458
458
|
summary: "find and tail a deployed host's application logs",
|
|
459
459
|
description: "Find the CloudWatch log group for the AgentCore stack derived from dir, then run aws logs tail. " +
|
|
460
|
-
"The default Runtime source
|
|
461
|
-
"
|
|
460
|
+
"The default Runtime source shows the agent process's own stdout/stderr; the forwarder source shows " +
|
|
461
|
+
"the Lambda ingress transport logs.",
|
|
462
462
|
args: [{ name: "<host>", description: "deployed host", choices: ["agentcore"] }, DIR_ARG],
|
|
463
463
|
flags: [
|
|
464
464
|
{ flags: "--source <source>", description: "agentcore log source: runtime (default) or forwarder" },
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -49,6 +49,10 @@ export declare function mountAgentcore(routes: Routes, options: {
|
|
|
49
49
|
stateRoot: string;
|
|
50
50
|
schedules: LoadedSchedule[];
|
|
51
51
|
onStateReady?: () => void;
|
|
52
|
+
/** The serving path's LAZY channel surface: constructed by the adapter on the first envelope
|
|
53
|
+
* AFTER the state-snapshot restore, never at boot (channels/agentcore.ts). When absent,
|
|
54
|
+
* `routes` is the dispatch target — for wirings whose state root is already authoritative. */
|
|
55
|
+
lazyChannels?: () => Promise<Routes>;
|
|
52
56
|
}): Routes;
|
|
53
57
|
/**
|
|
54
58
|
* Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
|