Skip to content

feat(config): make coding tools configurable - #331

Open
Sukitly wants to merge 2 commits into
mainfrom
feature/configurable-coding-tools
Open

feat(config): make coding tools configurable#331
Sukitly wants to merge 2 commits into
mainfrom
feature/configurable-coding-tools

Conversation

@Sukitly

@Sukitly Sukitly commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add codingTools to select the built-in read, bash, edit, and write tools for directory agents
  • support all-on (true/unset), all-off (false/[]), and least-privilege subsets such as codingTools: ["read"]
  • apply one resolved tool posture across dev, start, invoke, chat, tool, and info
  • preserve authored tools plus the independent search_tools and wake mounting policies
  • report model-visible skills that lack the built-in local-file reader
  • prevent Telegram, Slack, and Feishu from handing the model unreadable local attachment paths when read is absent
  • expose the effective coding-tool names in startup and info reports, and document the capability dependencies

This lets public-facing agents retain file-backed skills and non-image attachments with read while 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

codingTools accepts 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 require read, while they do not require bash, edit, or write.

Validation

  • npm run lint
  • npm run typecheck
  • npm test (104 files, 1344 tests)
  • Added or updated the smallest relevant tests

Checklist

  • Public-facing text is in English
  • Errors fail visibly; no silent fallbacks or swallowed exceptions
  • No secrets, local paths, or machine-specific state were committed
  • No process-only notes were added (*_PLAN.md, HANDOFF.md, session notes, etc.)
  • Docs were updated when behavior or public APIs changed

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.
@Sukitly
Sukitly requested a review from kid7st as a code owner August 12, 2026 08:25
@Sukitly Sukitly changed the title feat(config): make coding tools optional feat(config): make coding tools configurable Aug 12, 2026

@kid7st kid7st left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" } : {}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/engines/pi/create.ts
// 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] : []);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/engines/pi/create.ts
// (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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/engines/pi/report.ts
*/
export function reportFindingsIfChanged(dir: string, def: Findings): void {
const sig = findingsSignature(def);
export function reportFindingsIfChanged(dir: string, def: Findings, assembly: readonly AssemblyFinding[] = []): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/host/node.ts
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cli/commands/info.ts
sessionsDir,
authPath,
diagnostics: definition.diagnostics,
diagnostics: [...definition.diagnostics, ...assemblyFindings],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants