@fastagent-sh/fastagent 0.17.0 → 0.18.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/dist/agent.d.ts +11 -0
- package/dist/channels/agentcore.d.ts +16 -2
- package/dist/channels/agentcore.js +66 -4
- package/dist/channels/feishu/card.d.ts +20 -9
- package/dist/channels/feishu/card.js +27 -13
- package/dist/channels/feishu/feishu-api.d.ts +13 -2
- package/dist/channels/feishu/feishu.js +121 -17
- package/dist/channels/feishu/invoke-turn.d.ts +12 -2
- package/dist/channels/feishu/invoke-turn.js +159 -14
- package/dist/channels/feishu/normalize.js +97 -32
- package/dist/channels/feishu/parse.js +6 -0
- package/dist/channels/feishu/preview.d.ts +4 -3
- package/dist/channels/feishu/preview.js +77 -23
- package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
- package/dist/channels/http.js +15 -2
- package/dist/channels/invoke-turn-kit.d.ts +5 -2
- package/dist/channels/invoke-turn-kit.js +6 -2
- package/dist/channels/lark/scaffold/lark-send.ts +9 -6
- package/dist/channels/slack/invoke-turn.js +1 -1
- package/dist/channels/slack/slack.js +1 -5
- package/dist/channels/state.d.ts +0 -10
- package/dist/channels/state.js +2 -19
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/thread-participants.d.ts +7 -0
- package/dist/channels/thread-participants.js +3 -0
- package/dist/cli/commands/deploy.js +13 -5
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +21 -1
- package/dist/cli/commands/invoke.js +1 -1
- package/dist/cli/commands/start.js +40 -6
- package/dist/cli/serve.d.ts +4 -0
- package/dist/cli/serve.js +2 -2
- package/dist/cli/shared.d.ts +4 -2
- package/dist/cli/shared.js +12 -5
- package/dist/collect.d.ts +30 -4
- package/dist/collect.js +39 -6
- package/dist/deploy/agentcore/plan.js +17 -0
- package/dist/deploy/agentcore/run.d.ts +7 -1
- package/dist/deploy/agentcore/run.js +93 -8
- package/dist/deploy/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +55 -10
- package/dist/deploy/secrets.d.ts +3 -0
- package/dist/deploy/secrets.js +6 -0
- package/dist/dev-supervisor.js +8 -2
- package/dist/engines/pi/create.d.ts +2 -1
- package/dist/engines/pi/create.js +12 -7
- package/dist/engines/pi/harness.d.ts +6 -3
- package/dist/engines/pi/harness.js +4 -3
- package/dist/engines/pi/invoke-session.d.ts +32 -0
- package/dist/engines/pi/invoke-session.js +171 -0
- package/dist/engines/pi/invoke.d.ts +6 -27
- package/dist/engines/pi/invoke.js +49 -208
- package/dist/engines/pi/models.d.ts +45 -11
- package/dist/engines/pi/models.js +55 -8
- package/dist/engines/pi/session-builder.js +4 -2
- package/dist/engines/pi/session-control.d.ts +2 -1
- package/dist/engines/pi/sessions.d.ts +17 -1
- package/dist/engines/pi/sessions.js +292 -10
- package/dist/engines/pi/turn-kit.d.ts +56 -0
- package/dist/engines/pi/turn-kit.js +161 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +6 -0
- package/dist/pi.d.ts +3 -2
- package/dist/pi.js +1 -1
- package/dist/scaffold/templates/fastagent.config.mjs +2 -0
- package/dist/session-remote.js +10 -2
- package/package.json +1 -1
|
@@ -33,13 +33,17 @@ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
|
|
|
33
33
|
* busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
|
|
34
34
|
* is ever re-run.
|
|
35
35
|
*/
|
|
36
|
-
export async function* streamTurnWithBusyRetry(agent,
|
|
36
|
+
export async function* streamTurnWithBusyRetry(agent,
|
|
37
|
+
/** The full scope, not a session string — channels that set extension fields (lineage) pass them
|
|
38
|
+
* through here; channels that don't pass `{ session }` and nothing changes. */
|
|
39
|
+
scope, prompt, options) {
|
|
37
40
|
const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
|
|
41
|
+
const session = scope.session;
|
|
38
42
|
const deadline = Date.now() + busyRetry.maxWaitMs;
|
|
39
43
|
for (;;) {
|
|
40
44
|
let retryBusy = false;
|
|
41
45
|
let first = true;
|
|
42
|
-
for await (const e of agent.invoke(
|
|
46
|
+
for await (const e of agent.invoke(scope, prompt)) {
|
|
43
47
|
if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
|
|
44
48
|
retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
|
|
45
49
|
break;
|
|
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
|
|
|
56
56
|
|
|
57
57
|
export default defineTool({
|
|
58
58
|
description:
|
|
59
|
-
"Send a message to a Lark chat
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
59
|
+
"Send a message to a Lark chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
|
|
60
|
+
"channel is carrying — a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
|
|
61
|
+
"nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
|
|
62
|
+
"channel streams and delivers your reply itself, so do NOT call this to answer the current " +
|
|
63
|
+
"chat: it would post the message twice, outside the conversation thread. `chatId` (oc_…) names " +
|
|
64
|
+
"the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
|
|
65
|
+
"or memory); the [lark: chat …] context line only identifies the chat you are answering — the " +
|
|
66
|
+
"one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
|
|
67
|
+
"`markdown` (rendered as a card: headings, bold, code blocks, links).",
|
|
65
68
|
input: z.object({
|
|
66
69
|
chatId: z.string().describe("target chat id (oc_…)"),
|
|
67
70
|
text: z.string().optional().describe("plain text message to send"),
|
|
@@ -59,5 +59,5 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
|
|
62
|
-
yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
|
|
62
|
+
yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: transport.label, onCompleted, busyRetry });
|
|
63
63
|
}
|
|
@@ -7,7 +7,7 @@ import { text } from "../respond.js";
|
|
|
7
7
|
import { createSeenRing } from "../seen.js";
|
|
8
8
|
import { createThreadParticipants } from "../thread-participants.js";
|
|
9
9
|
import { createTaskTracker } from "../tasks.js";
|
|
10
|
-
import { ensureStateHome
|
|
10
|
+
import { ensureStateHome } from "../state.js";
|
|
11
11
|
import { dispatchStop, isStopText } from "../stop-command.js";
|
|
12
12
|
import { codePointPrefix } from "../text.js";
|
|
13
13
|
import { createTurnQueue } from "../turn-queue.js";
|
|
@@ -141,10 +141,6 @@ export function slackChannel(options) {
|
|
|
141
141
|
* under a custom route is the `routed.session === undefined` condition on the write, not the
|
|
142
142
|
* absence of a route: a route that supplies its own session records nothing here. */
|
|
143
143
|
const threadKey = (teamId, channelId, threadTs) => `slack:${teamId}:${channelId}:${threadTs}`;
|
|
144
|
-
// The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
|
|
145
|
-
// after the release following the participant model ships — by then no live deployment can still
|
|
146
|
-
// be carrying the file. test/migration-deadline.test.ts fails when due.
|
|
147
|
-
removeRetiredStateFile(stateHome, "owned-threads.json", label);
|
|
148
144
|
const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
|
|
149
145
|
const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
|
|
150
146
|
const store = createTurnStore(join(stateHome, "turns.json"), {
|
package/dist/channels/state.d.ts
CHANGED
|
@@ -4,13 +4,3 @@ export declare function ensureStateHome(dir: string): void;
|
|
|
4
4
|
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
5
5
|
export declare function loadStateFile(path: string): unknown;
|
|
6
6
|
export declare function saveStateFile(path: string, value: unknown): void;
|
|
7
|
-
/**
|
|
8
|
-
* Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
|
|
9
|
-
* so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
|
|
10
|
-
* whose loss changes behaviour needs a migration, not a delete.
|
|
11
|
-
*
|
|
12
|
-
* Shared because a retired file is usually retired in every channel at once: one best-effort
|
|
13
|
-
* semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
|
|
14
|
-
* here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
|
|
15
|
-
*/
|
|
16
|
-
export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
|
package/dist/channels/state.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
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, renameSync,
|
|
16
|
-
import { dirname
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
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) {
|
|
@@ -48,20 +48,3 @@ export function saveStateFile(path, value) {
|
|
|
48
48
|
writeFileSync(tmp, JSON.stringify(value));
|
|
49
49
|
renameSync(tmp, path);
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
|
|
53
|
-
* so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
|
|
54
|
-
* whose loss changes behaviour needs a migration, not a delete.
|
|
55
|
-
*
|
|
56
|
-
* Shared because a retired file is usually retired in every channel at once: one best-effort
|
|
57
|
-
* semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
|
|
58
|
-
* here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
|
|
59
|
-
*/
|
|
60
|
-
export function removeRetiredStateFile(stateHome, name, label) {
|
|
61
|
-
try {
|
|
62
|
-
rmSync(join(stateHome, name), { force: true });
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
@@ -76,5 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
|
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
|
|
79
|
-
yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
|
|
79
|
+
yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: "[telegram]", onCompleted, busyRetry });
|
|
80
80
|
}
|
|
@@ -6,6 +6,13 @@ export interface ThreadParticipants {
|
|
|
6
6
|
* get wrong twice, and "a second human restores the mention requirement" must have one place to change.
|
|
7
7
|
*/
|
|
8
8
|
admitsBareMessage(key: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Has the agent answered into this thread before — the "first answered turn" fact
|
|
11
|
+
* (participant-model.md §8), unlike {@link ThreadParticipants.admitsBareMessage} which also weighs
|
|
12
|
+
* the second-human rule. An evicted record answers false (this store is a cache — see the header),
|
|
13
|
+
* so gate a repeatable read on it, never a durable claim.
|
|
14
|
+
*/
|
|
15
|
+
agentSpokeIn(key: string): boolean;
|
|
9
16
|
/**
|
|
10
17
|
* Merge in what was just heard. Idempotent; a failed write is a warning, never a failed delivery.
|
|
11
18
|
*
|
|
@@ -68,6 +68,9 @@ export function createThreadParticipants(path, label) {
|
|
|
68
68
|
const heard = records.get(key);
|
|
69
69
|
return heard?.agentSpoke === true && heard.humans.length <= 1;
|
|
70
70
|
},
|
|
71
|
+
agentSpokeIn(key) {
|
|
72
|
+
return records.get(key)?.agentSpoke === true;
|
|
73
|
+
},
|
|
71
74
|
merge(key, heard) {
|
|
72
75
|
const previous = records.get(key);
|
|
73
76
|
const humans = new Set(previous?.humans ?? []);
|
|
@@ -76,7 +76,7 @@ export async function runDeploy(host, dirArg, opts) {
|
|
|
76
76
|
failStartup(new Error(`deploy stopped: ${pre.gate}`));
|
|
77
77
|
for (const m of pre.messages)
|
|
78
78
|
console.error(`[fastagent] ${m.level}: ${m.text}`);
|
|
79
|
-
const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets, } = pre;
|
|
79
|
+
const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets, } = pre;
|
|
80
80
|
const hasDeclaredChannels = routeChannels.length + longConnectionChannels.length > 0;
|
|
81
81
|
// Docker: one app service + loopback port + state volume. `--tunnel` shapes the generated topology
|
|
82
82
|
// with an optional Quick Tunnel service; `--run` alone decides whether Docker receives side effects.
|
|
@@ -126,6 +126,7 @@ export async function runDeploy(host, dirArg, opts) {
|
|
|
126
126
|
port,
|
|
127
127
|
requireTunnel: requestedTunnel,
|
|
128
128
|
modelAuth,
|
|
129
|
+
modelKeyInDefinition,
|
|
129
130
|
authPath,
|
|
130
131
|
channels,
|
|
131
132
|
longConnectionChannels,
|
|
@@ -176,6 +177,7 @@ export async function runDeploy(host, dirArg, opts) {
|
|
|
176
177
|
workspace,
|
|
177
178
|
name: serviceName,
|
|
178
179
|
modelAuth,
|
|
180
|
+
modelKeyInDefinition,
|
|
179
181
|
authPath,
|
|
180
182
|
channels,
|
|
181
183
|
longConnectionChannels,
|
|
@@ -278,6 +280,7 @@ export async function runDeploy(host, dirArg, opts) {
|
|
|
278
280
|
agentPrefix: container.agentPrefix,
|
|
279
281
|
name: acName,
|
|
280
282
|
modelAuth,
|
|
283
|
+
modelKeyInDefinition,
|
|
281
284
|
authPath,
|
|
282
285
|
channels,
|
|
283
286
|
extraSecrets,
|
|
@@ -371,6 +374,7 @@ export async function runDeploy(host, dirArg, opts) {
|
|
|
371
374
|
agentPrefix: container.agentPrefix,
|
|
372
375
|
appName,
|
|
373
376
|
modelAuth,
|
|
377
|
+
modelKeyInDefinition,
|
|
374
378
|
authPath,
|
|
375
379
|
channels,
|
|
376
380
|
longConnectionChannels,
|
|
@@ -468,9 +472,10 @@ function deployEnvironment(agentDir, channels) {
|
|
|
468
472
|
* when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
|
|
469
473
|
*/
|
|
470
474
|
async function runDeployDocker(params) {
|
|
471
|
-
const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, authPath, channels, longConnectionChannels, extraSecrets, } = params;
|
|
475
|
+
const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, } = params;
|
|
472
476
|
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
473
477
|
modelAuth,
|
|
478
|
+
modelKeyInDefinition,
|
|
474
479
|
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
475
480
|
channels,
|
|
476
481
|
longConnectionChannels,
|
|
@@ -511,7 +516,7 @@ async function runDeployDocker(params) {
|
|
|
511
516
|
* behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the whole workspace).
|
|
512
517
|
*/
|
|
513
518
|
async function runDeployFly(params) {
|
|
514
|
-
const { agentDir, workspace, agentPrefix, appName, modelAuth, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
|
|
519
|
+
const { agentDir, workspace, agentPrefix, appName, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
|
|
515
520
|
const fly = spawnRunner("fly", workspace);
|
|
516
521
|
// Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
|
|
517
522
|
if ((await fly(["version"], { capture: true })).code === 127) {
|
|
@@ -520,6 +525,7 @@ async function runDeployFly(params) {
|
|
|
520
525
|
const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
|
|
521
526
|
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
522
527
|
modelAuth,
|
|
528
|
+
modelKeyInDefinition,
|
|
523
529
|
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
524
530
|
channels,
|
|
525
531
|
longConnectionChannels,
|
|
@@ -553,9 +559,10 @@ async function runDeployFly(params) {
|
|
|
553
559
|
* file (secret values off argv) is created here — 0600, removed after the run either way.
|
|
554
560
|
*/
|
|
555
561
|
async function runDeployAgentcore(params) {
|
|
556
|
-
const { agentDir, workspace, agentPrefix, name, modelAuth, authPath, channels, extraSecrets, selfSchedule } = params;
|
|
562
|
+
const { agentDir, workspace, agentPrefix, name, modelAuth, modelKeyInDefinition, authPath, channels, extraSecrets, selfSchedule, } = params;
|
|
557
563
|
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
558
564
|
modelAuth,
|
|
565
|
+
modelKeyInDefinition,
|
|
559
566
|
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
560
567
|
channels,
|
|
561
568
|
extraSecrets,
|
|
@@ -627,7 +634,7 @@ async function runDeployAgentcore(params) {
|
|
|
627
634
|
* webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
|
|
628
635
|
*/
|
|
629
636
|
async function runDeployRailway(params) {
|
|
630
|
-
const { agentDir, workspace, name, modelAuth, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
|
|
637
|
+
const { agentDir, workspace, name, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
|
|
631
638
|
const railway = spawnRunner("railway", workspace);
|
|
632
639
|
// Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
|
|
633
640
|
if ((await railway(["--version"], { capture: true })).code === 127) {
|
|
@@ -635,6 +642,7 @@ async function runDeployRailway(params) {
|
|
|
635
642
|
}
|
|
636
643
|
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
637
644
|
modelAuth,
|
|
645
|
+
modelKeyInDefinition,
|
|
638
646
|
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
639
647
|
channels,
|
|
640
648
|
longConnectionChannels,
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -56,7 +56,7 @@ async function serveOnce(dir, opts) {
|
|
|
56
56
|
reportWorkspaceHint(workspaceHint(a));
|
|
57
57
|
reportLine("config", a.configPath ?? "(none)");
|
|
58
58
|
reportLine("model", `${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
|
|
59
|
-
await reportAuth(a.modelSpec, a.authPath);
|
|
59
|
+
await reportAuth(a.agentDir, a.modelSpec, a.authPath);
|
|
60
60
|
reportAgentsSkillsTools(a);
|
|
61
61
|
// Trace each turn's agent loop (tool calls + reply) to the log at debug level — shown in dev, gated
|
|
62
62
|
// out in start (level info), keeping end-user content out of production logs. Wired in both postures.
|
|
@@ -37,7 +37,7 @@ export async function runFire(name, dirArg, opts) {
|
|
|
37
37
|
authPath: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by the opener (one owner)
|
|
38
38
|
}).catch(failStartup);
|
|
39
39
|
console.error(`[fastagent] fire: ${name} (${modelSpec})`);
|
|
40
|
-
await reportAuth(modelSpec, authPath);
|
|
40
|
+
await reportAuth(placement.agentDir, modelSpec, authPath);
|
|
41
41
|
const exitCode = await runInvokeStream(agent.invoke({ session: scheduleSession(name) }, { text: schedule.prompt }), (text) => process.stdout.write(text), (line) => console.error(line));
|
|
42
42
|
process.stdout.write("\n");
|
|
43
43
|
process.exit(exitCode);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { loadDotEnv } from "../../env.js";
|
|
4
4
|
import { discoverChannelFiles } from "../../engines/pi/channel.js";
|
|
5
|
-
import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModelSpec, resolveSessionsDirOverride, } from "../../engines/pi/config.js";
|
|
5
|
+
import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModel, resolveModelSpec, resolveSessionsDirOverride, } from "../../engines/pi/config.js";
|
|
6
|
+
import { createPiModelRuntime } from "../../engines/pi/models.js";
|
|
6
7
|
import { resolveStateRoot, workspaceHint } from "../../paths.js";
|
|
7
8
|
import { resolveAgentTools } from "../../engines/pi/create.js";
|
|
8
9
|
import { loadAgentDefinition } from "../../engines/pi/definition.js";
|
|
@@ -55,12 +56,27 @@ export async function runInfo(dirArg, opts) {
|
|
|
55
56
|
const stateRoot = resolveStateRoot(agentDir);
|
|
56
57
|
const sessionsDir = resolveSessionsDirOverride(opts.sessionsDir) ?? defaultSessionsDir(stateRoot);
|
|
57
58
|
const authPath = resolveAuthPath(agentDir, opts.authPath); // flag > FASTAGENT_AUTH_PATH > default — the one owner
|
|
59
|
+
// RESOLVE the spec, do not just echo it: a spec is only real once its provider/model exist in the
|
|
60
|
+
// agent's own surface (built-ins + its models.json), which is exactly what a custom endpoint changes.
|
|
61
|
+
// Reporting a healthy-looking spec that `dev`/`start` then reject is the failure this pre-empts.
|
|
62
|
+
// Reported as DATA rather than thrown — a broken agent is what `info` is for — and read-only: the
|
|
63
|
+
// runtime reads models.json without creating anything (its catalog cache is written on refresh, and
|
|
64
|
+
// there is none here).
|
|
65
|
+
const modelError = modelSpec
|
|
66
|
+
? await createPiModelRuntime({ agentDir, authPath })
|
|
67
|
+
.then((models) => {
|
|
68
|
+
resolveModel(models, modelSpec);
|
|
69
|
+
return undefined;
|
|
70
|
+
})
|
|
71
|
+
.catch((error) => error.message)
|
|
72
|
+
: undefined;
|
|
58
73
|
if (opts.json) {
|
|
59
74
|
console.log(JSON.stringify({
|
|
60
75
|
agentDir,
|
|
61
76
|
workspace,
|
|
62
77
|
configPath: configPath ?? null,
|
|
63
78
|
model: modelSpec ?? null,
|
|
79
|
+
modelError: modelError ?? null,
|
|
64
80
|
thinkingLevel: config.thinkingLevel ?? null,
|
|
65
81
|
context: definition.contextFiles.map((f) => f.path),
|
|
66
82
|
persona: definition.persona !== undefined,
|
|
@@ -85,6 +101,8 @@ export async function runInfo(dirArg, opts) {
|
|
|
85
101
|
// One padded label writer: hand-spaced labels drifted out of alignment the moment a longer one
|
|
86
102
|
// (agent/workspace/selfSchedule) joined the report.
|
|
87
103
|
const line = (label, value) => console.log(`${`${label}:`.padEnd(13)} ${value}`);
|
|
104
|
+
/** A continuation under the previous line, aligned to the same column (no label, so no bare colon). */
|
|
105
|
+
const cont = (value) => console.log(`${"".padEnd(13)} ${value}`);
|
|
88
106
|
line("agent", agentDir);
|
|
89
107
|
line("workspace", workspace);
|
|
90
108
|
const hint = workspaceHint({ agentDir, workspace });
|
|
@@ -92,6 +110,8 @@ export async function runInfo(dirArg, opts) {
|
|
|
92
110
|
line("hint", hint);
|
|
93
111
|
line("config", configPath ?? "(none)");
|
|
94
112
|
line("model", modelSpec ?? "(not set — pass --model, set FASTAGENT_MODEL, or config.model)");
|
|
113
|
+
if (modelError)
|
|
114
|
+
cont(`⚠ does not resolve: ${modelError}`);
|
|
95
115
|
if (config.thinkingLevel)
|
|
96
116
|
line("thinking", config.thinkingLevel);
|
|
97
117
|
line("context", definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
|
|
@@ -20,7 +20,7 @@ export async function runInvoke(message, dirArg, opts) {
|
|
|
20
20
|
// BOTH directories, like dev/start: from the workspace, `placement.workspace` alone equals the dir you
|
|
21
21
|
// typed, so it cannot tell you which agent actually ran.
|
|
22
22
|
console.error(`[fastagent] invoke: ${placement.agentDir} (workspace ${placement.workspace}, ${modelSpec})`);
|
|
23
|
-
await reportAuth(modelSpec, authPath);
|
|
23
|
+
await reportAuth(placement.agentDir, modelSpec, authPath);
|
|
24
24
|
// Fresh session per invoke (one-shot, no resume). runInvokeStream maps events→IO: reply→stdout,
|
|
25
25
|
// tool/failure→stderr, exit 1 iff the turn failed (so CI can gate on it).
|
|
26
26
|
const exitCode = await runInvokeStream(agent.invoke({ session: randomUUID() }, { text: message }), (text) => process.stdout.write(text), (line) => console.error(line));
|
|
@@ -18,6 +18,7 @@ import { logAgentLoop } from "../../observe.js";
|
|
|
18
18
|
import { installProxyFetch } from "../../proxy.js";
|
|
19
19
|
import { exists } from "../../paths.js";
|
|
20
20
|
import { bindAddress } from "../../bind.js";
|
|
21
|
+
import { parseRouteKey } from "../../host/node.js";
|
|
21
22
|
import { failStartup, placementOrExit } from "../fail.js";
|
|
22
23
|
import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
|
|
23
24
|
import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
|
|
@@ -48,7 +49,7 @@ export async function runStart(dirArg, opts) {
|
|
|
48
49
|
reportLine("workspace", workspace);
|
|
49
50
|
reportWorkspaceHint(workspaceHint({ agentDir, workspace }));
|
|
50
51
|
reportLine("model", `${modelSpec}${config.thinkingLevel ? ` (thinking: ${config.thinkingLevel})` : ""}`);
|
|
51
|
-
await reportAuth(modelSpec, authPath);
|
|
52
|
+
await reportAuth(agentDir, modelSpec, authPath);
|
|
52
53
|
reportLine("context", definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
|
|
53
54
|
if (definition.persona)
|
|
54
55
|
reportLine("persona", "persona.md");
|
|
@@ -89,13 +90,21 @@ export async function runStart(dirArg, opts) {
|
|
|
89
90
|
const agentcore = process.env.FASTAGENT_AGENTCORE === "1";
|
|
90
91
|
// Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
|
|
91
92
|
const traced = logAgentLoop(agent);
|
|
92
|
-
|
|
93
|
+
// On AgentCore the channels are constructed LAZILY (mountAgentcore's lazyChannels, resolved on the
|
|
94
|
+
// first envelope after the state-snapshot restore): construction loads channel state and replays
|
|
95
|
+
// turn intent, and at boot the state mount is PRE-RESTORE — empty after every version update — so
|
|
96
|
+
// an eager build would cache that emptiness (thread participation, delivery dedup, pending turns)
|
|
97
|
+
// and then clobber the restored files with it. Everywhere else the state root is durable at boot,
|
|
98
|
+
// so channels mount eagerly and a broken channel fails startup.
|
|
99
|
+
const routed = agentcore
|
|
100
|
+
? undefined
|
|
101
|
+
: await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: true }).catch(failStartup);
|
|
93
102
|
// `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
|
|
94
103
|
// configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
|
|
95
104
|
const configured = config.http?.host;
|
|
96
105
|
const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
|
|
97
106
|
assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
|
|
98
|
-
const withControl = mountSessionControl(routed
|
|
107
|
+
const withControl = mountSessionControl(routed?.routes ?? {}, sessionControl, stateRoot, {
|
|
99
108
|
tunnel: opts.tunnel ?? false,
|
|
100
109
|
agent: traced,
|
|
101
110
|
host,
|
|
@@ -126,17 +135,42 @@ export async function runStart(dirArg, opts) {
|
|
|
126
135
|
});
|
|
127
136
|
let routes = withControl.routes;
|
|
128
137
|
if (agentcore) {
|
|
138
|
+
// The lazy channel surface the adapter resolves post-restore. Control routes ride along so a
|
|
139
|
+
// forwarder-relayed /control/* request dispatches the same as on a direct host. Long-connection
|
|
140
|
+
// channels cannot serve here — scale-to-zero severs a resident connection and nothing
|
|
141
|
+
// re-establishes it — so their presence is a configuration error, surfaced per envelope and by
|
|
142
|
+
// the deploy driver's health probe (there is no boot to fail on this host).
|
|
143
|
+
const lazyChannels = async () => {
|
|
144
|
+
const surface = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: false });
|
|
145
|
+
if (surface.longConnections.length > 0) {
|
|
146
|
+
throw new Error(`long-connection channel(s) ${surface.longConnections.map((c) => c.name).join(", ")} cannot serve on ` +
|
|
147
|
+
`AgentCore (scale-to-zero severs resident connections) — use the channel's webhook form`);
|
|
148
|
+
}
|
|
149
|
+
// mountSessionControl's PATH-level collision rule, re-asserted here: with no channels at boot
|
|
150
|
+
// its own check ran against an empty base, and a spread merge would silently let control win —
|
|
151
|
+
// but a channel on /control/* is the same configuration error it is on every other host.
|
|
152
|
+
const controlPaths = new Set(Object.keys(withControl.routes).map((key) => parseRouteKey(key).path));
|
|
153
|
+
const collisions = Object.keys(surface.routes).filter((key) => controlPaths.has(parseRouteKey(key).path));
|
|
154
|
+
if (collisions.length > 0) {
|
|
155
|
+
throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control ` +
|
|
156
|
+
`plane — rename the channel route or disable sessionControl in fastagent.config`);
|
|
157
|
+
}
|
|
158
|
+
return { ...surface.routes, ...withControl.routes };
|
|
159
|
+
};
|
|
129
160
|
try {
|
|
130
|
-
routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady });
|
|
161
|
+
routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady, lazyChannels });
|
|
131
162
|
}
|
|
132
163
|
catch (e) {
|
|
133
164
|
failStartup(e);
|
|
134
165
|
}
|
|
135
166
|
log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
|
|
136
167
|
}
|
|
137
|
-
serve({
|
|
168
|
+
serve({
|
|
169
|
+
...(routed ?? { longConnections: [], routeChannels: [], builtinInvoke: false, markReady() { } }),
|
|
170
|
+
routes,
|
|
171
|
+
}, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
|
|
138
172
|
withControl.announce(p);
|
|
139
|
-
maybeTunnel(agentDir, routed
|
|
173
|
+
maybeTunnel(agentDir, routed?.routeChannels ?? [], p, opts.tunnel ?? false, stateRoot);
|
|
140
174
|
});
|
|
141
175
|
// No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
|
|
142
176
|
// in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -49,6 +49,10 @@ export declare function mountAgentcore(routes: Routes, options: {
|
|
|
49
49
|
stateRoot: string;
|
|
50
50
|
schedules: LoadedSchedule[];
|
|
51
51
|
onStateReady?: () => void;
|
|
52
|
+
/** The serving path's LAZY channel surface: constructed by the adapter on the first envelope
|
|
53
|
+
* AFTER the state-snapshot restore, never at boot (channels/agentcore.ts). When absent,
|
|
54
|
+
* `routes` is the dispatch target — for wirings whose state root is already authoritative. */
|
|
55
|
+
lazyChannels?: () => Promise<Routes>;
|
|
52
56
|
}): Routes;
|
|
53
57
|
/**
|
|
54
58
|
* Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
|
package/dist/cli/serve.js
CHANGED
|
@@ -151,9 +151,9 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
|
151
151
|
* the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
|
|
152
152
|
*/
|
|
153
153
|
export function mountAgentcore(routes, options) {
|
|
154
|
-
const { agent, stateRoot, schedules, onStateReady } = options;
|
|
154
|
+
const { agent, stateRoot, schedules, onStateReady, lazyChannels } = options;
|
|
155
155
|
const mounted = agentcoreRoutes({
|
|
156
|
-
routes,
|
|
156
|
+
routes: lazyChannels ?? routes,
|
|
157
157
|
agent,
|
|
158
158
|
stateRoot,
|
|
159
159
|
isBusy: () => activeWork() > 0,
|
package/dist/cli/shared.d.ts
CHANGED
|
@@ -26,8 +26,10 @@ export declare function parsePort(value: string | undefined, source: string, fro
|
|
|
26
26
|
* a node bind failure, or worse, as a "the interface you bound" diagnostic downstream.
|
|
27
27
|
*/
|
|
28
28
|
export declare function parseBind(value: string | undefined): string | undefined;
|
|
29
|
-
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking.
|
|
30
|
-
|
|
29
|
+
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking.
|
|
30
|
+
* Probes through the AGENT's model surface (`agentDir` carries its models.json), so a custom endpoint is
|
|
31
|
+
* reported like any built-in rather than as an unknown provider. */
|
|
32
|
+
export declare function reportAuth(agentDir: string, modelSpec: string, authPath: string): Promise<void>;
|
|
31
33
|
/**
|
|
32
34
|
* First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
|
|
33
35
|
* funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
|
package/dist/cli/shared.js
CHANGED
|
@@ -10,7 +10,7 @@ import { buildModelPickerOptions } from "./models-view.js";
|
|
|
10
10
|
import { fastagentCredentialStore } from "../engines/pi/auth.js";
|
|
11
11
|
import { isValidPort, listModels, loadConfig, providerOf, resolveAuthPath, resolveModel, resolveModelSpec, rewriteConfigModel, } from "../engines/pi/config.js";
|
|
12
12
|
import { LoginCancelled, loginFlow } from "../engines/pi/login.js";
|
|
13
|
-
import { createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses } from "../engines/pi/models.js";
|
|
13
|
+
import { createPiModelRuntime, createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses, } from "../engines/pi/models.js";
|
|
14
14
|
import { formatAuthReport } from "./auth-view.js";
|
|
15
15
|
import { log } from "../log.js";
|
|
16
16
|
import { openExternalUrl } from "../open-url.js";
|
|
@@ -70,10 +70,13 @@ export function parseBind(value) {
|
|
|
70
70
|
failUsage(`invalid --bind "${value}": must be an IP address or "localhost"`);
|
|
71
71
|
return bindAddress(trimmed); // a name never travels past this point — see bind.ts
|
|
72
72
|
}
|
|
73
|
-
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking.
|
|
74
|
-
|
|
73
|
+
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking.
|
|
74
|
+
* Probes through the AGENT's model surface (`agentDir` carries its models.json), so a custom endpoint is
|
|
75
|
+
* reported like any built-in rather than as an unknown provider. */
|
|
76
|
+
export async function reportAuth(agentDir, modelSpec, authPath) {
|
|
75
77
|
const provider = providerOf(modelSpec);
|
|
76
|
-
const
|
|
78
|
+
const models = await createPiModelRuntime({ agentDir, authPath }).catch(failStartup);
|
|
79
|
+
const source = await probeAuthSource(models, modelSpec);
|
|
77
80
|
// Only when nothing satisfies auth do we read the store (refresh-FREE) to tell "nothing stored" from
|
|
78
81
|
// "stored but unusable" — see formatAuthReport for why. store.read warns on a corrupt file itself.
|
|
79
82
|
const stored = source === undefined
|
|
@@ -107,7 +110,9 @@ export async function resolveFirstRunModel(agentDir, options = {}) {
|
|
|
107
110
|
if (!isInteractive())
|
|
108
111
|
return; // CI/deploy: the opener throws the actionable missing-model error
|
|
109
112
|
const authPath = resolveAuthPath(agentDir, options.authPath);
|
|
110
|
-
|
|
113
|
+
// The picker lists the AGENT's surface: built-ins plus whatever its models.json declares, so a
|
|
114
|
+
// self-hosted endpoint is pickable on first run instead of being invisible until hand-set.
|
|
115
|
+
const models = await createPiModelRuntime({ agentDir, authPath }).catch(failStartup);
|
|
111
116
|
const chosen = await pickWithCredentials(models, authPath);
|
|
112
117
|
if (chosen === undefined)
|
|
113
118
|
return; // cancelled (or auth probe failed): the caller raises its clear missing-model error
|
|
@@ -208,6 +213,8 @@ seams = {}) {
|
|
|
208
213
|
* more than keeping a doubtful one.
|
|
209
214
|
*/
|
|
210
215
|
async function verifyApiKeyLogin(provider, authPath, spec) {
|
|
216
|
+
// Built-ins only: `login` itself offers built-in providers (login.ts), and a models.json endpoint
|
|
217
|
+
// authenticates from its own `apiKey` (env/command), so there is no stored credential to verify here.
|
|
211
218
|
const models = createPiModels({ authPath });
|
|
212
219
|
const model = spec ? resolveModel(models, spec) : models.getProvider(provider)?.getModels()[0];
|
|
213
220
|
if (!model) {
|
package/dist/collect.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Stream helpers around the SPEC's two stream disciplines.
|
|
3
|
+
*
|
|
4
|
+
* Caller side: `collect` (buffered consumption, SPEC §7) reduces an AgentEvent stream to a final
|
|
5
|
+
* value, encoding the terminal discipline (failed → throw, missing terminal → error) — streaming
|
|
6
|
+
* consumers for-await themselves.
|
|
7
|
+
*
|
|
8
|
+
* Agent side: the cancellation protocol (SPEC MUST 3), in two halves that only work together —
|
|
9
|
+
* `abortFirstIterator` delivers the consumer's knock, `cancellableStream` is what an engine wraps
|
|
10
|
+
* its turn in to receive it.
|
|
6
11
|
*/
|
|
7
12
|
import type { AgentEvent, Json } from "./agent.ts";
|
|
8
13
|
/**
|
|
@@ -13,8 +18,29 @@ import type { AgentEvent, Json } from "./agent.ts";
|
|
|
13
18
|
* settles the suspension), then delegates to `gen.return`, swallowing its rejection (the
|
|
14
19
|
* generator's own catch/finally already surfaced the outcome). `throw()` tears down identically
|
|
15
20
|
* and rethrows the caller's error deterministically instead of poking a completed generator.
|
|
21
|
+
*
|
|
22
|
+
* Cancellation also SILENCES the stream, and that belongs here rather than in each producer: a
|
|
23
|
+
* generator parked in an await can still reach a `yield` on its way out (an error path that
|
|
24
|
+
* yields a terminal, say), and that yield satisfies the pending `next()` — handing a terminal
|
|
25
|
+
* event to a consumer that already walked away, which SPEC MUST 3 forbids. Deciding it once, at
|
|
26
|
+
* the protocol boundary, is what keeps every producer from having to re-ask "is anyone still
|
|
27
|
+
* listening?" before each yield.
|
|
16
28
|
*/
|
|
17
29
|
export declare function abortFirstIterator<T>(gen: AsyncGenerator<T>, cancel: () => void): AsyncIterator<T>;
|
|
30
|
+
/** What a turn generator gets so a consumer walking away can stop it. */
|
|
31
|
+
export interface CancelHooks {
|
|
32
|
+
/** Publish the door: how to abort the engine work, once there is engine work to abort. */
|
|
33
|
+
onCancelReady: (cancel: () => void) => void;
|
|
34
|
+
/** The latch, for the window where the door is armed but the engine is still idle — knocking then
|
|
35
|
+
* does nothing, so a turn must read this before committing to work no one is waiting for. */
|
|
36
|
+
wasCancelled: () => boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a turn generator in the cancellation protocol: cancelling the returned stream latches the
|
|
40
|
+
* intent AND knocks on whatever door the generator published, in that order (the latch must be set
|
|
41
|
+
* before the knock, or a turn checking it mid-flight could miss the cancel it just received).
|
|
42
|
+
*/
|
|
43
|
+
export declare function cancellableStream<T>(start: (hooks: CancelHooks) => AsyncGenerator<T>): AsyncIterable<T>;
|
|
18
44
|
/** Exception form of a failed event (thrown by collect). Carries the failed event's fields verbatim, so
|
|
19
45
|
* a buffered consumer can branch on `code` (SPEC §8 failure subdivision) just like a streaming one. */
|
|
20
46
|
export declare class AgentFailure extends Error {
|
package/dist/collect.js
CHANGED
|
@@ -6,22 +6,55 @@
|
|
|
6
6
|
* settles the suspension), then delegates to `gen.return`, swallowing its rejection (the
|
|
7
7
|
* generator's own catch/finally already surfaced the outcome). `throw()` tears down identically
|
|
8
8
|
* and rethrows the caller's error deterministically instead of poking a completed generator.
|
|
9
|
+
*
|
|
10
|
+
* Cancellation also SILENCES the stream, and that belongs here rather than in each producer: a
|
|
11
|
+
* generator parked in an await can still reach a `yield` on its way out (an error path that
|
|
12
|
+
* yields a terminal, say), and that yield satisfies the pending `next()` — handing a terminal
|
|
13
|
+
* event to a consumer that already walked away, which SPEC MUST 3 forbids. Deciding it once, at
|
|
14
|
+
* the protocol boundary, is what keeps every producer from having to re-ask "is anyone still
|
|
15
|
+
* listening?" before each yield.
|
|
9
16
|
*/
|
|
10
17
|
export function abortFirstIterator(gen, cancel) {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
const teardown = async () => {
|
|
20
|
+
cancelled = true;
|
|
21
|
+
cancel();
|
|
22
|
+
await gen.return(undefined).catch(() => { });
|
|
23
|
+
};
|
|
11
24
|
return {
|
|
12
|
-
next
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
async next() {
|
|
26
|
+
const result = await gen.next();
|
|
27
|
+
return cancelled ? { done: true, value: undefined } : result;
|
|
28
|
+
},
|
|
29
|
+
async return() {
|
|
30
|
+
await teardown();
|
|
16
31
|
return { done: true, value: undefined };
|
|
17
32
|
},
|
|
18
33
|
async throw(error) {
|
|
19
|
-
|
|
20
|
-
await gen.return(undefined).catch(() => { });
|
|
34
|
+
await teardown();
|
|
21
35
|
throw error;
|
|
22
36
|
},
|
|
23
37
|
};
|
|
24
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Wrap a turn generator in the cancellation protocol: cancelling the returned stream latches the
|
|
41
|
+
* intent AND knocks on whatever door the generator published, in that order (the latch must be set
|
|
42
|
+
* before the knock, or a turn checking it mid-flight could miss the cancel it just received).
|
|
43
|
+
*/
|
|
44
|
+
export function cancellableStream(start) {
|
|
45
|
+
let door;
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
const iterator = abortFirstIterator(start({
|
|
48
|
+
onCancelReady: (cancel) => {
|
|
49
|
+
door = cancel;
|
|
50
|
+
},
|
|
51
|
+
wasCancelled: () => cancelled,
|
|
52
|
+
}), () => {
|
|
53
|
+
cancelled = true;
|
|
54
|
+
door?.();
|
|
55
|
+
});
|
|
56
|
+
return { [Symbol.asyncIterator]: () => iterator };
|
|
57
|
+
}
|
|
25
58
|
/** Exception form of a failed event (thrown by collect). Carries the failed event's fields verbatim, so
|
|
26
59
|
* a buffered consumer can branch on `code` (SPEC §8 failure subdivision) just like a streaming one. */
|
|
27
60
|
export class AgentFailure extends Error {
|