@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.
@@ -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`), which append override
11
- * records to the returned handle after an existence check, under the run lease.
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 only because
14
- * override records are not messages they cannot create or interact with a dangling tool_use
15
- * pair. Writing MESSAGE-class records through this handle would bypass that repair: use
16
- * `openOrCreate` for anything that enters the transcript.
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();
package/dist/session.d.ts CHANGED
@@ -21,12 +21,17 @@ export interface SessionControl {
21
21
  dispatch(session: string, command: SessionCommand): Promise<SessionResult>;
22
22
  }
23
23
  /**
24
- * Static support declaration, two kinds of flag:
25
- * - COMMAND GATES (`steering`, `followUp`, `manualCompaction`, `modelSelection`, `thinkingLevel`):
24
+ * STATIC support declaration — sessionless, so nothing here may depend on a session. Two kinds of flag:
25
+ * - COMMAND GATES (`steering`, `followUp`, `manualCompaction`, `modelSelection`, `thinkingLevel`,
26
+ * `navigate`):
26
27
  * clients MUST gate dispatch on them; an unsupported command is rejected before acceptance with
27
28
  * {@link UNSUPPORTED_CAPABILITY_CODE}.
28
29
  * - OBSERVATION-QUALITY flags (`toolProgress`, `usage`): whether those events/state fields appear
29
30
  * at all — nothing to dispatch, nothing to reject.
31
+ *
32
+ * `modelSelection` may carry a list because the registry is a deployment fact; thinking levels
33
+ * depend on the session's current model, so they live on {@link SessionState.availableThinkingLevels}.
34
+ *
30
35
  * `state`/`entries`/`events` are mandatory (the reconnect contract) and deliberately absent here.
31
36
  */
32
37
  export interface SessionCapabilities {
@@ -36,9 +41,15 @@ export interface SessionCapabilities {
36
41
  modelSelection: false | {
37
42
  allowedModels: string[];
38
43
  };
39
- thinkingLevel: false | {
40
- allowedLevels: string[];
41
- };
44
+ /** Whether `set_thinking` is servable at all. WHICH levels is per-session — see
45
+ * {@link SessionState.availableThinkingLevels}. */
46
+ thinkingLevel: boolean;
47
+ /** Whether `navigate` is servable at all — `false` both when the engine's sessions are linear and
48
+ * when this deployment has no write path for them; either way the tree the contract publishes
49
+ * (`SessionEntry.parentId` + `SessionEntries.leafEntryId`) is read-only here. Named after its COMMAND, unlike the older gates (`manualCompaction` gates
50
+ * `compact`, `thinkingLevel` gates `set_thinking`): those already force a client to translate,
51
+ * and a gate keyed by the command literal is the only naming a derived map could ever produce. */
52
+ navigate: boolean;
42
53
  toolProgress: boolean;
43
54
  usage: boolean;
44
55
  }
@@ -79,7 +90,7 @@ export declare const NOTHING_TO_COMPACT_CODE = "nothing_to_compact";
79
90
  * command as-is fails again. (A run registered without modulation controls is a capability
80
91
  * problem, not a run problem, and rejects with {@link UNSUPPORTED_CAPABILITY_CODE}.) */
81
92
  export declare const RUN_COMMAND_FAILED_CODE = "run_command_failed";
82
- /** Six commands; deliberately NO `prompt` — starting work is the data plane's definition. */
93
+ /** Seven commands; deliberately NO `prompt` — starting work is the data plane's definition. */
83
94
  export type SessionCommand = {
84
95
  type: "steer";
85
96
  prompt: Prompt;
@@ -97,6 +108,14 @@ export type SessionCommand = {
97
108
  } | {
98
109
  type: "set_thinking";
99
110
  level: string;
111
+ }
112
+ /** Move the session's active leaf to `targetId`, an existing entry — the write verb for the tree
113
+ * `entries()` already publishes (and how sibling branches come to exist: the next turn hangs off
114
+ * the new leaf). Every entry `entries()` publishes is a legal target; a `targetId` that is not
115
+ * one rejects `invalid_command`. A boundary mutation otherwise: same lease as a run. */
116
+ | {
117
+ type: "navigate";
118
+ targetId: string;
100
119
  };
101
120
  /**
102
121
  * Acceptance is not outcome: `ok: true` means admitted or applied, never that the run ultimately
@@ -125,10 +144,13 @@ export interface SessionState {
125
144
  * compaction happens inside a run's activity window and reports as `running`. */
126
145
  status: "idle" | "running" | "compacting";
127
146
  activeRunId?: string;
128
- /** The session's durable overrides (set_model / set_thinking), read from the record so a
129
- * reconnecting client sees them without scanning entries. Absent = the assembly default. */
147
+ /** What this session will RUN with, not what was recorded: overrides resolved against the
148
+ * deployment (a model the registry lost falls back to the configured one; a level the current
149
+ * model cannot do is clamped). Absent where the implementation exposes no model control. */
130
150
  model?: string;
131
151
  thinkingLevel?: string;
152
+ /** What `set_thinking` accepts for THIS session — re-read after a `set_model`. */
153
+ availableThinkingLevels?: string[];
132
154
  pending: {
133
155
  steering: number;
134
156
  followUp: number;
@@ -222,10 +244,13 @@ export type QueueChangedEvent = SessionEvent<"queue_changed", {
222
244
  runId: string;
223
245
  };
224
246
  /** A boundary mutation changed durable session state (L2; no runId — boundary mutations happen
225
- * between runs). */
247
+ * between runs). `leafEntryId` reports a `navigate` — a deliberate move of the branch head, which
248
+ * a second attached client would otherwise have no signal for. It is NOT a general leaf feed:
249
+ * every turn advances the leaf too, and that is read from `entries()`/`state()` after the run. */
226
250
  export type StateChangedEvent = SessionEvent<"state_changed", {
227
251
  model?: string;
228
252
  thinkingLevel?: string;
253
+ leafEntryId?: string;
229
254
  }>;
230
255
  /** Manual compaction bounds (L2): every `compaction_started` is closed by exactly one
231
256
  * `compaction_finished` — `summary` on success, `error` on failure, `aborted: true` on a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on GitHub, Telegram, Slack, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",