@fastagent-sh/fastagent 0.16.0 → 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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-state.js +21 -6
  5. package/dist/channels/control.js +3 -0
  6. package/dist/cli/commands/attach.d.ts +11 -1
  7. package/dist/cli/commands/attach.js +30 -2
  8. package/dist/cli/commands/deploy.d.ts +13 -0
  9. package/dist/cli/commands/deploy.js +18 -6
  10. package/dist/cli/commands/dev.d.ts +1 -0
  11. package/dist/cli/commands/dev.js +15 -4
  12. package/dist/cli/commands/info.js +1 -1
  13. package/dist/cli/commands/logs.d.ts +6 -0
  14. package/dist/cli/commands/logs.js +27 -0
  15. package/dist/cli/commands/start.d.ts +1 -0
  16. package/dist/cli/commands/start.js +11 -3
  17. package/dist/cli/commands/tool.js +10 -2
  18. package/dist/cli/program.js +35 -0
  19. package/dist/cli/serve.d.ts +26 -2
  20. package/dist/cli/serve.js +71 -17
  21. package/dist/cli/shared.d.ts +6 -0
  22. package/dist/cli/shared.js +14 -0
  23. package/dist/deploy/agentcore/logs.d.ts +30 -0
  24. package/dist/deploy/agentcore/logs.js +115 -0
  25. package/dist/deploy/agentcore/plan.d.ts +23 -0
  26. package/dist/deploy/agentcore/plan.js +41 -3
  27. package/dist/deploy/container.js +6 -3
  28. package/dist/deploy/preflight.js +18 -0
  29. package/dist/engines/pi/config.d.ts +7 -3
  30. package/dist/engines/pi/config.js +9 -3
  31. package/dist/engines/pi/create.d.ts +31 -18
  32. package/dist/engines/pi/create.js +46 -17
  33. package/dist/engines/pi/harness.d.ts +31 -33
  34. package/dist/engines/pi/harness.js +24 -76
  35. package/dist/engines/pi/open.d.ts +2 -2
  36. package/dist/engines/pi/open.js +2 -1
  37. package/dist/engines/pi/read-image.d.ts +4 -0
  38. package/dist/engines/pi/read-image.js +62 -0
  39. package/dist/engines/pi/search-tools.d.ts +6 -4
  40. package/dist/engines/pi/search-tools.js +3 -1
  41. package/dist/engines/pi/session-builder.js +7 -2
  42. package/dist/engines/pi/session-control.d.ts +12 -3
  43. package/dist/engines/pi/session-control.js +156 -27
  44. package/dist/engines/pi/session-settings.d.ts +51 -0
  45. package/dist/engines/pi/session-settings.js +73 -0
  46. package/dist/engines/pi/sessions.d.ts +21 -7
  47. package/dist/engines/pi/sessions.js +43 -0
  48. package/dist/engines/pi/tool.d.ts +13 -5
  49. package/dist/engines/pi/wake-tool.d.ts +3 -3
  50. package/dist/host/node.d.ts +2 -0
  51. package/dist/host/node.js +2 -1
  52. package/dist/pi.d.ts +1 -1
  53. package/dist/session.d.ts +34 -9
  54. package/package.json +4 -4
@@ -8,7 +8,9 @@
8
8
  import { z } from "zod";
9
9
  import { log } from "../../log.js";
10
10
  import { defineTool, isDeferredTool, stripDeferredMarker } from "./tool.js";
11
- /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
11
+ /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own.
12
+ * Typed on the MOUNTED tool, not the authored one: it inspects names and deferral and never executes,
13
+ * so narrowing here would reject the very set it is handed (pi's defaults take the turn's context). */
12
14
  export function withSearchTool(tools) {
13
15
  if (!tools.some(isDeferredTool))
14
16
  return tools;
@@ -120,7 +120,7 @@ sessionManager) {
120
120
  const env = new NodeExecutionEnv({ cwd });
121
121
  const definition = await loadAgentDefinition(agentDir, { cwd, env });
122
122
  reportDefinitionWarnings(definition.collisions, definition.diagnostics);
123
- const defaultNames = piDefaultTools(cwd).map((t) => t.name);
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
126
126
  // it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
@@ -141,7 +141,12 @@ sessionManager) {
141
141
  // session-lifecycle invariant as a normal out-of-turn call (fail visibly).
142
142
  if (!bound)
143
143
  throw new Error("tool executed before its session was built (lifecycle invariant broken)");
144
- return turnContext.run({ cwd, sessionManager: bound.sessionManager, tools: bound.activation }, () => t.execute(id, params, signal));
144
+ return turnContext.run({ cwd, sessionManager: bound.sessionManager, tools: bound.activation },
145
+ // pi's per-turn TOOL context (5th parameter) is read only by its default coding tools, and
146
+ // those are filtered out of `customTools` above; fastagent's own take theirs from
147
+ // `turnContext` (AsyncLocalStorage). So this env exists to satisfy the shape, and it is the
148
+ // chat cwd's — the same root a default tool would have got, had one reached here.
149
+ () => t.execute(id, params, signal, undefined, { env }));
145
150
  },
146
151
  }));
147
152
  // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
@@ -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 { PiSessionReader } from "./sessions.ts";
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, THINKING_LEVELS, harnessSession, lastOverrideEntries, } from "./harness.js";
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
- thinkingLevel: b ? { allowedLevels: [...THINKING_LEVELS] } : false,
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
- // The durable overrides (set_model / set_thinking), via the SAME walk the harness resolve
208
- // uses (lastOverrideEntries) one physical implementation, so the reporting surface and the
209
- // execution surface can never disagree on which record is "the" override. Reported as
210
- // recorded, even if the current registry lacks the model (state reports session truth; the
211
- // harness resolve owns the execution fallback).
212
- let model;
213
- let thinkingLevel;
214
- if (opened) {
215
- const recorded = lastOverrideEntries((await opened.getEntries()));
216
- if (recorded.model)
217
- model = `${recorded.model.provider}/${recorded.model.modelId}`;
218
- thinkingLevel = recorded.thinkingLevel;
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
- ...(model !== undefined ? { model } : {}),
224
- ...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
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
- const all = (await opened.getEntries()).map(toSessionEntry);
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 entry-append for set_model/set_thinking — undefined for compact (harness path). */
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
- // The CANONICAL spec, same string the durable entry and state() report the event
397
- // must not echo a client alias the other two surfaces would disagree with.
398
- return { type: "state_changed", timestamp: Date.now(), data: { model: `${model.provider}/${model.id}` } };
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}" — capabilities().thinkingLevel lists the allowed values`,
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
- if (!(await sessions.openIfExists(session))) {
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, and
571
- // both assign `apply` in validation. Throw rather than silently skip (fail visibly).
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;
@@ -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();
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
1
+ import type { AgentHarnessTool, ExecutionToolContext, AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { z } from "zod";
3
3
  import { type ModuleLoadFailure } from "../../loader.ts";
4
4
  import { type ReadonlySessionManager, type ToolActivation } from "./tool-context.ts";
@@ -34,6 +34,14 @@ export interface DefineToolOptions<I extends z.ZodType> {
34
34
  executionMode?: "sequential" | "parallel";
35
35
  execute: (input: z.infer<I>, ctx: ToolContext) => unknown | Promise<unknown>;
36
36
  }
37
+ /**
38
+ * A tool as MOUNTED: what the harness actually runs. Wider than the authored {@link AgentTool} on
39
+ * purpose — pi's default coding tools read the turn's tool context (its ExecutionEnv) as a fifth
40
+ * `execute` parameter, while fastagent's own tools take four and are assignable to it unchanged.
41
+ * Naming the wider type is what lets `defineTool` stay context-free for authors while both families
42
+ * live in one array; every helper that only inspects or reorders tools is typed on THIS.
43
+ */
44
+ export type MountedTool = AgentHarnessTool<ExecutionToolContext>;
37
45
  /** An AgentTool with fastagent's deferral marker — the type for raw tools handed to fastagent
38
46
  * (`config.tools`, L1/L2 `tools`): plain `AgentTool` has no `deferred`, so an object literal with the
39
47
  * marker would fail excess-property checking against upstream's type. `defineTool` produces it. */
@@ -42,10 +50,10 @@ export type FastagentTool = AgentTool & {
42
50
  };
43
51
  /** Read the {@link DefineToolOptions.deferred} marker off a mounted tool (extra property on the
44
52
  * AgentTool object — pi ignores it). */
45
- export declare function isDeferredTool(tool: AgentTool): boolean;
53
+ export declare function isDeferredTool(tool: MountedTool): boolean;
46
54
  /** The same tool without the deferred marker — for a loader that must stay active (a deferred loader
47
55
  * could never be activated and would strand every deferred tool). */
48
- export declare function stripDeferredMarker(tool: AgentTool): AgentTool;
56
+ export declare function stripDeferredMarker(tool: MountedTool): MountedTool;
49
57
  export declare function defineTool<I extends z.ZodType>(options: DefineToolOptions<I>): FastagentTool;
50
58
  /** A discarded same-name tool (within `tools/`, or against an existing tool). Surfaced, never silent. */
51
59
  export interface ToolCollision {
@@ -68,7 +76,7 @@ export declare function loadTools(dir: string): Promise<{
68
76
  * Merge resolved tools (pi defaults + `config.tools`) with discovered `tools/`, deduped by name.
69
77
  * Existing tools win; dropped discovered tools surface as collisions.
70
78
  */
71
- export declare function mergeDiscoveredTools(existing: AgentTool[], discovered: AgentTool[]): {
72
- tools: AgentTool[];
79
+ export declare function mergeDiscoveredTools(existing: MountedTool[], discovered: AgentTool[]): {
80
+ tools: MountedTool[];
73
81
  collisions: ToolCollision[];
74
82
  };
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
1
+ import { type MountedTool } from "./tool.ts";
2
2
  /**
3
3
  * Parse a delay to milliseconds: a number is SECONDS; a string MUST carry a unit — `"<n><s|m|h|d>"`
4
4
  * ("30m", "2h", "1d"). Undefined for anything else, INCLUDING a bare numeric string like "120": one
@@ -12,6 +12,6 @@ export declare function parseDelayMs(input: string | number): number | undefined
12
12
  * scheduler poller honors a wake-up) and only when the workspace hasn't defined its own `wake` (that
13
13
  * wins, like any tool collision). The single place the mount decision + collision rule run.
14
14
  */
15
- export declare function withWakeTool(tools: AgentTool[], stateRoot: string, enabled: boolean): AgentTool[];
15
+ export declare function withWakeTool(tools: MountedTool[], stateRoot: string, enabled: boolean): MountedTool[];
16
16
  /** Build the `wake` tool bound to `stateRoot` (where wake-ups persist). */
17
- export declare function makeWakeTool(stateRoot: string, now?: () => Date): AgentTool;
17
+ export declare function makeWakeTool(stateRoot: string, now?: () => Date): MountedTool;