@fastagent-sh/fastagent 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
2
|
+
/** A same-name skill collision (the discarded side). Surfaced, never swallowed. */
|
|
3
|
+
export interface SkillCollision {
|
|
4
|
+
name: string;
|
|
5
|
+
winnerPath: string;
|
|
6
|
+
loserPath: string;
|
|
7
|
+
}
|
|
8
|
+
/** Result of loading a definition directory. Produced by {@link loadAgentDefinition}. */
|
|
9
|
+
export interface LoadedDefinition {
|
|
10
|
+
/**
|
|
11
|
+
* Project-context files feeding segment ② `<project_context>`, sourced via pi's `loadProjectContextFiles`:
|
|
12
|
+
* the agentDir's own AGENTS.md/CLAUDE.md FIRST, then every AGENTS.md from root down to `cwd` (so the
|
|
13
|
+
* file nearest `cwd` comes LAST — pi's array order). Empty when none exist. This WALKS cwd's ancestors (pi's coding-agent behaviour) — see core.md §6.
|
|
14
|
+
*/
|
|
15
|
+
contextFiles: Array<{
|
|
16
|
+
path: string;
|
|
17
|
+
content: string;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Verbatim `persona.md` content — the authored persona that OVERRIDES segment ①'s identity line
|
|
21
|
+
* (piBasePrompt keeps the tool list + guidelines; NOT a full system-prompt replacement — that is L1
|
|
22
|
+
* createPiAgent's `instructions`). undefined when absent → segment ① is the default engine identity.
|
|
23
|
+
*/
|
|
24
|
+
persona?: string;
|
|
25
|
+
skills: Skill[];
|
|
26
|
+
/** Non-fatal per-file skill problems reported by pi's loader. */
|
|
27
|
+
diagnostics: SkillDiagnostic[];
|
|
28
|
+
/** Same-name conflicts across mounts (first-wins). */
|
|
29
|
+
collisions: SkillCollision[];
|
|
30
|
+
/** Absolute agent-definition directory path (persona.md/skills/ live here). */
|
|
31
|
+
dir: string;
|
|
32
|
+
}
|
|
33
|
+
export interface LoadAgentDefinitionOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Working directory whose ancestors are walked for context files (segment ②). Default = `agentDir`
|
|
36
|
+
* (flat: the agent dir is also the run root). The opener passes the run root so a coding agent that
|
|
37
|
+
* lives in `agentDir` picks up the host repo's AGENTS.md up the tree (core.md scenario grid).
|
|
38
|
+
*/
|
|
39
|
+
cwd?: string;
|
|
40
|
+
env?: ExecutionEnv;
|
|
41
|
+
}
|
|
42
|
+
/** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
|
|
43
|
+
export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
|
|
44
|
+
/**
|
|
45
|
+
* Whether `targetPath` lives inside `baseDir` (same path counts). The self-ignore guard uses it to ask
|
|
46
|
+
* "does the resolved state root land inside the workspace tree?" — an in-tree root (the default
|
|
47
|
+
* `.fastagent`, or a custom `FASTAGENT_STATE_DIR` pointed inside the agent dir) is ours to self-ignore;
|
|
48
|
+
* a root on an external volume resolves outside and must not be (we never write a `.gitignore` outside
|
|
49
|
+
* the tree). Whether a relative override lands in-tree is a cwd question — see `resolveStateRoot`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isUnderDir(targetPath: string, baseDir: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The single owner of the self-ignore MECHANISM: iff the resolved state ROOT lands inside the workspace
|
|
54
|
+
* tree, write `<stateRoot>/.gitignore="*"` — which then covers EVERYTHING under it (sessions, auth.json,
|
|
55
|
+
* every channel's `channels/<kind>` home). `ensureStateDirSelfIgnored` is private, so a caller cannot
|
|
56
|
+
* write a `.gitignore` bypassing this.
|
|
57
|
+
*
|
|
58
|
+
* ROOT-based, not path-based: everything derives from the state root (config.ts), so protecting the
|
|
59
|
+
* root protects all of it — INCLUDING a custom in-tree root (a `FASTAGENT_STATE_DIR` inside the agent
|
|
60
|
+
* dir), the case a path-based (`.fastagent`-only) guard would leak. A per-path override
|
|
61
|
+
* (`--sessions-dir`/`--auth-path`) is operator-owned: pointed at an external volume it is out-of-tree
|
|
62
|
+
* (correctly not ours to ignore); pointed at a custom in-tree dir WE DON'T OWN, we do not write a
|
|
63
|
+
* `.gitignore` into it (it may be a directory the operator deliberately tracks).
|
|
64
|
+
*
|
|
65
|
+
* Excludes the user's HOME-global `~/.fastagent` (e.g. `login`/`dev` run from `$HOME`): self-ignore is
|
|
66
|
+
* for protecting state inside an agent PROJECT tree, not for writing a `.gitignore` into the user's
|
|
67
|
+
* home, which a dotfiles repo may track. The global credential file there was never self-ignored.
|
|
68
|
+
*/
|
|
69
|
+
export declare function ensureStateRootSelfIgnored(dir: string, stateRoot: string): Promise<void>;
|
|
70
|
+
/** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
|
|
71
|
+
* A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
|
|
72
|
+
export declare function canonicalPath(p: string): string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition domain: read an agent definition directory (AGENTS.md + skills/) into memory. Produces
|
|
3
|
+
* data; create.ts consumes it.
|
|
4
|
+
*
|
|
5
|
+
* IO policy: persona.md + skills load through ExecutionEnv (portable across local/sandbox/remote); the
|
|
6
|
+
* invoke path never touches disk. EXCEPTION — ② project context comes from pi's loadProjectContextFiles,
|
|
7
|
+
* which reads via node fs DIRECTLY (not the injected `env`) and, on failure, at best warns to stderr and
|
|
8
|
+
* at worst is fully silent (its `existsSync` probe swallows a permission error) — no structured signal. So under a
|
|
9
|
+
* non-local ExecutionEnv the ② files still resolve against THIS process's filesystem, not the target env
|
|
10
|
+
* — a known break in the portability contract, deferred with the sandbox work (core.md §6). config/auth/
|
|
11
|
+
* sessions and this module's Node helpers are composition-root code and may use node fs.
|
|
12
|
+
*
|
|
13
|
+
* Errors: a broken persona.md / unresolvable dir throws (fail loudly at startup); non-fatal findings
|
|
14
|
+
* (bad skill files, name collisions) are returned as data. An unreadable ② context file only warns (pi).
|
|
15
|
+
*/
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
20
|
+
import { loadSkills } from "@earendil-works/pi-agent-core";
|
|
21
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
22
|
+
import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
/** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
|
|
24
|
+
export async function loadAgentDefinition(agentDir, options = {}) {
|
|
25
|
+
// One resolved default for the working directory (env cwd AND the context-walk start), so they can
|
|
26
|
+
// never diverge if a caller passes a relative agentDir.
|
|
27
|
+
const cwd = options.cwd ?? agentDir;
|
|
28
|
+
const e = options.env ?? new NodeExecutionEnv({ cwd });
|
|
29
|
+
const rootResult = await e.absolutePath(agentDir);
|
|
30
|
+
if (!rootResult.ok) {
|
|
31
|
+
throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
|
|
32
|
+
}
|
|
33
|
+
const root = rootResult.value;
|
|
34
|
+
// ② project context, following pi: the agentDir's own AGENTS.md + every AGENTS.md walking cwd up to
|
|
35
|
+
// root (loadProjectContextFiles). It reads via node fs directly (mirrors pi), NOT the ExecutionEnv —
|
|
36
|
+
// a deliberate, deferred deviation from this module's portable-IO policy (revisit with the sandbox; core.md §6).
|
|
37
|
+
const contextFiles = loadProjectContextFiles({ cwd, agentDir: root });
|
|
38
|
+
// persona.md → segment ① persona (overrides the identity line). Same error policy as AGENTS.md:
|
|
39
|
+
// only not_found means "absent"; any other read error surfaces rather than silently dropping the persona.
|
|
40
|
+
const personaPath = join(root, "persona.md");
|
|
41
|
+
const personaRead = await e.readTextFile(personaPath);
|
|
42
|
+
if (!personaRead.ok && personaRead.error.code !== "not_found") {
|
|
43
|
+
throw new Error(`cannot read ${personaPath}: ${personaRead.error.message}`);
|
|
44
|
+
}
|
|
45
|
+
const persona = personaRead.ok ? personaRead.value : undefined;
|
|
46
|
+
// Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
|
|
47
|
+
// definition loads the same skills on every machine.
|
|
48
|
+
const { skills: raw, diagnostics } = await loadSkills(e, [join(root, "skills")]);
|
|
49
|
+
const byName = new Map();
|
|
50
|
+
const collisions = [];
|
|
51
|
+
for (const skill of raw) {
|
|
52
|
+
const existing = byName.get(skill.name);
|
|
53
|
+
if (existing) {
|
|
54
|
+
collisions.push({ name: skill.name, winnerPath: existing.filePath, loserPath: skill.filePath });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
byName.set(skill.name, skill);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { contextFiles, persona, skills: [...byName.values()], diagnostics, collisions, dir: root };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether `targetPath` lives inside `baseDir` (same path counts). The self-ignore guard uses it to ask
|
|
64
|
+
* "does the resolved state root land inside the workspace tree?" — an in-tree root (the default
|
|
65
|
+
* `.fastagent`, or a custom `FASTAGENT_STATE_DIR` pointed inside the agent dir) is ours to self-ignore;
|
|
66
|
+
* a root on an external volume resolves outside and must not be (we never write a `.gitignore` outside
|
|
67
|
+
* the tree). Whether a relative override lands in-tree is a cwd question — see `resolveStateRoot`.
|
|
68
|
+
*/
|
|
69
|
+
export function isUnderDir(targetPath, baseDir) {
|
|
70
|
+
const rel = relative(baseDir, targetPath);
|
|
71
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Self-ignore a state dir: create it if missing, then write `<stateDir>/.gitignore` = "*" (idempotent
|
|
75
|
+
* — an existing one is kept), so a workspace that runs dev/start never shows machine state as
|
|
76
|
+
* untracked. Creates the dir because a caller may self-ignore it before anything else populates it
|
|
77
|
+
* (e.g. `login` writing auth.json into a not-yet-created root).
|
|
78
|
+
*
|
|
79
|
+
* Module-PRIVATE on purpose: the only entry to the leak guard is {@link ensureStateRootSelfIgnored}
|
|
80
|
+
* (home exclusion + containment). Keeping this unexported makes that single-owner claim hold at the
|
|
81
|
+
* type level — a sibling command can't bypass those checks by writing a `.gitignore` directly.
|
|
82
|
+
*/
|
|
83
|
+
async function ensureStateDirSelfIgnored(stateDir) {
|
|
84
|
+
await mkdir(stateDir, { recursive: true });
|
|
85
|
+
await writeFile(join(stateDir, ".gitignore"), "*\n", { flag: "wx" }).catch((e) => {
|
|
86
|
+
if (e.code !== "EEXIST")
|
|
87
|
+
throw e;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The single owner of the self-ignore MECHANISM: iff the resolved state ROOT lands inside the workspace
|
|
92
|
+
* tree, write `<stateRoot>/.gitignore="*"` — which then covers EVERYTHING under it (sessions, auth.json,
|
|
93
|
+
* every channel's `channels/<kind>` home). `ensureStateDirSelfIgnored` is private, so a caller cannot
|
|
94
|
+
* write a `.gitignore` bypassing this.
|
|
95
|
+
*
|
|
96
|
+
* ROOT-based, not path-based: everything derives from the state root (config.ts), so protecting the
|
|
97
|
+
* root protects all of it — INCLUDING a custom in-tree root (a `FASTAGENT_STATE_DIR` inside the agent
|
|
98
|
+
* dir), the case a path-based (`.fastagent`-only) guard would leak. A per-path override
|
|
99
|
+
* (`--sessions-dir`/`--auth-path`) is operator-owned: pointed at an external volume it is out-of-tree
|
|
100
|
+
* (correctly not ours to ignore); pointed at a custom in-tree dir WE DON'T OWN, we do not write a
|
|
101
|
+
* `.gitignore` into it (it may be a directory the operator deliberately tracks).
|
|
102
|
+
*
|
|
103
|
+
* Excludes the user's HOME-global `~/.fastagent` (e.g. `login`/`dev` run from `$HOME`): self-ignore is
|
|
104
|
+
* for protecting state inside an agent PROJECT tree, not for writing a `.gitignore` into the user's
|
|
105
|
+
* home, which a dotfiles repo may track. The global credential file there was never self-ignored.
|
|
106
|
+
*/
|
|
107
|
+
export async function ensureStateRootSelfIgnored(dir, stateRoot) {
|
|
108
|
+
// Compare CANONICAL paths for the home check: `dir` arrives realpath-resolved (it is `process.cwd()`
|
|
109
|
+
// or `resolve(".")`) but `homedir()` returns the raw `$HOME`, so a symlinked home would slip past raw
|
|
110
|
+
// equality and we'd write a `.gitignore` into the real `~/.fastagent` — the very thing the doc forbids
|
|
111
|
+
// (chat.ts canonicalizes for the same reason).
|
|
112
|
+
if (canonicalPath(dir) === canonicalPath(homedir()))
|
|
113
|
+
return;
|
|
114
|
+
// Containment on RAW paths: stateRoot is resolve()'d (config.ts) and `dir` is absolute, so it is exact
|
|
115
|
+
// by construction. An external-volume root resolves outside the tree → skip (not ours to ignore).
|
|
116
|
+
if (isUnderDir(stateRoot, dir))
|
|
117
|
+
await ensureStateDirSelfIgnored(stateRoot);
|
|
118
|
+
}
|
|
119
|
+
/** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
|
|
120
|
+
* A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
|
|
121
|
+
export function canonicalPath(p) {
|
|
122
|
+
const resolved = resolve(p);
|
|
123
|
+
try {
|
|
124
|
+
return realpathSync(resolved);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi harness wiring: construct one pi `AgentHarness` per session. The agent definition (AGENTS.md +
|
|
3
|
+
* skills) is content fed INTO the harness (see definition.ts), not part of it.
|
|
4
|
+
*
|
|
5
|
+
* Under the stateless design the harness is discarded after each use; continuity comes from
|
|
6
|
+
* persisting the session (PiSessionStore) and re-opening it per invoke — pi's prompt() folds the
|
|
7
|
+
* historical entries back into context via buildContext().
|
|
8
|
+
*/
|
|
9
|
+
import { AgentHarness } from "@earendil-works/pi-agent-core";
|
|
10
|
+
import type { AgentTool, ExecutionEnv, Skill } from "@earendil-works/pi-agent-core";
|
|
11
|
+
import type { Model, Models } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { PiSessionStore } from "./sessions.ts";
|
|
13
|
+
/**
|
|
14
|
+
* pi's Model with the API-shape generic erased — fastagent only passes models through to the
|
|
15
|
+
* harness, so the generic carries no information. One alias keeps the `any` auditable.
|
|
16
|
+
*/
|
|
17
|
+
export type AnyModel = Model<any>;
|
|
18
|
+
/** Builds a pi harness bound to the given session — called once per invoke. */
|
|
19
|
+
export type PiHarnessFactory = (session: string) => AgentHarness | Promise<AgentHarness>;
|
|
20
|
+
export interface PiHarnessFactoryOptions {
|
|
21
|
+
/** Session persistence. Continuity = same backing store + same session id. */
|
|
22
|
+
sessions: PiSessionStore;
|
|
23
|
+
env: ExecutionEnv;
|
|
24
|
+
/** Provider collection for all model requests; {@link model} must belong to it (same provider id). */
|
|
25
|
+
models: Models;
|
|
26
|
+
model: AnyModel;
|
|
27
|
+
tools?: AgentTool[];
|
|
28
|
+
/**
|
|
29
|
+
* Final assembled prompt, or a SYNC factory re-evaluated per invoke (how L1 serves dynamic
|
|
30
|
+
* `instructions` + the skills listing). Distinct from {@link live}, which is the directory rung's
|
|
31
|
+
* ASYNC re-read of prompt AND skills as one pair — both are exercised, by different rungs.
|
|
32
|
+
*/
|
|
33
|
+
systemPrompt?: string | (() => string);
|
|
34
|
+
/** Skills visible to the model / explicitly invokable (injected as harness resources). */
|
|
35
|
+
skills?: Skill[];
|
|
36
|
+
/**
|
|
37
|
+
* Per-invoke source for the prompt+skills PAIR, re-evaluated on every harness build. When set it
|
|
38
|
+
* supersedes {@link systemPrompt}/{@link skills} — one call yields both, so the skills listing
|
|
39
|
+
* inside the prompt and the mounted skill resources can never come from two different reads. The
|
|
40
|
+
* directory rung (L2) uses it to re-read the definition, so AGENTS.md/skills edits — the author's or
|
|
41
|
+
* the agent's own — take effect on the next turn without a process restart. A rejection surfaces
|
|
42
|
+
* as that invoke's `failed` event (the factory throw path), never a crash.
|
|
43
|
+
*/
|
|
44
|
+
live?: () => Promise<{
|
|
45
|
+
systemPrompt?: string;
|
|
46
|
+
skills?: Skill[];
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
/** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
|
|
50
|
+
export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi harness wiring: construct one pi `AgentHarness` per session. The agent definition (AGENTS.md +
|
|
3
|
+
* skills) is content fed INTO the harness (see definition.ts), not part of it.
|
|
4
|
+
*
|
|
5
|
+
* Under the stateless design the harness is discarded after each use; continuity comes from
|
|
6
|
+
* persisting the session (PiSessionStore) and re-opening it per invoke — pi's prompt() folds the
|
|
7
|
+
* historical entries back into context via buildContext().
|
|
8
|
+
*/
|
|
9
|
+
import { AgentHarness } from "@earendil-works/pi-agent-core";
|
|
10
|
+
/**
|
|
11
|
+
* Provider request retries. The OpenAI-family / Anthropic / Azure / Codex pi-ai adapters
|
|
12
|
+
* implement client-side retries (429/5xx/request-phase network failures with backoff, honoring
|
|
13
|
+
* Retry-After; a Codex websocket that fails before the stream starts falls back to SSE) but all
|
|
14
|
+
* default maxRetries to 0 (even SDK-backed ones override the SDK default), so a single transient
|
|
15
|
+
* `fetch failed` would otherwise kill the whole turn; 2 matches the OpenAI/Anthropic SDK default.
|
|
16
|
+
* The google / vertex / bedrock / mistral adapters ignore this option (a pi-ai upstream gap) —
|
|
17
|
+
* transients there still fail the turn. Mid-stream drops are deliberately NOT retried anywhere on
|
|
18
|
+
* this path — partial output was already streamed and the SPEC event stream cannot retract it,
|
|
19
|
+
* so a mid-stream failure surfaces as a `failed` event.
|
|
20
|
+
*/
|
|
21
|
+
const PROVIDER_MAX_RETRIES = 2;
|
|
22
|
+
/** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
|
|
23
|
+
export function piHarnessFactory(options) {
|
|
24
|
+
return async (sessionId) => {
|
|
25
|
+
const session = await options.sessions.openOrCreate(sessionId);
|
|
26
|
+
const fresh = options.live ? await options.live() : undefined;
|
|
27
|
+
const { systemPrompt } = options;
|
|
28
|
+
const prompt = fresh ? fresh.systemPrompt : typeof systemPrompt === "function" ? systemPrompt() : systemPrompt;
|
|
29
|
+
const skills = fresh ? fresh.skills : options.skills;
|
|
30
|
+
return new AgentHarness({
|
|
31
|
+
env: options.env,
|
|
32
|
+
session,
|
|
33
|
+
models: options.models,
|
|
34
|
+
model: options.model,
|
|
35
|
+
tools: options.tools,
|
|
36
|
+
systemPrompt: prompt,
|
|
37
|
+
resources: skills ? { skills } : undefined,
|
|
38
|
+
streamOptions: { maxRetries: PROVIDER_MAX_RETRIES },
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type Agent, type AgentEvent } from "../../agent.ts";
|
|
3
|
+
import type { PiHarnessFactory } from "./harness.ts";
|
|
4
|
+
export type Release = () => void;
|
|
5
|
+
export interface Lease {
|
|
6
|
+
/** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
|
|
7
|
+
tryAcquire(session: string): Release | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function inProcessLease(): Lease;
|
|
10
|
+
/** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
|
|
11
|
+
export declare function classifyRetryable(details: string, signal: {
|
|
12
|
+
status?: number;
|
|
13
|
+
code?: unknown;
|
|
14
|
+
}): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
|
|
17
|
+
* with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
|
|
18
|
+
* entire failure class (violating SPEC MUST 1).
|
|
19
|
+
*/
|
|
20
|
+
export declare function toTerminal(message: AssistantMessage): AgentEvent;
|
|
21
|
+
export declare function errorToTerminal(error: unknown): AgentEvent;
|
|
22
|
+
export interface CreatePiAgentFromHarnessOptions {
|
|
23
|
+
harnessFactory: PiHarnessFactory;
|
|
24
|
+
/** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
|
|
25
|
+
lease?: Lease;
|
|
26
|
+
}
|
|
27
|
+
/** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
|
|
28
|
+
export declare function createPiAgentFromHarness(options: CreatePiAgentFromHarnessOptions): Agent;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
3
|
+
import { log } from "../../log.js";
|
|
4
|
+
import { turnContext } from "./tool-context.js";
|
|
5
|
+
export function inProcessLease() {
|
|
6
|
+
const busy = new Set();
|
|
7
|
+
return {
|
|
8
|
+
tryAcquire(session) {
|
|
9
|
+
if (busy.has(session))
|
|
10
|
+
return null;
|
|
11
|
+
busy.add(session);
|
|
12
|
+
let released = false;
|
|
13
|
+
return () => {
|
|
14
|
+
if (released)
|
|
15
|
+
return;
|
|
16
|
+
released = true;
|
|
17
|
+
busy.delete(session);
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// ── §2 translate: the single pi↔SPEC translation point ───────────────────────
|
|
23
|
+
//
|
|
24
|
+
// `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
|
|
25
|
+
// guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
|
|
26
|
+
// is actually available differs by path, and the two are NOT symmetric:
|
|
27
|
+
// - thrown error (errorToTerminal): an HTTP `.status`/`.statusCode` AND a network `.code` (incl.
|
|
28
|
+
// `.cause.code`) — this is where a numeric status genuinely drives the decision.
|
|
29
|
+
// - failed message (toTerminal): ONLY `diagnostics[].error.code`. pi's `DiagnosticErrorInfo` carries
|
|
30
|
+
// a `code` (a network code, or a status delivered as a code), with no separate HTTP-status field —
|
|
31
|
+
// so a message whose provider `code` is a string label (e.g. "rate_limit_exceeded") is not
|
|
32
|
+
// decisive here and falls to prose.
|
|
33
|
+
// The prose fallback is bounded, not a cop-out: pi-ai already ran its own status-code-based client
|
|
34
|
+
// retries (harness.ts PROVIDER_MAX_RETRIES) before surfacing, so an error that reaches this point has
|
|
35
|
+
// already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
|
|
36
|
+
// Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
|
|
37
|
+
// entirely (mirrors the §11 "the deeper fix is upstream in pi" pattern).
|
|
38
|
+
/** Clearly-transient network error codes (Node/undici), decisive on their own. */
|
|
39
|
+
const RETRYABLE_CODES = new Set([
|
|
40
|
+
"ECONNRESET",
|
|
41
|
+
"ETIMEDOUT",
|
|
42
|
+
"ENETUNREACH",
|
|
43
|
+
"ENETDOWN",
|
|
44
|
+
"EAI_AGAIN",
|
|
45
|
+
"EPIPE",
|
|
46
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
47
|
+
"UND_ERR_SOCKET",
|
|
48
|
+
]);
|
|
49
|
+
/** 429 (rate limit) and 5xx (server) are worth retrying; other statuses are decisive NON-retryable. */
|
|
50
|
+
const statusIsRetryable = (status) => status === 429 || (status >= 500 && status < 600);
|
|
51
|
+
/** Last-resort prose match, used only when no structured status/code is available. */
|
|
52
|
+
const RETRYABLE_MESSAGE = /\b(429|5\d\d|timeout|timed out|rate.?limit|overloaded|ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|socket hang up)\b/i;
|
|
53
|
+
/** A structured status/code decision, or `null` when the signal is absent/undecisive → fall to prose. */
|
|
54
|
+
function retryableFromSignal(signal) {
|
|
55
|
+
if (typeof signal.status === "number")
|
|
56
|
+
return statusIsRetryable(signal.status);
|
|
57
|
+
const { code } = signal;
|
|
58
|
+
if (typeof code === "number")
|
|
59
|
+
return statusIsRetryable(code);
|
|
60
|
+
if (typeof code === "string") {
|
|
61
|
+
if (RETRYABLE_CODES.has(code))
|
|
62
|
+
return true;
|
|
63
|
+
if (/^\d{3}$/.test(code))
|
|
64
|
+
return statusIsRetryable(Number(code)); // a status carried as a string
|
|
65
|
+
}
|
|
66
|
+
return null; // no code, or an unknown one — not decisive on its own
|
|
67
|
+
}
|
|
68
|
+
/** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
|
|
69
|
+
export function classifyRetryable(details, signal) {
|
|
70
|
+
return retryableFromSignal(signal) ?? RETRYABLE_MESSAGE.test(details);
|
|
71
|
+
}
|
|
72
|
+
/** Pull a structured status/code off a thrown error (HTTP status or a network code, incl. its cause). */
|
|
73
|
+
function errorSignal(error) {
|
|
74
|
+
if (!error || typeof error !== "object")
|
|
75
|
+
return {};
|
|
76
|
+
const e = error;
|
|
77
|
+
const status = typeof e.status === "number" ? e.status : typeof e.statusCode === "number" ? e.statusCode : undefined;
|
|
78
|
+
const causeCode = e.cause && typeof e.cause === "object" ? e.cause.code : undefined;
|
|
79
|
+
return { status, code: e.code ?? causeCode };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Pull the structured error `code` pi records on a failed message's diagnostics. `diagnostics`
|
|
83
|
+
* accumulates across attempts (`appendAssistantMessageDiagnostic`), so the terminal cause is the LAST
|
|
84
|
+
* code-bearing entry — `findLast`, not `find`: an earlier attempt's transient 503 must not classify a
|
|
85
|
+
* terminal 400/auth failure as retryable. (Reverse scan rather than `findLast` — the tsconfig lib is
|
|
86
|
+
* ES2022.)
|
|
87
|
+
*/
|
|
88
|
+
function messageSignal(message) {
|
|
89
|
+
const diagnostics = message.diagnostics ?? [];
|
|
90
|
+
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
91
|
+
const code = diagnostics[i]?.error?.code;
|
|
92
|
+
if (code !== undefined)
|
|
93
|
+
return { code };
|
|
94
|
+
}
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
/** In-stream event mapping. Non text/tool_* pi events (turn_start, message_start, …) are dropped. */
|
|
98
|
+
function toAgentEvent(pe) {
|
|
99
|
+
switch (pe.type) {
|
|
100
|
+
case "message_update": {
|
|
101
|
+
const ev = pe.assistantMessageEvent;
|
|
102
|
+
if (ev.type === "text_delta")
|
|
103
|
+
return { type: "text", delta: ev.delta };
|
|
104
|
+
if (ev.type === "thinking_delta")
|
|
105
|
+
return { type: "thinking", delta: ev.delta };
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
case "tool_execution_start":
|
|
109
|
+
return { type: "tool_started", id: pe.toolCallId, name: pe.toolName, args: pe.args };
|
|
110
|
+
case "tool_execution_end":
|
|
111
|
+
return { type: "tool_ended", id: pe.toolCallId, isError: pe.isError, content: pe.result };
|
|
112
|
+
default:
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
|
|
118
|
+
* with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
|
|
119
|
+
* entire failure class (violating SPEC MUST 1).
|
|
120
|
+
*/
|
|
121
|
+
export function toTerminal(message) {
|
|
122
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
123
|
+
const details = message.errorMessage ?? `model stopped: ${message.stopReason}`;
|
|
124
|
+
return { type: "failed", details, retryable: classifyRetryable(details, messageSignal(message)) };
|
|
125
|
+
}
|
|
126
|
+
return { type: "completed" };
|
|
127
|
+
}
|
|
128
|
+
export function errorToTerminal(error) {
|
|
129
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
130
|
+
return { type: "failed", details, retryable: classifyRetryable(details, errorSignal(error)) };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* After a successful turn, compact the session if its context has grown past pi's threshold — a long
|
|
134
|
+
* shared (group) or 1:1 conversation otherwise overflows the model's window. pi owns the mechanism
|
|
135
|
+
* (`harness.compact()` writes a summary entry into the session, so the next reopen is compacted); the
|
|
136
|
+
* bare harness does NOT auto-trigger it, so fastagent checks `shouldCompact` here and fires it. The
|
|
137
|
+
* context size is the provider's own count from the turn's assistant message (`usage`).
|
|
138
|
+
*/
|
|
139
|
+
async function maybeCompact(harness, message) {
|
|
140
|
+
const contextWindow = harness.getModel().contextWindow;
|
|
141
|
+
if (!contextWindow)
|
|
142
|
+
return;
|
|
143
|
+
if (shouldCompact(calculateContextTokens(message.usage), contextWindow, DEFAULT_COMPACTION_SETTINGS)) {
|
|
144
|
+
await harness.compact();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Map prompt images to pi ImageContent, resizing each to model-friendly dimensions/size with pi's
|
|
149
|
+
* Photon resizer (reused from pi-coding-agent, lazy-imported so the common no-image headless path never
|
|
150
|
+
* loads the TUI module graph). A null resize (unresizable / Photon unavailable) keeps the original
|
|
151
|
+
* bytes — the provider then applies its own limit.
|
|
152
|
+
*/
|
|
153
|
+
async function toPiPromptOptions(prompt) {
|
|
154
|
+
if (!prompt.images || prompt.images.length === 0)
|
|
155
|
+
return undefined;
|
|
156
|
+
const { resizeImage } = await import("@earendil-works/pi-coding-agent");
|
|
157
|
+
const images = await Promise.all(prompt.images.map(async (img) => {
|
|
158
|
+
const resized = await resizeImage(Buffer.from(img.data, "base64"), img.mimeType, {
|
|
159
|
+
maxWidth: 1568,
|
|
160
|
+
maxHeight: 1568,
|
|
161
|
+
maxBytes: 5 * 1024 * 1024,
|
|
162
|
+
}).catch(() => null);
|
|
163
|
+
return resized
|
|
164
|
+
? { type: "image", data: resized.data, mimeType: resized.mimeType }
|
|
165
|
+
: { type: "image", data: img.data, mimeType: img.mimeType };
|
|
166
|
+
}));
|
|
167
|
+
return { images };
|
|
168
|
+
}
|
|
169
|
+
// ── §3 EventQueue: push→pull plumbing for pi's two-port shape ────────────────
|
|
170
|
+
//
|
|
171
|
+
// Single-consumer async queue; single-threaded JS means no await interleaves between push and
|
|
172
|
+
// drain, so no locking. Engines that are natively async-iterable would not need it.
|
|
173
|
+
class EventQueue {
|
|
174
|
+
buffer = [];
|
|
175
|
+
wake;
|
|
176
|
+
push(item) {
|
|
177
|
+
this.buffer.push(item);
|
|
178
|
+
const wake = this.wake;
|
|
179
|
+
this.wake = undefined;
|
|
180
|
+
wake?.();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Yield pushed events in order until `done` settles AND the buffer is drained. The terminal is
|
|
184
|
+
* produced separately (toTerminal); rejections of `done` are swallowed here (the caller awaits
|
|
185
|
+
* `run` itself) to avoid unhandled rejections.
|
|
186
|
+
*/
|
|
187
|
+
async *drainUntil(done) {
|
|
188
|
+
let settled = false;
|
|
189
|
+
const onSettle = () => {
|
|
190
|
+
settled = true;
|
|
191
|
+
const wake = this.wake;
|
|
192
|
+
this.wake = undefined;
|
|
193
|
+
wake?.();
|
|
194
|
+
};
|
|
195
|
+
const finished = done.then(onSettle, onSettle);
|
|
196
|
+
while (true) {
|
|
197
|
+
while (this.buffer.length > 0) {
|
|
198
|
+
yield this.buffer.shift();
|
|
199
|
+
}
|
|
200
|
+
if (settled)
|
|
201
|
+
break;
|
|
202
|
+
await new Promise((resolve) => {
|
|
203
|
+
this.wake = resolve;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
await finished;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
|
|
210
|
+
export function createPiAgentFromHarness(options) {
|
|
211
|
+
const { harnessFactory, lease = inProcessLease() } = options;
|
|
212
|
+
async function* invoke(scope, prompt) {
|
|
213
|
+
const release = lease.tryAcquire(scope.session);
|
|
214
|
+
if (!release) {
|
|
215
|
+
yield {
|
|
216
|
+
type: "failed",
|
|
217
|
+
details: "session busy: a turn is already in flight for this session",
|
|
218
|
+
retryable: true,
|
|
219
|
+
code: SESSION_BUSY_CODE,
|
|
220
|
+
};
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
let harness;
|
|
225
|
+
try {
|
|
226
|
+
harness = await harnessFactory(scope.session);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
// Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
|
|
230
|
+
yield errorToTerminal(error);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const queue = new EventQueue();
|
|
234
|
+
const unsub = harness.subscribe((pe) => {
|
|
235
|
+
const event = toAgentEvent(pe);
|
|
236
|
+
if (event)
|
|
237
|
+
queue.push(event);
|
|
238
|
+
});
|
|
239
|
+
let completed; // the assistant message of a cleanly completed turn
|
|
240
|
+
try {
|
|
241
|
+
// Run the turn inside the session context so a tool's `execute` can read which session it is in
|
|
242
|
+
// (turnContext / ToolContext.session). prompt() starts the async work synchronously here, so the
|
|
243
|
+
// store propagates to the tool calls awaited within it.
|
|
244
|
+
const opts = await toPiPromptOptions(prompt);
|
|
245
|
+
const run = turnContext.run({ session: scope.session }, () => harness.prompt(prompt.text, opts));
|
|
246
|
+
yield* queue.drainUntil(run);
|
|
247
|
+
let terminal;
|
|
248
|
+
try {
|
|
249
|
+
const message = await run;
|
|
250
|
+
terminal = toTerminal(message);
|
|
251
|
+
if (terminal.type === "completed")
|
|
252
|
+
completed = message;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
terminal = errorToTerminal(error);
|
|
256
|
+
}
|
|
257
|
+
yield terminal;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
// After a successful turn, keep the session under the model's context window (a long shared group
|
|
261
|
+
// or 1:1 conversation would otherwise overflow). Runs HERE — before teardown (it uses the harness)
|
|
262
|
+
// and BEFORE the lease release below, and is awaited via the generator's return(), so the next
|
|
263
|
+
// turn for this session waits and never reopens mid-compaction. That await rides the consumer's
|
|
264
|
+
// iteration: a STREAMING consumer (e.g. telegram) already sent the reply on the terminal before
|
|
265
|
+
// returning, so compaction — rare, only over threshold — does not delay it; a `collect`-style
|
|
266
|
+
// consumer returns the reply FROM the loop, so it waits for the (occasional) compaction. Non-fatal:
|
|
267
|
+
// a failed compaction leaves the (still-valid) session for the next turn to retry.
|
|
268
|
+
if (completed) {
|
|
269
|
+
try {
|
|
270
|
+
await maybeCompact(harness, completed);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
log.warn(`[fastagent] auto-compaction failed during cleanup: ${String(error)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Cleanup MUST NOT throw after the terminal was yielded — that would make an already-closed
|
|
277
|
+
// event stream throw on iteration (violating SPEC MUST 2 / MUST 3). Contain it, but surface it
|
|
278
|
+
// (a cleanup failure is abnormal).
|
|
279
|
+
try {
|
|
280
|
+
unsub();
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
log.warn(`[fastagent] harness unsubscribe failed during cleanup: ${String(error)}`);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await harness.abort();
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
release(); // after cleanup, so the next invoke for this session can enter
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { invoke };
|
|
298
|
+
}
|