diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3b2fe9..f607e811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This file is the canonical source for unreleased changes and published release n #### Added - Prompts queued while the agent works can be read and changed before it takes them in. `Control.pending` lists them, steers and follow-ups, in the order they were sent. `Control.cancel` withdraws one, recorded as a `session.prompt.cancelled` event; it returns `false` when the prompt was already taken in, and a cancelled message id may be sent again. `Control.update` moves one to the other lane or rewrites its text, keeping its place, recorded as a `session.prompt.updated` event; moving it to the steer lane also wakes the session, so a prompt left queued by a stop runs now. +- Tools declare an `exposure`. Only `"direct"` and `"model"` tools reach the request, the `` list and the tool rules, and only they run; a call to any other tool gets `Unknown tool: `. The bash file-ops rule and the skills hint name only declared tools, and `` is left out when neither `read` nor `bash` is declared. A plugin registering an unknown exposure fails in its `setup`. - `read` tells a model without image input that the image it read is left out of the request: `[Current model does not support images. The image will be omitted from this request.]` is added to its note. - A run cut off by a crash or a shutdown resumes when the server starts again (`Control.resumeUnfinished`). A crash counts as a restart and a shutdown does not; a committed turn starts the count over. After 5 restarts (`Control.RESTART_LIMIT`) the session is parked instead of resumed: `Session.Info.parked` is `true`, and its next prompt runs it again. A resumed run answers the reply it was cut off in before a queued follow-up goes in; steers join it at once. A run the user stopped is not resumed, and what was queued behind it stays queued. @@ -92,6 +93,7 @@ This file is the canonical source for unreleased changes and published release n #### Added - `ToolCallContext.modelInput`: the input modalities of the model the tool call answers, such as `["text"]` or `["text", "image"]`. It is optional, and tool hooks receive it too. +- `exposure` on tool definitions: `"direct"` (default), `"model"`, `"codemode"`, `"deferred"` or `"hidden"`, as the `ToolExposure` schema. `declared(def)` is true for `"direct"` and `"model"`, the tools declared to the model. ## [@codeworksh/cli@0.4.0] - 2026-10-20 diff --git a/packages/harness/src/plugin/builtin/prompt/default.ts b/packages/harness/src/plugin/builtin/prompt/default.ts index 4f072555..a3952439 100644 --- a/packages/harness/src/plugin/builtin/prompt/default.ts +++ b/packages/harness/src/plugin/builtin/prompt/default.ts @@ -2,6 +2,7 @@ import * as Section from "@codeworksh/plugin/plugin/section"; import { Effect } from "effect"; +import { declared } from "../../../tool/tool.ts"; import type { PromptResolver, SharedPluginContext } from "../../context.ts"; import { define } from "../../plugin.ts"; @@ -36,7 +37,12 @@ export const defaultPromptPlugin = define({ const append = (yield* slot(ctx, ctx.config.promptSystemAppend))?.trim(); const prompt = ctx.plugin.prompt; prompt.foundation.set(custom ?? foundation); - const names = new Set(ctx.plugin.tools.list().map((tool) => tool.name)); + const names = new Set( + ctx.plugin.tools + .list() + .filter(declared) + .map((tool) => tool.name), + ); for (const rule of [...toolRules(names), ...standingRules]) prompt.sections.append(Section.Rules, rule); if (append !== undefined && append.length > 0) prompt.sections.append(Section.Addendum, append); }), diff --git a/packages/harness/src/plugin/builtin/prompt/skill.ts b/packages/harness/src/plugin/builtin/prompt/skill.ts index d37e94f2..8d681b77 100644 --- a/packages/harness/src/plugin/builtin/prompt/skill.ts +++ b/packages/harness/src/plugin/builtin/prompt/skill.ts @@ -12,6 +12,7 @@ import { Global } from "../../../global.ts"; import { SandboxFileSystem } from "../../../sandbox/fs/filesystem.ts"; import { SandboxFs } from "../../../sandbox/fs/util.ts"; import { SandboxIO } from "../../../sandbox/io.ts"; +import { declared } from "../../../tool/tool.ts"; import { posix } from "../../../util/posix.ts"; import type { SharedPluginContext } from "../../context.ts"; import { define } from "../../plugin.ts"; @@ -382,11 +383,13 @@ export const skillPlugin = define({ kind: "prompt", setup: Effect.fn("SkillPlugin.setup")(function* (ctx) { const tools = ctx.plugin.tools; - const visible = (name: string) => { + const callable = (name: string) => { const tool = tools.get(name); - return tool !== undefined && tool.promptSnippet !== undefined && tool.promptSnippet.length > 0; + return tool !== undefined && declared(tool) ? tool : undefined; }; - const fileReadTool = readers.find(visible) ?? (readers.some((name) => tools.has(name)) ? "indirect" : undefined); + const visible = (name: string) => (callable(name)?.promptSnippet ?? "").length > 0; + const fileReadTool = + readers.find(visible) ?? (readers.some((name) => callable(name) !== undefined) ? "indirect" : undefined); if (fileReadTool === undefined) return; const fs = yield* SandboxIO.FileSystem; diff --git a/packages/harness/src/plugin/tool/registry.ts b/packages/harness/src/plugin/tool/registry.ts index 195ba2a3..b7ab780f 100644 --- a/packages/harness/src/plugin/tool/registry.ts +++ b/packages/harness/src/plugin/tool/registry.ts @@ -1,5 +1,5 @@ import { Predicate, Schema } from "effect"; -import type { RegisteredTool } from "../../tool/tool.ts"; +import { type RegisteredTool, ToolExposure } from "../../tool/tool.ts"; import type { ToolAddOptions, ToolDefPatch, ToolRegistration, ToolRegistry } from "./schema.ts"; const definition = (tool: RegisteredTool): RegisteredTool => ({ @@ -29,6 +29,8 @@ const assertTool = (tool: RegisteredTool) => { if (!Schema.isSchema(def.success)) throw new Error(`Tool ${name}: success must be an Effect Schema`); if (def.failure !== undefined && !Schema.isSchema(def.failure)) throw new Error(`Tool ${name}: failure must be an Effect Schema`); + if (def.exposure !== undefined && !Schema.is(ToolExposure)(def.exposure)) + throw new Error(`Tool ${name}: exposure must be one of ${ToolExposure.literals.join(", ")}`); if (def.encodeContent !== undefined && !Predicate.isFunction(def.encodeContent)) throw new Error(`Tool ${name}: encodeContent must be a function`); if (def.encodeFailureContent !== undefined && !Predicate.isFunction(def.encodeFailureContent)) diff --git a/packages/harness/src/tool/registry.ts b/packages/harness/src/tool/registry.ts index 2dc1b5a4..3e785910 100644 --- a/packages/harness/src/tool/registry.ts +++ b/packages/harness/src/tool/registry.ts @@ -2,7 +2,7 @@ import type { ToolRegistration } from "../plugin/tool/schema.ts"; import type { Message } from "@codeworksh/aikit"; import { Context, Layer } from "effect"; import * as Executor from "./executor.ts"; -import type { AnyToolDef, RegisteredTool } from "./tool.ts"; +import { type AnyToolDef, declared, type RegisteredTool } from "./tool.ts"; /** * `ToolRegistry` — the loop-facing entry point to the tool catalog @@ -37,12 +37,12 @@ export interface Resolved { /** The catalog: an immutable set of registered tools + a resolver. A plain list of tools. */ export interface Registry { - /** Metadata listing (name / description / …). */ + /** Metadata listing (name / description / …) of every registered tool, whatever its exposure. */ readonly defs: ReadonlyArray; readonly names: ReadonlyArray; /** Metadata lookup only — never exposes the executable impl. */ readonly getDef: (name: string) => AnyToolDef | undefined; - /** Resolve a frozen snapshot of the effective tool set. */ + /** Resolve a frozen snapshot of the tools declared to the model. */ readonly resolve: () => Resolved; } @@ -68,11 +68,13 @@ export const make = (tools: ReadonlyArray): R const defs = Object.freeze(effective.map((entry) => entry.tool.definition)); const names = Object.freeze(effective.map((entry) => entry.tool.definition.name)); + // Only declared tools run: a call to any other gets the executor's `Unknown tool` result. + const declaredSet = effective.filter((entry) => declared(entry.tool.definition)); // The effective set is unique, so the executor's own duplicate guard never trips. - const executor = Executor.make(effective); + const executor = Executor.make(declaredSet); const resolved: Resolved = Object.freeze({ - defs, + defs: Object.freeze(declaredSet.map((entry) => entry.tool.definition)), wire: Object.freeze([...executor.wire]), handle: executor.handle, }); diff --git a/packages/harness/test/__artifacts__/prompt.skill.exposure.json b/packages/harness/test/__artifacts__/prompt.skill.exposure.json new file mode 100644 index 00000000..b1e567fa --- /dev/null +++ b/packages/harness/test/__artifacts__/prompt.skill.exposure.json @@ -0,0 +1,5 @@ +{ + "read hidden": "\nThe following skills provide specialized instructions for specific tasks.\nUse bash to load a skill's file when the task matches its description.\nWhen a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\n\n\n \n break-ui\n Stress-test a piece of UI with worst-case data.\n /repo/.agents/skills/break-ui/SKILL.md\n \n \n daytona\n Creates and manages isolated cloud sandboxes on the Daytona platform.\n /repo/.agents/skills/daytona/SKILL.md\n \n\n", + "read deferred, bash codemode": null, + "read model": "\nThe following skills provide specialized instructions for specific tasks.\nUse the read tool to load a skill's file when the task matches its description.\nWhen a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\n\n\n \n break-ui\n Stress-test a piece of UI with worst-case data.\n /repo/.agents/skills/break-ui/SKILL.md\n \n \n daytona\n Creates and manages isolated cloud sandboxes on the Daytona platform.\n /repo/.agents/skills/daytona/SKILL.md\n \n\n" +} diff --git a/packages/harness/test/__artifacts__/tools.exposure.json b/packages/harness/test/__artifacts__/tools.exposure.json new file mode 100644 index 00000000..8b39530a --- /dev/null +++ b/packages/harness/test/__artifacts__/tools.exposure.json @@ -0,0 +1,46 @@ +{ + "requestTools": [ + [ + "read", + "write", + "edit", + "x_direct", + "x_model" + ], + [ + "read", + "write", + "edit", + "x_direct", + "x_model" + ] + ], + "tools": "- read: Read file contents\n- write: Create or overwrite files\n- edit: Make precise file edits with exact text replacement, including multiple disjoint edits in one call\n- x_direct: x_direct snippet\n- x_model: x_model snippet", + "rules": "- Use read to examine files instead of cat or sed.\n- Use write only for new files or complete rewrites.\n- Use edit for precise changes (edits[].oldText must match exactly)\n- When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls\n- Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.\n- Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.\n- x_direct guideline\n- x_model guideline\n- Be concise in your responses\n- Show file paths clearly when working with files", + "deferredCall": { + "type": "toolCall", + "callID": "call_deferred", + "name": "x_deferred", + "arguments": { + "value": "hello" + }, + "status": "error", + "time": { + "start": 1, + "end": 1 + }, + "result": { + "content": [ + { + "type": "text", + "text": "Unknown tool: x_deferred" + } + ], + "details": { + "error": "unknown_tool", + "name": "x_deferred" + }, + "isError": true + } + } +} diff --git a/packages/harness/test/plugin.skill.test.ts b/packages/harness/test/plugin.skill.test.ts index 84f5e7df..e8b756d4 100644 --- a/packages/harness/test/plugin.skill.test.ts +++ b/packages/harness/test/plugin.skill.test.ts @@ -1,5 +1,5 @@ import "./utils/env.ts"; -import { Effect, Logger } from "effect"; +import { Effect, Logger, Schema } from "effect"; import { execFile } from "node:child_process"; import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -17,6 +17,7 @@ import { ProjectSchema } from "../src/project/schema.ts"; import { SandboxFileSystem } from "../src/sandbox/fs/filesystem.ts"; import { SandboxInstance } from "../src/sandbox/instance.ts"; import { AbsolutePath } from "../src/schema.ts"; +import * as Tool from "../src/tool/tool.ts"; import { SpaceSchema } from "../src/space/schema.ts"; import { Space } from "../src/space/space.ts"; import { immediateOpen, toolTurn } from "./fixtures/llm.ts"; @@ -305,6 +306,63 @@ describe("codework.prompt.skill", () => { expect(withNoReader).toBeUndefined(); })); + it("names only a reader declared to the model, and omits when every reader is undeclared", () => + withSettings(async ({ root }) => { + const dir = join(root, "repo"); + await write( + join(dir, ".agents", "skills", "daytona", "SKILL.md"), + "---\nname: daytona\ndescription: Creates and manages isolated cloud sandboxes on the Daytona platform.\n---\n", + ); + await write( + join(dir, ".agents", "skills", "break-ui", "SKILL.md"), + "---\nname: break-ui\ndescription: Stress-test a piece of UI with worst-case data.\n---\n", + ); + // Re-registering a built-in reader under the same name overrides its exposure. + const expose = (exposures: Readonly>) => + define({ + id: "acme.tool.expose", + kind: "tool", + setup: (ctx) => { + for (const [name, exposure] of Object.entries(exposures)) { + ctx.plugin.tools.add( + Tool.register( + Tool.make({ + name, + description: `${name} tool`, + promptSnippet: `${name} snippet`, + exposure, + parameters: Schema.Struct({}), + success: Schema.String, + handler: () => Effect.succeed(""), + }), + ), + ); + } + }, + }); + const run = async (exposures: Readonly>) => + skillsSection( + await prompt({ + root, + directory: dir, + hostCwd: root, + plugins: [readPlugin, bashPlugin, expose(exposures), defaultPromptPlugin, skillPlugin], + }), + ) ?? null; + + const cases = { + "read hidden": await run({ read: "hidden" }), + "read deferred, bash codemode": await run({ read: "deferred", bash: "codemode" }), + "read model": await run({ read: "model" }), + }; + expect(cases["read hidden"]).toContain("Use bash to load a skill's file"); + expect(cases["read deferred, bash codemode"]).toBeNull(); + expect(cases["read model"]).toContain("Use the read tool to load a skill's file"); + await expect(`${JSON.stringify(cases, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/prompt.skill.exposure.json", + ); + })); + it("lazily loads full SKILL.md instructions only when the model reads the advertised location", () => withSettings(async ({ root }) => { const dir = join(root, "repo"); diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index 02dd3145..3b867695 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -1755,3 +1755,83 @@ describe("runner loop — lifecycle hooks", () => { ); }); }); + +describe("runner loop — tool exposure", () => { + const tool = (name: string, exposure?: Tool.ToolExposure) => + Tool.register( + Tool.make({ + name, + description: `${name} tool`, + promptSnippet: `${name} snippet`, + promptGuidelines: [`${name} guideline`], + ...(exposure === undefined ? {} : { exposure }), + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + encodeContent: (value: string) => [{ type: "text", text: value }], + handler: ({ value }) => Effect.succeed(value), + }), + ); + const contexts: Message.Context[] = []; + const next = toolTurn({ + type: "toolCall", + callID: "call_deferred", + name: "x_deferred", + arguments: { value: "hello" }, + status: "pending", + time: { start: 1, end: 1 }, + }); + const { effect: it } = testEffect( + runtime({ + open: (input, signal) => { + contexts.push(input.context); + return next(input, signal); + }, + plugins: [ + ...plugins, + { + id: "test.tool.exposure", + kind: "tool", + setup: (ctx) => { + ctx.plugin.tools.add(tool("x_direct")); + ctx.plugin.tools.add(tool("x_model", "model")); + ctx.plugin.tools.add(tool("x_codemode", "codemode")); + ctx.plugin.tools.add(tool("x_deferred", "deferred")); + ctx.plugin.tools.add(tool("x_hidden", "hidden")); + // Overrides the built-in, so the bash file-ops rule must go with it. + ctx.plugin.tools.add(tool("bash", "hidden")); + }, + }, + ], + }), + ); + + it( + "declares only direct and model tools, in the request and the prompt, and refuses the rest", + Effect.gen(function* () { + const execution = yield* RunnerExecution.Service; + const sessionId = yield* seedSession(); + yield* admit({ id: "msg_exposure", sessionId, delivery: "steer" }); + + yield* execution.resume(sessionId); + + expect(contexts).toHaveLength(2); + const section = (name: string) => + new RegExp(`<${name}>\\n([\\s\\S]*?)\\n`).exec(contexts[0]!.systemPrompt ?? "")?.[1]; + const reply = contexts[1]!.messages[1]!; + const call = reply.role === "assistant" ? reply.parts.find((part) => part.type === "toolCall") : undefined; + const result = { + requestTools: contexts.map((context) => (context.tools ?? []).map((tool) => tool.name)), + tools: section("tools"), + rules: section("rules"), + deferredCall: call, + }; + expect(result.requestTools[0]).toContain("x_model"); + expect(result.requestTools[0]).not.toContain("bash"); + yield* Effect.promise(() => + expect(`${JSON.stringify(result, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/tools.exposure.json", + ), + ); + }), + ); +}); diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 6a578da4..6c93f2f1 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -33,6 +33,18 @@ export const ToolCallContext = Schema.Struct({ }); export type ToolCallContext = typeof ToolCallContext.Type; +/** + * Where a tool is reachable. `direct` and `model` tools are declared to the model each turn; + * `model` ones are never callable from other tools. `codemode` and `deferred` tools stay off the + * wire until loaded, and `hidden` ones never reach the model. + */ +export const ToolExposure = Schema.Literals(["direct", "model", "codemode", "deferred", "hidden"]); +export type ToolExposure = typeof ToolExposure.Type; + +/** Whether a tool is declared to the model every turn. Omitted exposure means `direct`. */ +export const declared = (def: { readonly exposure?: ToolExposure }): boolean => + def.exposure === undefined || def.exposure === "direct" || def.exposure === "model"; + /** * A pure tool definition. Parametrised over the *schema* instances so the * decoded parameter/success/failure types can be recovered via `["Type"]`. @@ -54,6 +66,8 @@ export interface ToolDef< readonly promptSnippet?: string; /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */ readonly promptGuidelines?: ReadonlyArray; + /** Where the tool is reachable. Omit → `direct`. */ + readonly exposure?: ToolExposure; /** * Repair argument shapes models commonly send before they are validated against * `parameters`. Return a copy; a throw makes the arguments invalid. @@ -116,6 +130,7 @@ interface DefineInput< readonly label?: string; readonly promptSnippet?: string; readonly promptGuidelines?: ReadonlyArray; + readonly exposure?: ToolExposure; readonly prepareArguments?: (args: unknown) => unknown; readonly parameters: Params; readonly constrainedSampling?: Message.Tool["constrainedSampling"]; diff --git a/sites/www/content/docs/plugins/authoring.mdx b/sites/www/content/docs/plugins/authoring.mdx index 10348a3c..d91e626c 100644 --- a/sites/www/content/docs/plugins/authoring.mdx +++ b/sites/www/content/docs/plugins/authoring.mdx @@ -140,6 +140,7 @@ A tool is a definition plus a handler. `Tool.make` builds both at once: | `encodeFailureContent` | Optional. Renders a failure for the model. | | `promptSnippet` | Optional. One line for the system prompt's tool list. | | `promptGuidelines` | Optional. Bullets added to the prompt's guidelines while the tool is active. | +| `exposure` | Optional. Only `"direct"` (default) and `"model"` tools reach the model. | | `label` | Optional. A short display name for UIs. | Register it with `Tool.register(tool)` when the handler needs nothing but its arguments.