feat(config): make coding tools configurable - #331
Conversation
Allow directory agents to disable read, bash, edit, and write while retaining authored and conditional built-in tools. Keep the default enabled for backwards-compatible authoring and serving fidelity.
Allow selecting individual coding tools so agents can retain read without shell or mutation access. Surface skill-reader mismatches, keep channel attachments honest, centralize the resolved tool posture, and cover empty tool surfaces.
kid7st
left a comment
There was a problem hiding this comment.
Thesis: this PR introduces codingTools — a config-level selection of pi's four machine-reaching built-ins — and propagates one resolved fact (codingToolNames, and its canReadLocalFiles projection) through prompt identity, skill findings, report lines and every chat channel's attachment path.
Findings that do not anchor to a diff line:
docs/cli.md was not updated for the diagnostics change. info --json now emits AssemblyFinding inside diagnostics, but the doc still describes that field as "skills and diagnostics". See the inline comment on src/cli/commands/info.ts.
The three new channel tests do not cover the default error surface. test/telegram.test.ts, test/slack.test.ts and test/feishu.test.ts each mount with a custom onError that leaks failed.details, so they assert on wording no default deployment ever shows. With the shipped default the user sees "⚠️ Sorry, something went wrong…" and the operator sees nothing. See the inline comment on src/channels/invoke-turn-kit.ts.
canReadLocalFiles is derived ad hoc at six call sites. codingToolNames.includes("read") is recomputed in dev.ts, start.ts (×3), info.ts, open.ts, session-builder.ts and create.ts. The concept exists as a named field on ChannelContext but not on AgentAssembly, which is where it is produced — put it there once and let consumers read it.
| } | ||
|
|
||
| // Primary first and fail-fast: these are resources the current user explicitly pointed at. | ||
| if (!canReadLocalFiles && files.length > 0) throw new LocalFileAccessUnavailable(); |
There was a problem hiding this comment.
The referent's files are treated as user-attached primaries, so a thread reply hard-fails.
This function's own documented policy is explicit: a replied-to message is CONTEXT, "losing it must not cost the user their answer — every first message of a thread carries one, so a hard failure here would turn an ordinary platform edge into a lost turn." This guard runs after referent resolution has pushed parsed.fileRefs into files, so an agent without read loses the entire turn whenever the quoted message happens to be a file — and the message tells the user to "send the content as text" for a file they never sent. The new test rejects a primary non-image referent without a reader… encodes exactly that inverted semantics.
Guard attachments.primary.files only; referent files should degrade like every other unreadable referent (skip + note in the referent marker / unreadableAttachmentsNote).
| } | ||
|
|
||
| /** A primary local-file attachment cannot be represented faithfully for an agent without a reader. */ | ||
| export class LocalFileAccessUnavailable extends Error { |
There was a problem hiding this comment.
The actionable message is written for an operator and delivered to nobody.
With the default onError, retryable: false renders "⚠️ Sorry, something went wrong. Try rephrasing, or check I have access to what you need." — "mount the read coding tool" never reaches the end user. Nothing calls log.warn either, so the operator never sees it. A misconfigured agent silently answers "something went wrong" to every attachment. The three new tests all pass a custom onError that leaks details, so none covers the shipped path.
Log the condition operator-side at the throw site, and decide the user-facing wording deliberately: this is a configuration limitation, not an unknown failure, and defaultErrorMessage has no branch for it.
| type: "failed", | ||
| details: unavailable ? e.message : `could not load attachment: ${String(e)}`, | ||
| retryable: !unavailable, | ||
| ...(unavailable ? { code: "attachment_unsupported" } : {}), |
There was a problem hiding this comment.
"attachment_unsupported" is an undeclared code literal, copied three times (here, slack/invoke-turn.ts:126, feishu/invoke-turn.ts:239).
agent.ts states the convention and its reason: SESSION_BUSY_CODE / ABORTED_CODE exist as exported constants precisely so "a consumer that must branch on it should not string-match." This code has no constant, no doc, and no consumer — the channel emits it and the same channel consumes the event.
Either export it from agent.ts alongside the others with the consumer named, or drop it and let retryable: false be the whole signal.
| if (!canReadLocalFiles && (primary.fileIds?.length ?? 0) > 0) throw new LocalFileAccessUnavailable(); | ||
| const images = await resolveImages(api, botToken, primary.imageFileIds); | ||
| const files = await resolveFiles(api, botToken, primary.fileIds, chatId, filesDir); | ||
| const files = canReadLocalFiles ? await resolveFiles(api, botToken, primary.fileIds, chatId, filesDir) : undefined; |
There was a problem hiding this comment.
Dead ternary: line 66 already threw when primary.fileIds is non-empty, so the : undefined branch is unreachable, and resolveFiles over an empty list returns nothing anyway. Two readings of one rule two lines apart — drop the ternary.
| // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied | ||
| // it; a caller's own search_tools wins). | ||
| const tools = withSearchTool(options.tools ?? piDefaultTools()); | ||
| const codingToolNames = options.codingToolNames ?? (options.tools === undefined ? [...CODING_TOOL_NAMES] : []); |
There was a problem hiding this comment.
codingToolNames is inferred from whether options.tools was passed, not from what is mounted.
options.codingToolNames ?? (options.tools === undefined ? [...CODING_TOOL_NAMES] : []) records an L1/L2 caller doing createPiAgentFromDefinition(dir, { tools: [...piDefaultTools(), myTool] }) as having zero coding tools: it gets the degraded "AI assistant" identity while mounting all four, plus a false skills_require_file_reader warning naming every skill.
Derive from the mounted set (CODING_TOOL_NAMES.filter(n => tools.some(t => t.name === n))) and keep options.codingToolNames only as the directory-opener's override.
| // (core.md §11), keeping the tools list + guidelines below. | ||
| // (core.md §11), keeping the tools list + guidelines below. Preserve pi's coding identity only for | ||
| // the full coding surface; a partial/empty surface must not claim machine capabilities it lacks. | ||
| const inferredCodingNames = CODING_TOOL_NAMES.filter((name) => mounted.some((tool) => tool.name === name)); |
There was a problem hiding this comment.
inferredCodingNames is dead in every production path: both callers (live() below and session-builder.ts) always pass an explicit codingToolNames, so this branch only runs in test/definition.test.ts. It answers the same question as the assembly-site inference and answers it correctly — fixing that call site by inferring from the mounted tools makes this the single implementation instead of an unused sibling.
| */ | ||
| export function reportFindingsIfChanged(dir: string, def: Findings): void { | ||
| const sig = findingsSignature(def); | ||
| export function reportFindingsIfChanged(dir: string, def: Findings, assembly: readonly AssemblyFinding[] = []): void { |
There was a problem hiding this comment.
assembly defaults to [] in a memoized single-door reporter. This module's doc rules out a "record without printing" variant because "a memo entry that trusts some other caller to have printed would silently swallow findings" — an omitted third argument is that variant: it writes a signature without the assembly findings, so a later caller that does pass them re-warns, and an earlier one suppresses them. Every production caller already passes it; make the parameter required.
| stateRoot: string; | ||
| /** Whether the assembled agent can read absolute local attachment paths. Undefined preserves the | ||
| * legacy embedder posture (assume available); standard directory serving always supplies a fact. */ | ||
| canReadLocalFiles?: boolean; |
There was a problem hiding this comment.
canReadLocalFiles?: boolean is a tri-state whose unsafe value is the default: undefined means "assume it can read", i.e. hand the model dead paths — the exact failure this PR set out to remove. The three transports then each re-derive the rule as x !== false independently.
Every in-repo producer supplies the fact; the optionality exists only for external embedders. Make it required on ChannelContext (this surface is pre-1.0), or default it once at the loadChannels seam so no transport re-reads the tri-state.
| sessionsDir, | ||
| authPath, | ||
| diagnostics: definition.diagnostics, | ||
| diagnostics: [...definition.diagnostics, ...assemblyFindings], |
There was a problem hiding this comment.
diagnostics now mixes two concepts. It was SkillDiagnostic[] (per-skill parse problems, path = the SKILL.md); it now also carries AssemblyFinding, whose path is the definition dir and whose code is outside the skill-diagnostic vocabulary. A machine consumer of info --json branching on code or resolving path gets a second entity in one field.
Emit it as its own key (assemblyFindings) and update docs/cli.md, which still describes this as "skills and diagnostics".
| You are this workspace's agent. This file is your identity — it overrides the engine's default identity line, and it is re-read every turn along with the rest of your definition (`skills/` — capabilities you load when a task calls for them; `tools/` — code tools your author added, in the same directory as this file). An edit to any of them takes effect on your next message, no restart. | ||
|
|
||
| Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on, and where your `read` / `write` / `edit` / `bash` tools operate. It may be this same directory, or the one containing it; `fastagent info` prints both. If the workspace has an `AGENTS.md`, it is project context — read it to learn the project's conventions. | ||
| Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on. It may be this same directory, or the one containing it; `fastagent info` prints both. Use only the tools actually listed in your system prompt; `codingTools` may narrow or remove `read` / `write` / `edit` / `bash`. If the workspace has an `AGENTS.md`, it is project context — follow it without assuming a file tool is available. |
There was a problem hiding this comment.
The default scaffold now hedges capabilities the default scaffold has. init produces an agent with all four coding tools, yet the persona it writes says "codingTools may narrow or remove read/write/edit/bash", "If read is mounted, read skills/writing-great-skills/SKILL.md" (line 9) and "edit this file when an editing tool is mounted" (line 10) — conditional prose about a config the file cannot see, paid for by every default author, and it drops the concrete statement of where the workspace tools operate.
The system prompt already lists the mounted tools. Keep the definite text and leave narrowing authors to adjust the persona they own.
Summary
codingToolsto select the built-inread,bash,edit, andwritetools for directory agentstrue/unset), all-off (false/[]), and least-privilege subsets such ascodingTools: ["read"]dev,start,invoke,chat,tool, andinfosearch_toolsandwakemounting policiesreadis absentThis lets public-facing agents retain file-backed skills and non-image attachments with
readwhile removing shell and mutation capabilities. Agents that need neither can disable the coding surface entirely without leaving misleading prompt claims or dead attachment paths.Design decision
codingToolsaccepts both a boolean and a tool-name array. The boolean is the convenient all-or-none posture; the array is the minimum-privilege posture. This distinction is necessary because skills and downloaded non-image channel attachments are file-backed and requireread, while they do not requirebash,edit, orwrite.Validation
npm run lintnpm run typechecknpm test(104 files, 1344 tests)Checklist
*_PLAN.md,HANDOFF.md, session notes, etc.)