@fastagent-sh/fastagent 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) 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 +14 -6
  5. package/dist/cli/commands/deploy.d.ts +13 -0
  6. package/dist/cli/commands/deploy.js +7 -1
  7. package/dist/cli/commands/dev.d.ts +1 -0
  8. package/dist/cli/commands/dev.js +15 -4
  9. package/dist/cli/commands/info.js +1 -1
  10. package/dist/cli/commands/start.d.ts +1 -0
  11. package/dist/cli/commands/start.js +11 -3
  12. package/dist/cli/commands/tool.js +10 -2
  13. package/dist/cli/program.js +9 -0
  14. package/dist/cli/serve.d.ts +26 -2
  15. package/dist/cli/serve.js +71 -17
  16. package/dist/cli/shared.d.ts +6 -0
  17. package/dist/cli/shared.js +14 -0
  18. package/dist/deploy/agentcore/plan.js +1 -1
  19. package/dist/deploy/preflight.js +18 -0
  20. package/dist/engines/pi/config.d.ts +6 -2
  21. package/dist/engines/pi/config.js +8 -2
  22. package/dist/engines/pi/create.d.ts +25 -17
  23. package/dist/engines/pi/create.js +37 -14
  24. package/dist/engines/pi/harness.d.ts +19 -5
  25. package/dist/engines/pi/harness.js +3 -5
  26. package/dist/engines/pi/open.d.ts +2 -2
  27. package/dist/engines/pi/open.js +1 -1
  28. package/dist/engines/pi/read-image.d.ts +4 -0
  29. package/dist/engines/pi/read-image.js +62 -0
  30. package/dist/engines/pi/search-tools.d.ts +6 -4
  31. package/dist/engines/pi/search-tools.js +3 -1
  32. package/dist/engines/pi/session-builder.js +7 -2
  33. package/dist/engines/pi/tool.d.ts +13 -5
  34. package/dist/engines/pi/wake-tool.d.ts +3 -3
  35. package/dist/host/node.d.ts +2 -0
  36. package/dist/host/node.js +2 -1
  37. package/dist/pi.d.ts +1 -1
  38. package/package.json +4 -4
@@ -538,7 +538,7 @@ function template(input, translated) {
538
538
  ...envLines,
539
539
  ];
540
540
  if (needsForwarder) {
541
- lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ...(input.selfSchedule
541
+ lines.push(``, ` ForwarderRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, ` AssumeRolePolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Principal: { Service: lambda.amazonaws.com }`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns: [arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]`, ` Policies:`, ` - PolicyName: invoke-runtime`, ` PolicyDocument:`, ` Version: "2012-10-17"`, ` Statement:`, ` - Effect: Allow`, ` Action: bedrock-agentcore:InvokeAgentRuntime`, ` Resource:`, ` - !GetAtt Runtime.AgentRuntimeArn`, ` - !Sub "\${Runtime.AgentRuntimeArn}/*"`, ` - Effect: Allow # mint the presigned URLs the container uses for its state snapshot`, ` Action: [s3:GetObject, s3:PutObject]`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}/${STATE_KEY}`, ` # Without s3:ListBucket, S3 folds "key absent" into 403 (anti-enumeration), which is`, ` # indistinguishable from a broken signature — so the container's restore contract`, ` # (agentcore-state.ts: ONLY 404 means first deploy) would dead-end every first deploy.`, ` # Scoped to the snapshot prefix: this grants "may know whether the snapshot exists",`, ` # not a listing of the whole deployment bucket.`, ` - Effect: Allow`, ` Action: s3:ListBucket`, ` Resource: !Sub arn:aws:s3:::\${StateBucket}`, ` Condition:`, ` StringLike: { s3:prefix: state/* }`, ...(input.selfSchedule
542
542
  ? [
543
543
  ` - Effect: Allow # wake alarms: mirror pending wake-ups into one-shot schedules`,
544
544
  ` Action: [scheduler:CreateSchedule, scheduler:UpdateSchedule]`,
@@ -12,6 +12,7 @@
12
12
  import { readdir, readFile } from "node:fs/promises";
13
13
  import { basename, isAbsolute, join, relative, sep } from "node:path";
14
14
  import ignore from "ignore";
15
+ import { classifyBind } from "../bind.js";
15
16
  import { resolveAuthPath } from "../engines/pi/config.js";
16
17
  import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
17
18
  import { inspectChannels } from "../engines/pi/channel.js";
@@ -341,6 +342,23 @@ export async function preflightDeploy(input) {
341
342
  shipsGit,
342
343
  };
343
344
  const port = config.http?.port ?? 8787;
345
+ // `http.host` travels in the artifact (config is what deploy ships), and any non-wildcard value that
346
+ // is right on a laptop is wrong in a container: the wildcard bind is what makes the published port,
347
+ // the health check and webhook ingress reachable at all. `--bind` is the local-only knob; config is not.
348
+ const configBind = classifyBind(config.http?.host);
349
+ if (configBind !== "wildcard") {
350
+ const issue = `fastagent.config.ts sets http.host: "${config.http?.host}" — it travels into the image, where ` +
351
+ (configBind === "loopback"
352
+ ? `nothing outside the container can reach the serve (published port, health check, webhooks).`
353
+ : `that address does not exist, so the container fails to bind at start.`) +
354
+ ` Drop it and use \`--bind ${config.http?.host}\` locally instead.`;
355
+ // Same disposition as the model-travel issue: warn when producing artifacts (the operator may be
356
+ // deploying somewhere that fronts the port), gate `--run` — which would otherwise ship a box that
357
+ // answers nothing, or crash-loops on a bind that cannot resolve inside the container.
358
+ if (run)
359
+ return { ok: false, gate: issue };
360
+ messages.push({ level: "warn", text: issue });
361
+ }
344
362
  // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
345
363
  // secrets: listed in the runbook, set from the local env under --run, gated if a value is missing.
346
364
  const extraSecrets = config.deploy?.secrets ?? [];
@@ -12,8 +12,11 @@ export interface FastagentConfig {
12
12
  /** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
13
13
  * plus the optional `deferred` marker (see defineTool). */
14
14
  tools?: FastagentTool[];
15
+ /** `host` is the bind address: unset (or `0.0.0.0`) binds all interfaces — what containers need;
16
+ * `127.0.0.1` keeps the serve (including `/control/*`) off the LAN. Precedence: `--bind` > this. */
15
17
  http?: {
16
18
  port?: number;
19
+ host?: string;
17
20
  };
18
21
  /** Mount the built-in `wake` tool so the agent can schedule its OWN follow-up turns (self-scheduling).
19
22
  * Off by default — self-scheduling is an autonomy capability, opt in when you want it. Only takes
@@ -24,8 +27,9 @@ export interface FastagentConfig {
24
27
  * steer/abort/compact/set_model…) for remote consumers: a Web panel, a desktop app, `fastagent
25
28
  * attach`. Default off (it is a remote-control surface). When on, `dev`/`start` generate a
26
29
  * per-boot bearer token and write `<stateRoot>/control.json` for local discovery. The serve
27
- * binds all interfaces, so the routes are LAN-reachable with the token as the only protection —
28
- * firewall the port, or wrap it for real exposure (design §14).
30
+ * binds all interfaces by default, so the routes are LAN-reachable with the token as the only
31
+ * protection bind loopback (`--bind 127.0.0.1`; not `http.host`, which travels into a deployed
32
+ * image), firewall the port, or wrap it for real exposure (design §14).
29
33
  */
30
34
  sessionControl?: boolean;
31
35
  /** Deploy-time declarations for what the agent needs on the box, so real agents don't hand-write a
@@ -21,6 +21,7 @@ import { existsSync, statSync } from "node:fs";
21
21
  import { basename, join } from "node:path";
22
22
  import { pathToFileURL } from "node:url";
23
23
  import { THINKING_LEVELS } from "./harness.js";
24
+ import { isBindAddress } from "../../bind.js";
24
25
  import { moduleLoadHint } from "../../loader.js";
25
26
  import { AGENT_CONFIG_NAMES, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
26
27
  /** Identity function for typing and IDE completion (vite/next-style). */
@@ -116,13 +117,18 @@ export async function loadConfig(dir) {
116
117
  throw new Error(`${path}: "http" must be an object`);
117
118
  }
118
119
  for (const key of Object.keys(c.http ?? {})) {
119
- if (key !== "port") {
120
- throw new Error(`${path}: unknown key "http.${key}" (valid keys: port)`);
120
+ if (key !== "port" && key !== "host") {
121
+ throw new Error(`${path}: unknown key "http.${key}" (valid keys: port, host)`);
121
122
  }
122
123
  }
123
124
  if (c.http?.port !== undefined && (typeof c.http.port !== "number" || !isValidPort(c.http.port))) {
124
125
  throw new Error(`${path}: "http.port" must be an integer 0-65535`);
125
126
  }
127
+ // Validated as strictly as http.port: an unbindable string ("banana") must fail HERE, not surface
128
+ // later as a topology diagnostic about "the interface you bound".
129
+ if (c.http?.host !== undefined && (typeof c.http.host !== "string" || !isBindAddress(c.http.host))) {
130
+ throw new Error(`${path}: "http.host" must be an IP address or "localhost" (e.g. "127.0.0.1", "0.0.0.0")`);
131
+ }
126
132
  if (c.deploy !== undefined && (typeof c.deploy !== "object" || c.deploy === null)) {
127
133
  throw new Error(`${path}: "deploy" must be an object`);
128
134
  }
@@ -1,4 +1,4 @@
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";
@@ -6,19 +6,20 @@ import { type LoadedDefinition } from "./definition.ts";
6
6
  import { 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 {
@@ -76,8 +77,10 @@ export interface CreatePiAgentOptions {
76
77
  * or a factory re-evaluated per invoke. When {@link skills} are mounted their listing is appended.
77
78
  */
78
79
  instructions?: string | (() => string);
79
- /** `FastagentTool` = AgentTool plus the optional `deferred` marker (see {@link DefineToolOptions}). */
80
- tools?: FastagentTool[];
80
+ /** The tool set to mount. `FastagentTool` (AgentTool plus the optional `deferred` marker, see
81
+ * {@link DefineToolOptions}) widens into {@link MountedTool}, which additionally admits pi's default
82
+ * coding tools — they read the turn's ExecutionEnv as a fifth `execute` parameter. */
83
+ tools?: MountedTool[];
81
84
  skills?: Skill[];
82
85
  /**
83
86
  * Extra providers registered on top of the built-ins — your own gateway / self-hosted endpoint /
@@ -93,8 +96,10 @@ export interface CreatePiAgentOptions {
93
96
  authPath?: string;
94
97
  /** Session persistence. Defaults to in-memory; inject jsonlSessionStore for restart-surviving continuity. */
95
98
  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. */
99
+ /** Filesystem/process environment. Defaults to a local NodeExecutionEnv at `process.cwd()`, and its
100
+ * cwd is the agent's. The default coding tools (read/bash/edit/write) take it as the turn's tool
101
+ * context, so injecting a constrained one narrows where the agent reads, writes and shells. It does
102
+ * NOT constrain author-written `tools/`, which are code and can import anything. */
98
103
  env?: ExecutionEnv;
99
104
  /** Single-writer lease. Defaults to in-process fail-fast inProcessLease(). */
100
105
  lease?: Lease;
@@ -116,9 +121,9 @@ export interface CreatePiAgentFromDefinitionOptions {
116
121
  /** Override the engine base prompt (segment ①). Defaults to piBasePrompt({ tools, persona }) using the
117
122
  * live-read persona.md; pass base to fully opt out of persona.md. */
118
123
  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[];
124
+ /** Override tools. Defaults to {@link piDefaultTools} (lock down with a custom list). An authored
125
+ * `FastagentTool[]` (AgentTool plus the optional `deferred` marker) widens into {@link MountedTool}. */
126
+ tools?: MountedTool[];
122
127
  /**
123
128
  * The agent's working directory: where the default tools operate AND whose ancestors are walked for
124
129
  * ② project context (AGENTS.md). Defaults to `dir`. Set it to the enclosing repo so a coding agent
@@ -135,8 +140,11 @@ export interface CreatePiAgentFromDefinitionOptions {
135
140
  */
136
141
  authPath?: string;
137
142
  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. */
143
+ /** Filesystem/process environment; see {@link CreatePiAgentOptions.env}. At THIS rung it does more
144
+ * than root the default tools: persona.md and skills/ are read through it too. Two surfaces stay
145
+ * OUTSIDE it — ② project context (pi's loadProjectContextFiles uses node fs directly; see
146
+ * definition.ts) and author-written `tools/`, which are code and can import anything. Injecting an
147
+ * env narrows the blast radius rather than closing it. */
140
148
  env?: ExecutionEnv;
141
149
  lease?: Lease;
142
150
  /** Observation-plane tap; see {@link CreatePiAgentOptions.observer}. */
@@ -11,8 +11,9 @@
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";
@@ -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);
@@ -216,7 +239,7 @@ export async function createPiAgentFromDefinition(dir, options) {
216
239
  let reportedFindings = findingsSignature(definition);
217
240
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
218
241
  // it; a caller's own search_tools wins).
219
- const tools = withSearchTool(options.tools ?? piDefaultTools(env.cwd));
242
+ const tools = withSearchTool(options.tools ?? piDefaultTools());
220
243
  const agent = buildPiAgent({
221
244
  model: options.model,
222
245
  thinkingLevel: options.thinkingLevel,
@@ -7,9 +7,10 @@
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
12
  import type { PiSessionStore } from "./sessions.ts";
13
+ import { type MountedTool } from "./tool.ts";
13
14
  /**
14
15
  * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
15
16
  * tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
@@ -19,18 +20,30 @@ import type { PiSessionStore } from "./sessions.ts";
19
20
  * carry only what was actually discovered.
20
21
  */
21
22
  export declare const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
23
+ /** The session a factory-built harness is bound to — the seam the activation bridge (invoke.ts) uses
24
+ * to write {@link TOOL_ACTIVATION_ENTRY} deltas (pi's harness keeps its session private). Absent for
25
+ * a harness built outside {@link piHarnessFactory}: activation still works in-turn there, but is not
26
+ * recorded — the factory owns persistence. */
27
+ type AnyHarness = AgentHarness<any>;
22
28
  export type PiSession = Awaited<ReturnType<PiSessionStore["openOrCreate"]>>;
23
- export declare function harnessSession(harness: AgentHarness): PiSession | undefined;
29
+ export declare function harnessSession(harness: AnyHarness): PiSession | undefined;
24
30
  /**
25
31
  * pi's Model with the API-shape generic erased — fastagent only passes models through to the
26
32
  * harness, so the generic carries no information. One alias keeps the `any` auditable.
27
33
  */
28
34
  export type AnyModel = Model<any>;
29
35
  /** Builds a pi harness bound to the given session — called once per invoke. */
30
- export type PiHarnessFactory = (session: string) => AgentHarness | Promise<AgentHarness>;
36
+ /** The harness fastagent builds: context-typed on {@link ExecutionToolContext}, because that is what
37
+ * pi's env-backed default tools read (pi 0.83). Custom tools are context-FREE and stay assignable — a
38
+ * four-parameter `execute` satisfies the five-parameter one, so `defineTool` is untouched by this. */
39
+ type PiHarness = AgentHarness<ExecutionToolContext>;
40
+ export type PiHarnessFactory = (session: string) => PiHarness | Promise<PiHarness>;
31
41
  export interface PiHarnessFactoryOptions {
32
42
  /** Session persistence. Continuity = same backing store + same session id. */
33
43
  sessions: PiSessionStore;
44
+ /** Filesystem/process environment for the default coding tools. Handed to the harness as the TURN's
45
+ * tool context (pi 0.83), which is how read/bash/edit/write reach the machine at all — so this is the
46
+ * ONE seam a sandbox adapter implements, not a knob beside the tools that ignore it. */
34
47
  env: ExecutionEnv;
35
48
  /** Provider collection for all model requests; {@link model} must belong to it (same provider id). */
36
49
  models: Models;
@@ -38,7 +51,7 @@ export interface PiHarnessFactoryOptions {
38
51
  /** Reasoning effort for the model (pi's scale). Unset = fastagent's pinned default ("medium", pi
39
52
  * TUI parity — see {@link DEFAULT_THINKING_LEVEL}); unsupported levels are clamped by pi per model. */
40
53
  thinkingLevel?: ThinkingLevel;
41
- tools?: AgentTool[];
54
+ tools?: MountedTool[];
42
55
  /**
43
56
  * Final assembled prompt, or a SYNC factory re-evaluated per invoke (how L1 serves dynamic
44
57
  * `instructions` + the skills listing). Distinct from {@link live}, which is the directory rung's
@@ -109,6 +122,7 @@ export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], mo
109
122
  model: AnyModel;
110
123
  thinkingLevel: ThinkingLevel;
111
124
  };
112
- export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: AgentTool[], sessionId: string): string[] | undefined;
125
+ export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: MountedTool[], sessionId: string): string[] | undefined;
113
126
  /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
114
127
  export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
128
+ export {};
@@ -18,10 +18,6 @@ import { isDeferredTool } from "./tool.js";
18
18
  * carry only what was actually discovered.
19
19
  */
20
20
  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
21
  const harnessSessions = new WeakMap();
26
22
  export function harnessSession(harness) {
27
23
  return harnessSessions.get(harness);
@@ -187,7 +183,9 @@ export function piHarnessFactory(options) {
187
183
  // Session overrides (set_model / set_thinking) win over the assembly defaults — same entry walk.
188
184
  const overrides = resolveHarnessOverrides(entries, options.models, { model: options.model, thinkingLevel: options.thinkingLevel ?? DEFAULT_THINKING_LEVEL }, sessionId);
189
185
  const harness = new AgentHarness({
190
- env: options.env,
186
+ // Static, not a per-turn provider: the env is fixed for the agent's lifetime, and resolving a
187
+ // constant per turn would only add a promise to the turn's critical path.
188
+ toolContext: { env: options.env },
191
189
  session,
192
190
  models: options.models,
193
191
  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);
@@ -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;
@@ -8,7 +8,9 @@
8
8
  import { z } from "zod";
9
9
  import { log } from "../../log.js";
10
10
  import { defineTool, isDeferredTool, stripDeferredMarker } from "./tool.js";
11
- /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
11
+ /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own.
12
+ * Typed on the MOUNTED tool, not the authored one: it inspects names and deferral and never executes,
13
+ * so narrowing here would reject the very set it is handed (pi's defaults take the turn's context). */
12
14
  export function withSearchTool(tools) {
13
15
  if (!tools.some(isDeferredTool))
14
16
  return tools;
@@ -120,7 +120,7 @@ sessionManager) {
120
120
  const env = new NodeExecutionEnv({ cwd });
121
121
  const definition = await loadAgentDefinition(agentDir, { cwd, env });
122
122
  reportDefinitionWarnings(definition.collisions, definition.diagnostics);
123
- const defaultNames = piDefaultTools(cwd).map((t) => t.name);
123
+ const defaultNames = piDefaultTools().map((t) => t.name);
124
124
  const customTools = tools.filter((t) => !defaultNames.includes(t.name));
125
125
  // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
126
126
  // it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
@@ -141,7 +141,12 @@ sessionManager) {
141
141
  // session-lifecycle invariant as a normal out-of-turn call (fail visibly).
142
142
  if (!bound)
143
143
  throw new Error("tool executed before its session was built (lifecycle invariant broken)");
144
- return turnContext.run({ cwd, sessionManager: bound.sessionManager, tools: bound.activation }, () => t.execute(id, params, signal));
144
+ return turnContext.run({ cwd, sessionManager: bound.sessionManager, tools: bound.activation },
145
+ // pi's per-turn TOOL context (5th parameter) is read only by its default coding tools, and
146
+ // those are filtered out of `customTools` above; fastagent's own take theirs from
147
+ // `turnContext` (AsyncLocalStorage). So this env exists to satisfy the shape, and it is the
148
+ // chat cwd's — the same root a default tool would have got, had one reached here.
149
+ () => t.execute(id, params, signal, undefined, { env }));
145
150
  },
146
151
  }));
147
152
  // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
1
+ import type { AgentHarnessTool, ExecutionToolContext, AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { z } from "zod";
3
3
  import { type ModuleLoadFailure } from "../../loader.ts";
4
4
  import { type ReadonlySessionManager, type ToolActivation } from "./tool-context.ts";
@@ -34,6 +34,14 @@ export interface DefineToolOptions<I extends z.ZodType> {
34
34
  executionMode?: "sequential" | "parallel";
35
35
  execute: (input: z.infer<I>, ctx: ToolContext) => unknown | Promise<unknown>;
36
36
  }
37
+ /**
38
+ * A tool as MOUNTED: what the harness actually runs. Wider than the authored {@link AgentTool} on
39
+ * purpose — pi's default coding tools read the turn's tool context (its ExecutionEnv) as a fifth
40
+ * `execute` parameter, while fastagent's own tools take four and are assignable to it unchanged.
41
+ * Naming the wider type is what lets `defineTool` stay context-free for authors while both families
42
+ * live in one array; every helper that only inspects or reorders tools is typed on THIS.
43
+ */
44
+ export type MountedTool = AgentHarnessTool<ExecutionToolContext>;
37
45
  /** An AgentTool with fastagent's deferral marker — the type for raw tools handed to fastagent
38
46
  * (`config.tools`, L1/L2 `tools`): plain `AgentTool` has no `deferred`, so an object literal with the
39
47
  * marker would fail excess-property checking against upstream's type. `defineTool` produces it. */
@@ -42,10 +50,10 @@ export type FastagentTool = AgentTool & {
42
50
  };
43
51
  /** Read the {@link DefineToolOptions.deferred} marker off a mounted tool (extra property on the
44
52
  * AgentTool object — pi ignores it). */
45
- export declare function isDeferredTool(tool: AgentTool): boolean;
53
+ export declare function isDeferredTool(tool: MountedTool): boolean;
46
54
  /** The same tool without the deferred marker — for a loader that must stay active (a deferred loader
47
55
  * could never be activated and would strand every deferred tool). */
48
- export declare function stripDeferredMarker(tool: AgentTool): AgentTool;
56
+ export declare function stripDeferredMarker(tool: MountedTool): MountedTool;
49
57
  export declare function defineTool<I extends z.ZodType>(options: DefineToolOptions<I>): FastagentTool;
50
58
  /** A discarded same-name tool (within `tools/`, or against an existing tool). Surfaced, never silent. */
51
59
  export interface ToolCollision {
@@ -68,7 +76,7 @@ export declare function loadTools(dir: string): Promise<{
68
76
  * Merge resolved tools (pi defaults + `config.tools`) with discovered `tools/`, deduped by name.
69
77
  * Existing tools win; dropped discovered tools surface as collisions.
70
78
  */
71
- export declare function mergeDiscoveredTools(existing: AgentTool[], discovered: AgentTool[]): {
72
- tools: AgentTool[];
79
+ export declare function mergeDiscoveredTools(existing: MountedTool[], discovered: AgentTool[]): {
80
+ tools: MountedTool[];
73
81
  collisions: ToolCollision[];
74
82
  };
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
1
+ import { type MountedTool } from "./tool.ts";
2
2
  /**
3
3
  * Parse a delay to milliseconds: a number is SECONDS; a string MUST carry a unit — `"<n><s|m|h|d>"`
4
4
  * ("30m", "2h", "1d"). Undefined for anything else, INCLUDING a bare numeric string like "120": one
@@ -12,6 +12,6 @@ export declare function parseDelayMs(input: string | number): number | undefined
12
12
  * scheduler poller honors a wake-up) and only when the workspace hasn't defined its own `wake` (that
13
13
  * wins, like any tool collision). The single place the mount decision + collision rule run.
14
14
  */
15
- export declare function withWakeTool(tools: AgentTool[], stateRoot: string, enabled: boolean): AgentTool[];
15
+ export declare function withWakeTool(tools: MountedTool[], stateRoot: string, enabled: boolean): MountedTool[];
16
16
  /** Build the `wake` tool bound to `stateRoot` (where wake-ups persist). */
17
- export declare function makeWakeTool(stateRoot: string, now?: () => Date): AgentTool;
17
+ export declare function makeWakeTool(stateRoot: string, now?: () => Date): MountedTool;
@@ -48,9 +48,11 @@ export declare function router(routes: Routes): ChannelHandler;
48
48
  * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
49
49
  * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
50
50
  * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
51
+ * `host` is the bind address; unset means all interfaces (what containers need).
51
52
  */
52
53
  export declare function serveNode(handler: ChannelHandler, options: {
53
54
  port: number;
55
+ host?: string;
54
56
  }): {
55
57
  listening: Promise<number>;
56
58
  close: () => Promise<void>;
package/dist/host/node.js CHANGED
@@ -35,12 +35,13 @@ export function router(routes) {
35
35
  * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
36
36
  * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
37
37
  * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
38
+ * `host` is the bind address; unset means all interfaces (what containers need).
38
39
  */
39
40
  export function serveNode(handler, options) {
40
41
  const server = createServer(nodeListener(async (req) => handler(req)));
41
42
  const listening = new Promise((resolve, reject) => {
42
43
  server.once("error", reject); // a bind failure surfaces here, before "listening"
43
- server.listen(options.port, () => {
44
+ server.listen({ port: options.port, host: options.host }, () => {
44
45
  server.off("error", reject);
45
46
  resolve(server.address().port);
46
47
  });