@fastagent-sh/fastagent 0.16.1 → 0.16.2
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/control.js +3 -0
- package/dist/cli/commands/attach.d.ts +11 -1
- package/dist/cli/commands/attach.js +30 -2
- package/dist/cli/commands/deploy.js +11 -5
- package/dist/cli/commands/logs.d.ts +6 -0
- package/dist/cli/commands/logs.js +27 -0
- package/dist/cli/program.js +26 -0
- package/dist/deploy/agentcore/logs.d.ts +30 -0
- package/dist/deploy/agentcore/logs.js +115 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -0
- package/dist/deploy/agentcore/plan.js +40 -2
- 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 +9 -3
- package/dist/engines/pi/harness.d.ts +12 -28
- package/dist/engines/pi/harness.js +21 -71
- package/dist/engines/pi/open.js +1 -0
- package/dist/engines/pi/session-control.d.ts +12 -3
- package/dist/engines/pi/session-control.js +156 -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.d.ts +34 -9
- package/package.json +1 -1
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { AgentHarness } from "@earendil-works/pi-agent-core";
|
|
10
10
|
import type { ExecutionEnv, ExecutionToolContext, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
11
11
|
import type { Model, Models } from "@earendil-works/pi-ai";
|
|
12
|
-
import type
|
|
12
|
+
import { type PiSessionStore } from "./sessions.ts";
|
|
13
13
|
import { type MountedTool } from "./tool.ts";
|
|
14
|
+
import { type OverrideEntryLike } from "./session-settings.ts";
|
|
14
15
|
/**
|
|
15
16
|
* The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
|
|
16
17
|
* tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
|
|
@@ -84,36 +85,19 @@ export declare const SUMMARIZATION_RETRY_POLICY: {
|
|
|
84
85
|
readonly maxRetries: 3;
|
|
85
86
|
readonly baseDelayMs: 2000;
|
|
86
87
|
};
|
|
87
|
-
export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
|
|
88
|
-
/** The shape both override consumers walk — a session entry, structurally. */
|
|
89
|
-
export interface OverrideEntryLike {
|
|
90
|
-
type: string;
|
|
91
|
-
provider?: string;
|
|
92
|
-
modelId?: string;
|
|
93
|
-
thinkingLevel?: string;
|
|
94
|
-
}
|
|
95
88
|
/**
|
|
96
|
-
* The
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
89
|
+
* The serving default for reasoning effort, pinned to what pi's TUI defaults to (its
|
|
90
|
+
* DEFAULT_THINKING_LEVEL) — NOT inherited from the bare harness, whose own fallback is "off": an
|
|
91
|
+
* author vibes at "medium" in pi and must get "medium" when served (fidelity), and pinning the value
|
|
92
|
+
* here means an upstream default change in either place cannot silently alter deployments. Models
|
|
93
|
+
* that don't support a level are clamped by pi per model.
|
|
101
94
|
*/
|
|
102
|
-
export declare
|
|
103
|
-
model?: {
|
|
104
|
-
provider: string;
|
|
105
|
-
modelId: string;
|
|
106
|
-
};
|
|
107
|
-
thinkingLevel?: string;
|
|
108
|
-
};
|
|
95
|
+
export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
|
|
109
96
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
|
|
115
|
-
* back to the default with a deduped warn (fail visibly without bricking the session — the
|
|
116
|
-
* conversation must survive a registry change across deploys); an unknown thinking level likewise.
|
|
97
|
+
* {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
|
|
98
|
+
* being executable with no control-plane command involved (pi appends these entries itself; a
|
|
99
|
+
* deployment's configured model can change between restarts). Deduped per session+cause — it would
|
|
100
|
+
* otherwise repeat every turn.
|
|
117
101
|
*/
|
|
118
102
|
export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], models: Models, defaults: {
|
|
119
103
|
model: AnyModel;
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { AgentHarness } from "@earendil-works/pi-agent-core";
|
|
10
10
|
import { log } from "../../log.js";
|
|
11
|
+
import { activePathEntries } from "./sessions.js";
|
|
11
12
|
import { isDeferredTool } from "./tool.js";
|
|
13
|
+
import { resolveSessionSettings } from "./session-settings.js";
|
|
12
14
|
/**
|
|
13
15
|
* The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
|
|
14
16
|
* tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
|
|
@@ -48,7 +50,7 @@ export const SUMMARIZATION_RETRY_POLICY = { enabled: true, maxRetries: 3, baseDe
|
|
|
48
50
|
* here means an upstream default change in either place cannot silently alter deployments. Models
|
|
49
51
|
* that don't support a level are clamped by pi per model.
|
|
50
52
|
*/
|
|
51
|
-
const DEFAULT_THINKING_LEVEL = "medium";
|
|
53
|
+
export const DEFAULT_THINKING_LEVEL = "medium";
|
|
52
54
|
/**
|
|
53
55
|
* Resolve the active-tool set for a fresh harness — the ONE place both fallbacks live. pi's harness
|
|
54
56
|
* WRITES active-tool changes to the session (`setActiveTools` → `active_tools_change`) but its
|
|
@@ -72,83 +74,30 @@ const DEFAULT_THINKING_LEVEL = "medium";
|
|
|
72
74
|
* (like L2's findings memo), not session state — the resolve stays derived from the session.
|
|
73
75
|
*/
|
|
74
76
|
const warnedRestores = new Set();
|
|
75
|
-
/** pi's ThinkingLevel scale as a checkable set — THE single source for fastagent (session entries
|
|
76
|
-
* store plain strings; session-control's dispatch validation and capabilities derive from this).
|
|
77
|
-
* The `satisfies Record<ThinkingLevel, …>` anchor makes it EXHAUSTIVE against pi's union: pi
|
|
78
|
-
* adding a level turns this into a type error instead of a silent drift where `set_thinking`
|
|
79
|
-
* rejects a value pi supports. */
|
|
80
|
-
const ALL_THINKING_LEVELS = {
|
|
81
|
-
off: true,
|
|
82
|
-
minimal: true,
|
|
83
|
-
low: true,
|
|
84
|
-
medium: true,
|
|
85
|
-
high: true,
|
|
86
|
-
xhigh: true,
|
|
87
|
-
max: true,
|
|
88
|
-
};
|
|
89
|
-
export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
|
|
90
77
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* which record is "the" override.
|
|
96
|
-
*/
|
|
97
|
-
export function lastOverrideEntries(entries) {
|
|
98
|
-
let model;
|
|
99
|
-
let modelSeen = false;
|
|
100
|
-
let thinkingLevel;
|
|
101
|
-
let thinkingSeen = false;
|
|
102
|
-
for (let i = entries.length - 1; i >= 0 && !(modelSeen && thinkingSeen); i--) {
|
|
103
|
-
const e = entries[i];
|
|
104
|
-
if (!modelSeen && e?.type === "model_change") {
|
|
105
|
-
modelSeen = true;
|
|
106
|
-
if (e.provider !== undefined && e.modelId !== undefined)
|
|
107
|
-
model = { provider: e.provider, modelId: e.modelId };
|
|
108
|
-
}
|
|
109
|
-
if (!thinkingSeen && e?.type === "thinking_level_change") {
|
|
110
|
-
thinkingSeen = true;
|
|
111
|
-
if (e.thinkingLevel !== undefined)
|
|
112
|
-
thinkingLevel = e.thinkingLevel;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return { model, thinkingLevel };
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Resolve the session's model/thinking OVERRIDES for a fresh harness — same shape as the
|
|
119
|
-
* active-tools resolve above: pi writes `model_change`/`thinking_level_change` entries on explicit
|
|
120
|
-
* setModel/setThinkingLevel (the control plane's `set_model`/`set_thinking` append them directly)
|
|
121
|
-
* but a fresh harness never reads them back. Override facts come from {@link lastOverrideEntries};
|
|
122
|
-
* this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
|
|
123
|
-
* back to the default with a deduped warn (fail visibly without bricking the session — the
|
|
124
|
-
* conversation must survive a registry change across deploys); an unknown thinking level likewise.
|
|
78
|
+
* {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
|
|
79
|
+
* being executable with no control-plane command involved (pi appends these entries itself; a
|
|
80
|
+
* deployment's configured model can change between restarts). Deduped per session+cause — it would
|
|
81
|
+
* otherwise repeat every turn.
|
|
125
82
|
*/
|
|
126
83
|
export function resolveHarnessOverrides(entries, models, defaults, sessionId) {
|
|
127
|
-
|
|
128
|
-
let thinkingLevel = defaults.thinkingLevel;
|
|
84
|
+
const settings = resolveSessionSettings(entries, models, defaults);
|
|
129
85
|
const warnOnce = (key, message) => {
|
|
130
86
|
const emit = warnedRestores.has(key) ? log.debug : log.warn;
|
|
131
87
|
warnedRestores.add(key);
|
|
132
88
|
emit(message);
|
|
133
89
|
};
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
if (found)
|
|
138
|
-
model = found;
|
|
139
|
-
else {
|
|
140
|
-
warnOnce(`${sessionId}\u0000model\u0000${recorded.model.provider}/${recorded.model.modelId}`, `[fastagent] session ${sessionId}: recorded model override ${recorded.model.provider}/${recorded.model.modelId} is not in this deployment's registry — using the configured default`);
|
|
141
|
-
}
|
|
90
|
+
const dropped = settings.dropped;
|
|
91
|
+
if (dropped?.model) {
|
|
92
|
+
warnOnce(`${sessionId}\u0000model\u0000${dropped.model}`, `[fastagent] session ${sessionId}: recorded model override ${dropped.model} is not in this deployment's registry — using the configured default`);
|
|
142
93
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
warnOnce(`${sessionId}\u0000thinking\u0000${recorded.thinkingLevel}`, `[fastagent] session ${sessionId}: recorded thinking level "${recorded.thinkingLevel}" is unknown — using the configured default`);
|
|
149
|
-
}
|
|
94
|
+
if (dropped?.thinkingLevel) {
|
|
95
|
+
const { recorded, running, known } = dropped.thinkingLevel;
|
|
96
|
+
warnOnce(`${sessionId}\u0000thinking\u0000${settings.model.provider}/${settings.model.id}\u0000${recorded}`, known
|
|
97
|
+
? `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is not supported by ${settings.model.provider}/${settings.model.id} — running at "${running}"`
|
|
98
|
+
: `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is unknown — using the configured default`);
|
|
150
99
|
}
|
|
151
|
-
return { model, thinkingLevel };
|
|
100
|
+
return { model: settings.model, thinkingLevel: settings.thinkingLevel };
|
|
152
101
|
}
|
|
153
102
|
export function resolveHarnessActiveToolNames(recorded, tools, sessionId) {
|
|
154
103
|
const anyDeferred = tools.some(isDeferredTool);
|
|
@@ -170,9 +119,10 @@ export function piHarnessFactory(options) {
|
|
|
170
119
|
return async (sessionId) => {
|
|
171
120
|
const session = await options.sessions.openOrCreate(sessionId);
|
|
172
121
|
// One extra entry walk per invoke to collect the activation deltas — negligible against the model
|
|
173
|
-
// call, same trade as L2's per-invoke definition re-read.
|
|
174
|
-
//
|
|
175
|
-
|
|
122
|
+
// call, same trade as L2's per-invoke definition re-read. The walk is over the ACTIVE PATH, not
|
|
123
|
+
// the flat journal: `navigate` moves the leaf, so the tree can hold an abandoned branch whose
|
|
124
|
+
// activations and overrides this session has left behind.
|
|
125
|
+
const entries = await activePathEntries(session);
|
|
176
126
|
const activated = entries.flatMap((e) => e.type === "custom" && e.customType === TOOL_ACTIVATION_ENTRY
|
|
177
127
|
? (e.data?.names ?? [])
|
|
178
128
|
: []);
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -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
3
|
import { 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). */
|
|
@@ -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)
|
|
@@ -195,7 +207,12 @@ export function createPiSessionControl(options) {
|
|
|
195
207
|
followUp: true,
|
|
196
208
|
manualCompaction: !!b,
|
|
197
209
|
modelSelection: b ? { allowedModels: listModels(b.models) } : false,
|
|
198
|
-
|
|
210
|
+
// Servable or not. WHICH levels is a property of the session's model, so it rides
|
|
211
|
+
// `state().availableThinkingLevels` — a list here could only answer for one model.
|
|
212
|
+
thinkingLevel: !!b,
|
|
213
|
+
// Gated on the boundary wiring for its LEASE, not its models: moving the leaf is a write,
|
|
214
|
+
// and a write that races a run would hang the next turn off a stale branch.
|
|
215
|
+
navigate: !!b,
|
|
199
216
|
toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
|
|
200
217
|
usage: false,
|
|
201
218
|
};
|
|
@@ -204,24 +221,33 @@ export function createPiSessionControl(options) {
|
|
|
204
221
|
const run = active.get(session);
|
|
205
222
|
const opened = await sessions.openIfExists(session);
|
|
206
223
|
const leafEntryId = opened ? ((await opened.getLeafId()) ?? undefined) : undefined;
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
// What will RUN, not the raw record: a client steering a session needs the pair that executes.
|
|
225
|
+
// Without a boundary there is no model to resolve against, and the fields are absent.
|
|
226
|
+
// OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
|
|
227
|
+
// control-less deployment answers with) rather than rejecting a read that has no error-code
|
|
228
|
+
// channel to explain itself. The fault is not swallowed — it surfaces where codes exist: the
|
|
229
|
+
// next invoke fails (the harness build walks the same chain) and a boundary dispatch answers
|
|
230
|
+
// `boundary_command_failed`. Here it is a server-side warn.
|
|
231
|
+
const b = boundary?.();
|
|
232
|
+
let settings;
|
|
233
|
+
if (opened && b) {
|
|
234
|
+
try {
|
|
235
|
+
settings = resolveSessionSettings((await activePathEntries(opened)), b.models, b.defaults);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
|
|
239
|
+
}
|
|
219
240
|
}
|
|
220
241
|
return {
|
|
221
242
|
status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
|
|
222
243
|
...(run ? { activeRunId: run.runId } : {}),
|
|
223
|
-
...(
|
|
224
|
-
|
|
244
|
+
...(settings
|
|
245
|
+
? {
|
|
246
|
+
model: `${settings.model.provider}/${settings.model.id}`,
|
|
247
|
+
thinkingLevel: settings.thinkingLevel,
|
|
248
|
+
availableThinkingLevels: settings.availableThinkingLevels,
|
|
249
|
+
}
|
|
250
|
+
: {}),
|
|
225
251
|
pending: run ? { ...run.pending } : { steering: 0, followUp: 0 },
|
|
226
252
|
...(leafEntryId ? { leafEntryId } : {}),
|
|
227
253
|
};
|
|
@@ -230,8 +256,12 @@ export function createPiSessionControl(options) {
|
|
|
230
256
|
const opened = await sessions.openIfExists(session);
|
|
231
257
|
if (!opened)
|
|
232
258
|
return { entries: [] };
|
|
233
|
-
|
|
259
|
+
// LEAF FIRST, then the journal: `getEntries()` hands back a SNAPSHOT, so reading it first
|
|
260
|
+
// would race any concurrent append into a leaf the snapshot cannot contain — a live turn
|
|
261
|
+
// reading as a dangling head. This order makes the journal a superset of the leaf's chain,
|
|
262
|
+
// which is what lets the published head be trusted as one of the published entries.
|
|
234
263
|
const leafEntryId = (await opened.getLeafId()) ?? undefined;
|
|
264
|
+
const all = (await opened.getEntries()).filter(isNavigable).map(toSessionEntry);
|
|
235
265
|
let entries = all;
|
|
236
266
|
if (opts?.since !== undefined) {
|
|
237
267
|
const idx = all.findIndex((e) => e.id === opts.since);
|
|
@@ -361,7 +391,8 @@ export function createPiSessionControl(options) {
|
|
|
361
391
|
}
|
|
362
392
|
case "compact":
|
|
363
393
|
case "set_model":
|
|
364
|
-
case "set_thinking":
|
|
394
|
+
case "set_thinking":
|
|
395
|
+
case "navigate": {
|
|
365
396
|
const b = boundary?.();
|
|
366
397
|
if (!b) {
|
|
367
398
|
// No boundary wiring: rejected before acceptance; a capability-gating client never
|
|
@@ -376,7 +407,8 @@ export function createPiSessionControl(options) {
|
|
|
376
407
|
};
|
|
377
408
|
}
|
|
378
409
|
// Payload validation BEFORE the lease — an invalid value must not briefly block a run.
|
|
379
|
-
/** The
|
|
410
|
+
/** The durable write for set_model/set_thinking/navigate — undefined for compact (harness
|
|
411
|
+
* path). Answers the event to emit. */
|
|
380
412
|
let apply;
|
|
381
413
|
if (command.type === "set_model") {
|
|
382
414
|
const slash = command.model.indexOf("/");
|
|
@@ -393,18 +425,26 @@ export function createPiSessionControl(options) {
|
|
|
393
425
|
}
|
|
394
426
|
apply = async (s) => {
|
|
395
427
|
await s.appendModelChange(model.provider, model.id);
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
428
|
+
// Both halves: a new model can change which level executes. Nothing is re-recorded to
|
|
429
|
+
// make that true — the resolve reports it, so the preference survives a round trip.
|
|
430
|
+
const settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
|
|
431
|
+
return {
|
|
432
|
+
type: "state_changed",
|
|
433
|
+
timestamp: Date.now(),
|
|
434
|
+
// The CANONICAL spec, same string the durable entry and state() report — the event
|
|
435
|
+
// must not echo a client alias the other two surfaces would disagree with.
|
|
436
|
+
data: { model: `${model.provider}/${model.id}`, thinkingLevel: settings.thinkingLevel },
|
|
437
|
+
};
|
|
399
438
|
};
|
|
400
439
|
}
|
|
401
440
|
else if (command.type === "set_thinking") {
|
|
441
|
+
// A payload that is not a level at all — invalid before any session question.
|
|
402
442
|
if (!THINKING_LEVELS.has(command.level)) {
|
|
403
443
|
return {
|
|
404
444
|
ok: false,
|
|
405
445
|
error: {
|
|
406
446
|
code: INVALID_COMMAND_CODE,
|
|
407
|
-
message: `unknown thinking level "${command.level}" —
|
|
447
|
+
message: `unknown thinking level "${command.level}" — state().availableThinkingLevels lists what this session accepts`,
|
|
408
448
|
retryable: false,
|
|
409
449
|
},
|
|
410
450
|
};
|
|
@@ -414,10 +454,54 @@ export function createPiSessionControl(options) {
|
|
|
414
454
|
return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
|
|
415
455
|
};
|
|
416
456
|
}
|
|
457
|
+
else if (command.type === "navigate") {
|
|
458
|
+
apply = async (s) => {
|
|
459
|
+
// A move to where the leaf already is writes nothing: pi journals a move as a `leaf`
|
|
460
|
+
// record, so an idempotent re-dispatch (a client retry, a UI firing on every
|
|
461
|
+
// selection) would otherwise grow the session by a record no plane publishes. The
|
|
462
|
+
// EVENT is emitted either way — it reports the resulting position, not the fact that
|
|
463
|
+
// a record was written, and a client that dispatched must not have to poll for it.
|
|
464
|
+
if ((await s.getLeafId()) !== command.targetId)
|
|
465
|
+
await s.moveTo(command.targetId);
|
|
466
|
+
// moveTo's postcondition IS "targetId is the leaf" (it validates, then sets); a
|
|
467
|
+
// failure throws and travels as boundary_command_failed, so a read-back could only
|
|
468
|
+
// re-report what this line already knows. The SETTINGS ride along because a move can
|
|
469
|
+
// change them — an override recorded on the branch just left stops applying, and a
|
|
470
|
+
// client tracking model/level from the event stream would otherwise show what the
|
|
471
|
+
// next turn will not use.
|
|
472
|
+
// The move is already durable here, so a settings read that throws (the new path is
|
|
473
|
+
// above a gap) must NOT turn into "nothing took effect": report the position without
|
|
474
|
+
// the settings and let `state()`'s rejection be where the broken chain surfaces.
|
|
475
|
+
let settings;
|
|
476
|
+
try {
|
|
477
|
+
settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
// Absent rather than stale: the session cannot RUN with an unreadable chain either
|
|
481
|
+
// (the harness build walks the same path), so the next invoke fails visibly — this
|
|
482
|
+
// event does not need to carry a second signal for it.
|
|
483
|
+
log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
type: "state_changed",
|
|
487
|
+
timestamp: Date.now(),
|
|
488
|
+
data: {
|
|
489
|
+
leafEntryId: command.targetId,
|
|
490
|
+
...(settings
|
|
491
|
+
? {
|
|
492
|
+
model: `${settings.model.provider}/${settings.model.id}`,
|
|
493
|
+
thinkingLevel: settings.thinkingLevel,
|
|
494
|
+
}
|
|
495
|
+
: {}),
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
};
|
|
499
|
+
}
|
|
417
500
|
// Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
|
|
418
501
|
// not minted into a ghost record. (Existence check before the lease — read-only; the
|
|
419
502
|
// WRITE handle is re-opened under the lease below, this one is discarded.)
|
|
420
|
-
|
|
503
|
+
const existing = await sessions.openIfExists(session);
|
|
504
|
+
if (!existing) {
|
|
421
505
|
return {
|
|
422
506
|
ok: false,
|
|
423
507
|
error: {
|
|
@@ -427,6 +511,51 @@ export function createPiSessionControl(options) {
|
|
|
427
511
|
},
|
|
428
512
|
};
|
|
429
513
|
}
|
|
514
|
+
if (command.type === "navigate") {
|
|
515
|
+
// A target that cannot BE a leaf is a permanent payload error, not a session error — the
|
|
516
|
+
// same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
|
|
517
|
+
// "everything published is navigable" holds by construction rather than by two literals
|
|
518
|
+
// agreeing.
|
|
519
|
+
const entry = await existing.getEntry(command.targetId);
|
|
520
|
+
if (!entry || !isNavigable(entry)) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
error: {
|
|
524
|
+
code: INVALID_COMMAND_CODE,
|
|
525
|
+
message: entry
|
|
526
|
+
? `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`
|
|
527
|
+
: `entry "${command.targetId}" does not exist in session "${session}" — entries() lists the navigable ids`,
|
|
528
|
+
retryable: false,
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (command.type === "set_thinking") {
|
|
534
|
+
// The same set `state()` showed the client. Reject here rather than record a level the
|
|
535
|
+
// run would not use. The read is guarded because `dispatch` must never REJECT — the
|
|
536
|
+
// transport promises a SessionResult, so an unreadable chain has to arrive as a code.
|
|
537
|
+
let resolved;
|
|
538
|
+
try {
|
|
539
|
+
resolved = resolveSessionSettings((await activePathEntries(existing)), b.models, b.defaults);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const { model, availableThinkingLevels } = resolved;
|
|
548
|
+
if (!availableThinkingLevels.includes(command.level)) {
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
error: {
|
|
552
|
+
code: INVALID_COMMAND_CODE,
|
|
553
|
+
message: `thinking level "${command.level}" is not supported by ${model.provider}/${model.id} (allowed: ${availableThinkingLevels.join(", ")})`,
|
|
554
|
+
retryable: false,
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
}
|
|
430
559
|
// Boundary mutations are the control plane's only writers: same lease as every run — a
|
|
431
560
|
// mutation must never race one (design §9).
|
|
432
561
|
const release = b.lease.tryAcquire(session);
|
|
@@ -567,8 +696,8 @@ export function createPiSessionControl(options) {
|
|
|
567
696
|
},
|
|
568
697
|
};
|
|
569
698
|
}
|
|
570
|
-
// Unreachable by construction: only set_model/set_thinking reach this branch,
|
|
571
|
-
//
|
|
699
|
+
// Unreachable by construction: only set_model/set_thinking/navigate reach this branch,
|
|
700
|
+
// and all three assign `apply` in validation. Throw rather than silently skip (fail visibly).
|
|
572
701
|
if (!apply)
|
|
573
702
|
throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
|
|
574
703
|
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;
|