@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.
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 +134 -17
  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 +12 -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 +23 -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 +31 -1
  44. package/dist/engines/pi/turn-kit.js +54 -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 +10 -0
  52. package/package.json +1 -1
  53. package/dist/scaffold/templates/persona.md +0 -12
@@ -1,14 +1,25 @@
1
- /** Definition domain: read an agent definition directory (AGENTS.md + skills/) into memory. */
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. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd,
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
- // ② project context, following pi: the agentDir's own AGENTS.md + every AGENTS.md walking cwd up to root
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
- // persona.md → segment ① persona (overrides the identity line).
27
- const personaPath = join(root, "persona.md");
28
- const personaRead = await e.readTextFile(personaPath, BACKGROUND_CONTEXT);
29
- if (!personaRead.ok && personaRead.error.code !== "not_found") {
30
- throw new Error(`cannot read ${personaPath}: ${personaRead.error.message}`);
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
- const persona = personaRead.ok ? personaRead.value : undefined;
33
- const { skills, diagnostics, collisions } = await readSkills(e, root);
34
- return { contextFiles, persona, skills, diagnostics, collisions, dir: root };
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, shared by the full load and {@link loadAgentSkills}. */
171
+ /** The skills half of {@link loadAgentDefinition}. */
103
172
  async function readSkills(e, root) {
104
- // The definition's OWN skills/ — its half of the answer. The machine's half is machine.ts's, and `withMachine`
105
- // decides a name collision in this half's favour.
106
- await assertInsideAgentDir(root, "skills");
107
- const { skills: raw, diagnostics } = await loadSkills(e, [join(root, "skills")], BACKGROUND_CONTEXT);
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
- * The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same loader, same
123
- * containment guard, same first-wins collision rule).
124
- */
125
- export async function loadAgentSkills(agentDir, options = {}) {
126
- const cwd = options.cwd ?? agentDir;
127
- const e = options.env ?? new NodeExecutionEnv({ cwd });
128
- const rootResult = await e.absolutePath(agentDir, BACKGROUND_CONTEXT);
129
- if (!rootResult.ok)
130
- throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
131
- // `dir` is the RESOLVED root, like {@link LoadedDefinition.dir}.
132
- return { ...(await readSkills(e, rootResult.value)), dir: rootResult.value };
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 { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
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
- function toSessionEvent(event, runId) {
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
- return ev.delta === ""
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
- : errorToTerminal(error));
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 machine lends
17
- * (machine.ts) —, the commands its `extensions/` register, and the machine's prompt templates.
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
- /** The resolved "provider/modelId" spec in use. */
66
- modelSpec: string;
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 resolved "provider/modelId" spec actually in use. */
152
- modelSpec: string;
153
- /** Absolute agent dir in use — channels/tools/persona come from here. */
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;
@@ -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 { loadAgentSkills } from "./definition.js";
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 machine lends
26
- * (machine.ts) —, the commands its `extensions/` register, and the machine's prompt templates.
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
- const own = await loadAgentSkills(agentDir, { cwd: workspace });
38
- // A skill whose frontmatter broke simply is not in `skills` — it would vanish from the composer with no signal.
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
- ...machine.prompts
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: SkillDiagnostic[];
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(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
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
- return [...collisions, ...diagnostics].sort().join("\n");
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.collisions, def.diagnostics);
23
+ reportDefinitionWarnings(def);
17
24
  }
18
- export function reportDefinitionWarnings(collisions, diagnostics) {
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 d of diagnostics) {
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: front.modelSpec, assembly, definition: await assembly.readDefinition() };
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: AnyModel;
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
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
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
- const target = model ?? resolved.model;
638
- const levels = model ? getSupportedThinkingLevels(model) : resolved.availableThinkingLevels;
639
- if (!levels.includes(patch.thinkingLevel)) {
640
- return invalid(`thinking level "${patch.thinkingLevel}" is not supported by ${target.provider}/${target.id} (allowed: ${levels.join(", ")})`);
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 (persona, project context, skill and tool descriptions)
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.