@fastagent-sh/fastagent 0.17.1 → 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/feishu/feishu-api.d.ts +4 -2
- package/dist/channels/feishu/feishu.js +39 -9
- package/dist/channels/feishu/invoke-turn.d.ts +8 -2
- package/dist/channels/feishu/invoke-turn.js +150 -31
- package/dist/channels/feishu/parse.js +6 -0
- 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/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 +1 -1
- 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/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +21 -3
- 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
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* it into the harness alongside the selected `model`; the two must come from the same collection so
|
|
5
5
|
* the model's provider auth is in scope.
|
|
6
6
|
*/
|
|
7
|
+
import { join } from "node:path";
|
|
7
8
|
import { defaultProviderAuthContext } from "@earendil-works/pi-ai";
|
|
8
9
|
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
9
10
|
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { fastagentCredentialStore } from "./auth.js";
|
|
12
|
+
import { providerOf } from "./config.js";
|
|
11
13
|
import { interactiveLoginKind } from "./login.js";
|
|
14
|
+
import { AGENT_MODELS_FILE, resolveStateRoot } from "../../paths.js";
|
|
12
15
|
/**
|
|
13
16
|
* A `Models` with every built-in pi provider, wired to fastagent's auth: stored credentials from the
|
|
14
17
|
* {@link CreatePiModelsOptions.authPath} file (via {@link fastagentCredentialStore}; the global
|
|
@@ -28,18 +31,62 @@ export function createPiModels(options = {}) {
|
|
|
28
31
|
/**
|
|
29
32
|
* The `ModelRuntime`-shaped sibling of {@link createPiModels} — the SAME hub semantics (built-in
|
|
30
33
|
* providers + fastagent's credential store at `authPath`) in the type pi's session services require
|
|
31
|
-
* (`createAgentSessionServices({ modelRuntime })`).
|
|
32
|
-
*
|
|
33
|
-
* surface equals serving's.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* (`createAgentSessionServices({ modelRuntime })`). Built-ins PLUS the agent's own
|
|
35
|
+
* {@link AGENT_MODELS_FILE} when `agentDir` is given (a dir-less caller gets built-ins only), and no
|
|
36
|
+
* availability network, so the model surface equals serving's.
|
|
37
|
+
*
|
|
38
|
+
* `ModelRuntime` also takes `Provider` INSTANCES via `registerNativeProvider` (pi 0.83); the
|
|
39
|
+
* declarative file is what this rung wires because it is data that travels with the definition.
|
|
36
40
|
*/
|
|
37
|
-
export function createPiModelRuntime(options = {}) {
|
|
38
|
-
|
|
41
|
+
export async function createPiModelRuntime(options = {}) {
|
|
42
|
+
const { agentDir } = options;
|
|
43
|
+
const runtime = await ModelRuntime.create({
|
|
39
44
|
credentials: fastagentCredentialStore(options.authPath, { warn: options.warn }),
|
|
40
|
-
modelsPath: null,
|
|
45
|
+
modelsPath: agentDir ? join(agentDir, AGENT_MODELS_FILE) : null,
|
|
46
|
+
// MUST be set whenever modelsPath is: pi defaults this to `<dirname(modelsPath)>/models-store.json`,
|
|
47
|
+
// which would write a generated cache INTO the author's agent dir — and `deploy` bakes the whole
|
|
48
|
+
// tree, so it would travel into the image as stale state. Machinery belongs under the state root.
|
|
49
|
+
...(agentDir
|
|
50
|
+
? { modelsStorePath: join(options.stateRoot ?? resolveStateRoot(agentDir), "models-store.json") }
|
|
51
|
+
: {}),
|
|
41
52
|
allowModelNetwork: false,
|
|
42
53
|
});
|
|
54
|
+
// A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the
|
|
55
|
+
// reason in getError(). Left unread, a typo'd endpoint would silently degrade to "provider not in
|
|
56
|
+
// registry" at model-resolution time, i.e. the silent fallback this codebase forbids. The upstream
|
|
57
|
+
// message already names both the reason and the file, so it is surfaced verbatim.
|
|
58
|
+
const error = runtime.getError();
|
|
59
|
+
if (error)
|
|
60
|
+
throw new Error(error);
|
|
61
|
+
for (const provider of options.providers ?? [])
|
|
62
|
+
runtime.registerNativeProvider(provider);
|
|
63
|
+
return runtime;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* How a model's credential will REACH a deployed agent — the question `deploy` asks, which
|
|
67
|
+
* {@link probeAuthSource} cannot answer: it flattens every models.json endpoint to the display label
|
|
68
|
+
* "configured API key", so a self-hosted endpoint looks credential-less to the deploy gate even when
|
|
69
|
+
* its key is sitting in an env var.
|
|
70
|
+
*
|
|
71
|
+
* - `envVar`: an environment variable backs it, BY NAME — the shape `deploy` already understands, so
|
|
72
|
+
* the value carries as a host secret with no extra declaration from the author.
|
|
73
|
+
* - `inDefinition`: the definition itself carries it (a literal `apiKey`, or a `!command` run on the
|
|
74
|
+
* host). Nothing for `deploy` to carry — and nothing to gate on either, which is the point: the
|
|
75
|
+
* `fastagent login` remedy is meaningless for a provider login cannot serve.
|
|
76
|
+
*
|
|
77
|
+
* Neither set = a stored credential or nothing at all; the existing auth.json / gate paths decide.
|
|
78
|
+
*/
|
|
79
|
+
export function modelCredentialCarry(runtime, spec) {
|
|
80
|
+
const status = runtime.getProviderAuthStatus(providerOf(spec));
|
|
81
|
+
if (!status.configured)
|
|
82
|
+
return { inDefinition: false };
|
|
83
|
+
// An env-var name is only useful downstream if it IS one: `"${A}_${B}"` interpolation resolves from
|
|
84
|
+
// the environment but has no single name to carry, so it falls through to the definition-carried
|
|
85
|
+
// branch, where the author's `deploy.secrets` is the mechanism.
|
|
86
|
+
if (status.source === "environment" && status.label && /^[A-Z][A-Z0-9_]*$/.test(status.label)) {
|
|
87
|
+
return { envVar: status.label, inDefinition: false };
|
|
88
|
+
}
|
|
89
|
+
return { inDefinition: status.source !== "stored" };
|
|
43
90
|
}
|
|
44
91
|
/**
|
|
45
92
|
* Probe every provider's auth once (auth is provider-scoped, so any of its models works as the probe)
|
|
@@ -100,12 +100,14 @@ sessionManager) {
|
|
|
100
100
|
// on the session in createRuntime — pi's session starts all-active), and the activation bridge
|
|
101
101
|
// above rides the same turn context, so the SAME search_tools works against pi's AgentSession
|
|
102
102
|
// instead of fastagent's harness.
|
|
103
|
-
const { config, modelSpec, agentDir, authPath, tools, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentAssembly(cwd, options);
|
|
103
|
+
const { config, modelSpec, agentDir, authPath, stateRoot, tools, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentAssembly(cwd, options);
|
|
104
104
|
reportToolCollisions(toolCollisions);
|
|
105
105
|
reportModuleLoadFailures(toolFailures);
|
|
106
106
|
// ONE hub owns model resolution AND per-request auth — the ModelRuntime-shaped sibling of
|
|
107
107
|
// serving's createPiModels; see models.ts.
|
|
108
|
-
|
|
108
|
+
// agentDir carries the agent's own models.json (custom endpoints); stateRoot keeps pi's generated
|
|
109
|
+
// catalog cache out of the definition dir. See createPiModelRuntime.
|
|
110
|
+
const modelRuntime = await createPiModelRuntime({ authPath, agentDir, stateRoot });
|
|
109
111
|
// MIGRATION HINT (deliberate breaking change): chat historically used pi's own `~/.pi` auth;
|
|
110
112
|
// it now reads the agent's credential file like every other command. Probe the RESOLVED
|
|
111
113
|
// model's provider through the normal resolution path (stored credential OR env var — an
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Models } from "@earendil-works/pi-ai";
|
|
3
3
|
import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type { SessionObserver } from "./invoke.ts";
|
|
5
|
+
import type { Lease } from "./turn-kit.ts";
|
|
5
6
|
import { type AnyModel, type PiHarnessFactory } from "./harness.ts";
|
|
6
7
|
import { type PiSessionReader } from "./sessions.ts";
|
|
7
8
|
/** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import type { Session, SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
|
2
|
+
/**
|
|
3
|
+
* Where a NEW session starts from, when it names a parent (participant-model.md §5: "a thread starts
|
|
4
|
+
* from what the room knew"). Read only on the create path — an EXISTING session ignores it entirely,
|
|
5
|
+
* which is what makes inheritance one-time by construction: no marker to persist, no decision to
|
|
6
|
+
* retry per turn; the session existing IS the record that the decision was taken.
|
|
7
|
+
*/
|
|
8
|
+
export interface SessionInheritance {
|
|
9
|
+
/** The session to fork from. Missing or unreadable → the new session starts empty, with a warn —
|
|
10
|
+
* context is not the ask, and losing it must not cost the turn. */
|
|
11
|
+
parentSession: string;
|
|
12
|
+
/** Opaque markers that MAY locate the branch point on the parent's active path (searched in
|
|
13
|
+
* message content, first hit wins, most recent occurrence). No match → the parent's present. */
|
|
14
|
+
branchHints?: string[];
|
|
15
|
+
}
|
|
2
16
|
/** What fastagent needs from a session backend: open-or-create by opaque id. */
|
|
3
17
|
export interface PiSessionStore {
|
|
4
|
-
openOrCreate(sessionId: string): Promise<Session>;
|
|
18
|
+
openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<Session>;
|
|
5
19
|
}
|
|
6
20
|
/**
|
|
7
21
|
* OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
|
|
@@ -41,4 +55,6 @@ export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader
|
|
|
41
55
|
export declare function jsonlSessionStore(options: {
|
|
42
56
|
dir: string;
|
|
43
57
|
cwd?: string;
|
|
58
|
+
/** Inheritance guard override (tests): parent journals above this are not forked. Default 32 MiB. */
|
|
59
|
+
forkMaxBytes?: number;
|
|
44
60
|
}): PiSessionStore & PiSessionReader;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session persistence — the K-axis port and its first two backends.
|
|
3
3
|
*
|
|
4
|
-
* PiSessionStore is the consumer-owned port: open-or-create by opaque session id,
|
|
5
|
-
* pi's full SessionRepo surface (list/open/create/delete
|
|
6
|
-
*
|
|
4
|
+
* PiSessionStore is the consumer-owned port: open-or-create by opaque session id, plus one creation
|
|
5
|
+
* option — inheritance. pi's full SessionRepo surface (list/open/create/delete) stays behind the
|
|
6
|
+
* adapters; `fork` is surfaced only through {@link SessionInheritance}, never raw. The `Pi` prefix
|
|
7
|
+
* is honest — `openOrCreate` returns pi's `Session`, so this is pi-coupled, not a neutral
|
|
7
8
|
* persistence contract.
|
|
8
9
|
*
|
|
9
10
|
* Continuity = same backing store + same session id: in-memory continuity dies with the instance;
|
|
10
11
|
* jsonl survives process restarts (disk is the truth).
|
|
11
12
|
*/
|
|
13
|
+
import { mkdir, rename, stat } from "node:fs/promises";
|
|
14
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
12
15
|
import { InMemorySessionRepo } from "@earendil-works/pi-agent-core";
|
|
13
16
|
import { JsonlSessionRepo, NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
14
17
|
import { log } from "../../log.js";
|
|
@@ -131,14 +134,227 @@ export async function activePathEntries(session) {
|
|
|
131
134
|
}
|
|
132
135
|
return path.reverse(); // walked leaf→root; every consumer reads it root→leaf
|
|
133
136
|
}
|
|
137
|
+
// ── Inheritance: fork-on-first-open ───────────────────────────────────────────────────────
|
|
138
|
+
//
|
|
139
|
+
// The mechanism behind participant-model.md §5's rule, engine-side and channel-neutral: a channel
|
|
140
|
+
// only names WHERE a place branched from (scope.parentSession) and possibly at WHICH message
|
|
141
|
+
// (branchHints); how much is inherited and where the boundaries sit is decided here.
|
|
142
|
+
//
|
|
143
|
+
// Shape: fork the parent's ACTIVE PATH up to the branch point (everything — text, images, tool
|
|
144
|
+
// results — because they are session entries, not prompt text), then bound what the MODEL sees with
|
|
145
|
+
// one mechanical compaction mark (a plain string; zero model calls). Disk keeps the full copy —
|
|
146
|
+
// storage and context are different budgets (the fork is inspectable; the mark governs the window),
|
|
147
|
+
// and pi's context assembly honors the mark the same as a real compaction.
|
|
148
|
+
/** Inheritance window: at most this many exchanges of the parent reach the child's model context. */
|
|
149
|
+
const INHERIT_MAX_EXCHANGES = 50;
|
|
150
|
+
/** …and at most roughly this many tokens (~1/4 of a 200K context: generous, not everything). Both
|
|
151
|
+
* limits govern how far the window EXTENDS into older history — the newest exchange is a FLOOR,
|
|
152
|
+
* kept whole even when it alone exceeds the budget: the mark's boundary is entry-granular, and an
|
|
153
|
+
* inheritance that drops the exchange the thread branched off would be no inheritance at all. */
|
|
154
|
+
const INHERIT_MAX_TOKENS = 50_000;
|
|
155
|
+
/** Branch hints are IDS, not payloads: each one costs a scan over the parent's serialized path, and
|
|
156
|
+
* the wire accepts arbitrary arrays — so the engine caps them where the cost lives. */
|
|
157
|
+
const MAX_BRANCH_HINTS = 16;
|
|
158
|
+
const MAX_BRANCH_HINT_CHARS = 128;
|
|
159
|
+
/** A vision image is priced FLAT — what a provider bills for a resized image, roughly — because its
|
|
160
|
+
* base64 length (~1M chars for a photo) measures storage, not context: pricing it by chars would
|
|
161
|
+
* let one photo evict the whole text window. */
|
|
162
|
+
const INHERIT_IMAGE_TOKENS = 1_600;
|
|
163
|
+
/** The fork reads the whole parent journal into memory; beyond this it is skipped (empty session +
|
|
164
|
+
* warn) rather than stalling the thread's first turn. */
|
|
165
|
+
const FORK_MAX_BYTES = 32 * 1024 * 1024;
|
|
166
|
+
function isUserMessage(entry) {
|
|
167
|
+
return entry?.type === "message" && entry.message.role === "user";
|
|
168
|
+
}
|
|
169
|
+
/** Rough token estimate for windowing — text at chars/4, images flat. Precision is not the point:
|
|
170
|
+
* the window is a budget, and being 20% off moves a boundary by an exchange, not correctness. */
|
|
171
|
+
function estimateMessageTokens(message) {
|
|
172
|
+
// AgentMessage is a role-keyed union and not every member carries `content` — read it loosely.
|
|
173
|
+
const content = message.content;
|
|
174
|
+
if (typeof content === "string")
|
|
175
|
+
return Math.ceil(content.length / 4);
|
|
176
|
+
if (!Array.isArray(content))
|
|
177
|
+
return 0;
|
|
178
|
+
let tokens = 0;
|
|
179
|
+
// Blocks are role-dependent unions; the estimate only needs `type`/`text`, so read them loosely.
|
|
180
|
+
for (const block of content) {
|
|
181
|
+
if (block.type === "image")
|
|
182
|
+
tokens += INHERIT_IMAGE_TOKENS;
|
|
183
|
+
else if (typeof block.text === "string")
|
|
184
|
+
tokens += Math.ceil(block.text.length / 4);
|
|
185
|
+
else
|
|
186
|
+
tokens += Math.ceil(JSON.stringify(block).length / 4);
|
|
187
|
+
}
|
|
188
|
+
return tokens;
|
|
189
|
+
}
|
|
190
|
+
function estimateEntryTokens(entry) {
|
|
191
|
+
if (entry.type !== "message")
|
|
192
|
+
return 0;
|
|
193
|
+
return estimateMessageTokens(entry.message);
|
|
194
|
+
}
|
|
195
|
+
/** A compaction entry's summary and retained tail DO reach the model — they are the floor under
|
|
196
|
+
* every window that starts above the compaction, so the budget must count them. */
|
|
197
|
+
function estimateCompactionTokens(entry) {
|
|
198
|
+
if (entry?.type !== "compaction")
|
|
199
|
+
return 0;
|
|
200
|
+
let tokens = Math.ceil(entry.summary.length / 4);
|
|
201
|
+
for (const message of entry.retainedTail ?? [])
|
|
202
|
+
tokens += estimateMessageTokens(message);
|
|
203
|
+
return tokens;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Find the fork target on the parent's active path: the LAST message whose content carries a hint
|
|
207
|
+
* (the most recent turn that talked about that message), extended forward to the end of its exchange
|
|
208
|
+
* — forking mid-exchange would inherit a question without its answer. Hints are tried in caller
|
|
209
|
+
* order; the first that matches anywhere wins. No match → undefined (the caller forks the present).
|
|
210
|
+
*/
|
|
211
|
+
function locateBranchPoint(path, hints) {
|
|
212
|
+
const usable = hints
|
|
213
|
+
.filter((hint) => hint.length > 0 && hint.length <= MAX_BRANCH_HINT_CHARS)
|
|
214
|
+
.slice(0, MAX_BRANCH_HINTS);
|
|
215
|
+
if (usable.length < hints.length) {
|
|
216
|
+
log.warn(`[fastagent] ignored ${hints.length - usable.length} branch hint(s) (over ${MAX_BRANCH_HINTS} hints or ${MAX_BRANCH_HINT_CHARS} chars each) — hints are message ids, not payloads`);
|
|
217
|
+
}
|
|
218
|
+
if (usable.length === 0)
|
|
219
|
+
return undefined;
|
|
220
|
+
// Serialize each message ONCE — the scan is hints × entries, and stringify must not sit in the
|
|
221
|
+
// inner loop. The whole message, not just content: shape-agnostic, and a hint is a platform id —
|
|
222
|
+
// a false positive would need the id to appear outside content, which is where ids live anyway.
|
|
223
|
+
const serialized = path.map((entry) => (entry.type === "message" ? JSON.stringify(entry.message) : ""));
|
|
224
|
+
for (const hint of usable) {
|
|
225
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
226
|
+
if (!serialized[i]?.includes(hint))
|
|
227
|
+
continue;
|
|
228
|
+
let j = i + 1;
|
|
229
|
+
while (j < path.length && !isUserMessage(path[j]))
|
|
230
|
+
j++;
|
|
231
|
+
return path[j - 1]?.id;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Bound what the child's MODEL CONTEXT starts with: keep the newest exchange unconditionally, extend
|
|
238
|
+
* older while both window limits hold, and mark the boundary with a mechanical compaction entry.
|
|
239
|
+
* Entries above the parent's own last compaction are already outside model context and need no mark;
|
|
240
|
+
* a child whose visible history fits the window gets no mark at all.
|
|
241
|
+
*/
|
|
242
|
+
async function markInheritanceWindow(child) {
|
|
243
|
+
const path = await activePathEntries(child);
|
|
244
|
+
let scanFrom = 0;
|
|
245
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
246
|
+
if (path[i]?.type === "compaction") {
|
|
247
|
+
scanFrom = i + 1;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const scanned = path.slice(scanFrom);
|
|
252
|
+
// The compaction's own summary + retained tail reach the model regardless of where the window
|
|
253
|
+
// lands, so they charge the budget as a base cost — not estimating them would over-admit.
|
|
254
|
+
const baseTokens = estimateCompactionTokens(path[scanFrom - 1]);
|
|
255
|
+
const starts = [];
|
|
256
|
+
scanned.forEach((entry, i) => {
|
|
257
|
+
if (isUserMessage(entry))
|
|
258
|
+
starts.push(i);
|
|
259
|
+
});
|
|
260
|
+
if (starts.length <= 1)
|
|
261
|
+
return; // zero or one visible exchange — nothing to cut
|
|
262
|
+
const suffixTokens = new Array(scanned.length + 1).fill(0);
|
|
263
|
+
for (let i = scanned.length - 1; i >= 0; i--) {
|
|
264
|
+
const entry = scanned[i];
|
|
265
|
+
suffixTokens[i] = (suffixTokens[i + 1] ?? 0) + (entry ? estimateEntryTokens(entry) : 0);
|
|
266
|
+
}
|
|
267
|
+
let chosen = starts.length - 1;
|
|
268
|
+
for (let k = starts.length - 2; k >= 0; k--) {
|
|
269
|
+
const exchanges = starts.length - k;
|
|
270
|
+
const startIdx = starts[k];
|
|
271
|
+
if (startIdx === undefined)
|
|
272
|
+
break;
|
|
273
|
+
if (exchanges > INHERIT_MAX_EXCHANGES || baseTokens + (suffixTokens[startIdx] ?? 0) > INHERIT_MAX_TOKENS)
|
|
274
|
+
break;
|
|
275
|
+
chosen = k;
|
|
276
|
+
}
|
|
277
|
+
if (chosen === 0)
|
|
278
|
+
return; // the whole visible history fits the window
|
|
279
|
+
const boundaryIdx = starts[chosen];
|
|
280
|
+
if (boundaryIdx === undefined)
|
|
281
|
+
return;
|
|
282
|
+
const boundary = scanned[boundaryIdx];
|
|
283
|
+
if (boundary === undefined)
|
|
284
|
+
return;
|
|
285
|
+
await child.appendCompaction(`Inherited from the parent conversation; ${chosen} earlier exchange(s) are not shown.`, boundary.id, (suffixTokens[0] ?? 0) - (suffixTokens[boundaryIdx] ?? 0));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The create-with-parent path (the open path never reaches here) — SEMANTICS only: where to branch
|
|
289
|
+
* and what the newborn is born with. Writing and publishing it is
|
|
290
|
+
* {@link SessionBackend.createAtomically}'s contract.
|
|
291
|
+
*
|
|
292
|
+
* Every failure lands on "start empty + warn": a thread must not lose its first turn to an
|
|
293
|
+
* inheritance edge.
|
|
294
|
+
*/
|
|
295
|
+
async function createInheriting(backend, id, inherit, parentId, maxBytes) {
|
|
296
|
+
const startEmpty = () => backend.createAtomically(id, undefined, async () => { });
|
|
297
|
+
const parentMeta = await backend.find(parentId);
|
|
298
|
+
if (!parentMeta) {
|
|
299
|
+
log.warn(`[fastagent] session "${id}" names parent "${parentId}", which does not exist — starting empty`);
|
|
300
|
+
return startEmpty();
|
|
301
|
+
}
|
|
302
|
+
if (backend.bytes) {
|
|
303
|
+
const bytes = await backend.bytes(parentMeta).catch(() => 0);
|
|
304
|
+
if (bytes > maxBytes) {
|
|
305
|
+
log.warn(`[fastagent] parent session "${parentId}" is ${bytes} bytes (limit ${maxBytes}) — starting empty rather than stalling the first turn`);
|
|
306
|
+
return startEmpty();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const parent = await backend.open(parentMeta);
|
|
311
|
+
const path = await activePathEntries(parent);
|
|
312
|
+
const leaf = path[path.length - 1];
|
|
313
|
+
if (leaf !== undefined) {
|
|
314
|
+
const hints = inherit.branchHints ?? [];
|
|
315
|
+
const at = locateBranchPoint(path, hints);
|
|
316
|
+
if (at === undefined && hints.length > 0) {
|
|
317
|
+
log.warn(`[fastagent] no branch hint matched in parent "${parentId}" — inheriting from its present instead of the branch point`);
|
|
318
|
+
}
|
|
319
|
+
// The one repair that must run before the child is visible: a mid-turn parent forks with a
|
|
320
|
+
// dangling tool call at its leaf, which would hand the provider an invalid transcript.
|
|
321
|
+
return await backend.createAtomically(id, { meta: parentMeta, atEntryId: at ?? leaf.id }, async (draft) => {
|
|
322
|
+
await reconcileInterruptedToolCalls(draft);
|
|
323
|
+
await markInheritanceWindow(draft);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
// Unattributed on purpose: this spans reading the parent AND writing the child, so the fault may
|
|
329
|
+
// belong to either — a torn parent journal, or a store that cannot publish.
|
|
330
|
+
log.warn(`[fastagent] could not inherit from "${parentId}" into "${id}" (${String(error)}) — starting empty`);
|
|
331
|
+
}
|
|
332
|
+
return startEmpty();
|
|
333
|
+
}
|
|
134
334
|
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
135
335
|
export function inMemorySessionStore() {
|
|
136
336
|
const repo = new InMemorySessionRepo();
|
|
337
|
+
const backend = {
|
|
338
|
+
find: async (id) => (await repo.list()).find((m) => m.id === id),
|
|
339
|
+
open: (m) => repo.open(m),
|
|
340
|
+
// No draft realm needed: nothing partial outlives the process, and `fill` still runs before the
|
|
341
|
+
// session reaches any caller.
|
|
342
|
+
createAtomically: async (id, from, fill) => {
|
|
343
|
+
const draft = from
|
|
344
|
+
? await repo.fork(from.meta, { id, entryId: from.atEntryId, position: "at" })
|
|
345
|
+
: await repo.create({ id });
|
|
346
|
+
await fill(draft);
|
|
347
|
+
return draft;
|
|
348
|
+
},
|
|
349
|
+
};
|
|
137
350
|
return {
|
|
138
|
-
async openOrCreate(sessionId) {
|
|
351
|
+
async openOrCreate(sessionId, inherit) {
|
|
139
352
|
const existing = (await repo.list()).find((m) => m.id === sessionId);
|
|
140
|
-
if (!existing)
|
|
353
|
+
if (!existing) {
|
|
354
|
+
if (inherit)
|
|
355
|
+
return createInheriting(backend, sessionId, inherit, inherit.parentSession, FORK_MAX_BYTES);
|
|
141
356
|
return repo.create({ id: sessionId });
|
|
357
|
+
}
|
|
142
358
|
const session = await repo.open(existing);
|
|
143
359
|
await reconcileInterruptedToolCalls(session);
|
|
144
360
|
return session;
|
|
@@ -155,16 +371,63 @@ export function inMemorySessionStore() {
|
|
|
155
371
|
*/
|
|
156
372
|
export function jsonlSessionStore(options) {
|
|
157
373
|
const cwd = options.cwd ?? process.cwd();
|
|
158
|
-
const
|
|
374
|
+
const forkMaxBytes = options.forkMaxBytes ?? FORK_MAX_BYTES;
|
|
375
|
+
// Resolve ONCE, by pi's rule (`NodeExecutionEnv.absolutePath` is `resolve(cwd, path)`): the direct
|
|
376
|
+
// fs calls below resolve against process.cwd() instead, and a relative `dir` would straddle both.
|
|
377
|
+
const root = resolve(cwd, options.dir);
|
|
378
|
+
const repo = new JsonlSessionRepo({ fs: new NodeExecutionEnv({ cwd }), sessionsRoot: root });
|
|
379
|
+
// Where {@link SessionBackend.createAtomically} stages: a sibling root INSIDE the store root but
|
|
380
|
+
// OUTSIDE every lookup — `list({ cwd })` scans only `<root>/<encodedCwd>`, and a cwd-less `list()`
|
|
381
|
+
// scans `<root>/*/*.jsonl`, one level, which `.drafts/<encodedCwd>/*.jsonl` sits below.
|
|
382
|
+
//
|
|
383
|
+
// A crash, or a handled failure once the draft file exists (a throw from `fill`, or from the
|
|
384
|
+
// rename), leaves it behind. Drafts are never resumed, so staleness cannot poison anything — but
|
|
385
|
+
// nothing unlinks them either.
|
|
386
|
+
const draftRepo = new JsonlSessionRepo({
|
|
387
|
+
fs: new NodeExecutionEnv({ cwd }),
|
|
388
|
+
sessionsRoot: join(root, ".drafts"),
|
|
389
|
+
});
|
|
390
|
+
const backend = {
|
|
391
|
+
find: async (id) => (await repo.list({ cwd })).find((m) => m.id === id),
|
|
392
|
+
open: (m) => repo.open(m),
|
|
393
|
+
createAtomically: async (id, from, fill) => {
|
|
394
|
+
// `fork` opens the source by its metadata's absolute path, so the draft repo reads the real
|
|
395
|
+
// repo's parent file directly while writing into its own root.
|
|
396
|
+
const draft = from
|
|
397
|
+
? await draftRepo.fork(from.meta, { cwd, id, entryId: from.atEntryId, position: "at" })
|
|
398
|
+
: await draftRepo.create({ id, cwd });
|
|
399
|
+
await fill(draft);
|
|
400
|
+
// The interface erases the metadata generic; a jsonl draft's metadata always carries `path`.
|
|
401
|
+
const draftPath = (await draft.getMetadata()).path;
|
|
402
|
+
// `<root>/.drafts/<encodedCwd>/<file>` → `<root>/<encodedCwd>/<file>`: the draft's own
|
|
403
|
+
// parent directory NAME is pi's cwd encoding, already computed — read it back rather than
|
|
404
|
+
// re-deriving it, and rather than borrowing the parent session's path (a parentless draft has
|
|
405
|
+
// none). Same filesystem, so the rename is atomic; the real directory may not exist yet when
|
|
406
|
+
// this store has never created a session for this cwd.
|
|
407
|
+
const target = join(root, basename(dirname(draftPath)), basename(draftPath));
|
|
408
|
+
await mkdir(dirname(target), { recursive: true });
|
|
409
|
+
await rename(draftPath, target);
|
|
410
|
+
const published = (await repo.list({ cwd })).find((m) => m.path === target);
|
|
411
|
+
if (!published)
|
|
412
|
+
throw new Error(`published session vanished: ${target}`);
|
|
413
|
+
return repo.open(published);
|
|
414
|
+
},
|
|
415
|
+
bytes: async (m) => (await stat(m.path)).size,
|
|
416
|
+
};
|
|
159
417
|
return {
|
|
160
|
-
async openOrCreate(sessionId) {
|
|
418
|
+
async openOrCreate(sessionId, inherit) {
|
|
161
419
|
// Caller-provided ids land in jsonl FILENAMES — encode anything unsafe before it reaches disk.
|
|
162
420
|
const id = encodeSessionId(sessionId);
|
|
163
421
|
// Scope the lookup to this store's cwd: two stores sharing a sessionsRoot must not open each
|
|
164
422
|
// other's sessions (pi groups sessions by project dir).
|
|
165
423
|
const existing = (await repo.list({ cwd })).find((m) => m.id === id);
|
|
166
|
-
if (!existing)
|
|
424
|
+
if (!existing) {
|
|
425
|
+
if (inherit)
|
|
426
|
+
return createInheriting(backend, id, inherit, encodeSessionId(inherit.parentSession), forkMaxBytes);
|
|
427
|
+
// Straight into the store, unstaged: an empty session is complete the moment its file
|
|
428
|
+
// exists, so there is no half for a reader to catch.
|
|
167
429
|
return repo.create({ id, cwd });
|
|
430
|
+
}
|
|
168
431
|
const session = await repo.open(existing);
|
|
169
432
|
await reconcileInterruptedToolCalls(session);
|
|
170
433
|
return session;
|
|
@@ -176,7 +439,26 @@ export function jsonlSessionStore(options) {
|
|
|
176
439
|
},
|
|
177
440
|
};
|
|
178
441
|
}
|
|
179
|
-
/**
|
|
442
|
+
/**
|
|
443
|
+
* Filename-safe encoding, INJECTIVE: `[A-Za-z0-9._-]` verbatim, everything else `%XX` (one byte) or
|
|
444
|
+
* `%uXXXX` (above it). Two different ids must never encode alike: the encoded id is what
|
|
445
|
+
* `openOrCreate` matches on, so a collision is two conversations resolving to ONE session, and —
|
|
446
|
+
* since `parentSession` comes through the same encoder — one inheriting from the wrong room. Not the
|
|
447
|
+
* same thing as a filename clash: a file is `<timestamp>_<id>.jsonl`, and identity is the `id` its
|
|
448
|
+
* metadata carries, not the name on disk.
|
|
449
|
+
*
|
|
450
|
+
* Injectivity is what the previous form lacked: it padded to a MINIMUM of two hex digits, so an
|
|
451
|
+
* escape run could be re-split — `"\u0100"` and `"\u0010" + "0"` both produced `"%100"`. Every
|
|
452
|
+
* escape now has a self-describing width, so no two inputs can produce one output. ASCII ids — every
|
|
453
|
+
* id the built-in channels mint — encode exactly as before, so existing session FILES keep their
|
|
454
|
+
* names; only non-ASCII ids (a custom `route()` could mint one) change, and those are the ones that
|
|
455
|
+
* were unsafe anyway.
|
|
456
|
+
*/
|
|
180
457
|
function encodeSessionId(id) {
|
|
181
|
-
return id.replace(/[^A-Za-z0-9._-]/g, (c) =>
|
|
458
|
+
return id.replace(/[^A-Za-z0-9._-]/g, (c) => {
|
|
459
|
+
const code = c.charCodeAt(0);
|
|
460
|
+
return code < 0x100
|
|
461
|
+
? `%${code.toString(16).toUpperCase().padStart(2, "0")}`
|
|
462
|
+
: `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
463
|
+
});
|
|
182
464
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn mechanism's pi-CLASS-neutral half, shared by every pi L0. Not "engine-neutral" in this
|
|
3
|
+
* repo's sense — that term is reserved for code with no engine import at all (src/agent.ts), and
|
|
4
|
+
* everything here speaks pi's message and image types. What it is neutral about is which pi class
|
|
5
|
+
* runs the turn.
|
|
6
|
+
*
|
|
7
|
+
* Lease — single-writer concurrency floor (injectable port + in-process default)
|
|
8
|
+
* Terminals — a settled pi message or a thrown error → the SPEC terminal, `retryable` included
|
|
9
|
+
* EventQueue — push→pull plumbing for engines that emit events beside their result
|
|
10
|
+
* Prompt prep — SPEC images → pi's ImageContent
|
|
11
|
+
*
|
|
12
|
+
* What is NOT neutral — the harness's event vocabulary and its observation plane — stays in
|
|
13
|
+
* invoke.ts, and the AgentSession's in invoke-session.ts.
|
|
14
|
+
*/
|
|
15
|
+
import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai";
|
|
16
|
+
import { type AgentEvent, type Prompt } from "../../agent.ts";
|
|
17
|
+
export type Release = () => void;
|
|
18
|
+
export interface Lease {
|
|
19
|
+
/** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
|
|
20
|
+
tryAcquire(session: string): Release | null;
|
|
21
|
+
}
|
|
22
|
+
export declare function inProcessLease(): Lease;
|
|
23
|
+
/** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
|
|
24
|
+
export declare function classifyRetryable(details: string, signal: {
|
|
25
|
+
status?: number;
|
|
26
|
+
code?: unknown;
|
|
27
|
+
}): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
|
|
30
|
+
* with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
|
|
31
|
+
* entire failure class (violating SPEC MUST 1).
|
|
32
|
+
*/
|
|
33
|
+
export declare function toTerminal(message: AssistantMessage): AgentEvent;
|
|
34
|
+
export declare function errorToTerminal(error: unknown): Extract<AgentEvent, {
|
|
35
|
+
type: "failed";
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Map prompt images to pi ImageContent, resizing each to model-friendly dimensions/size with pi's
|
|
39
|
+
* Photon resizer (reused from pi-coding-agent, lazy-imported so the common no-image headless path never
|
|
40
|
+
* loads the TUI module graph). A null resize (unresizable / Photon unavailable) keeps the original
|
|
41
|
+
* bytes — the provider then applies its own limit.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toPiPromptOptions(prompt: Prompt): Promise<{
|
|
44
|
+
images?: ImageContent[];
|
|
45
|
+
} | undefined>;
|
|
46
|
+
export declare class EventQueue<T> {
|
|
47
|
+
private buffer;
|
|
48
|
+
private wake?;
|
|
49
|
+
push(item: T): void;
|
|
50
|
+
/**
|
|
51
|
+
* Yield pushed events in order until `done` settles AND the buffer is drained. The terminal is
|
|
52
|
+
* produced separately (toTerminal); rejections of `done` are swallowed here (the caller awaits
|
|
53
|
+
* `run` itself) to avoid unhandled rejections.
|
|
54
|
+
*/
|
|
55
|
+
drainUntil(done: Promise<unknown>): AsyncGenerator<T>;
|
|
56
|
+
}
|