Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tools>` list and the tool rules, and only they run; a call to any other tool gets `Unknown tool: <name>`. The bash file-ops rule and the skills hint name only declared tools, and `<skills>` 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.

Expand Down Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion packages/harness/src/plugin/builtin/prompt/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}),
Expand Down
9 changes: 6 additions & 3 deletions packages/harness/src/plugin/builtin/prompt/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/harness/src/plugin/tool/registry.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand Down Expand Up @@ -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))
Expand Down
12 changes: 7 additions & 5 deletions packages/harness/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AnyToolDef>;
readonly names: ReadonlyArray<string>;
/** 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;
}

Expand All @@ -68,11 +68,13 @@ export const make = (tools: ReadonlyArray<RegisteredTool | ToolRegistration>): 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,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"read hidden": "<skills>\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<available_skills>\n <skill>\n <name>break-ui</name>\n <description>Stress-test a piece of UI with worst-case data.</description>\n <location><root>/repo/.agents/skills/break-ui/SKILL.md</location>\n </skill>\n <skill>\n <name>daytona</name>\n <description>Creates and manages isolated cloud sandboxes on the Daytona platform.</description>\n <location><root>/repo/.agents/skills/daytona/SKILL.md</location>\n </skill>\n</available_skills>\n</skills>",
"read deferred, bash codemode": null,
"read model": "<skills>\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<available_skills>\n <skill>\n <name>break-ui</name>\n <description>Stress-test a piece of UI with worst-case data.</description>\n <location><root>/repo/.agents/skills/break-ui/SKILL.md</location>\n </skill>\n <skill>\n <name>daytona</name>\n <description>Creates and manages isolated cloud sandboxes on the Daytona platform.</description>\n <location><root>/repo/.agents/skills/daytona/SKILL.md</location>\n </skill>\n</available_skills>\n</skills>"
}
46 changes: 46 additions & 0 deletions packages/harness/test/__artifacts__/tools.exposure.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
60 changes: 59 additions & 1 deletion packages/harness/test/plugin.skill.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -305,6 +306,63 @@ describe("codework.prompt.skill", () => {
expect(withNoReader).toBeUndefined();
}));

it("names only a reader declared to the model, and omits <skills> 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<Record<string, Tool.ToolExposure>>) =>
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<Record<string, Tool.ToolExposure>>) =>
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");
Expand Down
80 changes: 80 additions & 0 deletions packages/harness/test/runner.loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</${name}>`).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",
),
);
}),
);
});
15 changes: 15 additions & 0 deletions packages/plugin/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]`.
Expand All @@ -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<string>;
/** 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.
Expand Down Expand Up @@ -116,6 +130,7 @@ interface DefineInput<
readonly label?: string;
readonly promptSnippet?: string;
readonly promptGuidelines?: ReadonlyArray<string>;
readonly exposure?: ToolExposure;
readonly prepareArguments?: (args: unknown) => unknown;
readonly parameters: Params;
readonly constrainedSampling?: Message.Tool["constrainedSampling"];
Expand Down
1 change: 1 addition & 0 deletions sites/www/content/docs/plugins/authoring.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading