@fastagent-sh/fastagent 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +23 -0
- package/dist/atomic-write.js +53 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +55 -0
- package/dist/channels/agentcore-service.js +107 -0
- package/dist/channels/agentcore.d.ts +22 -20
- package/dist/channels/agentcore.js +20 -11
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +55 -7
- package/dist/channels/control.js +312 -67
- package/dist/channels/discover.d.ts +59 -0
- package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.js +6 -9
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +41 -24
- package/dist/channels/feishu/invoke-turn.d.ts +1 -1
- package/dist/channels/feishu/invoke-turn.js +3 -4
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +1 -109
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/{state.js → kit/state.js} +4 -7
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
- package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
- package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
- package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +2 -2
- package/dist/channels/slack/invoke-turn.js +13 -6
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +23 -55
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +20 -20
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +67 -13
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +3 -3
- package/dist/channels/telegram/telegram.js +60 -72
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +25 -40
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +7 -5
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +80 -121
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +22 -70
- package/dist/cli/serve.js +66 -257
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +53 -6
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +11 -6
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +295 -0
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +5 -5
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +67 -42
- package/dist/engines/pi/create.js +142 -72
- package/dist/engines/pi/definition.d.ts +42 -8
- package/dist/engines/pi/definition.js +114 -11
- package/dist/engines/pi/invoke-session.d.ts +22 -19
- package/dist/engines/pi/invoke-session.js +286 -100
- package/dist/engines/pi/login.js +39 -20
- package/dist/engines/pi/models.d.ts +14 -0
- package/dist/engines/pi/models.js +9 -1
- package/dist/engines/pi/open.d.ts +10 -8
- package/dist/engines/pi/open.js +21 -11
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +27 -0
- package/dist/engines/pi/session-builder.js +87 -118
- package/dist/engines/pi/session-control.d.ts +28 -14
- package/dist/engines/pi/session-control.js +570 -438
- package/dist/engines/pi/session-inheritance.d.ts +30 -0
- package/dist/engines/pi/session-inheritance.js +261 -0
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +15 -3
- package/dist/engines/pi/session-settings.js +22 -1
- package/dist/engines/pi/session-store.d.ts +140 -0
- package/dist/engines/pi/session-store.js +666 -0
- package/dist/engines/pi/tool-context.d.ts +36 -13
- package/dist/engines/pi/tool-context.js +51 -4
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +3 -3
- package/dist/engines/pi/turn-kit.d.ts +32 -6
- package/dist/engines/pi/turn-kit.js +28 -1
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/env.js +1 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +37 -2
- package/dist/pi.d.ts +14 -8
- package/dist/pi.js +6 -5
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +2 -2
- package/dist/schedule/wakeups.js +11 -8
- package/dist/service.d.ts +157 -0
- package/dist/service.js +376 -0
- package/dist/session-remote.d.ts +8 -3
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +229 -93
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +14 -5
- package/dist/engines/pi/channel.d.ts +0 -37
- package/dist/engines/pi/harness.d.ts +0 -115
- package/dist/engines/pi/harness.js +0 -154
- package/dist/engines/pi/invoke.d.ts +0 -60
- package/dist/engines/pi/invoke.js +0 -459
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -60
- package/dist/engines/pi/sessions.js +0 -464
- package/dist/host/node.js +0 -52
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* un-summoned messages per conversation "place", kept under a char budget and folded into the next
|
|
5
5
|
* answered turn in that place, so a summoned agent has the discussion it didn't see turn-by-turn.
|
|
6
6
|
*
|
|
7
|
-
* Channel-neutral and generic over the entry shape (like ../turn-store.ts): the channel supplies its
|
|
7
|
+
* Channel-neutral and generic over the entry shape (like ../kit/turn-store.ts): the channel supplies its
|
|
8
8
|
* entry type, the shape validator (state files are an IO boundary — valid JSON of the WRONG shape
|
|
9
9
|
* must degrade exactly like a corrupt file: warn + empty, never flow in as trusted data), the
|
|
10
10
|
* fold-line renderer, and its log label. What stays per channel: the entry type itself, place-key
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* message that arrives while the turn runs survives for the next answered turn (a whole-bucket
|
|
22
22
|
* delete would lose it).
|
|
23
23
|
*/
|
|
24
|
-
import { log } from "
|
|
24
|
+
import { log } from "../../log.js";
|
|
25
25
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
26
26
|
/** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
|
|
27
27
|
* the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
|
|
@@ -39,6 +39,17 @@ export const BUFFER_LINE_MAX_CHARS = 280;
|
|
|
39
39
|
* note, so the model never sees an attachment reference it silently cannot open. Shared policy:
|
|
40
40
|
* each channel's attachment collector caps against this. */
|
|
41
41
|
export const BUFFER_ATTACH_MAX = 3;
|
|
42
|
+
/**
|
|
43
|
+
* The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
|
|
44
|
+
* empty. One renderer for every channel: what the agent is told about un-summoned discussion should
|
|
45
|
+
* not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
|
|
46
|
+
*
|
|
47
|
+
* A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
|
|
48
|
+
* different claim about a different place, not this block with another name.
|
|
49
|
+
*/
|
|
50
|
+
export function discussionBlock(text) {
|
|
51
|
+
return text ? `[recent group discussion:\n${text}\n]\n\n` : "";
|
|
52
|
+
}
|
|
42
53
|
export function createContextBuffer(options) {
|
|
43
54
|
const { path, label, isEntry, line } = options;
|
|
44
55
|
const load = () => {
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
|
|
8
8
|
* {@link missingAttachmentsNote}, {@link attributedFileName}.
|
|
9
9
|
*
|
|
10
|
+
* None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
|
|
11
|
+
* states what it found; deciding whether to open a file is the agent's, and one assembled without a
|
|
12
|
+
* file tool answers that it cannot — visibly, at the moment it is asked.
|
|
13
|
+
*
|
|
10
14
|
* Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
|
|
11
15
|
* message-scoped Feishu keys, Slack file objects) are real differences.
|
|
12
16
|
*/
|
|
13
|
-
import { type Agent, type AgentEvent, type Prompt, type Scope } from "
|
|
17
|
+
import { type Agent, type AgentEvent, type Prompt, type Scope } from "../../agent.ts";
|
|
14
18
|
/** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
|
|
15
19
|
* EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
|
|
16
20
|
* channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
|
|
@@ -49,8 +53,15 @@ export interface ManifestFile {
|
|
|
49
53
|
size: number;
|
|
50
54
|
path: string;
|
|
51
55
|
}
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
56
|
+
/**
|
|
57
|
+
* The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
|
|
58
|
+
*
|
|
59
|
+
* It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
|
|
60
|
+
* assumption about the reader, and an assumption has to be verified — which is where a capability
|
|
61
|
+
* flag threaded through eight files came from. An agent with a file tool decides for itself whether
|
|
62
|
+
* to open one, and how much of it; an agent without one says so. Neither needs this line to have
|
|
63
|
+
* guessed first.
|
|
64
|
+
*/
|
|
54
65
|
export declare function attachedFilesManifest(files: readonly ManifestFile[]): string;
|
|
55
66
|
/** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
|
|
56
67
|
* the way the fold attributes text lines. */
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
|
|
8
8
|
* {@link missingAttachmentsNote}, {@link attributedFileName}.
|
|
9
9
|
*
|
|
10
|
+
* None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
|
|
11
|
+
* states what it found; deciding whether to open a file is the agent's, and one assembled without a
|
|
12
|
+
* file tool answers that it cannot — visibly, at the moment it is asked.
|
|
13
|
+
*
|
|
10
14
|
* Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
|
|
11
15
|
* message-scoped Feishu keys, Slack file objects) are real differences.
|
|
12
16
|
*/
|
|
13
|
-
import { SESSION_BUSY_CODE } from "
|
|
14
|
-
import { log } from "
|
|
15
|
-
// Each retry is a lease-check-level reject (tryAcquire runs before
|
|
17
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
18
|
+
import { log } from "../../log.js";
|
|
19
|
+
// Each retry is a lease-check-level reject (tryAcquire runs before the session is bound) — waiting is nearly
|
|
16
20
|
// free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
|
|
17
21
|
// real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
|
|
18
22
|
// this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
|
|
@@ -59,11 +63,18 @@ scope, prompt, options) {
|
|
|
59
63
|
await new Promise((r) => setTimeout(r, busyRetry.delayMs));
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
66
|
+
/**
|
|
67
|
+
* The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
|
|
68
|
+
*
|
|
69
|
+
* It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
|
|
70
|
+
* assumption about the reader, and an assumption has to be verified — which is where a capability
|
|
71
|
+
* flag threaded through eight files came from. An agent with a file tool decides for itself whether
|
|
72
|
+
* to open one, and how much of it; an agent without one says so. Neither needs this line to have
|
|
73
|
+
* guessed first.
|
|
74
|
+
*/
|
|
64
75
|
export function attachedFilesManifest(files) {
|
|
65
76
|
return files.length
|
|
66
|
-
? `\n\n[attached files
|
|
77
|
+
? `\n\n[attached files:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
|
|
67
78
|
: "";
|
|
68
79
|
}
|
|
69
80
|
/** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* everything platform-independent lives here, so a new event type or a wording change lands in ONE
|
|
9
9
|
* place instead of one hunk per channel.
|
|
10
10
|
*/
|
|
11
|
-
import type { AgentEvent, Json } from "
|
|
11
|
+
import type { AgentEvent, Json } from "../../agent.ts";
|
|
12
12
|
/** A terminal failure, as a channel hands it to its `onError`. */
|
|
13
13
|
export interface ChannelFailure {
|
|
14
14
|
details: string;
|
|
15
15
|
retryable: boolean;
|
|
16
|
+
/** The engine's failure code, when it set one. */
|
|
17
|
+
code?: string;
|
|
16
18
|
}
|
|
17
19
|
/** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
|
|
18
20
|
* offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
|
|
2
|
-
import { log } from "
|
|
2
|
+
import { log } from "../../log.js";
|
|
3
3
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
4
4
|
export function createSeenRing(path, label, cap = 2000) {
|
|
5
5
|
const raw = loadStateFile(path);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
|
|
3
|
+
* nothing about it, so without a window a captured body plus its signed headers replays forever.
|
|
4
|
+
* The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
|
|
8
|
+
* ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
|
|
9
|
+
* signed material, so a value the signature commits to but this cannot read is a reason to refuse,
|
|
10
|
+
* not to wave through.
|
|
11
|
+
*/
|
|
12
|
+
export declare function signatureIsFresh(timestamp: string, maxAgeS: number, nowMs?: number): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
|
|
3
|
+
* nothing about it, so without a window a captured body plus its signed headers replays forever.
|
|
4
|
+
* The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
|
|
8
|
+
* ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
|
|
9
|
+
* signed material, so a value the signature commits to but this cannot read is a reason to refuse,
|
|
10
|
+
* not to wave through.
|
|
11
|
+
*/
|
|
12
|
+
export function signatureIsFresh(timestamp, maxAgeS, nowMs = Date.now()) {
|
|
13
|
+
if (!/^\d+$/.test(timestamp))
|
|
14
|
+
return false;
|
|
15
|
+
const seconds = Number(timestamp);
|
|
16
|
+
return Number.isSafeInteger(seconds) && Math.abs(Math.floor(nowMs / 1000) - seconds) <= maxAgeS;
|
|
17
|
+
}
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
|
|
13
13
|
* with silently-empty state would hide real data behind a config mistake.
|
|
14
14
|
*/
|
|
15
|
-
import { mkdirSync, readFileSync
|
|
16
|
-
import {
|
|
17
|
-
import { log } from "
|
|
15
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { writeFileAtomic } from "../../atomic-write.js";
|
|
17
|
+
import { log } from "../../log.js";
|
|
18
18
|
/** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
|
|
19
19
|
export function ensureStateHome(dir) {
|
|
20
20
|
mkdirSync(dir, { recursive: true });
|
|
@@ -43,8 +43,5 @@ export function loadStateFile(path) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
export function saveStateFile(path, value) {
|
|
46
|
-
|
|
47
|
-
const tmp = `${path}.tmp`;
|
|
48
|
-
writeFileSync(tmp, JSON.stringify(value));
|
|
49
|
-
renameSync(tmp, path);
|
|
46
|
+
writeFileAtomic(path, JSON.stringify(value));
|
|
50
47
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SessionControl } from "
|
|
1
|
+
import { type SessionControl } from "../../session.ts";
|
|
2
2
|
/** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
|
|
3
3
|
export declare function isStopText(text: string): boolean;
|
|
4
4
|
/** Dispatch `abort` for the session and map the outcome to the customer-facing line. Never throws;
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* at-least-once floor; and the hub stays gated by `config.sessionControl`, so without it the command
|
|
7
7
|
* degrades to a visible "not enabled" notice, never a silent ignore.
|
|
8
8
|
*/
|
|
9
|
-
import { log } from "
|
|
10
|
-
import { NO_ACTIVE_RUN_CODE } from "
|
|
9
|
+
import { log } from "../../log.js";
|
|
10
|
+
import { NO_ACTIVE_RUN_CODE } from "../../session.js";
|
|
11
11
|
/** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
|
|
12
12
|
export function isStopText(text) {
|
|
13
13
|
return /^(stop|cancel)[.!]?$/i.test(text.trim());
|
|
@@ -21,16 +21,16 @@ export async function dispatchStop(control, session, label) {
|
|
|
21
21
|
if (!control)
|
|
22
22
|
return STOP_UNAVAILABLE_NOTICE;
|
|
23
23
|
try {
|
|
24
|
-
const result = await control.
|
|
24
|
+
const result = await control.sessions.get(session).abort();
|
|
25
25
|
if (result.ok)
|
|
26
26
|
return STOPPED_NOTICE;
|
|
27
27
|
if (result.error.code === NO_ACTIVE_RUN_CODE)
|
|
28
28
|
return NOTHING_RUNNING_NOTICE;
|
|
29
|
-
log.warn(`${label} stop
|
|
29
|
+
log.warn(`${label} stop rejected for ${session}: ${result.error.code} — ${result.error.message}`);
|
|
30
30
|
return `⚠️ Could not stop (${result.error.code}).`;
|
|
31
31
|
}
|
|
32
32
|
catch (error) {
|
|
33
|
-
log.warn(`${label} stop
|
|
33
|
+
log.warn(`${label} stop failed for ${session}: ${String(error)}`);
|
|
34
34
|
return "⚠️ Could not stop — see the server logs.";
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
* SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
|
|
3
3
|
* feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
|
|
4
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
|
|
5
|
+
* handling stays with the caller: track() only guarantees the drain sees the task SETTLE, and settle
|
|
6
|
+
* includes reject. A caller that handles its error on a separate branch (`p.catch(log); track(p)`)
|
|
7
|
+
* still hands us a promise that rejects, and a drain that propagated it would fail the channel's whole
|
|
8
|
+
* `turnsIdle` over one side task. A rejection that reaches us is logged — we
|
|
9
|
+
* cannot tell a missing `.catch` from one on a separate branch, so the line is a visibility floor
|
|
10
|
+
* rather than a diagnosis, and without it a dropped side task leaves no trace anywhere.
|
|
6
11
|
*/
|
|
7
|
-
import { beginWork } from "
|
|
8
|
-
|
|
12
|
+
import { beginWork } from "../busy.js";
|
|
13
|
+
import { log } from "../../log.js";
|
|
14
|
+
export function createTaskTracker(label) {
|
|
9
15
|
const tasks = new Set();
|
|
10
16
|
return {
|
|
11
17
|
track(task) {
|
|
@@ -18,8 +24,8 @@ export function createTaskTracker() {
|
|
|
18
24
|
workDone();
|
|
19
25
|
tasks.delete(task);
|
|
20
26
|
})
|
|
21
|
-
.catch(() => {
|
|
27
|
+
.catch((error) => log.warn(`${label} side task rejected: ${String(error)}`));
|
|
22
28
|
},
|
|
23
|
-
drain: () => Promise.
|
|
29
|
+
drain: () => Promise.allSettled(tasks).then(() => undefined),
|
|
24
30
|
};
|
|
25
31
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - Keyed by `thread_id`, never a reply-chain root: Feishu's `root_id` moves with the chain, so it
|
|
20
20
|
* cannot identify a side conversation at all.
|
|
21
21
|
*/
|
|
22
|
-
import { log } from "
|
|
22
|
+
import { log } from "../../log.js";
|
|
23
23
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
24
24
|
/** Cap on remembered threads. Losing one costs a mention to re-enter that thread, so an unbounded file
|
|
25
25
|
* buys little — and a merge that carries new information rewrites the whole map synchronously, so the
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* process-crash recovery, at-least-once). Exactly-once / deterministic step-replay (L2) is the K-axis
|
|
11
11
|
* backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
|
|
12
12
|
*/
|
|
13
|
-
import { log } from "
|
|
14
|
-
import { beginWork } from "
|
|
13
|
+
import { log } from "../../log.js";
|
|
14
|
+
import { beginWork } from "../busy.js";
|
|
15
15
|
export function createTurnQueue(opts) {
|
|
16
16
|
const { label, run, onQueuedBehind } = opts;
|
|
17
17
|
// Per-session serial chains: a second turn for the same session waits its turn (FIFO) instead of
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ContextBuffer } from "./context-buffer.ts";
|
|
1
2
|
/** What every persisted turn record carries regardless of channel: identity, the session whose FIFO
|
|
2
3
|
* chain it runs on, and how many times it has STARTED executing without finishing (0 until its first
|
|
3
4
|
* run; bumped at each `startAttempt`). The channel's record type extends this with everything its
|
|
@@ -20,14 +21,14 @@ export interface TurnStore<T extends TurnRecordBase> {
|
|
|
20
21
|
recover(): T[];
|
|
21
22
|
/** Called when a turn is about to RUN (dequeued). Returns:
|
|
22
23
|
* - "run": bumped its persisted execution count; go ahead.
|
|
23
|
-
* - "exceeded": over
|
|
24
|
-
* cause); the record is dropped and the runner notifies the asker.
|
|
24
|
+
* - "exceeded": over {@link MAX_TURN_ATTEMPTS} starts without finishing (killed mid-run every time,
|
|
25
|
+
* whatever the cause); the record is dropped and the runner notifies the asker.
|
|
25
26
|
* - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
|
|
26
27
|
* would let a poison turn re-run forever); the record stays on disk and replays on the next start
|
|
27
28
|
* (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
|
|
28
29
|
* An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
|
|
29
30
|
* redelivery-double-run tail (see the header's pre-ACK window) lands here. */
|
|
30
|
-
startAttempt(id: string
|
|
31
|
+
startAttempt(id: string): "run" | "exceeded" | "defer";
|
|
31
32
|
}
|
|
32
33
|
export interface TurnStoreOptions<T extends TurnRecordBase> {
|
|
33
34
|
/** Log prefix naming the consumer (e.g. "[telegram]") — the store itself is channel-neutral. */
|
|
@@ -39,4 +40,19 @@ export interface TurnStoreOptions<T extends TurnRecordBase> {
|
|
|
39
40
|
* (telegram: numeric update_id; lark: an explicit per-record seq). */
|
|
40
41
|
order: (a: T, b: T) => number;
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
|
|
45
|
+
*
|
|
46
|
+
* The ORDER is the safety property, which is why this is a function and not two lines at each call
|
|
47
|
+
* site. A crash between the two writes may re-fold already-answered context into the next summon —
|
|
48
|
+
* additive and harmless. The reverse order leaves intent on disk with its context already consumed,
|
|
49
|
+
* so the replay runs the same turn with its context stripped.
|
|
50
|
+
*
|
|
51
|
+
* Called from the turn's `completed` event, when the fold provably lives in the durable session.
|
|
52
|
+
*/
|
|
53
|
+
export declare function commitAnsweredTurn<T extends TurnRecordBase, E>(store: TurnStore<T>, buffer: ContextBuffer<E>, turn: {
|
|
54
|
+
id: string;
|
|
55
|
+
bufferKey: string;
|
|
56
|
+
consumed: E[];
|
|
57
|
+
}): void;
|
|
42
58
|
export declare function createTurnStore<T extends TurnRecordBase>(path: string, opts: TurnStoreOptions<T>): TurnStore<T>;
|
|
@@ -49,8 +49,35 @@
|
|
|
49
49
|
* Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
|
|
50
50
|
* power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
|
|
51
51
|
*/
|
|
52
|
-
import { log } from "
|
|
52
|
+
import { log } from "../../log.js";
|
|
53
53
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
54
|
+
/**
|
|
55
|
+
* How many times a turn may START without finishing before it is dropped rather than run again — the
|
|
56
|
+
* poison-turn ceiling described at length above. One value for every channel: it prices replay against
|
|
57
|
+
* DEPLOY frequency, which is a property of how fastagent is operated, not of which chat platform is in
|
|
58
|
+
* front of it.
|
|
59
|
+
*
|
|
60
|
+
* Known limitation: the count cannot tell a self-inflicted crash from an external SIGTERM (there is no
|
|
61
|
+
* graceful drain), so a legitimately long turn interrupted by this many successive deploys is dropped
|
|
62
|
+
* as if it were poison. Three is a bet that such a turn is an outlier, not a defence against one:
|
|
63
|
+
* catching SIGTERM to spare it would reintroduce the drain the design refuses — raise this constant
|
|
64
|
+
* instead if such turns are expected.
|
|
65
|
+
*/
|
|
66
|
+
const MAX_TURN_ATTEMPTS = 3;
|
|
67
|
+
/**
|
|
68
|
+
* End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
|
|
69
|
+
*
|
|
70
|
+
* The ORDER is the safety property, which is why this is a function and not two lines at each call
|
|
71
|
+
* site. A crash between the two writes may re-fold already-answered context into the next summon —
|
|
72
|
+
* additive and harmless. The reverse order leaves intent on disk with its context already consumed,
|
|
73
|
+
* so the replay runs the same turn with its context stripped.
|
|
74
|
+
*
|
|
75
|
+
* Called from the turn's `completed` event, when the fold provably lives in the durable session.
|
|
76
|
+
*/
|
|
77
|
+
export function commitAnsweredTurn(store, buffer, turn) {
|
|
78
|
+
store.remove(turn.id);
|
|
79
|
+
buffer.commit(turn.bufferKey, turn.consumed);
|
|
80
|
+
}
|
|
54
81
|
export function createTurnStore(path, opts) {
|
|
55
82
|
const { label, isRecord, order } = opts;
|
|
56
83
|
const load = () => {
|
|
@@ -102,12 +129,12 @@ export function createTurnStore(path, opts) {
|
|
|
102
129
|
// happening to survive the load's JSON round-trip.
|
|
103
130
|
return [...turns.values()].sort(order);
|
|
104
131
|
},
|
|
105
|
-
startAttempt(id
|
|
132
|
+
startAttempt(id) {
|
|
106
133
|
const rec = turns.get(id);
|
|
107
134
|
if (!rec)
|
|
108
135
|
return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
|
|
109
136
|
const attempts = rec.attempts + 1;
|
|
110
|
-
if (attempts >
|
|
137
|
+
if (attempts > MAX_TURN_ATTEMPTS) {
|
|
111
138
|
// State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
|
|
112
139
|
// this whether IT poisoned the process or a deploy/OOM took it down each time.
|
|
113
140
|
log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* this module binds it to Lark's cloud profile and exposes natural Lark-branded public names. Lark's
|
|
4
4
|
* weaker control-plane capabilities live in onboarding/registration, not in a fork of the turn engine.
|
|
5
5
|
*/
|
|
6
|
-
import type { ChannelModule, LongConnectionChannelModule } from "../../
|
|
6
|
+
import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
|
|
7
7
|
import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, type FeishuWebSocketChannelOptions, defaultFeishuRoute } from "../feishu/feishu.ts";
|
|
8
8
|
export type LarkChannelOptions = FeishuChannelOptions;
|
|
9
9
|
export type LarkWebSocketChannelOptions = FeishuWebSocketChannelOptions;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { ChannelHandler, Routes } from "../channel.ts";
|
|
3
|
+
/** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method).
|
|
4
|
+
* An empty method (`" /x"`) parses as `""`, which {@link assertRouteKey} refuses — see there. */
|
|
5
|
+
export declare function parseRouteKey(key: string): {
|
|
6
|
+
method?: string;
|
|
7
|
+
path: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A route key is `"METHOD /path"` or `"/path"`, with a literal path.
|
|
11
|
+
*
|
|
12
|
+
* Every rule here exists because breaking it costs SOMEONE ELSE. Two keys naming one route, a route
|
|
13
|
+
* under a mount, a path a URL rewrites (`/a/../x` and `/x` are one route spelled two ways, while
|
|
14
|
+
* the conflict check compares strings) — in each case a channel goes dark and its author did
|
|
15
|
+
* nothing wrong.
|
|
16
|
+
*
|
|
17
|
+
* Mistakes that only cost their own author a debugging session are NOT policed: `:id` and `*` are
|
|
18
|
+
* literal path characters here, so such a key simply never matches, and an unusual method is the
|
|
19
|
+
* same. Refusing those would trade a working capability for a lecture.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertRouteKey(key: string, describe: (problem: string) => string): void;
|
|
22
|
+
/** Do these two keys fight over the same request? Equal paths, and a method each answers; a key
|
|
23
|
+
* with no method answers all of them. */
|
|
24
|
+
export declare function routeKeysConflict(a: string, b: string): boolean;
|
|
25
|
+
/** A handler owning a path prefix and everything beneath it — the session control plane is the one
|
|
26
|
+
* user. Kept out of {@link Routes} so a key is always a literal path and collision checks stay
|
|
27
|
+
* comparisons. */
|
|
28
|
+
export interface PrefixMount {
|
|
29
|
+
/** Absolute, no trailing slash, and not `/` (`/control`). Owns `/control` and everything below it.
|
|
30
|
+
* The root is excluded deliberately: a handler owning every path is that handler, and routing to
|
|
31
|
+
* it through here would only add a table nothing can reach. */
|
|
32
|
+
prefix: string;
|
|
33
|
+
handler: ChannelHandler;
|
|
34
|
+
}
|
|
35
|
+
/** Same status and headers, no content (RFC 9110's HEAD). The discarded body is cancelled, or a
|
|
36
|
+
* streaming producer keeps running with no reader. Shared with the control plane, which answers
|
|
37
|
+
* HEAD too. */
|
|
38
|
+
export declare function withoutBody(res: Response): Response;
|
|
39
|
+
/** Is `path` inside `prefix`? Segment-wise, so `/controlled` is not inside `/control`. */
|
|
40
|
+
export declare function pathUnderPrefix(path: string, prefix: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Compose a {@link Routes} table and its {@link PrefixMount}s into one handler.
|
|
43
|
+
*
|
|
44
|
+
* Refuses at assembly anything that could not receive a request: a key naming the same route as
|
|
45
|
+
* another (`"/x"` and `"GET /x"`), a route inside a mount, two mounts claiming the same ground. A
|
|
46
|
+
* channel must never go dark unannounced.
|
|
47
|
+
*
|
|
48
|
+
* 404 and 405 stay distinct: a remote client reads 404 as "this serve predates the route" (version
|
|
49
|
+
* skew) rather than as a fault.
|
|
50
|
+
*/
|
|
51
|
+
export declare function router(routes: Routes, mounts?: readonly PrefixMount[]): ChannelHandler;
|
|
52
|
+
/** The node:http adapter for a Fetch handler — the embedded server uses it, and an embedder mounting
|
|
53
|
+
* fastagent on its OWN node:http server can too.
|
|
54
|
+
*
|
|
55
|
+
* Takes the same {@link ChannelHandler} `serveNode` does, so both doors accept the same thing: a
|
|
56
|
+
* handler may answer synchronously, and `router()` returns exactly that. Requiring a Promise here
|
|
57
|
+
* made the most natural mount — `nodeListener(router(routes))`, the whole agent on the app's own
|
|
58
|
+
* server — a type error. */
|
|
59
|
+
export declare function nodeListener(handler: ChannelHandler): (req: IncomingMessage, res: ServerResponse) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
|
|
62
|
+
* accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
|
|
63
|
+
* `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
|
|
64
|
+
* `host` is the bind address; unset means all interfaces (what containers need).
|
|
65
|
+
*/
|
|
66
|
+
export declare function serveNode(handler: ChannelHandler, options: {
|
|
67
|
+
port: number;
|
|
68
|
+
host?: string;
|
|
69
|
+
}): {
|
|
70
|
+
listening: Promise<number>;
|
|
71
|
+
close: () => Promise<void>;
|
|
72
|
+
closeAllConnections: () => void;
|
|
73
|
+
};
|