@fastagent-sh/fastagent 0.24.2 → 0.24.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent.d.ts +6 -0
  2. package/dist/agent.js +6 -0
  3. package/dist/cli/commands/add.js +1 -1
  4. package/dist/cli/commands/chat.js +1 -1
  5. package/dist/cli/commands/deploy.js +2 -2
  6. package/dist/cli/commands/dev.js +2 -2
  7. package/dist/cli/commands/info.js +12 -5
  8. package/dist/cli/commands/invoke.js +2 -3
  9. package/dist/cli/commands/routine-run.js +4 -5
  10. package/dist/cli/commands/start.js +2 -2
  11. package/dist/cli/kernel.js +1 -1
  12. package/dist/cli/program.js +4 -3
  13. package/dist/cli/shared.d.ts +28 -2
  14. package/dist/cli/shared.js +36 -13
  15. package/dist/core.d.ts +1 -1
  16. package/dist/core.js +5 -4
  17. package/dist/deploy/build-context.js +1 -1
  18. package/dist/deploy/preflight.js +9 -1
  19. package/dist/dev-supervisor.js +1 -1
  20. package/dist/engines/pi/agent-session-factory.d.ts +21 -5
  21. package/dist/engines/pi/agent-session-factory.js +129 -18
  22. package/dist/engines/pi/config.d.ts +6 -0
  23. package/dist/engines/pi/config.js +8 -0
  24. package/dist/engines/pi/create.d.ts +25 -30
  25. package/dist/engines/pi/create.js +64 -66
  26. package/dist/engines/pi/definition.d.ts +44 -28
  27. package/dist/engines/pi/definition.js +123 -30
  28. package/dist/engines/pi/invoke-session.d.ts +4 -1
  29. package/dist/engines/pi/invoke-session.js +11 -6
  30. package/dist/engines/pi/machine.d.ts +1 -2
  31. package/dist/engines/pi/machine.js +10 -1
  32. package/dist/engines/pi/open.d.ts +11 -7
  33. package/dist/engines/pi/open.js +13 -13
  34. package/dist/engines/pi/report.d.ts +7 -8
  35. package/dist/engines/pi/report.js +23 -5
  36. package/dist/engines/pi/session-builder.js +6 -2
  37. package/dist/engines/pi/session-control.d.ts +3 -2
  38. package/dist/engines/pi/session-control.js +21 -9
  39. package/dist/engines/pi/session-markers.d.ts +1 -1
  40. package/dist/engines/pi/session-markers.js +1 -1
  41. package/dist/engines/pi/session-settings.d.ts +14 -3
  42. package/dist/engines/pi/session-settings.js +20 -1
  43. package/dist/engines/pi/turn-kit.d.ts +20 -0
  44. package/dist/engines/pi/turn-kit.js +45 -1
  45. package/dist/paths.d.ts +1 -1
  46. package/dist/paths.js +12 -1
  47. package/dist/pi.d.ts +1 -1
  48. package/dist/scaffold/init.js +2 -2
  49. package/dist/scaffold/templates/APPEND_SYSTEM.md +12 -0
  50. package/dist/scaffold/templates/fastagent.config.ts +1 -1
  51. package/dist/session.d.ts +4 -0
  52. package/package.json +1 -1
  53. package/dist/scaffold/templates/persona.md +0 -12
@@ -3,20 +3,55 @@
3
3
  * durable record, per invoke.
4
4
  */
5
5
  import { dirname } from "node:path";
6
- import { BUILTIN_EXTENSIONS, DISCOVERY, readMachine, withMachine } from "./machine.js";
7
- import { ExtensionRunner, ModelRegistry, SessionManager, createAgentSessionServices, createCodemodeExtension, createToolSearchExtension, createAgentSessionFromServices, getAgentDir, initTheme, } from "@earendil-works/pi-coding-agent";
6
+ import { BUILTIN_EXTENSIONS, DISCOVERY, readMachine, withMachine, } from "./machine.js";
7
+ import { ExtensionRunner, ModelRegistry, SessionManager, createAgentSessionServices, createCodemodeExtension, createToolSearchExtension, createAgentSessionFromServices, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, getAgentDir, initTheme, } from "@earendil-works/pi-coding-agent";
8
8
  import { log } from "../../log.js";
9
9
  import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
10
10
  import { resolveModel } from "./config.js";
11
- import { activePath, resolveSessionSettings } from "./session-settings.js";
11
+ import { MissingModel, activePath, resolveSessionSettings } from "./session-settings.js";
12
12
  import { DEFAULT_THINKING_LEVEL, withModelRegistration } from "./models.js";
13
13
  import { registerAccountModels } from "./openai-account-models.js";
14
- import { asksToEndRun, markEndsRun } from "./turn-kit.js";
14
+ import { abortedAnswer, asksToEndRun, markEndsRun } from "./turn-kit.js";
15
15
  import { agentSessionManager, sessionToolActivation, turnContext } from "./tool-context.js";
16
+ /**
17
+ * What pi's own coding tools say about themselves in its default prompt. The coding tools fastagent mounts are pi's
18
+ * `AgentTool`s, which carry neither field, and they replace pi's definitions of the same name, so without this pi's
19
+ * tool list would lose them and its rules would lose their guidelines.
20
+ */
21
+ const PI_TOOL_PROMPTS = new Map([
22
+ createReadToolDefinition,
23
+ createBashToolDefinition,
24
+ createEditToolDefinition,
25
+ createWriteToolDefinition,
26
+ createGrepToolDefinition,
27
+ createFindToolDefinition,
28
+ createLsToolDefinition,
29
+ ].map((create) => {
30
+ const definition = create(".");
31
+ return [
32
+ definition.name,
33
+ { promptSnippet: definition.promptSnippet, promptGuidelines: definition.promptGuidelines },
34
+ ];
35
+ }));
36
+ /**
37
+ * The line pi's default prompt lists a tool by. pi lists only tools that have one; an authored tool's is the first
38
+ * line of its description, so `defineTool` needs no field of its own.
39
+ */
40
+ function promptFields(tool) {
41
+ const own = tool;
42
+ const pi = PI_TOOL_PROMPTS.get(tool.name);
43
+ const promptSnippet = own.promptSnippet ?? pi?.promptSnippet ?? tool.description?.split("\n")[0]?.trim();
44
+ const promptGuidelines = own.promptGuidelines ?? pi?.promptGuidelines;
45
+ return {
46
+ ...(promptSnippet ? { promptSnippet } : {}),
47
+ ...(promptGuidelines ? { promptGuidelines } : {}),
48
+ };
49
+ }
16
50
  /** fastagent's tools as pi tool definitions, bound to ONE session. */
17
51
  function toolDefinitions(tools, bound, sessionId) {
18
52
  return tools.map((tool) => ({
19
53
  ...tool,
54
+ ...promptFields(tool),
20
55
  label: tool.label || tool.name,
21
56
  execute: (id, params, signal, onUpdate, ctx) => {
22
57
  const binding = bound.current;
@@ -159,10 +194,34 @@ const nativeExtensions = BUILTIN_EXTENSIONS.map((name) => ({
159
194
  replaceable: true,
160
195
  builtin: true,
161
196
  }));
197
+ /**
198
+ * FastAgent's own prompt sections, added on `before_agent_start`: pi renders custom sections after its own, so they
199
+ * survive a `SYSTEM.md` that replaced pi's default, and they stay out of the slot `APPEND_SYSTEM.md` fills.
200
+ */
201
+ function fastagentSections(sections) {
202
+ return {
203
+ name: "fastagent-sections",
204
+ hidden: true,
205
+ factory: (pi) => {
206
+ pi.on("before_agent_start", (event) => {
207
+ Object.assign(event.systemPromptOptions.sections, sections);
208
+ });
209
+ },
210
+ };
211
+ }
162
212
  /** The resource posture a fastagent definition asks pi for — ONE definition of it, for both assemblies. */
163
213
  export function definitionResourceLoaderOptions(source) {
214
+ const { definition } = source;
164
215
  return {
165
- extensionFactories: [...nativeExtensions, compactAdmission, recordEndsRun],
216
+ extensionFactories: [
217
+ ...nativeExtensions,
218
+ compactAdmission,
219
+ recordEndsRun,
220
+ recordAbortedAnswers,
221
+ ...(definition.sections && Object.keys(definition.sections).length > 0
222
+ ? [fastagentSections(definition.sections)]
223
+ : []),
224
+ ],
166
225
  // The machine's extensions are its owner's setup, not this agent's.
167
226
  noExtensions: true,
168
227
  // Explicit paths survive noExtensions, including Pi's built-ins; so they would also survive the machine's own
@@ -171,13 +230,14 @@ export function definitionResourceLoaderOptions(source) {
171
230
  ...source.machine.builtinExtensions.map((name) => `builtin:${name}`),
172
231
  ...(source.extensionPaths ?? []),
173
232
  ],
174
- // Not pi's: fastagent already loads the SAME files into segment ② (`loadProjectContextFiles` in
175
- // definition.ts). Leaving both on would put every AGENTS.md in the prompt twice.
233
+ // Not pi's discovery: the definition read them (definition.ts), and pi renders what it is handed.
176
234
  noContextFiles: true,
177
- // The IDENTITY is the definition's, whatever the machine thinks. Inheriting skills is inheriting capability;
178
- // inheriting a system prompt would be the agent becoming someone else's agent.
179
- systemPromptOverride: () => source.systemPrompt() || " ",
180
- appendSystemPromptOverride: () => [],
235
+ agentsFilesOverride: () => ({ agentsFiles: definition.contextFiles ?? [] }),
236
+ // The IDENTITY is the definition's, whatever the machine thinks: pi's `base` here is the machine's SYSTEM.md, and
237
+ // it is never used. Inheriting skills is inheriting capability; inheriting a system prompt would be the agent
238
+ // becoming someone else's agent. Undefined leaves pi to build its default.
239
+ systemPromptOverride: () => definition.systemPrompt,
240
+ appendSystemPromptOverride: () => (definition.appendSystemPrompt ? [definition.appendSystemPrompt] : []),
181
241
  /**
182
242
  * THE DEFINITION'S SKILLS, PLUS THE MACHINE'S — an agent inherits the box it runs on (machine.ts), and the
183
243
  * definition wins a name collision.
@@ -187,11 +247,14 @@ export function definitionResourceLoaderOptions(source) {
187
247
  */
188
248
  noSkills: true,
189
249
  skillsOverride: () => ({
190
- skills: withMachine(toPiSkills(source.skills()), source.machine.skills),
250
+ skills: withMachine(toPiSkills(definition.skills), source.machine.skills),
191
251
  diagnostics: [],
192
252
  }),
193
253
  noPromptTemplates: true,
194
- promptsOverride: () => ({ prompts: [...source.machine.prompts], diagnostics: [] }),
254
+ promptsOverride: () => ({
255
+ prompts: withMachine(toPiPrompts(definition.prompts ?? []), source.machine.prompts),
256
+ diagnostics: [],
257
+ }),
195
258
  };
196
259
  }
197
260
  /**
@@ -217,6 +280,23 @@ const recordEndsRun = {
217
280
  });
218
281
  },
219
282
  };
283
+ /**
284
+ * Records an answer that failed because its run was stopped as aborted ({@link abortedAnswer}), through pi's
285
+ * `message_end` replacement, which runs before pi appends the message to the record and before the plane's own
286
+ * listener sees it. `ctx.signal` is the run's own abort signal, the one pi's readings use.
287
+ */
288
+ const recordAbortedAnswers = {
289
+ name: "fastagent-record-aborted-answers",
290
+ hidden: true,
291
+ factory: (pi) => {
292
+ pi.on("message_end", (event, ctx) => {
293
+ if (event.message.role !== "assistant")
294
+ return;
295
+ const aborted = abortedAnswer(event.message, ctx.signal);
296
+ return aborted ? { message: aborted } : undefined;
297
+ });
298
+ },
299
+ };
220
300
  /** Manual compactions waiting for pi's admission, keyed by the session's record (what an extension's `ctx` names). */
221
301
  const compactAdmissions = new WeakMap();
222
302
  const COMPACT_ADMISSION = "fastagent-compact-admission";
@@ -293,8 +373,7 @@ export async function definitionServices(options) {
293
373
  settingsManager: machine.settingsManager(),
294
374
  resourceLoaderOptions: {
295
375
  ...definitionResourceLoaderOptions({
296
- systemPrompt: () => definition.systemPrompt,
297
- skills: () => definition.skills,
376
+ definition,
298
377
  machine,
299
378
  extensionPaths,
300
379
  }),
@@ -341,18 +420,37 @@ export function piAgentSessionFactory(options) {
341
420
  initTheme();
342
421
  themeReady = true;
343
422
  }
423
+ // Without a default, a turn that cannot run must leave no conversation behind in the listing. A new record with
424
+ // nothing to inherit records no model, so it is refused before it is created. One that inherits may get a model
425
+ // from its parent, which only the copy decides (the store's cut), so it is created and, if still model-less,
426
+ // removed below. The caller holds this session's lease, so nothing else creates the record in between.
427
+ const existed = options.modelSpec ? true : (await sessions.openIfExists(sessionId)) !== undefined;
428
+ if (!existed && !inherit)
429
+ throw new MissingModel(sessionId, []);
344
430
  // Publish the record before loading resources: boundary writes must find it while binding is in flight.
345
431
  const sessionManager = await sessions.openOrCreate(sessionId, inherit);
346
432
  const definition = await options.readDefinition();
347
433
  const { modelRuntime } = await options.engine();
348
434
  const services = await definitionServices({ cwd, modelRuntime, definition, extensionPaths });
349
- const model = resolveModel(modelRuntime, options.modelSpec);
435
+ const model = options.modelSpec ? resolveModel(modelRuntime, options.modelSpec) : undefined;
350
436
  // What the session RUNS on: the boundary plane records model/thinking overrides as entries, and pi does not read
351
437
  // them back.
352
- const settings = resolveSessionSettings(activePath(sessionManager), modelRuntime, {
353
- model,
438
+ const path = activePath(sessionManager);
439
+ const settings = resolveSessionSettings(path, modelRuntime, {
440
+ ...(model ? { model } : {}),
354
441
  thinkingLevel: thinkingLevel ?? DEFAULT_THINKING_LEVEL,
355
442
  });
443
+ if (!settings) {
444
+ const missing = new MissingModel(sessionId, path);
445
+ if (!existed) {
446
+ // The record this bind created for a thread whose parent supplied no model. A failed removal is reported on
447
+ // its own and does not replace `missing`: the caller still needs the missing_model answer and its fix.
448
+ await sessions.delete(sessionId).catch((error) => {
449
+ log.warn(`[fastagent] session ${sessionId}: could not remove the record of a refused turn: ${String(error)}`);
450
+ });
451
+ }
452
+ throw missing;
453
+ }
356
454
  const { session } = await bindPiSession({
357
455
  services,
358
456
  sessionManager,
@@ -402,6 +500,19 @@ export function piAgentSessionFactory(options) {
402
500
  return session;
403
501
  };
404
502
  }
503
+ /** The definition's prompt templates as pi's. */
504
+ function toPiPrompts(prompts) {
505
+ return prompts.map((prompt) => {
506
+ const baseDir = dirname(prompt.filePath);
507
+ return {
508
+ name: prompt.name,
509
+ description: prompt.description ?? "",
510
+ content: prompt.content,
511
+ filePath: prompt.filePath,
512
+ sourceInfo: { path: prompt.filePath, source: "fastagent", scope: "project", origin: "top-level", baseDir },
513
+ };
514
+ });
515
+ }
405
516
  /** fastagent's Skill (content inline) as pi's (read from filePath at invocation time). */
406
517
  function toPiSkills(skills) {
407
518
  return skills.map((skill) => {
@@ -63,3 +63,9 @@ export declare function rewriteConfigModel(src: string, spec: string): string |
63
63
  * would refuse to start an agent whose config names a model.
64
64
  */
65
65
  export declare function resolveModelSpec(flag: string | undefined, config: FastagentConfig, env?: NodeJS.ProcessEnv): string | undefined;
66
+ /**
67
+ * The refusal of a process that runs the agent here (the CLI's dev/start/invoke/routine run, and chat) when no
68
+ * default model resolves. The agent itself opens without one; such a process would fail every new conversation, so it
69
+ * stops at startup instead.
70
+ */
71
+ export declare function missingDefaultModel(): Error;
@@ -189,3 +189,11 @@ export function rewriteConfigModel(src, spec) {
189
189
  export function resolveModelSpec(flag, config, env = process.env) {
190
190
  return flag ?? (env.FASTAGENT_MODEL || config.model);
191
191
  }
192
+ /**
193
+ * The refusal of a process that runs the agent here (the CLI's dev/start/invoke/routine run, and chat) when no
194
+ * default model resolves. The agent itself opens without one; such a process would fail every new conversation, so it
195
+ * stops at startup instead.
196
+ */
197
+ export function missingDefaultModel() {
198
+ return new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
199
+ }
@@ -45,29 +45,15 @@ export declare function resolveAgentTools(config: FastagentConfig, agentDir: str
45
45
  * asserts it (open.ts) — this function must stay reportable. */
46
46
  toolSecrets: Map<string, DeclaredSecret[]>;
47
47
  }>;
48
- /** The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two deviations. */
49
- export declare function piBasePrompt(options?: {
50
- tools?: MountedTool[];
51
- persona?: string;
52
- /** The {@link BUILTIN_EXTENSIONS} this machine leaves enabled (`Machine.builtinExtensions`); all by default. */
53
- builtinExtensions?: readonly string[];
54
- }): string;
55
- export interface AssembleSystemPromptOptions {
56
- /**
57
- * Base prompt (①), REQUIRED — no default: a defaulted piBasePrompt() would render "Available tools: (none)" even
58
- * when tools are mounted.
59
- */
60
- base: string;
61
- /**
62
- * ② project-context files (AGENTS.md et al. from loadProjectContextFiles); each wrapped `<project_instructions
63
- * path=…>`.
64
- */
65
- contextFiles?: Array<{
66
- path: string;
67
- content: string;
68
- }>;
69
- }
70
- export declare function assembleSystemPrompt(options: AssembleSystemPromptOptions): string;
48
+ /**
49
+ * FastAgent's own prompt sections (docs/design/agent-model.md §2): what pi's prompt cannot know. Each is a named
50
+ * section pi renders after its own, so it holds whoever wrote the rest of the prompt.
51
+ */
52
+ export declare function fastagentPromptSections(options: {
53
+ tools: readonly MountedTool[];
54
+ /** The {@link BUILTIN_EXTENSIONS} this machine leaves enabled (`Machine.builtinExtensions`). */
55
+ builtinExtensions: readonly string[];
56
+ }): Record<string, string>;
71
57
  /** The assembly, as a value: what every rung builds and what the agent runs on. */
72
58
  export interface PiAssembly {
73
59
  lease: Lease;
@@ -76,10 +62,13 @@ export interface PiAssembly {
76
62
  modelRuntime: () => Promise<ModelRuntime>;
77
63
  /** A fresh runtime for every session, before loading its extensions. */
78
64
  createModelRuntime: () => Promise<ModelRuntime>;
79
- /** The registry and configured model, resolved on first use (a credential read is async). */
65
+ /**
66
+ * The registry and the default model, resolved on first use (a credential read is async). No model when the
67
+ * assembly has no default: each session then runs on the model it records (agent-session-factory.ts).
68
+ */
80
69
  engine: () => Promise<{
81
70
  modelRuntime: ModelRuntime;
82
- model: AnyModel;
71
+ model?: AnyModel;
83
72
  }>;
84
73
  /** The configured reasoning effort — the other half of the pair a session without overrides runs on. */
85
74
  thinkingLevel: ThinkingLevel;
@@ -101,8 +90,8 @@ export interface CreatePiAgentOptions {
101
90
  /** Reasoning effort (pi's scale). */
102
91
  thinkingLevel?: ThinkingLevel;
103
92
  /**
104
- * The system prompt itself — no engine base and no wrapping (unlike the directory path, which assembles the engine
105
- * base + AGENTS.md as segment ② + persona.md as segment ①).
93
+ * The system prompt itself — no engine base and no wrapping (unlike the directory path, where pi builds its default
94
+ * prompt, or SYSTEM.md replaces it, and AGENTS.md, APPEND_SYSTEM.md and FastAgent's sections are added).
106
95
  */
107
96
  instructions?: string | (() => string);
108
97
  /** The tool set to mount: authored tools or pi's cwd-bound coding tools, both AgentTool. */
@@ -129,11 +118,17 @@ export interface CreatePiAgentOptions {
129
118
  export declare function createPiAgent(options: CreatePiAgentOptions): Agent;
130
119
  /** L2 options. */
131
120
  export interface CreatePiAgentFromDefinitionOptions {
132
- /** Model spec "provider/modelId", resolved against {@link models}. */
133
- model: string;
121
+ /**
122
+ * The default model spec "provider/modelId", resolved against {@link models}. Optional: without it, a session runs
123
+ * on the model it records, and one that records none fails its invoke with `missing_model`.
124
+ */
125
+ model?: string;
134
126
  /** Reasoning effort (pi's scale). */
135
127
  thinkingLevel?: ThinkingLevel;
136
- /** Override the engine base prompt (segment ①). */
128
+ /**
129
+ * Replaces pi's default prompt, as `SYSTEM.md` does, and outranks it. Required when `tools` replaces the coding
130
+ * tools and the directory has no `SYSTEM.md`: pi's default would claim tools the agent does not have.
131
+ */
137
132
  base?: string;
138
133
  tools?: MountedTool[];
139
134
  /**
@@ -145,70 +145,57 @@ export async function resolveAgentTools(config, agentDir, cwd) {
145
145
  ]),
146
146
  };
147
147
  }
148
- // Fastagent owns identity and project context; Pi appends skills and cwd for both serving and chat.
149
- /** The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two deviations. */
150
- export function piBasePrompt(options = {}) {
151
- const mounted = options.tools ?? [];
152
- const builtinExtensions = options.builtinExtensions ?? BUILTIN_EXTENSIONS;
153
- // Deferred tools stay OUT of the list: their schemas are not in the request until activated, so naming them here
154
- // would invite calls to tools that don't exist yet.
155
- const tools = mounted.filter(isDefaultActiveTool);
148
+ // pi builds the prompt (its default, or SYSTEM.md in its place); FastAgent adds its own named sections after it.
149
+ /** The tools pi's default prompt says the agent has ("reading files, executing commands, editing code"). */
150
+ const CODING_IDENTITY_TOOLS = ["read", "bash", "edit", "write"];
151
+ /**
152
+ * Refuse a prompt that would claim tools the agent does not have: pi's default says the agent reads files, runs
153
+ * commands and edits code, which an embedder that replaced the coding tools made false. A prompt of the agent's own
154
+ * (`base`, or `SYSTEM.md`) describes it instead. Checked at assembly and on every turn, since `SYSTEM.md` is re-read
155
+ * per turn and an agent may delete it.
156
+ */
157
+ function refuseDefaultPromptOverReplacedTools(tools, hasOwnPrompt) {
158
+ if (hasOwnPrompt)
159
+ return;
160
+ const mounted = new Set(tools.map((tool) => tool.name));
161
+ const missing = CODING_IDENTITY_TOOLS.filter((name) => !mounted.has(name));
162
+ if (missing.length === 0)
163
+ return;
164
+ throw new Error(`the coding tools were replaced (${missing.join(", ")} not mounted), so pi's default prompt, which says the agent ` +
165
+ `reads files, runs commands and edits code, does not describe this agent: pass \`base\` or write SYSTEM.md`);
166
+ }
167
+ /**
168
+ * FastAgent's own prompt sections (docs/design/agent-model.md §2): what pi's prompt cannot know. Each is a named
169
+ * section pi renders after its own, so it holds whoever wrote the rest of the prompt.
170
+ */
171
+ export function fastagentPromptSections(options) {
172
+ const sections = {};
173
+ const mountedNames = new Set(options.tools.map((tool) => tool.name));
156
174
  // Only `deferred` tools are tool_search's to load (the session activates it for them); `codemode` ones are listed
157
175
  // in codemode's own description, and an inactive direct tool is reached only through an authored activation.
158
176
  // 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;
160
- const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
161
- // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line (core.md §2),
162
- // keeping the tools list + guidelines below.
163
- const mountedNames = new Set(mounted.map((tool) => tool.name));
164
- const fullCodingSurface = ["read", "bash", "edit", "write"].every((name) => mountedNames.has(name));
165
- const identity = options.persona?.trim() ||
166
- (fullCodingSurface
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."
168
- : "You are an AI assistant operating inside pi, an agent harness. Help users using only the tools and context available to you.");
169
- const deferredNote = deferredCount > 0
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.`
171
- : "";
177
+ const deferredCount = options.tools.filter((tool) => tool.exposure === "deferred" && hasWayIn(tool, options.builtinExtensions)).length;
178
+ if (deferredCount > 0) {
179
+ sections.deferred_tools = `${deferredCount} additional tool(s) are registered but not loaded — use tool_search to find and load them before concluding a capability is missing.`;
180
+ }
172
181
  // What takes effect when, and the agent's own path to a new capability, are the same on every host; only how long
173
182
  // the storage lives differs, below. The skill it writes lives in the definition directory, which the next
174
183
  // deployment replaces (or, on AgentCore, erases with everything else) — so the sentence says so, and where a skill
175
184
  // that should outlast it has to go. A skill outside the definition would survive, but it is machine state, read
176
185
  // once per process (machine.ts), so it would not be live.
177
- const RUNTIME_CHANGES = ` Markdown definition files are read each turn; changes to tools, channels or configuration take effect when the service restarts. To give yourself a new capability now, write a skill — a SKILL.md in your definition's skills/ directory, with any script it needs run through bash. It lasts until the next deployment replaces that directory, so a capability that should outlast it belongs in the author's release: propose it to them.${
178
- // Named only when mounted (a serve; not a one-shot invoke), like the deferred tools above: naming a tool the model does not
179
- // have invites calls to it.
186
+ const runtimeChanges = ` Markdown definition files are read each turn; changes to tools, channels or configuration take effect when the service restarts. To give yourself a new capability now, write a skill — a SKILL.md in your definition's skills/ directory, with any script it needs run through bash. It lasts until the next deployment replaces that directory, so a capability that should outlast it belongs in the author's release: propose it to them.${
187
+ // Named only when mounted (a serve; not a one-shot invoke), like the deferred tools above: naming a tool the model
188
+ // does not have invites calls to it.
180
189
  mountedNames.has("wake") ? " To schedule your own follow-up work, use the wake tool." : ""}`;
181
- // How long the storage lives is the HOST's answer, not a deployment-wide one: AgentCore's managed
182
- // mount is reset by every deploy, so telling that agent to keep work "outside the definition" would
183
- // name a location its next deploy erases.
184
- const deploymentNote = !isDeployedWorkspace()
185
- ? ""
186
- : isAgentcoreRuntime()
187
- ? `\n\nYour workspace survives restarts, including uncommitted work; /tmp does not. Every deployment of a new version resets this host's storage entirely, so anything that must outlive a deployment belongs in an external system (a git remote, an issue tracker, a database).${RUNTIME_CHANGES}`
188
- : `\n\nYour workspace survives restarts and deployments, including uncommitted work; /tmp does not. A new deployment replaces your definition directory with the author's release, so keep ongoing project work outside it.${RUNTIME_CHANGES}`;
189
- return `${identity}
190
-
191
- Available tools:
192
- ${toolsList}${deferredNote}
193
-
194
- In addition to the tools above, you may have access to other custom tools depending on the project.
195
-
196
- Guidelines:
197
- - Be concise in your responses
198
- - Show file paths clearly when working with files${deploymentNote}`;
199
- }
200
- export function assembleSystemPrompt(options) {
201
- let prompt = options.base;
202
- const contextFiles = options.contextFiles ?? [];
203
- if (contextFiles.length > 0) {
204
- // Mirrors pi's system-prompt.js: one <project_context> block, one <project_instructions path=…> per file.
205
- prompt += `\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n`;
206
- for (const { path, content } of contextFiles) {
207
- prompt += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
208
- }
209
- prompt += `</project_context>\n`;
190
+ // How long the storage lives is the HOST's answer, not a deployment-wide one: AgentCore's managed mount is reset by
191
+ // every deploy, so telling that agent to keep work "outside the definition" would name a location its next deploy
192
+ // erases.
193
+ if (isDeployedWorkspace()) {
194
+ sections.self_change = isAgentcoreRuntime()
195
+ ? `Your workspace survives restarts, including uncommitted work; /tmp does not. Every deployment of a new version resets this host's storage entirely, so anything that must outlive a deployment belongs in an external system (a git remote, an issue tracker, a database).${runtimeChanges}`
196
+ : `Your workspace survives restarts and deployments, including uncommitted work; /tmp does not. A new deployment replaces your definition directory with the author's release, so keep ongoing project work outside it.${runtimeChanges}`;
210
197
  }
211
- return prompt;
198
+ return sections;
212
199
  }
213
200
  /** Shared low-level wiring: resolve the model spec against the collection, default the K ports, build the parts. */
214
201
  function assemblePi(opts) {
@@ -225,7 +212,11 @@ function assemblePi(opts) {
225
212
  };
226
213
  let engine;
227
214
  const resolveEngine = () => {
228
- engine ??= modelRuntime().then((runtime) => ({ modelRuntime: runtime, model: resolveModel(runtime, opts.model) }));
215
+ const spec = opts.model;
216
+ engine ??= modelRuntime().then((runtime) => ({
217
+ modelRuntime: runtime,
218
+ ...(spec ? { model: resolveModel(runtime, spec) } : {}),
219
+ }));
229
220
  return engine;
230
221
  };
231
222
  // Deny omitted coding names so discovery cannot reintroduce tools a lower-level caller excluded.
@@ -233,7 +224,7 @@ function assemblePi(opts) {
233
224
  const sessionFactory = piAgentSessionFactory({
234
225
  sessions,
235
226
  engine: async () => ({ modelRuntime: await createModelRuntime() }),
236
- modelSpec: opts.model,
227
+ ...(opts.model ? { modelSpec: opts.model } : {}),
237
228
  thinkingLevel: opts.thinkingLevel,
238
229
  tools: opts.tools,
239
230
  readDefinition: opts.readDefinition,
@@ -267,8 +258,9 @@ export function createPiAgent(options) {
267
258
  thinkingLevel: options.thinkingLevel,
268
259
  models: models.createRuntime,
269
260
  catalog: models.runtime,
261
+ // No instructions is an empty prompt, not pi's default: this path takes the prompt whole.
270
262
  readDefinition: () => ({
271
- systemPrompt: typeof instructions === "function" ? instructions() : instructions,
263
+ systemPrompt: (typeof instructions === "function" ? instructions() : instructions) || " ",
272
264
  skills,
273
265
  }),
274
266
  tools: options.tools,
@@ -283,8 +275,8 @@ export function createPiAgent(options) {
283
275
  * is built here from the credential options.
284
276
  */
285
277
  export async function assemblePiFromDefinition(dir, options) {
286
- // `dir` = the agent-definition dir (persona.md/skills/); `cwd` (default = dir) is the run root where tools operate
287
- // and whose ancestors are walked for ② context.
278
+ // `dir` = the agent-definition dir; `cwd` (default = dir) is the run root where tools operate and whose ancestors are
279
+ // walked for project context.
288
280
  const cwd = options.cwd ?? dir;
289
281
  const env = options.env ?? new NodeExecutionEnv({ cwd });
290
282
  // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to report
@@ -294,6 +286,12 @@ export async function assemblePiFromDefinition(dir, options) {
294
286
  // Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by the resolved
295
287
  // dir).
296
288
  reportFindingsIfChanged(definition.dir, definition);
289
+ // pi treats an empty prompt as none and builds its default, so a blank `base` would look like a prompt of the
290
+ // caller's own while being nothing.
291
+ if (options.base !== undefined && options.base.trim() === "") {
292
+ throw new Error("`base` is empty: pass the prompt the agent should use, or leave `base` out for pi's default");
293
+ }
294
+ refuseDefaultPromptOverReplacedTools(tools, options.base !== undefined || definition.systemPrompt !== undefined);
297
295
  const { providers } = options;
298
296
  const models = options.models ?? agentModels(dir, options, { cwd, env, ...(providers ? { providers } : {}) });
299
297
  // Built at boot, so a malformed models.json fails the assembly rather than its first turn. The directory's own
@@ -317,15 +315,15 @@ export async function assemblePiFromDefinition(dir, options) {
317
315
  readDefinition: async () => {
318
316
  const def = await loadAgentDefinition(dir, { cwd, env });
319
317
  reportFindingsIfChanged(def.dir, def);
318
+ const systemPrompt = options.base ?? def.systemPrompt?.content;
319
+ refuseDefaultPromptOverReplacedTools(tools, systemPrompt !== undefined);
320
320
  return {
321
- systemPrompt: assembleSystemPrompt({
322
- // Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity, re-read per turn
323
- // like AGENTS.md so edits go live.
324
- base: options.base ?? piBasePrompt({ tools, persona: def.persona, builtinExtensions }),
325
- // ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
326
- contextFiles: def.contextFiles,
327
- }),
321
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
322
+ ...(def.appendSystemPrompt ? { appendSystemPrompt: def.appendSystemPrompt.content } : {}),
323
+ contextFiles: def.contextFiles,
324
+ sections: fastagentPromptSections({ tools, builtinExtensions }),
328
325
  skills: def.skills,
326
+ prompts: def.prompts,
329
327
  };
330
328
  },
331
329
  tools,
@@ -1,39 +1,68 @@
1
- import { type ExecutionEnv, type Skill, type SkillDiagnostic } from "@earendil-works/pi-agent-core";
1
+ import { type ExecutionEnv, type PromptTemplateDiagnostic, type Skill, type SkillDiagnostic } from "@earendil-works/pi-agent-core";
2
2
  /** A same-name skill collision (the discarded side). */
3
3
  export interface SkillCollision {
4
4
  name: string;
5
5
  winnerPath: string;
6
6
  loserPath: string;
7
7
  }
8
+ /**
9
+ * Two places in the definition holding the same thing: `SYSTEM.md` and `.pi/SYSTEM.md`, or one prompt template in
10
+ * `prompts/` and `.pi/prompts/`. The first is used; the other is reported, because it is a file the author wrote that
11
+ * does nothing.
12
+ */
13
+ export interface DefinitionShadow {
14
+ what: string;
15
+ winnerPath: string;
16
+ loserPath: string;
17
+ }
18
+ /** A file the definition holds verbatim, with where it came from. */
19
+ export interface DefinitionFile {
20
+ path: string;
21
+ content: string;
22
+ }
23
+ /** A prompt template of the definition's own (`prompts/`, `.pi/prompts/`). */
24
+ export interface DefinitionPrompt {
25
+ name: string;
26
+ description?: string;
27
+ content: string;
28
+ filePath: string;
29
+ }
30
+ export type DefinitionDiagnostic = SkillDiagnostic | PromptTemplateDiagnostic;
8
31
  /** Result of loading a definition directory. */
9
32
  export interface LoadedDefinition {
10
- /** Project-context files feeding segment ② `<project_context>`, sourced via pi's `loadProjectContextFiles`. */
33
+ /** Project-context files (AGENTS.md), sourced via pi's `loadProjectContextFiles`; pi renders them. */
11
34
  contextFiles: Array<{
12
35
  path: string;
13
36
  content: string;
14
37
  }>;
15
- /**
16
- * Verbatim `persona.md` content — the authored persona that OVERRIDES segment ①'s identity line (piBasePrompt keeps
17
- * the tool list + guidelines; NOT a full system-prompt replacement — that is L1 createPiAgent's `instructions`).
18
- * undefined when absent → segment ① is the default engine identity.
19
- */
20
- persona?: string;
38
+ /** `SYSTEM.md`, else `.pi/SYSTEM.md`: replaces pi's default prompt. Absent → pi builds its default. */
39
+ systemPrompt?: DefinitionFile;
40
+ /** `APPEND_SYSTEM.md`, else `.pi/APPEND_SYSTEM.md`: added after the prompt, whichever it is. */
41
+ appendSystemPrompt?: DefinitionFile;
21
42
  skills: Skill[];
22
- /** Non-fatal per-file skill problems reported by pi's loader. */
23
- diagnostics: SkillDiagnostic[];
24
- /** Same-name conflicts across mounts (first-wins). */
43
+ prompts: DefinitionPrompt[];
44
+ /** Non-fatal per-file problems reported by pi's skill and prompt-template loaders. */
45
+ diagnostics: DefinitionDiagnostic[];
46
+ /** Same-name skill conflicts across the definition's skill directories (first wins). */
25
47
  collisions: SkillCollision[];
26
- /** Absolute agent-definition directory path (persona.md/skills/ live here). */
48
+ /** Same-name files and prompt templates across the definition's locations (first wins). */
49
+ shadowed: DefinitionShadow[];
50
+ /** Paths that exist in the definition and are deliberately not loaded, each with why. */
51
+ ignored: Array<{
52
+ path: string;
53
+ reason: string;
54
+ }>;
55
+ /** Absolute agent-definition directory path. */
27
56
  dir: string;
28
57
  }
29
58
  export interface LoadAgentDefinitionOptions {
30
- /** Working directory whose ancestors are walked for context files (segment ②). */
59
+ /** Working directory whose ancestors are walked for context files. */
31
60
  cwd?: string;
32
61
  env?: ExecutionEnv;
33
62
  }
34
63
  /**
35
- * Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd,
36
- * agentDir }).
64
+ * Read an agent definition: its prompt files, skills and prompt templates from `agentDir`; the project context from
65
+ * pi's loadProjectContextFiles({ cwd, agentDir }).
37
66
  */
38
67
  export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
39
68
  /** Extension entry-point FILES under `<agentDir>/extensions/`, empty when there are none. */
@@ -41,18 +70,5 @@ export declare function loadExtensionPaths(agentDir: string, options?: {
41
70
  cwd?: string;
42
71
  env?: ExecutionEnv;
43
72
  }): Promise<string[]>;
44
- /**
45
- * The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same loader, same
46
- * containment guard, same first-wins collision rule).
47
- */
48
- export declare function loadAgentSkills(agentDir: string, options?: {
49
- cwd?: string;
50
- env?: ExecutionEnv;
51
- }): Promise<{
52
- skills: Skill[];
53
- diagnostics: SkillDiagnostic[];
54
- collisions: SkillCollision[];
55
- dir: string;
56
- }>;
57
73
  /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath. */
58
74
  export declare function canonicalPath(p: string): string;