@fastagent-sh/fastagent 0.12.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/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,161 @@
|
|
|
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 standalone `fastagent dev/start` server.
|
|
10
|
+
*/
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
import { log } from "../log.js";
|
|
13
|
+
import { readBodyCapped } from "./body.js";
|
|
14
|
+
import { text, textHeaders } from "./respond.js";
|
|
15
|
+
/** Request body cap (1 MiB). */
|
|
16
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
/**
|
|
19
|
+
* Fetch-shaped invoke handler. Mount it at any route in the host app; it accepts POST only.
|
|
20
|
+
* Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
|
|
21
|
+
*/
|
|
22
|
+
export function createInvokeHandler(agent) {
|
|
23
|
+
return async (req) => {
|
|
24
|
+
if (req.method !== "POST")
|
|
25
|
+
return text("POST only\n", 405);
|
|
26
|
+
const body = await readBodyCapped(req, MAX_BODY_BYTES);
|
|
27
|
+
if ("tooLarge" in body)
|
|
28
|
+
return text("body too large\n", 413);
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(body.text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return text("invalid json\n", 400);
|
|
35
|
+
}
|
|
36
|
+
const { session, text: promptText } = (payload ?? {});
|
|
37
|
+
if (typeof session !== "string" || typeof promptText !== "string") {
|
|
38
|
+
return text('need { "session": string, "text": string }\n', 400);
|
|
39
|
+
}
|
|
40
|
+
// Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
|
|
41
|
+
// run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
|
|
42
|
+
const iterator = agent.invoke({ session }, { text: promptText })[Symbol.asyncIterator]();
|
|
43
|
+
const stream = new ReadableStream({
|
|
44
|
+
async pull(controller) {
|
|
45
|
+
const { value, done } = await iterator.next();
|
|
46
|
+
if (done) {
|
|
47
|
+
controller.close();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
|
|
51
|
+
},
|
|
52
|
+
async cancel() {
|
|
53
|
+
await iterator.return?.();
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return new Response(stream, {
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: {
|
|
59
|
+
"content-type": "text/event-stream",
|
|
60
|
+
"cache-control": "no-cache",
|
|
61
|
+
connection: "keep-alive",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
|
|
68
|
+
* Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
|
|
69
|
+
* cancels both the request signal and the response stream (→ invoke cancellation).
|
|
70
|
+
*/
|
|
71
|
+
export function nodeListener(handler) {
|
|
72
|
+
return (req, res) => {
|
|
73
|
+
void pump(handler, req, res); // safe: pump is TOTAL (never rejects) — see its contract below
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Consume ONE request and drive its response to a terminal state. pump is TOTAL: a SINGLE try/catch wraps
|
|
78
|
+
* the whole request→response→stream path, so EVERY failure — a handler throw, a non-Response return
|
|
79
|
+
* (`response.headers` undefined), a header Node rejects, `getReader`, or a body stream that errors
|
|
80
|
+
* mid-flight — ends the response and the returned promise NEVER rejects, which is what lets the
|
|
81
|
+
* `void pump(...)` above be safe. Before any byte goes out (headers not sent) it is a clean 500; once the
|
|
82
|
+
* response is streaming, the only honest signal left is to destroy the socket (truncated stream, not a
|
|
83
|
+
* hang). The process installs no `unhandledRejection` handler by design: robustness against a background
|
|
84
|
+
* throw is each fire-and-forget's OWN contract (fail into a terminal HTTP response here), not a global net
|
|
85
|
+
* that would blanket-swallow.
|
|
86
|
+
*/
|
|
87
|
+
async function pump(handler, req, res) {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
res.on("close", () => controller.abort());
|
|
90
|
+
try {
|
|
91
|
+
const method = req.method ?? "GET";
|
|
92
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
93
|
+
const headers = new Headers();
|
|
94
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
95
|
+
if (Array.isArray(v))
|
|
96
|
+
for (const vv of v)
|
|
97
|
+
headers.append(k, vv);
|
|
98
|
+
else if (v != null)
|
|
99
|
+
headers.set(k, v);
|
|
100
|
+
}
|
|
101
|
+
const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
|
|
102
|
+
method,
|
|
103
|
+
headers,
|
|
104
|
+
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
105
|
+
duplex: "half",
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
});
|
|
108
|
+
const response = await handler(request);
|
|
109
|
+
const outHeaders = {};
|
|
110
|
+
response.headers.forEach((value, key) => {
|
|
111
|
+
outHeaders[key] = value;
|
|
112
|
+
});
|
|
113
|
+
res.writeHead(response.status, outHeaders);
|
|
114
|
+
if (!response.body) {
|
|
115
|
+
res.end();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const reader = response.body.getReader();
|
|
119
|
+
res.on("close", () => void reader.cancel());
|
|
120
|
+
for (;;) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done || res.destroyed)
|
|
123
|
+
break;
|
|
124
|
+
// Backpressure: wait for drain, but ALSO resolve on close. A client disconnect after write()
|
|
125
|
+
// returned false never emits 'drain' on the closed socket, so waiting on 'drain' alone would
|
|
126
|
+
// suspend pump() forever (leaking the request/stream).
|
|
127
|
+
if (!res.write(value)) {
|
|
128
|
+
await new Promise((resolve) => {
|
|
129
|
+
const settle = () => {
|
|
130
|
+
res.off("drain", settle);
|
|
131
|
+
res.off("close", settle);
|
|
132
|
+
resolve();
|
|
133
|
+
};
|
|
134
|
+
res.once("drain", settle);
|
|
135
|
+
res.once("close", settle);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!res.destroyed)
|
|
140
|
+
res.end(); // normal completion
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// The ONE totality boundary: every failure above lands here, so pump never rejects (see the header
|
|
144
|
+
// doc) — which REQUIRES the catch itself not to throw. Don't leak the internal message to the client.
|
|
145
|
+
log.error(`[host] request failed: ${String(error)}`);
|
|
146
|
+
// Never touch an already-terminal res: a client that disconnects during the handler await destroys res
|
|
147
|
+
// (headers not yet sent), and writeHead/end on a dead socket can throw ERR_STREAM_DESTROYED here — which
|
|
148
|
+
// WOULD be the unhandled rejection this boundary exists to kill. One named gate states the invariant;
|
|
149
|
+
// with it the catch is provably non-throwing (writeHead only when !headersSent && !destroyed, destroy is
|
|
150
|
+
// idempotent).
|
|
151
|
+
if (res.destroyed)
|
|
152
|
+
return;
|
|
153
|
+
if (res.headersSent) {
|
|
154
|
+
res.destroy(error instanceof Error ? error : undefined); // streaming → truncate (not a hang)
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
res.writeHead(500, textHeaders); // pre-header → a clean 500
|
|
158
|
+
res.end("internal error\n");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** One buffered un-summoned message (object identity is the commit key). Besides the sender label and
|
|
2
|
+
* one-line body, it carries what a LATER summon needs to resolve references into the discussion:
|
|
3
|
+
* message ids ("reply to the one Alex answered"), and attachment file_ids so "summarize the file from
|
|
4
|
+
* earlier" can actually open it — an un-summoned attachment otherwise surfaces only as its caption or
|
|
5
|
+
* a `[document: …]` label, never the bytes. */
|
|
6
|
+
export interface BufferEntry {
|
|
7
|
+
sender: string;
|
|
8
|
+
body: string;
|
|
9
|
+
/** The message's id — rendered into the fold so the model can correlate replies. */
|
|
10
|
+
messageId?: number;
|
|
11
|
+
/** The message_id this one replied to, when it was a reply. */
|
|
12
|
+
replyTo?: number;
|
|
13
|
+
/** file_ids of document/voice/video/audio attachments (downloadable on a later summon). */
|
|
14
|
+
fileIds?: string[];
|
|
15
|
+
/** file_ids of photos (usable as vision inputs on a later summon). */
|
|
16
|
+
imageIds?: string[];
|
|
17
|
+
}
|
|
18
|
+
/** A buffered attachment reference: its file_id plus WHO posted it in WHICH message, so the manifest
|
|
19
|
+
* can attribute it ("the file Bob sent") the way the fold attributes text. */
|
|
20
|
+
export interface BufferedRef {
|
|
21
|
+
id: string;
|
|
22
|
+
from: string;
|
|
23
|
+
msg?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The buffered attachment references a summoned turn pulls in with the fold — most recent
|
|
27
|
+
* BUFFER_ATTACH_MAX of each kind, MINUS the summoning message's own ids: replying to a still-buffered
|
|
28
|
+
* attachment puts its file_id in both sets, and without the filter the same file would download twice
|
|
29
|
+
* and appear in the manifest twice (primary wins — it is what the user pointed at this turn).
|
|
30
|
+
* Cap-skipped ones are COUNTED, not silently dropped: a fold line may show a [document: …] label, but
|
|
31
|
+
* a captioned attachment renders as its caption text alone — without a note, the model holds
|
|
32
|
+
* references it silently cannot open and may pretend it read them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function collectAttachments(consumed: BufferEntry[], primary: {
|
|
35
|
+
files: Set<string>;
|
|
36
|
+
images: Set<string>;
|
|
37
|
+
}): {
|
|
38
|
+
files: BufferedRef[];
|
|
39
|
+
images: BufferedRef[];
|
|
40
|
+
skipped: number;
|
|
41
|
+
};
|
|
42
|
+
export interface ContextBuffer {
|
|
43
|
+
/** Record an un-summoned message. Persists BEFORE returning (pre-ACK: a throw becomes the webhook's
|
|
44
|
+
* 500, and Telegram redelivers once the disk recovers) — staged on a copy and rolled back on a
|
|
45
|
+
* failed write, so the redelivery does not double-append the entry already in memory. */
|
|
46
|
+
push(placeKey: string, entry: BufferEntry): void;
|
|
47
|
+
/** Render the fold text and snapshot the consumed entries (see the module header's consume protocol). */
|
|
48
|
+
peek(placeKey: string): {
|
|
49
|
+
text: string;
|
|
50
|
+
consumed: BufferEntry[];
|
|
51
|
+
};
|
|
52
|
+
/** Remove exactly `consumed` (by identity) — call on the turn's `completed` event, when the folded
|
|
53
|
+
* discussion provably lives in the durable session. Consumes entries WHOLE, including ones whose
|
|
54
|
+
* attachments failed to load or were cap-skipped: their text is in the session (keeping them would
|
|
55
|
+
* re-fold duplicate text), and the prompt note said what is missing; re-post an attachment to use
|
|
56
|
+
* it. Post-ACK: a failed write is logged, never thrown (it must not abort the turn's delivery). */
|
|
57
|
+
commit(placeKey: string, consumed: BufferEntry[]): void;
|
|
58
|
+
}
|
|
59
|
+
export declare function createContextBuffer(path: string): ContextBuffer;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group-context buffer: recent UN-summoned messages per Telegram "place" (chat[:thread]), kept under a
|
|
3
|
+
* char budget and folded into the next answered turn's prompt, so a summoned agent has the discussion
|
|
4
|
+
* it didn't see turn-by-turn. Bucketed by place (not session): an un-summoned message has no route
|
|
5
|
+
* session, and the flush feeds whatever turn answers that place.
|
|
6
|
+
*
|
|
7
|
+
* DURABLE: persisted synchronously before the webhook 200 (Telegram never redelivers an ACKed update,
|
|
8
|
+
* so ACK-then-persist would be a silent-loss window) and reloaded on start. The consume protocol is
|
|
9
|
+
* peek → (turn completes) → commit: peek renders WITHOUT clearing and snapshots exactly which entries
|
|
10
|
+
* it consumed; commit removes only those, by object identity — so a failure or crash before the turn's
|
|
11
|
+
* `completed` leaves them intact for the next summon, and a message that arrives while the turn runs
|
|
12
|
+
* survives for the next answered turn (a whole-bucket delete would lose it).
|
|
13
|
+
*/
|
|
14
|
+
import { log } from "../../log.js";
|
|
15
|
+
import { loadStateFile, saveStateFile } from "./state.js";
|
|
16
|
+
/** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
|
|
17
|
+
* the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
|
|
18
|
+
* sparse-but-relevant lines, a busy burst is capped). */
|
|
19
|
+
const BUFFER_MAX_CHARS = 4000;
|
|
20
|
+
/** How many buffered files and images (each, most recent first) a summon pulls in with the folded
|
|
21
|
+
* discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
|
|
22
|
+
* group posting many attachments between summons. Skipped ones are counted into the prompt note, so
|
|
23
|
+
* the model never sees an attachment reference it silently cannot open. */
|
|
24
|
+
const BUFFER_ATTACH_MAX = 3;
|
|
25
|
+
/** One fold line. ALSO the eviction cost basis: the budget must price what the fold actually renders
|
|
26
|
+
* (sender + body + the msg/reply meta), or the fold would systematically overrun BUFFER_MAX_CHARS. */
|
|
27
|
+
function bufferLine(e) {
|
|
28
|
+
const meta = [
|
|
29
|
+
e.messageId !== undefined ? `msg ${e.messageId}` : undefined,
|
|
30
|
+
e.replyTo !== undefined ? `reply to msg ${e.replyTo}` : undefined,
|
|
31
|
+
]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(", ");
|
|
34
|
+
return `${e.sender}${meta ? ` (${meta})` : ""}: ${e.body}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The buffered attachment references a summoned turn pulls in with the fold — most recent
|
|
38
|
+
* BUFFER_ATTACH_MAX of each kind, MINUS the summoning message's own ids: replying to a still-buffered
|
|
39
|
+
* attachment puts its file_id in both sets, and without the filter the same file would download twice
|
|
40
|
+
* and appear in the manifest twice (primary wins — it is what the user pointed at this turn).
|
|
41
|
+
* Cap-skipped ones are COUNTED, not silently dropped: a fold line may show a [document: …] label, but
|
|
42
|
+
* a captioned attachment renders as its caption text alone — without a note, the model holds
|
|
43
|
+
* references it silently cannot open and may pretend it read them.
|
|
44
|
+
*/
|
|
45
|
+
export function collectAttachments(consumed, primary) {
|
|
46
|
+
const refs = (pick, exclude) => {
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const e of consumed) {
|
|
50
|
+
for (const id of pick(e) ?? []) {
|
|
51
|
+
if (exclude.has(id) || seen.has(id))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(id);
|
|
54
|
+
out.push({ id, from: e.sender, msg: e.messageId });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
};
|
|
59
|
+
const files = refs((e) => e.fileIds, primary.files);
|
|
60
|
+
const images = refs((e) => e.imageIds, primary.images);
|
|
61
|
+
return {
|
|
62
|
+
files: files.slice(-BUFFER_ATTACH_MAX),
|
|
63
|
+
images: images.slice(-BUFFER_ATTACH_MAX),
|
|
64
|
+
skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** State files are an IO boundary: valid JSON of the WRONG SHAPE (hand-edited, version drift) must
|
|
68
|
+
* degrade exactly like a corrupt file — warn + empty — not flow in as trusted data. */
|
|
69
|
+
function isBufferEntry(e) {
|
|
70
|
+
const t = e;
|
|
71
|
+
const strings = (v) => v === undefined || (Array.isArray(v) && v.every((x) => typeof x === "string"));
|
|
72
|
+
return (typeof t?.sender === "string" &&
|
|
73
|
+
typeof t.body === "string" &&
|
|
74
|
+
(t.messageId === undefined || typeof t.messageId === "number") &&
|
|
75
|
+
(t.replyTo === undefined || typeof t.replyTo === "number") &&
|
|
76
|
+
strings(t.fileIds) &&
|
|
77
|
+
strings(t.imageIds));
|
|
78
|
+
}
|
|
79
|
+
export function createContextBuffer(path) {
|
|
80
|
+
const load = () => {
|
|
81
|
+
const raw = loadStateFile(path);
|
|
82
|
+
if (raw === undefined)
|
|
83
|
+
return new Map();
|
|
84
|
+
if (typeof raw === "object" &&
|
|
85
|
+
raw !== null &&
|
|
86
|
+
!Array.isArray(raw) &&
|
|
87
|
+
Object.values(raw).every((v) => Array.isArray(v) && v.every(isBufferEntry))) {
|
|
88
|
+
return new Map(Object.entries(raw));
|
|
89
|
+
}
|
|
90
|
+
log.warn(`[telegram] unexpected shape in ${path} — starting with an empty buffer`);
|
|
91
|
+
return new Map();
|
|
92
|
+
};
|
|
93
|
+
const buffers = load();
|
|
94
|
+
const persist = () => saveStateFile(path, Object.fromEntries(buffers));
|
|
95
|
+
return {
|
|
96
|
+
push(placeKey, entry) {
|
|
97
|
+
const prev = buffers.get(placeKey);
|
|
98
|
+
const buf = prev ? [...prev] : [];
|
|
99
|
+
buf.push(entry);
|
|
100
|
+
let total = buf.reduce((n, e) => n + bufferLine(e).length + 1, 0);
|
|
101
|
+
while (buf.length > 1 && total > BUFFER_MAX_CHARS) {
|
|
102
|
+
const dropped = buf.shift();
|
|
103
|
+
if (dropped)
|
|
104
|
+
total -= bufferLine(dropped).length + 1;
|
|
105
|
+
}
|
|
106
|
+
buffers.set(placeKey, buf);
|
|
107
|
+
try {
|
|
108
|
+
persist();
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (prev)
|
|
112
|
+
buffers.set(placeKey, prev);
|
|
113
|
+
else
|
|
114
|
+
buffers.delete(placeKey);
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
peek(placeKey) {
|
|
119
|
+
const buf = buffers.get(placeKey) ?? [];
|
|
120
|
+
return { text: buf.map(bufferLine).join("\n"), consumed: [...buf] };
|
|
121
|
+
},
|
|
122
|
+
commit(placeKey, consumed) {
|
|
123
|
+
const buf = buffers.get(placeKey);
|
|
124
|
+
if (!buf)
|
|
125
|
+
return;
|
|
126
|
+
const remaining = buf.filter((e) => !consumed.includes(e));
|
|
127
|
+
if (remaining.length === 0)
|
|
128
|
+
buffers.delete(placeKey);
|
|
129
|
+
else
|
|
130
|
+
buffers.set(placeKey, remaining);
|
|
131
|
+
try {
|
|
132
|
+
persist();
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
log.error(`[telegram] buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(e)}`);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run one turn (the IO half of Telegram→Agent translation): assemble its inputs — resolve attachments
|
|
3
|
+
* (download files to disk, load vision images) — and stream `agent.invoke` with the assembled prompt.
|
|
4
|
+
* `invokeTurn` is the export; attachment resolution is an internal step. Split from parse.ts (which is
|
|
5
|
+
* pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
|
|
6
|
+
* wiring and the per-turn lifecycle.
|
|
7
|
+
*/
|
|
8
|
+
import { type Agent, type AgentEvent } from "../../agent.ts";
|
|
9
|
+
import type { BufferedRef } from "./context-buffer.ts";
|
|
10
|
+
/** Everything the transport needs to fetch a turn's attachments. */
|
|
11
|
+
export interface TurnTransport {
|
|
12
|
+
api: string;
|
|
13
|
+
botToken: string;
|
|
14
|
+
chatId: number | string;
|
|
15
|
+
filesDir: string;
|
|
16
|
+
}
|
|
17
|
+
/** A turn's attachment inputs: the summoning message's own file_ids (primary) and the ones folded in
|
|
18
|
+
* from the un-summoned discussion (buffered). */
|
|
19
|
+
export interface TurnAttachments {
|
|
20
|
+
primary: {
|
|
21
|
+
imageFileIds?: string[];
|
|
22
|
+
fileIds?: string[];
|
|
23
|
+
};
|
|
24
|
+
buffered: {
|
|
25
|
+
files: BufferedRef[];
|
|
26
|
+
images: BufferedRef[];
|
|
27
|
+
skipped: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
|
|
31
|
+
* EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
|
|
32
|
+
* channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
|
|
33
|
+
* always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
|
|
34
|
+
export interface BusyRetry {
|
|
35
|
+
delayMs: number;
|
|
36
|
+
maxWaitMs: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run one turn: resolve its attachments, then stream agent.invoke. A primary-attachment failure surfaces
|
|
40
|
+
* as a `failed` event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed`
|
|
41
|
+
* event — the durable-commit point: only then does the folded discussion provably live in the session,
|
|
42
|
+
* so a failure or crash at ANY earlier point leaves the buffer intact for the next summon (a re-folded
|
|
43
|
+
* block beats lost context). The caller uses it to remove the turn intent AND commit the context buffer,
|
|
44
|
+
* in that order (see the call site) so a crash between the two clears cannot replay a context-stripped turn.
|
|
45
|
+
*
|
|
46
|
+
* BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn (e.g. a self-scheduled
|
|
47
|
+
* wake) holds this session's lease and OUR turn never started — replay-safe. Retry (bounded) instead of
|
|
48
|
+
* yielding it: the user sees the "Thinking…" placeholder while waiting (the mirror of the scheduler
|
|
49
|
+
* deferring a wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a
|
|
50
|
+
* FIRST-event busy retries — attachments are already resolved, and a fail-fast reject is the only shape
|
|
51
|
+
* the engine emits it in, so nothing that started is ever re-run.
|
|
52
|
+
*/
|
|
53
|
+
export declare function invokeTurn(agent: Agent, session: string, text: string, transport: TurnTransport, attachments: TurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run one turn (the IO half of Telegram→Agent translation): assemble its inputs — resolve attachments
|
|
3
|
+
* (download files to disk, load vision images) — and stream `agent.invoke` with the assembled prompt.
|
|
4
|
+
* `invokeTurn` is the export; attachment resolution is an internal step. Split from parse.ts (which is
|
|
5
|
+
* pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
|
|
6
|
+
* wiring and the per-turn lifecycle.
|
|
7
|
+
*/
|
|
8
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
9
|
+
import { log } from "../../log.js";
|
|
10
|
+
import { resolveFiles, resolveImages } from "./telegram-api.js";
|
|
11
|
+
/** Appended to the prompt (not the system prompt): the channel owns Telegram-HTML formatting. */
|
|
12
|
+
const HTML_INSTRUCTION = "\n\n(Format your reply in Telegram-supported HTML — <b> <i> <u> <s> <code> <pre> <a href> — not Markdown.)";
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a turn's attachments: images (vision) inline, files downloaded to disk with their absolute
|
|
15
|
+
* paths listed in a manifest the agent reads with its tools. Two tiers, different failure policies.
|
|
16
|
+
* PRIMARY (this turn's own message) THROWS on any load failure — the caller turns it into a `failed`
|
|
17
|
+
* event, so the agent never runs on inputs the user sent but we failed to load. BACKGROUND (`buffered`,
|
|
18
|
+
* from the un-summoned discussion) degrades PER ATTACHMENT — a warn + a prompt note — rather than
|
|
19
|
+
* failing the ask it merely accompanies: one expired earlier file must neither block the answer nor drag
|
|
20
|
+
* down its still-valid siblings. Parallel (allSettled keeps input order + per-attachment isolation); the
|
|
21
|
+
* note counts EVERY missing one (load failures + cap-skipped) so the model never holds a reference it
|
|
22
|
+
* silently cannot open.
|
|
23
|
+
*/
|
|
24
|
+
async function resolveTurnAttachments(t, attachments) {
|
|
25
|
+
const { api, botToken, chatId, filesDir } = t;
|
|
26
|
+
const { primary, buffered } = attachments;
|
|
27
|
+
const images = await resolveImages(api, botToken, primary.imageFileIds);
|
|
28
|
+
const files = await resolveFiles(api, botToken, primary.fileIds, chatId, filesDir);
|
|
29
|
+
const bufferedImages = [];
|
|
30
|
+
const bufferedFiles = [];
|
|
31
|
+
let lost = 0;
|
|
32
|
+
const imageResults = await Promise.allSettled(buffered.images.map((ref) => resolveImages(api, botToken, [ref.id])));
|
|
33
|
+
for (const r of imageResults) {
|
|
34
|
+
if (r.status === "fulfilled")
|
|
35
|
+
bufferedImages.push(...(r.value ?? []));
|
|
36
|
+
else {
|
|
37
|
+
lost++;
|
|
38
|
+
log.warn(`[telegram] could not load an earlier (buffered) photo: ${String(r.reason)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const fileResults = await Promise.allSettled(buffered.files.map(async (ref) => ({
|
|
42
|
+
ref,
|
|
43
|
+
files: (await resolveFiles(api, botToken, [ref.id], chatId, filesDir)) ?? [],
|
|
44
|
+
})));
|
|
45
|
+
for (const r of fileResults) {
|
|
46
|
+
if (r.status === "fulfilled") {
|
|
47
|
+
for (const file of r.value.files)
|
|
48
|
+
bufferedFiles.push({ file, ref: r.value.ref });
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
lost++;
|
|
52
|
+
log.warn(`[telegram] could not load an earlier (buffered) attachment: ${String(r.reason)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const missing = lost + buffered.skipped;
|
|
56
|
+
const bufferedNote = missing > 0
|
|
57
|
+
? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
|
|
58
|
+
: "";
|
|
59
|
+
// PRIMARY first, background after — consistent with "primary wins": what the user pointed at this
|
|
60
|
+
// turn leads. Buffered file entries are attributed like the fold's text lines ("the file Bob sent"
|
|
61
|
+
// resolves); buffered PHOTOS cannot be (ImageRef carries no label), so their attribution stops at
|
|
62
|
+
// the fold's attachment markers (the buffer appends `[photo]` even to captioned lines) — a
|
|
63
|
+
// documented limit.
|
|
64
|
+
const allFiles = [
|
|
65
|
+
...(files ?? []),
|
|
66
|
+
...bufferedFiles.map(({ file, ref }) => ({
|
|
67
|
+
...file,
|
|
68
|
+
name: `${file.name} (from ${ref.from}${ref.msg !== undefined ? `, msg ${ref.msg}` : ""}, earlier discussion)`,
|
|
69
|
+
})),
|
|
70
|
+
];
|
|
71
|
+
const manifest = allFiles.length
|
|
72
|
+
? `\n\n[attached files — read them with your tools:\n${allFiles.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
|
|
73
|
+
: "";
|
|
74
|
+
const allImages = [...(images ?? []), ...bufferedImages];
|
|
75
|
+
return { images: allImages.length ? allImages : undefined, promptSuffix: `${bufferedNote}${manifest}` };
|
|
76
|
+
}
|
|
77
|
+
// Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
|
|
78
|
+
// free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
|
|
79
|
+
// real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
|
|
80
|
+
// this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
|
|
81
|
+
// turn forever.
|
|
82
|
+
const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
83
|
+
/**
|
|
84
|
+
* Run one turn: resolve its attachments, then stream agent.invoke. A primary-attachment failure surfaces
|
|
85
|
+
* as a `failed` event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed`
|
|
86
|
+
* event — the durable-commit point: only then does the folded discussion provably live in the session,
|
|
87
|
+
* so a failure or crash at ANY earlier point leaves the buffer intact for the next summon (a re-folded
|
|
88
|
+
* block beats lost context). The caller uses it to remove the turn intent AND commit the context buffer,
|
|
89
|
+
* in that order (see the call site) so a crash between the two clears cannot replay a context-stripped turn.
|
|
90
|
+
*
|
|
91
|
+
* BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn (e.g. a self-scheduled
|
|
92
|
+
* wake) holds this session's lease and OUR turn never started — replay-safe. Retry (bounded) instead of
|
|
93
|
+
* yielding it: the user sees the "Thinking…" placeholder while waiting (the mirror of the scheduler
|
|
94
|
+
* deferring a wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a
|
|
95
|
+
* FIRST-event busy retries — attachments are already resolved, and a fail-fast reject is the only shape
|
|
96
|
+
* the engine emits it in, so nothing that started is ever re-run.
|
|
97
|
+
*/
|
|
98
|
+
export async function* invokeTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
|
|
99
|
+
let resolved;
|
|
100
|
+
try {
|
|
101
|
+
resolved = await resolveTurnAttachments(transport, attachments);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
yield { type: "failed", details: `could not load attachment: ${String(e)}`, retryable: true };
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
|
|
108
|
+
const deadline = Date.now() + busyRetry.maxWaitMs;
|
|
109
|
+
for (;;) {
|
|
110
|
+
let retryBusy = false;
|
|
111
|
+
let first = true;
|
|
112
|
+
for await (const e of agent.invoke({ session }, prompt)) {
|
|
113
|
+
if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
|
|
114
|
+
retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
first = false;
|
|
118
|
+
if (e.type === "completed")
|
|
119
|
+
onCompleted?.(); // the turn is durably in the session — commit point
|
|
120
|
+
yield e;
|
|
121
|
+
}
|
|
122
|
+
if (!retryBusy)
|
|
123
|
+
return;
|
|
124
|
+
log.info(`[telegram] session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
|
|
125
|
+
await new Promise((r) => setTimeout(r, busyRetry.delayMs));
|
|
126
|
+
}
|
|
127
|
+
}
|