@fastagent-sh/fastagent 0.24.1 → 0.24.3
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 +6 -0
- package/dist/agent.js +6 -0
- package/dist/cli/commands/add.js +1 -1
- package/dist/cli/commands/chat.js +1 -1
- package/dist/cli/commands/deploy.js +2 -2
- package/dist/cli/commands/dev.js +2 -2
- package/dist/cli/commands/info.js +12 -5
- package/dist/cli/commands/invoke.js +2 -3
- package/dist/cli/commands/routine-run.js +4 -5
- package/dist/cli/commands/start.js +2 -2
- package/dist/cli/kernel.js +1 -1
- package/dist/cli/program.js +4 -3
- package/dist/cli/shared.d.ts +28 -2
- package/dist/cli/shared.js +36 -13
- package/dist/core.d.ts +1 -1
- package/dist/core.js +5 -4
- package/dist/deploy/build-context.js +1 -1
- package/dist/deploy/preflight.js +9 -1
- package/dist/dev-supervisor.js +1 -1
- package/dist/engines/pi/agent-session-factory.d.ts +21 -5
- package/dist/engines/pi/agent-session-factory.js +134 -17
- package/dist/engines/pi/config.d.ts +6 -0
- package/dist/engines/pi/config.js +8 -0
- package/dist/engines/pi/create.d.ts +25 -30
- package/dist/engines/pi/create.js +64 -66
- package/dist/engines/pi/definition.d.ts +44 -28
- package/dist/engines/pi/definition.js +123 -30
- package/dist/engines/pi/invoke-session.d.ts +4 -1
- package/dist/engines/pi/invoke-session.js +12 -6
- package/dist/engines/pi/machine.d.ts +1 -2
- package/dist/engines/pi/machine.js +10 -1
- package/dist/engines/pi/open.d.ts +11 -7
- package/dist/engines/pi/open.js +13 -13
- package/dist/engines/pi/report.d.ts +7 -8
- package/dist/engines/pi/report.js +23 -5
- package/dist/engines/pi/session-builder.js +6 -2
- package/dist/engines/pi/session-control.d.ts +3 -2
- package/dist/engines/pi/session-control.js +23 -9
- package/dist/engines/pi/session-markers.d.ts +1 -1
- package/dist/engines/pi/session-markers.js +1 -1
- package/dist/engines/pi/session-settings.d.ts +14 -3
- package/dist/engines/pi/session-settings.js +20 -1
- package/dist/engines/pi/turn-kit.d.ts +31 -1
- package/dist/engines/pi/turn-kit.js +54 -1
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +12 -1
- package/dist/pi.d.ts +1 -1
- package/dist/scaffold/init.js +2 -2
- package/dist/scaffold/templates/APPEND_SYSTEM.md +12 -0
- package/dist/scaffold/templates/fastagent.config.ts +1 -1
- package/dist/session.d.ts +10 -0
- package/package.json +1 -1
- package/dist/scaffold/templates/persona.md +0 -12
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Definition domain: read an agent definition directory into memory — its system prompt files, its skills and prompt
|
|
3
|
+
* templates (each from the agent directory's root spelling first, then pi's `.pi/` and the standard `.agents/` ones),
|
|
4
|
+
* and, until contexts exist, the AGENTS.md project context. docs/design/agent-model.md §2 is the rule this follows.
|
|
5
|
+
*/
|
|
2
6
|
import { realpathSync } from "node:fs";
|
|
3
7
|
import { join, resolve } from "node:path";
|
|
4
|
-
import { BACKGROUND_CONTEXT, loadSkills, } from "@earendil-works/pi-agent-core";
|
|
8
|
+
import { BACKGROUND_CONTEXT, loadPromptTemplates, loadSkills, } from "@earendil-works/pi-agent-core";
|
|
5
9
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
6
10
|
import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
|
|
7
11
|
import { log } from "../../log.js";
|
|
8
12
|
import { assertInsideAgentDir } from "../../paths.js";
|
|
13
|
+
/** Where each resource is read from, in order: the root spelling first, then pi's, then the standard one. */
|
|
14
|
+
const SYSTEM_PROMPT_FILES = ["SYSTEM.md", ".pi/SYSTEM.md"];
|
|
15
|
+
const APPEND_SYSTEM_PROMPT_FILES = ["APPEND_SYSTEM.md", ".pi/APPEND_SYSTEM.md"];
|
|
16
|
+
const SKILL_DIRS = ["skills", ".pi/skills", ".agents/skills"];
|
|
17
|
+
const PROMPT_DIRS = ["prompts", ".pi/prompts"];
|
|
18
|
+
/** The file `SYSTEM.md` and `APPEND_SYSTEM.md` replaced. Refused rather than ignored: its text would do nothing. */
|
|
19
|
+
const RETIRED_PERSONA = "persona.md";
|
|
9
20
|
/**
|
|
10
|
-
* Read an agent definition
|
|
11
|
-
* agentDir }).
|
|
21
|
+
* Read an agent definition: its prompt files, skills and prompt templates from `agentDir`; the project context from
|
|
22
|
+
* pi's loadProjectContextFiles({ cwd, agentDir }).
|
|
12
23
|
*/
|
|
13
24
|
export async function loadAgentDefinition(agentDir, options = {}) {
|
|
14
25
|
// One resolved default for the working directory (env cwd AND the context-walk start), so they can never diverge if
|
|
@@ -20,18 +31,76 @@ export async function loadAgentDefinition(agentDir, options = {}) {
|
|
|
20
31
|
throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
|
|
21
32
|
}
|
|
22
33
|
const root = rootResult.value;
|
|
23
|
-
|
|
34
|
+
await refuseRetiredPersona(e, root);
|
|
35
|
+
// Project context, following pi: the agentDir's own AGENTS.md + every AGENTS.md walking cwd up to root
|
|
24
36
|
// (loadProjectContextFiles).
|
|
25
37
|
const contextFiles = loadProjectContextFiles({ cwd, agentDir: root });
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
const shadowed = [];
|
|
39
|
+
const ignored = [];
|
|
40
|
+
const systemPrompt = await readFirstFile(e, root, SYSTEM_PROMPT_FILES, "system prompt", shadowed, ignored);
|
|
41
|
+
const appendSystemPrompt = await readFirstFile(e, root, APPEND_SYSTEM_PROMPT_FILES, "appended prompt", shadowed, ignored);
|
|
42
|
+
const { skills, diagnostics: skillDiagnostics, collisions } = await readSkills(e, root);
|
|
43
|
+
const { prompts, diagnostics: promptDiagnostics } = await readPrompts(e, root, shadowed);
|
|
44
|
+
ignored.push(...(await ignoredPaths(e, root)));
|
|
45
|
+
return {
|
|
46
|
+
contextFiles,
|
|
47
|
+
...(systemPrompt ? { systemPrompt } : {}),
|
|
48
|
+
...(appendSystemPrompt ? { appendSystemPrompt } : {}),
|
|
49
|
+
skills,
|
|
50
|
+
prompts,
|
|
51
|
+
diagnostics: [...skillDiagnostics, ...promptDiagnostics],
|
|
52
|
+
collisions,
|
|
53
|
+
shadowed,
|
|
54
|
+
ignored,
|
|
55
|
+
dir: root,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async function exists(e, path) {
|
|
59
|
+
const info = await e.fileInfo(path, BACKGROUND_CONTEXT);
|
|
60
|
+
if (info.ok)
|
|
61
|
+
return true;
|
|
62
|
+
if (info.error.code === "not_found")
|
|
63
|
+
return false;
|
|
64
|
+
throw new Error(`cannot read ${path}: ${info.error.message}`);
|
|
65
|
+
}
|
|
66
|
+
async function refuseRetiredPersona(e, root) {
|
|
67
|
+
const path = join(root, RETIRED_PERSONA);
|
|
68
|
+
if (!(await exists(e, path)))
|
|
69
|
+
return;
|
|
70
|
+
throw new Error(`${path} is no longer read. Move its text to SYSTEM.md to replace pi's default prompt with an identity of the ` +
|
|
71
|
+
`agent's own, or to APPEND_SYSTEM.md to add standing instructions to pi's default prompt, which already says ` +
|
|
72
|
+
`who the agent is (pi's coding assistant) — an identity written there gives the model two`);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The first of `names` that exists, read; every later one that also exists is shadowed by it. A blank file counts as
|
|
76
|
+
* absent and is reported: pi treats an empty prompt as none, so it would change nothing while looking like a prompt
|
|
77
|
+
* of the agent's own.
|
|
78
|
+
*/
|
|
79
|
+
async function readFirstFile(e, root, names, what, shadowed, ignored) {
|
|
80
|
+
let found;
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const path = join(root, name);
|
|
83
|
+
const read = await e.readTextFile(path, BACKGROUND_CONTEXT);
|
|
84
|
+
if (!read.ok) {
|
|
85
|
+
if (read.error.code === "not_found")
|
|
86
|
+
continue;
|
|
87
|
+
throw new Error(`cannot read ${path}: ${read.error.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (read.value.trim() === "") {
|
|
90
|
+
ignored.push({ path, reason: `empty, so it is not used as the ${what}` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (found)
|
|
94
|
+
shadowed.push({ what, winnerPath: found.path, loserPath: path });
|
|
95
|
+
else
|
|
96
|
+
found = { path, content: read.value };
|
|
31
97
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
/** `.pi/extensions/` is pi's place for project extensions; a definition's are `extensions/`, and only those load. */
|
|
101
|
+
async function ignoredPaths(e, root) {
|
|
102
|
+
const path = join(root, ".pi", "extensions");
|
|
103
|
+
return (await exists(e, path)) ? [{ path, reason: "not loaded: a definition's extensions live in extensions/" }] : [];
|
|
35
104
|
}
|
|
36
105
|
/** Extension entry-point FILES under `<agentDir>/extensions/`, empty when there are none. */
|
|
37
106
|
export async function loadExtensionPaths(agentDir, options = {}) {
|
|
@@ -99,15 +168,22 @@ function warnSymlinkRefused(path) {
|
|
|
99
168
|
`the definition so it travels with the artifact — move it in. (If it is not an extension, ` +
|
|
100
169
|
`keep it outside extensions/ to silence this.)`);
|
|
101
170
|
}
|
|
102
|
-
/** The skills half
|
|
171
|
+
/** The skills half of {@link loadAgentDefinition}. */
|
|
103
172
|
async function readSkills(e, root) {
|
|
104
|
-
// The definition's OWN skills
|
|
105
|
-
// decides a name collision in this half's favour.
|
|
106
|
-
|
|
107
|
-
|
|
173
|
+
// The definition's OWN skills — its half of the answer, from every place it may keep one, the root spelling first.
|
|
174
|
+
// The machine's half is machine.ts's, and `withMachine` decides a name collision in this half's favour.
|
|
175
|
+
for (const dir of SKILL_DIRS)
|
|
176
|
+
await assertInsideAgentDir(root, dir);
|
|
177
|
+
const { skills: raw, diagnostics } = await loadSkills(e, SKILL_DIRS.map((dir) => join(root, dir)), BACKGROUND_CONTEXT);
|
|
108
178
|
const byName = new Map();
|
|
109
179
|
const collisions = [];
|
|
110
180
|
for (const skill of raw) {
|
|
181
|
+
// pi only warns about a `/` in a skill name, and loads it. The slash names the context a skill comes from, so a
|
|
182
|
+
// definition's own skill spelled that way could collide with a context's and is refused here.
|
|
183
|
+
if (skill.name.includes("/")) {
|
|
184
|
+
throw new Error(`skill "${skill.name}" (${skill.filePath}): a skill's name may not contain "/", which names the context a ` +
|
|
185
|
+
`skill comes from — rename it`);
|
|
186
|
+
}
|
|
111
187
|
const existing = byName.get(skill.name);
|
|
112
188
|
if (existing) {
|
|
113
189
|
collisions.push({ name: skill.name, winnerPath: existing.filePath, loserPath: skill.filePath });
|
|
@@ -118,18 +194,35 @@ async function readSkills(e, root) {
|
|
|
118
194
|
}
|
|
119
195
|
return { skills: [...byName.values()], diagnostics, collisions };
|
|
120
196
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
197
|
+
/** The prompt-template half: `prompts/`, then `.pi/prompts/`, first wins. */
|
|
198
|
+
async function readPrompts(e, root, shadowed) {
|
|
199
|
+
const byName = new Map();
|
|
200
|
+
const diagnostics = [];
|
|
201
|
+
for (const dir of PROMPT_DIRS) {
|
|
202
|
+
await assertInsideAgentDir(root, dir);
|
|
203
|
+
const loaded = await loadPromptTemplates(e, join(root, dir), BACKGROUND_CONTEXT);
|
|
204
|
+
diagnostics.push(...loaded.diagnostics);
|
|
205
|
+
for (const template of loaded.promptTemplates) {
|
|
206
|
+
// pi's loader names a template after its file and reads direct children only, so this is its file.
|
|
207
|
+
const filePath = join(root, dir, `${template.name}.md`);
|
|
208
|
+
const existing = byName.get(template.name);
|
|
209
|
+
if (existing) {
|
|
210
|
+
shadowed.push({
|
|
211
|
+
what: `prompt template "${template.name}"`,
|
|
212
|
+
winnerPath: existing.filePath,
|
|
213
|
+
loserPath: filePath,
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
byName.set(template.name, {
|
|
218
|
+
name: template.name,
|
|
219
|
+
...(template.description ? { description: template.description } : {}),
|
|
220
|
+
content: template.content,
|
|
221
|
+
filePath,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { prompts: [...byName.values()], diagnostics };
|
|
133
226
|
}
|
|
134
227
|
/** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath. */
|
|
135
228
|
export function canonicalPath(p) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Pi's per-invoke binding over a durable session record. */
|
|
2
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { type Agent } from "../../agent.ts";
|
|
4
|
+
import type { SessionEvent } from "../../session.ts";
|
|
4
5
|
import type { SessionInheritance } from "./session-inheritance.ts";
|
|
5
6
|
import { type Lease, type SessionObserver } from "./turn-kit.ts";
|
|
6
7
|
/** Open or create a record and bind a session. */
|
|
@@ -12,4 +13,6 @@ export interface CreatePiAgentFromSessionOptions {
|
|
|
12
13
|
/** Trusted observation tap; controls arrive with run_started, before session binding. */
|
|
13
14
|
observer?: SessionObserver;
|
|
14
15
|
}
|
|
16
|
+
/** pi's session event as the observation plane's, or null for one it does not publish. Exported for tests only. */
|
|
17
|
+
export declare function toSessionEvent(event: AgentSessionEvent, runId: string): SessionEvent | null;
|
|
15
18
|
export declare function createPiAgentFromSession(options: CreatePiAgentFromSessionOptions): Agent;
|
|
@@ -3,20 +3,22 @@ import * as Deferred from "effect/Deferred";
|
|
|
3
3
|
import * as Effect from "effect/Effect";
|
|
4
4
|
import * as Fiber from "effect/Fiber";
|
|
5
5
|
import * as Queue from "effect/Queue";
|
|
6
|
-
import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
|
|
6
|
+
import { ABORTED_CODE, MISSING_MODEL_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
|
|
7
7
|
import { entryImages } from "./entry-images.js";
|
|
8
8
|
import { cancellableStream } from "../../collect.js";
|
|
9
9
|
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 {
|
|
13
|
+
import { MissingModel } from "./session-settings.js";
|
|
14
|
+
import { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, streamsThinkingDelta, asksToEndRun, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
|
|
14
15
|
// Structured output is for scripts. Observation publishes Pi's display result, including bounded nested traces.
|
|
15
16
|
function displayToolResult(result) {
|
|
16
17
|
const { structuredContent: _internal, ...display } = result;
|
|
17
18
|
return display;
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
/** pi's session event as the observation plane's, or null for one it does not publish. Exported for tests only. */
|
|
21
|
+
export function toSessionEvent(event, runId) {
|
|
20
22
|
const at = Date.now();
|
|
21
23
|
switch (event.type) {
|
|
22
24
|
case "message_start":
|
|
@@ -26,8 +28,9 @@ function toSessionEvent(event, runId) {
|
|
|
26
28
|
case "message_update": {
|
|
27
29
|
const ev = event.assistantMessageEvent;
|
|
28
30
|
if (ev.type === "text_delta" || ev.type === "thinking_delta") {
|
|
29
|
-
// Empty deltas must not spend the silent window in which a provider retry is safe.
|
|
30
|
-
|
|
31
|
+
// Empty deltas must not spend the silent window in which a provider retry is safe. A redacted block's
|
|
32
|
+
// placeholder is not reasoning, and its entry will not carry it either (turn-kit.ts answerThinking).
|
|
33
|
+
return ev.delta === "" || (ev.type === "thinking_delta" && !streamsThinkingDelta(ev))
|
|
31
34
|
? null
|
|
32
35
|
: {
|
|
33
36
|
type: "message_delta",
|
|
@@ -78,6 +81,7 @@ function toSessionEvent(event, runId) {
|
|
|
78
81
|
isError: event.isError,
|
|
79
82
|
content: displayToolResult(event.result),
|
|
80
83
|
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
84
|
+
...(asksToEndRun(event) ? { terminate: true } : {}),
|
|
81
85
|
},
|
|
82
86
|
};
|
|
83
87
|
case "queue_update":
|
|
@@ -352,7 +356,9 @@ export function createPiAgentFromSession(options) {
|
|
|
352
356
|
retryable: true,
|
|
353
357
|
code: SESSION_BUSY_CODE,
|
|
354
358
|
}
|
|
355
|
-
:
|
|
359
|
+
: error instanceof MissingModel
|
|
360
|
+
? { type: "failed", details: error.message, retryable: false, code: MISSING_MODEL_CODE }
|
|
361
|
+
: errorToTerminal(error));
|
|
356
362
|
}));
|
|
357
363
|
settled = true;
|
|
358
364
|
Queue.offerUnsafe(queue, terminal.type === "failed" && (abortSucceeded || abortsInFlight > 0)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
/** pi's own shapes, taken from its loader rather than re-declared. */
|
|
14
14
|
export type MachineSkill = ReturnType<DefaultResourceLoader["getSkills"]>["skills"][number];
|
|
15
|
-
type MachinePrompt = ReturnType<DefaultResourceLoader["getPrompts"]>["prompts"][number];
|
|
15
|
+
export type MachinePrompt = ReturnType<DefaultResourceLoader["getPrompts"]>["prompts"][number];
|
|
16
16
|
/** The Pi built-in extensions a definition loads, each unless the machine's settings disable it. */
|
|
17
17
|
export declare const BUILTIN_EXTENSIONS: readonly ["codemode", "tool-search"];
|
|
18
18
|
/**
|
|
@@ -51,4 +51,3 @@ export declare function withMachine<Own extends {
|
|
|
51
51
|
}, Lent extends {
|
|
52
52
|
name: string;
|
|
53
53
|
}>(own: readonly Own[], lent: readonly Lent[]): (Own | Lent)[];
|
|
54
|
-
export {};
|
|
@@ -91,10 +91,19 @@ async function read(workspace, agentDir) {
|
|
|
91
91
|
log.warn(`[fastagent] ${kind} ${diagnostic.type}: ${diagnostic.message}${diagnostic.path ? ` (${diagnostic.path})` : ""}`);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
// A `/` in a skill's name names the context it comes from (definition.ts), and pi only warns about one. The
|
|
95
|
+
// machine's own skill spelled that way is left out rather than let collide with a context's.
|
|
96
|
+
const lent = skills.filter((skill) => {
|
|
97
|
+
if (!skill.name.includes("/"))
|
|
98
|
+
return true;
|
|
99
|
+
log.warn(`[fastagent] machine skill "${skill.name}" (${skill.filePath}) is not loaded: a skill's name may not contain "/", ` +
|
|
100
|
+
`which names the context a skill comes from`);
|
|
101
|
+
return false;
|
|
102
|
+
});
|
|
94
103
|
const builtinExtensions = packages.extensions
|
|
95
104
|
.filter((resource) => resource.enabled && resource.metadata.source === "builtin")
|
|
96
105
|
.map((resource) => resource.path.slice("builtin:".length));
|
|
97
|
-
return { skills, prompts, builtinExtensions, settingsManager: () => scopedSettings(settings) };
|
|
106
|
+
return { skills: lent, prompts, builtinExtensions, settingsManager: () => scopedSettings(settings) };
|
|
98
107
|
}
|
|
99
108
|
/** A package's enabled resources; the loader discovers the top-level ones itself. */
|
|
100
109
|
function fromPackages(resources) {
|
|
@@ -13,8 +13,8 @@ import type { ToolCollision, MountedTool } from "./tool.ts";
|
|
|
13
13
|
import type { DeclaredSecret } from "../../declared-secrets.ts";
|
|
14
14
|
import type { HttpSurface } from "../../service.ts";
|
|
15
15
|
/**
|
|
16
|
-
* The names a `/` composer completes: this agent's skills — the definition's, plus the ones its
|
|
17
|
-
* (machine.ts)
|
|
16
|
+
* The names a `/` composer completes: this agent's skills and prompt templates — the definition's, plus the ones its
|
|
17
|
+
* machine lends (machine.ts) — and the commands its `extensions/` register.
|
|
18
18
|
*
|
|
19
19
|
* `source` says how a name is INVOKED, which is the one thing a client needs from it: a `skill` is sent as
|
|
20
20
|
* `/skill:<name>`, an `extension` command and a `prompt` as `/<name>` (docs/design/session-control.md §5.1.1).
|
|
@@ -62,8 +62,12 @@ export interface CreatePiAgentFromDirOptions {
|
|
|
62
62
|
export interface AgentAssembly {
|
|
63
63
|
config: FastagentConfig;
|
|
64
64
|
configPath?: string;
|
|
65
|
-
/**
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The default "provider/modelId" spec (`--model` > `FASTAGENT_MODEL` > config), or undefined when the agent sets none:
|
|
67
|
+
* it still opens, a session that records its model runs on it, and one that records none is refused with
|
|
68
|
+
* `missing_model` at invoke.
|
|
69
|
+
*/
|
|
70
|
+
modelSpec?: string;
|
|
67
71
|
/** Absolute agent dir — definition + config + machinery live here (resolvePlacement().agentDir). */
|
|
68
72
|
agentDir: string;
|
|
69
73
|
/** Absolute workspace — the agent's cwd and the start of the ②-context walk. */
|
|
@@ -148,9 +152,9 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
|
|
|
148
152
|
definition: LoadedDefinition;
|
|
149
153
|
config: FastagentConfig;
|
|
150
154
|
configPath?: string;
|
|
151
|
-
/** The
|
|
152
|
-
modelSpec
|
|
153
|
-
/** Absolute agent dir in use — channels/tools/
|
|
155
|
+
/** The default "provider/modelId" spec, or undefined when the agent sets none ({@link AgentAssembly.modelSpec}). */
|
|
156
|
+
modelSpec?: string;
|
|
157
|
+
/** Absolute agent dir in use — channels/tools/prompt come from here. */
|
|
154
158
|
agentDir: string;
|
|
155
159
|
/** Absolute workspace in use — the agent's cwd: ALWAYS the directory that was pointed at. */
|
|
156
160
|
workspace: string;
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -11,7 +11,7 @@ import { agentOf, assemblePiFromDefinition, resolveAgentTools } from "./create.j
|
|
|
11
11
|
import { createPiSessionControl } from "./session-control.js";
|
|
12
12
|
import { withWakeTool } from "./wake-tool.js";
|
|
13
13
|
import { refuseBrokenDeclarations } from "../../loader.js";
|
|
14
|
-
import {
|
|
14
|
+
import { loadAgentDefinition } from "./definition.js";
|
|
15
15
|
import { servedExtensionCommands } from "./agent-session-factory.js";
|
|
16
16
|
import { reportFindingsIfChanged } from "./report.js";
|
|
17
17
|
import { readMachine, withMachine } from "./machine.js";
|
|
@@ -22,8 +22,8 @@ import { log } from "../../log.js";
|
|
|
22
22
|
import { piSessionRecordStore } from "./session-store.js";
|
|
23
23
|
import { gateSecrets } from "../../secrets-gate.js";
|
|
24
24
|
/**
|
|
25
|
-
* The names a `/` composer completes: this agent's skills — the definition's, plus the ones its
|
|
26
|
-
* (machine.ts)
|
|
25
|
+
* The names a `/` composer completes: this agent's skills and prompt templates — the definition's, plus the ones its
|
|
26
|
+
* machine lends (machine.ts) — and the commands its `extensions/` register.
|
|
27
27
|
*
|
|
28
28
|
* `source` says how a name is INVOKED, which is the one thing a client needs from it: a `skill` is sent as
|
|
29
29
|
* `/skill:<name>`, an `extension` command and a `prompt` as `/<name>` (docs/design/session-control.md §5.1.1).
|
|
@@ -34,10 +34,13 @@ import { gateSecrets } from "../../secrets-gate.js";
|
|
|
34
34
|
* on (asked only when there are extensions) — never a fresh scan of `extensions/`.
|
|
35
35
|
*/
|
|
36
36
|
export async function agentCommands(agentDir, workspace, served) {
|
|
37
|
-
|
|
38
|
-
//
|
|
37
|
+
// The whole definition, read the way a turn reads it: a skill whose frontmatter broke simply is not in `skills`, and
|
|
38
|
+
// would vanish from the composer with no signal. The SAME findings a turn reports, so the per-directory memo sees
|
|
39
|
+
// one set from both readers instead of warning again after every menu request.
|
|
40
|
+
const own = await loadAgentDefinition(agentDir, { cwd: workspace });
|
|
39
41
|
reportFindingsIfChanged(own.dir, own);
|
|
40
42
|
const machine = await readMachine(workspace);
|
|
43
|
+
const prompts = withMachine(own.prompts, machine.prompts);
|
|
41
44
|
const { extensionPaths } = served;
|
|
42
45
|
const extensionCommands = await servedExtensionCommands({
|
|
43
46
|
cwd: workspace,
|
|
@@ -57,7 +60,7 @@ export async function agentCommands(agentDir, workspace, served) {
|
|
|
57
60
|
...(command.description ? { description: command.description } : {}),
|
|
58
61
|
source: "extension",
|
|
59
62
|
})),
|
|
60
|
-
...
|
|
63
|
+
...prompts
|
|
61
64
|
.filter((prompt) => !shadowed.has(prompt.name))
|
|
62
65
|
.map((prompt) => ({
|
|
63
66
|
name: prompt.name,
|
|
@@ -71,9 +74,6 @@ export async function resolveAgentAssembly(dir, options = {}) {
|
|
|
71
74
|
const { agentDir, workspace } = resolvePlacement(dir);
|
|
72
75
|
const { config, path: configPath } = await loadConfig(agentDir);
|
|
73
76
|
const modelSpec = resolveModelSpec(options.model, config);
|
|
74
|
-
if (!modelSpec) {
|
|
75
|
-
throw new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
|
|
76
|
-
}
|
|
77
77
|
const { tools, toolNames, indirectTools, toolCollisions, toolFailures, toolSecrets } = await resolveAgentTools(config, agentDir, workspace);
|
|
78
78
|
// THE serving-path gate for tool declarations, in the order that costs the fewest round trips. A file that could
|
|
79
79
|
// not be imported declares nothing, so its `secrets:` are missing from `toolSecrets` — gating secrets first would
|
|
@@ -94,7 +94,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
|
|
|
94
94
|
return {
|
|
95
95
|
config,
|
|
96
96
|
configPath,
|
|
97
|
-
modelSpec,
|
|
97
|
+
...(modelSpec ? { modelSpec } : {}),
|
|
98
98
|
agentDir,
|
|
99
99
|
workspace,
|
|
100
100
|
stateRoot,
|
|
@@ -114,7 +114,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
|
|
|
114
114
|
*/
|
|
115
115
|
export function assembleFront(front, extra = {}) {
|
|
116
116
|
return assemblePiFromDefinition(front.agentDir, {
|
|
117
|
-
model: front.modelSpec,
|
|
117
|
+
...(front.modelSpec ? { model: front.modelSpec } : {}),
|
|
118
118
|
thinkingLevel: front.config.thinkingLevel,
|
|
119
119
|
cwd: front.workspace,
|
|
120
120
|
tools: extra.tools ?? front.tools,
|
|
@@ -221,7 +221,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
221
221
|
lease: assembly.lease,
|
|
222
222
|
models: modelRuntime,
|
|
223
223
|
sessionFactory: assembly.sessionFactory,
|
|
224
|
-
defaults: { model, thinkingLevel: assembly.thinkingLevel },
|
|
224
|
+
defaults: { ...(model ? { model } : {}), thinkingLevel: assembly.thinkingLevel },
|
|
225
225
|
}))
|
|
226
226
|
: undefined;
|
|
227
227
|
hub = createPiSessionControl({
|
|
@@ -259,7 +259,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
259
259
|
workspace,
|
|
260
260
|
config,
|
|
261
261
|
configPath,
|
|
262
|
-
modelSpec,
|
|
262
|
+
...(modelSpec ? { modelSpec } : {}),
|
|
263
263
|
stateRoot,
|
|
264
264
|
sessionsDir,
|
|
265
265
|
models,
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
* Render assembly warnings to stderr — the one place both the CLI runners and the `chat` runtime show the non-fatal
|
|
3
|
-
* definition/tool findings the loaders return as data.
|
|
4
|
-
*/
|
|
5
|
-
import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
6
|
-
import type { SkillCollision } from "./definition.ts";
|
|
1
|
+
import type { DefinitionDiagnostic, DefinitionShadow, LoadedDefinition, SkillCollision } from "./definition.ts";
|
|
7
2
|
import type { ToolCollision } from "./tool.ts";
|
|
8
3
|
import type { IndirectTool } from "./create.ts";
|
|
9
4
|
type Findings = {
|
|
10
5
|
collisions: SkillCollision[];
|
|
11
|
-
diagnostics:
|
|
6
|
+
diagnostics: DefinitionDiagnostic[];
|
|
7
|
+
shadowed?: DefinitionShadow[];
|
|
8
|
+
ignored?: LoadedDefinition["ignored"];
|
|
12
9
|
};
|
|
13
10
|
/** THE door for definition findings: warns only when this dir's set CHANGED since the last report. */
|
|
14
11
|
export declare function reportFindingsIfChanged(dir: string, def: Findings): void;
|
|
15
|
-
export declare function reportDefinitionWarnings(
|
|
12
|
+
export declare function reportDefinitionWarnings(def: Findings): void;
|
|
13
|
+
/** What the agent's system prompt is made of, as a report line: pi's default or `SYSTEM.md`, plus an addendum. */
|
|
14
|
+
export declare function describePrompt(def: Pick<LoadedDefinition, "dir" | "systemPrompt" | "appendSystemPrompt">): string;
|
|
16
15
|
/** The report line for the mounted tools the model is not given up front: each name with its way in. */
|
|
17
16
|
export declare function describeIndirectTools(tools: readonly IndirectTool[]): string;
|
|
18
17
|
export declare function reportToolCollisions(collisions: ToolCollision[]): void;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render assembly warnings to stderr — the one place both the CLI runners and the `chat` runtime show the non-fatal
|
|
3
|
+
* definition/tool findings the loaders return as data.
|
|
4
|
+
*/
|
|
5
|
+
import { relative } from "node:path";
|
|
1
6
|
import { log } from "../../log.js";
|
|
2
7
|
/** Stable identity of a definition's non-fatal findings — the dedup key below. */
|
|
3
8
|
function findingsSignature(def) {
|
|
4
9
|
const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
|
|
5
10
|
const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
|
|
6
|
-
|
|
11
|
+
const shadowed = (def.shadowed ?? []).map((s) => `s:${s.what}:${s.winnerPath}:${s.loserPath}`);
|
|
12
|
+
const ignored = (def.ignored ?? []).map((i) => `i:${i.path}`);
|
|
13
|
+
return [...collisions, ...diagnostics, ...shadowed, ...ignored].sort().join("\n");
|
|
7
14
|
}
|
|
8
15
|
/** The last reported finding set PER DEFINITION DIR. */
|
|
9
16
|
const lastFindings = new Map();
|
|
@@ -13,16 +20,27 @@ export function reportFindingsIfChanged(dir, def) {
|
|
|
13
20
|
if (lastFindings.get(dir) === sig)
|
|
14
21
|
return;
|
|
15
22
|
lastFindings.set(dir, sig);
|
|
16
|
-
reportDefinitionWarnings(def
|
|
23
|
+
reportDefinitionWarnings(def);
|
|
17
24
|
}
|
|
18
|
-
export function reportDefinitionWarnings(
|
|
19
|
-
for (const c of collisions) {
|
|
25
|
+
export function reportDefinitionWarnings(def) {
|
|
26
|
+
for (const c of def.collisions) {
|
|
20
27
|
log.warn(`[fastagent] skill "${c.name}" collision — using ${c.winnerPath}, ignoring ${c.loserPath}`);
|
|
21
28
|
}
|
|
22
|
-
for (const
|
|
29
|
+
for (const s of def.shadowed ?? []) {
|
|
30
|
+
log.warn(`[fastagent] ${s.what} is in two places — using ${s.winnerPath}, ignoring ${s.loserPath}`);
|
|
31
|
+
}
|
|
32
|
+
for (const i of def.ignored ?? []) {
|
|
33
|
+
log.warn(`[fastagent] ${i.path} is ${i.reason}`);
|
|
34
|
+
}
|
|
35
|
+
for (const d of def.diagnostics) {
|
|
23
36
|
log.warn(`[fastagent] ${d.code}: ${d.message} (${d.path})`);
|
|
24
37
|
}
|
|
25
38
|
}
|
|
39
|
+
/** What the agent's system prompt is made of, as a report line: pi's default or `SYSTEM.md`, plus an addendum. */
|
|
40
|
+
export function describePrompt(def) {
|
|
41
|
+
const base = def.systemPrompt ? relative(def.dir, def.systemPrompt.path) : "pi's default";
|
|
42
|
+
return def.appendSystemPrompt ? `${base} + ${relative(def.dir, def.appendSystemPrompt.path)}` : base;
|
|
43
|
+
}
|
|
26
44
|
const REACH = {
|
|
27
45
|
tool_search: "loaded via tool_search",
|
|
28
46
|
codemode: "codemode scripts only",
|
|
@@ -6,7 +6,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { SessionManager, SettingsManager, createAgentSessionRuntime, getAgentDir, } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { bindPiSession, definitionServices } from "./agent-session-factory.js";
|
|
9
|
-
import { resolveModel } from "./config.js";
|
|
9
|
+
import { missingDefaultModel, resolveModel } from "./config.js";
|
|
10
10
|
import { canonicalPath } from "./definition.js";
|
|
11
11
|
import { reportToolCollisions } from "./report.js";
|
|
12
12
|
import { assembleFront, resolveAgentAssembly } from "./open.js";
|
|
@@ -19,11 +19,15 @@ import { resolvePlacement } from "../../paths.js";
|
|
|
19
19
|
export async function buildAgentSessionRuntime(dir, options = {}, sessionManager) {
|
|
20
20
|
async function resolveAssembly() {
|
|
21
21
|
const front = await resolveAgentAssembly(dir, options);
|
|
22
|
+
// chat's TUI always starts a conversation, so it needs the default the served agent can do without.
|
|
23
|
+
const modelSpec = front.modelSpec;
|
|
24
|
+
if (!modelSpec)
|
|
25
|
+
throw missingDefaultModel();
|
|
22
26
|
reportToolCollisions(front.toolCollisions);
|
|
23
27
|
const { assembly } = await assembleFront(front);
|
|
24
28
|
// Read ONCE per runtime: a rebuild (/new, fork) keeps the startup snapshot, because config and tools stay in the
|
|
25
29
|
// import cache and a half-refreshed agent is worse than a stale one. Restart chat to pick up edits.
|
|
26
|
-
return { modelSpec
|
|
30
|
+
return { modelSpec, assembly, definition: await assembly.readDefinition() };
|
|
27
31
|
}
|
|
28
32
|
// The workspace, like serving's: where tools run and what session records are keyed to. Canonical, because pi's
|
|
29
33
|
// process.cwd() fallback is a realpath and a symlinked workspace would otherwise mismatch it.
|
|
@@ -35,9 +35,10 @@ export interface PiBoundaryWiring {
|
|
|
35
35
|
/** The assembly's configured PAIR — what a session with no overrides runs on. One field because
|
|
36
36
|
* model and thinking level are one setting: which levels exist is a property of the model, so a
|
|
37
37
|
* wiring that could carry them apart could carry a pair no run uses. Must be what
|
|
38
|
-
* {@link sessionFactory} was built with.
|
|
38
|
+
* {@link sessionFactory} was built with. No model when the agent sets no default: a session then runs on the model
|
|
39
|
+
* it records, and `state()` of one that records none reports no model. */
|
|
39
40
|
defaults: {
|
|
40
|
-
model
|
|
41
|
+
model?: AnyModel;
|
|
41
42
|
thinkingLevel: ThinkingLevel;
|
|
42
43
|
};
|
|
43
44
|
}
|
|
@@ -8,14 +8,14 @@ import { PortFailure, port, portAbort, portCleanup, portError, portJoin } from "
|
|
|
8
8
|
import { acquireSession, acquireSessionLease } from "./session-effects.js";
|
|
9
9
|
import { calculateContextTokens, estimateTokens, getLastAssistantUsage, } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
11
|
-
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
11
|
+
import { MISSING_MODEL_CODE, 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
13
|
import { forkProvenance, isConversationMessage, isNavigable, publishedLeaf } from "./session-markers.js";
|
|
14
14
|
import { entryImages, imageAt } from "./entry-images.js";
|
|
15
|
-
import { answerOutcome } from "./turn-kit.js";
|
|
15
|
+
import { answerOutcome, answerThinking, endsRun, } 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, describeModels, resolveSessionSettings } from "./session-settings.js";
|
|
18
|
+
import { MissingModel, 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,10 @@ function toSessionEntry(entry, parentId) {
|
|
|
92
92
|
const data = { text: contentText(m.content, "") };
|
|
93
93
|
if (toolCalls.length > 0)
|
|
94
94
|
data.toolCalls = toolCalls;
|
|
95
|
+
// The same text the live `message_delta { channel: "thinking" }` added up to (turn-kit.ts answerThinking).
|
|
96
|
+
const thinking = answerThinking(m);
|
|
97
|
+
if (thinking !== undefined)
|
|
98
|
+
data.thinking = thinking;
|
|
95
99
|
const outcome = answerOutcome(m);
|
|
96
100
|
if (outcome)
|
|
97
101
|
data.outcome = outcome;
|
|
@@ -107,6 +111,8 @@ function toSessionEntry(entry, parentId) {
|
|
|
107
111
|
isError: m.isError ?? false,
|
|
108
112
|
text: contentText(m.content, ""),
|
|
109
113
|
...entryImages(entry.id, m),
|
|
114
|
+
// Same flag as the live `tool_finished` (turn-kit.ts asksToEndRun).
|
|
115
|
+
...(endsRun(m) ? { terminate: true } : {}),
|
|
110
116
|
},
|
|
111
117
|
};
|
|
112
118
|
}
|
|
@@ -504,7 +510,11 @@ export function createPiSessionControl(options) {
|
|
|
504
510
|
});
|
|
505
511
|
const failed = (error) => ({
|
|
506
512
|
ok: false,
|
|
507
|
-
|
|
513
|
+
// A command that binds the session (compaction is a model call) on a session with no model: the same code the
|
|
514
|
+
// invoke answers, since the same fix (`update({ model })`) applies.
|
|
515
|
+
error: error instanceof MissingModel
|
|
516
|
+
? { code: MISSING_MODEL_CODE, message: error.message, retryable: false }
|
|
517
|
+
: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
508
518
|
});
|
|
509
519
|
const runBoundary = (command) => Effect.runPromise(Effect.scoped(command).pipe(Effect.catchTag("SessionBusy", () => Effect.succeed(busy())), Effect.catchCause((cause) => Effect.succeed(failed(portError(cause))))));
|
|
510
520
|
/** steer / follow_up / abort — the run actions. They reach the LIVE run through the controls
|
|
@@ -633,11 +643,15 @@ export function createPiSessionControl(options) {
|
|
|
633
643
|
return failed(error);
|
|
634
644
|
}
|
|
635
645
|
// An explicit model in the same patch wins over the one that path resolves to: it is applied
|
|
636
|
-
// after the move, so it is what the session ends up running.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
646
|
+
// after the move, so it is what the session ends up running. With neither (no model recorded, no
|
|
647
|
+
// default), any level of the vocabulary is recorded, and clamped once the session has a model, like a
|
|
648
|
+
// recorded level after a model change.
|
|
649
|
+
const target = model ?? resolved?.model;
|
|
650
|
+
if (target) {
|
|
651
|
+
const levels = getSupportedThinkingLevels(target);
|
|
652
|
+
if (!levels.includes(patch.thinkingLevel)) {
|
|
653
|
+
return invalid(`thinking level "${patch.thinkingLevel}" is not supported by ${target.provider}/${target.id} (allowed: ${levels.join(", ")})`);
|
|
654
|
+
}
|
|
641
655
|
}
|
|
642
656
|
}
|
|
643
657
|
// Release before publishing state_changed: an observer may immediately issue another command.
|
|
@@ -22,7 +22,7 @@ export declare function isPlaneMarker(entry: {
|
|
|
22
22
|
* by "a message".
|
|
23
23
|
*
|
|
24
24
|
* The line it draws is the ENGINE's prompt state. Since pi 0.86 pi writes `system` messages into the same log the
|
|
25
|
-
* conversation lives in, carrying the assembled prompt (
|
|
25
|
+
* conversation lives in, carrying the assembled prompt (system prompt, project context, skill and tool descriptions)
|
|
26
26
|
* plus one more per prompt or tool-set change. They are bookkeeping, not something anyone said, and every reader
|
|
27
27
|
* of the journal has to decide about them. Deciding once, here, is the point: the first reader that forgot cut
|
|
28
28
|
* inheritance above every exchange and handed a new thread an empty history, with no diagnostic.
|