feat(core,web,cli): add file tools and per-tool call descriptions (#62)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-26 21:37:51 +08:00
committed by GitHub
parent 1e23452f48
commit abd5b13d52
43 changed files with 3449 additions and 181 deletions
+11
View File
@@ -187,6 +187,17 @@ export function optionalNumber(
return v;
}
export function optionalBoolean(
obj: Record<string, unknown>,
key: string,
label = key,
): boolean | undefined {
const v = obj[key];
if (v === undefined) return undefined;
if (typeof v !== "boolean") throw badRequest(`${label} must be a boolean.`);
return v;
}
/** Validate a yyyy-mm-dd query parameter (defaults to undefined). */
export function optionalDateParam(value: string | undefined, label: string): string | undefined {
if (value === undefined || value === "") return undefined;
@@ -35,7 +35,13 @@ import type {
VaultUpdateRequest,
} from "../api/types.js";
import { HttpError } from "../http/errors.js";
import { badRequest, optionalEnum, optionalNumber, optionalString } from "../http/validate.js";
import {
badRequest,
optionalBoolean,
optionalEnum,
optionalNumber,
optionalString,
} from "../http/validate.js";
import { maskApiKey } from "./project-config-service.js";
const THINKING_LEVELS: readonly ThinkingLevelName[] = ["none", "low", "medium", "high", "xhigh"];
@@ -328,6 +334,7 @@ function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
if (t.forModel !== undefined && t.forModel !== "vision" && t.forModel !== "text-only") {
throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
}
optionalBoolean(t, "call_description", `toolsBuiltin[${i}].call_description`);
optionalNumber(t, "timeoutMs", {
integer: true,
positiveOrMinusOne: true,
@@ -0,0 +1,86 @@
/**
* Agent config route: the per-tool `call_description` field on toolsBuiltin rows. The
* default config writes `call_description: true` (plus the `description` property in
* parameters) on the four command/subagent tools; PUT round-trips a flipped `false` into
* system_config.yaml (preserving the rest of the file); a non-boolean value is a 400.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import { systemConfigPath } from "@prismshadow/penguin-core";
import type { ToolDefinitionConfig } from "@prismshadow/penguin-core";
import type { AgentConfigResponse, ProjectCreateResponse } from "../src/api/types.js";
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";
describe("agent config: per-tool call_description", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
let projectId: string;
let configPath: string;
beforeEach(async () => {
t = await createTestApp();
const a = await provisionUser(t.app, "owner_cd");
owner = apiClient(t.app, a.cookie);
const created = (await (
await owner.post("/api/projects", { projectId: "owner_cd-calldesc", name: "cd project" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
configPath = `/api/projects/${projectId}/agents/default_agent/config`;
});
afterEach(async () => {
await t.cleanup();
});
it("defaults carry call_description: true and the description property; PUT false round-trips", async () => {
const initial = (await (await owner.get(configPath)).json()) as AgentConfigResponse;
const names = initial.config.toolsBuiltin.map((tool) => tool.name);
// File tools lead the default toolset; the renamed shell tool follows.
expect(names.slice(0, 4)).toEqual(["read_file", "edit_file", "write_file", "exec_command"]);
const propsOf = (tool: ToolDefinitionConfig): Record<string, unknown> =>
(tool.parameters as { properties: Record<string, unknown> }).properties;
for (const name of ["exec_command", "input_command", "run_subagent", "input_subagent"]) {
const tool = initial.config.toolsBuiltin.find((row) => row.name === name)!;
expect(tool.call_description).toBe(true);
expect(propsOf(tool)["description"]).toBeDefined();
}
for (const name of ["read_file", "edit_file", "write_file"]) {
const tool = initial.config.toolsBuiltin.find((row) => row.name === name)!;
expect(tool.call_description).toBeUndefined();
expect(propsOf(tool)["description"]).toBeUndefined();
}
// Flip exec_command's toggle off via the whole-table PUT.
const tools = initial.config.toolsBuiltin.map((row) =>
row.name === "exec_command" ? { ...row, call_description: false } : row,
);
const putRes = await owner.put(configPath, { config: { toolsBuiltin: tools } });
expect(putRes.status).toBe(200);
const updated = (await putRes.json()) as AgentConfigResponse;
expect(
updated.config.toolsBuiltin.find((row) => row.name === "exec_command")!.call_description,
).toBe(false);
// Written into the YAML itself; the rest of the config is preserved.
const yaml = await fs.readFile(systemConfigPath(t.root, projectId, "default_agent"), "utf8");
expect(yaml).toContain("call_description: false");
expect(updated.config.systemPrompt).toBe(initial.config.systemPrompt);
// The property stays declared in the stored parameters (filtering happens at assembly, not in the config).
expect(
propsOf(updated.config.toolsBuiltin.find((row) => row.name === "exec_command")!)[
"description"
],
).toBeDefined();
});
it("rejects a non-boolean call_description with 400", async () => {
const initial = (await (await owner.get(configPath)).json()) as AgentConfigResponse;
const tools = initial.config.toolsBuiltin.map((row) =>
row.name === "exec_command" ? { ...row, call_description: "yes" } : row,
);
const res = await owner.put(configPath, { config: { toolsBuiltin: tools } });
expect(res.status).toBe(400);
const body = (await res.json()) as { error?: { message?: string } };
expect(JSON.stringify(body)).toContain("call_description");
});
});