@fastagent-sh/fastagent 0.22.1 → 0.24.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.
Files changed (128) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/agentcore-protocol.d.ts +2 -1
  3. package/dist/channels/agentcore-service.js +10 -18
  4. package/dist/channels/agentcore.js +6 -3
  5. package/dist/channels/control.js +23 -0
  6. package/dist/channels/kit/preview-kit.d.ts +6 -1
  7. package/dist/channels/kit/preview-kit.js +21 -4
  8. package/dist/channels/slack/config-api.d.ts +2 -2
  9. package/dist/channels/slack/config-api.js +2 -2
  10. package/dist/channels/slack/onboard.js +2 -2
  11. package/dist/channels/slack/preview.js +2 -1
  12. package/dist/channels/slack/register-webhook.js +2 -2
  13. package/dist/channels/slack/slack-api.js +4 -4
  14. package/dist/cli/add-feishu.js +4 -2
  15. package/dist/cli/auth-view.d.ts +15 -6
  16. package/dist/cli/auth-view.js +24 -8
  17. package/dist/cli/box-login.d.ts +33 -0
  18. package/dist/cli/box-login.js +162 -0
  19. package/dist/cli/commands/deploy/agentcore.js +48 -11
  20. package/dist/cli/commands/deploy/docker.js +24 -13
  21. package/dist/cli/commands/deploy/fly.js +30 -7
  22. package/dist/cli/commands/deploy/railway.js +21 -7
  23. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  24. package/dist/cli/commands/deploy/shared.js +20 -21
  25. package/dist/cli/commands/destroy.js +2 -2
  26. package/dist/cli/commands/info.js +15 -12
  27. package/dist/cli/commands/invoke.js +2 -2
  28. package/dist/cli/commands/login.d.ts +7 -1
  29. package/dist/cli/commands/login.js +123 -10
  30. package/dist/cli/commands/logs.js +2 -2
  31. package/dist/cli/commands/models.d.ts +4 -1
  32. package/dist/cli/commands/models.js +37 -4
  33. package/dist/cli/commands/routine-run.js +2 -2
  34. package/dist/cli/commands/start.d.ts +0 -5
  35. package/dist/cli/commands/start.js +3 -13
  36. package/dist/cli/invoke-stream.js +14 -0
  37. package/dist/cli/kernel.d.ts +2 -0
  38. package/dist/cli/kernel.js +1 -1
  39. package/dist/cli/login-relay.d.ts +38 -0
  40. package/dist/cli/login-relay.js +121 -0
  41. package/dist/cli/program.js +46 -8
  42. package/dist/cli/shared.d.ts +6 -5
  43. package/dist/cli/shared.js +29 -36
  44. package/dist/deploy/agentcore/plan.d.ts +10 -11
  45. package/dist/deploy/agentcore/plan.js +8 -14
  46. package/dist/deploy/agentcore/run.d.ts +8 -1
  47. package/dist/deploy/agentcore/run.js +20 -14
  48. package/dist/deploy/agentcore/shell.d.ts +25 -0
  49. package/dist/deploy/agentcore/shell.js +202 -0
  50. package/dist/deploy/box-shell.d.ts +43 -0
  51. package/dist/deploy/box-shell.js +28 -0
  52. package/dist/deploy/build-context.js +16 -7
  53. package/dist/deploy/channel-ingress.d.ts +20 -1
  54. package/dist/deploy/channel-ingress.js +34 -7
  55. package/dist/deploy/container.d.ts +14 -0
  56. package/dist/deploy/container.js +45 -1
  57. package/dist/deploy/docker/plan.d.ts +7 -3
  58. package/dist/deploy/docker/plan.js +9 -14
  59. package/dist/deploy/docker/run.d.ts +14 -7
  60. package/dist/deploy/docker/run.js +58 -35
  61. package/dist/deploy/fly/plan.d.ts +6 -3
  62. package/dist/deploy/fly/plan.js +6 -8
  63. package/dist/deploy/fly/run.d.ts +8 -2
  64. package/dist/deploy/fly/run.js +15 -25
  65. package/dist/deploy/preflight.d.ts +9 -7
  66. package/dist/deploy/preflight.js +92 -20
  67. package/dist/deploy/railway/plan.d.ts +6 -3
  68. package/dist/deploy/railway/plan.js +7 -9
  69. package/dist/deploy/railway/run.d.ts +10 -6
  70. package/dist/deploy/railway/run.js +29 -13
  71. package/dist/deploy/runner.d.ts +16 -0
  72. package/dist/deploy/runner.js +31 -0
  73. package/dist/deploy/secrets.d.ts +4 -13
  74. package/dist/deploy/secrets.js +8 -27
  75. package/dist/dev-supervisor.js +7 -4
  76. package/dist/engines/pi/agent-models.d.ts +83 -0
  77. package/dist/engines/pi/agent-models.js +120 -0
  78. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  79. package/dist/engines/pi/agent-session-factory.js +132 -159
  80. package/dist/engines/pi/auth.d.ts +56 -3
  81. package/dist/engines/pi/auth.js +68 -109
  82. package/dist/engines/pi/config.d.ts +0 -25
  83. package/dist/engines/pi/config.js +3 -37
  84. package/dist/engines/pi/create.d.ts +34 -21
  85. package/dist/engines/pi/create.js +90 -57
  86. package/dist/engines/pi/entry-images.d.ts +14 -0
  87. package/dist/engines/pi/entry-images.js +23 -0
  88. package/dist/engines/pi/invoke-session.js +88 -20
  89. package/dist/engines/pi/locked-file.d.ts +9 -0
  90. package/dist/engines/pi/locked-file.js +102 -0
  91. package/dist/engines/pi/login.d.ts +27 -14
  92. package/dist/engines/pi/login.js +41 -18
  93. package/dist/engines/pi/machine.d.ts +23 -0
  94. package/dist/engines/pi/machine.js +24 -2
  95. package/dist/engines/pi/models.d.ts +120 -36
  96. package/dist/engines/pi/models.js +316 -66
  97. package/dist/engines/pi/open.d.ts +65 -20
  98. package/dist/engines/pi/open.js +88 -34
  99. package/dist/engines/pi/report.d.ts +3 -0
  100. package/dist/engines/pi/report.js +11 -0
  101. package/dist/engines/pi/service.d.ts +2 -2
  102. package/dist/engines/pi/service.js +1 -0
  103. package/dist/engines/pi/session-builder.d.ts +3 -2
  104. package/dist/engines/pi/session-builder.js +30 -76
  105. package/dist/engines/pi/session-control.d.ts +4 -4
  106. package/dist/engines/pi/session-control.js +86 -43
  107. package/dist/engines/pi/session-settings.d.ts +9 -0
  108. package/dist/engines/pi/session-settings.js +17 -0
  109. package/dist/engines/pi/session-store.d.ts +2 -2
  110. package/dist/engines/pi/tool-context.d.ts +3 -5
  111. package/dist/engines/pi/tool-context.js +4 -16
  112. package/dist/engines/pi/tool.d.ts +32 -21
  113. package/dist/engines/pi/tool.js +21 -25
  114. package/dist/engines/pi/turn-kit.d.ts +18 -9
  115. package/dist/engines/pi/turn-kit.js +115 -32
  116. package/dist/open-url.d.ts +15 -1
  117. package/dist/open-url.js +35 -3
  118. package/dist/paths.d.ts +4 -2
  119. package/dist/paths.js +6 -2
  120. package/dist/pi.d.ts +5 -3
  121. package/dist/pi.js +3 -2
  122. package/dist/service.d.ts +5 -8
  123. package/dist/session-remote.js +21 -0
  124. package/dist/session.d.ts +88 -15
  125. package/dist/session.js +5 -1
  126. package/package.json +6 -5
  127. package/dist/engines/pi/search-tools.d.ts +0 -4
  128. package/dist/engines/pi/search-tools.js +0 -109
@@ -10,11 +10,12 @@ import { calculateContextTokens, estimateTokens, getLastAssistantUsage, } from "
10
10
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
11
11
  import { SESSION_BUSY_CODE } from "../../agent.js";
12
12
  import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
13
- import { listModels } from "./config.js";
14
- import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
13
+ import { forkProvenance, isConversationMessage, isNavigable, publishedLeaf } from "./session-markers.js";
14
+ import { entryImages, imageAt } from "./entry-images.js";
15
+ import { answerOutcome } from "./turn-kit.js";
15
16
  import { startCompaction } from "./agent-session-factory.js";
16
17
  import { toRetryScheduledEvent } from "./retry-event.js";
17
- import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
18
+ import { THINKING_LEVELS, activePath, describeModels, resolveSessionSettings } from "./session-settings.js";
18
19
  import { log } from "../../log.js";
19
20
  // ── Usage (read from the record) ─────────────────────────────────────────────
20
21
  /**
@@ -79,15 +80,21 @@ function toSessionEntry(entry, parentId) {
79
80
  };
80
81
  if (entry.type === "message") {
81
82
  const m = entry.message;
82
- if (m.role === "user")
83
- return { ...base, kind: "user", data: { text: contentText(m.content, "") } };
83
+ if (m.role === "user") {
84
+ return { ...base, kind: "user", data: { text: contentText(m.content, ""), ...entryImages(entry.id, m) } };
85
+ }
84
86
  if (m.role === "assistant") {
87
+ // `args` is what `tool_started` carries live (pi emits the call's recorded `arguments` there), so a reopened
88
+ // conversation shows the same call a watcher saw.
85
89
  const toolCalls = m.content
86
90
  .filter((b) => b.type === "toolCall")
87
- .map((b) => ({ id: b.id ?? "", name: b.name ?? "" }));
91
+ .map((b) => ({ id: b.id ?? "", name: b.name ?? "", args: b.arguments ?? {} }));
88
92
  const data = { text: contentText(m.content, "") };
89
93
  if (toolCalls.length > 0)
90
94
  data.toolCalls = toolCalls;
95
+ const outcome = answerOutcome(m);
96
+ if (outcome)
97
+ data.outcome = outcome;
91
98
  return { ...base, kind: "assistant", data };
92
99
  }
93
100
  if (m.role === "toolResult") {
@@ -99,6 +106,7 @@ function toSessionEntry(entry, parentId) {
99
106
  toolName: m.toolName,
100
107
  isError: m.isError ?? false,
101
108
  text: contentText(m.content, ""),
109
+ ...entryImages(entry.id, m),
102
110
  },
103
111
  };
104
112
  }
@@ -262,10 +270,10 @@ export function createPiSessionControl(options) {
262
270
  // The CONTRACT's list, not a copy of it: a field added to SessionUpdate is advertised
263
271
  // without anyone remembering to, and one removed cannot linger here.
264
272
  updatable: b ? [...UPDATE_FIELDS] : [],
265
- // The registry is a deployment fact (any session may be pointed at any of it). Thinking
266
- // LEVELS are a property of the model a session is running, so they ride
267
- // `state().availableThinkingLevels` — a list here could only answer for one model.
268
- ...(b ? { allowedModels: listModels(b.models) } : {}),
273
+ // The registry is a deployment fact (any session may be pointed at any of it). Each model carries the levels
274
+ // a session on it accepts, so a picker can offer them before the session runs on that model;
275
+ // `state().availableThinkingLevels` answers for the model the session is on.
276
+ ...(b ? { allowedModels: describeModels(b.models.getModels()) } : {}),
269
277
  toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
270
278
  usage: true, // state().usage, and state_changed{usage} after each run and compaction
271
279
  };
@@ -284,12 +292,27 @@ export function createPiSessionControl(options) {
284
292
  const b = boundary;
285
293
  let settings;
286
294
  let usage;
287
- if (opened) {
295
+ if (!opened) {
296
+ // An id with no record is an empty conversation at the defaults: its first turn would run on these.
297
+ if (b)
298
+ settings = resolveSessionSettings([], b.models, b.defaults);
299
+ }
300
+ else {
288
301
  try {
289
302
  const path = activePath(opened);
290
303
  if (b)
291
304
  settings = resolveSessionSettings(path, b.models, b.defaults);
292
- usage = sessionUsage(path, opened, settings?.model.contextWindow);
305
+ const selected = settings?.model;
306
+ const latest = selected?.api === "pi-virtual"
307
+ ? opened
308
+ .buildSessionContext()
309
+ .messages.filter((message) => message.role === "assistant" &&
310
+ message.stopReason !== "error" &&
311
+ message.stopReason !== "aborted")
312
+ .at(-1)
313
+ : undefined;
314
+ const physical = latest?.role === "assistant" ? b?.models.getModel(latest.provider, latest.model) : undefined;
315
+ usage = sessionUsage(path, opened, (physical ?? selected)?.contextWindow);
293
316
  }
294
317
  catch (error) {
295
318
  log.warn(`[fastagent] session ${session}: state unreadable (entry chain): ${String(error)}`);
@@ -354,6 +377,11 @@ export function createPiSessionControl(options) {
354
377
  }
355
378
  return { entries, ...(leafEntryId ? { leafEntryId } : {}) };
356
379
  },
380
+ async image(session, ref) {
381
+ // ponytail: opens (parses) the whole record per image; an index when long image-heavy sessions make it show.
382
+ const opened = await sessions.openIfExists(session);
383
+ return opened ? imageAt(opened, ref) : undefined;
384
+ },
357
385
  events(session) {
358
386
  // ONE CALL IS ONE SUBSCRIPTION, and `ready` is its boundary: the moment after which nothing emitted for this
359
387
  // session can be missed (session.ts, SessionEventStream). A second iteration of the same stream would be a
@@ -447,6 +475,21 @@ export function createPiSessionControl(options) {
447
475
  ok: false,
448
476
  error: { code: NO_SUCH_SESSION_CODE, message: `session "${session}" does not exist`, retryable: false },
449
477
  });
478
+ /**
479
+ * The refusal for an id no client could then open: the empty string, `.` and `..` are not URL path segments
480
+ * (isAddressableSession), so minting one would put a row in list() that nothing can address — listed, unopenable by
481
+ * the client that just listed it. Both writes that create a record (fork's `into`, and an update to an id with no
482
+ * record) refuse it.
483
+ */
484
+ const unaddressable = (session) => invalid(`${JSON.stringify(session)} cannot be a session id — the control plane could not address it`);
485
+ const nothingToCompact = () => ({
486
+ ok: false,
487
+ error: {
488
+ code: NOTHING_TO_COMPACT_CODE,
489
+ message: "nothing to compact — the session has no compactable history yet; retry after more turns",
490
+ retryable: false,
491
+ },
492
+ });
450
493
  const invalid = (message) => ({
451
494
  ok: false,
452
495
  error: { code: INVALID_COMMAND_CODE, message, retryable: false },
@@ -508,12 +551,12 @@ export function createPiSessionControl(options) {
508
551
  };
509
552
  }
510
553
  const controls = run.controls;
511
- yield* port(() => action.type === "steer"
512
- ? controls.steer(action.prompt)
513
- : action.type === "follow_up"
514
- ? controls.followUp(action.prompt)
515
- : controls.abort());
516
- return { ok: true, runId: run.runId };
554
+ if (action.type === "abort") {
555
+ yield* port(() => controls.abort());
556
+ return { ok: true, runId: run.runId };
557
+ }
558
+ const disposition = yield* port(() => action.type === "steer" ? controls.steer(action.prompt) : controls.followUp(action.prompt));
559
+ return { ok: true, runId: run.runId, disposition };
517
560
  }).pipe(Effect.catchCause((cause) => Effect.succeed({
518
561
  ok: false,
519
562
  error: { code: RUN_COMMAND_FAILED_CODE, message: String(portError(cause)), retryable: false },
@@ -547,7 +590,7 @@ export function createPiSessionControl(options) {
547
590
  const slash = patch.model.indexOf("/");
548
591
  model = slash > 0 ? b.models.getModel(patch.model.slice(0, slash), patch.model.slice(slash + 1)) : undefined;
549
592
  if (!model) {
550
- return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted specs`);
593
+ return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted models`);
551
594
  }
552
595
  }
553
596
  if (patch.thinkingLevel !== undefined && !THINKING_LEVELS.has(patch.thinkingLevel)) {
@@ -556,18 +599,18 @@ export function createPiSessionControl(options) {
556
599
  // A name is the client's own label; the only thing that cannot be one is nothing.
557
600
  if (patch.name !== undefined && patch.name.trim() === "")
558
601
  return invalid("a session name cannot be empty");
559
- // Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
560
- // rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
561
- // the lease below, and this one is discarded.)
602
+ // An id with no record takes properties too: its first turn is the "next turn" they apply to, so the write
603
+ // creates its record (below, under the lease). (Read-only handle — the WRITE one is opened under the lease,
604
+ // and this one is discarded.)
562
605
  const existing = yield* port(() => sessions.openIfExists(session));
563
- if (!existing)
564
- return noSuchSession(session);
606
+ if (!existing && !isAddressableSession(session))
607
+ return unaddressable(session);
565
608
  if (patch.leafEntryId !== undefined) {
566
609
  // A target that cannot BE a leaf is a permanent payload error, not a session error — the same
567
610
  // disposition as an unknown model spec. Same predicate `entries()` publishes by, so
568
611
  // "everything published is a position" holds by construction rather than by two literals
569
612
  // agreeing.
570
- const entry = existing.getEntry(patch.leafEntryId);
613
+ const entry = existing?.getEntry(patch.leafEntryId);
571
614
  if (!entry || !isNavigable(entry)) {
572
615
  return invalid(entry
573
616
  ? `entry "${patch.leafEntryId}" is not a position — entries() publishes every id you can move to, and this is not one of them`
@@ -583,7 +626,8 @@ export function createPiSessionControl(options) {
583
626
  // Against the path this patch LANDS on: a leaf move is written first, and the branch it
584
627
  // moves to can carry a model override of its own — validating on the path being left would
585
628
  // reject a level the destination supports, and accept one it does not.
586
- resolved = resolveSessionSettings(activePath(existing, patch.leafEntryId), b.models, b.defaults);
629
+ const path = existing ? activePath(existing, patch.leafEntryId) : [];
630
+ resolved = resolveSessionSettings(path, b.models, b.defaults);
587
631
  }
588
632
  catch (error) {
589
633
  return failed(error);
@@ -601,6 +645,10 @@ export function createPiSessionControl(options) {
601
645
  yield* acquireSessionLease(b.lease, session);
602
646
  // portJoin, not port: the lease is held for the duration of this write, so an interrupted
603
647
  // fiber must not release it while the store is still writing (effect-port.ts, rule 1).
648
+ // A never-run session's record is created through the data plane's own path, under the same lease an
649
+ // invoke creating it takes; one that invoke created meanwhile is simply opened.
650
+ if (!existing)
651
+ yield* portJoin(() => sessions.openOrCreate(session));
604
652
  return yield* portJoin(() => sessions.applyProperties(session, {
605
653
  ...(patch.name !== undefined ? { name: patch.name } : {}),
606
654
  ...(model ? { model: { provider: model.provider, id: model.id } } : {}),
@@ -609,7 +657,7 @@ export function createPiSessionControl(options) {
609
657
  }));
610
658
  }));
611
659
  if (!applied)
612
- return noSuchSession(session); // vanished in the window: same condition, same code
660
+ return noSuchSession(session); // deleted in the window between the read and the lease
613
661
  if (applied.landed.length > 0) {
614
662
  // ONE event for the patch, built from what the RECORD holds. The settings pair rides along
615
663
  // whenever anything but the name changed: model and thinking level are one setting, and a
@@ -678,6 +726,12 @@ export function createPiSessionControl(options) {
678
726
  const existing = yield* port(() => sessions.openIfExists(session));
679
727
  if (!existing)
680
728
  return noSuchSession(session);
729
+ // A record with no conversation in it (the list's `messageCount: 0`, e.g. one `update()` created before a first
730
+ // turn) has nothing to compact on any branch. Said before binding, because binding is a write: pi records the
731
+ // model and thinking level on a session that has no messages, so asking pi would add two entries and then
732
+ // refuse. Whether a record WITH messages has a cut point stays pi's decision (below).
733
+ if (!existing.getEntries().some(isConversationMessage))
734
+ return nothingToCompact();
681
735
  yield* acquireSessionLease(b.lease, session);
682
736
  const bound = yield* acquireSession(b.sessionFactory, session);
683
737
  yield* Effect.acquireRelease(Effect.try({
@@ -703,16 +757,8 @@ export function createPiSessionControl(options) {
703
757
  Deferred.doneUnsafe(admitted, Effect.succeed({ ok: true }));
704
758
  });
705
759
  const admission = yield* portAbort("compact admission", () => compaction.admission, () => bound.abortCompaction());
706
- if (admission === "nothing_to_compact") {
707
- return {
708
- ok: false,
709
- error: {
710
- code: NOTHING_TO_COMPACT_CODE,
711
- message: "nothing to compact — the session has no compactable history yet; retry after more turns",
712
- retryable: false,
713
- },
714
- };
715
- }
760
+ if (admission === "nothing_to_compact")
761
+ return nothingToCompact();
716
762
  // Any other error pi raises before admission. A served session has no known trigger: it always binds a
717
763
  // model, and pi swallows a credential failure here and meets it at the model call (compaction_finished).
718
764
  // NOT retryable all the same: it is pi's decision on this session, not a transport or lease fault that
@@ -757,12 +803,8 @@ export function createPiSessionControl(options) {
757
803
  const b = boundary;
758
804
  if (!b)
759
805
  return unsupported("fork()");
760
- // An id no client could then open: the empty string, `.` and `..` are not URL path segments
761
- // (isAddressableSession), so minting one would put a row in list() that nothing can address —
762
- // listed, unopenable by the client that just listed it.
763
- if (!isAddressableSession(into)) {
764
- return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
765
- }
806
+ if (!isAddressableSession(into))
807
+ return unaddressable(into);
766
808
  const source = yield* port(() => sessions.openIfExists(from));
767
809
  if (!source)
768
810
  return noSuchSession(from);
@@ -837,6 +879,7 @@ export function createPiSessionControl(options) {
837
879
  id: session,
838
880
  state: () => reads.state(session),
839
881
  entries: (options) => reads.entries(session, options),
882
+ image: (ref) => reads.image(session, ref),
840
883
  events: () => reads.events(session),
841
884
  update: (patch) => updateOf(session, patch),
842
885
  steer: (prompt) => runAction(session, { type: "steer", prompt }),
@@ -2,8 +2,17 @@
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import { type Models } from "@earendil-works/pi-ai";
4
4
  import type { SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import type { ModelDescriptor } from "../../session.ts";
5
6
  import type { AnyModel } from "./models.ts";
6
7
  export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
8
+ /**
9
+ * Models as a client's picker shows them, ordered by spec: the ONE description both listings give
10
+ * (`capabilities().allowedModels` and `availableModelsFromDir`). Its levels come from the function
11
+ * `resolveSessionSettings` reads, so a picker offers what `state()` will then report. A model that declares no name or
12
+ * context window (an extension's virtual model may not: pi reads the window as 0) leaves the field out rather than
13
+ * inventing one.
14
+ */
15
+ export declare function describeModels(models: readonly AnyModel[]): ModelDescriptor[];
7
16
  /** The shape both override consumers walk — a session entry, structurally. */
8
17
  export interface OverrideEntryLike {
9
18
  type: string;
@@ -10,6 +10,23 @@ const ALL_THINKING_LEVELS = {
10
10
  max: true,
11
11
  };
12
12
  export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
13
+ /**
14
+ * Models as a client's picker shows them, ordered by spec: the ONE description both listings give
15
+ * (`capabilities().allowedModels` and `availableModelsFromDir`). Its levels come from the function
16
+ * `resolveSessionSettings` reads, so a picker offers what `state()` will then report. A model that declares no name or
17
+ * context window (an extension's virtual model may not: pi reads the window as 0) leaves the field out rather than
18
+ * inventing one.
19
+ */
20
+ export function describeModels(models) {
21
+ return models
22
+ .map((model) => ({
23
+ spec: `${model.provider}/${model.id}`,
24
+ ...(model.name ? { name: model.name } : {}),
25
+ thinkingLevels: getSupportedThinkingLevels(model),
26
+ ...(model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
27
+ }))
28
+ .sort((a, b) => (a.spec < b.spec ? -1 : a.spec > b.spec ? 1 : 0));
29
+ }
13
30
  /**
14
31
  * The last entry of each kind wins, and a malformed one reads as ABSENT rather than falling through to an earlier
15
32
  * record.
@@ -6,8 +6,8 @@ import { type SessionInheritance } from "./session-inheritance.ts";
6
6
  export interface PiSessionRecordStore {
7
7
  openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<SessionManager>;
8
8
  /**
9
- * OPEN-EXISTING sibling: an unknown session answers undefined, never creates one — sessions are the data plane's
10
- * monopoly.
9
+ * OPEN-EXISTING sibling: an unknown session answers undefined, never creates one. The reads and every write that
10
+ * needs history go through it; creating is `openOrCreate`'s.
11
11
  */
12
12
  openIfExists(sessionId: string): Promise<SessionManager | undefined>;
13
13
  /** Write session properties, in the order pi's leaf pointer requires, and report what LANDED. */
@@ -17,14 +17,12 @@ export declare function agentSessionManager(session: AgentSession, sessionId: st
17
17
  * activate deferred tools mid-turn without tool.ts importing the engine. pi anchors the addition in the transcript
18
18
  * (a system message carrying `toolsAdded`, written after the batch of tool results the activating call belongs to),
19
19
  * so providers with native deferred loading keep their
20
- * prompt-cache prefix; that message describes the run that wrote it, so what carries an activation into LATER turns
21
- * is fastagent's own `fastagent:tool-activation` entry, replayed by the per-invoke restore
22
- * (agent-session-factory.ts).
20
+ * prompt-cache prefix. The same transcript declarations restore the loadout on the next binding.
23
21
  */
24
22
  export interface ToolActivation {
25
23
  /** Names of the currently ACTIVE tools. */
26
24
  active(): string[];
27
- /** Every registered tool (active or not) — the discovery corpus for a loader like `search_tools`. */
25
+ /** Every registered tool (active or not). */
28
26
  registered(): Array<{
29
27
  name: string;
30
28
  description: string;
@@ -37,7 +35,7 @@ export interface ToolActivation {
37
35
  activate(names: string[]): string[];
38
36
  }
39
37
  /** The activation bridge over a live pi session — the ONE implementation, for both consumers. */
40
- export declare function sessionToolActivation(session: AgentSession, onActivated?: (added: string[]) => void): ToolActivation;
38
+ export declare function sessionToolActivation(session: AgentSession): ToolActivation;
41
39
  export interface TurnContext {
42
40
  /** Working directory for this execution. */
43
41
  cwd?: string;
@@ -16,28 +16,16 @@ export function agentSessionManager(session, sessionId) {
16
16
  };
17
17
  }
18
18
  /** The activation bridge over a live pi session — the ONE implementation, for both consumers. */
19
- export function sessionToolActivation(session, onActivated) {
19
+ export function sessionToolActivation(session) {
20
20
  return {
21
21
  active: () => session.getActiveToolNames(),
22
22
  registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
23
23
  activate(names) {
24
24
  const current = session.getActiveToolNames();
25
- const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
26
- if (added.length === 0)
27
- return added;
28
- session.setActiveToolsByName([...current, ...added]);
29
- onActivated?.(added);
30
- return added;
25
+ session.setActiveToolsByName([...current, ...names]);
26
+ const before = new Set(current);
27
+ return session.getActiveToolNames().filter((name) => !before.has(name));
31
28
  },
32
29
  };
33
30
  }
34
31
  export const turnContext = new AsyncLocalStorage();
35
- /**
36
- * dedupe → keep registered names only (pi's setters THROW on unknown) → exclude already-active → the names to actually
37
- * add (empty = nothing to set).
38
- */
39
- function additiveActivation(registered, current, names) {
40
- const known = new Set(registered);
41
- const active = new Set(current);
42
- return [...new Set(names)].filter((name) => known.has(name) && !active.has(name));
43
- }
@@ -1,5 +1,5 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionToolContext, ToolAnnotations, ToolDefinition, ToolExposure, ToolNamespace } from "@earendil-works/pi-coding-agent";
3
3
  import { z } from "zod";
4
4
  import { type ModuleLoadFailure } from "../../loader.ts";
5
5
  import { type DeclaredSecret } from "../../declared-secrets.ts";
@@ -11,10 +11,23 @@ export interface ToolContext {
11
11
  signal?: AbortSignal;
12
12
  sessionManager?: ReadonlySessionManager;
13
13
  /**
14
- * Tool activation for the current turn (a loader tool activates {@link DefineToolOptions.deferred} tools with it —
15
- * the built-in `search_tools` is one consumer).
14
+ * Tool activation for the current turn; Pi records changes in the transcript.
16
15
  */
17
16
  tools?: ToolActivation;
17
+ /**
18
+ * Run another mounted tool through pi's own pipeline: argument validation, the definition's `tool_call` and
19
+ * `tool_result` hooks, cancellation (this call's signal by default). It reaches the active `direct` tools and every
20
+ * `codemode` or `deferred` one. A tool failure comes back as `isError: true`, never as a rejection. The nested call
21
+ * is observed with `parentToolCallId` and does not enter the transcript. Undefined without a session
22
+ * (`fastagent tool`).
23
+ */
24
+ executeTool?: ExtensionToolContext["executeTool"];
25
+ /**
26
+ * Report progress while this call runs: a snapshot of everything so far (each call replaces the last), wrapped the
27
+ * way a return value is. Its last line is the call's status (`tool_progress` on the invoke stream); session-control
28
+ * observers receive the whole snapshot. Undefined when the caller takes no progress (`fastagent tool`).
29
+ */
30
+ onUpdate?: (partial: unknown) => void;
18
31
  /** THE values of {@link DefineToolOptions.secrets}, keyed by the names this tool declared — read
19
32
  * from the process environment per call, so a value rotated THERE takes effect without a restart
20
33
  * (one rotated in `.secrets/.env` does not: that file is loaded once at startup). This is how a
@@ -26,11 +39,20 @@ export interface DefineToolOptions<I extends z.ZodType, S extends readonly strin
26
39
  name?: string;
27
40
  description: string;
28
41
  input: I;
42
+ /** Pi's native visibility: direct (default), model-only, codemode, deferred, or hidden. */
43
+ exposure?: ToolExposure;
44
+ /** Direct tools start active unless false; settings may still select them. */
45
+ defaultActive?: boolean;
29
46
  /**
30
- * Registered but NOT initially active: the tool's schema stays out of every request (and the model's sight) until a
31
- * loader.
47
+ * MCP-style hints about what the tool does (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).
48
+ * Pi does not enforce them; a permission extension reads them from `pi.getAllTools()` to decide which calls to
49
+ * confirm, and treats a tool without them as possibly destructive.
32
50
  */
33
- deferred?: boolean;
51
+ annotations?: ToolAnnotations;
52
+ /** Groups the tool with related ones (`{ name, description? }`): codemode lists a namespace under one heading. */
53
+ namespace?: ToolNamespace;
54
+ /** Structured output schema for programmatic callers such as codemode. */
55
+ output?: z.ZodType;
34
56
  /** pi's per-tool execution mode: "sequential" makes pi run any batch containing this tool serially. */
35
57
  executionMode?: "sequential" | "parallel";
36
58
  /**
@@ -46,25 +68,14 @@ export interface DefineToolOptions<I extends z.ZodType, S extends readonly strin
46
68
  }) => unknown | Promise<unknown>;
47
69
  }
48
70
  /** AgentTool with Pi's optional per-call context; absent for sessionless CLI execution. */
49
- export type MountedTool = Omit<AgentTool, "execute"> & {
50
- execute(...args: [...Parameters<AgentTool["execute"]>, context?: ExtensionContext]): ReturnType<AgentTool["execute"]>;
71
+ export type MountedTool = Omit<AgentTool, "execute"> & Pick<ToolDefinition, "exposure" | "defaultActive" | "annotations" | "namespace"> & {
72
+ execute(...args: [...Parameters<AgentTool["execute"]>, context?: ExtensionToolContext]): ReturnType<AgentTool["execute"]>;
51
73
  };
52
- /** An AgentTool with fastagent's deferral marker. */
53
- export type FastagentTool = AgentTool & {
54
- deferred?: boolean;
74
+ /** A Pi tool with fastagent's declared secrets. */
75
+ export type FastagentTool = MountedTool & {
55
76
  /** {@link DefineToolOptions.secrets} — read back by `readSecretDeclaration`; pi ignores it. */
56
77
  secrets?: readonly string[];
57
78
  };
58
- /**
59
- * Read the {@link DefineToolOptions.deferred} marker off a mounted tool (extra property on the AgentTool object — pi
60
- * ignores it).
61
- */
62
- export declare function isDeferredTool(tool: MountedTool): boolean;
63
- /**
64
- * The same tool without the deferred marker — for a loader that must stay active (a deferred loader could never be
65
- * activated and would strand every deferred tool).
66
- */
67
- export declare function stripDeferredMarker(tool: MountedTool): MountedTool;
68
79
  export declare function defineTool<I extends z.ZodType, const S extends readonly string[] = readonly []>(options: DefineToolOptions<I, S>): FastagentTool;
69
80
  /** A discarded same-name tool (within `tools/`, or against an existing tool). */
70
81
  export interface ToolCollision {
@@ -5,27 +5,11 @@ import { z } from "zod";
5
5
  import { loadModuleDir } from "../../loader.js";
6
6
  import { readSecretDeclaration, secretValues } from "../../declared-secrets.js";
7
7
  import { turnContext } from "./tool-context.js";
8
- /**
9
- * Read the {@link DefineToolOptions.deferred} marker off a mounted tool (extra property on the AgentTool object — pi
10
- * ignores it).
11
- */
12
- export function isDeferredTool(tool) {
13
- return tool.deferred === true;
14
- }
15
- /**
16
- * The same tool without the deferred marker — for a loader that must stay active (a deferred loader could never be
17
- * activated and would strand every deferred tool).
18
- */
19
- export function stripDeferredMarker(tool) {
20
- if (!isDeferredTool(tool))
21
- return tool;
22
- const { deferred: _drop, ...active } = tool;
23
- return active;
24
- }
25
8
  /**
26
9
  * Ask the provider to constrain sampling to the tool's schema, the posture pi's own built-ins take. `"prefer"`
27
- * rather than `"require"`: a schema pi cannot express strictly, or a provider without strict mode, silently falls
28
- * back to an ordinary function tool instead of failing the turn.
10
+ * rather than `"require"`: a schema pi cannot express strictly, one using a keyword the provider's strict mode rejects
11
+ * (Anthropic: numeric bounds, which every `z.number().int()` carries), or a provider without strict mode silently
12
+ * falls back to an ordinary function tool instead of failing the turn.
29
13
  *
30
14
  * Nothing here has to undo pi's strict rewrite. To express "optional" strictly, pi marks every property required and
31
15
  * unions the optional ones with `null`, so a constrained model emits `null` where it would have omitted the key — and
@@ -36,12 +20,13 @@ export function stripDeferredMarker(tool) {
36
20
  */
37
21
  const CONSTRAINED_SAMPLING = { type: "json_schema", strict: "prefer" };
38
22
  /** Wrap a plain return value into pi's tool-result shape; pass a full result through unchanged. */
39
- function wrapResult(value) {
23
+ function wrapResult(value, output) {
40
24
  if (value && typeof value === "object" && Array.isArray(value.content)) {
41
25
  return value;
42
26
  }
27
+ const structuredContent = output ? output.parse(value) : undefined;
43
28
  const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
44
- return { content: [{ type: "text", text }], details: value };
29
+ return { content: [{ type: "text", text }], details: value, ...(output ? { structuredContent } : {}) };
45
30
  }
46
31
  // `S` defaults to the EMPTY tuple, not `readonly string[]`: a tool that declares nothing then gets
47
32
  // `ctx.secrets` with no keys, so `ctx.secrets.X_API_KEY` fails to compile instead of type-checking as
@@ -55,15 +40,23 @@ export function defineTool(options) {
55
40
  description: options.description,
56
41
  parameters,
57
42
  constrainedSampling: CONSTRAINED_SAMPLING,
58
- ...(options.deferred ? { deferred: true } : {}),
43
+ ...(options.exposure ? { exposure: options.exposure } : {}),
44
+ ...(options.defaultActive !== undefined ? { defaultActive: options.defaultActive } : {}),
45
+ ...(options.annotations ? { annotations: options.annotations } : {}),
46
+ ...(options.namespace ? { namespace: options.namespace } : {}),
47
+ ...(options.output ? { outputSchema: z.toJSONSchema(options.output) } : {}),
59
48
  ...(options.executionMode ? { executionMode: options.executionMode } : {}),
60
49
  ...(options.secrets?.length ? { secrets: options.secrets } : {}),
61
- async execute(_toolCallId, rawParams, signal) {
50
+ async execute(_toolCallId, rawParams, signal, onUpdate, context) {
62
51
  const parsed = options.input.safeParse(rawParams);
63
52
  if (!parsed.success) {
64
53
  // Validation failure is reported TO THE MODEL (it can correct and retry), not thrown.
65
54
  const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
66
- return { content: [{ type: "text", text: `Invalid arguments: ${detail}` }], details: { error: detail } };
55
+ return {
56
+ content: [{ type: "text", text: `Invalid arguments: ${detail}` }],
57
+ details: { error: detail },
58
+ isError: true,
59
+ };
67
60
  }
68
61
  const store = turnContext.getStore();
69
62
  return wrapResult(await options.execute(parsed.data, {
@@ -71,8 +64,11 @@ export function defineTool(options) {
71
64
  signal,
72
65
  sessionManager: store?.sessionManager,
73
66
  tools: store?.tools,
67
+ // Per CALL, not per turn: pi binds `executeTool` to this call's id, the parent of what it runs.
68
+ executeTool: context?.executeTool,
69
+ onUpdate: onUpdate && ((partial) => onUpdate(wrapResult(partial))),
74
70
  secrets: secretValues(options.secrets),
75
- }));
71
+ }), options.output);
76
72
  },
77
73
  };
78
74
  return tool;
@@ -1,7 +1,6 @@
1
- /** The turn mechanism's ENGINE-agnostic half: the parts that describe a turn rather than pi. */
2
- import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai";
1
+ import { type AssistantMessage, type ImageContent } from "@earendil-works/pi-ai";
3
2
  import { type AgentEvent, type Prompt } from "../../agent.ts";
4
- import type { SessionEvent } from "../../session.ts";
3
+ import type { AnswerOutcome, PromptDisposition, SessionEvent } from "../../session.ts";
5
4
  export type Release = () => void;
6
5
  export interface Lease {
7
6
  /** Try to acquire exclusive write access for the session (fail-fast). */
@@ -14,9 +13,15 @@ export declare function classifyRetryable(details: string, signal: {
14
13
  code?: unknown;
15
14
  }): boolean;
16
15
  /**
17
- * Terminal mapping, decided by the resolved message's stopReason: pi's `prompt()` RESOLVES a message with stopReason
16
+ * How an answer ended, read off the stopReason pi records on the message. The ONE reading: `message_finished`,
17
+ * the `assistant` entry `entries()` publishes, and the invoke terminal all derive from it, so the live outcome and
18
+ * the recorded one cannot disagree.
19
+ */
20
+ export declare function answerOutcome(message: AssistantMessage): AnswerOutcome | undefined;
21
+ /**
22
+ * Terminal mapping, decided by the resolved message's outcome: pi's `prompt()` RESOLVES a message with stopReason
18
23
  * "error"/"aborted" rather than throwing, so relying on catch alone would miss that whole failure class and violate
19
- * SPEC MUST 1.
24
+ * SPEC MUST 1. A truncated final answer still completes the run.
20
25
  */
21
26
  export declare function toTerminal(message: AssistantMessage): AgentEvent;
22
27
  export declare function errorToTerminal(error: unknown): Extract<AgentEvent, {
@@ -34,11 +39,15 @@ export declare function toPiPromptOptions(prompt: Prompt, delivery: "prompt" | "
34
39
  } | undefined>;
35
40
  /** Live modulation handles for one active run — what the control plane's `dispatch` routes to. */
36
41
  export interface RunControls {
37
- steer(prompt: Prompt): Promise<void>;
38
- followUp(prompt: Prompt): Promise<void>;
42
+ steer(prompt: Prompt): Promise<PromptDisposition>;
43
+ followUp(prompt: Prompt): Promise<PromptDisposition>;
39
44
  abort(): Promise<void>;
40
45
  }
41
46
  /** The DATA-plane observation seam: every rich event of every run, pushed as it happens. */
42
47
  export type SessionObserver = (session: string, event: SessionEvent, run?: RunControls) => void;
43
- /** The SPEC projection of the rich stream. */
44
- export declare function projectAgentEvent(se: SessionEvent): AgentEvent | null;
48
+ /**
49
+ * The SPEC projection of ONE run's rich stream. Stateful only for `tool_progress`, the one event that is a status
50
+ * rather than a fact: an outer call's status line is sent when it changes, and a call that call makes, at any depth,
51
+ * is its status. Nested calls are otherwise the observation plane's alone.
52
+ */
53
+ export declare function agentEventProjection(): (se: SessionEvent) => AgentEvent | null;