@fastagent-sh/fastagent 0.23.0 → 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.
- 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 +132 -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 +147 -67
- package/dist/engines/pi/open.d.ts +25 -11
- package/dist/engines/pi/open.js +42 -20
- 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Agent } from "../../agent.ts";
|
|
2
2
|
import { type FastagentConfig } from "./config.ts";
|
|
3
|
-
import type { AgentCommand, SessionControl } from "../../session.ts";
|
|
4
|
-
import { type PiAssembly } from "./create.ts";
|
|
3
|
+
import type { AgentCommand, ModelDescriptor, SessionControl } from "../../session.ts";
|
|
4
|
+
import { type IndirectTool, type PiAssembly } from "./create.ts";
|
|
5
5
|
import type { SessionObserver } from "./turn-kit.ts";
|
|
6
6
|
import { type LoadedDefinition } from "./definition.ts";
|
|
7
7
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
@@ -72,10 +72,10 @@ export interface AgentAssembly {
|
|
|
72
72
|
stateRoot: string;
|
|
73
73
|
/** The credential store and model registry every turn and every report reads ({@link agentModels}). */
|
|
74
74
|
models: AgentModels;
|
|
75
|
-
/** The full mounted tool surface (all coding tools + config.tools + discovered tools
|
|
75
|
+
/** The full mounted tool surface (all coding tools + config.tools + discovered tools/). */
|
|
76
76
|
tools: MountedTool[];
|
|
77
77
|
toolNames: string[];
|
|
78
|
-
|
|
78
|
+
indirectTools: IndirectTool[];
|
|
79
79
|
toolCollisions: ToolCollision[];
|
|
80
80
|
/** Env vars the mounted tools declared, by tool name — already asserted present by this function. */
|
|
81
81
|
toolSecrets: Map<string, DeclaredSecret[]>;
|
|
@@ -96,15 +96,16 @@ export declare function assembleFront(front: AgentAssembly, extra?: {
|
|
|
96
96
|
definition: LoadedDefinition;
|
|
97
97
|
}>;
|
|
98
98
|
/**
|
|
99
|
-
* The
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
99
|
+
* The models `createPiAgentFromDir(dir, { authPath })` could run now, as a picker shows them (spec, name, the thinking
|
|
100
|
+
* levels a session on it accepts, context window), sorted by spec: the agent's registry (pi's built-ins, its
|
|
101
|
+
* `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
|
|
102
|
+
* directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
|
|
103
|
+
* authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
|
|
103
104
|
*
|
|
104
105
|
* `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
|
|
105
106
|
* configured", so a client that must not show that as an empty list passes a sink that throws.
|
|
106
107
|
*/
|
|
107
|
-
export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<
|
|
108
|
+
export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<ModelDescriptor[]>;
|
|
108
109
|
/**
|
|
109
110
|
* Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
|
|
110
111
|
* `createPiAgentFromDir(dir, { authPath })` would use, so a model released after the installed pi appears in
|
|
@@ -122,6 +123,19 @@ export declare function refreshModelCatalog(dir: string, options?: FastagentAuth
|
|
|
122
123
|
export declare function refreshModelCatalogOver(dir: string, options: FastagentAuthOptions & CredentialSourceOptions & {
|
|
123
124
|
signal?: AbortSignal;
|
|
124
125
|
}, catalogBaseUrl?: string): Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* Refresh the MACHINE's model catalog (`~/.fastagent/models-store.json`), which every agent here reads under its own,
|
|
128
|
+
* so one refresh serves them all and no file is written into any agent. Credentials resolve as for
|
|
129
|
+
* {@link refreshModelCatalog} without a directory: `credentialStore`, else `authPath`, else the global credentials file;
|
|
130
|
+
* the environment applies either way. Rejects for the same reasons. The file never ships with a deploy.
|
|
131
|
+
*/
|
|
132
|
+
export declare function refreshMachineModelCatalog(options?: FastagentAuthOptions & CredentialSourceOptions & {
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}): Promise<void>;
|
|
135
|
+
/** {@link refreshMachineModelCatalog} against another catalog server. Not public: a test seam. */
|
|
136
|
+
export declare function refreshMachineModelCatalogOver(options: FastagentAuthOptions & CredentialSourceOptions & {
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
}, catalogBaseUrl?: string): Promise<void>;
|
|
125
139
|
/**
|
|
126
140
|
* "Point at a directory → agent": resolve the placement (`dir` may be either end — the workspace or the agent dir
|
|
127
141
|
* itself), load the config, resolve model and tools, then L2.
|
|
@@ -153,7 +167,7 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
|
|
|
153
167
|
http?: HttpSurface;
|
|
154
168
|
/** Non-default, active-by-default tool names in effect: config.tools + discovered tools/. */
|
|
155
169
|
toolNames: string[];
|
|
156
|
-
/**
|
|
157
|
-
|
|
170
|
+
/** Mounted authored tools the model is not given up front, with how each is reached. */
|
|
171
|
+
indirectTools: IndirectTool[];
|
|
158
172
|
toolCollisions: ToolCollision[];
|
|
159
173
|
}>;
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -6,6 +6,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { loadConfig, resolveModelSpec } from "./config.js";
|
|
8
8
|
import { AGENT_MODEL_CATALOG_FILE, resolveSessionsDir, resolveStateRoot, resolvePlacement } from "../../paths.js";
|
|
9
|
+
import { describeModels } from "./session-settings.js";
|
|
9
10
|
import { agentOf, assemblePiFromDefinition, resolveAgentTools } from "./create.js";
|
|
10
11
|
import { createPiSessionControl } from "./session-control.js";
|
|
11
12
|
import { withWakeTool } from "./wake-tool.js";
|
|
@@ -14,7 +15,7 @@ import { loadAgentSkills } from "./definition.js";
|
|
|
14
15
|
import { servedExtensionCommands } from "./agent-session-factory.js";
|
|
15
16
|
import { reportFindingsIfChanged } from "./report.js";
|
|
16
17
|
import { readMachine, withMachine } from "./machine.js";
|
|
17
|
-
import { createPiModelRuntime, refreshCatalog } from "./models.js";
|
|
18
|
+
import { createPiModelRuntime, globalCatalogPath, machineModelRuntime, refreshCatalog } from "./models.js";
|
|
18
19
|
import { agentModels } from "./agent-models.js";
|
|
19
20
|
import { piSessionRecordStore } from "./session-store.js";
|
|
20
21
|
import { gateSecrets } from "../../secrets-gate.js";
|
|
@@ -36,9 +37,11 @@ export async function agentCommands(agentDir, workspace, served) {
|
|
|
36
37
|
reportFindingsIfChanged(own.dir, own);
|
|
37
38
|
const machine = await readMachine(workspace);
|
|
38
39
|
const { extensionPaths } = served;
|
|
39
|
-
const extensionCommands =
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const extensionCommands = await servedExtensionCommands({
|
|
41
|
+
cwd: workspace,
|
|
42
|
+
modelRuntime: await served.modelRuntime(),
|
|
43
|
+
extensionPaths,
|
|
44
|
+
});
|
|
42
45
|
// pi dispatches an extension command before it expands a template, so a template it shadows never runs.
|
|
43
46
|
const shadowed = new Set(extensionCommands.map((command) => command.invocationName));
|
|
44
47
|
return [
|
|
@@ -69,7 +72,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
|
|
|
69
72
|
if (!modelSpec) {
|
|
70
73
|
throw new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
|
|
71
74
|
}
|
|
72
|
-
const { tools, toolNames,
|
|
75
|
+
const { tools, toolNames, indirectTools, toolCollisions, toolFailures, toolSecrets } = await resolveAgentTools(config, agentDir, workspace);
|
|
73
76
|
// THE serving-path gate for tool declarations, in the order that costs the fewest round trips. A file that could
|
|
74
77
|
// not be imported declares nothing, so its `secrets:` are missing from `toolSecrets` — gating secrets first would
|
|
75
78
|
// report an incomplete set, and fixing the file could then reveal more missing values. Refuse the broken file
|
|
@@ -79,7 +82,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
|
|
|
79
82
|
// definition and must survive both faults; every path through this function is about to run ALL of the tools, so
|
|
80
83
|
// no `owner`.
|
|
81
84
|
//
|
|
82
|
-
// DEFERRED tools gate too, deliberately:
|
|
85
|
+
// DEFERRED tools gate too, deliberately: native discovery can activate one mid-turn, so "registered"
|
|
83
86
|
// means "may run in this process" — letting it start would put the empty-credential failure back
|
|
84
87
|
// inside a turn. An author who does not want that opts out per tool by not declaring.
|
|
85
88
|
gateSecrets({ declared: toolSecrets, failures: [] });
|
|
@@ -94,10 +97,10 @@ export async function resolveAgentAssembly(dir, options = {}) {
|
|
|
94
97
|
workspace,
|
|
95
98
|
stateRoot,
|
|
96
99
|
// Project-level by default (under `<agentDir>/.secrets`), with the global file behind it per provider.
|
|
97
|
-
models: agentModels(agentDir, options),
|
|
100
|
+
models: agentModels(agentDir, options, { cwd: workspace }),
|
|
98
101
|
tools,
|
|
99
102
|
toolNames,
|
|
100
|
-
|
|
103
|
+
indirectTools,
|
|
101
104
|
toolCollisions,
|
|
102
105
|
toolSecrets,
|
|
103
106
|
};
|
|
@@ -118,18 +121,19 @@ export function assembleFront(front, extra = {}) {
|
|
|
118
121
|
});
|
|
119
122
|
}
|
|
120
123
|
/**
|
|
121
|
-
* The
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
124
|
+
* The models `createPiAgentFromDir(dir, { authPath })` could run now, as a picker shows them (spec, name, the thinking
|
|
125
|
+
* levels a session on it accepts, context window), sorted by spec: the agent's registry (pi's built-ins, its
|
|
126
|
+
* `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
|
|
127
|
+
* directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
|
|
128
|
+
* authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
|
|
125
129
|
*
|
|
126
130
|
* `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
|
|
127
131
|
* configured", so a client that must not show that as an empty list passes a sink that throws.
|
|
128
132
|
*/
|
|
129
133
|
export async function availableModelsFromDir(dir, options = {}) {
|
|
130
|
-
const { agentDir } = resolvePlacement(dir);
|
|
131
|
-
const models = await agentModels(agentDir, options).runtime();
|
|
132
|
-
return (await models.getAvailable())
|
|
134
|
+
const { agentDir, workspace } = resolvePlacement(dir);
|
|
135
|
+
const models = await agentModels(agentDir, options, { cwd: workspace }).runtime();
|
|
136
|
+
return describeModels(await models.getAvailable());
|
|
133
137
|
}
|
|
134
138
|
/**
|
|
135
139
|
* Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
|
|
@@ -148,10 +152,28 @@ export function refreshModelCatalog(dir, options = {}) {
|
|
|
148
152
|
export async function refreshModelCatalogOver(dir, options, catalogBaseUrl) {
|
|
149
153
|
const { agentDir } = resolvePlacement(dir);
|
|
150
154
|
const { credentials } = agentModels(agentDir, options);
|
|
151
|
-
await refreshCatalog((catalogFile) => createPiModelRuntime({
|
|
155
|
+
await refreshCatalog(join(agentDir, AGENT_MODEL_CATALOG_FILE), (catalogFile) => createPiModelRuntime({
|
|
152
156
|
agentDir,
|
|
153
157
|
credentials,
|
|
154
|
-
|
|
158
|
+
catalogFile,
|
|
159
|
+
...(catalogBaseUrl ? { catalogBaseUrl } : {}),
|
|
160
|
+
}), options.signal ? { signal: options.signal } : {});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Refresh the MACHINE's model catalog (`~/.fastagent/models-store.json`), which every agent here reads under its own,
|
|
164
|
+
* so one refresh serves them all and no file is written into any agent. Credentials resolve as for
|
|
165
|
+
* {@link refreshModelCatalog} without a directory: `credentialStore`, else `authPath`, else the global credentials file;
|
|
166
|
+
* the environment applies either way. Rejects for the same reasons. The file never ships with a deploy.
|
|
167
|
+
*/
|
|
168
|
+
export function refreshMachineModelCatalog(options = {}) {
|
|
169
|
+
return refreshMachineModelCatalogOver(options);
|
|
170
|
+
}
|
|
171
|
+
/** {@link refreshMachineModelCatalog} against another catalog server. Not public: a test seam. */
|
|
172
|
+
export async function refreshMachineModelCatalogOver(options, catalogBaseUrl) {
|
|
173
|
+
const { credentials } = agentModels(undefined, options);
|
|
174
|
+
await refreshCatalog(globalCatalogPath(), (catalogFile) => machineModelRuntime({
|
|
175
|
+
credentials,
|
|
176
|
+
catalogFile,
|
|
155
177
|
...(catalogBaseUrl ? { catalogBaseUrl } : {}),
|
|
156
178
|
}), options.signal ? { signal: options.signal } : {});
|
|
157
179
|
}
|
|
@@ -161,7 +183,7 @@ export async function refreshModelCatalogOver(dir, options, catalogBaseUrl) {
|
|
|
161
183
|
*/
|
|
162
184
|
export async function createPiAgentFromDir(dir, options = {}) {
|
|
163
185
|
const front = await resolveAgentAssembly(dir, options);
|
|
164
|
-
const { config, configPath, modelSpec, agentDir, workspace, stateRoot, models, tools, toolNames,
|
|
186
|
+
const { config, configPath, modelSpec, agentDir, workspace, stateRoot, models, tools, toolNames, indirectTools, toolCollisions, } = front;
|
|
165
187
|
// Every serve mounts the built-in `wake` tool: the agent's own follow-up work is a default capability, and a serve
|
|
166
188
|
// is where the poller that honors it runs. A one-shot `invoke` has no poller, so nothing there would fire it.
|
|
167
189
|
const mountedTools = withWakeTool(tools, stateRoot, !!options.serving);
|
|
@@ -201,7 +223,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
201
223
|
// the prompt would not expand — a second reading of "which skills exist" is how those two come to disagree.
|
|
202
224
|
commands: () => agentCommands(agentDir, workspace, {
|
|
203
225
|
extensionPaths: assembly.extensionPaths,
|
|
204
|
-
modelRuntime:
|
|
226
|
+
modelRuntime: assembly.createModelRuntime,
|
|
205
227
|
}),
|
|
206
228
|
// The caller tap's boundary-event half: state_changed/compaction_* originate in the hub and never cross the
|
|
207
229
|
// data plane's observer seam.
|
|
@@ -233,7 +255,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
233
255
|
sessionsDir,
|
|
234
256
|
models,
|
|
235
257
|
toolNames,
|
|
236
|
-
|
|
258
|
+
indirectTools,
|
|
237
259
|
toolCollisions,
|
|
238
260
|
};
|
|
239
261
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
6
6
|
import type { SkillCollision } from "./definition.ts";
|
|
7
7
|
import type { ToolCollision } from "./tool.ts";
|
|
8
|
+
import type { IndirectTool } from "./create.ts";
|
|
8
9
|
type Findings = {
|
|
9
10
|
collisions: SkillCollision[];
|
|
10
11
|
diagnostics: SkillDiagnostic[];
|
|
@@ -12,5 +13,7 @@ type Findings = {
|
|
|
12
13
|
/** THE door for definition findings: warns only when this dir's set CHANGED since the last report. */
|
|
13
14
|
export declare function reportFindingsIfChanged(dir: string, def: Findings): void;
|
|
14
15
|
export declare function reportDefinitionWarnings(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
|
|
16
|
+
/** The report line for the mounted tools the model is not given up front: each name with its way in. */
|
|
17
|
+
export declare function describeIndirectTools(tools: readonly IndirectTool[]): string;
|
|
15
18
|
export declare function reportToolCollisions(collisions: ToolCollision[]): void;
|
|
16
19
|
export {};
|
|
@@ -23,6 +23,17 @@ export function reportDefinitionWarnings(collisions, diagnostics) {
|
|
|
23
23
|
log.warn(`[fastagent] ${d.code}: ${d.message} (${d.path})`);
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
const REACH = {
|
|
27
|
+
tool_search: "loaded via tool_search",
|
|
28
|
+
codemode: "codemode scripts only",
|
|
29
|
+
hidden: "hidden",
|
|
30
|
+
inactive: "inactive until an authored loader activates it",
|
|
31
|
+
unreachable: "unreachable: pi's settings disable the built-in extension it needs",
|
|
32
|
+
};
|
|
33
|
+
/** The report line for the mounted tools the model is not given up front: each name with its way in. */
|
|
34
|
+
export function describeIndirectTools(tools) {
|
|
35
|
+
return tools.map((tool) => `${tool.name} (${REACH[tool.reach]})`).join(", ");
|
|
36
|
+
}
|
|
26
37
|
export function reportToolCollisions(collisions) {
|
|
27
38
|
for (const c of collisions) {
|
|
28
39
|
log.warn(`[fastagent] tool "${c.name}" (${c.source}) dropped — a default/config tool already uses that name`);
|
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
-
import { SessionManager, SettingsManager, createAgentSessionRuntime,
|
|
8
|
-
import { bindPiSession,
|
|
9
|
-
import { readMachine } from "./machine.js";
|
|
7
|
+
import { SessionManager, SettingsManager, createAgentSessionRuntime, getAgentDir, } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { bindPiSession, definitionServices } from "./agent-session-factory.js";
|
|
10
9
|
import { resolveModel } from "./config.js";
|
|
11
10
|
import { canonicalPath } from "./definition.js";
|
|
12
11
|
import { reportToolCollisions } from "./report.js";
|
|
@@ -42,29 +41,14 @@ export async function buildAgentSessionRuntime(dir, options = {}, sessionManager
|
|
|
42
41
|
const createRuntime = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
43
42
|
const { modelSpec, assembly, definition } = await assemblyFor(cwd);
|
|
44
43
|
// Per session, NOT memoized with the assembly.
|
|
45
|
-
const
|
|
46
|
-
const loaded = await
|
|
47
|
-
cwd,
|
|
48
|
-
// fastagent's models + auth hub replaces pi's default (~/.pi-backed) one — the auth unification point.
|
|
49
|
-
modelRuntime,
|
|
50
|
-
// PACKAGELESS, for the loader: fastagent never installs a pi package (machine.ts). Handed pi's own settings, the
|
|
51
|
-
// loader resolves `packages` itself — installing a missing one, and failing chat's start when that fails.
|
|
52
|
-
settingsManager: machine.settingsManager(),
|
|
53
|
-
resourceLoaderOptions: definitionResourceLoaderOptions({
|
|
54
|
-
systemPrompt: () => definition.systemPrompt,
|
|
55
|
-
skills: () => definition.skills,
|
|
56
|
-
machine,
|
|
57
|
-
extensionPaths: assembly.extensionPaths,
|
|
58
|
-
}),
|
|
59
|
-
});
|
|
44
|
+
const modelRuntime = await assembly.createModelRuntime();
|
|
45
|
+
const loaded = await definitionServices({ cwd, modelRuntime, definition, extensionPaths: assembly.extensionPaths });
|
|
60
46
|
// ...while the SESSION keeps pi's own file-backed settings, so `/settings` in the TUI still saves. pi persists by
|
|
61
47
|
// re-reading the file under its lock and writing only the fields that changed, so `packages` there is untouched.
|
|
62
48
|
const services = { ...loaded, settingsManager: SettingsManager.create(cwd, loaded.agentDir) };
|
|
63
|
-
reportExtensionErrors(services);
|
|
64
49
|
// AFTER the services, because an extension may be what defines the model.
|
|
65
50
|
const model = resolveModel(modelRuntime, modelSpec);
|
|
66
|
-
//
|
|
67
|
-
// the documented divergence.
|
|
51
|
+
// Serving and chat restore the same native transcript declarations.
|
|
68
52
|
const result = await bindPiSession({
|
|
69
53
|
services,
|
|
70
54
|
sessionManager,
|
|
@@ -74,9 +58,9 @@ export async function buildAgentSessionRuntime(dir, options = {}, sessionManager
|
|
|
74
58
|
// is the definition's (`thinkingLevel` in fastagent.config.ts), like serving's.
|
|
75
59
|
thinkingLevel: assembly.thinkingLevel,
|
|
76
60
|
tools: assembly.tools,
|
|
61
|
+
excludedToolNames: assembly.excludedToolNames,
|
|
77
62
|
// A tool must see one spelling of the workspace, including when opened through a symlink.
|
|
78
63
|
cwd: rootCwd,
|
|
79
|
-
recordActivations: false,
|
|
80
64
|
});
|
|
81
65
|
return { ...result, services, diagnostics: services.diagnostics };
|
|
82
66
|
};
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
16
16
|
import { type Models } from "@earendil-works/pi-ai";
|
|
17
17
|
import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
|
|
18
|
-
import type
|
|
18
|
+
import { type SessionObserver, type Lease } from "./turn-kit.ts";
|
|
19
19
|
import type { AnyModel } from "./models.ts";
|
|
20
20
|
import type { PiAgentSessionFactory } from "./invoke-session.ts";
|
|
21
21
|
import type { PiSessionRecordStore } from "./session-store.ts";
|
|
@@ -25,9 +25,9 @@ export declare const SUBSCRIBER_BUFFER_CAP = 10000;
|
|
|
25
25
|
export declare const SUBSCRIBER_BUFFER_BYTES: number;
|
|
26
26
|
/** What the plane's writes (`update` / `compact` / `fork` / `delete`) need — the SAME instances the
|
|
27
27
|
* agent assembly uses: the lease (a write must not race a run), the model registry (validation +
|
|
28
|
-
* allowedModels), and the session factory (compaction is a model call).
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* allowedModels), and the session factory (compaction is a model call). Two writes create a record:
|
|
29
|
+
* fork's `into`, and `update()` on an id that has none (through the store's `openOrCreate`, like an
|
|
30
|
+
* invoke). Every other write needs a record that exists. */
|
|
31
31
|
export interface PiBoundaryWiring {
|
|
32
32
|
lease: Lease;
|
|
33
33
|
models: Models;
|
|
@@ -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. */
|