@fastagent-sh/fastagent 0.14.0 β 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +35 -0
- package/dist/channels/context-buffer.js +103 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +15 -13
- package/dist/channels/feishu/context-buffer.js +62 -69
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +24 -3
- package/dist/channels/feishu/feishu.d.ts +27 -27
- package/dist/channels/feishu/feishu.js +318 -207
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +44 -79
- package/dist/channels/feishu/model.d.ts +2 -2
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +22 -8
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +27 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +37 -0
- package/dist/channels/feishu/setup-mode.js +28 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +16 -7
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +36 -3
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +115 -0
- package/dist/channels/preview-kit.js +184 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/parse.d.ts +38 -0
- package/dist/channels/slack/parse.js +124 -0
- package/dist/channels/slack/preview.d.ts +23 -0
- package/dist/channels/slack/preview.js +403 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +57 -0
- package/dist/channels/slack/slack-api.js +399 -0
- package/dist/channels/slack/slack.d.ts +51 -0
- package/dist/channels/slack/slack.js +496 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +22 -15
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +7 -0
- package/dist/channels/tasks.js +25 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +24 -10
- package/dist/channels/{feishu/text.d.ts β text.d.ts} +15 -1
- package/dist/channels/{feishu/text.js β text.js} +15 -1
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +8 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +30 -0
- package/dist/cli/add-feishu.js +366 -0
- package/dist/cli/add-slack.d.ts +11 -0
- package/dist/cli/add-slack.js +199 -0
- package/dist/{cli-auth.d.ts β cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js β cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +191 -60
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +563 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +12 -7
- package/dist/cli/commands/deploy.d.ts +3 -1
- package/dist/cli/commands/deploy.js +384 -82
- package/dist/cli/commands/dev.js +34 -26
- package/dist/cli/commands/fire.js +17 -18
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +11 -9
- package/dist/cli/commands/login.js +48 -45
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +102 -39
- package/dist/cli/commands/tool.js +23 -18
- package/dist/cli/fail.d.ts +17 -2
- package/dist/cli/fail.js +25 -1
- package/dist/{invoke-stream.d.ts β cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js β cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +135 -56
- package/dist/cli/serve.d.ts +60 -20
- package/dist/cli/serve.js +252 -47
- package/dist/cli/shared.d.ts +40 -7
- package/dist/cli/shared.js +180 -38
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +3 -3
- package/dist/deploy/docker/plan.js +20 -23
- package/dist/deploy/fly/plan.d.ts +5 -1
- package/dist/deploy/fly/plan.js +39 -26
- package/dist/deploy/fly/run.d.ts +17 -5
- package/dist/deploy/fly/run.js +52 -7
- package/dist/deploy/preflight.d.ts +20 -8
- package/dist/deploy/preflight.js +267 -71
- package/dist/deploy/railway/plan.d.ts +10 -1
- package/dist/deploy/railway/plan.js +53 -20
- package/dist/deploy/railway/run.d.ts +13 -5
- package/dist/deploy/railway/run.js +22 -7
- package/dist/deploy/runner.d.ts +7 -3
- package/dist/deploy/runner.js +9 -3
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +11 -10
- package/dist/dev-supervisor.js +56 -50
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +92 -62
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +20 -55
- package/dist/engines/pi/config.js +49 -121
- package/dist/engines/pi/create.d.ts +30 -10
- package/dist/engines/pi/create.js +33 -26
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +12 -1
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +59 -13
- package/dist/engines/pi/models.js +62 -24
- package/dist/engines/pi/open.d.ts +114 -0
- package/dist/engines/pi/open.js +127 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +10 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +25 -8
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +6 -5
- package/dist/pi.js +4 -3
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +26 -9
- package/dist/scaffold/add-channel.js +175 -40
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +5 -4
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -3
- package/dist/schedule/wakeups.js +19 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +23 -12
- package/package.json +22 -7
- package/dist/channels/feishu/owned-threads.d.ts +0 -11
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-add-feishu.js +0 -223
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
- package/dist/engines/pi/workspace.d.ts +0 -56
- package/dist/engines/pi/workspace.js +0 -72
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHARED user-facing stop command: the chat channels (telegram, feishu/lark, slack) map an explicit
|
|
3
|
+
* "stop" ask onto the session control plane's `abort`. Policy decisions (design session-control.md
|
|
4
|
+
* Β§15): the stop message is a CONTROL ACTION, never a turn (it must not queue behind the run it
|
|
5
|
+
* stops); only the ACTIVE run is aborted β queued durable turns are independent asks and keep their
|
|
6
|
+
* at-least-once floor; and the hub stays gated by `config.sessionControl`, so without it the command
|
|
7
|
+
* degrades to a visible "not enabled" notice, never a silent ignore.
|
|
8
|
+
*/
|
|
9
|
+
import { log } from "../log.js";
|
|
10
|
+
import { NO_ACTIVE_RUN_CODE } from "../session.js";
|
|
11
|
+
/** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
|
|
12
|
+
export function isStopText(text) {
|
|
13
|
+
return /^(stop|cancel)[.!]?$/i.test(text.trim());
|
|
14
|
+
}
|
|
15
|
+
const STOPPED_NOTICE = "βΉ Stopped.";
|
|
16
|
+
const NOTHING_RUNNING_NOTICE = "Nothing is running.";
|
|
17
|
+
const STOP_UNAVAILABLE_NOTICE = "β οΈ Stop isn't enabled on this deployment (set sessionControl: true in fastagent.config).";
|
|
18
|
+
/** Dispatch `abort` for the session and map the outcome to the customer-facing line. Never throws;
|
|
19
|
+
* full details go to the operator log. */
|
|
20
|
+
export async function dispatchStop(control, session, label) {
|
|
21
|
+
if (!control)
|
|
22
|
+
return STOP_UNAVAILABLE_NOTICE;
|
|
23
|
+
try {
|
|
24
|
+
const result = await control.dispatch(session, { type: "abort" });
|
|
25
|
+
if (result.ok)
|
|
26
|
+
return STOPPED_NOTICE;
|
|
27
|
+
if (result.error.code === NO_ACTIVE_RUN_CODE)
|
|
28
|
+
return NOTHING_RUNNING_NOTICE;
|
|
29
|
+
log.warn(`${label} stop dispatch rejected for ${session}: ${result.error.code} β ${result.error.message}`);
|
|
30
|
+
return `β οΈ Could not stop (${result.error.code}).`;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
log.warn(`${label} stop dispatch failed for ${session}: ${String(error)}`);
|
|
34
|
+
return "β οΈ Could not stop β see the server logs.";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface TaskTracker {
|
|
2
|
+
/** Track one task. The caller keeps its own `.catch` β rejections must already be handled. */
|
|
3
|
+
track(task: Promise<unknown>): void;
|
|
4
|
+
/** Resolves when every currently-tracked task has settled. */
|
|
5
|
+
drain(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function createTaskTracker(): TaskTracker;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
|
|
3
|
+
* feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
|
|
4
|
+
* (`turnsIdle`) β otherwise a reply in flight when the process exits is silently dropped. Error
|
|
5
|
+
* handling stays with the caller: track() only guarantees the drain sees the task settle.
|
|
6
|
+
*/
|
|
7
|
+
import { beginWork } from "./busy.js";
|
|
8
|
+
export function createTaskTracker() {
|
|
9
|
+
const tasks = new Set();
|
|
10
|
+
return {
|
|
11
|
+
track(task) {
|
|
12
|
+
tasks.add(task);
|
|
13
|
+
// Tracked side tasks count as process-wide in-flight work (busy.ts) β same signal the turn
|
|
14
|
+
// queue reports, read by serving surfaces that must not idle while background work runs.
|
|
15
|
+
const workDone = beginWork();
|
|
16
|
+
void task
|
|
17
|
+
.finally(() => {
|
|
18
|
+
workDone();
|
|
19
|
+
tasks.delete(task);
|
|
20
|
+
})
|
|
21
|
+
.catch(() => { }); // the caller's chain owns the error
|
|
22
|
+
},
|
|
23
|
+
drain: () => Promise.all(tasks).then(() => undefined),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
|
|
3
|
+
* the entry shape, its fold-line rendering, and buffered-attachment selection. Bucketed by Telegram
|
|
4
|
+
* "place" (chat[:thread]), not session: an un-summoned message has no route session, and the flush
|
|
5
|
+
* feeds whatever turn answers that place.
|
|
6
|
+
*/
|
|
7
|
+
import { type ContextBuffer as GenericContextBuffer } from "../context-buffer.ts";
|
|
1
8
|
/** One buffered un-summoned message (object identity is the commit key). Besides the sender label and
|
|
2
9
|
* one-line body, it carries what a LATER summon needs to resolve references into the discussion:
|
|
3
10
|
* message ids ("reply to the one Alex answered"), and attachment file_ids so "summarize the file from
|
|
@@ -39,21 +46,5 @@ export declare function collectAttachments(consumed: BufferEntry[], primary: {
|
|
|
39
46
|
images: BufferedRef[];
|
|
40
47
|
skipped: number;
|
|
41
48
|
};
|
|
42
|
-
export
|
|
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
|
-
}
|
|
49
|
+
export type ContextBuffer = GenericContextBuffer<BufferEntry>;
|
|
59
50
|
export declare function createContextBuffer(path: string): ContextBuffer;
|
|
@@ -1,29 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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).
|
|
2
|
+
* Telegram's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
|
|
3
|
+
* the entry shape, its fold-line rendering, and buffered-attachment selection. Bucketed by Telegram
|
|
4
|
+
* "place" (chat[:thread]), not session: an un-summoned message has no route session, and the flush
|
|
5
|
+
* feeds whatever turn answers that place.
|
|
13
6
|
*/
|
|
14
|
-
import {
|
|
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. */
|
|
7
|
+
import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
|
|
27
8
|
function bufferLine(e) {
|
|
28
9
|
const meta = [
|
|
29
10
|
e.messageId !== undefined ? `msg ${e.messageId}` : undefined,
|
|
@@ -64,8 +45,6 @@ export function collectAttachments(consumed, primary) {
|
|
|
64
45
|
skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
|
|
65
46
|
};
|
|
66
47
|
}
|
|
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
48
|
function isBufferEntry(e) {
|
|
70
49
|
const t = e;
|
|
71
50
|
const strings = (v) => v === undefined || (Array.isArray(v) && v.every((x) => typeof x === "string"));
|
|
@@ -77,63 +56,5 @@ function isBufferEntry(e) {
|
|
|
77
56
|
strings(t.imageIds));
|
|
78
57
|
}
|
|
79
58
|
export function createContextBuffer(path) {
|
|
80
|
-
|
|
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
|
-
};
|
|
59
|
+
return createGenericContextBuffer({ path, label: "[telegram]", isEntry: isBufferEntry, line: bufferLine });
|
|
139
60
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
|
|
6
6
|
* wiring and the per-turn lifecycle.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
9
|
+
import { type BusyRetry } from "../invoke-turn-kit.ts";
|
|
9
10
|
import type { BufferedRef } from "./context-buffer.ts";
|
|
10
11
|
/** Everything the transport needs to fetch a turn's attachments. */
|
|
11
12
|
export interface TurnTransport {
|
|
@@ -27,27 +28,9 @@ export interface TurnAttachments {
|
|
|
27
28
|
skipped: number;
|
|
28
29
|
};
|
|
29
30
|
}
|
|
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
31
|
/**
|
|
39
|
-
* Run one turn: resolve its attachments, then stream agent.invoke
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
32
|
+
* Run one turn: resolve its attachments, then stream agent.invoke with the shared busy-wait
|
|
33
|
+
* (invoke-turn-kit β `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
|
|
34
|
+
* primary-attachment failure surfaces as a `failed` event (never a silent drop).
|
|
52
35
|
*/
|
|
53
36
|
export declare function invokeTurn(agent: Agent, session: string, text: string, transport: TurnTransport, attachments: TurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
|
|
@@ -1,12 +1,5 @@
|
|
|
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
1
|
import { log } from "../../log.js";
|
|
2
|
+
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
|
|
10
3
|
import { resolveFiles, resolveImages } from "./telegram-api.js";
|
|
11
4
|
/** Appended to the prompt (not the system prompt): the channel owns Telegram-HTML formatting. */
|
|
12
5
|
const HTML_INSTRUCTION = "\n\n(Format your reply in Telegram-supported HTML β <b> <i> <u> <s> <code> <pre> <a href> β not Markdown.)";
|
|
@@ -52,10 +45,7 @@ async function resolveTurnAttachments(t, attachments) {
|
|
|
52
45
|
log.warn(`[telegram] could not load an earlier (buffered) attachment: ${String(r.reason)}`);
|
|
53
46
|
}
|
|
54
47
|
}
|
|
55
|
-
const
|
|
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
|
-
: "";
|
|
48
|
+
const missingNote = missingAttachmentsNote(lost + buffered.skipped);
|
|
59
49
|
// PRIMARY first, background after β consistent with "primary wins": what the user pointed at this
|
|
60
50
|
// turn leads. Buffered file entries are attributed like the fold's text lines ("the file Bob sent"
|
|
61
51
|
// resolves); buffered PHOTOS cannot be (ImageRef carries no label), so their attribution stops at
|
|
@@ -63,37 +53,18 @@ async function resolveTurnAttachments(t, attachments) {
|
|
|
63
53
|
// documented limit.
|
|
64
54
|
const allFiles = [
|
|
65
55
|
...(files ?? []),
|
|
66
|
-
...bufferedFiles.map(({ file, ref }) => ({
|
|
67
|
-
...file,
|
|
68
|
-
name: `${file.name} (from ${ref.from}${ref.msg !== undefined ? `, msg ${ref.msg}` : ""}, earlier discussion)`,
|
|
69
|
-
})),
|
|
56
|
+
...bufferedFiles.map(({ file, ref }) => ({ ...file, name: attributedFileName(file.name, ref.from, ref.msg) })),
|
|
70
57
|
];
|
|
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
58
|
const allImages = [...(images ?? []), ...bufferedImages];
|
|
75
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
images: allImages.length ? allImages : undefined,
|
|
61
|
+
promptSuffix: `${missingNote}${attachedFilesManifest(allFiles)}`,
|
|
62
|
+
};
|
|
76
63
|
}
|
|
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
64
|
/**
|
|
84
|
-
* Run one turn: resolve its attachments, then stream agent.invoke
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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.
|
|
65
|
+
* Run one turn: resolve its attachments, then stream agent.invoke with the shared busy-wait
|
|
66
|
+
* (invoke-turn-kit β `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
|
|
67
|
+
* primary-attachment failure surfaces as a `failed` event (never a silent drop).
|
|
97
68
|
*/
|
|
98
69
|
export async function* invokeTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
|
|
99
70
|
let resolved;
|
|
@@ -105,23 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
|
|
|
105
76
|
return;
|
|
106
77
|
}
|
|
107
78
|
const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
|
|
108
|
-
|
|
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
|
-
}
|
|
79
|
+
yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
|
|
127
80
|
}
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram protocol parsing β PURE: message field extraction, the prompt envelope, and the summon/route
|
|
3
|
-
* policy. The defining invariant is purity: no state, no IO, no Bot API calls β plain data-in β data-out.
|
|
4
|
-
* In telegram.ts's pipeline (verify β decide via `route` β run the turn β stream reply), this is the
|
|
5
|
-
* "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
|
|
6
|
-
* separate so this layer tests as plain functions and reads without the factory's noise.
|
|
7
|
-
*/
|
|
8
1
|
/** A Telegram message (the common subset; `[k]` keeps the rest reachable without a types dependency). */
|
|
9
2
|
export interface TelegramMessage {
|
|
10
3
|
message_id: number;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
|
|
6
6
|
* separate so this layer tests as plain functions and reads without the factory's noise.
|
|
7
7
|
*/
|
|
8
|
+
import { BUFFER_LINE_MAX_CHARS } from "../context-buffer.js";
|
|
9
|
+
import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
|
|
8
10
|
/** The actionable message in an update (a fresh message or channel post). Edits (`edited_message` /
|
|
9
11
|
* `edited_channel_post`) are deliberately NOT actionable: answering them re-answers every typo fix (a
|
|
10
12
|
* duplicate reply per edit), so an edited message changes nothing β the standard bot behavior. The
|
|
@@ -62,7 +64,7 @@ function bodyOf(m) {
|
|
|
62
64
|
}
|
|
63
65
|
/** A one-line, length-capped rendering of a message's content for the context buffer. */
|
|
64
66
|
export function messageText(m) {
|
|
65
|
-
return (bodyOf(m) ?? "").replace(/\s+/g, " ").trim()
|
|
67
|
+
return truncateCodePointPrefix((bodyOf(m) ?? "").replace(/\s+/g, " ").trim(), BUFFER_LINE_MAX_CHARS);
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
68
70
|
* The default base prompt: a context envelope (chat/thread/sender + a group note + reply) then the
|
|
@@ -88,7 +90,7 @@ export function telegramEnvelope(m) {
|
|
|
88
90
|
const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
|
|
89
91
|
const scope = isGroup ? "\n[group chat β multiple people; each message is prefixed with its sender]" : "";
|
|
90
92
|
const replyTo = r
|
|
91
|
-
? `\n[in reply to ${fromLabel(r.from) ?? `msg ${r.message_id}`} (msg ${r.message_id}): ${(bodyOf(r) ?? "(empty)"
|
|
93
|
+
? `\n[in reply to ${fromLabel(r.from) ?? `msg ${r.message_id}`} (msg ${r.message_id}): ${truncateCodePointPrefix(bodyOf(r) ?? "(empty)", REFERENT_MAX_CODE_POINTS)}]`
|
|
92
94
|
: "";
|
|
93
95
|
const parts = [bodyOf(m) ?? ""];
|
|
94
96
|
if (m.location)
|
|
@@ -6,14 +6,11 @@
|
|
|
6
6
|
* message, works in groups and private (unlike sendMessageDraft, which is private/forum-topic only).
|
|
7
7
|
*/
|
|
8
8
|
import type { AgentEvent } from "../../agent.ts";
|
|
9
|
+
import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
|
|
9
10
|
import { type Target } from "./telegram-api.ts";
|
|
10
|
-
/** A terminal failure, as the channel hands it to `onError
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
retryable: boolean;
|
|
14
|
-
}
|
|
15
|
-
/** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
|
|
16
|
-
export declare function defaultErrorMessage(failed: TelegramFailure): string;
|
|
11
|
+
/** A terminal failure, as the channel hands it to `onError` β the shared channel shape. */
|
|
12
|
+
export type TelegramFailure = ChannelFailure;
|
|
13
|
+
export { defaultErrorMessage };
|
|
17
14
|
/**
|
|
18
15
|
* Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
|
|
19
16
|
* model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
|
|
@@ -1,37 +1,14 @@
|
|
|
1
|
+
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
|
|
1
2
|
import { log } from "../../log.js";
|
|
2
3
|
import { TELEGRAM_MAX_TEXT, callApi, editMessageText, sendMessage } from "./telegram-api.js";
|
|
3
|
-
|
|
4
|
-
export function defaultErrorMessage(failed) {
|
|
5
|
-
return failed.retryable ? "β οΈ Temporary problem β please try again." : "β οΈ Sorry, something went wrong.";
|
|
6
|
-
}
|
|
4
|
+
export { defaultErrorMessage };
|
|
7
5
|
/** How often (ms) to edit the live-preview message; tool events still flush on the next loop. Edits to
|
|
8
6
|
* one message are rate-limited tighter than sends, so pace them ~1.5s (vs every token). Doubles as the
|
|
9
7
|
* answer-preview aging window (see answerView): partial answer text stays hidden until it has existed
|
|
10
8
|
* this long β one knob, same order of magnitude. */
|
|
11
9
|
const EDIT_THROTTLE_MS = 1500;
|
|
12
|
-
/** Max length of a tool's arg preview in the live view. */
|
|
13
|
-
const TOOL_ARG_MAX = 48;
|
|
14
10
|
/** How much of the (growing) reasoning to peek at in the live view β the most recent tail. */
|
|
15
11
|
const THINKING_PREVIEW = 280;
|
|
16
|
-
/** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
|
|
17
|
-
function clip(s) {
|
|
18
|
-
const one = s.replace(/\s+/g, " ").trim();
|
|
19
|
-
return one.length > TOOL_ARG_MAX ? `${one.slice(0, TOOL_ARG_MAX - 1)}β¦` : one;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* A compact, human-readable preview of a tool call's args so the live view reads `π§ read AGENTS.md`
|
|
23
|
-
* rather than just `π§ read`. Generic (the channel knows no tool schemas): show the salient value β the
|
|
24
|
-
* first primitive field, conventionally the subject (path / command / query / url) β else compact JSON.
|
|
25
|
-
*/
|
|
26
|
-
function summarizeArgs(args) {
|
|
27
|
-
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
28
|
-
return clip(String(args));
|
|
29
|
-
const values = Object.values(args);
|
|
30
|
-
const primary = values.find((v) => typeof v === "string" || typeof v === "number");
|
|
31
|
-
if (primary !== undefined)
|
|
32
|
-
return clip(String(primary));
|
|
33
|
-
return values.length > 0 ? clip(JSON.stringify(args)) : "";
|
|
34
|
-
}
|
|
35
12
|
/**
|
|
36
13
|
* The terminal-write POLICY: resolve the single preview message into `text`. streamReply owns the
|
|
37
14
|
* preview lifecycle, so this composition of transport primitives lives here, not in telegram-api. One
|
|
@@ -73,39 +50,18 @@ async function finalize(api, botToken, target, messageId, text, opts = {}) {
|
|
|
73
50
|
* and surfaces a real failure (bad token, etc.).
|
|
74
51
|
*/
|
|
75
52
|
export async function streamReply(events, api, botToken, target, formatError, previewId) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
let answer = "";
|
|
80
|
-
let answerPreviewSince;
|
|
81
|
-
const mark = { running: "β¦", ok: "β", error: "β" };
|
|
82
|
-
const toolView = () => tools.map((t) => `π§ ${t.label} ${mark[t.status]}`).join("\n");
|
|
83
|
-
// Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
|
|
84
|
-
// in the persisted final message (which is `answer` alone).
|
|
85
|
-
const thinkingView = () => {
|
|
86
|
-
const t = thinking.replace(/\s+/g, " ").trim();
|
|
87
|
-
if (t === "")
|
|
88
|
-
return "";
|
|
89
|
-
return `π ${t.length > THINKING_PREVIEW ? `β¦${t.slice(t.length - THINKING_PREVIEW + 1)}` : t}`;
|
|
90
|
-
};
|
|
91
|
-
// The answer is hidden until its first delta has aged one EDIT_THROTTLE_MS: the pump's leading-edge
|
|
92
|
-
// flush would otherwise turn the very first content delta (often a lone character or unbalanced markup)
|
|
93
|
-
// into its own Telegram edit β the short-reply flicker (placeholder β "O" β "OK."). Aging is anchored
|
|
94
|
-
// at delta ARRIVAL (set in the event loop, not here) so an in-flight edit can't skew the clock, and
|
|
95
|
-
// there is deliberately NO timer at the boundary: a young answer surfaces on the next content-driven
|
|
96
|
-
// preview pass, so a turn completing within the window sends the final answer edit only.
|
|
97
|
-
const answerView = () => {
|
|
98
|
-
if (answer.trim() === "" || answerPreviewSince === undefined)
|
|
99
|
-
return "";
|
|
100
|
-
return Date.now() - answerPreviewSince >= EDIT_THROTTLE_MS ? answer : "";
|
|
101
|
-
};
|
|
53
|
+
// Event β view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
|
|
54
|
+
// policy, formatting, and delivery below.
|
|
55
|
+
const turn = createTurnView();
|
|
102
56
|
const view = () => {
|
|
103
|
-
const v = [
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
57
|
+
const v = composeTurnBody([
|
|
58
|
+
thinkingLine(turn, THINKING_PREVIEW),
|
|
59
|
+
toolLines(turn),
|
|
60
|
+
turn.retrying ? RETRY_NOTICE : "",
|
|
61
|
+
revealedAnswer(turn, EDIT_THROTTLE_MS),
|
|
62
|
+
]);
|
|
107
63
|
// Before any reasoning/tool/text arrives, show an explicit placeholder rather than an empty edit.
|
|
108
|
-
return v === "" ?
|
|
64
|
+
return v === "" ? THINKING_PLACEHOLDER : v;
|
|
109
65
|
};
|
|
110
66
|
// The live preview is ONE real message: sent once (capturing its id + threading under the asker),
|
|
111
67
|
// then edited in place. messageId/lastSent are shared with the final write on completion.
|
|
@@ -134,103 +90,27 @@ export async function streamReply(events, api, botToken, target, formatError, pr
|
|
|
134
90
|
if (messageId === undefined)
|
|
135
91
|
throw new Error("telegram sendMessage returned ok without a message_id β live preview disabled for this turn");
|
|
136
92
|
};
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let pumping = false;
|
|
145
|
-
let stopped = false;
|
|
146
|
-
let previewErrLogged = false;
|
|
147
|
-
let pumpDone;
|
|
148
|
-
let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
|
|
149
|
-
const runPump = async () => {
|
|
150
|
-
pumping = true;
|
|
151
|
-
try {
|
|
152
|
-
while (dirty && !stopped) {
|
|
153
|
-
dirty = false;
|
|
154
|
-
try {
|
|
155
|
-
await flushPreview();
|
|
156
|
-
}
|
|
157
|
-
catch (e) {
|
|
158
|
-
// Best-effort preview (the final write is authoritative), but a failing edit must be visible β
|
|
159
|
-
// log once per turn so a never-rendering preview is diagnosable, not silent.
|
|
160
|
-
if (!previewErrLogged) {
|
|
161
|
-
previewErrLogged = true;
|
|
162
|
-
log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (dirty && !stopped) {
|
|
166
|
-
// Pace + coalesce a burst into one edit. Interruptible: finish() cuts this short so the final
|
|
167
|
-
// write is not delayed by up to EDIT_THROTTLE_MS after the turn completes.
|
|
168
|
-
await new Promise((resolve) => {
|
|
169
|
-
const t = setTimeout(resolve, EDIT_THROTTLE_MS);
|
|
170
|
-
wakeThrottle = () => {
|
|
171
|
-
clearTimeout(t);
|
|
172
|
-
resolve();
|
|
173
|
-
};
|
|
174
|
-
});
|
|
175
|
-
wakeThrottle = undefined;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
finally {
|
|
180
|
-
pumping = false;
|
|
181
|
-
}
|
|
182
|
-
};
|
|
183
|
-
// Mark the preview dirty and ensure the single writer is running (an edit already in flight picks up
|
|
184
|
-
// the new state on its next loop). Synchronous β callers never await a network write.
|
|
185
|
-
const touch = () => {
|
|
186
|
-
dirty = true;
|
|
187
|
-
if (!pumping)
|
|
188
|
-
pumpDone = runPump();
|
|
189
|
-
};
|
|
93
|
+
// The shared single-writer pump (preview-kit) serializes edits to the one preview message. (No
|
|
94
|
+
// keepalive: a real message does not expire, unlike a Bot API `sendMessageDraft` (30s window).)
|
|
95
|
+
const { touch, finish } = createPreviewPump({
|
|
96
|
+
flush: flushPreview,
|
|
97
|
+
throttleMs: EDIT_THROTTLE_MS,
|
|
98
|
+
onError: (e) => log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`),
|
|
99
|
+
});
|
|
190
100
|
touch(); // send the "π Thinkingβ¦" placeholder immediately
|
|
191
|
-
// Stop the pump and await any in-flight edit, so the final write below is strictly the LAST one to the
|
|
192
|
-
// preview message (no stale frame landing after the answer).
|
|
193
|
-
const finish = async () => {
|
|
194
|
-
stopped = true;
|
|
195
|
-
wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to EDIT_THROTTLE_MS
|
|
196
|
-
await pumpDone?.catch(() => { });
|
|
197
|
-
};
|
|
198
101
|
try {
|
|
199
102
|
for await (const e of events) {
|
|
200
|
-
if (e.type === "
|
|
201
|
-
answer += e.delta;
|
|
202
|
-
if (answerPreviewSince === undefined && answer.trim() !== "")
|
|
203
|
-
answerPreviewSince = Date.now();
|
|
204
|
-
touch();
|
|
205
|
-
}
|
|
206
|
-
else if (e.type === "thinking") {
|
|
207
|
-
thinking += e.delta;
|
|
208
|
-
touch();
|
|
209
|
-
}
|
|
210
|
-
else if (e.type === "tool_started") {
|
|
211
|
-
const arg = summarizeArgs(e.args);
|
|
212
|
-
toolIndexById.set(e.id, tools.length);
|
|
213
|
-
tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
|
|
214
|
-
touch();
|
|
215
|
-
}
|
|
216
|
-
else if (e.type === "tool_ended") {
|
|
217
|
-
const i = toolIndexById.get(e.id);
|
|
218
|
-
const t = i === undefined ? undefined : tools[i];
|
|
219
|
-
if (t)
|
|
220
|
-
t.status = e.isError ? "error" : "ok";
|
|
221
|
-
touch();
|
|
222
|
-
}
|
|
223
|
-
else if (e.type === "completed") {
|
|
103
|
+
if (e.type === "completed") {
|
|
224
104
|
await finish();
|
|
225
105
|
// Edit the preview into the final answer (HTML, plain fallback); the persisted message is the
|
|
226
106
|
// answer alone β the process (thinking/tools) was preview-only. Mark finalized BEFORE delivering:
|
|
227
107
|
// the terminal was reached, so a delivery failure here is a plain failure, not an "abnormal exit"
|
|
228
108
|
// (which would wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
|
|
229
109
|
finalized = true;
|
|
230
|
-
await finalize(api, botToken, target, messageId, answer.trim() !== "" ? answer : "(no reply)");
|
|
110
|
+
await finalize(api, botToken, target, messageId, turn.answer.trim() !== "" ? turn.answer : "(no reply)");
|
|
231
111
|
return;
|
|
232
112
|
}
|
|
233
|
-
|
|
113
|
+
if (e.type === "failed") {
|
|
234
114
|
await finish();
|
|
235
115
|
// Two audiences: the chat (customer-facing β formatError, neutral by default) and the operator
|
|
236
116
|
// log (dev-facing β the full details, via the throw below + the handler's catch). Same terminal
|
|
@@ -245,6 +125,8 @@ export async function streamReply(events, api, botToken, target, formatError, pr
|
|
|
245
125
|
}
|
|
246
126
|
throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
|
|
247
127
|
}
|
|
128
|
+
if (applyTurnEvent(turn, e))
|
|
129
|
+
touch();
|
|
248
130
|
}
|
|
249
131
|
throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
|
|
250
132
|
}
|