@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.
- package/dist/agent.d.ts +10 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/preview.js +2 -1
- package/dist/cli/commands/info.js +7 -7
- package/dist/cli/commands/login.js +2 -2
- package/dist/cli/commands/models.js +3 -3
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/shared.d.ts +2 -1
- package/dist/cli/shared.js +6 -7
- package/dist/deploy/preflight.js +19 -1
- package/dist/dev-supervisor.js +2 -2
- package/dist/engines/pi/agent-models.d.ts +14 -1
- package/dist/engines/pi/agent-models.js +35 -8
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +137 -159
- package/dist/engines/pi/auth.js +6 -94
- package/dist/engines/pi/create.d.ts +16 -9
- package/dist/engines/pi/create.js +71 -38
- package/dist/engines/pi/invoke-session.js +82 -16
- package/dist/engines/pi/locked-file.d.ts +9 -0
- package/dist/engines/pi/locked-file.js +102 -0
- package/dist/engines/pi/login.d.ts +7 -1
- package/dist/engines/pi/login.js +22 -6
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +47 -42
- package/dist/engines/pi/models.js +154 -67
- package/dist/engines/pi/open.d.ts +29 -12
- package/dist/engines/pi/open.js +52 -21
- package/dist/engines/pi/openai-account-models.d.ts +35 -0
- package/dist/engines/pi/openai-account-models.js +116 -0
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/session-builder.js +6 -22
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +72 -40
- package/dist/engines/pi/session-settings.d.ts +9 -0
- package/dist/engines/pi/session-settings.js +17 -0
- package/dist/engines/pi/session-store.d.ts +2 -2
- package/dist/engines/pi/tool-context.d.ts +3 -5
- package/dist/engines/pi/tool-context.js +4 -16
- package/dist/engines/pi/tool.d.ts +32 -21
- package/dist/engines/pi/tool.js +21 -25
- package/dist/engines/pi/turn-kit.d.ts +18 -9
- package/dist/engines/pi/turn-kit.js +104 -27
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +1 -1
- package/dist/session.d.ts +60 -8
- package/dist/session.js +5 -1
- package/package.json +5 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- 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 {
|
|
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).
|
|
271
|
-
//
|
|
272
|
-
// `state().availableThinkingLevels`
|
|
273
|
-
...(b ? { allowedModels:
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
:
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
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
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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); //
|
|
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
|
-
|
|
771
|
-
|
|
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
|
|
10
|
-
*
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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?:
|
|
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
|
-
/**
|
|
53
|
-
export type FastagentTool =
|
|
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 {
|
package/dist/engines/pi/tool.js
CHANGED
|
@@ -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,
|
|
28
|
-
*
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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<
|
|
38
|
-
followUp(prompt: Prompt): Promise<
|
|
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
|
-
/**
|
|
44
|
-
|
|
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;
|