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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user