@fastagent-sh/fastagent 0.16.1 → 0.17.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/channels/agentcore-state.js +7 -0
- package/dist/channels/agentcore.js +24 -5
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +5 -1
- package/dist/cli/commands/attach.d.ts +29 -1
- package/dist/cli/commands/attach.js +76 -4
- package/dist/cli/commands/deploy.js +11 -5
- package/dist/cli/commands/dev.js +2 -2
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/logs.d.ts +6 -0
- package/dist/cli/commands/logs.js +27 -0
- package/dist/cli/commands/start.js +2 -2
- package/dist/cli/program.js +26 -0
- package/dist/deploy/agentcore/logs.d.ts +35 -0
- package/dist/deploy/agentcore/logs.js +112 -0
- package/dist/deploy/agentcore/plan.d.ts +26 -2
- package/dist/deploy/agentcore/plan.js +45 -6
- package/dist/deploy/container.js +6 -3
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +6 -1
- package/dist/engines/pi/create.js +16 -19
- package/dist/engines/pi/definition.d.ts +15 -0
- package/dist/engines/pi/definition.js +22 -1
- package/dist/engines/pi/harness.d.ts +12 -28
- package/dist/engines/pi/harness.js +21 -71
- package/dist/engines/pi/open.d.ts +1 -1
- package/dist/engines/pi/open.js +20 -0
- package/dist/engines/pi/report.d.ts +16 -0
- package/dist/engines/pi/report.js +30 -0
- package/dist/engines/pi/session-builder.js +2 -2
- package/dist/engines/pi/session-control.d.ts +23 -4
- package/dist/engines/pi/session-control.js +159 -27
- package/dist/engines/pi/session-settings.d.ts +51 -0
- package/dist/engines/pi/session-settings.js +73 -0
- package/dist/engines/pi/sessions.d.ts +21 -7
- package/dist/engines/pi/sessions.js +43 -0
- package/dist/session-remote.js +17 -0
- package/dist/session.d.ts +54 -9
- package/package.json +1 -1
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import { log } from "../../log.js";
|
|
2
|
+
/** Stable identity of a definition's non-fatal findings — the dedup key below. */
|
|
3
|
+
function findingsSignature(def) {
|
|
4
|
+
const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
|
|
5
|
+
const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
|
|
6
|
+
return [...collisions, ...diagnostics].sort().join("\n");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The last reported finding set PER DEFINITION DIR. One memo for every reader of a definition (the
|
|
10
|
+
* per-turn live read, the control plane's command list), because the thing being deduped is the
|
|
11
|
+
* FINDING, not the reader: a newly broken skill deserves one warning, not one per code path that
|
|
12
|
+
* noticed it, and a static one deserves none at all.
|
|
13
|
+
*/
|
|
14
|
+
const lastFindings = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* THE door for definition findings: warns only when this dir's set CHANGED since the last report.
|
|
17
|
+
* Every reader calls it — boot, the per-turn live read, the control plane's command list — so a
|
|
18
|
+
* finding is announced when it appears and never repeated. There is deliberately no "record without
|
|
19
|
+
* printing" variant: a memo entry that trusts some other caller to have printed would silently
|
|
20
|
+
* swallow findings for a caller that does not.
|
|
21
|
+
*
|
|
22
|
+
* `dir` must be the RESOLVED definition root (`LoadedDefinition.dir`), or two spellings of one path
|
|
23
|
+
* become two memos and warn twice.
|
|
24
|
+
*/
|
|
25
|
+
export function reportFindingsIfChanged(dir, def) {
|
|
26
|
+
const sig = findingsSignature(def);
|
|
27
|
+
if (lastFindings.get(dir) === sig)
|
|
28
|
+
return;
|
|
29
|
+
lastFindings.set(dir, sig);
|
|
30
|
+
reportDefinitionWarnings(def.collisions, def.diagnostics);
|
|
31
|
+
}
|
|
2
32
|
export function reportDefinitionWarnings(collisions, diagnostics) {
|
|
3
33
|
for (const c of collisions) {
|
|
4
34
|
log.warn(`[fastagent] skill "${c.name}" collision — using ${c.winnerPath}, ignoring ${c.loserPath}`);
|
|
@@ -37,7 +37,7 @@ import { canonicalPath, loadAgentDefinition } from "./definition.js";
|
|
|
37
37
|
import { createPiModelRuntime, probeAuthSource } from "./models.js";
|
|
38
38
|
import { log } from "../../log.js";
|
|
39
39
|
import { additiveActivation, turnContext } from "./tool-context.js";
|
|
40
|
-
import {
|
|
40
|
+
import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
|
|
41
41
|
import { resolveAgentAssembly } from "./open.js";
|
|
42
42
|
/** Adapt coding-agent's resident SessionManager to FastAgent's shared tool-runtime manager port. */
|
|
43
43
|
function toolChatSessionManager(session) {
|
|
@@ -119,7 +119,7 @@ sessionManager) {
|
|
|
119
119
|
const model = resolveModel(modelRuntime, modelSpec);
|
|
120
120
|
const env = new NodeExecutionEnv({ cwd });
|
|
121
121
|
const definition = await loadAgentDefinition(agentDir, { cwd, env });
|
|
122
|
-
|
|
122
|
+
reportFindingsIfChanged(definition.dir, definition);
|
|
123
123
|
const defaultNames = piDefaultTools().map((t) => t.name);
|
|
124
124
|
const customTools = tools.filter((t) => !defaultNames.includes(t.name));
|
|
125
125
|
// Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
1
2
|
import type { Models } from "@earendil-works/pi-ai";
|
|
2
|
-
import { type SessionControl, type SessionEvent } from "../../session.ts";
|
|
3
|
+
import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
|
|
3
4
|
import type { Lease, SessionObserver } from "./invoke.ts";
|
|
4
|
-
import { type PiHarnessFactory } from "./harness.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type AnyModel, type PiHarnessFactory } from "./harness.ts";
|
|
6
|
+
import { type PiSessionReader } from "./sessions.ts";
|
|
6
7
|
/** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
|
|
7
8
|
* connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
|
|
8
9
|
* has its buffer FROZEN at the cap (memory bounded — the actual goal: ≈10k small events ≈ a few
|
|
@@ -12,7 +13,7 @@ import type { PiSessionReader } from "./sessions.ts";
|
|
|
12
13
|
* permanently stalled one holds the frozen buffer until its TCP connection dies. Recovery either
|
|
13
14
|
* way is the standard reconnect+backfill, semantically lossless. */
|
|
14
15
|
export declare const SUBSCRIBER_BUFFER_CAP = 10000;
|
|
15
|
-
/** What boundary mutations (compact / set_model / set_thinking) need — the SAME instances the
|
|
16
|
+
/** What boundary mutations (compact / set_model / set_thinking / navigate) need — the SAME instances the
|
|
16
17
|
* agent assembly uses: the lease (mutations must not race a run), the model registry (validation +
|
|
17
18
|
* allowedModels), and the harness factory (compaction is a model call). Writes go through the
|
|
18
19
|
* session the hub's reader opened — after an existence check, so the control plane never creates
|
|
@@ -21,6 +22,14 @@ export interface PiBoundaryWiring {
|
|
|
21
22
|
lease: Lease;
|
|
22
23
|
models: Models;
|
|
23
24
|
harnessFactory: PiHarnessFactory;
|
|
25
|
+
/** The assembly's configured PAIR — what a session with no overrides runs on. One field because
|
|
26
|
+
* model and thinking level are one setting: which levels exist is a property of the model, so a
|
|
27
|
+
* wiring that could carry them apart could carry a pair no run uses. Must be what
|
|
28
|
+
* {@link harnessFactory} was built with. */
|
|
29
|
+
defaults: {
|
|
30
|
+
model: AnyModel;
|
|
31
|
+
thinkingLevel: ThinkingLevel;
|
|
32
|
+
};
|
|
24
33
|
}
|
|
25
34
|
export interface CreatePiSessionControlOptions {
|
|
26
35
|
/** Read-only access to the durable session repository (the same root the agent writes). */
|
|
@@ -30,6 +39,16 @@ export interface CreatePiSessionControlOptions {
|
|
|
30
39
|
* (assembly completes before any dispatch can arrive). Absent / undefined → boundary commands
|
|
31
40
|
* are gated off in `capabilities()` and rejected `unsupported_capability`. */
|
|
32
41
|
boundary?: () => PiBoundaryWiring | undefined;
|
|
42
|
+
/** The definition's names, as a LAZY thunk for the same reason {@link boundary} is one (the hub
|
|
43
|
+
* exists before the assembly that can read a definition) — and async because the definition is
|
|
44
|
+
* live: this must re-read it, not close over a boot snapshot, or `commands()` would advertise a
|
|
45
|
+
* list the next turn no longer runs.
|
|
46
|
+
*
|
|
47
|
+
* OPTIONAL because absence is a TRUE answer for the assembly that omits it: a hub over an L1
|
|
48
|
+
* agent (`createPiAgent({ model, instructions, tools })`) has no definition and therefore no
|
|
49
|
+
* names, and `[]` says exactly that. Wire it whenever the agent came from a DIRECTORY — there
|
|
50
|
+
* `[]` would be a lie; the directory constructor (`createPiAgentFromDir`) always does. */
|
|
51
|
+
commands?: () => Promise<AgentCommand[]>;
|
|
33
52
|
/** Tap for the events the HUB ITSELF generates (boundary mutations: `state_changed`,
|
|
34
53
|
* `compaction_*`) — those never pass through the data plane's observer seam, so a consumer
|
|
35
54
|
* composing a full-vocabulary tap wires the run events via the observer AND this. Called after
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* stays in the session repository (read via {@link PiSessionReader}), live truth in the events the
|
|
9
9
|
* data plane emits, modulation in the controls the data plane registers.
|
|
10
10
|
*
|
|
11
|
-
* Boundary mutations (Phase 2b: compact/set_model/set_thinking) take the same lease as runs;
|
|
11
|
+
* Boundary mutations (Phase 2b: compact/set_model/set_thinking/navigate) take the same lease as runs;
|
|
12
12
|
* without boundary wiring they are rejected before acceptance with `unsupported_capability` — a
|
|
13
13
|
* client gating on `capabilities()` never sends them.
|
|
14
14
|
*/
|
|
@@ -16,8 +16,10 @@ import { DEFAULT_COMPACTION_SETTINGS, compact, prepareCompaction } from "@earend
|
|
|
16
16
|
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
17
17
|
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
18
18
|
import { listModels } from "./config.js";
|
|
19
|
-
import { SUMMARIZATION_RETRY_POLICY,
|
|
19
|
+
import { SUMMARIZATION_RETRY_POLICY, harnessSession } from "./harness.js";
|
|
20
|
+
import { THINKING_LEVELS, resolveSessionSettings } from "./session-settings.js";
|
|
20
21
|
import { log } from "../../log.js";
|
|
22
|
+
import { activePathEntries } from "./sessions.js";
|
|
21
23
|
// ── Entry normalization (durable plane) ──────────────────────────────────────
|
|
22
24
|
/** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
|
|
23
25
|
* AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
|
|
@@ -73,6 +75,16 @@ function toSessionEntry(entry) {
|
|
|
73
75
|
}
|
|
74
76
|
return { ...base, kind: entry.type, data: {} };
|
|
75
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* THE invariant the client's rule rests on: everything `entries()` publishes is a legal `navigate`
|
|
80
|
+
* target. pi's `leaf` records are the exception — they journal a MOVE rather than mark a position
|
|
81
|
+
* (their parentId is the OLD leaf, nothing is ever chained onto them), so navigating to one would
|
|
82
|
+
* put the branch head off every conversation path. Withheld from the published plane and refused as
|
|
83
|
+
* a target THROUGH THIS ONE PREDICATE, so a second exclusion cannot make the two disagree.
|
|
84
|
+
*/
|
|
85
|
+
function isNavigable(entry) {
|
|
86
|
+
return entry.type !== "leaf";
|
|
87
|
+
}
|
|
76
88
|
// ── Live fan-out (events plane) ──────────────────────────────────────────────
|
|
77
89
|
/** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
|
|
78
90
|
* connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
|
|
@@ -188,6 +200,9 @@ export function createPiSessionControl(options) {
|
|
|
188
200
|
fanOut(session, event);
|
|
189
201
|
};
|
|
190
202
|
const control = {
|
|
203
|
+
async commands() {
|
|
204
|
+
return (await options.commands?.()) ?? [];
|
|
205
|
+
},
|
|
191
206
|
capabilities: () => {
|
|
192
207
|
const b = boundary?.();
|
|
193
208
|
return {
|
|
@@ -195,7 +210,12 @@ export function createPiSessionControl(options) {
|
|
|
195
210
|
followUp: true,
|
|
196
211
|
manualCompaction: !!b,
|
|
197
212
|
modelSelection: b ? { allowedModels: listModels(b.models) } : false,
|
|
198
|
-
|
|
213
|
+
// Servable or not. WHICH levels is a property of the session's model, so it rides
|
|
214
|
+
// `state().availableThinkingLevels` — a list here could only answer for one model.
|
|
215
|
+
thinkingLevel: !!b,
|
|
216
|
+
// Gated on the boundary wiring for its LEASE, not its models: moving the leaf is a write,
|
|
217
|
+
// and a write that races a run would hang the next turn off a stale branch.
|
|
218
|
+
navigate: !!b,
|
|
199
219
|
toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
|
|
200
220
|
usage: false,
|
|
201
221
|
};
|
|
@@ -204,24 +224,33 @@ export function createPiSessionControl(options) {
|
|
|
204
224
|
const run = active.get(session);
|
|
205
225
|
const opened = await sessions.openIfExists(session);
|
|
206
226
|
const leafEntryId = opened ? ((await opened.getLeafId()) ?? undefined) : undefined;
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
// What will RUN, not the raw record: a client steering a session needs the pair that executes.
|
|
228
|
+
// Without a boundary there is no model to resolve against, and the fields are absent.
|
|
229
|
+
// OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
|
|
230
|
+
// control-less deployment answers with) rather than rejecting a read that has no error-code
|
|
231
|
+
// channel to explain itself. The fault is not swallowed — it surfaces where codes exist: the
|
|
232
|
+
// next invoke fails (the harness build walks the same chain) and a boundary dispatch answers
|
|
233
|
+
// `boundary_command_failed`. Here it is a server-side warn.
|
|
234
|
+
const b = boundary?.();
|
|
235
|
+
let settings;
|
|
236
|
+
if (opened && b) {
|
|
237
|
+
try {
|
|
238
|
+
settings = resolveSessionSettings((await activePathEntries(opened)), b.models, b.defaults);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
|
|
242
|
+
}
|
|
219
243
|
}
|
|
220
244
|
return {
|
|
221
245
|
status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
|
|
222
246
|
...(run ? { activeRunId: run.runId } : {}),
|
|
223
|
-
...(
|
|
224
|
-
|
|
247
|
+
...(settings
|
|
248
|
+
? {
|
|
249
|
+
model: `${settings.model.provider}/${settings.model.id}`,
|
|
250
|
+
thinkingLevel: settings.thinkingLevel,
|
|
251
|
+
availableThinkingLevels: settings.availableThinkingLevels,
|
|
252
|
+
}
|
|
253
|
+
: {}),
|
|
225
254
|
pending: run ? { ...run.pending } : { steering: 0, followUp: 0 },
|
|
226
255
|
...(leafEntryId ? { leafEntryId } : {}),
|
|
227
256
|
};
|
|
@@ -230,8 +259,12 @@ export function createPiSessionControl(options) {
|
|
|
230
259
|
const opened = await sessions.openIfExists(session);
|
|
231
260
|
if (!opened)
|
|
232
261
|
return { entries: [] };
|
|
233
|
-
|
|
262
|
+
// LEAF FIRST, then the journal: `getEntries()` hands back a SNAPSHOT, so reading it first
|
|
263
|
+
// would race any concurrent append into a leaf the snapshot cannot contain — a live turn
|
|
264
|
+
// reading as a dangling head. This order makes the journal a superset of the leaf's chain,
|
|
265
|
+
// which is what lets the published head be trusted as one of the published entries.
|
|
234
266
|
const leafEntryId = (await opened.getLeafId()) ?? undefined;
|
|
267
|
+
const all = (await opened.getEntries()).filter(isNavigable).map(toSessionEntry);
|
|
235
268
|
let entries = all;
|
|
236
269
|
if (opts?.since !== undefined) {
|
|
237
270
|
const idx = all.findIndex((e) => e.id === opts.since);
|
|
@@ -361,7 +394,8 @@ export function createPiSessionControl(options) {
|
|
|
361
394
|
}
|
|
362
395
|
case "compact":
|
|
363
396
|
case "set_model":
|
|
364
|
-
case "set_thinking":
|
|
397
|
+
case "set_thinking":
|
|
398
|
+
case "navigate": {
|
|
365
399
|
const b = boundary?.();
|
|
366
400
|
if (!b) {
|
|
367
401
|
// No boundary wiring: rejected before acceptance; a capability-gating client never
|
|
@@ -376,7 +410,8 @@ export function createPiSessionControl(options) {
|
|
|
376
410
|
};
|
|
377
411
|
}
|
|
378
412
|
// Payload validation BEFORE the lease — an invalid value must not briefly block a run.
|
|
379
|
-
/** The
|
|
413
|
+
/** The durable write for set_model/set_thinking/navigate — undefined for compact (harness
|
|
414
|
+
* path). Answers the event to emit. */
|
|
380
415
|
let apply;
|
|
381
416
|
if (command.type === "set_model") {
|
|
382
417
|
const slash = command.model.indexOf("/");
|
|
@@ -393,18 +428,26 @@ export function createPiSessionControl(options) {
|
|
|
393
428
|
}
|
|
394
429
|
apply = async (s) => {
|
|
395
430
|
await s.appendModelChange(model.provider, model.id);
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
431
|
+
// Both halves: a new model can change which level executes. Nothing is re-recorded to
|
|
432
|
+
// make that true — the resolve reports it, so the preference survives a round trip.
|
|
433
|
+
const settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
|
|
434
|
+
return {
|
|
435
|
+
type: "state_changed",
|
|
436
|
+
timestamp: Date.now(),
|
|
437
|
+
// The CANONICAL spec, same string the durable entry and state() report — the event
|
|
438
|
+
// must not echo a client alias the other two surfaces would disagree with.
|
|
439
|
+
data: { model: `${model.provider}/${model.id}`, thinkingLevel: settings.thinkingLevel },
|
|
440
|
+
};
|
|
399
441
|
};
|
|
400
442
|
}
|
|
401
443
|
else if (command.type === "set_thinking") {
|
|
444
|
+
// A payload that is not a level at all — invalid before any session question.
|
|
402
445
|
if (!THINKING_LEVELS.has(command.level)) {
|
|
403
446
|
return {
|
|
404
447
|
ok: false,
|
|
405
448
|
error: {
|
|
406
449
|
code: INVALID_COMMAND_CODE,
|
|
407
|
-
message: `unknown thinking level "${command.level}" —
|
|
450
|
+
message: `unknown thinking level "${command.level}" — state().availableThinkingLevels lists what this session accepts`,
|
|
408
451
|
retryable: false,
|
|
409
452
|
},
|
|
410
453
|
};
|
|
@@ -414,10 +457,54 @@ export function createPiSessionControl(options) {
|
|
|
414
457
|
return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
|
|
415
458
|
};
|
|
416
459
|
}
|
|
460
|
+
else if (command.type === "navigate") {
|
|
461
|
+
apply = async (s) => {
|
|
462
|
+
// A move to where the leaf already is writes nothing: pi journals a move as a `leaf`
|
|
463
|
+
// record, so an idempotent re-dispatch (a client retry, a UI firing on every
|
|
464
|
+
// selection) would otherwise grow the session by a record no plane publishes. The
|
|
465
|
+
// EVENT is emitted either way — it reports the resulting position, not the fact that
|
|
466
|
+
// a record was written, and a client that dispatched must not have to poll for it.
|
|
467
|
+
if ((await s.getLeafId()) !== command.targetId)
|
|
468
|
+
await s.moveTo(command.targetId);
|
|
469
|
+
// moveTo's postcondition IS "targetId is the leaf" (it validates, then sets); a
|
|
470
|
+
// failure throws and travels as boundary_command_failed, so a read-back could only
|
|
471
|
+
// re-report what this line already knows. The SETTINGS ride along because a move can
|
|
472
|
+
// change them — an override recorded on the branch just left stops applying, and a
|
|
473
|
+
// client tracking model/level from the event stream would otherwise show what the
|
|
474
|
+
// next turn will not use.
|
|
475
|
+
// The move is already durable here, so a settings read that throws (the new path is
|
|
476
|
+
// above a gap) must NOT turn into "nothing took effect": report the position without
|
|
477
|
+
// the settings and let `state()`'s rejection be where the broken chain surfaces.
|
|
478
|
+
let settings;
|
|
479
|
+
try {
|
|
480
|
+
settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
// Absent rather than stale: the session cannot RUN with an unreadable chain either
|
|
484
|
+
// (the harness build walks the same path), so the next invoke fails visibly — this
|
|
485
|
+
// event does not need to carry a second signal for it.
|
|
486
|
+
log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
type: "state_changed",
|
|
490
|
+
timestamp: Date.now(),
|
|
491
|
+
data: {
|
|
492
|
+
leafEntryId: command.targetId,
|
|
493
|
+
...(settings
|
|
494
|
+
? {
|
|
495
|
+
model: `${settings.model.provider}/${settings.model.id}`,
|
|
496
|
+
thinkingLevel: settings.thinkingLevel,
|
|
497
|
+
}
|
|
498
|
+
: {}),
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
}
|
|
417
503
|
// Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
|
|
418
504
|
// not minted into a ghost record. (Existence check before the lease — read-only; the
|
|
419
505
|
// WRITE handle is re-opened under the lease below, this one is discarded.)
|
|
420
|
-
|
|
506
|
+
const existing = await sessions.openIfExists(session);
|
|
507
|
+
if (!existing) {
|
|
421
508
|
return {
|
|
422
509
|
ok: false,
|
|
423
510
|
error: {
|
|
@@ -427,6 +514,51 @@ export function createPiSessionControl(options) {
|
|
|
427
514
|
},
|
|
428
515
|
};
|
|
429
516
|
}
|
|
517
|
+
if (command.type === "navigate") {
|
|
518
|
+
// A target that cannot BE a leaf is a permanent payload error, not a session error — the
|
|
519
|
+
// same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
|
|
520
|
+
// "everything published is navigable" holds by construction rather than by two literals
|
|
521
|
+
// agreeing.
|
|
522
|
+
const entry = await existing.getEntry(command.targetId);
|
|
523
|
+
if (!entry || !isNavigable(entry)) {
|
|
524
|
+
return {
|
|
525
|
+
ok: false,
|
|
526
|
+
error: {
|
|
527
|
+
code: INVALID_COMMAND_CODE,
|
|
528
|
+
message: entry
|
|
529
|
+
? `entry "${command.targetId}" is a leaf-move record — entries() does not publish those, and they are not positions; navigate to the entry it points at`
|
|
530
|
+
: `entry "${command.targetId}" does not exist in session "${session}" — entries() lists the navigable ids`,
|
|
531
|
+
retryable: false,
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (command.type === "set_thinking") {
|
|
537
|
+
// The same set `state()` showed the client. Reject here rather than record a level the
|
|
538
|
+
// run would not use. The read is guarded because `dispatch` must never REJECT — the
|
|
539
|
+
// transport promises a SessionResult, so an unreadable chain has to arrive as a code.
|
|
540
|
+
let resolved;
|
|
541
|
+
try {
|
|
542
|
+
resolved = resolveSessionSettings((await activePathEntries(existing)), b.models, b.defaults);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const { model, availableThinkingLevels } = resolved;
|
|
551
|
+
if (!availableThinkingLevels.includes(command.level)) {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
error: {
|
|
555
|
+
code: INVALID_COMMAND_CODE,
|
|
556
|
+
message: `thinking level "${command.level}" is not supported by ${model.provider}/${model.id} (allowed: ${availableThinkingLevels.join(", ")})`,
|
|
557
|
+
retryable: false,
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
430
562
|
// Boundary mutations are the control plane's only writers: same lease as every run — a
|
|
431
563
|
// mutation must never race one (design §9).
|
|
432
564
|
const release = b.lease.tryAcquire(session);
|
|
@@ -567,8 +699,8 @@ export function createPiSessionControl(options) {
|
|
|
567
699
|
},
|
|
568
700
|
};
|
|
569
701
|
}
|
|
570
|
-
// Unreachable by construction: only set_model/set_thinking reach this branch,
|
|
571
|
-
//
|
|
702
|
+
// Unreachable by construction: only set_model/set_thinking/navigate reach this branch,
|
|
703
|
+
// and all three assign `apply` in validation. Throw rather than silently skip (fail visibly).
|
|
572
704
|
if (!apply)
|
|
573
705
|
throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
|
|
574
706
|
emitOwn(session, await apply(fresh));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session is SET TO, and what it may be set to. Model and thinking level are ONE setting —
|
|
3
|
+
* which levels exist is a property of the model — so they resolve together, here, and `state()`, the
|
|
4
|
+
* `set_thinking` gate and the fresh-harness resolve all read this rather than deriving their own.
|
|
5
|
+
*
|
|
6
|
+
* Read-only by design. The durable record may hold a level the current model cannot do; that record
|
|
7
|
+
* is the user's PREFERENCE, so resolving per read restores it when the session returns to a capable
|
|
8
|
+
* model. (It could not be made unrepresentable anyway: `model_change`/`thinking_level_change` are
|
|
9
|
+
* pi's entries, and pi appends them itself.)
|
|
10
|
+
*/
|
|
11
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
12
|
+
import { type Models } from "@earendil-works/pi-ai";
|
|
13
|
+
import type { AnyModel } from "./harness.ts";
|
|
14
|
+
export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
|
|
15
|
+
/** The shape both override consumers walk — a session entry, structurally. */
|
|
16
|
+
export interface OverrideEntryLike {
|
|
17
|
+
type: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
modelId?: string;
|
|
20
|
+
thinkingLevel?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The last entry of each kind wins, and a malformed one reads as ABSENT rather than falling through
|
|
23
|
+
* to an earlier record — which record is "the" override must not depend on who is asking. */
|
|
24
|
+
export declare function lastOverrideEntries(entries: OverrideEntryLike[]): {
|
|
25
|
+
model?: {
|
|
26
|
+
provider: string;
|
|
27
|
+
modelId: string;
|
|
28
|
+
};
|
|
29
|
+
thinkingLevel?: string;
|
|
30
|
+
};
|
|
31
|
+
export interface SessionSettings {
|
|
32
|
+
model: AnyModel;
|
|
33
|
+
/** Already clamped to what {@link model} supports. */
|
|
34
|
+
thinkingLevel: ThinkingLevel;
|
|
35
|
+
/** What `set_thinking` accepts for this session. */
|
|
36
|
+
availableThinkingLevels: string[];
|
|
37
|
+
/** Recorded but not honored — only the execution path reports it (as a warn). */
|
|
38
|
+
dropped?: {
|
|
39
|
+
model?: string;
|
|
40
|
+
thinkingLevel?: {
|
|
41
|
+
recorded: string;
|
|
42
|
+
running: string;
|
|
43
|
+
known: boolean;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** `defaults` is the assembly's configured pair — what a session with no overrides runs on. */
|
|
48
|
+
export declare function resolveSessionSettings(entries: OverrideEntryLike[], models: Models, defaults: {
|
|
49
|
+
model: AnyModel;
|
|
50
|
+
thinkingLevel: ThinkingLevel;
|
|
51
|
+
}): SessionSettings;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
2
|
+
/** Which strings are levels at all — the vocabulary. What a MODEL supports is
|
|
3
|
+
* `getSupportedThinkingLevels`. The `satisfies` anchor keeps this exhaustive against pi's union: a
|
|
4
|
+
* level pi adds becomes a type error here rather than a value `set_thinking` silently rejects. */
|
|
5
|
+
const ALL_THINKING_LEVELS = {
|
|
6
|
+
off: true,
|
|
7
|
+
minimal: true,
|
|
8
|
+
low: true,
|
|
9
|
+
medium: true,
|
|
10
|
+
high: true,
|
|
11
|
+
xhigh: true,
|
|
12
|
+
max: true,
|
|
13
|
+
};
|
|
14
|
+
export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
|
|
15
|
+
/** The last entry of each kind wins, and a malformed one reads as ABSENT rather than falling through
|
|
16
|
+
* to an earlier record — which record is "the" override must not depend on who is asking. */
|
|
17
|
+
export function lastOverrideEntries(entries) {
|
|
18
|
+
let model;
|
|
19
|
+
let modelSeen = false;
|
|
20
|
+
let thinkingLevel;
|
|
21
|
+
let thinkingSeen = false;
|
|
22
|
+
for (let i = entries.length - 1; i >= 0 && !(modelSeen && thinkingSeen); i--) {
|
|
23
|
+
const e = entries[i];
|
|
24
|
+
if (!modelSeen && e?.type === "model_change") {
|
|
25
|
+
modelSeen = true;
|
|
26
|
+
if (e.provider !== undefined && e.modelId !== undefined)
|
|
27
|
+
model = { provider: e.provider, modelId: e.modelId };
|
|
28
|
+
}
|
|
29
|
+
if (!thinkingSeen && e?.type === "thinking_level_change") {
|
|
30
|
+
thinkingSeen = true;
|
|
31
|
+
if (e.thinkingLevel !== undefined)
|
|
32
|
+
thinkingLevel = e.thinkingLevel;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { model, thinkingLevel };
|
|
36
|
+
}
|
|
37
|
+
/** `defaults` is the assembly's configured pair — what a session with no overrides runs on. */
|
|
38
|
+
export function resolveSessionSettings(entries, models, defaults) {
|
|
39
|
+
const recorded = lastOverrideEntries(entries);
|
|
40
|
+
const dropped = {};
|
|
41
|
+
// A registry change across deploys must not brick the conversation.
|
|
42
|
+
let model = defaults.model;
|
|
43
|
+
if (recorded.model) {
|
|
44
|
+
const found = models.getModel(recorded.model.provider, recorded.model.modelId);
|
|
45
|
+
if (found)
|
|
46
|
+
model = found;
|
|
47
|
+
else
|
|
48
|
+
dropped.model = `${recorded.model.provider}/${recorded.model.modelId}`;
|
|
49
|
+
}
|
|
50
|
+
const availableThinkingLevels = getSupportedThinkingLevels(model);
|
|
51
|
+
let thinkingLevel = defaults.thinkingLevel;
|
|
52
|
+
if (recorded.thinkingLevel !== undefined) {
|
|
53
|
+
const level = recorded.thinkingLevel;
|
|
54
|
+
if (!THINKING_LEVELS.has(level)) {
|
|
55
|
+
dropped.thinkingLevel = { recorded: level, running: thinkingLevel, known: false };
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// pi's clamp takes the lowest supported level AT OR ABOVE the recorded one, falling back
|
|
59
|
+
// downward only when nothing is above: a gap resolves UPWARD and costs more, not less. Which is
|
|
60
|
+
// why `dropped` names both levels rather than reporting a substitution.
|
|
61
|
+
thinkingLevel = clampThinkingLevel(model, level);
|
|
62
|
+
if (thinkingLevel !== level) {
|
|
63
|
+
dropped.thinkingLevel = { recorded: level, running: thinkingLevel, known: true };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
model,
|
|
69
|
+
thinkingLevel,
|
|
70
|
+
availableThinkingLevels,
|
|
71
|
+
...(dropped.model || dropped.thinkingLevel ? { dropped } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Session } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import type { Session, SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
|
2
2
|
/** What fastagent needs from a session backend: open-or-create by opaque id. */
|
|
3
3
|
export interface PiSessionStore {
|
|
4
4
|
openOrCreate(sessionId: string): Promise<Session>;
|
|
@@ -7,17 +7,31 @@ export interface PiSessionStore {
|
|
|
7
7
|
* OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
|
|
8
8
|
* `undefined`, never creates one — sessions are the data plane's monopoly. Two consumers:
|
|
9
9
|
* - the OBSERVATION plane (`state()`/`entries()`), strictly read-only (design §16 invariant 4);
|
|
10
|
-
* - the control plane's BOUNDARY writers (`set_model`/`set_thinking`
|
|
11
|
-
*
|
|
10
|
+
* - the control plane's BOUNDARY writers (`set_model`/`set_thinking` append override records to the
|
|
11
|
+
* returned handle; `navigate` moves its leaf) after an existence check, under the run lease.
|
|
12
12
|
* `openIfExists` skips the open-time crash reconciliation (that appends repair entries — a write
|
|
13
|
-
* the observation plane must not perform). The boundary writers are safe WITHOUT it
|
|
14
|
-
* override
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* the observation plane must not perform). The boundary writers are safe WITHOUT it for two
|
|
14
|
+
* different reasons: an override record is not a message, so it cannot create or pair with a
|
|
15
|
+
* dangling tool_use; a `navigate` writes no message either, but it CAN expose one — parking the
|
|
16
|
+
* leaf on an assistant entry whose tool results are now off-path is the dangling-pair state
|
|
17
|
+
* {@link reconcileInterruptedToolCalls} exists for. That is repaired at the next `openOrCreate`,
|
|
18
|
+
* which repairs AT THE LEAF — exactly where a move puts it.
|
|
19
|
+
*
|
|
20
|
+
* Writing MESSAGE-class records through this handle would bypass that repair: use `openOrCreate`
|
|
21
|
+
* for anything that enters the transcript.
|
|
17
22
|
*/
|
|
18
23
|
export interface PiSessionReader {
|
|
19
24
|
openIfExists(sessionId: string): Promise<Session | undefined>;
|
|
20
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The entries on the session's ACTIVE path, root→leaf — what every last-wins read must walk.
|
|
28
|
+
* `getEntries()` is the whole TREE: once `navigate` can move the leaf, the journal still carries
|
|
29
|
+
* the abandoned branch, and reading it flat would run the session on a setting it moved away from.
|
|
30
|
+
* Deliberately NOT `Session.getBranch()`: that walk is bounded by the last compaction's retained
|
|
31
|
+
* window, which is the right bound for MODEL CONTEXT and the wrong one for settings — an override
|
|
32
|
+
* recorded before a compaction is a preference, and it still governs the session after one.
|
|
33
|
+
*/
|
|
34
|
+
export declare function activePathEntries(session: Session): Promise<SessionTreeEntry[]>;
|
|
21
35
|
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
22
36
|
export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader;
|
|
23
37
|
/**
|
|
@@ -88,6 +88,49 @@ async function reconcileInterruptedToolCalls(session) {
|
|
|
88
88
|
await session.appendMessage(result);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The entries on the session's ACTIVE path, root→leaf — what every last-wins read must walk.
|
|
93
|
+
* `getEntries()` is the whole TREE: once `navigate` can move the leaf, the journal still carries
|
|
94
|
+
* the abandoned branch, and reading it flat would run the session on a setting it moved away from.
|
|
95
|
+
* Deliberately NOT `Session.getBranch()`: that walk is bounded by the last compaction's retained
|
|
96
|
+
* window, which is the right bound for MODEL CONTEXT and the wrong one for settings — an override
|
|
97
|
+
* recorded before a compaction is a preference, and it still governs the session after one.
|
|
98
|
+
*/
|
|
99
|
+
export async function activePathEntries(session) {
|
|
100
|
+
// LEAF FIRST, then the journal — the order is load-bearing, not incidental: `getEntries()` is a
|
|
101
|
+
// SNAPSHOT, so reading it first would let any concurrent append (i.e. any turn in progress) leave
|
|
102
|
+
// a leaf the snapshot cannot contain, and the integrity throw below would fire on a live session
|
|
103
|
+
// instead of a corrupt one. This way the snapshot is always a superset of the leaf's chain.
|
|
104
|
+
const leafId = await session.getLeafId();
|
|
105
|
+
const entries = await session.getEntries();
|
|
106
|
+
// A null leaf is pi's ROOT position (what `moveTo(null)` sets), so the active path is EMPTY — not
|
|
107
|
+
// "the whole journal", which on a branched session would mean every abandoned branch at once.
|
|
108
|
+
if (leafId === null)
|
|
109
|
+
return [];
|
|
110
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
111
|
+
// A chain that is not intact THROWS: any other disposition returns a short path that reads like a
|
|
112
|
+
// short session (every override and activation above the gap gone, the next turn silently on
|
|
113
|
+
// assembly defaults). pi's storages already reject a leaf outside the journal in `getLeafId()`,
|
|
114
|
+
// but the port is swappable — so the walk names it here rather than dying on `undefined.parentId`.
|
|
115
|
+
const start = byId.get(leafId);
|
|
116
|
+
if (!start)
|
|
117
|
+
throw new Error(`session leaf "${leafId}" is missing from the journal`);
|
|
118
|
+
const path = [];
|
|
119
|
+
for (let cur = start;;) {
|
|
120
|
+
path.push(cur);
|
|
121
|
+
// A path cannot be longer than the journal it walks; anything more is a parentId cycle, which
|
|
122
|
+
// would otherwise hang the turn with no `failed` event at all.
|
|
123
|
+
if (path.length > entries.length)
|
|
124
|
+
throw new Error(`session entry "${cur.id}" cycles through its parents`);
|
|
125
|
+
if (!cur.parentId)
|
|
126
|
+
break;
|
|
127
|
+
const parent = byId.get(cur.parentId);
|
|
128
|
+
if (!parent)
|
|
129
|
+
throw new Error(`session entry "${cur.parentId}" is missing from the journal (parent of "${cur.id}")`);
|
|
130
|
+
cur = parent;
|
|
131
|
+
}
|
|
132
|
+
return path.reverse(); // walked leaf→root; every consumer reads it root→leaf
|
|
133
|
+
}
|
|
91
134
|
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
92
135
|
export function inMemorySessionStore() {
|
|
93
136
|
const repo = new InMemorySessionRepo();
|