@fastagent-sh/fastagent 0.16.0 → 0.16.2

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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-state.js +21 -6
  5. package/dist/channels/control.js +3 -0
  6. package/dist/cli/commands/attach.d.ts +11 -1
  7. package/dist/cli/commands/attach.js +30 -2
  8. package/dist/cli/commands/deploy.d.ts +13 -0
  9. package/dist/cli/commands/deploy.js +18 -6
  10. package/dist/cli/commands/dev.d.ts +1 -0
  11. package/dist/cli/commands/dev.js +15 -4
  12. package/dist/cli/commands/info.js +1 -1
  13. package/dist/cli/commands/logs.d.ts +6 -0
  14. package/dist/cli/commands/logs.js +27 -0
  15. package/dist/cli/commands/start.d.ts +1 -0
  16. package/dist/cli/commands/start.js +11 -3
  17. package/dist/cli/commands/tool.js +10 -2
  18. package/dist/cli/program.js +35 -0
  19. package/dist/cli/serve.d.ts +26 -2
  20. package/dist/cli/serve.js +71 -17
  21. package/dist/cli/shared.d.ts +6 -0
  22. package/dist/cli/shared.js +14 -0
  23. package/dist/deploy/agentcore/logs.d.ts +30 -0
  24. package/dist/deploy/agentcore/logs.js +115 -0
  25. package/dist/deploy/agentcore/plan.d.ts +23 -0
  26. package/dist/deploy/agentcore/plan.js +41 -3
  27. package/dist/deploy/container.js +6 -3
  28. package/dist/deploy/preflight.js +18 -0
  29. package/dist/engines/pi/config.d.ts +7 -3
  30. package/dist/engines/pi/config.js +9 -3
  31. package/dist/engines/pi/create.d.ts +31 -18
  32. package/dist/engines/pi/create.js +46 -17
  33. package/dist/engines/pi/harness.d.ts +31 -33
  34. package/dist/engines/pi/harness.js +24 -76
  35. package/dist/engines/pi/open.d.ts +2 -2
  36. package/dist/engines/pi/open.js +2 -1
  37. package/dist/engines/pi/read-image.d.ts +4 -0
  38. package/dist/engines/pi/read-image.js +62 -0
  39. package/dist/engines/pi/search-tools.d.ts +6 -4
  40. package/dist/engines/pi/search-tools.js +3 -1
  41. package/dist/engines/pi/session-builder.js +7 -2
  42. package/dist/engines/pi/session-control.d.ts +12 -3
  43. package/dist/engines/pi/session-control.js +156 -27
  44. package/dist/engines/pi/session-settings.d.ts +51 -0
  45. package/dist/engines/pi/session-settings.js +73 -0
  46. package/dist/engines/pi/sessions.d.ts +21 -7
  47. package/dist/engines/pi/sessions.js +43 -0
  48. package/dist/engines/pi/tool.d.ts +13 -5
  49. package/dist/engines/pi/wake-tool.d.ts +3 -3
  50. package/dist/host/node.d.ts +2 -0
  51. package/dist/host/node.js +2 -1
  52. package/dist/pi.d.ts +1 -1
  53. package/dist/session.d.ts +34 -9
  54. package/package.json +4 -4
@@ -1,24 +1,25 @@
1
- import type { AgentTool, ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
1
+ import { type ExecutionEnv, type Skill, type ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Models, Provider } from "@earendil-works/pi-ai";
3
3
  import type { Agent } from "../../agent.ts";
4
4
  import { type FastagentConfig } from "./config.ts";
5
5
  import { type LoadedDefinition } from "./definition.ts";
6
- import { piHarnessFactory } from "./harness.ts";
6
+ import { type AnyModel, piHarnessFactory } from "./harness.ts";
7
7
  import { type PiSessionStore } from "./sessions.ts";
8
8
  import type { ModuleLoadFailure } from "../../loader.ts";
9
- import { type FastagentTool, type ToolCollision } from "./tool.ts";
9
+ import { type ToolCollision, type MountedTool } from "./tool.ts";
10
10
  import { type Lease, type SessionObserver } from "./invoke.ts";
11
- /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
12
- export declare function piDefaultTools(cwd: string): AgentTool[];
11
+ /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
12
+ * turn as the harness tool context — hence no argument here. */
13
+ export declare function piDefaultTools(): MountedTool[];
13
14
  /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
14
- export declare function resolveTools(config: FastagentConfig, cwd: string): AgentTool[];
15
+ export declare function resolveTools(config: FastagentConfig): MountedTool[];
15
16
  /**
16
17
  * The full tool set an agent mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
17
18
  * existing win), plus the non-default tool names and collisions to report. One source for the
18
19
  * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
19
20
  */
20
- export declare function resolveAgentTools(config: FastagentConfig, agentDir: string, cwd?: string): Promise<{
21
- tools: AgentTool[];
21
+ export declare function resolveAgentTools(config: FastagentConfig, agentDir: string): Promise<{
22
+ tools: MountedTool[];
22
23
  toolNames: string[];
23
24
  /** Tools registered but not initially active (defineTool `deferred: true`) — discovered/activated
24
25
  * via the built-in `search_tools` loader. Surfaced so the operator can see deferral took effect. */
@@ -33,7 +34,7 @@ export declare function resolveAgentTools(config: FastagentConfig, agentDir: str
33
34
  * `persona` (from persona.md) replaces the default identity line, keeping the tools list + guidelines.
34
35
  */
35
36
  export declare function piBasePrompt(options?: {
36
- tools?: AgentTool[];
37
+ tools?: MountedTool[];
37
38
  persona?: string;
38
39
  }): string;
39
40
  export interface AssembleSystemPromptOptions {
@@ -63,6 +64,11 @@ type OnAssembly = (parts: {
63
64
  models: Models;
64
65
  harnessFactory: ReturnType<typeof piHarnessFactory>;
65
66
  lease: Lease;
67
+ /** The resolved configured pair — what a session without overrides runs on. */
68
+ defaults: {
69
+ model: AnyModel;
70
+ thinkingLevel: ThinkingLevel;
71
+ };
66
72
  }) => void;
67
73
  /** L1 options. Tier 1: model (spec) + instructions + tools. Tier 2: the injectable ports. */
68
74
  export interface CreatePiAgentOptions {
@@ -76,8 +82,10 @@ export interface CreatePiAgentOptions {
76
82
  * or a factory re-evaluated per invoke. When {@link skills} are mounted their listing is appended.
77
83
  */
78
84
  instructions?: string | (() => string);
79
- /** `FastagentTool` = AgentTool plus the optional `deferred` marker (see {@link DefineToolOptions}). */
80
- tools?: FastagentTool[];
85
+ /** The tool set to mount. `FastagentTool` (AgentTool plus the optional `deferred` marker, see
86
+ * {@link DefineToolOptions}) widens into {@link MountedTool}, which additionally admits pi's default
87
+ * coding tools — they read the turn's ExecutionEnv as a fifth `execute` parameter. */
88
+ tools?: MountedTool[];
81
89
  skills?: Skill[];
82
90
  /**
83
91
  * Extra providers registered on top of the built-ins — your own gateway / self-hosted endpoint /
@@ -93,8 +101,10 @@ export interface CreatePiAgentOptions {
93
101
  authPath?: string;
94
102
  /** Session persistence. Defaults to in-memory; inject jsonlSessionStore for restart-surviving continuity. */
95
103
  sessions?: PiSessionStore;
96
- /** Harness filesystem/process environment. Defaults to local NodeExecutionEnv (cwd). This is not yet
97
- * a sandbox boundary for pi's cwd-bound coding tools; a sandbox adapter must wire those tools too. */
104
+ /** Filesystem/process environment. Defaults to a local NodeExecutionEnv at `process.cwd()`, and its
105
+ * cwd is the agent's. The default coding tools (read/bash/edit/write) take it as the turn's tool
106
+ * context, so injecting a constrained one narrows where the agent reads, writes and shells. It does
107
+ * NOT constrain author-written `tools/`, which are code and can import anything. */
98
108
  env?: ExecutionEnv;
99
109
  /** Single-writer lease. Defaults to in-process fail-fast inProcessLease(). */
100
110
  lease?: Lease;
@@ -116,9 +126,9 @@ export interface CreatePiAgentFromDefinitionOptions {
116
126
  /** Override the engine base prompt (segment ①). Defaults to piBasePrompt({ tools, persona }) using the
117
127
  * live-read persona.md; pass base to fully opt out of persona.md. */
118
128
  base?: string;
119
- /** Override tools. Defaults to piDefaultTools (lock down with a custom list). `FastagentTool` =
120
- * AgentTool plus the optional `deferred` marker. */
121
- tools?: FastagentTool[];
129
+ /** Override tools. Defaults to {@link piDefaultTools} (lock down with a custom list). An authored
130
+ * `FastagentTool[]` (AgentTool plus the optional `deferred` marker) widens into {@link MountedTool}. */
131
+ tools?: MountedTool[];
122
132
  /**
123
133
  * The agent's working directory: where the default tools operate AND whose ancestors are walked for
124
134
  * ② project context (AGENTS.md). Defaults to `dir`. Set it to the enclosing repo so a coding agent
@@ -135,8 +145,11 @@ export interface CreatePiAgentFromDefinitionOptions {
135
145
  */
136
146
  authPath?: string;
137
147
  sessions?: PiSessionStore;
138
- /** Harness environment; see {@link CreatePiAgentOptions.env}. The default coding tools and project-
139
- * context loader remain local today, so injecting this alone does not sandbox a directory agent. */
148
+ /** Filesystem/process environment; see {@link CreatePiAgentOptions.env}. At THIS rung it does more
149
+ * than root the default tools: persona.md and skills/ are read through it too. Two surfaces stay
150
+ * OUTSIDE it — ② project context (pi's loadProjectContextFiles uses node fs directly; see
151
+ * definition.ts) and author-written `tools/`, which are code and can import anything. Injecting an
152
+ * env narrows the blast radius rather than closing it. */
140
153
  env?: ExecutionEnv;
141
154
  lease?: Lease;
142
155
  /** Observation-plane tap; see {@link CreatePiAgentOptions.observer}. */
@@ -11,12 +11,13 @@
11
11
  * they come from the definition; the openers own model/tools — from config resolution).
12
12
  */
13
13
  import { formatSkillsForSystemPrompt } from "@earendil-works/pi-agent-core";
14
+ import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-agent-core";
14
15
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
- import { createCodingTools } from "@earendil-works/pi-coding-agent";
16
+ import { readImageProcessor } from "./read-image.js";
16
17
  import { defaultAuthPath, resolveModel } from "./config.js";
17
18
  import { resolveSecretsDir } from "../../paths.js";
18
19
  import { loadAgentDefinition } from "./definition.js";
19
- import { piHarnessFactory } from "./harness.js";
20
+ import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
20
21
  import { createPiModels } from "./models.js";
21
22
  import { reportDefinitionWarnings } from "./report.js";
22
23
  import { inMemorySessionStore } from "./sessions.js";
@@ -26,15 +27,36 @@ import { createPiAgentFromHarness, inProcessLease } from "./invoke.js";
26
27
  // ── §1 tools ─────────────────────────────────────────────────────────────────
27
28
  //
28
29
  // The full pi toolset is the default for fidelity: authors vibe in local pi with it, so serving with
29
- // fewer tools is behavior drift. Isolation is the K-side ExecutionEnv/sandbox's job, not the tool
30
- // layer's; locking down for public exposure = passing a restricted `tools` list (a deployment posture).
31
- /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
32
- export function piDefaultTools(cwd) {
33
- return createCodingTools(cwd);
30
+ // fewer tools is behavior drift. Locking down for public exposure = passing a restricted `tools` list
31
+ // (a deployment posture).
32
+ //
33
+ // These are pi-agent-core's tools, which reach the filesystem and the shell through the
34
+ // {@link ExecutionEnv} the harness hands them per turn — NOT pi-coding-agent's, which are the same four
35
+ // tools wired to `node:fs` directly. Going through the env is the point, and the whole of it: it makes
36
+ // {@link CreatePiAgentOptions.env} the one seam a sandbox adapter has to implement, instead of a knob
37
+ // that governed everything except the tools that actually touch the machine. (It buys no decoupling
38
+ // from pi-coding-agent — definition.ts, models.ts and read-image.ts all import it regardless.)
39
+ //
40
+ // The swap holds only while the two behave alike, which they do NOT for free: core's `read` does
41
+ // nothing with images unless a processor is injected (read-image.ts), and both families are compared
42
+ // on every path in test/tools-parity.test.ts.
43
+ //
44
+ // `chat` is unaffected: it takes these NAMES only and lets pi's own runtime rebuild the tools it
45
+ // renders (see session-builder.ts).
46
+ /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
47
+ * turn as the harness tool context — hence no argument here. */
48
+ export function piDefaultTools() {
49
+ // `read` needs its image pipeline INJECTED (core ships none); see read-image.ts for what is at stake.
50
+ return [
51
+ createReadTool({ imageProcessor: readImageProcessor }),
52
+ createBashTool(),
53
+ createEditTool(),
54
+ createWriteTool(),
55
+ ];
34
56
  }
35
57
  /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
36
- export function resolveTools(config, cwd) {
37
- const defaults = piDefaultTools(cwd);
58
+ export function resolveTools(config) {
59
+ const defaults = piDefaultTools();
38
60
  return config.tools ? [...defaults, ...config.tools] : defaults;
39
61
  }
40
62
  /**
@@ -42,11 +64,12 @@ export function resolveTools(config, cwd) {
42
64
  * existing win), plus the non-default tool names and collisions to report. One source for the
43
65
  * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
44
66
  */
45
- export async function resolveAgentTools(config, agentDir, cwd = agentDir) {
46
- // Default coding tools (read/bash/edit/write) are rooted at `cwd` (the workspace the agent operates
47
- // on); discovered `tools/` come from `agentDir` (the agent's own surface).
67
+ export async function resolveAgentTools(config, agentDir) {
68
+ // Discovered `tools/` come from `agentDir` (the agent's own surface); the default coding tools carry
69
+ // no root of their own they operate through the ExecutionEnv handed to them per turn, whose cwd is
70
+ // the workspace.
48
71
  const discovered = await loadTools(agentDir);
49
- const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
72
+ const merged = mergeDiscoveredTools(resolveTools(config), discovered.tools);
50
73
  // The built-in `search_tools` loader mounts here — the one place the agent's full tool set is
51
74
  // computed — so `dev`/`start`/`info`/`fastagent tool` all see the same surface (idempotent; an
52
75
  // agent-defined search_tools wins).
@@ -59,7 +82,7 @@ export async function resolveAgentTools(config, agentDir, cwd = agentDir) {
59
82
  // defaults, the builtin loader (like wake, a builtin gets its own report line, not an anonymous
60
83
  // slot in the author's list — an author-DEFINED search_tools still shows), and deferred tools —
61
84
  // each name lives in exactly ONE report slot, and deferred names live in `deferredToolNames`.
62
- const defaultNames = new Set(piDefaultTools(cwd).map((t) => t.name));
85
+ const defaultNames = new Set(piDefaultTools().map((t) => t.name));
63
86
  const toolNames = tools
64
87
  .filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
65
88
  .map((t) => t.name);
@@ -146,18 +169,24 @@ function buildPiAgent(opts) {
146
169
  // Materialized here (not defaulted inside createPiAgentFromHarness) so the exposed parts carry
147
170
  // the SAME lease instance the agent runs under — boundary mutations must contend on it.
148
171
  const lease = opts.lease ?? inProcessLease();
172
+ // The assembly's configured PAIR — handed to the factory and to the control plane as ONE value, so
173
+ // there is no wiring in which they could disagree (which levels exist depends on the model).
174
+ const defaults = {
175
+ model: resolveModel(models, opts.model),
176
+ thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
177
+ };
149
178
  const harnessFactory = piHarnessFactory({
150
179
  sessions: opts.sessions ?? inMemorySessionStore(),
151
180
  env,
152
181
  models,
153
- model: resolveModel(models, opts.model),
182
+ model: defaults.model,
154
183
  thinkingLevel: opts.thinkingLevel,
155
184
  systemPrompt: opts.systemPrompt,
156
185
  tools: opts.tools,
157
186
  skills: opts.skills,
158
187
  live: opts.live,
159
188
  });
160
- opts.onAssembly?.({ models, harnessFactory, lease });
189
+ opts.onAssembly?.({ models, harnessFactory, lease, defaults });
161
190
  return createPiAgentFromHarness({ lease, observer: opts.observer, cwd: env.cwd, harnessFactory });
162
191
  }
163
192
  /**
@@ -216,7 +245,7 @@ export async function createPiAgentFromDefinition(dir, options) {
216
245
  let reportedFindings = findingsSignature(definition);
217
246
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
218
247
  // it; a caller's own search_tools wins).
219
- const tools = withSearchTool(options.tools ?? piDefaultTools(env.cwd));
248
+ const tools = withSearchTool(options.tools ?? piDefaultTools());
220
249
  const agent = buildPiAgent({
221
250
  model: options.model,
222
251
  thinkingLevel: options.thinkingLevel,
@@ -7,9 +7,11 @@
7
7
  * historical entries back into context via buildContext().
8
8
  */
9
9
  import { AgentHarness } from "@earendil-works/pi-agent-core";
10
- import type { AgentTool, ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
10
+ import type { ExecutionEnv, ExecutionToolContext, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
11
11
  import type { Model, Models } from "@earendil-works/pi-ai";
12
- import type { PiSessionStore } from "./sessions.ts";
12
+ import { type PiSessionStore } from "./sessions.ts";
13
+ import { type MountedTool } from "./tool.ts";
14
+ import { type OverrideEntryLike } from "./session-settings.ts";
13
15
  /**
14
16
  * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
15
17
  * tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
@@ -19,18 +21,30 @@ import type { PiSessionStore } from "./sessions.ts";
19
21
  * carry only what was actually discovered.
20
22
  */
21
23
  export declare const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
24
+ /** The session a factory-built harness is bound to — the seam the activation bridge (invoke.ts) uses
25
+ * to write {@link TOOL_ACTIVATION_ENTRY} deltas (pi's harness keeps its session private). Absent for
26
+ * a harness built outside {@link piHarnessFactory}: activation still works in-turn there, but is not
27
+ * recorded — the factory owns persistence. */
28
+ type AnyHarness = AgentHarness<any>;
22
29
  export type PiSession = Awaited<ReturnType<PiSessionStore["openOrCreate"]>>;
23
- export declare function harnessSession(harness: AgentHarness): PiSession | undefined;
30
+ export declare function harnessSession(harness: AnyHarness): PiSession | undefined;
24
31
  /**
25
32
  * pi's Model with the API-shape generic erased — fastagent only passes models through to the
26
33
  * harness, so the generic carries no information. One alias keeps the `any` auditable.
27
34
  */
28
35
  export type AnyModel = Model<any>;
29
36
  /** Builds a pi harness bound to the given session — called once per invoke. */
30
- export type PiHarnessFactory = (session: string) => AgentHarness | Promise<AgentHarness>;
37
+ /** The harness fastagent builds: context-typed on {@link ExecutionToolContext}, because that is what
38
+ * pi's env-backed default tools read (pi 0.83). Custom tools are context-FREE and stay assignable — a
39
+ * four-parameter `execute` satisfies the five-parameter one, so `defineTool` is untouched by this. */
40
+ type PiHarness = AgentHarness<ExecutionToolContext>;
41
+ export type PiHarnessFactory = (session: string) => PiHarness | Promise<PiHarness>;
31
42
  export interface PiHarnessFactoryOptions {
32
43
  /** Session persistence. Continuity = same backing store + same session id. */
33
44
  sessions: PiSessionStore;
45
+ /** Filesystem/process environment for the default coding tools. Handed to the harness as the TURN's
46
+ * tool context (pi 0.83), which is how read/bash/edit/write reach the machine at all — so this is the
47
+ * ONE seam a sandbox adapter implements, not a knob beside the tools that ignore it. */
34
48
  env: ExecutionEnv;
35
49
  /** Provider collection for all model requests; {@link model} must belong to it (same provider id). */
36
50
  models: Models;
@@ -38,7 +52,7 @@ export interface PiHarnessFactoryOptions {
38
52
  /** Reasoning effort for the model (pi's scale). Unset = fastagent's pinned default ("medium", pi
39
53
  * TUI parity — see {@link DEFAULT_THINKING_LEVEL}); unsupported levels are clamped by pi per model. */
40
54
  thinkingLevel?: ThinkingLevel;
41
- tools?: AgentTool[];
55
+ tools?: MountedTool[];
42
56
  /**
43
57
  * Final assembled prompt, or a SYNC factory re-evaluated per invoke (how L1 serves dynamic
44
58
  * `instructions` + the skills listing). Distinct from {@link live}, which is the directory rung's
@@ -71,36 +85,19 @@ export declare const SUMMARIZATION_RETRY_POLICY: {
71
85
  readonly maxRetries: 3;
72
86
  readonly baseDelayMs: 2000;
73
87
  };
74
- export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
75
- /** The shape both override consumers walk — a session entry, structurally. */
76
- export interface OverrideEntryLike {
77
- type: string;
78
- provider?: string;
79
- modelId?: string;
80
- thinkingLevel?: string;
81
- }
82
88
  /**
83
- * The session's durable override FACTS the ONE walk both surfaces consume (`state()` reports the
84
- * recorded truth; `resolveHarnessOverrides` below applies registry/scale fallbacks on top). The
85
- * LAST entry of each kind wins, and a malformed record reads as ABSENT for that kind — never
86
- * skipped over to an earlier record: the reporting surface and the execution surface must agree on
87
- * which record is "the" override.
89
+ * The serving default for reasoning effort, pinned to what pi's TUI defaults to (its
90
+ * DEFAULT_THINKING_LEVEL) NOT inherited from the bare harness, whose own fallback is "off": an
91
+ * author vibes at "medium" in pi and must get "medium" when served (fidelity), and pinning the value
92
+ * here means an upstream default change in either place cannot silently alter deployments. Models
93
+ * that don't support a level are clamped by pi per model.
88
94
  */
89
- export declare function lastOverrideEntries(entries: OverrideEntryLike[]): {
90
- model?: {
91
- provider: string;
92
- modelId: string;
93
- };
94
- thinkingLevel?: string;
95
- };
95
+ export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
96
96
  /**
97
- * Resolve the session's model/thinking OVERRIDES for a fresh harness same shape as the
98
- * active-tools resolve above: pi writes `model_change`/`thinking_level_change` entries on explicit
99
- * setModel/setThinkingLevel (the control plane's `set_model`/`set_thinking` append them directly)
100
- * but a fresh harness never reads them back. Override facts come from {@link lastOverrideEntries};
101
- * this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
102
- * back to the default with a deduped warn (fail visibly without bricking the session — the
103
- * conversation must survive a registry change across deploys); an unknown thinking level likewise.
97
+ * {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
98
+ * being executable with no control-plane command involved (pi appends these entries itself; a
99
+ * deployment's configured model can change between restarts). Deduped per session+cause — it would
100
+ * otherwise repeat every turn.
104
101
  */
105
102
  export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], models: Models, defaults: {
106
103
  model: AnyModel;
@@ -109,6 +106,7 @@ export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], mo
109
106
  model: AnyModel;
110
107
  thinkingLevel: ThinkingLevel;
111
108
  };
112
- export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: AgentTool[], sessionId: string): string[] | undefined;
109
+ export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: MountedTool[], sessionId: string): string[] | undefined;
113
110
  /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
114
111
  export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
112
+ export {};
@@ -8,7 +8,9 @@
8
8
  */
9
9
  import { AgentHarness } from "@earendil-works/pi-agent-core";
10
10
  import { log } from "../../log.js";
11
+ import { activePathEntries } from "./sessions.js";
11
12
  import { isDeferredTool } from "./tool.js";
13
+ import { resolveSessionSettings } from "./session-settings.js";
12
14
  /**
13
15
  * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
14
16
  * tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
@@ -18,10 +20,6 @@ import { isDeferredTool } from "./tool.js";
18
20
  * carry only what was actually discovered.
19
21
  */
20
22
  export const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
21
- /** The session a factory-built harness is bound to — the seam the activation bridge (invoke.ts) uses
22
- * to write {@link TOOL_ACTIVATION_ENTRY} deltas (pi's harness keeps its session private). Absent for
23
- * a harness built outside {@link piHarnessFactory}: activation still works in-turn there, but is not
24
- * recorded — the factory owns persistence. */
25
23
  const harnessSessions = new WeakMap();
26
24
  export function harnessSession(harness) {
27
25
  return harnessSessions.get(harness);
@@ -52,7 +50,7 @@ export const SUMMARIZATION_RETRY_POLICY = { enabled: true, maxRetries: 3, baseDe
52
50
  * here means an upstream default change in either place cannot silently alter deployments. Models
53
51
  * that don't support a level are clamped by pi per model.
54
52
  */
55
- const DEFAULT_THINKING_LEVEL = "medium";
53
+ export const DEFAULT_THINKING_LEVEL = "medium";
56
54
  /**
57
55
  * Resolve the active-tool set for a fresh harness — the ONE place both fallbacks live. pi's harness
58
56
  * WRITES active-tool changes to the session (`setActiveTools` → `active_tools_change`) but its
@@ -76,83 +74,30 @@ const DEFAULT_THINKING_LEVEL = "medium";
76
74
  * (like L2's findings memo), not session state — the resolve stays derived from the session.
77
75
  */
78
76
  const warnedRestores = new Set();
79
- /** pi's ThinkingLevel scale as a checkable set — THE single source for fastagent (session entries
80
- * store plain strings; session-control's dispatch validation and capabilities derive from this).
81
- * The `satisfies Record<ThinkingLevel, …>` anchor makes it EXHAUSTIVE against pi's union: pi
82
- * adding a level turns this into a type error instead of a silent drift where `set_thinking`
83
- * rejects a value pi supports. */
84
- const ALL_THINKING_LEVELS = {
85
- off: true,
86
- minimal: true,
87
- low: true,
88
- medium: true,
89
- high: true,
90
- xhigh: true,
91
- max: true,
92
- };
93
- export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
94
77
  /**
95
- * The session's durable override FACTS the ONE walk both surfaces consume (`state()` reports the
96
- * recorded truth; `resolveHarnessOverrides` below applies registry/scale fallbacks on top). The
97
- * LAST entry of each kind wins, and a malformed record reads as ABSENT for that kind — never
98
- * skipped over to an earlier record: the reporting surface and the execution surface must agree on
99
- * which record is "the" override.
100
- */
101
- export function lastOverrideEntries(entries) {
102
- let model;
103
- let modelSeen = false;
104
- let thinkingLevel;
105
- let thinkingSeen = false;
106
- for (let i = entries.length - 1; i >= 0 && !(modelSeen && thinkingSeen); i--) {
107
- const e = entries[i];
108
- if (!modelSeen && e?.type === "model_change") {
109
- modelSeen = true;
110
- if (e.provider !== undefined && e.modelId !== undefined)
111
- model = { provider: e.provider, modelId: e.modelId };
112
- }
113
- if (!thinkingSeen && e?.type === "thinking_level_change") {
114
- thinkingSeen = true;
115
- if (e.thinkingLevel !== undefined)
116
- thinkingLevel = e.thinkingLevel;
117
- }
118
- }
119
- return { model, thinkingLevel };
120
- }
121
- /**
122
- * Resolve the session's model/thinking OVERRIDES for a fresh harness — same shape as the
123
- * active-tools resolve above: pi writes `model_change`/`thinking_level_change` entries on explicit
124
- * setModel/setThinkingLevel (the control plane's `set_model`/`set_thinking` append them directly)
125
- * but a fresh harness never reads them back. Override facts come from {@link lastOverrideEntries};
126
- * this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
127
- * back to the default with a deduped warn (fail visibly without bricking the session — the
128
- * conversation must survive a registry change across deploys); an unknown thinking level likewise.
78
+ * {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
79
+ * being executable with no control-plane command involved (pi appends these entries itself; a
80
+ * deployment's configured model can change between restarts). Deduped per session+cause it would
81
+ * otherwise repeat every turn.
129
82
  */
130
83
  export function resolveHarnessOverrides(entries, models, defaults, sessionId) {
131
- let model = defaults.model;
132
- let thinkingLevel = defaults.thinkingLevel;
84
+ const settings = resolveSessionSettings(entries, models, defaults);
133
85
  const warnOnce = (key, message) => {
134
86
  const emit = warnedRestores.has(key) ? log.debug : log.warn;
135
87
  warnedRestores.add(key);
136
88
  emit(message);
137
89
  };
138
- const recorded = lastOverrideEntries(entries);
139
- if (recorded.model) {
140
- const found = models.getModel(recorded.model.provider, recorded.model.modelId);
141
- if (found)
142
- model = found;
143
- else {
144
- warnOnce(`${sessionId}\u0000model\u0000${recorded.model.provider}/${recorded.model.modelId}`, `[fastagent] session ${sessionId}: recorded model override ${recorded.model.provider}/${recorded.model.modelId} is not in this deployment's registry — using the configured default`);
145
- }
90
+ const dropped = settings.dropped;
91
+ if (dropped?.model) {
92
+ warnOnce(`${sessionId}\u0000model\u0000${dropped.model}`, `[fastagent] session ${sessionId}: recorded model override ${dropped.model} is not in this deployment's registry — using the configured default`);
146
93
  }
147
- if (recorded.thinkingLevel !== undefined) {
148
- if (THINKING_LEVELS.has(recorded.thinkingLevel)) {
149
- thinkingLevel = recorded.thinkingLevel;
150
- }
151
- else {
152
- warnOnce(`${sessionId}\u0000thinking\u0000${recorded.thinkingLevel}`, `[fastagent] session ${sessionId}: recorded thinking level "${recorded.thinkingLevel}" is unknown — using the configured default`);
153
- }
94
+ if (dropped?.thinkingLevel) {
95
+ const { recorded, running, known } = dropped.thinkingLevel;
96
+ warnOnce(`${sessionId}\u0000thinking\u0000${settings.model.provider}/${settings.model.id}\u0000${recorded}`, known
97
+ ? `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is not supported by ${settings.model.provider}/${settings.model.id} — running at "${running}"`
98
+ : `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is unknown — using the configured default`);
154
99
  }
155
- return { model, thinkingLevel };
100
+ return { model: settings.model, thinkingLevel: settings.thinkingLevel };
156
101
  }
157
102
  export function resolveHarnessActiveToolNames(recorded, tools, sessionId) {
158
103
  const anyDeferred = tools.some(isDeferredTool);
@@ -174,9 +119,10 @@ export function piHarnessFactory(options) {
174
119
  return async (sessionId) => {
175
120
  const session = await options.sessions.openOrCreate(sessionId);
176
121
  // One extra entry walk per invoke to collect the activation deltas — negligible against the model
177
- // call, same trade as L2's per-invoke definition re-read. Serving sessions never branch, so a flat
178
- // getEntries() read (no leaf-path walk) is correct.
179
- const entries = await session.getEntries();
122
+ // call, same trade as L2's per-invoke definition re-read. The walk is over the ACTIVE PATH, not
123
+ // the flat journal: `navigate` moves the leaf, so the tree can hold an abandoned branch whose
124
+ // activations and overrides this session has left behind.
125
+ const entries = await activePathEntries(session);
180
126
  const activated = entries.flatMap((e) => e.type === "custom" && e.customType === TOOL_ACTIVATION_ENTRY
181
127
  ? (e.data?.names ?? [])
182
128
  : []);
@@ -187,7 +133,9 @@ export function piHarnessFactory(options) {
187
133
  // Session overrides (set_model / set_thinking) win over the assembly defaults — same entry walk.
188
134
  const overrides = resolveHarnessOverrides(entries, options.models, { model: options.model, thinkingLevel: options.thinkingLevel ?? DEFAULT_THINKING_LEVEL }, sessionId);
189
135
  const harness = new AgentHarness({
190
- env: options.env,
136
+ // Static, not a per-turn provider: the env is fixed for the agent's lifetime, and resolving a
137
+ // constant per turn would only add a promise to the turn's critical path.
138
+ toolContext: { env: options.env },
191
139
  session,
192
140
  models: options.models,
193
141
  model: overrides.model,
@@ -1,4 +1,3 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
2
1
  import type { Agent } from "../../agent.ts";
3
2
  import { type FastagentConfig } from "./config.ts";
4
3
  import type { SessionControl } from "../../session.ts";
@@ -7,6 +6,7 @@ import type { PiSessionReader, PiSessionStore } from "./sessions.ts";
7
6
  import type { ModuleLoadFailure } from "../../loader.ts";
8
7
  import type { LoadedDefinition } from "./definition.ts";
9
8
  import type { ToolCollision } from "./tool.ts";
9
+ import type { MountedTool } from "./tool.ts";
10
10
  export interface CreatePiAgentFromDirOptions {
11
11
  /** Model spec override (e.g. the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
12
12
  model?: string;
@@ -66,7 +66,7 @@ export interface AgentAssembly {
66
66
  /** Absolute credentials file (--auth-path/authPath option > FASTAGENT_AUTH_PATH > <agentDir>/.secrets/auth.json). */
67
67
  authPath: string;
68
68
  /** The full mounted tool surface (config.tools + discovered tools/, search_tools applied). */
69
- tools: AgentTool[];
69
+ tools: MountedTool[];
70
70
  toolNames: string[];
71
71
  deferredToolNames: string[];
72
72
  toolCollisions: ToolCollision[];
@@ -25,7 +25,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
25
25
  if (!modelSpec) {
26
26
  throw new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
27
27
  }
28
- const { tools, toolNames, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir, workspace);
28
+ const { tools, toolNames, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir);
29
29
  // The state root: sessions/channel state/schedule state derive from it (FASTAGENT_STATE_DIR moves it
30
30
  // in one knob — a container points it at its volume); the finer overrides below still win.
31
31
  const stateRoot = resolveStateRoot(agentDir);
@@ -102,6 +102,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
102
102
  lease: parts.lease,
103
103
  models: parts.models,
104
104
  harnessFactory: parts.harnessFactory,
105
+ defaults: parts.defaults,
105
106
  };
106
107
  }
107
108
  : undefined,
@@ -0,0 +1,4 @@
1
+ import type { ReadImageProcessor } from "@earendil-works/pi-agent-core";
2
+ /** The `read` tool's image processor. Matches pi-coding-agent's messages verbatim: they reach the model
3
+ * as tool output, so a reworded one is a different prompt, not a different implementation detail. */
4
+ export declare const readImageProcessor: ReadImageProcessor;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The image pipeline pi's `read` tool needs: normalize an unsupported format to PNG, resize below the
3
+ * inline limit, and hand back the hints that tell the model what it is looking at.
4
+ *
5
+ * pi-agent-core's `createReadTool` takes this as an INJECTED processor and does nothing without one —
6
+ * unlike pi-coding-agent's, which wires its private `processImage` internally. That function is not
7
+ * exported (nor reachable: the package's `exports` map has no deep paths), so this rebuilds it from the
8
+ * two halves that ARE public, `convertToPng` and `resizeImage`/`formatDimensionNote`.
9
+ *
10
+ * It is upstream logic restated, which is a real cost — without it `read` on a screenshot sends the raw
11
+ * bytes (measured: 7.48 MB of base64 where pi-coding-agent sends 3.48 MB, and no dimension note for the
12
+ * model's coordinate math), and a bmp is dropped entirely while the tool's own description still
13
+ * advertises it. test/tools-parity.test.ts compares this against pi-coding-agent's real `read` on both
14
+ * paths, so upstream changing the pipeline surfaces as a failing test rather than as drift.
15
+ */
16
+ import { convertToPng, formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
17
+ /** Formats a provider takes inline as-is; everything else has to become a PNG first. */
18
+ const INLINE_MIME = {
19
+ "image/png": "image/png",
20
+ "image/jpeg": "image/jpeg",
21
+ "image/jpg": "image/jpeg",
22
+ "image/gif": "image/gif",
23
+ "image/webp": "image/webp",
24
+ };
25
+ /** The `read` tool's image processor. Matches pi-coding-agent's messages verbatim: they reach the model
26
+ * as tool output, so a reworded one is a different prompt, not a different implementation detail. */
27
+ export const readImageProcessor = async (bytes, mimeType, options) => {
28
+ const base = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
29
+ const inline = INLINE_MIME[base];
30
+ let normalized;
31
+ if (inline) {
32
+ normalized = { bytes, mimeType: inline };
33
+ }
34
+ else {
35
+ const png = await convertToPng(Buffer.from(bytes).toString("base64"), base);
36
+ if (!png)
37
+ return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" };
38
+ normalized = { bytes: Buffer.from(png.data, "base64"), mimeType: png.mimeType, convertedFrom: base };
39
+ }
40
+ const hints = [];
41
+ const converted = (to) => normalized.convertedFrom && normalized.convertedFrom !== to
42
+ ? `[Image converted from ${normalized.convertedFrom} to ${to}.]`
43
+ : undefined;
44
+ if (!options.autoResizeImages) {
45
+ const hint = converted(normalized.mimeType);
46
+ if (hint)
47
+ hints.push(hint);
48
+ return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints };
49
+ }
50
+ const resized = await resizeImage(normalized.bytes, normalized.mimeType);
51
+ if (!resized)
52
+ return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" };
53
+ const hint = converted(resized.mimeType);
54
+ if (hint)
55
+ hints.push(hint);
56
+ // The scale factor the model needs to map coordinates back onto the original — dropping it is what
57
+ // makes a resized screenshot unusable for anything positional.
58
+ const note = formatDimensionNote(resized);
59
+ if (note)
60
+ hints.push(note);
61
+ return { ok: true, data: resized.data, mimeType: resized.mimeType, hints };
62
+ };
@@ -1,10 +1,12 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
2
- /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
3
- export declare function withSearchTool(tools: AgentTool[]): AgentTool[];
1
+ import { type MountedTool } from "./tool.ts";
2
+ /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own.
3
+ * Typed on the MOUNTED tool, not the authored one: it inspects names and deferral and never executes,
4
+ * so narrowing here would reject the very set it is handed (pi's defaults take the turn's context). */
5
+ export declare function withSearchTool(tools: MountedTool[]): MountedTool[];
4
6
  /** Build the `search_tools` loader. Keyword search over the inactive tools' name+description.
5
7
  *
6
8
  * `executionMode: "sequential"` — pi turns any batch containing a sequential tool serial. Required for
7
9
  * correct load-point attribution everywhere an OUTER active-set diff exists: pi wraps SDK customTools
8
10
  * (the chat path) in a before/after diff, and two parallel loader calls would both snapshot the
9
11
  * pre-activation set and get stamped with the same activation. Custom loader authors must set it too. */
10
- export declare function makeSearchToolsTool(): AgentTool;
12
+ export declare function makeSearchToolsTool(): MountedTool;