@fastagent-sh/fastagent 0.23.0 → 0.24.1

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 (53) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/kit/preview-kit.d.ts +6 -1
  3. package/dist/channels/kit/preview-kit.js +21 -4
  4. package/dist/channels/slack/preview.js +2 -1
  5. package/dist/cli/commands/info.js +7 -7
  6. package/dist/cli/commands/login.js +2 -2
  7. package/dist/cli/commands/models.js +3 -3
  8. package/dist/cli/invoke-stream.js +14 -0
  9. package/dist/cli/shared.d.ts +2 -1
  10. package/dist/cli/shared.js +6 -7
  11. package/dist/deploy/preflight.js +19 -1
  12. package/dist/dev-supervisor.js +2 -2
  13. package/dist/engines/pi/agent-models.d.ts +14 -1
  14. package/dist/engines/pi/agent-models.js +35 -8
  15. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  16. package/dist/engines/pi/agent-session-factory.js +137 -159
  17. package/dist/engines/pi/auth.js +6 -94
  18. package/dist/engines/pi/create.d.ts +16 -9
  19. package/dist/engines/pi/create.js +71 -38
  20. package/dist/engines/pi/invoke-session.js +82 -16
  21. package/dist/engines/pi/locked-file.d.ts +9 -0
  22. package/dist/engines/pi/locked-file.js +102 -0
  23. package/dist/engines/pi/login.d.ts +7 -1
  24. package/dist/engines/pi/login.js +22 -6
  25. package/dist/engines/pi/machine.d.ts +23 -0
  26. package/dist/engines/pi/machine.js +24 -2
  27. package/dist/engines/pi/models.d.ts +47 -42
  28. package/dist/engines/pi/models.js +154 -67
  29. package/dist/engines/pi/open.d.ts +29 -12
  30. package/dist/engines/pi/open.js +52 -21
  31. package/dist/engines/pi/openai-account-models.d.ts +35 -0
  32. package/dist/engines/pi/openai-account-models.js +116 -0
  33. package/dist/engines/pi/report.d.ts +3 -0
  34. package/dist/engines/pi/report.js +11 -0
  35. package/dist/engines/pi/session-builder.js +6 -22
  36. package/dist/engines/pi/session-control.d.ts +4 -4
  37. package/dist/engines/pi/session-control.js +72 -40
  38. package/dist/engines/pi/session-settings.d.ts +9 -0
  39. package/dist/engines/pi/session-settings.js +17 -0
  40. package/dist/engines/pi/session-store.d.ts +2 -2
  41. package/dist/engines/pi/tool-context.d.ts +3 -5
  42. package/dist/engines/pi/tool-context.js +4 -16
  43. package/dist/engines/pi/tool.d.ts +32 -21
  44. package/dist/engines/pi/tool.js +21 -25
  45. package/dist/engines/pi/turn-kit.d.ts +18 -9
  46. package/dist/engines/pi/turn-kit.js +104 -27
  47. package/dist/pi.d.ts +1 -1
  48. package/dist/pi.js +1 -1
  49. package/dist/session.d.ts +60 -8
  50. package/dist/session.js +5 -1
  51. package/package.json +5 -5
  52. package/dist/engines/pi/search-tools.d.ts +0 -4
  53. package/dist/engines/pi/search-tools.js +0 -109
@@ -10,12 +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";
15
14
  import { entryImages, imageAt } from "./entry-images.js";
15
+ import { answerOutcome } from "./turn-kit.js";
16
16
  import { startCompaction } from "./agent-session-factory.js";
17
17
  import { toRetryScheduledEvent } from "./retry-event.js";
18
- import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
18
+ import { THINKING_LEVELS, activePath, describeModels, resolveSessionSettings } from "./session-settings.js";
19
19
  import { log } from "../../log.js";
20
20
  // ── Usage (read from the record) ─────────────────────────────────────────────
21
21
  /**
@@ -92,6 +92,9 @@ function toSessionEntry(entry, parentId) {
92
92
  const data = { text: contentText(m.content, "") };
93
93
  if (toolCalls.length > 0)
94
94
  data.toolCalls = toolCalls;
95
+ const outcome = answerOutcome(m);
96
+ if (outcome)
97
+ data.outcome = outcome;
95
98
  return { ...base, kind: "assistant", data };
96
99
  }
97
100
  if (m.role === "toolResult") {
@@ -267,10 +270,10 @@ export function createPiSessionControl(options) {
267
270
  // The CONTRACT's list, not a copy of it: a field added to SessionUpdate is advertised
268
271
  // without anyone remembering to, and one removed cannot linger here.
269
272
  updatable: b ? [...UPDATE_FIELDS] : [],
270
- // The registry is a deployment fact (any session may be pointed at any of it). Thinking
271
- // LEVELS are a property of the model a session is running, so they ride
272
- // `state().availableThinkingLevels` — a list here could only answer for one model.
273
- ...(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()) } : {}),
274
277
  toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
275
278
  usage: true, // state().usage, and state_changed{usage} after each run and compaction
276
279
  };
@@ -289,12 +292,27 @@ export function createPiSessionControl(options) {
289
292
  const b = boundary;
290
293
  let settings;
291
294
  let usage;
292
- 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 {
293
301
  try {
294
302
  const path = activePath(opened);
295
303
  if (b)
296
304
  settings = resolveSessionSettings(path, b.models, b.defaults);
297
- 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);
298
316
  }
299
317
  catch (error) {
300
318
  log.warn(`[fastagent] session ${session}: state unreadable (entry chain): ${String(error)}`);
@@ -457,6 +475,21 @@ export function createPiSessionControl(options) {
457
475
  ok: false,
458
476
  error: { code: NO_SUCH_SESSION_CODE, message: `session "${session}" does not exist`, retryable: false },
459
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
+ });
460
493
  const invalid = (message) => ({
461
494
  ok: false,
462
495
  error: { code: INVALID_COMMAND_CODE, message, retryable: false },
@@ -518,12 +551,12 @@ export function createPiSessionControl(options) {
518
551
  };
519
552
  }
520
553
  const controls = run.controls;
521
- yield* port(() => action.type === "steer"
522
- ? controls.steer(action.prompt)
523
- : action.type === "follow_up"
524
- ? controls.followUp(action.prompt)
525
- : controls.abort());
526
- 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 };
527
560
  }).pipe(Effect.catchCause((cause) => Effect.succeed({
528
561
  ok: false,
529
562
  error: { code: RUN_COMMAND_FAILED_CODE, message: String(portError(cause)), retryable: false },
@@ -557,7 +590,7 @@ export function createPiSessionControl(options) {
557
590
  const slash = patch.model.indexOf("/");
558
591
  model = slash > 0 ? b.models.getModel(patch.model.slice(0, slash), patch.model.slice(slash + 1)) : undefined;
559
592
  if (!model) {
560
- 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`);
561
594
  }
562
595
  }
563
596
  if (patch.thinkingLevel !== undefined && !THINKING_LEVELS.has(patch.thinkingLevel)) {
@@ -566,18 +599,18 @@ export function createPiSessionControl(options) {
566
599
  // A name is the client's own label; the only thing that cannot be one is nothing.
567
600
  if (patch.name !== undefined && patch.name.trim() === "")
568
601
  return invalid("a session name cannot be empty");
569
- // Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
570
- // rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
571
- // 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.)
572
605
  const existing = yield* port(() => sessions.openIfExists(session));
573
- if (!existing)
574
- return noSuchSession(session);
606
+ if (!existing && !isAddressableSession(session))
607
+ return unaddressable(session);
575
608
  if (patch.leafEntryId !== undefined) {
576
609
  // A target that cannot BE a leaf is a permanent payload error, not a session error — the same
577
610
  // disposition as an unknown model spec. Same predicate `entries()` publishes by, so
578
611
  // "everything published is a position" holds by construction rather than by two literals
579
612
  // agreeing.
580
- const entry = existing.getEntry(patch.leafEntryId);
613
+ const entry = existing?.getEntry(patch.leafEntryId);
581
614
  if (!entry || !isNavigable(entry)) {
582
615
  return invalid(entry
583
616
  ? `entry "${patch.leafEntryId}" is not a position — entries() publishes every id you can move to, and this is not one of them`
@@ -593,7 +626,8 @@ export function createPiSessionControl(options) {
593
626
  // Against the path this patch LANDS on: a leaf move is written first, and the branch it
594
627
  // moves to can carry a model override of its own — validating on the path being left would
595
628
  // reject a level the destination supports, and accept one it does not.
596
- 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);
597
631
  }
598
632
  catch (error) {
599
633
  return failed(error);
@@ -611,6 +645,10 @@ export function createPiSessionControl(options) {
611
645
  yield* acquireSessionLease(b.lease, session);
612
646
  // portJoin, not port: the lease is held for the duration of this write, so an interrupted
613
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));
614
652
  return yield* portJoin(() => sessions.applyProperties(session, {
615
653
  ...(patch.name !== undefined ? { name: patch.name } : {}),
616
654
  ...(model ? { model: { provider: model.provider, id: model.id } } : {}),
@@ -619,7 +657,7 @@ export function createPiSessionControl(options) {
619
657
  }));
620
658
  }));
621
659
  if (!applied)
622
- 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
623
661
  if (applied.landed.length > 0) {
624
662
  // ONE event for the patch, built from what the RECORD holds. The settings pair rides along
625
663
  // whenever anything but the name changed: model and thinking level are one setting, and a
@@ -688,6 +726,12 @@ export function createPiSessionControl(options) {
688
726
  const existing = yield* port(() => sessions.openIfExists(session));
689
727
  if (!existing)
690
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();
691
735
  yield* acquireSessionLease(b.lease, session);
692
736
  const bound = yield* acquireSession(b.sessionFactory, session);
693
737
  yield* Effect.acquireRelease(Effect.try({
@@ -713,16 +757,8 @@ export function createPiSessionControl(options) {
713
757
  Deferred.doneUnsafe(admitted, Effect.succeed({ ok: true }));
714
758
  });
715
759
  const admission = yield* portAbort("compact admission", () => compaction.admission, () => bound.abortCompaction());
716
- if (admission === "nothing_to_compact") {
717
- return {
718
- ok: false,
719
- error: {
720
- code: NOTHING_TO_COMPACT_CODE,
721
- message: "nothing to compact — the session has no compactable history yet; retry after more turns",
722
- retryable: false,
723
- },
724
- };
725
- }
760
+ if (admission === "nothing_to_compact")
761
+ return nothingToCompact();
726
762
  // Any other error pi raises before admission. A served session has no known trigger: it always binds a
727
763
  // model, and pi swallows a credential failure here and meets it at the model call (compaction_finished).
728
764
  // NOT retryable all the same: it is pi's decision on this session, not a transport or lease fault that
@@ -767,12 +803,8 @@ export function createPiSessionControl(options) {
767
803
  const b = boundary;
768
804
  if (!b)
769
805
  return unsupported("fork()");
770
- // An id no client could then open: the empty string, `.` and `..` are not URL path segments
771
- // (isAddressableSession), so minting one would put a row in list() that nothing can address —
772
- // listed, unopenable by the client that just listed it.
773
- if (!isAddressableSession(into)) {
774
- return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
775
- }
806
+ if (!isAddressableSession(into))
807
+ return unaddressable(into);
776
808
  const source = yield* port(() => sessions.openIfExists(from));
777
809
  if (!source)
778
810
  return noSuchSession(from);
@@ -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;