@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
|
@@ -7,11 +7,12 @@ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
|
7
7
|
import { createCodingTools, createPowerShellTool, createReadOnlyTools, } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { resolveModel } from "./config.js";
|
|
9
9
|
import { isAgentcoreRuntime, isDeployedWorkspace } from "../../paths.js";
|
|
10
|
-
import { loadAgentDefinition
|
|
10
|
+
import { loadAgentDefinition } from "./definition.js";
|
|
11
11
|
import { reportFindingsIfChanged } from "./report.js";
|
|
12
|
-
import {
|
|
12
|
+
import { log } from "../../log.js";
|
|
13
|
+
import { BUILTIN_EXTENSIONS, DISCOVERY, readMachine } from "./machine.js";
|
|
14
|
+
import { loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
13
15
|
import { readSecretDeclaration } from "../../declared-secrets.js";
|
|
14
|
-
import { withSearchTool } from "./search-tools.js";
|
|
15
16
|
import { createPiAgentFromSession } from "./invoke-session.js";
|
|
16
17
|
import { piAgentSessionFactory } from "./agent-session-factory.js";
|
|
17
18
|
import { DEFAULT_THINKING_LEVEL } from "./models.js";
|
|
@@ -57,6 +58,32 @@ function omittedBuiltinNames(mounted, cwd) {
|
|
|
57
58
|
const mountedNames = new Set(mounted.map((tool) => tool.name));
|
|
58
59
|
return [...new Set(piRegisteredToolNames(cwd))].filter((name) => !mountedNames.has(name));
|
|
59
60
|
}
|
|
61
|
+
function isDefaultActiveTool(tool) {
|
|
62
|
+
return ((!tool.exposure || tool.exposure === "direct" || tool.exposure === "model-only") && tool.defaultActive !== false);
|
|
63
|
+
}
|
|
64
|
+
/** The built-in extension a `codemode`/`deferred` tool is reached through ({@link DISCOVERY}). */
|
|
65
|
+
function wayIn(tool) {
|
|
66
|
+
return tool.exposure === "codemode" || tool.exposure === "deferred" ? DISCOVERY[tool.exposure].extension : undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Whether this machine leaves the tool's way in enabled (a tool that needs none always has one). */
|
|
69
|
+
function hasWayIn(tool, builtinExtensions) {
|
|
70
|
+
const extension = wayIn(tool);
|
|
71
|
+
return extension === undefined || builtinExtensions.includes(extension);
|
|
72
|
+
}
|
|
73
|
+
function indirectReach(tool, builtinExtensions) {
|
|
74
|
+
if (isDefaultActiveTool(tool))
|
|
75
|
+
return undefined;
|
|
76
|
+
if (!hasWayIn(tool, builtinExtensions))
|
|
77
|
+
return "unreachable";
|
|
78
|
+
if (tool.exposure === "deferred")
|
|
79
|
+
return "tool_search";
|
|
80
|
+
if (tool.exposure === "codemode")
|
|
81
|
+
return "codemode";
|
|
82
|
+
if (tool.exposure === "hidden")
|
|
83
|
+
return "hidden";
|
|
84
|
+
// `defaultActive: false`: only an authored loader (`ToolContext.tools.activate`) brings it in.
|
|
85
|
+
return "inactive";
|
|
86
|
+
}
|
|
60
87
|
/**
|
|
61
88
|
* The full directory-agent tool set: all pi coding tools + `config.tools` + discovered `tools/` (deduped, existing
|
|
62
89
|
* win), plus the authored names and collisions to report.
|
|
@@ -82,24 +109,22 @@ export async function resolveAgentTools(config, agentDir, cwd) {
|
|
|
82
109
|
mountedConfigTools.push(tool);
|
|
83
110
|
}
|
|
84
111
|
const merged = mergeDiscoveredTools(configured, discovered.tools);
|
|
85
|
-
|
|
86
|
-
const tools = withSearchTool(merged.tools);
|
|
87
|
-
// Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire on the
|
|
88
|
-
// deferred-authored-loader case, where withSearchTool returns a new array without adding one).
|
|
89
|
-
const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
|
|
112
|
+
const tools = merged.tools;
|
|
90
113
|
const toolCollisions = [...discovered.collisions, ...configuredCollisions, ...merged.collisions];
|
|
91
114
|
// `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/).
|
|
92
115
|
// The discovered tools that were DROPPED (a coding tool or config.tools already owns the name):
|
|
93
116
|
// asking the mounted set instead would read the winner's name as proof the loser is mounted.
|
|
94
117
|
const shadowed = new Set(merged.collisions.map((c) => c.name));
|
|
118
|
+
const { builtinExtensions } = await readMachine(cwd);
|
|
95
119
|
const defaultNames = new Set(CODING_TOOL_NAMES);
|
|
96
|
-
const toolNames = tools
|
|
97
|
-
.filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
|
|
98
|
-
.map((t) => t.name);
|
|
120
|
+
const toolNames = tools.filter((t) => !defaultNames.has(t.name) && isDefaultActiveTool(t)).map((t) => t.name);
|
|
99
121
|
return {
|
|
100
122
|
tools,
|
|
101
123
|
toolNames,
|
|
102
|
-
|
|
124
|
+
indirectTools: tools.flatMap((t) => {
|
|
125
|
+
const reach = defaultNames.has(t.name) ? undefined : indirectReach(t, builtinExtensions);
|
|
126
|
+
return reach ? [{ name: t.name, reach }] : [];
|
|
127
|
+
}),
|
|
103
128
|
toolCollisions,
|
|
104
129
|
toolFailures: discovered.failures,
|
|
105
130
|
// Mounted only, and config.tools are FastagentTools too, so a programmatic tool declares the
|
|
@@ -124,10 +149,14 @@ export async function resolveAgentTools(config, agentDir, cwd) {
|
|
|
124
149
|
/** The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two deviations. */
|
|
125
150
|
export function piBasePrompt(options = {}) {
|
|
126
151
|
const mounted = options.tools ?? [];
|
|
152
|
+
const builtinExtensions = options.builtinExtensions ?? BUILTIN_EXTENSIONS;
|
|
127
153
|
// Deferred tools stay OUT of the list: their schemas are not in the request until activated, so naming them here
|
|
128
154
|
// would invite calls to tools that don't exist yet.
|
|
129
|
-
const tools = mounted.filter(
|
|
130
|
-
|
|
155
|
+
const tools = mounted.filter(isDefaultActiveTool);
|
|
156
|
+
// Only `deferred` tools are tool_search's to load (the session activates it for them); `codemode` ones are listed
|
|
157
|
+
// in codemode's own description, and an inactive direct tool is reached only through an authored activation.
|
|
158
|
+
// A disabled tool-search leaves them no way in, and naming tool_search would invite calls to a tool that is not there.
|
|
159
|
+
const deferredCount = mounted.filter((tool) => tool.exposure === "deferred" && hasWayIn(tool, builtinExtensions)).length;
|
|
131
160
|
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
|
|
132
161
|
// Segment ① identity: an authored persona (persona.md) replaces the default engine identity line (core.md §2),
|
|
133
162
|
// keeping the tools list + guidelines below.
|
|
@@ -138,7 +167,7 @@ export function piBasePrompt(options = {}) {
|
|
|
138
167
|
? "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files."
|
|
139
168
|
: "You are an AI assistant operating inside pi, an agent harness. Help users using only the tools and context available to you.");
|
|
140
169
|
const deferredNote = deferredCount > 0
|
|
141
|
-
? `\n\n${deferredCount} additional tool(s) are registered but
|
|
170
|
+
? `\n\n${deferredCount} additional tool(s) are registered but not loaded — use tool_search to find and load them before concluding a capability is missing.`
|
|
142
171
|
: "";
|
|
143
172
|
// What takes effect when, and the agent's own path to a new capability, are the same on every host; only how long
|
|
144
173
|
// the storage lives differs, below. The skill it writes lives in the definition directory, which the next
|
|
@@ -188,16 +217,10 @@ function assemblePi(opts) {
|
|
|
188
217
|
// — boundary mutations must contend on it.
|
|
189
218
|
const lease = opts.lease ?? inProcessLease();
|
|
190
219
|
const sessions = opts.sessions ?? piInMemorySessionRecordStore({ cwd });
|
|
191
|
-
|
|
220
|
+
const createModelRuntime = opts.models;
|
|
192
221
|
let registry;
|
|
193
222
|
const modelRuntime = () => {
|
|
194
|
-
registry ??= opts.
|
|
195
|
-
// ModelRuntime registers providers by config record, so an injected Provider INSTANCE (a gateway, a self-hosted
|
|
196
|
-
// endpoint, a test fake) goes in through its native seam.
|
|
197
|
-
for (const provider of opts.providers ?? [])
|
|
198
|
-
runtime.registerNativeProvider(provider);
|
|
199
|
-
return runtime;
|
|
200
|
-
});
|
|
223
|
+
registry ??= opts.catalog();
|
|
201
224
|
return registry;
|
|
202
225
|
};
|
|
203
226
|
let engine;
|
|
@@ -205,25 +228,28 @@ function assemblePi(opts) {
|
|
|
205
228
|
engine ??= modelRuntime().then((runtime) => ({ modelRuntime: runtime, model: resolveModel(runtime, opts.model) }));
|
|
206
229
|
return engine;
|
|
207
230
|
};
|
|
231
|
+
// Deny omitted coding names so discovery cannot reintroduce tools a lower-level caller excluded.
|
|
232
|
+
const excludedToolNames = omittedBuiltinNames(opts.tools ?? [], cwd);
|
|
208
233
|
const sessionFactory = piAgentSessionFactory({
|
|
209
234
|
sessions,
|
|
210
|
-
engine:
|
|
235
|
+
engine: async () => ({ modelRuntime: await createModelRuntime() }),
|
|
236
|
+
modelSpec: opts.model,
|
|
211
237
|
thinkingLevel: opts.thinkingLevel,
|
|
212
238
|
tools: opts.tools,
|
|
213
239
|
readDefinition: opts.readDefinition,
|
|
214
240
|
cwd,
|
|
215
241
|
...(opts.extensionPaths ? { extensionPaths: opts.extensionPaths } : {}),
|
|
216
|
-
|
|
217
|
-
// omitted coding name so a loader cannot reactivate one later.
|
|
218
|
-
excludedToolNames: omittedBuiltinNames(opts.tools ?? [], cwd),
|
|
242
|
+
excludedToolNames,
|
|
219
243
|
});
|
|
220
244
|
return {
|
|
221
245
|
lease,
|
|
222
246
|
sessionFactory,
|
|
223
247
|
modelRuntime,
|
|
248
|
+
createModelRuntime,
|
|
224
249
|
engine: resolveEngine,
|
|
225
250
|
thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
|
|
226
251
|
tools: opts.tools ?? [],
|
|
252
|
+
excludedToolNames,
|
|
227
253
|
readDefinition: opts.readDefinition,
|
|
228
254
|
extensionPaths: opts.extensionPaths ?? [],
|
|
229
255
|
};
|
|
@@ -235,17 +261,17 @@ export function agentOf(assembly, observer) {
|
|
|
235
261
|
/** L1: assemble from typed parts. */
|
|
236
262
|
export function createPiAgent(options) {
|
|
237
263
|
const { instructions, skills = [] } = options;
|
|
264
|
+
const models = agentModels(undefined, options, { providers: options.providers });
|
|
238
265
|
return agentOf(assemblePi({
|
|
239
266
|
model: options.model,
|
|
240
267
|
thinkingLevel: options.thinkingLevel,
|
|
241
|
-
|
|
242
|
-
|
|
268
|
+
models: models.createRuntime,
|
|
269
|
+
catalog: models.runtime,
|
|
243
270
|
readDefinition: () => ({
|
|
244
271
|
systemPrompt: typeof instructions === "function" ? instructions() : instructions,
|
|
245
272
|
skills,
|
|
246
273
|
}),
|
|
247
|
-
|
|
248
|
-
tools: options.tools ? withSearchTool(options.tools) : options.tools,
|
|
274
|
+
tools: options.tools,
|
|
249
275
|
sessions: options.sessions,
|
|
250
276
|
env: options.env,
|
|
251
277
|
lease: options.lease,
|
|
@@ -264,21 +290,27 @@ export async function assemblePiFromDefinition(dir, options) {
|
|
|
264
290
|
// Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to report
|
|
265
291
|
// (skills/diagnostics/collisions).
|
|
266
292
|
const definition = await loadAgentDefinition(dir, { cwd, env });
|
|
267
|
-
|
|
268
|
-
// own search_tools wins).
|
|
269
|
-
const tools = withSearchTool(options.tools ?? piAllCodingTools(cwd));
|
|
293
|
+
const tools = options.tools ?? piAllCodingTools(cwd);
|
|
270
294
|
// Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by the resolved
|
|
271
295
|
// dir).
|
|
272
296
|
reportFindingsIfChanged(definition.dir, definition);
|
|
273
297
|
const { providers } = options;
|
|
274
|
-
const models = options.models ?? agentModels(dir, options, providers ? { providers } : {});
|
|
298
|
+
const models = options.models ?? agentModels(dir, options, { cwd, env, ...(providers ? { providers } : {}) });
|
|
275
299
|
// Built at boot, so a malformed models.json fails the assembly rather than its first turn. The directory's own
|
|
276
300
|
// models.json is what a turn resolves against, layered over the machine's (models.ts).
|
|
277
301
|
await models.runtime();
|
|
302
|
+
const { builtinExtensions } = await readMachine(cwd);
|
|
303
|
+
const unreachable = tools.filter((tool) => !hasWayIn(tool, builtinExtensions));
|
|
304
|
+
// Said once per assembly: the model cannot call these at all, while the tools themselves loaded fine.
|
|
305
|
+
for (const tool of unreachable) {
|
|
306
|
+
log.warn(`[fastagent] tool "${tool.name}" (exposure: ${tool.exposure}) cannot be reached: pi's settings disable ` +
|
|
307
|
+
`builtin:${wayIn(tool)}, its only way in`);
|
|
308
|
+
}
|
|
278
309
|
const assembly = assemblePi({
|
|
279
310
|
model: options.model,
|
|
280
311
|
thinkingLevel: options.thinkingLevel,
|
|
281
|
-
models: models.
|
|
312
|
+
models: models.createRuntime,
|
|
313
|
+
catalog: models.runtime,
|
|
282
314
|
// The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills edits (the
|
|
283
315
|
// author's, or the agent's own self-modification) take effect on the next turn with no process restart — restarts
|
|
284
316
|
// are reserved for code (tools/channels/config, module cache).
|
|
@@ -289,7 +321,7 @@ export async function assemblePiFromDefinition(dir, options) {
|
|
|
289
321
|
systemPrompt: assembleSystemPrompt({
|
|
290
322
|
// Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity, re-read per turn
|
|
291
323
|
// like AGENTS.md so edits go live.
|
|
292
|
-
base: options.base ?? piBasePrompt({ tools, persona: def.persona }),
|
|
324
|
+
base: options.base ?? piBasePrompt({ tools, persona: def.persona, builtinExtensions }),
|
|
293
325
|
// ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
|
|
294
326
|
contextFiles: def.contextFiles,
|
|
295
327
|
}),
|
|
@@ -298,7 +330,8 @@ export async function assemblePiFromDefinition(dir, options) {
|
|
|
298
330
|
},
|
|
299
331
|
tools,
|
|
300
332
|
sessions: options.sessions,
|
|
301
|
-
|
|
333
|
+
// The catalog's own discovery: the models it registered and the extensions sessions load are one list.
|
|
334
|
+
extensionPaths: [...(await models.extensionPaths())],
|
|
302
335
|
cwd,
|
|
303
336
|
env,
|
|
304
337
|
lease: options.lease,
|
|
@@ -10,7 +10,12 @@ import { log } from "../../log.js";
|
|
|
10
10
|
import { toRetryScheduledEvent } from "./retry-event.js";
|
|
11
11
|
import { PortFailure, port, portAbort, portCleanup, portError } from "../../effect-port.js";
|
|
12
12
|
import { SessionBusy, acquireSession, acquireSessionLease } from "./session-effects.js";
|
|
13
|
-
import { errorToTerminal, inProcessLease,
|
|
13
|
+
import { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
|
|
14
|
+
// Structured output is for scripts. Observation publishes Pi's display result, including bounded nested traces.
|
|
15
|
+
function displayToolResult(result) {
|
|
16
|
+
const { structuredContent: _internal, ...display } = result;
|
|
17
|
+
return display;
|
|
18
|
+
}
|
|
14
19
|
function toSessionEvent(event, runId) {
|
|
15
20
|
const at = Date.now();
|
|
16
21
|
switch (event.type) {
|
|
@@ -33,30 +38,47 @@ function toSessionEvent(event, runId) {
|
|
|
33
38
|
}
|
|
34
39
|
return null;
|
|
35
40
|
}
|
|
36
|
-
case "message_end":
|
|
41
|
+
case "message_end": {
|
|
37
42
|
if (event.message.role !== "assistant")
|
|
38
43
|
return null;
|
|
39
|
-
|
|
44
|
+
const outcome = answerOutcome(event.message);
|
|
45
|
+
return { type: "message_finished", timestamp: at, runId, data: outcome ? { outcome } : {} };
|
|
46
|
+
}
|
|
40
47
|
case "tool_execution_start":
|
|
41
48
|
return {
|
|
42
49
|
type: "tool_started",
|
|
43
50
|
timestamp: at,
|
|
44
51
|
runId,
|
|
45
|
-
data: {
|
|
52
|
+
data: {
|
|
53
|
+
id: event.toolCallId,
|
|
54
|
+
name: event.toolName,
|
|
55
|
+
args: event.args,
|
|
56
|
+
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
57
|
+
},
|
|
46
58
|
};
|
|
47
59
|
case "tool_execution_update":
|
|
48
60
|
return {
|
|
49
61
|
type: "tool_progress",
|
|
50
62
|
timestamp: at,
|
|
51
63
|
runId,
|
|
52
|
-
data: {
|
|
64
|
+
data: {
|
|
65
|
+
id: event.toolCallId,
|
|
66
|
+
name: event.toolName,
|
|
67
|
+
partialResult: displayToolResult(event.partialResult),
|
|
68
|
+
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
69
|
+
},
|
|
53
70
|
};
|
|
54
71
|
case "tool_execution_end":
|
|
55
72
|
return {
|
|
56
73
|
type: "tool_finished",
|
|
57
74
|
timestamp: at,
|
|
58
75
|
runId,
|
|
59
|
-
data: {
|
|
76
|
+
data: {
|
|
77
|
+
id: event.toolCallId,
|
|
78
|
+
isError: event.isError,
|
|
79
|
+
content: displayToolResult(event.result),
|
|
80
|
+
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
81
|
+
},
|
|
60
82
|
};
|
|
61
83
|
case "queue_update":
|
|
62
84
|
return {
|
|
@@ -125,10 +147,27 @@ export function createPiAgentFromSession(options) {
|
|
|
125
147
|
const abort = new AbortController();
|
|
126
148
|
onCancelReady(() => abort.abort());
|
|
127
149
|
const runId = crypto.randomUUID();
|
|
150
|
+
const project = agentEventProjection();
|
|
151
|
+
/**
|
|
152
|
+
* Each call's `tool_progress` still waiting in the queue. A newer status replaces its text instead of queuing
|
|
153
|
+
* behind it, so the queue holds at most one per running call, and a consumer that falls behind (a slow `/invoke`
|
|
154
|
+
* client) gets the latest status, not every snapshot in between: pi's bash reports every 100 ms, and a single
|
|
155
|
+
* growing line is up to 50 KB each time. The replaced event keeps its place, which moves a status earlier, never
|
|
156
|
+
* past its own call's `tool_ended`: pi takes no progress from a call once it has returned.
|
|
157
|
+
*/
|
|
158
|
+
const unreadProgress = new Map();
|
|
128
159
|
let settled = false;
|
|
129
160
|
let outcome;
|
|
130
161
|
let abortsInFlight = 0;
|
|
131
162
|
let abortSucceeded = false;
|
|
163
|
+
/**
|
|
164
|
+
* An abort is owed to every model request this invoke makes, including one not yet started. pi's `abort()` stops
|
|
165
|
+
* only an agent run or a compaction already in flight: one that lands while `prompt()` prepares (input handlers,
|
|
166
|
+
* the auth check, `before_agent_start`) finds nothing to stop, and `_runAgentPrompt` then clears pi's own flag.
|
|
167
|
+
* So the run remembers it and aborts each agent run and automatic compaction as it starts (`agent_start`,
|
|
168
|
+
* `compaction_start`: both emitted once pi's controller for it exists).
|
|
169
|
+
*/
|
|
170
|
+
let abortRequested = false;
|
|
132
171
|
const observe = (event, run) => {
|
|
133
172
|
if (!event || !observer)
|
|
134
173
|
return;
|
|
@@ -144,25 +183,38 @@ export function createPiAgentFromSession(options) {
|
|
|
144
183
|
* Every control command obeys one admission rule: wait for this run's binding, then refuse once it has settled.
|
|
145
184
|
* Kept in one place so a fourth command cannot arrive with a fifth spelling of it.
|
|
146
185
|
*/
|
|
186
|
+
const alreadySettled = () => new PortFailure(new Error("run already settled; the command cannot take effect"));
|
|
147
187
|
const command = (run) => Effect.runPromise(Effect.gen(function* () {
|
|
148
188
|
const session = yield* ready;
|
|
149
189
|
if (settled)
|
|
150
|
-
return yield* Effect.fail(
|
|
151
|
-
yield* run(session);
|
|
190
|
+
return yield* Effect.fail(alreadySettled());
|
|
191
|
+
return yield* run(session);
|
|
152
192
|
}));
|
|
153
|
-
// Prompt preparation stays OUTSIDE admission
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
193
|
+
// Prompt preparation stays OUTSIDE admission: pi accepts a message for a finished run without complaint
|
|
194
|
+
// (`_steeringMessages.push`), so a run that ends between the settled check and the enqueue drops the command while
|
|
195
|
+
// it reports success. Image resizing would be such a gap, hundreds of milliseconds of dynamic import and Photon
|
|
196
|
+
// work, and doing it here also keeps it overlapping session acquisition. pi has a gap of its own: it awaits the
|
|
197
|
+
// definition's `input` handlers before it queues, so a `queued` answer is checked against settlement once more.
|
|
198
|
+
// A `handled` one stands either way: the handler took the prompt whatever became of the run.
|
|
157
199
|
const enqueue = async (p, kind) => {
|
|
158
200
|
const opts = await toPiPromptOptions(p, "queued");
|
|
159
|
-
return command((session) =>
|
|
201
|
+
return command((session) => Effect.gen(function* () {
|
|
202
|
+
const disposition = yield* port(() => session[kind](p.text, opts?.images));
|
|
203
|
+
if (disposition === "queued" && settled) {
|
|
204
|
+
// pi has queued it, and announced it (`queue_update`), on a run that takes nothing in any more. Emptying
|
|
205
|
+
// the queue tells the observation plane so, which would otherwise report the refused prompt pending.
|
|
206
|
+
session.clearQueue();
|
|
207
|
+
return yield* Effect.fail(alreadySettled());
|
|
208
|
+
}
|
|
209
|
+
return disposition;
|
|
210
|
+
}));
|
|
160
211
|
};
|
|
161
212
|
const controls = {
|
|
162
213
|
steer: (p) => enqueue(p, "steer"),
|
|
163
214
|
followUp: (p) => enqueue(p, "followUp"),
|
|
164
215
|
abort: () => command((session) => Effect.gen(function* () {
|
|
165
216
|
abortsInFlight++;
|
|
217
|
+
abortRequested = true;
|
|
166
218
|
yield* port(() => session.abort()).pipe(Effect.tap(() => Effect.sync(() => {
|
|
167
219
|
abortSucceeded = true;
|
|
168
220
|
})), Effect.ensuring(Effect.sync(() => {
|
|
@@ -190,9 +242,10 @@ export function createPiAgentFromSession(options) {
|
|
|
190
242
|
let streamedAnswer = false;
|
|
191
243
|
let retriedAfterAnswer;
|
|
192
244
|
let eventFailure;
|
|
193
|
-
const
|
|
194
|
-
void Effect.runPromise(portCleanup(
|
|
245
|
+
const abortWith = (operation) => {
|
|
246
|
+
void Effect.runPromise(portCleanup(operation, () => session.abort()));
|
|
195
247
|
};
|
|
248
|
+
const stop = () => abortWith("event-fault abort");
|
|
196
249
|
yield* Effect.acquireRelease(Effect.try({
|
|
197
250
|
try: () => session.subscribe((event) => {
|
|
198
251
|
if (retriedAfterAnswer !== undefined || eventFailure)
|
|
@@ -200,6 +253,8 @@ export function createPiAgentFromSession(options) {
|
|
|
200
253
|
try {
|
|
201
254
|
if (event.type === "agent_start")
|
|
202
255
|
runStarted = true;
|
|
256
|
+
if ((event.type === "agent_start" || event.type === "compaction_start") && abortRequested)
|
|
257
|
+
abortWith("requested abort");
|
|
203
258
|
// Compaction rewrites session history; the event's assistant message is the turn's fact.
|
|
204
259
|
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
205
260
|
finalAssistant = event.message;
|
|
@@ -222,11 +277,19 @@ export function createPiAgentFromSession(options) {
|
|
|
222
277
|
observe(rich);
|
|
223
278
|
if (!rich)
|
|
224
279
|
return;
|
|
225
|
-
const projected =
|
|
280
|
+
const projected = project(rich);
|
|
226
281
|
if (!projected)
|
|
227
282
|
return;
|
|
228
283
|
if (projected.type === "text" || projected.type === "thinking")
|
|
229
284
|
streamedAnswer = true;
|
|
285
|
+
if (projected.type === "tool_progress") {
|
|
286
|
+
const unread = unreadProgress.get(projected.id);
|
|
287
|
+
if (unread) {
|
|
288
|
+
unread.text = projected.text;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
unreadProgress.set(projected.id, projected);
|
|
292
|
+
}
|
|
230
293
|
Queue.offerUnsafe(queue, projected);
|
|
231
294
|
}
|
|
232
295
|
catch (error) {
|
|
@@ -318,6 +381,9 @@ export function createPiAgentFromSession(options) {
|
|
|
318
381
|
}
|
|
319
382
|
else if (event.type === "completed")
|
|
320
383
|
outcome = { status: "completed" };
|
|
384
|
+
// Handed over: the consumer owns it now, and the next status queues afresh.
|
|
385
|
+
if (event.type === "tool_progress")
|
|
386
|
+
unreadProgress.delete(event.id);
|
|
321
387
|
yield event;
|
|
322
388
|
}
|
|
323
389
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface LockResult<T> {
|
|
2
|
+
result: T;
|
|
3
|
+
/** New file content to persist under the lock; undefined = no write. */
|
|
4
|
+
next?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
|
|
7
|
+
export declare function withLockedFile<T>(path: string, fn: (current: string | undefined) => Promise<LockResult<T>>, options?: {
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one cross-process read-modify-write for a JSON file several processes share: fastagent's credentials file and a
|
|
3
|
+
* model catalog. It takes the lock pi takes for the same files (pi-coding-agent's `FileAuthStorageBackend`), so a
|
|
4
|
+
* fastagent writer and a pi writer queue behind each other, and it REPLACES the file rather than rewriting it in
|
|
5
|
+
* place, which is what lets every reader of these files read without the lock: a reader sees the old file or the new
|
|
6
|
+
* one, never one cut short.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import lockfile from "proper-lockfile";
|
|
11
|
+
import { writeFileAtomic } from "../../atomic-write.js";
|
|
12
|
+
import { SECRET_FILE_MODE } from "../../paths.js";
|
|
13
|
+
const WRITE_OPTIONS = { encoding: "utf8", mode: SECRET_FILE_MODE };
|
|
14
|
+
/**
|
|
15
|
+
* The file a write must land IN, which is not always the path it is addressed BY. pi writes these files in place,
|
|
16
|
+
* so it follows a symlink to the target; an atomic rename would replace the link with a regular file instead, and
|
|
17
|
+
* from then on the two tools would be editing different files — the same split the shared lock exists to prevent,
|
|
18
|
+
* arriving one write later. A dangling link (a dotfile manager that has not checked the target out yet) still names
|
|
19
|
+
* where the content belongs, so it is followed too rather than overwritten.
|
|
20
|
+
*/
|
|
21
|
+
function writeTarget(path) {
|
|
22
|
+
if (existsSync(path))
|
|
23
|
+
return realpathSync(path);
|
|
24
|
+
const link = lstatSync(path, { throwIfNoEntry: false });
|
|
25
|
+
// ponytail: one hop. A chain of dangling links would have its second link replaced; resolve iteratively if that
|
|
26
|
+
// ever shows up in a real layout.
|
|
27
|
+
return link?.isSymbolicLink() ? resolve(dirname(path), readlinkSync(path)) : path;
|
|
28
|
+
}
|
|
29
|
+
/** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
|
|
30
|
+
export async function withLockedFile(path, fn, options = {}) {
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
try {
|
|
34
|
+
// `wx` makes this an exclusive CREATE, which is the one case `writeFileSync` honours `mode` in — so no chmod
|
|
35
|
+
// after it (unlike writeFileAtomic, whose temp may already exist from a crashed writer).
|
|
36
|
+
writeFileSync(path, "{}", { ...WRITE_OPTIONS, flag: "wx" });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// EEXIST: the path IS taken, by something `existsSync` does not see through — either another process created
|
|
40
|
+
// the file just now (its content must not be clobbered) or the path is a dangling symlink. Both are left
|
|
41
|
+
// alone: the write below resolves where they belong.
|
|
42
|
+
if (error.code !== "EEXIST")
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let compromised;
|
|
47
|
+
const throwIfCompromised = () => {
|
|
48
|
+
if (compromised)
|
|
49
|
+
throw compromised;
|
|
50
|
+
};
|
|
51
|
+
const release = await lockfile.lock(path, {
|
|
52
|
+
// MUST match pi-coding-agent's `core/auth-storage.js`, which locks with `realpath: false`. When the file
|
|
53
|
+
// itself is a symlink (dotfile managers do this), the two settings name DIFFERENT lock files and both locks can
|
|
54
|
+
// be held at once — so `pi` and `fastagent` would refresh the same provider concurrently, and a rotated refresh
|
|
55
|
+
// token logs one of them out. Resolving the link would be the stronger rule, but only if BOTH sides did it, and
|
|
56
|
+
// we do not own the other side.
|
|
57
|
+
realpath: false,
|
|
58
|
+
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
|
|
59
|
+
stale: 30_000,
|
|
60
|
+
onCompromised: (error) => {
|
|
61
|
+
compromised = error;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
throwIfCompromised();
|
|
67
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
68
|
+
const out = await fn(current);
|
|
69
|
+
throwIfCompromised();
|
|
70
|
+
// The last moment a caller that gave up can keep its change off disk: waiting for the lock may have outlasted it
|
|
71
|
+
// (another process held the lock through the caller's timeout or cancel).
|
|
72
|
+
options.signal?.throwIfAborted();
|
|
73
|
+
// Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only spelling that applies
|
|
74
|
+
// the mode before the content is reachable.
|
|
75
|
+
if (out.next !== undefined) {
|
|
76
|
+
writeFileAtomic(writeTarget(path), out.next, SECRET_FILE_MODE);
|
|
77
|
+
}
|
|
78
|
+
throwIfCompromised();
|
|
79
|
+
result = out.result;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// The primary failure stays the signal; unlock noise must not mask it.
|
|
83
|
+
try {
|
|
84
|
+
await release();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
// Success path: a failed release is a real cleanup failure (the leftover `<file>.lock` stalls the next writer for
|
|
92
|
+
// the staleness window with zero diagnostics), so it surfaces instead of resolving a silently degraded operation.
|
|
93
|
+
try {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
catch (releaseError) {
|
|
97
|
+
if (compromised === undefined)
|
|
98
|
+
throw releaseError;
|
|
99
|
+
}
|
|
100
|
+
throwIfCompromised();
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -31,6 +31,12 @@ export declare function loginOptions(credentials: string | CredentialStore): Pro
|
|
|
31
31
|
/** {@link loginOptions} over pi's built-ins plus `providers` (a same id replaces one). Not public: a test seam. */
|
|
32
32
|
export declare function loginOptionsOver(credentials: string | CredentialStore, providers?: readonly Provider[]): Promise<LoginOption[]>;
|
|
33
33
|
export type LoginRequest = {
|
|
34
|
+
/**
|
|
35
|
+
* Stable installation identity (Sign in with ChatGPT requires one). With `authPath` it defaults to Pi settings
|
|
36
|
+
* beside that file (`settings.json`, written before the flow runs); over a caller's `credentialStore` there is no
|
|
37
|
+
* file to keep it beside, so a flow that needs one fails unless this supplies it.
|
|
38
|
+
*/
|
|
39
|
+
getDeviceId?: () => string;
|
|
34
40
|
provider: string;
|
|
35
41
|
method: LoginMethod;
|
|
36
42
|
/** pi-ai's own interaction: prompts (`text`, `secret`, `select`, `manual_code`) and events (`auth_url`, …). */
|
|
@@ -63,7 +69,7 @@ export interface LoginResult {
|
|
|
63
69
|
* A provider an agent's models.json points elsewhere is not this call's: store that key with `fastagentCredentialStore`.
|
|
64
70
|
*
|
|
65
71
|
* Aborting `interaction.signal` at any point, the verification included, rejects with {@link LoginCancelled} and
|
|
66
|
-
* writes
|
|
72
|
+
* writes no credential (an OAuth device ID, the installation's identity, is kept once created). Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
|
|
67
73
|
* beating a `manual_code` prompt), and when the flow settles with a prompt still pending.
|
|
68
74
|
*/
|
|
69
75
|
export declare function login(request: LoginRequest): Promise<LoginResult>;
|
package/dist/engines/pi/login.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* one front end ({@link loginFlow}: terminal menus, then {@link login}); a GUI client is another.
|
|
6
6
|
*/
|
|
7
7
|
import { InMemoryCredentialStore, } from "@earendil-works/pi-ai";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
8
10
|
import { fastagentCredentialStore } from "./auth.js";
|
|
9
11
|
import { createPiModelRuntime, interactiveAuth, loginProviders, piModelsOver, probeApiKey } from "./models.js";
|
|
10
12
|
import { agentModels } from "./agent-models.js";
|
|
@@ -61,7 +63,7 @@ function anySignal(...signals) {
|
|
|
61
63
|
* A provider an agent's models.json points elsewhere is not this call's: store that key with `fastagentCredentialStore`.
|
|
62
64
|
*
|
|
63
65
|
* Aborting `interaction.signal` at any point, the verification included, rejects with {@link LoginCancelled} and
|
|
64
|
-
* writes
|
|
66
|
+
* writes no credential (an OAuth device ID, the installation's identity, is kept once created). Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
|
|
65
67
|
* beating a `manual_code` prompt), and when the flow settles with a prompt still pending.
|
|
66
68
|
*/
|
|
67
69
|
export function login(request) {
|
|
@@ -80,6 +82,7 @@ export async function loginOver(request, internals = {}) {
|
|
|
80
82
|
const auth = interactiveAuth(provider, method);
|
|
81
83
|
if (!auth?.login)
|
|
82
84
|
throw new Error(`provider "${provider.id}" has no interactive ${method} login`);
|
|
85
|
+
const flow = auth.login.bind(auth);
|
|
83
86
|
// The key under test lives here, not in the file, until it passes.
|
|
84
87
|
const trial = new InMemoryCredentialStore();
|
|
85
88
|
// Only a key needs the registry, and building an agent's fails on a broken models.json: an OAuth sign-in must not.
|
|
@@ -97,8 +100,21 @@ export async function loginOver(request, internals = {}) {
|
|
|
97
100
|
await store.modify(provider.id, async () => undefined);
|
|
98
101
|
const signal = interaction.signal ?? new AbortController().signal;
|
|
99
102
|
const notify = (event) => interaction.notify(event);
|
|
103
|
+
let getDeviceId = request.getDeviceId;
|
|
104
|
+
if (method === "oauth" && !getDeviceId && request.authPath !== undefined) {
|
|
105
|
+
// Only the settings file beside the credentials: no project's `.pi/settings.json` has a say in this write.
|
|
106
|
+
const settings = SettingsManager.create(dirname(request.authPath), dirname(request.authPath), {
|
|
107
|
+
projectTrusted: false,
|
|
108
|
+
});
|
|
109
|
+
const deviceId = settings.getOrCreateDeviceId();
|
|
110
|
+
await settings.flush();
|
|
111
|
+
const errors = settings.drainErrors();
|
|
112
|
+
if (errors.length)
|
|
113
|
+
throw new Error(`could not persist login device ID: ${errors.map(({ error }) => error.message).join("; ")}`);
|
|
114
|
+
getDeviceId = () => deviceId;
|
|
115
|
+
}
|
|
100
116
|
for (;;) {
|
|
101
|
-
const credential = await runFlow(
|
|
117
|
+
const credential = await runFlow((io) => flow(io, { getDeviceId }), interaction, signal);
|
|
102
118
|
let verified = "n/a";
|
|
103
119
|
if (models) {
|
|
104
120
|
await trial.modify(provider.id, async () => credential);
|
|
@@ -150,7 +166,7 @@ export const PI_DEFAULT_MODELS = {
|
|
|
150
166
|
anthropic: "claude-opus-4-8",
|
|
151
167
|
openai: "gpt-5.5",
|
|
152
168
|
"azure-openai-responses": "gpt-5.4",
|
|
153
|
-
"openai-codex": "gpt-
|
|
169
|
+
"openai-codex": "gpt-6.1-sol",
|
|
154
170
|
radius: "balanced",
|
|
155
171
|
nvidia: "nvidia/nemotron-3-super-120b-a12b",
|
|
156
172
|
deepseek: "deepseek-v4-pro",
|
|
@@ -170,11 +186,11 @@ export const PI_DEFAULT_MODELS = {
|
|
|
170
186
|
moonshotai: "kimi-k2.6",
|
|
171
187
|
"moonshotai-cn": "kimi-k2.6",
|
|
172
188
|
huggingface: "moonshotai/Kimi-K2.6",
|
|
173
|
-
fireworks: "accounts/fireworks/models/kimi-
|
|
174
|
-
together: "moonshotai/Kimi-
|
|
189
|
+
fireworks: "accounts/fireworks/models/kimi-k3",
|
|
190
|
+
together: "moonshotai/Kimi-K3",
|
|
175
191
|
baseten: "zai-org/GLM-5.2",
|
|
176
192
|
opencode: "kimi-k2.6",
|
|
177
|
-
"opencode-go": "kimi-
|
|
193
|
+
"opencode-go": "kimi-k3",
|
|
178
194
|
"kimi-coding": "kimi-for-coding",
|
|
179
195
|
meta: "muse-spark-1.3",
|
|
180
196
|
"cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6",
|